create-website-build-kit 0.1.14 → 0.1.16

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.
@@ -27,7 +27,34 @@
27
27
  import { readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
28
28
  import { dirname, basename, resolve } from 'node:path';
29
29
 
30
- import puppeteer from 'puppeteer';
30
+ /*
31
+ * ⚠ puppeteer IS A TRANSITIVE DEPENDENCY, NOT A DECLARED ONE. The comment above
32
+ * is true only while `pa11y-ci` is a devDependency of this project. Run pa11y
33
+ * as `npx --yes pa11y-ci` instead — which a project reasonably might — and
34
+ * puppeteer is never installed here at all.
35
+ *
36
+ * A fork that did exactly that compensated by hunting for a puppeteer inside
37
+ * `~/.npm/_npx`, found a stale one whose bundled Chrome would not launch, and
38
+ * timed out after thirty seconds with nothing pointing at the cause.
39
+ *
40
+ * So say it plainly rather than let a bare import throw ERR_MODULE_NOT_FOUND:
41
+ * a script whose dependency is a side effect of how you happened to run a
42
+ * different script is a script that breaks later, on someone else's machine,
43
+ * for reasons that look unrelated.
44
+ */
45
+ let puppeteer;
46
+ try {
47
+ puppeteer = (await import('puppeteer')).default;
48
+ } catch {
49
+ console.error(
50
+ `\nmd-to-pdf needs puppeteer, which is not installed.\n\n` +
51
+ ` It normally arrives with pa11y-ci, so this usually means pa11y-ci was\n` +
52
+ ` removed from devDependencies, or is being run as \`npx --yes pa11y-ci\`\n` +
53
+ ` rather than installed.\n\n` +
54
+ ` Fix: npm install --save-dev puppeteer\n`,
55
+ );
56
+ process.exit(1);
57
+ }
31
58
 
32
59
  const [, , input, outputArg] = process.argv;
33
60
  if (!input) {
@@ -16,9 +16,18 @@
16
16
  import sharp from 'sharp';
17
17
  import fs from 'node:fs/promises';
18
18
  import path from 'node:path';
19
- import { fileURLToPath } from 'node:url';
20
19
 
21
- const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
20
+ /*
21
+ * ⚠ THE PROJECT ROOT IS THE CWD, as it is in every other script here.
22
+ *
23
+ * This resolved from the SCRIPT's own location. In every supported flow the
24
+ * two are the same directory — `npm run media` runs with the cwd at the
25
+ * package root — so the change is a no-op in use. What it was not was
26
+ * testable: the script read its own media/source/ no matter where it was
27
+ * pointed, so a fixture could not exercise it at all, and the run that added
28
+ * a non-zero exit had nothing proving the exit fires.
29
+ */
30
+ const ROOT = process.cwd();
22
31
  const SRC = path.join(ROOT, 'media/source');
23
32
  const OUT = path.join(ROOT, 'public/img');
24
33
  const MANIFEST = path.join(ROOT, 'src/data/image-manifest.json');
@@ -56,7 +65,25 @@ const WEBP = { quality: 78, effort: 6 };
56
65
  const AVIF = { quality: 55, effort: 4 };
57
66
  const FORMATS = ['avif', 'webp'];
58
67
 
59
- const RASTER = /\.(jpe?g|png|webp|tiff?)$/i;
68
+ const RESET = '\x1b[0m';
69
+ const RED = '\x1b[31m';
70
+ const YELLOW = '\x1b[33m';
71
+ const DIM = '\x1b[2m';
72
+
73
+ /*
74
+ * ⚠ heic/heif ARE HERE ON PURPOSE. libvips in the sharp this kit already ships
75
+ * reads them — check with `node -e "console.log(require('sharp').format.heif)"`.
76
+ * Leaving them out meant the single likeliest wrong format, a photo straight
77
+ * off an iPhone, was discarded by our own regex while the library underneath
78
+ * handled it fine. It produced no output, no warning and no manifest entry.
79
+ */
80
+ const RASTER = /\.(jpe?g|png|webp|tiff?|heic|heif)$/i;
81
+
82
+ /* Sources are committed forever. Output is capped by the width ladder, so a
83
+ huge original costs visitors nothing and costs the REPO permanently — say so
84
+ in those words, or someone "fixes" a page-speed problem that does not exist. */
85
+ const BIG_PIXELS = 24_000_000;
86
+ const BIG_BYTES = 8 * 1024 * 1024;
60
87
 
61
88
  /*
62
89
  * ── DO NOT RESET THIS TO {} ────────────────────────────────────────────────
@@ -362,11 +389,45 @@ const before = await bytes(SRC);
362
389
  console.log('optimizing media…');
363
390
  await copyBrand(files.filter((f) => f.includes(`${path.sep}brand${path.sep}`)));
364
391
 
365
- for (const file of files.filter((f) => RASTER.test(f))) {
392
+ /*
393
+ * ⚠ THE RUN MUST NAME EVERYTHING IT DID NOT PRODUCE.
394
+ *
395
+ * This loop used to be `files.filter(RASTER)`. Anything else was dropped with
396
+ * no output, no warning and no manifest entry — so a `.heic` in media/source/
397
+ * simply was not on the site, and the failure surfaced much later as <Img>
398
+ * throwing "no manifest entry for …" against a file plainly sitting in the
399
+ * repo. That reads as a bug in the kit rather than a rejected upload.
400
+ *
401
+ * And there was no try/catch, so one corrupt file aborted the run midway —
402
+ * after outputs were written and the manifest was partly updated, leaving the
403
+ * manifest describing a state on disk that no longer matched it.
404
+ */
405
+ const skipped = [];
406
+ const failed = [];
407
+ const oversized = [];
408
+
409
+ for (const file of files) {
366
410
  if (file.includes(`${path.sep}brand${path.sep}`)) continue;
367
- if (file.includes(`${path.sep}certifications${path.sep}`)) await emitSingle(file);
368
- else if (file.includes(`${path.sep}blog${path.sep}`)) await emitResponsive(file, BLOG_WIDTHS);
369
- else await emitResponsive(file, PHOTO_WIDTHS);
411
+ const rel = path.relative(SRC, file);
412
+
413
+ if (!RASTER.test(file)) {
414
+ skipped.push(rel);
415
+ continue;
416
+ }
417
+
418
+ try {
419
+ const { size } = await fs.stat(file);
420
+ const meta = await sharp(file).metadata();
421
+ if (size > BIG_BYTES || (meta.width ?? 0) * (meta.height ?? 0) > BIG_PIXELS) {
422
+ oversized.push(`${rel} ${meta.width}x${meta.height}, ${mb(size)}`);
423
+ }
424
+
425
+ if (file.includes(`${path.sep}certifications${path.sep}`)) await emitSingle(file);
426
+ else if (file.includes(`${path.sep}blog${path.sep}`)) await emitResponsive(file, BLOG_WIDTHS);
427
+ else await emitResponsive(file, PHOTO_WIDTHS);
428
+ } catch (err) {
429
+ failed.push(`${rel} ← ${String(err.message).split('\n')[0]}`);
430
+ }
370
431
  }
371
432
 
372
433
  await emitFavicons();
@@ -378,3 +439,29 @@ await fs.writeFile(MANIFEST, JSON.stringify(manifest, null, 2) + '\n');
378
439
  const after = await bytes(OUT);
379
440
  console.log(`\nsource ${mb(before)} → dist ${mb(after)} (${Math.round((1 - after / before) * 100)}% smaller)`);
380
441
  console.log(`${Object.keys(manifest).length} images in manifest`);
442
+
443
+ /* A report, not an error. A PDF or a .txt in media/source/ is legitimate — the
444
+ kit's own comp lives there — so this says what produced no image and lets the
445
+ reader judge. Only a genuine processing failure exits non-zero. */
446
+ if (skipped.length) {
447
+ console.log(`\n${YELLOW}!${RESET} ${skipped.length} file(s) produced no image (not a raster format):`);
448
+ for (const f of skipped) console.log(` ${DIM}${f}${RESET}`);
449
+ }
450
+
451
+ if (oversized.length) {
452
+ console.log(`\n${YELLOW}!${RESET} ${oversized.length} oversized source(s) — the SITE is unaffected:`);
453
+ for (const f of oversized) console.log(` ${DIM}${f}${RESET}`);
454
+ console.log(
455
+ ` ${DIM}Output is capped by the width ladder, so visitors never download these.\n` +
456
+ ` The cost is the repository, which carries them forever.${RESET}`,
457
+ );
458
+ }
459
+
460
+ if (failed.length) {
461
+ console.error(`\n${RED}✗ ${failed.length} file(s) failed to process:${RESET}`);
462
+ for (const f of failed) console.error(` ${f}`);
463
+ console.error(
464
+ `\n ${DIM}Every other file was still written and the manifest is complete for them.${RESET}\n`,
465
+ );
466
+ process.exit(1);
467
+ }
@@ -9,6 +9,7 @@
9
9
  * content migration.
10
10
  */
11
11
  import manifest from '../data/image-manifest.json';
12
+ import { isPickerPath, toImageKey } from '../lib/image-key';
12
13
 
13
14
  interface Props {
14
15
  /** Manifest key, e.g. "photos/hero-home". */
@@ -36,13 +37,29 @@ const {
36
37
  style,
37
38
  } = Astro.props;
38
39
 
39
- const entry = (manifest as Record<string, { src: string; srcset: string; width: number; height: number; widths?: number[]; avifSrcset?: string | null }>)[name];
40
+ /* A CMS picker returns `/img/photos/hero-1200.webp`, never `photos/hero`.
41
+ Normalising here is what lets an image field be a real picker instead of a
42
+ free-text box asking the client to type a manifest key from memory. */
43
+ const key = toImageKey(name);
44
+ const entry = (manifest as Record<string, { src: string; srcset: string; width: number; height: number; widths?: number[]; avifSrcset?: string | null }>)[key];
40
45
 
41
46
  if (!entry) {
42
47
  // Fail the build, not the page. A missing image is a content bug and should
43
48
  // never reach a deploy as a silently broken <img>.
49
+ //
50
+ // ⚠ THE THROWN TEXT IS PURE ASCII ON PURPOSE. The Cloudflare adapter puts a
51
+ // prerender failure into the `x-astro-prerender-error` HTTP HEADER, and a
52
+ // header carrying non-ASCII warns loudly and arrives mangled: an em dash
53
+ // came back as `â`. Comments may use the house style; anything inside a
54
+ // thrown string may not. See docs/traps.md.
44
55
  throw new Error(
45
- `Img: no manifest entry for "${name}". Run \`npm run media\` after adding it to media/source/.`,
56
+ isPickerPath(name)
57
+ ? `Img: "${name}" was uploaded straight into public/img/, which is where the media pipeline ` +
58
+ `WRITES, so it has no responsive variants, no width/height and no manifest entry.\n\n` +
59
+ `Point the CMS media source at media/source/ (what the pipeline READS), re-upload, ` +
60
+ `then run \`npm run media\`.\n\n` +
61
+ `The last good deploy is still live; this change just will not appear.`
62
+ : `Img: no manifest entry for "${name}" (resolved to "${key}"). Run \`npm run media\` after adding it to media/source/.`,
46
63
  );
47
64
  }
48
65
 
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Turn a public image PATH back into the manifest KEY it came from.
3
+ *
4
+ * ── WHY THIS EXISTS ────────────────────────────────────────────────────────
5
+ * `<Img>` resolves a manifest key — `photos/hero`. A CMS image picker cannot
6
+ * return one: it browses files and returns the public path of what it found,
7
+ * `/img/photos/hero-1200.webp`, because it has no idea a manifest exists.
8
+ *
9
+ * Without this, every image field in a CMS-managed site has to be a plain
10
+ * string with a description asking a non-technical editor to type a key from
11
+ * memory. That is not an editable field, it is a quiz — and it is why image
12
+ * editing was the part of the CMS that clients could never actually use.
13
+ *
14
+ * The two can meet because `optimize-media.mjs` writes exactly one shape:
15
+ *
16
+ * /img/ + <key> -<width>.<ext>
17
+ *
18
+ * so the mapping back is exact rather than a guess.
19
+ *
20
+ * ── THREE PROPERTIES THAT MAKE IT SAFE ─────────────────────────────────────
21
+ * 1. **Which variant the editor clicks does not matter.** `-480` and `-1800`
22
+ * normalise to the same key and render the identical full srcset.
23
+ * 2. **A key ending in a digit survives.** The width strip is anchored to the
24
+ * extension, so `photos/gift-card-slider-1`, reached via `…-1-480.webp`,
25
+ * comes back whole. An unanchored `-\d+` would eat the `-1`.
26
+ * 3. ⚠ **It must not assume `.webp`.** The kit emits AVIF alongside WebP and
27
+ * social cards are `.jpg`, so the extension strip stays generic.
28
+ */
29
+
30
+ /** `/img/photos/hero-1200.webp` → `photos/hero`. Anything else is returned as-is. */
31
+ export const toImageKey = (nameOrPath: string): string =>
32
+ isPickerPath(nameOrPath)
33
+ ? nameOrPath
34
+ .replace(/^\/img\//, '')
35
+ .replace(/-\d+(?=\.[a-z0-9]+$)/i, '')
36
+ .replace(/\.[a-z0-9]+$/i, '')
37
+ : nameOrPath;
38
+
39
+ /**
40
+ * Did this come from a media picker rather than being a manifest key?
41
+ *
42
+ * Used to tell an editor what actually went wrong. `/img/` is where the
43
+ * pipeline WRITES, so a value pointing there is either a processed variant
44
+ * (fine — `toImageKey` handles it) or a file uploaded straight into the output
45
+ * directory, which has no variants and no manifest entry at all.
46
+ */
47
+ export const isPickerPath = (nameOrPath: string): boolean => nameOrPath.startsWith('/img/');