- rebuild editors/vscode bundles, stale since the parser escape fix - attach the caught ParseError as `cause` in both migration validators - drop two unused test bindings flagged by eslint Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
286 lines
9.7 KiB
TypeScript
286 lines
9.7 KiB
TypeScript
/**
|
|
* 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 <name> <METHOD> <path> { … }` 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<string, "ssr" | "client">();
|
|
const perBlockEntries = new Map<ModeBlock, ApiEntry[]>();
|
|
|
|
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 };
|
|
}
|