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
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,8 @@ Bloom/
├── examples/ # Fully worked artifacts
│ ├── pr-review-example.html
│ ├── incident-report-example.html
│ └── design-system-example.html
│ ├── design-system-example.html
│ └── feature-flag-editor-example.html
└── .github/
└── workflows/
└── ci.yml # Validator tests + artifact validation
Expand Down Expand Up @@ -368,7 +369,7 @@ cd bloom-validator && npm run validate-all

Exit codes: `0` clean, `1` errors found, `2` invalid usage.

The validator currently enforces 10 rules:
The validator currently enforces 13 rules:

- `no-external-deps`
- `no-hardcoded-hex`
Expand All @@ -379,6 +380,9 @@ The validator currently enforces 10 rules:
- `no-dialog-apis`
- `lang-attribute`
- `focus-visible`
- `responsive-images`
- `no-empty-elements`
- `no-inline-styles-except-root`
- `security-hardening` (bundles `no-eval`, `innerHTML-with-variable`, `no-network`, `no-inline-handlers`, `no-data-html-uri`, `no-javascript-uri`, and two more — see [`bloom-validator/README.md`](bloom-validator/README.md))

Every template under `templates/` and every artifact under `examples/` passes this validator on every push (see [`.github/workflows/ci.yml`](.github/workflows/ci.yml)).
Expand Down
10 changes: 5 additions & 5 deletions bloom-validator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ 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).
The validator currently enforces 13 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 |
|---|---|---|---|
Expand All @@ -31,6 +31,9 @@ The validator currently enforces 10 rules. Each rule is a single file under [`sr
| `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 |
| `rule-15` | `responsive-images` | warning | Warns when `<img>` elements exist but no responsive `img { max-width: 100%; ... }` style is defined |
| `rule-16` | `no-empty-elements` | warning | Warns on empty `<div>`, `<span>`, and `<p>` elements unless marked with `data-template` or `aria-hidden` |
| `rule-17` | `no-inline-styles-except-root` | error | No inline `style=` attributes; move styles into the document stylesheet and use tokens |
| `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`:
Expand All @@ -46,11 +49,8 @@ The `security-hardening` rule emits issues under these `ruleName`s, all classifi

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`.

Expand Down Expand Up @@ -83,7 +83,7 @@ When multiple files are passed, the top level is an array of these objects.
npm test
```

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.
13 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.

## Validating every template, skeleton, and example

Expand Down
2 changes: 1 addition & 1 deletion bloom-validator/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "bloom-validator",
"version": "1.0.0",
"description": "Validates .html files against Bloom's 12 construction rules and 8 security rules",
"description": "Validates self-contained Bloom .html artifacts against active construction and security rules",
"type": "module",
"bin": {
"bloom-validate": "./src/index.ts"
Expand Down
6 changes: 6 additions & 0 deletions bloom-validator/src/rule-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ 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 { noEmptyElements } from "./rules/no-empty-elements.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 { noInlineStylesExceptRoot } from "./rules/no-inline-styles-except-root.ts";
import { printMediaQuery } from "./rules/print-media-query.ts";
import { responsiveImages } from "./rules/responsive-images.ts";
import { securityHardening } from "./rules/security-hardening.ts";
import { semanticHtml } from "./rules/semantic-html.ts";
import { viewportMeta } from "./rules/viewport-meta.ts";
Expand All @@ -20,5 +23,8 @@ export const ALL_RULES: Rule[] = [
noDialogApis,
langAttribute,
focusVisible,
responsiveImages,
noEmptyElements,
noInlineStylesExceptRoot,
securityHardening,
];
31 changes: 31 additions & 0 deletions bloom-validator/src/rules/no-empty-elements.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { Issue, Rule } from "../types.ts";
import { offsetToLine, snippet } from "../parser.ts";

const EMPTY_ELEMENT_RE = /<(div|span|p)\b([^>]*)>\s*<\/\1>/gi;

export const noEmptyElements: Rule = {
id: "rule-16",
name: "no-empty-elements",
check(ctx): Issue[] {
const issues: Issue[] = [];
let m: RegExpExecArray | null;
EMPTY_ELEMENT_RE.lastIndex = 0;

while ((m = EMPTY_ELEMENT_RE.exec(ctx.source)) !== null) {
const attrs = m[2] ?? "";
if (/\b(?:aria-hidden|data-template|class|role)\b/i.test(attrs)) continue;

const line = offsetToLine(ctx.source, m.index);
issues.push({
rule: "rule-16",
ruleName: "no-empty-elements",
severity: "warning",
line,
message: `Empty <${m[1]}> element found — remove it or mark intentional generated content with data-template (Rule 16)`,
snippet: snippet(ctx.lines, line),
});
}

return issues;
},
};
29 changes: 29 additions & 0 deletions bloom-validator/src/rules/no-inline-styles-except-root.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { Issue, Rule } from "../types.ts";
import { offsetToLine, snippet } from "../parser.ts";

const STYLE_ATTR_RE = /<[^>]+\sstyle\s*=\s*("([^"]*)"|'([^']*)'|[^\s>]+)[^>]*>/gi;

export const noInlineStylesExceptRoot: Rule = {
id: "rule-17",
name: "no-inline-styles-except-root",
check(ctx): Issue[] {
const issues: Issue[] = [];
let m: RegExpExecArray | null;
STYLE_ATTR_RE.lastIndex = 0;

while ((m = STYLE_ATTR_RE.exec(ctx.source)) !== null) {
const line = offsetToLine(ctx.source, m.index);
issues.push({
rule: "rule-17",
ruleName: "no-inline-styles-except-root",
severity: "error",
line,
message:
"Inline `style=` attributes are forbidden — move styles into the document stylesheet and use tokens (Rule 17)",
snippet: snippet(ctx.lines, line),
});
}

return issues;
},
};
34 changes: 34 additions & 0 deletions bloom-validator/src/rules/responsive-images.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { Issue, Rule } from "../types.ts";
import { offsetToLine, snippet } from "../parser.ts";

const IMG_RE = /<img\b[^>]*>/gi;
const RESPONSIVE_IMG_CSS_RE =
/(?:^|})\s*(?:img|picture\s+img|figure\s+img|\.responsive-img)\b[^{]*\{[^}]*\bmax-width\s*:\s*100%/i;

export const responsiveImages: Rule = {
id: "rule-15",
name: "responsive-images",
check(ctx): Issue[] {
const images = [...ctx.source.matchAll(IMG_RE)];
if (images.length === 0) return [];

const hasResponsiveRule = ctx.styleBlocks.some((block) =>
RESPONSIVE_IMG_CSS_RE.test(block.content),
);
if (hasResponsiveRule) return [];

const first = images[0]!;
const line = offsetToLine(ctx.source, first.index ?? 0);
return [
{
rule: "rule-15",
ruleName: "responsive-images",
severity: "warning",
line,
message:
"Images are present but no responsive `img { max-width: 100%; ... }` style was found (Rule 15)",
snippet: snippet(ctx.lines, line),
},
];
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Warns — image and empty elements</title>
<style>
:root { --slate: #141413; --ivory: #FAF9F5; }
body { background: var(--ivory); color: var(--slate); padding: 2rem; }
:focus-visible { outline: 2px solid var(--slate); }
@media print { body { padding: 0; } }
</style>
</head>
<body>
<header><h1>Missing responsive image styles</h1></header>
<main>
<section>
<img src="diagram.png" alt="Dependency graph">
<p></p>
<div style="color: var(--slate)">Inline style should move to the stylesheet.</div>
</section>
</main>
</body>
</html>
17 changes: 17 additions & 0 deletions bloom-validator/tests/validator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,4 +174,21 @@ describe("bloom-validator", () => {
assert.equal(issue.severity, "warning");
}
});

it("flags unresponsive images, empty elements, and inline styles", () => {
const { path, source } = load("invalid-responsive-empty-inline-style.html");
const report = validate(path, source);
assert.equal(report.passed, false);

const rules = new Set(report.issues.map((i) => i.ruleName));
assert.ok(rules.has("responsive-images"), `missing responsive-images in ${[...rules].join(", ")}`);
assert.ok(rules.has("no-empty-elements"), `missing no-empty-elements in ${[...rules].join(", ")}`);
assert.ok(
rules.has("no-inline-styles-except-root"),
`missing no-inline-styles-except-root in ${[...rules].join(", ")}`,
);

const inlineStyle = report.issues.find((i) => i.ruleName === "no-inline-styles-except-root");
assert.equal(inlineStyle?.severity, "error");
});
});
30 changes: 20 additions & 10 deletions examples/design-system-example.html
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,16 @@
border: 1px solid rgba(20,20,19,0.08);
margin-bottom: 12px;
}
.chip.ivory { background: var(--ivory); }
.chip.slate { background: var(--slate); }
.chip.clay { background: var(--clay); }
.chip.oat { background: var(--oat); }
.chip.olive { background: var(--olive); }
.chip.rust { background: var(--rust); }
.chip.gray-100 { background: var(--gray-100); }
.chip.gray-300 { background: var(--gray-300); }
.chip.gray-500 { background: var(--gray-500); }
.chip.gray-700 { background: var(--gray-700); }
.swatch .name { font-weight: 600; color: var(--slate); font-size: 14px; }
.swatch .var, .swatch .hex {
font-family: var(--mono);
Expand Down Expand Up @@ -213,16 +223,16 @@ <h1>Tokens, type, and components — all in one file</h1>
<h2>Colors</h2>
<p class="sec-note">Tap a swatch's <code>copy</code> button to copy the <code>var(--token)</code> reference to your clipboard.</p>
<div class="swatches">
<div class="swatch"><div class="chip" style="background:var(--ivory)"></div><div class="name">Ivory</div><span class="var">var(--ivory)</span><span class="hex">#FAF9F5</span><button class="copy" data-token="var(--ivory)">copy var</button></div>
<div class="swatch"><div class="chip" style="background:var(--slate)"></div><div class="name">Slate</div><span class="var">var(--slate)</span><span class="hex">#141413</span><button class="copy" data-token="var(--slate)">copy var</button></div>
<div class="swatch"><div class="chip" style="background:var(--clay)"></div><div class="name">Clay</div><span class="var">var(--clay)</span><span class="hex">#D97757</span><button class="copy" data-token="var(--clay)">copy var</button></div>
<div class="swatch"><div class="chip" style="background:var(--oat)"></div><div class="name">Oat</div><span class="var">var(--oat)</span><span class="hex">#E3DACC</span><button class="copy" data-token="var(--oat)">copy var</button></div>
<div class="swatch"><div class="chip" style="background:var(--olive)"></div><div class="name">Olive</div><span class="var">var(--olive)</span><span class="hex">#788C5D</span><button class="copy" data-token="var(--olive)">copy var</button></div>
<div class="swatch"><div class="chip" style="background:var(--rust)"></div><div class="name">Rust</div><span class="var">var(--rust)</span><span class="hex">#B04A3F</span><button class="copy" data-token="var(--rust)">copy var</button></div>
<div class="swatch"><div class="chip" style="background:var(--gray-100)"></div><div class="name">Gray 100</div><span class="var">var(--gray-100)</span><span class="hex">#F0EEE6</span><button class="copy" data-token="var(--gray-100)">copy var</button></div>
<div class="swatch"><div class="chip" style="background:var(--gray-300)"></div><div class="name">Gray 300</div><span class="var">var(--gray-300)</span><span class="hex">#D1CFC5</span><button class="copy" data-token="var(--gray-300)">copy var</button></div>
<div class="swatch"><div class="chip" style="background:var(--gray-500)"></div><div class="name">Gray 500</div><span class="var">var(--gray-500)</span><span class="hex">#87867F</span><button class="copy" data-token="var(--gray-500)">copy var</button></div>
<div class="swatch"><div class="chip" style="background:var(--gray-700)"></div><div class="name">Gray 700</div><span class="var">var(--gray-700)</span><span class="hex">#3D3D3A</span><button class="copy" data-token="var(--gray-700)">copy var</button></div>
<div class="swatch"><div class="chip ivory"></div><div class="name">Ivory</div><span class="var">var(--ivory)</span><span class="hex">#FAF9F5</span><button class="copy" data-token="var(--ivory)">copy var</button></div>
<div class="swatch"><div class="chip slate"></div><div class="name">Slate</div><span class="var">var(--slate)</span><span class="hex">#141413</span><button class="copy" data-token="var(--slate)">copy var</button></div>
<div class="swatch"><div class="chip clay"></div><div class="name">Clay</div><span class="var">var(--clay)</span><span class="hex">#D97757</span><button class="copy" data-token="var(--clay)">copy var</button></div>
<div class="swatch"><div class="chip oat"></div><div class="name">Oat</div><span class="var">var(--oat)</span><span class="hex">#E3DACC</span><button class="copy" data-token="var(--oat)">copy var</button></div>
<div class="swatch"><div class="chip olive"></div><div class="name">Olive</div><span class="var">var(--olive)</span><span class="hex">#788C5D</span><button class="copy" data-token="var(--olive)">copy var</button></div>
<div class="swatch"><div class="chip rust"></div><div class="name">Rust</div><span class="var">var(--rust)</span><span class="hex">#B04A3F</span><button class="copy" data-token="var(--rust)">copy var</button></div>
<div class="swatch"><div class="chip gray-100"></div><div class="name">Gray 100</div><span class="var">var(--gray-100)</span><span class="hex">#F0EEE6</span><button class="copy" data-token="var(--gray-100)">copy var</button></div>
<div class="swatch"><div class="chip gray-300"></div><div class="name">Gray 300</div><span class="var">var(--gray-300)</span><span class="hex">#D1CFC5</span><button class="copy" data-token="var(--gray-300)">copy var</button></div>
<div class="swatch"><div class="chip gray-500"></div><div class="name">Gray 500</div><span class="var">var(--gray-500)</span><span class="hex">#87867F</span><button class="copy" data-token="var(--gray-500)">copy var</button></div>
<div class="swatch"><div class="chip gray-700"></div><div class="name">Gray 700</div><span class="var">var(--gray-700)</span><span class="hex">#3D3D3A</span><button class="copy" data-token="var(--gray-700)">copy var</button></div>
</div>
</section>

Expand Down
Loading
Loading