release: WRNexusJS 0.6.0
This commit is contained in:
@@ -22,6 +22,9 @@ export interface WrnDiagnostic {
|
||||
hint?: string;
|
||||
file?: string;
|
||||
position?: WrnSourcePosition;
|
||||
expected?: string;
|
||||
received?: string;
|
||||
related?: Array<{ file?: string; message: string; position?: WrnSourcePosition }>;
|
||||
}
|
||||
|
||||
export interface DiagnoseOptions {
|
||||
@@ -29,6 +32,95 @@ export interface DiagnoseOptions {
|
||||
accessibility?: boolean;
|
||||
}
|
||||
|
||||
function maskJavaScriptTrivia(source: string): string {
|
||||
let result = "";
|
||||
let index = 0;
|
||||
let quote: "'" | '"' | "`" | null = null;
|
||||
let lineComment = false;
|
||||
let blockComment = false;
|
||||
|
||||
while (index < source.length) {
|
||||
const char = source[index]!;
|
||||
const next = source[index + 1];
|
||||
|
||||
if (lineComment) {
|
||||
if (char === "\n") {
|
||||
lineComment = false;
|
||||
result += "\n";
|
||||
} else result += " ";
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (blockComment) {
|
||||
if (char === "*" && next === "/") {
|
||||
result += " ";
|
||||
index += 2;
|
||||
blockComment = false;
|
||||
} else {
|
||||
result += char === "\n" ? "\n" : " ";
|
||||
index++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote) {
|
||||
if (char === "\\") {
|
||||
result += " ";
|
||||
index += Math.min(2, source.length - index);
|
||||
} else if (char === quote) {
|
||||
result += " ";
|
||||
index++;
|
||||
quote = null;
|
||||
} else {
|
||||
result += char === "\n" ? "\n" : " ";
|
||||
index++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "/" && next === "/") {
|
||||
result += " ";
|
||||
index += 2;
|
||||
lineComment = true;
|
||||
continue;
|
||||
}
|
||||
if (char === "/" && next === "*") {
|
||||
result += " ";
|
||||
index += 2;
|
||||
blockComment = true;
|
||||
continue;
|
||||
}
|
||||
if (char === "'" || char === '"' || char === "`") {
|
||||
quote = char;
|
||||
result += " ";
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
result += char;
|
||||
index++;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function containsReadonlyPropMutation(
|
||||
body: string,
|
||||
propName: string,
|
||||
parameterNames: Set<string>,
|
||||
): boolean {
|
||||
const code = maskJavaScriptTrivia(body);
|
||||
const escaped = propName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const operator = String.raw`(?:\+\+|--|(?:\*\*|&&|\|\||\?\?|[+\-*/%&|^])?=(?!=|>))`;
|
||||
|
||||
if (new RegExp(String.raw`\bprops\.${escaped}\s*${operator}`).test(code)) return true;
|
||||
if (parameterNames.has(propName)) return false;
|
||||
if (new RegExp(String.raw`\b(?:const|let|var)\s+${escaped}\b`).test(code)) return false;
|
||||
|
||||
return new RegExp(String.raw`(?:^|[^\w$.])${escaped}\s*${operator}`, "m").test(code);
|
||||
}
|
||||
|
||||
export function positionAt(source: string, offset: number): WrnSourcePosition {
|
||||
const safe = Math.max(0, Math.min(offset, source.length));
|
||||
const before = source.slice(0, safe);
|
||||
@@ -155,6 +247,100 @@ function astDiagnostics(ast: PageAst, options: DiagnoseOptions): WrnDiagnostic[]
|
||||
file: options.file,
|
||||
});
|
||||
}
|
||||
|
||||
const outputs = new Set(ast.outputs.map((output) => output.name));
|
||||
for (const fn of ast.runtimeFunctions) {
|
||||
for (const call of fn.body.matchAll(/\boutput\.([A-Za-z_$][\w$]*)\s*\(/g)) {
|
||||
const outputName = call[1]!;
|
||||
if (fn.runtime === "server") {
|
||||
diagnostics.push({
|
||||
code: "WRN-OUTPUT-SERVER-CALL",
|
||||
severity: "error",
|
||||
message: `Server function '${fn.name}' cannot call output.${outputName}().`,
|
||||
hint: "Return a typed value to the browser and call the output from a client function.",
|
||||
file: options.file,
|
||||
});
|
||||
} else if (!outputs.has(outputName)) {
|
||||
diagnostics.push({
|
||||
code: "WRN-OUTPUT-UNKNOWN",
|
||||
severity: "error",
|
||||
message: `Unknown output '${outputName}' called from '${fn.name}'.`,
|
||||
hint: `Declare ${outputName}(payload) inside outputs { ... }.`,
|
||||
file: options.file,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (fn.runtime === "client" && /\b(?:process|Bun|Deno|__dirname|require)\b/.test(fn.body)) {
|
||||
diagnostics.push({
|
||||
code: "WRN-CLIENT-SERVER-API",
|
||||
severity: "error",
|
||||
message: `Client function '${fn.name}' references a server-only API.`,
|
||||
hint: "Move that operation into a server function and call it through server.name(...).",
|
||||
file: options.file,
|
||||
});
|
||||
}
|
||||
if (
|
||||
fn.runtime === "server" &&
|
||||
/\b(?:window|document|localStorage|sessionStorage|navigator)\b/.test(fn.body)
|
||||
) {
|
||||
diagnostics.push({
|
||||
code: "WRN-SERVER-BROWSER-API",
|
||||
severity: "error",
|
||||
message: `Server function '${fn.name}' references a browser-only API.`,
|
||||
hint: "Move that code into a client function.",
|
||||
file: options.file,
|
||||
});
|
||||
}
|
||||
const parameterNames = new Set(fn.parameters.map((parameter) => parameter.name));
|
||||
for (const prop of ast.props) {
|
||||
if (containsReadonlyPropMutation(fn.body, prop.name, parameterNames)) {
|
||||
diagnostics.push({
|
||||
code: "WRN-PROP-READONLY",
|
||||
severity: "error",
|
||||
message: `Function '${fn.name}' attempts to mutate readonly prop '${prop.name}'.`,
|
||||
hint: "Copy the prop into state before mutating it.",
|
||||
file: options.file,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const state of ast.states) {
|
||||
if (
|
||||
state.runtime === "shared" &&
|
||||
/^(?:new\s+(?:Map|Set|WeakMap|WeakSet)|(?:async\s+)?function\b|.*=>)/.test(state.expr.trim())
|
||||
) {
|
||||
diagnostics.push({
|
||||
code: "WRN-STATE-NON-SERIALIZABLE",
|
||||
severity: "error",
|
||||
message: `Shared state '${state.name}' is not safely serializable.`,
|
||||
hint: "Use JSON-compatible data or move the value into client/server state.",
|
||||
file: options.file,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (ast.persist) {
|
||||
const stateNames = new Set(
|
||||
ast.states.filter((state) => state.runtime !== "server").map((state) => state.name),
|
||||
);
|
||||
for (const name of ast.persist.include)
|
||||
if (!stateNames.has(name))
|
||||
diagnostics.push({
|
||||
code: "WRN-PERSIST-UNKNOWN-FIELD",
|
||||
severity: "error",
|
||||
message: `Persist include references unknown or server-only state '${name}'.`,
|
||||
hint: "Persist only declared shared/client state fields.",
|
||||
file: options.file,
|
||||
});
|
||||
for (const name of ast.persist.include)
|
||||
if (/token|password|secret|otp|api.?key/i.test(name))
|
||||
diagnostics.push({
|
||||
code: "WRN-PERSIST-SENSITIVE",
|
||||
severity: "error",
|
||||
message: `Sensitive field '${name}' cannot be persisted.`,
|
||||
hint: "Remove secrets, tokens, passwords, OTPs, and API keys from persistence.",
|
||||
file: options.file,
|
||||
});
|
||||
}
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ export type { RuntimeType } from "./types.ts";
|
||||
export {
|
||||
assertValidAst,
|
||||
classifyParseError,
|
||||
containsReadonlyPropMutation,
|
||||
diagnose,
|
||||
diagnosticFromError,
|
||||
formatDiagnostic,
|
||||
@@ -54,3 +55,25 @@ export {
|
||||
supportsSyntaxFeature,
|
||||
} from "./versioning.ts";
|
||||
export type { SourceRange, WrnSyntaxFeature } from "./versioning.ts";
|
||||
|
||||
export {
|
||||
parseComputedDeclarations,
|
||||
parseOutputs,
|
||||
parsePersist,
|
||||
parseRuntimeFunctions,
|
||||
parseStateDeclarations,
|
||||
parseStoreLifecycle,
|
||||
parseStructuredImports,
|
||||
stripRuntimeFunctionModifiers,
|
||||
} from "./v060.ts";
|
||||
export type {
|
||||
FunctionParameterDecl,
|
||||
FunctionRuntime,
|
||||
OutputDecl,
|
||||
PersistDecl,
|
||||
RuntimeFunctionDecl,
|
||||
StateRuntime,
|
||||
StoreKind,
|
||||
StoreLifecycleDecl,
|
||||
StructuredImportDecl,
|
||||
} from "./v060.ts";
|
||||
|
||||
+219
-55
@@ -24,9 +24,27 @@
|
||||
|
||||
import { Lexer, LexError, type Token } from "./tokenizer.ts";
|
||||
import { validateTypedInitializer } from "./types.ts";
|
||||
import {
|
||||
parseComputedDeclarations,
|
||||
parseOutputs,
|
||||
parsePersist,
|
||||
parseRuntimeFunctions,
|
||||
parseStateDeclarations,
|
||||
parseStoreLifecycle,
|
||||
parseStructuredImports,
|
||||
type OutputDecl,
|
||||
type PersistDecl,
|
||||
type RuntimeFunctionDecl,
|
||||
type StateRuntime,
|
||||
type StoreKind,
|
||||
type StoreLifecycleDecl,
|
||||
type StructuredImportDecl,
|
||||
} from "./v060.ts";
|
||||
|
||||
export interface StateDecl {
|
||||
name: string;
|
||||
/** Runtime visibility. Legacy declarations are shared. */
|
||||
runtime: StateRuntime;
|
||||
/** Explicit TypeScript-style type annotation, when supplied. */
|
||||
valueType?: string;
|
||||
/** Raw JS initializer expression, e.g. `0` or `'x'`. */
|
||||
@@ -35,6 +53,7 @@ export interface StateDecl {
|
||||
|
||||
export interface ComputedDecl {
|
||||
name: string;
|
||||
valueType?: string;
|
||||
expr: string;
|
||||
}
|
||||
|
||||
@@ -166,7 +185,7 @@ export interface PropDecl {
|
||||
}
|
||||
|
||||
export interface EventDecl {
|
||||
/** Public event name used by consumers as `@name="handler(event)"`. */
|
||||
/** Legacy public event declaration retained for compatibility. */
|
||||
name: string;
|
||||
}
|
||||
|
||||
@@ -174,22 +193,27 @@ export interface PageAst {
|
||||
type: "page";
|
||||
/** Static ES module imports declared before the WRN root declaration. */
|
||||
imports: string[];
|
||||
structuredImports: StructuredImportDecl[];
|
||||
/**
|
||||
* `page` is a route, `component` is a reusable fragment,
|
||||
* and `layout` is a reusable page wrapper.
|
||||
*/
|
||||
kind: "page" | "component" | "layout";
|
||||
kind: "page" | "component" | "layout" | "global-store" | "page-store";
|
||||
storeKind?: StoreKind;
|
||||
name: string;
|
||||
/** Name of the page layout (`app/layouts/<layout>.wrn`), if the page sets one. */
|
||||
layout?: string;
|
||||
layoutIsSymbol?: boolean;
|
||||
/** Execution boundary metadata. Defaults to universal. */
|
||||
runtime?: "server" | "client" | "universal";
|
||||
/** Client hydration strategy. Defaults to load when interactivity is present. */
|
||||
hydrate?: string;
|
||||
/** Declared component props (empty for pages). */
|
||||
props: PropDecl[];
|
||||
/** Public events exposed by a reusable component. */
|
||||
/** Legacy public events exposed by a reusable component. */
|
||||
events: EventDecl[];
|
||||
/** Canonical typed callable outputs. */
|
||||
outputs: OutputDecl[];
|
||||
/** Raw declarations from `types { ... }`, emitted as TypeScript. */
|
||||
types: string[];
|
||||
states: StateDecl[];
|
||||
@@ -202,9 +226,12 @@ export interface PageAst {
|
||||
view: ViewNode[];
|
||||
styles: string[];
|
||||
functions: string[];
|
||||
runtimeFunctions: RuntimeFunctionDecl[];
|
||||
dataApis: DataApiBlock[];
|
||||
modeFunctions: ModeFunctionsBlock[];
|
||||
lifecycle: LifecycleBlock;
|
||||
storeLifecycle: StoreLifecycleDecl;
|
||||
persist?: PersistDecl;
|
||||
watches: WatchBlock[];
|
||||
apis: ApiBlock[];
|
||||
realtimes: RealtimeBlock[];
|
||||
@@ -277,26 +304,49 @@ export function parse(source: string): PageAst {
|
||||
};
|
||||
|
||||
try {
|
||||
// A file may contain a page, reusable component, or reusable layout.
|
||||
// A file may contain a page, component, layout, global store, or page store.
|
||||
const opener = lx.next();
|
||||
|
||||
if (opener.type !== "ident" || !["page", "component", "layout"].includes(opener.value)) {
|
||||
if (
|
||||
opener.type !== "ident" ||
|
||||
!["page", "component", "layout", "global"].includes(opener.value)
|
||||
) {
|
||||
throw new ParseError(
|
||||
`Expected 'page', 'component', or 'layout' but got '${
|
||||
`Expected 'page', 'component', 'layout', 'global store', or 'page store' but got '${
|
||||
opener.value || opener.type
|
||||
}' at offset ${opener.pos}`,
|
||||
);
|
||||
}
|
||||
|
||||
const kind = opener.value as "page" | "component" | "layout";
|
||||
const name = expect("ident").value;
|
||||
let kind: PageAst["kind"];
|
||||
let storeKind: StoreKind | undefined;
|
||||
let name: string;
|
||||
if (opener.value === "global") {
|
||||
expectKeyword("store");
|
||||
kind = "global-store";
|
||||
storeKind = "global";
|
||||
name = expect("ident").value;
|
||||
} else if (
|
||||
opener.value === "page" &&
|
||||
lx.peek().type === "ident" &&
|
||||
lx.peek().value === "store"
|
||||
) {
|
||||
lx.next();
|
||||
kind = "page-store";
|
||||
storeKind = "page";
|
||||
name = expect("ident").value;
|
||||
} else {
|
||||
kind = opener.value as "page" | "component" | "layout";
|
||||
name = expect("ident").value;
|
||||
}
|
||||
expect("lbrace");
|
||||
|
||||
let layout: string | undefined;
|
||||
let layoutIsSymbol = false;
|
||||
let runtime: PageAst["runtime"];
|
||||
let hydrate: string | undefined;
|
||||
const props: PropDecl[] = [];
|
||||
const events: EventDecl[] = [];
|
||||
const outputs: OutputDecl[] = [];
|
||||
const types: string[] = [];
|
||||
const states: StateDecl[] = [];
|
||||
const computed: ComputedDecl[] = [];
|
||||
@@ -308,9 +358,12 @@ export function parse(source: string): PageAst {
|
||||
const view: ViewNode[] = [];
|
||||
const styles: string[] = [];
|
||||
const functions: string[] = [];
|
||||
const runtimeFunctions: RuntimeFunctionDecl[] = [];
|
||||
const dataApis: DataApiBlock[] = [];
|
||||
const modeFunctions: ModeFunctionsBlock[] = [];
|
||||
const lifecycle: LifecycleBlock = {};
|
||||
let storeLifecycle: StoreLifecycleDecl = {};
|
||||
let persist: PersistDecl | undefined;
|
||||
const watches: WatchBlock[] = [];
|
||||
const apis: ApiBlock[] = [];
|
||||
const realtimes: RealtimeBlock[] = [];
|
||||
@@ -326,7 +379,14 @@ export function parse(source: string): PageAst {
|
||||
// layout = "public" — selects app/layouts/<name>.wrn for this page.
|
||||
lx.next();
|
||||
expect("eq");
|
||||
layout = expect("string").value;
|
||||
const layoutToken = lx.next();
|
||||
if (layoutToken.type !== "string" && layoutToken.type !== "ident") {
|
||||
throw new ParseError(
|
||||
`Expected a layout string or imported symbol at offset ${layoutToken.pos}`,
|
||||
);
|
||||
}
|
||||
layout = layoutToken.value;
|
||||
layoutIsSymbol = layoutToken.type === "ident";
|
||||
break;
|
||||
}
|
||||
case "runtime": {
|
||||
@@ -378,6 +438,7 @@ export function parse(source: string): PageAst {
|
||||
throw new ParseError(`Expected a prop name at offset ${t.pos}`);
|
||||
}
|
||||
const pName = expect("ident").value;
|
||||
const optional = lx.peek().type === "question" ? (lx.next(), true) : false;
|
||||
let valueType: string | undefined;
|
||||
let hasDefault = false;
|
||||
if (lx.peek().type === "colon") {
|
||||
@@ -390,43 +451,70 @@ export function parse(source: string): PageAst {
|
||||
hasDefault = true;
|
||||
}
|
||||
const defaultValue = hasDefault ? lx.readPropInitializer() : "undefined";
|
||||
props.push({ name: pName, valueType, required: !hasDefault, default: defaultValue });
|
||||
props.push({
|
||||
name: pName,
|
||||
valueType,
|
||||
required: !hasDefault && !optional,
|
||||
default: defaultValue,
|
||||
});
|
||||
}
|
||||
expect("rbrace");
|
||||
break;
|
||||
}
|
||||
case "state": {
|
||||
lx.next();
|
||||
if (lx.peek().type === "lbrace") {
|
||||
const grouped = parseStateDeclarations(lx.readBalancedBraces(), "shared");
|
||||
states.push(...grouped);
|
||||
break;
|
||||
}
|
||||
const sName = expect("ident").value;
|
||||
let valueType: string | undefined;
|
||||
if (lx.peek().type === "colon") {
|
||||
lx.next();
|
||||
const annotation = lx.readTypeAnnotation();
|
||||
valueType = annotation.type;
|
||||
if (!annotation.hasDefault) {
|
||||
if (!annotation.hasDefault)
|
||||
throw new ParseError(`State '${sName}' requires an initializer`);
|
||||
}
|
||||
} else {
|
||||
expect("eq");
|
||||
}
|
||||
// State values may be multiline structured expressions. Use the same
|
||||
// balanced initializer reader as props so formatted arrays/objects
|
||||
// remain one declaration instead of exposing their inner braces as
|
||||
// component members on the next line.
|
||||
states.push({ name: sName, valueType, expr: lx.readPropInitializer() });
|
||||
states.push({
|
||||
name: sName,
|
||||
valueType,
|
||||
expr: lx.readPropInitializer(),
|
||||
runtime: "shared",
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "computed": {
|
||||
lx.next();
|
||||
expect("lbrace");
|
||||
while (lx.peek().type !== "rbrace") {
|
||||
const t = lx.peek();
|
||||
if (t.type === "eof") throw new ParseError("Unexpected end of input inside computed");
|
||||
const name = expect("ident").value;
|
||||
expect("eq");
|
||||
computed.push({ name, expr: lx.readPropInitializer() });
|
||||
if (lx.peek().type === "lbrace") {
|
||||
computed.push(...parseComputedDeclarations(lx.readBalancedBraces()));
|
||||
} else {
|
||||
const cName = expect("ident").value;
|
||||
let valueType: string | undefined;
|
||||
if (lx.peek().type === "colon") {
|
||||
lx.next();
|
||||
const annotation = lx.readTypeAnnotation();
|
||||
valueType = annotation.type;
|
||||
if (!annotation.hasDefault)
|
||||
throw new ParseError(`Computed '${cName}' requires an expression`);
|
||||
} else expect("eq");
|
||||
computed.push({ name: cName, valueType, expr: lx.readPropInitializer() });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "outputs": {
|
||||
lx.next();
|
||||
try {
|
||||
outputs.push(...parseOutputs(lx.readBalancedBraces()));
|
||||
} catch (error) {
|
||||
throw new ParseError(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
"WRN-OUTPUT-DECLARATION",
|
||||
);
|
||||
}
|
||||
expect("rbrace");
|
||||
break;
|
||||
}
|
||||
case "effect": {
|
||||
@@ -496,9 +584,24 @@ export function parse(source: string): PageAst {
|
||||
break;
|
||||
}
|
||||
case "ssr":
|
||||
case "client": {
|
||||
const mode: DataMode = kw.value === "ssr" ? "ssr" : "client";
|
||||
case "client":
|
||||
case "server": {
|
||||
const rawMode = kw.value;
|
||||
const mode: DataMode = rawMode === "client" ? "client" : "ssr";
|
||||
lx.next();
|
||||
if (
|
||||
(rawMode === "client" || rawMode === "server") &&
|
||||
lx.peek().type === "ident" &&
|
||||
lx.peek().value === "state"
|
||||
) {
|
||||
lx.next();
|
||||
if (lx.peek().type !== "lbrace")
|
||||
throw new ParseError(`Expected a grouped ${rawMode} state block`);
|
||||
states.push(
|
||||
...parseStateDeclarations(lx.readBalancedBraces(), rawMode as StateRuntime),
|
||||
);
|
||||
break;
|
||||
}
|
||||
if (mode === "client" && lx.peek().type === "eq") {
|
||||
lx.next();
|
||||
hydrate = expect("string").value;
|
||||
@@ -537,6 +640,14 @@ export function parse(source: string): PageAst {
|
||||
expect("rbrace");
|
||||
break;
|
||||
}
|
||||
case "shared": {
|
||||
lx.next();
|
||||
const member = expect("ident");
|
||||
if (member.value !== "state")
|
||||
throw new ParseError(`Expected 'state' after shared at offset ${member.pos}`);
|
||||
states.push(...parseStateDeclarations(lx.readBalancedBraces(), "shared"));
|
||||
break;
|
||||
}
|
||||
case "realtime": {
|
||||
lx.next();
|
||||
const rName = expect("ident").value;
|
||||
@@ -565,35 +676,51 @@ export function parse(source: string): PageAst {
|
||||
}
|
||||
case "lifecycle": {
|
||||
lx.next();
|
||||
expect("lbrace");
|
||||
|
||||
while (lx.peek().type !== "rbrace") {
|
||||
const hook = lx.peek();
|
||||
|
||||
if (hook.type === "eof") {
|
||||
throw new ParseError("Unexpected end of input inside lifecycle block");
|
||||
const body = lx.readBalancedBraces();
|
||||
if (kind === "global-store" || kind === "page-store") {
|
||||
storeLifecycle = parseStoreLifecycle(body);
|
||||
const allowedStoreHooks = new Set(["serverInit", "clientInit", "hydrate", "dispose"]);
|
||||
const hookLexer = new Lexer(body);
|
||||
while (hookLexer.peek().type !== "eof") {
|
||||
const token = hookLexer.next();
|
||||
if (token.type !== "ident") {
|
||||
throw new ParseError(`Expected a lifecycle hook at offset ${token.pos}`);
|
||||
}
|
||||
if (!allowedStoreHooks.has(token.value)) {
|
||||
throw new ParseError(`Unknown store lifecycle hook '${token.value}'`);
|
||||
}
|
||||
hookLexer.readBalancedBraces();
|
||||
}
|
||||
|
||||
if (hook.type !== "ident") {
|
||||
throw new ParseError(`Expected a lifecycle hook at offset ${hook.pos}`);
|
||||
} else {
|
||||
const allowedComponentHooks = new Set([
|
||||
"mount",
|
||||
"update",
|
||||
"unmount",
|
||||
"clientInit",
|
||||
"dispose",
|
||||
]);
|
||||
const hookLexer = new Lexer(body);
|
||||
while (hookLexer.peek().type !== "eof") {
|
||||
const token = hookLexer.next();
|
||||
if (token.type !== "ident") {
|
||||
throw new ParseError(`Expected a lifecycle hook at offset ${token.pos}`);
|
||||
}
|
||||
if (!allowedComponentHooks.has(token.value)) {
|
||||
throw new ParseError(`Unknown lifecycle hook '${token.value}'`);
|
||||
}
|
||||
const hookBody = hookLexer.readBalancedBraces();
|
||||
const hook =
|
||||
token.value === "clientInit"
|
||||
? "mount"
|
||||
: token.value === "dispose"
|
||||
? "unmount"
|
||||
: (token.value as LifecycleHookName);
|
||||
if (lifecycle[hook] !== undefined) {
|
||||
throw new ParseError(`Duplicate lifecycle hook '${hook}'`);
|
||||
}
|
||||
lifecycle[hook] = hookBody;
|
||||
}
|
||||
|
||||
if (hook.value !== "mount" && hook.value !== "update" && hook.value !== "unmount") {
|
||||
throw new ParseError(`Unknown lifecycle hook '${hook.value}' at offset ${hook.pos}`);
|
||||
}
|
||||
|
||||
const hookName = hook.value as LifecycleHookName;
|
||||
|
||||
lx.next();
|
||||
|
||||
if (lifecycle[hookName] !== undefined) {
|
||||
throw new ParseError(`Duplicate lifecycle hook '${hookName}' at offset ${hook.pos}`);
|
||||
}
|
||||
|
||||
lifecycle[hookName] = lx.readBalancedBraces();
|
||||
}
|
||||
|
||||
expect("rbrace");
|
||||
break;
|
||||
}
|
||||
case "watch": {
|
||||
@@ -611,7 +738,21 @@ export function parse(source: string): PageAst {
|
||||
}
|
||||
case "functions": {
|
||||
lx.next();
|
||||
functions.push(lx.readBalancedBraces());
|
||||
const body = lx.readBalancedBraces();
|
||||
functions.push(body);
|
||||
try {
|
||||
runtimeFunctions.push(...parseRuntimeFunctions(body));
|
||||
} catch (error) {
|
||||
throw new ParseError(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
"WRN-FUNCTION-DECLARATION",
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "persist": {
|
||||
lx.next();
|
||||
persist = parsePersist(lx.readBalancedBraces());
|
||||
break;
|
||||
}
|
||||
default:
|
||||
@@ -649,16 +790,36 @@ export function parse(source: string): PageAst {
|
||||
symbols.add(declaration.name);
|
||||
}
|
||||
|
||||
const outputNames = new Set<string>();
|
||||
for (const output of outputs) {
|
||||
if (outputNames.has(output.name))
|
||||
throw new ParseError(`Duplicate output '${output.name}'`, "WRN-OUTPUT-DUPLICATE");
|
||||
outputNames.add(output.name);
|
||||
}
|
||||
const functionKeys = new Set<string>();
|
||||
for (const fn of runtimeFunctions) {
|
||||
const key = `${fn.runtime}:${fn.name}`;
|
||||
if (functionKeys.has(key))
|
||||
throw new ParseError(
|
||||
`Duplicate ${fn.runtime} function '${fn.name}'`,
|
||||
"WRN-FUNCTION-DUPLICATE",
|
||||
);
|
||||
functionKeys.add(key);
|
||||
}
|
||||
return {
|
||||
type: "page",
|
||||
imports,
|
||||
structuredImports: parseStructuredImports(imports),
|
||||
kind,
|
||||
storeKind,
|
||||
name,
|
||||
layout,
|
||||
layoutIsSymbol,
|
||||
runtime,
|
||||
hydrate,
|
||||
props,
|
||||
events,
|
||||
outputs,
|
||||
types,
|
||||
states,
|
||||
computed,
|
||||
@@ -670,9 +831,12 @@ export function parse(source: string): PageAst {
|
||||
view,
|
||||
styles,
|
||||
functions,
|
||||
runtimeFunctions,
|
||||
dataApis,
|
||||
modeFunctions,
|
||||
lifecycle,
|
||||
storeLifecycle,
|
||||
persist,
|
||||
watches,
|
||||
apis,
|
||||
realtimes,
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
/** Canonical, machine-readable WRN language capabilities. */
|
||||
export const WRN_LANGUAGE_VERSION = "1.0";
|
||||
export const WRN_LANGUAGE_VERSION = "0.6";
|
||||
|
||||
export const WRN_ROOT_KINDS = ["page", "component", "layout"] as const;
|
||||
export const WRN_ROOT_KINDS = [
|
||||
"page",
|
||||
"component",
|
||||
"layout",
|
||||
"global-store",
|
||||
"page-store",
|
||||
] as const;
|
||||
export const WRN_ROOT_MEMBERS = [
|
||||
"layout",
|
||||
"runtime",
|
||||
@@ -9,7 +15,10 @@ export const WRN_ROOT_MEMBERS = [
|
||||
"client",
|
||||
"types",
|
||||
"props",
|
||||
"outputs",
|
||||
"state",
|
||||
"shared",
|
||||
"server",
|
||||
"computed",
|
||||
"effect",
|
||||
"watch",
|
||||
@@ -24,6 +33,7 @@ export const WRN_ROOT_MEMBERS = [
|
||||
"realtime",
|
||||
"style",
|
||||
"functions",
|
||||
"persist",
|
||||
] as const;
|
||||
|
||||
export const WRN_HYDRATION_STRATEGIES = ["load", "idle", "visible", "interaction", "none"] as const;
|
||||
@@ -47,4 +57,18 @@ export const WRN_DIAGNOSTIC_CODES = {
|
||||
invalidRuntime: "WRN-RUNTIME-TARGET",
|
||||
serverInteractive: "WRN-RUNTIME-SERVER-INTERACTIVE",
|
||||
accessibility: "WRN-A11Y-001",
|
||||
import: "WRN-IMPORT-001",
|
||||
function: "WRN-FUNCTION-001",
|
||||
client: "WRN-CLIENT-001",
|
||||
server: "WRN-SERVER-001",
|
||||
output: "WRN-OUTPUT-001",
|
||||
type: "WRN-TYPE-001",
|
||||
state: "WRN-STATE-001",
|
||||
component: "WRN-COMPONENT-001",
|
||||
template: "WRN-TEMPLATE-001",
|
||||
store: "WRN-STORE-001",
|
||||
persist: "WRN-PERSIST-001",
|
||||
rpc: "WRN-RPC-001",
|
||||
hydration: "WRN-HYDRATION-001",
|
||||
migration: "WRN-MIGRATION-001",
|
||||
} as const;
|
||||
|
||||
@@ -19,6 +19,7 @@ export type TokenType =
|
||||
| "eq"
|
||||
| "colon"
|
||||
| "comma"
|
||||
| "question"
|
||||
| "eof";
|
||||
|
||||
export interface Token {
|
||||
@@ -87,6 +88,9 @@ export class Lexer {
|
||||
case ",":
|
||||
this.pos++;
|
||||
return { type: "comma", value: c, pos };
|
||||
case "?":
|
||||
this.pos++;
|
||||
return { type: "question", value: c, pos };
|
||||
case '"':
|
||||
case "'":
|
||||
return this.readString(c, pos);
|
||||
@@ -243,6 +247,7 @@ export class Lexer {
|
||||
this.pos++;
|
||||
continue;
|
||||
}
|
||||
if (c === "}" && angle === 0 && square === 0 && brace === 0 && paren === 0) break;
|
||||
if (c === "<") angle++;
|
||||
else if (c === ">" && angle > 0) angle--;
|
||||
else if (c === "[") square++;
|
||||
@@ -253,6 +258,11 @@ export class Lexer {
|
||||
else if (c === ")" && paren > 0) paren--;
|
||||
|
||||
if (angle === 0 && square === 0 && brace === 0 && paren === 0) {
|
||||
if (c === " " || c === "\t") {
|
||||
let look = this.pos;
|
||||
while (look < src.length && (src[look] === " " || src[look] === "\t")) look++;
|
||||
if (/^[A-Za-z_][A-Za-z0-9_]*\??\s*:/.test(src.slice(look))) break;
|
||||
}
|
||||
if (c === "=") {
|
||||
this.pos++;
|
||||
const type = value.trim();
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
import { Lexer, LexError } from "./tokenizer.ts";
|
||||
|
||||
export type FunctionRuntime = "legacy" | "client" | "server" | "shared";
|
||||
export type StateRuntime = "shared" | "client" | "server";
|
||||
export type StoreKind = "global" | "page";
|
||||
|
||||
export interface FunctionParameterDecl {
|
||||
name: string;
|
||||
optional: boolean;
|
||||
valueType?: string;
|
||||
default?: string;
|
||||
}
|
||||
|
||||
export interface RuntimeFunctionDecl {
|
||||
name: string;
|
||||
runtime: FunctionRuntime;
|
||||
async: boolean;
|
||||
parameters: FunctionParameterDecl[];
|
||||
returnType?: string;
|
||||
body: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface OutputDecl {
|
||||
name: string;
|
||||
payload?: {
|
||||
name: string;
|
||||
valueType: string;
|
||||
optional: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StructuredImportDecl {
|
||||
source: string;
|
||||
typeOnly: boolean;
|
||||
defaultImport?: string;
|
||||
namespaceImport?: string;
|
||||
namedImports: Array<{ imported: string; local: string; typeOnly: boolean }>;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
export interface PersistDecl {
|
||||
storage: "memory" | "session" | "local";
|
||||
include: string[];
|
||||
version: number;
|
||||
migrations?: string;
|
||||
validation?: string;
|
||||
}
|
||||
|
||||
export interface StoreLifecycleDecl {
|
||||
serverInit?: string;
|
||||
clientInit?: string;
|
||||
hydrate?: string;
|
||||
dispose?: string;
|
||||
}
|
||||
|
||||
function splitTopLevel(input: string, separator = ","): string[] {
|
||||
const parts: string[] = [];
|
||||
let start = 0;
|
||||
let quote: string | null = null;
|
||||
let angle = 0;
|
||||
let square = 0;
|
||||
let brace = 0;
|
||||
let paren = 0;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const c = input[i]!;
|
||||
if (quote) {
|
||||
if (c === "\\") i++;
|
||||
else if (c === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
if (c === '"' || c === "'" || c === "`") {
|
||||
quote = c;
|
||||
continue;
|
||||
}
|
||||
if (c === "<") angle++;
|
||||
else if (c === ">" && angle > 0) angle--;
|
||||
else if (c === "[") square++;
|
||||
else if (c === "]" && square > 0) square--;
|
||||
else if (c === "{") brace++;
|
||||
else if (c === "}" && brace > 0) brace--;
|
||||
else if (c === "(") paren++;
|
||||
else if (c === ")" && paren > 0) paren--;
|
||||
else if (c === separator && angle === 0 && square === 0 && brace === 0 && paren === 0) {
|
||||
parts.push(input.slice(start, i).trim());
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
const tail = input.slice(start).trim();
|
||||
if (tail) parts.push(tail);
|
||||
return parts;
|
||||
}
|
||||
|
||||
function findTopLevelChar(input: string, wanted: string): number {
|
||||
let quote: string | null = null;
|
||||
let angle = 0;
|
||||
let square = 0;
|
||||
let brace = 0;
|
||||
let paren = 0;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const c = input[i]!;
|
||||
if (quote) {
|
||||
if (c === "\\") i++;
|
||||
else if (c === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
if (c === '"' || c === "'" || c === "`") {
|
||||
quote = c;
|
||||
continue;
|
||||
}
|
||||
if (c === "<") angle++;
|
||||
else if (c === ">" && angle > 0) angle--;
|
||||
else if (c === "[") square++;
|
||||
else if (c === "]" && square > 0) square--;
|
||||
else if (c === "{") brace++;
|
||||
else if (c === "}" && brace > 0) brace--;
|
||||
else if (c === "(") paren++;
|
||||
else if (c === ")" && paren > 0) paren--;
|
||||
if (c === wanted && angle === 0 && square === 0 && brace === 0 && paren === 0) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function parseParameters(source: string): FunctionParameterDecl[] {
|
||||
return splitTopLevel(source)
|
||||
.filter(Boolean)
|
||||
.map((entry) => {
|
||||
const eq = findTopLevelChar(entry, "=");
|
||||
const declaration = (eq >= 0 ? entry.slice(0, eq) : entry).trim();
|
||||
const defaultValue = eq >= 0 ? entry.slice(eq + 1).trim() : undefined;
|
||||
const colon = findTopLevelChar(declaration, ":");
|
||||
const rawName = (colon >= 0 ? declaration.slice(0, colon) : declaration).trim();
|
||||
const optional = rawName.endsWith("?");
|
||||
const name = optional ? rawName.slice(0, -1).trim() : rawName;
|
||||
const valueType = colon >= 0 ? declaration.slice(colon + 1).trim() : undefined;
|
||||
return {
|
||||
name,
|
||||
optional,
|
||||
...(valueType ? { valueType } : {}),
|
||||
...(defaultValue ? { default: defaultValue } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function skipTrivia(source: string, start: number): number {
|
||||
let i = start;
|
||||
while (i < source.length) {
|
||||
if (/\s/.test(source[i]!)) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (source.startsWith("//", i)) {
|
||||
const end = source.indexOf("\n", i + 2);
|
||||
i = end < 0 ? source.length : end + 1;
|
||||
continue;
|
||||
}
|
||||
if (source.startsWith("/*", i)) {
|
||||
const end = source.indexOf("*/", i + 2);
|
||||
i = end < 0 ? source.length : end + 2;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
function readWord(source: string, start: number): { word: string; end: number } | null {
|
||||
const match = /^[A-Za-z_$][\w$]*/.exec(source.slice(start));
|
||||
return match ? { word: match[0], end: start + match[0].length } : null;
|
||||
}
|
||||
|
||||
function readBalanced(
|
||||
source: string,
|
||||
start: number,
|
||||
open: string,
|
||||
close: string,
|
||||
): { inner: string; end: number } {
|
||||
if (source[start] !== open) throw new Error(`Expected '${open}' at offset ${start}`);
|
||||
let depth = 0;
|
||||
let quote: string | null = null;
|
||||
for (let i = start; i < source.length; i++) {
|
||||
const c = source[i]!;
|
||||
if (quote) {
|
||||
if (c === "\\") i++;
|
||||
else if (c === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
if (c === '"' || c === "'" || c === "`") {
|
||||
quote = c;
|
||||
continue;
|
||||
}
|
||||
if (c === open) depth++;
|
||||
else if (c === close && --depth === 0) return { inner: source.slice(start + 1, i), end: i + 1 };
|
||||
}
|
||||
throw new Error(`Unbalanced '${open}${close}' starting at offset ${start}`);
|
||||
}
|
||||
|
||||
export function parseRuntimeFunctions(source: string): RuntimeFunctionDecl[] {
|
||||
const declarations: RuntimeFunctionDecl[] = [];
|
||||
let i = 0;
|
||||
while (i < source.length) {
|
||||
i = skipTrivia(source, i);
|
||||
const start = i;
|
||||
let token = readWord(source, i);
|
||||
if (!token) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
let runtime: FunctionRuntime = "legacy";
|
||||
if (["client", "server", "shared"].includes(token.word)) {
|
||||
runtime = token.word as FunctionRuntime;
|
||||
i = skipTrivia(source, token.end);
|
||||
token = readWord(source, i);
|
||||
if (!token) continue;
|
||||
}
|
||||
let isAsync = false;
|
||||
if (token.word === "async") {
|
||||
isAsync = true;
|
||||
i = skipTrivia(source, token.end);
|
||||
token = readWord(source, i);
|
||||
if (!token) continue;
|
||||
}
|
||||
if (token.word !== "function") {
|
||||
i = token.end;
|
||||
continue;
|
||||
}
|
||||
i = skipTrivia(source, token.end);
|
||||
const nameToken = readWord(source, i);
|
||||
if (!nameToken) throw new Error(`Expected function name at offset ${i}`);
|
||||
const name = nameToken.word;
|
||||
i = skipTrivia(source, nameToken.end);
|
||||
const params = readBalanced(source, i, "(", ")");
|
||||
i = skipTrivia(source, params.end);
|
||||
let returnType: string | undefined;
|
||||
if (source[i] === ":") {
|
||||
i++;
|
||||
const typeStart = i;
|
||||
let quote: string | null = null;
|
||||
let angle = 0;
|
||||
let square = 0;
|
||||
let paren = 0;
|
||||
while (i < source.length) {
|
||||
const c = source[i]!;
|
||||
if (quote) {
|
||||
if (c === "\\") i++;
|
||||
else if (c === quote) quote = null;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c === '"' || c === "'" || c === "`") quote = c;
|
||||
else if (c === "<") angle++;
|
||||
else if (c === ">" && angle > 0) angle--;
|
||||
else if (c === "[") square++;
|
||||
else if (c === "]" && square > 0) square--;
|
||||
else if (c === "(") paren++;
|
||||
else if (c === ")" && paren > 0) paren--;
|
||||
else if (c === "{" && angle === 0 && square === 0 && paren === 0) break;
|
||||
i++;
|
||||
}
|
||||
returnType = source.slice(typeStart, i).trim();
|
||||
}
|
||||
i = skipTrivia(source, i);
|
||||
const body = readBalanced(source, i, "{", "}");
|
||||
i = body.end;
|
||||
declarations.push({
|
||||
name,
|
||||
runtime,
|
||||
async: isAsync,
|
||||
parameters: parseParameters(params.inner),
|
||||
...(returnType ? { returnType } : {}),
|
||||
body: body.inner,
|
||||
source: source.slice(start, body.end).trim(),
|
||||
});
|
||||
}
|
||||
return declarations;
|
||||
}
|
||||
|
||||
export function stripRuntimeFunctionModifiers(source: string, include: FunctionRuntime[]): string {
|
||||
const allowed = new Set(include);
|
||||
return parseRuntimeFunctions(source)
|
||||
.filter((entry) => allowed.has(entry.runtime))
|
||||
.map((entry) => {
|
||||
const params = entry.parameters
|
||||
.map(
|
||||
(param) =>
|
||||
`${param.name}${param.optional ? "?" : ""}${param.valueType ? `: ${param.valueType}` : ""}${param.default ? ` = ${param.default}` : ""}`,
|
||||
)
|
||||
.join(", ");
|
||||
return `${entry.async ? "async " : ""}function ${entry.name}(${params})${entry.returnType ? `: ${entry.returnType}` : ""} {${entry.body}}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
export function parseOutputs(source: string): OutputDecl[] {
|
||||
const out: OutputDecl[] = [];
|
||||
let i = 0;
|
||||
while (i < source.length) {
|
||||
i = skipTrivia(source, i);
|
||||
if (i >= source.length) break;
|
||||
const nameToken = readWord(source, i);
|
||||
if (!nameToken) throw new Error(`Expected output name at offset ${i}`);
|
||||
i = skipTrivia(source, nameToken.end);
|
||||
const args = readBalanced(source, i, "(", ")");
|
||||
i = args.end;
|
||||
const parameters = parseParameters(args.inner);
|
||||
if (parameters.length > 1)
|
||||
throw new Error(`Output '${nameToken.word}' accepts zero or one payload`);
|
||||
const payload = parameters[0];
|
||||
if (payload && !payload.valueType)
|
||||
throw new Error(`Output '${nameToken.word}' payload requires a type`);
|
||||
out.push({
|
||||
name: nameToken.word,
|
||||
...(payload
|
||||
? {
|
||||
payload: {
|
||||
name: payload.name,
|
||||
valueType: payload.valueType!,
|
||||
optional: payload.optional,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseStructuredImports(imports: string[]): StructuredImportDecl[] {
|
||||
return imports.map((raw) => {
|
||||
const sourceMatch = /\sfrom\s+["']([^"']+)["']|^import\s+["']([^"']+)["']/.exec(raw);
|
||||
const source = sourceMatch?.[1] ?? sourceMatch?.[2] ?? "";
|
||||
const typeOnly = /^import\s+type\b/.test(raw);
|
||||
const clause = raw
|
||||
.replace(/^import\s+(?:type\s+)?/, "")
|
||||
.replace(/\s+from\s+["'][^"']+["']\s*;?$/, "")
|
||||
.trim();
|
||||
const declaration: StructuredImportDecl = { source, typeOnly, namedImports: [], raw };
|
||||
if (!clause || clause.startsWith('"') || clause.startsWith("'")) return declaration;
|
||||
if (clause.startsWith("*")) {
|
||||
declaration.namespaceImport = /\*\s+as\s+([A-Za-z_$][\w$]*)/.exec(clause)?.[1];
|
||||
return declaration;
|
||||
}
|
||||
let rest = clause;
|
||||
if (!rest.startsWith("{")) {
|
||||
const comma = findTopLevelChar(rest, ",");
|
||||
declaration.defaultImport = (comma < 0 ? rest : rest.slice(0, comma)).trim();
|
||||
rest = comma < 0 ? "" : rest.slice(comma + 1).trim();
|
||||
}
|
||||
const named = /^\{([\s\S]*)\}$/.exec(rest)?.[1];
|
||||
if (named !== undefined) {
|
||||
declaration.namedImports = splitTopLevel(named).map((item) => {
|
||||
const localTypeOnly = /^type\s+/.test(item);
|
||||
const cleaned = item.replace(/^type\s+/, "").trim();
|
||||
const [imported, local] = cleaned.split(/\s+as\s+/);
|
||||
return {
|
||||
imported: imported!.trim(),
|
||||
local: (local ?? imported)!.trim(),
|
||||
typeOnly: typeOnly || localTypeOnly,
|
||||
};
|
||||
});
|
||||
}
|
||||
return declaration;
|
||||
});
|
||||
}
|
||||
|
||||
export function parseStateDeclarations(
|
||||
source: string,
|
||||
runtime: StateRuntime,
|
||||
): Array<{ name: string; valueType?: string; expr: string; runtime: StateRuntime }> {
|
||||
const lx = new Lexer(source);
|
||||
const out: Array<{ name: string; valueType?: string; expr: string; runtime: StateRuntime }> = [];
|
||||
while (lx.peek().type !== "eof") {
|
||||
const nameToken = lx.next();
|
||||
if (nameToken.type !== "ident")
|
||||
throw new LexError(`Expected a state name at offset ${nameToken.pos}`);
|
||||
let valueType: string | undefined;
|
||||
if (lx.peek().type === "colon") {
|
||||
lx.next();
|
||||
const annotation = lx.readTypeAnnotation();
|
||||
valueType = annotation.type;
|
||||
if (!annotation.hasDefault)
|
||||
throw new LexError(`State '${nameToken.value}' requires an initializer`);
|
||||
} else {
|
||||
const eq = lx.next();
|
||||
if (eq.type !== "eq")
|
||||
throw new LexError(`Expected '=' after state '${nameToken.value}' at offset ${eq.pos}`);
|
||||
}
|
||||
out.push({
|
||||
name: nameToken.value,
|
||||
...(valueType ? { valueType } : {}),
|
||||
expr: lx.readPropInitializer(),
|
||||
runtime,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseComputedDeclarations(
|
||||
source: string,
|
||||
): Array<{ name: string; valueType?: string; expr: string }> {
|
||||
const lx = new Lexer(source);
|
||||
const out: Array<{ name: string; valueType?: string; expr: string }> = [];
|
||||
while (lx.peek().type !== "eof") {
|
||||
const nameToken = lx.next();
|
||||
if (nameToken.type !== "ident")
|
||||
throw new LexError(`Expected a computed name at offset ${nameToken.pos}`);
|
||||
let valueType: string | undefined;
|
||||
if (lx.peek().type === "colon") {
|
||||
lx.next();
|
||||
const annotation = lx.readTypeAnnotation();
|
||||
valueType = annotation.type;
|
||||
if (!annotation.hasDefault)
|
||||
throw new LexError(`Computed '${nameToken.value}' requires an expression`);
|
||||
} else {
|
||||
const eq = lx.next();
|
||||
if (eq.type !== "eq")
|
||||
throw new LexError(`Expected '=' after computed '${nameToken.value}' at offset ${eq.pos}`);
|
||||
}
|
||||
out.push({
|
||||
name: nameToken.value,
|
||||
...(valueType ? { valueType } : {}),
|
||||
expr: lx.readPropInitializer(),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function nestedBlock(source: string, name: string): string | undefined {
|
||||
const match = new RegExp(`\\b${name}\\s*\\{`).exec(source);
|
||||
if (!match) return undefined;
|
||||
const brace = source.indexOf("{", match.index);
|
||||
return readBalanced(source, brace, "{", "}").inner.trim() || undefined;
|
||||
}
|
||||
|
||||
export function parsePersist(source: string): PersistDecl {
|
||||
const storage = /\bstorage\s*=\s*["'](memory|session|local)["']/.exec(source)?.[1] as
|
||||
PersistDecl["storage"] | undefined;
|
||||
const includeRaw = /\binclude\s*=\s*\[([\s\S]*?)\]/.exec(source)?.[1] ?? "";
|
||||
const include = Array.from(includeRaw.matchAll(/["']([^"']+)["']/g), (match) => match[1]!);
|
||||
const version = Number(/\bversion\s*=\s*(\d+)/.exec(source)?.[1] ?? "1");
|
||||
const migrations = nestedBlock(source, "migrations");
|
||||
const validation = nestedBlock(source, "validate");
|
||||
return {
|
||||
storage: storage ?? "memory",
|
||||
include,
|
||||
version,
|
||||
...(migrations ? { migrations } : {}),
|
||||
...(validation ? { validation } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseStoreLifecycle(source: string): StoreLifecycleDecl {
|
||||
const out: StoreLifecycleDecl = {};
|
||||
for (const hook of ["serverInit", "clientInit", "hydrate", "dispose"] as const) {
|
||||
const start = new RegExp(`\\b${hook}\\s*\\{`).exec(source);
|
||||
if (!start) continue;
|
||||
const brace = source.indexOf("{", start.index);
|
||||
out[hook] = readBalanced(source, brace, "{", "}").inner;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user