436 lines
15 KiB
TypeScript
436 lines
15 KiB
TypeScript
import {
|
|
eraseFunctionTypes,
|
|
type PageAst,
|
|
type RuntimeFunctionDecl,
|
|
type StructuredImportDecl,
|
|
type ViewNode,
|
|
} from "@wrnexus/syntax";
|
|
|
|
const RESERVED_BINDINGS = new Set([
|
|
"await",
|
|
"break",
|
|
"case",
|
|
"catch",
|
|
"class",
|
|
"const",
|
|
"continue",
|
|
"debugger",
|
|
"default",
|
|
"delete",
|
|
"do",
|
|
"else",
|
|
"enum",
|
|
"export",
|
|
"extends",
|
|
"false",
|
|
"finally",
|
|
"for",
|
|
"function",
|
|
"if",
|
|
"implements",
|
|
"import",
|
|
"in",
|
|
"instanceof",
|
|
"interface",
|
|
"let",
|
|
"new",
|
|
"null",
|
|
"package",
|
|
"private",
|
|
"protected",
|
|
"public",
|
|
"return",
|
|
"static",
|
|
"super",
|
|
"switch",
|
|
"this",
|
|
"throw",
|
|
"true",
|
|
"try",
|
|
"typeof",
|
|
"var",
|
|
"void",
|
|
"while",
|
|
"with",
|
|
"yield",
|
|
]);
|
|
const RUNTIME_BINDINGS = new Set([
|
|
"context",
|
|
"state",
|
|
"output",
|
|
"server",
|
|
"props",
|
|
"refs",
|
|
"api",
|
|
"event",
|
|
"payload",
|
|
]);
|
|
|
|
function safeIdentifier(name: string): boolean {
|
|
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) && !RESERVED_BINDINGS.has(name);
|
|
}
|
|
|
|
function identifierReferenced(source: string, name: string): boolean {
|
|
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
return new RegExp(`(?:^|[^A-Za-z0-9_$])${escaped}(?![A-Za-z0-9_$])`).test(source);
|
|
}
|
|
|
|
function viewReferenceSource(nodes: ViewNode[]): string[] {
|
|
return nodes.flatMap((node): string[] => {
|
|
if (node.type === "text") return [node.value];
|
|
if (node.type === "each") {
|
|
return [
|
|
node.list,
|
|
node.key ?? "",
|
|
...viewReferenceSource(node.body),
|
|
...viewReferenceSource(node.empty),
|
|
];
|
|
}
|
|
if (node.type === "if") {
|
|
return node.branches.flatMap((branch) => [
|
|
branch.cond ?? "",
|
|
...viewReferenceSource(branch.body),
|
|
]);
|
|
}
|
|
return [...node.attrs.map((attr) => attr.value), ...viewReferenceSource(node.children)];
|
|
});
|
|
}
|
|
|
|
function browserReferenceSource(ast: PageAst, functions: RuntimeFunctionDecl[]): string {
|
|
return [
|
|
...functions.flatMap((fn) => [fn.source, fn.body]),
|
|
...ast.computed.map((entry) => entry.expr),
|
|
...ast.effects,
|
|
ast.lifecycle.mount ?? "",
|
|
ast.lifecycle.update ?? "",
|
|
ast.lifecycle.unmount ?? "",
|
|
...viewReferenceSource(ast.view),
|
|
].join("\n");
|
|
}
|
|
|
|
function renderSelectedImport(
|
|
entry: StructuredImportDecl,
|
|
source: string,
|
|
): { code: string; bindings: string[] } | null {
|
|
if (entry.typeOnly) return null;
|
|
const isStore = entry.source.endsWith(".wrn") && /(?:^|\/)stores?\//.test(entry.source);
|
|
if (entry.source.endsWith(".wrn") && !isStore) return null;
|
|
|
|
const defaultImport =
|
|
entry.defaultImport && (isStore || identifierReferenced(source, entry.defaultImport))
|
|
? entry.defaultImport
|
|
: undefined;
|
|
const namespaceImport =
|
|
entry.namespaceImport && (isStore || identifierReferenced(source, entry.namespaceImport))
|
|
? entry.namespaceImport
|
|
: undefined;
|
|
const namedImports = entry.namedImports.filter(
|
|
(item) => !item.typeOnly && (isStore || identifierReferenced(source, item.local)),
|
|
);
|
|
|
|
const bindings = [
|
|
...(defaultImport ? [defaultImport] : []),
|
|
...(namespaceImport ? [namespaceImport] : []),
|
|
...namedImports.map((item) => item.local),
|
|
].filter(safeIdentifier);
|
|
|
|
const hasBindings = Boolean(defaultImport || namespaceImport || namedImports.length);
|
|
const sideEffectOnly =
|
|
!entry.defaultImport && !entry.namespaceImport && entry.namedImports.length === 0;
|
|
if (!hasBindings && !sideEffectOnly) return null;
|
|
if (sideEffectOnly) return { code: entry.raw, bindings: [] };
|
|
|
|
const clauses: string[] = [];
|
|
if (defaultImport) clauses.push(defaultImport);
|
|
if (namespaceImport) clauses.push(`* as ${namespaceImport}`);
|
|
if (namedImports.length) {
|
|
clauses.push(
|
|
`{ ${namedImports
|
|
.map((item) =>
|
|
item.imported === item.local ? item.imported : `${item.imported} as ${item.local}`,
|
|
)
|
|
.join(", ")} }`,
|
|
);
|
|
}
|
|
return {
|
|
code: `import ${clauses.join(", ")} from ${JSON.stringify(entry.source)};`,
|
|
bindings,
|
|
};
|
|
}
|
|
|
|
function selectedBrowserImports(
|
|
ast: PageAst,
|
|
functions: RuntimeFunctionDecl[],
|
|
): { code: string; bindings: string[] }[] {
|
|
const referenceSource = browserReferenceSource(ast, functions);
|
|
return ast.structuredImports
|
|
.map((entry) => renderSelectedImport(entry, referenceSource))
|
|
.filter((entry): entry is { code: string; bindings: string[] } => entry !== null);
|
|
}
|
|
|
|
export function browserModuleRequired(ast: PageAst): boolean {
|
|
const functions = ast.runtimeFunctions.filter((fn) => ["client", "shared"].includes(fn.runtime));
|
|
return functions.length > 0 || selectedBrowserImports(ast, functions).length > 0;
|
|
}
|
|
|
|
function _functionEntry(
|
|
ast: PageAst,
|
|
fn: RuntimeFunctionDecl,
|
|
availableFunctions: string[],
|
|
): string {
|
|
const parameterNames = new Set(fn.parameters.map((parameter) => parameter.name));
|
|
|
|
/*
|
|
* Names the function body declares for itself.
|
|
*
|
|
* Props and state are destructured into the SAME scope as the body, so a
|
|
* body that declares `var size` when `size` is also a prop produced
|
|
* "Identifier 'size' has already been declared" and the entire module
|
|
* failed to parse -- taking every function in the component down with it,
|
|
* with nothing to point at the one line responsible. Skipping the alias for
|
|
* a shadowed name is also what plain JavaScript does: inside that function
|
|
* the local wins.
|
|
*/
|
|
const declaredLocals = new Set<string>();
|
|
for (const match of fn.body.matchAll(
|
|
/\b(?:var|let|const)\s+([A-Za-z_$][\w$]*)|\bfunction\s+([A-Za-z_$][\w$]*)/g,
|
|
)) {
|
|
const name = match[1] ?? match[2];
|
|
if (name) declaredLocals.add(name);
|
|
}
|
|
|
|
const stateNames = ast.states
|
|
.filter(
|
|
(state) =>
|
|
state.runtime !== "server" &&
|
|
safeIdentifier(state.name) &&
|
|
!RUNTIME_BINDINGS.has(state.name) &&
|
|
!parameterNames.has(state.name) &&
|
|
!declaredLocals.has(state.name),
|
|
)
|
|
.map((state) => state.name);
|
|
const stateSet = new Set(stateNames);
|
|
const propNames = ast.props
|
|
.filter(
|
|
(prop) =>
|
|
safeIdentifier(prop.name) &&
|
|
!RUNTIME_BINDINGS.has(prop.name) &&
|
|
!parameterNames.has(prop.name) &&
|
|
!stateSet.has(prop.name) &&
|
|
!declaredLocals.has(prop.name),
|
|
)
|
|
.map((prop) => prop.name);
|
|
const functionAliases = availableFunctions.filter(
|
|
(name) =>
|
|
safeIdentifier(name) &&
|
|
!RUNTIME_BINDINGS.has(name) &&
|
|
!parameterNames.has(name) &&
|
|
!stateSet.has(name) &&
|
|
!propNames.includes(name) &&
|
|
!declaredLocals.has(name),
|
|
);
|
|
const parameters = fn.parameters.map((parameter) => parameter.name).join(", ");
|
|
const initialStateSnapshot = stateNames.length
|
|
? `const __wrnexusInitialState = { ${stateNames.map((name) => `${JSON.stringify(name)}: context.state.${name}`).join(", ")} };`
|
|
: "";
|
|
const stateAliases = stateNames.length ? `let { ${stateNames.join(", ")} } = context.state;` : "";
|
|
const propAliases = propNames.length ? `const { ${propNames.join(", ")} } = context.props;` : "";
|
|
const syncStateToContext = stateNames.map((name) => `context.state.${name} = ${name};`).join(" ");
|
|
const syncStateFromContext = stateNames
|
|
.map((name) => `${name} = context.state.${name};`)
|
|
.join(" ");
|
|
const peerAliases = !stateNames.length
|
|
? functionAliases
|
|
.map(
|
|
(name) =>
|
|
`const ${name} = (...__wrnexusPeerArgs) => context.functions[${JSON.stringify(name)}](...__wrnexusPeerArgs);`,
|
|
)
|
|
.join("\n")
|
|
: `const __wrnexusFlush = () => { ${syncStateToContext} };
|
|
const __wrnexusRestore = () => { ${syncStateFromContext} };
|
|
const __wrnexusPeer = (name, args) => {
|
|
__wrnexusFlush();
|
|
let result;
|
|
try {
|
|
result = context.functions[name](...args);
|
|
} catch (error) {
|
|
__wrnexusRestore();
|
|
throw error;
|
|
}
|
|
if (result && typeof result.then === "function") {
|
|
return Promise.resolve(result).finally(__wrnexusRestore);
|
|
}
|
|
__wrnexusRestore();
|
|
return result;
|
|
};
|
|
${functionAliases.map((name) => `const ${name} = (...__wrnexusPeerArgs) => __wrnexusPeer(${JSON.stringify(name)}, __wrnexusPeerArgs);`).join("\n")}`;
|
|
const copyBack = stateNames
|
|
.map(
|
|
(name) =>
|
|
`if (!Object.is(${name}, __wrnexusInitialState[${JSON.stringify(name)}])) context.state.${name} = ${name};`,
|
|
)
|
|
.join("\n");
|
|
const commitBinding = stateNames.length
|
|
? `const __wrnexusCommit = () => { ${copyBack} };
|
|
${!parameterNames.has("commit") && !declaredLocals.has("commit") && !functionAliases.includes("commit") ? "const commit = __wrnexusCommit;" : ""}
|
|
${
|
|
!parameterNames.has("setTimeout") &&
|
|
!declaredLocals.has("setTimeout") &&
|
|
!functionAliases.includes("setTimeout")
|
|
? `const setTimeout = (callback, delay, ...args) => globalThis.setTimeout(() => {
|
|
try { return callback(...args); } finally { __wrnexusCommit(); }
|
|
}, delay);`
|
|
: ""
|
|
}`
|
|
: "";
|
|
const body = eraseFunctionTypes(fn.body);
|
|
const runtimeBindings = [
|
|
!parameterNames.has("output") ? "const output = context.output;" : "",
|
|
!parameterNames.has("server") ? "const server = context.server;" : "",
|
|
!parameterNames.has("props") ? "const props = context.props;" : "",
|
|
!parameterNames.has("refs") ? "const refs = context.refs;" : "",
|
|
]
|
|
.filter(Boolean)
|
|
.join("\n ");
|
|
return `${JSON.stringify(fn.name)}: ${fn.async ? "async " : ""}function(context${parameters ? `, ${parameters}` : ""}) {
|
|
${initialStateSnapshot}
|
|
${stateAliases}
|
|
${propAliases}
|
|
${commitBinding}
|
|
${peerAliases}
|
|
${runtimeBindings}
|
|
try {
|
|
${body}
|
|
} finally {
|
|
${stateNames.length ? "__wrnexusCommit();" : ""}
|
|
}
|
|
}`;
|
|
}
|
|
|
|
/**
|
|
* Client-mode api blocks become members of an `api` object in client scope.
|
|
*
|
|
* Only the response and error bodies are emitted; the declared field types are
|
|
* type-only and are consumed by the types generator instead. Anything
|
|
* TypeScript reaching this module would be a syntax error in the .mjs artifact.
|
|
*/
|
|
function apiBindings(ast: PageAst): string {
|
|
const members = ast.dataApis
|
|
.filter((block) => block.mode === "client" && block.sections)
|
|
.map((block) => {
|
|
const sections = block.sections!;
|
|
const response = eraseFunctionTypes(sections.response).trim() || "return data;";
|
|
const error = eraseFunctionTypes(sections.error).trim();
|
|
const failure = error
|
|
? `(error) => { const status = error.status; const message = error.message; const data = error.data; ${error} }`
|
|
: `(error) => { throw error; }`;
|
|
|
|
return ` ${JSON.stringify(block.name)}: async (input) => context.callApi(${JSON.stringify(
|
|
block.path,
|
|
)}, ${JSON.stringify(block.method)}, input).then((data) => { ${response} }, ${failure})`;
|
|
});
|
|
|
|
return members.length ? `const api = {\n${members.join(",\n")}\n };` : "";
|
|
}
|
|
|
|
export function generateBrowserModule(ast: PageAst): string {
|
|
const functions = ast.runtimeFunctions.filter((fn) => ["client", "shared"].includes(fn.runtime));
|
|
const functionNames = functions.map((fn) => fn.name);
|
|
const state = ast.states.filter((entry) => entry.runtime !== "server").map((entry) => entry.name);
|
|
const selectedImports = selectedBrowserImports(ast, functions);
|
|
const imports = selectedImports.map((entry) => entry.code).join("\n");
|
|
const importedBindings = [...new Set(selectedImports.flatMap((entry) => entry.bindings))];
|
|
// `api` is only defined as a client-scope binding when the page actually has
|
|
// client-mode api blocks (see apiBindings below). A page that declares
|
|
// `state api` without any client api blocks must keep reading/writing that
|
|
// state as before, so only exclude the "api" name from destructuring when
|
|
// there is a real `api` binding to shadow it.
|
|
const hasClientApi = ast.dataApis.some((block) => block.mode === "client" && block.sections);
|
|
const localRuntimeBindings = hasClientApi
|
|
? RUNTIME_BINDINGS
|
|
: new Set([...RUNTIME_BINDINGS].filter((name) => name !== "api"));
|
|
const sharedState = state.filter(
|
|
(name) => safeIdentifier(name) && !localRuntimeBindings.has(name),
|
|
);
|
|
const sharedProps = ast.props
|
|
.map((entry) => entry.name)
|
|
.filter(
|
|
(name) =>
|
|
safeIdentifier(name) && !localRuntimeBindings.has(name) && !sharedState.includes(name),
|
|
);
|
|
const callableAliases = functionNames.filter(
|
|
(name) =>
|
|
safeIdentifier(name) &&
|
|
!RUNTIME_BINDINGS.has(name) &&
|
|
!sharedState.includes(name) &&
|
|
!sharedProps.includes(name),
|
|
);
|
|
const sharedCommit = sharedState.map((name) => `context.state.${name} = ${name};`).join(" ");
|
|
const sharedRestore = [
|
|
...sharedState.map((name) => `${name} = context.state.${name};`),
|
|
...sharedProps.map((name) => `${name} = context.props.${name};`),
|
|
].join(" ");
|
|
const hasAuthoredCommit = callableAliases.includes("commit");
|
|
const hasAuthoredSetTimeout = callableAliases.includes("setTimeout");
|
|
const implementations = functions
|
|
.map((fn) => {
|
|
const parameters = fn.parameters.map((parameter) => parameter.name).join(", ");
|
|
return `${JSON.stringify(fn.name)}: ${fn.async ? "async " : ""}function(${parameters}) {
|
|
try { ${eraseFunctionTypes(fn.body)} } finally { __wrnexusCommit(); }
|
|
}`;
|
|
})
|
|
.join(",\n");
|
|
return `// generated WRNexusJS browser module for ${ast.name}
|
|
${imports}
|
|
export const __wrnexusClientFunctions = {
|
|
${functions
|
|
.map(
|
|
(fn) =>
|
|
` ${JSON.stringify(fn.name)}: (context, ...args) => __wrnexusBindings(context)[${JSON.stringify(fn.name)}](...args)`,
|
|
)
|
|
.join(",\n")}
|
|
};
|
|
export const __wrnexusClientState = ${JSON.stringify(state)};
|
|
export const __wrnexusOutputs = ${JSON.stringify(ast.outputs)};
|
|
export const __wrnexusImportedBindings = { ${importedBindings.join(", ")} };
|
|
function __wrnexusCreateClientFunctions(context) {
|
|
${sharedState.length ? `let { ${sharedState.join(", ")} } = context.state;` : ""}
|
|
${sharedProps.length ? `let { ${sharedProps.join(", ")} } = context.props;` : ""}
|
|
const output = context.output;
|
|
const server = context.server;
|
|
const props = context.props;
|
|
const refs = context.refs;
|
|
${apiBindings(ast)}
|
|
const __wrnexusCommit = () => { ${sharedCommit} };
|
|
const __wrnexusRestore = () => { ${sharedRestore} };
|
|
${!hasAuthoredCommit ? "const commit = __wrnexusCommit;" : ""}
|
|
${
|
|
!hasAuthoredSetTimeout
|
|
? `const setTimeout = (callback, delay, ...args) => globalThis.setTimeout(() => {
|
|
try { return callback(...args); } finally { __wrnexusCommit(); }
|
|
}, delay);`
|
|
: ""
|
|
}
|
|
const implementations = {
|
|
${implementations}
|
|
};
|
|
${callableAliases.map((name) => `const ${name} = (...args) => implementations[${JSON.stringify(name)}](...args);`).join("\n ")}
|
|
const functions = {};
|
|
for (const name of Object.keys(implementations)) {
|
|
functions[name] = (...args) => {
|
|
__wrnexusRestore();
|
|
return implementations[name](...args);
|
|
};
|
|
}
|
|
return functions;
|
|
}
|
|
function __wrnexusBindings(context) {
|
|
return context.__wrnexusBoundFunctions ||
|
|
(context.__wrnexusBoundFunctions = __wrnexusCreateClientFunctions(context));
|
|
}
|
|
export function bindClientScope(context) {
|
|
return __wrnexusBindings(context);
|
|
}
|
|
`;
|
|
}
|