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
115 lines
4.8 KiB
JavaScript
115 lines
4.8 KiB
JavaScript
#!/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);
|
||
});
|