feat(cli): migrate api entries into the apis block
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
/** 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. */
|
||||
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 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}`);
|
||||
}
|
||||
|
||||
return { source: after, changed: true };
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import { pathToFileURL } from "node:url";
|
||||
import { formatWrn, parse } from "@wrnexus/syntax";
|
||||
import { AI_GUIDE, CLAUDE_MD } from "./ai-guide.ts";
|
||||
import { inspectProject, type DoctorCheck } from "./doctor.ts";
|
||||
import { migrateApisBlock } from "./migrations/apis-block.ts";
|
||||
import { generateApplicationTypes } from "./types.ts";
|
||||
|
||||
/** The version of the CLI currently running (its own package.json). */
|
||||
@@ -868,6 +869,41 @@ const MIGRATIONS: Migration[] = [
|
||||
if (!ctx.dryRun) writeFileSync(file, after, "utf8");
|
||||
},
|
||||
},
|
||||
{
|
||||
version: "0.9.0",
|
||||
id: "move-api-blocks",
|
||||
description:
|
||||
"Move api entries out of legacy ssr/client data blocks into a page-level apis block",
|
||||
apply(ctx) {
|
||||
const appDirectory = join(ctx.appRoot, "app");
|
||||
if (!existsSync(appDirectory)) return;
|
||||
|
||||
for (const file of walkProjectFiles(appDirectory, ".wrn")) {
|
||||
const relativeFile = relative(ctx.appRoot, file).replace(/\\/g, "/");
|
||||
const before = readFileSync(file, "utf8");
|
||||
|
||||
let result: ReturnType<typeof migrateApisBlock>;
|
||||
try {
|
||||
result = migrateApisBlock(before);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message.split("\n", 1)[0] : String(error);
|
||||
ctx.report.parseFailures.push(`${relativeFile}: ${message}`);
|
||||
ctx.log(`! ${relativeFile}: left unchanged because the apis-block migration failed`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ("skip" in result) {
|
||||
ctx.report.needsReview.push(`${relativeFile}: ${result.skip}`);
|
||||
continue;
|
||||
}
|
||||
if (!result.changed) continue;
|
||||
|
||||
ctx.report.changedAutomatically.push(`${relativeFile}: moved api entries into apis { }`);
|
||||
ctx.log(`~ ${relativeFile}: moved api entries into apis { }`);
|
||||
if (!ctx.dryRun) writeFileSync(file, result.source, "utf8");
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** Release tooling uses this to require an explicit migration entry per version. */
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { migrateApisBlock } from "../src/migrations/apis-block.ts";
|
||||
|
||||
const SOURCE = `page Search {
|
||||
client {
|
||||
api searchUsers POST /api/users {
|
||||
request { body { name?: string } }
|
||||
response { return data.users }
|
||||
error { return [] }
|
||||
}
|
||||
}
|
||||
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
|
||||
test("a client api entry moves into an apis block", () => {
|
||||
const result = migrateApisBlock(SOURCE) as { source: string; changed: boolean };
|
||||
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.source).toContain("apis {");
|
||||
expect(result.source).toContain("searchUsers POST /api/users");
|
||||
expect(result.source).not.toContain("client {\n api");
|
||||
});
|
||||
|
||||
test("the sections survive unchanged", () => {
|
||||
const result = migrateApisBlock(SOURCE) as { source: string };
|
||||
|
||||
expect(result.source).toContain("return data.users");
|
||||
expect(result.source).toContain("return []");
|
||||
});
|
||||
|
||||
test("running it on migrated source changes nothing", () => {
|
||||
const once = (migrateApisBlock(SOURCE) as { source: string }).source;
|
||||
const twice = migrateApisBlock(once) as { source: string; changed: boolean };
|
||||
|
||||
expect(twice.changed).toBe(false);
|
||||
expect(twice.source).toBe(once);
|
||||
});
|
||||
|
||||
test("a name declared in both modes is skipped with a reason", () => {
|
||||
const clash = `page P {
|
||||
ssr { api dup GET /api/a { response { return data } } }
|
||||
client { api dup GET /api/a { response { return data } } }
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
const result = migrateApisBlock(clash) as { skip: string };
|
||||
|
||||
expect(result.skip).toContain("dup");
|
||||
});
|
||||
|
||||
test("a page with an existing apis block merges legacy entries instead of producing two apis blocks", () => {
|
||||
const source = `page P {
|
||||
apis {
|
||||
existing GET /api/existing {
|
||||
response { return data }
|
||||
}
|
||||
}
|
||||
|
||||
client {
|
||||
api added POST /api/added {
|
||||
response { return data.value }
|
||||
}
|
||||
}
|
||||
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
const result = migrateApisBlock(source) as { source: string; changed: boolean };
|
||||
|
||||
expect(result.changed).toBe(true);
|
||||
const apisCount = (result.source.match(/\bapis\s*\{/g) ?? []).length;
|
||||
expect(apisCount).toBe(1);
|
||||
expect(result.source).toContain("existing GET /api/existing");
|
||||
expect(result.source).toContain("added POST /api/added");
|
||||
expect(result.source).not.toContain("client {");
|
||||
});
|
||||
|
||||
test("a mode block mixing api entries with other content is skipped, not partially rewritten", () => {
|
||||
// A bare `ssr { … }` block is invalid syntax once api entries are removed
|
||||
// (only `state` and hydrate forms survive) -- leftover content such as
|
||||
// `functions { }` belongs to the move-mode-functions migration, so this
|
||||
// file cannot be completed by this migration alone.
|
||||
const source = `page P {
|
||||
ssr {
|
||||
api foo GET /api/foo {
|
||||
response { return data }
|
||||
}
|
||||
|
||||
functions {
|
||||
function helper() { return 1 }
|
||||
}
|
||||
}
|
||||
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
const result = migrateApisBlock(source) as { skip: string };
|
||||
|
||||
expect(result.skip).toContain("ssr");
|
||||
expect(result.skip).toContain("functions");
|
||||
});
|
||||
|
||||
test("a brace inside a string literal in a section body does not truncate the entry", () => {
|
||||
const source = `page P {
|
||||
client {
|
||||
api foo GET /api/foo {
|
||||
response { return { text: "a } b" } }
|
||||
}
|
||||
}
|
||||
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
const result = migrateApisBlock(source) as { source: string; changed: boolean };
|
||||
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.source).toContain('text: "a } b"');
|
||||
expect(result.source).toContain("apis {");
|
||||
});
|
||||
Reference in New Issue
Block a user