@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.
- package/conventions/publish-docs.yml +15 -0
- package/doc-guide/build-manual.d.ts +14 -1
- package/doc-guide/build-manual.js +89 -38
- package/package.json +1 -1
|
@@ -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,
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
20
|
-
/** Resize a captured PNG to <=1100px wide JPEG
|
|
21
|
-
*
|
|
22
|
-
function
|
|
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
|
-
|
|
28
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
${
|
|
223
|
+
${body}
|
|
180
224
|
</body>
|
|
181
225
|
</html>`;
|
|
182
|
-
|
|
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
|
}
|