Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -522,7 +522,7 @@ Dark (via prefers-color-scheme):

The *judgment* items (shape fit, headlines that argue, flatten test) are enforced by me before I touch Write — skipping them is how v1 *Magnifica Humanitas* shipped. The *mechanical* items (self-containment, favicon, social-card meta, ≥3 HTML-native features, copy-as-prompt naming + state delimiting, clipboard guard, no copy-as-markdown, no `<artifact.html>` placeholder, SVG sizing, long-form floor) are now also checkable by a tool — run it in step 7.
6. **Write to `~/Reports/<YYYY-MM-DD>-<slug>.html`**
7. **Lint the output — hard gate:** run `node <this-skill-dir>/scripts/lint-artifact.mjs <output.html>` (add `--longform` for ≥5,000-word source renders). Fix every `✗ FAIL` before reporting done; `⚠` warnings are judgment calls. Beyond the mechanical checklist, the linter now **compiles every inline `<script>`** — a single stray brace is a `SyntaxError` that kills the whole script and silently deadens every toggle/search/copy — and asserts **every `getElementById`/`querySelector('#id')` target exists** (a control wired to nothing). **Copy the `<script>` block verbatim from `examples/<shape>.html`; never hand-retype or minify it** — that is how the 2026-07-04 dead-script shipped. Loading the file and clicking each control once is still worth it: the linter catches dead scripts, a click catches dead wiring.
7. **Lint the output — hard gate:** run `node <this-skill-dir>/scripts/lint-artifact.mjs <output.html>` (add `--longform` for ≥5,000-word source renders; add `--against <prior.html>` when updating an existing file). Fix every `✗ FAIL` before reporting done; `⚠` warnings are judgment calls. Beyond the mechanical checklist, the linter now **compiles every inline `<script>`** — a single stray brace is a `SyntaxError` that kills the whole script and silently deadens every toggle/search/copy — and asserts **every `getElementById`/`querySelector('#id')` target exists** (a control wired to nothing). **Copy the `<script>` block verbatim from `examples/<shape>.html`; never hand-retype or minify it** — that is how the 2026-07-04 dead-script shipped. Loading the file and clicking each control once is still worth it: the linter catches dead scripts, a click catches dead wiring.
8. **Publish (when asked) — og:image is part of the contract.** Generate the social card, then lint in published mode (which now *requires* og:image):
```bash
node <this-skill-dir>/bin/og-card.mjs <output.html> --inject # writes <stem>.og.png, injects og:image/twitter:image, twitter:card=summary_large_image
Expand Down
72 changes: 67 additions & 5 deletions scripts/lint-artifact.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,23 +20,36 @@ import vm from "node:vm";
import { REQUIRED_META, FEATURES, EXTERNAL_RESOURCE, hasMeta, attrValue, hasAccessibleName } from "./lib/html-checks.mjs";

const argv = process.argv.slice(2);
const opts = { longform: false, reference: false, json: false, published: false, files: [] };
for (const a of argv) {
const opts = {
longform: false,
reference: false,
json: false,
published: false,
against: null,
allowDroppedIds: new Set(),
files: [],
};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--longform") opts.longform = true;
else if (a === "--reference") opts.reference = true;
else if (a === "--json") opts.json = true;
else if (a === "--published") opts.published = true;
else if (a === "--against") opts.against = argv[++i];
else if (a === "--allow-dropped-id") opts.allowDroppedIds.add(argv[++i]);
else if (a === "-h" || a === "--help") { usage(); process.exit(0); }
else opts.files.push(a);
}
if (!opts.files.length) { usage(); process.exit(2); }
if (!opts.files.length || (opts.against === undefined)) { usage(); process.exit(2); }

const LONGFORM_FLOOR = 30_000;

function usage() {
process.stderr.write(
"usage: node scripts/lint-artifact.mjs [--longform] [--reference] [--published] [--json] <file.html> [more.html ...]\n" +
" --reference single-primitive reference pages: skip the >=3 HTML-native feature floor (all other checks still run)\n",
"usage: node scripts/lint-artifact.mjs [--longform] [--reference] [--published] [--json] [--against prior.html] [--allow-dropped-id id] <file.html> [more.html ...]\n" +
" --reference single-primitive reference pages: skip the >=3 HTML-native feature floor (all other checks still run)\n" +
" --against prior.html fail the update if it dropped ids, copy-as-prompt, or every inline script\n" +
" --allow-dropped-id permit one dropped id when the rename is the point of the update; repeat as needed\n",
);
}

Expand Down Expand Up @@ -151,7 +164,56 @@ function lintOne(file) {
return { file, ok: fails.length === 0, fails, warns, features, bytes };
}

function collectIds(html) {
return new Set([...html.matchAll(/\bid=["']([^"']+)["']/g)].map((m) => m[1]));
}

function countInlineScripts(html) {
return [...html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)]
.filter((m) => !/\bsrc=/i.test(m[1]) && !/type\s*=\s*["']application\/(json|ld\+json)["']/i.test(m[1]))
.length;
}

function hasCopyPrompt(html) {
return /copy as prompt|copyPrompt|prompt-output/i.test(html);
}

function againstFails(priorHtml, nextHtml) {
const fails = [];
const dropped = [...collectIds(priorHtml)].filter(
(id) => !collectIds(nextHtml).has(id) && !opts.allowDroppedIds.has(id),
);
if (dropped.length) fails.push(`dropped ids: ${dropped.join(", ")}`);
if (countInlineScripts(priorHtml) > 0 && countInlineScripts(nextHtml) === 0) {
fails.push("update deleted every inline script");
}
if (hasCopyPrompt(priorHtml) && !hasCopyPrompt(nextHtml)) {
fails.push("update dropped copy-as-prompt");
}
return fails;
}

const results = opts.files.map(lintOne);
if (opts.against) {
let priorHtml = "";
try {
priorHtml = fs.readFileSync(opts.against, "utf8");
} catch (e) {
console.error(`cannot read --against file: ${e.message}`);
process.exit(2);
}
for (const r of results) {
let nextHtml = "";
try {
nextHtml = fs.readFileSync(r.file, "utf8");
} catch {
continue;
}
const extra = againstFails(priorHtml, nextHtml);
r.fails.push(...extra);
if (extra.length) r.ok = false;
}
}

if (opts.json) {
console.log(JSON.stringify({ results, ok: results.every((r) => r.ok) }, null, 2));
Expand Down
43 changes: 43 additions & 0 deletions tests/test_linters.py
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,49 @@ def normalize(pattern):

# --- the gallery pages are artifacts, not static lists -------------------------

def test_lint_artifact_against_same_file_passes():
r = _run(
"scripts/lint-artifact.mjs",
"--against",
"examples/dashboard.html",
"examples/dashboard.html",
)
assert r.returncode == 0, r.stdout + r.stderr


def test_lint_artifact_against_dropped_ids_fails(tmp_path):
src = (REPO / "examples" / "dashboard.html").read_text()
bad = tmp_path / "dropped-id.html"
bad.write_text(src.replace('id="donut-svg"', 'id="donut-moved"'))
r = _run("scripts/lint-artifact.mjs", "--against", "examples/dashboard.html", str(bad))
assert r.returncode == 1
assert "donut-svg" in r.stdout


def test_lint_artifact_against_dropped_copy_prompt_fails(tmp_path):
src = (REPO / "examples" / "dashboard.html").read_text()
stripped = src
for needle in ("copy as prompt", "copyAsPrompt", "copyPrompt", "prompt-output"):
stripped = stripped.replace(needle, "copy-plain")
stripped = stripped.replace(needle.replace(" ", "-"), "copy-plain")
bad = tmp_path / "dropped-copy.html"
bad.write_text(stripped)
r = _run("scripts/lint-artifact.mjs", "--against", "examples/dashboard.html", str(bad))
assert r.returncode == 1
assert "copy-as-prompt" in r.stdout


def test_lint_artifact_against_dropped_script_fails(tmp_path):
src = (REPO / "examples" / "dashboard.html").read_text()
bad = tmp_path / "dropped-script.html"
bad.write_text(
__import__("re").sub(r"<script\b[^>]*>[\s\S]*?</script>", "", src, flags=__import__("re").I)
)
r = _run("scripts/lint-artifact.mjs", "--against", "examples/dashboard.html", str(bad))
assert r.returncode == 1
assert "script" in r.stdout.lower()


def test_gallery_pages_pass_the_full_artifact_gate():
"""examples/index.html and examples/primitives.html used to clear the >=3
feature floor on phantom prose matches ("search"/"filter"/"toggle" as WORDS)
Expand Down
Loading