/**
* The copy tier: content realism and the ship floor (Elaya-derived, MIT; see
* NOTICE.md), each tell reported at its line. Copy rules read the visible
* text (comments, scripts, styles and templates removed, entities decoded);
* code rules read script bodies and inline handlers; tag rules read the
* parsed tags, so a hydration payload or commented-out markup is never
* counted. Head rules run only on a document with a
. A page whose body
* holds no text and loads a script is an empty mount node: script renders it,
* so nothing here saw its content, and it says so instead of passing.
*/
import type { LintContext } from '../context';
import { getAttr, hasAttr, stripTags, textOf, treeOf, type Doc, type Tag } from '../html';
import type { Reporter } from '../report';
import { RULES } from './ids';
/** The catalog's marketing-buzzword words plus Elaya's AI cliché words. */
export const BUZZWORDS: readonly string[] = [
'seamless', 'effortless', 'supercharge', 'streamline', 'revolutionize', 'unlock', 'empower', 'elevate',
'unleash', 'next-gen', 'game-changer', 'delve', 'tapestry', 'in the world of',
];
const stem = (w: string) => w.replace(/-/g, '[- ]').replace(/e$/, '');
const TEXT_RULES: Array<[string, RegExp]> = [
['placeholder-name', /\b(?:John|Jane) Doe\b/gi],
['placeholder-brand', /\b(?:Acme|Nexus|SmartFlow)\b/g],
['lorem', /\blorem ipsum\b/gi],
['marketing-buzzword', new RegExp(`\\b(?:${BUZZWORDS.map(stem).join('|')})(?:e|es|ed|ing|ly|er|ers|s|d|eration)?\\b`, 'gi')],
['round-number', /(? = [
['scroll-listener', /addEventListener\(\s*['"`]scroll['"`]/g],
['alert', /(? id), ...SCRIPT_RULES.map(([id]) => id), 'dead-link', 'img-no-alt',
'missing-title', 'missing-meta-description', 'missing-favicon', 'missing-skip-link', 'missing-aria-current', 'empty-mount-node',
];
const ENTITY = /&(?:#(\d{1,7})|#x([0-9a-f]{1,6})|(nbsp|lt|gt|quot|apos|amp));/iy;
const NAMED: Record = { nbsp: ' ', lt: '<', gt: '>', quot: '"', apos: "'", amp: '&' };
/** The visible text with each character's source position: tags, comments, scripts, styles and the dropped elements removed, entities decoded, whitespace folded. */
function visible(doc: Doc, drop: ReadonlySet): { text: string; at: number[] } {
const { close } = treeOf(doc);
const ranges: Array<[number, number]> = [];
doc.tags.forEach((t, i) => {
if (!t.close && !t.selfClosing && drop.has(t.name)) ranges.push([t.pos, close[i] < 0 ? doc.masked.length : doc.tags[close[i]].end]);
});
let blanked = '', from = 0;
for (const [a, b] of ranges) if (a >= from) { blanked += doc.masked.slice(from, a) + ' '.repeat(b - a); from = b; }
const src = stripTags(blanked + doc.masked.slice(from), true);
let text = '';
const at: number[] = [];
const push = (ch: string, pos: number) => {
if (/\s/.test(ch)) { if (!text.endsWith(' ')) { text += ' '; at.push(pos); } return; }
for (let k = 0; k < ch.length; k++) at.push(pos);
text += ch;
};
for (let i = 0; i < src.length;) {
if (src[i] === '&') {
ENTITY.lastIndex = i;
const m = ENTITY.exec(src);
const cp = m && (m[1] ? Number(m[1]) : m[2] ? parseInt(m[2], 16) : -1);
if (m && (cp === -1 || cp! <= 0x10ffff)) {
push(m[3] ? NAMED[m[3].toLowerCase()] : String.fromCodePoint(cp!), i);
i += m[0].length;
continue;
}
}
const c = src.codePointAt(i)!;
const ch = String.fromCodePoint(c);
push(ch, i);
i += ch.length;
}
return { text, at };
}
/** Each script body and inline event handler, with the position its code starts. */
function codeOf(doc: Doc): Array<{ code: string; pos: number }> {
const { close } = treeOf(doc);
const out: Array<{ code: string; pos: number }> = [];
doc.tags.forEach((t, i) => {
if (t.close) return;
if (t.name === 'script' && close[i] >= 0) out.push({ code: doc.src.slice(t.end, doc.tags[close[i]].pos), pos: t.end });
for (const m of t.attrs.matchAll(/(?:^|\s)on[a-z]+\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)) {
const code = m[1] ?? m[2];
out.push({ code, pos: t.pos + 1 + t.name.length + m.index! + m[0].length - 1 - code.length });
}
});
return out;
}
const firstOpen = (doc: Doc, name: string): number => doc.tags.findIndex((t) => !t.close && t.name === name);
export function run(ctx: LintContext, r: Reporter): void {
for (const doc of ctx.docs) {
if (doc.kind !== 'html') continue;
const hits = new Map>();
const hit = (id: string, pos: number, what: string) => {
const byLine = hits.get(id) ?? hits.set(id, new Map()).get(id)!;
const line = doc.lineOf(pos);
const found = byLine.get(line);
if (found) found.push(what);
else byLine.set(line, [what]);
};
const { text, at } = visible(doc, new Set(['template']));
for (const [id, re] of TEXT_RULES) for (const m of text.matchAll(re)) hit(id, at[m.index!], `"${m[0]}"`);
for (const { code, pos } of codeOf(doc)) for (const [id, re] of SCRIPT_RULES) for (const m of code.matchAll(re)) hit(id, pos + m.index!, '');
for (const t of doc.tags) {
if (t.close) continue;
if (getAttr(t.attrs, 'href')?.trim() === '#') hit('dead-link', t.pos, '');
if (t.name === 'img' && !hasAttr(t.attrs, 'alt')) hit('img-no-alt', t.pos, '');
}
const head = firstOpen(doc, 'head');
const bodyIdx = firstOpen(doc, 'body');
const bodyTag: Tag | undefined = doc.tags[bodyIdx];
if (head >= 0) {
const at0 = doc.tags[head].pos;
const opens = (name: string) => doc.tags.map((t, i) => [t, i] as const).filter(([t]) => !t.close && t.name === name);
if (!opens('title').some(([, i]) => textOf(doc, i).trim() !== '')) hit('missing-title', at0, '');
if (!opens('meta').some(([t]) => getAttr(t.attrs, 'name')?.trim().toLowerCase() === 'description')) hit('missing-meta-description', at0, '');
if (!opens('link').some(([t]) => /\bicon\b/i.test(getAttr(t.attrs, 'rel') ?? ''))) hit('missing-favicon', at0, '');
const focusable = doc.tags.slice(Math.max(bodyIdx, 0)).find((t) => !t.close && ((t.name === 'a' && hasAttr(t.attrs, 'href')) || /^(?:button|input|select|textarea)$/.test(t.name)));
const skipTarget = focusable?.name === 'a' ? getAttr(focusable.attrs, 'href')?.match(/^#(.+)$/)?.[1] : undefined;
const mainTag = doc.tags.find((t) => !t.close && t.name === 'main' && getAttr(t.attrs, 'id'))
?? doc.tags.find((t) => !t.close && getAttr(t.attrs, 'role') === 'main' && getAttr(t.attrs, 'id'));
if (!skipTarget || skipTarget !== getAttr(mainTag?.attrs ?? '', 'id')) hit('missing-skip-link', bodyTag?.pos ?? at0, '');
const { close } = treeOf(doc);
let firstNav = -1, navEnd = -1, routeLinks = 0;
for (let i = 0; i < doc.tags.length && routeLinks < 2; i++) {
const t = doc.tags[i];
if (t.close) continue;
if (t.name === 'nav') {
if (firstNav < 0) firstNav = i;
navEnd = Math.max(navEnd, close[i] < 0 ? doc.tags.length : close[i]);
} else if (i < navEnd && t.name === 'a' && /^[^#]/.test(getAttr(t.attrs, 'href') ?? '')) routeLinks++;
}
if (routeLinks >= 2 && !doc.tags.some((t) => !t.close && getAttr(t.attrs, 'aria-current') === 'page')) hit('missing-aria-current', doc.tags[firstNav].pos, '');
}
const headClose = head >= 0 ? treeOf(doc).close[head] : -1;
const bodyFrom = bodyTag?.pos ?? (headClose >= 0 ? doc.tags[headClose].end : 0);
const shellText = visible(doc, new Set(['template', 'noscript']));
const bodyStart = shellText.at.findIndex((p) => p >= bodyFrom);
const bodyText = bodyStart < 0 ? '' : shellText.text.slice(bodyStart);
if (!/[\p{L}\p{N}]/u.test(bodyText) && doc.tags.some((t) => !t.close && t.name === 'script')) {
const { close } = treeOf(doc);
const mount = doc.tags.find((t, i) => !t.close && t.pos >= bodyFrom && !t.selfClosing && t.name !== 'script' && t.name !== 'noscript' && t.name !== 'body'
&& (getAttr(t.attrs, 'id') !== null || t.name.includes('-')) && close[i] === i + 1);
hit('empty-mount-node', mount?.pos ?? bodyFrom, mount ? `<${mount.name}${getAttr(mount.attrs, 'id') ? ` id="${getAttr(mount.attrs, 'id')}"` : ''}>` : '');
}
for (const [id, byLine] of hits) {
const title = RULES[id].title;
for (const [line, found] of byLine) {
const named = [...new Set(found.filter(Boolean))];
const evidence = named.length ? `${title}: ${named.join(', ')}` : found.length > 1 ? `${title}, ${found.length} on this line` : title;
r.report(id, doc.file, line, evidence, { source: 'copy' });
}
}
}
}