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
25 changes: 25 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: CI

on:
push:
branches: [main, master]
pull_request:

jobs:
validator:
name: bloom-validator tests + artifact validation
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: "22"

- name: Run validator unit tests
working-directory: bloom-validator
run: npm test

- name: Validate every template, skeleton, and example
working-directory: bloom-validator
run: npm run validate-all
256 changes: 152 additions & 104 deletions README.md

Large diffs are not rendered by default.

58 changes: 41 additions & 17 deletions bloom-validator/README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
# bloom-validator

A zero-dependency TypeScript CLI that validates `.html` files against Bloom's 12 construction rules and 8 security rules.
A zero-dependency TypeScript CLI that validates `.html` files against Bloom's construction and security rules. Runs on Node ≥ 22.6 using native TypeScript type-stripping — no build step, no dependencies.

## Quick start

Requires Node ≥ 22.6 (uses native TypeScript type-stripping — no build step).

```bash
node src/index.ts path/to/file.html
node src/index.ts path/to/file.html --json
Expand All @@ -20,19 +18,41 @@ Exit codes:

## Rules implemented

The validator currently enforces 10 rules. Each rule is a single file under [`src/rules/`](src/rules/) and is registered in [`src/rule-registry.ts`](src/rule-registry.ts).

| ID | Name | Severity | Description |
|---|---|---|---|
| `rule-1` | `no-external-deps` | error | No `<script src>`, `<link rel="stylesheet">`, `@import`, ES module `import`/`export` |
| `rule-1` | `no-external-deps` | error | No `<script src>`, `<link rel="stylesheet">`, `@import`, or ES module `import`/`export` |
| `rule-2` | `no-hardcoded-hex` | error | No `#RRGGBB`/`#RGB`/`#RRGGBBAA` outside `:root` blocks |
| `rule-3` | `semantic-html` | error+warn | Requires `<header>` and `<main>` in `<body>`; warns when no `<section>`/`<article>`/`<nav>`/`<aside>` are used |
| `rule-8` | `heading-hierarchy` | error | Exactly one `<h1>`; no skipped levels |
| `rule-7` | `print-media-query` | warning | Warns when no `@media print` block is defined in any `<style>` |
| `rule-8` | `heading-hierarchy` | error | Exactly one `<h1>`; no skipped levels (h1 → h3) |
| `rule-10` | `viewport-meta` | error | `<meta name="viewport">` required in `<head>` |
| `S2` | `no-eval`, `no-function-ctor`, `no-string-timer` | error | No `eval()`, `new Function()`, string-based timers |
| `S3` | `innerHTML-with-variable` | error | `.innerHTML` may only be assigned a string literal |
| `S4` | `no-network` | error | No `fetch`, `XMLHttpRequest`, `WebSocket`, `EventSource` |
| `S5` | `no-inline-handlers` | error | No `on*=` attributes — use `addEventListener` |
| `S6` | `no-data-html-uri` | error | No `data:text/html` or `data:text/javascript` URIs |
| `S8` | `no-javascript-uri` | error | No `javascript:` in `href`, `src`, `action` |
| `rule-12` | `no-dialog-apis` | error | No `alert()`, `prompt()`, or `confirm()` calls in inline `<script>` |
| `rule-13` | `lang-attribute` | error | `<html>` must have a non-empty `lang` attribute |
| `rule-14` | `focus-visible` | warning | Warns when interactive elements exist but no `:focus`/`:focus-visible` style is defined |
| `security` | `security-hardening` | error | Bundle of S2/S3/S4/S5/S6/S8 checks — see below |

The `security-hardening` rule emits issues under these `ruleName`s, all classified under rule id `security`:

- `no-eval`, `no-function-ctor`, `no-string-timer` (S2) — `eval()`, `new Function()`, `setTimeout("...")`, `setInterval("...")`
- `innerHTML-with-variable` (S3) — `.innerHTML = ` from anything but a plain string/template literal
- `no-network` (S4) — `fetch`, `XMLHttpRequest`, `WebSocket`, `EventSource`
- `no-inline-handlers` (S5) — `onclick=`, `onload=`, any inline `on*=` attribute
- `no-data-html-uri` (S6) — `href="data:text/html"` or `data:text/javascript`
- `no-javascript-uri` (S8) — `javascript:` in `href`, `src`, `action`, `formaction`

### Planned (not yet enforced)

The following are part of Bloom's published construction rules but are not yet implemented in the validator. They are tracked roadmap items, not silent stubs:

- `responsive-images` — `<img>` should declare `max-width`
- `aria-landmarks` — landmark role coverage
- `contrast-minimum` — token-pair contrast heuristic
- `no-empty-elements` — empty `<div>`/`<span>`/`<p>`
- `no-inline-styles-except-root` — `style=` attribute outside `<style>` blocks

If you'd like to implement one, drop a file under `src/rules/`, export a `Rule`, and add it to `src/rule-registry.ts`.

## JSON output shape

