@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.
Files changed (55) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +18 -0
  3. package/README.md +78 -2
  4. package/evals/build-datasheet/case.json +147 -0
  5. package/fonts/KodeMono-Bold.ttf +0 -0
  6. package/fonts/KodeMono-Medium.ttf +0 -0
  7. package/fonts/KodeMono-Regular.ttf +0 -0
  8. package/fonts/KodeMono-SemiBold.ttf +0 -0
  9. package/fonts/OFL.txt +93 -0
  10. package/lib/blocks.ts +268 -0
  11. package/lib/bom.ts +126 -0
  12. package/lib/build.ts +202 -0
  13. package/lib/charts.ts +241 -0
  14. package/lib/context.ts +122 -0
  15. package/lib/derived.ts +181 -0
  16. package/lib/docs/assembly.ts +674 -0
  17. package/lib/docs/common.ts +81 -0
  18. package/lib/docs/datasheet.ts +875 -0
  19. package/lib/docs/product.ts +45 -0
  20. package/lib/figures.ts +195 -0
  21. package/lib/format.ts +221 -0
  22. package/lib/html.ts +135 -0
  23. package/lib/model.ts +312 -0
  24. package/lib/package/assembly.ts +268 -0
  25. package/lib/package/common.ts +109 -0
  26. package/lib/package/plan.ts +432 -0
  27. package/lib/package/spec.ts +203 -0
  28. package/lib/png.ts +91 -0
  29. package/lib/render/cdp.ts +5 -0
  30. package/lib/render/page/render.html +17 -0
  31. package/lib/render/page/render.js +390 -0
  32. package/lib/render/renderer.ts +161 -0
  33. package/lib/render/server.ts +105 -0
  34. package/lib/standin.ts +36 -0
  35. package/lib/standins/multirotor.ts +283 -0
  36. package/lib/templates/doc.css +247 -0
  37. package/lib/templates/flow.js +131 -0
  38. package/lib/testdata.ts +85 -0
  39. package/lib/types.ts +120 -0
  40. package/lib/values.ts +309 -0
  41. package/lib/verify.ts +276 -0
  42. package/ops/docs.ts +52 -0
  43. package/ops/package.ts +9 -0
  44. package/package.json +51 -4
  45. package/protoboard-extension.json +83 -0
  46. package/skills/uhd-tech-docs/SKILL.md +234 -0
  47. package/skills/uhd-tech-docs/references/docspec.md +56 -0
  48. package/skills/uhd-tech-docs/references/house-style.md +50 -0
  49. package/skills/uhd-tech-docs/references/style-study.md +270 -0
  50. package/skills/uhd-tech-docs/references/test-data.md +49 -0
  51. package/skills/uhd-tech-docs/references/value-queries.md +67 -0
  52. package/skills/uhd-tech-docs/templates/test-data.example.json +33 -0
  53. package/skills/uhd-tech-docs/templates/test-data.schema.json +213 -0
  54. package/skills/uhd-tech-docs-verify/SKILL.md +104 -0
  55. package/skills/uhd-tech-docs-verify/references/visual-review.md +43 -0
