/** * Migration: move `api` entries out of legacy `ssr { … }` / `client { … }` * data blocks into a single page-level `apis { }` block. * * IMPORTANT: this is a text-level transform, not a parse-and-rewrite. The * `@wrnexus/syntax` parser deliberately THROWS a `ParseError` on `ssr { … }` * / `client { … }` data blocks (see `packages/syntax/src/parser.ts`, the * `case "ssr": case "client": case "server":` handling) — that legacy syntax * is exactly what this migration exists to read, so it cannot be reached by * parsing first. Instead this scans the source with the same balanced-brace * technique used throughout `update.ts` (`findMatching`), and only parses * the RESULT, as a validity check. */ import { parse } from "@wrnexus/syntax"; /** Blank out string/template literal contents and comments, preserving length. */ function maskLiteralsAndComments(source: string): string { let out = ""; let index = 0; while (index < source.length) { const char = source[index]!; if (char === '"' || char === "'" || char === "`") { const quote = char; let end = index + 1; while (end < source.length) { if (source[end] === "\\") { end += 2; continue; } if (source[end] === quote) { end++; break; } end++; } out += " ".repeat(end - index); index = end; continue; } if (char === "/" && source[index + 1] === "/") { let end = index; while (end < source.length && source[end] !== "\n") end++; out += " ".repeat(end - index); index = end; continue; } if (char === "/" && source[index + 1] === "*") { let end = source.indexOf("*/", index + 2); end = end < 0 ? source.length : end + 2; out += source.slice(index, end).replace(/[^\n]/g, " "); index = end; continue; } out += char; index++; } return out; } /** Balanced-brace scan: returns the index of the brace matching `open`, or -1. */ function findMatching(source: string, open: number, openChar = "{", closeChar = "}"): number { let depth = 0; let quote = ""; for (let index = open; index < source.length; index++) { const char = source[index]!; if (quote) { if (char === "\\") index++; else if (char === quote) quote = ""; continue; } if (char === '"' || char === "'" || char === "`") quote = char; else if (char === openChar) depth++; else if (char === closeChar && --depth === 0) return index; } return -1; } interface ModeBlock { mode: "ssr" | "client"; /** Start of the `ssr`/`client` keyword. */ start: number; /** One past the block's closing `}`. */ end: number; bodyStart: number; bodyEnd: number; } /** Find page-level `ssr { … }` / `client { … }` data blocks (not `state`/hydrate forms). */ function findModeBlocks(source: string): ModeBlock[] { const blocks: ModeBlock[] = []; const header = /\b(ssr|client)\s*\{/g; let match: RegExpExecArray | null; while ((match = header.exec(source))) { const open = match.index + match[0].length - 1; const close = findMatching(source, open); if (close < 0) { throw new Error(`unterminated "${match[1]} {" block at offset ${match.index}`); } blocks.push({ mode: match[1] as "ssr" | "client", start: match.index, end: close + 1, bodyStart: open + 1, bodyEnd: close, }); header.lastIndex = close + 1; } return blocks; } interface ApiEntry { name: string; method: string; path: string; /** `name METHOD path { … }` text, without the leading `api` keyword. */ text: string; /** Offsets relative to the block body the entry was found in. */ localStart: number; localEnd: number; } /** * Find `api { … }` entries inside a mode-block body. * * A legacy BARE-BODY entry (no `request`/`response`/`error` sections — its * body is JS evaluated inside `with ($data ?? {})`) is deliberately excluded * here. The `apis { }` grammar requires sections, so folding a bare body into * it would produce source that fails to parse — a spurious "failed to parse" * report for a file that is actually fine. `report-legacy-api-bodies` * (`legacy-api-body.ts`) is the migration that surfaces these for manual * review; leaving them out of this scan lets that block's leftover content * fall through to the "mixes content" skip below when needed, or leaves the * block entirely untouched when it holds only bare-body entries. */ function findApiEntries(body: string): ApiEntry[] { const entries: ApiEntry[] = []; const header = /\bapi\s+([A-Za-z_$][\w$]*)\s+([A-Za-z]+)\s+(\S+?)\s*\{/g; let match: RegExpExecArray | null; while ((match = header.exec(body))) { const open = match.index + match[0].length - 1; const close = findMatching(body, open); if (close < 0) { throw new Error(`unterminated api entry '${match[1]}' at offset ${match.index}`); } const localStart = match.index; const localEnd = close + 1; const bodyText = body.slice(open + 1, close); const hasSections = /\b(request|response|error)\s*\{/.test(maskLiteralsAndComments(bodyText)); if (!hasSections) { header.lastIndex = localEnd; continue; } const text = body.slice(localStart, localEnd).replace(/^api\s+/, ""); entries.push({ name: match[1]!, method: match[2]!.toUpperCase(), path: match[3]!, text, localStart, localEnd, }); header.lastIndex = localEnd; } return entries; } interface Edit { start: number; end: number; replacement: string; } function applyEdits(source: string, edits: Edit[]): string { const sorted = [...edits].sort((a, b) => a.start - b.start); let output = ""; let cursor = 0; for (const edit of sorted) { output += source.slice(cursor, edit.start) + edit.replacement; cursor = edit.end; } output += source.slice(cursor); return output; } export function migrateApisBlock( source: string, ): { source: string; changed: boolean } | { skip: string } { const blocks = findModeBlocks(source); if (blocks.length === 0) return { source, changed: false }; const allEntries: ApiEntry[] = []; const nameMode = new Map(); const perBlockEntries = new Map(); for (const block of blocks) { const body = source.slice(block.bodyStart, block.bodyEnd); const entries = findApiEntries(body); if (entries.length === 0) continue; perBlockEntries.set(block, entries); for (const entry of entries) { const existingMode = nameMode.get(entry.name); if (existingMode && existingMode !== block.mode) { return { skip: `api '${entry.name}' is declared in both ssr and client — merge them by hand before this migration can run`, }; } nameMode.set(entry.name, block.mode); allEntries.push(entry); } } if (allEntries.length === 0) return { source, changed: false }; // Locate an existing page-level `apis { }` block, if any. const apisMatch = /\bapis\s*\{/.exec(source); let existingApis: { bodyStart: number; bodyEnd: number } | null = null; if (apisMatch) { const open = apisMatch.index + apisMatch[0].length - 1; const close = findMatching(source, open); if (close < 0) throw new Error(`unterminated "apis {" block at offset ${apisMatch.index}`); existingApis = { bodyStart: open + 1, bodyEnd: close }; } const designatedBlock = blocks.find((block) => perBlockEntries.has(block)); const edits: Edit[] = []; for (const block of blocks) { const entries = perBlockEntries.get(block); if (!entries) continue; // block had no api entries; leave it untouched entirely const body = source.slice(block.bodyStart, block.bodyEnd); let remainder = body; for (const entry of [...entries].reverse()) { remainder = remainder.slice(0, entry.localStart) + remainder.slice(entry.localEnd); } if (remainder.trim() !== "") { // A bare `ssr { … }` / `client { … }` block is no longer valid syntax at // all once its api entries are gone -- only "state" and hydrate forms // survive. Leftover content (e.g. `functions { }`) belongs to the // move-mode-functions migration; migrating api entries alone here would // strand it inside a block shape the parser rejects. Skip rather than // partially rewrite. return { skip: `${block.mode} { … } mixes api entries with other content (e.g. functions) that must be migrated first`, }; } if (!existingApis && block === designatedBlock) { const apisBody = allEntries.map((entry) => ` ${entry.text}`).join("\n\n"); const apisBlockText = `apis {\n${apisBody}\n }`; edits.push({ start: block.start, end: block.end, replacement: apisBlockText }); } else { edits.push({ start: block.start, end: block.end, replacement: "" }); } } if (existingApis) { const existingBody = source.slice(existingApis.bodyStart, existingApis.bodyEnd); const additions = allEntries.map((entry) => ` ${entry.text}`).join("\n\n"); const mergedBody = `${existingBody.replace(/\s*$/, "")}\n\n${additions}\n `; edits.push({ start: existingApis.bodyStart, end: existingApis.bodyEnd, replacement: mergedBody, }); } let after = applyEdits(source, edits); after = after.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n"); if (after === source) return { source, changed: false }; try { parse(after); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`apis-block migration produced source that failed to parse: ${message}`, { cause: error, }); } return { source: after, changed: true }; }