@norskvideo/ctl-dev-kit 0.1.37 → 0.1.38

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.
@@ -51,6 +51,14 @@ export interface BuildManualResult {
51
51
  /** `<slug>/<file>` of every referenced capture not found on disk. */
52
52
  missing: string[];
53
53
  }
54
+ /** Where to write de-inlined captures for the standalone manual. `dir` is the
55
+ * filesystem directory; `href` (default "images") is the relative URL prefix
56
+ * the manual references them by — so `dir` sits at `<manual>/<href>`. */
57
+ export interface ManualAssets {
58
+ dir: string;
59
+ href?: string;
60
+ }
54
61
  export declare function buildManual(spec: ManualSpec, opts: {
55
62
  docsRoot: string;
63
+ assets?: ManualAssets;
56
64
  }): BuildManualResult;
@@ -4,28 +4,37 @@
4
4
  //
5
5
  // Every screenshot is a real capture a doc-guide wrote under
6
6
  // <docsRoot>/<slug>/<slug>-00N.png. This assembler reads those PNGs, resizes each
7
- // with ImageMagick, inlines it as a data URI, and returns both the Artifact
8
- // FRAGMENT (index.html — the Artifact tool supplies the doctype/head/body) and a
9
- // complete STANDALONE document (manual.html — for hosting the manual directly as
10
- // a page). A referenced shot that hasn't been generated yet renders as a labelled
11
- // gap rather than crashing the build; every gap is reported in `missing`.
7
+ // with ImageMagick, and returns both the Artifact FRAGMENT (index.html the
8
+ // Artifact tool supplies the doctype/head/body) and a complete STANDALONE document
9
+ // (manual.html — for hosting the manual directly as a page). A referenced shot that
10
+ // hasn't been generated yet renders as a labelled gap rather than crashing the
11
+ // build; every gap is reported in `missing`.
12
+ //
13
+ // The FRAGMENT always inlines each image as a data URI — Artifacts serve under a
14
+ // CSP that blocks external image hosts, so a self-contained fragment is the only
15
+ // thing that renders there. The STANDALONE inlines too by default, but pass
16
+ // `opts.assets` to write each capture as a sidecar file and reference it by a
17
+ // relative href instead — the shape marketing hosts and zips, where individual
18
+ // images must be liftable rather than buried in base64.
12
19
  //
13
20
  // Product-specific content — the page arrays, the overview copy, the brand — is
14
21
  // supplied by the caller in `ManualSpec`; everything structural (layout, CSS,
15
22
  // routing) lives here so every product's manual looks and behaves the same.
16
23
  import { spawnSync } from "node:child_process";
17
- import { existsSync } from "node:fs";
24
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
18
25
  import { join } from "node:path";
19
26
  const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
