/** * `netlify.toml` generator for Netlify Edge Functions. * * Emits a minimal Netlify config covering: * - [build] block (functions directory + dev command) * - [[edge_functions]] catch-all entry pointing at the SSR handler * - Optional [[edge_functions]] rewrites / path overrides * - Optional [functions] block for scheduled functions (Netlify Scheduled) * * Netlify uses TOML for its config file. We hand-assemble the TOML to * avoid a new dependency on a TOML serializer. */ export interface NetlifyEdgeConfigOptions { /** Project / site name — used for the leading comment only. */ projectName: string; /** * Relative path to the Edge Function entry. Defaults to * `netlify/edge-functions/ssr.ts`. Must live under * `netlify/edge-functions/` for Netlify to auto-detect it. */ functionPath?: string; /** * Function name for the [[edge_functions]] block. Defaults to `ssr`. * Must match the exported function's identifier for logging purposes * and be unique across your site's edge functions. */ functionName?: string; /** * Path glob for the SSR catch-all. Defaults to `/*` so Mandu sees * every request. Users can pass a narrower pattern (e.g. `/api/*`) * if they only want to route specific paths through Mandu. */ path?: string; /** * Extra edge_functions entries for custom routing / middleware. */ extraEdgeFunctions?: Array<{ path: string; function: string; cache?: "off" | "manual"; }>; /** Build publish directory. Defaults to `public`. */ publishDir?: string; /** Dev server command for `netlify dev`. Defaults to `mandu dev`. */ devCommand?: string; /** Build command. Defaults to `mandu build --target=netlify-edge`. */ buildCommand?: string; /** * Netlify scheduled functions. Each entry maps to a `[functions."name"]` * block with a `schedule` cron value. */ scheduled?: Array<{ name: string; schedule: string }>; } const TOML_STRING_RE = /^[^"\\\n\r]*$/; function quoteTomlString(value: string): string { // Reject control chars / quotes / backslashes to keep the generator // unambiguous. Callers should preserve ASCII printable content only. if (!TOML_STRING_RE.test(value)) { throw new Error( `generateNetlifyEdgeConfig: string value "${value}" contains characters ` + `that require TOML escaping — use a simpler value.` ); } return `"${value}"`; } /** * Generate `netlify.toml` contents as a string. * * @example * ```ts * const toml = generateNetlifyEdgeConfig({ projectName: "my-mandu-app" }); * await Bun.write("./netlify.toml", toml); * ``` */ export function generateNetlifyEdgeConfig( options: NetlifyEdgeConfigOptions ): string { if (!options.projectName || typeof options.projectName !== "string") { throw new Error("generateNetlifyEdgeConfig: projectName is required"); } if (!/^[a-z0-9-]+$/.test(options.projectName)) { throw new Error( `generateNetlifyEdgeConfig: projectName must match /^[a-z0-9-]+$/ ` + `(got: "${options.projectName}")` ); } const functionPath = options.functionPath ?? "netlify/edge-functions/ssr.ts"; if (!functionPath.startsWith("netlify/edge-functions/")) { throw new Error( `generateNetlifyEdgeConfig: functionPath must live under ` + `netlify/edge-functions/ (got: "${functionPath}")` ); } const functionName = options.functionName ?? "ssr"; if (!/^[a-z][a-z0-9_-]*$/i.test(functionName)) { throw new Error( `generateNetlifyEdgeConfig: functionName must be a valid identifier ` + `(got: "${functionName}")` ); } const path = options.path ?? "/*"; const publishDir = options.publishDir ?? "public"; const devCommand = options.devCommand ?? "mandu dev"; const buildCommand = options.buildCommand ?? "mandu build --target=netlify-edge"; const lines: string[] = []; lines.push(`# Generated by mandu build --target=netlify-edge`); lines.push(`# Project: ${options.projectName}`); lines.push(`# See https://docs.netlify.com/configure-builds/file-based-configuration/`); lines.push(""); // [build] lines.push("[build]"); lines.push(` command = ${quoteTomlString(buildCommand)}`); lines.push(` publish = ${quoteTomlString(publishDir)}`); lines.push(` edge_functions = "netlify/edge-functions"`); // [dev] lines.push(""); lines.push("[dev]"); lines.push(` command = ${quoteTomlString(devCommand)}`); lines.push(` framework = "#custom"`); lines.push(` targetPort = 3333`); // Primary [[edge_functions]] entry — the SSR catch-all. lines.push(""); lines.push("[[edge_functions]]"); lines.push(` path = ${quoteTomlString(path)}`); lines.push(` function = ${quoteTomlString(functionName)}`); // Additional edge function entries. if (options.extraEdgeFunctions && options.extraEdgeFunctions.length > 0) { for (const entry of options.extraEdgeFunctions) { if (!entry.path || !entry.function) { throw new Error( `generateNetlifyEdgeConfig: extraEdgeFunctions entries require both path and function` ); } lines.push(""); lines.push("[[edge_functions]]"); lines.push(` path = ${quoteTomlString(entry.path)}`); lines.push(` function = ${quoteTomlString(entry.function)}`); if (entry.cache) { lines.push(` cache = ${quoteTomlString(entry.cache)}`); } } } // Scheduled functions live under [functions.""] and use a // Serverless Function (not Edge) — but we wire the config here so // users have a single source of truth. if (options.scheduled && options.scheduled.length > 0) { for (const entry of options.scheduled) { if (!entry.name || !entry.schedule) { throw new Error( `generateNetlifyEdgeConfig: scheduled entries require both name and schedule` ); } if (!/^[a-z][a-z0-9_-]*$/i.test(entry.name)) { throw new Error( `generateNetlifyEdgeConfig: scheduled function name "${entry.name}" ` + `must be a valid identifier` ); } lines.push(""); lines.push(`[functions.${entry.name}]`); lines.push(` schedule = ${quoteTomlString(entry.schedule)}`); } } return lines.join("\n") + "\n"; }