@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.
- package/README.md +82 -22
- package/bin/check-hooks.sh +84 -0
- package/bin/check-linear.sh +47 -0
- package/bin/check-personal-data.mjs +270 -0
- package/bin/pre-push.sh +2 -1
- package/bin/render-figures.mjs +159 -0
- package/bin/render-report.mjs +47 -46
- package/bin/spec-check.mjs +229 -0
- package/bin/utils.mjs +19 -0
- package/bin/verify-pack.mjs +1 -0
- package/package.json +14 -6
- package/styles/casomo-spec.css +230 -0
- package/styles/casomo-tokens.css +43 -0
|
@@ -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('&', '&').replaceAll('<', '<');
|
|
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}`);
|
package/bin/render-report.mjs
CHANGED
|
@@ -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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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 {
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
|
85
|
-
|
|
86
|
-
|
|
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*(?:·|·)/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*(?:·|·)/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(/(?:§|§)\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
|
*
|
package/bin/verify-pack.mjs
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@casomoltd/tooling",
|
|
3
|
-
"version": "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
|
-
"
|
|
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": {
|