@rtorcato/repo-tooling 4.3.1 → 4.4.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.
package/dist/base/checks.js
CHANGED
|
@@ -100,7 +100,7 @@ export async function checkCommunityHealth(dir) {
|
|
|
100
100
|
hint: 'Run `npx @rtorcato/repo-tooling fix community-health` to scaffold them',
|
|
101
101
|
};
|
|
102
102
|
}
|
|
103
|
-
const BRAND_HINT = 'Run `npx @rtorcato/repo-tooling fix brand` to scaffold brand/ (SVG sources + render.sh)
|
|
103
|
+
const BRAND_HINT = 'Run `npx @rtorcato/repo-tooling fix brand` to scaffold brand/ (SVG sources + render.sh) and render the PNGs (needs librsvg)';
|
|
104
104
|
/** The two banners the README consumes — the pair `brand/` exists to keep regenerable. */
|
|
105
105
|
const BANNERS = ['banner', 'banner-mobile'];
|
|
106
106
|
/**
|
package/dist/base/fixers.js
CHANGED
|
@@ -15,7 +15,7 @@ import path from 'node:path';
|
|
|
15
15
|
import chalk from 'chalk';
|
|
16
16
|
import fs from 'fs-extra';
|
|
17
17
|
import { installAgentRules, installAiSetup } from '../cli/generators/agent-rules.js';
|
|
18
|
-
import { generateBrand } from '../cli/generators/brand.js';
|
|
18
|
+
import { addReadmeBanner, generateBrand, renderBrand, resolveBrandMeta, } from '../cli/generators/brand.js';
|
|
19
19
|
import { generateCommunityHealth } from '../cli/generators/community-health.js';
|
|
20
20
|
import { generateCommitlintConfig } from '../cli/generators/git.js';
|
|
21
21
|
import { generateCodeowners, generateEditorConfig } from '../cli/generators/misc.js';
|
|
@@ -164,12 +164,13 @@ export const BASE_FIXERS = [
|
|
|
164
164
|
},
|
|
165
165
|
{
|
|
166
166
|
target: 'github-settings',
|
|
167
|
-
description: 'Apply branch protection + auto-merge + workflow permissions + code-scanning ruleset on GitHub via gh api (mutates the remote repo, not files)',
|
|
167
|
+
description: 'Apply branch protection + auto-merge + workflow permissions + Dependabot security updates + code-scanning ruleset on GitHub via gh api (mutates the remote repo, not files)',
|
|
168
168
|
appliesTo: [
|
|
169
169
|
'Branch protection',
|
|
170
170
|
'Merge settings',
|
|
171
171
|
'Workflow permissions',
|
|
172
172
|
'Code-scanning gate',
|
|
173
|
+
'Security updates',
|
|
173
174
|
],
|
|
174
175
|
outputs: ['GitHub repo settings (remote, via gh api)'],
|
|
175
176
|
// safe-add is load-bearing: it exempts this fixer from the `--diff` shadow-run
|
|
@@ -249,24 +250,34 @@ export const BASE_FIXERS = [
|
|
|
249
250
|
{
|
|
250
251
|
target: 'brand',
|
|
251
252
|
selfSafe: true,
|
|
252
|
-
description: 'Scaffold brand/ — banner, mobile-banner and social-card SVG sources + render.sh
|
|
253
|
+
description: 'Scaffold brand/ — favicon, banner, mobile-banner and social-card SVG sources + render.sh — render the PNGs and favicon.ico when rsvg-convert is on PATH, and add the README banner',
|
|
253
254
|
appliesTo: ['Brand assets'],
|
|
254
255
|
outputs: [
|
|
256
|
+
'brand/favicon.svg',
|
|
255
257
|
'brand/banner.svg',
|
|
256
258
|
'brand/banner-mobile.svg',
|
|
257
259
|
'brand/social-card.svg',
|
|
258
260
|
'brand/render.sh',
|
|
261
|
+
'brand/banner.png',
|
|
262
|
+
'brand/banner-mobile.png',
|
|
263
|
+
'brand/social-card.png',
|
|
264
|
+
'brand/favicon-512.png',
|
|
265
|
+
'brand/favicon.ico',
|
|
259
266
|
'README.md',
|
|
260
267
|
],
|
|
261
|
-
// Every SVG is written only when absent
|
|
262
|
-
//
|
|
268
|
+
// Every SVG is written only when absent, PNGs are re-rendered only when
|
|
269
|
+
// older than their source, and the README edit is a delimited block (or
|
|
270
|
+
// two image paths) — hand-edited art is never clobbered.
|
|
263
271
|
riskLevel: 'safe-merge',
|
|
264
272
|
canFixDrift: true,
|
|
265
273
|
async run({ targetDir, pkg, lock }) {
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
}
|
|
274
|
+
const tagline = lock?.rules?.brand?.tagline;
|
|
275
|
+
const filesWritten = await generateBrand(pkg, targetDir, tagline);
|
|
276
|
+
filesWritten.push(...((await renderBrand(targetDir)) ?? []));
|
|
277
|
+
const { name } = await resolveBrandMeta(pkg, targetDir, tagline);
|
|
278
|
+
const readme = await addReadmeBanner(targetDir, name);
|
|
279
|
+
if (readme && !filesWritten.includes(readme))
|
|
280
|
+
filesWritten.push(readme);
|
|
270
281
|
return { filesWritten };
|
|
271
282
|
},
|
|
272
283
|
},
|
|
@@ -54,10 +54,12 @@ export const GITHUB_STANDARD = {
|
|
|
54
54
|
const CODE_SCANNING_CHECK = 'Code-scanning gate';
|
|
55
55
|
export const RELEASE_GATE_CHECK = 'Release gate';
|
|
56
56
|
export const RELEASE_ENV_CHECK = 'Release environment';
|
|
57
|
+
const SECURITY_UPDATES_CHECK = 'Security updates';
|
|
57
58
|
const CHECK_NAMES = [
|
|
58
59
|
'Branch protection',
|
|
59
60
|
'Merge settings',
|
|
60
61
|
'Workflow permissions',
|
|
62
|
+
SECURITY_UPDATES_CHECK,
|
|
61
63
|
CODE_SCANNING_CHECK,
|
|
62
64
|
RELEASE_GATE_CHECK,
|
|
63
65
|
RELEASE_ENV_CHECK,
|
|
@@ -147,6 +149,7 @@ export async function checkGitHubSettings(dir, exec) {
|
|
|
147
149
|
await checkBranchProtection(gh, info.nwo, info.branch),
|
|
148
150
|
checkMergeSettings(info),
|
|
149
151
|
await checkWorkflowPermissions(gh, info.nwo),
|
|
152
|
+
await checkSecurityUpdates(gh, info.nwo),
|
|
150
153
|
await checkCodeScanningRuleset(gh, info.nwo, info.branch, dir),
|
|
151
154
|
...(await checkReleaseGate(gh, info.nwo, dir)),
|
|
152
155
|
];
|
|
@@ -277,6 +280,60 @@ async function checkWorkflowPermissions(exec, nwo) {
|
|
|
277
280
|
};
|
|
278
281
|
return { check, status: 'ok', detail: 'read-only default, no workflow PR approvals' };
|
|
279
282
|
}
|
|
283
|
+
/**
|
|
284
|
+
* Dependabot vulnerability alerts and automated security fixes (#692). A
|
|
285
|
+
* `dependabot.yml` only schedules version bumps; these two repo toggles are what
|
|
286
|
+
* surface and patch advisories, and both default off on a new repo. Neither
|
|
287
|
+
* endpoint has a body worth reading for alerts: 204 is on, 404 is off.
|
|
288
|
+
*/
|
|
289
|
+
async function readSecurityUpdates(exec, nwo) {
|
|
290
|
+
const enabled = async (endpoint) => {
|
|
291
|
+
const r = await exec(['api', `repos/${nwo}/${endpoint}`]);
|
|
292
|
+
if (!r.ok) {
|
|
293
|
+
if (/404|not found/i.test(r.stderr))
|
|
294
|
+
return false;
|
|
295
|
+
if (/403|forbidden/i.test(r.stderr))
|
|
296
|
+
return { skip: 'token lacks admin access' };
|
|
297
|
+
return { skip: `could not read ${endpoint}` };
|
|
298
|
+
}
|
|
299
|
+
if (endpoint === 'vulnerability-alerts')
|
|
300
|
+
return true;
|
|
301
|
+
try {
|
|
302
|
+
return JSON.parse(r.stdout).enabled === true;
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
return { skip: `could not parse ${endpoint} response` };
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
const alerts = await enabled('vulnerability-alerts');
|
|
309
|
+
if (typeof alerts !== 'boolean')
|
|
310
|
+
return alerts;
|
|
311
|
+
const fixes = await enabled('automated-security-fixes');
|
|
312
|
+
if (typeof fixes !== 'boolean')
|
|
313
|
+
return fixes;
|
|
314
|
+
return { alerts, fixes };
|
|
315
|
+
}
|
|
316
|
+
async function checkSecurityUpdates(exec, nwo) {
|
|
317
|
+
const check = SECURITY_UPDATES_CHECK;
|
|
318
|
+
const s = await readSecurityUpdates(exec, nwo);
|
|
319
|
+
if ('skip' in s)
|
|
320
|
+
return skip(check, s.skip);
|
|
321
|
+
const deltas = [];
|
|
322
|
+
if (!s.alerts)
|
|
323
|
+
deltas.push('vulnerability alerts disabled');
|
|
324
|
+
if (!s.fixes)
|
|
325
|
+
deltas.push('automated security fixes disabled');
|
|
326
|
+
// optional-missing, not drift: doctor promotes it when the lock records
|
|
327
|
+
// securityAutomation: true, and demotes it when the lock records false.
|
|
328
|
+
if (deltas.length)
|
|
329
|
+
return {
|
|
330
|
+
check,
|
|
331
|
+
status: 'optional-missing',
|
|
332
|
+
detail: deltas.join('; '),
|
|
333
|
+
hint: 'Run `npx @rtorcato/repo-tooling fix github-settings` to enable Dependabot alerts and security updates',
|
|
334
|
+
};
|
|
335
|
+
return { check, status: 'ok', detail: 'vulnerability alerts and automated security fixes on' };
|
|
336
|
+
}
|
|
280
337
|
/**
|
|
281
338
|
* True when CodeQL/code-scanning is enabled for the repo. Covers both ways it
|
|
282
339
|
* ships: an advanced-setup workflow on disk (what `fix codeql` scaffolds) or
|
|
@@ -764,6 +821,17 @@ export function buildGhApplyCommands(state) {
|
|
|
764
821
|
'can_approve_pull_request_reviews=false',
|
|
765
822
|
],
|
|
766
823
|
});
|
|
824
|
+
// Alerts first: GitHub refuses security fixes on a repo with alerts off.
|
|
825
|
+
if (state.alerts)
|
|
826
|
+
commands.push({
|
|
827
|
+
label: 'vulnerability alerts',
|
|
828
|
+
args: ['api', '-X', 'PUT', `repos/${state.nwo}/vulnerability-alerts`],
|
|
829
|
+
});
|
|
830
|
+
if (state.securityFixes)
|
|
831
|
+
commands.push({
|
|
832
|
+
label: 'automated security fixes',
|
|
833
|
+
args: ['api', '-X', 'PUT', `repos/${state.nwo}/automated-security-fixes`],
|
|
834
|
+
});
|
|
767
835
|
return commands;
|
|
768
836
|
}
|
|
769
837
|
/**
|
|
@@ -795,12 +863,15 @@ export async function applyGithubSettings(dir, exec) {
|
|
|
795
863
|
// (no admin) reports `ok` → treated as "nothing to apply", never a failed PUT.
|
|
796
864
|
const bp = await checkBranchProtection(gh, info.nwo, info.branch);
|
|
797
865
|
const wp = await checkWorkflowPermissions(gh, info.nwo);
|
|
866
|
+
const sec = await readSecurityUpdates(gh, info.nwo);
|
|
798
867
|
const commands = buildGhApplyCommands({
|
|
799
868
|
nwo: info.nwo,
|
|
800
869
|
branch: info.branch,
|
|
801
870
|
merge: checkMergeSettings(info).status === 'drift',
|
|
802
871
|
protection: bp.status === 'optional-missing' || bp.status === 'drift',
|
|
803
872
|
workflow: wp.status === 'drift',
|
|
873
|
+
alerts: !('skip' in sec) && !sec.alerts,
|
|
874
|
+
securityFixes: !('skip' in sec) && !sec.fixes,
|
|
804
875
|
});
|
|
805
876
|
const applied = [];
|
|
806
877
|
for (const cmd of commands) {
|
|
@@ -140,14 +140,26 @@ function checkLockfile(lock) {
|
|
|
140
140
|
detail: `.repo-tooling.json v${lock.version} (record written by ${lock.record.writtenBy})`,
|
|
141
141
|
};
|
|
142
142
|
}
|
|
143
|
+
// Checks whose absence `securityAutomation: true` turns from an optional gap
|
|
144
|
+
// into drift (#692): the lock says the repo chose them, so silence is a lie.
|
|
145
|
+
const REQUIRED_BY_SECURITY_AUTOMATION = new Set(['Dependabot', 'Security updates']);
|
|
143
146
|
// Lockfile-driven demotion: if the lock records an intentional opt-out for a
|
|
144
147
|
// check that's currently optional-missing, demote it to ok with a clear detail.
|
|
148
|
+
// The converse holds for a recorded opt-in (#692): promote it to drift.
|
|
145
149
|
function demoteDeclined(results, lock) {
|
|
146
150
|
if (!lock)
|
|
147
151
|
return results;
|
|
148
152
|
return results.map((r) => {
|
|
149
153
|
if (r.status !== 'optional-missing')
|
|
150
154
|
return r;
|
|
155
|
+
if (lock.record.config.securityAutomation === true &&
|
|
156
|
+
REQUIRED_BY_SECURITY_AUTOMATION.has(r.check)) {
|
|
157
|
+
return {
|
|
158
|
+
...r,
|
|
159
|
+
status: 'drift',
|
|
160
|
+
detail: `${r.detail}, but .repo-tooling.json records securityAutomation: true`,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
151
163
|
if (!declinedInLock(lock, r.check))
|
|
152
164
|
return r;
|
|
153
165
|
return {
|
|
@@ -28,6 +28,7 @@ export const FIX_TARGETS = {
|
|
|
28
28
|
'Merge settings': 'github-settings',
|
|
29
29
|
'Workflow permissions': 'github-settings',
|
|
30
30
|
'Code-scanning gate': 'github-settings',
|
|
31
|
+
'Security updates': 'github-settings',
|
|
31
32
|
Milestones: 'milestones',
|
|
32
33
|
CODEOWNERS: 'codeowners',
|
|
33
34
|
'GitLab CI': 'gitlab-ci',
|
|
@@ -159,6 +160,7 @@ export function declinedInLock(lock, checkName) {
|
|
|
159
160
|
case 'Merge settings':
|
|
160
161
|
case 'Workflow permissions':
|
|
161
162
|
case 'Code-scanning gate':
|
|
163
|
+
case 'Security updates':
|
|
162
164
|
return c.securityAutomation === false;
|
|
163
165
|
case 'publint':
|
|
164
166
|
return c.publint === false;
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* and falls back to a neutral grey. Nothing about any particular org is baked
|
|
13
13
|
* in; the templates are meant to be hand-edited afterwards.
|
|
14
14
|
*/
|
|
15
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
15
16
|
import path from 'node:path';
|
|
16
17
|
import fs from 'fs-extra';
|
|
17
18
|
/** Grey, so an unbranded repo reads as unbranded rather than borrowing a colour. */
|
|
@@ -80,10 +81,11 @@ async function accentFromDocsTheme(targetDir) {
|
|
|
80
81
|
return dark[1];
|
|
81
82
|
return css.match(/--ifm-color-primary:\s*(#[0-9a-fA-F]{6})/)?.[1] ?? null;
|
|
82
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'];
|
|
83
86
|
/** Failing that, the favicon's own ink — the other place a repo commits its colour. */
|
|
84
87
|
async function accentFromFavicon(targetDir) {
|
|
85
|
-
|
|
86
|
-
for (const rel of candidates) {
|
|
88
|
+
for (const rel of EXISTING_FAVICONS) {
|
|
87
89
|
const file = path.join(targetDir, rel);
|
|
88
90
|
if (!(await fs.pathExists(file)))
|
|
89
91
|
continue;
|
|
@@ -123,17 +125,23 @@ export async function resolveBrandMeta(pkg, targetDir, tagline) {
|
|
|
123
125
|
};
|
|
124
126
|
}
|
|
125
127
|
/**
|
|
126
|
-
* The logo
|
|
127
|
-
*
|
|
128
|
-
*
|
|
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).
|
|
129
131
|
*/
|
|
130
|
-
function
|
|
132
|
+
export function faviconSvg(meta) {
|
|
131
133
|
const initial = esc((meta.name[0] ?? '?').toUpperCase());
|
|
132
|
-
return
|
|
133
|
-
<
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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}"/>`;
|
|
137
145
|
}
|
|
138
146
|
/** `repo-tooling` renders as a muted `repo-` and an accented `tooling`. */
|
|
139
147
|
function wordmark(meta) {
|
|
@@ -188,7 +196,7 @@ function canvas(meta, w, h, glow) {
|
|
|
188
196
|
/** 1280×320 README banner — left-aligned lockup, install pill on the right. */
|
|
189
197
|
export function bannerSvg(meta) {
|
|
190
198
|
return `${canvas(meta, 1280, 320, { cx: 0.16, cy: 0 })}
|
|
191
|
-
${mark(
|
|
199
|
+
${mark(60, 88, 72)}
|
|
192
200
|
|
|
193
201
|
<text x="156" y="150" font-family="Avenir Next" font-weight="800" font-size="62" letter-spacing="-1.5">${wordmark(meta)}</text>
|
|
194
202
|
|
|
@@ -200,7 +208,7 @@ ${installPanel(meta, { x: 845, y: 118, w: 378, h: 84, size: 20 })}
|
|
|
200
208
|
/** 1280×786 mobile banner — the same content stacked so it stays legible on a phone. */
|
|
201
209
|
export function bannerMobileSvg(meta) {
|
|
202
210
|
return `${canvas(meta, 1280, 786, { cx: 0.12, cy: 0.05 })}
|
|
203
|
-
${mark(
|
|
211
|
+
${mark(565, 104, 150)}
|
|
204
212
|
|
|
205
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>
|
|
206
214
|
|
|
@@ -212,7 +220,7 @@ ${installPanel(meta, { x: 427, y: 650, w: 426, h: 78, size: 24 })}
|
|
|
212
220
|
/** 1280×640 Open Graph / GitHub social card. Keep content inside an ~8% safe inset. */
|
|
213
221
|
export function socialCardSvg(meta) {
|
|
214
222
|
return `${canvas(meta, 1280, 640, { cx: 0.1, cy: 0.05 })}
|
|
215
|
-
${mark(
|
|
223
|
+
${mark(590, 120, 100)}
|
|
216
224
|
|
|
217
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>
|
|
218
226
|
|
|
@@ -229,7 +237,7 @@ ${installPanel(meta, { x: 427, y: 470, w: 426, h: 78, size: 24 })}
|
|
|
229
237
|
export const RENDER_SH = `#!/usr/bin/env bash
|
|
230
238
|
# Render the committed brand PNGs from their SVG sources.
|
|
231
239
|
# Sizes come from the brand-asset spec: 1280x320 banner, 1280x786 mobile,
|
|
232
|
-
# 1280x640 social card, 512x512 PWA icon.
|
|
240
|
+
# 1280x640 social card, 512x512 PWA icon. (\`fix brand\` also packs favicon.ico.)
|
|
233
241
|
set -euo pipefail
|
|
234
242
|
cd "$(dirname "$0")/.."
|
|
235
243
|
|
|
@@ -241,7 +249,9 @@ fi
|
|
|
241
249
|
|
|
242
250
|
rsvg-convert -w 1280 -h 320 brand/banner.svg -o brand/banner.png
|
|
243
251
|
rsvg-convert -w 1280 -h 786 brand/banner-mobile.svg -o brand/banner-mobile.png
|
|
244
|
-
|
|
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"
|
|
245
255
|
|
|
246
256
|
# The docs-site assets, rendered only when the site exists to hold them.
|
|
247
257
|
img=apps/docs/static/img
|
|
@@ -279,15 +289,25 @@ export async function repointReadmeBanners(targetDir) {
|
|
|
279
289
|
await fs.writeFile(file, next);
|
|
280
290
|
return 'README.md';
|
|
281
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
|
+
}
|
|
282
301
|
/**
|
|
283
|
-
* Scaffold `brand/`: three SVG sources
|
|
284
|
-
* README still on the old root-level paths.
|
|
285
|
-
* absent, so `fix brand` is idempotent.
|
|
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.
|
|
286
305
|
*/
|
|
287
306
|
export async function generateBrand(pkg, targetDir, tagline) {
|
|
288
307
|
const meta = await resolveBrandMeta(pkg, targetDir, tagline);
|
|
289
308
|
const written = [];
|
|
290
309
|
const files = [
|
|
310
|
+
['brand/favicon.svg', (await existingFavicon(targetDir)) ?? faviconSvg(meta)],
|
|
291
311
|
['brand/banner.svg', bannerSvg(meta)],
|
|
292
312
|
['brand/banner-mobile.svg', bannerMobileSvg(meta)],
|
|
293
313
|
['brand/social-card.svg', socialCardSvg(meta)],
|
|
@@ -307,3 +327,118 @@ export async function generateBrand(pkg, targetDir, tagline) {
|
|
|
307
327
|
written.push(readme);
|
|
308
328
|
return written;
|
|
309
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
|
+
export const BANNER_START = '<!-- js-tooling:banner:start -->';
|
|
406
|
+
export const BANNER_END = '<!-- js-tooling:banner:end -->';
|
|
407
|
+
/** The README `<picture>` banner, mobile variant under 640px, as a delimited block. */
|
|
408
|
+
export function buildBannerBlock(name) {
|
|
409
|
+
return `${BANNER_START}
|
|
410
|
+
<picture>
|
|
411
|
+
<source media="(max-width: 640px)" srcset="./brand/banner-mobile.png">
|
|
412
|
+
<img src="./brand/banner.png" alt="${esc(name)} banner" width="1600">
|
|
413
|
+
</picture>
|
|
414
|
+
${BANNER_END}`;
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Put the banner block at the top of a README. Refreshes an existing block in
|
|
418
|
+
* place; leaves alone a README that already shows a banner outside one (a
|
|
419
|
+
* hand-written `<picture>`); otherwise prepends. Idempotent.
|
|
420
|
+
*/
|
|
421
|
+
export function upsertBanner(readme, block) {
|
|
422
|
+
const start = readme.indexOf(BANNER_START);
|
|
423
|
+
const end = readme.indexOf(BANNER_END);
|
|
424
|
+
if (start !== -1 && end > start) {
|
|
425
|
+
return readme.slice(0, start) + block + readme.slice(end + BANNER_END.length);
|
|
426
|
+
}
|
|
427
|
+
if (/banner(?:-mobile)?\.png/.test(readme))
|
|
428
|
+
return readme;
|
|
429
|
+
return `${block}\n\n${readme}`;
|
|
430
|
+
}
|
|
431
|
+
/** Add the banner block to README.md once `brand/banner.png` exists to show. */
|
|
432
|
+
export async function addReadmeBanner(targetDir, name) {
|
|
433
|
+
const file = path.join(targetDir, 'README.md');
|
|
434
|
+
if (!(await fs.pathExists(file)))
|
|
435
|
+
return null;
|
|
436
|
+
if (!(await fs.pathExists(path.join(targetDir, 'brand', 'banner.png'))))
|
|
437
|
+
return null;
|
|
438
|
+
const readme = await fs.readFile(file, 'utf-8');
|
|
439
|
+
const next = upsertBanner(readme, buildBannerBlock(name));
|
|
440
|
+
if (next === readme)
|
|
441
|
+
return null;
|
|
442
|
+
await fs.writeFile(file, next);
|
|
443
|
+
return 'README.md';
|
|
444
|
+
}
|
package/package.json
CHANGED