fix(editor): type nested attribute expressions
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/compiler",
|
||||
"version": "0.8.21",
|
||||
"version": "0.8.22",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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 === "<" ? "<" : ">"));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/syntax",
|
||||
"version": "0.8.17",
|
||||
"version": "0.8.18",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/typecheck",
|
||||
"version": "0.8.15",
|
||||
"version": "0.8.16",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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<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>;",
|
||||
);
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 {
|
||||
<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([]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user