feat: support WRN imports and dynamic public shell
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## 0.2.14
|
||||
|
||||
- Added top-level ES module import highlighting, completion, snippets, formatting,
|
||||
and diagnostics support for `.wrn` files.
|
||||
- Imported helpers can now be used in server-rendered component prop expressions.
|
||||
|
||||
## 0.2.13
|
||||
|
||||
- Added explicit prop and state types, local `types` blocks, and typed function
|
||||
|
||||
@@ -27,6 +27,19 @@ component UserCard {
|
||||
|
||||
The extension shows declared prop types and reports missing required props and incompatible values. Typed prop and state expressions passed into nested components retain their declared type.
|
||||
|
||||
Top-level imports are supported before `page`, `component`, or `layout`. Imported
|
||||
helpers can be used in server-rendered prop expressions:
|
||||
|
||||
```wrn
|
||||
import { appUrl } from "@wrnexus/helpers";
|
||||
|
||||
layout PublicLayout {
|
||||
view {
|
||||
<PublicHeader signInHref="{appUrl('sso', '/sign-in')}" />
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
Search for **WRNexus Language Support** in the VS Code Extensions view or run:
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wrnexus",
|
||||
"version": "0.2.13",
|
||||
"version": "0.2.14",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wrnexus",
|
||||
"version": "0.2.13",
|
||||
"version": "0.2.14",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"devDependencies": {
|
||||
"@vscode/vsce": "^3.9.2"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "wrnexus",
|
||||
"displayName": "WRNexus Language Support",
|
||||
"description": "Complete language support for WRNexus .wrn files, including highlighting, formatting, diagnostics, snippets, lifecycle hooks, state watchers, component functions, completions, and definition navigation.",
|
||||
"version": "0.2.13",
|
||||
"version": "0.2.14",
|
||||
"publisher": "wrnexus",
|
||||
"private": true,
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
@@ -68,7 +68,7 @@
|
||||
"extensions": [
|
||||
".wrn"
|
||||
],
|
||||
"firstLine": "^\\s*(?:page|component|layout)\\s+[A-Za-z_$][A-Za-z0-9_$]*\\s*\\{",
|
||||
"firstLine": "^\\s*(?:(?:import\\b[^;\\n]*(?:;|\\n)\\s*)*)(?:page|component|layout)\\s+[A-Za-z_$][A-Za-z0-9_$]*\\s*\\{",
|
||||
"configuration": "./language-configuration.json",
|
||||
"icon": {
|
||||
"light": "./icons/wrn.png",
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"WRN import": {
|
||||
"prefix": ["wrn-import", "import"],
|
||||
"description": "Import a server-side helper into a WRN file",
|
||||
"body": ["import { ${1:appUrl} } from \"${2:@wrnexus/helpers}\";", "", "$0"]
|
||||
},
|
||||
"WRN page": {
|
||||
"prefix": ["wrn-page", "page"],
|
||||
"description": "Create a WRN page",
|
||||
|
||||
@@ -382,6 +382,24 @@ function unescapeSeoValue(value) {
|
||||
}
|
||||
function parse(source) {
|
||||
const lx = new Lexer(source);
|
||||
const imports = [];
|
||||
const importPattern = /import\s+(?:type\s+)?(?:[\s\S]*?\s+from\s+)?["'][^"'\r\n]+["']\s*;?/y;
|
||||
while (true) {
|
||||
while (/\s/u.test(source[lx.pos] ?? ""))
|
||||
lx.pos++;
|
||||
if (source.startsWith("//", lx.pos)) {
|
||||
while (lx.pos < source.length && source[lx.pos] !== `
|
||||
`)
|
||||
lx.pos++;
|
||||
continue;
|
||||
}
|
||||
importPattern.lastIndex = lx.pos;
|
||||
const statement = importPattern.exec(source);
|
||||
if (!statement)
|
||||
break;
|
||||
imports.push(statement[0].trim());
|
||||
lx.pos = importPattern.lastIndex;
|
||||
}
|
||||
const expect = (type) => {
|
||||
const t = lx.next();
|
||||
if (t.type !== type) {
|
||||
@@ -627,6 +645,7 @@ function parse(source) {
|
||||
}
|
||||
return {
|
||||
type: "page",
|
||||
imports,
|
||||
kind,
|
||||
name,
|
||||
layout,
|
||||
@@ -1304,6 +1323,9 @@ function generate(ast) {
|
||||
return generateComponent(ast);
|
||||
}
|
||||
const out = [];
|
||||
if (ast.imports.length > 0)
|
||||
out.push(ast.imports.join(`
|
||||
`));
|
||||
const ssrBindings = [];
|
||||
const csrBindings = [];
|
||||
const helpers = ast.functions.map((body2) => body2.trim()).filter(Boolean).join(`
|
||||
@@ -1722,6 +1744,9 @@ function renderComponentNode(node, ctx) {
|
||||
}
|
||||
function generateComponent(ast) {
|
||||
const out = [];
|
||||
if (ast.imports.length > 0)
|
||||
out.push(ast.imports.join(`
|
||||
`));
|
||||
const hasServerEach = viewHasServerEach(ast.view);
|
||||
if (hasServerEach) {
|
||||
out.push(`import { Buffer } from "node:buffer";`);
|
||||
@@ -2192,6 +2217,8 @@ function generateNative(ast) {
|
||||
import React, { useState } from "react";
|
||||
import { ActivityIndicator, FlatList, Image, Pressable, SafeAreaView, ScrollView, StyleSheet, Text, TextInput, View } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
${ast.imports.join(`
|
||||
`)}
|
||||
|
||||
${typeSource}
|
||||
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
const vscode = require("vscode");
|
||||
|
||||
const BLOCK_COMPLETIONS = [
|
||||
{
|
||||
label: "import",
|
||||
detail: "WRN server module import",
|
||||
documentation:
|
||||
"Import a package or module before the page, component, or layout declaration for server rendering.",
|
||||
snippet: 'import { ${1:appUrl} } from "${2:@wrnexus/helpers}";\n\n$0',
|
||||
},
|
||||
{
|
||||
label: "page",
|
||||
detail: "WRN page",
|
||||
|
||||
@@ -89,6 +89,7 @@ function stripComments(source) {
|
||||
function maskLeadingTrivia(source) {
|
||||
const masked = [...source];
|
||||
let offset = 0;
|
||||
const importPattern = /import\s+(?:type\s+)?(?:[\s\S]*?\s+from\s+)?["'][^"'\r\n]+["']\s*;?/y;
|
||||
|
||||
while (offset < source.length) {
|
||||
if (/\s/u.test(source[offset])) {
|
||||
@@ -96,6 +97,17 @@ function maskLeadingTrivia(source) {
|
||||
continue;
|
||||
}
|
||||
|
||||
importPattern.lastIndex = offset;
|
||||
const importStatement = importPattern.exec(source);
|
||||
if (importStatement) {
|
||||
const end = importPattern.lastIndex;
|
||||
while (offset < end) {
|
||||
if (source[offset] !== "\n" && source[offset] !== "\r") masked[offset] = " ";
|
||||
offset += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!source.startsWith("//", offset)) break;
|
||||
|
||||
while (offset < source.length && source[offset] !== "\n") {
|
||||
|
||||
@@ -326,6 +326,20 @@ function formatWrn(source, options = {}) {
|
||||
|
||||
previousWasBlank = false;
|
||||
|
||||
if (/^import\b/.test(value)) {
|
||||
const importLines = [value];
|
||||
while (
|
||||
!/(?:\bfrom\s+)?["'][^"']+["']\s*;?$/.test(importLines[importLines.length - 1]) &&
|
||||
index + 1 < inputLines.length
|
||||
) {
|
||||
index += 1;
|
||||
importLines.push(inputLines[index].trim());
|
||||
}
|
||||
output.push(importLines[0], ...importLines.slice(1).map((line) => `${unit}${line}`));
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isMultilineOpeningTagStart(value)) {
|
||||
const collected = collectOpeningTag(inputLines, index);
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
{
|
||||
"include": "#comments"
|
||||
},
|
||||
{
|
||||
"include": "#imports"
|
||||
},
|
||||
{
|
||||
"include": "#declaration"
|
||||
},
|
||||
@@ -14,6 +17,21 @@
|
||||
}
|
||||
],
|
||||
"repository": {
|
||||
"imports": {
|
||||
"begin": "^\\s*(import)\\b",
|
||||
"beginCaptures": {
|
||||
"1": {
|
||||
"name": "keyword.control.import.ts"
|
||||
}
|
||||
},
|
||||
"end": ";|$",
|
||||
"name": "meta.import.wrn",
|
||||
"patterns": [
|
||||
{
|
||||
"include": "source.ts"
|
||||
}
|
||||
]
|
||||
},
|
||||
"comments": {
|
||||
"patterns": [
|
||||
{
|
||||
|
||||
@@ -56,6 +56,19 @@ component ThemeToggle {
|
||||
assert.equal(declaration.diagnostic, undefined);
|
||||
});
|
||||
|
||||
test("recognizes a WRN declaration after top-level imports", () => {
|
||||
const source = `import { appUrl } from "@wrnexus/helpers";
|
||||
import type { Context } from "@wrnexus/core";
|
||||
|
||||
page Home {
|
||||
view { <PublicHeader signInHref="{appUrl('sso', '/sign-in')}" /> }
|
||||
}`;
|
||||
const declaration = findTopLevelDeclaration({}, source);
|
||||
assert.equal(declaration.kind, "page");
|
||||
assert.equal(declaration.name, "Home");
|
||||
assert.equal(declaration.diagnostic, undefined);
|
||||
});
|
||||
|
||||
test("masks only leading trivia and preserves source offsets", () => {
|
||||
const source = "\uFEFF// Summary\r\npage Home {\n // member comment\n}";
|
||||
const masked = maskLeadingTrivia(source);
|
||||
|
||||
@@ -127,6 +127,13 @@ try {
|
||||
formatWrn(formatted, { insertSpaces: true, tabSize: 2 }) === formatted
|
||||
? ok("formatter is idempotent")
|
||||
: bad("formatter is idempotent");
|
||||
const imported = formatWrn(
|
||||
`import {\nappUrl,\nother\n} from "@wrnexus/helpers";\n\nlayout Public {\nview { <p>Hi</p> }\n}\n`,
|
||||
{ insertSpaces: true, tabSize: 2 },
|
||||
);
|
||||
imported.includes(`\nlayout Public {`) && formatWrn(imported, { tabSize: 2 }) === imported
|
||||
? ok("formatter preserves top-level imports")
|
||||
: bad("formatter preserves top-level imports");
|
||||
} catch (e) {
|
||||
bad("formatter loads", e.message);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user