diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 855acc6..1dfd3ef 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -20,6 +20,10 @@ jobs:
run: npm test
- name: Lint
run: npm run lint
+ # Blocking: catches structural HTML errors (e.g. an unescaped " that
+ # terminates an attribute) that node --check + vitest + eslint can't see.
+ - name: HTML parse check
+ run: npm run check:html
- name: Format check (advisory)
run: npm run format:check
continue-on-error: true
diff --git a/eslint.config.js b/eslint.config.js
index 7b0b7a7..ab1cfc8 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -28,4 +28,10 @@ export default [
files: ['tests/**/*.js'],
languageOptions: { globals: { ...globals.node, ...globals.browser } },
},
+ {
+ // CLI tooling (e.g. the HTML parse gate) reports via stdout/stderr by design.
+ files: ['tools/**/*.{js,mjs}'],
+ languageOptions: { globals: { ...globals.node } },
+ rules: { 'no-console': 'off' },
+ },
];
diff --git a/library.html b/library.html
index ce71db4..87deb4e 100644
--- a/library.html
+++ b/library.html
@@ -613,7 +613,7 @@
Library
diff --git a/package.json b/package.json
index 1635850..274145d 100644
--- a/package.json
+++ b/package.json
@@ -8,6 +8,7 @@
"test:coverage": "vitest run --coverage",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
+ "check:html": "node tools/check-html.mjs",
"format": "prettier --write \"**/*.{js,json,css,html,md}\"",
"format:check": "prettier --check \"**/*.{js,json,css,html,md}\""
},
diff --git a/tools/check-html.mjs b/tools/check-html.mjs
new file mode 100644
index 0000000..3c48196
--- /dev/null
+++ b/tools/check-html.mjs
@@ -0,0 +1,41 @@
+#!/usr/bin/env node
+// HTML parse-validity gate.
+//
+// Fails ONLY on a true parse error (e.g. an unescaped quote that terminates an
+// attribute early), NOT on formatting drift — that stays advisory via the
+// `format:check` script. This closes the blind spot that let attribute-quote
+// bugs ship in options.html and library.html past node --check + vitest + eslint
+// (none of which look at HTML).
+//
+// Uses prettier's parser via its Node API: prettier.format() throws on a parse
+// error and merely reformats on style differences, so a thrown error is a real
+// structural problem, not a whitespace nit.
+
+import prettier from 'prettier';
+import { readFileSync } from 'node:fs';
+import { execSync } from 'node:child_process';
+
+const files = execSync('git ls-files "*.html"', { encoding: 'utf8' })
+ .trim()
+ .split('\n')
+ .filter(Boolean);
+
+const failures = [];
+for (const file of files) {
+ try {
+ await prettier.format(readFileSync(file, 'utf8'), { parser: 'html', filepath: file });
+ } catch (err) {
+ failures.push({ file, message: String(err.message).split('\n')[0] });
+ }
+}
+
+if (failures.length > 0) {
+ console.error(`HTML parse check FAILED — ${failures.length} file(s) with structural errors:\n`);
+ for (const { file, message } of failures) {
+ console.error(` ${file}: ${message}`);
+ }
+ console.error('\nThese are real parse errors (often an unescaped " inside an attribute). Fix the markup.');
+ process.exit(1);
+}
+
+console.log(`HTML parse check passed — ${files.length} file(s) parse cleanly.`);