Expand Down Expand Up @@ -60,15 +80,19 @@ When multiple files are passed, the top level is an array of these objects.
## Running tests

```bash
node --test tests/validator.test.ts
npm test
```

The fixtures under `tests/fixtures/` cover one passing file and one targeted failure per rule family.
12 tests under [`tests/`](tests/) cover one passing file (`valid.html`), one false-positive guard (`valid-with-urls.html`), and one targeted failure fixture per rule family.

## Notes on the inline template `<script>`
## Validating every template, skeleton, and example

```bash
npm run validate-all
```

Bloom templates themselves must include inline JavaScript (not TypeScript) because Rule 1 forbids any build step — the file has to run from `file://`. This validator is the only place in the Bloom repo where TypeScript runs at all, and it runs in Node, not the browser.
This runs [`scripts/validate-all.mjs`](scripts/validate-all.mjs), which resolves the artifact list in Node (not via shell globbing) and shells out to `bloom-validate` once with every file. It works identically on Bash, Zsh, PowerShell, and CMD — useful for Windows contributors, and the single canonical command CI uses too.

## Notes on the inline template `<script>`

### Rule 12: no-dialog-apis
Flags `alert()`, `prompt()`, and `confirm()` calls in `<script>` blocks. These blocking dialog APIs violate progressive enhancement — use inline UI instead.
Bloom templates themselves use inline JavaScript (not TypeScript) because Rule 1 forbids any build step — every artifact has to run from `file://`. This validator is the only place in the Bloom repo where TypeScript runs at all, and it runs in Node, not the browser.
3 changes: 2 additions & 1 deletion bloom-validator/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"scripts": {
"start": "node src/index.ts",
"test": "node --test tests/validator.test.ts",
"test:fixtures": "node src/index.ts tests/fixtures/valid.html"
"test:fixtures": "node src/index.ts tests/fixtures/valid.html",
"validate-all": "node scripts/validate-all.mjs"
},
"engines": {
"node": ">=22.6.0"
Expand Down
49 changes: 49 additions & 0 deletions bloom-validator/scripts/validate-all.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#!/usr/bin/env node
// Resolve every Bloom artifact in the repo and run bloom-validate over them.
// Globs are expanded in Node so this works identically on Bash, Zsh, PowerShell,
// and CMD without relying on shell glob expansion.

import { readdirSync, statSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";

const HERE = dirname(fileURLToPath(import.meta.url));
const VALIDATOR_DIR = resolve(HERE, "..");
const REPO_ROOT = resolve(VALIDATOR_DIR, "..");

const GROUPS = ["templates", "templates/skeletons", "examples"];

function htmlFilesIn(relDir) {
const abs = join(REPO_ROOT, relDir);
let entries;
try {
entries = readdirSync(abs);
} catch (err) {
if (err.code === "ENOENT") {
console.error(`validate-all: directory not found: ${relDir}`);
process.exit(2);
}
throw err;
}
return entries
.filter((name) => name.endsWith(".html"))
.map((name) => join(abs, name))
.filter((p) => statSync(p).isFile())
.sort();
}

const files = GROUPS.flatMap(htmlFilesIn);

if (files.length === 0) {
console.error("validate-all: no .html files found to validate");
process.exit(2);
}

const result = spawnSync(
process.execPath,
[join(VALIDATOR_DIR, "src", "index.ts"), ...files],
{ stdio: "inherit", cwd: VALIDATOR_DIR },
);

process.exit(result.status ?? 1);
20 changes: 3 additions & 17 deletions bloom-validator/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,10 @@ import { readFileSync, statSync } from "node:fs";
import { resolve } from "node:path";
import { buildContext } from "./parser.ts";
import { renderJson, renderText } from "./reporter.ts";
import { headingHierarchy } from "./rules/heading-hierarchy.ts";
import { noDialogApis } from "./rules/no-dialog-apis.ts";
import { noExternalDeps } from "./rules/no-external-deps.ts";
import { noHardcodedHex } from "./rules/no-hardcoded-hex.ts";
import { securityHardening } from "./rules/security-hardening.ts";
import { semanticHtml } from "./rules/semantic-html.ts";
import { viewportMeta } from "./rules/viewport-meta.ts";
import type { Issue, Rule, ValidationReport } from "./types.ts";
import { ALL_RULES } from "./rule-registry.ts";
import type { Issue, ValidationReport } from "./types.ts";

const ALL_RULES: Rule[] = [
noExternalDeps,
noHardcodedHex,
semanticHtml,
headingHierarchy,
viewportMeta,
securityHardening,
noDialogApis,
];
export { ALL_RULES };

export function validate(filePath: string, source: string): ValidationReport {
const ctx = buildContext(filePath, source);
Expand Down
24 changes: 24 additions & 0 deletions bloom-validator/src/rule-registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { Rule } from "./types.ts";
import { focusVisible } from "./rules/focus-visible.ts";
import { headingHierarchy } from "./rules/heading-hierarchy.ts";
import { langAttribute } from "./rules/lang-attribute.ts";
import { noDialogApis } from "./rules/no-dialog-apis.ts";
import { noExternalDeps } from "./rules/no-external-deps.ts";
import { noHardcodedHex } from "./rules/no-hardcoded-hex.ts";
import { printMediaQuery } from "./rules/print-media-query.ts";
import { securityHardening } from "./rules/security-hardening.ts";
import { semanticHtml } from "./rules/semantic-html.ts";
import { viewportMeta } from "./rules/viewport-meta.ts";

export const ALL_RULES: Rule[] = [
noExternalDeps,
noHardcodedHex,
semanticHtml,
printMediaQuery,
headingHierarchy,
viewportMeta,
noDialogApis,
langAttribute,
focusVisible,
securityHardening,
];
11 changes: 0 additions & 11 deletions bloom-validator/src/rules/aria-landmarks.ts

This file was deleted.

11 changes: 0 additions & 11 deletions bloom-validator/src/rules/contrast-minimum.ts

This file was deleted.

58 changes: 54 additions & 4 deletions bloom-validator/src/rules/focus-visible.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,61 @@
import type { Issue, Rule } from "../types.ts";
import { lineFromOffset } from "../parser.ts";

export const focusvisible: Rule = {
const INTERACTIVE_RE = /<(?:button|a|input|select|textarea|details|summary)\b/i;
const FOCUS_SELECTOR_RE = /:focus(?:-visible|-within)?\b/;
const OUTLINE_NONE_RE = /\boutline\s*:\s*(?:none|0(?:px)?|hidden)\b(?![^{}]*:focus)/i;

export const focusVisible: Rule = {
id: "rule-14",
name: "focus-visible",
description: "Ensures interactive elements have visible focus indicators.",
check(ctx) {
check(ctx): Issue[] {
const body = ctx.bodyHtml;
const hasInteractive = INTERACTIVE_RE.test(body);
if (!hasInteractive) return [];

let hasFocusStyle = false;
let outlineNoneOffender: { line: number; block: number } | null = null;

for (const block of ctx.styleBlocks) {
if (FOCUS_SELECTOR_RE.test(block.content)) {
hasFocusStyle = true;
}
// Flag broad `outline: none` that isn't scoped to a :focus selector.
const rules = block.content.split("}");
let offsetInBlock = 0;
for (const rule of rules) {
if (/outline\s*:\s*(?:none|0(?:px)?|hidden)\b/i.test(rule) && !/:focus/i.test(rule)) {
if (!outlineNoneOffender) {
outlineNoneOffender = {
line: block.startLine,
block: offsetInBlock,
};
}
}
offsetInBlock += rule.length + 1;
}
}

const issues: Issue[] = [];
if (!hasFocusStyle) {
issues.push({
rule: "rule-14",
ruleName: "focus-visible",
severity: "warning",
line: 1,
message:
"No :focus or :focus-visible style found — interactive elements should have a visible focus indicator (Rule 14)",
});
}
if (outlineNoneOffender && !hasFocusStyle) {
issues.push({
rule: "rule-14",
ruleName: "focus-visible",
severity: "warning",
line: outlineNoneOffender.line,
message:
"`outline: none` removes the default focus ring without providing a :focus-visible replacement (Rule 14)",
});
}
return issues;
},
};
54 changes: 48 additions & 6 deletions bloom-validator/src/rules/lang-attribute.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,53 @@
import type { Issue, Rule } from "../types.ts";
import { lineFromOffset } from "../parser.ts";
import { offsetToLine, snippet } from "../parser.ts";

export const langattribute: Rule = {
const HTML_OPEN_RE = /<html\b([^>]*)>/i;
const LANG_ATTR_RE = /\blang\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/i;

export const langAttribute: Rule = {
id: "rule-13",
name: "lang-attribute",
description: "Ensures <html> has a valid lang attribute.",
check(ctx) {
const issues: Issue[] = [];
return issues;
check(ctx): Issue[] {
const match = HTML_OPEN_RE.exec(ctx.source);
if (!match) {
return [
{
rule: "rule-13",
ruleName: "lang-attribute",
severity: "error",
line: 1,
message: "No <html> element found (Rule 13)",
},
];
}
const attrs = match[1] ?? "";
const langMatch = LANG_ATTR_RE.exec(attrs);
const line = offsetToLine(ctx.source, match.index);
if (!langMatch) {
return [
{
rule: "rule-13",
ruleName: "lang-attribute",
severity: "error",
line,
message: '<html> is missing a lang attribute — add lang="en" or your document language (Rule 13)',
snippet: snippet(ctx.lines, line),
},
];
}
const value = (langMatch[2] ?? langMatch[3] ?? langMatch[4] ?? "").trim();
if (value === "") {
return [
{
rule: "rule-13",
ruleName: "lang-attribute",
severity: "error",
line,
message: '<html lang=""> is empty — set a valid BCP-47 language code like "en" (Rule 13)',
snippet: snippet(ctx.lines, line),
},
];
}
return [];
},
};
Loading
Loading