@norskvideo/ctl-dev-kit 0.2.2 → 0.2.4
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/check-drift.ts +39 -0
- package/conventions/docs-site.md +185 -0
- package/conventions/sync-drift.ts +23 -0
- package/create-product/create-product.ts +10 -1
- package/create-product/docs-site.ts +316 -0
- package/create-product/turnkey.ts +51 -5
- package/docs-theme/README.md +106 -0
- package/docs-theme/components/Footer.astro +40 -0
- package/docs-theme/components/Sidebar.astro +73 -0
- package/docs-theme/components/SiteTitle.astro +52 -0
- package/docs-theme/content.config.ts +25 -0
- package/docs-theme/custom.css +108 -0
- package/docs-theme/fonts/Geist-LICENSE.txt +92 -0
- package/docs-theme/fonts/Geist.woff2 +0 -0
- package/docs-theme/fonts/GeistMono.woff2 +0 -0
- package/docs-theme/public/favicon.png +0 -0
- package/docs-theme/starlight.ts +162 -0
- package/docs-theme/sync.ts +163 -0
- package/package.json +2 -1
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Astro + Starlight configuration every Norsk product docs site is built
|
|
3
|
+
* from. A site's own `astro.config.mjs` is reduced to what genuinely differs:
|
|
4
|
+
* its origin, its two names, its sidebar, and the versions it was built
|
|
5
|
+
* against.
|
|
6
|
+
*
|
|
7
|
+
* export default defineConfig(
|
|
8
|
+
* norskDocsSite({
|
|
9
|
+
* origin: PUBLIC_SITE_ORIGIN,
|
|
10
|
+
* tabTitle: "Norsk",
|
|
11
|
+
* wordmark: "norsk-ctl",
|
|
12
|
+
* buildVersions: [...],
|
|
13
|
+
* sidebar: [...],
|
|
14
|
+
* }),
|
|
15
|
+
* );
|
|
16
|
+
*
|
|
17
|
+
* Everything here is the part that must NOT differ between sites: the theme,
|
|
18
|
+
* the component overrides, the mermaid pipeline, the favicon convention, and
|
|
19
|
+
* the two-build (public / embedded) shape described on `publicBuild` below.
|
|
20
|
+
*/
|
|
21
|
+
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
import { unified } from "@astrojs/markdown-remark";
|
|
25
|
+
import starlight from "@astrojs/starlight";
|
|
26
|
+
import rehypeMermaid from "rehype-mermaid";
|
|
27
|
+
|
|
28
|
+
/** One row of the sidebar's "Built against" footer (components/Sidebar.astro). */
|
|
29
|
+
export interface BuildVersion {
|
|
30
|
+
/** Row label, e.g. "norsk-ctl". */
|
|
31
|
+
label: string;
|
|
32
|
+
/** Row value, read at build time from the product's own files. */
|
|
33
|
+
value: string;
|
|
34
|
+
/** Break a `repo:tag` image pin after the last colon so it wraps sensibly. */
|
|
35
|
+
splitOnLastColon?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface NorskDocsSiteOptions {
|
|
39
|
+
/** Canonical public origin. Drives the sitemap and <link rel="canonical">. */
|
|
40
|
+
origin: string;
|
|
41
|
+
/** BROWSER TAB suffix only ("Quickstart | Norsk"), kept short — tabs truncate. */
|
|
42
|
+
tabTitle: string;
|
|
43
|
+
/** Masthead wordmark. Deliberately different from `tabTitle`; see SiteTitle.astro. */
|
|
44
|
+
wordmark: string;
|
|
45
|
+
/** Starlight's sidebar. The one part of the nav a site fully owns. */
|
|
46
|
+
sidebar: unknown[];
|
|
47
|
+
/** Rows for the sidebar's "Built against" footer. */
|
|
48
|
+
buildVersions: BuildVersion[];
|
|
49
|
+
/**
|
|
50
|
+
* Dev-preview only: machine names `astro dev --host` may be reached by on the
|
|
51
|
+
* LAN (Vite 403s unknown Host headers otherwise). No effect on builds.
|
|
52
|
+
*/
|
|
53
|
+
devAllowedHosts?: string[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Two builds from one content collection, selected by ASTRO_BUILD:
|
|
58
|
+
* `dist-embedded` (base `/docs`) is the bundle baked into a product binary and
|
|
59
|
+
* served by the daemon, `dist-public` (base `/`) is the standalone site on the
|
|
60
|
+
* product's own domain. Exported because a site's sidebar legitimately differs
|
|
61
|
+
* between the two — a section that only exists once it is published.
|
|
62
|
+
*/
|
|
63
|
+
export function isPublicBuild(): boolean {
|
|
64
|
+
return process.env.ASTRO_BUILD === "public";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Content (and any vendored HTML) is authored with absolute /docs/... links —
|
|
69
|
+
* correct for the daemon-embedded build (served under /docs), but a 404 in the
|
|
70
|
+
* public build (served at /). Astro doesn't base-prefix absolute links in
|
|
71
|
+
* MDX/components/static files, so for the public build a post-build pass strips
|
|
72
|
+
* the /docs prefix from href/src attributes across the emitted HTML.
|
|
73
|
+
* Attribute-scoped so it never touches /docs literals in body text. No-op for
|
|
74
|
+
* the embedded build.
|
|
75
|
+
*/
|
|
76
|
+
function stripDocsBaseForPublic() {
|
|
77
|
+
const rewriteHtml = (html: string) =>
|
|
78
|
+
html.replace(/((?:href|src)=")\/docs(\/|")/g, (_m, attr, tail) => (tail === "/" ? `${attr}/` : `${attr}/"`));
|
|
79
|
+
const walk = (root: string, fn: (p: string) => void) => {
|
|
80
|
+
for (const name of readdirSync(root)) {
|
|
81
|
+
const p = join(root, name);
|
|
82
|
+
if (statSync(p).isDirectory()) walk(p, fn);
|
|
83
|
+
else if (p.endsWith(".html")) fn(p);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
return {
|
|
87
|
+
name: "strip-docs-base-public",
|
|
88
|
+
hooks: {
|
|
89
|
+
"astro:build:done": ({ dir }: { dir: URL }) => {
|
|
90
|
+
if (!isPublicBuild()) return;
|
|
91
|
+
walk(fileURLToPath(dir), (p) => {
|
|
92
|
+
writeFileSync(p, rewriteHtml(readFileSync(p, "utf-8")));
|
|
93
|
+
});
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function norskDocsSite(options: NorskDocsSiteOptions) {
|
|
100
|
+
const isPublic = isPublicBuild();
|
|
101
|
+
const mermaidLaunchOptions = process.env.BROWSER_FOR_TESTING
|
|
102
|
+
? { executablePath: process.env.BROWSER_FOR_TESTING }
|
|
103
|
+
: undefined;
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
// The public site lives at the domain root; the embedded bundle is served
|
|
107
|
+
// by the daemon under /docs. `site` is the canonical origin: it drives the
|
|
108
|
+
// sitemap and <link rel="canonical">, and without it @astrojs/sitemap
|
|
109
|
+
// (pulled in by Starlight) skips and warns. The embedded build reuses the
|
|
110
|
+
// same origin — its sitemap is inert since it is never crawled, but setting
|
|
111
|
+
// `site` keeps the build warning-free.
|
|
112
|
+
site: options.origin,
|
|
113
|
+
base: isPublic ? "/" : "/docs",
|
|
114
|
+
outDir: isPublic ? "./dist-public" : "./dist-embedded",
|
|
115
|
+
markdown: {
|
|
116
|
+
syntaxHighlight: { excludeLangs: ["mermaid"] },
|
|
117
|
+
// Astro 6 deprecated top-level `rehypePlugins`; the pipeline now lives in
|
|
118
|
+
// a `unified()` processor. `syntaxHighlight` stays at the markdown level.
|
|
119
|
+
processor: unified({
|
|
120
|
+
rehypePlugins: [
|
|
121
|
+
[
|
|
122
|
+
rehypeMermaid,
|
|
123
|
+
{ strategy: "img-svg", dark: true, mermaidConfig: { theme: "dark" }, launchOptions: mermaidLaunchOptions },
|
|
124
|
+
],
|
|
125
|
+
],
|
|
126
|
+
}),
|
|
127
|
+
},
|
|
128
|
+
integrations: [
|
|
129
|
+
stripDocsBaseForPublic(),
|
|
130
|
+
starlight({
|
|
131
|
+
title: options.tabTitle,
|
|
132
|
+
customCss: ["./src/custom.css"],
|
|
133
|
+
components: {
|
|
134
|
+
// Appends the "Built against" footer below the nav.
|
|
135
|
+
Sidebar: "./src/components/Sidebar.astro",
|
|
136
|
+
// Decouples the masthead wordmark from `title` above.
|
|
137
|
+
SiteTitle: "./src/components/SiteTitle.astro",
|
|
138
|
+
// Adds the "captured against" line to a generated guide (front
|
|
139
|
+
// matter `capturedAgainst`, see content.config.ts).
|
|
140
|
+
Footer: "./src/components/Footer.astro",
|
|
141
|
+
},
|
|
142
|
+
// Starlight base-prefixes this (emits a `/docs/favicon.png`
|
|
143
|
+
// shortcut-icon link for the embedded build); a raw head <link> would
|
|
144
|
+
// not, and 404s the embedded link-check.
|
|
145
|
+
favicon: "/favicon.png",
|
|
146
|
+
sidebar: options.sidebar,
|
|
147
|
+
}),
|
|
148
|
+
],
|
|
149
|
+
vite: {
|
|
150
|
+
server: { allowedHosts: options.devAllowedHosts ?? [] },
|
|
151
|
+
// Injected rather than imported: a component resolving its own path at
|
|
152
|
+
// runtime finds dist-embedded/.prerender/chunks/ instead of the source
|
|
153
|
+
// tree and fails the build, and the site's version readers want the
|
|
154
|
+
// product's files, not the site's. Both values are build-time constants,
|
|
155
|
+
// so a define is the cheapest correct channel.
|
|
156
|
+
define: {
|
|
157
|
+
__BUILD_VERSIONS__: JSON.stringify(options.buildVersions),
|
|
158
|
+
__SITE_WORDMARK__: JSON.stringify(options.wordmark),
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* The docs theme is a FORCED COPY, and this is its writer and its gate.
|
|
4
|
+
*
|
|
5
|
+
* sync.ts check <site> diff the site's copies against this dev-kit's
|
|
6
|
+
* canonical bytes; exit 1 on drift, naming the file.
|
|
7
|
+
* sync.ts sync <site> rewrite the copies from the canonical.
|
|
8
|
+
*
|
|
9
|
+
* `<site>` is the docs site's package root (the directory holding
|
|
10
|
+
* `astro.config.mjs`); it defaults to the current directory.
|
|
11
|
+
*
|
|
12
|
+
* WHY A COPY, when the dev-kit's rule is to single-source anything consumable.
|
|
13
|
+
* A docs site is a STANDALONE package: it carries its own bun.lock and its own
|
|
14
|
+
* node_modules, outside the monorepo's workspaces, because Astro's dependency
|
|
15
|
+
* tree has no business in the product install. So it cannot depend on the
|
|
16
|
+
* dev-kit the way every other consumer does. That is not a preference — it is
|
|
17
|
+
* checked: adding `"@norskvideo/ctl-dev-kit": "file:../../dev-kit"` to
|
|
18
|
+
* norsk-ctl's website and installing fails outright, because bun then has to
|
|
19
|
+
* resolve the dev-kit's own `workspace:*` dependencies (ctl-sdk, foundation,
|
|
20
|
+
* test-harness, product-template-schema) from a directory that is not in a
|
|
21
|
+
* workspace:
|
|
22
|
+
*
|
|
23
|
+
* error: @norskvideo/ctl-sdk@workspace:* failed to resolve
|
|
24
|
+
*
|
|
25
|
+
* A product repo outside this monorepo installs the published tarball and hits
|
|
26
|
+
* no such wall — but it would then consume the theme by a second mechanism, and
|
|
27
|
+
* one theme reaching two sites two ways is exactly the drift this package
|
|
28
|
+
* exists to stop. So every site, here and elsewhere, gets the same deal: copies
|
|
29
|
+
* at fixed paths, rewritten by `sync`, and `check` in its test suite.
|
|
30
|
+
*
|
|
31
|
+
* The copies land at the paths Starlight and Astro already expect
|
|
32
|
+
* (`src/custom.css`, `src/components/*.astro`, `src/content.config.ts`), which
|
|
33
|
+
* is also why the canonical directory mirrors a site's own tree: the map below
|
|
34
|
+
* is an identity. Keeping the component paths identical is load-bearing for
|
|
35
|
+
* more than tidiness — Astro derives a component's scoped-style class from its
|
|
36
|
+
* path, so moving one rewrites a class attribute on every page.
|
|
37
|
+
*
|
|
38
|
+
* One canonical subdirectory is NOT under `src/`: `public/` mirrors the site's
|
|
39
|
+
* `public/`, because `starlight.ts` already names the favicon
|
|
40
|
+
* (`favicon: "/favicon.png"`) and a path the theme dictates whose bytes each
|
|
41
|
+
* site supplies for itself is half a convention. Both sites' copies were
|
|
42
|
+
* byte-identical when it moved here, so the gate gained a file rather than the
|
|
43
|
+
* sites gaining a difference.
|
|
44
|
+
*
|
|
45
|
+
* Canonical bytes are resolved relative to THIS file, so the script works both
|
|
46
|
+
* workspace-symlinked and installed from the published tarball — the same
|
|
47
|
+
* arrangement as conventions/check-drift.ts.
|
|
48
|
+
*/
|
|
49
|
+
import { cpSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
50
|
+
import { dirname, join, relative } from "node:path";
|
|
51
|
+
import { fileURLToPath } from "node:url";
|
|
52
|
+
|
|
53
|
+
const CANONICAL = dirname(fileURLToPath(import.meta.url));
|
|
54
|
+
|
|
55
|
+
/** The site subdirectory the canonical tree is copied into. */
|
|
56
|
+
export const SITE_DIR = "src";
|
|
57
|
+
|
|
58
|
+
/** The one canonical subdirectory that mirrors the site's root, not its `src/`
|
|
59
|
+
* — Astro serves `public/` verbatim, and `starlight.ts` names `/favicon.png`. */
|
|
60
|
+
export const PUBLIC_DIR = "public";
|
|
61
|
+
|
|
62
|
+
/** Where a canonical file lands in a site, relative to the site root. */
|
|
63
|
+
export function sitePath(rel: string): string {
|
|
64
|
+
return rel === PUBLIC_DIR || rel.startsWith(`${PUBLIC_DIR}/`) ? rel : join(SITE_DIR, rel);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Every canonical file, as a path relative to this directory — which is also
|
|
69
|
+
* its path relative to the site's `src/` ({@link sitePath}, bar `public/`).
|
|
70
|
+
* Derived from the tree rather than listed, so a file added here reaches every
|
|
71
|
+
* site without a second edit; the script's own source and its test are the only
|
|
72
|
+
* exclusions.
|
|
73
|
+
*/
|
|
74
|
+
export function themeFiles(canonical: string = CANONICAL): string[] {
|
|
75
|
+
const out: string[] = [];
|
|
76
|
+
const walk = (dir: string) => {
|
|
77
|
+
for (const name of readdirSync(dir).sort()) {
|
|
78
|
+
const p = join(dir, name);
|
|
79
|
+
if (statSync(p).isDirectory()) {
|
|
80
|
+
walk(p);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const rel = relative(canonical, p);
|
|
84
|
+
if (rel === "sync.ts" || rel === "sync.test.ts" || rel === "README.md") continue;
|
|
85
|
+
out.push(rel);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
walk(canonical);
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface ThemeDrift {
|
|
93
|
+
ok: boolean;
|
|
94
|
+
problems: string[];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const RESYNC = "Edit the dev-kit source and re-run `sync`, never hand-edit the copy.";
|
|
98
|
+
|
|
99
|
+
/** Compare a site's copies against the canonical bytes. Text is diffed by line
|
|
100
|
+
* so the failure names the drift; binaries (the woff2) compare byte-wise. */
|
|
101
|
+
export function checkTheme(siteRoot: string, canonical: string = CANONICAL): ThemeDrift {
|
|
102
|
+
const problems: string[] = [];
|
|
103
|
+
for (const rel of themeFiles(canonical)) {
|
|
104
|
+
const at = sitePath(rel);
|
|
105
|
+
const want = readFileSync(join(canonical, rel));
|
|
106
|
+
let got: Buffer;
|
|
107
|
+
try {
|
|
108
|
+
got = readFileSync(join(siteRoot, at));
|
|
109
|
+
} catch {
|
|
110
|
+
problems.push(`${at}: missing — the docs theme is not synced. ${RESYNC}`);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (got.equals(want)) continue;
|
|
114
|
+
problems.push(`${at}: ${firstDiff(got, want)}. ${RESYNC}`);
|
|
115
|
+
}
|
|
116
|
+
return { ok: problems.length === 0, problems };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function firstDiff(got: Buffer, want: Buffer): string {
|
|
120
|
+
// A woff2 has no lines worth naming; say so rather than printing binary.
|
|
121
|
+
if (got.includes(0) || want.includes(0))
|
|
122
|
+
return `differs from the canonical (${want.length} bytes, found ${got.length})`;
|
|
123
|
+
const a = got.toString("utf8").split("\n");
|
|
124
|
+
const e = want.toString("utf8").split("\n");
|
|
125
|
+
for (let i = 0; i < Math.max(a.length, e.length); i++) {
|
|
126
|
+
if (a[i] !== e[i]) {
|
|
127
|
+
return `line ${i + 1}: expected ${JSON.stringify(e[i] ?? "<missing>")}, found ${JSON.stringify(a[i] ?? "<missing>")}`;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return "content differs";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Rewrite the site's copies from the canonical bytes. */
|
|
134
|
+
export function syncTheme(siteRoot: string, canonical: string = CANONICAL): string[] {
|
|
135
|
+
const written: string[] = [];
|
|
136
|
+
for (const rel of themeFiles(canonical)) {
|
|
137
|
+
const at = sitePath(rel);
|
|
138
|
+
const to = join(siteRoot, at);
|
|
139
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
140
|
+
cpSync(join(canonical, rel), to);
|
|
141
|
+
written.push(at);
|
|
142
|
+
}
|
|
143
|
+
return written;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (import.meta.main) {
|
|
147
|
+
const [mode, site] = process.argv.slice(2);
|
|
148
|
+
const siteRoot = site ?? process.cwd();
|
|
149
|
+
if (mode === "sync") {
|
|
150
|
+
for (const f of syncTheme(siteRoot)) console.log(`wrote ${f}`);
|
|
151
|
+
} else if (mode === "check") {
|
|
152
|
+
const { ok, problems } = checkTheme(siteRoot);
|
|
153
|
+
if (!ok) {
|
|
154
|
+
console.error("docs theme drift:");
|
|
155
|
+
for (const p of problems) console.error(` - ${p}`);
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
console.log(`docs theme: ${themeFiles().length} files in step with the dev-kit.`);
|
|
159
|
+
} else {
|
|
160
|
+
console.error("usage: sync.ts check|sync [site-root]");
|
|
161
|
+
process.exit(2);
|
|
162
|
+
}
|
|
163
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@norskvideo/ctl-dev-kit",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./create-product": "./create-product/create-product.ts",
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"./doc-guide/instance-proxy": "./doc-guide/instance-proxy.js",
|
|
13
13
|
"./doc-guide/live-browser": "./doc-guide/live-browser.js",
|
|
14
14
|
"./doc-guide/prose-bundle": "./doc-guide/prose-bundle.js",
|
|
15
|
+
"./docs-theme/sync": "./docs-theme/sync.ts",
|
|
15
16
|
"./docs/path-existence": "./docs/path-existence.ts",
|
|
16
17
|
"./docs/splice-generated": "./docs/splice-generated.ts",
|
|
17
18
|
"./licence/check-entitlement": "./licence/check-entitlement.ts",
|