fix(editor): type nested attribute expressions
Quality / quality (ubuntu-latest) (push) Failing after 9m52s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
2026-08-25 15:16:38 +05:30
parent 3cb6ba1233
commit 7658c5d499
12 changed files with 193 additions and 22 deletions
+3 -3
View File
@@ -320,7 +320,7 @@
}, },
"packages/compiler": { "packages/compiler": {
"name": "@wrnexus/compiler", "name": "@wrnexus/compiler",
"version": "0.8.21", "version": "0.8.22",
"dependencies": { "dependencies": {
"@wrnexus/csr": "workspace:*", "@wrnexus/csr": "workspace:*",
"@wrnexus/store": "workspace:*", "@wrnexus/store": "workspace:*",
@@ -668,7 +668,7 @@
}, },
"packages/syntax": { "packages/syntax": {
"name": "@wrnexus/syntax", "name": "@wrnexus/syntax",
"version": "0.8.17", "version": "0.8.18",
}, },
"packages/test": { "packages/test": {
"name": "@wrnexus/test", "name": "@wrnexus/test",
@@ -680,7 +680,7 @@
}, },
"packages/typecheck": { "packages/typecheck": {
"name": "@wrnexus/typecheck", "name": "@wrnexus/typecheck",
"version": "0.8.15", "version": "0.8.16",
"dependencies": { "dependencies": {
"@wrnexus/syntax": "workspace:*", "@wrnexus/syntax": "workspace:*",
"typescript": "^6.0.3", "typescript": "^6.0.3",
+54 -7
View File
@@ -1,6 +1,6 @@
"use strict"; "use strict";
// Generated by scripts/build-editor-compiler.mjs. Do not edit directly. // 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 // WRN editor compiler generator hash: f8c1094b47e695151205899de28cd63d574356d26ac49219c4a43db0f2391ef5
// Generated with TypeScript: 6.0.3 // Generated with TypeScript: 6.0.3
const __nodeRequire = require; const __nodeRequire = require;
@@ -4067,6 +4067,7 @@ function islandNamesFrom(imports) {
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.NativeCompileError = void 0; exports.NativeCompileError = void 0;
exports.generateNative = generateNative; exports.generateNative = generateNative;
const syntax_1 = require("@wrnexus/syntax");
class NativeCompileError extends Error { class NativeCompileError extends Error {
constructor(message) { constructor(message) {
super(message); super(message);
@@ -4132,12 +4133,12 @@ function expression(value) {
function textJsx(value) { function textJsx(value) {
const pieces = []; const pieces = [];
let last = 0; let last = 0;
for (const match of value.matchAll(/\{([^{}]+)\}/g)) { for (const segment of (0, syntax_1.extractInterpolations)(value)) {
if (match.index > last) if (segment.start > last)
pieces.push(value.slice(last, match.index)); pieces.push(value.slice(last, segment.start));
const expr = match[1].trim(); const expr = segment.expression;
pieces.push(expr.startsWith("t:") ? `{${JSON.stringify(expr.slice(2).trim())}}` : `{${expr}}`); 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)); pieces.push(value.slice(last));
return pieces.join("").replace(/([<>])/g, (char) => (char === "<" ? "&lt;" : "&gt;")); return pieces.join("").replace(/([<>])/g, (char) => (char === "<" ? "&lt;" : "&gt;"));
@@ -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); for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
}; };
Object.defineProperty(exports, "__esModule", { value: true }); 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"); var tokenizer_ts_1 = require("./tokenizer.js");
Object.defineProperty(exports, "Lexer", { enumerable: true, get: function () { return tokenizer_ts_1.Lexer; } }); 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; } }); 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, "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, "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; } }); 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"); var types_ts_1 = require("./types.js");
Object.defineProperty(exports, "eraseFunctionTypes", { enumerable: true, get: function () { return types_ts_1.eraseFunctionTypes; } }); 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; } }); 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, "parseStructuredImports", { enumerable: true, get: function () { return v060_ts_1.parseStructuredImports; } });
Object.defineProperty(exports, "stripRuntimeFunctionModifiers", { enumerable: true, get: function () { return v060_ts_1.stripRuntimeFunctionModifiers; } }); 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) { "packages/syntax/src/parser.ts": function (module, exports, require, __filename, __dirname) {
"use strict"; "use strict";
+41 -2
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env node #!/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 // WRN editor language server generator hash: f593a44aaf05495b789ce7a3086bee1eebb951b884d41c0e017bbcfe5f547e72
// @bun @bun-cjs // @bun @bun-cjs
(function(exports, require, module, __filename, __dirname) {var __create = Object.create; (function(exports, require, module, __filename, __dirname) {var __create = Object.create;
@@ -172419,6 +172419,44 @@ function parseHtmlView(src, pos) {
const nodes = parseNodeList("root"); const nodes = parseNodeList("root");
return { nodes, endPos: i }; 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 // packages/syntax/src/diagnostics.ts
function stripAsciiControlAndSpace(value) { function stripAsciiControlAndSpace(value) {
let result = ""; let result = "";
@@ -173152,6 +173190,7 @@ function virtualTypeScriptModule(source, filePath = "component.wrn", appRoot = f
append(`declare const api: { ${apiType} };`); append(`declare const api: { ${apiType} };`);
append(`declare const props: Readonly<${ast.name}Props>;`); append(`declare const props: Readonly<${ast.name}Props>;`);
append("declare const refs: Record<string, Element | null>;"); append("declare const refs: Record<string, Element | null>;");
append("declare const toast: ((message: string, options?: Record<string, unknown>) => unknown) & { success(message: string, options?: Record<string, unknown>): unknown; error(message: string, options?: Record<string, unknown>): unknown; danger(message: string, options?: Record<string, unknown>): unknown; warning(message: string, options?: Record<string, unknown>): unknown; info(message: string, options?: Record<string, unknown>): 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<any>;"); append("declare function useFetch(path: string, method?: string | { method?: string; query?: unknown; params?: unknown; body?: unknown; data?: unknown }, input?: unknown): Promise<any>;");
append(ast.kind === "global-store" || ast.kind === "page-store" ? storeContract(ast) : componentContract(ast)); append(ast.kind === "global-store" || ast.kind === "page-store" ? storeContract(ast) : componentContract(ast));
append(importedWrnDeclarations(ast, filePath, appRoot)); append(importedWrnDeclarations(ast, filePath, appRoot));
@@ -173183,7 +173222,7 @@ ${sharedAliases}`);
for (const node of nodes) { for (const node of nodes) {
if (node.type === "element") { if (node.type === "element") {
for (const attr of node.attrs) { 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) { for (const expression of expressions) {
if (!expression.trim()) if (!expression.trim())
continue; continue;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/compiler", "name": "@wrnexus/compiler",
"version": "0.8.21", "version": "0.8.22",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
+5 -5
View File
@@ -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 { export class NativeCompileError extends Error {
constructor(message: string) { constructor(message: string) {
@@ -68,11 +68,11 @@ function expression(value: string): string | null {
function textJsx(value: string): string { function textJsx(value: string): string {
const pieces: string[] = []; const pieces: string[] = [];
let last = 0; let last = 0;
for (const match of value.matchAll(/\{([^{}]+)\}/g)) { for (const segment of extractInterpolations(value)) {
if (match.index! > last) pieces.push(value.slice(last, match.index)); if (segment.start > last) pieces.push(value.slice(last, segment.start));
const expr = match[1]!.trim(); const expr = segment.expression;
pieces.push(expr.startsWith("t:") ? `{${JSON.stringify(expr.slice(2).trim())}}` : `{${expr}}`); 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)); pieces.push(value.slice(last));
return pieces.join("").replace(/([<>])/g, (char) => (char === "<" ? "&lt;" : "&gt;")); return pieces.join("").replace(/([<>])/g, (char) => (char === "<" ? "&lt;" : "&gt;"));
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/syntax", "name": "@wrnexus/syntax",
"version": "0.8.17", "version": "0.8.18",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
+2
View File
@@ -2,6 +2,8 @@ export { Lexer, LexError, skipLiteralOrComment } from "./tokenizer.ts";
export { formatWrn } from "./formatter.ts"; export { formatWrn } from "./formatter.ts";
export type { FormatWrnOptions } from "./formatter.ts"; export type { FormatWrnOptions } from "./formatter.ts";
export { parse, parseHtmlView, ParseError, VOID_ELEMENTS } from "./parser.ts"; export { parse, parseHtmlView, ParseError, VOID_ELEMENTS } from "./parser.ts";
export { extractInterpolations } from "./interpolation.ts";
export type { InterpolationSegment } from "./interpolation.ts";
export type { export type {
ActionBlock, ActionBlock,
ApiBlock, ApiBlock,
+39
View File
@@ -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;
}
+12 -1
View File
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"; 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", () => { test("parses explicit rendering modes and the never hydration alias", () => {
const ast = parse(`page Marketing { 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"); 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,
},
]);
});
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/typecheck", "name": "@wrnexus/typecheck",
"version": "0.8.15", "version": "0.8.16",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
+5 -1
View File
@@ -3,6 +3,7 @@ import { dirname, join, normalize, resolve } from "node:path";
import ts from "typescript"; import ts from "typescript";
import { import {
containsReadonlyPropMutation, containsReadonlyPropMutation,
extractInterpolations,
inferredRuntimeType, inferredRuntimeType,
parse, parse,
runtimeTypeOf, runtimeTypeOf,
@@ -332,6 +333,9 @@ export function virtualTypeScriptModule(
if (ast.dataApis.length) append(`declare const api: { ${apiType} };`); if (ast.dataApis.length) append(`declare const api: { ${apiType} };`);
append(`declare const props: Readonly<${ast.name}Props>;`); append(`declare const props: Readonly<${ast.name}Props>;`);
append("declare const refs: Record<string, Element | null>;"); append("declare const refs: Record<string, Element | null>;");
append(
"declare const toast: ((message: string, options?: Record<string, unknown>) => unknown) & { success(message: string, options?: Record<string, unknown>): unknown; error(message: string, options?: Record<string, unknown>): unknown; danger(message: string, options?: Record<string, unknown>): unknown; warning(message: string, options?: Record<string, unknown>): unknown; info(message: string, options?: Record<string, unknown>): unknown; dismiss(id: string | number): void; clear(): void };",
);
append( append(
"declare function useFetch(path: string, method?: string | { method?: string; query?: unknown; params?: unknown; body?: unknown; data?: unknown }, input?: unknown): Promise<any>;", "declare function useFetch(path: string, method?: string | { method?: string; query?: unknown; params?: unknown; body?: unknown; data?: unknown }, input?: unknown): Promise<any>;",
); );
@@ -382,7 +386,7 @@ export function virtualTypeScriptModule(
for (const attr of node.attrs) { for (const attr of node.attrs) {
const expressions = attr.event const expressions = attr.event
? [attr.value] ? [attr.value]
: [...attr.value.matchAll(/\{([^{}]+)\}/g)].map((match) => match[1]!.trim()); : extractInterpolations(attr.value).map((segment) => segment.expression);
for (const expression of expressions) { for (const expression of expressions) {
if (!expression.trim()) continue; if (!expression.trim()) continue;
const declarations = [ const declarations = [
+29
View File
@@ -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([]); 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 {
<div data-component="StatsBar" items="{[
{ label: 'Product catalog', value: '81', suffix: ' apps' },
{ label: 'Starting point', value: 'One workflow' }
]}"></div>
}
}`,
{ 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 {
<form @wrn:success="toast.success('Sent', { title: 'Request sent' })"></form>
}
}`,
{ appRoot: root, filePath: join(root, "app", "pages", "contact.wrn") },
);
expect(diagnostics.filter((diagnostic) => diagnostic.code.startsWith("WRN-TYPE-"))).toEqual([]);
});