release: WRNexusJS 0.6.0

This commit is contained in:
2026-08-01 01:09:58 +05:30
parent 3e565e8d03
commit 687d345882
502 changed files with 33038 additions and 11358 deletions
+186
View File
@@ -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;
}
+23
View File
@@ -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
View File
@@ -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,
+26 -2
View File
@@ -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;
+10
View File
@@ -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();
+460
View File
@@ -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;
}