package/lib/blocks.ts ADDED
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Shared document blocks: headings, tables, figures with vector callouts,
3
+ * admonitions, and the 2D pad-to-pad wiring diagram.
4
+ */
5
+ import { relative } from "path";
6
+ import type { InterfaceDef, ModuleDef } from "@deltarobotics/uhd";
7
+ import { resolveLeafEndpoint } from "@deltarobotics/uhd";
8
+ import type { WiringStep } from "@deltarobotics/uhd";
9
+ import { esc } from "./html.ts";
10
+ import type { RenderResult } from "./render/renderer.ts";
11
+
12
+ export const sec = (num: string, title: string, aside = "", attrs = "") =>
13
+ `<h1 class="sec" data-num="${esc(num)}" data-toc="${esc(title)}"${attrs}><span class="num">${esc(num)}</span>${esc(title)}${aside ? `<span class="aside">${aside}</span>` : ""}</h1>`;
14
+
15
+ export const sub = (title: string) => `<h2 class="sub">${esc(title)}</h2>`;
16
+
17
+ export interface Col {
18
+ h: string;
19
+ cls?: string;
20
+ }
21
+
22
+ export function table(cols: Col[], rows: (string | { group: string })[][] | string[][], opts: { cls?: string; split?: boolean; caption?: string; foot?: string } = {}): string {
23
+ const head = `<thead><tr>${cols.map((c) => `<th class="${c.cls ?? ""}">${c.h}</th>`).join("")}</tr></thead>`;
24
+ const body = (rows as any[])
25
+ .map((r) => {
26
+ if (!Array.isArray(r) && r.group) return `<tr class="group"><td colspan="${cols.length}">${esc(r.group)}</td></tr>`;
27
+ return `<tr>${(r as string[]).map((c, i) => `<td class="${cols[i]?.cls ?? ""}">${c}</td>`).join("")}</tr>`;
28
+ })
29
+ .join("");
30
+ const cls = [opts.cls, opts.split ? "split" : ""].filter(Boolean).join(" ");
31
+ return `<div class="tblock block"><div class="cont-note">${esc(opts.caption ?? "")} (continued)</div><table class="${cls}">${head}<tbody>${body}</tbody></table>${opts.foot ? `<div class="fn">${opts.foot}</div>` : ""}</div>`;
32
+ }
33
+
34
+ export const groupRow = (group: string) => ({ group });
35
+
36
+ /** Image path relative to the document, with forward slashes on every system (a URL in the HTML; PB-1018). */
37
+ export const rel = (docDir: string, file: string) => relative(docDir, file).split("\\").join("/");
38
+
39
+ export function figure(o: { src: string; w: number; h: number; overlay?: string; caption?: string; num?: string; unit?: string; cls?: string; maxHeightMm?: number; widthPct?: number }): string {
40
+ const style = o.widthPct ? ` style="width:${o.widthPct}%;margin:0 auto"` : "";
41
+ return `<figure class="block ${o.cls ?? ""}"><div class="figwrap"${style}><img src="${esc(o.src)}" width="${o.w}" height="${o.h}" alt="${esc(o.caption ?? "")}">${
42
+ o.overlay ? `<svg class="overlay" viewBox="0 0 ${o.w} ${o.h}" preserveAspectRatio="none">${o.overlay}</svg>` : ""
43
+ }</div>${o.caption ? `<figcaption>${o.num ? `<b>Figure ${esc(o.num)}</b>` : ""}<span>${o.caption}</span>${o.unit ? `<span class="unit">(unit: ${esc(o.unit)})</span>` : ""}</figcaption>` : ""}</figure>`;
44
+ }
45
+
46
+ export interface CalloutItem {
47
+ anchor: string;
48
+ label: string;
49
+ accent?: boolean;
50
+ /** Text beside the bubble (named label); omit for number-only bubbles. */
51
+ text?: string;
52
+ }
53
+
54
+ /**
55
+ * Callout bubbles in columns at the figure's left and right edges, straight
56
+ * 0.5 pt leaders ending in a dot on the part. Sizes are in figure pixels,
57
+ * scaled from millimetres of the printed figure width.
58
+ */
59
+ export function callouts(fig: RenderResult, items: CalloutItem[], displayWidthMm: number, opts: { edge?: number; side?: "auto" | "left" | "right" } = {}): string {
60
+ const k = fig.width / displayWidthMm; // px per printed mm
61
+ const R = 2.25 * k;
62
+ const edge = (opts.edge ?? 3) * k + R;
63
+ const placed = items
64
+ .map((it) => ({ it, a: fig.anchors[it.anchor] }))
65
+ // an anchor the renderer could not project (no body drawn around it) has no position
66
+ .filter((x) => x.a && Number.isFinite(x.a.x) && Number.isFinite(x.a.y))
67
+ .map((x) => ({ ...x, side: opts.side && opts.side !== "auto" ? opts.side : x.a.x < fig.width / 2 ? "left" : "right" }));
68
+ const out: string[] = [];
69
+ for (const side of ["left", "right"] as const) {
70
+ const col = placed.filter((p) => p.side === side).sort((a, b) => a.a.y - b.a.y);
71
+ const gap = 2.9 * R;
72
+ const ys = col.map((p) => Math.min(Math.max(p.a.y, R + 2), fig.height - R - 2));
73
+ for (let i = 1; i < ys.length; i++) if (ys[i] - ys[i - 1] < gap) ys[i] = ys[i - 1] + gap;
74
+ const over = ys.length ? ys[ys.length - 1] - (fig.height - R - 2) : 0;
75
+ if (over > 0) for (let i = 0; i < ys.length; i++) ys[i] -= over;
76
+ col.forEach((p, i) => {
77
+ const bx = side === "left" ? edge : fig.width - edge;
78
+ const by = ys[i];
79
+ const dx = p.a.x - bx;
80
+ const dy = p.a.y - by;
81
+ const len = Math.hypot(dx, dy) || 1;
82
+ const sx = bx + (dx / len) * R;
83
+ const sy = by + (dy / len) * R;
84
+ out.push(`<line class="co-leader" x1="${sx.toFixed(1)}" y1="${sy.toFixed(1)}" x2="${p.a.x.toFixed(1)}" y2="${p.a.y.toFixed(1)}" stroke-width="${(0.18 * k).toFixed(2)}"/>`);
85
+ out.push(`<circle class="co-dot" cx="${p.a.x.toFixed(1)}" cy="${p.a.y.toFixed(1)}" r="${(0.55 * k).toFixed(1)}"/>`);
86
+ out.push(`<circle class="co-bubble${p.it.accent ? " acc" : ""}" cx="${bx.toFixed(1)}" cy="${by.toFixed(1)}" r="${R.toFixed(1)}" stroke-width="${(0.18 * k).toFixed(2)}"/>`);
87
+ out.push(`<text class="co-num${p.it.accent ? " acc" : ""}" x="${bx.toFixed(1)}" y="${(by + 0.95 * k).toFixed(1)}" text-anchor="middle" font-size="${(2.6 * k).toFixed(1)}">${esc(p.it.label)}</text>`);
88
+ if (p.it.text) {
89
+ const tx = side === "left" ? bx + R + 1.2 * k : bx - R - 1.2 * k;
90
+ out.push(`<text class="co-label" x="${tx.toFixed(1)}" y="${(by - 1.4 * k).toFixed(1)}" text-anchor="${side === "left" ? "start" : "end"}" font-size="${(2.3 * k).toFixed(1)}">${esc(p.it.text)}</text>`);
91
+ }
92
+ });
93
+ }
94
+ return out.join("");
95
+ }
96
+
97
+ /** A dimension line between two anchors, offset perpendicular by `offsetMm` (printed mm), label bound to a query. */
98
+ export function dimension(fig: RenderResult, a: string, b: string, labelSvg: (x: number, y: number, size: number, rot: number) => string, displayWidthMm: number, offsetMm = 6): string {
99
+ const A = fig.anchors[a];
100
+ const B = fig.anchors[b];
101
+ if (!A || !B) return "";
102
+ const k = fig.width / displayWidthMm;
103
+ const dx = B.x - A.x;
104
+ const dy = B.y - A.y;
105
+ const len = Math.hypot(dx, dy) || 1;
106
+ const nx = -dy / len;
107
+ const ny = dx / len;
108
+ const o = offsetMm * k;
109
+ const a2 = { x: A.x + nx * o, y: A.y + ny * o };
110
+ const b2 = { x: B.x + nx * o, y: B.y + ny * o };
111
+ const sw = (0.15 * k).toFixed(2);
112
+ const arrow = (p: { x: number; y: number }, dir: number) => {
113
+ const ux = (dx / len) * dir;
114
+ const uy = (dy / len) * dir;
115
+ const s = 1.6 * k;
116
+ const w = 0.55 * k;
117
+ return `<path d="M${p.x},${p.y} L${p.x + ux * s - uy * w},${p.y + uy * s + ux * w} L${p.x + ux * s + uy * w},${p.y + uy * s - ux * w} Z" fill="#d94e00"/>`;
118
+ };
119
+ let rot = (Math.atan2(dy, dx) * 180) / Math.PI;
120
+ if (rot > 90) rot -= 180;
121
+ if (rot < -90) rot += 180;
122
+ const mx = (a2.x + b2.x) / 2 + nx * 1.4 * k;
123
+ const my = (a2.y + b2.y) / 2 + ny * 1.4 * k;
124
+ return [
125
+ `<line x1="${A.x}" y1="${A.y}" x2="${a2.x + nx * 1.2 * k}" y2="${a2.y + ny * 1.2 * k}" stroke="#d94e00" stroke-width="${sw}"/>`,
126
+ `<line x1="${B.x}" y1="${B.y}" x2="${b2.x + nx * 1.2 * k}" y2="${b2.y + ny * 1.2 * k}" stroke="#d94e00" stroke-width="${sw}"/>`,
127
+ `<line x1="${a2.x}" y1="${a2.y}" x2="${b2.x}" y2="${b2.y}" stroke="#d94e00" stroke-width="${sw}"/>`,
128
+ arrow(a2, 1),
129
+ arrow(b2, -1),
130
+ labelSvg(mx, my, 2.5 * k, rot),
131
+ ].join("");
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Admonitions
136
+ // ---------------------------------------------------------------------------
137
+
138
+ const ICON = {
139
+ warning: `<svg viewBox="0 0 24 24"><path d="M12 2.5 1.8 21h20.4L12 2.5z" fill="none" stroke="#c62828" stroke-width="1.8" stroke-linejoin="round"/><path d="M12 9v5.5" stroke="#c62828" stroke-width="2" stroke-linecap="round"/><circle cx="12" cy="17.6" r="1.2" fill="#c62828"/></svg>`,
140
+ caution: `<svg viewBox="0 0 24 24"><path d="M12 2.5 1.8 21h20.4L12 2.5z" fill="none" stroke="#d94e00" stroke-width="1.8" stroke-linejoin="round"/><path d="M12 9v5.5" stroke="#d94e00" stroke-width="2" stroke-linecap="round"/><circle cx="12" cy="17.6" r="1.2" fill="#d94e00"/></svg>`,
141
+ tip: `<svg viewBox="0 0 24 24"><circle cx="12" cy="10" r="6.2" fill="none" stroke="#1b1b1b" stroke-width="1.7"/><path d="M9.3 17.5h5.4M10 21h4" stroke="#1b1b1b" stroke-width="1.7" stroke-linecap="round"/></svg>`,
142
+ info: `<svg viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="1" fill="none" stroke="#1b1b1b" stroke-width="1.7"/><path d="M12 10.5V17" stroke="#1b1b1b" stroke-width="2" stroke-linecap="round"/><circle cx="12" cy="7.4" r="1.2" fill="#1b1b1b"/></svg>`,
143
+ };
144
+ const NOTE_LABEL = { warning: "WARNING", caution: "CAUTION", tip: "TIP", info: "NOTE" };
145
+
146
+ export function note(kind: keyof typeof ICON, html: string): string {
147
+ return `<div class="note ${kind}">${ICON[kind]}<div><b>${NOTE_LABEL[kind]}</b> ${html}</div></div>`;
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // Wiring diagram (pad to pad), for links without 3D geometry
152
+ // ---------------------------------------------------------------------------
153
+
154
+ export interface WireEnd {
155
+ module: string;
156
+ pad: string;
157
+ detail: string;
158
+ }
159
+
160
+ /** Split "stack:uart1 TX1" into module path, interface and pad label. */
161
+ export function parseEnd(s: string): { path: string; iface: string; pad: string } {
162
+ const sp = s.indexOf(" ");
163
+ const head = sp < 0 ? s : s.slice(0, sp);
164
+ const pad = sp < 0 ? "" : s.slice(sp + 1);
165
+ const c = head.lastIndexOf(":");
166
+ return { path: head.slice(0, c), iface: head.slice(c + 1), pad };
167
+ }
168
+
169
+ /** Leaf module and pad label for a wiring end (follows exports through assemblies). */
170
+ export function leafOf(system: ModuleDef, lookup: (id: string) => ModuleDef | undefined, path: string, iface: string) {
171
+ const segs = path.split("/");
172
+ let owner = system;
173
+ for (const s of segs.slice(0, -1)) {
174
+ const ref = owner.children?.find((c) => c.id === s);
175
+ const d = ref && lookup(ref.moduleDefId);
176
+ if (!d) break;
177
+ owner = d;
178
+ }
179
+ const leaf = resolveLeafEndpoint(owner, { child: segs[segs.length - 1], interfaceId: iface }, lookup);
180
+ return leaf;
181
+ }
182
+
183
+ /**
184
+ * leafOf, or for an end that names no single interface (a link-scoped
185
+ * composition: `compose` gathers several pads under an id the module does not
186
+ * declare) the instance itself, with that id as a stand-in interface.
187
+ */
188
+ export function endOf(system: ModuleDef, lookup: (id: string) => ModuleDef | undefined, path: string, iface: string, name?: string) {
189
+ const leaf = leafOf(system, lookup, path, iface);
190
+ if (leaf) return leaf;
191
+ const segs = path.split("/");
192
+ let def: ModuleDef | undefined = system;
193
+ for (const s of segs) {
194
+ const ref: { moduleDefId: string } | undefined = def?.children?.find((c) => c.id === s);
195
+ def = ref ? lookup(ref.moduleDefId) : undefined;
196
+ }
197
+ if (!def) return undefined;
198
+ return { path: segs, def, iface: { id: iface, name: name ?? iface, domain: "electrical", exposed: true } as InterfaceDef };
199
+ }
200
+
201
+ export interface WireRow {
202
+ a: string;
203
+ b: string;
204
+ aDetail?: string;
205
+ bDetail?: string;
206
+ crossover?: boolean;
207
+ signal: string;
208
+ }
209
+
210
+ /** Two module columns with pad rows and straight wires; crossovers in accent. */
211
+ export function wiringDiagram(o: { aTitle: string; bTitle: string; aSub?: string; bSub?: string; rows: WireRow[]; harness?: string }): string {
212
+ const W = 520;
213
+ const rowH = 17;
214
+ const head = 30;
215
+ const top = 8 + head + 6;
216
+ const H = top + o.rows.length * rowH + 18;
217
+ const boxW = 176;
218
+ const ax = 2;
219
+ const bx = W - 2 - boxW;
220
+ const parts: string[] = [];
221
+ const clip = (t: string, n: number) => (t.length > n ? t.slice(0, n - 1) + "…" : t);
222
+ const box = (x: number, title: string, subt?: string) =>
223
+ `<rect x="${x}" y="8" width="${boxW}" height="${H - 20}" fill="#fff" stroke="#1b1b1b" stroke-width="0.8"/>` +
224
+ `<rect x="${x}" y="8" width="${boxW}" height="${head}" fill="#1b1b1b"/>` +
225
+ `<text x="${x + 8}" y="22" font-size="9" font-weight="600" fill="#fff">${esc(clip(title, 30))}</text>` +
226
+ (subt ? `<text x="${x + 8}" y="33" font-size="6.4" fill="#bdbdbd">${esc(clip(subt, 44))}</text>` : "");
227
+ parts.push(box(ax, o.aTitle, o.aSub), box(bx, o.bTitle, o.bSub));
228
+ o.rows.forEach((r, i) => {
229
+ const y = top + i * rowH + rowH / 2;
230
+ const col = r.crossover ? "#ff5c00" : "#1b1b1b";
231
+ parts.push(`<rect x="${ax + boxW - 8}" y="${y - 3.5}" width="8" height="7" fill="${col}"/>`);
232
+ parts.push(`<rect x="${bx}" y="${y - 3.5}" width="8" height="7" fill="${col}"/>`);
233
+ parts.push(`<text x="${ax + boxW - 13}" y="${y + 3}" font-size="8" font-weight="600" text-anchor="end" fill="#1b1b1b">${esc(r.a)}</text>`);
234
+ if (r.aDetail) parts.push(`<text x="${ax + 8}" y="${y + 3}" font-size="6.4" fill="#6f6f6f">${esc(clip(r.aDetail, 30))}</text>`);
235
+ parts.push(`<text x="${bx + 13}" y="${y + 3}" font-size="8" font-weight="600" fill="#1b1b1b">${esc(r.b)}</text>`);
236
+ if (r.bDetail) parts.push(`<text x="${bx + boxW - 8}" y="${y + 3}" font-size="6.4" text-anchor="end" fill="#6f6f6f">${esc(clip(r.bDetail, 30))}</text>`);
237
+ parts.push(`<line x1="${ax + boxW}" y1="${y}" x2="${bx}" y2="${y}" stroke="${col}" stroke-width="${r.crossover ? 1.6 : 1.2}"/>`);
238
+ parts.push(`<text x="${W / 2}" y="${y - 3}" font-size="6.4" text-anchor="middle" fill="${r.crossover ? "#d94e00" : "#6f6f6f"}">${esc(r.signal)}${r.crossover ? " · crossover" : ""}</text>`);
239
+ });
240
+ if (o.harness) parts.push(`<text x="${W / 2}" y="${H - 4}" font-size="6.6" text-anchor="middle" fill="#6f6f6f">via ${esc(o.harness)}</text>`);
241
+ return `<svg class="wiring" viewBox="0 0 ${W} ${H}" width="100%" xmlns="http://www.w3.org/2000/svg" font-family="inherit">${parts.join("")}</svg>`;
242
+ }
243
+
244
+ export function wiringRows(system: ModuleDef, lookup: (id: string) => ModuleDef | undefined, steps: WiringStep[]) {
245
+ // group by the pair of leaf modules
246
+ const groups = new Map<string, { a: ModuleDef; b: ModuleDef; aPath: string; bPath: string; rows: WireRow[]; harness?: string; links: string[] }>();
247
+ for (const s of steps) {
248
+ const ea = parseEnd(s.a);
249
+ const eb = parseEnd(s.b);
250
+ const la = leafOf(system, lookup, ea.path, ea.iface);
251
+ const lb = leafOf(system, lookup, eb.path, eb.iface);
252
+ if (!la || !lb) continue;
253
+ const key = `${la.path.join("/")}|${lb.path.join("/")}`;
254
+ const g = groups.get(key) ?? { a: la.def, b: lb.def, aPath: la.path.join("/"), bPath: lb.path.join("/"), rows: [], harness: s.harness, links: [] };
255
+ g.links.push(s.linkId);
256
+ if (s.connections.length) {
257
+ for (const c of s.connections) {
258
+ const pa = parseEnd(c.from);
259
+ const pb = parseEnd(c.to);
260
+ g.rows.push({ a: pa.pad || pa.iface, b: pb.pad || pb.iface, signal: s.name, crossover: c.crossover, aDetail: la.iface.name, bDetail: lb.iface.name });
261
+ }
262
+ } else {
263
+ g.rows.push({ a: String(la.iface.pin ?? la.iface.id), b: String(lb.iface.pin ?? lb.iface.id), signal: s.name, aDetail: la.iface.name, bDetail: lb.iface.name });
264
+ }
265
+ groups.set(key, g);
266
+ }
267
+ return [...groups.values()];
268
+ }
package/lib/bom.ts ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The bill of materials the documents publish: the system tree walked
3
+ * (children × quantity, through assemblies, groups and harnesses), one row
4
+ * per part (per part and MPN for a series passive placed at several values,
5
+ * from `ChildModuleRef.overrides`) with its primary manufacturer source and its verification
6
+ * summary, so every line can be traced. Moved with the documents generator
7
+ * from UHD's scripts/bom.ts; evidence comes from the project's evidence
8
+ * folders (model.ts `evidenceDirs`). `tech-docs` writes it to
9
+ * `docs/<system-id>-bom.json`, and `tech-docs-verify` checks it against the
10
+ * live model. (The project's plain BOM export is ext-hardware's `bom`.)
11
+ */
12
+ import { existsSync, readFileSync } from "fs";
13
+ import { join } from "path";
14
+ import type { ModuleDef } from "@deltarobotics/uhd";
15
+ import type { ModuleLookup } from "@deltarobotics/uhd";
16
+ import { formatPassive, placementPart } from "@protoboardai/core";
17
+
18
+ export interface BomRow {
19
+ line: number;
20
+ partId: string;
21
+ name: string;
22
+ manufacturer?: string;
23
+ partNumber?: string;
24
+ /** A passive's value as placed, e.g. "4.7 kΩ ±1 %". */
25
+ value?: string;
26
+ quantity: number;
27
+ /** Units per retail pack, when the part is sold in packs. */
28
+ packQuantity?: number;
29
+ buy: "purchase" | "included" | "fabricate";
30
+ includedWith?: string;
31
+ usedBy: string[];
32
+ source?: string;
33
+ verification?: string;
34
+ assumptions: number;
35
+ openGaps: number;
36
+ }
37
+
38
+ function trait(def: ModuleDef, type: string, key: string): unknown {
39
+ for (const t of def.traits ?? []) {
40
+ const params = t.params as Record<string, unknown> | undefined;
41
+ if (t.type === type && params && key in params) return params[key];
42
+ }
43
+ return undefined;
44
+ }
45
+
46
+ function evidence(dirs: string[], def?: ModuleDef): { source?: string; verification?: string; assumptions: number; openGaps: number } {
47
+ const find = (name: string) => dirs.map((d) => join(d, name)).find((f) => existsSync(f));
48
+ // system-level hardware without a research folder cites its supplier listing in a usage_note
49
+ let source: string | undefined = def?.traits?.find((t) => t.type === "usage_note" && typeof t.params?.source === "string")?.params?.source as string | undefined;
50
+ const sources = find("sources.json");
51
+ if (sources) {
52
+ const rows = JSON.parse(readFileSync(sources, "utf8")) as Array<Record<string, unknown>>;
53
+ source = String((rows.find((r) => r.authority === "manufacturer") ?? rows[0])?.url ?? "");
54
+ }
55
+ const verification = find("verification.json");
56
+ if (!verification) {
57
+ const count = (type: string) => def?.traits?.filter((t) => t.type === type).length ?? 0;
58
+ return { source, assumptions: count("assumption"), openGaps: count("data_gap") };
59
+ }
60
+ const v = JSON.parse(readFileSync(verification, "utf8"));
61
+ return {
62
+ source,
63
+ // older records state an audit without counts ({ by, result }); a self-audit
64
+ // (PB-862: audit.independent false) is not shown as an audit
65
+ verification: v.audit
66
+ ? `${v.audit.independent === false ? "self-audited" : "audited"}${typeof v.audit.checked === "number" ? ` ${v.audit.confirmed}/${v.audit.checked}` : ""}`
67
+ : "not audited",
68
+ assumptions: v.assumptions?.length ?? 0,
69
+ openGaps: v.data_gaps?.length ?? 0,
70
+ };
71
+ }
72
+
73
+ /** `evidenceDirs(id)`: the folders that may hold a part's sources.json and verification.json. */
74
+ /** A module designed in the project: it carries its native design (an artifact with role "source"). */
75
+ export const designed = (def: ModuleDef) => (def.artifacts ?? []).some((a) => a.role === "source");
76
+
77
+ export function buildBom(system: ModuleDef, lookup: ModuleLookup, evidenceDirs: (id: string) => string[]): BomRow[] {
78
+ const rows = new Map<string, BomRow>();
79
+
80
+ const visit = (def: ModuleDef, multiplier: number, path: string) => {
81
+ for (const ref of def.children ?? []) {
82
+ const child = lookup(ref.moduleDefId);
83
+ if (!child) throw new Error(`${path}/${ref.id}: unknown module ${ref.moduleDefId}`);
84
+ const qty = multiplier * (ref.quantity ?? 1);
85
+ const childPath = path ? `${path}/${ref.id}` : ref.id;
86
+ if (child.children?.length) {
87
+ // assemblies, groups and harness kits: their contents are the line items. A module designed in the project
88
+ // (it has a native design: a board, a housing with its inserts) is made, so it is a line item too, with its parts
89
+ if (designed(child)) add(child, ref, qty, childPath, "fabricate");
90
+ visit(child, qty, childPath);
91
+ continue;
92
+ }
93
+ add(child, ref, qty, childPath);
94
+ }
95
+ };
96
+ const add = (child: ModuleDef, ref: { overrides?: Record<string, unknown> }, qty: number, childPath: string, buyAs?: BomRow["buy"]) => {
97
+ const includedWith = trait(child, "usage_note", "included_with") as string | undefined;
98
+ const custom = /\(custom\)/i.test(child.manufacturer ?? "");
99
+ const placed = placementPart(child, ref.overrides);
100
+ const value = placed.value ? formatPassive(placed.value) : undefined;
101
+ const key = `${child.id}\u0000${placed.mpn ?? value ?? ""}`;
102
+ const row = rows.get(key) ?? {
103
+ line: 0,
104
+ partId: child.id,
105
+ name: child.name,
106
+ manufacturer: child.manufacturer,
107
+ partNumber: placed.mpn,
108
+ ...(value ? { value } : {}),
109
+ quantity: 0,
110
+ packQuantity: trait(child, "performance", "pack_quantity") as number | undefined,
111
+ buy: buyAs ?? (custom ? "fabricate" : includedWith ? "included" : "purchase"),
112
+ includedWith,
113
+ usedBy: [],
114
+ ...evidence(evidenceDirs(child.id), child),
115
+ };
116
+ row.quantity += qty;
117
+ row.usedBy.push(childPath);
118
+ rows.set(key, row);
119
+ };
120
+ visit(system, 1, "");
121
+
122
+ const order = { purchase: 0, fabricate: 1, included: 2 };
123
+ return [...rows.values()]
124
+ .sort((a, b) => order[a.buy] - order[b.buy] || a.name.localeCompare(b.name) || (a.partNumber ?? "").localeCompare(b.partNumber ?? ""))
125
+ .map((row, i) => ({ ...row, line: i + 1 }));
126
+ }
package/lib/build.ts ADDED
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Build technical documents for a Protoboard project's system (the
3
+ * uhd-tech-docs skill; `protoboard run tech-docs`). Moved from UHD's
4
+ * scripts/docs/build.ts (PB-801) and made to read a project instead of a
5
+ * library system folder.
6
+ *
7
+ * Writes into the project's docs/:
8
+ * <system-id>-datasheet.html / .pdf / .values.json
9
+ * <system-id>-assembly-guide.html / .pdf / .values.json
10
+ * <system-id>-bom.json the BOM the documents publish (bom.ts)
11
+ * images/ figures rendered from the GLB artifacts (cached by content)
12
+ * test-data/ stand-in (and measured) test data
13
+ * .review/<doc>/ page PNGs for visual review (gitignored)
14
+ * assets/fonts/ fonts copied at build time (gitignored)
15
+ *
16
+ * Every value is a query into the model (values.ts); run verify.ts afterwards.
17
+ */
18
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "fs";
19
+ import { execFileSync } from "child_process";
20
+ import { join } from "path";
21
+ import { fileURLToPath } from "url";
22
+ import type { Project } from "@protoboardai/core";
23
+ import { bomFile, buildContext, type BuiltContext } from "./context.ts";
24
+ import { Doc, shell, type DocMeta } from "./html.ts";
25
+ import { FigureRenderer } from "./render/renderer.ts";
26
+ import { projectPath } from "./render/server.ts";
27
+ import { buildDatasheet, provenanceSection } from "./docs/datasheet.ts";
28
+ import { buildAssemblyGuide, assemblyProvenance } from "./docs/assembly.ts";
29
+ import { imgPath, type BuildEnv } from "./docs/common.ts";
30
+ import { writeStandins } from "./standin.ts";
31
+
32
+ export const DOC_TYPES = {
33
+ datasheet: { suffix: "datasheet", title: "Datasheet", code: "DS" },
34
+ assembly: { suffix: "assembly-guide", title: "Assembly guide", code: "AG" },
35
+ } as const;
36
+ export type DocType = keyof typeof DOC_TYPES;
37
+
38
+ /** Kode Mono (SIL Open Font License 1.1), shipped in this package: `fonts/`, with its licence. */
39
+ export const BUNDLED_FONTS = fileURLToPath(new URL("../fonts/", import.meta.url));
40
+
41
+ /** The font files documents use: Kode Mono, its Bold for display text. */
42
+ export const DOC_FONT = /^KodeMono-.*\.ttf$/;
43
+
44
+ /**
45
+ * The folder named by $PROTOBOARD_DOC_FONTS (or the older $UHD_DOC_FONTS), if
46
+ * any: a file there replaces the bundled Kode Mono file of the same name.
47
+ * Documents need none of it.
48
+ */
49
+ export const fontsDir = (): string | undefined => process.env.PROTOBOARD_DOC_FONTS || process.env.UHD_DOC_FONTS || undefined;
50
+
51
+ /**
52
+ * The document font files, by name, each with where it is read from: the
53
+ * bundled Kode Mono, with any file of the same name in $PROTOBOARD_DOC_FONTS
54
+ * taking its place. The default never depends on
55
+ * another checkout, so every machine without the variable renders the same
56
+ * documents (PB-809).
57
+ */
58
+ export function docFonts(): { file: string; path: string }[] {
59
+ const byName = new Map<string, string>();
60
+ for (const dir of [BUNDLED_FONTS, fontsDir()]) {
61
+ if (!dir || !existsSync(dir)) continue;
62
+ for (const f of readdirSync(dir).sort()) if (DOC_FONT.test(f)) byName.set(f, join(dir, f));
63
+ }
64
+ return [...byName.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([file, path]) => ({ file, path }));
65
+ }
66
+
67
+ /** Fonts are copied next to the documents at build time (not committed). */
68
+ function copyFonts(docDir: string): boolean {
69
+ const dst = join(docDir, "assets", "fonts");
70
+ mkdirSync(dst, { recursive: true });
71
+ writeFileSync(join(docDir, "assets", ".gitignore"), "# Fonts are copied in at build time: Kode Mono from @protoboardai/ext-docs.\nfonts/\n");
72
+ const env = fontsDir();
73
+ if (env && !existsSync(env)) console.warn(`fonts: PROTOBOARD_DOC_FONTS=${env} not found; using the bundled Kode Mono only`);
74
+ const fonts = docFonts();
75
+ for (const f of fonts) copyFileSync(f.path, join(dst, f.file));
76
+ return fonts.length > 0;
77
+ }
78
+
79
+ export interface BuildResult {
80
+ type: DocType;
81
+ /** Project-relative files. */
82
+ html: string;
83
+ pdf?: string;
84
+ values: string;
85
+ pages: number;
86
+ overflow: number[];
87
+ boundValues: number;
88
+ }
89
+
90
+ export interface BuildReport {
91
+ docs: BuildResult[];
92
+ /** Project-relative files this build wrote and the documents use (images, test data, BOM). */
93
+ files: string[];
94
+ standins: { written: string[]; kept: string[] };
95
+ figures: { rendered: number; reused: number };
96
+ fonts: boolean;
97
+ }
98
+
99
+ export async function buildDocs(p: Project, opts: { only?: DocType; pdf?: boolean; standins?: boolean; log?: (line: string) => void } = {}): Promise<BuildReport> {
100
+ const log = opts.log ?? ((l: string) => console.log(l));
101
+ let ctx: BuiltContext = await buildContext(p);
102
+ let standins = { written: [] as string[], kept: [] as string[] };
103
+ if (opts.standins !== false) {
104
+ standins = await writeStandins(ctx);
105
+ if (standins.written.length || standins.kept.length) log(`test data: stand-ins ${standins.written.join(", ") || "none"}; measured ${standins.kept.join(", ") || "none"}`);
106
+ ctx = await buildContext(p);
107
+ }
108
+ if (ctx.testErrors.length) throw new Error(`test data invalid:\n ${ctx.testErrors.join("\n ")}`);
109
+ const docDir = ctx.sys.absDocs;
110
+ mkdirSync(docDir, { recursive: true });
111
+ // the BOM the documents publish is the live model's; verify compares the committed file with the model again
112
+ // (the context is rebuilt from the written file: its value resolver reads the BOM it was built with, and a stale file
113
+ // would leave the document's bound BOM values one model behind its tables)
114
+ const bomPath = join(ctx.sys.root, bomFile(ctx.sys));
115
+ const liveJson = JSON.stringify(ctx.liveBom, null, 2) + "\n";
116
+ const stale = !existsSync(bomPath) || readFileSync(bomPath, "utf8") !== liveJson;
117
+ writeFileSync(bomPath, liveJson);
118
+ if (stale) ctx = await buildContext(p);
119
+ ctx = { ...ctx, generatedBom: ctx.liveBom, bom: ctx.liveBom };
120
+ const imgDir = join(docDir, "images");
121
+ mkdirSync(imgDir, { recursive: true });
122
+ writeFileSync(join(docDir, ".gitignore"), "# build byproducts (@protoboardai/ext-docs)\n.review/\nimages/.render-cache.json\n");
123
+ const fonts = copyFonts(docDir);
124
+ const r = new FigureRenderer(imgDir, ctx.sys.roots);
125
+ const date = new Date().toISOString().slice(0, 10);
126
+ const results: BuildResult[] = [];
127
+ const docServed = projectPath(ctx.sys.docsDir);
128
+ try {
129
+ for (const type of Object.keys(DOC_TYPES) as DocType[]) {
130
+ if (opts.only && opts.only !== type) continue;
131
+ const t = DOC_TYPES[type];
132
+ const meta: DocMeta = {
133
+ title: ctx.sys.config.title ?? ctx.sys.system.name,
134
+ docType: t.title,
135
+ docId: `${ctx.sys.config.docId ?? ctx.sys.system.id.toUpperCase()}-${t.code}`,
136
+ revision: ctx.sys.config.revision ?? "A",
137
+ date,
138
+ system: ctx.sys.name,
139
+ };
140
+ const doc = new Doc(ctx, meta);
141
+ const env: BuildEnv = { ctx, doc, r, docDir, docRel: ctx.sys.docsDir, img: imgPath(docDir), counters: { fig: 0 } };
142
+ const body = type === "datasheet" ? (await buildDatasheet(env)) + provenanceSection(env) : (await buildAssemblyGuide(env)) + assemblyProvenance(env);
143
+ const name = `${ctx.sys.system.id}-${t.suffix}`;
144
+ const html = doc.fillLegends(shell(meta, body, { fontsHref: "assets/fonts" }));
145
+ const htmlPath = join(docDir, `${name}.html`);
146
+ writeFileSync(htmlPath, html);
147
+ const pag = await r.paginate(`${docServed}/${name}.html`);
148
+ writeFileSync(htmlPath, pag.html);
149
+ writeFileSync(
150
+ join(docDir, `${name}.values.json`),
151
+ JSON.stringify(
152
+ {
153
+ document: `${name}.html`,
154
+ system: ctx.sys.system.id,
155
+ project: ctx.sys.name,
156
+ docId: meta.docId,
157
+ generated: date,
158
+ values: [...doc.values.values()].map((v) => ({ q: v.q, f: v.f, text: v.text, status: v.status, unit: v.unit, source: v.source, formula: v.formula, inputs: v.inputs, note: v.note })),
159
+ },
160
+ null,
161
+ 1,
162
+ ) + "\n",
163
+ );
164
+ let pdfRel: string | undefined;
165
+ if (opts.pdf !== false) {
166
+ const pdfPath = join(docDir, `${name}.pdf`);
167
+ writeFileSync(pdfPath, await r.printPdf(`${docServed}/${name}.html`));
168
+ pdfRel = `${ctx.sys.docsDir}/${name}.pdf`;
169
+ const review = join(docDir, ".review", name);
170
+ rmSync(review, { recursive: true, force: true });
171
+ mkdirSync(review, { recursive: true });
172
+ try {
173
+ execFileSync("pdftoppm", ["-r", "70", "-png", pdfPath, join(review, "page")]);
174
+ } catch {
175
+ console.warn("pdftoppm not available: no review PNGs");
176
+ }
177
+ }
178
+ results.push({
179
+ type,
180
+ html: `${ctx.sys.docsDir}/${name}.html`,
181
+ pdf: pdfRel,
182
+ values: `${ctx.sys.docsDir}/${name}.values.json`,
183
+ pages: pag.pages,
184
+ overflow: pag.overflow,
185
+ boundValues: doc.values.size,
186
+ });
187
+ log(`${name}: ${pag.pages} pages, ${doc.values.size} bound values${pag.overflow.length ? `, OVERFLOW on pages ${pag.overflow.join(", ")}` : ""}`);
188
+ }
189
+ } finally {
190
+ log(`figures: ${r.rendered} rendered, ${r.reused} reused`);
191
+ await r.close();
192
+ }
193
+ // files only: a project may keep its own pictures in a subfolder (docs/images/viewer)
194
+ const rel = (dir: string) => (existsSync(join(docDir, dir)) ? readdirSync(join(docDir, dir), { withFileTypes: true }).filter((f) => f.isFile() && !f.name.startsWith(".")).map((f) => f.name).sort().map((f) => `${ctx.sys.docsDir}/${dir}/${f}`) : []);
195
+ return {
196
+ docs: results,
197
+ files: [bomFile(ctx.sys), ...rel("images"), ...rel("test-data")],
198
+ standins,
199
+ figures: { rendered: r.rendered, reused: r.reused },
200
+ fonts,
201
+ };
202
+ }