release: WRNexusJS 0.3.0

This commit is contained in:
2026-07-22 17:29:08 +05:30
parent 13dfa31d19
commit 07d8fb59d6
145 changed files with 9664 additions and 3881 deletions
+7
View File
@@ -0,0 +1,7 @@
# @wrnexus/syntax
Canonical WRN lexer, parser, AST, language metadata, source positions, and stable
diagnostics. Framework tooling should import this package instead of implementing a
separate `.wrn` parser.
See `docs/WRN-LANGUAGE-SPEC-1.0.md` in the WRNexusJS repository.
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@wrnexus/syntax",
"version": "0.3.0",
"type": "module",
"main": "src/index.ts",
"exports": {
".": "./src/index.ts",
"./parser": "./src/parser.ts",
"./tokenizer": "./src/tokenizer.ts",
"./types": "./src/types.ts",
"./diagnostics": "./src/diagnostics.ts",
"./spec": "./src/spec.ts"
}
}
+206
View File
@@ -0,0 +1,206 @@
import { parse, ParseError, type PageAst, type ViewNode } from "./parser.ts";
import {
WRN_DIAGNOSTIC_CODES,
WRN_HYDRATION_STRATEGIES,
WRN_RUNTIME_TARGETS,
type WrnHydrationStrategy,
type WrnRuntimeTarget,
} from "./spec.ts";
export type WrnDiagnosticSeverity = "error" | "warning" | "info";
export interface WrnSourcePosition {
offset: number;
line: number;
column: number;
}
export interface WrnDiagnostic {
code: string;
severity: WrnDiagnosticSeverity;
message: string;
hint?: string;
file?: string;
position?: WrnSourcePosition;
}
export interface DiagnoseOptions {
file?: string;
accessibility?: boolean;
}
export function positionAt(source: string, offset: number): WrnSourcePosition {
const safe = Math.max(0, Math.min(offset, source.length));
const before = source.slice(0, safe);
const lines = before.split(/\r?\n/);
return { offset: safe, line: lines.length, column: (lines.at(-1)?.length ?? 0) + 1 };
}
function offsetFromMessage(message: string): number | undefined {
const match = /offset\s+(\d+)/i.exec(message);
return match ? Number(match[1]) : undefined;
}
export function classifyParseError(message: string): string {
if (/Expected 'page', 'component', or 'layout'/.test(message)) return WRN_DIAGNOSTIC_CODES.root;
if (/Unknown (?:page|component|layout|ssr|client) member/.test(message)) {
return WRN_DIAGNOSTIC_CODES.member;
}
if (/prop initializer|Expected eq/.test(message)) return WRN_DIAGNOSTIC_CODES.propInitializer;
if (/State '.+' requires an initializer/.test(message)) {
return WRN_DIAGNOSTIC_CODES.stateInitializer;
}
if (/Cannot watch undeclared state/.test(message)) return WRN_DIAGNOSTIC_CODES.watchUndeclared;
return WRN_DIAGNOSTIC_CODES.parse;
}
export function diagnosticFromError(
source: string,
error: unknown,
options: DiagnoseOptions = {},
): WrnDiagnostic {
const message = error instanceof Error ? error.message : String(error);
const offset =
error instanceof ParseError && error.offset !== undefined
? error.offset
: offsetFromMessage(message);
return {
code: error instanceof ParseError ? error.code : classifyParseError(message),
severity: "error",
message,
file: options.file,
...(offset === undefined ? {} : { position: positionAt(source, offset) }),
};
}
function walk(nodes: ViewNode[], visit: (node: ViewNode) => void): void {
for (const node of nodes) {
visit(node);
if (node.type === "element") walk(node.children, visit);
else if (node.type === "each") {
walk(node.body, visit);
walk(node.empty, visit);
} else if (node.type === "if") {
for (const branch of node.branches) walk(branch.body, visit);
}
}
}
function astDiagnostics(ast: PageAst, options: DiagnoseOptions): WrnDiagnostic[] {
const diagnostics: WrnDiagnostic[] = [];
const seen = new Map<string, string>();
for (const [kind, declarations] of [
["prop", ast.props],
["state", ast.states],
["computed", ast.computed],
] as const) {
for (const declaration of declarations) {
const previous = seen.get(declaration.name);
if (previous) {
diagnostics.push({
code: WRN_DIAGNOSTIC_CODES.duplicateSymbol,
severity: "error",
message: `Duplicate symbol '${declaration.name}' (${previous} and ${kind}).`,
hint: "Rename one declaration so every prop, state, and computed value is unique.",
file: options.file,
});
} else {
seen.set(declaration.name, kind);
}
}
}
if (ast.hydrate && !isHydrationStrategy(ast.hydrate)) {
diagnostics.push({
code: WRN_DIAGNOSTIC_CODES.invalidHydration,
severity: "error",
message: `Unknown hydration strategy '${ast.hydrate}'.`,
hint: "Use load, idle, visible, interaction, none, or media:<query>.",
file: options.file,
});
}
if (ast.runtime && !isRuntimeTarget(ast.runtime)) {
diagnostics.push({
code: WRN_DIAGNOSTIC_CODES.invalidRuntime,
severity: "error",
message: `Unknown runtime target '${ast.runtime}'.`,
hint: "Use server, client, or universal.",
file: options.file,
});
}
let interactive = ast.states.length > 0 || ast.effects.length > 0 || ast.watches.length > 0;
walk(ast.view, (node) => {
if (node.type === "element" && node.attrs.some((attribute) => attribute.event))
interactive = true;
if (!options.accessibility || node.type !== "element") return;
const tag = node.tag.toLowerCase();
if (tag === "img" && !node.attrs.some((attribute) => attribute.name === "alt")) {
diagnostics.push({
code: WRN_DIAGNOSTIC_CODES.accessibility,
severity: "warning",
message: "Image is missing an alt attribute.",
hint: 'Add alt text, or alt="" for a decorative image.',
file: options.file,
});
}
});
if (ast.runtime === "server" && interactive) {
diagnostics.push({
code: WRN_DIAGNOSTIC_CODES.serverInteractive,
severity: "error",
message:
"A server-only WRN root cannot contain client state, effects, watches, or event handlers.",
hint: 'Use runtime = "universal" or remove interactive behavior.',
file: options.file,
});
}
return diagnostics;
}
export function diagnose(source: string, options: DiagnoseOptions = {}): WrnDiagnostic[] {
try {
return astDiagnostics(parse(source), options);
} catch (error) {
return [diagnosticFromError(source, error, options)];
}
}
export function assertValidAst(ast: PageAst, options: DiagnoseOptions = {}): void {
const errors = astDiagnostics(ast, options).filter(
(diagnostic) => diagnostic.severity === "error",
);
if (!errors.length) return;
const first = errors[0]!;
throw new ParseError(first.message, first.code);
}
export function isHydrationStrategy(value: string): value is WrnHydrationStrategy {
return (
(WRN_HYDRATION_STRATEGIES as readonly string[]).includes(value) ||
(value.startsWith("media:") && value.length > "media:".length)
);
}
export function isRuntimeTarget(value: string): value is WrnRuntimeTarget {
return (WRN_RUNTIME_TARGETS as readonly string[]).includes(value);
}
export function formatDiagnostic(source: string, diagnostic: WrnDiagnostic): string {
const location = diagnostic.position
? `${diagnostic.file ?? "<inline .wrn>"}:${diagnostic.position.line}:${diagnostic.position.column}`
: (diagnostic.file ?? "<inline .wrn>");
const lines = [
`${diagnostic.code} ${diagnostic.severity.toUpperCase()}`,
"",
diagnostic.message,
"",
location,
];
if (diagnostic.position) {
const sourceLine = source.split(/\r?\n/)[diagnostic.position.line - 1] ?? "";
lines.push("", sourceLine, `${" ".repeat(Math.max(0, diagnostic.position.column - 1))}^`);
}
if (diagnostic.hint) lines.push("", `Hint: ${diagnostic.hint}`);
return lines.join("\n");
}
+46
View File
@@ -0,0 +1,46 @@
export { Lexer, LexError } from "./tokenizer.ts";
export { parse, parseHtmlView, ParseError, VOID_ELEMENTS } from "./parser.ts";
export type {
ActionBlock,
ApiBlock,
Attr,
ComputedDecl,
DataApiBlock,
DataMode,
EffectBlock,
LifecycleBlock,
LoadBlock,
ModeFunctionsBlock,
PageAst,
PropDecl,
RealtimeBlock,
RealtimeHandler,
SeoBlock,
StateDecl,
ViewNode,
WatchBlock,
} from "./parser.ts";
export {
eraseFunctionTypes,
inferredRuntimeType,
runtimeTypeOf,
validateTypedInitializer,
} from "./types.ts";
export type { RuntimeType } from "./types.ts";
export {
assertValidAst,
classifyParseError,
diagnose,
diagnosticFromError,
formatDiagnostic,
isHydrationStrategy,
isRuntimeTarget,
positionAt,
} from "./diagnostics.ts";
export type {
DiagnoseOptions,
WrnDiagnostic,
WrnDiagnosticSeverity,
WrnSourcePosition,
} from "./diagnostics.ts";
export * from "./spec.ts";
+953
View File
@@ -0,0 +1,953 @@
/**
* Recursive-descent parser for `.wrn`, producing a small AST.
*
* Grammar (subset of the vision, but real):
*
* page <Name> {
* types { <TypeScript declarations> }
* props { <ident>: <type> [= <expr>] } // no default means required
* state <ident>: <type> = <expr> // type annotation is optional
* view { <html> } // plain HTML (see parseHtmlView)
* seo { title = "Home" description = "..." }
* ssr { api <name> <METHOD> <path> { <render js> } functions { <raw js> } }
* client { api <name> <METHOD> <path> { <render js> } functions { <raw js> } }
* style { <raw css> } // zero or more, inlined with the page
* functions { <raw js> } // zero or more, shared helpers
* api <METHOD> <path> { <raw js> } // zero or more
* realtime <name> { on <evt>(<args>) { <raw js> } * } // zero or more
* }
*
* The `view` block is written as ordinary HTML — nothing new to learn. Text may
* contain `{expr}` interpolation, attributes may be hyphenated (`data-*`), and
* `@event="..."` declares a client event binding. See `parseHtmlView`.
*/
import { Lexer, LexError, type Token } from "./tokenizer.ts";
import { validateTypedInitializer } from "./types.ts";
export interface StateDecl {
name: string;
/** Explicit TypeScript-style type annotation, when supplied. */
valueType?: string;
/** Raw JS initializer expression, e.g. `0` or `'x'`. */
expr: string;
}
export interface ComputedDecl {
name: string;
expr: string;
}
export interface EffectBlock {
body: string;
}
export interface LoadBlock {
mode: "server" | "client";
body: string;
}
export interface ActionBlock {
name: string;
args: string[];
body: string;
}
export interface Attr {
name: string;
value: string;
/** True for `@event` bindings (vs. plain HTML attributes). */
event: boolean;
/** True for a valueless boolean attribute, e.g. `<button disabled>`. */
boolean?: boolean;
}
/**
* HTML void elements: they have no children and no closing tag.
* @see https://html.spec.whatwg.org/multipage/syntax.html#void-elements
*/
export const VOID_ELEMENTS = new Set([
"area",
"base",
"br",
"col",
"embed",
"hr",
"img",
"input",
"link",
"meta",
"param",
"source",
"track",
"wbr",
]);
export type ViewNode =
| { type: "text"; value: string }
| { type: "element"; tag: string; attrs: Attr[]; children: ViewNode[] }
/**
* A server-side loop: `{#each <list> as <item>[, <index>] [key <expr>]} …body… {:empty} …empty… {/each}`.
* `list` is a JS expression (evaluated on the server, may reference an `ssr` data
* binding). The `body` is rendered once per item with `{item.field}` interpolation;
* `empty` renders when the list is empty. See codegen `compileEach`.
*/
| {
type: "each";
list: string;
item: string;
index?: string;
key?: string;
body: ViewNode[];
empty: ViewNode[];
}
/**
* A server-side conditional: `{#if <expr>} … {:else if <expr>} … {:else} … {/if}`.
* Rendered branches are chosen on the server. Each branch's `cond` is a JS expression
* (`null` for the final `{:else}`); the first truthy branch renders. See `compileIfExpr`.
*/
| { type: "if"; branches: { cond: string | null; body: ViewNode[] }[] };
export interface ApiBlock {
method: string;
path: string;
body: string;
}
export type SeoBlock = Record<string, string>;
export type DataMode = "ssr" | "client";
export interface DataApiBlock {
mode: DataMode;
name: string;
method: string;
path: string;
body: string;
}
export interface ModeFunctionsBlock {
mode: DataMode;
body: string;
}
export type LifecycleHookName = "mount" | "update" | "unmount";
export interface LifecycleBlock {
mount?: string;
update?: string;
unmount?: string;
}
export interface WatchBlock {
state: string;
body: string;
}
export interface RealtimeHandler {
event: string;
args: string[];
body: string;
}
export interface RealtimeBlock {
name: string;
handlers: RealtimeHandler[];
}
export interface PropDecl {
name: string;
/** Explicit TypeScript-style type annotation, when supplied. */
valueType?: string;
/** Props without a default are required. */
required: boolean;
/** Raw JS default expression, e.g. `0` or `'Count'`. Its type drives coercion. */
default: string;
}
export interface PageAst {
type: "page";
/** Static ES module imports declared before the WRN root declaration. */
imports: string[];
/**
* `page` is a route, `component` is a reusable fragment,
* and `layout` is a reusable page wrapper.
*/
kind: "page" | "component" | "layout";
name: string;
/** Name of the page layout (`app/layouts/<layout>.wrn`), if the page sets one. */
layout?: string;
/** 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[];
/** Raw declarations from `types { ... }`, emitted as TypeScript. */
types: string[];
states: StateDecl[];
computed: ComputedDecl[];
effects: EffectBlock[];
loads: LoadBlock[];
actions: ActionBlock[];
security: Record<string, string>;
seo: SeoBlock;
view: ViewNode[];
styles: string[];
functions: string[];
dataApis: DataApiBlock[];
modeFunctions: ModeFunctionsBlock[];
lifecycle: LifecycleBlock;
watches: WatchBlock[];
apis: ApiBlock[];
realtimes: RealtimeBlock[];
}
export class ParseError extends Error {
readonly code: string;
readonly offset?: number;
constructor(message: string, code = "WRN-PARSE-001") {
super(message);
this.name = "ParseError";
this.code = code;
const match = /offset\s+(\d+)/i.exec(message);
this.offset = match ? Number(match[1]) : undefined;
}
}
function parseSeoBlock(body: string): SeoBlock {
const out: SeoBlock = {};
const pair =
/([A-Za-z][A-Za-z0-9_-]*)\s*=\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|([^\n;]+))/g;
for (const match of body.matchAll(pair)) {
const key = match[1]!;
const rawValue = match[2] ?? match[3] ?? match[4] ?? "";
out[key] = unescapeSeoValue(rawValue.trim());
}
return out;
}
function unescapeSeoValue(value: string): string {
return value.replace(/\\(["'\\nrt])/g, (_match, ch: string) => {
if (ch === "n") return "\n";
if (ch === "r") return "\r";
if (ch === "t") return "\t";
return ch;
});
}
export function parse(source: string): PageAst {
const lx = new Lexer(source);
const imports: string[] = [];
const importPattern = /import\s+(?:type\s+)?(?:[\s\S]*?\s+from\s+)?["'][^"'\r\n]+["']\s*;?/y;
while (true) {
while (/\s/u.test(source[lx.pos] ?? "")) lx.pos++;
if (source.startsWith("//", lx.pos)) {
while (lx.pos < source.length && source[lx.pos] !== "\n") lx.pos++;
continue;
}
importPattern.lastIndex = lx.pos;
const statement = importPattern.exec(source);
if (!statement) break;
imports.push(statement[0].trim());
lx.pos = importPattern.lastIndex;
}
const expect = (type: Token["type"]): Token => {
const t = lx.next();
if (t.type !== type) {
throw new ParseError(`Expected ${type} but got '${t.value || t.type}' at offset ${t.pos}`);
}
return t;
};
const expectKeyword = (kw: string): void => {
const t = lx.next();
if (t.type !== "ident" || t.value !== kw) {
throw new ParseError(`Expected '${kw}' but got '${t.value || t.type}' at offset ${t.pos}`);
}
};
try {
// A file may contain a page, reusable component, or reusable layout.
const opener = lx.next();
if (opener.type !== "ident" || !["page", "component", "layout"].includes(opener.value)) {
throw new ParseError(
`Expected 'page', 'component', or 'layout' but got '${
opener.value || opener.type
}' at offset ${opener.pos}`,
);
}
const kind = opener.value as "page" | "component" | "layout";
const name = expect("ident").value;
expect("lbrace");
let layout: string | undefined;
let runtime: PageAst["runtime"];
let hydrate: string | undefined;
const props: PropDecl[] = [];
const types: string[] = [];
const states: StateDecl[] = [];
const computed: ComputedDecl[] = [];
const effects: EffectBlock[] = [];
const loads: LoadBlock[] = [];
const actions: ActionBlock[] = [];
const security: Record<string, string> = {};
const seo: SeoBlock = {};
const view: ViewNode[] = [];
const styles: string[] = [];
const functions: string[] = [];
const dataApis: DataApiBlock[] = [];
const modeFunctions: ModeFunctionsBlock[] = [];
const lifecycle: LifecycleBlock = {};
const watches: WatchBlock[] = [];
const apis: ApiBlock[] = [];
const realtimes: RealtimeBlock[] = [];
while (lx.peek().type !== "rbrace") {
const kw = lx.peek();
if (kw.type === "eof") throw new ParseError(`Unexpected end of input inside ${kind}`);
if (kw.type !== "ident") {
throw new ParseError(`Expected a ${kind} member keyword at offset ${kw.pos}`);
}
switch (kw.value) {
case "layout": {
// layout = "public" — selects app/layouts/<name>.wrn for this page.
lx.next();
expect("eq");
layout = expect("string").value;
break;
}
case "runtime": {
lx.next();
expect("eq");
const value = expect("string").value;
if (value !== "server" && value !== "client" && value !== "universal") {
throw new ParseError(
`Unknown runtime target '${value}' at offset ${kw.pos}`,
"WRN-RUNTIME-TARGET",
);
}
runtime = value;
break;
}
case "hydrate": {
lx.next();
expect("eq");
hydrate = expect("string").value;
break;
}
case "props": {
// props { name: Type = <default> } — omit the default for required props.
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 props");
if (t.type !== "ident") {
throw new ParseError(`Expected a prop name at offset ${t.pos}`);
}
const pName = expect("ident").value;
let valueType: string | undefined;
let hasDefault = false;
if (lx.peek().type === "colon") {
lx.next();
const annotation = lx.readTypeAnnotation();
valueType = annotation.type;
hasDefault = annotation.hasDefault;
} else {
expect("eq");
hasDefault = true;
}
const defaultValue = hasDefault ? lx.readPropInitializer() : "undefined";
props.push({ name: pName, valueType, required: !hasDefault, default: defaultValue });
}
expect("rbrace");
break;
}
case "state": {
lx.next();
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) {
throw new ParseError(`State '${sName}' requires an initializer`);
}
} else {
expect("eq");
}
states.push({ name: sName, valueType, expr: lx.readToLineEnd() });
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() });
}
expect("rbrace");
break;
}
case "effect": {
lx.next();
effects.push({ body: lx.readBalancedBraces() });
break;
}
case "types": {
lx.next();
types.push(lx.readBalancedBraces());
break;
}
case "view": {
lx.next();
expect("lbrace");
// The view body is plain HTML. Parse it straight off the source
// (the token lexer isn't used for markup), then resume after the
// block's closing `}`.
const { nodes, endPos } = parseHtmlView(lx.src, lx.pos);
view.push(...nodes);
lx.pos = endPos;
expect("rbrace");
break;
}
case "seo": {
lx.next();
Object.assign(seo, parseSeoBlock(lx.readBalancedBraces()));
break;
}
case "security": {
lx.next();
Object.assign(security, parseSeoBlock(lx.readBalancedBraces()));
break;
}
case "load": {
lx.next();
const modeToken = expect("ident");
if (modeToken.value !== "server" && modeToken.value !== "client") {
throw new ParseError(
`Expected 'server' or 'client' after load at offset ${modeToken.pos}`,
);
}
loads.push({ mode: modeToken.value, body: lx.readBalancedBraces() });
break;
}
case "action": {
lx.next();
const actionName = expect("ident").value;
const args: string[] = [];
if (lx.peek().type === "lparen") {
lx.next();
while (lx.peek().type !== "rparen") {
args.push(expect("ident").value);
if (lx.peek().type === "comma") lx.next();
}
expect("rparen");
}
actions.push({ name: actionName, args, body: lx.readBalancedBraces() });
break;
}
case "api": {
lx.next();
const method = expect("ident").value.toUpperCase();
const path = lx.readPath();
const body = lx.readBalancedBraces();
apis.push({ method, path, body });
break;
}
case "ssr":
case "client": {
const mode: DataMode = kw.value === "ssr" ? "ssr" : "client";
lx.next();
if (mode === "client" && lx.peek().type === "eq") {
lx.next();
hydrate = expect("string").value;
break;
}
expect("lbrace");
while (lx.peek().type !== "rbrace") {
const member = lx.peek();
if (member.type === "eof") {
throw new ParseError(`Unexpected end of input inside ${mode} block`);
}
if (member.type !== "ident") {
throw new ParseError(`Expected a ${mode} member keyword at offset ${member.pos}`);
}
switch (member.value) {
case "api": {
lx.next();
const name = expect("ident").value;
const method = expect("ident").value.toUpperCase();
const path = lx.readPath();
const body = lx.readBalancedBraces();
dataApis.push({ mode, name, method, path, body });
break;
}
case "functions": {
lx.next();
modeFunctions.push({ mode, body: lx.readBalancedBraces() });
break;
}
default:
throw new ParseError(
`Unknown ${mode} member '${member.value}' at offset ${member.pos}`,
);
}
}
expect("rbrace");
break;
}
case "realtime": {
lx.next();
const rName = expect("ident").value;
expect("lbrace");
const handlers: RealtimeHandler[] = [];
while (lx.peek().type !== "rbrace") {
expectKeyword("on");
const event = expect("ident").value;
expect("lparen");
const args: string[] = [];
while (lx.peek().type !== "rparen") {
args.push(expect("ident").value);
if (lx.peek().type === "comma") lx.next();
}
expect("rparen");
handlers.push({ event, args, body: lx.readBalancedBraces() });
}
expect("rbrace");
realtimes.push({ name: rName, handlers });
break;
}
case "style": {
lx.next();
styles.push(lx.readBalancedBraces());
break;
}
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");
}
if (hook.type !== "ident") {
throw new ParseError(`Expected a lifecycle hook at offset ${hook.pos}`);
}
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": {
lx.next();
const stateName = expect("ident").value;
const body = lx.readBalancedBraces();
watches.push({
state: stateName,
body,
});
break;
}
case "functions": {
lx.next();
functions.push(lx.readBalancedBraces());
break;
}
default:
throw new ParseError(`Unknown page member '${kw.value}' at offset ${kw.pos}`);
}
}
expect("rbrace");
const declaredStates = new Set(states.map((state) => state.name));
for (const watcher of watches) {
if (!declaredStates.has(watcher.state)) {
throw new ParseError(`Cannot watch undeclared state '${watcher.state}'`);
}
}
for (const prop of props) {
const problem = validateTypedInitializer(`Prop '${prop.name}'`, prop.valueType, prop.default);
if (problem) throw new ParseError(problem);
}
for (const state of states) {
const problem = validateTypedInitializer(
`State '${state.name}'`,
state.valueType,
state.expr,
);
if (problem) throw new ParseError(problem);
}
const symbols = new Set<string>();
for (const declaration of [...props, ...states, ...computed]) {
if (symbols.has(declaration.name)) {
throw new ParseError(`Duplicate symbol '${declaration.name}'`, "WRN-SYMBOL-DUPLICATE");
}
symbols.add(declaration.name);
}
return {
type: "page",
imports,
kind,
name,
layout,
runtime,
hydrate,
props,
types,
states,
computed,
effects,
loads,
actions,
security,
seo,
view,
styles,
functions,
dataApis,
modeFunctions,
lifecycle,
watches,
apis,
realtimes,
};
} catch (err) {
if (err instanceof LexError) throw new ParseError(err.message);
throw err;
}
}
/**
* Parse the body of a `view { ... }` block as plain HTML.
*
* `src` is the whole `.wrn` source; `pos` points just past the view block's
* opening `{`. Returns the parsed nodes plus the index of the block's closing
* `}` (left for the caller to consume). It is intentionally lenient — you write
* markup the way you already know:
*
* - `<tag attr="v" @event="expr">children</tag>` — elements with attributes
* - `<tag/>` and HTML void elements (`<br>`, `<img>`, …) — no closing tag
* - text may contain `{expr}` interpolation, kept verbatim for the runtime
* - `@event="..."` becomes a client event binding; hyphenated names are fine
* - `<!-- comments -->` are dropped
*
* `{` and `}` in text are reserved for interpolation; a lone `<` that isn't a
* tag is treated as literal text.
*/
export function parseHtmlView(src: string, pos: number): { nodes: ViewNode[]; endPos: number } {
let i = pos;
const isNameStart = (c: string): boolean => /[A-Za-z_]/.test(c);
const isTagNamePart = (c: string): boolean => /[A-Za-z0-9_$:.-]/.test(c);
const isWs = (c: string): boolean => c === " " || c === "\t" || c === "\n" || c === "\r";
const fail = (msg: string): never => {
throw new ParseError(`${msg} at offset ${i}`);
};
const skipWs = (): void => {
while (i < src.length && isWs(src[i]!)) i++;
};
/** Read a `{...}` interpolation (brace-balanced), braces included. */
const readInterpolation = (): string => {
const start = i;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === "{") depth++;
else if (src[i] === "}" && --depth === 0) {
i++;
return src.slice(start, i);
}
}
return fail("Unterminated `{` interpolation in view");
};
const readQuoted = (): string => {
const quote = src[i];
if (quote !== '"' && quote !== "'") return fail("Expected a quoted attribute value");
i++;
const start = i;
while (i < src.length && src[i] !== quote) i++;
if (i >= src.length) return fail("Unterminated attribute value");
const value = src.slice(start, i);
i++; // closing quote
return value;
};
const readTagName = (): string => {
if (i >= src.length || !isNameStart(src[i]!)) {
return fail("Expected a tag name");
}
const start = i++;
while (i < src.length && isTagNamePart(src[i]!)) {
i++;
}
return src.slice(start, i);
};
const readAttributeName = (): string => {
if (i >= src.length) {
return fail("Expected an attribute name");
}
const start = i;
while (i < src.length) {
const char = src[i];
const next = src[i + 1];
if (
char === "=" ||
char === ">" ||
char === '"' ||
char === "'" ||
char === " " ||
char === "\t" ||
char === "\n" ||
char === "\r" ||
(char === "/" && next === ">")
) {
break;
}
i++;
}
if (i === start) {
return fail("Expected an attribute name");
}
return src.slice(start, i);
};
const parseTag = (): ViewNode => {
i++; // consume '<'
const tag = readTagName();
const attrs: Attr[] = [];
for (;;) {
skipWs();
const c = src[i];
if (c === undefined) return fail(`Unterminated <${tag}> tag`);
if (c === ">") {
i++;
break;
}
if (c === "/" && src[i + 1] === ">") {
i += 2;
return { type: "element", tag, attrs, children: [] };
}
if (c === "@") {
i++;
const name = readAttributeName();
skipWs();
if (src[i] !== "=") return fail(`Expected '=' after @${name}`);
i++;
skipWs();
attrs.push({ name, value: readQuoted(), event: true });
continue;
}
const name = readAttributeName();
skipWs();
if (src[i] === "=") {
i++;
skipWs();
attrs.push({ name, value: readQuoted(), event: false });
} else {
attrs.push({ name, value: "", event: false, boolean: true });
}
}
if (VOID_ELEMENTS.has(tag.toLowerCase())) {
return { type: "element", tag, attrs, children: [] };
}
const children = parseNodeList("element");
// parseNodeList stops at the parent's closing tag `</`.
if (src[i] !== "<" || src[i + 1] !== "/") return fail(`Expected </${tag}>`);
i += 2;
skipWs();
const close = readTagName();
if (close !== tag) return fail(`Mismatched </${close}>, expected </${tag}>`);
skipWs();
if (src[i] !== ">") return fail(`Expected '>' to close </${tag}>`);
i++;
return { type: "element", tag, attrs, children };
};
const EACH_HEADER =
/^\{#each\s+([\s\S]+?)\s+as\s+([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*))?(?:\s+key\s+([\s\S]+?))?\s*\}$/;
/** Parse `{#each <list> as <item>[, <index>] [key <expr>]} …body… {:empty} …empty… {/each}`. */
function parseEach(): ViewNode {
const header = readInterpolation(); // reads the full `{#each …}`
const m = EACH_HEADER.exec(header);
if (!m) return fail(`Invalid {#each …} header: ${header}`);
const list = m[1]!.trim();
const item = m[2]!;
const index = m[3];
const key = m[4]?.trim();
const body = parseNodeList("each"); // stops at {:empty} or {/each}
let empty: ViewNode[] = [];
if (src.startsWith("{:empty}", i)) {
i += "{:empty}".length;
empty = parseNodeList("each"); // stops at {/each}
}
if (!src.startsWith("{/each}", i)) return fail("Expected `{/each}` to close `{#each}`");
i += "{/each}".length;
return { type: "each", list, item, index, key, body, empty };
}
/** Parse `{#if <expr>} … {:else if <expr>} … {:else} … {/if}`. */
function parseIf(): ViewNode {
const header = readInterpolation(); // reads the full `{#if …}`
const m = /^\{#if\s+([\s\S]+?)\s*\}$/.exec(header);
if (!m) return fail(`Invalid {#if …} header: ${header}`);
const branches: { cond: string | null; body: ViewNode[] }[] = [
{ cond: m[1]!.trim(), body: parseNodeList("if") },
];
for (;;) {
if (src.startsWith("{:else if", i)) {
const h = readInterpolation();
const mm = /^\{:else if\s+([\s\S]+?)\s*\}$/.exec(h);
if (!mm) return fail(`Invalid {:else if …}: ${h}`);
branches.push({ cond: mm[1]!.trim(), body: parseNodeList("if") });
continue;
}
if (src.startsWith("{:else}", i)) {
i += "{:else}".length;
branches.push({ cond: null, body: parseNodeList("if") });
continue;
}
break;
}
if (!src.startsWith("{/if}", i)) return fail("Expected `{/if}` to close `{#if}`");
i += "{/if}".length;
return { type: "if", branches };
}
/**
* Parse a run of nodes. `mode` sets the terminator:
* - "root": stops at the view block's closing `}`
* - "element": stops at the parent element's closing tag (`</`)
* - "each": stops (without consuming) at `{:empty}` or `{/each}`
* - "if": stops (without consuming) at `{:else …}` or `{/if}`
* `{#each …}` and `{#if …}` start nested blocks in any mode.
*/
function parseNodeList(mode: "root" | "element" | "each" | "if"): ViewNode[] {
const nodes: ViewNode[] = [];
let text = "";
const flush = (): void => {
if (text.length > 0) {
nodes.push({ type: "text", value: text });
text = "";
}
};
for (;;) {
if (i >= src.length) {
return mode === "root"
? fail("Unexpected end of view (missing `}`)")
: fail("Unclosed block");
}
const c = src[i]!;
if (c === "<") {
const next = src[i + 1];
if (next === "/") {
flush();
break; // parent's closing tag
}
if (src.startsWith("<!--", i)) {
const end = src.indexOf("-->", i + 4);
i = end === -1 ? src.length : end + 3;
continue;
}
if (next !== undefined && (isNameStart(next) || next === "!")) {
flush();
nodes.push(parseTag());
continue;
}
// A lone `<` that doesn't start a tag: treat as literal text.
text += c;
i++;
continue;
}
if (c === "{") {
if (src.startsWith("{#each", i)) {
flush();
nodes.push(parseEach());
continue;
}
if (src.startsWith("{#if", i)) {
flush();
nodes.push(parseIf());
continue;
}
if (mode === "each" && (src.startsWith("{:empty}", i) || src.startsWith("{/each}", i))) {
flush();
break; // loop-section terminator; left for parseEach
}
if (mode === "if" && (src.startsWith("{:else", i) || src.startsWith("{/if}", i))) {
flush();
break; // conditional-section terminator; left for parseIf
}
text += readInterpolation();
continue;
}
if (c === "}" && mode === "root") {
flush();
break; // view terminator; leave `}` for the caller
}
text += c;
i++;
}
return nodes;
}
const nodes = parseNodeList("root");
return { nodes, endPos: i };
}
+50
View File
@@ -0,0 +1,50 @@
/** Canonical, machine-readable WRN language capabilities. */
export const WRN_LANGUAGE_VERSION = "1.0";
export const WRN_ROOT_KINDS = ["page", "component", "layout"] as const;
export const WRN_ROOT_MEMBERS = [
"layout",
"runtime",
"hydrate",
"client",
"types",
"props",
"state",
"computed",
"effect",
"watch",
"lifecycle",
"view",
"seo",
"security",
"load",
"action",
"api",
"ssr",
"realtime",
"style",
"functions",
] as const;
export const WRN_HYDRATION_STRATEGIES = ["load", "idle", "visible", "interaction", "none"] as const;
export const WRN_RUNTIME_TARGETS = ["server", "client", "universal"] as const;
export type WrnRootKind = (typeof WRN_ROOT_KINDS)[number];
export type WrnRootMember = (typeof WRN_ROOT_MEMBERS)[number];
export type WrnHydrationStrategy = (typeof WRN_HYDRATION_STRATEGIES)[number] | `media:${string}`;
export type WrnRuntimeTarget = (typeof WRN_RUNTIME_TARGETS)[number];
export const WRN_DIAGNOSTIC_CODES = {
parse: "WRN-PARSE-001",
root: "WRN-PARSE-ROOT",
member: "WRN-PARSE-MEMBER",
propInitializer: "WRN-PROP-INITIALIZER",
stateInitializer: "WRN-STATE-INITIALIZER",
watchUndeclared: "WRN-WATCH-UNDECLARED",
duplicateSymbol: "WRN-SYMBOL-DUPLICATE",
invalidHydration: "WRN-HYDRATE-STRATEGY",
invalidRuntime: "WRN-RUNTIME-TARGET",
serverInteractive: "WRN-RUNTIME-SERVER-INTERACTIVE",
accessibility: "WRN-A11Y-001",
} as const;
+321
View File
@@ -0,0 +1,321 @@
/**
* Lexer for the `.wrn` language.
*
* `.wrn` mixes a small structural grammar (page/state/view/api/realtime) with
* raw JavaScript bodies. A pure token stream can't represent the raw JS, so the
* lexer is driven on demand by the parser: it yields structural tokens via
* `next()`/`peek()`, and exposes `readBalancedBraces()`, `readPath()` and
* `readToLineEnd()` for the parser to grab raw spans when grammar demands it.
*/
export type TokenType =
| "ident"
| "string"
| "lbrace"
| "rbrace"
| "lparen"
| "rparen"
| "at"
| "eq"
| "colon"
| "comma"
| "eof";
export interface Token {
type: TokenType;
value: string;
pos: number;
}
export class LexError extends Error {}
const isWs = (c: string) => c === " " || c === "\t" || c === "\n" || c === "\r";
const isIdentStart = (c: string) => /[A-Za-z_]/.test(c);
const isIdentPart = (c: string) => /[A-Za-z0-9_]/.test(c);
export class Lexer {
pos = 0;
constructor(public readonly src: string) {}
/** Skip whitespace and `// line comments`. */
private skipTrivia(): void {
const { src } = this;
while (this.pos < src.length) {
const c = src[this.pos]!;
if (isWs(c)) {
this.pos++;
continue;
}
if (c === "/" && src[this.pos + 1] === "/") {
while (this.pos < src.length && src[this.pos] !== "\n") this.pos++;
continue;
}
break;
}
}
/** Read and consume the next structural token. */
next(): Token {
this.skipTrivia();
const { src } = this;
const pos = this.pos;
if (pos >= src.length) return { type: "eof", value: "", pos };
const c = src[pos]!;
switch (c) {
case "{":
this.pos++;
return { type: "lbrace", value: c, pos };
case "}":
this.pos++;
return { type: "rbrace", value: c, pos };
case "(":
this.pos++;
return { type: "lparen", value: c, pos };
case ")":
this.pos++;
return { type: "rparen", value: c, pos };
case "@":
this.pos++;
return { type: "at", value: c, pos };
case "=":
this.pos++;
return { type: "eq", value: c, pos };
case ":":
this.pos++;
return { type: "colon", value: c, pos };
case ",":
this.pos++;
return { type: "comma", value: c, pos };
case '"':
case "'":
return this.readString(c, pos);
}
if (isIdentStart(c)) {
let v = "";
while (this.pos < src.length && isIdentPart(src[this.pos]!)) v += src[this.pos++];
return { type: "ident", value: v, pos };
}
throw new LexError(`Unexpected character '${c}' at offset ${pos} (line ${this.lineAt(pos)})`);
}
/** Look at the next token without consuming it. */
peek(): Token {
const save = this.pos;
const t = this.next();
this.pos = save;
return t;
}
private readString(quote: string, pos: number): Token {
const { src } = this;
let v = "";
this.pos++; // opening quote
while (this.pos < src.length) {
const c = src[this.pos++]!;
if (c === "\\") {
const n = src[this.pos++]!;
v += n === "n" ? "\n" : n === "t" ? "\t" : n;
continue;
}
if (c === quote) return { type: "string", value: v, pos };
v += c;
}
throw new LexError(`Unterminated string at offset ${pos}`);
}
/** Read a route path like `/users/[id]` up to whitespace or `{`. */
readPath(): string {
this.skipTrivia();
const { src } = this;
let v = "";
while (this.pos < src.length && !isWs(src[this.pos]!) && src[this.pos] !== "{") {
v += src[this.pos++];
}
if (!v) throw new LexError(`Expected a path at offset ${this.pos}`);
return v;
}
/**
* Read a prop default initializer. The initializer may contain nested arrays,
* objects, calls, strings, or template literals. At top level it ends at a
* newline, the closing brace of the props block, or the next inline prop
* declaration (`name = ...` / `name: Type = ...`).
*/
readPropInitializer(): string {
const { src } = this;
while (this.pos < src.length && (src[this.pos] === " " || src[this.pos] === "\t")) {
this.pos++;
}
const start = this.pos;
let square = 0;
let brace = 0;
let paren = 0;
let angle = 0;
let quote: string | null = null;
const atTopLevel = () => square === 0 && brace === 0 && paren === 0 && angle === 0;
while (this.pos < src.length) {
const c = src[this.pos]!;
if (quote) {
this.pos++;
if (c === "\\" && this.pos < src.length) {
this.pos++;
} else if (c === quote) {
quote = null;
}
continue;
}
if (c === '"' || c === "'" || c === "`") {
quote = c;
this.pos++;
continue;
}
if (atTopLevel()) {
if (c === "\n" || c === "\r" || c === "}") break;
if (c === " " || c === "\t") {
let look = this.pos;
while (look < src.length && (src[look] === " " || src[look] === "\t")) look++;
const rest = src.slice(look);
if (/^[A-Za-z_][A-Za-z0-9_]*(?:\s*:[^=\r\n{}]+)?\s*=/.test(rest)) break;
}
}
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 === "<") angle++;
else if (c === ">" && angle > 0) angle--;
this.pos++;
}
const value = src.slice(start, this.pos).trim();
if (!value) throw new LexError(`Expected a prop initializer at offset ${start}`);
return value;
}
/** Read the rest of the current line (used for `state x = <expr>`). */
readToLineEnd(): string {
const { src } = this;
let v = "";
while (this.pos < src.length && src[this.pos] !== "\n") v += src[this.pos++];
return v.trim();
}
/**
* Read a TypeScript-style type annotation after `:`. Reading stops at a
* top-level `=` or line ending, while nested object/tuple/generic syntax is
* preserved. The optional `=` is consumed for the caller.
*/
readTypeAnnotation(): { type: string; hasDefault: boolean } {
const { src } = this;
let value = "";
let angle = 0;
let square = 0;
let brace = 0;
let paren = 0;
let quote: string | null = null;
while (this.pos < src.length) {
const c = src[this.pos]!;
if (quote) {
value += c;
this.pos++;
if (c === "\\" && this.pos < src.length) value += src[this.pos++]!;
else if (c === quote) quote = null;
continue;
}
if (c === '"' || c === "'" || c === "`") {
quote = c;
value += c;
this.pos++;
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 (angle === 0 && square === 0 && brace === 0 && paren === 0) {
if (c === "=") {
this.pos++;
const type = value.trim();
if (!type) throw new LexError(`Expected a type annotation at offset ${this.pos}`);
return { type, hasDefault: true };
}
if (c === "\n" || c === "\r") break;
}
value += c;
this.pos++;
}
const type = value.trim();
if (!type) throw new LexError(`Expected a type annotation at offset ${this.pos}`);
return { type, hasDefault: false };
}
/**
* Read a `{ ... }` block and return its INNER text (no outer braces), with
* brace counting that respects string and template literals so a `}` inside a
* string doesn't end the block early.
*/
readBalancedBraces(): string {
this.skipTrivia();
const { src } = this;
if (src[this.pos] !== "{") {
throw new LexError(`Expected '{' at offset ${this.pos}`);
}
const start = this.pos + 1;
let depth = 0;
let i = this.pos;
let str: string | null = null;
for (; i < src.length; i++) {
const c = src[i]!;
if (str) {
if (c === "\\") {
i++;
continue;
}
if (c === str) str = null;
continue;
}
if (c === '"' || c === "'" || c === "`") {
str = c;
continue;
}
if (c === "{") depth++;
else if (c === "}") {
depth--;
if (depth === 0) {
this.pos = i + 1;
return src.slice(start, i);
}
}
}
throw new LexError(`Unbalanced braces starting at offset ${this.pos}`);
}
private lineAt(pos: number): number {
let line = 1;
for (let i = 0; i < pos && i < this.src.length; i++) {
if (this.src[i] === "\n") line++;
}
return line;
}
}
+63
View File
@@ -0,0 +1,63 @@
/** Utilities shared by typed `.wrn` parsing, validation, and code generation. */
export type RuntimeType =
"string" | "number" | "boolean" | "bigint" | "array" | "object" | "function" | "unknown";
export function runtimeTypeOf(annotation: string | undefined): RuntimeType {
if (!annotation) return "unknown";
const type = annotation.trim().replace(/^readonly\s+/, "");
if (/^(?:string|String)(?:\s*\|\s*(?:null|undefined))*$/.test(type)) return "string";
if (/^(?:number|Number)(?:\s*\|\s*(?:null|undefined))*$/.test(type)) return "number";
if (/^(?:boolean|Boolean)(?:\s*\|\s*(?:null|undefined))*$/.test(type)) return "boolean";
if (/^bigint(?:\s*\|\s*(?:null|undefined))*$/.test(type)) return "bigint";
if (/^(?:Array\s*<|ReadonlyArray\s*<|.+\[\])/.test(type) || /^\[/.test(type)) return "array";
if (/^(?:Record\s*<|object\b|\{)/.test(type)) return "object";
if (/=>|^(?:Function|\([^)]*\)\s*=>)/.test(type)) return "function";
return "unknown";
}
export function inferredRuntimeType(expression: string): RuntimeType {
const value = expression.trim();
if (/^["'`]/.test(value)) return "string";
if (/^-?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i.test(value)) return "number";
if (/^(?:true|false)$/.test(value)) return "boolean";
if (/^-?\d+n$/.test(value)) return "bigint";
if (value.startsWith("[")) return "array";
if (value.startsWith("{") || /^new\s+(?:Map|Set|Date)\b/.test(value)) return "object";
if (/^(?:async\s+)?(?:function\b|\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)/.test(value)) {
return "function";
}
return "unknown";
}
export function validateTypedInitializer(
name: string,
annotation: string | undefined,
expression: string,
): string | null {
if (!annotation || expression.trim() === "undefined" || expression.trim() === "null") return null;
const expected = runtimeTypeOf(annotation);
const actual = inferredRuntimeType(expression);
if (expected === "unknown" || actual === "unknown" || expected === actual) return null;
return `${name} is declared as ${annotation}, but its initializer is ${actual}`;
}
/**
* Browser behavior is evaluated as JavaScript, so erase TypeScript annotations
* from ordinary function declarations before serializing it into HTML.
* Server output retains the original typed source.
*/
export function eraseFunctionTypes(source: string): string {
return source.replace(
/(\b(?:async\s+)?function\s+[A-Za-z_$][\w$]*\s*\()([^)]*)(\)\s*)(?::\s*([^{}=>]+)\s*)?(\{)/g,
(_whole, open: string, params: string, close: string, _returnType: string, brace: string) => {
const plainParams = params
.split(",")
.map((param) =>
param.replace(/([A-Za-z_$][\w$]*)(\?)?\s*:\s*([^=]+?)(?=\s*=|$)/, "$1").trim(),
)
.join(", ");
return `${open}${plainParams}${close}${brace}`;
},
);
}
+94
View File
@@ -0,0 +1,94 @@
import { expect, test } from "bun:test";
import { diagnose, formatDiagnostic, parse } from "../src/index.ts";
test("parses WRN 0.3 execution, data, security, and reactivity blocks", () => {
const ast = parse(`page Dashboard {
runtime = "universal"
hydrate = "visible"
state count = 1
computed {
doubled = count * 2
}
effect {
console.log(doubled)
}
security {
auth = "required"
csrf = "true"
}
load server {
return { count: 1 }
}
load client {
return { refreshed: true }
}
action save(input) {
return input
}
view { <button @click="count++">{doubled}</button> }
}`);
expect(ast.runtime).toBe("universal");
expect(ast.hydrate).toBe("visible");
expect(ast.computed).toEqual([{ name: "doubled", expr: "count * 2" }]);
expect(ast.effects).toHaveLength(1);
expect(ast.security).toEqual({ auth: "required", csrf: "true" });
expect(ast.loads.map((load) => load.mode)).toEqual(["server", "client"]);
expect(ast.actions).toEqual([expect.objectContaining({ name: "save", args: ["input"] })]);
});
test("diagnoses server-only interactive roots and accessibility issues", () => {
const diagnostics = diagnose(
`component AvatarButton {
runtime = "server"
state open = false
view {
<button @click="open = true"><img src="/avatar.png"></button>
}
}`,
{ file: "AvatarButton.wrn", accessibility: true },
);
expect(diagnostics.map((diagnostic) => diagnostic.code)).toContain(
"WRN-RUNTIME-SERVER-INTERACTIVE",
);
expect(diagnostics.map((diagnostic) => diagnostic.code)).toContain("WRN-A11Y-001");
});
test("formats parser diagnostics with stable codes and source locations", () => {
const source = `page Broken { runtime = "worker"\n view { <main></main> } }`;
const [diagnostic] = diagnose(source, { file: "Broken.wrn" });
expect(diagnostic?.code).toBe("WRN-RUNTIME-TARGET");
expect(formatDiagnostic(source, diagnostic!)).toContain("Broken.wrn");
});
test("parses keyed each blocks without changing legacy loop syntax", () => {
const keyed = parse(`component Rows {
props { rows = [] }
view {
{#each rows as row, index key row.id}
<p>{index}: {row.name}</p>
{/each}
}
}`);
const loop = keyed.view.find((node) => node.type === "each");
expect(loop).toEqual(
expect.objectContaining({
type: "each",
list: "rows",
item: "row",
index: "index",
key: "row.id",
}),
);
const legacy = parse(`component Rows {
props { rows = [] }
view { {#each rows as row}<p>{row.name}</p>{/each} }
}`);
expect(legacy.view.find((node) => node.type === "each")).toEqual(
expect.objectContaining({ type: "each", key: undefined }),
);
});