@norskvideo/ctl-dev-kit 0.1.37 → 0.1.39

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.
@@ -136,6 +136,21 @@ jobs:
136
136
  # the Artifact fragment index.html — ctl hosts it directly as a page.
137
137
  man="docs/generated/manual/manual.html"
138
138
  [ -s "$man" ] || { echo "::error::manual was not generated at $man"; exit 1; }
139
+ # This asset is published ALONE, so it must carry its own images.
140
+ # A manual referencing sidecars renders as a page of broken images
141
+ # on the ctl docs site, and the only symptom is ctl's release
142
+ # link-check going red days later -- fail here, where the cause is
143
+ # visible. Every <img> chunk must carry a data: URI; the leading
144
+ # dot matches the attribute quote so no escaping is needed here.
145
+ # Counted rather than tested with grep -qv, whose empty-input exit
146
+ # status differs between GNU and BSD grep.
147
+ imgs="$(grep -oE "<img[^>]*src=[^>]*" "$man" || true)"
148
+ external="$(printf %s "$imgs" | grep -cv "src=.data:" || true)"
149
+ if [ -n "$imgs" ] && [ "${external:-0}" -gt 0 ]; then
150
+ echo "::error::manual.html references external images -- publish the self-contained standalone (buildManual standaloneHtml), not the sidecar variant"
151
+ printf %s "$imgs" | grep -v "src=.data:" | head -5
152
+ exit 1
153
+ fi
139
154
  cp "$man" "$RUNNER_TEMP/manual.html"
140
155
  if ! gh release view docs-latest >/dev/null 2>&1; then
141
156
  gh release create docs-latest --title "Docs (latest)" --prerelease \
