diff --git a/editors/vscode/language-configuration.json b/editors/vscode/language-configuration.json
index 68fe0402..9302a17d 100644
--- a/editors/vscode/language-configuration.json
+++ b/editors/vscode/language-configuration.json
@@ -46,7 +46,7 @@
"folding": {
"offSide": false,
"markers": {
- "start": "^\\s*(?:page|component|api|middleware|realtime|view|seo|props|functions)\\b.*\\{\\s*$",
+ "start": "^\\s*(?:page|component|layout|api|middleware|realtime|view|seo|props|functions)\\b.*\\{\\s*$",
"end": "^\\s*\\}\\s*$"
}
}
diff --git a/editors/vscode/package.json b/editors/vscode/package.json
index 562e6544..bfda88c2 100644
--- a/editors/vscode/package.json
+++ b/editors/vscode/package.json
@@ -2,7 +2,7 @@
"name": "wrnexus",
"displayName": "WRNexus Language Support",
"description": "Syntax highlighting, formatting, diagnostics, snippets and completions for WrNexus .wrn files.",
- "version": "0.2.4",
+ "version": "0.2.5",
"publisher": "wrnexus",
"private": true,
"license": "SEE LICENSE IN LICENSE",
diff --git a/editors/vscode/snippets/wrn.json b/editors/vscode/snippets/wrn.json
index ae144f67..12952f52 100644
--- a/editors/vscode/snippets/wrn.json
+++ b/editors/vscode/snippets/wrn.json
@@ -56,6 +56,20 @@
]
},
+ "WRN layout": {
+ "prefix": ["wrn-layout", "layout"],
+ "description": "Create a WRN layout",
+ "body": [
+ "layout ${1:LayoutName} {",
+ " view {",
+ " ",
+ " {content}",
+ " ",
+ " }",
+ "}"
+ ]
+ },
+
"WRN state": {
"prefix": ["wrn-state", "state"],
"description": "Create reactive state",
diff --git a/editors/vscode/src/compiler.cjs b/editors/vscode/src/compiler.cjs
index 8a053f29..efb59e19 100644
--- a/editors/vscode/src/compiler.cjs
+++ b/editors/vscode/src/compiler.cjs
@@ -274,10 +274,10 @@ function parse(source) {
};
try {
const opener = lx.next();
- if (opener.type !== "ident" || opener.value !== "page" && opener.value !== "component") {
- throw new ParseError(`Expected 'page' or 'component' but got '${opener.value || opener.type}' at offset ${opener.pos}`);
+ 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 === "component" ? "component" : "page";
+ const kind = opener.value;
const name = expect("ident").value;
expect("lbrace");
let layout;
@@ -448,12 +448,12 @@ function parse(source) {
function parseHtmlView(src, pos) {
let i = pos;
const isNameStart = (c) => /[A-Za-z_]/.test(c);
- const isNamePart = (c) => /[A-Za-z0-9_:.[\]%-]/.test(c);
+ const isTagNamePart = (c) => /[A-Za-z0-9_$:.-]/.test(c);
const isAttributeNamePart = (c, next) => {
if (c === "/") {
return next !== ">";
}
- return isNamePart(c);
+ return /[A-Za-z0-9_$:.[\]%-]/.test(c);
};
const isWs2 = (c) => c === " " || c === "\t" || c === `
` || c === "\r";
@@ -491,9 +491,21 @@ function parseHtmlView(src, pos) {
i++;
return value;
};
- const readName = () => {
- if (i >= src.length || !isNameStart(src[i]))
- return fail("Expected a tag or attribute name");
+ const readTagName = () => {
+ 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 = () => {
+ const first = src[i];
+ if (i >= src.length || !isNameStart(first) && first !== ":" && first !== "$") {
+ return fail("Expected an attribute name");
+ }
const start = i++;
while (i < src.length && isAttributeNamePart(src[i], src[i + 1])) {
i++;
@@ -502,7 +514,7 @@ function parseHtmlView(src, pos) {
};
const parseTag = () => {
i++;
- const tag = readName();
+ const tag = readTagName();
const attrs = [];
for (;; ) {
skipWs();
@@ -519,7 +531,7 @@ function parseHtmlView(src, pos) {
}
if (c === "@") {
i++;
- const name2 = readName();
+ const name2 = readAttributeName();
skipWs();
if (src[i] !== "=")
return fail(`Expected '=' after @${name2}`);
@@ -528,7 +540,7 @@ function parseHtmlView(src, pos) {
attrs.push({ name: name2, value: readQuoted(), event: true });
continue;
}
- const name = readName();
+ const name = readAttributeName();
skipWs();
if (src[i] === "=") {
i++;
@@ -546,7 +558,7 @@ function parseHtmlView(src, pos) {
return fail(`Expected ${tag}>`);
i += 2;
skipWs();
- const close = readName();
+ const close = readTagName();
if (close !== tag)
return fail(`Mismatched ${close}>, expected ${tag}>`);
skipWs();
@@ -674,6 +686,9 @@ function parseHtmlView(src, pos) {
}
// ../../packages/compiler/src/codegen.ts
+function isComponentTag(tag) {
+ return /^[A-Z][A-Za-z0-9_$]*$/.test(tag);
+}
function attrEscape(value) {
return value.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">");
}
@@ -804,21 +819,30 @@ function bakeLoopAttr(raw) {
return out + escLit(attrEscape(raw.slice(last)));
}
function renderLoopBody(node) {
- if (node.type === "text")
+ if (node.type === "text") {
return bakeLoopText(node.value);
- if (node.type === "each")
+ }
+ if (node.type === "each") {
return compileEachExpr(node);
- if (node.type === "if")
+ }
+ if (node.type === "if") {
return compileIfExpr(node);
- const attrs = node.attrs.map((a) => {
- const name = a.event ? eventAttribute(a.name) : a.name;
- if (a.boolean)
+ }
+ const componentTag = isComponentTag(node.tag);
+ const attrs = node.attrs.filter((attr) => attr.name !== "data-component").map((attr) => {
+ const name = attr.event ? eventAttribute(attr.name) : attr.name;
+ if (attr.boolean) {
return escLit(` ${name}`);
- return escLit(` ${name}="`) + bakeLoopAttr(a.value) + escLit(`"`);
+ }
+ return escLit(` ${name}="`) + bakeLoopAttr(attr.value) + escLit(`"`);
}).join("");
- if (VOID_ELEMENTS.has(node.tag.toLowerCase()))
- return escLit(`<${node.tag}`) + attrs + escLit(">");
const inner = node.children.map(renderLoopBody).join("");
+ if (componentTag) {
+ return escLit(`
") + inner + escLit("
");
+ }
+ if (VOID_ELEMENTS.has(node.tag.toLowerCase())) {
+ return escLit(`<${node.tag}`) + attrs + escLit(">");
+ }
return escLit(`<${node.tag}`) + attrs + escLit(">") + inner + escLit(`${node.tag}>`);
}
function compileEachExpr(node) {
@@ -862,6 +886,9 @@ function renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive
loops.push(node.type === "each" ? compileEachExpr(node) : compileIfExpr(node));
return `\x00WRNEACH${loops.length - 1}\x00`;
}
+ if (isComponentTag(node.tag)) {
+ return renderPageComponentInvocation(node, ssrBindings, csrBindings, apiBindings, loops, reactive);
+ }
const apiName = attrValue(node.attrs, "api");
const apiBinding = apiName ? apiBindings.get(apiName) : undefined;
if (apiName && !apiBinding) {
@@ -888,6 +915,35 @@ function renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive
}) : node.children.map((child) => renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive)).join("");
return `<${node.tag}${renderAttrs(node.attrs, csrId, reactive)}>${inner}${node.tag}>`;
}
+function renderPageComponentInvocation(node, ssrBindings, csrBindings, apiBindings, loops, reactive) {
+ const attrs = node.attrs.filter((attr) => attr.name !== "data-component");
+ const inner = node.children.map((child) => renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive)).join("");
+ return `${inner}
`;
+}
+function renderNestedComponentInvocation(node, ctx) {
+ let bindIndex = 0;
+ const attrs = node.attrs.filter((attr) => attr.name !== "data-component").map((attr) => {
+ if (attr.event) {
+ return ` ${eventAttribute(attr.name)}="${compileAttrValue(attr.value, ctx)}"`;
+ }
+ if (attr.boolean) {
+ return ` ${attr.name}`;
+ }
+ const rendered = ` ${attr.name}="` + `${compileAttrValue(attr.value, ctx)}"`;
+ if (!attr.value.includes("{") || !exprRefsState(attr.value, ctx.stateNames)) {
+ return rendered;
+ }
+ const marker = attrEscape(JSON.stringify([attr.name, attr.value]));
+ return rendered + ` data-wrn-bind-${bindIndex++}="${escLit(marker)}"`;
+ }).join("");
+ const loops = loopVarsOf(node);
+ const childCtx = loops.length > 0 ? {
+ ...ctx,
+ loopVars: new Set([...ctx.loopVars ?? [], ...loops])
+ } : ctx;
+ const inner = node.children.map((child) => renderComponentNode(child, childCtx)).join("");
+ return `${inner}
`;
+}
function ssrMarker(bindings, binding) {
const marker = ``;
bindings.push({ marker, ...binding });
@@ -1000,8 +1056,9 @@ async function __wrnexusRenderSsrBindings(html: string, ctx: any): Promise`) + `\${__wireHtml(${ctx.resolveExpr(expr)})}` + escLit(``);
+ } else if (expr === "content") {
+ out += `\${__wireRaw(${ctx.resolveExpr(expr)})}`;
} else {
out += `\${__wireHtml(${ctx.resolveExpr(expr)})}`;
}
@@ -1262,6 +1321,9 @@ function renderComponentNode(node, ctx) {
if (node.type === "each" || node.type === "if") {
throw new Error("Server `{#each}` / `{#if}` blocks are supported in pages, not components. Move them into a page (or use data-for / data-show on the client).");
}
+ if (isComponentTag(node.tag)) {
+ return renderNestedComponentInvocation(node, ctx);
+ }
let bindIndex = 0;
const staticClasses = [];
const conditionalClasses = [];
@@ -1309,10 +1371,18 @@ function renderComponentNode(node, ctx) {
}
function generateComponent(ast) {
const out = [];
+ const effectiveProps = ast.kind === "layout" && !ast.props.some((prop) => prop.name === "content") ? [
+ {
+ name: "content",
+ default: '""'
+ },
+ ...ast.props
+ ] : ast.props;
const stateNames = new Set(ast.states.map((s) => s.name));
const nameRefs = new Map;
- for (const p of ast.props)
+ for (const p of effectiveProps) {
nameRefs.set(p.name, safeRef(p.name));
+ }
for (const s of ast.states)
nameRefs.set(s.name, safeRef(s.name));
const resolveExpr = (expr) => {
@@ -1331,9 +1401,12 @@ ${styles.map(styleEscape).join(`
`)}
`) : "";
const needsScope = ast.states.length > 0 || viewHasEvents(ast.view);
- const scopeKeys = [...ast.props.map((p) => p.name), ...ast.states.map((s) => s.name)];
+ const scopeKeys = [
+ ...effectiveProps.map((prop) => prop.name),
+ ...ast.states.map((state) => state.name)
+ ];
const decls = [];
- for (const prop of ast.props) {
+ for (const prop of effectiveProps) {
decls.push(` const ${nameRefs.get(prop.name)} = __coerce(__p[${JSON.stringify(prop.name)}], (${resolveExpr(prop.default)}));`);
}
for (const state of ast.states) {
@@ -1343,7 +1416,11 @@ ${styles.map(styleEscape).join(`
const scopeLine = needsScope && scopeKeys.length > 0 ? ` const __scope = __wrnexusScopeDecl({ ${scopeKeys.map((k) => `${JSON.stringify(k)}: ${nameRefs.get(k)}`).join(", ")} });
` : needsScope ? ` const __scope = "";
` : "";
- out.push(`export const __wrnexusComponent = ${JSON.stringify(ast.name)};`);
+ if (ast.kind === "layout") {
+ out.push(`export const __wrnexusLayout = ${JSON.stringify(ast.name)};`);
+ } else {
+ out.push(`export const __wrnexusComponent = ${JSON.stringify(ast.name)};`);
+ }
out.push(`function __coerce(v: any, def: any): any {
if (v === undefined || v === null) return def;
if (typeof def === "number") return Number(v);
diff --git a/editors/vscode/src/completion.js b/editors/vscode/src/completion.js
index 9d20d4b2..8020fb00 100644
--- a/editors/vscode/src/completion.js
+++ b/editors/vscode/src/completion.js
@@ -33,6 +33,20 @@ const BLOCK_COMPLETIONS = [
"}",
].join("\n"),
},
+ {
+ label: "layout",
+ detail: "WRN layout",
+ documentation: "Create a reusable WRN layout.",
+ snippet: [
+ "layout ${1:LayoutName} {",
+ " view {",
+ " ",
+ " {content}",
+ " ",
+ " }",
+ "}",
+ ].join("\n"),
+ },
{
label: "seo",
detail: "SEO metadata block",
diff --git a/editors/vscode/src/definition.js b/editors/vscode/src/definition.js
new file mode 100644
index 00000000..d4be17e7
--- /dev/null
+++ b/editors/vscode/src/definition.js
@@ -0,0 +1,134 @@
+"use strict";
+
+const vscode = require("vscode");
+
+const COMPONENT_DECLARATION =
+ /^\s*(component|layout)\s+([A-Za-z_$][\w$]*)\s*\{/gm;
+
+function getTagAtPosition(document, position) {
+ const range = document.getWordRangeAtPosition(
+ position,
+ /[A-Za-z_$][\w$]*/,
+ );
+
+ if (!range) {
+ return null;
+ }
+
+ const name = document.getText(range);
+
+ if (!/^[A-Z][A-Za-z0-9_$]*$/.test(name)) {
+ return null;
+ }
+
+ const line = document.lineAt(position.line).text;
+ const offset = document.offsetAt(position);
+ const lineStart = document.offsetAt(
+ new vscode.Position(position.line, 0),
+ );
+
+ const characterOffset = offset - lineStart;
+ const before = line.slice(0, characterOffset);
+ const after = line.slice(characterOffset);
+
+ const insideTag =
+ before.lastIndexOf("<") > before.lastIndexOf(">") &&
+ after.includes(">");
+
+ if (!insideTag) {
+ return null;
+ }
+
+ return {
+ name,
+ range,
+ };
+}
+
+async function findDeclaration(name) {
+ const files = await vscode.workspace.findFiles(
+ "**/*.wrn",
+ "**/{node_modules,dist,.wrnexus,.git}/**",
+ );
+
+ const matches = [];
+
+ for (const uri of files) {
+ let document;
+
+ try {
+ document = await vscode.workspace.openTextDocument(uri);
+ } catch {
+ continue;
+ }
+
+ const source = document.getText();
+
+ COMPONENT_DECLARATION.lastIndex = 0;
+
+ let match;
+
+ while ((match = COMPONENT_DECLARATION.exec(source)) !== null) {
+ const declarationName = match[2];
+
+ if (declarationName !== name) {
+ continue;
+ }
+
+ const nameOffset =
+ match.index +
+ match[0].indexOf(declarationName);
+
+ const start = document.positionAt(nameOffset);
+ const end = document.positionAt(
+ nameOffset + declarationName.length,
+ );
+
+ matches.push(
+ new vscode.Location(
+ uri,
+ new vscode.Range(start, end),
+ ),
+ );
+ }
+ }
+
+ return matches;
+}
+
+async function provideDefinition(document, position) {
+ const tag = getTagAtPosition(document, position);
+
+ if (!tag) {
+ return null;
+ }
+
+ const matches = await findDeclaration(tag.name);
+
+ if (matches.length === 0) {
+ return null;
+ }
+
+ return matches.length === 1 ? matches[0] : matches;
+}
+
+function registerDefinitionProvider(context) {
+ const disposable = vscode.languages.registerDefinitionProvider(
+ {
+ language: "wrn",
+ scheme: "file",
+ },
+ {
+ provideDefinition,
+ },
+ );
+
+ context.subscriptions.push(disposable);
+}
+
+module.exports = {
+ findDeclaration,
+ getTagAtPosition,
+ provideDefinition,
+ registerDefinitionProvider,
+};
\ No newline at end of file
diff --git a/editors/vscode/src/diagnostics.js b/editors/vscode/src/diagnostics.js
new file mode 100644
index 00000000..01b1d245
--- /dev/null
+++ b/editors/vscode/src/diagnostics.js
@@ -0,0 +1,761 @@
+"use strict";
+
+const vscode = require("vscode");
+
+const COLLECTION_NAME = "wrnexus";
+const WRN_LANGUAGE_ID = "wrn";
+
+const TOP_LEVEL_PATTERN =
+ /^\s*(page|component|layout)\s+([A-Za-z_$][\w$]*)\s*\{/;
+
+const VALID_TOP_LEVEL_KINDS = new Set([
+ "page",
+ "component",
+ "layout",
+]);
+
+const VALID_MEMBERS = {
+ page: new Set([
+ "layout",
+ "state",
+ "view",
+ "seo",
+ "style",
+ "functions",
+ "api",
+ "ssr",
+ "client",
+ "realtime",
+ ]),
+
+ component: new Set([
+ "props",
+ "state",
+ "view",
+ "style",
+ "functions",
+ ]),
+
+ layout: new Set([
+ "props",
+ "state",
+ "view",
+ "style",
+ "functions",
+ ]),
+};
+
+function createDiagnostic(
+ document,
+ startOffset,
+ endOffset,
+ message,
+ severity = vscode.DiagnosticSeverity.Error,
+ code,
+) {
+ const diagnostic = new vscode.Diagnostic(
+ new vscode.Range(
+ document.positionAt(startOffset),
+ document.positionAt(endOffset),
+ ),
+ message,
+ severity,
+ );
+
+ diagnostic.source = "WRNexus";
+
+ if (code) {
+ diagnostic.code = code;
+ }
+
+ return diagnostic;
+}
+
+function lineDiagnostic(
+ document,
+ lineNumber,
+ message,
+ severity = vscode.DiagnosticSeverity.Error,
+ code,
+) {
+ const line = document.lineAt(lineNumber);
+
+ const diagnostic = new vscode.Diagnostic(
+ line.range,
+ message,
+ severity,
+ );
+
+ diagnostic.source = "WRNexus";
+
+ if (code) {
+ diagnostic.code = code;
+ }
+
+ return diagnostic;
+}
+
+function stripComments(source) {
+ return source.replace(//g, (comment) =>
+ comment.replace(/[^\n]/g, " "),
+ );
+}
+
+function findTopLevelDeclaration(document, source) {
+ const match = TOP_LEVEL_PATTERN.exec(source);
+
+ if (!match) {
+ const firstMeaningfulLine = source
+ .split(/\r?\n/)
+ .findIndex((line) => line.trim().length > 0);
+
+ return {
+ diagnostic: lineDiagnostic(
+ document,
+ Math.max(0, firstMeaningfulLine),
+ "A .wrn file must start with `page`, `component`, or `layout`.",
+ vscode.DiagnosticSeverity.Error,
+ "wrn-invalid-root",
+ ),
+ };
+ }
+
+ return {
+ kind: match[1],
+ name: match[2],
+ match,
+ };
+}
+
+function validateBalancedCharacters(document, source) {
+ const diagnostics = [];
+ const stack = [];
+
+ let quote = null;
+ let escaped = false;
+
+ const pairs = {
+ "}": "{",
+ "]": "[",
+ ")": "(",
+ };
+
+ for (let index = 0; index < source.length; index += 1) {
+ const character = source[index];
+
+ if (quote !== null) {
+ if (escaped) {
+ escaped = false;
+ continue;
+ }
+
+ if (character === "\\") {
+ escaped = true;
+ continue;
+ }
+
+ if (character === quote) {
+ quote = null;
+ }
+
+ continue;
+ }
+
+ if (character === '"' || character === "'") {
+ quote = character;
+ continue;
+ }
+
+ if (
+ source.startsWith("", index + 4);
+
+ if (commentEnd === -1) {
+ diagnostics.push(
+ createDiagnostic(
+ document,
+ index,
+ Math.min(source.length, index + 4),
+ "Unclosed HTML comment.",
+ vscode.DiagnosticSeverity.Error,
+ "wrn-unclosed-comment",
+ ),
+ );
+
+ break;
+ }
+
+ index = commentEnd + 2;
+ continue;
+ }
+
+ if (
+ character === "{" ||
+ character === "[" ||
+ character === "("
+ ) {
+ stack.push({
+ character,
+ offset: index,
+ });
+
+ continue;
+ }
+
+ if (
+ character === "}" ||
+ character === "]" ||
+ character === ")"
+ ) {
+ const expectedOpening = pairs[character];
+ const opening = stack.pop();
+
+ if (!opening || opening.character !== expectedOpening) {
+ diagnostics.push(
+ createDiagnostic(
+ document,
+ index,
+ index + 1,
+ `Unexpected \`${character}\`.`,
+ vscode.DiagnosticSeverity.Error,
+ "wrn-unexpected-closing",
+ ),
+ );
+ }
+ }
+ }
+
+ for (const opening of stack) {
+ const expectedClosing =
+ opening.character === "{"
+ ? "}"
+ : opening.character === "["
+ ? "]"
+ : ")";
+
+ diagnostics.push(
+ createDiagnostic(
+ document,
+ opening.offset,
+ opening.offset + 1,
+ `Missing closing \`${expectedClosing}\`.`,
+ vscode.DiagnosticSeverity.Error,
+ "wrn-missing-closing",
+ ),
+ );
+ }
+
+ if (quote !== null) {
+ diagnostics.push(
+ createDiagnostic(
+ document,
+ Math.max(0, source.length - 1),
+ source.length,
+ `Unclosed ${quote === '"' ? "double" : "single"} quote.`,
+ vscode.DiagnosticSeverity.Error,
+ "wrn-unclosed-string",
+ ),
+ );
+ }
+
+ return diagnostics;
+}
+
+function validateHtmlTags(document, source) {
+ const diagnostics = [];
+ const stack = [];
+
+ const voidElements = new Set([
+ "area",
+ "base",
+ "br",
+ "col",
+ "embed",
+ "hr",
+ "img",
+ "input",
+ "link",
+ "meta",
+ "param",
+ "source",
+ "track",
+ "wbr",
+ ]);
+
+ const cleaned = stripComments(source);
+ const tagPattern =
+ /<\/?([A-Za-z][A-Za-z0-9_$:.-]*)(?:\s[\s\S]*?)?\/?>/g;
+
+ let match;
+
+ while ((match = tagPattern.exec(cleaned)) !== null) {
+ const completeTag = match[0];
+ const tagName = match[1];
+ const lowerTag = tagName.toLowerCase();
+
+ const isClosing = completeTag.startsWith("");
+ const isSelfClosing = completeTag.endsWith("/>");
+ const isVoid = voidElements.has(lowerTag);
+
+ if (isClosing) {
+ const last = stack.pop();
+
+ if (!last) {
+ diagnostics.push(
+ createDiagnostic(
+ document,
+ match.index,
+ match.index + completeTag.length,
+ `Unexpected closing tag ${tagName}>.`,
+ vscode.DiagnosticSeverity.Error,
+ "wrn-unexpected-html-close",
+ ),
+ );
+
+ continue;
+ }
+
+ if (last.tagName !== tagName) {
+ diagnostics.push(
+ createDiagnostic(
+ document,
+ match.index,
+ match.index + completeTag.length,
+ `Mismatched closing tag ${tagName}>. Expected ${last.tagName}>.`,
+ vscode.DiagnosticSeverity.Error,
+ "wrn-mismatched-html-tag",
+ ),
+ );
+ }
+
+ continue;
+ }
+
+ if (!isSelfClosing && !isVoid) {
+ stack.push({
+ tagName,
+ offset: match.index,
+ length: completeTag.length,
+ });
+ }
+ }
+
+ for (const tag of stack) {
+ diagnostics.push(
+ createDiagnostic(
+ document,
+ tag.offset,
+ tag.offset + tag.length,
+ `Missing closing tag ${tag.tagName}>.`,
+ vscode.DiagnosticSeverity.Error,
+ "wrn-missing-html-close",
+ ),
+ );
+ }
+
+ return diagnostics;
+}
+
+function getRootBodyRange(source, rootMatch) {
+ const openingBrace =
+ rootMatch.index + rootMatch[0].lastIndexOf("{");
+
+ let depth = 0;
+ let quote = null;
+ let escaped = false;
+
+ for (
+ let index = openingBrace;
+ index < source.length;
+ index += 1
+ ) {
+ const character = source[index];
+
+ if (quote !== null) {
+ if (escaped) {
+ escaped = false;
+ continue;
+ }
+
+ if (character === "\\") {
+ escaped = true;
+ continue;
+ }
+
+ if (character === quote) {
+ quote = null;
+ }
+
+ continue;
+ }
+
+ if (character === '"' || character === "'") {
+ quote = character;
+ continue;
+ }
+
+ if (character === "{") {
+ depth += 1;
+ continue;
+ }
+
+ if (character === "}") {
+ depth -= 1;
+
+ if (depth === 0) {
+ return {
+ start: openingBrace + 1,
+ end: index,
+ };
+ }
+ }
+ }
+
+ return {
+ start: openingBrace + 1,
+ end: source.length,
+ };
+}
+
+function findRootMembers(source, bodyStart, bodyEnd) {
+ const members = [];
+ let index = bodyStart;
+ let depth = 0;
+ let quote = null;
+ let escaped = false;
+
+ while (index < bodyEnd) {
+ const character = source[index];
+
+ if (quote !== null) {
+ if (escaped) {
+ escaped = false;
+ index += 1;
+ continue;
+ }
+
+ if (character === "\\") {
+ escaped = true;
+ index += 1;
+ continue;
+ }
+
+ if (character === quote) {
+ quote = null;
+ }
+
+ index += 1;
+ continue;
+ }
+
+ if (character === '"' || character === "'") {
+ quote = character;
+ index += 1;
+ continue;
+ }
+
+ if (source.startsWith("", index + 4);
+ index = end === -1 ? bodyEnd : end + 3;
+ continue;
+ }
+
+ if (character === "{") {
+ depth += 1;
+ index += 1;
+ continue;
+ }
+
+ if (character === "}") {
+ depth = Math.max(0, depth - 1);
+ index += 1;
+ continue;
+ }
+
+ if (
+ depth === 0 &&
+ /[A-Za-z_]/.test(character)
+ ) {
+ const start = index;
+ index += 1;
+
+ while (
+ index < bodyEnd &&
+ /[A-Za-z0-9_-]/.test(source[index])
+ ) {
+ index += 1;
+ }
+
+ const name = source.slice(start, index);
+
+ members.push({
+ name,
+ start,
+ end: index,
+ });
+
+ continue;
+ }
+
+ index += 1;
+ }
+
+ return members;
+}
+
+function validateRootMembers(
+ document,
+ source,
+ rootKind,
+ rootMatch,
+) {
+ const diagnostics = [];
+ const allowed = VALID_MEMBERS[rootKind];
+
+ if (!allowed) {
+ return diagnostics;
+ }
+
+ const bodyRange = getRootBodyRange(source, rootMatch);
+ const members = findRootMembers(
+ source,
+ bodyRange.start,
+ bodyRange.end,
+ );
+
+ for (const member of members) {
+ if (allowed.has(member.name)) {
+ continue;
+ }
+
+ diagnostics.push(
+ createDiagnostic(
+ document,
+ member.start,
+ member.end,
+ `Unknown ${rootKind} member \`${member.name}\`.`,
+ vscode.DiagnosticSeverity.Error,
+ "wrn-unknown-member",
+ ),
+ );
+ }
+
+ return diagnostics;
+}
+
+function validateRequiredView(
+ document,
+ source,
+ rootKind,
+ rootMatch,
+) {
+ const bodyRange = getRootBodyRange(source, rootMatch);
+ const body = source.slice(bodyRange.start, bodyRange.end);
+
+ if (/\bview\s*\{/.test(body)) {
+ return [];
+ }
+
+ return [
+ createDiagnostic(
+ document,
+ rootMatch.index,
+ rootMatch.index + rootMatch[0].length,
+ `The ${rootKind} \`${rootMatch[2]}\` does not contain a \`view { ... }\` block.`,
+ vscode.DiagnosticSeverity.Warning,
+ "wrn-missing-view",
+ ),
+ ];
+}
+
+function validateLayoutUsage(
+ document,
+ source,
+ rootKind,
+) {
+ const diagnostics = [];
+
+ if (
+ rootKind !== "page" &&
+ /^\s*layout\s*=/m.test(source)
+ ) {
+ const match = /^\s*layout\s*=/m.exec(source);
+
+ if (match) {
+ diagnostics.push(
+ createDiagnostic(
+ document,
+ match.index,
+ match.index + match[0].length,
+ "`layout = \"...\"` is only valid inside a page.",
+ vscode.DiagnosticSeverity.Error,
+ "wrn-invalid-layout-member",
+ ),
+ );
+ }
+ }
+
+ return diagnostics;
+}
+
+function validateDocument(document) {
+ if (document.languageId !== WRN_LANGUAGE_ID) {
+ return [];
+ }
+
+ const source = document.getText();
+
+ if (!source.trim()) {
+ return [];
+ }
+
+ const diagnostics = [];
+ const declaration = findTopLevelDeclaration(
+ document,
+ source,
+ );
+
+ if (declaration.diagnostic) {
+ diagnostics.push(declaration.diagnostic);
+ diagnostics.push(
+ ...validateBalancedCharacters(document, source),
+ );
+
+ return diagnostics;
+ }
+
+ if (!VALID_TOP_LEVEL_KINDS.has(declaration.kind)) {
+ diagnostics.push(
+ createDiagnostic(
+ document,
+ declaration.match.index,
+ declaration.match.index +
+ declaration.match[0].length,
+ `Unsupported WRN declaration \`${declaration.kind}\`.`,
+ vscode.DiagnosticSeverity.Error,
+ "wrn-invalid-kind",
+ ),
+ );
+
+ return diagnostics;
+ }
+
+ diagnostics.push(
+ ...validateBalancedCharacters(document, source),
+ );
+
+ diagnostics.push(
+ ...validateHtmlTags(document, source),
+ );
+
+ diagnostics.push(
+ ...validateRootMembers(
+ document,
+ source,
+ declaration.kind,
+ declaration.match,
+ ),
+ );
+
+ diagnostics.push(
+ ...validateRequiredView(
+ document,
+ source,
+ declaration.kind,
+ declaration.match,
+ ),
+ );
+
+ diagnostics.push(
+ ...validateLayoutUsage(
+ document,
+ source,
+ declaration.kind,
+ ),
+ );
+
+ return diagnostics;
+}
+
+function registerDiagnostics(context) {
+ const collection =
+ vscode.languages.createDiagnosticCollection(
+ COLLECTION_NAME,
+ );
+
+ const timers = new Map();
+
+ const update = (document) => {
+ if (document.languageId !== WRN_LANGUAGE_ID) {
+ return;
+ }
+
+ const previousTimer = timers.get(
+ document.uri.toString(),
+ );
+
+ if (previousTimer) {
+ clearTimeout(previousTimer);
+ }
+
+ const timer = setTimeout(() => {
+ timers.delete(document.uri.toString());
+
+ collection.set(
+ document.uri,
+ validateDocument(document),
+ );
+ }, 150);
+
+ timers.set(document.uri.toString(), timer);
+ };
+
+ for (const document of vscode.workspace.textDocuments) {
+ update(document);
+ }
+
+ context.subscriptions.push(
+ collection,
+
+ vscode.workspace.onDidOpenTextDocument(update),
+
+ vscode.workspace.onDidChangeTextDocument((event) => {
+ update(event.document);
+ }),
+
+ vscode.workspace.onDidSaveTextDocument(update),
+
+ vscode.workspace.onDidCloseTextDocument(
+ (document) => {
+ const key = document.uri.toString();
+ const timer = timers.get(key);
+
+ if (timer) {
+ clearTimeout(timer);
+ timers.delete(key);
+ }
+
+ collection.delete(document.uri);
+ },
+ ),
+
+ {
+ dispose() {
+ for (const timer of timers.values()) {
+ clearTimeout(timer);
+ }
+
+ timers.clear();
+ },
+ },
+ );
+}
+
+module.exports = {
+ registerDiagnostics,
+ validateBalancedCharacters,
+ validateDocument,
+ validateHtmlTags,
+ validateRootMembers,
+};
\ No newline at end of file
diff --git a/editors/vscode/src/extension.js b/editors/vscode/src/extension.js
index 331d40e6..435f4d1c 100644
--- a/editors/vscode/src/extension.js
+++ b/editors/vscode/src/extension.js
@@ -4,6 +4,8 @@
const vscode = require("vscode");
const { formatWrn } = require("./formatter");
const { registerCompletionProvider } = require("./completion");
+const { registerDefinitionProvider } = require("./definition");
+const { registerDiagnostics } = require("./diagnostics");
// The .wrn compiler, bundled to CJS by `bun run build:compiler`. Loaded
// defensively so the rest of the extension (highlighting, snippets, completion)
@@ -68,7 +70,9 @@ const EVENTS = [
* @param {vscode.ExtensionContext} context
*/
function activate(context) {
+ registerDiagnostics(context);
registerCompletionProvider(context);
+ registerDefinitionProvider(context);
const diagnostics = vscode.languages.createDiagnosticCollection("wrn");
context.subscriptions.push(diagnostics);
diff --git a/editors/vscode/syntaxes/wrn.tmLanguage.json b/editors/vscode/syntaxes/wrn.tmLanguage.json
index e7693513..134ed2e4 100644
--- a/editors/vscode/syntaxes/wrn.tmLanguage.json
+++ b/editors/vscode/syntaxes/wrn.tmLanguage.json
@@ -14,7 +14,7 @@
},
"declaration": {
- "match": "\\b(page|component)\\b\\s+([A-Za-z_][A-Za-z0-9_]*)?",
+ "match": "\\b(page|component|layout)\\b\\s+([A-Za-z_][A-Za-z0-9_]*)?",
"captures": {
"1": { "name": "storage.type.wrn keyword.control.wrn" },
"2": { "name": "entity.name.type.wrn" }
diff --git a/editors/vscode/wrnexus-0.2.5.vsix b/editors/vscode/wrnexus-0.2.5.vsix
new file mode 100644
index 00000000..17333bc2
Binary files /dev/null and b/editors/vscode/wrnexus-0.2.5.vsix differ
diff --git a/packages/cli/src/generate.ts b/packages/cli/src/generate.ts
index b4a92bdf..8644a254 100644
--- a/packages/cli/src/generate.ts
+++ b/packages/cli/src/generate.ts
@@ -111,7 +111,7 @@ export function runGenerate(
): void {
const type = typeArg ? ALIASES[typeArg] : undefined;
if (!type || !name) {
- console.error("Usage: wrnexus generate ");
+ console.error("Usage: wrnexus generate ");
process.exit(1);
}