@mr_hugo/boredom
Version:
The LLM-First JavaScript Framework
1,557 lines (1,432 loc) • 57.5 kB
JavaScript
const parse5 = require("parse5");
const acorn = require("acorn");
const SUPPORTED_EVENTS = new Set([
"click",
"dblclick",
"input",
"change",
"dragstart",
"dragover",
"drop",
"dragend",
"pointerdown",
"pointermove",
"pointerup",
"pointerout",
"keydown",
"keyup",
"focus",
"blur",
]);
const EXPRESSION_ATTRS = new Set([
"data-text",
"data-show",
"data-value",
"data-checked",
"data-list",
"data-list-key",
]);
const RESERVED_LIST_ALIASES = new Set(["state", "local", "refs", "self", "e", "item", "index"]);
const DOC_QUERY_METHODS = new Set([
"querySelector",
"querySelectorAll",
"getElementById",
"getElementsByClassName",
"getElementsByTagName",
]);
const COLLECTION_QUERY_METHODS = new Set([
"querySelectorAll",
"getElementsByClassName",
"getElementsByTagName",
]);
const HTML_MUTATION_PROPS = new Set(["innerHTML", "outerHTML"]);
const HTML_MUTATION_METHODS = new Set(["insertAdjacentHTML"]);
const TIMER_CALLS = new Set([
"requestAnimationFrame",
"setInterval",
"setTimeout",
"addEventListener",
]);
const SEVERITY_BY_CODE = {
W001: "error",
W002: "warning",
W003: "error",
W004: "warning",
W005: "error",
W006: "warning",
W007: "error",
W008: "error",
W009: "error",
W010: "warning",
W011: "error",
W012: "warning",
W013: "error",
W014: "error",
W015: "warning",
W016: "warning",
W017: "warning",
W018: "warning",
W019: "error",
W020: "error",
W021: "warning",
W022: "info",
W023: "warning",
W024: "info",
W025: "error",
W026: "error",
W027: "error",
W028: "error",
W029: "warning",
J010: "error",
J001: "warning",
J002: "warning",
J003: "warning",
J004: "warning",
J005: "warning",
J006: "warning",
J007: "info",
J008: "warning",
J009: "info",
};
function validateHtml(html) {
const warnings = [];
const lineIndex = buildLineIndex(html);
const document = parse5.parse(html, { sourceCodeLocationInfo: true });
const components = new Map();
const boredomScripts = [];
let foundRuntimeScript = /window\.__BOREDOM__/.test(html);
let runtimeScriptLoc = null;
let firstComponentLoc = null;
let foundExternalFont = false;
let foundCanvas = false;
let canvasLoc = null;
const getComponent = (name) => {
if (!components.has(name)) {
components.set(name, {
name,
templateCount: 0,
scriptCount: 0,
refs: new Map(),
dispatches: new Map(),
handlers: new Map(),
parseFailed: false,
hasKeydown: false,
hasKeyup: false,
keydownLoc: null,
keydownNeedsGuard: false,
hasEditableGuard: false,
editableGuardLoc: null,
usesItemSelected: false,
itemSelectedLoc: null,
selectedAssigned: false,
});
}
return components.get(name);
};
const walk = (node, context) => {
if (!node) return;
const tagName = node.tagName ? node.tagName.toLowerCase() : null;
const attrs = tagName ? getAttrMap(node) : new Map();
if (tagName === "template" && attrs.has("data-component")) {
const componentName = attrs.get("data-component");
const comp = getComponent(componentName);
comp.templateCount += 1;
if (!firstComponentLoc) {
firstComponentLoc = getAttrLocation(node, "data-component") || getNodeLocation(node);
}
if (comp.templateCount > 1) {
pushWarning(
warnings,
"W019",
`Multiple templates found for component \"${componentName}\"`,
getAttrLocation(node, "data-component"),
"Keep a single <template data-component> per component.",
);
}
if (node.content) {
walk(node.content, { component: componentName });
}
return;
}
if (tagName === "script") {
const src = attrs.get("src") || "";
if (src && /boredom\.js|boreDOM\.js/i.test(src)) {
foundRuntimeScript = true;
if (!runtimeScriptLoc) {
runtimeScriptLoc = getAttrLocation(node, "src") || getNodeLocation(node);
}
}
const type = attrs.get("type");
if (type === "text/boredom") {
const componentName = attrs.get("data-component") || null;
if (componentName) {
const comp = getComponent(componentName);
comp.scriptCount += 1;
if (!firstComponentLoc) {
firstComponentLoc = getAttrLocation(node, "data-component") || getNodeLocation(node);
}
if (comp.scriptCount > 1) {
pushWarning(
warnings,
"W020",
`Multiple scripts found for component \"${componentName}\"`,
getAttrLocation(node, "data-component"),
"Keep a single <script type=\"text/boredom\" data-component> per component.",
);
}
}
boredomScripts.push({
componentName,
content: getTextContent(node),
startOffset: getScriptContentStartOffset(node),
});
}
}
if (tagName) {
const currentComponent = context.component || null;
const comp = currentComponent ? getComponent(currentComponent) : null;
if (tagName === "link") {
const href = attrs.get("href") || "";
if (/fonts\.googleapis\.com/i.test(href) || /fonts\.gstatic\.com/i.test(href)) {
foundExternalFont = true;
pushWarning(
warnings,
"W022",
"External font dependency detected",
getAttrLocation(node, "href") || getNodeLocation(node),
"Prefer local/system fonts for offline-friendly demos.",
);
}
}
if (tagName === "style") {
const content = getTextContent(node);
const lower = content.toLowerCase();
if (lower.includes("@import") && (lower.includes("fonts.googleapis.com") || lower.includes("fonts.gstatic.com"))) {
foundExternalFont = true;
pushWarning(
warnings,
"W022",
"External font dependency detected via @import",
getNodeLocation(node),
"Prefer local/system fonts for offline-friendly demos.",
);
}
}
if (tagName === "canvas") {
foundCanvas = true;
if (!canvasLoc) canvasLoc = getNodeLocation(node);
}
if (attrs.has("data-ref") && comp) {
const refName = attrs.get("data-ref");
if (refName) {
if (comp.refs.has(refName)) {
pushWarning(
warnings,
"W012",
`Duplicate data-ref \"${refName}\" in component \"${comp.name}\"`,
getAttrLocation(node, "data-ref"),
"Use unique data-ref values within a component.",
);
} else {
comp.refs.set(refName, getAttrLocation(node, "data-ref"));
}
}
}
if (attrs.has("data-action")) {
const value = attrs.get("data-action");
pushWarning(
warnings,
"W001",
`Found data-action=\"${value}\" (boreDOM uses data-dispatch)`,
getAttrLocation(node, "data-action"),
"Rename to data-dispatch=\"...\" (click) or data-dispatch-<event> for other events.",
`data-dispatch=\"${value}\"`,
);
}
if (attrs.has("data-class")) {
const raw = attrs.get("data-class") || "";
const parts = raw
.split(";")
.map((part) => part.trim())
.filter(Boolean);
if (!parts.length) {
pushWarning(
warnings,
"W007",
`data-class is empty`,
getAttrLocation(node, "data-class"),
"Format should be \"className:expression\" or \"className:expr; other:expr\".",
);
}
parts.forEach((pair) => {
const parsedPair = parseDataClassPair(pair);
if (!parsedPair) {
pushWarning(
warnings,
"W007",
`data-class missing \":\" separator: \"${pair}\"`,
getAttrLocation(node, "data-class"),
"Format should be \"className:expression\".",
);
return;
}
if (!parsedPair.cls || !parsedPair.expr) {
pushWarning(
warnings,
"W007",
`data-class has empty side: \"${pair}\"`,
getAttrLocation(node, "data-class"),
"Format should be \"className:expression\".",
);
return;
}
if (!parsedPair.validExpression) {
pushWarning(
warnings,
"W013",
`Invalid expression in data-class: \"${parsedPair.expr}\"`,
getAttrLocation(node, "data-class"),
"Check expression syntax in data-class.",
);
return;
}
const left = parsedPair.cls;
const right = parsedPair.expr;
const looksLikeExpr =
/(^|\b)(state|local|item|refs|e)\b/.test(left) ||
/[\[\]\(\)\.\?=]/.test(left);
if (looksLikeExpr) {
pushWarning(
warnings,
"W002",
`data-class looks reversed: \"${pair}\"`,
getAttrLocation(node, "data-class"),
"Format should be \"className:expression\". Swap sides if needed.",
`data-class=\"${right}:${left}\"`,
);
}
if (/[A-Za-z_$][\w$]*\.\d+\b/.test(right)) {
pushWarning(
warnings,
"W005",
`Expression uses numeric dot access: \"${right}\"`,
getAttrLocation(node, "data-class"),
"Use bracket notation for numeric keys (e.g. state.foo['1']).",
);
}
trackItemSelected(comp, right, getAttrLocation(node, "data-class"));
});
}
if (attrs.has("data-show") && attrs.has("style")) {
const style = attrs.get("style") || "";
if (/display\s*:/i.test(style)) {
pushWarning(
warnings,
"W017",
"data-show used with inline display style",
getAttrLocation(node, "data-show"),
"Avoid inline display styles when using data-show.",
);
}
}
if (attrs.has("data-list")) {
const listExprRaw = attrs.get("data-list") || "";
const parsedListBinding = parseListBindingExpression(listExprRaw);
if (parsedListBinding.invalidAlias) {
pushWarning(
warnings,
"W013",
`Invalid alias in data-list: \"${parsedListBinding.invalidAlias}\"`,
getAttrLocation(node, "data-list"),
"Use a valid identifier before in/of (e.g. item in state.items).",
);
}
if (parsedListBinding.invalidOperator) {
pushWarning(
warnings,
"W013",
`Invalid data-list alias syntax: \"${listExprRaw}\"`,
getAttrLocation(node, "data-list"),
"Use \"alias in expression\" or \"alias of expression\" for list aliases.",
);
}
if (parsedListBinding.alias && RESERVED_LIST_ALIASES.has(parsedListBinding.alias)) {
pushWarning(
warnings,
"W013",
`Reserved alias in data-list: \"${parsedListBinding.alias}\"`,
getAttrLocation(node, "data-list"),
"Choose an alias other than state/local/refs/self/e/item/index.",
);
}
const listOnce = attrs.has("data-list-once") || attrs.has("data-list-static");
if (!attrs.has("data-list-key") && !listOnce) {
pushWarning(
warnings,
"W015",
"data-list without data-list-key",
getAttrLocation(node, "data-list"),
"Add data-list-key for stable list item updates, or add data-list-once for static lists.",
);
}
const templateCount = countTemplateDataItems(node);
if (templateCount === 0) {
pushWarning(
warnings,
"W003",
"data-list found without a <template data-item> inside the list element",
getAttrLocation(node, "data-list") || getNodeLocation(node),
"Add a <template data-item> inside the list element to render items.",
);
}
if (templateCount > 1) {
pushWarning(
warnings,
"W011",
"data-list contains multiple <template data-item> blocks",
getAttrLocation(node, "data-list") || getNodeLocation(node),
"Use a single <template data-item> per list.",
);
}
const listExpr = parsedListBinding.itemsExpr;
if (listExpr && !isValidExpression(listExpr)) {
pushWarning(
warnings,
"W013",
`Invalid expression in data-list: \"${listExpr}\"`,
getAttrLocation(node, "data-list"),
"Check expression syntax in data-list.",
);
}
if (attrs.has("data-list-key")) {
const keyExpr = attrs.get("data-list-key");
if (keyExpr) {
if (!isValidExpression(keyExpr)) {
pushWarning(
warnings,
"W013",
`Invalid expression in data-list-key: \"${keyExpr}\"`,
getAttrLocation(node, "data-list-key"),
"Check expression syntax in data-list-key.",
);
}
if (isUnstableKeyExpression(keyExpr)) {
pushWarning(
warnings,
"W016",
`data-list-key looks unstable: \"${keyExpr}\"`,
getAttrLocation(node, "data-list-key"),
isStaticListExpression(listExpr)
? "If this is a fixed grid, prefer data-list-once/data-list-static over index keys."
: "Use stable unique IDs for list keys (e.g. item.id).",
);
} else if (isLikelyNonUniqueKeyExpression(keyExpr)) {
pushWarning(
warnings,
"W023",
`data-list-key may not be unique: \"${keyExpr}\"`,
getAttrLocation(node, "data-list-key"),
"Time-based keys can collide; prefer unique ids (e.g. item.id).",
);
}
}
}
}
if (tagName === "input" || tagName === "select" || tagName === "textarea") {
if (attrs.has("data-value")) {
const valueExpr = attrs.get("data-value") || "";
if (valueExpr && !isAssignableExpression(valueExpr)) {
pushWarning(
warnings,
"W004",
`${tagName} has non-assignable data-value expression`,
getAttrLocation(node, "data-value") || getNodeLocation(node),
"Use assignable paths (e.g. local.name), or keep one-way binding and handle updates with data-dispatch-input/change.",
);
}
}
}
for (const [name, value] of attrs.entries()) {
if (name.startsWith("data-dispatch-")) {
const eventName = name.slice("data-dispatch-".length);
if (!SUPPORTED_EVENTS.has(eventName)) {
pushWarning(
warnings,
"W008",
`Unsupported event in ${name}`,
getAttrLocation(node, name),
`Use one of: ${Array.from(SUPPORTED_EVENTS).join(", ")}.`,
);
}
if (eventName === "keydown") {
if (comp) {
comp.hasKeydown = true;
if (!comp.keydownLoc) comp.keydownLoc = getAttrLocation(node, name);
const isInputLike = tagName === "input" || tagName === "select" || tagName === "textarea";
const editable = attrs.has("contenteditable") && attrs.get("contenteditable") !== "false";
if (!isInputLike && !editable) {
comp.keydownNeedsGuard = true;
}
}
}
if (eventName === "keyup" && comp) {
comp.hasKeyup = true;
}
if (eventName === "pointerout") {
pushWarning(
warnings,
"W021",
"data-dispatch-pointerout can fire when moving between children",
getAttrLocation(node, name),
"Prefer pointerup or guard pointerout with relatedTarget checks.",
);
}
if (comp && SUPPORTED_EVENTS.has(eventName)) {
const actionName = value || "";
if (actionName) {
if (!comp.dispatches.has(actionName)) {
comp.dispatches.set(actionName, getAttrLocation(node, name));
}
}
}
if ((eventName === "input" || eventName === "change") && tagName) {
const isInputLike = tagName === "input" || tagName === "select" || tagName === "textarea";
const editable = attrs.has("contenteditable") && attrs.get("contenteditable") !== "false";
if (!isInputLike && !editable) {
pushWarning(
warnings,
"W018",
`${name} used on non-input element`,
getAttrLocation(node, name),
"Use data-dispatch-input/change on input, select, textarea, or contenteditable elements.",
);
}
}
}
if (name === "data-dispatch" && comp) {
const actionName = value || "";
if (actionName && !comp.dispatches.has(actionName)) {
comp.dispatches.set(actionName, getAttrLocation(node, name));
}
}
if (name.startsWith("data-prop-")) {
pushWarning(
warnings,
"W025",
`data-prop-* is not supported in boreDOM Lite: ${name}`,
getAttrLocation(node, name),
"Use data-arg-* for event arguments or data-attr-* to set attributes.",
);
}
if (name.startsWith("data-arg-") || name.startsWith("data-attr-")) {
if (!value || !value.trim()) {
pushWarning(
warnings,
"W014",
`Empty expression for ${name}`,
getAttrLocation(node, name),
"Provide a valid expression for data-arg-* or data-attr-*.",
);
} else if (!isValidExpression(value)) {
pushWarning(
warnings,
"W013",
`Invalid expression in ${name}: \"${value}\"`,
getAttrLocation(node, name),
"Check expression syntax in data-arg-* or data-attr-*.",
);
}
trackItemSelected(comp, value, getAttrLocation(node, name));
}
if ((EXPRESSION_ATTRS.has(name) || name.startsWith("data-arg-") || name.startsWith("data-attr-")) && /[A-Za-z_$][\w$]*\.\d+\b/.test(value)) {
pushWarning(
warnings,
"W005",
`Expression uses numeric dot access: \"${value}\"`,
getAttrLocation(node, name) || getNodeLocation(node),
"Use bracket notation for numeric keys (e.g. state.foo['1']).",
);
}
if (EXPRESSION_ATTRS.has(name)) {
if (name === "data-list" || name === "data-list-key") continue;
if (name === "data-class") continue;
if (value && !isValidExpression(value)) {
pushWarning(
warnings,
"W013",
`Invalid expression in ${name}: \"${value}\"`,
getAttrLocation(node, name),
"Check expression syntax in bindings.",
);
}
trackItemSelected(comp, value, getAttrLocation(node, name));
}
}
}
const nextContext = context;
if (node.childNodes) {
node.childNodes.forEach((child) => walk(child, nextContext));
}
if (node.content) {
walk(node.content, nextContext);
}
};
walk(document, { component: null });
const listOnceNodes = [];
const collectListOnceNodes = (node) => {
if (!node) return;
if (node.tagName) {
const attrs = getAttrMap(node);
if (attrs.has("data-list") && (attrs.has("data-list-once") || attrs.has("data-list-static"))) {
listOnceNodes.push({ node, attrs });
}
}
if (node.childNodes) {
node.childNodes.forEach((child) => collectListOnceNodes(child));
}
if (node.content) {
collectListOnceNodes(node.content);
}
};
collectListOnceNodes(document);
listOnceNodes.forEach(({ node, attrs }) => {
const listBinding = parseListBindingExpression(attrs.get("data-list"));
const listExpr = listBinding.itemsExpr;
if (!listExpr || isStaticListExpression(listExpr)) return;
const templateNode = findTemplateDataItem(node);
const dynamicBinding = templateNode ? findDynamicBindingInTemplate(templateNode) : null;
if (!dynamicBinding) return;
pushWarning(
warnings,
"W029",
"data-list-once used on a list with dynamic bindings",
getAttrLocation(node, "data-list-once") ||
getAttrLocation(node, "data-list-static") ||
getAttrLocation(node, "data-list") ||
dynamicBinding.loc,
"Remove data-list-once/data-list-static when list items or state are expected to change.",
);
});
const placeholderIndex = html.indexOf("${scriptTag}");
if (placeholderIndex !== -1) {
const pos = getLineCol(lineIndex, placeholderIndex);
pushWarning(
warnings,
"W028",
"Template placeholder ${scriptTag} detected",
pos ? { startLine: pos.line, startCol: pos.col } : null,
"Replace with <script src=\"./boreDOM.js\" data-state=\"#initial-state\"></script>.",
);
}
if (!foundRuntimeScript && (components.size > 0 || boredomScripts.length > 0)) {
pushWarning(
warnings,
"W027",
"boreDOM runtime script not found",
runtimeScriptLoc || firstComponentLoc || { startLine: 1, startCol: 1 },
"Add <script src=\"./boreDOM.js\" data-state=\"#initial-state\"></script>.",
);
}
if (
!foundExternalFont &&
(/fonts\.googleapis\.com/i.test(html) || /fonts\.gstatic\.com/i.test(html))
) {
const matchIndex = html.search(/fonts\.googleapis\.com|fonts\.gstatic\.com/i);
const pos = matchIndex >= 0 ? getLineCol(lineIndex, matchIndex) : null;
pushWarning(
warnings,
"W022",
"External font dependency detected",
pos ? { startLine: pos.line, startCol: pos.col } : null,
"Prefer local/system fonts for offline-friendly demos.",
);
}
components.forEach((comp) => {
if (comp.hasKeydown && !comp.hasKeyup) {
pushWarning(
warnings,
"W006",
"Found data-dispatch-keydown but no data-dispatch-keyup",
comp.keydownLoc,
"If your handler branches on keyup, add data-dispatch-keyup to the same element.",
);
}
if (comp.keydownNeedsGuard && !comp.hasEditableGuard) {
pushWarning(
warnings,
"J008",
"Keyboard handler lacks editable-target guard",
comp.keydownLoc,
"Guard key handlers with event.target/composedPath() or closest('input, textarea, [contenteditable]'). Consider a hidden input key-capture pattern if global shortcuts are needed.",
);
}
});
boredomScripts.forEach((script) => {
warnings.push(...validateScript(script, lineIndex, components));
});
if (foundCanvas && !warnings.some((warning) => warning.code === "J009")) {
pushWarning(
warnings,
"J009",
"Canvas usage detected",
canvasLoc,
"Consider state echoing for audio/canvas so behavior can be tested/verified.",
);
}
components.forEach((comp) => {
if (!comp.parseFailed) {
comp.dispatches.forEach((loc, actionName) => {
if (!comp.handlers.has(actionName)) {
pushWarning(
warnings,
"W009",
`No handler found for action \"${actionName}\" in component \"${comp.name}\"`,
loc,
`Add on(\"${actionName}\", ...) in the component script. Note: the validator only detects literal on(\"...\") calls.`,
);
}
});
comp.handlers.forEach((loc, actionName) => {
if (!comp.dispatches.has(actionName)) {
pushWarning(
warnings,
"W010",
`Handler \"${actionName}\" is never dispatched in component \"${comp.name}\"`,
loc,
`Add data-dispatch=\"${actionName}\" to an element in the template.`,
);
}
});
}
if (comp.usesItemSelected && !comp.selectedAssigned) {
pushWarning(
warnings,
"W024",
`item.selected is used in component \"${comp.name}\" but never assigned`,
comp.itemSelectedLoc,
"Either set item.selected in script or bind selection from shared state.",
);
}
});
return warnings;
}
function validateScript(script, lineIndex, components) {
const warnings = [];
const source = script.content || "";
if (!source.trim()) return warnings;
const comp = script.componentName ? components.get(script.componentName) : null;
let ast;
try {
ast = acorn.parse(source, {
ecmaVersion: "latest",
sourceType: "module",
locations: true,
});
} catch (err) {
if (comp) {
comp.parseFailed = true;
}
const loc = err && err.loc ? { start: err.loc } : null;
pushWarning(
warnings,
"J010",
`Script parse error: ${err.message}`,
loc ? mapScriptLoc(script, lineIndex, loc) : null,
"Fix syntax errors so handlers can be analyzed.",
);
return warnings;
}
let hasOnCleanup = false;
let needsMediaHint = false;
let mediaHintLoc = null;
const keyupChecks = [];
const collectionVars = new Set();
const onAliases = new Set(["on"]);
const objectKeyMap = new Map();
walkAst(ast, (node) => {
if (node.type === "CallExpression") {
if (node.callee && node.callee.type === "Identifier" && node.callee.name === "onCleanup") {
hasOnCleanup = true;
}
}
});
walkAst(ast, (node, parent) => {
if (node.type === "VariableDeclarator") {
if (node.id) {
if (node.id.type === "Identifier") {
if (isOnReference(node.init, onAliases)) {
onAliases.add(node.id.name);
}
if (node.init && node.init.type === "ObjectExpression") {
const keys = getObjectExpressionKeys(node.init);
if (keys.size) {
objectKeyMap.set(node.id.name, { keys, loc: mapScriptLoc(script, lineIndex, node.loc) });
}
}
} else if (node.id.type === "ObjectPattern") {
node.id.properties.forEach((prop) => {
if (!prop || prop.type !== "Property") return;
const keyName = getPropertyKeyName(prop.key);
if (keyName === "on") {
const value = prop.value;
if (value && value.type === "Identifier") {
onAliases.add(value.name);
} else if (value && value.type === "AssignmentPattern" && value.left.type === "Identifier") {
onAliases.add(value.left.name);
}
}
});
}
}
if (node.id && node.id.type === "Identifier") {
if (isCollectionCall(node.init) || isArrayFromCollectionCall(node.init, collectionVars)) {
collectionVars.add(node.id.name);
}
}
}
if (node.type === "CallExpression") {
const onCallName = getOnCallName(node, onAliases);
if (onCallName && comp && !comp.handlers.has(onCallName)) {
comp.handlers.set(onCallName, mapScriptLoc(script, lineIndex, node.loc));
}
if (node.callee && node.callee.type === "Identifier") {
if (TIMER_CALLS.has(node.callee.name) && !hasOnCleanup) {
pushWarning(
warnings,
"J003",
`${node.callee.name} used without onCleanup`,
mapScriptLoc(script, lineIndex, node.loc),
"Add onCleanup to cancel timers/listeners created in onMount.",
);
}
if (node.callee.name === "AudioContext" || node.callee.name === "webkitAudioContext") {
needsMediaHint = true;
if (!mediaHintLoc) mediaHintLoc = mapScriptLoc(script, lineIndex, node.loc);
}
}
const entry = getObjectEntriesInfo(node, objectKeyMap, onAliases, script, lineIndex);
if (entry) {
if (entry && comp) {
entry.keys.forEach((name) => {
if (!comp.handlers.has(name)) {
comp.handlers.set(name, entry.loc);
}
});
}
}
if (
node.callee &&
node.callee.type === "MemberExpression" &&
node.callee.property &&
node.callee.property.type === "Identifier"
) {
if (HTML_MUTATION_METHODS.has(node.callee.property.name)) {
pushWarning(
warnings,
"J002",
`Imperative HTML mutation via ${node.callee.property.name}`,
mapScriptLoc(script, lineIndex, node.loc),
"Prefer data-list/data-text bindings over innerHTML mutations.",
);
}
if (node.callee.property.name === "addEventListener" && !hasOnCleanup) {
pushWarning(
warnings,
"J003",
"addEventListener used without onCleanup",
mapScriptLoc(script, lineIndex, node.loc),
"Add onCleanup to remove event listeners created in onMount.",
);
}
if (node.callee.property.name === "getContext") {
needsMediaHint = true;
if (!mediaHintLoc) mediaHintLoc = mapScriptLoc(script, lineIndex, node.loc);
}
if (node.callee.property.name === "addEventListener") {
const target = node.callee.object;
const eventName = getStringLiteral(node.arguments?.[0]);
if (
eventName &&
(eventName === "keydown" || eventName === "keyup") &&
target &&
target.type === "Identifier" &&
(target.name === "window" || target.name === "document")
) {
pushWarning(
warnings,
"J006",
`Global ${eventName} handler registered on ${target.name}`,
mapScriptLoc(script, lineIndex, node.loc),
"Scope keyboard handlers to the component or guard with self.contains(e.target).",
);
}
}
}
if (isCollectionForEach(node, collectionVars)) {
const callback = node.arguments?.[0];
if (callback && isFunctionNode(callback)) {
const paramNames = getFunctionParamNames(callback);
if (paramNames.size && hasStyleAssignmentsInNode(callback.body, paramNames)) {
pushWarning(
warnings,
"J007",
"Imperative layout loop setting inline styles on DOM collections",
mapScriptLoc(script, lineIndex, node.loc),
"Prefer data-attr-style or bindings instead of manual DOM loops.",
);
}
}
}
}
if (node.type === "ForOfStatement") {
if (isCollectionExpression(node.right, collectionVars)) {
const loopVars = getLoopVarNames(node.left);
if (loopVars.size && hasStyleAssignmentsInNode(node.body, loopVars)) {
pushWarning(
warnings,
"J007",
"Imperative layout loop setting inline styles on DOM collections",
mapScriptLoc(script, lineIndex, node.loc),
"Prefer data-attr-style or bindings instead of manual DOM loops.",
);
}
}
}
if (node.type === "ForStatement") {
const collectionVar = getCollectionVarFromFor(node, collectionVars);
if (collectionVar) {
if (hasStyleAssignmentsInNode(node.body, new Set([collectionVar]))) {
pushWarning(
warnings,
"J007",
"Imperative layout loop setting inline styles on DOM collections",
mapScriptLoc(script, lineIndex, node.loc),
"Prefer data-attr-style or bindings instead of manual DOM loops.",
);
}
}
}
if (node.type === "MemberExpression") {
const obj = node.object;
const propName = getPropName(node);
if (obj && obj.type === "Identifier" && obj.name === "document") {
if (propName && DOC_QUERY_METHODS.has(propName)) {
pushWarning(
warnings,
"J001",
`document.${propName} used in component logic`,
mapScriptLoc(script, lineIndex, node.loc),
"Prefer refs or self.querySelector inside the component.",
);
}
if (propName === "activeElement") {
pushWarning(
warnings,
"J005",
"document.activeElement used inside component logic",
mapScriptLoc(script, lineIndex, node.loc),
"Use event.target or e.composedPath() to detect editable inputs.",
);
}
}
if (propName && HTML_MUTATION_PROPS.has(propName)) {
if (parent && parent.type === "AssignmentExpression" && parent.left === node) {
pushWarning(
warnings,
"J002",
`Imperative HTML mutation via ${propName}`,
mapScriptLoc(script, lineIndex, node.loc),
"Prefer data-list/data-text bindings over innerHTML mutations.",
);
}
}
// no data-prop mirroring in Lite
if (
obj &&
obj.type === "Identifier" &&
(obj.name === "window" || obj.name === "document") &&
propName === "AudioContext"
) {
needsMediaHint = true;
if (!mediaHintLoc) mediaHintLoc = mapScriptLoc(script, lineIndex, node.loc);
}
}
if (node.type === "NewExpression" && comp) {
if (isAudioContextCtor(node.callee)) {
needsMediaHint = true;
if (!mediaHintLoc) mediaHintLoc = mapScriptLoc(script, lineIndex, node.loc);
}
}
if (node.type === "AssignmentExpression" && comp) {
if (isSelectedAssignment(node.left)) {
comp.selectedAssigned = true;
}
}
if (comp && !comp.hasEditableGuard && isEditableGuardPattern(node)) {
comp.hasEditableGuard = true;
if (!comp.editableGuardLoc) comp.editableGuardLoc = mapScriptLoc(script, lineIndex, node.loc);
}
if (node.type === "BinaryExpression" || node.type === "LogicalExpression") {
if (isLiteral(node.left, "keyup") || isLiteral(node.right, "keyup")) {
keyupChecks.push(node.loc);
}
}
if (node.type === "SwitchCase" && isLiteral(node.test, "keyup")) {
keyupChecks.push(node.loc);
}
});
if (comp && !comp.hasKeyup && keyupChecks.length) {
pushWarning(
warnings,
"J004",
"Handler checks for keyup but template has no data-dispatch-keyup",
mapScriptLoc(script, lineIndex, keyupChecks[0]),
"Add data-dispatch-keyup to the same element as data-dispatch-keydown.",
);
}
if (needsMediaHint) {
pushWarning(
warnings,
"J009",
"Audio/canvas usage detected",
mediaHintLoc,
"Consider state echoing for audio/canvas so behavior can be tested/verified.",
);
}
return warnings;
}
function walkHtml(node, visit) {
visit(node);
if (node.childNodes) {
node.childNodes.forEach((child) => walkHtml(child, visit));
}
if (node.content && node.content.childNodes) {
node.content.childNodes.forEach((child) => walkHtml(child, visit));
}
}
function getAttrMap(node) {
const map = new Map();
if (!node.attrs) return map;
node.attrs.forEach((attr) => {
map.set(attr.name, attr.value);
});
return map;
}
function getAttrLocation(node, name) {
if (!node.sourceCodeLocation || !node.sourceCodeLocation.attrs) return null;
return node.sourceCodeLocation.attrs[name] || null;
}
function getNodeLocation(node) {
return node.sourceCodeLocation || null;
}
function unwrapChain(node) {
if (!node) return null;
return node.type === "ChainExpression" ? node.expression : node;
}
function getPropertyKeyName(node) {
if (!node) return null;
if (node.type === "Identifier") return node.name;
if (node.type === "Literal") return String(node.value);
return null;
}
function getObjectExpressionKeys(node) {
const keys = new Set();
if (!node || node.type !== "ObjectExpression") return keys;
node.properties.forEach((prop) => {
if (!prop || prop.type !== "Property" || prop.computed) return;
const keyName = getPropertyKeyName(prop.key);
if (keyName != null) keys.add(keyName);
});
return keys;
}
function countTemplateDataItems(root) {
let count = 0;
const walk = (node, isRoot) => {
if (!node || !node.tagName) return;
const attrs = getAttrMap(node);
if (!isRoot && attrs.has("data-list")) return;
if (node.tagName.toLowerCase() === "template" && attrs.has("data-item")) {
count += 1;
}
if (node.childNodes) {
node.childNodes.forEach((child) => walk(child, false));
}
if (node.content) {
walk(node.content, false);
}
};
walk(root, true);
return count;
}
function findTemplateDataItem(root) {
let found = null;
const walk = (node, isRoot) => {
if (!node || !node.tagName || found) return;
const attrs = getAttrMap(node);
if (!isRoot && attrs.has("data-list")) return;
if (node.tagName.toLowerCase() === "template" && attrs.has("data-item")) {
found = node;
return;
}
if (node.childNodes) {
node.childNodes.forEach((child) => walk(child, false));
}
if (node.content) {
walk(node.content, false);
}
};
walk(root, true);
return found;
}
function findDynamicBindingInTemplate(templateNode) {
let found = null;
const isBindingAttr = (name) =>
EXPRESSION_ATTRS.has(name) ||
name === "data-class" ||
name.startsWith("data-arg-") ||
name.startsWith("data-attr-");
const hasDynamicToken = (value) => {
if (!value) return false;
return /(^|\b)(state|local|item|index)\b/.test(value);
};
const walk = (node) => {
if (!node || found) return;
if (node.tagName) {
const attrs = getAttrMap(node);
for (const [name, value] of attrs.entries()) {
if (isBindingAttr(name) && hasDynamicToken(value)) {
found = { name, value, loc: getAttrLocation(node, name) || getNodeLocation(node) };
return;
}
}
}
if (node.childNodes) {
node.childNodes.forEach((child) => walk(child));
}
if (node.content) {
walk(node.content);
}
};
if (templateNode.content) {
walk(templateNode.content);
} else {
walk(templateNode);
}
return found;
}
function getTextContent(node) {
if (!node.childNodes) return "";
return node.childNodes
.filter((child) => child.nodeName === "#text")
.map((child) => child.value)
.join("");
}
function getScriptContentStartOffset(node) {
const loc = node.sourceCodeLocation;
if (loc && loc.startTag && typeof loc.startTag.endOffset === "number") {
return loc.startTag.endOffset;
}
return 0;
}
function getPropName(node) {
if (node.property) {
if (node.property.type === "Identifier") return node.property.name;
if (node.property.type === "Literal") return node.property.value;
}
return null;
}
function isOnReference(node, onAliases) {
if (!node) return false;
const unwrapped = unwrapChain(node);
if (unwrapped && unwrapped.type === "Identifier" && onAliases && onAliases.has(unwrapped.name)) {
return true;
}
if (unwrapped && unwrapped.type === "MemberExpression") {
const propName = getPropertyKeyName(unwrapped.property);
return propName === "on";
}
return false;
}
function getOnCallName(node, onAliases) {
if (!node || node.type !== "CallExpression") return null;
const callee = unwrapChain(node.callee);
if (!callee) return null;
let isOn = false;
if (callee.type === "Identifier") {
isOn = onAliases && onAliases.has(callee.name);
} else if (callee.type === "MemberExpression") {
const propName = getPropertyKeyName(callee.property);
isOn = propName === "on";
}
if (!isOn) return null;
const name = getStringLiteral(node.arguments?.[0]);
return name || null;
}
function hasOnCallWithParam(node, paramName, onAliases) {
if (!node || !paramName) return false;
let found = false;
walkAst(node, (child) => {
if (found) return;
if (child.type !== "CallExpression") return;
const callee = unwrapChain(child.callee);
if (!callee) return;
let isOn = false;
if (callee.type === "Identifier") {
isOn = onAliases && onAliases.has(callee.name);
} else if (callee.type === "MemberExpression") {
const propName = getPropertyKeyName(callee.property);
isOn = propName === "on";
}
if (!isOn) return;
const arg = child.arguments?.[0];
if (arg && arg.type === "Identifier" && arg.name === paramName) {
found = true;
}
});
return found;
}
function getObjectEntriesInfo(node, objectKeyMap, onAliases, script, lineIndex) {
if (!node || node.type !== "CallExpression") return null;
const callee = unwrapChain(node.callee);
if (!callee || callee.type !== "MemberExpression") return null;
const propName = getPropertyKeyName(callee.property);
if (propName !== "forEach") return null;
const target = callee.object;
if (!target || target.type !== "CallExpression") return null;
const targetCallee = unwrapChain(target.callee);
if (!targetCallee || targetCallee.type !== "MemberExpression") return null;
const targetProp = getPropertyKeyName(targetCallee.property);
const targetObj = targetCallee.object;
if (!targetObj || targetObj.type !== "Identifier" || targetObj.name !== "Object") return null;
if (targetProp !== "entries" && targetProp !== "keys") return null;
const arg = target.arguments?.[0];
if (!arg || arg.type !== "Identifier") return null;
const entry = objectKeyMap.get(arg.name);
if (!entry) return null;
const callback = node.arguments?.[0];
if (!callback || !isFunctionNode(callback)) return null;
let keyParam = null;
if (targetProp === "entries") {
const param = callback.params?.[0];
if (param && param.type === "ArrayPattern") {
const first = param.elements?.[0];
if (first && first.type === "Identifier") {
keyParam = first.name;
}
}
} else {
const param = callback.params?.[0];
if (param && param.type === "Identifier") {
keyParam = param.name;
}
}
if (!keyParam) return null;
if (!hasOnCallWithParam(callback.body, keyParam, onAliases)) return null;
return {
keys: entry.keys,
loc: mapScriptLoc(script, lineIndex, node.loc),
};
}
function isFunctionNode(node) {
return node && (node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression");
}
function getFunctionParamNames(node) {
const names = new Set();
if (!node || !node.params) return names;
node.params.forEach((param) => {
if (param.type === "Identifier") {
names.add(param.name);
}
});
return names;
}
function getLoopVarNames(node) {
const names = new Set();
if (!node) return names;
if (node.type === "Identifier") {
names.add(node.name);
} else if (node.type === "VariableDeclaration") {
node.declarations.forEach((decl) => {
if (decl.id && decl.id.type === "Identifier") {
names.add(decl.id.name);
}
});
}
return names;
}
function isCollectionCall(node) {
if (!node || node.type !== "CallExpression") return false;
const callee = node.callee;
if (!callee || callee.type !== "MemberExpression") return false;
const propName = getPropName(callee);
return propName && COLLECTION_QUERY_METHODS.has(propName);
}
function isArrayFromCall(node) {
if (!node || node.type !== "CallExpression") return false;
const callee = node.callee;
return (
callee &&
callee.type === "MemberExpression" &&
callee.object &&
callee.object.type === "Identifier" &&
callee.object.name === "Array" &&
getPropName(callee) === "from"
);
}
function isArrayFromCollectionCall(node, collectionVars) {
if (!isArrayFromCall(node)) return false;
const arg = node.arguments?.[0];
if (!arg) return false;
if (isCollectionCall(arg)) return true;
return arg.type === "Identifier" && collectionVars.has(arg.name);
}
function isCollectionExpression(node, collectionVars) {
if (!node) return false;
if (isCollectionCall(node)) return true;
if (node.type === "Identifier" && collectionVars.has(node.name)) return true;
if (isArrayFromCollectionCall(node, collectionVars)) return true;
return false;
}
function isCollectionForEach(node, collectionVars) {
if (!node || node.type !== "CallExpression") return false;
const callee = node.callee;
if (!callee || callee.type !== "MemberExpression") return false;
if (getPropName(callee) !== "forEach") return false;
return isCollectionExpression(callee.object, collectionVars);
}
function hasStyleAssignmentsInNode(node, targetNames) {
let found = false;
if (!node) return false;
walkAst(node, (child) => {
if (found) return;
if (child.type === "AssignmentExpression") {
if (isStyleAssignmentTarget(child.left, targetNames)) {
found = true;
}
}
if (child.type === "CallExpression") {
if (isStyleSetPropertyCall(child, targetNames)) {
found = true;
}
}
});
return found;
}
function isStyleAssignmentTarget(node, targetNames) {
if (!node || node.type !== "MemberExpression") return false;
if (!memberExpressionHasProperty(node, "style")) return false;
const base = getMemberBaseIdentifier(node);
return base && targetNames.has(base);
}
function isStyleSetPropertyCall(node, targetNames) {
if (!node || node.type !== "CallExpression") return false;
const callee = node.callee;
if (!callee || callee.type !== "MemberExpression") return false;
if (getPropName(callee) !== "setProperty") return false;
const obj = callee.object;
if (!obj || obj.type !== "MemberExpression") return false;
if (!memberExpressionHasProperty(obj, "style")) return false;
const base = getMemberBaseIdentifier(obj);
return base && targetNames.has(base);
}
function memberExpressionHasProperty(node, propName) {
let current = node;
while (current && current.type === "MemberExpression") {
if (getPropName(current) === propName) return true;
current = current.object;
}
return false;
}
function getMemberBaseIdentifier(node) {
let current = node;
while (current && current.type === "MemberExpression") {
const obj = current.object;
if (!obj) return null;
if (obj.type === "Identifier") {
return obj.name;
}
if (obj.type === "MemberExpression") {
current = obj;
continue;
}
return null;
}
return null;
}
function getCollectionVarFromFor(node, collectionVars) {
if (!node || node.type !== "ForStatement") return null;
const test = node.test;
if (!test || test.type !== "BinaryExpression") return null;
const right = test.right;
if (!right || right.type !== "MemberExpression") return null;
if (getPropName(right) !== "length") return null;
const base = getMemberBaseIdentifier(right);
if (!base) return null;
return collectionVars.has(base) ? base : null;
}
function getStringLiteral(node) {
if (!node) return null;
if (node.type === "Literal" && typeof node.value === "string") return node.value;
if (node.type === "TemplateLiteral" && node.expressions.length === 0) {
return node.quasis.map((q) => q.value.cooked).join("");
}
return null;
}
function isLiteral(node, value) {
return node && node.type === "Literal" && node.value === value;
}
function isValidExpression(expr) {
if (expr == null) return false;
const source = String(expr).trim();
if (!source) return false;
try {
acorn.parseExpressionAt(source, 0, { ecmaVersion: "latest" });
return true;
} catch (err) {
return false;
}
}
function parseDataClassPair(pair) {
const source = String(pair || "").trim();
if (!source) return null;
let idx = source.indexOf(":");
while (idx !== -1) {
const cls = source.slice(0, idx).trim();
const expr = source.slice(idx + 1).trim();
if (cls && expr && isValidExpression(expr)) {
return { cls, expr, validExpression: true };
}
idx = source.indexOf(":", idx + 1);
}
const fallbackIdx = source.indexOf(":");
if (fallbackIdx === -1) return null;
const cls = source.slice(0, fallbackIdx).trim();
const expr = source.slice(fallbackIdx + 1).trim();
return {
cls,
expr,
validExpression: !!(expr && isValidExpression(expr)),
};
}
function parseListBindingExpression(expr) {
const source = String(expr || "").trim();
if (!source) {
return { alias: null, itemsExpr: source, invalidAlias: null, invalidOperator: false };
}
const validAliasMatch = source.match(/^([A-Za-z_$][\w$]*)\s+(?:in|of)\s+([\s\S]+)$/);
if (validAliasMatch) {
return {
alias: validAliasMatch[1],
itemsExpr: validAliasMatch[2].trim(),
invalidAlias: null,
invalidOperator: false,
};
}
const aliasLikeMatch = source.match(/^(\S+)\s+(\S+)\s+([\s\S]+)$/);
if (aliasLikeMatch) {
const aliasToken = aliasLikeMatch[1];
const operatorToken = aliasLikeMatch[2];
const itemsExpr = aliasLikeMatch[3].trim();
const looksLikeIdentifier = /^[A-Za-z_$][\w$]*$/.test(aliasToken);
if ((operatorToken === "in" || operatorToken === "of") && !looksLikeIdentifier) {
return