@protoboardai/ext-docs 0.0.0-stage → 0.2.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/LICENSE +202 -0
- package/NOTICE +18 -0
- package/README.md +78 -2
- package/evals/build-datasheet/case.json +147 -0
- package/fonts/KodeMono-Bold.ttf +0 -0
- package/fonts/KodeMono-Medium.ttf +0 -0
- package/fonts/KodeMono-Regular.ttf +0 -0
- package/fonts/KodeMono-SemiBold.ttf +0 -0
- package/fonts/OFL.txt +93 -0
- package/lib/blocks.ts +268 -0
- package/lib/bom.ts +126 -0
- package/lib/build.ts +202 -0
- package/lib/charts.ts +241 -0
- package/lib/context.ts +122 -0
- package/lib/derived.ts +181 -0
- package/lib/docs/assembly.ts +674 -0
- package/lib/docs/common.ts +81 -0
- package/lib/docs/datasheet.ts +875 -0
- package/lib/docs/product.ts +45 -0
- package/lib/figures.ts +195 -0
- package/lib/format.ts +221 -0
- package/lib/html.ts +135 -0
- package/lib/model.ts +312 -0
- package/lib/package/assembly.ts +268 -0
- package/lib/package/common.ts +109 -0
- package/lib/package/plan.ts +432 -0
- package/lib/package/spec.ts +203 -0
- package/lib/png.ts +91 -0
- package/lib/render/cdp.ts +5 -0
- package/lib/render/page/render.html +17 -0
- package/lib/render/page/render.js +390 -0
- package/lib/render/renderer.ts +161 -0
- package/lib/render/server.ts +105 -0
- package/lib/standin.ts +36 -0
- package/lib/standins/multirotor.ts +283 -0
- package/lib/templates/doc.css +247 -0
- package/lib/templates/flow.js +131 -0
- package/lib/testdata.ts +85 -0
- package/lib/types.ts +120 -0
- package/lib/values.ts +309 -0
- package/lib/verify.ts +276 -0
- package/ops/docs.ts +52 -0
- package/ops/package.ts +9 -0
- package/package.json +51 -4
- package/protoboard-extension.json +83 -0
- package/skills/uhd-tech-docs/SKILL.md +234 -0
- package/skills/uhd-tech-docs/references/docspec.md +56 -0
- package/skills/uhd-tech-docs/references/house-style.md +50 -0
- package/skills/uhd-tech-docs/references/style-study.md +270 -0
- package/skills/uhd-tech-docs/references/test-data.md +49 -0
- package/skills/uhd-tech-docs/references/value-queries.md +67 -0
- package/skills/uhd-tech-docs/templates/test-data.example.json +33 -0
- package/skills/uhd-tech-docs/templates/test-data.schema.json +213 -0
- package/skills/uhd-tech-docs-verify/SKILL.md +104 -0
- package/skills/uhd-tech-docs-verify/references/visual-review.md +43 -0
package/lib/verify.ts
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verify generated documents against the live UHD model (the
|
|
3
|
+
* uhd-tech-docs-verify skill; `protoboard run tech-docs-verify`). Moved from
|
|
4
|
+
* UHD's scripts/docs/verify.ts (PB-801).
|
|
5
|
+
*
|
|
6
|
+
* Checks (error unless noted):
|
|
7
|
+
* values every [data-q] element re-resolved and re-formatted equals its text; status unchanged
|
|
8
|
+
* format numeric values are "value unit" with a space; one spelling per unit (warning)
|
|
9
|
+
* placeholders no NaN / undefined / null / [object Object] / raw JSON / TODO / TBD / {{ }} in visible text
|
|
10
|
+
* standin stand-in values carry the S marker; charts from stand-in data carry the watermark
|
|
11
|
+
* images every <img> exists, decodes, and is not blank
|
|
12
|
+
* layout no page overflow, nothing wider than the page body, no heading stranded at a page
|
|
13
|
+
* end, no table continuation with fewer than 2 rows, PDF page count = HTML pages
|
|
14
|
+
* typography only the document fonts, fonts loaded, embedded in the PDF, sizes within range
|
|
15
|
+
* bom the datasheet BOM rows are docs/<system-id>-bom.json's, and that file matches the model
|
|
16
|
+
* coverage the assembly steps cover every mate, hardware placement and wiring link
|
|
17
|
+
*
|
|
18
|
+
* Writes docs/.review/verify-report.json and page PNGs in .review/<doc>/ for the
|
|
19
|
+
* visual review (the uhd-tech-docs-verify skill's references/visual-review.md);
|
|
20
|
+
* both are gitignored.
|
|
21
|
+
*/
|
|
22
|
+
import { execFileSync } from "child_process";
|
|
23
|
+
import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "fs";
|
|
24
|
+
import { join } from "path";
|
|
25
|
+
import type { Project } from "@protoboardai/core";
|
|
26
|
+
import { bomFile, buildContext, type BuiltContext } from "./context.ts";
|
|
27
|
+
import { formatValue, glyphSafe } from "./format.ts";
|
|
28
|
+
import { launchBrowser } from "./render/cdp.ts";
|
|
29
|
+
import { projectPath, startServer } from "./render/server.ts";
|
|
30
|
+
import { decodePng, inkStats } from "./png.ts";
|
|
31
|
+
import { planSteps } from "./docs/assembly.ts";
|
|
32
|
+
import { DOC_TYPES, type DocType } from "./build.ts";
|
|
33
|
+
|
|
34
|
+
export interface Finding {
|
|
35
|
+
doc: string;
|
|
36
|
+
check: string;
|
|
37
|
+
severity: "error" | "warning" | "info";
|
|
38
|
+
message: string;
|
|
39
|
+
page?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface DomReport {
|
|
43
|
+
pages: number;
|
|
44
|
+
values: { q: string; f: string | null; s: string | null; text: string; tag: string; marker: string | null; page: number }[];
|
|
45
|
+
overflow: number[];
|
|
46
|
+
wide: { page: number; what: string; by: number }[];
|
|
47
|
+
stranded: { page: number; what: string }[];
|
|
48
|
+
thinTables: { page: number; rows: number }[];
|
|
49
|
+
charts: { page: number; status: string; tests: string; mark: boolean }[];
|
|
50
|
+
images: { src: string; w: number; h: number; ok: boolean; page: number }[];
|
|
51
|
+
fonts: { family: string; count: number }[];
|
|
52
|
+
sizes: number[];
|
|
53
|
+
fontsLoaded: Record<string, boolean>;
|
|
54
|
+
text: string;
|
|
55
|
+
steps: { mates: string; wiring: string; hardware: string }[];
|
|
56
|
+
standinTags: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const DOM_PROBE = `(() => {
|
|
60
|
+
const pageOf = (el) => { const p = el.closest('.page'); return p ? Number(p.dataset.page) : 0; };
|
|
61
|
+
const pages = [...document.querySelectorAll('.page')];
|
|
62
|
+
const out = { pages: pages.length, values: [], overflow: [], wide: [], stranded: [], thinTables: [], charts: [], images: [], fonts: [], sizes: [], fontsLoaded: {}, text: '', steps: [], standinTags: document.querySelectorAll('.standin-tag').length };
|
|
63
|
+
for (const e of document.querySelectorAll('[data-q]')) {
|
|
64
|
+
const n = e.nextElementSibling;
|
|
65
|
+
out.values.push({ q: e.dataset.q, f: e.getAttribute('data-f'), s: e.getAttribute('data-s'), text: e.textContent, tag: e.tagName.toLowerCase(), marker: n && n.matches('sup.mk') ? n.className : null, page: pageOf(e) });
|
|
66
|
+
}
|
|
67
|
+
pages.forEach((p, i) => {
|
|
68
|
+
const body = p.querySelector('.pg-body');
|
|
69
|
+
if (body.scrollHeight > body.clientHeight + 1) out.overflow.push(i + 1);
|
|
70
|
+
const br = body.getBoundingClientRect();
|
|
71
|
+
for (const el of body.querySelectorAll('table, figure, .kpis, .kit, svg.chart, svg.wiring, svg.sysdiag, .parts, p, ol, ul')) {
|
|
72
|
+
const r = el.getBoundingClientRect();
|
|
73
|
+
const by = Math.max(r.right - br.right, br.left - r.left);
|
|
74
|
+
if (by > 1.5) out.wide.push({ page: i + 1, what: el.tagName.toLowerCase() + '.' + (el.className.baseVal ?? el.className), by: Math.round(by) });
|
|
75
|
+
}
|
|
76
|
+
for (const t of body.querySelectorAll('table')) {
|
|
77
|
+
if (t.scrollWidth > t.parentElement.clientWidth + 1) out.wide.push({ page: i + 1, what: 'table content', by: t.scrollWidth - t.parentElement.clientWidth });
|
|
78
|
+
const rows = t.tBodies[0] ? t.tBodies[0].rows.length : 0;
|
|
79
|
+
if (t.closest('.continued') && rows < 2) out.thinTables.push({ page: i + 1, rows });
|
|
80
|
+
}
|
|
81
|
+
const last = body.lastElementChild;
|
|
82
|
+
if (last && i < pages.length - 1 && (last.matches('h1.sec, h2.sub, .keep-next') || (last.lastElementChild && last.lastElementChild.matches && last.lastElementChild.matches('h1.sec, h2.sub')))) out.stranded.push({ page: i + 1, what: last.className });
|
|
83
|
+
});
|
|
84
|
+
for (const c of document.querySelectorAll('svg.chart')) out.charts.push({ page: pageOf(c), status: c.dataset.status, tests: c.dataset.test || '', mark: Boolean(c.querySelector('.standin-mark')) });
|
|
85
|
+
for (const im of document.images) out.images.push({ src: im.getAttribute('src'), w: im.naturalWidth, h: im.naturalHeight, ok: im.complete && im.naturalWidth > 0, page: pageOf(im) });
|
|
86
|
+
const fam = new Map(); const sizes = new Set();
|
|
87
|
+
const walker = document.createTreeWalker(document.querySelector('#pages'), NodeFilter.SHOW_TEXT);
|
|
88
|
+
while (walker.nextNode()) {
|
|
89
|
+
const t = walker.currentNode; if (!t.textContent.trim()) continue;
|
|
90
|
+
const el = t.parentElement; const cs = getComputedStyle(el);
|
|
91
|
+
if (cs.display === 'none' || el.closest('svg')) continue;
|
|
92
|
+
const f = cs.fontFamily.split(',')[0].replace(/["']/g, '').trim();
|
|
93
|
+
fam.set(f, (fam.get(f) || 0) + 1);
|
|
94
|
+
sizes.add(Math.round(parseFloat(cs.fontSize) * 0.75 * 10) / 10);
|
|
95
|
+
}
|
|
96
|
+
out.fonts = [...fam].map(([family, count]) => ({ family, count }));
|
|
97
|
+
out.sizes = [...sizes].sort((a, b) => a - b);
|
|
98
|
+
out.fontsLoaded = { 'Kode Mono': document.fonts.check("12px 'Kode Mono'") && [...document.fonts].some(f => f.family.includes('Kode Mono') && f.status === 'loaded'), 'Kode Mono Bold': [...document.fonts].some(f => f.family === 'Kode Mono Bold' && f.status === 'loaded') };
|
|
99
|
+
out.text = document.querySelector('#pages').innerText;
|
|
100
|
+
out.steps = [...document.querySelectorAll('.step[data-step]')].map(s => ({ mates: s.dataset.mates || '', wiring: s.dataset.wiring || '', hardware: s.dataset.hardware || '' }));
|
|
101
|
+
return out;
|
|
102
|
+
})()`;
|
|
103
|
+
|
|
104
|
+
const unescapeText = (s: string) => s.replace(/\s+/g, " ").trim();
|
|
105
|
+
|
|
106
|
+
export async function verifyDocs(p: Project, opts: { only?: DocType } = {}): Promise<Finding[]> {
|
|
107
|
+
const ctx: BuiltContext = await buildContext(p);
|
|
108
|
+
const docDir = ctx.sys.absDocs;
|
|
109
|
+
const docRel = ctx.sys.docsDir;
|
|
110
|
+
const bomRel = bomFile(ctx.sys);
|
|
111
|
+
const findings: Finding[] = [];
|
|
112
|
+
const server = await startServer(ctx.sys.roots);
|
|
113
|
+
const browser = await launchBrowser();
|
|
114
|
+
try {
|
|
115
|
+
for (const type of Object.keys(DOC_TYPES) as DocType[]) {
|
|
116
|
+
if (opts.only && opts.only !== type) continue;
|
|
117
|
+
const name = `${ctx.sys.system.id}-${DOC_TYPES[type].suffix}`;
|
|
118
|
+
const add = (check: string, severity: Finding["severity"], message: string, page?: number) => findings.push({ doc: name, check, severity, message, page });
|
|
119
|
+
const html = join(docDir, `${name}.html`);
|
|
120
|
+
if (!existsSync(html)) {
|
|
121
|
+
add("exists", "error", `${name}.html not found; run \`protoboard run tech-docs\``);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
await browser.setViewport(1200, 1600);
|
|
125
|
+
await browser.navigate(`${server.url}/${projectPath(docRel)}/${name}.html`);
|
|
126
|
+
await browser.evaluate("document.fonts.ready.then(() => true)");
|
|
127
|
+
await browser.evaluate("Promise.all([...document.images].map(i => i.complete ? 0 : new Promise(r => { i.onload = i.onerror = r; })))");
|
|
128
|
+
const dom = await browser.evaluate<DomReport>(DOM_PROBE);
|
|
129
|
+
|
|
130
|
+
// ---- values
|
|
131
|
+
let checked = 0;
|
|
132
|
+
const unitSpellings = new Map<string, Set<string>>();
|
|
133
|
+
for (const v of dom.values) {
|
|
134
|
+
const r = ctx.resolve(v.q);
|
|
135
|
+
const expected = r.status === "gap" && (r.value === undefined || r.value === null) ? "—" : formatValue(r.value, r.unit, v.f ?? undefined);
|
|
136
|
+
checked++;
|
|
137
|
+
// the documents print with glyph substitutions (µ as μ, ...): compare through the same mapping
|
|
138
|
+
if (glyphSafe(unescapeText(expected)) !== glyphSafe(unescapeText(v.text))) add("values", "error", `${v.q}${v.f ? ` [${v.f}]` : ""}: document shows "${v.text}", model gives "${expected}"`, v.page);
|
|
139
|
+
if (v.s && v.s !== r.status) add("values", "error", `${v.q}: status in document "${v.s}", model now "${r.status}"`, v.page);
|
|
140
|
+
// format: numbers with units need a space; collect unit spellings
|
|
141
|
+
const numeric = !(v.f ?? "").includes("hex") && typeof r.value === "number" || (Array.isArray(r.value) && r.value.every((x) => typeof x === "number"));
|
|
142
|
+
const m = numeric ? v.text.match(/^[−-]?\d[\d.]*(?:\u00a0\d{3})*(?:–[−-]?\d[\d.]*(?:\u00a0\d{3})*)?(\s?)([^\d\s].*)?$/) : null;
|
|
143
|
+
if (m && m[2] && !m[2].startsWith(":") && m[2] !== "°" && !m[1]) add("format", "error", `${v.q}: "${v.text}" has no space between value and unit`, v.page);
|
|
144
|
+
if (m && m[2]) {
|
|
145
|
+
const key = m[2].toLowerCase();
|
|
146
|
+
unitSpellings.set(key, new Set([...(unitSpellings.get(key) ?? []), m[2]]));
|
|
147
|
+
}
|
|
148
|
+
// stand-in marker
|
|
149
|
+
if (v.s === "standin" && v.tag === "span" && !(v.marker ?? "").includes("mk-standin") && !v.q.startsWith("derived:diagCount")) add("standin", "error", `${v.q}: stand-in value without the S marker`, v.page);
|
|
150
|
+
if (/^(NaN|undefined|null|Infinity)$/.test(v.text.trim())) add("placeholders", "error", `${v.q}: renders "${v.text}"`, v.page);
|
|
151
|
+
}
|
|
152
|
+
for (const [k, spellings] of unitSpellings) if (spellings.size > 1) add("format", "warning", `unit spelled ${[...spellings].map((x) => `"${x}"`).join(" and ")} (${k})`);
|
|
153
|
+
add("values", "info", `${checked} bound values re-resolved against the model`);
|
|
154
|
+
|
|
155
|
+
// ---- placeholders in visible text
|
|
156
|
+
for (const pat of [/\bNaN\b/, /\bundefined\b/, /\[object Object\]/, /\{"\w+":/, /\bTODO\b/, /\bTBD\b/, /\{\{|\}\}/, /\bInfinity\b/, /\bnull\b/]) {
|
|
157
|
+
const m = dom.text.match(pat);
|
|
158
|
+
if (m) add("placeholders", "error", `visible text contains "${m[0]}" near "${dom.text.slice(Math.max(0, (m.index ?? 0) - 40), (m.index ?? 0) + 40).replace(/\s+/g, " ")}"`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---- stand-in charts
|
|
162
|
+
for (const c of dom.charts) {
|
|
163
|
+
const ids = c.tests.split(" ").filter(Boolean);
|
|
164
|
+
const statuses = ids.map((id) => ctx.tests.get(id)?.status);
|
|
165
|
+
if (ids.some((id) => !ctx.tests.get(id))) add("standin", "error", `chart draws missing test data ${c.tests}`, c.page);
|
|
166
|
+
const standin = statuses.includes("standin");
|
|
167
|
+
if (standin && (!c.mark || c.status !== "standin")) add("standin", "error", `chart from stand-in data (${c.tests}) has no STAND-IN watermark`, c.page);
|
|
168
|
+
if (!standin && ids.length && c.mark) add("standin", "error", `chart from measured data (${c.tests}) still carries the stand-in watermark; rebuild`, c.page);
|
|
169
|
+
}
|
|
170
|
+
if (dom.values.some((v) => v.s === "standin") && !/Stand-in/.test(dom.text)) add("standin", "error", "stand-in values are shown but the marker legend is missing");
|
|
171
|
+
|
|
172
|
+
// ---- images
|
|
173
|
+
for (const im of dom.images) {
|
|
174
|
+
const file = join(docDir, im.src);
|
|
175
|
+
if (!im.ok || !existsSync(file)) {
|
|
176
|
+
add("images", "error", `image ${im.src} missing or failed to load`, im.page);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
const st = inkStats(decodePng(file));
|
|
181
|
+
if (st.ink < 0.004 || st.spread < 3) add("images", "error", `image ${im.src} looks blank (ink ${(st.ink * 100).toFixed(2)} %, spread ${st.spread.toFixed(1)})`, im.page);
|
|
182
|
+
} catch (e) {
|
|
183
|
+
add("images", "error", `image ${im.src}: ${(e as Error).message}`, im.page);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
add("images", "info", `${dom.images.length} images checked`);
|
|
187
|
+
|
|
188
|
+
// ---- layout
|
|
189
|
+
for (const p of dom.overflow) add("layout", "error", "page body overflows (content cut off)", p);
|
|
190
|
+
for (const w of dom.wide) add("layout", "error", `${w.what} is ${w.by}px wider than the page body`, w.page);
|
|
191
|
+
for (const s of dom.stranded) add("layout", "error", `heading or keep-with-next block stranded at the end of the page (${s.what})`, s.page);
|
|
192
|
+
for (const t of dom.thinTables) add("layout", "error", `table continuation with only ${t.rows} row(s)`, t.page);
|
|
193
|
+
const pdf = join(docDir, `${name}.pdf`);
|
|
194
|
+
if (existsSync(pdf)) {
|
|
195
|
+
const pages = Number(execFileSync("pdfinfo", [pdf]).toString().match(/Pages:\s+(\d+)/)?.[1]);
|
|
196
|
+
if (pages !== dom.pages) add("layout", "error", `PDF has ${pages} pages, HTML has ${dom.pages} (print CSS is breaking pages)`);
|
|
197
|
+
const fonts = execFileSync("pdffonts", [pdf]).toString().split("\n").slice(2).filter(Boolean);
|
|
198
|
+
for (const l of fonts) {
|
|
199
|
+
const cols = l.trim().split(/\s+/);
|
|
200
|
+
const fontName = cols[0];
|
|
201
|
+
const emb = cols[cols.length - 5];
|
|
202
|
+
if (emb !== "yes") add("typography", "error", `PDF font ${fontName} is not embedded`);
|
|
203
|
+
if (/DejaVu|Liberation|Noto|Arial|Helvetica|Times|Courier|FreeMono|Ubuntu/i.test(fontName)) add("typography", "warning", `PDF falls back to ${fontName.replace(/^[A-Z]{6}\+/, "")} for glyphs the document fonts lack`);
|
|
204
|
+
}
|
|
205
|
+
// page images for the visual review
|
|
206
|
+
const review = join(docDir, ".review", name);
|
|
207
|
+
rmSync(review, { recursive: true, force: true });
|
|
208
|
+
mkdirSync(review, { recursive: true });
|
|
209
|
+
try {
|
|
210
|
+
execFileSync("pdftoppm", ["-r", "70", "-png", pdf, join(review, "page")]);
|
|
211
|
+
const pngs = readdirSync(review).filter((f) => f.endsWith(".png"));
|
|
212
|
+
for (const f of pngs) {
|
|
213
|
+
const st = inkStats(decodePng(join(review, f)));
|
|
214
|
+
if (st.ink < 0.003) add("layout", "error", `PDF ${f} is blank`, Number(f.match(/(\d+)/)?.[1]));
|
|
215
|
+
}
|
|
216
|
+
add("review", "info", `${pngs.length} page PNGs in ${docRel}/.review/${name}/ for the visual checklist`);
|
|
217
|
+
} catch {
|
|
218
|
+
add("review", "warning", "pdftoppm not available: no page PNGs for the visual review");
|
|
219
|
+
}
|
|
220
|
+
} else add("layout", "error", `${name}.pdf missing`);
|
|
221
|
+
|
|
222
|
+
// ---- typography
|
|
223
|
+
const allowed = new Set(["Kode Mono", "Kode Mono Bold"]);
|
|
224
|
+
for (const f of dom.fonts) if (!allowed.has(f.family)) add("typography", "error", `text in font "${f.family}" (${f.count} runs)`);
|
|
225
|
+
for (const [f, ok] of Object.entries(dom.fontsLoaded))
|
|
226
|
+
if (ok) continue;
|
|
227
|
+
else add("typography", "warning", `font ${f} did not load (assets/fonts)`);
|
|
228
|
+
const small = dom.sizes.filter((s) => s < 5);
|
|
229
|
+
if (small.length) add("typography", "error", `text below 5 pt: ${small.join(", ")} pt`);
|
|
230
|
+
if (dom.sizes.length > 16) add("typography", "warning", `${dom.sizes.length} distinct text sizes (${dom.sizes.join(", ")} pt): tighten the type scale`);
|
|
231
|
+
add("typography", "info", `text sizes ${dom.sizes.join(", ")} pt; fonts ${dom.fonts.map((f) => f.family).join(", ")}`);
|
|
232
|
+
|
|
233
|
+
// ---- BOM
|
|
234
|
+
if (type === "datasheet") {
|
|
235
|
+
const bom = ctx.generatedBom;
|
|
236
|
+
if (!bom) add("bom", "error", `${bomRel} missing; run \`protoboard run tech-docs\``);
|
|
237
|
+
else {
|
|
238
|
+
const key = (rows: any[]) => JSON.stringify(rows.map((r) => [r.partId, r.quantity, r.buy]));
|
|
239
|
+
if (key(bom) !== key(ctx.liveBom)) add("bom", "error", `${bomRel} is stale against the model: rebuild the documents`);
|
|
240
|
+
for (const row of bom) {
|
|
241
|
+
const qs = dom.values.filter((v) => v.q.startsWith(`sys:bom[line=${row.line}].`));
|
|
242
|
+
const qty = qs.find((v) => v.q.endsWith(".quantity"));
|
|
243
|
+
const nm = qs.find((v) => v.q.endsWith(".name"));
|
|
244
|
+
if (!qty || !nm) add("bom", "error", `BOM line ${row.line} (${row.partId}) is not in the document`);
|
|
245
|
+
}
|
|
246
|
+
const lines = new Set(dom.values.filter((v) => /^sys:bom\[line=\d+\]\.line$/.test(v.q)).map((v) => v.q));
|
|
247
|
+
if (lines.size !== bom.length) add("bom", "error", `document lists ${lines.size} BOM lines, ${bomRel} has ${bom.length}`);
|
|
248
|
+
else add("bom", "info", `${bom.length} BOM lines match ${bomRel}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ---- assembly coverage
|
|
253
|
+
if (type === "assembly") {
|
|
254
|
+
const mates = new Set(dom.steps.flatMap((s) => s.mates.split(" ").filter(Boolean)));
|
|
255
|
+
const wires = new Set(dom.steps.flatMap((s) => s.wiring.split(" ").filter(Boolean)));
|
|
256
|
+
const hw = new Set(dom.steps.flatMap((s) => s.hardware.split(" ").filter(Boolean)));
|
|
257
|
+
for (const m of ctx.scene.assembly.mates) if (!mates.has(m.linkId)) add("coverage", "error", `mate ${m.linkId} is in no step`);
|
|
258
|
+
for (const h of ctx.scene.hardware) if (!hw.has(h.key)) add("coverage", "error", `hardware ${h.key} (${h.def.id}) is in no step`);
|
|
259
|
+
for (const w of ctx.wiring.filter((x) => x.domain !== "mechanical")) if (!wires.has(w.linkId)) add("coverage", "error", `wiring link ${w.linkId} is in no step`);
|
|
260
|
+
const known = new Set(ctx.scene.hardware.map((h) => h.key));
|
|
261
|
+
for (const k of hw) if (!known.has(k)) add("coverage", "error", `step lists hardware ${k}, which the model no longer places`);
|
|
262
|
+
const placedModules = ctx.scene.bodies.filter((b) => b.via && !b.via.startsWith("hint:"));
|
|
263
|
+
add("coverage", "info", `${mates.size}/${ctx.scene.assembly.mates.length} mates, ${hw.size}/${ctx.scene.hardware.length} hardware placements, ${wires.size}/${ctx.wiring.filter((x) => x.domain !== "mechanical").length} wiring links covered; ${placedModules.length} bodies placed by mates`);
|
|
264
|
+
const plans = planSteps({ ctx } as any);
|
|
265
|
+
if (plans.some((p) => p.spec.id.startsWith("auto-"))) add("coverage", "warning", `doc spec leaves ${plans.filter((p) => p.spec.id.startsWith("auto-")).length} automatic step(s); order them in docspec.ts`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
} finally {
|
|
269
|
+
await browser.close();
|
|
270
|
+
await server.close();
|
|
271
|
+
}
|
|
272
|
+
const reportDir = join(docDir, ".review");
|
|
273
|
+
mkdirSync(reportDir, { recursive: true });
|
|
274
|
+
writeFileSync(join(reportDir, "verify-report.json"), JSON.stringify({ system: ctx.sys.system.id, project: ctx.sys.name, at: new Date().toISOString(), findings }, null, 1) + "\n");
|
|
275
|
+
return findings;
|
|
276
|
+
}
|
package/ops/docs.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { OperationFn } from "@protoboardai/core";
|
|
4
|
+
import { buildDocs, DOC_TYPES, type DocType } from "../lib/build.ts";
|
|
5
|
+
import { DOCS_DIR } from "../lib/model.ts";
|
|
6
|
+
import { verifyDocs } from "../lib/verify.ts";
|
|
7
|
+
|
|
8
|
+
const docType = (v?: string): DocType | undefined | Error => {
|
|
9
|
+
if (!v) return undefined;
|
|
10
|
+
return v in DOC_TYPES ? (v as DocType) : new Error(`only must be ${Object.keys(DOC_TYPES).join(" or ")}, not "${v}"`);
|
|
11
|
+
};
|
|
12
|
+
const flag = (v: string | undefined, fallback: boolean) => (v === undefined ? fallback : !/^(false|no|0)$/i.test(v));
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* tech-docs: build the datasheet and assembly guide for the project's root
|
|
16
|
+
* system from its model, its CAD artifacts and docs/docspec.ts. Every file
|
|
17
|
+
* the documents use is recorded as an output; run tech-docs-verify next.
|
|
18
|
+
*/
|
|
19
|
+
export const techDocs: OperationFn = async (ctx) => {
|
|
20
|
+
const only = docType(ctx.inputs.only);
|
|
21
|
+
if (only instanceof Error) return { status: "failed", summary: only.message };
|
|
22
|
+
const report = await buildDocs(ctx.project, { only, pdf: flag(ctx.inputs.pdf, true), standins: flag(ctx.inputs.standins, true), log: ctx.log });
|
|
23
|
+
const files = [...report.docs.flatMap((d) => [d.html, d.values, ...(d.pdf ? [d.pdf] : [])]), ...report.files];
|
|
24
|
+
for (const f of files) ctx.writeOutput(f, readFileSync(join(ctx.project.root, f)), "technical document");
|
|
25
|
+
const overflow = report.docs.filter((d) => d.overflow.length);
|
|
26
|
+
return {
|
|
27
|
+
status: overflow.length ? "failed" : "succeeded",
|
|
28
|
+
summary: `${report.docs.map((d) => `${d.type}: ${d.pages} pages, ${d.boundValues} bound values`).join("; ")}${overflow.length ? `; page overflow in ${overflow.map((d) => d.type).join(", ")}` : ""}${report.fonts ? "" : "; no document fonts found, system monospace used"}`,
|
|
29
|
+
data: report,
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* tech-docs-verify: re-resolve every bound value against the live model and
|
|
35
|
+
* check units, placeholders, stand-in marking, images, layout, typography,
|
|
36
|
+
* the BOM and assembly coverage. Fails on any error; writes the report and
|
|
37
|
+
* page PNGs for the visual review under docs/.review/ (gitignored).
|
|
38
|
+
*/
|
|
39
|
+
export const techDocsVerify: OperationFn = async (ctx) => {
|
|
40
|
+
const only = docType(ctx.inputs.only);
|
|
41
|
+
if (only instanceof Error) return { status: "failed", summary: only.message };
|
|
42
|
+
const findings = await verifyDocs(ctx.project, { only });
|
|
43
|
+
const reportFile = `${DOCS_DIR}/.review/verify-report.json`;
|
|
44
|
+
ctx.writeOutput(reportFile, readFileSync(join(ctx.project.root, reportFile)), "verification report");
|
|
45
|
+
const count = (s: string) => findings.filter((f) => f.severity === s).length;
|
|
46
|
+
for (const f of findings.filter((x) => x.severity !== "info")) ctx.log(`${f.severity.toUpperCase().padEnd(7)} ${f.doc} · ${f.check}${f.page ? ` · p${f.page}` : ""} — ${f.message}`);
|
|
47
|
+
return {
|
|
48
|
+
status: count("error") ? "failed" : "succeeded",
|
|
49
|
+
summary: `${count("error")} error(s), ${count("warning")} warning(s), ${count("info")} info`,
|
|
50
|
+
data: { findings },
|
|
51
|
+
};
|
|
52
|
+
};
|
package/ops/package.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contributions to the Manufacturing Package (@protoboardai/ext-release section
|
|
3
|
+
* contract): each unit's spec sheet gets what is made for it (boards, parts,
|
|
4
|
+
* cables, from the providers' standard outputs, with figures), and its
|
|
5
|
+
* assembly guide the illustrated steps of the links the unit makes.
|
|
6
|
+
* `protoboard release` calls these once per unit.
|
|
7
|
+
*/
|
|
8
|
+
export { spec } from "../lib/package/spec.ts";
|
|
9
|
+
export { assembly } from "../lib/package/assembly.ts";
|
package/package.json
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protoboardai/ext-docs",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Protoboard extension: technical documents (datasheet, assembly guide) generated from a project's UHD model, with every value a query into the model, figures rendered from its CAD, and a verifier",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"author": "Delta Robotics, Inc.",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/Delta-Robotics-Inc/protoboard-stack.git",
|
|
10
|
+
"directory": "packages/ext-docs"
|
|
11
|
+
},
|
|
12
|
+
"type": "module",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./lib/types.ts",
|
|
15
|
+
"./html": "./lib/html.ts",
|
|
16
|
+
"./blocks": "./lib/blocks.ts",
|
|
17
|
+
"./build": "./lib/build.ts",
|
|
18
|
+
"./model": "./lib/model.ts",
|
|
19
|
+
"./figures": "./lib/figures.ts",
|
|
20
|
+
"./renderer": "./lib/render/renderer.ts",
|
|
21
|
+
"./plan": "./lib/package/plan.ts",
|
|
22
|
+
"./package.json": "./package.json"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"protoboard-extension.json",
|
|
26
|
+
"lib",
|
|
27
|
+
"fonts",
|
|
28
|
+
"ops",
|
|
29
|
+
"skills",
|
|
30
|
+
"evals",
|
|
31
|
+
"LICENSE",
|
|
32
|
+
"NOTICE"
|
|
33
|
+
],
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=22"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"three": "^0.180.0"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"@protoboardai/core": "^0.2.0",
|
|
45
|
+
"@deltarobotics/uhd": ">=0.3.0",
|
|
46
|
+
"@protoboardai/ext-release": "^0.2.0"
|
|
47
|
+
},
|
|
48
|
+
"peerDependenciesMeta": {
|
|
49
|
+
"@protoboardai/ext-release": {
|
|
50
|
+
"optional": true
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": "protoboard.extension/v0",
|
|
3
|
+
"id": "@protoboardai/ext-docs",
|
|
4
|
+
"version": "0.2.0",
|
|
5
|
+
"description": "Technical documents (datasheet, assembly guide) for a project's system: every value a query into the UHD model, figures rendered from the CAD artifacts, stand-in test data watermarked until measured data replaces it, and a verifier.",
|
|
6
|
+
"compatibility": {
|
|
7
|
+
"protoboard": "^0.2.0",
|
|
8
|
+
"uhd": ">=0.3.0"
|
|
9
|
+
},
|
|
10
|
+
"skills": [
|
|
11
|
+
{
|
|
12
|
+
"id": "uhd-tech-docs",
|
|
13
|
+
"summary": "Generate datasheets and assembly guides whose every value is a query into the UHD model",
|
|
14
|
+
"path": "skills/uhd-tech-docs/SKILL.md",
|
|
15
|
+
"verifiedBy": "uhd-tech-docs-verify"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"id": "uhd-tech-docs-verify",
|
|
19
|
+
"summary": "Verify generated technical documents: values, units, stand-in watermarks, layout, coverage",
|
|
20
|
+
"path": "skills/uhd-tech-docs-verify/SKILL.md"
|
|
21
|
+
}
|
|
22
|
+
],
|
|
23
|
+
"tools": [
|
|
24
|
+
{
|
|
25
|
+
"id": "chrome",
|
|
26
|
+
"purpose": "A Chromium-family browser (Google Chrome, Chromium, Microsoft Edge or Brave), run headless: the datasheet and the assembly guide are laid out, their figures drawn and the PDFs printed with it",
|
|
27
|
+
"probe": ["protoboard:browser"],
|
|
28
|
+
"install": "install Google Chrome, Chromium, Microsoft Edge or Brave (found where each platform installs them), or set CHROME to a browser's executable"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"id": "poppler",
|
|
32
|
+
"purpose": "poppler-utils: pdfinfo, pdffonts (document checks) and pdftoppm (review page images)",
|
|
33
|
+
"probe": ["sh", "-c", "exec pdfinfo -v 2>&1"],
|
|
34
|
+
"install": "apt install poppler-utils (brew install poppler)"
|
|
35
|
+
}
|
|
36
|
+
],
|
|
37
|
+
"operations": [
|
|
38
|
+
{
|
|
39
|
+
"id": "tech-docs",
|
|
40
|
+
"summary": "Build the datasheet and assembly guide (HTML, PDF, bound values) for the project's root system from its model, CAD artifacts and docs/docspec.ts; stand-in test data is refreshed, measured data kept",
|
|
41
|
+
"entry": "./ops/docs.ts#techDocs",
|
|
42
|
+
"effect": "export",
|
|
43
|
+
"requires": ["chrome"],
|
|
44
|
+
"inputs": {
|
|
45
|
+
"only": {
|
|
46
|
+
"description": "datasheet or assembly (default: both)"
|
|
47
|
+
},
|
|
48
|
+
"pdf": {
|
|
49
|
+
"description": "print the PDFs (true/false)",
|
|
50
|
+
"default": "true"
|
|
51
|
+
},
|
|
52
|
+
"standins": {
|
|
53
|
+
"description": "refresh stand-in test data from the model first (true/false)",
|
|
54
|
+
"default": "true"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"id": "tech-docs-verify",
|
|
60
|
+
"summary": "Verify the built documents against the live model: values, units, placeholders, stand-in marks, images, layout, typography, BOM, assembly coverage; fails on any error",
|
|
61
|
+
"entry": "./ops/docs.ts#techDocsVerify",
|
|
62
|
+
"effect": "export",
|
|
63
|
+
"requires": ["chrome", "poppler"],
|
|
64
|
+
"inputs": {
|
|
65
|
+
"only": {
|
|
66
|
+
"description": "datasheet or assembly (default: both)"
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"id": "package:spec",
|
|
72
|
+
"summary": "Manufacturing Package: what is made for each unit (boards, mechanical parts, cables) from the providers' standard outputs, with figures from their GLBs",
|
|
73
|
+
"entry": "./ops/package.ts#spec",
|
|
74
|
+
"effect": "pure"
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"id": "package:assembly",
|
|
78
|
+
"summary": "Manufacturing Package: each unit's assembly guide: tools, kit with thumbnails, one illustrated step per planned step (exploded mates, routed cables), connections, torques, coverage",
|
|
79
|
+
"entry": "./ops/package.ts#assembly",
|
|
80
|
+
"effect": "pure"
|
|
81
|
+
}
|
|
82
|
+
]
|
|
83
|
+
}
|