release: Vs Code New Extension with layout Support

This commit is contained in:
2026-07-14 15:37:02 +05:30
parent 56027531ef
commit e27c92e3b6
11 changed files with 1034 additions and 30 deletions
+1 -1
View File
@@ -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*$"
}
}
+1 -1
View File
@@ -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",
+14
View File
@@ -56,6 +56,20 @@
]
},
"WRN layout": {
"prefix": ["wrn-layout", "layout"],
"description": "Create a WRN layout",
"body": [
"layout ${1:LayoutName} {",
" view {",
" <main>",
" {content}",
" </main>",
" }",
"}"
]
},
"WRN state": {
"prefix": ["wrn-state", "state"],
"description": "Create reactive state",
+103 -26
View File
@@ -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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
@@ -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(`<div data-component="${attrEscape(node.tag)}"`) + attrs + escLit(">") + inner + escLit("</div>");
}
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 `<div data-component="${attrEscape(node.tag)}"${renderAttrs(attrs, undefined, reactive)}>${inner}</div>`;
}
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 `<div data-component="${attrEscape(node.tag)}"${attrs}>${inner}</div>`;
}
function ssrMarker(bindings, binding) {
const marker = `<!--wrnexus-ssr:${bindings.length}-->`;
bindings.push({ marker, ...binding });
@@ -1000,8 +1056,9 @@ async function __wrnexusRenderSsrBindings(html: string, ctx: any): Promise<strin
}`;
}
function generate(ast) {
if (ast.kind === "component")
if (ast.kind === "component" || ast.kind === "layout") {
return generateComponent(ast);
}
const out = [];
const ssrBindings = [];
const csrBindings = [];
@@ -1230,6 +1287,8 @@ function compileText(raw, ctx) {
out += escLit(`{${expr}}`);
} else if (exprRefsState(expr, ctx.stateNames)) {
out += escLit(`<span data-text="${attrEscape(expr)}">`) + `\${__wireHtml(${ctx.resolveExpr(expr)})}` + escLit(`</span>`);
} 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(`
`)}
</style>`) : "";
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);
+14
View File
@@ -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 {",
" <main>",
" {content}",
" </main>",
" }",
"}",
].join("\n"),
},
{
label: "seo",
detail: "SEO metadata block",
+134
View File
@@ -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,
};
+761
View File
@@ -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(/<!--[\s\S]*?-->/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)
) {
const commentEnd = source.indexOf("-->", 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)) {
const end = source.indexOf("-->", 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,
};
+4
View File
@@ -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);
+1 -1
View File
@@ -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" }
Binary file not shown.
+1 -1
View File
@@ -111,7 +111,7 @@ export function runGenerate(
): void {
const type = typeArg ? ALIASES[typeArg] : undefined;
if (!type || !name) {
console.error("Usage: wrnexus generate <page|component|api|schema> <name>");
console.error("Usage: wrnexus generate <page|component|layout|api|schema> <name>");
process.exit(1);
}