diff --git a/bun.lock b/bun.lock index 1e72c531..fcc4697c 100644 --- a/bun.lock +++ b/bun.lock @@ -320,7 +320,7 @@ }, "packages/compiler": { "name": "@wrnexus/compiler", - "version": "0.8.21", + "version": "0.8.22", "dependencies": { "@wrnexus/csr": "workspace:*", "@wrnexus/store": "workspace:*", @@ -668,7 +668,7 @@ }, "packages/syntax": { "name": "@wrnexus/syntax", - "version": "0.8.17", + "version": "0.8.18", }, "packages/test": { "name": "@wrnexus/test", @@ -680,7 +680,7 @@ }, "packages/typecheck": { "name": "@wrnexus/typecheck", - "version": "0.8.15", + "version": "0.8.16", "dependencies": { "@wrnexus/syntax": "workspace:*", "typescript": "^6.0.3", diff --git a/editors/vscode/src/compiler.cjs b/editors/vscode/src/compiler.cjs index a362c49b..1d61a9f7 100644 --- a/editors/vscode/src/compiler.cjs +++ b/editors/vscode/src/compiler.cjs @@ -1,6 +1,6 @@ "use strict"; // Generated by scripts/build-editor-compiler.mjs. Do not edit directly. -// WRN editor compiler source hash: edac5449f97a341e79ee7bf7e7f3676f42bbb4ec6aac4432f5ace9ff88f4f85c +// WRN editor compiler source hash: 3d346a264807ba7b107584dc9a7b505b1d14057b2ad1aa85c5ea890f9b09c272 // WRN editor compiler generator hash: f8c1094b47e695151205899de28cd63d574356d26ac49219c4a43db0f2391ef5 // Generated with TypeScript: 6.0.3 const __nodeRequire = require; @@ -4067,6 +4067,7 @@ function islandNamesFrom(imports) { Object.defineProperty(exports, "__esModule", { value: true }); exports.NativeCompileError = void 0; exports.generateNative = generateNative; +const syntax_1 = require("@wrnexus/syntax"); class NativeCompileError extends Error { constructor(message) { super(message); @@ -4132,12 +4133,12 @@ function expression(value) { function textJsx(value) { const pieces = []; let last = 0; - for (const match of value.matchAll(/\{([^{}]+)\}/g)) { - if (match.index > last) - pieces.push(value.slice(last, match.index)); - const expr = match[1].trim(); + for (const segment of (0, syntax_1.extractInterpolations)(value)) { + if (segment.start > last) + pieces.push(value.slice(last, segment.start)); + const expr = segment.expression; pieces.push(expr.startsWith("t:") ? `{${JSON.stringify(expr.slice(2).trim())}}` : `{${expr}}`); - last = match.index + match[0].length; + last = segment.end; } pieces.push(value.slice(last)); return pieces.join("").replace(/([<>])/g, (char) => (char === "<" ? "<" : ">")); @@ -6388,7 +6389,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) { for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); }; Object.defineProperty(exports, "__esModule", { value: true }); -exports.stripRuntimeFunctionModifiers = exports.parseStructuredImports = exports.parseStoreLifecycle = exports.parseStateDeclarations = exports.parseRuntimeFunctions = exports.parsePersist = exports.parseOutputs = exports.parseComputedDeclarations = exports.supportsSyntaxFeature = exports.diagnosticSummary = exports.sliceSource = exports.createSourceRange = exports.WRN_SYNTAX_FEATURES = exports.WRN_SYNTAX_VERSION = exports.positionAt = exports.isRuntimeTarget = exports.isHydrationStrategy = exports.formatDiagnostic = exports.diagnosticFromError = exports.diagnose = exports.containsReadonlyPropMutation = exports.classifyParseError = exports.assertValidAst = exports.validateTypedInitializer = exports.runtimeTypeOf = exports.inferredRuntimeType = exports.eraseFunctionTypes = exports.VOID_ELEMENTS = exports.ParseError = exports.parseHtmlView = exports.parse = exports.formatWrn = exports.skipLiteralOrComment = exports.LexError = exports.Lexer = void 0; +exports.stripRuntimeFunctionModifiers = exports.parseStructuredImports = exports.parseStoreLifecycle = exports.parseStateDeclarations = exports.parseRuntimeFunctions = exports.parsePersist = exports.parseOutputs = exports.parseComputedDeclarations = exports.supportsSyntaxFeature = exports.diagnosticSummary = exports.sliceSource = exports.createSourceRange = exports.WRN_SYNTAX_FEATURES = exports.WRN_SYNTAX_VERSION = exports.positionAt = exports.isRuntimeTarget = exports.isHydrationStrategy = exports.formatDiagnostic = exports.diagnosticFromError = exports.diagnose = exports.containsReadonlyPropMutation = exports.classifyParseError = exports.assertValidAst = exports.validateTypedInitializer = exports.runtimeTypeOf = exports.inferredRuntimeType = exports.eraseFunctionTypes = exports.extractInterpolations = exports.VOID_ELEMENTS = exports.ParseError = exports.parseHtmlView = exports.parse = exports.formatWrn = exports.skipLiteralOrComment = exports.LexError = exports.Lexer = void 0; var tokenizer_ts_1 = require("./tokenizer.js"); Object.defineProperty(exports, "Lexer", { enumerable: true, get: function () { return tokenizer_ts_1.Lexer; } }); Object.defineProperty(exports, "LexError", { enumerable: true, get: function () { return tokenizer_ts_1.LexError; } }); @@ -6400,6 +6401,8 @@ Object.defineProperty(exports, "parse", { enumerable: true, get: function () { r Object.defineProperty(exports, "parseHtmlView", { enumerable: true, get: function () { return parser_ts_1.parseHtmlView; } }); Object.defineProperty(exports, "ParseError", { enumerable: true, get: function () { return parser_ts_1.ParseError; } }); Object.defineProperty(exports, "VOID_ELEMENTS", { enumerable: true, get: function () { return parser_ts_1.VOID_ELEMENTS; } }); +var interpolation_ts_1 = require("./interpolation.js"); +Object.defineProperty(exports, "extractInterpolations", { enumerable: true, get: function () { return interpolation_ts_1.extractInterpolations; } }); var types_ts_1 = require("./types.js"); Object.defineProperty(exports, "eraseFunctionTypes", { enumerable: true, get: function () { return types_ts_1.eraseFunctionTypes; } }); Object.defineProperty(exports, "inferredRuntimeType", { enumerable: true, get: function () { return types_ts_1.inferredRuntimeType; } }); @@ -6433,6 +6436,50 @@ Object.defineProperty(exports, "parseStoreLifecycle", { enumerable: true, get: f Object.defineProperty(exports, "parseStructuredImports", { enumerable: true, get: function () { return v060_ts_1.parseStructuredImports; } }); Object.defineProperty(exports, "stripRuntimeFunctionModifiers", { enumerable: true, get: function () { return v060_ts_1.stripRuntimeFunctionModifiers; } }); +}, +"packages/syntax/src/interpolation.ts": function (module, exports, require, __filename, __dirname) { +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.extractInterpolations = extractInterpolations; +/** Extract top-level `{...}` expressions while preserving nested object/array literals. */ +function extractInterpolations(value) { + const segments = []; + let index = 0; + while (index < value.length) { + if (value[index] !== "{") { + index++; + continue; + } + const start = index++; + let depth = 1; + let quote = null; + while (index < value.length && depth > 0) { + const char = value[index]; + if (quote) { + if (char === "\\" && index + 1 < value.length) + index += 2; + else { + if (char === quote) + quote = null; + index++; + } + continue; + } + if (char === '"' || char === "'" || char === "`") + quote = char; + else if (char === "{") + depth++; + else if (char === "}") + depth--; + index++; + } + if (depth === 0) { + segments.push({ expression: value.slice(start + 1, index - 1).trim(), start, end: index }); + } + } + return segments; +} + }, "packages/syntax/src/parser.ts": function (module, exports, require, __filename, __dirname) { "use strict"; diff --git a/editors/vscode/src/language-server.cjs b/editors/vscode/src/language-server.cjs index 2c304cd1..18d82ac7 100644 --- a/editors/vscode/src/language-server.cjs +++ b/editors/vscode/src/language-server.cjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// WRN editor language server source hash: 8579d66eaa70e4696e7a7fb8da796a292bf0b1395d3859a92d4cc6aa200ba71c +// WRN editor language server source hash: 5258b3e76cec47df752d4bb886fbd57a3f3c4af9ca9b6b2bf6742c8d78025ff1 // WRN editor language server generator hash: f593a44aaf05495b789ce7a3086bee1eebb951b884d41c0e017bbcfe5f547e72 // @bun @bun-cjs (function(exports, require, module, __filename, __dirname) {var __create = Object.create; @@ -172419,6 +172419,44 @@ function parseHtmlView(src, pos) { const nodes = parseNodeList("root"); return { nodes, endPos: i }; } +// packages/syntax/src/interpolation.ts +function extractInterpolations(value) { + const segments = []; + let index = 0; + while (index < value.length) { + if (value[index] !== "{") { + index++; + continue; + } + const start = index++; + let depth = 1; + let quote = null; + while (index < value.length && depth > 0) { + const char = value[index]; + if (quote) { + if (char === "\\" && index + 1 < value.length) + index += 2; + else { + if (char === quote) + quote = null; + index++; + } + continue; + } + if (char === '"' || char === "'" || char === "`") + quote = char; + else if (char === "{") + depth++; + else if (char === "}") + depth--; + index++; + } + if (depth === 0) { + segments.push({ expression: value.slice(start + 1, index - 1).trim(), start, end: index }); + } + } + return segments; +} // packages/syntax/src/diagnostics.ts function stripAsciiControlAndSpace(value) { let result = ""; @@ -173152,6 +173190,7 @@ function virtualTypeScriptModule(source, filePath = "component.wrn", appRoot = f append(`declare const api: { ${apiType} };`); append(`declare const props: Readonly<${ast.name}Props>;`); append("declare const refs: Record;"); + append("declare const toast: ((message: string, options?: Record) => unknown) & { success(message: string, options?: Record): unknown; error(message: string, options?: Record): unknown; danger(message: string, options?: Record): unknown; warning(message: string, options?: Record): unknown; info(message: string, options?: Record): unknown; dismiss(id: string | number): void; clear(): void };"); append("declare function useFetch(path: string, method?: string | { method?: string; query?: unknown; params?: unknown; body?: unknown; data?: unknown }, input?: unknown): Promise;"); append(ast.kind === "global-store" || ast.kind === "page-store" ? storeContract(ast) : componentContract(ast)); append(importedWrnDeclarations(ast, filePath, appRoot)); @@ -173183,7 +173222,7 @@ ${sharedAliases}`); for (const node of nodes) { if (node.type === "element") { for (const attr of node.attrs) { - const expressions = attr.event ? [attr.value] : [...attr.value.matchAll(/\{([^{}]+)\}/g)].map((match) => match[1].trim()); + const expressions = attr.event ? [attr.value] : extractInterpolations(attr.value).map((segment) => segment.expression); for (const expression of expressions) { if (!expression.trim()) continue; diff --git a/packages/compiler/package.json b/packages/compiler/package.json index 73da421d..ed58490d 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/compiler", - "version": "0.8.21", + "version": "0.8.22", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/compiler/src/native-codegen.ts b/packages/compiler/src/native-codegen.ts index ea0500c6..cebdf640 100644 --- a/packages/compiler/src/native-codegen.ts +++ b/packages/compiler/src/native-codegen.ts @@ -1,4 +1,4 @@ -import type { Attr, PageAst, ViewNode } from "@wrnexus/syntax"; +import { extractInterpolations, type Attr, type PageAst, type ViewNode } from "@wrnexus/syntax"; export class NativeCompileError extends Error { constructor(message: string) { @@ -68,11 +68,11 @@ function expression(value: string): string | null { function textJsx(value: string): string { const pieces: string[] = []; let last = 0; - for (const match of value.matchAll(/\{([^{}]+)\}/g)) { - if (match.index! > last) pieces.push(value.slice(last, match.index)); - const expr = match[1]!.trim(); + for (const segment of extractInterpolations(value)) { + if (segment.start > last) pieces.push(value.slice(last, segment.start)); + const expr = segment.expression; pieces.push(expr.startsWith("t:") ? `{${JSON.stringify(expr.slice(2).trim())}}` : `{${expr}}`); - last = match.index! + match[0].length; + last = segment.end; } pieces.push(value.slice(last)); return pieces.join("").replace(/([<>])/g, (char) => (char === "<" ? "<" : ">")); diff --git a/packages/syntax/package.json b/packages/syntax/package.json index 23de0eef..9f9bbd20 100644 --- a/packages/syntax/package.json +++ b/packages/syntax/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/syntax", - "version": "0.8.17", + "version": "0.8.18", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/syntax/src/index.ts b/packages/syntax/src/index.ts index cfe3ce36..00e86f89 100644 --- a/packages/syntax/src/index.ts +++ b/packages/syntax/src/index.ts @@ -2,6 +2,8 @@ export { Lexer, LexError, skipLiteralOrComment } from "./tokenizer.ts"; export { formatWrn } from "./formatter.ts"; export type { FormatWrnOptions } from "./formatter.ts"; export { parse, parseHtmlView, ParseError, VOID_ELEMENTS } from "./parser.ts"; +export { extractInterpolations } from "./interpolation.ts"; +export type { InterpolationSegment } from "./interpolation.ts"; export type { ActionBlock, ApiBlock, diff --git a/packages/syntax/src/interpolation.ts b/packages/syntax/src/interpolation.ts new file mode 100644 index 00000000..6a28cdc6 --- /dev/null +++ b/packages/syntax/src/interpolation.ts @@ -0,0 +1,39 @@ +export interface InterpolationSegment { + expression: string; + start: number; + end: number; +} + +/** Extract top-level `{...}` expressions while preserving nested object/array literals. */ +export function extractInterpolations(value: string): InterpolationSegment[] { + const segments: InterpolationSegment[] = []; + let index = 0; + while (index < value.length) { + if (value[index] !== "{") { + index++; + continue; + } + const start = index++; + let depth = 1; + let quote: string | null = null; + while (index < value.length && depth > 0) { + const char = value[index]!; + if (quote) { + if (char === "\\" && index + 1 < value.length) index += 2; + else { + if (char === quote) quote = null; + index++; + } + continue; + } + if (char === '"' || char === "'" || char === "`") quote = char; + else if (char === "{") depth++; + else if (char === "}") depth--; + index++; + } + if (depth === 0) { + segments.push({ expression: value.slice(start + 1, index - 1).trim(), start, end: index }); + } + } + return segments; +} diff --git a/packages/syntax/test/syntax.test.ts b/packages/syntax/test/syntax.test.ts index 5d9ce948..2431d1ff 100644 --- a/packages/syntax/test/syntax.test.ts +++ b/packages/syntax/test/syntax.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { diagnose, formatDiagnostic, parse } from "../src/index.ts"; +import { diagnose, extractInterpolations, formatDiagnostic, parse } from "../src/index.ts"; test("parses explicit rendering modes and the never hydration alias", () => { const ast = parse(`page Marketing { @@ -337,3 +337,14 @@ test("a block comment inside props is still refused with its own message", () => `; expect(() => parse(source)).toThrow("Block comments are not allowed inside props"); }); + +test("extracts nested object and array interpolation as one expression", () => { + const value = `prefix {[{ label: 'One', meta: { count: 1 } }, { label: "Two" }]} suffix`; + expect(extractInterpolations(value)).toEqual([ + { + expression: `[{ label: 'One', meta: { count: 1 } }, { label: "Two" }]`, + start: 7, + end: value.length - 7, + }, + ]); +}); diff --git a/packages/typecheck/package.json b/packages/typecheck/package.json index 5387befe..848dcdf4 100644 --- a/packages/typecheck/package.json +++ b/packages/typecheck/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/typecheck", - "version": "0.8.15", + "version": "0.8.16", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/typecheck/src/index.ts b/packages/typecheck/src/index.ts index d80a4d56..c7acaa7d 100644 --- a/packages/typecheck/src/index.ts +++ b/packages/typecheck/src/index.ts @@ -3,6 +3,7 @@ import { dirname, join, normalize, resolve } from "node:path"; import ts from "typescript"; import { containsReadonlyPropMutation, + extractInterpolations, inferredRuntimeType, parse, runtimeTypeOf, @@ -332,6 +333,9 @@ export function virtualTypeScriptModule( if (ast.dataApis.length) append(`declare const api: { ${apiType} };`); append(`declare const props: Readonly<${ast.name}Props>;`); append("declare const refs: Record;"); + append( + "declare const toast: ((message: string, options?: Record) => unknown) & { success(message: string, options?: Record): unknown; error(message: string, options?: Record): unknown; danger(message: string, options?: Record): unknown; warning(message: string, options?: Record): unknown; info(message: string, options?: Record): unknown; dismiss(id: string | number): void; clear(): void };", + ); append( "declare function useFetch(path: string, method?: string | { method?: string; query?: unknown; params?: unknown; body?: unknown; data?: unknown }, input?: unknown): Promise;", ); @@ -382,7 +386,7 @@ export function virtualTypeScriptModule( for (const attr of node.attrs) { const expressions = attr.event ? [attr.value] - : [...attr.value.matchAll(/\{([^{}]+)\}/g)].map((match) => match[1]!.trim()); + : extractInterpolations(attr.value).map((segment) => segment.expression); for (const expression of expressions) { if (!expression.trim()) continue; const declarations = [ diff --git a/packages/typecheck/test/typecheck.test.ts b/packages/typecheck/test/typecheck.test.ts index 55708990..fbab70c9 100644 --- a/packages/typecheck/test/typecheck.test.ts +++ b/packages/typecheck/test/typecheck.test.ts @@ -112,3 +112,32 @@ test("accepts handler payload, loop locals, and the public useFetch helper", () ); expect(diagnostics.filter((diagnostic) => diagnostic.code === "WRN-TYPE-2304")).toEqual([]); }); + +test("accepts multiline array props containing nested object literals", () => { + const root = app(); + const diagnostics = checkWrnSource( + `page Contact { + view { +
+ } + }`, + { appRoot: root, filePath: join(root, "app", "pages", "contact.wrn") }, + ); + expect(diagnostics.filter((diagnostic) => diagnostic.code.startsWith("WRN-TYPE-"))).toEqual([]); +}); + +test("provides the toast API to view event expressions", () => { + const root = app(); + const diagnostics = checkWrnSource( + `page Contact { + view { +
+ } + }`, + { appRoot: root, filePath: join(root, "app", "pages", "contact.wrn") }, + ); + expect(diagnostics.filter((diagnostic) => diagnostic.code.startsWith("WRN-TYPE-"))).toEqual([]); +});