release: WRNexusJS 0.6.0
This commit is contained in:
+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,
|
||||
|
||||
Reference in New Issue
Block a user