@casomoltd/tooling 0.27.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Re-render a spec's mermaid figures to static inline SVG.
4
+ *
5
+ * The spec embeds rendered SVG rather than a mermaid runtime, because the
6
+ * artifact viewer ships its own runtime that claims every `pre.mermaid` and
7
+ * renames the svg it produces. Mermaid scopes a diagram's fills to the svg's
8
+ * own id, so a rename leaves those rules matching nothing and every shape
9
+ * falls back to the default fill, which is black. Embedding removes both the
10
+ * race and the id dependency.
11
+ *
12
+ * Each figure keeps its source in an HTML comment directly above it. This
13
+ * reads those back, renders them, freezes the computed paint inline, and
14
+ * writes the file in place.
15
+ *
16
+ * node bin/render-figures.mjs <spec.html>
17
+ */
18
+ import { readFileSync, writeFileSync } from 'node:fs';
19
+ import { createRequire } from 'node:module';
20
+ import { resolve } from 'node:path';
21
+ import { FIGURE_WIDTH_BUDGET } from './utils.mjs';
22
+
23
+ /** Playwright is a heavy prerequisite this package deliberately does not
24
+ * declare, because a declared optional peer resolves into every consumer's
25
+ * lockfile. So it is resolved from the calling project rather than from
26
+ * here, and RENDER_FIGURES_RESOLVE_FROM names that project when the working
27
+ * directory is not it. */
28
+ const from = resolve(process.env.RENDER_FIGURES_RESOLVE_FROM || process.cwd());
29
+ let chromium;
30
+ try {
31
+ ({ chromium } = createRequire(`${from}/`)('playwright'));
32
+ } catch {
33
+ throw new Error('playwright is not installed — add it as a devDependency'
34
+ + ' (npm i -D playwright && npx playwright install chromium) in the project'
35
+ + ` you are rendering from, or point RENDER_FIGURES_RESOLVE_FROM at one`
36
+ + ` that has it. Looked in ${from}.`);
37
+ }
38
+
39
+ /** The marker above each figure, as one producer: it is both searched for
40
+ * and written back, so the two must be the same string. */
41
+ const MARKER = ' <!-- figure source, regenerate with bin/render-figures.mjs:';
42
+ const SOURCE = new RegExp(`${MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`
43
+ + '\n([\\s\\S]*?)\n -->\n <div class="figure-card">[\\s\\S]*?<\\/div>\n', 'g');
44
+
45
+ /** Written even when they compute to `none`: an arrowhead that silently gains
46
+ * a fill is the same defect as a box that silently loses one. */
47
+ const PAINT = ['fill', 'stroke', 'stroke-width', 'stroke-dasharray', 'color'];
48
+
49
+ const file = process.argv[2];
50
+ if (!file) throw new Error('usage: render-figures.mjs <spec.html>');
51
+
52
+ const html = readFileSync(file, 'utf8');
53
+ // An HTML comment ends at the first `-->`, so a mermaid arrow written whole
54
+ // would close the comment and truncate the source. The spec writes `- ->`
55
+ // and this puts it back.
56
+ const sources = [...html.matchAll(SOURCE)].map((m) => m[1].replaceAll('- -', '--'));
57
+ // Counted against the figures rather than against zero: one malformed marker
58
+ // leaves that figure holding a stale SVG while the run still reports success.
59
+ const figures = [...html.matchAll(/<div\b[^>]*\bid="fig-\d+"/g)].length;
60
+ if (sources.length !== figures) {
61
+ throw new Error(`${figures} figure(s) in ${file} but ${sources.length} source`
62
+ + ' comment(s); a figure marker is malformed or missing');
63
+ }
64
+
65
+ /** A multi-line node label is what overflows a box: mermaid sizes a
66
+ * foreignObject from the first line and the rest spills below it. The rubric
67
+ * already says keep labels short and put the detail in the legend (M10); this
68
+ * is that rule where it can actually be measured. */
69
+ for (const [i, src] of sources.entries()) {
70
+ const multi = [...src.matchAll(/\[".*?<br\s*\/?>.*?"\]/g)].length;
71
+ if (multi) {
72
+ console.warn(`figure ${i + 1}: ${multi} node label(s) span more than one`
73
+ + ' line and will overflow their box — shorten them, per diagram-rubric M10');
74
+ }
75
+ }
76
+
77
+ const browser = await chromium.launch();
78
+ const page = await browser.newPage({ viewport: { width: 1400, height: 1200 } });
79
+ // The font must be LOADED, not just named: mermaid measures every label to
80
+ // size its box, so rendering against a fallback and publishing against IBM
81
+ // Plex Sans puts wider text into boxes cut for narrower text. Every figure
82
+ // overflows slightly, and it looks like a mermaid bug rather than a missing
83
+ // stylesheet.
84
+ const FONT = 'https://fonts.googleapis.com/css2?family=IBM+Plex+Sans'
85
+ + ':ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap';
86
+ const MERMAID = 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js';
87
+ const STACK = '"IBM Plex Sans", ui-sans-serif, system-ui, sans-serif';
88
+ await page.setContent(`<!doctype html><html><head>
89
+ <link rel="stylesheet" href="${FONT}">
90
+ <style>body { font-family: ${STACK}; }</style>
91
+ <script src="${MERMAID}"><\/script>
92
+ </head><body>${sources.map((s) => {
93
+ const escaped = s.replaceAll('&', '&amp;').replaceAll('<', '&lt;');
94
+ return `<pre class="m">${escaped}</pre>`;
95
+ }).join('')}</body></html>`);
96
+ await page.waitForFunction(() => typeof window.mermaid !== 'undefined');
97
+ await page.evaluate(() => document.fonts.load('600 15px "IBM Plex Sans"')
98
+ .then(() => document.fonts.ready));
99
+
100
+ const rendered = await page.evaluate(async (paint) => {
101
+ window.mermaid.initialize({
102
+ startOnLoad: false, theme: 'neutral', securityLevel: 'loose',
103
+ themeVariables: {
104
+ fontSize: '15px',
105
+ fontFamily: '"IBM Plex Sans", ui-sans-serif, system-ui, sans-serif',
106
+ textColor: '#1f2530', primaryTextColor: '#1f2530', nodeTextColor: '#1f2530',
107
+ titleColor: '#1f2530', clusterBkg: '#f7f8fa', clusterBorder: '#b9c0cb',
108
+ lineColor: '#5b6472',
109
+ },
110
+ flowchart: { htmlLabels: true, useMaxWidth: false },
111
+ class: { useMaxWidth: false },
112
+ });
113
+ const out = [];
114
+ const nodes = [...document.querySelectorAll('pre.m')];
115
+ for (const [i, el] of nodes.entries()) {
116
+ const { svg } = await window.mermaid.render(`fig-svg-${i}`, el.textContent);
117
+ const holder = document.createElement('div');
118
+ holder.innerHTML = svg;
119
+ document.body.appendChild(holder);
120
+ const node = holder.querySelector('svg');
121
+ node.querySelectorAll('*').forEach((child) => {
122
+ const cs = getComputedStyle(child);
123
+ paint.forEach((prop) => {
124
+ const v = cs.getPropertyValue(prop);
125
+ if (v) child.style.setProperty(prop, v);
126
+ });
127
+ });
128
+ // Mermaid emits only the deprecated xlink:href on a click-through link.
129
+ // Every current browser still honours it, but SVG2 names the attribute
130
+ // `href`, so write both and let the newer one win.
131
+ node.querySelectorAll('a[*|href]').forEach((a) => {
132
+ const x = a.getAttributeNS('http://www.w3.org/1999/xlink', 'href');
133
+ if (x && !a.getAttribute('href')) a.setAttribute('href', x);
134
+ });
135
+ node.setAttribute('style', 'max-width:100%;height:auto');
136
+ out.push({ svg: node.outerHTML, viewBox: node.getAttribute('viewBox') });
137
+ }
138
+ return out;
139
+ }, PAINT);
140
+ await browser.close();
141
+
142
+ let n = 0;
143
+ const next = html.replace(SOURCE, (whole, src) => {
144
+ const { svg } = rendered[n++];
145
+ return `${MARKER}\n${src}\n -->\n <div class="figure-card">${svg}</div>\n`;
146
+ });
147
+ writeFileSync(file, next);
148
+
149
+ // Producing a measurable figure is this script's job, so one it cannot
150
+ // measure is a failure rather than a figure reported as zero pixels wide.
151
+ rendered.forEach(({ viewBox }, i) => {
152
+ const parsed = /^[-\d.]+\s+[-\d.]+\s+([\d.]+)/.exec(viewBox || '');
153
+ if (!parsed) throw new Error(`figure ${i + 1} rendered without a usable viewBox`);
154
+ const w = Math.round(Number(parsed[1]));
155
+ const over = w > FIGURE_WIDTH_BUDGET
156
+ ? ` ** OVER the ${FIGURE_WIDTH_BUDGET}px budget **` : '';
157
+ console.log(`figure ${i + 1}: ${w}px${over}`);
158
+ });
159
+ console.log(`wrote ${n} figures to ${file}`);
@@ -1,14 +1,24 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * Render a markdown + mermaid report to a self-contained HTML file and open it
5
- * in the browser — the reliable renderer for agent reports (docs-xray,
6
- * design-xray) whose mermaid diagrams render unreliably in editor previews.
4
+ * Render a markdown + mermaid report to a self-contained HTML page — the
5
+ * renderer for agent reports (docs-xray, design-xray), whose mermaid diagrams
6
+ * render unreliably in editor previews. The page is meant to be published as
7
+ * an artifact by the caller; the agents themselves cannot publish.
7
8
  *
