Files
school_compare/nextjs-app/scripts/build-hero-images.cjs
TudorandClaude Opus 5 c5a4d106da feat(cms): install Payload and serve the admin panel
Payload 3.88 runs inside the Next app against the existing Postgres, in
its own 'payload' schema so no pipeline operation on public — the app
tables, Airflow's metadata, migrate_csv_to_db.py --drop — can reach blog
content.

Its REST API is mounted at /cms-api. /api is the FastAPI proxy's
catch-all, which would swallow every admin call and forward it to the
backend with no error. The mount points live in lib/payloadRoutes.ts so
there is one definition and a test can assert it without importing
Payload: it is ESM-only, next/jest will not transform it, and appending
transformIgnorePatterns cannot un-ignore a package. Forcing it through
transpilePackages would change how the production build bundles Payload
to serve a test, so the live proof that /api still reaches FastAPI stays
where it belongs — the e2e journeys, which call /api/schools.

The package becomes ESM ("type": "module"), which Payload's CLI requires:
richtext-lexical has top-level await and the config cannot be require()d.
Only two files needed renaming, jest.config.cjs and a build script.

The build is verified to succeed with DATABASE_URL and PAYLOAD_SECRET
both unset, which is how CI builds it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YmbBhr8s7GusjDE12hrZM
2026-09-02 16:17:01 +01:00

115 lines
4.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* Derivatives for the landing hero artwork.
*
* node scripts/build-hero-images.js
*
* Source of truth is assets/hero-source.png (1672×941). It is committed so
* these can be regenerated: never hand-edit anything in public/brand/hero-*,
* change the source or the numbers here and re-run.
*
* TWO CROPS, ON PURPOSE
* ---------------------
* The hero slot is not one shape. On desktop the artwork sits behind the whole
* panel at roughly 2.1:1 → 2.7:1; below the one-column breakpoint it collapses
* to a band between 2.6:1 and 4.9:1. A single file under `object-fit: cover`
* centre-crops, which at the extremes slices a strip through the middle of the
* scene and loses the schoolhouse — the same failure the previous SVG hero had.
*
* So:
* wide the full frame, including the empty cream area the artwork reserves
* on the left for the headline. Used behind the desktop panel.
* band a crop around the schoolhouse and the path, already near 2.6:1, so
* the narrow band barely crops it at all. The school sits at ~65%
* across, which is why the CSS pairs this with object-position: 65%:
* squeezing further toward 4.9:1 crops the empty sides, not the subject.
*
* AVIF and WebP are both emitted. This is flat-shaded vector-style artwork, so
* AVIF lands roughly an order of magnitude under the source PNG. Both crops
* also get a JPEG: the wide one backs the <img src>, and the band one backs a
* <source media> — without it a browser taking neither modern format would
* fall through to the desktop frame on a phone, since <img src> cannot vary by
* viewport.
*
* sharp is declared in devDependencies rather than leaned on as a transitive
* dependency of next. It does currently arrive that way, but relying on that
* means a Next upgrade can silently break this script — the same reasoning as
* the outputFileTracingIncludes note in next.config.js.
*/
const path = require('path');
const fs = require('fs');
const sharp = require('sharp');
const SRC = path.join(__dirname, '..', 'assets', 'hero-source.png');
const OUT = path.join(__dirname, '..', 'public', 'brand');
/*
* Band crop, in source pixels, measured against the artwork rather than
* guessed: the schoolhouse spans roughly x 1420–1860, y 420–660, with its flag
* up at y 330.
*
* The crop is flush to the right edge because it has to be. The school sits at
* about 82% across the frame, so there is only ~140px of artwork to its right
* — not enough to centre it. Anchoring right and taking 1050px back puts the
* school at ~66% across the crop, which is what BAND_FOCUS_X below reports to
* the CSS so the narrowest band crops the empty left side rather than the
* subject.
*/
const BAND = { left: 656, top: 300, width: 1344, height: 420 };
/** Where the schoolhouse lands in the band crop, for CSS object-position. */
const BAND_FOCUS_X = Math.round(((1420 + 1860) / 2 - BAND.left) / BAND.width * 100);
const WIDE_WIDTHS = [2000, 1400, 1000];
const BAND_WIDTHS = [1344, 900, 600];
async function emit(pipeline, name, width) {
const base = pipeline.clone().resize({ width, withoutEnlargement: true });
await base.clone().avif({ quality: 62, effort: 6 }).toFile(path.join(OUT, `${name}-${width}.avif`));
await base.clone().webp({ quality: 80, effort: 6 }).toFile(path.join(OUT, `${name}-${width}.webp`));
return width;
}
async function main() {
if (!fs.existsSync(SRC)) throw new Error(`missing source: ${SRC}`);
fs.mkdirSync(OUT, { recursive: true });
const meta = await sharp(SRC).metadata();
console.log(`source ${meta.width}×${meta.height}`);
console.log(`band focus x: ${BAND_FOCUS_X}% (keep .heroArt object-position in step)`);
const wide = sharp(SRC);
for (const w of WIDE_WIDTHS) await emit(wide, 'hero-wide', w);
// Fallback for the <img> itself. Flattened onto the artwork's own cream so a
// JPEG (no alpha) can never composite against black.
await sharp(SRC)
.resize({ width: 1400 })
.flatten({ background: '#FEF2E1' })
.jpeg({ quality: 82, mozjpeg: true })
.toFile(path.join(OUT, 'hero-wide-1400.jpg'));
const band = sharp(SRC).extract(BAND);
for (const w of BAND_WIDTHS) await emit(band, 'hero-band', w);
await sharp(SRC)
.extract(BAND)
.resize({ width: 900 })
.flatten({ background: '#FEF2E1' })
.jpeg({ quality: 82, mozjpeg: true })
.toFile(path.join(OUT, 'hero-band-900.jpg'));
const files = fs.readdirSync(OUT).filter(f => f.startsWith('hero-')).sort();
let total = 0;
for (const f of files) {
const { size } = fs.statSync(path.join(OUT, f));
total += size;
console.log(` ${f.padEnd(24)} ${(size / 1024).toFixed(1)} kB`);
}
console.log(`total ${(total / 1024).toFixed(1)} kB across ${files.length} files`);
}
main().catch(err => {
console.error(err);
process.exit(1);
});