505 lines
17 KiB
TypeScript
505 lines
17 KiB
TypeScript
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;
|
|
expected?: string;
|
|
received?: string;
|
|
related?: Array<{ file?: string; message: string; position?: WrnSourcePosition }>;
|
|
}
|
|
|
|
export interface DiagnoseOptions {
|
|
file?: string;
|
|
accessibility?: boolean;
|
|
}
|
|
|
|
function stripAsciiControlAndSpace(value: string): string {
|
|
let result = "";
|
|
for (const character of value) {
|
|
if (character.charCodeAt(0) > 0x20) result += character;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function maskJavaScriptTrivia(source: string): string {
|
|
let result = "";
|
|
let index = 0;
|
|
let quote: "'" | '"' | "`" | null = null;
|
|
let lineComment = false;
|
|
let blockComment = false;
|
|
|
|
while (index < source.length) {
|
|
const char = source[index]!;
|
|
const next = source[index + 1];
|
|
|
|
if (lineComment) {
|
|
if (char === "\n") {
|
|
lineComment = false;
|
|
result += "\n";
|
|
} else result += " ";
|
|
index++;
|
|
continue;
|
|
}
|
|
|
|
if (blockComment) {
|
|
if (char === "*" && next === "/") {
|
|
result += " ";
|
|
index += 2;
|
|
blockComment = false;
|
|
} else {
|
|
result += char === "\n" ? "\n" : " ";
|
|
index++;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (quote) {
|
|
if (char === "\\") {
|
|
result += " ";
|
|
index += Math.min(2, source.length - index);
|
|
} else if (char === quote) {
|
|
result += " ";
|
|
index++;
|
|
quote = null;
|
|
} else {
|
|
result += char === "\n" ? "\n" : " ";
|
|
index++;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (char === "/" && next === "/") {
|
|
result += " ";
|
|
index += 2;
|
|
lineComment = true;
|
|
continue;
|
|
}
|
|
if (char === "/" && next === "*") {
|
|
result += " ";
|
|
index += 2;
|
|
blockComment = true;
|
|
continue;
|
|
}
|
|
if (char === "'" || char === '"' || char === "`") {
|
|
quote = char;
|
|
result += " ";
|
|
index++;
|
|
continue;
|
|
}
|
|
|
|
result += char;
|
|
index++;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
export function containsReadonlyPropMutation(
|
|
body: string,
|
|
propName: string,
|
|
parameterNames: Set<string>,
|
|
): boolean {
|
|
const code = maskJavaScriptTrivia(body);
|
|
const escaped = propName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
const operator = String.raw`(?:\+\+|--|(?:\*\*|&&|\|\||\?\?|[+\-*/%&|^])?=(?!=|>))`;
|
|
|
|
if (new RegExp(String.raw`\bprops\.${escaped}\s*${operator}`).test(code)) return true;
|
|
if (parameterNames.has(propName)) return false;
|
|
if (new RegExp(String.raw`\b(?:const|let|var)\s+${escaped}\b`).test(code)) return false;
|
|
|
|
return new RegExp(String.raw`(?:^|[^\w$.])${escaped}\s*${operator}`, "m").test(code);
|
|
}
|
|
|
|
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;
|
|
const urlAttributes = new Set([
|
|
"href",
|
|
"src",
|
|
"action",
|
|
"formaction",
|
|
"poster",
|
|
"cite",
|
|
"background",
|
|
"xlink:href",
|
|
]);
|
|
walk(ast.view, (node) => {
|
|
if (node.type !== "element") return;
|
|
if (node.attrs.some((attribute) => attribute.event)) interactive = true;
|
|
const tag = node.tag.toLowerCase();
|
|
|
|
for (const attribute of node.attrs) {
|
|
if (attribute.event && (attribute.name === "for" || attribute.name === "key")) {
|
|
diagnostics.push({
|
|
code: "WRN-TEMPLATE-LOOP-DIRECTIVE",
|
|
severity: "error",
|
|
message: `@${attribute.name} is an event binding, not a loop directive.`,
|
|
hint:
|
|
attribute.name === "for"
|
|
? 'Use data-for="item in items".'
|
|
: 'Use data-key="item.id" alongside data-for.',
|
|
file: options.file,
|
|
});
|
|
}
|
|
if (attribute.event || attribute.boolean || !urlAttributes.has(attribute.name.toLowerCase()))
|
|
continue;
|
|
if (attribute.value.includes("{")) continue;
|
|
const value = stripAsciiControlAndSpace(attribute.value.trim()).toLowerCase();
|
|
if (
|
|
/^(?:javascript|vbscript|file):/.test(value) ||
|
|
/^data:(?!image\/(?:png|gif|jpeg|webp|avif);)/.test(value)
|
|
) {
|
|
diagnostics.push({
|
|
code: "WRN-SEC-UNSAFE-URL",
|
|
severity: "error",
|
|
message: `Unsafe URL protocol in ${attribute.name} on <${node.tag}>.`,
|
|
hint: "Use a relative URL, https:, mailto:, tel:, or a framework-validated URL helper.",
|
|
file: options.file,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (tag === "a") {
|
|
const target = node.attrs.find((attribute) => attribute.name === "target")?.value;
|
|
const rel = node.attrs.find((attribute) => attribute.name === "rel")?.value ?? "";
|
|
if (target === "_blank" && !/\bnoopener\b/i.test(rel)) {
|
|
diagnostics.push({
|
|
code: "WRN-SEC-BLANK-REL",
|
|
severity: "warning",
|
|
message: "A target=_blank link should include rel=noopener.",
|
|
hint: 'Add rel="noopener noreferrer".',
|
|
file: options.file,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (!options.accessibility) return;
|
|
if (tag === "img") {
|
|
if (!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,
|
|
});
|
|
}
|
|
const hasWidth = node.attrs.some((attribute) => attribute.name === "width");
|
|
const hasHeight = node.attrs.some((attribute) => attribute.name === "height");
|
|
if (!hasWidth || !hasHeight) {
|
|
diagnostics.push({
|
|
code: "WRN-PERF-IMAGE-DIMENSIONS",
|
|
severity: "warning",
|
|
message: "Image width and height are required to prevent layout shifts.",
|
|
hint: "Declare intrinsic width and height, or use @wrnexus/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,
|
|
});
|
|
}
|
|
|
|
const outputs = new Set(ast.outputs.map((output) => output.name));
|
|
for (const fn of ast.runtimeFunctions) {
|
|
for (const call of fn.body.matchAll(/\boutput\.([A-Za-z_$][\w$]*)\s*\(/g)) {
|
|
const outputName = call[1]!;
|
|
if (fn.runtime === "server") {
|
|
diagnostics.push({
|
|
code: "WRN-OUTPUT-SERVER-CALL",
|
|
severity: "error",
|
|
message: `Server function '${fn.name}' cannot call output.${outputName}().`,
|
|
hint: "Return a typed value to the browser and call the output from a client function.",
|
|
file: options.file,
|
|
});
|
|
} else if (!outputs.has(outputName)) {
|
|
diagnostics.push({
|
|
code: "WRN-OUTPUT-UNKNOWN",
|
|
severity: "error",
|
|
message: `Unknown output '${outputName}' called from '${fn.name}'.`,
|
|
hint: `Declare ${outputName}(payload) inside outputs { ... }.`,
|
|
file: options.file,
|
|
});
|
|
}
|
|
}
|
|
if (fn.runtime === "client" && /\b(?:process|Bun|Deno|__dirname|require)\b/.test(fn.body)) {
|
|
diagnostics.push({
|
|
code: "WRN-CLIENT-SERVER-API",
|
|
severity: "error",
|
|
message: `Client function '${fn.name}' references a server-only API.`,
|
|
hint: "Move that operation into a server function and call it through server.name(...).",
|
|
file: options.file,
|
|
});
|
|
}
|
|
if (
|
|
fn.runtime === "server" &&
|
|
/\b(?:window|document|localStorage|sessionStorage|navigator)\b/.test(fn.body)
|
|
) {
|
|
diagnostics.push({
|
|
code: "WRN-SERVER-BROWSER-API",
|
|
severity: "error",
|
|
message: `Server function '${fn.name}' references a browser-only API.`,
|
|
hint: "Move that code into a client function.",
|
|
file: options.file,
|
|
});
|
|
}
|
|
if (
|
|
fn.runtime !== "server" &&
|
|
/\b(?:eval\s*\(|new\s+Function\s*\(|document\.write\s*\(|\.innerHTML\s*=|\.outerHTML\s*=|insertAdjacentHTML\s*\()/.test(
|
|
fn.body,
|
|
)
|
|
) {
|
|
diagnostics.push({
|
|
code: "WRN-SEC-DOM-SINK",
|
|
severity: "error",
|
|
message: `Client function '${fn.name}' uses an unsafe dynamic-code or HTML sink.`,
|
|
hint: "Use compiled templates, textContent, typed outputs, or a reviewed TrustedHTML sanitizer.",
|
|
file: options.file,
|
|
});
|
|
}
|
|
if (fn.runtime !== "server" && /\b(?:setTimeout|setInterval)\s*\(\s*["'`]/.test(fn.body)) {
|
|
diagnostics.push({
|
|
code: "WRN-SEC-STRING-TIMER",
|
|
severity: "error",
|
|
message: `Client function '${fn.name}' passes a string to a timer.`,
|
|
hint: "Pass a function instead of executable text.",
|
|
file: options.file,
|
|
});
|
|
}
|
|
const parameterNames = new Set(fn.parameters.map((parameter) => parameter.name));
|
|
for (const prop of ast.props) {
|
|
if (containsReadonlyPropMutation(fn.body, prop.name, parameterNames)) {
|
|
diagnostics.push({
|
|
code: "WRN-PROP-READONLY",
|
|
severity: "error",
|
|
message: `Function '${fn.name}' attempts to mutate readonly prop '${prop.name}'.`,
|
|
hint: "Copy the prop into state before mutating it.",
|
|
file: options.file,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
for (const state of ast.states) {
|
|
if (
|
|
state.runtime === "shared" &&
|
|
/^(?:new\s+(?:Map|Set|WeakMap|WeakSet)|(?:async\s+)?function\b|.*=>)/.test(state.expr.trim())
|
|
) {
|
|
diagnostics.push({
|
|
code: "WRN-STATE-NON-SERIALIZABLE",
|
|
severity: "error",
|
|
message: `Shared state '${state.name}' is not safely serializable.`,
|
|
hint: "Use JSON-compatible data or move the value into client/server state.",
|
|
file: options.file,
|
|
});
|
|
}
|
|
if (
|
|
state.runtime !== "server" &&
|
|
/\b(?:process\.env|Bun\.env|Deno\.env|ctx\.env|import\.meta\.env)\b/.test(state.expr)
|
|
) {
|
|
diagnostics.push({
|
|
code: "WRN-SEC-SERVER-SECRET-SOURCE",
|
|
severity: "error",
|
|
message: `Browser-visible state '${state.name}' reads from a server environment source.`,
|
|
hint: "Move environment-backed values into server state and return only an explicitly safe result.",
|
|
file: options.file,
|
|
});
|
|
}
|
|
}
|
|
if (ast.persist) {
|
|
const stateNames = new Set(
|
|
ast.states.filter((state) => state.runtime !== "server").map((state) => state.name),
|
|
);
|
|
for (const name of ast.persist.include)
|
|
if (!stateNames.has(name))
|
|
diagnostics.push({
|
|
code: "WRN-PERSIST-UNKNOWN-FIELD",
|
|
severity: "error",
|
|
message: `Persist include references unknown or server-only state '${name}'.`,
|
|
hint: "Persist only declared shared/client state fields.",
|
|
file: options.file,
|
|
});
|
|
for (const name of ast.persist.include)
|
|
if (/token|password|secret|otp|api.?key/i.test(name))
|
|
diagnostics.push({
|
|
code: "WRN-PERSIST-SENSITIVE",
|
|
severity: "error",
|
|
message: `Sensitive field '${name}' cannot be persisted.`,
|
|
hint: "Remove secrets, tokens, passwords, OTPs, and API keys from persistence.",
|
|
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");
|
|
}
|