@rtorcato/repo-tooling 4.7.0 → 5.0.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.
@@ -72,7 +72,6 @@ import { generateTailwind } from '../../cli/generators/tailwind.js';
72
72
  import { generateTurborepo } from '../../cli/generators/turborepo.js';
73
73
  import { generateNx } from '../../cli/generators/nx.js';
74
74
  import { generateBun } from '../../cli/generators/bun.js';
75
- import { generateDocsSite } from '../../cli/generators/docs-site.js';
76
75
  import { generateTypedocConfig, generateTypedocWorkflow } from '../../cli/generators/typedoc.js';
77
76
  import { copyPreset } from '../../cli/utils/copy-preset.js';
78
77
  import { identifiablePresetHashes } from '../../cli/utils/copied-assets.js';
@@ -739,45 +738,6 @@ export const FIXERS = [
739
738
  return { filesWritten };
740
739
  },
741
740
  },
742
- {
743
- target: 'docs-site',
744
- description: 'Scaffold a Docusaurus docs site under apps/docs (config/sidebars/tokens + reusable Pages deploy), inferring name/org/repo from package.json',
745
- // Manual/opt-in target — the "Docs site" doctor check is opt-in (only
746
- // surfaces once a site exists), so this never nags a repo without one.
747
- appliesTo: [],
748
- outputs: [
749
- 'apps/docs/**',
750
- 'scripts/sync-changelog.mjs',
751
- 'pnpm-workspace.yaml',
752
- '.github/workflows/docs.yml',
753
- ],
754
- riskLevel: 'safe-add',
755
- async run({ targetDir, pkg }) {
756
- // Wire the TypeDoc API section (#229) when the repo already uses TypeDoc —
757
- // a typedoc config on disk or the dep installed. No new CLI flag needed.
758
- const deps = {
759
- ...(pkg?.dependencies ?? {}),
760
- ...(pkg?.devDependencies ?? {}),
761
- };
762
- const typedocConfigs = [
763
- 'typedoc.json',
764
- 'typedoc.config.js',
765
- 'typedoc.config.mjs',
766
- 'typedoc.config.cjs',
767
- 'typedoc.config.ts',
768
- ];
769
- let hasTypedocConfig = false;
770
- for (const c of typedocConfigs) {
771
- if (await fs.pathExists(path.join(targetDir, c))) {
772
- hasTypedocConfig = true;
773
- break;
774
- }
775
- }
776
- const typedoc = hasTypedocConfig || 'typedoc' in deps;
777
- const filesWritten = await generateDocsSite(pkg, targetDir, { typedoc });
778
- return { filesWritten };
779
- },
780
- },
781
741
  {
782
742
  target: 'attw',
783
743
  description: 'Install @arethetypeswrong/cli + add an `attw` script (esm-only profile when applicable) and wire it into verify',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "4.7.0",
3
+ "version": "5.0.0",
4
4
  "description": "One CLI to scaffold, audit and fix your repo's whole toolchain — linting, tests, commits, releases & CI.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -85,12 +85,6 @@
85
85
  "tooling/rollup/rollup.config.d.mts",
86
86
  "tooling/rolldown/rolldown.config.mjs",
87
87
  "tooling/rolldown/rolldown.config.d.mts",
88
- "tooling/docusaurus/index.mjs",
89
- "tooling/docusaurus/index.d.mts",
90
- "tooling/docusaurus/sync-changelog.mjs",
91
- "tooling/docusaurus/docs-helpers.mjs",
92
- "tooling/docusaurus/theme-tokens.css",
93
- "tooling/docusaurus/theme.css",
94
88
  "tooling/biome/preset.json",
95
89
  "tooling/bun/bunfig.toml",
96
90
  "tooling/nx/nx.json",
@@ -199,10 +193,6 @@
199
193
  "types": "./tooling/rolldown/rolldown.config.d.mts",
200
194
  "import": "./tooling/rolldown/rolldown.config.mjs"
201
195
  },
202
- "./docusaurus": {
203
- "types": "./tooling/docusaurus/index.d.mts",
204
- "import": "./tooling/docusaurus/index.mjs"
205
- },
206
196
  "./biome": "./tooling/biome/preset.json",
207
197
  "./nx": "./tooling/nx/nx.json",
208
198
  "./changesets": "./tooling/changesets/config.json",