8
- * The report `.md` is inlined into an HTML harness that loads `marked` and
9
- * `mermaid` from CDN, so the output is one portable file — no build step, no
10
- * editor extension. Write reports into a repo-local dir (e.g. `scratch/`), not
11
- * `/tmp`: sandboxed browsers (snap/flatpak) often can't read outside $HOME.
9
+ * Two properties the output depends on, both learned the hard way.
10
+ *
11
+ * The markdown is converted HERE, in node, not in the page. A page that builds
12
+ * its own body at load time has no `pre.mermaid` in it when the artifact host
13
+ * looks for diagrams to render, so the diagrams never appear — and its first
14
+ * still frame, which is what a thumbnail and a skimming reader get, says
15
+ * "Rendering…".
16
+ *
17
+ * The page ships NO mermaid runtime. The artifact host has its own and renders
18
+ * every `pre.mermaid` itself; a second runtime does not add a fallback, it
19
+ * races for the same elements, and because mermaid scopes a diagram's fills to
20
+ * the svg id it generated, the loser leaves those rules matching nothing and
21
+ * every shape falls back to the SVG default fill, which is black.
12
22
  *
13
23
  * Usage:
14
24
  * node bin/render-report.mjs <report.md> [--no-open]
@@ -23,7 +33,9 @@
23
33
  * 1 Bad arguments or the input file is missing
