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
+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,
};