release: WRNexusJS 0.3.0
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
import { parse, ParseError, type PageAst, type ViewNode } from "./parser.ts";
|
||||
import {
|
||||
WRN_DIAGNOSTIC_CODES,
|
||||
WRN_HYDRATION_STRATEGIES,
|
||||
WRN_RUNTIME_TARGETS,
|
||||
type WrnHydrationStrategy,
|
||||
type WrnRuntimeTarget,
|
||||
} from "./spec.ts";
|
||||
|
||||
export type WrnDiagnosticSeverity = "error" | "warning" | "info";
|
||||
|
||||
export interface WrnSourcePosition {
|
||||
offset: number;
|
||||
line: number;
|
||||
column: number;
|
||||
}
|
||||
|
||||
export interface WrnDiagnostic {
|
||||
code: string;
|
||||
severity: WrnDiagnosticSeverity;
|
||||
message: string;
|
||||
hint?: string;
|
||||
file?: string;
|
||||
position?: WrnSourcePosition;
|
||||
}
|
||||
|
||||
export interface DiagnoseOptions {
|
||||
file?: string;
|
||||
accessibility?: boolean;
|
||||
}
|
||||
|
||||
export function positionAt(source: string, offset: number): WrnSourcePosition {
|
||||
const safe = Math.max(0, Math.min(offset, source.length));
|
||||
const before = source.slice(0, safe);
|
||||
const lines = before.split(/\r?\n/);
|
||||
return { offset: safe, line: lines.length, column: (lines.at(-1)?.length ?? 0) + 1 };
|
||||
}
|
||||
|
||||
function offsetFromMessage(message: string): number | undefined {
|
||||
const match = /offset\s+(\d+)/i.exec(message);
|
||||
return match ? Number(match[1]) : undefined;
|
||||
}
|
||||
|
||||
export function classifyParseError(message: string): string {
|
||||
if (/Expected 'page', 'component', or 'layout'/.test(message)) return WRN_DIAGNOSTIC_CODES.root;
|
||||
if (/Unknown (?:page|component|layout|ssr|client) member/.test(message)) {
|
||||
return WRN_DIAGNOSTIC_CODES.member;
|
||||
}
|
||||
if (/prop initializer|Expected eq/.test(message)) return WRN_DIAGNOSTIC_CODES.propInitializer;
|
||||
if (/State '.+' requires an initializer/.test(message)) {
|
||||
return WRN_DIAGNOSTIC_CODES.stateInitializer;
|
||||
}
|
||||
if (/Cannot watch undeclared state/.test(message)) return WRN_DIAGNOSTIC_CODES.watchUndeclared;
|
||||
return WRN_DIAGNOSTIC_CODES.parse;
|
||||
}
|
||||
|
||||
export function diagnosticFromError(
|
||||
source: string,
|
||||
error: unknown,
|
||||
options: DiagnoseOptions = {},
|
||||
): WrnDiagnostic {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const offset =
|
||||
error instanceof ParseError && error.offset !== undefined
|
||||
? error.offset
|
||||
: offsetFromMessage(message);
|
||||
return {
|
||||
code: error instanceof ParseError ? error.code : classifyParseError(message),
|
||||
severity: "error",
|
||||
message,
|
||||
file: options.file,
|
||||
...(offset === undefined ? {} : { position: positionAt(source, offset) }),
|
||||
};
|
||||
}
|
||||
|
||||
function walk(nodes: ViewNode[], visit: (node: ViewNode) => void): void {
|
||||
for (const node of nodes) {
|
||||
visit(node);
|
||||
if (node.type === "element") walk(node.children, visit);
|
||||
else if (node.type === "each") {
|
||||
walk(node.body, visit);
|
||||
walk(node.empty, visit);
|
||||
} else if (node.type === "if") {
|
||||
for (const branch of node.branches) walk(branch.body, visit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function astDiagnostics(ast: PageAst, options: DiagnoseOptions): WrnDiagnostic[] {
|
||||
const diagnostics: WrnDiagnostic[] = [];
|
||||
const seen = new Map<string, string>();
|
||||
for (const [kind, declarations] of [
|
||||
["prop", ast.props],
|
||||
["state", ast.states],
|
||||
["computed", ast.computed],
|
||||
] as const) {
|
||||
for (const declaration of declarations) {
|
||||
const previous = seen.get(declaration.name);
|
||||
if (previous) {
|
||||
diagnostics.push({
|
||||
code: WRN_DIAGNOSTIC_CODES.duplicateSymbol,
|
||||
severity: "error",
|
||||
message: `Duplicate symbol '${declaration.name}' (${previous} and ${kind}).`,
|
||||
hint: "Rename one declaration so every prop, state, and computed value is unique.",
|
||||
file: options.file,
|
||||
});
|
||||
} else {
|
||||
seen.set(declaration.name, kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ast.hydrate && !isHydrationStrategy(ast.hydrate)) {
|
||||
diagnostics.push({
|
||||
code: WRN_DIAGNOSTIC_CODES.invalidHydration,
|
||||
severity: "error",
|
||||
message: `Unknown hydration strategy '${ast.hydrate}'.`,
|
||||
hint: "Use load, idle, visible, interaction, none, or media:<query>.",
|
||||
file: options.file,
|
||||
});
|
||||
}
|
||||
if (ast.runtime && !isRuntimeTarget(ast.runtime)) {
|
||||
diagnostics.push({
|
||||
code: WRN_DIAGNOSTIC_CODES.invalidRuntime,
|
||||
severity: "error",
|
||||
message: `Unknown runtime target '${ast.runtime}'.`,
|
||||
hint: "Use server, client, or universal.",
|
||||
file: options.file,
|
||||
});
|
||||
}
|
||||
|
||||
let interactive = ast.states.length > 0 || ast.effects.length > 0 || ast.watches.length > 0;
|
||||
walk(ast.view, (node) => {
|
||||
if (node.type === "element" && node.attrs.some((attribute) => attribute.event))
|
||||
interactive = true;
|
||||
if (!options.accessibility || node.type !== "element") return;
|
||||
const tag = node.tag.toLowerCase();
|
||||
if (tag === "img" && !node.attrs.some((attribute) => attribute.name === "alt")) {
|
||||
diagnostics.push({
|
||||
code: WRN_DIAGNOSTIC_CODES.accessibility,
|
||||
severity: "warning",
|
||||
message: "Image is missing an alt attribute.",
|
||||
hint: 'Add alt text, or alt="" for a decorative image.',
|
||||
file: options.file,
|
||||
});
|
||||
}
|
||||
});
|
||||
if (ast.runtime === "server" && interactive) {
|
||||
diagnostics.push({
|
||||
code: WRN_DIAGNOSTIC_CODES.serverInteractive,
|
||||
severity: "error",
|
||||
message:
|
||||
"A server-only WRN root cannot contain client state, effects, watches, or event handlers.",
|
||||
hint: 'Use runtime = "universal" or remove interactive behavior.',
|
||||
file: options.file,
|
||||
});
|
||||
}
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
export function diagnose(source: string, options: DiagnoseOptions = {}): WrnDiagnostic[] {
|
||||
try {
|
||||
return astDiagnostics(parse(source), options);
|
||||
} catch (error) {
|
||||
return [diagnosticFromError(source, error, options)];
|
||||
}
|
||||
}
|
||||
|
||||
export function assertValidAst(ast: PageAst, options: DiagnoseOptions = {}): void {
|
||||
const errors = astDiagnostics(ast, options).filter(
|
||||
(diagnostic) => diagnostic.severity === "error",
|
||||
);
|
||||
if (!errors.length) return;
|
||||
const first = errors[0]!;
|
||||
throw new ParseError(first.message, first.code);
|
||||
}
|
||||
|
||||
export function isHydrationStrategy(value: string): value is WrnHydrationStrategy {
|
||||
return (
|
||||
(WRN_HYDRATION_STRATEGIES as readonly string[]).includes(value) ||
|
||||
(value.startsWith("media:") && value.length > "media:".length)
|
||||
);
|
||||
}
|
||||
|
||||
export function isRuntimeTarget(value: string): value is WrnRuntimeTarget {
|
||||
return (WRN_RUNTIME_TARGETS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function formatDiagnostic(source: string, diagnostic: WrnDiagnostic): string {
|
||||
const location = diagnostic.position
|
||||
? `${diagnostic.file ?? "<inline .wrn>"}:${diagnostic.position.line}:${diagnostic.position.column}`
|
||||
: (diagnostic.file ?? "<inline .wrn>");
|
||||
const lines = [
|
||||
`${diagnostic.code} ${diagnostic.severity.toUpperCase()}`,
|
||||
"",
|
||||
diagnostic.message,
|
||||
"",
|
||||
location,
|
||||
];
|
||||
if (diagnostic.position) {
|
||||
const sourceLine = source.split(/\r?\n/)[diagnostic.position.line - 1] ?? "";
|
||||
lines.push("", sourceLine, `${" ".repeat(Math.max(0, diagnostic.position.column - 1))}^`);
|
||||
}
|
||||
if (diagnostic.hint) lines.push("", `Hint: ${diagnostic.hint}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
Reference in New Issue
Block a user