24
34
  */
25
35
  import {existsSync, readFileSync, writeFileSync} from "node:fs";
26
- import {resolve} from "node:path";
36
+ import {marked} from "marked";
37
+ import {dirname, resolve} from "node:path";
38
+ import {fileURLToPath} from "node:url";
27
39
  import {openPath} from "./utils.mjs";
28
40
 
29
41
  const args = process.argv.slice(2);
@@ -37,6 +49,20 @@ if (!mdPath || !existsSync(mdPath)) {
37
49
 
38
50
  const htmlPath = mdPath.replace(/\.md$/, "") + ".html";
39
51
 
52
+ /* The house stylesheets, read from `styles/` at render time and inlined.
53
+ *
54
+ * Inlining a GENERATED copy is not the thing `draft-design-spec` forbids. That
55
+ * rule is about authoring a second copy by hand, which drifts; here the single
56
+ * file on disk stays the only source, so a restyle is still one edit. What it
57
+ * buys is a report that is one self-contained page — nothing to publish
58
+ * alongside it, and nothing to resolve at the far end.
59
+ *
60
+ * Tokens first: the chrome sheet reads every colour and face from them. */
61
+ const styleDir = resolve(dirname(fileURLToPath(import.meta.url)), "..", "styles");
62
+ const css = ["casomo-tokens.css", "casomo-spec.css"]
63
+ .map((f) => readFileSync(resolve(styleDir, f), "utf8"))
64
+ .join("\n");
65
+
40
66
  // A mermaid classDiagram rejects union/bracket types in a member line
41
67
  // (`date|None`, `list[Item]`) — soften them inside class bodies so an imperfect
42
68
  // source (e.g. a design-xray map) still renders. Scoped to classDiagram fences
@@ -52,13 +78,15 @@ const softenClassDiagrams = (text) =>
52
78
  : block,
53
79
  );
