release: WRNexusJS 0.8.0
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { basename, extname, join, relative, resolve } from "node:path";
|
||||
import { parse } from "@wrnexus/syntax";
|
||||
import type { Range, TextDocument } from "./index.ts";
|
||||
|
||||
const SKIPPED_DIRECTORIES = new Set([
|
||||
".git",
|
||||
".wrnexus",
|
||||
".wirefw",
|
||||
"build",
|
||||
"coverage",
|
||||
"dist",
|
||||
"node_modules",
|
||||
"out",
|
||||
]);
|
||||
const MAX_INDEX_FILES = 5_000;
|
||||
const MAX_SOURCE_BYTES = 1_048_576;
|
||||
const INDEX_TTL_MS = 5_000;
|
||||
const MAX_CACHED_ROOTS = 8;
|
||||
const workspaceIndexCache = new Map<
|
||||
string,
|
||||
{ expiresAt: number; items: WorkspaceCompletionItem[] }
|
||||
>();
|
||||
|
||||
function walk(root: string, test: (file: string) => boolean): string[] {
|
||||
if (!existsSync(root)) return [];
|
||||
const files: string[] = [];
|
||||
const pending = [root];
|
||||
while (pending.length > 0 && files.length < MAX_INDEX_FILES) {
|
||||
const directory = pending.pop()!;
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(directory, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (files.length >= MAX_INDEX_FILES) break;
|
||||
const file = join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (!SKIPPED_DIRECTORIES.has(entry.name)) pending.push(file);
|
||||
} else if (entry.isFile() && test(file)) {
|
||||
try {
|
||||
if (statSync(file).size <= MAX_SOURCE_BYTES) files.push(file);
|
||||
} catch {
|
||||
// Files can disappear while the editor indexes a changing workspace.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
function flatten(value: unknown, prefix = ""): string[] {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return prefix ? [prefix] : [];
|
||||
return Object.entries(value).flatMap(([key, child]) =>
|
||||
flatten(child, prefix ? `${prefix}.${key}` : key),
|
||||
);
|
||||
}
|
||||
function routeFor(root: string, file: string): string {
|
||||
let value = relative(join(root, "app", "pages"), file)
|
||||
.replace(/\\/g, "/")
|
||||
.replace(/\.wrn$/, "");
|
||||
value = value
|
||||
.replace(/(?:^|\/)index$/, "")
|
||||
.replace(/\[\.\.\.([^\]]+)\]/g, "*$1")
|
||||
.replace(/\[([^\]]+)\]/g, ":$1");
|
||||
return `/${value}`.replace(/\/$/, "") || "/";
|
||||
}
|
||||
|
||||
export interface WorkspaceCompletionItem {
|
||||
label: string;
|
||||
kind: number;
|
||||
detail: string;
|
||||
insertText?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function buildWorkspaceCompletionItems(root: string): WorkspaceCompletionItem[] {
|
||||
const app = join(root, "app");
|
||||
const items: WorkspaceCompletionItem[] = [];
|
||||
for (const file of walk(join(app, "components"), (path) => extname(path) === ".wrn")) {
|
||||
try {
|
||||
const source = readFileSync(file, "utf8");
|
||||
const ast = parse(source);
|
||||
const slots = [...source.matchAll(/<slot(?:\s+name=["']([^"']+)["'])?/g)].map(
|
||||
(match) => match[1] ?? "default",
|
||||
);
|
||||
items.push({
|
||||
label: ast.name,
|
||||
kind: 7,
|
||||
detail: `Component · ${relative(root, file)}`,
|
||||
insertText: `<${ast.name} />`,
|
||||
data: { file, props: ast.props, outputs: ast.outputs, slots },
|
||||
});
|
||||
for (const prop of ast.props)
|
||||
items.push({ label: prop.name, kind: 10, detail: `${ast.name} prop · ${prop.valueType}` });
|
||||
for (const output of ast.outputs)
|
||||
items.push({ label: `@${output.name}`, kind: 10, detail: `${ast.name} event` });
|
||||
for (const slot of slots)
|
||||
items.push({ label: `slot:${slot}`, kind: 10, detail: `${ast.name} slot` });
|
||||
} catch {
|
||||
// Diagnostics handle invalid components; indexing remains best-effort.
|
||||
}
|
||||
}
|
||||
for (const file of walk(join(app, "pages"), (path) => extname(path) === ".wrn")) {
|
||||
const route = routeFor(root, file);
|
||||
items.push({
|
||||
label: route,
|
||||
kind: 12,
|
||||
detail: `Application route · ${relative(root, file)}`,
|
||||
data: { file },
|
||||
});
|
||||
}
|
||||
for (const file of walk(join(app, "locales"), (path) => extname(path) === ".json")) {
|
||||
try {
|
||||
for (const key of flatten(JSON.parse(readFileSync(file, "utf8"))))
|
||||
items.push({
|
||||
label: key,
|
||||
kind: 12,
|
||||
detail: `Translation key · ${basename(file, ".json")}`,
|
||||
});
|
||||
} catch {
|
||||
// Invalid locale JSON is reported by application diagnostics.
|
||||
}
|
||||
}
|
||||
const sourceFiles = walk(app, (path) => /\.(?:ts|js|wrn)$/.test(path));
|
||||
for (const file of sourceFiles) {
|
||||
const source = readFileSync(file, "utf8");
|
||||
for (const match of source.matchAll(
|
||||
/\b(?:schema|model)\s+([A-Za-z_$][\w$]*)|\bexport\s+const\s+([A-Za-z_$][\w$]*Schema)\b/g,
|
||||
)) {
|
||||
const name = match[1] ?? match[2];
|
||||
if (name)
|
||||
items.push({
|
||||
label: name,
|
||||
kind: 7,
|
||||
detail: `Database/validation schema · ${relative(root, file)}`,
|
||||
data: { file },
|
||||
});
|
||||
}
|
||||
for (const match of source.matchAll(/(?:class|className)\s*=\s*["']([^"']+)["']/g)) {
|
||||
for (const name of match[1]!.split(/\s+/))
|
||||
if (name) items.push({ label: name, kind: 12, detail: "Workspace CSS/Tailwind class" });
|
||||
}
|
||||
}
|
||||
const unique = new Map(items.map((item) => [`${item.label}:${item.detail}`, item]));
|
||||
return [...unique.values()].slice(0, 2_000);
|
||||
}
|
||||
|
||||
export function workspaceCompletionItems(root: string): WorkspaceCompletionItem[] {
|
||||
const normalizedRoot = resolve(root);
|
||||
const now = Date.now();
|
||||
const cached = workspaceIndexCache.get(normalizedRoot);
|
||||
if (cached && cached.expiresAt > now) return cached.items;
|
||||
const items = buildWorkspaceCompletionItems(normalizedRoot);
|
||||
workspaceIndexCache.delete(normalizedRoot);
|
||||
workspaceIndexCache.set(normalizedRoot, { expiresAt: now + INDEX_TTL_MS, items });
|
||||
while (workspaceIndexCache.size > MAX_CACHED_ROOTS) {
|
||||
const oldest = workspaceIndexCache.keys().next().value;
|
||||
if (typeof oldest !== "string") break;
|
||||
workspaceIndexCache.delete(oldest);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export function clearWorkspaceIndexCache(root?: string): void {
|
||||
if (root) workspaceIndexCache.delete(resolve(root));
|
||||
else workspaceIndexCache.clear();
|
||||
}
|
||||
|
||||
export function extractComponentRefactor(document: TextDocument, range: Range, name: string) {
|
||||
if (!/^[A-Z][A-Za-z0-9_$]*$/.test(name)) throw new Error("Component name must use PascalCase");
|
||||
const lines = document.text.split(/\r?\n/);
|
||||
const start =
|
||||
lines.slice(0, range.start.line).reduce((n, line) => n + line.length + 1, 0) +
|
||||
range.start.character;
|
||||
const end =
|
||||
lines.slice(0, range.end.line).reduce((n, line) => n + line.length + 1, 0) +
|
||||
range.end.character;
|
||||
const selected = document.text.slice(start, end);
|
||||
if (!selected.trim().startsWith("<")) throw new Error("Select WRN markup to extract");
|
||||
const sourcePath = decodeURIComponent(document.uri.replace(/^file:\/\//, "")).replace(
|
||||
/^\/([A-Za-z]:)/,
|
||||
"$1",
|
||||
);
|
||||
const root = sourcePath.includes(`${join("app", "pages")}`)
|
||||
? sourcePath.slice(0, sourcePath.indexOf(`${join("app", "pages")}`))
|
||||
: resolve(".");
|
||||
const target = join(root, "app", "components", `${name}.wrn`);
|
||||
return {
|
||||
documentChanges: [
|
||||
{ kind: "create", uri: `file:///${target.replace(/\\/g, "/")}` },
|
||||
{
|
||||
textDocument: { uri: `file:///${target.replace(/\\/g, "/")}`, version: null },
|
||||
edits: [
|
||||
{
|
||||
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } },
|
||||
newText: `component ${name} {\n view {\n ${selected.trim()}\n }\n}\n`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
textDocument: { uri: document.uri, version: document.version ?? null },
|
||||
edits: [{ range, newText: `<${name} />` }],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function htmlToWrn(html: string, name = "ImportedPage"): string {
|
||||
if (!/^[A-Z][A-Za-z0-9_$]*$/.test(name)) throw new Error("WRN name must use PascalCase");
|
||||
const converted = html
|
||||
.replace(/\sclass=/g, " class=")
|
||||
.replace(/\son([a-z]+)=/gi, (_all, event) => ` @${String(event).toLowerCase()}=`)
|
||||
.replace(/<!--([\s\S]*?)-->/g, "{/*$1*/}");
|
||||
return `page ${name} {\n view {\n ${converted.trim()}\n }\n}\n`;
|
||||
}
|
||||
Reference in New Issue
Block a user