20
- /** Resize a captured PNG to <=1100px wide JPEG and return a data URI. Missing
21
- * captures become a labelled placeholder so the manual still builds. */
22
- function makeUri(docsRoot, slug, file, missing) {
27
+ /** Resize a captured PNG to <=1100px wide JPEG. A missing capture becomes a
28
+ * labelled placeholder so the manual still builds, and is recorded in `missing`. */
29
+ function loadAsset(docsRoot, slug, file, missing) {
23
30
  const path = join(docsRoot, slug, file);
24
31
  if (!existsSync(path)) {
25
32
  missing.push(`${slug}/${file}`);
26
33
  const label = esc(`missing: ${slug}/${file}`);
27
- const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="880" height="200"><rect width="100%" height="100%" fill="#1a2230"/><text x="50%" y="50%" fill="#6b7787" font-family="monospace" font-size="15" text-anchor="middle" dominant-baseline="middle">${label}</text></svg>`;
28
- return `data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}`;
34
+ return {
35
+ kind: "svg",
36
+ text: `<svg xmlns="http://www.w3.org/2000/svg" width="880" height="200"><rect width="100%" height="100%" fill="#1a2230"/><text x="50%" y="50%" fill="#6b7787" font-family="monospace" font-size="15" text-anchor="middle" dominant-baseline="middle">${label}</text></svg>`,
37
+ };
29
38
  }
30
39
  const r = spawnSync("magick", [path, "-resize", "1100x>", "-quality", "82", "jpg:-"], {
31
40
  maxBuffer: 64 * 1024 * 1024,
@@ -33,43 +42,73 @@ function makeUri(docsRoot, slug, file, missing) {
33
42
  if (r.status !== 0 || !r.stdout?.length) {
34
43
  throw new Error(`magick failed for ${path}: ${r.stderr?.toString() ?? "no output"}`);
35
44
  }
36
- return `data:image/jpeg;base64,${r.stdout.toString("base64")}`;
45
+ return { kind: "jpeg", bytes: r.stdout };
46
+ }
47
+ /** Inline the asset as a data URI (fragment path — always self-contained). */
48
+ function inlineUri(a) {
49
+ return a.kind === "svg"
50
+ ? `data:image/svg+xml;base64,${Buffer.from(a.text).toString("base64")}`
51
+ : `data:image/jpeg;base64,${a.bytes.toString("base64")}`;
52
+ }
53
+ /** Write the asset as a sidecar file under `assets.dir` and return its relative
54
+ * href (standalone path). The capture filename convention `<slug>-00N.png` is
55
+ * already unique, so the basename (extension swapped for the encoded format) is
56
+ * a safe sidecar name. */
57
+ function fileUri(a, file, assets) {
58
+ const ext = a.kind === "svg" ? "svg" : "jpg";
59
+ const name = `${file.replace(/\.[^.]+$/, "")}.${ext}`;
60
+ writeFileSync(join(assets.dir, name), a.kind === "svg" ? Buffer.from(a.text) : a.bytes);
61
+ return `${assets.href ?? "images"}/${name}`;
37
62
  }
38
63
  export function buildManual(spec, opts) {
39
- const { docsRoot } = opts;
64
+ const { docsRoot, assets } = opts;
40
65
  const missing = [];
41
- const uri = (slug, file) => makeUri(docsRoot, slug, file, missing);
66
+ // Each shot is loaded (resized) at most once, then rendered inline for the
67
+ // fragment and — when `assets` is set — as a sidecar file for the standalone.
68
+ const cache = new Map();
69
+ const load = (slug, file) => {
70
+ const key = `${slug}/${file}`;
71
+ let a = cache.get(key);
72
+ if (!a) {
73
+ a = loadAsset(docsRoot, slug, file, missing);
74
+ cache.set(key, a);
75
+ }
76
+ return a;
77
+ };
78
+ const inline = (slug, file) => inlineUri(load(slug, file));
79
+ const external = (slug, file) => fileUri(load(slug, file), file, assets);
42
80
  const FN = spec.functionality;
43
81
  const EX = spec.examples;
44
82
  const ALL = [...FN, ...EX];
45
83
  const byId = new Map(ALL.map((p) => [p.id, p]));
46
- function pageHtml(p) {
47
- const steps = p.steps
48
- .map((s, i) => `<li class="step">
84
+ const render = (uri) => {
85
+ function pageHtml(p) {
86
+ const steps = p.steps
87
+ .map((s, i) => `<li class="step">
49
88
  <div class="stinfo"><span class="stnum">${String(i + 1).padStart(2, "0")}</span><div><h3>${esc(s.head)}</h3><p>${esc(s.desc)}</p></div></div>
50
89
  <div class="shot"><img loading="lazy" src="${uri(s.slug, s.file)}" alt="${esc(s.head)}"></div>
51
90
  </li>`)
52
- .join("\n");
53
- const links = p.links.length
54
- ? `<div class="xlinks"><span class="xlab">${p.kind === "fn" ? "Seen in" : "Uses"}</span>${p.links
55
- .map((id) => {
56
- const t = byId.get(id);
57
- return t ? `<a href="#${id}" class="chip">${esc(t.nav)}</a>` : "";
58
- })
59
- .join("")}</div>`
60
- : "";
61
- const thin = p.thin
62
- ? `<p class="thin">More of this walkthrough is coming — this deployment shape has one screen captured so far.</p>`
63
- : "";
64
- return `<article class="page" id="${p.id}">
91
+ .join("\n");
92
+ const links = p.links.length
93
+ ? `<div class="xlinks"><span class="xlab">${p.kind === "fn" ? "Seen in" : "Uses"}</span>${p.links
94
+ .map((id) => {
95
+ const t = byId.get(id);
96
+ return t ? `<a href="#${id}" class="chip">${esc(t.nav)}</a>` : "";
97
+ })
98
+ .join("")}</div>`
99
+ : "";
100
+ const thin = p.thin
101
+ ? `<p class="thin">More of this walkthrough is coming — this deployment shape has one screen captured so far.</p>`
102
+ : "";
103
+ return `<article class="page" id="${p.id}">
65
104
  <div class="phead"><span class="kind ${p.kind}">${p.kind === "fn" ? "Functionality" : "Example deployment"}</span><h1>${esc(p.title)}</h1>${p.tag ? `<div class="ptag mono">${esc(p.tag)}</div>` : ""}</div>
66
105
  <p class="lead">${esc(p.intro)}</p>
67
106
  <ol class="steps">${steps}</ol>
68
107
  ${thin}${links}
69
108
  </article>`;
70
- }
71
- const ov = spec.overview;
72
- const overview = `<article class="page" id="overview">
109
+ }
110
+ const ov = spec.overview;
111
+ const overview = `<article class="page" id="overview">
73
112
  <div class="phead"><span class="kind ov">Overview</span><h1>${esc(ov.headline)}</h1></div>
74
113
  <p class="lead">${esc(ov.lead)}</p>
75
114
  <div class="heroshot"><div class="shot"><img src="${uri(ov.hero.slug, ov.hero.file)}" alt="${esc(ov.hero.alt)}"></div><p class="hcap">${esc(ov.hero.caption)}</p></div>
@@ -78,11 +117,11 @@ export function buildManual(spec, opts) {
78
117
  <div><h2>${esc(ov.byExampleHeading ?? "By example")}</h2><p class="mini">${esc(ov.byExampleIntro)}</p><div class="linklist">${EX.map((p) => `<a href="#${p.id}">${esc(p.nav)}</a>`).join("")}</div></div>
79
118
  </div>
80
119
  </article>`;
81
- const nav = `
120
+ const nav = `
82
121
  <a href="#overview" data-nav class="ovlink">Overview</a>
83
122
  <div class="ngroup"><div class="ghead">Functionality</div>${FN.map((p) => `<a href="#${p.id}" data-nav>${esc(p.nav)}</a>`).join("")}</div>
84
123
  <div class="ngroup"><div class="ghead">Examples <span class="ct">${EX.length}</span></div>${EX.map((p) => `<a href="#${p.id}" data-nav>${esc(p.nav)}</a>`).join("")}</div>`;
85
- const html = `<title>${esc(spec.title)}</title>
124
+ const html = `<title>${esc(spec.title)}</title>
86
125
  <style>
87
126
  :root{--bg:#0a0d12;--bg2:#0c1118;--surf:#131923;--ink:#eaeff5;--dim:#939dab;--faint:#5f6a79;
88
127
  --line:#212a36;--accent:#54d6cf;--measure:64ch}
@@ -168,6 +207,16 @@ ${ALL.map(pageHtml).join("\n")}
168
207
  window.addEventListener('hashchange',show);show();
169
208
  })();
170
209
  </script>`;
210
+ return html;
211
+ };
212
+ // Fragment: always inlined (Artifact CSP). Standalone: de-inlined sidecar files
213
+ // when `assets` is set, otherwise the same inlined body wrapped in a document.
214
+ const indexHtml = render(inline);
215
+ let standaloneBody = indexHtml;
216
+ if (assets) {
217
+ mkdirSync(assets.dir, { recursive: true });
218
+ standaloneBody = render(external);
219
+ }
171
220
  const standaloneHtml = `<!doctype html>
172
221
  <html lang="en">
173
222
  <head>
@@ -176,8 +225,8 @@ ${ALL.map(pageHtml).join("\n")}
176
225
  <title>${esc(spec.title)}</title>
177
226
  </head>
178
227
  <body>
179
- ${html}
228
+ ${standaloneBody}
180
229
  </body>
181
230
  </html>`;
182
- return { indexHtml: html, standaloneHtml, missing };
231
+ return { indexHtml, standaloneHtml, missing };
183
232
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-dev-kit",
3
- "version": "0.1.37",
3
+ "version": "0.1.38",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./package.json": "./package.json",