@@ -46,11 +46,24 @@ export interface ManualSpec {
46
46
  export interface BuildManualResult {
47
47
  /** Artifact-ready fragment (no doctype/head/body). */
48
48
  indexHtml: string;
49
- /** Complete standalone document wrapping the fragment. */
49
+ /** Complete standalone document wrapping the fragment. Always self-contained,
50
+ * whether or not sidecars were asked for — this is the shape that survives
51
+ * being published on its own. */
50
52
  standaloneHtml: string;
53
+ /** The same document referencing the sidecar files instead of inlining them.
54
+ * Only present when `assets` is set, and only useful alongside `assets.dir`. */
55
+ standaloneAssetHtml?: string;
51
56
  /** `<slug>/<file>` of every referenced capture not found on disk. */
52
57
  missing: string[];
53
58
  }
59
+ /** Where to write de-inlined captures for the standalone manual. `dir` is the
60
+ * filesystem directory; `href` (default "images") is the relative URL prefix
61
+ * the manual references them by — so `dir` sits at `<manual>/<href>`. */
62
+ export interface ManualAssets {
63
+ dir: string;
64
+ href?: string;
65
+ }
54
66
  export declare function buildManual(spec: ManualSpec, opts: {
55
67
  docsRoot: string;
68
+ assets?: ManualAssets;
56
69
  }): BuildManualResult;
@@ -4,28 +4,40 @@
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 is self-contained too, ALWAYS: it is
16
+ // the one file publish-docs uploads on its own, so anything it references by
17
+ // relative href would 404 wherever it lands. Pass `opts.assets` to ADDITIONALLY
18
+ // get `standaloneAssetHtml` plus the captures written as sidecar files — the
19
+ // shape marketing hosts and zips, where individual images must be liftable
20
+ // rather than buried in base64. Both renders share one resize cache, so asking
21
+ // for sidecars costs no extra ImageMagick work.
12
22
  //
13
23
  // Product-specific content — the page arrays, the overview copy, the brand — is
14
24
  // supplied by the caller in `ManualSpec`; everything structural (layout, CSS,
15
25
  // routing) lives here so every product's manual looks and behaves the same.
16
26
  import { spawnSync } from "node:child_process";
17
- import { existsSync } from "node:fs";
27
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
18
28
  import { join } from "node:path";
19
29
  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) {
30
+ /** Resize a captured PNG to <=1100px wide JPEG. A missing capture becomes a
31
+ * labelled placeholder so the manual still builds, and is recorded in `missing`. */
32
+ function loadAsset(docsRoot, slug, file, missing) {
23
33
  const path = join(docsRoot, slug, file);
24
34
  if (!existsSync(path)) {
25
35
  missing.push(`${slug}/${file}`);
26
36
  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")}`;
37
+ return {
38
+ kind: "svg",
39
+ 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>`,
40
+ };
29
41
  }
30
42
  const r = spawnSync("magick", [path, "-resize", "1100x>", "-quality", "82", "jpg:-"], {
31
43
  maxBuffer: 64 * 1024 * 1024,
@@ -33,43 +45,73 @@ function makeUri(docsRoot, slug, file, missing) {
33
45
  if (r.status !== 0 || !r.stdout?.length) {
34
46
  throw new Error(`magick failed for ${path}: ${r.stderr?.toString() ?? "no output"}`);
35
47
  }
36
- return `data:image/jpeg;base64,${r.stdout.toString("base64")}`;
48
+ return { kind: "jpeg", bytes: r.stdout };
49
+ }
50
+ /** Inline the asset as a data URI (fragment path — always self-contained). */
51
+ function inlineUri(a) {
52
+ return a.kind === "svg"
53
+ ? `data:image/svg+xml;base64,${Buffer.from(a.text).toString("base64")}`
54
+ : `data:image/jpeg;base64,${a.bytes.toString("base64")}`;
55
+ }
56
+ /** Write the asset as a sidecar file under `assets.dir` and return its relative
57
+ * href (standalone path). The capture filename convention `<slug>-00N.png` is
58
+ * already unique, so the basename (extension swapped for the encoded format) is
59
+ * a safe sidecar name. */
60
+ function fileUri(a, file, assets) {
61
+ const ext = a.kind === "svg" ? "svg" : "jpg";
62
+ const name = `${file.replace(/\.[^.]+$/, "")}.${ext}`;
63
+ writeFileSync(join(assets.dir, name), a.kind === "svg" ? Buffer.from(a.text) : a.bytes);
64
+ return `${assets.href ?? "images"}/${name}`;
37
65
  }
38
66
  export function buildManual(spec, opts) {
39
- const { docsRoot } = opts;
67
+ const { docsRoot, assets } = opts;
40
68
  const missing = [];
41
- const uri = (slug, file) => makeUri(docsRoot, slug, file, missing);
69
+ // Each shot is loaded (resized) at most once, then rendered inline for the
70
+ // fragment and — when `assets` is set — as a sidecar file for the standalone.
71
+ const cache = new Map();
72
+ const load = (slug, file) => {
73
+ const key = `${slug}/${file}`;
74
+ let a = cache.get(key);
75
+ if (!a) {
76
+ a = loadAsset(docsRoot, slug, file, missing);
77
+ cache.set(key, a);
78
+ }
79
+ return a;
80
+ };
81
+ const inline = (slug, file) => inlineUri(load(slug, file));
82
+ const external = (slug, file) => fileUri(load(slug, file), file, assets);
42
83
  const FN = spec.functionality;
43
84
  const EX = spec.examples;
44
85
  const ALL = [...FN, ...EX];
45
86
  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">
87
+ const render = (uri) => {
88
+ function pageHtml(p) {
89
+ const steps = p.steps
90
+ .map((s, i) => `<li class="step">
49
91
  <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
92
  <div class="shot"><img loading="lazy" src="${uri(s.slug, s.file)}" alt="${esc(s.head)}"></div>
51
93
  </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}">
94
+ .join("\n");
95
+ const links = p.links.length
96
+ ? `<div class="xlinks"><span class="xlab">${p.kind === "fn" ? "Seen in" : "Uses"}</span>${p.links
97
+ .map((id) => {
98
+ const t = byId.get(id);
99
+ return t ? `<a href="#${id}" class="chip">${esc(t.nav)}</a>` : "";
100
+ })
101
+ .join("")}</div>`
102
+ : "";
103
+ const thin = p.thin
104
+ ? `<p class="thin">More of this walkthrough is coming — this deployment shape has one screen captured so far.</p>`
105
+ : "";
106
+ return `<article class="page" id="${p.id}">
65
107
  <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
108
  <p class="lead">${esc(p.intro)}</p>
67
109
  <ol class="steps">${steps}</ol>
68
110
  ${thin}${links}
69
111
  </article>`;
70
- }
71
- const ov = spec.overview;
72
- const overview = `<article class="page" id="overview">
112
+ }
113
+ const ov = spec.overview;
114
+ const overview = `<article class="page" id="overview">
73
115
  <div class="phead"><span class="kind ov">Overview</span><h1>${esc(ov.headline)}</h1></div>
74
116
  <p class="lead">${esc(ov.lead)}</p>
75
117
  <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 +120,11 @@ export function buildManual(spec, opts) {
78
120
  <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
121
  </div>
80
122
  </article>`;
81
- const nav = `
123
+ const nav = `
82
124
  <a href="#overview" data-nav class="ovlink">Overview</a>
83
125
  <div class="ngroup"><div class="ghead">Functionality</div>${FN.map((p) => `<a href="#${p.id}" data-nav>${esc(p.nav)}</a>`).join("")}</div>
84
126
  <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>
127
+ const html = `<title>${esc(spec.title)}</title>
86
128
  <style>
87
129
  :root{--bg:#0a0d12;--bg2:#0c1118;--surf:#131923;--ink:#eaeff5;--dim:#939dab;--faint:#5f6a79;
88
130
  --line:#212a36;--accent:#54d6cf;--measure:64ch}
@@ -168,7 +210,9 @@ ${ALL.map(pageHtml).join("\n")}
168
210
  window.addEventListener('hashchange',show);show();
169
211
  })();
170
212
  </script>`;
171
- const standaloneHtml = `<!doctype html>
213
+ return html;
214
+ };
215
+ const document = (body) => `<!doctype html>
172
216
  <html lang="en">
173
217
  <head>
174
218
  <meta charset="utf-8" />
@@ -176,8 +220,15 @@ ${ALL.map(pageHtml).join("\n")}
176
220
  <title>${esc(spec.title)}</title>
177
221
  </head>
178
222
  <body>
179
- ${html}
223
+ ${body}
180
224
  </body>
181
225
  </html>`;
182
- return { indexHtml: html, standaloneHtml, missing };
226
+ // Fragment and standalone are both inlined (Artifact CSP; publishable on its
227
+ // own). Sidecars are an EXTRA output, never a substitution.
228
+ const indexHtml = render(inline);
229
+ const standaloneHtml = document(indexHtml);
230
+ if (!assets)
231
+ return { indexHtml, standaloneHtml, missing };
232
+ mkdirSync(assets.dir, { recursive: true });
233
+ return { indexHtml, standaloneHtml, standaloneAssetHtml: document(render(external)), missing };
183
234
  }
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.39",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./package.json": "./package.json",