@protoboardai/ext-release 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/lib/release.ts ADDED
@@ -0,0 +1,279 @@
1
+ /**
2
+ * `protoboard release`: the Manufacturing Package (release-package-and-evals.md
3
+ * §1–§3). Nothing is written unless every precondition holds; then the
4
+ * package is built in a staging folder (a temporary directory) and
5
+ * written to release/<version>/ through the operation's recorded writes.
6
+ *
7
+ * Order: configuration and units → preconditions → files collected from the
8
+ * standard outputs → sections from every extension (package:<section>, the
9
+ * release's own first) → figures, unit documents, overview, index.html →
10
+ * manifest.
11
+ */
12
+ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join, relative } from "node:path";
15
+ import { loadEntry, probeTools, readProduct, stableStringify, uhdBundle, type LoadedExtension, type Project } from "@protoboardai/core";
16
+ import { readFx } from "@protoboardai/ext-sourcing/lib/cost.ts";
17
+ import { productDiagnostics } from "./product.ts";
18
+ import { orderingList, orderTotals } from "./bom.ts";
19
+ import { collectUnitFiles } from "./collect.ts";
20
+ import { readReleaseConfig } from "./config.ts";
21
+ import { docId, docPath, indexHtml, overviewBody, unitDocBody, type Contributed, type DocSet } from "./documents.ts";
22
+ import { buildManifest, type ReleaseManifest } from "./manifest.ts";
23
+ import { freshness, gitState, preconditions } from "./preconditions.ts";
24
+ import { pdfDate, Renderer, shippableFont, stageFonts } from "./render.ts";
25
+ import { SECTION_DOCS, SECTIONS, type PackageFile, type RenderedFigure, type SectionContext, type SectionDiagnostic, type SectionFn, type SectionId, type Unit } from "./types.ts";
26
+ import { resolveUnits } from "./units.ts";
27
+ import { byCode, posix, sha256 } from "./util.ts";
28
+
29
+ export const RELEASE_DIR = "release";
30
+ export const EXTENSION_ID = "@protoboardai/ext-release";
31
+
32
+ export interface ReleaseOptions {
33
+ version?: string;
34
+ allowDirty?: boolean;
35
+ /** Write one package file (project-relative); the operation's recorded write. */
36
+ write: (path: string, content: Buffer, description?: string) => void;
37
+ log?: (line: string) => void;
38
+ /** Stop after the checks, writing nothing (`--input check=true`). */
39
+ checkOnly?: boolean;
40
+ }
41
+
42
+ export interface ReleaseResult {
43
+ status: "succeeded" | "failed";
44
+ summary: string;
45
+ diagnostics: SectionDiagnostic[];
46
+ version?: string;
47
+ dir?: string;
48
+ units?: { id: string; folder: string; perProduct: number }[];
49
+ documents?: { path: string; pages: number }[];
50
+ packageDigest?: string;
51
+ manifestDigest?: string;
52
+ }
53
+
54
+ const VERSION = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
55
+
56
+ /** The release date: SOURCE_DATE_EPOCH (seconds), else the source commit's date, else none. */
57
+ function releaseDate(git?: { date: string }): string | undefined {
58
+ const epoch = process.env.SOURCE_DATE_EPOCH;
59
+ if (epoch && /^\d+$/.test(epoch)) return new Date(Number(epoch) * 1000).toISOString().replace(/\.000Z$/, "Z");
60
+ return git ? new Date(git.date).toISOString().replace(/\.000Z$/, "Z") : undefined;
61
+ }
62
+
63
+ /** Section entries of every loaded extension: the release's own first, then in configuration order. */
64
+ function sectionEntries(p: Project, section: SectionId): { ext: LoadedExtension; entry: string }[] {
65
+ const exts = [...p.extensions].sort((a, b) => Number(b.manifest.id === EXTENSION_ID) - Number(a.manifest.id === EXTENSION_ID));
66
+ return exts.flatMap((ext) => (ext.manifest.operations ?? []).filter((o) => o.id === `package:${section}`).map((o) => ({ ext, entry: o.entry })));
67
+ }
68
+
69
+ const fail = (diagnostics: SectionDiagnostic[], summary?: string): ReleaseResult => {
70
+ const errors = diagnostics.filter((d) => d.severity === "error");
71
+ return { status: "failed", summary: summary ?? `release refused: ${errors.length} problem(s); first: ${errors[0]?.message ?? "?"}`, diagnostics };
72
+ };
73
+
74
+ export async function buildRelease(p: Project, opts: ReleaseOptions): Promise<ReleaseResult> {
75
+ const log = opts.log ?? (() => {});
76
+ const diagnostics: SectionDiagnostic[] = [];
77
+ if (!p.rootDef) return fail([{ code: "REL_NO_ROOT", severity: "error", message: "the project has no root system" }]);
78
+
79
+ // ---- configuration, units, preconditions
80
+ const cfg = await readReleaseConfig(p);
81
+ diagnostics.push(...cfg.diagnostics);
82
+ const { units, diagnostics: unitDiags } = resolveUnits(p, cfg.config.units ?? []);
83
+ diagnostics.push(...unitDiags);
84
+ const fresh = freshness(p);
85
+ diagnostics.push(...preconditions(p, fresh));
86
+ const version = opts.version ?? p.rootDef.version;
87
+ if (!version) diagnostics.push({ code: "REL_NO_VERSION", severity: "error", message: `no release version: pass --version <v> (the root module ${p.rootDef.id} states none)` });
88
+ else if (!VERSION.test(version)) diagnostics.push({ code: "REL_NO_VERSION", severity: "error", message: `release version "${version}" must be letters, digits, ".", "_" or "-"` });
89
+ // what the product is, as the project states it (docs/product.json): the first page reads it; what is missing is a warning
90
+ const product = readProduct(p.root);
91
+ diagnostics.push(...productDiagnostics(product));
92
+ // the release folder (and `releases/`, its earlier name) is the output, not a design input
93
+ const git = gitState(p.root, [RELEASE_DIR, "releases", "operations"]);
94
+ if (git?.dirty.length && !opts.allowDirty)
95
+ diagnostics.push({ code: "REL_DIRTY", severity: "error", message: `uncommitted changes in the project (${git.dirty.slice(0, 5).join(", ")}${git.dirty.length > 5 ? ", …" : ""}); commit them, or pass --allow-dirty (recorded in the manifest)` });
96
+ if (diagnostics.some((d) => d.severity === "error") || opts.checkOnly) {
97
+ return diagnostics.some((d) => d.severity === "error") ? fail(diagnostics) : { status: "succeeded", summary: `ready to release ${version}: ${units.length} unit(s), no blocking problems`, diagnostics };
98
+ }
99
+ const date = releaseDate(git);
100
+ const day = date ? date.slice(0, 10) : "undated";
101
+ const build = cfg.config.build ?? 1;
102
+ const outDir = `${RELEASE_DIR}/${version}`;
103
+ log(`release ${version}: ${units.map((u) => `${u.id}${u.kind !== "root" ? ` ×${u.perProduct}` : ""}`).join(", ")}`);
104
+
105
+ // ---- files from the standard outputs, and the model
106
+ const files: PackageFile[] = units.flatMap((u) => collectUnitFiles(p, u));
107
+ const bundle = await uhdBundle(p);
108
+ // machine-independent: artifact bases relative to the project
109
+ bundle.artifactBases = Object.fromEntries(Object.entries(bundle.artifactBases ?? {}).map(([k, v]) => [k, posix(relative(p.root, v)) || "."]));
110
+ const bundleFile: PackageFile = { path: "system/files/model/uhd-bundle.json", unit: "system", role: "model", content: Buffer.from(stableStringify(bundle)), description: "UHD bundle of the released system" };
111
+ files.push(bundleFile);
112
+
113
+ // ---- rendering: one Chrome for the figures (sections may ask for them) and the documents.
114
+ // Staging is outside the project and laid out like the package, so relative links print correctly.
115
+ const staging = mkdtempSync(join(tmpdir(), "protoboard-release-"));
116
+ const renderer = new Renderer(p, staging);
117
+ let manifest: ReleaseManifest;
118
+ const documents: { path: string; pages: number }[] = [];
119
+ try {
120
+ const fonts = stageFonts(staging);
121
+ const set: DocSet = { project: p.name, title: p.rootDef.name ?? p.rootDef.id, description: p.rootDef.description, product, version: version!, date: day, units, content: new Map(), figures: new Map() };
122
+ set.figures = await renderer.unitFigures(units);
123
+ for (const f of set.figures.values()) files.push({ path: f.file, role: "figure", content: readFileSync(join(staging, f.file)), description: "assembled unit, line art from the model's CAD" });
124
+ const figure = figureFn(renderer, staging, files, new Set([...set.figures.values()].map((f) => f.file)));
125
+
126
+ // ---- sections
127
+ const sections: ReleaseManifest["sections"] = [];
128
+ const releaseInfo = { version: version!, date: day, build, config: cfg.config };
129
+ for (const u of units) {
130
+ const bySection = new Map<SectionId, Contributed[]>();
131
+ for (const s of SECTIONS) {
132
+ const entries = sectionEntries(p, s);
133
+ const contributors = [...new Set(entries.map((e) => e.ext.manifest.id))];
134
+ // the release's own entry runs last, so its work order can number the operations the others contribute
135
+ const byEntry: Contributed[][] = entries.map(() => []);
136
+ const operations: SectionContext["operations"] = [];
137
+ const runOrder = entries.map((e, i) => ({ ...e, i })).sort((a, b) => Number(a.ext.manifest.id === EXTENSION_ID) - Number(b.ext.manifest.id === EXTENSION_ID));
138
+ for (const { ext, entry, i } of runOrder) {
139
+ const parts = byEntry[i];
140
+ const ctx: SectionContext = { project: p, section: s, unit: u, units, release: releaseInfo, files: files.filter((f) => f.unit === u.id), contributors, operations: ext.manifest.id === EXTENSION_ID ? operations : [], figure, log };
141
+ let got;
142
+ try {
143
+ const fn = await loadEntry<SectionFn>(ext, entry);
144
+ got = await fn(ctx);
145
+ } catch (e) {
146
+ diagnostics.push({ code: "REL_SECTION_FAILED", severity: "error", message: `${ext.manifest.id} package:${s} failed for unit ${u.id}: ${(e as Error).message.split("\n")[0]}` });
147
+ continue;
148
+ }
149
+ for (const c of (Array.isArray(got) ? got : got ? [got] : []).filter((c) => c.blocks?.length || c.files?.length || c.operations?.length)) {
150
+ for (const d of c.diagnostics ?? []) diagnostics.push({ ...d, message: `${ext.manifest.id} package:${s} (${u.id}): ${d.message}` });
151
+ for (const f of c.files ?? []) {
152
+ const problem = sectionFileProblem(p, u, f.path, f.from);
153
+ if (problem) {
154
+ diagnostics.push({ code: "REL_SECTION_FILE", severity: "error", message: `${ext.manifest.id} package:${s} (${u.id}): ${problem}` });
155
+ continue;
156
+ }
157
+ const path = `${u.folder}/${f.path}`;
158
+ if (files.some((x) => x.path === path)) {
159
+ diagnostics.push({ code: "REL_SECTION_FILE", severity: "error", message: `${ext.manifest.id} package:${s} (${u.id}): ${path} is already in the package` });
160
+ continue;
161
+ }
162
+ const bytes = f.from ? readFileSync(join(p.root, f.from)) : Buffer.isBuffer(f.content) ? f.content : Buffer.from(f.content ?? "");
163
+ files.push({ path, unit: u.id, role: f.role ?? "data", ...(f.from ? { from: f.from } : {}), content: bytes, ...(f.description ? { description: f.description } : {}) });
164
+ }
165
+ if (s === "work-order") for (const o of c.operations ?? []) operations.push({ ...o, extension: ext.manifest.id });
166
+ parts.push({ ...c, extension: ext.manifest.id });
167
+ }
168
+ }
169
+ const parts = byEntry.flat();
170
+ parts.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
171
+ bySection.set(s, parts);
172
+ sections.push({ unit: u.id, section: s, contributors: [...new Set(parts.map((c) => c.extension))] });
173
+ }
174
+ set.content.set(u.id, bySection);
175
+ }
176
+ await renderer.closeFigures();
177
+ if (diagnostics.some((d) => d.severity === "error")) return fail(diagnostics);
178
+
179
+ // ---- documents
180
+ const tools = toolDetails(p);
181
+ const stamp = pdfDate(date ?? "1970-01-01T00:00:00Z");
182
+ const overflow: string[] = [];
183
+ for (const u of units) {
184
+ for (const s of SECTIONS) {
185
+ const rel = docPath(u, s);
186
+ const up = "../".repeat(rel.split("/").length - 1);
187
+ const meta = { title: u.kind === "root" ? set.title : `${set.title}: ${u.name}`, docType: SECTION_DOCS[s].title, docId: docId(set, u, s), revision: version!, date: day, system: p.rootDef.id };
188
+ const r = await renderer.pdf(rel, meta, unitDocBody(set, u, s, (x) => up + x), `${up}assets/fonts`, stamp);
189
+ if (r.overflow.length) overflow.push(`${rel} p${r.overflow.join(",")}`);
190
+ files.push({ path: rel, unit: u.id, role: "document", content: r.pdf, description: SECTION_DOCS[s].title });
191
+ documents.push({ path: rel, pages: r.pages });
192
+ log(`${rel}: ${r.pages} page(s)`);
193
+ }
194
+ }
195
+ const lines = orderingList(p, units, cfg.config);
196
+ const t = orderTotals(lines, readFx(p.root).table);
197
+ const totals = [
198
+ { label: "Units", value: String(units.length), sub: units.map((u) => u.id).join(", ") },
199
+ { label: "Parts", value: String(t.pieces), sub: `${t.lines} ordering lines (${t.buy} buy, ${t.fabricate} make, ${t.included} included)` },
200
+ { label: "Cost estimate", value: t.costText || "—", sub: [`${t.priced} of ${t.buy} bought lines priced`, t.costNote].filter(Boolean).join("; ") },
201
+ { label: "Lead time", value: t.leadTimeWeeks !== null ? `${t.leadTimeWeeks} wk` : "—", sub: t.leadTimeWeeks !== null ? "longest offer lead time" : "no offer states one" },
202
+ ];
203
+ for (const f of fonts.files.filter((x) => shippableFont(x.file))) files.push({ path: `assets/fonts/${f.file}`, role: "asset", content: readFileSync(f.path), description: "Kode Mono (SIL Open Font License)" });
204
+ const draft = () => buildManifest({ p, version: version!, date: date ?? "", source: git ? { commit: git.commit, dirty: git.dirty } : null, build, units, bundle: bundleFile, fresh, files, tools, fonts: fonts.files.map((f) => ({ file: f.file, digest: sha256(readFileSync(f.path)) })), sections });
205
+ const ov = await renderer.pdf("00-overview.pdf", { title: set.title, docType: "Manufacturing package", docId: docId(set, units.find((u) => u.kind === "root")!, "overview"), revision: version!, date: day, system: p.rootDef.id }, overviewBody(set, draft(), totals, (x) => x), "assets/fonts", stamp);
206
+ if (ov.overflow.length) overflow.push(`00-overview.pdf p${ov.overflow.join(",")}`);
207
+ files.push({ path: "00-overview.pdf", role: "document", content: ov.pdf, description: "overview, order of work, index and traceability" });
208
+ documents.unshift({ path: "00-overview.pdf", pages: ov.pages });
209
+ files.push({ path: "index.html", role: "document", content: Buffer.from(indexHtml(set, draft(), totals, files)), description: "the whole package, by unit and by section" });
210
+ manifest = draft();
211
+ if (overflow.length) diagnostics.push({ code: "REL_PAGE_OVERFLOW", severity: "warning", message: `content overflows a page in ${overflow.join("; ")}` });
212
+ } finally {
213
+ await renderer.close();
214
+ rmSync(staging, { recursive: true, force: true });
215
+ }
216
+
217
+ // ---- write release/<version>/ (replacing an earlier build of the same version)
218
+ rmSync(join(p.root, outDir), { recursive: true, force: true });
219
+ const manifestBytes = Buffer.from(stableStringify(manifest));
220
+ for (const f of [...files].sort((a, b) => byCode(a.path, b.path))) opts.write(`${outDir}/${f.path}`, f.content, f.description);
221
+ opts.write(`${outDir}/manifest.json`, manifestBytes, "release manifest");
222
+ return {
223
+ status: "succeeded",
224
+ summary: `${outDir}: ${units.length} unit(s), ${documents.length} documents (${documents.reduce((s, d) => s + d.pages, 0)} pages), ${files.length + 1} files; package ${manifest.packageDigest.slice(7, 19)}`,
225
+ diagnostics,
226
+ version,
227
+ dir: outDir,
228
+ units: units.map((u) => ({ id: u.id, folder: u.folder, perProduct: u.perProduct })),
229
+ documents,
230
+ packageDigest: manifest.packageDigest,
231
+ manifestDigest: sha256(manifestBytes),
232
+ };
233
+ }
234
+
235
+ const FIGURE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
236
+
237
+ /**
238
+ * The figure callback sections get (SectionContext.figure): renders into
239
+ * staging/assets/images/<name>.png once per name and adds the file to the
240
+ * package. A name reused with another spec is a contributor's bug.
241
+ */
242
+ function figureFn(renderer: Renderer, staging: string, files: PackageFile[], taken: Set<string>): SectionContext["figure"] {
243
+ const done = new Map<string, { spec: string; fig: RenderedFigure }>();
244
+ return async (name, spec) => {
245
+ if (!FIGURE_NAME.test(name)) throw new Error(`figure name "${name}" must be letters, digits, ".", "_" or "-"`);
246
+ const file = `assets/images/${name}.png`;
247
+ const key = stableStringify(spec);
248
+ const hit = done.get(name);
249
+ if (hit) {
250
+ if (hit.spec !== key) throw new Error(`figure "${name}" was already rendered from another spec`);
251
+ return hit.fig;
252
+ }
253
+ if (taken.has(file)) throw new Error(`figure "${name}" is taken by the release`);
254
+ const r = await renderer.figure(name, spec);
255
+ const fig: RenderedFigure = { file, width: r.width, height: r.height, anchors: r.anchors };
256
+ done.set(name, { spec: key, fig });
257
+ files.push({ path: file, role: "figure", content: readFileSync(join(staging, file)), description: "figure rendered from the model's CAD" });
258
+ return fig;
259
+ };
260
+ }
261
+
262
+ /** A contributed file must stay inside its unit folder and not take a document's name. */
263
+ function sectionFileProblem(p: Project, u: Unit, path: string, from?: string): string | undefined {
264
+ const norm = posix(path);
265
+ if (!norm || norm.startsWith("/") || norm.split("/").includes("..")) return `file path ${path} must be relative to the unit folder`;
266
+ if (/\.pdf$/i.test(norm) && !norm.startsWith("files/")) return `${path}: PDFs at the unit level are the release's documents; put contributed files under files/`;
267
+ if (from && !existsSync(join(p.root, from))) return `${from} does not exist`;
268
+ return undefined;
269
+ }
270
+
271
+ /** Versions of the tools the release itself ran (Chrome for figures and PDFs). */
272
+ function toolDetails(p: Project): { id: string; detail?: string }[] {
273
+ const ext = p.extensions.find((e) => e.manifest.id === EXTENSION_ID);
274
+ return ext ? probeTools([ext], { root: p.root }).map((t) => ({ id: t.id, ...(t.detail ? { detail: t.detail } : {}) })) : [];
275
+ }
276
+
277
+ /** Files under a directory, for tests and the CLI summary. */
278
+ export const listTree = (abs: string, base = abs): string[] =>
279
+ !existsSync(abs) ? [] : readdirSync(abs, { withFileTypes: true }).flatMap((d) => (d.isDirectory() ? listTree(join(abs, d.name), base) : [posix(relative(base, join(abs, d.name)))])).sort(byCode);
package/lib/render.ts ADDED
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Rendering through @protoboardai/ext-docs: one headless Chrome and one static
3
+ * server for the whole release (FigureRenderer), serving a staging folder in
4
+ * which every document is laid out as it is in the package.
5
+ *
6
+ * - figures: each unit's assembled CAD (assemble() placements and fastener
7
+ * hardware, filtered to the unit's instances), line art from the GLBs;
8
+ * - documents: ext-docs' page shell and flow paginator, then printed to PDF.
9
+ *
10
+ * Chrome writes the print time into a PDF's /CreationDate and /ModDate; they
11
+ * are replaced by the release date (same length, so the xref table stays
12
+ * valid), which makes a rebuild byte-identical.
13
+ */
14
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import type { Project } from "@protoboardai/core";
17
+ import { docFonts } from "@protoboardai/ext-docs/build";
18
+ import { shell, type DocMeta } from "@protoboardai/ext-docs/html";
19
+ import { buildScene, loadSystem, type SceneBody } from "@protoboardai/ext-docs/model";
20
+ import { ISO, renderBody } from "@protoboardai/ext-docs/figures";
21
+ import { FigureRenderer, type RenderResult, type RenderSpec } from "@protoboardai/ext-docs/renderer";
22
+ import type { Unit } from "./types.ts";
23
+
24
+ /** Fonts the package may carry: Kode Mono (SIL Open Font License). */
25
+ const SHIPPABLE_FONT = /^KodeMono-.*\.(ttf|woff2)$/;
26
+
27
+ export interface Fonts {
28
+ /** Each font staged, with the file it was read from. */
29
+ files: { file: string; path: string }[];
30
+ }
31
+
32
+ /** Copy the document fonts into the staging folder (assets/fonts): the bundled Kode Mono, or the files of the same name PROTOBOARD_DOC_FONTS gives. */
33
+ export function stageFonts(staging: string): Fonts {
34
+ const dst = join(staging, "assets", "fonts");
35
+ mkdirSync(dst, { recursive: true });
36
+ const files = docFonts();
37
+ for (const f of files) copyFileSync(f.path, join(dst, f.file));
38
+ return { files };
39
+ }
40
+ export const shippableFont = (name: string) => SHIPPABLE_FONT.test(name);
41
+
42
+ export class Renderer {
43
+ /** Pagination and printing: serves the staging folder. */
44
+ private r: FigureRenderer;
45
+ /** Figures: serves the project (GLBs resolve where they are), writing into staging/assets/images. */
46
+ private fig?: FigureRenderer;
47
+ private sys?: Awaited<ReturnType<typeof loadSystem>>;
48
+ constructor(
49
+ private p: Project,
50
+ private staging: string,
51
+ ) {
52
+ this.r = new FigureRenderer(join(staging, "assets", "images"), { project: staging, packageRoot: () => undefined });
53
+ }
54
+
55
+ private async figures(): Promise<FigureRenderer> {
56
+ this.sys ??= await loadSystem(this.p);
57
+ this.fig ??= new FigureRenderer(join(this.staging, "assets", "images"), this.sys.roots);
58
+ return this.fig;
59
+ }
60
+
61
+ /** Render one figure to assets/images/<name>.png in the staging folder. */
62
+ async figure(name: string, spec: RenderSpec): Promise<RenderResult> {
63
+ return (await this.figures()).render(name, spec);
64
+ }
65
+
66
+ /** A line-art figure of each unit with placed CAD bodies (`assets/images/unit-<id>.png`). */
67
+ async unitFigures(units: Unit[]): Promise<Map<string, { file: string; width: number; height: number; bodies: number }>> {
68
+ const out = new Map<string, { file: string; width: number; height: number; bodies: number }>();
69
+ if (!this.p.rootDef) return out;
70
+ const fig = await this.figures();
71
+ let scene;
72
+ try {
73
+ scene = buildScene(this.sys!);
74
+ } catch {
75
+ return out;
76
+ }
77
+ for (const u of units) {
78
+ const inUnit = (path: string) => u.kind === "root" || u.instances.some((i) => path === i || path.startsWith(i + "/"));
79
+ const bodies: SceneBody[] = [...scene.bodies.filter((b) => !b.via?.startsWith("hint:") && inUnit(b.path.join("/"))), ...scene.hardware.filter((h) => inUnit(h.path.join("/")))];
80
+ // one build: a module unit shows its representative instance only
81
+ const rep = (path: string) => path === u.representative || path.startsWith(u.representative + "/");
82
+ const shown = u.kind === "module" ? bodies.filter((b) => rep(b.path.join("/"))) : bodies;
83
+ if (!shown.filter((b) => b.glb).length) continue;
84
+ const res = await fig.render(`unit-${u.id}`, { width: 2000, height: 1180, mode: "lineart", bodies: shown.map((b) => renderBody(b, "normal")), camera: { dir: ISO, margin: 0.05 } });
85
+ out.set(u.id, { file: `assets/images/unit-${u.id}.png`, width: res.width, height: res.height, bodies: shown.length });
86
+ }
87
+ return out;
88
+ }
89
+
90
+ /** Write a document into the staging folder, paginate it, print it; returns the PDF bytes (dates normalised) and page count. */
91
+ async pdf(rel: string, meta: DocMeta, body: string, fontsHref: string, pdfDate: string): Promise<{ pdf: Buffer; pages: number; overflow: number[] }> {
92
+ const htmlRel = rel.replace(/\.pdf$/, ".print.html");
93
+ const abs = join(this.staging, htmlRel);
94
+ mkdirSync(join(abs, ".."), { recursive: true });
95
+ writeFileSync(abs, shell(meta, body, { fontsHref, extraCss: PRINT_CSS }));
96
+ const pag = await this.r.paginate(`project/${htmlRel}`);
97
+ writeFileSync(abs, pag.html);
98
+ const pdf = normalisePdf(await this.r.printPdf(`project/${htmlRel}`), pdfDate);
99
+ return { pdf, pages: pag.pages, overflow: pag.overflow };
100
+ }
101
+
102
+ /** Stop the figure renderer's Chrome (one Chrome at a time: the documents start their own). */
103
+ async closeFigures() {
104
+ await this.fig?.close();
105
+ this.fig = undefined;
106
+ }
107
+
108
+ async close() {
109
+ await this.fig?.close();
110
+ await this.r.close();
111
+ }
112
+ }
113
+
114
+ /** D:YYYYMMDDHHmmSS+00'00' from an ISO date-time. */
115
+ export function pdfDate(iso: string): string {
116
+ const d = new Date(iso);
117
+ const t = Number.isNaN(d.getTime()) ? new Date(0) : d;
118
+ const p = (n: number, w = 2) => String(n).padStart(w, "0");
119
+ return `D:${p(t.getUTCFullYear(), 4)}${p(t.getUTCMonth() + 1)}${p(t.getUTCDate())}${p(t.getUTCHours())}${p(t.getUTCMinutes())}${p(t.getUTCSeconds())}+00'00'`;
120
+ }
121
+
122
+ /**
123
+ * Make Chrome's PDF depend only on the document: the creation and
124
+ * modification dates become the release date, and the tagged-PDF element ids
125
+ * (`nodeNNNNNNNN`) are renumbered in document order. Chrome takes them from
126
+ * a DOM node counter that depends on what the tab did before, and their
127
+ * relative order can change from run to run (the table header cells of a
128
+ * smart-bottle BOM came out in another order once, PB-809), so neither the
129
+ * numbers nor their ranks are stable; the order of the structure elements in
130
+ * the file is. The ID tree's name arrays are then sorted again and their
131
+ * limits reset. Every replacement keeps its length, so the xref offsets stay
132
+ * valid. A PDF whose ID tree cannot be rewritten in place falls back to
133
+ * renumbering the ids by rank, as before PB-809.
134
+ */
135
+ export function normalisePdf(pdf: Buffer, date: string): Buffer {
136
+ let text = pdf.toString("latin1");
137
+ text = text.replace(/\/(CreationDate|ModDate) \(D:[0-9]{14}[^)]*\)/g, (m, key: string) => {
138
+ const value = `/${key} (${date})`;
139
+ return value.length === m.length ? value : m;
140
+ });
141
+ return Buffer.from(renumberElementIds(text), "latin1");
142
+ }
143
+
144
+ const ELEMENT_ID = /\/ID \((node\d+)\)/g;
145
+ const ANY_ID = /\((node\d+)\)/g;
146
+ const NAMES_LEAF = /\/Limits \[\((node\d+)\) \((node\d+)\)\]\n\/Names \[((?:\(node\d+\) \d+ \d+ R ?)+)\]/g;
147
+
148
+ /**
149
+ * The fallback: ids renumbered by the rank of Chrome's number, which keeps the
150
+ * ID tree sorted and every id its length. Stable unless Chrome changes the
151
+ * relative order of its node counter, which document order also covers.
152
+ */
153
+ export function renumberByRank(text: string): string {
154
+ const ids = [...new Set(text.match(/\(node\d+\)/g) ?? [])].sort((a, b) => Number(a.slice(5, -1)) - Number(b.slice(5, -1)));
155
+ const rank = new Map(ids.map((id, i) => [id, `(node${String(i + 1).padStart(id.length - 6, "0")})`]));
156
+ return text.replace(/\(node\d+\)/g, (m) => (rank.get(m)!.length === m.length ? rank.get(m)! : m));
157
+ }
158
+
159
+ function renumberElementIds(text: string): string {
160
+ const order: string[] = [];
161
+ for (const m of text.matchAll(ELEMENT_ID)) if (!order.includes(m[1])) order.push(m[1]);
162
+ if (!order.length) return renumberByRank(text);
163
+ const renamed = new Map(order.map((id, i) => [id, `node${String(i + 1).padStart(id.length - 4, "0")}`]));
164
+ if ([...renamed].some(([a, b]) => a.length !== b.length)) return renumberByRank(text);
165
+ // every reference (the ID tree, a table cell's /Headers) must name an element the file defines
166
+ if ([...text.matchAll(ANY_ID)].some((m) => !renamed.has(m[1]))) return renumberByRank(text);
167
+ const name = (id: string) => renamed.get(id)!;
168
+ const rename = (part: string) => part.replace(ANY_ID, (_m, id: string) => `(${name(id)})`);
169
+
170
+ // the ID tree's leaves, in file order: rename every entry, sort them all, and refill each leaf with as many as it had
171
+ const leaves = [...text.matchAll(NAMES_LEAF)];
172
+ const entries = leaves.flatMap((l) => [...l[3].matchAll(/\((node\d+)\) (\d+ \d+ R)/g)].map((e) => ({ id: name(e[1]), ref: e[2] })));
173
+ entries.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
174
+ let next = 0;
175
+ const rewritten = leaves.map((l) => {
176
+ const n = [...l[3].matchAll(/\(node\d+\)/g)].length;
177
+ const mine = entries.slice(next, (next += n));
178
+ const body = mine.map((e) => `(${e.id}) ${e.ref}`).join(" ") + (l[3].endsWith(" ") ? " " : "");
179
+ return { at: l.index!, from: l[0], to: `/Limits [(${mine[0].id}) (${mine[mine.length - 1].id})]\n/Names [${body}]` };
180
+ });
181
+ // a leaf that would change length (entries moved between leaves) would move bytes: renumber by rank then
182
+ if (rewritten.some((r) => r.to.length !== r.from.length)) return renumberByRank(text);
183
+ let out = "";
184
+ let last = 0;
185
+ for (const r of rewritten) {
186
+ out += rename(text.slice(last, r.at)) + r.to;
187
+ last = r.at + r.from.length;
188
+ }
189
+ return out + rename(text.slice(last));
190
+ }
191
+
192
+ /** Rules the release documents add to ext-docs' print theme. */
193
+ export const PRINT_CSS = `
194
+ table.kv td:first-child { width: 42mm; }
195
+ .doc-head { display: flex; gap: 4mm; align-items: baseline; font-size: 6.4pt; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; margin: -1mm 0 4mm; }
196
+ .doc-head b { color: var(--ink); font-weight: 600; }
197
+ .part .p-name { grid-column: 2; }
198
+ td.nw, th.nw { white-space: nowrap; }
199
+ .part.noimg { grid-template-columns: auto; min-width: 0; }
200
+ .part.noimg .p-name { grid-column: 1; }
201
+ .step-no.wide { width: auto; min-width: 8.5mm; padding: 0 2.2mm; border-radius: 4.25mm; font-size: 9.5pt; }
202
+ .step .figwrap { margin-top: 1mm; }
203
+ `;
@@ -0,0 +1,164 @@
1
+ /**
2
+ * The release's own contributions to every section, derived from the model
3
+ * and the collected files only. Other extensions add to the same sections
4
+ * (the circuit provider's board fabrication orders, ext-harness-cad's harness
5
+ * operations, ext-drone's flight-controller configuration); these are the
6
+ * parts every project has.
7
+ */
8
+ import { sectionBom } from "./bom.ts";
9
+ import { unitPlan } from "@protoboardai/ext-docs/plan";
10
+ import { grams, unitDef, unitMass } from "./model.ts";
11
+ import { testPlanSection } from "./testplan.ts";
12
+ import { workOrderSection } from "./workorder.ts";
13
+ import { resolveExports, type InterfaceDef } from "@deltarobotics/uhd";
14
+ import { classify, originLabel, unitBom } from "../bom.ts";
15
+ import { num } from "../util.ts";
16
+ import type { Block, SectionFn, Unit } from "../types.ts";
17
+
18
+ const EXTENSION = "@protoboardai/ext-release";
19
+
20
+ const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
21
+
22
+ /** Voltage, current and clock an interface states ("5 V · 2 A"). */
23
+ function rating(i: InterfaceDef): string {
24
+ const out: string[] = [];
25
+ for (const p of i.parameters ?? []) {
26
+ const v = p.value !== undefined ? num(p.value) : p.range ? `${num(p.range[0])}–${num(p.range[1])}` : undefined;
27
+ if (v === undefined) continue;
28
+ if (p.id === "voltage") out.push(`${v} V`);
29
+ else if (p.id === "max_current") out.push(`${v} ${p.unit === "mA" ? "mA" : "A"} max`);
30
+ else if (p.id === "clock_freq") out.push(`${p.value !== undefined && p.value >= 1e6 ? `${num(p.value / 1e6)} MHz` : p.value !== undefined ? `${num(p.value / 1e3)} kHz` : v}`);
31
+ else if (p.id === "i2c_address" && p.value !== undefined) out.push(`0x${p.value.toString(16).toUpperCase().padStart(2, "0")}`);
32
+ }
33
+ return out.join(" · ");
34
+ }
35
+ const unitRef = (u: Unit) => (u.kind === "root" ? "system/" : `${u.folder}/`);
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // spec
39
+ // ---------------------------------------------------------------------------
40
+
41
+ export const spec: SectionFn = ({ project: p, unit: u, units }) => {
42
+ const def = unitDef(p, u);
43
+ const rows = unitBom(p, u);
44
+ const leaves = rows.filter((r) => r.action !== "build");
45
+ const mass = unitMass(p, u);
46
+ const custom = rows.filter((r) => r.action === "fabricate");
47
+ const blocks: Block[] = [];
48
+ if (def.description) blocks.push({ kind: "paragraph", text: def.description });
49
+ blocks.push({
50
+ kind: "figures",
51
+ items: [
52
+ { label: u.kind === "root" ? "Builds" : "Per product", value: u.kind === "root" ? "1" : `× ${u.perProduct}`, sub: u.kind === "root" ? "the product" : `assembled into ${u.parents.join(", ")}` },
53
+ { label: "Parts per build", value: String(leaves.reduce((s, r) => s + r.qty_per_unit, 0)), sub: plural(new Set(leaves.map((r) => r.definition)).size, "line item") },
54
+ { label: "Mass", value: mass.totalG ? grams(mass.totalG) : "—", sub: mass.missing.length ? `${plural(mass.missing.length, "instance")} without a mass not counted` : mass.assumedG ? `${grams(mass.assumedG)} from assumed materials` : "stated weights and CAD volume" },
55
+ { label: "Made here", value: String(custom.length), sub: custom.length ? custom.map((r) => r.item).join(", ") : "no custom parts" },
56
+ ],
57
+ });
58
+ const o = originLabel(p, def.id);
59
+ blocks.push({ kind: "heading", text: "Identity" });
60
+ blocks.push({
61
+ kind: "keyvalues",
62
+ rows: [
63
+ { label: "Module", value: def.id },
64
+ ...(u.kind !== "root" ? [{ label: u.kind === "module" ? "Every instance of" : "Instance", value: u.kind === "module" ? def.id : u.representative, note: u.kind === "module" ? u.instances.join(", ") : undefined }] : []),
65
+ ...(def.manufacturer ? [{ label: "Manufacturer", value: def.manufacturer }] : []),
66
+ ...(def.part_number ? [{ label: "Part number", value: def.part_number }] : []),
67
+ ...(def.version ? [{ label: "Version", value: def.version }] : []),
68
+ { label: "Source", value: `${o.tier} · ${o.origin}` },
69
+ ],
70
+ });
71
+ blocks.push({ kind: "heading", text: "What one build contains" });
72
+ blocks.push({
73
+ kind: "table",
74
+ columns: [
75
+ { key: "item", label: "Item" },
76
+ { key: "qty", label: "Qty", align: "right" },
77
+ { key: "kind", label: "Kind" },
78
+ { key: "ref", label: "Instance" },
79
+ ],
80
+ rows: u.members.filter((m) => m.depth === 0).map((m) => ({ item: m.name, qty: m.quantity, kind: m.unit ? `unit ${m.unit}` : classify(p, p.lookup(m.definition)!).kind, ref: m.path })),
81
+ });
82
+ const exported = new Map<string, InterfaceDef>();
83
+ try {
84
+ for (const e of resolveExports(def, p.lookup)) {
85
+ const i = e.childDef.interfaces.find((x) => x.id === e.from.interfaceId);
86
+ if (i) exported.set(e.id, i);
87
+ }
88
+ } catch {
89
+ // an unresolvable export is reported by protoboard check
90
+ }
91
+ const boundary = [
92
+ ...def.interfaces.filter((i) => i.exposed !== false).map((i) => ({ id: i.id, name: i.name ?? i.id, what: [i.domain, ...(i.protocols ?? []).map((x) => x.type)].filter(Boolean).join(" · "), rating: rating(i) })),
93
+ ...(def.exports ?? []).map((e) => ({ id: e.id, name: e.name ?? e.id, what: `${exported.get(e.id)?.protocols.map((x) => x.type).join(" · ") ?? ""} from ${"child" in e.from ? `${e.from.child}:` : ""}${e.from.interfaceId}`.trim(), rating: exported.has(e.id) ? rating(exported.get(e.id)!) : "" })),
94
+ ];
95
+ if (boundary.length) {
96
+ blocks.push({ kind: "heading", text: "Interfaces to the rest of the product" });
97
+ blocks.push({ kind: "table", columns: [{ key: "id", label: "Interface" }, { key: "name", label: "Name" }, { key: "what", label: "Kind / source" }, { key: "rating", label: "Rating" }], rows: boundary, note: "Ratings are the model's values for the interface (voltage, current, bus clock); an empty cell means the model states none." });
98
+ }
99
+ if (u.kind === "root" && units.length > 1) {
100
+ blocks.push({ kind: "heading", text: "Units" });
101
+ blocks.push({ kind: "table", columns: [{ key: "unit", label: "Unit" }, { key: "name", label: "Module" }, { key: "qty", label: "Per product", align: "right" }, { key: "folder", label: "Folder" }], rows: units.map((x) => ({ unit: x.id, name: x.name, qty: x.perProduct, folder: unitRef(x) })) });
102
+ }
103
+ if (mass.missing.length) blocks.push({ kind: "note", level: "tip", text: `Mass leaves out ${mass.missing.map((e) => e.path).slice(0, 8).join(", ")}${mass.missing.length > 8 ? ", …" : ""}: the model states no weight or material for them.` });
104
+ return { blocks };
105
+ };
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // bom
109
+ // ---------------------------------------------------------------------------
110
+
111
+ export const bom: SectionFn = (ctx) => sectionBom(ctx);
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // work order (./workorder.ts) and test plan (./testplan.ts)
115
+ // ---------------------------------------------------------------------------
116
+
117
+ export const workOrder: SectionFn = (ctx) => workOrderSection(ctx);
118
+ export const testPlan: SectionFn = (ctx) => testPlanSection(ctx);
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // assembly
122
+ // ---------------------------------------------------------------------------
123
+
124
+ /**
125
+ * Plain steps from the same plan as the work order (ext-docs' unitPlan: the
126
+ * order, parts, actions, soldering of parts that ship with loose connectors,
127
+ * and connection tables), so "Assembly guide step N" in the work order is
128
+ * step N here. A richer contributor (ext-docs: the same steps illustrated)
129
+ * replaces it.
130
+ */
131
+ export const assembly: SectionFn = async ({ project: p, unit: u, contributors }) => {
132
+ if (contributors.some((c) => c !== EXTENSION)) return undefined;
133
+ const rows = unitBom(p, u);
134
+ const kit = rows.filter((r) => r.level === 0 || r.kind === "unit");
135
+ const { steps: planned } = await unitPlan(p, u);
136
+ const blocks: Block[] = [
137
+ { kind: "heading", text: "Kit" },
138
+ { kind: "paragraph", text: `Check every item against the bill of materials (bom.pdf) before starting.${u.members.some((m) => m.unit) ? " Bring the finished sub-units listed; each has its own folder in this package." : ""}` },
139
+ { kind: "kit", items: kit.map((r) => ({ name: r.item, quantity: r.qty_per_unit, ref: r.path })) },
140
+ ];
141
+ const steps: Extract<Block, { kind: "steps" }>["steps"] = planned.map((st) => ({
142
+ title: st.title,
143
+ actions: st.actions,
144
+ ...(st.parts.length ? { parts: st.parts.map((pt) => ({ name: pt.def.name ?? pt.def.id, quantity: pt.count, ref: pt.def.id })) } : {}),
145
+ ...(st.connections.length
146
+ ? {
147
+ table: {
148
+ columns: [{ key: "name", label: "Link" }, { key: "from", label: "From" }, { key: "to", label: "To" }, { key: "x", label: "" }, { key: "term", label: "Termination" }],
149
+ rows: st.connections.map((c) => ({ name: c.name, from: c.from, to: c.to, x: c.crossover ? "× crossover" : "", term: c.termination })),
150
+ },
151
+ }
152
+ : {}),
153
+ ...(st.torque ? { torque: st.torque } : {}),
154
+ ...(st.tools.length ? { tools: st.tools } : {}),
155
+ ...(st.materials.length ? { materials: st.materials } : {}),
156
+ ...(st.notes.length ? { notes: st.notes } : {}),
157
+ trace: st.trace,
158
+ }));
159
+ if (steps.length) blocks.push({ kind: "heading", text: "Steps" }, { kind: "steps", steps });
160
+ else blocks.push({ kind: "paragraph", text: "This unit makes no joints: its parts are used as they come." });
161
+ return { blocks };
162
+ };
163
+
164
+ export const BASELINE: Record<string, SectionFn> = { spec, bom, "work-order": workOrder, assembly, "test-plan": testPlan };