@@ -84,7 +84,7 @@ export default {
84
84
  // and the `version` field in package.json stop moving on the default branch.**
85
85
  // The git tag, the npm publish and the GitHub Release are unaffected and are
86
86
  // the source of truth for what shipped. Build any user-facing changelog from
87
- // GitHub Releases — `repo-tooling copy docusaurus-sync-changelog` does exactly
87
+ // GitHub Releases — `@rtorcato/shared-docs`'s sync-changelog script does exactly
88
88
  // that — never from the frozen file.
89
89
  //
90
90
  // Adding a bypass actor to the ruleset would also make the push succeed. It is
@@ -1,467 +0,0 @@
1
- /**
2
- * Brand generator (#395) — the sources half of the brand-asset spec (#318).
3
- *
4
- * #318 standardised *which* images a repo ships; this writes **where they come
5
- * from**: `brand/` holds the SVG sources and `brand/render.sh` renders them to
6
- * PNG, so a banner can be recoloured, retitled or resized instead of being a
7
- * committed binary nobody can regenerate.
8
- *
9
- * Everything in the emitted SVGs is derived from the consuming repo — name from
10
- * its package.json, tagline from `rules.brand.tagline` in .repo-tooling.json or
11
- * else the package.json description (#666), accent from its own docs theme or favicon —
12
- * and falls back to a neutral grey. Nothing about any particular org is baked
13
- * in; the templates are meant to be hand-edited afterwards.
14
- */
15
- import { execFileSync, spawnSync } from 'node:child_process';
16
- import path from 'node:path';
17
- import fs from 'fs-extra';
18
- /** Grey, so an unbranded repo reads as unbranded rather than borrowing a colour. */
19
- const NEUTRAL_ACCENT = '#8b95a7';
20
- /** The cool counter-glow in the corner opposite the accent one. Fixed — it reads as depth, not brand. */
21
- const COUNTER_GLOW = '#6e7bff';
22
- const INK = '#0A0E16';
23
- const TEXT = '#e6edf3';
24
- const MUTED = '#9ba6b8';
25
- /** `"` matters because this output also lands in double-quoted attributes (the `aria-label` below). */
26
- function esc(s) {
27
- return s
28
- .replace(/&/g, '&')
29
- .replace(/</g, '&lt;')
30
- .replace(/>/g, '&gt;')
31
- .replace(/"/g, '&quot;');
32
- }
33
- /**
34
- * Greedy word wrap to a character budget. Character-budgeted rather than
35
- * measured because there is no text metric available here — the templates are
36
- * meant to be nudged by hand once rendered.
37
- */
38
- export function wrapText(text, maxChars, maxLines) {
39
- const lines = [];
40
- let line = '';
41
- for (const word of text.split(/\s+/).filter(Boolean)) {
42
- const next = line ? `${line} ${word}` : word;
43
- if (next.length > maxChars && line) {
44
- lines.push(line);
45
- line = word;
46
- if (lines.length === maxLines)
47
- break;
48
- }
49
- else {
50
- line = next;
51
- }
52
- }
53
- if (lines.length < maxLines && line)
54
- lines.push(line);
55
- // Anything that didn't fit is dropped rather than overflowing the canvas.
56
- if (lines.length === maxLines && text.length > lines.join(' ').length) {
57
- lines[maxLines - 1] = `${lines[maxLines - 1]}…`;
58
- }
59
- return lines;
60
- }
61
- /** Near-black and near-white are background, not brand — skip them when sniffing a favicon. */
62
- function isBackgroundColour(hex) {
63
- const n = Number.parseInt(hex.slice(1), 16);
64
- const r = (n >> 16) & 0xff;
65
- const g = (n >> 8) & 0xff;
66
- const b = n & 0xff;
67
- const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
68
- return luminance < 60 || luminance > 225;
69
- }
70
- /**
71
- * The docs site's own accent, which is the most deliberate colour choice a repo
72
- * makes. Prefers the dark-mode value: these banners sit on a dark canvas.
73
- */
74
- async function accentFromDocsTheme(targetDir) {
75
- const file = path.join(targetDir, 'apps', 'docs', 'src', 'css', 'custom.css');
76
- if (!(await fs.pathExists(file)))
77
- return null;
78
- const css = await fs.readFile(file, 'utf-8');
79
- const dark = css.match(/\[data-theme=["']dark["']\][\s\S]*?--ifm-color-primary:\s*(#[0-9a-fA-F]{6})/);
80
- if (dark?.[1])
81
- return dark[1];
82
- return css.match(/--ifm-color-primary:\s*(#[0-9a-fA-F]{6})/)?.[1] ?? null;
83
- }
84
- /** Where a repo already keeps a favicon — the docs site's first. */
85
- const EXISTING_FAVICONS = [path.join('apps', 'docs', 'static', 'img', 'favicon.svg'), 'favicon.svg'];
86
- /** Failing that, the favicon's own ink — the other place a repo commits its colour. */
87
- async function accentFromFavicon(targetDir) {
88
- for (const rel of EXISTING_FAVICONS) {
89
- const file = path.join(targetDir, rel);
90
- if (!(await fs.pathExists(file)))
91
- continue;
92
- const svg = await fs.readFile(file, 'utf-8');
93
- for (const [hex] of svg.matchAll(/#[0-9a-fA-F]{6}\b/g)) {
94
- if (!isBackgroundColour(hex))
95
- return hex;
96
- }
97
- }
98
- return null;
99
- }
100
- /**
101
- * The narrowest tagline budget any canvas uses (the mobile banner). A tagline
102
- * that needs more than two lines of it crowds the layout and gets cut off.
103
- */
104
- const TAGLINE_MAX_CHARS = 42;
105
- /** True when `tagline` fits in two lines on every canvas, without an ellipsis. */
106
- export function taglineFits(tagline) {
107
- const words = tagline.split(/\s+/).filter(Boolean).join(' ');
108
- return wrapText(tagline, TAGLINE_MAX_CHARS, 2).join(' ') === words;
109
- }
110
- /**
111
- * `tagline` is `rules.brand.tagline` from .repo-tooling.json: a short line
112
- * written for the banner. Without it the package.json description stands in,
113
- * which is often a full sentence too long for the canvas (#666).
114
- */
115
- export async function resolveBrandMeta(pkg, targetDir, tagline) {
116
- const pkgName = typeof pkg?.name === 'string' ? pkg.name : undefined;
117
- const name = pkgName?.split('/').pop() ?? path.basename(path.resolve(targetDir));
118
- const description = typeof pkg?.description === 'string' ? pkg.description : '';
119
- const accent = (await accentFromDocsTheme(targetDir)) ?? (await accentFromFavicon(targetDir)) ?? NEUTRAL_ACCENT;
120
- return {
121
- name,
122
- tagline: tagline || description || 'Add a short tagline as rules.brand.tagline in .repo-tooling.json.',
123
- accent,
124
- install: pkgName && pkg?.private !== true ? pkgName : null,
125
- };
126
- }
127
- /**
128
- * The logo tile: a rounded square in the accent carrying the project's
129
- * initial. It *is* `brand/favicon.svg`, and every canvas below draws that file
130
- * rather than a copy of it, so swapping in a real glyph is a one-file edit (#678).
131
- */
132
- export function faviconSvg(meta) {
133
- const initial = esc((meta.name[0] ?? '?').toUpperCase());
134
- return `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
135
- <title>${esc(meta.name)}</title>
136
- <rect width="32" height="32" rx="8" fill="${meta.accent}"/>
137
- <text x="16" y="23" text-anchor="middle" font-family="Avenir Next" font-weight="800" font-size="19" fill="${INK}">${initial}</text>
138
- </svg>
139
- `;
140
- }
141
- /** The logo mark: `brand/favicon.svg`, drawn `size` px square. */
142
- function mark(x, y, size) {
143
- return ` <!-- Logo mark: brand/favicon.svg — edit that file to change it on every canvas. -->
144
- <image href="favicon.svg" x="${x}" y="${y}" width="${size}" height="${size}"/>`;
145
- }
146
- /** `repo-tooling` renders as a muted `repo-` and an accented `tooling`. */
147
- function wordmark(meta) {
148
- const i = meta.name.lastIndexOf('-');
149
- if (i <= 0)
150
- return `<tspan fill="${meta.accent}">${esc(meta.name)}</tspan>`;
151
- return `<tspan fill="${TEXT}">${esc(meta.name.slice(0, i + 1))}</tspan><tspan fill="${meta.accent}">${esc(meta.name.slice(i + 1))}</tspan>`;
152
- }
153
- function taglineBlock(meta, opts) {
154
- // Three lines: every canvas has room for a third, and cutting a real tagline
155
- // short is worse than one extra line of copy.
156
- const lines = wrapText(meta.tagline, opts.maxChars, 3);
157
- // librsvg does not reset x on a y-only tspan, so every line repeats x.
158
- const tspans = lines
159
- .map((l, i) => `\t\t<tspan x="${opts.x}" y="${opts.y + i * opts.step}">${esc(l)}</tspan>`)
160
- .join('\n');
161
- const anchor = opts.centred ? ' text-anchor="middle"' : '';
162
- return ` <text${anchor} font-family="Avenir Next" font-weight="500" font-size="${opts.size}" fill="${MUTED}">
163
- ${tspans}
164
- </text>`;
165
- }
166
- /** The install pill. Omitted entirely for a repo with nothing to `npm i`. */
167
- function installPanel(meta, opts) {
168
- if (!meta.install)
169
- return '';
170
- return `
171
- <rect x="${opts.x}" y="${opts.y}" width="${opts.w}" height="${opts.h}" rx="14" fill="#11151d" stroke="#232936" stroke-width="1"/>
172
- <text xml:space="preserve" x="${opts.x + opts.w / 2}" y="${opts.y + opts.h / 2 + opts.size / 3}" text-anchor="middle" font-family="Menlo" font-size="${opts.size}"><tspan fill="${meta.accent}">npm i </tspan><tspan fill="${TEXT}">${esc(meta.install)}</tspan></text>`;
173
- }
174
- function canvas(meta, w, h, glow) {
175
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" role="img" aria-label="${esc(meta.name)} — ${esc(meta.tagline)}">
176
- <defs>
177
- <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
178
- <stop offset="0" stop-color="#0d1117"/>
179
- <stop offset="1" stop-color="#090c13"/>
180
- </linearGradient>
181
- <radialGradient id="glow" cx="${glow.cx}" cy="${glow.cy}" r="0.55">
182
- <stop offset="0" stop-color="${meta.accent}" stop-opacity="0.15"/>
183
- <stop offset="1" stop-color="${meta.accent}" stop-opacity="0"/>
184
- </radialGradient>
185
- <radialGradient id="glow2" cx="0.92" cy="1" r="0.5">
186
- <stop offset="0" stop-color="${COUNTER_GLOW}" stop-opacity="0.12"/>
187
- <stop offset="1" stop-color="${COUNTER_GLOW}" stop-opacity="0"/>
188
- </radialGradient>
189
- </defs>
190
-
191
- <rect width="${w}" height="${h}" fill="url(#bg)"/>
192
- <rect width="${w}" height="${h}" fill="url(#glow)"/>
193
- <rect width="${w}" height="${h}" fill="url(#glow2)"/>
194
- `;
195
- }
196
- /** 1280×320 README banner — left-aligned lockup, install pill on the right. */
197
- export function bannerSvg(meta) {
198
- return `${canvas(meta, 1280, 320, { cx: 0.16, cy: 0 })}
199
- ${mark(60, 88, 72)}
200
-
201
- <text x="156" y="150" font-family="Avenir Next" font-weight="800" font-size="62" letter-spacing="-1.5">${wordmark(meta)}</text>
202
-
203
- ${taglineBlock(meta, { x: 62, y: 198, step: 28, size: 20, centred: false, maxChars: 44 })}
204
- ${installPanel(meta, { x: 845, y: 118, w: 378, h: 84, size: 20 })}
205
- </svg>
206
- `;
207
- }
208
- /** 1280×786 mobile banner — the same content stacked so it stays legible on a phone. */
209
- export function bannerMobileSvg(meta) {
210
- return `${canvas(meta, 1280, 786, { cx: 0.12, cy: 0.05 })}
211
- ${mark(565, 104, 150)}
212
-
213
- <text x="640" y="360" text-anchor="middle" font-family="Avenir Next" font-weight="800" font-size="76" letter-spacing="-1.8">${wordmark(meta)}</text>
214
-
215
- ${taglineBlock(meta, { x: 640, y: 510, step: 44, size: 30, centred: true, maxChars: 42 })}
216
- ${installPanel(meta, { x: 427, y: 650, w: 426, h: 78, size: 24 })}
217
- </svg>
218
- `;
219
- }
220
- /** 1280×640 Open Graph / GitHub social card. Keep content inside an ~8% safe inset. */
221
- export function socialCardSvg(meta) {
222
- return `${canvas(meta, 1280, 640, { cx: 0.1, cy: 0.05 })}
223
- ${mark(590, 120, 100)}
224
-
225
- <text x="640" y="300" text-anchor="middle" font-family="Avenir Next" font-weight="800" font-size="76" letter-spacing="-1.8">${wordmark(meta)}</text>
226
-
227
- ${taglineBlock(meta, { x: 640, y: 372, step: 42, size: 28, centred: true, maxChars: 44 })}
228
- ${installPanel(meta, { x: 427, y: 470, w: 426, h: 78, size: 24 })}
229
- </svg>
230
- `;
231
- }
232
- /**
233
- * The render script. Sizes come from the #318 spec. It self-skips outputs whose
234
- * source or destination doesn't exist, so the same script works in a repo with
235
- * no docs site.
236
- */
237
- export const RENDER_SH = `#!/usr/bin/env bash
238
- # Render the committed brand PNGs from their SVG sources.
239
- # Sizes come from the brand-asset spec: 1280x320 banner, 1280x786 mobile,
240
- # 1280x640 social card, 512x512 PWA icon. (\`fix brand\` also packs favicon.ico.)
241
- set -euo pipefail
242
- cd "$(dirname "$0")/.."
243
-
244
- if ! command -v rsvg-convert >/dev/null 2>&1; then
245
- echo "brand/render.sh needs librsvg — install it with: brew install librsvg" >&2
246
- echo "(apt: apt-get install librsvg2-bin)" >&2
247
- exit 1
248
- fi
249
-
250
- rsvg-convert -w 1280 -h 320 brand/banner.svg -o brand/banner.png
251
- rsvg-convert -w 1280 -h 786 brand/banner-mobile.svg -o brand/banner-mobile.png
252
- rsvg-convert -w 1280 -h 640 brand/social-card.svg -o brand/social-card.png
253
- rsvg-convert -w 512 -h 512 brand/favicon.svg -o brand/favicon-512.png
254
- echo "rendered: brand/banner.png brand/banner-mobile.png brand/social-card.png brand/favicon-512.png"
255
-
256
- # The docs-site assets, rendered only when the site exists to hold them.
257
- img=apps/docs/static/img
258
- if [ -d "$img" ]; then
259
- rsvg-convert -w 1280 -h 640 brand/social-card.svg -o "$img/social-card.png"
260
- echo "rendered: $img/social-card.png"
261
- if [ -f "$img/favicon.svg" ]; then
262
- rsvg-convert -w 512 -h 512 "$img/favicon.svg" -o "$img/favicon-512.png"
263
- echo "rendered: $img/favicon-512.png"
264
- fi
265
- fi
266
- `;
267
- /** Write `contents` at `rel` only when absent, so re-running never clobbers hand-edited art. */
268
- async function writeIfMissing(targetDir, rel, contents, mode) {
269
- const file = path.join(targetDir, rel);
270
- if (await fs.pathExists(file))
271
- return null;
272
- await fs.ensureDir(path.dirname(file));
273
- await fs.writeFile(file, contents, mode ? { mode } : undefined);
274
- return rel;
275
- }
276
- /**
277
- * Repoint a README still using the pre-amendment root-level banner paths at
278
- * `brand/`. Only the two banner `srcset`/`src` values move — nothing else in the
279
- * README is touched.
280
- */
281
- export async function repointReadmeBanners(targetDir) {
282
- const file = path.join(targetDir, 'README.md');
283
- if (!(await fs.pathExists(file)))
284
- return null;
285
- const readme = await fs.readFile(file, 'utf-8');
286
- const next = readme.replace(/(?<!brand\/)(?:\.\/)?(banner(?:-mobile)?\.png)/g, './brand/$1');
287
- if (next === readme)
288
- return null;
289
- await fs.writeFile(file, next);
290
- return 'README.md';
291
- }
292
- /** A favicon the repo already commits beats the generated initial tile. */
293
- async function existingFavicon(targetDir) {
294
- for (const rel of EXISTING_FAVICONS) {
295
- const file = path.join(targetDir, rel);
296
- if (await fs.pathExists(file))
297
- return fs.readFile(file, 'utf-8');
298
- }
299
- return null;
300
- }
301
- /**
302
- * Scaffold `brand/`: the favicon tile, three SVG sources that draw it, and the
303
- * render script, then repoint a README still on the old root-level paths.
304
- * Every file is written only when absent, so `fix brand` is idempotent.
305
- */
306
- export async function generateBrand(pkg, targetDir, tagline) {
307
- const meta = await resolveBrandMeta(pkg, targetDir, tagline);
308
- const written = [];
309
- const files = [
310
- ['brand/favicon.svg', (await existingFavicon(targetDir)) ?? faviconSvg(meta)],
311
- ['brand/banner.svg', bannerSvg(meta)],
312
- ['brand/banner-mobile.svg', bannerMobileSvg(meta)],
313
- ['brand/social-card.svg', socialCardSvg(meta)],
314
- ['brand/render.sh', RENDER_SH, 0o755],
315
- ];
316
- for (const [rel, contents, mode] of files) {
317
- const w = await writeIfMissing(targetDir, rel, contents, mode);
318
- if (w)
319
- written.push(w);
320
- }
321
- // stderr, not stdout: `fix --json` owns stdout (#357).
322
- if (written.some((f) => f.endsWith('.svg')) && !taglineFits(meta.tagline)) {
323
- console.error(' warning: the tagline needs more than two lines and will be cut off on the mobile banner — set a shorter one as rules.brand.tagline in .repo-tooling.json');
324
- }
325
- const readme = await repointReadmeBanners(targetDir);
326
- if (readme)
327
- written.push(readme);
328
- return written;
329
- }
330
- /** Printed when `rsvg-convert` is not on PATH — the sources are still written. */
331
- export const RSVG_HINT = ' next: install librsvg to render the brand PNGs (`brew install librsvg`, apt: `apt-get install librsvg2-bin`), then re-run `fix brand` or `brand/render.sh`';
332
- /** `[source, output, width, height]` under `brand/` — the same set render.sh draws. */
333
- const RENDERS = [
334
- ['banner.svg', 'banner.png', 1280, 320],
335
- ['banner-mobile.svg', 'banner-mobile.png', 1280, 786],
336
- ['social-card.svg', 'social-card.png', 1280, 640],
337
- ['favicon.svg', 'favicon-512.png', 512, 512],
338
- ['favicon.svg', 'favicon.ico', 32, 32],
339
- ];
340
- /** Classic favicon sizes packed into favicon.ico. */
341
- const ICO_SIZES = [16, 32];
342
- /**
343
- * An ICO container holding PNG frames — every browser since IE Vista reads
344
- * PNG-in-ICO, so no bitmap conversion is needed.
345
- */
346
- export function packIco(frames) {
347
- const header = Buffer.alloc(6 + 16 * frames.length);
348
- header.writeUInt16LE(1, 2); // type: icon
349
- header.writeUInt16LE(frames.length, 4);
350
- let offset = header.length;
351
- frames.forEach(([size, png], i) => {
352
- const e = 6 + 16 * i;
353
- header.writeUInt8(size % 256, e); // 0 means 256
354
- header.writeUInt8(size % 256, e + 1);
355
- header.writeUInt16LE(1, e + 4); // colour planes
356
- header.writeUInt16LE(32, e + 6); // bits per pixel
357
- header.writeUInt32LE(png.length, e + 8);
358
- header.writeUInt32LE(offset, e + 12);
359
- offset += png.length;
360
- });
361
- return Buffer.concat([header, ...frames.map(([, png]) => png)]);
362
- }
363
- async function mtime(file) {
364
- return (await fs.stat(file)).mtimeMs;
365
- }
366
- /**
367
- * Render every `brand/` PNG (and favicon.ico) that is missing or older than its
368
- * source — or than favicon.svg, which every canvas draws. Returns the files
369
- * written, or null when `rsvg-convert` is not on PATH (after printing
370
- * {@link RSVG_HINT}). Nothing stale means nothing to do and no PATH lookup.
371
- */
372
- export async function renderBrand(targetDir) {
373
- const brand = path.join(targetDir, 'brand');
374
- const favicon = path.join(brand, 'favicon.svg');
375
- const stale = [];
376
- for (const job of RENDERS) {
377
- const [src, out] = job;
378
- const srcFile = path.join(brand, src);
379
- const outFile = path.join(brand, out);
380
- if (!(await fs.pathExists(srcFile)))
381
- continue;
382
- const newest = Math.max(await mtime(srcFile), (await fs.pathExists(favicon)) ? await mtime(favicon) : 0);
383
- if (!(await fs.pathExists(outFile)) || (await mtime(outFile)) < newest)
384
- stale.push(job);
385
- }
386
- if (stale.length === 0)
387
- return [];
388
- if (spawnSync('rsvg-convert', ['--version']).error) {
389
- // stderr, not stdout: `fix --json` owns stdout (#357).
390
- console.error(RSVG_HINT);
391
- return null;
392
- }
393
- // cwd = brand/ so each canvas's `href="favicon.svg"` resolves beside it.
394
- const rsvg = (src, w, h) => execFileSync('rsvg-convert', ['-w', String(w), '-h', String(h), src], { cwd: brand });
395
- const written = [];
396
- for (const [src, out, w, h] of stale) {
397
- const png = out.endsWith('.ico')
398
- ? packIco(ICO_SIZES.map((s) => [s, rsvg(src, s, s)]))
399
- : rsvg(src, w, h);
400
- await fs.writeFile(path.join(brand, out), png);
401
- written.push(`brand/${out}`);
402
- }
403
- return written;
404
- }
405
- /** brand/ file → docs-site static/img file. The ico and card PNG exist only once rendered. */
406
- const DOCS_ASSETS = ['favicon.svg', 'favicon.ico', 'social-card.png'];
407
- /**
408
- * Copy the brand favicon and social card into the docs site's `static/img`
409
- * (#680). Copy-if-missing, and a no-op without `apps/docs`, so `fix brand` and
410
- * `fix docs-site` reach the same tree in either order — each calls it.
411
- */
412
- export async function syncBrandToDocs(targetDir) {
413
- if (!(await fs.pathExists(path.join(targetDir, 'apps', 'docs'))))
414
- return [];
415
- const img = path.join('apps', 'docs', 'static', 'img');
416
- const written = [];
417
- for (const name of DOCS_ASSETS) {
418
- const src = path.join(targetDir, 'brand', name);
419
- const dest = path.join(targetDir, img, name);
420
- if (!(await fs.pathExists(src)) || (await fs.pathExists(dest)))
421
- continue;
422
- await fs.ensureDir(path.dirname(dest));
423
- await fs.copyFile(src, dest);
424
- written.push(path.join(img, name));
425
- }
426
- return written;
427
- }
428
- export const BANNER_START = '<!-- js-tooling:banner:start -->';
429
- export const BANNER_END = '<!-- js-tooling:banner:end -->';
430
- /** The README `<picture>` banner, mobile variant under 640px, as a delimited block. */
431
- export function buildBannerBlock(name) {
432
- return `${BANNER_START}
433
- <picture>
434
- <source media="(max-width: 640px)" srcset="./brand/banner-mobile.png">
435
- <img src="./brand/banner.png" alt="${esc(name)} banner" width="1600">
436
- </picture>
437
- ${BANNER_END}`;
438
- }
439
- /**
440
- * Put the banner block at the top of a README. Refreshes an existing block in
441
- * place; leaves alone a README that already shows a banner outside one (a
442
- * hand-written `<picture>`); otherwise prepends. Idempotent.
443
- */
444
- export function upsertBanner(readme, block) {
445
- const start = readme.indexOf(BANNER_START);
446
- const end = readme.indexOf(BANNER_END);
447
- if (start !== -1 && end > start) {
448
- return readme.slice(0, start) + block + readme.slice(end + BANNER_END.length);
449
- }
450
- if (/banner(?:-mobile)?\.png/.test(readme))
451
- return readme;
452
- return `${block}\n\n${readme}`;
453
- }
454
- /** Add the banner block to README.md once `brand/banner.png` exists to show. */
455
- export async function addReadmeBanner(targetDir, name) {
456
- const file = path.join(targetDir, 'README.md');
457
- if (!(await fs.pathExists(file)))
458
- return null;
459
- if (!(await fs.pathExists(path.join(targetDir, 'brand', 'banner.png'))))
460
- return null;
461
- const readme = await fs.readFile(file, 'utf-8');
462
- const next = upsertBanner(readme, buildBannerBlock(name));
463
- if (next === readme)
464
- return null;
465
- await fs.writeFile(file, next);
466
- return 'README.md';
467
- }