54
80
 
55
- // Inline the markdown in a <script type="text/markdown"> so its backticks and
56
- // fences aren't parsed as HTML. The only hazard is a literal </script> in the
57
- // source, which we defuse.
58
- const md = softenClassDiagrams(readFileSync(mdPath, "utf8")).replaceAll(
59
- "</script>",
60
- "<\\/script>",
61
- );
81
+ const body = marked
82
+ .parse(softenClassDiagrams(readFileSync(mdPath, "utf8")))
83
+ // `marked` emits a fenced mermaid block as <pre><code class="language-mermaid">.
84
+ // The artifact host looks for <pre class="mermaid">, so rewrite it at build
85
+ // time rather than in the page — see the header note on why that matters.
86
+ .replace(
87
+ /<pre><code class="language-mermaid">([\s\S]*?)<\/code><\/pre>/g,
88
+ (_, diagram) => `<pre class="mermaid">${diagram}</pre>`,
89
+ );
62
90
 
63
91
  const html = `<!doctype html>
64
92
  <html lang="en">
@@ -67,40 +95,13 @@ const html = `<!doctype html>
67
95
  <meta name="viewport" content="width=device-width, initial-scale=1">
68
96
  <title>${mdPath}</title>
69
97
  <style>
70
- body { max-width: 1200px; margin: 2rem auto; padding: 0 1.5rem;
71
- font: 15px/1.6 system-ui, sans-serif; color: #1a1a1a; }
72
- h1, h2, h3 { line-height: 1.25; }
73
- h2 { border-bottom: 1px solid #eee; padding-bottom: .3rem; margin-top: 2.5rem; }
74
- table { border-collapse: collapse; margin: 1rem 0; font-size: 14px; }
75
- th, td { border: 1px solid #ddd; padding: 6px 10px; text-align: left; vertical-align: top; }
76
- th { background: #f7f7f7; }
77
- code { background: #f4f4f4; padding: 1px 5px; border-radius: 3px; font-size: .9em; }
78
- pre { background: #f7f7f7; padding: 1rem; border-radius: 6px; overflow-x: auto; }
79
- pre.mermaid { background: #fff; }
80
- a { color: #0b66c3; }
98
+ ${css}
81
99
  </style>
82
100
  </head>
83
101
  <body>
84
- <div id="content">Rendering…</div>
85
- <script id="md" type="text/markdown">
86
- ${md}
87
- </script>
88
- <script type="module">
89
- import { marked } from "https://cdn.jsdelivr.net/npm/marked/lib/marked.esm.js";
90
- import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs";
91
- mermaid.initialize({ startOnLoad: false, theme: "neutral" });
92
- const el = document.getElementById("content");
93
- el.innerHTML = marked.parse(document.getElementById("md").textContent);
94
- // marked emits mermaid fences as <code class="language-mermaid">; mermaid wants
95
- // <pre class="mermaid">.
96
- el.querySelectorAll("code.language-mermaid").forEach((c) => {
97
- const pre = document.createElement("pre");
98
- pre.className = "mermaid";
99
- pre.textContent = c.textContent;
100
- c.closest("pre").replaceWith(pre);
101
- });
102
- await mermaid.run({ querySelector: ".mermaid" });
103
- </script>
102
+ <div class="wrap" style="padding-top:32px">
103
+ ${body}
104
+ </div>
104
105
  </body>
105
106
  </html>
106
107
  `;
@@ -0,0 +1,229 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Check one HTML design spec's mechanics, so the authoring skill does not
4
+ * have to teach them.
5
+ *
6
+ * These rules are measurements -- a width budget, a contiguous figure
7
+ * sequence, a resolving anchor -- so they belong in a command. What stays in
8
+ * the skill is the half no tool can apply: what to draw, and whether it is
9
+ * worth drawing.
10
+ *
11
+ * Exits non-zero on the first failing rule set, listing every instance.
12
+ *
13
+ * node bin/spec-check.mjs <spec.html>
14
+ */
15
+ import { readFileSync } from 'node:fs';
16
+ import { FIGURE_WIDTH_BUDGET } from './utils.mjs';
17
+
18
+ const file = process.argv[2];
19
+ if (!file) throw new Error('usage: spec-check.mjs <spec.html>');
20
+ const html = readFileSync(file, 'utf8');
21
+
22
+ /** A pre-rendered figure is hundreds of kilobytes of inline SVG carrying its
23
+ * own ids, and a figure's mermaid source sits in an HTML comment above it.
24
+ * Neither is the document, so an id in either must not answer one of the
25
+ * document's links, and neither may be counted as an anchor a reader has. */
26
+ const doc = html
27
+ .replace(/<!--[\s\S]*?-->/g, '')
28
+ .replace(/<svg[\s\S]*?<\/svg>/g, '');
29
+
30
+ const findings = [];
31
+ const fail = (rule, detail) => findings.push({ rule, detail });
32
+ const all = (re, s = html) => [...s.matchAll(re)];
33
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
34
+ const text = (s) => s.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
35
+
36
+ // --- anchors -------------------------------------------------------------
37
+ const ids = new Set(all(/\sid="([^"]+)"/g, doc).map((m) => m[1]));
38
+ for (const m of all(/href="#([^"]+)"/g, doc)) {
39
+ if (!ids.has(m[1])) fail('anchor', `href="#${m[1]}" resolves to nothing`);
40
+ }
41
+
42
+ // --- contents against the headings ---------------------------------------
43
+ const nav = doc.match(/<nav class="toc">([\s\S]*?)<\/nav>/);
44
+ if (!nav) fail('toc', 'no <nav class="toc">');
45
+ else {
46
+ // Parsed from inside the nav, not filtered out of the whole document: once
47
+ // prose cites a section by name, a body link and a contents entry are the
48
+ // same markup, and a filter counts both.
49
+ //
50
+ // Both sides capture markup and are reduced to text, so a heading carrying
51
+ // a <code> span stays in the comparison. Matching bare text instead drops
52
+ // such a heading from BOTH sets, which leaves the counts equal and the
53
+ // mismatch never compared.
54
+ const labels = [...nav[1].matchAll(/<a href="#[^"]+"[^>]*>([\s\S]*?)<\/a>/g)]
55
+ .map((m) => text(m[1]));
56
+ const heads = all(/<h2[^>]*>([\s\S]*?)<\/h2>/g, doc).map((m) => text(m[1]));
57
+ if (labels.length !== heads.length) {
58
+ fail('toc', `${labels.length} entries against ${heads.length} sections`);
59
+ } else {
60
+ labels.forEach((l, i) => {
61
+ if (l !== heads[i]) fail('toc', `entry "${l}" against heading "${heads[i]}"`);
62
+ });
63
+ }
64
+ }
65
+
66
+ // --- S11: the masthead carries no loose prose ----------------------------
67
+ // The Overview & goals card is the document's summary. A standfirst above it
68
+ // says the same thing a second time, and belongs to no section, so it is the
69
+ // copy that goes stale unread. Everything else in the masthead -- eyebrow,
70
+ // title, contents, status pills -- is not a paragraph, so a <p> left after
71
+ // the nav and the card are removed is the defect.
72
+ const hero = doc.match(/<header[^>]*class="hero"[\s\S]*?<\/header>/);
73
+ if (hero) {
74
+ const loose = hero[0]
75
+ .replace(/<nav[\s\S]*?<\/nav>/g, '')
76
+ .replace(/<section[\s\S]*?<\/section>/g, '');
77
+ // Prose, not `<p>`: the standfirst this rule exists to catch was carried as
78
+ // a non-paragraph element, so keying on the tag let the exact defect past.
79
+ for (const m of loose.matchAll(/<(?:p|div)\b[^>]*>([\s\S]*?)<\/(?:p|div)>/g)) {
80
+ if (/\bclass="[^"]*\b(?:eyebrow|pillrow|pill|wrap)\b/.test(m[0])) continue;
81
+ const words = text(m[1]);
82
+ if (!words) continue;
83
+ fail('masthead', `prose sits in the masthead outside the Overview card: `
84
+ + `"${words.slice(0, 60)}…" (S11)`);
85
+ }
86
+ }
87
+
88
+ // --- no typed numbers: the stylesheet generates them -------------------
89
+ for (const m of all(/<h[23][^>]*>\s*(\d+(?:\.\d+)?)\s*(?:&middot;|·)/g, doc)) {
90
+ fail('numbering', `a heading types "${m[1]}"; section numbers are generated`);
91
+ }
92
+ const TYPED_TOC = /<a href="#[^"]+"[^>]*>\s*(\d+(?:\.\d+)?)\s*(?:&middot;|·)/g;
93
+ for (const m of all(TYPED_TOC, doc)) {
94
+ fail('numbering', `a contents entry types "${m[1]}"; numbers are generated`);
95
+ }
96
+ for (const m of all(/(?:&sect;|§)\d+(?:\.\d+)?/g, doc)) {
97
+ fail('numbering', `prose cites "${m[0]}" by number; cite the section by name`);
98
+ }
99
+
100
+ // --- figures: numbered, anchored, captioned, and inside the budget -------
101
+ const figs = all(/<div\b[^>]*\bid="fig-(\d+)"[^>]*>/g);
102
+ figs.forEach((m, i) => {
103
+ const n = Number(m[1]);
104
+ if (n !== i + 1) {
105
+ fail('figure', i === 0 ? `expected fig-1, found fig-${n}`
106
+ : `fig-${n} follows fig-${figs[i - 1][1]}`);
107
+ }
108
+ if (!m[0].includes('scroll-margin-top')) {
109
+ fail('figure', `fig-${n} has no scroll-margin-top, so host chrome can cover it`);
110
+ }
111
+ // Bounded by what comes next rather than by a character count: a
112
+ // pre-rendered figure is hundreds of kilobytes of inline SVG, so a window
113
+ // wide enough to clear one is wide enough to borrow the next figure's
114
+ // caption and call every figure captioned.
115
+ const from = m.index + m[0].length;
116
+ const nextFig = figs[i + 1]?.index ?? -1;
117
+ const nextSec = html.indexOf('</section>', from);
118
+ const ends = [nextFig, nextSec].filter((x) => x !== -1);
119
+ const between = html.slice(from, ends.length ? Math.min(...ends) : html.length);
120
+ if (!/<(?:div|p)\b[^>]*\bclass="[^"]*\blegend\b/.test(between)) {
121
+ fail('caption', `fig-${n} has no legend before the next figure or section end`);
122
+ }
123
+ });
124
+
125
+ // Mermaid emits `viewBox="-8 -8 W H"` on the figure, while every arrowhead
126
+ // marker inside it carries its own `viewBox="0 0 10 10"`. So the figure is
127
+ // found by the id the renderer assigns, and the width read as the third
128
+ // value whatever the origin.
129
+ //
130
+ // The cardinality check is the load-bearing half. A width rule that matches
131
+ // nothing reports nothing, so an unrendered spec, or one figure whose render
132
+ // failed, would otherwise be certified clean by a measurement that never ran.
133
+ const svgTags = all(/<svg\b[^>]*\bid="fig-svg-\d+"[^>]*>/g).map((m) => m[0]);
134
+ if (svgTags.length !== figs.length) {
135
+ fail('figure', `${figs.length} figure(s) but ${svgTags.length} rendered svg(s); `
136
+ + 'run render-figures.mjs');
137
+ }
138
+ svgTags.forEach((tag, i) => {
139
+ const vb = tag.match(/viewBox="[-\d.]+\s+[-\d.]+\s+([\d.]+)/);
140
+ if (!vb) {
141
+ fail('width', `fig-${i + 1} rendered without a parsable viewBox`);
142
+ return;
143
+ }
144
+ const w = Math.round(Number(vb[1]));
145
+ if (w > FIGURE_WIDTH_BUDGET) {
146
+ fail('width',
147
+ `fig-${i + 1} renders ${w}px against a ${FIGURE_WIDTH_BUDGET}px budget`);
148
+ }
149
+ });
150
+
151
+ // --- things that only fail once the host renders the page ----------------
152
+ if (/<pre[^>]*class="[^"]*\bmermaid\b/.test(html)) {
153
+ fail('runtime', 'a <pre class="mermaid"> is present; the artifact host claims it '
154
+ + 'and renames the svg, so every fill falls back to black. Pre-render instead.');
155
+ }
156
+ if (html.includes('Syntax error in text')) {
157
+ fail('render', 'a rendered figure carries mermaid\'s syntax-error text');
158
+ }
159
+
160
+ // A mermaid click directive compiles to one of two things. `call fn()` and the
161
+ // bare-callback form become inline JS, which the host CSP drops. `href "#a"`
162
+ // becomes an ordinary <a> in the SVG, which works. So a call is refused, and
163
+ // an href has to be findable on that node in the pre-rendered output -- which
164
+ // also catches a directive naming a node no longer in the diagram.
165
+ //
166
+ // The node id is matched loosely and the remainder classified, so a spelling
167
+ // this does not know becomes a finding rather than a skip: the rule exists to
168
+ // ban inline JS, and a pattern that only recognises one spelling of it lets
169
+ // the rest through silently.
170
+ const svgs = all(/<svg\b[^>]*\bid="fig-svg-\d+"[\s\S]*?<\/svg>/g).map((m) => m[0]);
171
+ for (const m of all(/^[ \t]*click\s+(\S+)\s+(.*)$/gm)) {
172
+ const [, node, rest] = m;
173
+ const href = rest.match(/^(?:href\s+)?"([^"]+)"/);
174
+ if (/^call\b/.test(rest)) {
175
+ fail('click',
176
+ `click ${node} ${rest.trim()} is inline JS, which the host CSP blocks`);
177
+ } else if (!href) {
178
+ fail('click', `click ${node} ${rest.trim()} is not an href; a bare callback is `
179
+ + 'inline JS, which the host CSP blocks');
180
+ } else if (!svgs.some((svg) => {
181
+ // Find the anchor by the node it wraps, then assert the attribute on that
182
+ // tag. Searching for `href="…"` alone matches the tail of `xlink:href`,
183
+ // so it would pass on the deprecated attribute and never see the one the
184
+ // renderer adds.
185
+ const tag = svg.match(new RegExp(`<a\\b[^>]*\\bdata-id="${esc(node)}"[^>]*>`));
186
+ return tag && new RegExp(`(^|\\s)href="${esc(href[1])}"`).test(tag[0]);
187
+ })) {
188
+ fail('click', `click ${node} href ${href[1]} did not survive pre-rendering`);
189
+ }
190
+ }
191
+
192
+ // Matched as a whole tag, then read attribute by attribute: requiring `rel`
193
+ // before `href` let a reordered link through, and a missed blocked stylesheet
194
+ // is the direction that fails open.
195
+ for (const m of all(/<link\b[^>]*>/g, doc)) {
196
+ if (!/\brel="[^"]*\bstylesheet\b/.test(m[0])) continue;
197
+ const href = m[0].match(/\bhref="([^"]+)"/);
198
+ if (!href || !/^https?:/.test(href[1])) continue;
199
+ let host = null;
200
+ try { host = new URL(href[1]).host; } catch { /* foreign by default */ }
201
+ if (host !== 'fonts.googleapis.com') {
202
+ fail('csp', `${href[1]} is blocked by the host CSP; inline it`);
203
+ }
204
+ }
205
+
206
+ // --- open questions ------------------------------------------------------
207
+ const qs = all(/<tr id="q(\d+)"/g, doc).map((m) => Number(m[1]));
208
+ qs.forEach((n, i) => {
209
+ if (n !== i + 1) {
210
+ fail('questions', i === 0 ? `expected Q1, found Q${n}`
211
+ : `Q${n} follows Q${qs[i - 1]}`);
212
+ }
213
+ });
214
+
215
+ // --- report --------------------------------------------------------------
216
+ if (findings.length === 0) {
217
+ const n = figs.length;
218
+ const s = n === 1 ? '' : 's';
219
+ console.log(`spec-check: clean (${ids.size} anchors, ${n} figure${s})`);
220
+ process.exit(0);
221
+ }
222
+ const byRule = new Map();
223
+ for (const f of findings) byRule.set(f.rule, [...(byRule.get(f.rule) || []), f.detail]);
224
+ for (const [rule, details] of byRule) {
225
+ console.error(`\n${rule}:`);
226
+ for (const d of details) console.error(` - ${d}`);
227
+ }
228
+ console.error(`\nspec-check: ${findings.length} finding(s) in ${file}`);
229
+ process.exit(1);
package/bin/utils.mjs CHANGED
@@ -7,6 +7,25 @@ import path from "node:path";
7
7
  import fs from "node:fs";
8
8
  import {pathToFileURL} from "node:url";
9
9
 
10
+ /**
11
+ * Usable width inside a design spec's figure card, in px.
12
+ *
13
+ * Read off `styles/casomo-spec.css`, which sets no `box-sizing`, so every
14
+ * width there is a CONTENT width:
15
+ *
16
+ * --measure: 62rem .wrap's content box 992
17
+ * .figure-card padding 16px each side -32
18
+ * .figure-card border 1px each side -2
19
+ * ---
20
+ * 958
21
+ *
22
+ * One producer, because the renderer reports a figure against this and the
23
+ * checker fails a spec over it; two copies are two answers at the boundary.
24
+ * It mirrors a stylesheet, so a change to `--measure` or to the card's
25
+ * padding is a change to this number.
26
+ */
27
+ export const FIGURE_WIDTH_BUDGET = 958;
28
+
10
29
  /**
11
30
  * Open a file or URL with the platform opener, detached so the CLI can exit.
12
31
  *
@@ -18,6 +18,7 @@ const ALLOWED_PREFIXES = [
18
18
  "jscpd/",
19
19
  "readability/",
20
20
  "report/",
21
+ "styles/",
21
22
  ];
22
23
  const ALLOWED_FILES = ["README.md", "package.json", "LICENSE", "LICENSE.md"];
23
24
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@casomoltd/tooling",
3
- "version": "0.27.0",
4
- "description": "Casomo's shared dev config: ESLint, Prettier, commitlint, knip & jscpd presets and dev CLIs (git hooks, readability, screenshots)",
3
+ "version": "0.29.0",
4
+ "description": "Casomo's shared dev config: ESLint, Prettier, commitlint, knip & jscpd presets and dev CLIs (git hooks, readability, screenshots, design specs)",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -16,17 +16,19 @@
16
16
  "jscpd/",
17
17
  "readability/",
18
18
  "report/",
19
+ "styles/",
19
20
  "README.md",
20
21
  "!**/*.test.mjs"
21
22
  ],
22
23
  "scripts": {
23
- "check": "node bin/check-gates.mjs && node bin/verify-pack.mjs && npm run check:private-refs && npm run typecheck && npm run test && npm run lint:md",
24
+ "check": "node bin/check-gates.mjs && node bin/verify-pack.mjs && npm run check:private-refs && npm run check:personal-data && npm run typecheck && npm run test && npm run lint:md",
24
25
  "typecheck": "tsc --noEmit -p hooks/tsconfig.json",
25
- "test": "node hooks/test.mts && node bin/check-gates.test.mjs && node commitlint/index.test.mjs",
26
+ "test": "node hooks/test.mts && node bin/check-gates.test.mjs && node bin/check-personal-data.test.mjs && node commitlint/index.test.mjs",
26
27
  "lint:md": "remark --frail --quiet --no-stdout --rc-path bin/skills-lint.config.mjs skills agents docs",
27
28
  "build": "echo 'no-op: source-only repo, nothing to build'",
28
29
  "prepare": "husky || true",
29
- "check:private-refs": "node bin/check-private-refs.mjs"
30
+ "check:private-refs": "node bin/check-private-refs.mjs",
31
+ "check:personal-data": "node bin/check-personal-data.mjs"
30
32
  },
31
33
  "casomo": {
32
34
  "gates": {
@@ -49,6 +51,9 @@
49
51
  "bin": {
50
52
  "build-report": "./bin/build-report.mjs",
51
53
  "check-gates": "./bin/check-gates.mjs",
54
+ "check-hooks": "./bin/check-hooks.sh",
55
+ "check-linear": "./bin/check-linear.sh",
56
+ "check-personal-data": "./bin/check-personal-data.mjs",
52
57
  "check-private-refs": "./bin/check-private-refs.mjs",
53
58
  "check-tags": "./bin/check-tags.sh",
54
59
  "check-version": "./bin/check-version.sh",
@@ -56,12 +61,15 @@
56
61
  "pre-commit": "./bin/pre-commit.sh",
57
62
  "pre-push": "./bin/pre-push.sh",
58
63
  "readability": "./bin/readability.mjs",
59
- "screenshot": "./bin/screenshot.mjs"
64
+ "render-figures": "./bin/render-figures.mjs",
65
+ "screenshot": "./bin/screenshot.mjs",
66
+ "spec-check": "./bin/spec-check.mjs"
60
67
  },
61
68
  "publishConfig": {
62
69
  "access": "public"
63
70
  },
64
71
  "dependencies": {
72
+ "marked": "^18.0.13",
65
73
  "text-readability": "^1.1.1"
66
74
  },
67
75
  "peerDependencies": {