From 4afe4a3bf307bfecc167bf5868f437809b451f4e Mon Sep 17 00:00:00 2001 From: Yasin Date: Wed, 15 Jul 2026 12:58:10 +0200 Subject: [PATCH 01/42] docs(slides): document ownership model and operator commands --- scripts/slides/README.md | 41 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 scripts/slides/README.md diff --git a/scripts/slides/README.md b/scripts/slides/README.md new file mode 100644 index 00000000..ad5bde61 --- /dev/null +++ b/scripts/slides/README.md @@ -0,0 +1,41 @@ +# Slides automation + +Keeps `src/data/slides.json` (the homepage highlights carousel) fresh via a +twice-weekly GitHub Actions job. Canonical data lives in `src/data/slides.json` +and `src/data/slides/`; `public/data/slides/` is gitignored and regenerated at +build by `src/plugins/content-assets.mjs`. Never edit `public/`. + +## Ownership tags + +Every slide entry carries exactly one signal: + +- `"evergreen": true`, pinned. Always kept, text frozen, image never deleted by + the bot. Set this to protect a slide. +- `"sourceArticle": "collection/year/slug"`, bot-managed. Scored from that + article each run; rotated by recency + editorial weight; dropped when it ages + out. `funding-and-projects` refs have two segments (no year). +- Neither key, treated as evergreen (fail closed) and logged. Should not occur + after bootstrap. + +Bot-created image files are named `-.`. The bot only ever +deletes files matching `^\d{4}-[a-z0-9-]+\.(png|jpe?g|webp)$` that are no longer +referenced and belonged to a `sourceArticle` entry, so legacy/human files +(none start with a 4-digit year) are structurally safe. + +## CMS interaction + +The `/admin` SlidesEditor seeds its form with `useState({ ...slide })` and edits +only `alt`/`caption`/`src`, so `sourceArticle`/`evergreen` survive both editing +and reordering. Ownership keys are preserved end to end; no action required. + +## Operator commands + +- `pnpm slides:collect`, print the ranked candidate pool + current state (dry). +- `pnpm slides:refresh`, run the full pipeline locally (writes files). +- `pnpm slides:validate`, run the sanity gate against the working tree. +- `bash scripts/manage-slides.sh`, interactive manual editor (unchanged). + +The GitHub workflow `.github/workflows/refresh-highlights.yml` runs the pipeline +on cron (Mon 07:00 UTC, Fri 15:00 UTC) and on manual dispatch, then opens a PR +and auto-merges. On any hard failure it opens/updates one `slides-bot`-labelled +issue instead of merging. From 74b96312d3adade2b133451f0ec2b9538bf8c1b7 Mon Sep 17 00:00:00 2001 From: Yasin Date: Wed, 15 Jul 2026 13:02:26 +0200 Subject: [PATCH 02/42] feat(slides): add constants and article date parser --- scripts/slides/constants.mjs | 42 +++++++++++++++++++++++++++++++++++ scripts/slides/dates.mjs | 21 ++++++++++++++++++ scripts/slides/dates.test.mjs | 16 +++++++++++++ 3 files changed, 79 insertions(+) create mode 100644 scripts/slides/constants.mjs create mode 100644 scripts/slides/dates.mjs create mode 100644 scripts/slides/dates.test.mjs diff --git a/scripts/slides/constants.mjs b/scripts/slides/constants.mjs new file mode 100644 index 00000000..37fa4520 --- /dev/null +++ b/scripts/slides/constants.mjs @@ -0,0 +1,42 @@ +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +export const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +export const SLIDES_JSON = path.join(REPO_ROOT, 'src/data/slides.json'); +export const SLIDES_DIR = path.join(REPO_ROOT, 'src/data/slides'); +export const CONTENT_DIR = path.join(REPO_ROOT, 'src/content'); + +export const COLLECTIONS = ['news', 'events', 'funding-and-projects']; + +export const MAX_SLIDES = 6; +export const MIN_SLIDES = 1; +export const CANDIDATE_POOL = 12; +export const HYSTERESIS_MARGIN = 0.15; // fraction of score an incumbent gets as a stay bonus +export const MAX_SWAPS = 2; + +export const MAX_CAPTION = 280; +export const MAX_ALT = 125; +export const MIN_IMG_WIDTH = 800; +export const MAX_IMG_BYTES = 3_000_000; +export const MIN_ASPECT = 0.9; // width/height must be >= this (landscape-ish) + +export const SRC_RE = /^\/data\/slides\/[a-z0-9-]+\.(png|jpe?g|webp)$/; +export const BOT_FILE_RE = /^\d{4}-[a-z0-9-]+\.(png|jpe?g|webp)$/; + +// Editorial weighting: matched against lowercased `${title} ${summary} ${tags}`. +export const FLAGSHIP_TOPICS = [ + {re: /\ball hands\b|all-hands/, weight: 1.0}, + {re: /\bgdi\b|genomic data infrastructure/, weight: 0.9}, + {re: /\bfega\b|federated ega/, weight: 0.9}, + {re: /\beosc\b/, weight: 0.8}, + {re: /1\+ ?million genomes|1\+mg|genome of europe|\bgoe\b/, weight: 0.8}, + {re: /infrastructure|hackathon|workshop/, weight: 0.5}, + {re: /training|course|webinar/, weight: 0.4}, +]; +export const DEMOTE_TOPICS = [ + {re: /scheduled maintenance|maintenance window|downtime/, weight: -1.0}, + {re: /job vacancy|call for|deadline reminder/, weight: -0.4}, +]; + +export const NEWS_HALFLIFE_DAYS = 120; // news/funding recency half-life +export const EVENT_DECAY_DAYS = 21; // events die ~this fast after their date diff --git a/scripts/slides/dates.mjs b/scripts/slides/dates.mjs new file mode 100644 index 00000000..93e14233 --- /dev/null +++ b/scripts/slides/dates.mjs @@ -0,0 +1,21 @@ +const MONTHS = { + jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, + jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11, +}; + +// Article dates are free-text English "Month D, YYYY" (full or abbreviated +// month, optional trailing period on the abbreviation). Returns a UTC-midnight +// Date, or null if the string does not match this exact shape. +export function parseArticleDate(str) { + if (typeof str !== 'string') return null; + const m = str.trim().match(/^([A-Za-z]{3,9})\.?\s+(\d{1,2}),?\s+(\d{4})$/); + if (!m) return null; + const month = MONTHS[m[1].slice(0, 3).toLowerCase()]; + if (month === undefined) return null; + const day = Number(m[2]); + const year = Number(m[3]); + if (day < 1 || day > 31) return null; + const d = new Date(Date.UTC(year, month, day)); + if (d.getUTCMonth() !== month || d.getUTCDate() !== day) return null; // reject e.g. Feb 30 + return d; +} diff --git a/scripts/slides/dates.test.mjs b/scripts/slides/dates.test.mjs new file mode 100644 index 00000000..98cfe2d9 --- /dev/null +++ b/scripts/slides/dates.test.mjs @@ -0,0 +1,16 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {parseArticleDate} from './dates.mjs'; + +test('parses full and abbreviated English month dates', () => { + assert.equal(parseArticleDate('September 17, 2025').toISOString(), '2025-09-17T00:00:00.000Z'); + assert.equal(parseArticleDate('Apr 16, 2026').toISOString(), '2026-04-16T00:00:00.000Z'); + assert.equal(parseArticleDate('Sept 1, 2024').toISOString(), '2024-09-01T00:00:00.000Z'); +}); + +test('returns null for unparseable input', () => { + assert.equal(parseArticleDate('2025-09-17'), null); + assert.equal(parseArticleDate('someday'), null); + assert.equal(parseArticleDate(''), null); + assert.equal(parseArticleDate(undefined), null); +}); From 18f8ad1e4415fe361d9976977c3f7e5ebf7eed36 Mon Sep 17 00:00:00 2001 From: Yasin Date: Wed, 15 Jul 2026 13:09:00 +0200 Subject: [PATCH 03/42] feat(slides): load article frontmatter across collections --- package.json | 1 + pnpm-lock.yaml | 77 +++++++++++++++++++++++++++++ scripts/slides/frontmatter.mjs | 73 +++++++++++++++++++++++++++ scripts/slides/frontmatter.test.mjs | 19 +++++++ 4 files changed, 170 insertions(+) create mode 100644 scripts/slides/frontmatter.mjs create mode 100644 scripts/slides/frontmatter.test.mjs diff --git a/package.json b/package.json index 15754da1..feeff45a 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@types/dompurify": "^3.2.0", "@types/react": "^18.2.37", "@types/react-dom": "^18.2.15", + "gray-matter": "^4.0.3", "npm-run-all2": "^9.0.2", "pagefind": "^1.5.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dcdbb1a4..48881979 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -103,6 +103,9 @@ importers: '@types/react-dom': specifier: ^18.2.15 version: 18.3.1 + gray-matter: + specifier: ^4.0.3 + version: 4.0.3 npm-run-all2: specifier: ^9.0.2 version: 9.0.2 @@ -1362,6 +1365,9 @@ packages: arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -1697,6 +1703,11 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + estree-util-attach-comments@3.0.0: resolution: {integrity: sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==} @@ -1724,6 +1735,10 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + extend-shallow@2.0.1: + resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} + engines: {node: '>=0.10.0'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -1828,6 +1843,10 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true + gray-matter@4.0.3: + resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} + engines: {node: '>=6.0'} + h3@1.15.9: resolution: {integrity: sha512-H7UPnyIupUOYUQu7f2x7ABVeMyF/IbJjqn20WSXpMdnQB260luADUkSgJU7QTWLutq8h3tUayMQ1DdbSYX5LkA==} @@ -1917,6 +1936,10 @@ packages: engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} hasBin: true + is-extendable@0.1.1: + resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} + engines: {node: '>=0.10.0'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1966,6 +1989,10 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-yaml@3.15.0: + resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==} + hasBin: true + js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true @@ -1997,6 +2024,10 @@ packages: jsonc-parser@3.3.1: resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + kleur@3.0.3: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} @@ -2716,6 +2747,10 @@ packages: scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + section-matter@1.0.0: + resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} + engines: {node: '>=4'} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -2781,6 +2816,9 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + stream-replace-string@2.0.0: resolution: {integrity: sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==} @@ -2807,6 +2845,10 @@ packages: resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} engines: {node: '>=12'} + strip-bom-string@1.0.0: + resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==} + engines: {node: '>=0.10.0'} + strnum@2.2.3: resolution: {integrity: sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==} @@ -4534,6 +4576,10 @@ snapshots: arg@5.0.2: {} + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + argparse@2.0.1: {} aria-query@5.3.2: {} @@ -4977,6 +5023,8 @@ snapshots: escape-string-regexp@5.0.0: {} + esprima@4.0.1: {} + estree-util-attach-comments@3.0.0: dependencies: '@types/estree': 1.0.8 @@ -5014,6 +5062,10 @@ snapshots: eventemitter3@5.0.4: {} + extend-shallow@2.0.1: + dependencies: + is-extendable: 0.1.1 + extend@3.0.2: {} fast-deep-equal@3.1.3: {} @@ -5106,6 +5158,13 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 + gray-matter@4.0.3: + dependencies: + js-yaml: 3.15.0 + kind-of: 6.0.3 + section-matter: 1.0.0 + strip-bom-string: 1.0.0 + h3@1.15.9: dependencies: cookie-es: 1.2.3 @@ -5297,6 +5356,8 @@ snapshots: is-docker@3.0.0: {} + is-extendable@0.1.1: {} + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -5333,6 +5394,11 @@ snapshots: js-tokens@4.0.0: {} + js-yaml@3.15.0: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + js-yaml@4.1.0: dependencies: argparse: 2.0.1 @@ -5353,6 +5419,8 @@ snapshots: jsonc-parser@3.3.1: {} + kind-of@6.0.3: {} + kleur@3.0.3: {} kleur@4.1.5: {} @@ -6511,6 +6579,11 @@ snapshots: dependencies: loose-envify: 1.4.0 + section-matter@1.0.0: + dependencies: + extend-shallow: 2.0.1 + kind-of: 6.0.3 + semver@6.3.1: {} semver@7.7.4: {} @@ -6609,6 +6682,8 @@ snapshots: space-separated-tokens@2.0.2: {} + sprintf-js@1.0.3: {} + stream-replace-string@2.0.0: {} string-width@4.2.3: @@ -6642,6 +6717,8 @@ snapshots: dependencies: ansi-regex: 6.1.0 + strip-bom-string@1.0.0: {} + strnum@2.2.3: {} style-to-js@1.1.21: diff --git a/scripts/slides/frontmatter.mjs b/scripts/slides/frontmatter.mjs new file mode 100644 index 00000000..e927c4ab --- /dev/null +++ b/scripts/slides/frontmatter.mjs @@ -0,0 +1,73 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import matter from 'gray-matter'; +import {CONTENT_DIR, COLLECTIONS} from './constants.mjs'; +import {parseArticleDate} from './dates.mjs'; + +function findEntryDirs(root, rel, out) { + const abs = path.join(root, rel); + const entries = fs.readdirSync(abs, {withFileTypes: true}); + if (entries.some(e => e.isFile() && /^index\.mdx?$/i.test(e.name))) { + out.push(rel); + return; + } + for (const e of entries) { + if (e.isDirectory()) findEntryDirs(root, path.join(rel, e.name), out); + } +} + +function readArticle(collection, ref) { + const dir = path.join(CONTENT_DIR, ref); + const file = ['index.mdx', 'index.md'].map(f => path.join(dir, f)).find(fs.existsSync); + if (!file) return null; + const {data} = matter(fs.readFileSync(file, 'utf8')); + const parts = ref.split('/'); + const slug = parts[parts.length - 1]; + const date = parseArticleDate(data.date); + + let coverAbsPath = null, coverExt = null; + if (data.cover?.source) { + const p = path.join(dir, String(data.cover.source).replace(/^\.\//, '')); + if (fs.existsSync(p)) { + coverAbsPath = p; + coverExt = path.extname(p).slice(1).toLowerCase(); + } + } + + return { + ref, collection, slug, + year: date ? date.getUTCFullYear() : (Number(parts[1]) || null), + title: data.title ?? slug, + summary: data.summary ?? '', + tags: Array.isArray(data.tags) ? data.tags : [], + date, coverAbsPath, coverExt, + }; +} + +export function listArticles() { + const out = []; + for (const collection of COLLECTIONS) { + const collRoot = path.join(CONTENT_DIR, collection); + if (!fs.existsSync(collRoot)) continue; + const dirs = []; + for (const child of fs.readdirSync(collRoot, {withFileTypes: true})) { + if (child.isDirectory()) findEntryDirs(CONTENT_DIR, path.join(collection, child.name), dirs); + } + for (const rel of dirs) { + const a = readArticle(collection, rel); + if (a) out.push(a); + } + } + return out; +} + +export function resolveArticle(ref) { + const collection = ref.split('/')[0]; + if (!COLLECTIONS.includes(collection)) return null; + if (!fs.existsSync(path.join(CONTENT_DIR, ref))) return null; + return readArticle(collection, ref); +} + +export function withCover(articles) { + return articles.filter(a => a.coverAbsPath); +} diff --git a/scripts/slides/frontmatter.test.mjs b/scripts/slides/frontmatter.test.mjs new file mode 100644 index 00000000..3a6fd9f5 --- /dev/null +++ b/scripts/slides/frontmatter.test.mjs @@ -0,0 +1,19 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {listArticles, resolveArticle, withCover} from './frontmatter.mjs'; + +test('lists real news articles with parsed fields', () => { + const all = listArticles(); + const eosc = resolveArticle('news/2025/eosc-entrust-workshop'); + assert.ok(eosc, 'eosc-entrust-workshop resolves'); + assert.equal(eosc.title, 'EOSC-ENTRUST workshop hosted by ELIXIR Norway'); + assert.equal(eosc.date.getUTCFullYear(), 2025); + assert.ok(eosc.coverAbsPath.endsWith('.jpeg')); + assert.equal(eosc.coverExt, 'jpeg'); + assert.ok(all.length > 20); +}); + +test('withCover drops articles without a cover image', () => { + const covered = withCover(listArticles()); + assert.ok(covered.every(a => a.coverAbsPath)); +}); From b54556f0e87ce4e93bbe2193628a2388b88cde4b Mon Sep 17 00:00:00 2001 From: Yasin Date: Wed, 15 Jul 2026 13:15:24 +0200 Subject: [PATCH 04/42] feat(slides): add dependency-free image dimension probe --- scripts/slides/image-probe.mjs | 58 +++++++++++++++++++++++++++++ scripts/slides/image-probe.test.mjs | 22 +++++++++++ 2 files changed, 80 insertions(+) create mode 100644 scripts/slides/image-probe.mjs create mode 100644 scripts/slides/image-probe.test.mjs diff --git a/scripts/slides/image-probe.mjs b/scripts/slides/image-probe.mjs new file mode 100644 index 00000000..09bacbc5 --- /dev/null +++ b/scripts/slides/image-probe.mjs @@ -0,0 +1,58 @@ +import fs from 'node:fs'; + +function readPng(buf) { + const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + if (buf.length < 24 || !sig.every((b, i) => buf[i] === b)) return null; + return {format: 'png', width: buf.readUInt32BE(16), height: buf.readUInt32BE(20)}; +} + +function readJpeg(buf) { + if (buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return null; + let o = 2; + while (o + 9 < buf.length) { + if (buf[o] !== 0xff) return null; + const marker = buf[o + 1]; + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd9)) {o += 2; continue;} + const len = buf.readUInt16BE(o + 2); + const isSOF = marker >= 0xc0 && marker <= 0xcf && + marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc; + if (isSOF) return {format: 'jpeg', height: buf.readUInt16BE(o + 5), width: buf.readUInt16BE(o + 7)}; + o += 2 + len; + } + return null; +} + +function readWebp(buf) { + if (buf.length < 30 || buf.toString('ascii', 0, 4) !== 'RIFF' || + buf.toString('ascii', 8, 12) !== 'WEBP') return null; + const chunk = buf.toString('ascii', 12, 16); + if (chunk === 'VP8 ') { + return {format: 'webp', width: buf.readUInt16LE(26) & 0x3fff, height: buf.readUInt16LE(28) & 0x3fff}; + } + if (chunk === 'VP8L') { + const b = buf.subarray(21); + return { + format: 'webp', + width: 1 + (((b[1] & 0x3f) << 8) | b[0]), + height: 1 + (((b[3] & 0x0f) << 10) | (b[2] << 2) | ((b[1] & 0xc0) >> 6)), + }; + } + if (chunk === 'VP8X') { + return { + format: 'webp', + width: 1 + (buf[24] | (buf[25] << 8) | (buf[26] << 16)), + height: 1 + (buf[27] | (buf[28] << 8) | (buf[29] << 16)), + }; + } + return null; +} + +// Reads image dimensions from the file header without any native dependency. +// Throws if the file is missing, empty, or not a valid PNG/JPEG/WebP. +export function probeImage(absPath) { + const buf = fs.readFileSync(absPath); + if (buf.length === 0) throw new Error(`empty file: ${absPath}`); + const r = readPng(buf) || readJpeg(buf) || readWebp(buf); + if (!r || !r.width || !r.height) throw new Error(`unrecognized or corrupt image: ${absPath}`); + return {...r, bytes: buf.length}; +} diff --git a/scripts/slides/image-probe.test.mjs b/scripts/slides/image-probe.test.mjs new file mode 100644 index 00000000..7bfeb14a --- /dev/null +++ b/scripts/slides/image-probe.test.mjs @@ -0,0 +1,22 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import {probeImage} from './image-probe.mjs'; +import {SLIDES_DIR} from './constants.mjs'; + +test('reads PNG dimensions and format', () => { + const r = probeImage(path.join(SLIDES_DIR, 'nels.png')); + assert.equal(r.format, 'png'); + assert.ok(r.width > 100 && r.height > 100); + assert.ok(r.bytes > 0); +}); + +test('reads JPEG dimensions', () => { + const r = probeImage(path.join(SLIDES_DIR, 'elixir-no-all-hands-2025.jpg')); + assert.equal(r.format, 'jpeg'); + assert.ok(r.width > 100 && r.height > 100); +}); + +test('throws on a non-image', () => { + assert.throws(() => probeImage(path.join(SLIDES_DIR, '..', 'slides.json'))); +}); From 8f5696c9a68b6dd052bde1edf475ce02fead46b5 Mon Sep 17 00:00:00 2001 From: Yasin Date: Wed, 15 Jul 2026 13:23:55 +0200 Subject: [PATCH 05/42] feat(slides): rank candidates by recency, lifecycle and editorial weight --- scripts/slides/rank.mjs | 66 ++++++++++++++++++++++++++++++++++++ scripts/slides/rank.test.mjs | 24 +++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 scripts/slides/rank.mjs create mode 100644 scripts/slides/rank.test.mjs diff --git a/scripts/slides/rank.mjs b/scripts/slides/rank.mjs new file mode 100644 index 00000000..31e6e96b --- /dev/null +++ b/scripts/slides/rank.mjs @@ -0,0 +1,66 @@ +import { + FLAGSHIP_TOPICS, DEMOTE_TOPICS, CANDIDATE_POOL, + NEWS_HALFLIFE_DAYS, EVENT_DECAY_DAYS, +} from './constants.mjs'; + +const DAY = 86_400_000; + +function haystack(a) { + return `${a.title} ${a.summary} ${(a.tags || []).join(' ')}`.toLowerCase(); +} + +export function topicsOf(a) { + const h = haystack(a); + return FLAGSHIP_TOPICS.filter(t => t.re.test(h)).map(t => t.re.source); +} + +function editorial(a) { + const h = haystack(a); + let w = 0; + for (const t of FLAGSHIP_TOPICS) if (t.re.test(h)) w = Math.max(w, t.weight); + for (const t of DEMOTE_TOPICS) if (t.re.test(h)) w += t.weight; + return w; +} + +function recency(a, now) { + if (!a.date) return 0.2; // dateless (e.g. some funding) rely on editorial weight + const ageDays = (now - a.date) / DAY; + if (a.collection === 'events') { + if (ageDays < 0) { + // upcoming: rises as the date approaches, capped + return Math.min(1, 1 - Math.min(1, -ageDays / 90)); + } + return Math.exp(-ageDays / EVENT_DECAY_DAYS); // dies fast after the date + } + if (ageDays < 0) return 1; // future-dated news treated as brand new + return Math.pow(0.5, ageDays / NEWS_HALFLIFE_DAYS); +} + +// Combined score: recency/lifecycle weighted, plus editorial topic weight. +export function scoreArticle(a, now) { + return recency(a, now) + 0.6 * editorial(a); +} + +export function rankCandidates(articles, now) { + const scored = articles + .filter(a => a.coverAbsPath) + .map(a => ({...a, score: scoreArticle(a, now), topics: topicsOf(a)})) + .sort((x, y) => + y.score - x.score || + (y.date?.getTime() || 0) - (x.date?.getTime() || 0) || + x.slug.localeCompare(y.slug)); + + const topicCount = new Map(); + const kept = []; + for (const a of scored) { + const primary = a.topics[0]; + if (primary) { + const n = topicCount.get(primary) || 0; + if (n >= 2) continue; // anti-repeat floor + topicCount.set(primary, n + 1); + } + kept.push(a); + if (kept.length >= CANDIDATE_POOL) break; + } + return kept; +} diff --git a/scripts/slides/rank.test.mjs b/scripts/slides/rank.test.mjs new file mode 100644 index 00000000..e4c45570 --- /dev/null +++ b/scripts/slides/rank.test.mjs @@ -0,0 +1,24 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {scoreArticle, rankCandidates} from './rank.mjs'; + +const now = new Date(Date.UTC(2026, 6, 15)); +const mk = (o) => ({collection: 'news', slug: o.slug, title: o.title ?? '', summary: '', tags: [], date: o.date, coverAbsPath: '/x.png', ...o}); + +test('recent flagship news outranks an old routine notice', () => { + const flagship = mk({slug: 'gdi-go-live', title: 'GDI infrastructure go-live', date: new Date(Date.UTC(2026, 6, 1))}); + const routine = mk({slug: 'maint', title: 'Scheduled maintenance window', date: new Date(Date.UTC(2026, 6, 10))}); + assert.ok(scoreArticle(flagship, now) > scoreArticle(routine, now)); +}); + +test('a past event decays below a fresh news item', () => { + const pastEvent = mk({collection: 'events', slug: 'old-workshop', title: 'Workshop', date: new Date(Date.UTC(2026, 4, 1))}); + const freshNews = mk({slug: 'news', title: 'Infrastructure update', date: new Date(Date.UTC(2026, 6, 12))}); + assert.ok(scoreArticle(freshNews, now) > scoreArticle(pastEvent, now)); +}); + +test('anti-repeat caps flagship topic at 2', () => { + const arts = [1, 2, 3, 4].map(i => mk({collection: 'events', slug: `all-hands-${i}`, title: 'ELIXIR All Hands', date: new Date(Date.UTC(2026, 6, i))})); + const ranked = rankCandidates(arts, now); + assert.equal(ranked.filter(a => /all hands/.test(a.title.toLowerCase())).length, 2); +}); From adeb64d79352b66d53031c258513be7dceb3c523 Mon Sep 17 00:00:00 2001 From: Yasin Date: Wed, 15 Jul 2026 13:30:37 +0200 Subject: [PATCH 06/42] feat(slides): add collect CLI emitting current state and candidate pool --- package.json | 3 +- scripts/slides/collect-candidates.mjs | 35 ++++++++++++++++++++++ scripts/slides/collect-candidates.test.mjs | 18 +++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 scripts/slides/collect-candidates.mjs create mode 100644 scripts/slides/collect-candidates.test.mjs diff --git a/package.json b/package.json index feeff45a..0c5e196c 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "astro": "astro", "postbuild": "pagefind --site dist", "test:slugs": "node scripts/check-slugs.mjs", - "test:pages": "node scripts/test-pages.mjs" + "test:pages": "node scripts/test-pages.mjs", + "slides:collect": "node scripts/slides/collect-candidates.mjs" }, "dependencies": { "@astrojs/check": "^0.9.0", diff --git a/scripts/slides/collect-candidates.mjs b/scripts/slides/collect-candidates.mjs new file mode 100644 index 00000000..4f495537 --- /dev/null +++ b/scripts/slides/collect-candidates.mjs @@ -0,0 +1,35 @@ +import fs from 'node:fs'; +import {SLIDES_JSON} from './constants.mjs'; +import {listArticles, resolveArticle, withCover} from './frontmatter.mjs'; +import {rankCandidates, scoreArticle, topicsOf} from './rank.mjs'; + +export function readCurrent() { + return JSON.parse(fs.readFileSync(SLIDES_JSON, 'utf8')); +} + +function toCandidate(a) { + return { + id: a.ref, ref: a.ref, collection: a.collection, year: a.year, slug: a.slug, + title: a.title, summary: a.summary, + date: a.date ? a.date.toISOString() : null, + coverAbsPath: a.coverAbsPath, coverExt: a.coverExt, + topics: a.topics ?? topicsOf(a), score: a.score, + }; +} + +export function collect(now = new Date()) { + const current = readCurrent(); + const ranked = rankCandidates(withCover(listArticles()), now); + const byRef = new Map(ranked.map(a => [a.ref, a])); + for (const s of current) { + if (s.sourceArticle && !byRef.has(s.sourceArticle)) { + const a = resolveArticle(s.sourceArticle); + if (a && a.coverAbsPath) byRef.set(a.ref, {...a, score: scoreArticle(a, now), topics: topicsOf(a)}); + } + } + return {current, candidates: [...byRef.values()].map(toCandidate)}; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + process.stdout.write(JSON.stringify(collect(), null, 2) + '\n'); +} diff --git a/scripts/slides/collect-candidates.test.mjs b/scripts/slides/collect-candidates.test.mjs new file mode 100644 index 00000000..51f4df95 --- /dev/null +++ b/scripts/slides/collect-candidates.test.mjs @@ -0,0 +1,18 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {collect, readCurrent} from './collect-candidates.mjs'; + +test('collect returns current slides and a ranked candidate pool', () => { + const {current, candidates} = collect(new Date(Date.UTC(2026, 6, 15))); + assert.ok(Array.isArray(current) && current.length >= 1); + assert.ok(candidates.length >= 1 && candidates.length <= 12); + for (const c of candidates) { + assert.equal(c.id, c.ref); + assert.ok(c.coverAbsPath, 'candidate has a cover'); + assert.equal(typeof c.score, 'number'); + } +}); + +test('readCurrent parses slides.json', () => { + assert.ok(Array.isArray(readCurrent())); +}); From 8b476a63cf9ffe43d53454f596b68bc36660e849 Mon Sep 17 00:00:00 2001 From: Yasin Date: Wed, 15 Jul 2026 13:37:01 +0200 Subject: [PATCH 07/42] feat(slides): select slides with pinning, hysteresis and swap cap --- scripts/slides/select.mjs | 53 ++++++++++++++++++++++++++++++++++ scripts/slides/select.test.mjs | 33 +++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 scripts/slides/select.mjs create mode 100644 scripts/slides/select.test.mjs diff --git a/scripts/slides/select.mjs b/scripts/slides/select.mjs new file mode 100644 index 00000000..aeb875e6 --- /dev/null +++ b/scripts/slides/select.mjs @@ -0,0 +1,53 @@ +import {MAX_SLIDES, HYSTERESIS_MARGIN, MAX_SWAPS} from './constants.mjs'; + +const botFilename = c => `${c.year ?? '0000'}-${c.slug}.${c.coverExt}`; +const pick = s => ({src: s.src, alt: s.alt ?? null, caption: s.caption ?? null}); +const sameSeq = (a, b) => + JSON.stringify(a.map(pick)) === JSON.stringify(b.map(pick)); + +export function selectSlides({current, candidates}) { + const byRef = new Map(candidates.map(c => [c.ref, c])); + const scoreOf = ref => byRef.get(ref)?.score ?? 0; + + const evergreens = current.filter(s => s.evergreen === true); + const budget = Math.max(0, MAX_SLIDES - evergreens.length); + + const botIncumbents = current.filter(s => s.sourceArticle && s.evergreen !== true); + const incumbentRefs = new Set(botIncumbents.map(s => s.sourceArticle)); + const fresh = candidates.filter(c => !incumbentRefs.has(c.ref)); + + const eff = (ref, isInc) => scoreOf(ref) * (isInc ? 1 + HYSTERESIS_MARGIN : 1); + const pool = [ + ...botIncumbents.map(s => ({ref: s.sourceArticle, isInc: true, entry: s})), + ...fresh.map(c => ({ref: c.ref, isInc: false, cand: c})), + ].sort((x, y) => + eff(y.ref, y.isInc) - eff(x.ref, x.isInc) || + (y.isInc === x.isInc ? 0 : y.isInc ? 1 : -1) || + x.ref.localeCompare(y.ref)); + + let chosen = pool.slice(0, budget); + + // Swap cap: at most MAX_SWAPS fresh refs enter per run; backfill from + // remaining incumbents if we blocked some. + const freshChosen = chosen.filter(p => !p.isInc); + if (freshChosen.length > MAX_SWAPS) { + const allowed = new Set(freshChosen.slice(0, MAX_SWAPS).map(p => p.ref)); + chosen = chosen.filter(p => p.isInc || allowed.has(p.ref)); + const spare = pool.filter(p => p.isInc && !chosen.includes(p)); + while (chosen.length < budget && spare.length) chosen.push(spare.shift()); + chosen = chosen.slice(0, budget); + } + + // Order: surviving incumbents in current order, then new ones by score. + const chosenRefs = new Set(chosen.map(p => p.ref)); + const survivors = botIncumbents.filter(s => chosenRefs.has(s.sourceArticle)); + const news = chosen + .filter(p => !p.isInc) + .map(p => ({ + src: `/data/slides/${botFilename(p.cand)}`, + alt: null, caption: null, sourceArticle: p.cand.ref, _candidate: p.cand, + })); + + const slides = [...evergreens, ...survivors, ...news]; + return {slides, changed: !sameSeq(current, slides)}; +} diff --git a/scripts/slides/select.test.mjs b/scripts/slides/select.test.mjs new file mode 100644 index 00000000..f4de4c4c --- /dev/null +++ b/scripts/slides/select.test.mjs @@ -0,0 +1,33 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {selectSlides} from './select.mjs'; + +const cand = (ref, slug, score, over = {}) => ({ + id: ref, ref, collection: 'news', year: 2026, slug, + title: slug, summary: 's', date: '2026-07-01T00:00:00.000Z', + coverAbsPath: `/x/${slug}.png`, coverExt: 'png', topics: [], score, ...over, +}); + +test('no-op when only evergreens and budget is full', () => { + const current = [ + {src: '/data/slides/nels.png', alt: 'NeLS', caption: 'c', evergreen: true}, + {src: '/data/slides/rdm.png', alt: 'RDM', caption: 'c', evergreen: true}, + ]; + const {slides, changed} = selectSlides({current, candidates: [cand('news/2026/x', 'x', 0.1)]}); + assert.equal(changed, true); // one free slot gets filled + assert.equal(slides[0].evergreen, true); +}); + +test('caps fresh additions at MAX_SWAPS (2)', () => { + const current = []; + const candidates = ['a', 'b', 'c', 'd'].map((s, i) => cand(`news/2026/${s}`, s, 1 - i * 0.1)); + const {slides} = selectSlides({current, candidates}); + assert.equal(slides.filter(s => s._candidate).length, 2); +}); + +test('unchanged selection reports changed=false', () => { + const current = [{src: '/data/slides/2026-a.png', alt: 'A', caption: 'c', sourceArticle: 'news/2026/a'}]; + const candidates = [cand('news/2026/a', 'a', 0.9)]; + const {changed} = selectSlides({current, candidates}); + assert.equal(changed, false); +}); From 81994301f8160e3e04d43c14885f6cf8ed8ed77a Mon Sep 17 00:00:00 2001 From: Yasin Date: Wed, 15 Jul 2026 13:45:18 +0200 Subject: [PATCH 08/42] test(slides): cover hysteresis and swap-cap backfill in selection --- scripts/slides/select.test.mjs | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/scripts/slides/select.test.mjs b/scripts/slides/select.test.mjs index f4de4c4c..4ca8e8a6 100644 --- a/scripts/slides/select.test.mjs +++ b/scripts/slides/select.test.mjs @@ -31,3 +31,32 @@ test('unchanged selection reports changed=false', () => { const {changed} = selectSlides({current, candidates}); assert.equal(changed, false); }); + +test('incumbent keeps its slot unless a challenger beats it by the hysteresis margin', () => { + const evergreens = ['a', 'b', 'c', 'd', 'e'].map(s => ({src: `/data/slides/${s}.png`, alt: s.toUpperCase(), caption: 'c', evergreen: true})); + const incumbent = {src: '/data/slides/2026-inc.png', alt: 'Inc', caption: 'c', sourceArticle: 'news/2026/inc'}; + // budget = 6 - 5 evergreens = 1 bot slot. incumbent eff = 0.5 * 1.15 = 0.575. + const near = selectSlides({current: [...evergreens, incumbent], candidates: [cand('news/2026/inc', 'inc', 0.5), cand('news/2026/new', 'new', 0.55)]}); + assert.equal(near.slides.at(-1).sourceArticle, 'news/2026/inc'); // 0.55 < 0.575 -> incumbent stays + const beats = selectSlides({current: [...evergreens, incumbent], candidates: [cand('news/2026/inc', 'inc', 0.5), cand('news/2026/new', 'new', 0.58)]}); + assert.equal(beats.slides.at(-1).sourceArticle, 'news/2026/new'); // 0.58 > 0.575 -> challenger wins +}); + +test('swap cap admits the top 2 fresh and backfills freed slots from displaced incumbents', () => { + const incs = [1, 2, 3, 4, 5].map(i => ({src: `/data/slides/2026-i${i}.png`, alt: `I${i}`, caption: 'c', sourceArticle: `news/2026/i${i}`})); + const incCands = [1, 2, 3, 4, 5].map(i => cand(`news/2026/i${i}`, `i${i}`, 0.5 - i * 0.01)); // i1 highest .49 .. i5 .45 + const fresh = ['a', 'b', 'c', 'd', 'e'].map((s, i) => cand(`news/2026/${s}`, s, 0.9 - i * 0.05)); // a .9 .. e .7 (all outrank incumbents) + const {slides} = selectSlides({current: incs, candidates: [...incCands, ...fresh]}); + const news = slides.filter(s => s._candidate).map(s => s.sourceArticle).sort(); + assert.deepEqual(news, ['news/2026/a', 'news/2026/b']); // only top-2 fresh admitted + const survivors = slides.filter(s => s.sourceArticle && !s._candidate).map(s => s.sourceArticle); + assert.equal(survivors.length, 4); // 4 slots backfilled from incumbents + assert.ok(!survivors.includes('news/2026/i5')); // lowest-scored incumbent dropped +}); + +test('the swap cap keeps the two highest-scored fresh, not any two', () => { + const candidates = ['a', 'b', 'c', 'd'].map((s, i) => cand(`news/2026/${s}`, s, 1 - i * 0.1)); // a highest + const {slides} = selectSlides({current: [], candidates}); + const news = slides.filter(s => s._candidate).map(s => s.sourceArticle).sort(); + assert.deepEqual(news, ['news/2026/a', 'news/2026/b']); // top two by score, not c/d +}); From 22678d15b6c7edc1375292182014e104ac2e38a7 Mon Sep 17 00:00:00 2001 From: Yasin Date: Wed, 15 Jul 2026 13:49:32 +0200 Subject: [PATCH 09/42] feat(slides): add optional caption agent with summary fallback --- scripts/slides/caption-agent.mjs | 82 +++++++++++++++++++++++++++ scripts/slides/caption-agent.test.mjs | 28 +++++++++ scripts/slides/opencode.json | 14 +++++ scripts/slides/slides.AGENTS.md | 33 +++++++++++ 4 files changed, 157 insertions(+) create mode 100644 scripts/slides/caption-agent.mjs create mode 100644 scripts/slides/caption-agent.test.mjs create mode 100644 scripts/slides/opencode.json create mode 100644 scripts/slides/slides.AGENTS.md diff --git a/scripts/slides/caption-agent.mjs b/scripts/slides/caption-agent.mjs new file mode 100644 index 00000000..2a418e14 --- /dev/null +++ b/scripts/slides/caption-agent.mjs @@ -0,0 +1,82 @@ +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {spawnSync} from 'node:child_process'; +import {MAX_CAPTION, MAX_ALT} from './constants.mjs'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); + +export function clamp(str, n) { + const s = String(str ?? '').replace(/\s+/g, ' ').trim(); + return s.length <= n ? s : s.slice(0, n - 1).trimEnd() + '…'; +} + +export function fallbackText(cand) { + const caption = clamp(cand.summary || cand.title, MAX_CAPTION); + return {alt: clamp(cand.title, MAX_ALT), caption}; +} + +export function properNounsOk(text, cand) { + const src = `${cand.title} ${cand.summary}`; + const runs = text.match(/[A-ZÅØÆ][\wÅØÆåøæ.'-]+(?:\s+[A-ZÅØÆ][\wÅØÆåøæ.'-]+)+/g) || []; + return runs.every(r => src.includes(r)); +} + +export function validAgentText(alt, caption, cand) { + if (typeof alt !== 'string' || typeof caption !== 'string') return false; + if (!alt.trim() || !caption.trim()) return false; + if (alt.length > MAX_ALT || caption.length > MAX_CAPTION) return false; + if (/[\x00-\x1f<>`]/.test(alt + caption)) return false; + if (alt.trim() === caption.trim()) return false; + return properNounsOk(caption, cand) && properNounsOk(alt, cand); +} + +export function extractJsonArray(text) { + const t = String(text || '').trim(); + if (!t) return null; + for (const candidate of [t, (t.match(/\[[\s\S]*\]/) || [])[0]]) { + if (!candidate) continue; + try { + const v = JSON.parse(candidate); + if (Array.isArray(v)) return v; + } catch { /* try next */ } + } + return null; +} + +export function defaultRunAgent(inputJson) { + const model = process.env.SLIDES_AGENT_MODEL; + if (!model || process.env.SLIDES_AGENT === 'off') return Promise.resolve(''); + const prompt = `Here is the input. Return only the JSON array.\n${inputJson}`; + const r = spawnSync('opencode', ['run', '--model', model, prompt], + {cwd: HERE, encoding: 'utf8', timeout: 120_000, maxBuffer: 4 << 20}); + return Promise.resolve(r.status === 0 ? (r.stdout || '') : ''); +} + +export async function writeCaptions(slides, {runAgent = defaultRunAgent} = {}) { + const news = slides.filter(s => s._candidate && (s.alt == null || s.caption == null)); + if (!news.length) return slides; + + const input = JSON.stringify({ + slides: news.map(s => ({id: s._candidate.id, title: s._candidate.title, summary: s._candidate.summary})), + }); + + let byId = new Map(); + try { + const arr = extractJsonArray(await runAgent(input)); + if (arr) byId = new Map(arr.map(o => [o.id, o])); + } catch { /* fall back below */ } + + for (const s of news) { + const c = s._candidate; + const a = byId.get(c.id); + if (a && validAgentText(a.alt, a.caption, c)) { + s.alt = a.alt.trim(); + s.caption = a.caption.trim(); + } else { + const fb = fallbackText(c); + s.alt = fb.alt; + s.caption = fb.caption; + } + } + return slides; +} diff --git a/scripts/slides/caption-agent.test.mjs b/scripts/slides/caption-agent.test.mjs new file mode 100644 index 00000000..f97dc5ee --- /dev/null +++ b/scripts/slides/caption-agent.test.mjs @@ -0,0 +1,28 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {writeCaptions, fallbackText, properNounsOk} from './caption-agent.mjs'; + +const newSlide = (id, title, summary) => ({ + src: `/data/slides/2026-${id}.png`, alt: null, caption: null, + sourceArticle: `news/2026/${id}`, + _candidate: {id: `news/2026/${id}`, title, summary}, +}); + +test('falls back to summary/title when the agent returns nothing', async () => { + const s = [newSlide('x', 'GDI go-live', 'ELIXIR Norway deploys GDI infrastructure.')]; + const out = await writeCaptions(s, {runAgent: async () => ''}); + assert.equal(out[0].alt, 'GDI go-live'); + assert.equal(out[0].caption, 'ELIXIR Norway deploys GDI infrastructure.'); +}); + +test('uses valid agent text', async () => { + const s = [newSlide('x', 'GDI go-live', 'ELIXIR Norway deploys GDI infrastructure.')]; + const agent = async () => JSON.stringify([{id: 'news/2026/x', alt: 'A network diagram', caption: 'ELIXIR Norway deploys GDI infrastructure across Europe.'}]); + const out = await writeCaptions(s, {runAgent: agent}); + assert.equal(out[0].alt, 'A network diagram'); +}); + +test('rejects hallucinated proper nouns', () => { + assert.equal(properNounsOk('Written by Jane Doe', {title: 'GDI', summary: 'about gdi'}), false); + assert.equal(properNounsOk('About the GDI project', {title: 'GDI project', summary: 'the GDI project'}), true); +}); diff --git a/scripts/slides/opencode.json b/scripts/slides/opencode.json new file mode 100644 index 00000000..1bd9ec47 --- /dev/null +++ b/scripts/slides/opencode.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://opencode.ai/config.json", + "model": "{env:SLIDES_AGENT_MODEL}", + "tools": { + "write": false, + "edit": false, + "bash": false, + "read": false, + "glob": false, + "grep": false, + "webfetch": false, + "task": false + } +} diff --git a/scripts/slides/slides.AGENTS.md b/scripts/slides/slides.AGENTS.md new file mode 100644 index 00000000..b611cae1 --- /dev/null +++ b/scripts/slides/slides.AGENTS.md @@ -0,0 +1,33 @@ +# Slides caption agent + +You write short captions and alt text for homepage highlight slides of ELIXIR +Norway, the Norwegian node of the European life-science data infrastructure. + +## Input + +A JSON object `{ "slides": [ { "id", "title", "summary" } ] }`. Each entry is a +new slide that needs text. + +## Output, follow exactly + +Return **only** a single JSON array, no prose, no code fences: + +``` +[ { "id": "", "alt": "", "caption": "" } ] +``` + +One object per input slide, same `id`. + +## Rules (hard) + +1. Output is one JSON array in the exact schema above. No fences, no commentary. +2. Use only the provided `id` values. Never invent slides, ids, images, or paths. +3. Derive all wording solely from that slide's `title` and `summary`. Do not add + outside facts, numbers, dates, or claims. +4. Include a person's name only if it appears verbatim in the summary. +5. Plain text only, no HTML, markdown, emoji, backticks, or line breaks. + `caption` ≤ 280 characters, `alt` ≤ 125 characters. +6. `alt` describes what the image shows; never copy the caption; do not start + with "image of" / "photo of". +7. Neutral institutional English. No superlatives, marketing, or speculation. +8. Keep Norwegian characters (Å, å, Ø, ø, Æ, æ) intact. From cc48e953c05760ac5be5f27e9d8ae378040de281 Mon Sep 17 00:00:00 2001 From: Yasin Date: Wed, 15 Jul 2026 13:56:12 +0200 Subject: [PATCH 10/42] feat(slides): apply selection by copying images and pruning stale files --- scripts/slides/apply-slides.mjs | 35 ++++++++++++++++++++++++++++ scripts/slides/apply-slides.test.mjs | 14 +++++++++++ 2 files changed, 49 insertions(+) create mode 100644 scripts/slides/apply-slides.mjs create mode 100644 scripts/slides/apply-slides.test.mjs diff --git a/scripts/slides/apply-slides.mjs b/scripts/slides/apply-slides.mjs new file mode 100644 index 00000000..8011c4c5 --- /dev/null +++ b/scripts/slides/apply-slides.mjs @@ -0,0 +1,35 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import {SLIDES_DIR, SLIDES_JSON, BOT_FILE_RE} from './constants.mjs'; + +export function cleanEntry(s) { + const out = {src: s.src, alt: s.alt, caption: s.caption}; + if (s.evergreen === true) out.evergreen = true; + else if (s.sourceArticle) out.sourceArticle = s.sourceArticle; + return out; +} + +export function referencedBasenames(slides) { + return new Set(slides.map(s => path.basename(s.src))); +} + +export function staleBotFiles(existing, referenced) { + return existing.filter(f => BOT_FILE_RE.test(f) && !referenced.has(f)); +} + +export function apply(slides) { + for (const s of slides) { + if (s._candidate) { + const dest = path.join(SLIDES_DIR, path.basename(s.src)); + fs.copyFileSync(s._candidate.coverAbsPath, dest); + } + } + const clean = slides.map(cleanEntry); + const referenced = referencedBasenames(clean); + const existing = fs.readdirSync(SLIDES_DIR); + const deleted = staleBotFiles(existing, referenced); + for (const f of deleted) fs.rmSync(path.join(SLIDES_DIR, f)); + + fs.writeFileSync(SLIDES_JSON, JSON.stringify(clean, null, 4) + '\n'); + return {deleted}; +} diff --git a/scripts/slides/apply-slides.test.mjs b/scripts/slides/apply-slides.test.mjs new file mode 100644 index 00000000..30773980 --- /dev/null +++ b/scripts/slides/apply-slides.test.mjs @@ -0,0 +1,14 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {cleanEntry, referencedBasenames, staleBotFiles} from './apply-slides.mjs'; + +test('cleanEntry strips transient fields', () => { + const e = cleanEntry({src: '/data/slides/2026-x.png', alt: 'A', caption: 'C', sourceArticle: 'news/2026/x', _candidate: {}, _new: true}); + assert.deepEqual(e, {src: '/data/slides/2026-x.png', alt: 'A', caption: 'C', sourceArticle: 'news/2026/x'}); +}); + +test('staleBotFiles only targets bot-named unreferenced files', () => { + const referenced = referencedBasenames([{src: '/data/slides/2026-keep.png'}, {src: '/data/slides/nels.png'}]); + const existing = ['2026-keep.png', '2025-drop.jpeg', 'nels.png', 'rdm-promotion.png']; + assert.deepEqual(staleBotFiles(existing, referenced), ['2025-drop.jpeg']); +}); From 5c1e507fae2bd597effa3b424fbe94ed413d58ba Mon Sep 17 00:00:00 2001 From: Yasin Date: Wed, 15 Jul 2026 14:03:38 +0200 Subject: [PATCH 11/42] feat(slides): add hard validation gate and diff-scope guard --- scripts/slides/validate-slides.mjs | 68 +++++++++++++++++++++++++ scripts/slides/validate-slides.test.mjs | 25 +++++++++ 2 files changed, 93 insertions(+) create mode 100644 scripts/slides/validate-slides.mjs create mode 100644 scripts/slides/validate-slides.test.mjs diff --git a/scripts/slides/validate-slides.mjs b/scripts/slides/validate-slides.mjs new file mode 100644 index 00000000..742e2fd8 --- /dev/null +++ b/scripts/slides/validate-slides.mjs @@ -0,0 +1,68 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import {execFileSync} from 'node:child_process'; +import { + SLIDES_JSON, SLIDES_DIR, MAX_SLIDES, MIN_SLIDES, SRC_RE, + MAX_CAPTION, MAX_ALT, MIN_IMG_WIDTH, MIN_ASPECT, MAX_IMG_BYTES, +} from './constants.mjs'; +import {probeImage} from './image-probe.mjs'; + +const EXT_FORMAT = {png: 'png', jpg: 'jpeg', jpeg: 'jpeg', webp: 'webp'}; + +export function validateSlides(slides, {slidesDir = SLIDES_DIR} = {}) { + const v = []; + if (!Array.isArray(slides)) return ['slides.json is not an array']; + if (slides.length < MIN_SLIDES || slides.length > MAX_SLIDES) + v.push(`slide count ${slides.length} outside ${MIN_SLIDES}..${MAX_SLIDES}`); + + const seen = new Set(); + for (const [i, s] of slides.entries()) { + const at = `slide[${i}]`; + if (!SRC_RE.test(s.src || '')) {v.push(`${at} src invalid: ${s.src}`); continue;} + if (seen.has(s.src)) v.push(`${at} duplicate src: ${s.src}`); + seen.add(s.src); + + if (!(s.evergreen === true) && !s.sourceArticle) v.push(`${at} untracked (no evergreen/sourceArticle)`); + + for (const [field, max] of [['caption', MAX_CAPTION], ['alt', MAX_ALT]]) { + const val = s[field]; + if (typeof val !== 'string' || !val.trim()) {v.push(`${at} ${field} empty`); continue;} + if (val.length > max) v.push(`${at} ${field} too long (${val.length} > ${max})`); + if (/[\x00-\x1f<>`]/.test(val)) v.push(`${at} ${field} has illegal characters`); + } + if (typeof s.alt === 'string' && s.alt.trim() === (s.caption || '').trim()) + v.push(`${at} alt equals caption`); + + const abs = path.join(slidesDir, path.basename(s.src)); + if (!fs.existsSync(abs)) {v.push(`${at} image missing: ${abs}`); continue;} + try { + const img = probeImage(abs); + const ext = path.extname(abs).slice(1).toLowerCase(); + if (EXT_FORMAT[ext] !== img.format) v.push(`${at} format ${img.format} != extension .${ext}`); + if (img.width < MIN_IMG_WIDTH) v.push(`${at} width ${img.width} < ${MIN_IMG_WIDTH}`); + if (img.width / img.height < MIN_ASPECT) v.push(`${at} not landscape (${img.width}x${img.height})`); + if (img.bytes > MAX_IMG_BYTES) v.push(`${at} file too large (${img.bytes} > ${MAX_IMG_BYTES})`); + } catch (e) { + v.push(`${at} image probe failed: ${e.message}`); + } + } + return v; +} + +export function diffScopeViolations() { + const out = execFileSync('git', ['diff', '--name-only', 'HEAD'], {encoding: 'utf8'}); + return out.split('\n').map(s => s.trim()).filter(Boolean) + .filter(p => p !== 'src/data/slides.json' && !p.startsWith('src/data/slides/')) + .map(p => `out-of-scope change: ${p}`); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const slides = JSON.parse(fs.readFileSync(SLIDES_JSON, 'utf8')); + const v = validateSlides(slides); + if (process.argv.includes('--diff-scope')) v.push(...diffScopeViolations()); + if (v.length) { + console.error('Slide validation failed:\n' + v.map(m => ' - ' + m).join('\n')); + process.exit(1); + } + console.log(`Slides valid (${slides.length}).`); +} diff --git a/scripts/slides/validate-slides.test.mjs b/scripts/slides/validate-slides.test.mjs new file mode 100644 index 00000000..404a0bd1 --- /dev/null +++ b/scripts/slides/validate-slides.test.mjs @@ -0,0 +1,25 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {validateSlides} from './validate-slides.mjs'; +import {SLIDES_DIR} from './constants.mjs'; + +const ok = {src: '/data/slides/nels.png', alt: 'NeLS landing page', caption: 'The Norwegian e-Infrastructure for Life Sciences.', evergreen: true}; + +test('flags empty slide set', () => { + const v = validateSlides([], {slidesDir: SLIDES_DIR}); + assert.ok(v.some(m => /count/i.test(m))); +}); + +test('flags a bad src and a too-long caption', () => { + const v = validateSlides([ + {src: '/data/slides/BAD NAME.png', alt: 'a', caption: 'c', evergreen: true}, + {...ok, caption: 'x'.repeat(400)}, + ], {slidesDir: SLIDES_DIR}); + assert.ok(v.some(m => /src/i.test(m))); + assert.ok(v.some(m => /caption/i.test(m))); +}); + +test('accepts a valid evergreen slide backed by a real image', () => { + const v = validateSlides([ok], {slidesDir: SLIDES_DIR}); + assert.deepEqual(v, []); +}); From 95bc26c159ff930ef1354fe9a69f6611a67f1e48 Mon Sep 17 00:00:00 2001 From: Yasin Date: Wed, 15 Jul 2026 14:08:32 +0200 Subject: [PATCH 12/42] fix(slides): apply image quality gates to bot-created images only --- scripts/slides/validate-slides.mjs | 12 ++++++++---- scripts/slides/validate-slides.test.mjs | 11 +++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/scripts/slides/validate-slides.mjs b/scripts/slides/validate-slides.mjs index 742e2fd8..84fca961 100644 --- a/scripts/slides/validate-slides.mjs +++ b/scripts/slides/validate-slides.mjs @@ -2,7 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import {execFileSync} from 'node:child_process'; import { - SLIDES_JSON, SLIDES_DIR, MAX_SLIDES, MIN_SLIDES, SRC_RE, + SLIDES_JSON, SLIDES_DIR, MAX_SLIDES, MIN_SLIDES, SRC_RE, BOT_FILE_RE, MAX_CAPTION, MAX_ALT, MIN_IMG_WIDTH, MIN_ASPECT, MAX_IMG_BYTES, } from './constants.mjs'; import {probeImage} from './image-probe.mjs'; @@ -39,9 +39,13 @@ export function validateSlides(slides, {slidesDir = SLIDES_DIR} = {}) { const img = probeImage(abs); const ext = path.extname(abs).slice(1).toLowerCase(); if (EXT_FORMAT[ext] !== img.format) v.push(`${at} format ${img.format} != extension .${ext}`); - if (img.width < MIN_IMG_WIDTH) v.push(`${at} width ${img.width} < ${MIN_IMG_WIDTH}`); - if (img.width / img.height < MIN_ASPECT) v.push(`${at} not landscape (${img.width}x${img.height})`); - if (img.bytes > MAX_IMG_BYTES) v.push(`${at} file too large (${img.bytes} > ${MAX_IMG_BYTES})`); + // Quality gates apply only to bot-created images (-.). + // Legacy/human pins predate the automation and are grandfathered. + if (BOT_FILE_RE.test(path.basename(abs))) { + if (img.width < MIN_IMG_WIDTH) v.push(`${at} width ${img.width} < ${MIN_IMG_WIDTH}`); + if (img.width / img.height < MIN_ASPECT) v.push(`${at} not landscape (${img.width}x${img.height})`); + if (img.bytes > MAX_IMG_BYTES) v.push(`${at} file too large (${img.bytes} > ${MAX_IMG_BYTES})`); + } } catch (e) { v.push(`${at} image probe failed: ${e.message}`); } diff --git a/scripts/slides/validate-slides.test.mjs b/scripts/slides/validate-slides.test.mjs index 404a0bd1..5c96849b 100644 --- a/scripts/slides/validate-slides.test.mjs +++ b/scripts/slides/validate-slides.test.mjs @@ -23,3 +23,14 @@ test('accepts a valid evergreen slide backed by a real image', () => { const v = validateSlides([ok], {slidesDir: SLIDES_DIR}); assert.deepEqual(v, []); }); + +test('grandfathers a large legacy-named evergreen image (quality gates are bot-only)', () => { + const bigLegacy = { + src: '/data/slides/elixir-no-all-hands-2025.jpg', + alt: 'Group photo for ELIXIR Norway All Hands 2025', + caption: "This year's ELIXIR Norway All Hands was organised physically in Ås!", + evergreen: true, + }; + // 3.37MB and a legacy filename (no - prefix) → exempt from size/width/aspect. + assert.deepEqual(validateSlides([bigLegacy], {slidesDir: SLIDES_DIR}), []); +}); From 77617c5077dcd47f1fbdb6541146951bc5f7ff8b Mon Sep 17 00:00:00 2001 From: Yasin Date: Wed, 15 Jul 2026 14:14:51 +0200 Subject: [PATCH 13/42] test(slides): assert quality gates still fire on bot-named images --- scripts/slides/validate-slides.test.mjs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/scripts/slides/validate-slides.test.mjs b/scripts/slides/validate-slides.test.mjs index 5c96849b..75aa8e27 100644 --- a/scripts/slides/validate-slides.test.mjs +++ b/scripts/slides/validate-slides.test.mjs @@ -1,5 +1,8 @@ import {test} from 'node:test'; import assert from 'node:assert/strict'; +import os from 'node:os'; +import fs from 'node:fs'; +import path from 'node:path'; import {validateSlides} from './validate-slides.mjs'; import {SLIDES_DIR} from './constants.mjs'; @@ -34,3 +37,23 @@ test('grandfathers a large legacy-named evergreen image (quality gates are bot-o // 3.37MB and a legacy filename (no - prefix) → exempt from size/width/aspect. assert.deepEqual(validateSlides([bigLegacy], {slidesDir: SLIDES_DIR}), []); }); + +test('still enforces quality gates on a bot-named image (guard is not a blanket exemption)', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'slides-validate-')); + try { + // The 3.37MB image copied under a bot-style name (BOT_FILE_RE matches), + // so the size gate must fire even though the same bytes are exempt under + // the legacy filename. + fs.copyFileSync(path.join(SLIDES_DIR, 'elixir-no-all-hands-2025.jpg'), path.join(dir, '2025-all-hands.jpg')); + const slide = { + src: '/data/slides/2025-all-hands.jpg', + alt: 'A group photo', + caption: 'A caption about the meeting.', + sourceArticle: 'news/2025/all-hands', + }; + const violations = validateSlides([slide], {slidesDir: dir}); + assert.ok(violations.some(m => /file too large/.test(m)), violations.join('; ')); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } +}); From 02f9844f505e6b421c67096b5e8c6a1eb7bdcb2f Mon Sep 17 00:00:00 2001 From: Yasin Date: Wed, 15 Jul 2026 14:18:13 +0200 Subject: [PATCH 14/42] feat(slides): add pipeline orchestrator --- package.json | 4 +++- scripts/slides/refresh.mjs | 44 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 scripts/slides/refresh.mjs diff --git a/package.json b/package.json index 0c5e196c..67b4abd8 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,9 @@ "postbuild": "pagefind --site dist", "test:slugs": "node scripts/check-slugs.mjs", "test:pages": "node scripts/test-pages.mjs", - "slides:collect": "node scripts/slides/collect-candidates.mjs" + "slides:collect": "node scripts/slides/collect-candidates.mjs", + "slides:refresh": "node scripts/slides/refresh.mjs", + "slides:validate": "node scripts/slides/validate-slides.mjs" }, "dependencies": { "@astrojs/check": "^0.9.0", diff --git a/scripts/slides/refresh.mjs b/scripts/slides/refresh.mjs new file mode 100644 index 00000000..7638557b --- /dev/null +++ b/scripts/slides/refresh.mjs @@ -0,0 +1,44 @@ +import fs from 'node:fs'; +import {SLIDES_JSON} from './constants.mjs'; +import {collect} from './collect-candidates.mjs'; +import {selectSlides} from './select.mjs'; +import {writeCaptions} from './caption-agent.mjs'; +import {apply} from './apply-slides.mjs'; +import {validateSlides, diffScopeViolations} from './validate-slides.mjs'; + +function setOutput(result) { + const out = process.env.GITHUB_OUTPUT; + if (out) fs.appendFileSync(out, `result=${result}\n`); + console.log(`result=${result}`); +} + +export async function refresh({diffScope = false} = {}) { + const {current, candidates} = collect(new Date()); + const {slides, changed} = selectSlides({current, candidates}); + if (!changed) { + console.log('No slide changes needed.'); + setOutput('noop'); + return 0; + } + + await writeCaptions(slides); + const {deleted} = apply(slides); + + const applied = JSON.parse(fs.readFileSync(SLIDES_JSON, 'utf8')); + const violations = validateSlides(applied); + if (diffScope) violations.push(...diffScopeViolations()); + if (violations.length) { + console.error('Validation failed after apply:\n' + violations.map(m => ' - ' + m).join('\n')); + return 1; + } + + console.log(`Applied ${applied.length} slides; deleted ${deleted.length} stale file(s).`); + setOutput('changed'); + return 0; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + refresh({diffScope: process.argv.includes('--diff-scope')}) + .then(code => process.exit(code)) + .catch(e => {console.error(e); process.exit(1);}); +} From 6faa6e040687c2e056a28b2fb23a216b3a5b021e Mon Sep 17 00:00:00 2001 From: Yasin Date: Wed, 15 Jul 2026 14:26:03 +0200 Subject: [PATCH 15/42] fix(slides): skip candidates whose cover fails the bot quality gates --- scripts/slides/collect-candidates.mjs | 22 ++++++++++++++++++++-- scripts/slides/collect-candidates.test.mjs | 15 ++++++++++++++- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/scripts/slides/collect-candidates.mjs b/scripts/slides/collect-candidates.mjs index 4f495537..28878133 100644 --- a/scripts/slides/collect-candidates.mjs +++ b/scripts/slides/collect-candidates.mjs @@ -1,12 +1,30 @@ import fs from 'node:fs'; -import {SLIDES_JSON} from './constants.mjs'; +import {SLIDES_JSON, MIN_IMG_WIDTH, MIN_ASPECT, MAX_IMG_BYTES} from './constants.mjs'; import {listArticles, resolveArticle, withCover} from './frontmatter.mjs'; import {rankCandidates, scoreArticle, topicsOf} from './rank.mjs'; +import {probeImage} from './image-probe.mjs'; export function readCurrent() { return JSON.parse(fs.readFileSync(SLIDES_JSON, 'utf8')); } +// A fresh candidate's cover becomes a bot-created slide image, so it must pass +// the same quality gates the validator enforces on bot images. Filtering here +// keeps selection from ever picking an unusable cover (e.g. a raw portrait phone +// photo), which would otherwise abort every run. Incumbents are unaffected: +// their image was already copied and validated when the slide was created. +export function usableCover(a) { + if (!a.coverAbsPath) return false; + try { + const img = probeImage(a.coverAbsPath); + return img.width >= MIN_IMG_WIDTH + && img.width / img.height >= MIN_ASPECT + && img.bytes <= MAX_IMG_BYTES; + } catch { + return false; + } +} + function toCandidate(a) { return { id: a.ref, ref: a.ref, collection: a.collection, year: a.year, slug: a.slug, @@ -19,7 +37,7 @@ function toCandidate(a) { export function collect(now = new Date()) { const current = readCurrent(); - const ranked = rankCandidates(withCover(listArticles()), now); + const ranked = rankCandidates(withCover(listArticles()).filter(usableCover), now); const byRef = new Map(ranked.map(a => [a.ref, a])); for (const s of current) { if (s.sourceArticle && !byRef.has(s.sourceArticle)) { diff --git a/scripts/slides/collect-candidates.test.mjs b/scripts/slides/collect-candidates.test.mjs index 51f4df95..c95a4097 100644 --- a/scripts/slides/collect-candidates.test.mjs +++ b/scripts/slides/collect-candidates.test.mjs @@ -1,6 +1,7 @@ import {test} from 'node:test'; import assert from 'node:assert/strict'; -import {collect, readCurrent} from './collect-candidates.mjs'; +import {collect, readCurrent, usableCover} from './collect-candidates.mjs'; +import {resolveArticle} from './frontmatter.mjs'; test('collect returns current slides and a ranked candidate pool', () => { const {current, candidates} = collect(new Date(Date.UTC(2026, 6, 15))); @@ -16,3 +17,15 @@ test('collect returns current slides and a ranked candidate pool', () => { test('readCurrent parses slides.json', () => { assert.ok(Array.isArray(readCurrent())); }); + +test('usableCover rejects a raw portrait/oversized cover and accepts a good one', () => { + const badArt = resolveArticle('news/2026/elixir-norway-all-hands'); // 3888x5184, 24.9MB + const goodArt = resolveArticle('news/2025/eosc-entrust-workshop'); // landscape, small + assert.equal(usableCover(badArt), false); + assert.equal(usableCover(goodArt), true); +}); + +test('collect excludes candidates whose cover fails the quality gates', () => { + const {candidates} = collect(new Date(Date.UTC(2026, 6, 15))); + assert.ok(!candidates.some(c => c.ref === 'news/2026/elixir-norway-all-hands')); +}); From 836a2c76affe978f0b91c6fd985619e7ca02792f Mon Sep 17 00:00:00 2001 From: Yasin Date: Wed, 15 Jul 2026 14:39:39 +0200 Subject: [PATCH 16/42] chore(slides): tag existing slides with ownership metadata --- scripts/slides/collect-candidates.test.mjs | 6 ++++++ src/data/slides.json | 15 ++++++++++----- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/scripts/slides/collect-candidates.test.mjs b/scripts/slides/collect-candidates.test.mjs index c95a4097..c9d90d71 100644 --- a/scripts/slides/collect-candidates.test.mjs +++ b/scripts/slides/collect-candidates.test.mjs @@ -29,3 +29,9 @@ test('collect excludes candidates whose cover fails the quality gates', () => { const {candidates} = collect(new Date(Date.UTC(2026, 6, 15))); assert.ok(!candidates.some(c => c.ref === 'news/2026/elixir-norway-all-hands')); }); + +test('a bootstrapped sourceArticle ref is always present in candidates', () => { + const {candidates} = collect(new Date(Date.UTC(2026, 6, 15))); + assert.ok(candidates.some(c => c.ref === 'news/2025/eosc-entrust-workshop'), + 'eosc-entrust (a tagged sourceArticle) must be scored and included'); +}); diff --git a/src/data/slides.json b/src/data/slides.json index 58f57c84..f8a6df96 100644 --- a/src/data/slides.json +++ b/src/data/slides.json @@ -2,26 +2,31 @@ { "src": "/data/slides/eosc-entrust.png", "alt": "EOSC-ENTRUST", - "caption": "Pål Sætrom and Miikka Kallberg co-led the 2nd TRE Evaluation Workshop, bringing together 30 stakeholders to advance the TRE Blueprint and strengthen Trusted Research Environments across Europe. Organizers included Ingeborg Winge, Christine Stansberg, and Stefanie Kirschenmann." + "caption": "Pål Sætrom and Miikka Kallberg co-led the 2nd TRE Evaluation Workshop, bringing together 30 stakeholders to advance the TRE Blueprint and strengthen Trusted Research Environments across Europe. Organizers included Ingeborg Winge, Christine Stansberg, and Stefanie Kirschenmann.", + "sourceArticle": "news/2025/eosc-entrust-workshop" }, { "src": "/data/slides/elixir-no-all-hands-2025.jpg", "alt": "Group photo for ELIXIR Norway All Hands 2025", - "caption": "This year's ELIXIR Norway All Hands was organised physically in Ås!" + "caption": "This year's ELIXIR Norway All Hands was organised physically in Ås!", + "evergreen": true }, { "src": "/data/slides/nels.png", "alt": "NeLS Landing Page", - "caption": "NeLS, the Norwegian e-Infrastructure for Life Sciences, for data analysis, sharing and storage" + "caption": "NeLS, the Norwegian e-Infrastructure for Life Sciences, for data analysis, sharing and storage", + "evergreen": true }, { "src": "/data/slides/genomic-data-infrastructure.png", "alt": "Genomic Data Infrastructure (GDI)", - "caption": "ELIXIR Norway is deploying GDI infrastructure to go live by 2026 with existing datasets and Genome of Europe (GoE) reference data, enabling federated discovery and analysis across 27+ European countries." + "caption": "ELIXIR Norway is deploying GDI infrastructure to go live by 2026 with existing datasets and Genome of Europe (GoE) reference data, enabling federated discovery and analysis across 27+ European countries.", + "evergreen": true }, { "src": "/data/slides/rdm-promotion.png", "alt": "RDMkit", - "caption": "The ELIXIR RDMkit: research data management made simple" + "caption": "The ELIXIR RDMkit: research data management made simple", + "evergreen": true } ] From fad6999d43fec8a4a15c2eeb7d2d6d3d99db1d80 Mon Sep 17 00:00:00 2001 From: Yasin Date: Wed, 15 Jul 2026 14:48:16 +0200 Subject: [PATCH 17/42] ci(slides): add scheduled highlights refresh workflow --- .github/workflows/refresh-highlights.yml | 96 ++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 .github/workflows/refresh-highlights.yml diff --git a/.github/workflows/refresh-highlights.yml b/.github/workflows/refresh-highlights.yml new file mode 100644 index 00000000..a62799d0 --- /dev/null +++ b/.github/workflows/refresh-highlights.yml @@ -0,0 +1,96 @@ +name: Refresh Highlights + +on: + schedule: + - cron: '0 7 * * 1' # Mon ~08:00 Europe/Oslo (UTC; ±1h DST drift) + - cron: '0 15 * * 5' # Fri ~16:00 Europe/Oslo + workflow_dispatch: + +concurrency: + group: refresh-highlights + cancel-in-progress: false + +permissions: + contents: write + pull-requests: write + issues: write + +jobs: + refresh: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: pnpm + + - name: Install dependencies + run: pnpm install + + - name: Install OpenCode (pinned; agent is optional) + continue-on-error: true + run: npm install -g opencode-ai@0.5.29 + + - name: Run refresh pipeline + id: refresh + continue-on-error: true + env: + # Optional: set repo secrets to enable the caption agent. Absent → the + # pipeline uses summary-derived captions (fully functional). + SLIDES_AGENT_MODEL: ${{ vars.SLIDES_AGENT_MODEL }} + OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + run: node scripts/slides/refresh.mjs --diff-scope + + - name: Build sanity gate + id: build + if: steps.refresh.outcome == 'success' && steps.refresh.outputs.result == 'changed' + continue-on-error: true + run: pnpm build + env: + GITHUB_PAGES: true + + - name: Open PR and auto-merge + if: steps.refresh.outputs.result == 'changed' && steps.build.outcome == 'success' + env: + GH_TOKEN: ${{ secrets.SLIDES_BOT_TOKEN || github.token }} + run: | + set -euo pipefail + BRANCH="bot/slides-refresh-${{ github.run_id }}" + git config user.name "elixir-no-bot" + git config user.email "actions@github.com" + git checkout -b "$BRANCH" + git add src/data/slides.json src/data/slides + git commit -m "chore(slides): refresh homepage highlights" + git push origin "$BRANCH" + PR_URL=$(gh pr create --base main --head "$BRANCH" \ + --title "chore(slides): refresh homepage highlights" \ + --body "Automated highlights refresh. Slide selection and captions were regenerated from recent content; validation and build passed.") + gh pr merge "$PR_URL" --squash --auto --delete-branch \ + || gh pr merge "$PR_URL" --squash --admin --delete-branch + + - name: Report failure + if: steps.refresh.outcome == 'failure' || steps.build.outcome == 'failure' + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + TITLE="Highlights refresh failed" + BODY="The scheduled highlights refresh failed on run ${{ github.run_id }}. See the [workflow logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})." + EXISTING=$(gh issue list --label slides-bot --state open --json number --jq '.[0].number' || echo "") + if [ -n "$EXISTING" ]; then + gh issue comment "$EXISTING" --body "$BODY" + else + gh label create slides-bot --color BFD4F2 --description "Automated highlights refresh" 2>/dev/null || true + gh issue create --title "$TITLE" --label slides-bot --body "$BODY" + fi + + - name: Fail job if pipeline or build failed + if: steps.refresh.outcome == 'failure' || steps.build.outcome == 'failure' + run: exit 1 From 088328058a3669a578994fd95ae3db7d3a562a8c Mon Sep 17 00:00:00 2001 From: Yasin Date: Wed, 15 Jul 2026 15:02:57 +0200 Subject: [PATCH 18/42] ci(slides): merge deterministically and report merge failures loudly --- .github/workflows/refresh-highlights.yml | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/.github/workflows/refresh-highlights.yml b/.github/workflows/refresh-highlights.yml index a62799d0..3ec1b505 100644 --- a/.github/workflows/refresh-highlights.yml +++ b/.github/workflows/refresh-highlights.yml @@ -56,8 +56,10 @@ jobs: env: GITHUB_PAGES: true - - name: Open PR and auto-merge + - name: Open PR and merge + id: pr if: steps.refresh.outputs.result == 'changed' && steps.build.outcome == 'success' + continue-on-error: true env: GH_TOKEN: ${{ secrets.SLIDES_BOT_TOKEN || github.token }} run: | @@ -72,18 +74,21 @@ jobs: PR_URL=$(gh pr create --base main --head "$BRANCH" \ --title "chore(slides): refresh homepage highlights" \ --body "Automated highlights refresh. Slide selection and captions were regenerated from recent content; validation and build passed.") - gh pr merge "$PR_URL" --squash --auto --delete-branch \ - || gh pr merge "$PR_URL" --squash --admin --delete-branch + # The workflow already ran validation + pnpm build as its own gate, so + # merge immediately with --admin (deterministic, no waiting on pr-test, + # which does not run for GITHUB_TOKEN-created PRs). A failure here is + # loud: continue-on-error keeps the run going so the issue is opened. + gh pr merge "$PR_URL" --squash --admin --delete-branch - name: Report failure - if: steps.refresh.outcome == 'failure' || steps.build.outcome == 'failure' + if: steps.refresh.outcome == 'failure' || steps.build.outcome == 'failure' || steps.pr.outcome == 'failure' env: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail TITLE="Highlights refresh failed" BODY="The scheduled highlights refresh failed on run ${{ github.run_id }}. See the [workflow logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})." - EXISTING=$(gh issue list --label slides-bot --state open --json number --jq '.[0].number' || echo "") + EXISTING=$(gh issue list --label slides-bot --state open --json number --jq '.[0].number // empty' || echo "") if [ -n "$EXISTING" ]; then gh issue comment "$EXISTING" --body "$BODY" else @@ -91,6 +96,6 @@ jobs: gh issue create --title "$TITLE" --label slides-bot --body "$BODY" fi - - name: Fail job if pipeline or build failed - if: steps.refresh.outcome == 'failure' || steps.build.outcome == 'failure' + - name: Fail job if pipeline, build, or merge failed + if: steps.refresh.outcome == 'failure' || steps.build.outcome == 'failure' || steps.pr.outcome == 'failure' run: exit 1 From dbc89dc8e9c282c54f83d97078bb8bab958b11c5 Mon Sep 17 00:00:00 2001 From: Yasin Date: Wed, 15 Jul 2026 15:17:15 +0200 Subject: [PATCH 19/42] fix(slides): retain untracked slides and require candidate summaries --- scripts/slides/collect-candidates.mjs | 6 +++++- scripts/slides/collect-candidates.test.mjs | 6 ++++++ scripts/slides/select.mjs | 8 +++++++- scripts/slides/select.test.mjs | 11 +++++++++++ 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/scripts/slides/collect-candidates.mjs b/scripts/slides/collect-candidates.mjs index 28878133..028d2b6f 100644 --- a/scripts/slides/collect-candidates.mjs +++ b/scripts/slides/collect-candidates.mjs @@ -37,7 +37,11 @@ function toCandidate(a) { export function collect(now = new Date()) { const current = readCurrent(); - const ranked = rankCandidates(withCover(listArticles()).filter(usableCover), now); + // A fresh candidate must have a non-empty summary: the fallback caption is + // derived from it, and an empty summary would make caption == alt (== title), + // which the validator rejects and which would abort every run. + const usable = a => usableCover(a) && !!(a.summary && a.summary.trim()); + const ranked = rankCandidates(withCover(listArticles()).filter(usable), now); const byRef = new Map(ranked.map(a => [a.ref, a])); for (const s of current) { if (s.sourceArticle && !byRef.has(s.sourceArticle)) { diff --git a/scripts/slides/collect-candidates.test.mjs b/scripts/slides/collect-candidates.test.mjs index c9d90d71..86f69041 100644 --- a/scripts/slides/collect-candidates.test.mjs +++ b/scripts/slides/collect-candidates.test.mjs @@ -35,3 +35,9 @@ test('a bootstrapped sourceArticle ref is always present in candidates', () => { assert.ok(candidates.some(c => c.ref === 'news/2025/eosc-entrust-workshop'), 'eosc-entrust (a tagged sourceArticle) must be scored and included'); }); + +test('every candidate has a non-empty summary (fallback caption needs it)', () => { + const {candidates} = collect(new Date(Date.UTC(2026, 6, 15))); + assert.ok(candidates.length > 0); + assert.ok(candidates.every(c => c.summary && c.summary.trim()), 'no candidate may have an empty summary'); +}); diff --git a/scripts/slides/select.mjs b/scripts/slides/select.mjs index aeb875e6..b4726eb1 100644 --- a/scripts/slides/select.mjs +++ b/scripts/slides/select.mjs @@ -9,7 +9,13 @@ export function selectSlides({current, candidates}) { const byRef = new Map(candidates.map(c => [c.ref, c])); const scoreOf = ref => byRef.get(ref)?.score ?? 0; - const evergreens = current.filter(s => s.evergreen === true); + // Evergreen pins AND untracked entries (e.g. a slide freshly added via the + // CMS, which has no ownership key yet) are retained in place. Untracked ones + // are stamped `evergreen: true` so they are protected and self-heal their + // tag — never dropped. This is the spec's fail-closed rule. + const evergreens = current + .filter(s => s.evergreen === true || !s.sourceArticle) + .map(s => (s.evergreen === true ? s : {...s, evergreen: true})); const budget = Math.max(0, MAX_SLIDES - evergreens.length); const botIncumbents = current.filter(s => s.sourceArticle && s.evergreen !== true); diff --git a/scripts/slides/select.test.mjs b/scripts/slides/select.test.mjs index 4ca8e8a6..8d1ba65c 100644 --- a/scripts/slides/select.test.mjs +++ b/scripts/slides/select.test.mjs @@ -60,3 +60,14 @@ test('the swap cap keeps the two highest-scored fresh, not any two', () => { const news = slides.filter(s => s._candidate).map(s => s.sourceArticle).sort(); assert.deepEqual(news, ['news/2026/a', 'news/2026/b']); // top two by score, not c/d }); + +test('retains an untracked (CMS-added) current entry and tags it evergreen', () => { + const current = [ + {src: '/data/slides/nels.png', alt: 'NeLS', caption: 'c', evergreen: true}, + {src: '/data/slides/human-added.png', alt: 'Human highlight', caption: 'Added via CMS'}, + ]; + const {slides} = selectSlides({current, candidates: []}); + const human = slides.find(s => s.src === '/data/slides/human-added.png'); + assert.ok(human, 'untracked entry must survive'); + assert.equal(human.evergreen, true, 'untracked entry must be tagged evergreen'); +}); From 244004982a0aba3048776376f1de4c88aa42616f Mon Sep 17 00:00:00 2001 From: Yasin Date: Tue, 28 Jul 2026 13:49:12 +0200 Subject: [PATCH 20/42] fix(slides): persist the evergreen stamp and reject dual ownership tags The change detector compared only src/alt/caption, so a run whose sole effect was stamping an untracked (CMS-added) entry with `evergreen: true` reported no-op and never wrote the tag back, leaving the entry untracked run after run. Ownership keys are now part of the comparison. Validation flagged the neither-key case but accepted slides carrying both `evergreen` and `sourceArticle`, which reads as bot-managed while silently behaving as a pin. Also drops the README's claim that untracked entries are logged; the stamp shows up in the run's PR diff instead. --- scripts/slides/README.md | 7 ++++++- scripts/slides/select.mjs | 8 +++++++- scripts/slides/select.test.mjs | 11 +++++++++++ scripts/slides/validate-slides.mjs | 3 ++- scripts/slides/validate-slides.test.mjs | 5 +++++ 5 files changed, 31 insertions(+), 3 deletions(-) diff --git a/scripts/slides/README.md b/scripts/slides/README.md index ad5bde61..b0db0350 100644 --- a/scripts/slides/README.md +++ b/scripts/slides/README.md @@ -14,9 +14,14 @@ Every slide entry carries exactly one signal: - `"sourceArticle": "collection/year/slug"`, bot-managed. Scored from that article each run; rotated by recency + editorial weight; dropped when it ages out. `funding-and-projects` refs have two segments (no year). -- Neither key, treated as evergreen (fail closed) and logged. Should not occur +- Neither key, treated as evergreen (fail closed) and stamped `evergreen: true` + on the next run, so the tag shows up in that run's PR diff. Should not occur after bootstrap. +Carrying both keys is a validation error. If one slips in anyway, `evergreen` +wins and the redundant `sourceArticle` is dropped the next time the file is +written. + Bot-created image files are named `-.`. The bot only ever deletes files matching `^\d{4}-[a-z0-9-]+\.(png|jpe?g|webp)$` that are no longer referenced and belonged to a `sourceArticle` entry, so legacy/human files diff --git a/scripts/slides/select.mjs b/scripts/slides/select.mjs index b4726eb1..c132f569 100644 --- a/scripts/slides/select.mjs +++ b/scripts/slides/select.mjs @@ -1,7 +1,13 @@ import {MAX_SLIDES, HYSTERESIS_MARGIN, MAX_SWAPS} from './constants.mjs'; const botFilename = c => `${c.year ?? '0000'}-${c.slug}.${c.coverExt}`; -const pick = s => ({src: s.src, alt: s.alt ?? null, caption: s.caption ?? null}); +// Ownership keys are part of the comparison: a run whose only effect is +// stamping an untracked entry `evergreen` must still be reported as changed, +// or the tag is never persisted and the entry stays untracked forever. +const pick = s => ({ + src: s.src, alt: s.alt ?? null, caption: s.caption ?? null, + evergreen: s.evergreen === true, sourceArticle: s.sourceArticle ?? null, +}); const sameSeq = (a, b) => JSON.stringify(a.map(pick)) === JSON.stringify(b.map(pick)); diff --git a/scripts/slides/select.test.mjs b/scripts/slides/select.test.mjs index 8d1ba65c..d39c3f0f 100644 --- a/scripts/slides/select.test.mjs +++ b/scripts/slides/select.test.mjs @@ -71,3 +71,14 @@ test('retains an untracked (CMS-added) current entry and tags it evergreen', () assert.ok(human, 'untracked entry must survive'); assert.equal(human.evergreen, true, 'untracked entry must be tagged evergreen'); }); + +test('stamping an untracked entry counts as a change so the tag is written back', () => { + const current = [ + {src: '/data/slides/nels.png', alt: 'NeLS', caption: 'c', evergreen: true}, + {src: '/data/slides/human-added.png', alt: 'Human highlight', caption: 'Added via CMS'}, + ]; + // src/alt/caption are all identical to current; only the new evergreen tag + // differs. Reporting no-op here would strand the entry untracked forever. + const {changed} = selectSlides({current, candidates: []}); + assert.equal(changed, true); +}); diff --git a/scripts/slides/validate-slides.mjs b/scripts/slides/validate-slides.mjs index 84fca961..6340c01a 100644 --- a/scripts/slides/validate-slides.mjs +++ b/scripts/slides/validate-slides.mjs @@ -22,7 +22,8 @@ export function validateSlides(slides, {slidesDir = SLIDES_DIR} = {}) { if (seen.has(s.src)) v.push(`${at} duplicate src: ${s.src}`); seen.add(s.src); - if (!(s.evergreen === true) && !s.sourceArticle) v.push(`${at} untracked (no evergreen/sourceArticle)`); + if (s.evergreen === true && s.sourceArticle) v.push(`${at} has both evergreen and sourceArticle`); + else if (!(s.evergreen === true) && !s.sourceArticle) v.push(`${at} untracked (no evergreen/sourceArticle)`); for (const [field, max] of [['caption', MAX_CAPTION], ['alt', MAX_ALT]]) { const val = s[field]; diff --git a/scripts/slides/validate-slides.test.mjs b/scripts/slides/validate-slides.test.mjs index 75aa8e27..4b8f0b40 100644 --- a/scripts/slides/validate-slides.test.mjs +++ b/scripts/slides/validate-slides.test.mjs @@ -38,6 +38,11 @@ test('grandfathers a large legacy-named evergreen image (quality gates are bot-o assert.deepEqual(validateSlides([bigLegacy], {slidesDir: SLIDES_DIR}), []); }); +test('rejects a slide carrying both ownership tags', () => { + const v = validateSlides([{...ok, sourceArticle: 'news/2026/x'}], {slidesDir: SLIDES_DIR}); + assert.ok(v.some(m => /both/i.test(m)), v.join('; ')); +}); + test('still enforces quality gates on a bot-named image (guard is not a blanket exemption)', () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'slides-validate-')); try { From eab27be9171e96b35d459bc092be5be672d3fa1d Mon Sep 17 00:00:00 2001 From: Yasin Date: Tue, 28 Jul 2026 14:12:04 +0200 Subject: [PATCH 21/42] refactor(slides): share acceptance rules between the gate and its producers The image quality thresholds and the alt/caption text rules were each written twice, once as an accept predicate in the producer and once as a reject list in the validator, down to a copy-pasted illegal-character regex. Nothing kept the pairs in sync, and drift there is expensive: selection would pick a cover, or the caption agent would emit text, that the gate then rejects, aborting every subsequent run. The validator now owns both predicates and the producers import them. Also has apply() return the array it just wrote so refresh no longer reads the file back to validate it, and unexports REPO_ROOT, which nothing imported. --- scripts/slides/apply-slides.mjs | 2 +- scripts/slides/caption-agent.mjs | 7 ++--- scripts/slides/collect-candidates.mjs | 8 ++---- scripts/slides/constants.mjs | 5 +++- scripts/slides/refresh.mjs | 4 +-- scripts/slides/validate-slides.mjs | 41 ++++++++++++++++++--------- 6 files changed, 38 insertions(+), 29 deletions(-) diff --git a/scripts/slides/apply-slides.mjs b/scripts/slides/apply-slides.mjs index 8011c4c5..3fcc6265 100644 --- a/scripts/slides/apply-slides.mjs +++ b/scripts/slides/apply-slides.mjs @@ -31,5 +31,5 @@ export function apply(slides) { for (const f of deleted) fs.rmSync(path.join(SLIDES_DIR, f)); fs.writeFileSync(SLIDES_JSON, JSON.stringify(clean, null, 4) + '\n'); - return {deleted}; + return {deleted, slides: clean}; } diff --git a/scripts/slides/caption-agent.mjs b/scripts/slides/caption-agent.mjs index 2a418e14..ea38b14d 100644 --- a/scripts/slides/caption-agent.mjs +++ b/scripts/slides/caption-agent.mjs @@ -2,6 +2,7 @@ import path from 'node:path'; import {fileURLToPath} from 'node:url'; import {spawnSync} from 'node:child_process'; import {MAX_CAPTION, MAX_ALT} from './constants.mjs'; +import {textIssues} from './validate-slides.mjs'; const HERE = path.dirname(fileURLToPath(import.meta.url)); @@ -22,11 +23,7 @@ export function properNounsOk(text, cand) { } export function validAgentText(alt, caption, cand) { - if (typeof alt !== 'string' || typeof caption !== 'string') return false; - if (!alt.trim() || !caption.trim()) return false; - if (alt.length > MAX_ALT || caption.length > MAX_CAPTION) return false; - if (/[\x00-\x1f<>`]/.test(alt + caption)) return false; - if (alt.trim() === caption.trim()) return false; + if (textIssues(alt, caption).length) return false; return properNounsOk(caption, cand) && properNounsOk(alt, cand); } diff --git a/scripts/slides/collect-candidates.mjs b/scripts/slides/collect-candidates.mjs index 028d2b6f..a902b453 100644 --- a/scripts/slides/collect-candidates.mjs +++ b/scripts/slides/collect-candidates.mjs @@ -1,8 +1,9 @@ import fs from 'node:fs'; -import {SLIDES_JSON, MIN_IMG_WIDTH, MIN_ASPECT, MAX_IMG_BYTES} from './constants.mjs'; +import {SLIDES_JSON} from './constants.mjs'; import {listArticles, resolveArticle, withCover} from './frontmatter.mjs'; import {rankCandidates, scoreArticle, topicsOf} from './rank.mjs'; import {probeImage} from './image-probe.mjs'; +import {imageQualityIssues} from './validate-slides.mjs'; export function readCurrent() { return JSON.parse(fs.readFileSync(SLIDES_JSON, 'utf8')); @@ -16,10 +17,7 @@ export function readCurrent() { export function usableCover(a) { if (!a.coverAbsPath) return false; try { - const img = probeImage(a.coverAbsPath); - return img.width >= MIN_IMG_WIDTH - && img.width / img.height >= MIN_ASPECT - && img.bytes <= MAX_IMG_BYTES; + return !imageQualityIssues(probeImage(a.coverAbsPath)).length; } catch { return false; } diff --git a/scripts/slides/constants.mjs b/scripts/slides/constants.mjs index 37fa4520..7864a1df 100644 --- a/scripts/slides/constants.mjs +++ b/scripts/slides/constants.mjs @@ -1,7 +1,7 @@ import path from 'node:path'; import {fileURLToPath} from 'node:url'; -export const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); export const SLIDES_JSON = path.join(REPO_ROOT, 'src/data/slides.json'); export const SLIDES_DIR = path.join(REPO_ROOT, 'src/data/slides'); export const CONTENT_DIR = path.join(REPO_ROOT, 'src/content'); @@ -20,6 +20,9 @@ export const MIN_IMG_WIDTH = 800; export const MAX_IMG_BYTES = 3_000_000; export const MIN_ASPECT = 0.9; // width/height must be >= this (landscape-ish) +// Control characters plus the three that break MDX/JSX or shell-quote a caption. +export const ILLEGAL_TEXT_RE = /[\x00-\x1f<>`]/; + export const SRC_RE = /^\/data\/slides\/[a-z0-9-]+\.(png|jpe?g|webp)$/; export const BOT_FILE_RE = /^\d{4}-[a-z0-9-]+\.(png|jpe?g|webp)$/; diff --git a/scripts/slides/refresh.mjs b/scripts/slides/refresh.mjs index 7638557b..40126665 100644 --- a/scripts/slides/refresh.mjs +++ b/scripts/slides/refresh.mjs @@ -1,5 +1,4 @@ import fs from 'node:fs'; -import {SLIDES_JSON} from './constants.mjs'; import {collect} from './collect-candidates.mjs'; import {selectSlides} from './select.mjs'; import {writeCaptions} from './caption-agent.mjs'; @@ -22,9 +21,8 @@ export async function refresh({diffScope = false} = {}) { } await writeCaptions(slides); - const {deleted} = apply(slides); + const {deleted, slides: applied} = apply(slides); - const applied = JSON.parse(fs.readFileSync(SLIDES_JSON, 'utf8')); const violations = validateSlides(applied); if (diffScope) violations.push(...diffScopeViolations()); if (violations.length) { diff --git a/scripts/slides/validate-slides.mjs b/scripts/slides/validate-slides.mjs index 6340c01a..ad4edba8 100644 --- a/scripts/slides/validate-slides.mjs +++ b/scripts/slides/validate-slides.mjs @@ -3,12 +3,35 @@ import path from 'node:path'; import {execFileSync} from 'node:child_process'; import { SLIDES_JSON, SLIDES_DIR, MAX_SLIDES, MIN_SLIDES, SRC_RE, BOT_FILE_RE, - MAX_CAPTION, MAX_ALT, MIN_IMG_WIDTH, MIN_ASPECT, MAX_IMG_BYTES, + MAX_CAPTION, MAX_ALT, MIN_IMG_WIDTH, MIN_ASPECT, MAX_IMG_BYTES, ILLEGAL_TEXT_RE, } from './constants.mjs'; import {probeImage} from './image-probe.mjs'; const EXT_FORMAT = {png: 'png', jpg: 'jpeg', jpeg: 'jpeg', webp: 'webp'}; +// The acceptance rules live here so producers can check themselves against the +// same predicate the gate enforces. `collect-candidates` screens covers with +// imageQualityIssues, `caption-agent` screens model output with textIssues; if +// either drifted from the gate the pipeline would pick work it then rejects. +export function imageQualityIssues({width, height, bytes}) { + const issues = []; + if (width < MIN_IMG_WIDTH) issues.push(`width ${width} < ${MIN_IMG_WIDTH}`); + if (width / height < MIN_ASPECT) issues.push(`not landscape (${width}x${height})`); + if (bytes > MAX_IMG_BYTES) issues.push(`file too large (${bytes} > ${MAX_IMG_BYTES})`); + return issues; +} + +export function textIssues(alt, caption) { + const issues = []; + for (const [field, val, max] of [['caption', caption, MAX_CAPTION], ['alt', alt, MAX_ALT]]) { + if (typeof val !== 'string' || !val.trim()) {issues.push(`${field} empty`); continue;} + if (val.length > max) issues.push(`${field} too long (${val.length} > ${max})`); + if (ILLEGAL_TEXT_RE.test(val)) issues.push(`${field} has illegal characters`); + } + if (typeof alt === 'string' && alt.trim() === (caption || '').trim()) issues.push('alt equals caption'); + return issues; +} + export function validateSlides(slides, {slidesDir = SLIDES_DIR} = {}) { const v = []; if (!Array.isArray(slides)) return ['slides.json is not an array']; @@ -25,14 +48,7 @@ export function validateSlides(slides, {slidesDir = SLIDES_DIR} = {}) { if (s.evergreen === true && s.sourceArticle) v.push(`${at} has both evergreen and sourceArticle`); else if (!(s.evergreen === true) && !s.sourceArticle) v.push(`${at} untracked (no evergreen/sourceArticle)`); - for (const [field, max] of [['caption', MAX_CAPTION], ['alt', MAX_ALT]]) { - const val = s[field]; - if (typeof val !== 'string' || !val.trim()) {v.push(`${at} ${field} empty`); continue;} - if (val.length > max) v.push(`${at} ${field} too long (${val.length} > ${max})`); - if (/[\x00-\x1f<>`]/.test(val)) v.push(`${at} ${field} has illegal characters`); - } - if (typeof s.alt === 'string' && s.alt.trim() === (s.caption || '').trim()) - v.push(`${at} alt equals caption`); + for (const issue of textIssues(s.alt, s.caption)) v.push(`${at} ${issue}`); const abs = path.join(slidesDir, path.basename(s.src)); if (!fs.existsSync(abs)) {v.push(`${at} image missing: ${abs}`); continue;} @@ -42,11 +58,8 @@ export function validateSlides(slides, {slidesDir = SLIDES_DIR} = {}) { if (EXT_FORMAT[ext] !== img.format) v.push(`${at} format ${img.format} != extension .${ext}`); // Quality gates apply only to bot-created images (-.). // Legacy/human pins predate the automation and are grandfathered. - if (BOT_FILE_RE.test(path.basename(abs))) { - if (img.width < MIN_IMG_WIDTH) v.push(`${at} width ${img.width} < ${MIN_IMG_WIDTH}`); - if (img.width / img.height < MIN_ASPECT) v.push(`${at} not landscape (${img.width}x${img.height})`); - if (img.bytes > MAX_IMG_BYTES) v.push(`${at} file too large (${img.bytes} > ${MAX_IMG_BYTES})`); - } + if (BOT_FILE_RE.test(path.basename(abs))) + for (const issue of imageQualityIssues(img)) v.push(`${at} ${issue}`); } catch (e) { v.push(`${at} image probe failed: ${e.message}`); } From 01480ff3d394836a411f7b0d21c6287eb1a57b7a Mon Sep 17 00:00:00 2001 From: Yasin Date: Tue, 28 Jul 2026 14:25:14 +0200 Subject: [PATCH 22/42] fix(slides): close the collision, overflow and duplication paths Six ways an unattended run could damage the carousel or wedge itself: Bot image names were `-`, which is not unique across collections (news and events both hold 2025/elixir-industry-engagement-day) and collides with CMS uploads, which slugify the alt text and so can also start with a year. A collision made apply() overwrite a human's image and then fail validation on a duplicate src for good. Names now carry the collection, and apply() refuses to write over any file a retained slide still points at. An article pinned by an entry that also carried sourceArticle was eligible for selection again, appearing twice under two filenames, and cleanEntry normalized the evidence away before validation ran. Refs held by pins are now claimed. Pins exceeding the slot limit produced an over-length set that failed the count check on every subsequent run. The run now stops and says which state it is in rather than dropping bot slides to make room. Survivors were matched by ref instead of identity, so two entries sharing one sourceArticle both survived and pushed the set past budget. An article whose summary repeats its title yields alt === caption from the fallback captions, which validation rejects; one such article is in the pool today. Candidates are now screened with the same text rules the gate applies, which subsumes the old non-empty-summary check. Covers whose extension disagrees with their bytes are screened there too. Also closes the bot's PR when an automated merge fails, since it carries a whole-file slides.json that would revert newer slides if merged later, and runs slide validation and the pipeline tests on PRs. --- .github/workflows/pr-test.yml | 6 +++ .github/workflows/refresh-highlights.yml | 9 ++++- scripts/slides/README.md | 18 ++++++--- scripts/slides/apply-slides.mjs | 7 +++- scripts/slides/apply-slides.test.mjs | 12 ++++-- scripts/slides/collect-candidates.mjs | 21 ++++++---- scripts/slides/collect-candidates.test.mjs | 30 ++++++++++++++- scripts/slides/constants.mjs | 7 +++- scripts/slides/refresh.mjs | 7 +++- scripts/slides/select.mjs | 26 +++++++++---- scripts/slides/select.test.mjs | 45 ++++++++++++++++++++++ scripts/slides/validate-slides.mjs | 4 +- scripts/slides/validate-slides.test.mjs | 4 +- 13 files changed, 164 insertions(+), 32 deletions(-) diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index 4d3b17f3..dec08389 100644 --- a/.github/workflows/pr-test.yml +++ b/.github/workflows/pr-test.yml @@ -26,6 +26,12 @@ jobs: - name: Check slug naming conventions run: node scripts/check-slugs.mjs + - name: Validate homepage slides + run: node scripts/slides/validate-slides.mjs + + - name: Slides pipeline unit tests + run: node --test scripts/slides/*.test.mjs + - name: Build (static output) run: pnpm build env: diff --git a/.github/workflows/refresh-highlights.yml b/.github/workflows/refresh-highlights.yml index 3ec1b505..9ad4270d 100644 --- a/.github/workflows/refresh-highlights.yml +++ b/.github/workflows/refresh-highlights.yml @@ -78,7 +78,14 @@ jobs: # merge immediately with --admin (deterministic, no waiting on pr-test, # which does not run for GITHUB_TOKEN-created PRs). A failure here is # loud: continue-on-error keeps the run going so the issue is opened. - gh pr merge "$PR_URL" --squash --admin --delete-branch + # An unmerged PR must not survive the run. It holds a whole-file + # slides.json from this run's base, so merging it days later would + # revert everything written in between. + if ! gh pr merge "$PR_URL" --squash --admin --delete-branch; then + gh pr close "$PR_URL" --delete-branch \ + --comment "Automated merge failed, closing so the next run starts from a clean base." + exit 1 + fi - name: Report failure if: steps.refresh.outcome == 'failure' || steps.build.outcome == 'failure' || steps.pr.outcome == 'failure' diff --git a/scripts/slides/README.md b/scripts/slides/README.md index b0db0350..529deaf7 100644 --- a/scripts/slides/README.md +++ b/scripts/slides/README.md @@ -13,7 +13,9 @@ Every slide entry carries exactly one signal: the bot. Set this to protect a slide. - `"sourceArticle": "collection/year/slug"`, bot-managed. Scored from that article each run; rotated by recency + editorial weight; dropped when it ages - out. `funding-and-projects` refs have two segments (no year). + out. `funding-and-projects` refs have two segments (no year), and no entry in + that collection can surface until its schema gains a `cover` field: the + selector only considers articles that have one. - Neither key, treated as evergreen (fail closed) and stamped `evergreen: true` on the next run, so the tag shows up in that run's PR diff. Should not occur after bootstrap. @@ -22,10 +24,16 @@ Carrying both keys is a validation error. If one slips in anyway, `evergreen` wins and the redundant `sourceArticle` is dropped the next time the file is written. -Bot-created image files are named `-.`. The bot only ever -deletes files matching `^\d{4}-[a-z0-9-]+\.(png|jpe?g|webp)$` that are no longer -referenced and belonged to a `sourceArticle` entry, so legacy/human files -(none start with a 4-digit year) are structurally safe. +Bot-created image files are named `--.`, and the bot +only ever deletes unreferenced files matching that shape (`BOT_FILE_RE`). The +collection is in the name for two reasons: a slug is unique only within its +collection (news and events both hold `2025/elixir-industry-engagement-day`), +and it keeps bot names clear of CMS uploads, which are slugified from the alt +text and so can start with a year. `apply()` additionally refuses to copy over +any file a retained slide still points at. + +A slide count above `MAX_SLIDES` is unreachable by rotation: if pins alone +exceed the limit the run stops and reports it instead of dropping anything. ## CMS interaction diff --git a/scripts/slides/apply-slides.mjs b/scripts/slides/apply-slides.mjs index 3fcc6265..ac8c59bc 100644 --- a/scripts/slides/apply-slides.mjs +++ b/scripts/slides/apply-slides.mjs @@ -18,10 +18,13 @@ export function staleBotFiles(existing, referenced) { } export function apply(slides) { + const retained = new Set(slides.filter(s => !s._candidate).map(s => path.basename(s.src))); for (const s of slides) { if (s._candidate) { - const dest = path.join(SLIDES_DIR, path.basename(s.src)); - fs.copyFileSync(s._candidate.coverAbsPath, dest); + const name = path.basename(s.src); + if (retained.has(name)) + throw new Error(`refusing to overwrite an image already in use: ${name}`); + fs.copyFileSync(s._candidate.coverAbsPath, path.join(SLIDES_DIR, name)); } } const clean = slides.map(cleanEntry); diff --git a/scripts/slides/apply-slides.test.mjs b/scripts/slides/apply-slides.test.mjs index 30773980..30fa3a47 100644 --- a/scripts/slides/apply-slides.test.mjs +++ b/scripts/slides/apply-slides.test.mjs @@ -8,7 +8,13 @@ test('cleanEntry strips transient fields', () => { }); test('staleBotFiles only targets bot-named unreferenced files', () => { - const referenced = referencedBasenames([{src: '/data/slides/2026-keep.png'}, {src: '/data/slides/nels.png'}]); - const existing = ['2026-keep.png', '2025-drop.jpeg', 'nels.png', 'rdm-promotion.png']; - assert.deepEqual(staleBotFiles(existing, referenced), ['2025-drop.jpeg']); + const referenced = referencedBasenames([{src: '/data/slides/news-2026-keep.png'}, {src: '/data/slides/nels.png'}]); + const existing = ['news-2026-keep.png', 'events-2025-drop.jpeg', 'nels.png', 'rdm-promotion.png']; + assert.deepEqual(staleBotFiles(existing, referenced), ['events-2025-drop.jpeg']); +}); + +test('staleBotFiles spares a CMS upload that merely starts with a year', () => { + // The CMS names uploads by slugifying the alt text, so "2025 All Hands" + // becomes 2025-all-hands.png. That must never look bot-owned. + assert.deepEqual(staleBotFiles(['2025-all-hands.png'], new Set()), []); }); diff --git a/scripts/slides/collect-candidates.mjs b/scripts/slides/collect-candidates.mjs index a902b453..c21e7055 100644 --- a/scripts/slides/collect-candidates.mjs +++ b/scripts/slides/collect-candidates.mjs @@ -3,7 +3,8 @@ import {SLIDES_JSON} from './constants.mjs'; import {listArticles, resolveArticle, withCover} from './frontmatter.mjs'; import {rankCandidates, scoreArticle, topicsOf} from './rank.mjs'; import {probeImage} from './image-probe.mjs'; -import {imageQualityIssues} from './validate-slides.mjs'; +import {imageQualityIssues, textIssues, extensionMatches} from './validate-slides.mjs'; +import {fallbackText} from './caption-agent.mjs'; export function readCurrent() { return JSON.parse(fs.readFileSync(SLIDES_JSON, 'utf8')); @@ -17,12 +18,22 @@ export function readCurrent() { export function usableCover(a) { if (!a.coverAbsPath) return false; try { - return !imageQualityIssues(probeImage(a.coverAbsPath)).length; + const img = probeImage(a.coverAbsPath); + return extensionMatches(img, a.coverExt) && !imageQualityIssues(img).length; } catch { return false; } } +// The captions an article would get if the agent is off or rejected must +// themselves pass the gate. Without this an article whose summary repeats its +// title yields alt === caption, which fails validation after every apply. +export function usableCandidate(a) { + if (!usableCover(a)) return false; + const {alt, caption} = fallbackText(a); + return !textIssues(alt, caption).length; +} + function toCandidate(a) { return { id: a.ref, ref: a.ref, collection: a.collection, year: a.year, slug: a.slug, @@ -35,11 +46,7 @@ function toCandidate(a) { export function collect(now = new Date()) { const current = readCurrent(); - // A fresh candidate must have a non-empty summary: the fallback caption is - // derived from it, and an empty summary would make caption == alt (== title), - // which the validator rejects and which would abort every run. - const usable = a => usableCover(a) && !!(a.summary && a.summary.trim()); - const ranked = rankCandidates(withCover(listArticles()).filter(usable), now); + const ranked = rankCandidates(withCover(listArticles()).filter(usableCandidate), now); const byRef = new Map(ranked.map(a => [a.ref, a])); for (const s of current) { if (s.sourceArticle && !byRef.has(s.sourceArticle)) { diff --git a/scripts/slides/collect-candidates.test.mjs b/scripts/slides/collect-candidates.test.mjs index 86f69041..63ab9505 100644 --- a/scripts/slides/collect-candidates.test.mjs +++ b/scripts/slides/collect-candidates.test.mjs @@ -1,7 +1,16 @@ import {test} from 'node:test'; import assert from 'node:assert/strict'; -import {collect, readCurrent, usableCover} from './collect-candidates.mjs'; +import path from 'node:path'; +import {collect, readCurrent, usableCover, usableCandidate} from './collect-candidates.mjs'; import {resolveArticle} from './frontmatter.mjs'; +import {SLIDES_DIR} from './constants.mjs'; + +const goodCandidate = { + title: 'A perfectly ordinary headline', + summary: 'A summary that says something else entirely.', + coverAbsPath: path.join(SLIDES_DIR, 'nels.png'), + coverExt: 'png', +}; test('collect returns current slides and a ranked candidate pool', () => { const {current, candidates} = collect(new Date(Date.UTC(2026, 6, 15))); @@ -36,6 +45,25 @@ test('a bootstrapped sourceArticle ref is always present in candidates', () => { 'eosc-entrust (a tagged sourceArticle) must be scored and included'); }); +test('usableCandidate accepts a well-formed article', () => { + assert.equal(usableCandidate(goodCandidate), true); +}); + +test('usableCandidate rejects an article whose summary repeats its title', () => { + // The fallback caption is the summary and the fallback alt is the title, so + // an article like this would produce alt === caption and fail the gate. + assert.equal(usableCandidate({...goodCandidate, summary: goodCandidate.title}), false); +}); + +test('usableCandidate rejects a cover whose extension disagrees with its bytes', () => { + assert.equal(usableCandidate({...goodCandidate, coverExt: 'jpg'}), false); +}); + +test('no article in the repo would produce a caption identical to its alt', () => { + const {candidates} = collect(new Date(Date.UTC(2026, 6, 15))); + assert.ok(candidates.every(c => c.title.trim() !== c.summary.trim())); +}); + test('every candidate has a non-empty summary (fallback caption needs it)', () => { const {candidates} = collect(new Date(Date.UTC(2026, 6, 15))); assert.ok(candidates.length > 0); diff --git a/scripts/slides/constants.mjs b/scripts/slides/constants.mjs index 7864a1df..2b1f0961 100644 --- a/scripts/slides/constants.mjs +++ b/scripts/slides/constants.mjs @@ -24,7 +24,12 @@ export const MIN_ASPECT = 0.9; // width/height must be >= this (landscape-ish) export const ILLEGAL_TEXT_RE = /[\x00-\x1f<>`]/; export const SRC_RE = /^\/data\/slides\/[a-z0-9-]+\.(png|jpe?g|webp)$/; -export const BOT_FILE_RE = /^\d{4}-[a-z0-9-]+\.(png|jpe?g|webp)$/; + +// Bot-created images are `--.`. The collection is +// part of the name because a slug is only unique within its collection: news +// and events both hold `2025/elixir-industry-engagement-day`. +export const BOT_FILE_RE = + new RegExp(`^(?:${COLLECTIONS.join('|')})-\\d{4}-[a-z0-9-]+\\.(?:png|jpe?g|webp)$`); // Editorial weighting: matched against lowercased `${title} ${summary} ${tags}`. export const FLAGSHIP_TOPICS = [ diff --git a/scripts/slides/refresh.mjs b/scripts/slides/refresh.mjs index 40126665..1499bb08 100644 --- a/scripts/slides/refresh.mjs +++ b/scripts/slides/refresh.mjs @@ -13,7 +13,12 @@ function setOutput(result) { export async function refresh({diffScope = false} = {}) { const {current, candidates} = collect(new Date()); - const {slides, changed} = selectSlides({current, candidates}); + const {slides, changed, blocked, budget} = selectSlides({current, candidates}); + if (blocked) { + console.error(`Cannot refresh: ${blocked}.`); + return 1; + } + if (budget === 0) console.warn('Every slot is pinned; the bot has nothing to rotate.'); if (!changed) { console.log('No slide changes needed.'); setOutput('noop'); diff --git a/scripts/slides/select.mjs b/scripts/slides/select.mjs index c132f569..2313ad23 100644 --- a/scripts/slides/select.mjs +++ b/scripts/slides/select.mjs @@ -1,6 +1,7 @@ import {MAX_SLIDES, HYSTERESIS_MARGIN, MAX_SWAPS} from './constants.mjs'; -const botFilename = c => `${c.year ?? '0000'}-${c.slug}.${c.coverExt}`; +const botFilename = c => `${c.collection}-${c.year ?? '0000'}-${c.slug}.${c.coverExt}`; +const botSrc = c => `/data/slides/${botFilename(c)}`; // Ownership keys are part of the comparison: a run whose only effect is // stamping an untracked entry `evergreen` must still be reported as changed, // or the tag is never persisted and the entry stays untracked forever. @@ -22,11 +23,20 @@ export function selectSlides({current, candidates}) { const evergreens = current .filter(s => s.evergreen === true || !s.sourceArticle) .map(s => (s.evergreen === true ? s : {...s, evergreen: true})); - const budget = Math.max(0, MAX_SLIDES - evergreens.length); + if (evergreens.length > MAX_SLIDES) + return { + slides: current, changed: false, budget: 0, + blocked: `${evergreens.length} pinned slides exceed the ${MAX_SLIDES} slot limit; unpin one`, + }; + const budget = MAX_SLIDES - evergreens.length; const botIncumbents = current.filter(s => s.sourceArticle && s.evergreen !== true); - const incumbentRefs = new Set(botIncumbents.map(s => s.sourceArticle)); - const fresh = candidates.filter(c => !incumbentRefs.has(c.ref)); + // Every ref already on screen is spoken for, including one held by a pin + // that also carries a sourceArticle. Otherwise the article would be picked + // again and shown twice under two filenames. + const claimedRefs = new Set(current.filter(s => s.sourceArticle).map(s => s.sourceArticle)); + const claimedSrcs = new Set(current.map(s => s.src)); + const fresh = candidates.filter(c => !claimedRefs.has(c.ref) && !claimedSrcs.has(botSrc(c))); const eff = (ref, isInc) => scoreOf(ref) * (isInc ? 1 + HYSTERESIS_MARGIN : 1); const pool = [ @@ -51,15 +61,15 @@ export function selectSlides({current, candidates}) { } // Order: surviving incumbents in current order, then new ones by score. - const chosenRefs = new Set(chosen.map(p => p.ref)); - const survivors = botIncumbents.filter(s => chosenRefs.has(s.sourceArticle)); + // Matched by identity, not by ref: two entries can share a sourceArticle. + const survivors = botIncumbents.filter(s => chosen.some(p => p.entry === s)); const news = chosen .filter(p => !p.isInc) .map(p => ({ - src: `/data/slides/${botFilename(p.cand)}`, + src: botSrc(p.cand), alt: null, caption: null, sourceArticle: p.cand.ref, _candidate: p.cand, })); const slides = [...evergreens, ...survivors, ...news]; - return {slides, changed: !sameSeq(current, slides)}; + return {slides, changed: !sameSeq(current, slides), budget}; } diff --git a/scripts/slides/select.test.mjs b/scripts/slides/select.test.mjs index d39c3f0f..27c578fc 100644 --- a/scripts/slides/select.test.mjs +++ b/scripts/slides/select.test.mjs @@ -72,6 +72,51 @@ test('retains an untracked (CMS-added) current entry and tags it evergreen', () assert.equal(human.evergreen, true, 'untracked entry must be tagged evergreen'); }); +test('an article already pinned by a dual-key entry is not added a second time', () => { + const current = [ + {src: '/data/slides/eosc.png', alt: 'EOSC', caption: 'c', evergreen: true, sourceArticle: 'news/2026/a'}, + ]; + const {slides} = selectSlides({current, candidates: [cand('news/2026/a', 'a', 0.9)]}); + assert.equal(slides.length, 1, 'one article must never occupy two slots'); +}); + +test('filenames stay unique when two collections share a slug and year', () => { + const candidates = [ + cand('news/2025/x', 'x', 0.9, {collection: 'news', year: 2025}), + cand('events/2025/x', 'x', 0.8, {collection: 'events', year: 2025}), + ]; + const {slides} = selectSlides({current: [], candidates}); + assert.equal(new Set(slides.map(s => s.src)).size, 2); +}); + +test('skips a candidate whose generated filename is already claimed by a pin', () => { + const current = [{src: '/data/slides/news-2025-x.png', alt: 'Human pin', caption: 'c', evergreen: true}]; + const {slides} = selectSlides({current, candidates: [cand('news/2025/x', 'x', 0.9, {year: 2025})]}); + assert.equal(slides.length, 1, 'the pin must not be shadowed by a same-named bot slide'); + assert.equal(slides[0].alt, 'Human pin'); +}); + +test('refuses to act when pins alone exceed MAX_SLIDES rather than emitting an invalid set', () => { + const current = [ + ...Array.from({length: 7}, (_, i) => ({src: `/data/slides/p${i}.png`, alt: `P${i}`, caption: 'c', evergreen: true})), + {src: '/data/slides/news-2026-a.png', alt: 'A', caption: 'c', sourceArticle: 'news/2026/a'}, + ]; + const {slides, changed, blocked} = selectSlides({current, candidates: [cand('news/2026/a', 'a', 0.9)]}); + assert.ok(blocked, 'over-pinned state must be reported, not written'); + assert.equal(changed, false, 'must not drop the bot slide or write an over-length set'); + assert.equal(slides.length, current.length); +}); + +test('two incumbents sharing one sourceArticle do not inflate the set past budget', () => { + const pins = [1, 2, 3, 4, 5].map(i => ({src: `/data/slides/p${i}.png`, alt: `P${i}`, caption: 'c', evergreen: true})); + const dupes = [ + {src: '/data/slides/news-2026-a.png', alt: 'A', caption: 'c', sourceArticle: 'news/2026/a'}, + {src: '/data/slides/news-2026-a-copy.png', alt: 'A copy', caption: 'c', sourceArticle: 'news/2026/a'}, + ]; + const {slides} = selectSlides({current: [...pins, ...dupes], candidates: [cand('news/2026/a', 'a', 0.9)]}); + assert.equal(slides.length, 6); +}); + test('stamping an untracked entry counts as a change so the tag is written back', () => { const current = [ {src: '/data/slides/nels.png', alt: 'NeLS', caption: 'c', evergreen: true}, diff --git a/scripts/slides/validate-slides.mjs b/scripts/slides/validate-slides.mjs index ad4edba8..342008f7 100644 --- a/scripts/slides/validate-slides.mjs +++ b/scripts/slides/validate-slides.mjs @@ -9,6 +9,8 @@ import {probeImage} from './image-probe.mjs'; const EXT_FORMAT = {png: 'png', jpg: 'jpeg', jpeg: 'jpeg', webp: 'webp'}; +export const extensionMatches = (img, ext) => EXT_FORMAT[ext] === img.format; + // The acceptance rules live here so producers can check themselves against the // same predicate the gate enforces. `collect-candidates` screens covers with // imageQualityIssues, `caption-agent` screens model output with textIssues; if @@ -55,7 +57,7 @@ export function validateSlides(slides, {slidesDir = SLIDES_DIR} = {}) { try { const img = probeImage(abs); const ext = path.extname(abs).slice(1).toLowerCase(); - if (EXT_FORMAT[ext] !== img.format) v.push(`${at} format ${img.format} != extension .${ext}`); + if (!extensionMatches(img, ext)) v.push(`${at} format ${img.format} != extension .${ext}`); // Quality gates apply only to bot-created images (-.). // Legacy/human pins predate the automation and are grandfathered. if (BOT_FILE_RE.test(path.basename(abs))) diff --git a/scripts/slides/validate-slides.test.mjs b/scripts/slides/validate-slides.test.mjs index 4b8f0b40..c83c11e5 100644 --- a/scripts/slides/validate-slides.test.mjs +++ b/scripts/slides/validate-slides.test.mjs @@ -49,9 +49,9 @@ test('still enforces quality gates on a bot-named image (guard is not a blanket // The 3.37MB image copied under a bot-style name (BOT_FILE_RE matches), // so the size gate must fire even though the same bytes are exempt under // the legacy filename. - fs.copyFileSync(path.join(SLIDES_DIR, 'elixir-no-all-hands-2025.jpg'), path.join(dir, '2025-all-hands.jpg')); + fs.copyFileSync(path.join(SLIDES_DIR, 'elixir-no-all-hands-2025.jpg'), path.join(dir, 'news-2025-all-hands.jpg')); const slide = { - src: '/data/slides/2025-all-hands.jpg', + src: '/data/slides/news-2025-all-hands.jpg', alt: 'A group photo', caption: 'A caption about the meeting.', sourceArticle: 'news/2025/all-hands', From 88c524be431d584127756d7aa2bcf80f7ce50b12 Mon Sep 17 00:00:00 2001 From: Yasin Date: Tue, 28 Jul 2026 14:38:49 +0200 Subject: [PATCH 23/42] fix(slides): stop guessing at ambiguous ownership and duplicated refs An entry carrying both ownership keys was resolved by dropping sourceArticle on write, which unclaimed the ref and let the next run pick the same article up again as fresh, putting one article on two slides with output that validates clean. Neither key can be assumed to be the right one, so the run now stops and names the entry instead. Two incumbents naming one article both survived, indefinitely and silently. Refs and generated filenames are now claimed once, which also stops two candidates in a single run from generating the same filename: the year comes from the frontmatter date rather than the directory, so two entries can agree on collection, slug and year. apply() tracks what it has already written for the same reason. The warning for a fully pinned carousel claimed there was nothing to rotate while the run went on to delete a bot slide and its image, and exited 0. It now distinguishes a purge from a genuine no-op. An article with no summary and a title over the alt limit passed the candidate screen, since clamping made alt and caption differ by an ellipsis. The one bot-managed image left over from bootstrapping was named outside the bot convention, so it could never be pruned once its article rotated out. It is a hand-made composite rather than the article's cover, so it cannot be regenerated; renaming is what puts it under the bot's ownership. Also stops a merge that failed only at branch cleanup from reporting a red job, deletes the pushed branch when PR creation fails, and decouples the two tests that pr-test.yml now runs from specific articles. --- .github/workflows/refresh-highlights.yml | 20 ++++++-- scripts/slides/apply-slides.mjs | 1 + scripts/slides/collect-candidates.mjs | 1 + scripts/slides/collect-candidates.test.mjs | 22 +++++++-- scripts/slides/refresh.mjs | 8 +++- scripts/slides/select.mjs | 43 +++++++++++++----- scripts/slides/select.test.mjs | 39 ++++++++++++++-- src/data/slides.json | 2 +- ...ng => news-2025-eosc-entrust-workshop.png} | Bin 9 files changed, 109 insertions(+), 27 deletions(-) rename src/data/slides/{eosc-entrust.png => news-2025-eosc-entrust-workshop.png} (100%) diff --git a/.github/workflows/refresh-highlights.yml b/.github/workflows/refresh-highlights.yml index 9ad4270d..53c5df01 100644 --- a/.github/workflows/refresh-highlights.yml +++ b/.github/workflows/refresh-highlights.yml @@ -71,9 +71,12 @@ jobs: git add src/data/slides.json src/data/slides git commit -m "chore(slides): refresh homepage highlights" git push origin "$BRANCH" - PR_URL=$(gh pr create --base main --head "$BRANCH" \ + if ! PR_URL=$(gh pr create --base main --head "$BRANCH" \ --title "chore(slides): refresh homepage highlights" \ - --body "Automated highlights refresh. Slide selection and captions were regenerated from recent content; validation and build passed.") + --body "Automated highlights refresh. Slide selection and captions were regenerated from recent content; validation and build passed."); then + git push origin --delete "$BRANCH" || true + exit 1 + fi # The workflow already ran validation + pnpm build as its own gate, so # merge immediately with --admin (deterministic, no waiting on pr-test, # which does not run for GITHUB_TOKEN-created PRs). A failure here is @@ -82,9 +85,16 @@ jobs: # slides.json from this run's base, so merging it days later would # revert everything written in between. if ! gh pr merge "$PR_URL" --squash --admin --delete-branch; then - gh pr close "$PR_URL" --delete-branch \ - --comment "Automated merge failed, closing so the next run starts from a clean base." - exit 1 + # The merge can fail after actually merging, e.g. when only the + # branch deletion is denied. Closing a merged PR would error and + # turn a successful refresh into a red job. + if [ "$(gh pr view "$PR_URL" --json state --jq .state)" = "MERGED" ]; then + echo "Merged; branch cleanup failed. Continuing." + else + gh pr close "$PR_URL" --delete-branch \ + --comment "Automated merge failed, closing so the next run starts from a clean base." + exit 1 + fi fi - name: Report failure diff --git a/scripts/slides/apply-slides.mjs b/scripts/slides/apply-slides.mjs index ac8c59bc..f0b41ac5 100644 --- a/scripts/slides/apply-slides.mjs +++ b/scripts/slides/apply-slides.mjs @@ -24,6 +24,7 @@ export function apply(slides) { const name = path.basename(s.src); if (retained.has(name)) throw new Error(`refusing to overwrite an image already in use: ${name}`); + retained.add(name); fs.copyFileSync(s._candidate.coverAbsPath, path.join(SLIDES_DIR, name)); } } diff --git a/scripts/slides/collect-candidates.mjs b/scripts/slides/collect-candidates.mjs index c21e7055..708308d8 100644 --- a/scripts/slides/collect-candidates.mjs +++ b/scripts/slides/collect-candidates.mjs @@ -30,6 +30,7 @@ export function usableCover(a) { // title yields alt === caption, which fails validation after every apply. export function usableCandidate(a) { if (!usableCover(a)) return false; + if (!a.summary?.trim()) return false; // fallbackText would caption it with the title const {alt, caption} = fallbackText(a); return !textIssues(alt, caption).length; } diff --git a/scripts/slides/collect-candidates.test.mjs b/scripts/slides/collect-candidates.test.mjs index 63ab9505..1828e2a4 100644 --- a/scripts/slides/collect-candidates.test.mjs +++ b/scripts/slides/collect-candidates.test.mjs @@ -27,7 +27,10 @@ test('readCurrent parses slides.json', () => { assert.ok(Array.isArray(readCurrent())); }); -test('usableCover rejects a raw portrait/oversized cover and accepts a good one', () => { +test('usableCover rejects a raw portrait/oversized cover and accepts a good one', { + skip: ['news/2026/elixir-norway-all-hands', 'news/2025/eosc-entrust-workshop'] + .some(ref => !resolveArticle(ref)) && 'fixture articles no longer present', +}, () => { const badArt = resolveArticle('news/2026/elixir-norway-all-hands'); // 3888x5184, 24.9MB const goodArt = resolveArticle('news/2025/eosc-entrust-workshop'); // landscape, small assert.equal(usableCover(badArt), false); @@ -39,10 +42,13 @@ test('collect excludes candidates whose cover fails the quality gates', () => { assert.ok(!candidates.some(c => c.ref === 'news/2026/elixir-norway-all-hands')); }); -test('a bootstrapped sourceArticle ref is always present in candidates', () => { - const {candidates} = collect(new Date(Date.UTC(2026, 6, 15))); - assert.ok(candidates.some(c => c.ref === 'news/2025/eosc-entrust-workshop'), - 'eosc-entrust (a tagged sourceArticle) must be scored and included'); +test('every bot-managed slide on screen is scored and offered back as a candidate', () => { + // Asserted against whatever slides.json holds rather than a fixed ref, so a + // content PR that retires an article cannot fail this on unrelated grounds. + const {current, candidates} = collect(new Date(Date.UTC(2026, 6, 15))); + const refs = candidates.map(c => c.ref); + for (const s of current.filter(s => s.sourceArticle)) + assert.ok(refs.includes(s.sourceArticle), `${s.sourceArticle} must be scored, not silently dropped`); }); test('usableCandidate accepts a well-formed article', () => { @@ -55,6 +61,12 @@ test('usableCandidate rejects an article whose summary repeats its title', () => assert.equal(usableCandidate({...goodCandidate, summary: goodCandidate.title}), false); }); +test('usableCandidate rejects an article with no summary even when its title is long', () => { + // A title over MAX_ALT clamps, so alt and caption differ by the ellipsis and + // the alt-equals-caption rule alone would let this through. + assert.equal(usableCandidate({...goodCandidate, title: 'T'.repeat(200), summary: ''}), false); +}); + test('usableCandidate rejects a cover whose extension disagrees with its bytes', () => { assert.equal(usableCandidate({...goodCandidate, coverExt: 'jpg'}), false); }); diff --git a/scripts/slides/refresh.mjs b/scripts/slides/refresh.mjs index 1499bb08..5a5e0ed4 100644 --- a/scripts/slides/refresh.mjs +++ b/scripts/slides/refresh.mjs @@ -13,12 +13,16 @@ function setOutput(result) { export async function refresh({diffScope = false} = {}) { const {current, candidates} = collect(new Date()); - const {slides, changed, blocked, budget} = selectSlides({current, candidates}); + const {slides, changed, blocked, budget, dropped} = selectSlides({current, candidates}); if (blocked) { console.error(`Cannot refresh: ${blocked}.`); return 1; } - if (budget === 0) console.warn('Every slot is pinned; the bot has nothing to rotate.'); + if (budget === 0) { + console.warn(dropped + ? `Pins fill every slot; dropping ${dropped} bot slide(s) to make room.` + : 'Every slot is pinned; the bot has nothing to rotate.'); + } if (!changed) { console.log('No slide changes needed.'); setOutput('noop'); diff --git a/scripts/slides/select.mjs b/scripts/slides/select.mjs index 2313ad23..875c1163 100644 --- a/scripts/slides/select.mjs +++ b/scripts/slides/select.mjs @@ -20,23 +20,41 @@ export function selectSlides({current, candidates}) { // CMS, which has no ownership key yet) are retained in place. Untracked ones // are stamped `evergreen: true` so they are protected and self-heal their // tag — never dropped. This is the spec's fail-closed rule. + const halt = reason => ({slides: current, changed: false, budget: 0, dropped: 0, blocked: reason}); + + // Which key wins is a guess either way, and guessing defers the problem: + // dropping sourceArticle unclaims the ref, so the next run picks the same + // article up again and shows it twice. + const ambiguous = current.find(s => s.evergreen === true && s.sourceArticle); + if (ambiguous) return halt(`${ambiguous.src} carries both evergreen and sourceArticle; remove one`); + const evergreens = current .filter(s => s.evergreen === true || !s.sourceArticle) .map(s => (s.evergreen === true ? s : {...s, evergreen: true})); if (evergreens.length > MAX_SLIDES) - return { - slides: current, changed: false, budget: 0, - blocked: `${evergreens.length} pinned slides exceed the ${MAX_SLIDES} slot limit; unpin one`, - }; + return halt(`${evergreens.length} pinned slides exceed the ${MAX_SLIDES} slot limit; unpin one`); const budget = MAX_SLIDES - evergreens.length; - const botIncumbents = current.filter(s => s.sourceArticle && s.evergreen !== true); - // Every ref already on screen is spoken for, including one held by a pin - // that also carries a sourceArticle. Otherwise the article would be picked - // again and shown twice under two filenames. - const claimedRefs = new Set(current.filter(s => s.sourceArticle).map(s => s.sourceArticle)); + // One slide per article and one slide per file. A ref or a filename already + // spoken for disqualifies whatever comes next, whether that is a second + // incumbent naming the same article or a candidate whose generated filename + // collides with a pin or with an earlier candidate this same run. + const claimedRefs = new Set(); const claimedSrcs = new Set(current.map(s => s.src)); - const fresh = candidates.filter(c => !claimedRefs.has(c.ref) && !claimedSrcs.has(botSrc(c))); + + const botIncumbents = []; + for (const s of current) { + if (!s.sourceArticle || claimedRefs.has(s.sourceArticle)) continue; + claimedRefs.add(s.sourceArticle); + botIncumbents.push(s); + } + + const fresh = []; + for (const c of candidates) { + if (claimedRefs.has(c.ref) || claimedSrcs.has(botSrc(c))) continue; + claimedSrcs.add(botSrc(c)); + fresh.push(c); + } const eff = (ref, isInc) => scoreOf(ref) * (isInc ? 1 + HYSTERESIS_MARGIN : 1); const pool = [ @@ -71,5 +89,8 @@ export function selectSlides({current, candidates}) { })); const slides = [...evergreens, ...survivors, ...news]; - return {slides, changed: !sameSeq(current, slides), budget}; + return { + slides, changed: !sameSeq(current, slides), budget, + dropped: botIncumbents.length - survivors.length, + }; } diff --git a/scripts/slides/select.test.mjs b/scripts/slides/select.test.mjs index 27c578fc..c9745c29 100644 --- a/scripts/slides/select.test.mjs +++ b/scripts/slides/select.test.mjs @@ -72,12 +72,45 @@ test('retains an untracked (CMS-added) current entry and tags it evergreen', () assert.equal(human.evergreen, true, 'untracked entry must be tagged evergreen'); }); -test('an article already pinned by a dual-key entry is not added a second time', () => { +test('refuses to act on a dual-key entry instead of silently resolving it', () => { + // Stripping the redundant key would only defer the problem: the ref stops + // being claimed, and the next run picks the article up again as fresh. const current = [ {src: '/data/slides/eosc.png', alt: 'EOSC', caption: 'c', evergreen: true, sourceArticle: 'news/2026/a'}, ]; - const {slides} = selectSlides({current, candidates: [cand('news/2026/a', 'a', 0.9)]}); - assert.equal(slides.length, 1, 'one article must never occupy two slots'); + const {slides, changed, blocked} = selectSlides({current, candidates: [cand('news/2026/a', 'a', 0.9)]}); + assert.ok(blocked, 'ambiguous ownership must be reported, not guessed at'); + assert.equal(changed, false); + assert.deepEqual(slides, current); +}); + +test('keeps one slide when two incumbents name the same article', () => { + const dupes = [ + {src: '/data/slides/news-2026-a.png', alt: 'A', caption: 'c', sourceArticle: 'news/2026/a'}, + {src: '/data/slides/news-2026-a-copy.png', alt: 'A copy', caption: 'c', sourceArticle: 'news/2026/a'}, + ]; + const {slides} = selectSlides({current: dupes, candidates: [cand('news/2026/a', 'a', 0.9)]}); + assert.equal(slides.filter(s => s.sourceArticle === 'news/2026/a').length, 1); +}); + +test('two candidates that would generate one filename cannot both be selected', () => { + // Same collection, slug and date-year, different refs: reachable because the + // year comes from the frontmatter date rather than the directory. + const candidates = [ + cand('news/2025/foo', 'foo', 0.9, {year: 2025}), + cand('news/2024/foo', 'foo', 0.8, {year: 2025}), + ]; + const {slides} = selectSlides({current: [], candidates}); + assert.equal(new Set(slides.map(s => s.src)).size, slides.length); + assert.equal(slides.length, 1); +}); + +test('reports bot slides dropped for want of a slot rather than claiming a no-op', () => { + const pins = [1, 2, 3, 4, 5, 6].map(i => ({src: `/data/slides/p${i}.png`, alt: `P${i}`, caption: 'c', evergreen: true})); + const inc = {src: '/data/slides/news-2026-a.png', alt: 'A', caption: 'c', sourceArticle: 'news/2026/a'}; + const {budget, dropped} = selectSlides({current: [...pins, inc], candidates: [cand('news/2026/a', 'a', 0.9)]}); + assert.equal(budget, 0); + assert.equal(dropped, 1, 'the purge must be visible to the caller'); }); test('filenames stay unique when two collections share a slug and year', () => { diff --git a/src/data/slides.json b/src/data/slides.json index f8a6df96..4e24d9ba 100644 --- a/src/data/slides.json +++ b/src/data/slides.json @@ -1,6 +1,6 @@ [ { - "src": "/data/slides/eosc-entrust.png", + "src": "/data/slides/news-2025-eosc-entrust-workshop.png", "alt": "EOSC-ENTRUST", "caption": "Pål Sætrom and Miikka Kallberg co-led the 2nd TRE Evaluation Workshop, bringing together 30 stakeholders to advance the TRE Blueprint and strengthen Trusted Research Environments across Europe. Organizers included Ingeborg Winge, Christine Stansberg, and Stefanie Kirschenmann.", "sourceArticle": "news/2025/eosc-entrust-workshop" diff --git a/src/data/slides/eosc-entrust.png b/src/data/slides/news-2025-eosc-entrust-workshop.png similarity index 100% rename from src/data/slides/eosc-entrust.png rename to src/data/slides/news-2025-eosc-entrust-workshop.png From 3a87e71a97fcee95a9635ef03767c6bb0da456f2 Mon Sep 17 00:00:00 2001 From: Yasin Date: Tue, 28 Jul 2026 14:49:11 +0200 Subject: [PATCH 24/42] fix(slides): pin the curated composite instead of handing it to the bot Renaming eosc-entrust.png into the bot convention was wrong. It is a hand-made 960x540 composite, not the article's cover, and the rename opted it out of the filename protection that keeps human assets safe: the next rotation deleted it outright, and re-entry regenerated the slide from the article's raw group photo, a different image. Tagging it evergreen matches what it actually is, and the orphan the rename was chasing cannot occur for a slide that is never rotated. Two incumbents naming one article were deduped by dropping the later entry, which silently deleted a slide and its image on a guess, and left the purge count understating what went to disk. That is the same unanswerable question as a dual-key entry, so it now halts the same way. The restored summary check tested truthiness on a value that need not be a string; YAML yields a number for an unquoted `summary: 2024`, and the bot runs before the build that would reject it, so it crashed with a bare TypeError. Also retries the branch delete when a merge succeeds but its cleanup does not. --- .github/workflows/refresh-highlights.yml | 3 ++- scripts/slides/collect-candidates.mjs | 5 +++- scripts/slides/collect-candidates.test.mjs | 7 ++++++ scripts/slides/select.mjs | 23 +++++++++--------- scripts/slides/select.test.mjs | 20 ++++++--------- src/data/slides.json | 4 +-- ...-entrust-workshop.png => eosc-entrust.png} | Bin 7 files changed, 33 insertions(+), 29 deletions(-) rename src/data/slides/{news-2025-eosc-entrust-workshop.png => eosc-entrust.png} (100%) diff --git a/.github/workflows/refresh-highlights.yml b/.github/workflows/refresh-highlights.yml index 53c5df01..801c6689 100644 --- a/.github/workflows/refresh-highlights.yml +++ b/.github/workflows/refresh-highlights.yml @@ -89,7 +89,8 @@ jobs: # branch deletion is denied. Closing a merged PR would error and # turn a successful refresh into a red job. if [ "$(gh pr view "$PR_URL" --json state --jq .state)" = "MERGED" ]; then - echo "Merged; branch cleanup failed. Continuing." + echo "Merged; branch cleanup failed. Retrying the delete." + git push origin --delete "$BRANCH" || true else gh pr close "$PR_URL" --delete-branch \ --comment "Automated merge failed, closing so the next run starts from a clean base." diff --git a/scripts/slides/collect-candidates.mjs b/scripts/slides/collect-candidates.mjs index 708308d8..80d209d0 100644 --- a/scripts/slides/collect-candidates.mjs +++ b/scripts/slides/collect-candidates.mjs @@ -30,7 +30,10 @@ export function usableCover(a) { // title yields alt === caption, which fails validation after every apply. export function usableCandidate(a) { if (!usableCover(a)) return false; - if (!a.summary?.trim()) return false; // fallbackText would caption it with the title + // fallbackText would otherwise caption it with the title. Typed rather than + // truthy: YAML yields a number for an unquoted `summary: 2024`, and the bot + // runs before the build that would reject it. + if (typeof a.summary !== 'string' || !a.summary.trim()) return false; const {alt, caption} = fallbackText(a); return !textIssues(alt, caption).length; } diff --git a/scripts/slides/collect-candidates.test.mjs b/scripts/slides/collect-candidates.test.mjs index 1828e2a4..faaed6b5 100644 --- a/scripts/slides/collect-candidates.test.mjs +++ b/scripts/slides/collect-candidates.test.mjs @@ -67,6 +67,13 @@ test('usableCandidate rejects an article with no summary even when its title is assert.equal(usableCandidate({...goodCandidate, title: 'T'.repeat(200), summary: ''}), false); }); +test('usableCandidate rejects a non-string summary instead of throwing', () => { + // YAML turns an unquoted `summary: 2024` into a number. The bot runs before + // the build that would reject it, so it must not crash the pipeline. + for (const summary of [2024, true, ['a'], {a: 1}]) + assert.equal(usableCandidate({...goodCandidate, summary}), false); +}); + test('usableCandidate rejects a cover whose extension disagrees with its bytes', () => { assert.equal(usableCandidate({...goodCandidate, coverExt: 'jpg'}), false); }); diff --git a/scripts/slides/select.mjs b/scripts/slides/select.mjs index 875c1163..2361c391 100644 --- a/scripts/slides/select.mjs +++ b/scripts/slides/select.mjs @@ -35,20 +35,19 @@ export function selectSlides({current, candidates}) { return halt(`${evergreens.length} pinned slides exceed the ${MAX_SLIDES} slot limit; unpin one`); const budget = MAX_SLIDES - evergreens.length; - // One slide per article and one slide per file. A ref or a filename already - // spoken for disqualifies whatever comes next, whether that is a second - // incumbent naming the same article or a candidate whose generated filename - // collides with a pin or with an earlier candidate this same run. - const claimedRefs = new Set(); - const claimedSrcs = new Set(current.map(s => s.src)); - - const botIncumbents = []; - for (const s of current) { - if (!s.sourceArticle || claimedRefs.has(s.sourceArticle)) continue; - claimedRefs.add(s.sourceArticle); - botIncumbents.push(s); + const botIncumbents = current.filter(s => s.sourceArticle); + const claimedRefs = new Set(botIncumbents.map(s => s.sourceArticle)); + // Keeping one of two slides that name the same article means deleting the + // other and its image, on a guess. Same unanswerable question as a dual-key + // entry, so it gets the same answer. + if (claimedRefs.size < botIncumbents.length) { + const dupe = botIncumbents.find((s, i) => botIncumbents.findIndex(o => o.sourceArticle === s.sourceArticle) < i); + return halt(`two slides name ${dupe.sourceArticle}; remove one`); } + // One slide per file: a generated filename already taken by a pin, or by a + // higher-scored candidate this same run, disqualifies the candidate. + const claimedSrcs = new Set(current.map(s => s.src)); const fresh = []; for (const c of candidates) { if (claimedRefs.has(c.ref) || claimedSrcs.has(botSrc(c))) continue; diff --git a/scripts/slides/select.test.mjs b/scripts/slides/select.test.mjs index c9745c29..a51d47c5 100644 --- a/scripts/slides/select.test.mjs +++ b/scripts/slides/select.test.mjs @@ -84,13 +84,17 @@ test('refuses to act on a dual-key entry instead of silently resolving it', () = assert.deepEqual(slides, current); }); -test('keeps one slide when two incumbents name the same article', () => { +test('refuses to act when two incumbents name the same article', () => { + // Which of the two to keep is the same unanswerable question as a dual-key + // entry. Picking one silently deletes the other slide and its image. const dupes = [ {src: '/data/slides/news-2026-a.png', alt: 'A', caption: 'c', sourceArticle: 'news/2026/a'}, {src: '/data/slides/news-2026-a-copy.png', alt: 'A copy', caption: 'c', sourceArticle: 'news/2026/a'}, ]; - const {slides} = selectSlides({current: dupes, candidates: [cand('news/2026/a', 'a', 0.9)]}); - assert.equal(slides.filter(s => s.sourceArticle === 'news/2026/a').length, 1); + const {slides, changed, blocked} = selectSlides({current: dupes, candidates: [cand('news/2026/a', 'a', 0.9)]}); + assert.ok(blocked); + assert.equal(changed, false); + assert.deepEqual(slides, dupes); }); test('two candidates that would generate one filename cannot both be selected', () => { @@ -140,16 +144,6 @@ test('refuses to act when pins alone exceed MAX_SLIDES rather than emitting an i assert.equal(slides.length, current.length); }); -test('two incumbents sharing one sourceArticle do not inflate the set past budget', () => { - const pins = [1, 2, 3, 4, 5].map(i => ({src: `/data/slides/p${i}.png`, alt: `P${i}`, caption: 'c', evergreen: true})); - const dupes = [ - {src: '/data/slides/news-2026-a.png', alt: 'A', caption: 'c', sourceArticle: 'news/2026/a'}, - {src: '/data/slides/news-2026-a-copy.png', alt: 'A copy', caption: 'c', sourceArticle: 'news/2026/a'}, - ]; - const {slides} = selectSlides({current: [...pins, ...dupes], candidates: [cand('news/2026/a', 'a', 0.9)]}); - assert.equal(slides.length, 6); -}); - test('stamping an untracked entry counts as a change so the tag is written back', () => { const current = [ {src: '/data/slides/nels.png', alt: 'NeLS', caption: 'c', evergreen: true}, diff --git a/src/data/slides.json b/src/data/slides.json index 4e24d9ba..ff27a716 100644 --- a/src/data/slides.json +++ b/src/data/slides.json @@ -1,9 +1,9 @@ [ { - "src": "/data/slides/news-2025-eosc-entrust-workshop.png", + "src": "/data/slides/eosc-entrust.png", "alt": "EOSC-ENTRUST", "caption": "Pål Sætrom and Miikka Kallberg co-led the 2nd TRE Evaluation Workshop, bringing together 30 stakeholders to advance the TRE Blueprint and strengthen Trusted Research Environments across Europe. Organizers included Ingeborg Winge, Christine Stansberg, and Stefanie Kirschenmann.", - "sourceArticle": "news/2025/eosc-entrust-workshop" + "evergreen": true }, { "src": "/data/slides/elixir-no-all-hands-2025.jpg", diff --git a/src/data/slides/news-2025-eosc-entrust-workshop.png b/src/data/slides/eosc-entrust.png similarity index 100% rename from src/data/slides/news-2025-eosc-entrust-workshop.png rename to src/data/slides/eosc-entrust.png From d67f74625cbbb894915548c063b2fc0be0e1dd3c Mon Sep 17 00:00:00 2001 From: Yasin Date: Tue, 28 Jul 2026 14:55:44 +0200 Subject: [PATCH 25/42] test(slides): cover the incumbent rescue path and halt on a malformed ref Pinning the composite left slides.json with no bot-managed entry, which made the test that walks them assert nothing and left the incumbent rescue with no coverage at all. That path keeps an on-screen article scored once it drops below the ranked pool, so without it hysteresis has nothing to compare and the slide leaves the moment it stops ranking. Driven from a synthetic current now, which is also what it was really testing. A non-string sourceArticle slipped past the duplicate check, since a Set compares objects by reference, and then died inside the comparator on a missing localeCompare. It is hand-edited-JSON only, but an opaque crash is the one outcome this pipeline should never have. --- scripts/slides/collect-candidates.mjs | 3 +-- scripts/slides/collect-candidates.test.mjs | 18 +++++++++++------- scripts/slides/select.mjs | 3 +++ scripts/slides/select.test.mjs | 7 +++++++ 4 files changed, 22 insertions(+), 9 deletions(-) diff --git a/scripts/slides/collect-candidates.mjs b/scripts/slides/collect-candidates.mjs index 80d209d0..dd94ae6d 100644 --- a/scripts/slides/collect-candidates.mjs +++ b/scripts/slides/collect-candidates.mjs @@ -48,8 +48,7 @@ function toCandidate(a) { }; } -export function collect(now = new Date()) { - const current = readCurrent(); +export function collect(now = new Date(), {current = readCurrent()} = {}) { const ranked = rankCandidates(withCover(listArticles()).filter(usableCandidate), now); const byRef = new Map(ranked.map(a => [a.ref, a])); for (const s of current) { diff --git a/scripts/slides/collect-candidates.test.mjs b/scripts/slides/collect-candidates.test.mjs index faaed6b5..8318be82 100644 --- a/scripts/slides/collect-candidates.test.mjs +++ b/scripts/slides/collect-candidates.test.mjs @@ -42,13 +42,17 @@ test('collect excludes candidates whose cover fails the quality gates', () => { assert.ok(!candidates.some(c => c.ref === 'news/2026/elixir-norway-all-hands')); }); -test('every bot-managed slide on screen is scored and offered back as a candidate', () => { - // Asserted against whatever slides.json holds rather than a fixed ref, so a - // content PR that retires an article cannot fail this on unrelated grounds. - const {current, candidates} = collect(new Date(Date.UTC(2026, 6, 15))); - const refs = candidates.map(c => c.ref); - for (const s of current.filter(s => s.sourceArticle)) - assert.ok(refs.includes(s.sourceArticle), `${s.sourceArticle} must be scored, not silently dropped`); +test('an incumbent that has aged out of the ranked pool is still scored', () => { + // Hysteresis compares an incumbent against its challengers, so an incumbent + // missing from the pool would score 0 and be dropped the moment it left the + // top slots. Driven from a synthetic current: the committed slides.json has + // no bot-managed entry to exercise this with. + const aged = 'news/2018/fair-data-management-in-molecular-life-sciences'; + const current = [{src: '/data/slides/x.png', alt: 'X', caption: 'c', sourceArticle: aged}]; + const {candidates} = collect(new Date(Date.UTC(2026, 6, 15)), {current}); + const rescued = candidates.find(c => c.ref === aged); + assert.ok(rescued, 'an on-screen article must be scored even when it ranks below the pool'); + assert.equal(typeof rescued.score, 'number'); }); test('usableCandidate accepts a well-formed article', () => { diff --git a/scripts/slides/select.mjs b/scripts/slides/select.mjs index 2361c391..fbf2190b 100644 --- a/scripts/slides/select.mjs +++ b/scripts/slides/select.mjs @@ -28,6 +28,9 @@ export function selectSlides({current, candidates}) { const ambiguous = current.find(s => s.evergreen === true && s.sourceArticle); if (ambiguous) return halt(`${ambiguous.src} carries both evergreen and sourceArticle; remove one`); + const malformed = current.find(s => s.sourceArticle && typeof s.sourceArticle !== 'string'); + if (malformed) return halt(`${malformed.src} has a non-string sourceArticle`); + const evergreens = current .filter(s => s.evergreen === true || !s.sourceArticle) .map(s => (s.evergreen === true ? s : {...s, evergreen: true})); diff --git a/scripts/slides/select.test.mjs b/scripts/slides/select.test.mjs index a51d47c5..da2693e4 100644 --- a/scripts/slides/select.test.mjs +++ b/scripts/slides/select.test.mjs @@ -84,6 +84,13 @@ test('refuses to act on a dual-key entry instead of silently resolving it', () = assert.deepEqual(slides, current); }); +test('refuses to act on a sourceArticle that is not a ref string', () => { + const current = [{src: '/data/slides/x.png', alt: 'X', caption: 'c', sourceArticle: {ref: 'news/2026/a'}}]; + const {blocked, changed} = selectSlides({current, candidates: [cand('news/2026/a', 'a', 0.9)]}); + assert.ok(blocked, 'a non-string ref must be named, not crash the comparator'); + assert.equal(changed, false); +}); + test('refuses to act when two incumbents name the same article', () => { // Which of the two to keep is the same unanswerable question as a dual-key // entry. Picking one silently deletes the other slide and its image. From 90d3ce36580ce30856eb94759ce289afca26ad42 Mon Sep 17 00:00:00 2001 From: Yasin Date: Tue, 28 Jul 2026 14:56:42 +0200 Subject: [PATCH 26/42] docs(slides): state the halt-on-ambiguity rule the pipeline now follows --- scripts/slides/README.md | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/scripts/slides/README.md b/scripts/slides/README.md index 529deaf7..e44415fb 100644 --- a/scripts/slides/README.md +++ b/scripts/slides/README.md @@ -20,9 +20,23 @@ Every slide entry carries exactly one signal: on the next run, so the tag shows up in that run's PR diff. Should not occur after bootstrap. -Carrying both keys is a validation error. If one slips in anyway, `evergreen` -wins and the redundant `sourceArticle` is dropped the next time the file is -written. +## The bot never guesses + +It acts only where one reading of the file is possible, and stops otherwise. +Every state below is one a human can author but no bot run can produce, so +stopping costs a rotation and resolving one silently costs a slide: + +- an entry carrying both ownership keys (dropping either one unclaims the + article, and the next run puts it on screen a second time) +- two entries naming the same `sourceArticle` (keeping one deletes the other + and its image) +- a `sourceArticle` that is not a ref string +- more pinned slides than `MAX_SLIDES` + +Each halts the run with the offending `src` named and writes nothing. The +workflow reports it on the `slides-bot` issue, and a human resolves it by +editing `slides.json`. `pnpm slides:validate` catches all of them before a +merge, which is why `pr-test.yml` runs it on every PR. Bot-created image files are named `--.`, and the bot only ever deletes unreferenced files matching that shape (`BOT_FILE_RE`). The @@ -32,8 +46,9 @@ and it keeps bot names clear of CMS uploads, which are slugified from the alt text and so can start with a year. `apply()` additionally refuses to copy over any file a retained slide still points at. -A slide count above `MAX_SLIDES` is unreachable by rotation: if pins alone -exceed the limit the run stops and reports it instead of dropping anything. +Only bot-created images are subject to the width, aspect and size gates. +Anything a human put there predates the automation and is grandfathered, which +is why a pinned image is best left under a name the bot cannot generate. ## CMS interaction From 5301dd04621a0220d33b501f9b334534eac1956c Mon Sep 17 00:00:00 2001 From: Yasin Date: Tue, 28 Jul 2026 14:57:28 +0200 Subject: [PATCH 27/42] fix(slides): make the validator actually cover every state the bot halts on The README claims `slides:validate` catches all four, and pr-test.yml gates every PR on that claim, but a duplicate sourceArticle and a non-string one both passed: the duplicate check only looked at src, and an object is truthy so the ownership check waved it through. A human PR could land either one and the breakage would surface later, inside a bot run, as a halt nobody asked for. --- scripts/slides/validate-slides.mjs | 8 ++++++++ scripts/slides/validate-slides.test.mjs | 14 ++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/scripts/slides/validate-slides.mjs b/scripts/slides/validate-slides.mjs index 342008f7..3bd39581 100644 --- a/scripts/slides/validate-slides.mjs +++ b/scripts/slides/validate-slides.mjs @@ -41,14 +41,22 @@ export function validateSlides(slides, {slidesDir = SLIDES_DIR} = {}) { v.push(`slide count ${slides.length} outside ${MIN_SLIDES}..${MAX_SLIDES}`); const seen = new Set(); + const seenRefs = new Set(); for (const [i, s] of slides.entries()) { const at = `slide[${i}]`; if (!SRC_RE.test(s.src || '')) {v.push(`${at} src invalid: ${s.src}`); continue;} if (seen.has(s.src)) v.push(`${at} duplicate src: ${s.src}`); seen.add(s.src); + // Mirrors what select.mjs halts on, so a human PR cannot land a state + // that would stop the bot on its next run. if (s.evergreen === true && s.sourceArticle) v.push(`${at} has both evergreen and sourceArticle`); else if (!(s.evergreen === true) && !s.sourceArticle) v.push(`${at} untracked (no evergreen/sourceArticle)`); + else if (s.sourceArticle && typeof s.sourceArticle !== 'string') v.push(`${at} sourceArticle is not a string`); + else if (s.sourceArticle) { + if (seenRefs.has(s.sourceArticle)) v.push(`${at} duplicate sourceArticle: ${s.sourceArticle}`); + seenRefs.add(s.sourceArticle); + } for (const issue of textIssues(s.alt, s.caption)) v.push(`${at} ${issue}`); diff --git a/scripts/slides/validate-slides.test.mjs b/scripts/slides/validate-slides.test.mjs index c83c11e5..0ec68aac 100644 --- a/scripts/slides/validate-slides.test.mjs +++ b/scripts/slides/validate-slides.test.mjs @@ -38,6 +38,20 @@ test('grandfathers a large legacy-named evergreen image (quality gates are bot-o assert.deepEqual(validateSlides([bigLegacy], {slidesDir: SLIDES_DIR}), []); }); +test('rejects two slides naming the same article', () => { + const v = validateSlides([ + {...ok, evergreen: undefined, sourceArticle: 'news/2026/a'}, + {...ok, src: '/data/slides/rdm-promotion.png', evergreen: undefined, sourceArticle: 'news/2026/a'}, + ], {slidesDir: SLIDES_DIR}); + assert.ok(v.some(m => /duplicate sourceArticle/i.test(m)), v.join('; ')); +}); + +test('rejects a sourceArticle that is not a ref string', () => { + const v = validateSlides([{...ok, evergreen: undefined, sourceArticle: {ref: 'news/2026/a'}}], + {slidesDir: SLIDES_DIR}); + assert.ok(v.some(m => /sourceArticle/i.test(m)), v.join('; ')); +}); + test('rejects a slide carrying both ownership tags', () => { const v = validateSlides([{...ok, sourceArticle: 'news/2026/x'}], {slidesDir: SLIDES_DIR}); assert.ok(v.some(m => /both/i.test(m)), v.join('; ')); From a955b2adfa859520b4e816377439286d10bae225 Mon Sep 17 00:00:00 2001 From: Yasin Date: Wed, 29 Jul 2026 10:34:57 +0200 Subject: [PATCH 28/42] feat(slides): leave the refresh PR for a human instead of merging it Nobody sees what the carousel becomes until it is live on the homepage. The selection is deterministic but the captions are not, and an editorial mistake on the front page is worth more than the two days a review costs. Dropping the merge means an open PR can now outlive its run, so each run closes the previous one. The branch carries a whole slides.json computed against main as it stood then, and merging two of them can restore a slide the newer run deliberately dropped. --- .github/workflows/refresh-highlights.yml | 56 ++++++++++++++---------- scripts/slides/README.md | 11 ++++- 2 files changed, 42 insertions(+), 25 deletions(-) diff --git a/.github/workflows/refresh-highlights.yml b/.github/workflows/refresh-highlights.yml index 801c6689..dcd45186 100644 --- a/.github/workflows/refresh-highlights.yml +++ b/.github/workflows/refresh-highlights.yml @@ -56,11 +56,14 @@ jobs: env: GITHUB_PAGES: true - - name: Open PR and merge + - name: Open PR id: pr if: steps.refresh.outputs.result == 'changed' && steps.build.outcome == 'success' continue-on-error: true env: + # A PAT makes pr-test.yml run on the PR; PRs opened with the default + # GITHUB_TOKEN get no checks. Validation and pnpm build above already + # gated this branch either way. GH_TOKEN: ${{ secrets.SLIDES_BOT_TOKEN || github.token }} run: | set -euo pipefail @@ -71,32 +74,39 @@ jobs: git add src/data/slides.json src/data/slides git commit -m "chore(slides): refresh homepage highlights" git push origin "$BRANCH" + + # Read before creating, or the new PR supersedes itself. + SUPERSEDED=$(gh pr list --state open --base main --json number,headRefName \ + --jq '.[] | select(.headRefName | startswith("bot/slides-refresh-")) | .number') + + cat > "$RUNNER_TEMP/pr-body.md" <<'EOF' + Automated highlights refresh, open for review. + + Slide selection and captions were regenerated from recent content; + `pnpm slides:validate` and `pnpm build` passed in the workflow run that + opened this. + + Merge it or close it, but do not leave it sitting. The branch holds a + whole `slides.json` written against main as of this run, so the next + run replaces this PR rather than rebasing it. + EOF + if ! PR_URL=$(gh pr create --base main --head "$BRANCH" \ --title "chore(slides): refresh homepage highlights" \ - --body "Automated highlights refresh. Slide selection and captions were regenerated from recent content; validation and build passed."); then + --body-file "$RUNNER_TEMP/pr-body.md"); then git push origin --delete "$BRANCH" || true exit 1 fi - # The workflow already ran validation + pnpm build as its own gate, so - # merge immediately with --admin (deterministic, no waiting on pr-test, - # which does not run for GITHUB_TOKEN-created PRs). A failure here is - # loud: continue-on-error keeps the run going so the issue is opened. - # An unmerged PR must not survive the run. It holds a whole-file - # slides.json from this run's base, so merging it days later would - # revert everything written in between. - if ! gh pr merge "$PR_URL" --squash --admin --delete-branch; then - # The merge can fail after actually merging, e.g. when only the - # branch deletion is denied. Closing a merged PR would error and - # turn a successful refresh into a red job. - if [ "$(gh pr view "$PR_URL" --json state --jq .state)" = "MERGED" ]; then - echo "Merged; branch cleanup failed. Retrying the delete." - git push origin --delete "$BRANCH" || true - else - gh pr close "$PR_URL" --delete-branch \ - --comment "Automated merge failed, closing so the next run starts from a clean base." - exit 1 - fi - fi + + # One open refresh at a time. Each run recomputes the carousel from + # main as it stands now, so an older PR is not a smaller change, it is + # a different answer to the same question; merging both can put back a + # slide the newer run deliberately dropped. Closed PRs keep a Restore + # branch button, so nothing is lost. + for N in $SUPERSEDED; do + gh pr close "$N" --delete-branch \ + --comment "Superseded by ${PR_URL}, computed from the current main." + done - name: Report failure if: steps.refresh.outcome == 'failure' || steps.build.outcome == 'failure' || steps.pr.outcome == 'failure' @@ -114,6 +124,6 @@ jobs: gh issue create --title "$TITLE" --label slides-bot --body "$BODY" fi - - name: Fail job if pipeline, build, or merge failed + - name: Fail job if pipeline, build, or PR failed if: steps.refresh.outcome == 'failure' || steps.build.outcome == 'failure' || steps.pr.outcome == 'failure' run: exit 1 diff --git a/scripts/slides/README.md b/scripts/slides/README.md index e44415fb..e954ddec 100644 --- a/scripts/slides/README.md +++ b/scripts/slides/README.md @@ -65,5 +65,12 @@ and reordering. Ownership keys are preserved end to end; no action required. The GitHub workflow `.github/workflows/refresh-highlights.yml` runs the pipeline on cron (Mon 07:00 UTC, Fri 15:00 UTC) and on manual dispatch, then opens a PR -and auto-merges. On any hard failure it opens/updates one `slides-bot`-labelled -issue instead of merging. +from `bot/slides-refresh-` for a human to merge. On any hard failure it +opens/updates one `slides-bot`-labelled issue and opens no PR. + +Only one refresh PR is open at a time: opening a new one closes any older one. +Each run recomputes the whole carousel against main as it stands then, so an +older PR is a competing answer rather than an earlier instalment, and merging +both can restore a slide the newer run dropped. Review promptly or the work is +thrown away; the closed PR still offers a Restore branch button if you need it +back. From b5b6ccff45748e38e73bfc4c8bb6ffbd2d4ec28a Mon Sep 17 00:00:00 2001 From: Yasin Date: Wed, 29 Jul 2026 10:53:32 +0200 Subject: [PATCH 29/42] fix(slides): load the caption agent's rules into the caption agent opencode reads rules from AGENTS.md or from paths under the instructions key, and this file is slides.AGENTS.md with no instructions key, so the model was running on the one-line prompt in caption-agent.mjs alone. The schema, the length caps and the ban on inventing names never reached it. Nothing failed visibly because every failure in this path degrades to summary-derived captions on purpose, which is also why no test caught it: the unit tests inject a fake runAgent and never spawn the binary. --- scripts/slides/opencode.json | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/slides/opencode.json b/scripts/slides/opencode.json index 1bd9ec47..d7b46519 100644 --- a/scripts/slides/opencode.json +++ b/scripts/slides/opencode.json @@ -1,6 +1,7 @@ { "$schema": "https://opencode.ai/config.json", "model": "{env:SLIDES_AGENT_MODEL}", + "instructions": ["slides.AGENTS.md"], "tools": { "write": false, "edit": false, From f6337556bf8383020d76531160d20539450eb65e Mon Sep 17 00:00:00 2001 From: Yasin Date: Wed, 29 Jul 2026 11:06:30 +0200 Subject: [PATCH 30/42] refactor(slides): collapse the pipeline into one module Twenty .mjs files for 1160 lines meant most of them existed to re-import each other, and the split had started to lie: the shared acceptance rules lived in validate-slides.mjs because that is who enforces them, while collect-candidates and caption-agent imported them to screen their own output. One file, sectioned in dependency order, drops that indirection and the three separate CLI entrypoints with it. slides.js collect|refresh|validate is now the only way in, so package.json, pr-test.yml and the refresh workflow all name the same command. No behaviour change: the same 52 tests pass and slides.json validates unchanged. --- .github/workflows/pr-test.yml | 4 +- .github/workflows/refresh-highlights.yml | 2 +- package.json | 7 +- scripts/slides/README.md | 19 + scripts/slides/apply-slides.mjs | 39 -- scripts/slides/apply-slides.test.mjs | 20 - scripts/slides/caption-agent.mjs | 79 --- scripts/slides/caption-agent.test.mjs | 28 - scripts/slides/collect-candidates.mjs | 65 -- scripts/slides/collect-candidates.test.mjs | 94 --- scripts/slides/constants.mjs | 50 -- scripts/slides/dates.mjs | 21 - scripts/slides/dates.test.mjs | 16 - scripts/slides/frontmatter.mjs | 73 --- scripts/slides/frontmatter.test.mjs | 19 - scripts/slides/image-probe.mjs | 58 -- scripts/slides/image-probe.test.mjs | 22 - scripts/slides/rank.mjs | 66 -- scripts/slides/rank.test.mjs | 24 - scripts/slides/refresh.mjs | 51 -- scripts/slides/select.mjs | 98 --- scripts/slides/select.test.mjs | 163 ----- scripts/slides/slides.js | 721 +++++++++++++++++++++ scripts/slides/slides.test.js | 475 ++++++++++++++ scripts/slides/validate-slides.mjs | 96 --- scripts/slides/validate-slides.test.mjs | 78 --- 26 files changed, 1222 insertions(+), 1166 deletions(-) delete mode 100644 scripts/slides/apply-slides.mjs delete mode 100644 scripts/slides/apply-slides.test.mjs delete mode 100644 scripts/slides/caption-agent.mjs delete mode 100644 scripts/slides/caption-agent.test.mjs delete mode 100644 scripts/slides/collect-candidates.mjs delete mode 100644 scripts/slides/collect-candidates.test.mjs delete mode 100644 scripts/slides/constants.mjs delete mode 100644 scripts/slides/dates.mjs delete mode 100644 scripts/slides/dates.test.mjs delete mode 100644 scripts/slides/frontmatter.mjs delete mode 100644 scripts/slides/frontmatter.test.mjs delete mode 100644 scripts/slides/image-probe.mjs delete mode 100644 scripts/slides/image-probe.test.mjs delete mode 100644 scripts/slides/rank.mjs delete mode 100644 scripts/slides/rank.test.mjs delete mode 100644 scripts/slides/refresh.mjs delete mode 100644 scripts/slides/select.mjs delete mode 100644 scripts/slides/select.test.mjs create mode 100644 scripts/slides/slides.js create mode 100644 scripts/slides/slides.test.js delete mode 100644 scripts/slides/validate-slides.mjs delete mode 100644 scripts/slides/validate-slides.test.mjs diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index dec08389..6f38371c 100644 --- a/.github/workflows/pr-test.yml +++ b/.github/workflows/pr-test.yml @@ -27,10 +27,10 @@ jobs: run: node scripts/check-slugs.mjs - name: Validate homepage slides - run: node scripts/slides/validate-slides.mjs + run: node scripts/slides/slides.js validate - name: Slides pipeline unit tests - run: node --test scripts/slides/*.test.mjs + run: node --test scripts/slides/slides.test.js - name: Build (static output) run: pnpm build diff --git a/.github/workflows/refresh-highlights.yml b/.github/workflows/refresh-highlights.yml index dcd45186..dc23d09e 100644 --- a/.github/workflows/refresh-highlights.yml +++ b/.github/workflows/refresh-highlights.yml @@ -46,7 +46,7 @@ jobs: # pipeline uses summary-derived captions (fully functional). SLIDES_AGENT_MODEL: ${{ vars.SLIDES_AGENT_MODEL }} OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} - run: node scripts/slides/refresh.mjs --diff-scope + run: node scripts/slides/slides.js refresh --diff-scope - name: Build sanity gate id: build diff --git a/package.json b/package.json index 67b4abd8..164ffa10 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,10 @@ "postbuild": "pagefind --site dist", "test:slugs": "node scripts/check-slugs.mjs", "test:pages": "node scripts/test-pages.mjs", - "slides:collect": "node scripts/slides/collect-candidates.mjs", - "slides:refresh": "node scripts/slides/refresh.mjs", - "slides:validate": "node scripts/slides/validate-slides.mjs" + "slides:collect": "node scripts/slides/slides.js collect", + "slides:refresh": "node scripts/slides/slides.js refresh", + "slides:validate": "node scripts/slides/slides.js validate", + "slides:test": "node --test scripts/slides/slides.test.js" }, "dependencies": { "@astrojs/check": "^0.9.0", diff --git a/scripts/slides/README.md b/scripts/slides/README.md index e954ddec..7f9e884a 100644 --- a/scripts/slides/README.md +++ b/scripts/slides/README.md @@ -56,13 +56,32 @@ The `/admin` SlidesEditor seeds its form with `useState({ ...slide })` and edits only `alt`/`caption`/`src`, so `sourceArticle`/`evergreen` survive both editing and reordering. Ownership keys are preserved end to end; no action required. +## Layout + +Four files, and `README.md`: + +- `slides.js`, the whole pipeline. Sections run in dependency order (constants, + article reading, ranking, acceptance rules, collection, selection, captions, + apply, refresh) and everything is a pure function of its arguments except the + clearly marked file writes in `apply` and the `spawnSync` in the caption agent. +- `slides.test.js`, the suite, sectioned to match. +- `slides.AGENTS.md`, the caption agent's rules. Loaded via the `instructions` + key below, not by filename: opencode only auto-discovers a file called + exactly `AGENTS.md`. +- `opencode.json`, model plus a tool allowlist that denies everything. The agent + gets JSON in and returns JSON out; it cannot read, write, or run anything. + ## Operator commands - `pnpm slides:collect`, print the ranked candidate pool + current state (dry). - `pnpm slides:refresh`, run the full pipeline locally (writes files). - `pnpm slides:validate`, run the sanity gate against the working tree. +- `pnpm slides:test`, the unit suite. - `bash scripts/manage-slides.sh`, interactive manual editor (unchanged). +Each maps to `node scripts/slides/slides.js `; `refresh` and `validate` +also take `--diff-scope` to assert nothing outside `src/data/slides*` changed. + The GitHub workflow `.github/workflows/refresh-highlights.yml` runs the pipeline on cron (Mon 07:00 UTC, Fri 15:00 UTC) and on manual dispatch, then opens a PR from `bot/slides-refresh-` for a human to merge. On any hard failure it diff --git a/scripts/slides/apply-slides.mjs b/scripts/slides/apply-slides.mjs deleted file mode 100644 index f0b41ac5..00000000 --- a/scripts/slides/apply-slides.mjs +++ /dev/null @@ -1,39 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import {SLIDES_DIR, SLIDES_JSON, BOT_FILE_RE} from './constants.mjs'; - -export function cleanEntry(s) { - const out = {src: s.src, alt: s.alt, caption: s.caption}; - if (s.evergreen === true) out.evergreen = true; - else if (s.sourceArticle) out.sourceArticle = s.sourceArticle; - return out; -} - -export function referencedBasenames(slides) { - return new Set(slides.map(s => path.basename(s.src))); -} - -export function staleBotFiles(existing, referenced) { - return existing.filter(f => BOT_FILE_RE.test(f) && !referenced.has(f)); -} - -export function apply(slides) { - const retained = new Set(slides.filter(s => !s._candidate).map(s => path.basename(s.src))); - for (const s of slides) { - if (s._candidate) { - const name = path.basename(s.src); - if (retained.has(name)) - throw new Error(`refusing to overwrite an image already in use: ${name}`); - retained.add(name); - fs.copyFileSync(s._candidate.coverAbsPath, path.join(SLIDES_DIR, name)); - } - } - const clean = slides.map(cleanEntry); - const referenced = referencedBasenames(clean); - const existing = fs.readdirSync(SLIDES_DIR); - const deleted = staleBotFiles(existing, referenced); - for (const f of deleted) fs.rmSync(path.join(SLIDES_DIR, f)); - - fs.writeFileSync(SLIDES_JSON, JSON.stringify(clean, null, 4) + '\n'); - return {deleted, slides: clean}; -} diff --git a/scripts/slides/apply-slides.test.mjs b/scripts/slides/apply-slides.test.mjs deleted file mode 100644 index 30fa3a47..00000000 --- a/scripts/slides/apply-slides.test.mjs +++ /dev/null @@ -1,20 +0,0 @@ -import {test} from 'node:test'; -import assert from 'node:assert/strict'; -import {cleanEntry, referencedBasenames, staleBotFiles} from './apply-slides.mjs'; - -test('cleanEntry strips transient fields', () => { - const e = cleanEntry({src: '/data/slides/2026-x.png', alt: 'A', caption: 'C', sourceArticle: 'news/2026/x', _candidate: {}, _new: true}); - assert.deepEqual(e, {src: '/data/slides/2026-x.png', alt: 'A', caption: 'C', sourceArticle: 'news/2026/x'}); -}); - -test('staleBotFiles only targets bot-named unreferenced files', () => { - const referenced = referencedBasenames([{src: '/data/slides/news-2026-keep.png'}, {src: '/data/slides/nels.png'}]); - const existing = ['news-2026-keep.png', 'events-2025-drop.jpeg', 'nels.png', 'rdm-promotion.png']; - assert.deepEqual(staleBotFiles(existing, referenced), ['events-2025-drop.jpeg']); -}); - -test('staleBotFiles spares a CMS upload that merely starts with a year', () => { - // The CMS names uploads by slugifying the alt text, so "2025 All Hands" - // becomes 2025-all-hands.png. That must never look bot-owned. - assert.deepEqual(staleBotFiles(['2025-all-hands.png'], new Set()), []); -}); diff --git a/scripts/slides/caption-agent.mjs b/scripts/slides/caption-agent.mjs deleted file mode 100644 index ea38b14d..00000000 --- a/scripts/slides/caption-agent.mjs +++ /dev/null @@ -1,79 +0,0 @@ -import path from 'node:path'; -import {fileURLToPath} from 'node:url'; -import {spawnSync} from 'node:child_process'; -import {MAX_CAPTION, MAX_ALT} from './constants.mjs'; -import {textIssues} from './validate-slides.mjs'; - -const HERE = path.dirname(fileURLToPath(import.meta.url)); - -export function clamp(str, n) { - const s = String(str ?? '').replace(/\s+/g, ' ').trim(); - return s.length <= n ? s : s.slice(0, n - 1).trimEnd() + '…'; -} - -export function fallbackText(cand) { - const caption = clamp(cand.summary || cand.title, MAX_CAPTION); - return {alt: clamp(cand.title, MAX_ALT), caption}; -} - -export function properNounsOk(text, cand) { - const src = `${cand.title} ${cand.summary}`; - const runs = text.match(/[A-ZÅØÆ][\wÅØÆåøæ.'-]+(?:\s+[A-ZÅØÆ][\wÅØÆåøæ.'-]+)+/g) || []; - return runs.every(r => src.includes(r)); -} - -export function validAgentText(alt, caption, cand) { - if (textIssues(alt, caption).length) return false; - return properNounsOk(caption, cand) && properNounsOk(alt, cand); -} - -export function extractJsonArray(text) { - const t = String(text || '').trim(); - if (!t) return null; - for (const candidate of [t, (t.match(/\[[\s\S]*\]/) || [])[0]]) { - if (!candidate) continue; - try { - const v = JSON.parse(candidate); - if (Array.isArray(v)) return v; - } catch { /* try next */ } - } - return null; -} - -export function defaultRunAgent(inputJson) { - const model = process.env.SLIDES_AGENT_MODEL; - if (!model || process.env.SLIDES_AGENT === 'off') return Promise.resolve(''); - const prompt = `Here is the input. Return only the JSON array.\n${inputJson}`; - const r = spawnSync('opencode', ['run', '--model', model, prompt], - {cwd: HERE, encoding: 'utf8', timeout: 120_000, maxBuffer: 4 << 20}); - return Promise.resolve(r.status === 0 ? (r.stdout || '') : ''); -} - -export async function writeCaptions(slides, {runAgent = defaultRunAgent} = {}) { - const news = slides.filter(s => s._candidate && (s.alt == null || s.caption == null)); - if (!news.length) return slides; - - const input = JSON.stringify({ - slides: news.map(s => ({id: s._candidate.id, title: s._candidate.title, summary: s._candidate.summary})), - }); - - let byId = new Map(); - try { - const arr = extractJsonArray(await runAgent(input)); - if (arr) byId = new Map(arr.map(o => [o.id, o])); - } catch { /* fall back below */ } - - for (const s of news) { - const c = s._candidate; - const a = byId.get(c.id); - if (a && validAgentText(a.alt, a.caption, c)) { - s.alt = a.alt.trim(); - s.caption = a.caption.trim(); - } else { - const fb = fallbackText(c); - s.alt = fb.alt; - s.caption = fb.caption; - } - } - return slides; -} diff --git a/scripts/slides/caption-agent.test.mjs b/scripts/slides/caption-agent.test.mjs deleted file mode 100644 index f97dc5ee..00000000 --- a/scripts/slides/caption-agent.test.mjs +++ /dev/null @@ -1,28 +0,0 @@ -import {test} from 'node:test'; -import assert from 'node:assert/strict'; -import {writeCaptions, fallbackText, properNounsOk} from './caption-agent.mjs'; - -const newSlide = (id, title, summary) => ({ - src: `/data/slides/2026-${id}.png`, alt: null, caption: null, - sourceArticle: `news/2026/${id}`, - _candidate: {id: `news/2026/${id}`, title, summary}, -}); - -test('falls back to summary/title when the agent returns nothing', async () => { - const s = [newSlide('x', 'GDI go-live', 'ELIXIR Norway deploys GDI infrastructure.')]; - const out = await writeCaptions(s, {runAgent: async () => ''}); - assert.equal(out[0].alt, 'GDI go-live'); - assert.equal(out[0].caption, 'ELIXIR Norway deploys GDI infrastructure.'); -}); - -test('uses valid agent text', async () => { - const s = [newSlide('x', 'GDI go-live', 'ELIXIR Norway deploys GDI infrastructure.')]; - const agent = async () => JSON.stringify([{id: 'news/2026/x', alt: 'A network diagram', caption: 'ELIXIR Norway deploys GDI infrastructure across Europe.'}]); - const out = await writeCaptions(s, {runAgent: agent}); - assert.equal(out[0].alt, 'A network diagram'); -}); - -test('rejects hallucinated proper nouns', () => { - assert.equal(properNounsOk('Written by Jane Doe', {title: 'GDI', summary: 'about gdi'}), false); - assert.equal(properNounsOk('About the GDI project', {title: 'GDI project', summary: 'the GDI project'}), true); -}); diff --git a/scripts/slides/collect-candidates.mjs b/scripts/slides/collect-candidates.mjs deleted file mode 100644 index dd94ae6d..00000000 --- a/scripts/slides/collect-candidates.mjs +++ /dev/null @@ -1,65 +0,0 @@ -import fs from 'node:fs'; -import {SLIDES_JSON} from './constants.mjs'; -import {listArticles, resolveArticle, withCover} from './frontmatter.mjs'; -import {rankCandidates, scoreArticle, topicsOf} from './rank.mjs'; -import {probeImage} from './image-probe.mjs'; -import {imageQualityIssues, textIssues, extensionMatches} from './validate-slides.mjs'; -import {fallbackText} from './caption-agent.mjs'; - -export function readCurrent() { - return JSON.parse(fs.readFileSync(SLIDES_JSON, 'utf8')); -} - -// A fresh candidate's cover becomes a bot-created slide image, so it must pass -// the same quality gates the validator enforces on bot images. Filtering here -// keeps selection from ever picking an unusable cover (e.g. a raw portrait phone -// photo), which would otherwise abort every run. Incumbents are unaffected: -// their image was already copied and validated when the slide was created. -export function usableCover(a) { - if (!a.coverAbsPath) return false; - try { - const img = probeImage(a.coverAbsPath); - return extensionMatches(img, a.coverExt) && !imageQualityIssues(img).length; - } catch { - return false; - } -} - -// The captions an article would get if the agent is off or rejected must -// themselves pass the gate. Without this an article whose summary repeats its -// title yields alt === caption, which fails validation after every apply. -export function usableCandidate(a) { - if (!usableCover(a)) return false; - // fallbackText would otherwise caption it with the title. Typed rather than - // truthy: YAML yields a number for an unquoted `summary: 2024`, and the bot - // runs before the build that would reject it. - if (typeof a.summary !== 'string' || !a.summary.trim()) return false; - const {alt, caption} = fallbackText(a); - return !textIssues(alt, caption).length; -} - -function toCandidate(a) { - return { - id: a.ref, ref: a.ref, collection: a.collection, year: a.year, slug: a.slug, - title: a.title, summary: a.summary, - date: a.date ? a.date.toISOString() : null, - coverAbsPath: a.coverAbsPath, coverExt: a.coverExt, - topics: a.topics ?? topicsOf(a), score: a.score, - }; -} - -export function collect(now = new Date(), {current = readCurrent()} = {}) { - const ranked = rankCandidates(withCover(listArticles()).filter(usableCandidate), now); - const byRef = new Map(ranked.map(a => [a.ref, a])); - for (const s of current) { - if (s.sourceArticle && !byRef.has(s.sourceArticle)) { - const a = resolveArticle(s.sourceArticle); - if (a && a.coverAbsPath) byRef.set(a.ref, {...a, score: scoreArticle(a, now), topics: topicsOf(a)}); - } - } - return {current, candidates: [...byRef.values()].map(toCandidate)}; -} - -if (import.meta.url === `file://${process.argv[1]}`) { - process.stdout.write(JSON.stringify(collect(), null, 2) + '\n'); -} diff --git a/scripts/slides/collect-candidates.test.mjs b/scripts/slides/collect-candidates.test.mjs deleted file mode 100644 index 8318be82..00000000 --- a/scripts/slides/collect-candidates.test.mjs +++ /dev/null @@ -1,94 +0,0 @@ -import {test} from 'node:test'; -import assert from 'node:assert/strict'; -import path from 'node:path'; -import {collect, readCurrent, usableCover, usableCandidate} from './collect-candidates.mjs'; -import {resolveArticle} from './frontmatter.mjs'; -import {SLIDES_DIR} from './constants.mjs'; - -const goodCandidate = { - title: 'A perfectly ordinary headline', - summary: 'A summary that says something else entirely.', - coverAbsPath: path.join(SLIDES_DIR, 'nels.png'), - coverExt: 'png', -}; - -test('collect returns current slides and a ranked candidate pool', () => { - const {current, candidates} = collect(new Date(Date.UTC(2026, 6, 15))); - assert.ok(Array.isArray(current) && current.length >= 1); - assert.ok(candidates.length >= 1 && candidates.length <= 12); - for (const c of candidates) { - assert.equal(c.id, c.ref); - assert.ok(c.coverAbsPath, 'candidate has a cover'); - assert.equal(typeof c.score, 'number'); - } -}); - -test('readCurrent parses slides.json', () => { - assert.ok(Array.isArray(readCurrent())); -}); - -test('usableCover rejects a raw portrait/oversized cover and accepts a good one', { - skip: ['news/2026/elixir-norway-all-hands', 'news/2025/eosc-entrust-workshop'] - .some(ref => !resolveArticle(ref)) && 'fixture articles no longer present', -}, () => { - const badArt = resolveArticle('news/2026/elixir-norway-all-hands'); // 3888x5184, 24.9MB - const goodArt = resolveArticle('news/2025/eosc-entrust-workshop'); // landscape, small - assert.equal(usableCover(badArt), false); - assert.equal(usableCover(goodArt), true); -}); - -test('collect excludes candidates whose cover fails the quality gates', () => { - const {candidates} = collect(new Date(Date.UTC(2026, 6, 15))); - assert.ok(!candidates.some(c => c.ref === 'news/2026/elixir-norway-all-hands')); -}); - -test('an incumbent that has aged out of the ranked pool is still scored', () => { - // Hysteresis compares an incumbent against its challengers, so an incumbent - // missing from the pool would score 0 and be dropped the moment it left the - // top slots. Driven from a synthetic current: the committed slides.json has - // no bot-managed entry to exercise this with. - const aged = 'news/2018/fair-data-management-in-molecular-life-sciences'; - const current = [{src: '/data/slides/x.png', alt: 'X', caption: 'c', sourceArticle: aged}]; - const {candidates} = collect(new Date(Date.UTC(2026, 6, 15)), {current}); - const rescued = candidates.find(c => c.ref === aged); - assert.ok(rescued, 'an on-screen article must be scored even when it ranks below the pool'); - assert.equal(typeof rescued.score, 'number'); -}); - -test('usableCandidate accepts a well-formed article', () => { - assert.equal(usableCandidate(goodCandidate), true); -}); - -test('usableCandidate rejects an article whose summary repeats its title', () => { - // The fallback caption is the summary and the fallback alt is the title, so - // an article like this would produce alt === caption and fail the gate. - assert.equal(usableCandidate({...goodCandidate, summary: goodCandidate.title}), false); -}); - -test('usableCandidate rejects an article with no summary even when its title is long', () => { - // A title over MAX_ALT clamps, so alt and caption differ by the ellipsis and - // the alt-equals-caption rule alone would let this through. - assert.equal(usableCandidate({...goodCandidate, title: 'T'.repeat(200), summary: ''}), false); -}); - -test('usableCandidate rejects a non-string summary instead of throwing', () => { - // YAML turns an unquoted `summary: 2024` into a number. The bot runs before - // the build that would reject it, so it must not crash the pipeline. - for (const summary of [2024, true, ['a'], {a: 1}]) - assert.equal(usableCandidate({...goodCandidate, summary}), false); -}); - -test('usableCandidate rejects a cover whose extension disagrees with its bytes', () => { - assert.equal(usableCandidate({...goodCandidate, coverExt: 'jpg'}), false); -}); - -test('no article in the repo would produce a caption identical to its alt', () => { - const {candidates} = collect(new Date(Date.UTC(2026, 6, 15))); - assert.ok(candidates.every(c => c.title.trim() !== c.summary.trim())); -}); - -test('every candidate has a non-empty summary (fallback caption needs it)', () => { - const {candidates} = collect(new Date(Date.UTC(2026, 6, 15))); - assert.ok(candidates.length > 0); - assert.ok(candidates.every(c => c.summary && c.summary.trim()), 'no candidate may have an empty summary'); -}); diff --git a/scripts/slides/constants.mjs b/scripts/slides/constants.mjs deleted file mode 100644 index 2b1f0961..00000000 --- a/scripts/slides/constants.mjs +++ /dev/null @@ -1,50 +0,0 @@ -import path from 'node:path'; -import {fileURLToPath} from 'node:url'; - -const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -export const SLIDES_JSON = path.join(REPO_ROOT, 'src/data/slides.json'); -export const SLIDES_DIR = path.join(REPO_ROOT, 'src/data/slides'); -export const CONTENT_DIR = path.join(REPO_ROOT, 'src/content'); - -export const COLLECTIONS = ['news', 'events', 'funding-and-projects']; - -export const MAX_SLIDES = 6; -export const MIN_SLIDES = 1; -export const CANDIDATE_POOL = 12; -export const HYSTERESIS_MARGIN = 0.15; // fraction of score an incumbent gets as a stay bonus -export const MAX_SWAPS = 2; - -export const MAX_CAPTION = 280; -export const MAX_ALT = 125; -export const MIN_IMG_WIDTH = 800; -export const MAX_IMG_BYTES = 3_000_000; -export const MIN_ASPECT = 0.9; // width/height must be >= this (landscape-ish) - -// Control characters plus the three that break MDX/JSX or shell-quote a caption. -export const ILLEGAL_TEXT_RE = /[\x00-\x1f<>`]/; - -export const SRC_RE = /^\/data\/slides\/[a-z0-9-]+\.(png|jpe?g|webp)$/; - -// Bot-created images are `--.`. The collection is -// part of the name because a slug is only unique within its collection: news -// and events both hold `2025/elixir-industry-engagement-day`. -export const BOT_FILE_RE = - new RegExp(`^(?:${COLLECTIONS.join('|')})-\\d{4}-[a-z0-9-]+\\.(?:png|jpe?g|webp)$`); - -// Editorial weighting: matched against lowercased `${title} ${summary} ${tags}`. -export const FLAGSHIP_TOPICS = [ - {re: /\ball hands\b|all-hands/, weight: 1.0}, - {re: /\bgdi\b|genomic data infrastructure/, weight: 0.9}, - {re: /\bfega\b|federated ega/, weight: 0.9}, - {re: /\beosc\b/, weight: 0.8}, - {re: /1\+ ?million genomes|1\+mg|genome of europe|\bgoe\b/, weight: 0.8}, - {re: /infrastructure|hackathon|workshop/, weight: 0.5}, - {re: /training|course|webinar/, weight: 0.4}, -]; -export const DEMOTE_TOPICS = [ - {re: /scheduled maintenance|maintenance window|downtime/, weight: -1.0}, - {re: /job vacancy|call for|deadline reminder/, weight: -0.4}, -]; - -export const NEWS_HALFLIFE_DAYS = 120; // news/funding recency half-life -export const EVENT_DECAY_DAYS = 21; // events die ~this fast after their date diff --git a/scripts/slides/dates.mjs b/scripts/slides/dates.mjs deleted file mode 100644 index 93e14233..00000000 --- a/scripts/slides/dates.mjs +++ /dev/null @@ -1,21 +0,0 @@ -const MONTHS = { - jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, - jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11, -}; - -// Article dates are free-text English "Month D, YYYY" (full or abbreviated -// month, optional trailing period on the abbreviation). Returns a UTC-midnight -// Date, or null if the string does not match this exact shape. -export function parseArticleDate(str) { - if (typeof str !== 'string') return null; - const m = str.trim().match(/^([A-Za-z]{3,9})\.?\s+(\d{1,2}),?\s+(\d{4})$/); - if (!m) return null; - const month = MONTHS[m[1].slice(0, 3).toLowerCase()]; - if (month === undefined) return null; - const day = Number(m[2]); - const year = Number(m[3]); - if (day < 1 || day > 31) return null; - const d = new Date(Date.UTC(year, month, day)); - if (d.getUTCMonth() !== month || d.getUTCDate() !== day) return null; // reject e.g. Feb 30 - return d; -} diff --git a/scripts/slides/dates.test.mjs b/scripts/slides/dates.test.mjs deleted file mode 100644 index 98cfe2d9..00000000 --- a/scripts/slides/dates.test.mjs +++ /dev/null @@ -1,16 +0,0 @@ -import {test} from 'node:test'; -import assert from 'node:assert/strict'; -import {parseArticleDate} from './dates.mjs'; - -test('parses full and abbreviated English month dates', () => { - assert.equal(parseArticleDate('September 17, 2025').toISOString(), '2025-09-17T00:00:00.000Z'); - assert.equal(parseArticleDate('Apr 16, 2026').toISOString(), '2026-04-16T00:00:00.000Z'); - assert.equal(parseArticleDate('Sept 1, 2024').toISOString(), '2024-09-01T00:00:00.000Z'); -}); - -test('returns null for unparseable input', () => { - assert.equal(parseArticleDate('2025-09-17'), null); - assert.equal(parseArticleDate('someday'), null); - assert.equal(parseArticleDate(''), null); - assert.equal(parseArticleDate(undefined), null); -}); diff --git a/scripts/slides/frontmatter.mjs b/scripts/slides/frontmatter.mjs deleted file mode 100644 index e927c4ab..00000000 --- a/scripts/slides/frontmatter.mjs +++ /dev/null @@ -1,73 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import matter from 'gray-matter'; -import {CONTENT_DIR, COLLECTIONS} from './constants.mjs'; -import {parseArticleDate} from './dates.mjs'; - -function findEntryDirs(root, rel, out) { - const abs = path.join(root, rel); - const entries = fs.readdirSync(abs, {withFileTypes: true}); - if (entries.some(e => e.isFile() && /^index\.mdx?$/i.test(e.name))) { - out.push(rel); - return; - } - for (const e of entries) { - if (e.isDirectory()) findEntryDirs(root, path.join(rel, e.name), out); - } -} - -function readArticle(collection, ref) { - const dir = path.join(CONTENT_DIR, ref); - const file = ['index.mdx', 'index.md'].map(f => path.join(dir, f)).find(fs.existsSync); - if (!file) return null; - const {data} = matter(fs.readFileSync(file, 'utf8')); - const parts = ref.split('/'); - const slug = parts[parts.length - 1]; - const date = parseArticleDate(data.date); - - let coverAbsPath = null, coverExt = null; - if (data.cover?.source) { - const p = path.join(dir, String(data.cover.source).replace(/^\.\//, '')); - if (fs.existsSync(p)) { - coverAbsPath = p; - coverExt = path.extname(p).slice(1).toLowerCase(); - } - } - - return { - ref, collection, slug, - year: date ? date.getUTCFullYear() : (Number(parts[1]) || null), - title: data.title ?? slug, - summary: data.summary ?? '', - tags: Array.isArray(data.tags) ? data.tags : [], - date, coverAbsPath, coverExt, - }; -} - -export function listArticles() { - const out = []; - for (const collection of COLLECTIONS) { - const collRoot = path.join(CONTENT_DIR, collection); - if (!fs.existsSync(collRoot)) continue; - const dirs = []; - for (const child of fs.readdirSync(collRoot, {withFileTypes: true})) { - if (child.isDirectory()) findEntryDirs(CONTENT_DIR, path.join(collection, child.name), dirs); - } - for (const rel of dirs) { - const a = readArticle(collection, rel); - if (a) out.push(a); - } - } - return out; -} - -export function resolveArticle(ref) { - const collection = ref.split('/')[0]; - if (!COLLECTIONS.includes(collection)) return null; - if (!fs.existsSync(path.join(CONTENT_DIR, ref))) return null; - return readArticle(collection, ref); -} - -export function withCover(articles) { - return articles.filter(a => a.coverAbsPath); -} diff --git a/scripts/slides/frontmatter.test.mjs b/scripts/slides/frontmatter.test.mjs deleted file mode 100644 index 3a6fd9f5..00000000 --- a/scripts/slides/frontmatter.test.mjs +++ /dev/null @@ -1,19 +0,0 @@ -import {test} from 'node:test'; -import assert from 'node:assert/strict'; -import {listArticles, resolveArticle, withCover} from './frontmatter.mjs'; - -test('lists real news articles with parsed fields', () => { - const all = listArticles(); - const eosc = resolveArticle('news/2025/eosc-entrust-workshop'); - assert.ok(eosc, 'eosc-entrust-workshop resolves'); - assert.equal(eosc.title, 'EOSC-ENTRUST workshop hosted by ELIXIR Norway'); - assert.equal(eosc.date.getUTCFullYear(), 2025); - assert.ok(eosc.coverAbsPath.endsWith('.jpeg')); - assert.equal(eosc.coverExt, 'jpeg'); - assert.ok(all.length > 20); -}); - -test('withCover drops articles without a cover image', () => { - const covered = withCover(listArticles()); - assert.ok(covered.every(a => a.coverAbsPath)); -}); diff --git a/scripts/slides/image-probe.mjs b/scripts/slides/image-probe.mjs deleted file mode 100644 index 09bacbc5..00000000 --- a/scripts/slides/image-probe.mjs +++ /dev/null @@ -1,58 +0,0 @@ -import fs from 'node:fs'; - -function readPng(buf) { - const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; - if (buf.length < 24 || !sig.every((b, i) => buf[i] === b)) return null; - return {format: 'png', width: buf.readUInt32BE(16), height: buf.readUInt32BE(20)}; -} - -function readJpeg(buf) { - if (buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return null; - let o = 2; - while (o + 9 < buf.length) { - if (buf[o] !== 0xff) return null; - const marker = buf[o + 1]; - if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd9)) {o += 2; continue;} - const len = buf.readUInt16BE(o + 2); - const isSOF = marker >= 0xc0 && marker <= 0xcf && - marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc; - if (isSOF) return {format: 'jpeg', height: buf.readUInt16BE(o + 5), width: buf.readUInt16BE(o + 7)}; - o += 2 + len; - } - return null; -} - -function readWebp(buf) { - if (buf.length < 30 || buf.toString('ascii', 0, 4) !== 'RIFF' || - buf.toString('ascii', 8, 12) !== 'WEBP') return null; - const chunk = buf.toString('ascii', 12, 16); - if (chunk === 'VP8 ') { - return {format: 'webp', width: buf.readUInt16LE(26) & 0x3fff, height: buf.readUInt16LE(28) & 0x3fff}; - } - if (chunk === 'VP8L') { - const b = buf.subarray(21); - return { - format: 'webp', - width: 1 + (((b[1] & 0x3f) << 8) | b[0]), - height: 1 + (((b[3] & 0x0f) << 10) | (b[2] << 2) | ((b[1] & 0xc0) >> 6)), - }; - } - if (chunk === 'VP8X') { - return { - format: 'webp', - width: 1 + (buf[24] | (buf[25] << 8) | (buf[26] << 16)), - height: 1 + (buf[27] | (buf[28] << 8) | (buf[29] << 16)), - }; - } - return null; -} - -// Reads image dimensions from the file header without any native dependency. -// Throws if the file is missing, empty, or not a valid PNG/JPEG/WebP. -export function probeImage(absPath) { - const buf = fs.readFileSync(absPath); - if (buf.length === 0) throw new Error(`empty file: ${absPath}`); - const r = readPng(buf) || readJpeg(buf) || readWebp(buf); - if (!r || !r.width || !r.height) throw new Error(`unrecognized or corrupt image: ${absPath}`); - return {...r, bytes: buf.length}; -} diff --git a/scripts/slides/image-probe.test.mjs b/scripts/slides/image-probe.test.mjs deleted file mode 100644 index 7bfeb14a..00000000 --- a/scripts/slides/image-probe.test.mjs +++ /dev/null @@ -1,22 +0,0 @@ -import {test} from 'node:test'; -import assert from 'node:assert/strict'; -import path from 'node:path'; -import {probeImage} from './image-probe.mjs'; -import {SLIDES_DIR} from './constants.mjs'; - -test('reads PNG dimensions and format', () => { - const r = probeImage(path.join(SLIDES_DIR, 'nels.png')); - assert.equal(r.format, 'png'); - assert.ok(r.width > 100 && r.height > 100); - assert.ok(r.bytes > 0); -}); - -test('reads JPEG dimensions', () => { - const r = probeImage(path.join(SLIDES_DIR, 'elixir-no-all-hands-2025.jpg')); - assert.equal(r.format, 'jpeg'); - assert.ok(r.width > 100 && r.height > 100); -}); - -test('throws on a non-image', () => { - assert.throws(() => probeImage(path.join(SLIDES_DIR, '..', 'slides.json'))); -}); diff --git a/scripts/slides/rank.mjs b/scripts/slides/rank.mjs deleted file mode 100644 index 31e6e96b..00000000 --- a/scripts/slides/rank.mjs +++ /dev/null @@ -1,66 +0,0 @@ -import { - FLAGSHIP_TOPICS, DEMOTE_TOPICS, CANDIDATE_POOL, - NEWS_HALFLIFE_DAYS, EVENT_DECAY_DAYS, -} from './constants.mjs'; - -const DAY = 86_400_000; - -function haystack(a) { - return `${a.title} ${a.summary} ${(a.tags || []).join(' ')}`.toLowerCase(); -} - -export function topicsOf(a) { - const h = haystack(a); - return FLAGSHIP_TOPICS.filter(t => t.re.test(h)).map(t => t.re.source); -} - -function editorial(a) { - const h = haystack(a); - let w = 0; - for (const t of FLAGSHIP_TOPICS) if (t.re.test(h)) w = Math.max(w, t.weight); - for (const t of DEMOTE_TOPICS) if (t.re.test(h)) w += t.weight; - return w; -} - -function recency(a, now) { - if (!a.date) return 0.2; // dateless (e.g. some funding) rely on editorial weight - const ageDays = (now - a.date) / DAY; - if (a.collection === 'events') { - if (ageDays < 0) { - // upcoming: rises as the date approaches, capped - return Math.min(1, 1 - Math.min(1, -ageDays / 90)); - } - return Math.exp(-ageDays / EVENT_DECAY_DAYS); // dies fast after the date - } - if (ageDays < 0) return 1; // future-dated news treated as brand new - return Math.pow(0.5, ageDays / NEWS_HALFLIFE_DAYS); -} - -// Combined score: recency/lifecycle weighted, plus editorial topic weight. -export function scoreArticle(a, now) { - return recency(a, now) + 0.6 * editorial(a); -} - -export function rankCandidates(articles, now) { - const scored = articles - .filter(a => a.coverAbsPath) - .map(a => ({...a, score: scoreArticle(a, now), topics: topicsOf(a)})) - .sort((x, y) => - y.score - x.score || - (y.date?.getTime() || 0) - (x.date?.getTime() || 0) || - x.slug.localeCompare(y.slug)); - - const topicCount = new Map(); - const kept = []; - for (const a of scored) { - const primary = a.topics[0]; - if (primary) { - const n = topicCount.get(primary) || 0; - if (n >= 2) continue; // anti-repeat floor - topicCount.set(primary, n + 1); - } - kept.push(a); - if (kept.length >= CANDIDATE_POOL) break; - } - return kept; -} diff --git a/scripts/slides/rank.test.mjs b/scripts/slides/rank.test.mjs deleted file mode 100644 index e4c45570..00000000 --- a/scripts/slides/rank.test.mjs +++ /dev/null @@ -1,24 +0,0 @@ -import {test} from 'node:test'; -import assert from 'node:assert/strict'; -import {scoreArticle, rankCandidates} from './rank.mjs'; - -const now = new Date(Date.UTC(2026, 6, 15)); -const mk = (o) => ({collection: 'news', slug: o.slug, title: o.title ?? '', summary: '', tags: [], date: o.date, coverAbsPath: '/x.png', ...o}); - -test('recent flagship news outranks an old routine notice', () => { - const flagship = mk({slug: 'gdi-go-live', title: 'GDI infrastructure go-live', date: new Date(Date.UTC(2026, 6, 1))}); - const routine = mk({slug: 'maint', title: 'Scheduled maintenance window', date: new Date(Date.UTC(2026, 6, 10))}); - assert.ok(scoreArticle(flagship, now) > scoreArticle(routine, now)); -}); - -test('a past event decays below a fresh news item', () => { - const pastEvent = mk({collection: 'events', slug: 'old-workshop', title: 'Workshop', date: new Date(Date.UTC(2026, 4, 1))}); - const freshNews = mk({slug: 'news', title: 'Infrastructure update', date: new Date(Date.UTC(2026, 6, 12))}); - assert.ok(scoreArticle(freshNews, now) > scoreArticle(pastEvent, now)); -}); - -test('anti-repeat caps flagship topic at 2', () => { - const arts = [1, 2, 3, 4].map(i => mk({collection: 'events', slug: `all-hands-${i}`, title: 'ELIXIR All Hands', date: new Date(Date.UTC(2026, 6, i))})); - const ranked = rankCandidates(arts, now); - assert.equal(ranked.filter(a => /all hands/.test(a.title.toLowerCase())).length, 2); -}); diff --git a/scripts/slides/refresh.mjs b/scripts/slides/refresh.mjs deleted file mode 100644 index 5a5e0ed4..00000000 --- a/scripts/slides/refresh.mjs +++ /dev/null @@ -1,51 +0,0 @@ -import fs from 'node:fs'; -import {collect} from './collect-candidates.mjs'; -import {selectSlides} from './select.mjs'; -import {writeCaptions} from './caption-agent.mjs'; -import {apply} from './apply-slides.mjs'; -import {validateSlides, diffScopeViolations} from './validate-slides.mjs'; - -function setOutput(result) { - const out = process.env.GITHUB_OUTPUT; - if (out) fs.appendFileSync(out, `result=${result}\n`); - console.log(`result=${result}`); -} - -export async function refresh({diffScope = false} = {}) { - const {current, candidates} = collect(new Date()); - const {slides, changed, blocked, budget, dropped} = selectSlides({current, candidates}); - if (blocked) { - console.error(`Cannot refresh: ${blocked}.`); - return 1; - } - if (budget === 0) { - console.warn(dropped - ? `Pins fill every slot; dropping ${dropped} bot slide(s) to make room.` - : 'Every slot is pinned; the bot has nothing to rotate.'); - } - if (!changed) { - console.log('No slide changes needed.'); - setOutput('noop'); - return 0; - } - - await writeCaptions(slides); - const {deleted, slides: applied} = apply(slides); - - const violations = validateSlides(applied); - if (diffScope) violations.push(...diffScopeViolations()); - if (violations.length) { - console.error('Validation failed after apply:\n' + violations.map(m => ' - ' + m).join('\n')); - return 1; - } - - console.log(`Applied ${applied.length} slides; deleted ${deleted.length} stale file(s).`); - setOutput('changed'); - return 0; -} - -if (import.meta.url === `file://${process.argv[1]}`) { - refresh({diffScope: process.argv.includes('--diff-scope')}) - .then(code => process.exit(code)) - .catch(e => {console.error(e); process.exit(1);}); -} diff --git a/scripts/slides/select.mjs b/scripts/slides/select.mjs deleted file mode 100644 index fbf2190b..00000000 --- a/scripts/slides/select.mjs +++ /dev/null @@ -1,98 +0,0 @@ -import {MAX_SLIDES, HYSTERESIS_MARGIN, MAX_SWAPS} from './constants.mjs'; - -const botFilename = c => `${c.collection}-${c.year ?? '0000'}-${c.slug}.${c.coverExt}`; -const botSrc = c => `/data/slides/${botFilename(c)}`; -// Ownership keys are part of the comparison: a run whose only effect is -// stamping an untracked entry `evergreen` must still be reported as changed, -// or the tag is never persisted and the entry stays untracked forever. -const pick = s => ({ - src: s.src, alt: s.alt ?? null, caption: s.caption ?? null, - evergreen: s.evergreen === true, sourceArticle: s.sourceArticle ?? null, -}); -const sameSeq = (a, b) => - JSON.stringify(a.map(pick)) === JSON.stringify(b.map(pick)); - -export function selectSlides({current, candidates}) { - const byRef = new Map(candidates.map(c => [c.ref, c])); - const scoreOf = ref => byRef.get(ref)?.score ?? 0; - - // Evergreen pins AND untracked entries (e.g. a slide freshly added via the - // CMS, which has no ownership key yet) are retained in place. Untracked ones - // are stamped `evergreen: true` so they are protected and self-heal their - // tag — never dropped. This is the spec's fail-closed rule. - const halt = reason => ({slides: current, changed: false, budget: 0, dropped: 0, blocked: reason}); - - // Which key wins is a guess either way, and guessing defers the problem: - // dropping sourceArticle unclaims the ref, so the next run picks the same - // article up again and shows it twice. - const ambiguous = current.find(s => s.evergreen === true && s.sourceArticle); - if (ambiguous) return halt(`${ambiguous.src} carries both evergreen and sourceArticle; remove one`); - - const malformed = current.find(s => s.sourceArticle && typeof s.sourceArticle !== 'string'); - if (malformed) return halt(`${malformed.src} has a non-string sourceArticle`); - - const evergreens = current - .filter(s => s.evergreen === true || !s.sourceArticle) - .map(s => (s.evergreen === true ? s : {...s, evergreen: true})); - if (evergreens.length > MAX_SLIDES) - return halt(`${evergreens.length} pinned slides exceed the ${MAX_SLIDES} slot limit; unpin one`); - const budget = MAX_SLIDES - evergreens.length; - - const botIncumbents = current.filter(s => s.sourceArticle); - const claimedRefs = new Set(botIncumbents.map(s => s.sourceArticle)); - // Keeping one of two slides that name the same article means deleting the - // other and its image, on a guess. Same unanswerable question as a dual-key - // entry, so it gets the same answer. - if (claimedRefs.size < botIncumbents.length) { - const dupe = botIncumbents.find((s, i) => botIncumbents.findIndex(o => o.sourceArticle === s.sourceArticle) < i); - return halt(`two slides name ${dupe.sourceArticle}; remove one`); - } - - // One slide per file: a generated filename already taken by a pin, or by a - // higher-scored candidate this same run, disqualifies the candidate. - const claimedSrcs = new Set(current.map(s => s.src)); - const fresh = []; - for (const c of candidates) { - if (claimedRefs.has(c.ref) || claimedSrcs.has(botSrc(c))) continue; - claimedSrcs.add(botSrc(c)); - fresh.push(c); - } - - const eff = (ref, isInc) => scoreOf(ref) * (isInc ? 1 + HYSTERESIS_MARGIN : 1); - const pool = [ - ...botIncumbents.map(s => ({ref: s.sourceArticle, isInc: true, entry: s})), - ...fresh.map(c => ({ref: c.ref, isInc: false, cand: c})), - ].sort((x, y) => - eff(y.ref, y.isInc) - eff(x.ref, x.isInc) || - (y.isInc === x.isInc ? 0 : y.isInc ? 1 : -1) || - x.ref.localeCompare(y.ref)); - - let chosen = pool.slice(0, budget); - - // Swap cap: at most MAX_SWAPS fresh refs enter per run; backfill from - // remaining incumbents if we blocked some. - const freshChosen = chosen.filter(p => !p.isInc); - if (freshChosen.length > MAX_SWAPS) { - const allowed = new Set(freshChosen.slice(0, MAX_SWAPS).map(p => p.ref)); - chosen = chosen.filter(p => p.isInc || allowed.has(p.ref)); - const spare = pool.filter(p => p.isInc && !chosen.includes(p)); - while (chosen.length < budget && spare.length) chosen.push(spare.shift()); - chosen = chosen.slice(0, budget); - } - - // Order: surviving incumbents in current order, then new ones by score. - // Matched by identity, not by ref: two entries can share a sourceArticle. - const survivors = botIncumbents.filter(s => chosen.some(p => p.entry === s)); - const news = chosen - .filter(p => !p.isInc) - .map(p => ({ - src: botSrc(p.cand), - alt: null, caption: null, sourceArticle: p.cand.ref, _candidate: p.cand, - })); - - const slides = [...evergreens, ...survivors, ...news]; - return { - slides, changed: !sameSeq(current, slides), budget, - dropped: botIncumbents.length - survivors.length, - }; -} diff --git a/scripts/slides/select.test.mjs b/scripts/slides/select.test.mjs deleted file mode 100644 index da2693e4..00000000 --- a/scripts/slides/select.test.mjs +++ /dev/null @@ -1,163 +0,0 @@ -import {test} from 'node:test'; -import assert from 'node:assert/strict'; -import {selectSlides} from './select.mjs'; - -const cand = (ref, slug, score, over = {}) => ({ - id: ref, ref, collection: 'news', year: 2026, slug, - title: slug, summary: 's', date: '2026-07-01T00:00:00.000Z', - coverAbsPath: `/x/${slug}.png`, coverExt: 'png', topics: [], score, ...over, -}); - -test('no-op when only evergreens and budget is full', () => { - const current = [ - {src: '/data/slides/nels.png', alt: 'NeLS', caption: 'c', evergreen: true}, - {src: '/data/slides/rdm.png', alt: 'RDM', caption: 'c', evergreen: true}, - ]; - const {slides, changed} = selectSlides({current, candidates: [cand('news/2026/x', 'x', 0.1)]}); - assert.equal(changed, true); // one free slot gets filled - assert.equal(slides[0].evergreen, true); -}); - -test('caps fresh additions at MAX_SWAPS (2)', () => { - const current = []; - const candidates = ['a', 'b', 'c', 'd'].map((s, i) => cand(`news/2026/${s}`, s, 1 - i * 0.1)); - const {slides} = selectSlides({current, candidates}); - assert.equal(slides.filter(s => s._candidate).length, 2); -}); - -test('unchanged selection reports changed=false', () => { - const current = [{src: '/data/slides/2026-a.png', alt: 'A', caption: 'c', sourceArticle: 'news/2026/a'}]; - const candidates = [cand('news/2026/a', 'a', 0.9)]; - const {changed} = selectSlides({current, candidates}); - assert.equal(changed, false); -}); - -test('incumbent keeps its slot unless a challenger beats it by the hysteresis margin', () => { - const evergreens = ['a', 'b', 'c', 'd', 'e'].map(s => ({src: `/data/slides/${s}.png`, alt: s.toUpperCase(), caption: 'c', evergreen: true})); - const incumbent = {src: '/data/slides/2026-inc.png', alt: 'Inc', caption: 'c', sourceArticle: 'news/2026/inc'}; - // budget = 6 - 5 evergreens = 1 bot slot. incumbent eff = 0.5 * 1.15 = 0.575. - const near = selectSlides({current: [...evergreens, incumbent], candidates: [cand('news/2026/inc', 'inc', 0.5), cand('news/2026/new', 'new', 0.55)]}); - assert.equal(near.slides.at(-1).sourceArticle, 'news/2026/inc'); // 0.55 < 0.575 -> incumbent stays - const beats = selectSlides({current: [...evergreens, incumbent], candidates: [cand('news/2026/inc', 'inc', 0.5), cand('news/2026/new', 'new', 0.58)]}); - assert.equal(beats.slides.at(-1).sourceArticle, 'news/2026/new'); // 0.58 > 0.575 -> challenger wins -}); - -test('swap cap admits the top 2 fresh and backfills freed slots from displaced incumbents', () => { - const incs = [1, 2, 3, 4, 5].map(i => ({src: `/data/slides/2026-i${i}.png`, alt: `I${i}`, caption: 'c', sourceArticle: `news/2026/i${i}`})); - const incCands = [1, 2, 3, 4, 5].map(i => cand(`news/2026/i${i}`, `i${i}`, 0.5 - i * 0.01)); // i1 highest .49 .. i5 .45 - const fresh = ['a', 'b', 'c', 'd', 'e'].map((s, i) => cand(`news/2026/${s}`, s, 0.9 - i * 0.05)); // a .9 .. e .7 (all outrank incumbents) - const {slides} = selectSlides({current: incs, candidates: [...incCands, ...fresh]}); - const news = slides.filter(s => s._candidate).map(s => s.sourceArticle).sort(); - assert.deepEqual(news, ['news/2026/a', 'news/2026/b']); // only top-2 fresh admitted - const survivors = slides.filter(s => s.sourceArticle && !s._candidate).map(s => s.sourceArticle); - assert.equal(survivors.length, 4); // 4 slots backfilled from incumbents - assert.ok(!survivors.includes('news/2026/i5')); // lowest-scored incumbent dropped -}); - -test('the swap cap keeps the two highest-scored fresh, not any two', () => { - const candidates = ['a', 'b', 'c', 'd'].map((s, i) => cand(`news/2026/${s}`, s, 1 - i * 0.1)); // a highest - const {slides} = selectSlides({current: [], candidates}); - const news = slides.filter(s => s._candidate).map(s => s.sourceArticle).sort(); - assert.deepEqual(news, ['news/2026/a', 'news/2026/b']); // top two by score, not c/d -}); - -test('retains an untracked (CMS-added) current entry and tags it evergreen', () => { - const current = [ - {src: '/data/slides/nels.png', alt: 'NeLS', caption: 'c', evergreen: true}, - {src: '/data/slides/human-added.png', alt: 'Human highlight', caption: 'Added via CMS'}, - ]; - const {slides} = selectSlides({current, candidates: []}); - const human = slides.find(s => s.src === '/data/slides/human-added.png'); - assert.ok(human, 'untracked entry must survive'); - assert.equal(human.evergreen, true, 'untracked entry must be tagged evergreen'); -}); - -test('refuses to act on a dual-key entry instead of silently resolving it', () => { - // Stripping the redundant key would only defer the problem: the ref stops - // being claimed, and the next run picks the article up again as fresh. - const current = [ - {src: '/data/slides/eosc.png', alt: 'EOSC', caption: 'c', evergreen: true, sourceArticle: 'news/2026/a'}, - ]; - const {slides, changed, blocked} = selectSlides({current, candidates: [cand('news/2026/a', 'a', 0.9)]}); - assert.ok(blocked, 'ambiguous ownership must be reported, not guessed at'); - assert.equal(changed, false); - assert.deepEqual(slides, current); -}); - -test('refuses to act on a sourceArticle that is not a ref string', () => { - const current = [{src: '/data/slides/x.png', alt: 'X', caption: 'c', sourceArticle: {ref: 'news/2026/a'}}]; - const {blocked, changed} = selectSlides({current, candidates: [cand('news/2026/a', 'a', 0.9)]}); - assert.ok(blocked, 'a non-string ref must be named, not crash the comparator'); - assert.equal(changed, false); -}); - -test('refuses to act when two incumbents name the same article', () => { - // Which of the two to keep is the same unanswerable question as a dual-key - // entry. Picking one silently deletes the other slide and its image. - const dupes = [ - {src: '/data/slides/news-2026-a.png', alt: 'A', caption: 'c', sourceArticle: 'news/2026/a'}, - {src: '/data/slides/news-2026-a-copy.png', alt: 'A copy', caption: 'c', sourceArticle: 'news/2026/a'}, - ]; - const {slides, changed, blocked} = selectSlides({current: dupes, candidates: [cand('news/2026/a', 'a', 0.9)]}); - assert.ok(blocked); - assert.equal(changed, false); - assert.deepEqual(slides, dupes); -}); - -test('two candidates that would generate one filename cannot both be selected', () => { - // Same collection, slug and date-year, different refs: reachable because the - // year comes from the frontmatter date rather than the directory. - const candidates = [ - cand('news/2025/foo', 'foo', 0.9, {year: 2025}), - cand('news/2024/foo', 'foo', 0.8, {year: 2025}), - ]; - const {slides} = selectSlides({current: [], candidates}); - assert.equal(new Set(slides.map(s => s.src)).size, slides.length); - assert.equal(slides.length, 1); -}); - -test('reports bot slides dropped for want of a slot rather than claiming a no-op', () => { - const pins = [1, 2, 3, 4, 5, 6].map(i => ({src: `/data/slides/p${i}.png`, alt: `P${i}`, caption: 'c', evergreen: true})); - const inc = {src: '/data/slides/news-2026-a.png', alt: 'A', caption: 'c', sourceArticle: 'news/2026/a'}; - const {budget, dropped} = selectSlides({current: [...pins, inc], candidates: [cand('news/2026/a', 'a', 0.9)]}); - assert.equal(budget, 0); - assert.equal(dropped, 1, 'the purge must be visible to the caller'); -}); - -test('filenames stay unique when two collections share a slug and year', () => { - const candidates = [ - cand('news/2025/x', 'x', 0.9, {collection: 'news', year: 2025}), - cand('events/2025/x', 'x', 0.8, {collection: 'events', year: 2025}), - ]; - const {slides} = selectSlides({current: [], candidates}); - assert.equal(new Set(slides.map(s => s.src)).size, 2); -}); - -test('skips a candidate whose generated filename is already claimed by a pin', () => { - const current = [{src: '/data/slides/news-2025-x.png', alt: 'Human pin', caption: 'c', evergreen: true}]; - const {slides} = selectSlides({current, candidates: [cand('news/2025/x', 'x', 0.9, {year: 2025})]}); - assert.equal(slides.length, 1, 'the pin must not be shadowed by a same-named bot slide'); - assert.equal(slides[0].alt, 'Human pin'); -}); - -test('refuses to act when pins alone exceed MAX_SLIDES rather than emitting an invalid set', () => { - const current = [ - ...Array.from({length: 7}, (_, i) => ({src: `/data/slides/p${i}.png`, alt: `P${i}`, caption: 'c', evergreen: true})), - {src: '/data/slides/news-2026-a.png', alt: 'A', caption: 'c', sourceArticle: 'news/2026/a'}, - ]; - const {slides, changed, blocked} = selectSlides({current, candidates: [cand('news/2026/a', 'a', 0.9)]}); - assert.ok(blocked, 'over-pinned state must be reported, not written'); - assert.equal(changed, false, 'must not drop the bot slide or write an over-length set'); - assert.equal(slides.length, current.length); -}); - -test('stamping an untracked entry counts as a change so the tag is written back', () => { - const current = [ - {src: '/data/slides/nels.png', alt: 'NeLS', caption: 'c', evergreen: true}, - {src: '/data/slides/human-added.png', alt: 'Human highlight', caption: 'Added via CMS'}, - ]; - // src/alt/caption are all identical to current; only the new evergreen tag - // differs. Reporting no-op here would strand the entry untracked forever. - const {changed} = selectSlides({current, candidates: []}); - assert.equal(changed, true); -}); diff --git a/scripts/slides/slides.js b/scripts/slides/slides.js new file mode 100644 index 00000000..9965f038 --- /dev/null +++ b/scripts/slides/slides.js @@ -0,0 +1,721 @@ +// The slides pipeline, whole. Sections run in dependency order: constants, +// article reading, ranking, the acceptance rules every producer checks itself +// against, candidate collection, selection, captions, apply, and the refresh +// that drives them. `slides.js ` is the only entry. +import fs from 'node:fs'; +import path from 'node:path'; +import {execFileSync, spawnSync} from 'node:child_process'; +import {fileURLToPath} from 'node:url'; +import matter from 'gray-matter'; + +// ======================================================================== +// Constants +// ======================================================================== + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +export const SLIDES_JSON = path.join(REPO_ROOT, 'src/data/slides.json'); +export const SLIDES_DIR = path.join(REPO_ROOT, 'src/data/slides'); +export const CONTENT_DIR = path.join(REPO_ROOT, 'src/content'); + +export const COLLECTIONS = ['news', 'events', 'funding-and-projects']; + +export const MAX_SLIDES = 6; +export const MIN_SLIDES = 1; +export const CANDIDATE_POOL = 12; +export const HYSTERESIS_MARGIN = 0.15; // fraction of score an incumbent gets as a stay bonus +export const MAX_SWAPS = 2; + +export const MAX_CAPTION = 280; +export const MAX_ALT = 125; +export const MIN_IMG_WIDTH = 800; +export const MAX_IMG_BYTES = 3_000_000; +export const MIN_ASPECT = 0.9; // width/height must be >= this (landscape-ish) + +// Control characters plus the three that break MDX/JSX or shell-quote a caption. +export const ILLEGAL_TEXT_RE = /[\x00-\x1f<>`]/; + +export const SRC_RE = /^\/data\/slides\/[a-z0-9-]+\.(png|jpe?g|webp)$/; + +// Bot-created images are `--.`. The collection is +// part of the name because a slug is only unique within its collection: news +// and events both hold `2025/elixir-industry-engagement-day`. +export const BOT_FILE_RE = + new RegExp(`^(?:${COLLECTIONS.join('|')})-\\d{4}-[a-z0-9-]+\\.(?:png|jpe?g|webp)$`); + +// Editorial weighting: matched against lowercased `${title} ${summary} ${tags}`. +export const FLAGSHIP_TOPICS = [ + {re: /\ball hands\b|all-hands/, weight: 1.0}, + {re: /\bgdi\b|genomic data infrastructure/, weight: 0.9}, + {re: /\bfega\b|federated ega/, weight: 0.9}, + {re: /\beosc\b/, weight: 0.8}, + {re: /1\+ ?million genomes|1\+mg|genome of europe|\bgoe\b/, weight: 0.8}, + {re: /infrastructure|hackathon|workshop/, weight: 0.5}, + {re: /training|course|webinar/, weight: 0.4}, +]; +export const DEMOTE_TOPICS = [ + {re: /scheduled maintenance|maintenance window|downtime/, weight: -1.0}, + {re: /job vacancy|call for|deadline reminder/, weight: -0.4}, +]; + +export const NEWS_HALFLIFE_DAYS = 120; // news/funding recency half-life +export const EVENT_DECAY_DAYS = 21; // events die ~this fast after their date + +// ======================================================================== +// Dates +// ======================================================================== + +const MONTHS = { + jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, + jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11, +}; + +// Article dates are free-text English "Month D, YYYY" (full or abbreviated +// month, optional trailing period on the abbreviation). Returns a UTC-midnight +// Date, or null if the string does not match this exact shape. +export function parseArticleDate(str) { + if (typeof str !== 'string') return null; + const m = str.trim().match(/^([A-Za-z]{3,9})\.?\s+(\d{1,2}),?\s+(\d{4})$/); + if (!m) return null; + const month = MONTHS[m[1].slice(0, 3).toLowerCase()]; + if (month === undefined) return null; + const day = Number(m[2]); + const year = Number(m[3]); + if (day < 1 || day > 31) return null; + const d = new Date(Date.UTC(year, month, day)); + if (d.getUTCMonth() !== month || d.getUTCDate() !== day) return null; // reject e.g. Feb 30 + return d; +} + +// ======================================================================== +// Image probe +// ======================================================================== + +function readPng(buf) { + const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + if (buf.length < 24 || !sig.every((b, i) => buf[i] === b)) return null; + return {format: 'png', width: buf.readUInt32BE(16), height: buf.readUInt32BE(20)}; +} + +function readJpeg(buf) { + if (buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return null; + let o = 2; + while (o + 9 < buf.length) { + if (buf[o] !== 0xff) return null; + const marker = buf[o + 1]; + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd9)) {o += 2; continue;} + const len = buf.readUInt16BE(o + 2); + const isSOF = marker >= 0xc0 && marker <= 0xcf && + marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc; + if (isSOF) return {format: 'jpeg', height: buf.readUInt16BE(o + 5), width: buf.readUInt16BE(o + 7)}; + o += 2 + len; + } + return null; +} + +function readWebp(buf) { + if (buf.length < 30 || buf.toString('ascii', 0, 4) !== 'RIFF' || + buf.toString('ascii', 8, 12) !== 'WEBP') return null; + const chunk = buf.toString('ascii', 12, 16); + if (chunk === 'VP8 ') { + return {format: 'webp', width: buf.readUInt16LE(26) & 0x3fff, height: buf.readUInt16LE(28) & 0x3fff}; + } + if (chunk === 'VP8L') { + const b = buf.subarray(21); + return { + format: 'webp', + width: 1 + (((b[1] & 0x3f) << 8) | b[0]), + height: 1 + (((b[3] & 0x0f) << 10) | (b[2] << 2) | ((b[1] & 0xc0) >> 6)), + }; + } + if (chunk === 'VP8X') { + return { + format: 'webp', + width: 1 + (buf[24] | (buf[25] << 8) | (buf[26] << 16)), + height: 1 + (buf[27] | (buf[28] << 8) | (buf[29] << 16)), + }; + } + return null; +} + +// Reads image dimensions from the file header without any native dependency. +// Throws if the file is missing, empty, or not a valid PNG/JPEG/WebP. +export function probeImage(absPath) { + const buf = fs.readFileSync(absPath); + if (buf.length === 0) throw new Error(`empty file: ${absPath}`); + const r = readPng(buf) || readJpeg(buf) || readWebp(buf); + if (!r || !r.width || !r.height) throw new Error(`unrecognized or corrupt image: ${absPath}`); + return {...r, bytes: buf.length}; +} + +// ======================================================================== +// Frontmatter +// ======================================================================== + +function findEntryDirs(root, rel, out) { + const abs = path.join(root, rel); + const entries = fs.readdirSync(abs, {withFileTypes: true}); + if (entries.some(e => e.isFile() && /^index\.mdx?$/i.test(e.name))) { + out.push(rel); + return; + } + for (const e of entries) { + if (e.isDirectory()) findEntryDirs(root, path.join(rel, e.name), out); + } +} + +function readArticle(collection, ref) { + const dir = path.join(CONTENT_DIR, ref); + const file = ['index.mdx', 'index.md'].map(f => path.join(dir, f)).find(fs.existsSync); + if (!file) return null; + const {data} = matter(fs.readFileSync(file, 'utf8')); + const parts = ref.split('/'); + const slug = parts[parts.length - 1]; + const date = parseArticleDate(data.date); + + let coverAbsPath = null, coverExt = null; + if (data.cover?.source) { + const p = path.join(dir, String(data.cover.source).replace(/^\.\//, '')); + if (fs.existsSync(p)) { + coverAbsPath = p; + coverExt = path.extname(p).slice(1).toLowerCase(); + } + } + + return { + ref, collection, slug, + year: date ? date.getUTCFullYear() : (Number(parts[1]) || null), + title: data.title ?? slug, + summary: data.summary ?? '', + tags: Array.isArray(data.tags) ? data.tags : [], + date, coverAbsPath, coverExt, + }; +} + +export function listArticles() { + const out = []; + for (const collection of COLLECTIONS) { + const collRoot = path.join(CONTENT_DIR, collection); + if (!fs.existsSync(collRoot)) continue; + const dirs = []; + for (const child of fs.readdirSync(collRoot, {withFileTypes: true})) { + if (child.isDirectory()) findEntryDirs(CONTENT_DIR, path.join(collection, child.name), dirs); + } + for (const rel of dirs) { + const a = readArticle(collection, rel); + if (a) out.push(a); + } + } + return out; +} + +export function resolveArticle(ref) { + const collection = ref.split('/')[0]; + if (!COLLECTIONS.includes(collection)) return null; + if (!fs.existsSync(path.join(CONTENT_DIR, ref))) return null; + return readArticle(collection, ref); +} + +export function withCover(articles) { + return articles.filter(a => a.coverAbsPath); +} + +// ======================================================================== +// Ranking +// ======================================================================== + +const DAY = 86_400_000; + +function haystack(a) { + return `${a.title} ${a.summary} ${(a.tags || []).join(' ')}`.toLowerCase(); +} + +export function topicsOf(a) { + const h = haystack(a); + return FLAGSHIP_TOPICS.filter(t => t.re.test(h)).map(t => t.re.source); +} + +function editorial(a) { + const h = haystack(a); + let w = 0; + for (const t of FLAGSHIP_TOPICS) if (t.re.test(h)) w = Math.max(w, t.weight); + for (const t of DEMOTE_TOPICS) if (t.re.test(h)) w += t.weight; + return w; +} + +function recency(a, now) { + if (!a.date) return 0.2; // dateless (e.g. some funding) rely on editorial weight + const ageDays = (now - a.date) / DAY; + if (a.collection === 'events') { + if (ageDays < 0) { + // upcoming: rises as the date approaches, capped + return Math.min(1, 1 - Math.min(1, -ageDays / 90)); + } + return Math.exp(-ageDays / EVENT_DECAY_DAYS); // dies fast after the date + } + if (ageDays < 0) return 1; // future-dated news treated as brand new + return Math.pow(0.5, ageDays / NEWS_HALFLIFE_DAYS); +} + +// Combined score: recency/lifecycle weighted, plus editorial topic weight. +export function scoreArticle(a, now) { + return recency(a, now) + 0.6 * editorial(a); +} + +export function rankCandidates(articles, now) { + const scored = articles + .filter(a => a.coverAbsPath) + .map(a => ({...a, score: scoreArticle(a, now), topics: topicsOf(a)})) + .sort((x, y) => + y.score - x.score || + (y.date?.getTime() || 0) - (x.date?.getTime() || 0) || + x.slug.localeCompare(y.slug)); + + const topicCount = new Map(); + const kept = []; + for (const a of scored) { + const primary = a.topics[0]; + if (primary) { + const n = topicCount.get(primary) || 0; + if (n >= 2) continue; // anti-repeat floor + topicCount.set(primary, n + 1); + } + kept.push(a); + if (kept.length >= CANDIDATE_POOL) break; + } + return kept; +} + +// ======================================================================== +// Acceptance rules and the validation gate +// ======================================================================== + +const EXT_FORMAT = {png: 'png', jpg: 'jpeg', jpeg: 'jpeg', webp: 'webp'}; + +export const extensionMatches = (img, ext) => EXT_FORMAT[ext] === img.format; + +// The acceptance rules live here so producers can check themselves against the +// same predicate the gate enforces. `collect-candidates` screens covers with +// imageQualityIssues, `caption-agent` screens model output with textIssues; if +// either drifted from the gate the pipeline would pick work it then rejects. +export function imageQualityIssues({width, height, bytes}) { + const issues = []; + if (width < MIN_IMG_WIDTH) issues.push(`width ${width} < ${MIN_IMG_WIDTH}`); + if (width / height < MIN_ASPECT) issues.push(`not landscape (${width}x${height})`); + if (bytes > MAX_IMG_BYTES) issues.push(`file too large (${bytes} > ${MAX_IMG_BYTES})`); + return issues; +} + +export function textIssues(alt, caption) { + const issues = []; + for (const [field, val, max] of [['caption', caption, MAX_CAPTION], ['alt', alt, MAX_ALT]]) { + if (typeof val !== 'string' || !val.trim()) {issues.push(`${field} empty`); continue;} + if (val.length > max) issues.push(`${field} too long (${val.length} > ${max})`); + if (ILLEGAL_TEXT_RE.test(val)) issues.push(`${field} has illegal characters`); + } + if (typeof alt === 'string' && alt.trim() === (caption || '').trim()) issues.push('alt equals caption'); + return issues; +} + +export function validateSlides(slides, {slidesDir = SLIDES_DIR} = {}) { + const v = []; + if (!Array.isArray(slides)) return ['slides.json is not an array']; + if (slides.length < MIN_SLIDES || slides.length > MAX_SLIDES) + v.push(`slide count ${slides.length} outside ${MIN_SLIDES}..${MAX_SLIDES}`); + + const seen = new Set(); + const seenRefs = new Set(); + for (const [i, s] of slides.entries()) { + const at = `slide[${i}]`; + if (!SRC_RE.test(s.src || '')) {v.push(`${at} src invalid: ${s.src}`); continue;} + if (seen.has(s.src)) v.push(`${at} duplicate src: ${s.src}`); + seen.add(s.src); + + // Mirrors what select.mjs halts on, so a human PR cannot land a state + // that would stop the bot on its next run. + if (s.evergreen === true && s.sourceArticle) v.push(`${at} has both evergreen and sourceArticle`); + else if (!(s.evergreen === true) && !s.sourceArticle) v.push(`${at} untracked (no evergreen/sourceArticle)`); + else if (s.sourceArticle && typeof s.sourceArticle !== 'string') v.push(`${at} sourceArticle is not a string`); + else if (s.sourceArticle) { + if (seenRefs.has(s.sourceArticle)) v.push(`${at} duplicate sourceArticle: ${s.sourceArticle}`); + seenRefs.add(s.sourceArticle); + } + + for (const issue of textIssues(s.alt, s.caption)) v.push(`${at} ${issue}`); + + const abs = path.join(slidesDir, path.basename(s.src)); + if (!fs.existsSync(abs)) {v.push(`${at} image missing: ${abs}`); continue;} + try { + const img = probeImage(abs); + const ext = path.extname(abs).slice(1).toLowerCase(); + if (!extensionMatches(img, ext)) v.push(`${at} format ${img.format} != extension .${ext}`); + // Quality gates apply only to bot-created images (-.). + // Legacy/human pins predate the automation and are grandfathered. + if (BOT_FILE_RE.test(path.basename(abs))) + for (const issue of imageQualityIssues(img)) v.push(`${at} ${issue}`); + } catch (e) { + v.push(`${at} image probe failed: ${e.message}`); + } + } + return v; +} + +export function diffScopeViolations() { + const out = execFileSync('git', ['diff', '--name-only', 'HEAD'], {encoding: 'utf8'}); + return out.split('\n').map(s => s.trim()).filter(Boolean) + .filter(p => p !== 'src/data/slides.json' && !p.startsWith('src/data/slides/')) + .map(p => `out-of-scope change: ${p}`); +} + +// ======================================================================== +// Candidate collection +// ======================================================================== + +export function readCurrent() { + return JSON.parse(fs.readFileSync(SLIDES_JSON, 'utf8')); +} + +// A fresh candidate's cover becomes a bot-created slide image, so it must pass +// the same quality gates the validator enforces on bot images. Filtering here +// keeps selection from ever picking an unusable cover (e.g. a raw portrait phone +// photo), which would otherwise abort every run. Incumbents are unaffected: +// their image was already copied and validated when the slide was created. +export function usableCover(a) { + if (!a.coverAbsPath) return false; + try { + const img = probeImage(a.coverAbsPath); + return extensionMatches(img, a.coverExt) && !imageQualityIssues(img).length; + } catch { + return false; + } +} + +// The captions an article would get if the agent is off or rejected must +// themselves pass the gate. Without this an article whose summary repeats its +// title yields alt === caption, which fails validation after every apply. +export function usableCandidate(a) { + if (!usableCover(a)) return false; + // fallbackText would otherwise caption it with the title. Typed rather than + // truthy: YAML yields a number for an unquoted `summary: 2024`, and the bot + // runs before the build that would reject it. + if (typeof a.summary !== 'string' || !a.summary.trim()) return false; + const {alt, caption} = fallbackText(a); + return !textIssues(alt, caption).length; +} + +function toCandidate(a) { + return { + id: a.ref, ref: a.ref, collection: a.collection, year: a.year, slug: a.slug, + title: a.title, summary: a.summary, + date: a.date ? a.date.toISOString() : null, + coverAbsPath: a.coverAbsPath, coverExt: a.coverExt, + topics: a.topics ?? topicsOf(a), score: a.score, + }; +} + +export function collect(now = new Date(), {current = readCurrent()} = {}) { + const ranked = rankCandidates(withCover(listArticles()).filter(usableCandidate), now); + const byRef = new Map(ranked.map(a => [a.ref, a])); + for (const s of current) { + if (s.sourceArticle && !byRef.has(s.sourceArticle)) { + const a = resolveArticle(s.sourceArticle); + if (a && a.coverAbsPath) byRef.set(a.ref, {...a, score: scoreArticle(a, now), topics: topicsOf(a)}); + } + } + return {current, candidates: [...byRef.values()].map(toCandidate)}; +} + +// ======================================================================== +// Selection +// ======================================================================== + +const botFilename = c => `${c.collection}-${c.year ?? '0000'}-${c.slug}.${c.coverExt}`; +const botSrc = c => `/data/slides/${botFilename(c)}`; +// Ownership keys are part of the comparison: a run whose only effect is +// stamping an untracked entry `evergreen` must still be reported as changed, +// or the tag is never persisted and the entry stays untracked forever. +const pick = s => ({ + src: s.src, alt: s.alt ?? null, caption: s.caption ?? null, + evergreen: s.evergreen === true, sourceArticle: s.sourceArticle ?? null, +}); +const sameSeq = (a, b) => + JSON.stringify(a.map(pick)) === JSON.stringify(b.map(pick)); + +export function selectSlides({current, candidates}) { + const byRef = new Map(candidates.map(c => [c.ref, c])); + const scoreOf = ref => byRef.get(ref)?.score ?? 0; + + // Evergreen pins AND untracked entries (e.g. a slide freshly added via the + // CMS, which has no ownership key yet) are retained in place. Untracked ones + // are stamped `evergreen: true` so they are protected and self-heal their + // tag — never dropped. This is the spec's fail-closed rule. + const halt = reason => ({slides: current, changed: false, budget: 0, dropped: 0, blocked: reason}); + + // Which key wins is a guess either way, and guessing defers the problem: + // dropping sourceArticle unclaims the ref, so the next run picks the same + // article up again and shows it twice. + const ambiguous = current.find(s => s.evergreen === true && s.sourceArticle); + if (ambiguous) return halt(`${ambiguous.src} carries both evergreen and sourceArticle; remove one`); + + const malformed = current.find(s => s.sourceArticle && typeof s.sourceArticle !== 'string'); + if (malformed) return halt(`${malformed.src} has a non-string sourceArticle`); + + const evergreens = current + .filter(s => s.evergreen === true || !s.sourceArticle) + .map(s => (s.evergreen === true ? s : {...s, evergreen: true})); + if (evergreens.length > MAX_SLIDES) + return halt(`${evergreens.length} pinned slides exceed the ${MAX_SLIDES} slot limit; unpin one`); + const budget = MAX_SLIDES - evergreens.length; + + const botIncumbents = current.filter(s => s.sourceArticle); + const claimedRefs = new Set(botIncumbents.map(s => s.sourceArticle)); + // Keeping one of two slides that name the same article means deleting the + // other and its image, on a guess. Same unanswerable question as a dual-key + // entry, so it gets the same answer. + if (claimedRefs.size < botIncumbents.length) { + const dupe = botIncumbents.find((s, i) => botIncumbents.findIndex(o => o.sourceArticle === s.sourceArticle) < i); + return halt(`two slides name ${dupe.sourceArticle}; remove one`); + } + + // One slide per file: a generated filename already taken by a pin, or by a + // higher-scored candidate this same run, disqualifies the candidate. + const claimedSrcs = new Set(current.map(s => s.src)); + const fresh = []; + for (const c of candidates) { + if (claimedRefs.has(c.ref) || claimedSrcs.has(botSrc(c))) continue; + claimedSrcs.add(botSrc(c)); + fresh.push(c); + } + + const eff = (ref, isInc) => scoreOf(ref) * (isInc ? 1 + HYSTERESIS_MARGIN : 1); + const pool = [ + ...botIncumbents.map(s => ({ref: s.sourceArticle, isInc: true, entry: s})), + ...fresh.map(c => ({ref: c.ref, isInc: false, cand: c})), + ].sort((x, y) => + eff(y.ref, y.isInc) - eff(x.ref, x.isInc) || + (y.isInc === x.isInc ? 0 : y.isInc ? 1 : -1) || + x.ref.localeCompare(y.ref)); + + let chosen = pool.slice(0, budget); + + // Swap cap: at most MAX_SWAPS fresh refs enter per run; backfill from + // remaining incumbents if we blocked some. + const freshChosen = chosen.filter(p => !p.isInc); + if (freshChosen.length > MAX_SWAPS) { + const allowed = new Set(freshChosen.slice(0, MAX_SWAPS).map(p => p.ref)); + chosen = chosen.filter(p => p.isInc || allowed.has(p.ref)); + const spare = pool.filter(p => p.isInc && !chosen.includes(p)); + while (chosen.length < budget && spare.length) chosen.push(spare.shift()); + chosen = chosen.slice(0, budget); + } + + // Order: surviving incumbents in current order, then new ones by score. + // Matched by identity, not by ref: two entries can share a sourceArticle. + const survivors = botIncumbents.filter(s => chosen.some(p => p.entry === s)); + const news = chosen + .filter(p => !p.isInc) + .map(p => ({ + src: botSrc(p.cand), + alt: null, caption: null, sourceArticle: p.cand.ref, _candidate: p.cand, + })); + + const slides = [...evergreens, ...survivors, ...news]; + return { + slides, changed: !sameSeq(current, slides), budget, + dropped: botIncumbents.length - survivors.length, + }; +} + +// ======================================================================== +// Captions +// ======================================================================== + +const HERE = path.dirname(fileURLToPath(import.meta.url)); + +export function clamp(str, n) { + const s = String(str ?? '').replace(/\s+/g, ' ').trim(); + return s.length <= n ? s : s.slice(0, n - 1).trimEnd() + '…'; +} + +export function fallbackText(cand) { + const caption = clamp(cand.summary || cand.title, MAX_CAPTION); + return {alt: clamp(cand.title, MAX_ALT), caption}; +} + +export function properNounsOk(text, cand) { + const src = `${cand.title} ${cand.summary}`; + const runs = text.match(/[A-ZÅØÆ][\wÅØÆåøæ.'-]+(?:\s+[A-ZÅØÆ][\wÅØÆåøæ.'-]+)+/g) || []; + return runs.every(r => src.includes(r)); +} + +export function validAgentText(alt, caption, cand) { + if (textIssues(alt, caption).length) return false; + return properNounsOk(caption, cand) && properNounsOk(alt, cand); +} + +export function extractJsonArray(text) { + const t = String(text || '').trim(); + if (!t) return null; + for (const candidate of [t, (t.match(/\[[\s\S]*\]/) || [])[0]]) { + if (!candidate) continue; + try { + const v = JSON.parse(candidate); + if (Array.isArray(v)) return v; + } catch { /* try next */ } + } + return null; +} + +export function defaultRunAgent(inputJson) { + const model = process.env.SLIDES_AGENT_MODEL; + if (!model || process.env.SLIDES_AGENT === 'off') return Promise.resolve(''); + const prompt = `Here is the input. Return only the JSON array.\n${inputJson}`; + const r = spawnSync('opencode', ['run', '--model', model, prompt], + {cwd: HERE, encoding: 'utf8', timeout: 120_000, maxBuffer: 4 << 20}); + return Promise.resolve(r.status === 0 ? (r.stdout || '') : ''); +} + +export async function writeCaptions(slides, {runAgent = defaultRunAgent} = {}) { + const news = slides.filter(s => s._candidate && (s.alt == null || s.caption == null)); + if (!news.length) return slides; + + const input = JSON.stringify({ + slides: news.map(s => ({id: s._candidate.id, title: s._candidate.title, summary: s._candidate.summary})), + }); + + let byId = new Map(); + try { + const arr = extractJsonArray(await runAgent(input)); + if (arr) byId = new Map(arr.map(o => [o.id, o])); + } catch { /* fall back below */ } + + for (const s of news) { + const c = s._candidate; + const a = byId.get(c.id); + if (a && validAgentText(a.alt, a.caption, c)) { + s.alt = a.alt.trim(); + s.caption = a.caption.trim(); + } else { + const fb = fallbackText(c); + s.alt = fb.alt; + s.caption = fb.caption; + } + } + return slides; +} + +// ======================================================================== +// Apply +// ======================================================================== + +export function cleanEntry(s) { + const out = {src: s.src, alt: s.alt, caption: s.caption}; + if (s.evergreen === true) out.evergreen = true; + else if (s.sourceArticle) out.sourceArticle = s.sourceArticle; + return out; +} + +export function referencedBasenames(slides) { + return new Set(slides.map(s => path.basename(s.src))); +} + +export function staleBotFiles(existing, referenced) { + return existing.filter(f => BOT_FILE_RE.test(f) && !referenced.has(f)); +} + +export function apply(slides) { + const retained = new Set(slides.filter(s => !s._candidate).map(s => path.basename(s.src))); + for (const s of slides) { + if (s._candidate) { + const name = path.basename(s.src); + if (retained.has(name)) + throw new Error(`refusing to overwrite an image already in use: ${name}`); + retained.add(name); + fs.copyFileSync(s._candidate.coverAbsPath, path.join(SLIDES_DIR, name)); + } + } + const clean = slides.map(cleanEntry); + const referenced = referencedBasenames(clean); + const existing = fs.readdirSync(SLIDES_DIR); + const deleted = staleBotFiles(existing, referenced); + for (const f of deleted) fs.rmSync(path.join(SLIDES_DIR, f)); + + fs.writeFileSync(SLIDES_JSON, JSON.stringify(clean, null, 4) + '\n'); + return {deleted, slides: clean}; +} + +// ======================================================================== +// Refresh +// ======================================================================== + +function setOutput(result) { + const out = process.env.GITHUB_OUTPUT; + if (out) fs.appendFileSync(out, `result=${result}\n`); + console.log(`result=${result}`); +} + +export async function refresh({diffScope = false} = {}) { + const {current, candidates} = collect(new Date()); + const {slides, changed, blocked, budget, dropped} = selectSlides({current, candidates}); + if (blocked) { + console.error(`Cannot refresh: ${blocked}.`); + return 1; + } + if (budget === 0) { + console.warn(dropped + ? `Pins fill every slot; dropping ${dropped} bot slide(s) to make room.` + : 'Every slot is pinned; the bot has nothing to rotate.'); + } + if (!changed) { + console.log('No slide changes needed.'); + setOutput('noop'); + return 0; + } + + await writeCaptions(slides); + const {deleted, slides: applied} = apply(slides); + + const violations = validateSlides(applied); + if (diffScope) violations.push(...diffScopeViolations()); + if (violations.length) { + console.error('Validation failed after apply:\n' + violations.map(m => ' - ' + m).join('\n')); + return 1; + } + + console.log(`Applied ${applied.length} slides; deleted ${deleted.length} stale file(s).`); + setOutput('changed'); + return 0; +} + +// ======================================================================== +// CLI +// ======================================================================== + +const COMMANDS = { + collect: () => { + process.stdout.write(JSON.stringify(collect(), null, 2) + '\n'); + return 0; + }, + refresh: () => refresh({diffScope: process.argv.includes('--diff-scope')}), + validate: () => { + const slides = JSON.parse(fs.readFileSync(SLIDES_JSON, 'utf8')); + const v = validateSlides(slides); + if (process.argv.includes('--diff-scope')) v.push(...diffScopeViolations()); + if (v.length) { + console.error('Slide validation failed:\n' + v.map(m => ' - ' + m).join('\n')); + return 1; + } + console.log(`Slides valid (${slides.length}).`); + return 0; + }, +}; + +if (import.meta.url === `file://${process.argv[1]}`) { + const command = COMMANDS[process.argv[2]]; + if (!command) { + console.error(`Usage: slides.js <${Object.keys(COMMANDS).join('|')}> [--diff-scope]`); + process.exit(2); + } + Promise.resolve(command()) + .then(code => process.exit(code)) + .catch(e => {console.error(e); process.exit(1);}); +} diff --git a/scripts/slides/slides.test.js b/scripts/slides/slides.test.js new file mode 100644 index 00000000..1c5a3679 --- /dev/null +++ b/scripts/slides/slides.test.js @@ -0,0 +1,475 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import {test} from 'node:test'; +import { + cleanEntry, collect, fallbackText, listArticles, parseArticleDate, probeImage, + properNounsOk, rankCandidates, readCurrent, referencedBasenames, resolveArticle, + scoreArticle, selectSlides, SLIDES_DIR, staleBotFiles, usableCandidate, + usableCover, validateSlides, withCover, writeCaptions, +} from './slides.js'; + +// ======================================================================== +// dates +// ======================================================================== + +test('parses full and abbreviated English month dates', () => { + assert.equal(parseArticleDate('September 17, 2025').toISOString(), '2025-09-17T00:00:00.000Z'); + assert.equal(parseArticleDate('Apr 16, 2026').toISOString(), '2026-04-16T00:00:00.000Z'); + assert.equal(parseArticleDate('Sept 1, 2024').toISOString(), '2024-09-01T00:00:00.000Z'); +}); + +test('returns null for unparseable input', () => { + assert.equal(parseArticleDate('2025-09-17'), null); + assert.equal(parseArticleDate('someday'), null); + assert.equal(parseArticleDate(''), null); + assert.equal(parseArticleDate(undefined), null); +}); + +// ======================================================================== +// image-probe +// ======================================================================== + +test('reads PNG dimensions and format', () => { + const r = probeImage(path.join(SLIDES_DIR, 'nels.png')); + assert.equal(r.format, 'png'); + assert.ok(r.width > 100 && r.height > 100); + assert.ok(r.bytes > 0); +}); + +test('reads JPEG dimensions', () => { + const r = probeImage(path.join(SLIDES_DIR, 'elixir-no-all-hands-2025.jpg')); + assert.equal(r.format, 'jpeg'); + assert.ok(r.width > 100 && r.height > 100); +}); + +test('throws on a non-image', () => { + assert.throws(() => probeImage(path.join(SLIDES_DIR, '..', 'slides.json'))); +}); + +// ======================================================================== +// frontmatter +// ======================================================================== + +test('lists real news articles with parsed fields', () => { + const all = listArticles(); + const eosc = resolveArticle('news/2025/eosc-entrust-workshop'); + assert.ok(eosc, 'eosc-entrust-workshop resolves'); + assert.equal(eosc.title, 'EOSC-ENTRUST workshop hosted by ELIXIR Norway'); + assert.equal(eosc.date.getUTCFullYear(), 2025); + assert.ok(eosc.coverAbsPath.endsWith('.jpeg')); + assert.equal(eosc.coverExt, 'jpeg'); + assert.ok(all.length > 20); +}); + +test('withCover drops articles without a cover image', () => { + const covered = withCover(listArticles()); + assert.ok(covered.every(a => a.coverAbsPath)); +}); + +// ======================================================================== +// rank +// ======================================================================== + +const now = new Date(Date.UTC(2026, 6, 15)); +const mk = (o) => ({collection: 'news', slug: o.slug, title: o.title ?? '', summary: '', tags: [], date: o.date, coverAbsPath: '/x.png', ...o}); + +test('recent flagship news outranks an old routine notice', () => { + const flagship = mk({slug: 'gdi-go-live', title: 'GDI infrastructure go-live', date: new Date(Date.UTC(2026, 6, 1))}); + const routine = mk({slug: 'maint', title: 'Scheduled maintenance window', date: new Date(Date.UTC(2026, 6, 10))}); + assert.ok(scoreArticle(flagship, now) > scoreArticle(routine, now)); +}); + +test('a past event decays below a fresh news item', () => { + const pastEvent = mk({collection: 'events', slug: 'old-workshop', title: 'Workshop', date: new Date(Date.UTC(2026, 4, 1))}); + const freshNews = mk({slug: 'news', title: 'Infrastructure update', date: new Date(Date.UTC(2026, 6, 12))}); + assert.ok(scoreArticle(freshNews, now) > scoreArticle(pastEvent, now)); +}); + +test('anti-repeat caps flagship topic at 2', () => { + const arts = [1, 2, 3, 4].map(i => mk({collection: 'events', slug: `all-hands-${i}`, title: 'ELIXIR All Hands', date: new Date(Date.UTC(2026, 6, i))})); + const ranked = rankCandidates(arts, now); + assert.equal(ranked.filter(a => /all hands/.test(a.title.toLowerCase())).length, 2); +}); + +// ======================================================================== +// validate-slides +// ======================================================================== + +const ok = {src: '/data/slides/nels.png', alt: 'NeLS landing page', caption: 'The Norwegian e-Infrastructure for Life Sciences.', evergreen: true}; + +test('flags empty slide set', () => { + const v = validateSlides([], {slidesDir: SLIDES_DIR}); + assert.ok(v.some(m => /count/i.test(m))); +}); + +test('flags a bad src and a too-long caption', () => { + const v = validateSlides([ + {src: '/data/slides/BAD NAME.png', alt: 'a', caption: 'c', evergreen: true}, + {...ok, caption: 'x'.repeat(400)}, + ], {slidesDir: SLIDES_DIR}); + assert.ok(v.some(m => /src/i.test(m))); + assert.ok(v.some(m => /caption/i.test(m))); +}); + +test('accepts a valid evergreen slide backed by a real image', () => { + const v = validateSlides([ok], {slidesDir: SLIDES_DIR}); + assert.deepEqual(v, []); +}); + +test('grandfathers a large legacy-named evergreen image (quality gates are bot-only)', () => { + const bigLegacy = { + src: '/data/slides/elixir-no-all-hands-2025.jpg', + alt: 'Group photo for ELIXIR Norway All Hands 2025', + caption: "This year's ELIXIR Norway All Hands was organised physically in Ås!", + evergreen: true, + }; + // 3.37MB and a legacy filename (no - prefix) → exempt from size/width/aspect. + assert.deepEqual(validateSlides([bigLegacy], {slidesDir: SLIDES_DIR}), []); +}); + +test('rejects two slides naming the same article', () => { + const v = validateSlides([ + {...ok, evergreen: undefined, sourceArticle: 'news/2026/a'}, + {...ok, src: '/data/slides/rdm-promotion.png', evergreen: undefined, sourceArticle: 'news/2026/a'}, + ], {slidesDir: SLIDES_DIR}); + assert.ok(v.some(m => /duplicate sourceArticle/i.test(m)), v.join('; ')); +}); + +test('rejects a sourceArticle that is not a ref string', () => { + const v = validateSlides([{...ok, evergreen: undefined, sourceArticle: {ref: 'news/2026/a'}}], + {slidesDir: SLIDES_DIR}); + assert.ok(v.some(m => /sourceArticle/i.test(m)), v.join('; ')); +}); + +test('rejects a slide carrying both ownership tags', () => { + const v = validateSlides([{...ok, sourceArticle: 'news/2026/x'}], {slidesDir: SLIDES_DIR}); + assert.ok(v.some(m => /both/i.test(m)), v.join('; ')); +}); + +test('still enforces quality gates on a bot-named image (guard is not a blanket exemption)', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'slides-validate-')); + try { + // The 3.37MB image copied under a bot-style name (BOT_FILE_RE matches), + // so the size gate must fire even though the same bytes are exempt under + // the legacy filename. + fs.copyFileSync(path.join(SLIDES_DIR, 'elixir-no-all-hands-2025.jpg'), path.join(dir, 'news-2025-all-hands.jpg')); + const slide = { + src: '/data/slides/news-2025-all-hands.jpg', + alt: 'A group photo', + caption: 'A caption about the meeting.', + sourceArticle: 'news/2025/all-hands', + }; + const violations = validateSlides([slide], {slidesDir: dir}); + assert.ok(violations.some(m => /file too large/.test(m)), violations.join('; ')); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } +}); + +// ======================================================================== +// collect-candidates +// ======================================================================== + +const goodCandidate = { + title: 'A perfectly ordinary headline', + summary: 'A summary that says something else entirely.', + coverAbsPath: path.join(SLIDES_DIR, 'nels.png'), + coverExt: 'png', +}; + +test('collect returns current slides and a ranked candidate pool', () => { + const {current, candidates} = collect(new Date(Date.UTC(2026, 6, 15))); + assert.ok(Array.isArray(current) && current.length >= 1); + assert.ok(candidates.length >= 1 && candidates.length <= 12); + for (const c of candidates) { + assert.equal(c.id, c.ref); + assert.ok(c.coverAbsPath, 'candidate has a cover'); + assert.equal(typeof c.score, 'number'); + } +}); + +test('readCurrent parses slides.json', () => { + assert.ok(Array.isArray(readCurrent())); +}); + +test('usableCover rejects a raw portrait/oversized cover and accepts a good one', { + skip: ['news/2026/elixir-norway-all-hands', 'news/2025/eosc-entrust-workshop'] + .some(ref => !resolveArticle(ref)) && 'fixture articles no longer present', +}, () => { + const badArt = resolveArticle('news/2026/elixir-norway-all-hands'); // 3888x5184, 24.9MB + const goodArt = resolveArticle('news/2025/eosc-entrust-workshop'); // landscape, small + assert.equal(usableCover(badArt), false); + assert.equal(usableCover(goodArt), true); +}); + +test('collect excludes candidates whose cover fails the quality gates', () => { + const {candidates} = collect(new Date(Date.UTC(2026, 6, 15))); + assert.ok(!candidates.some(c => c.ref === 'news/2026/elixir-norway-all-hands')); +}); + +test('an incumbent that has aged out of the ranked pool is still scored', () => { + // Hysteresis compares an incumbent against its challengers, so an incumbent + // missing from the pool would score 0 and be dropped the moment it left the + // top slots. Driven from a synthetic current: the committed slides.json has + // no bot-managed entry to exercise this with. + const aged = 'news/2018/fair-data-management-in-molecular-life-sciences'; + const current = [{src: '/data/slides/x.png', alt: 'X', caption: 'c', sourceArticle: aged}]; + const {candidates} = collect(new Date(Date.UTC(2026, 6, 15)), {current}); + const rescued = candidates.find(c => c.ref === aged); + assert.ok(rescued, 'an on-screen article must be scored even when it ranks below the pool'); + assert.equal(typeof rescued.score, 'number'); +}); + +test('usableCandidate accepts a well-formed article', () => { + assert.equal(usableCandidate(goodCandidate), true); +}); + +test('usableCandidate rejects an article whose summary repeats its title', () => { + // The fallback caption is the summary and the fallback alt is the title, so + // an article like this would produce alt === caption and fail the gate. + assert.equal(usableCandidate({...goodCandidate, summary: goodCandidate.title}), false); +}); + +test('usableCandidate rejects an article with no summary even when its title is long', () => { + // A title over MAX_ALT clamps, so alt and caption differ by the ellipsis and + // the alt-equals-caption rule alone would let this through. + assert.equal(usableCandidate({...goodCandidate, title: 'T'.repeat(200), summary: ''}), false); +}); + +test('usableCandidate rejects a non-string summary instead of throwing', () => { + // YAML turns an unquoted `summary: 2024` into a number. The bot runs before + // the build that would reject it, so it must not crash the pipeline. + for (const summary of [2024, true, ['a'], {a: 1}]) + assert.equal(usableCandidate({...goodCandidate, summary}), false); +}); + +test('usableCandidate rejects a cover whose extension disagrees with its bytes', () => { + assert.equal(usableCandidate({...goodCandidate, coverExt: 'jpg'}), false); +}); + +test('no article in the repo would produce a caption identical to its alt', () => { + const {candidates} = collect(new Date(Date.UTC(2026, 6, 15))); + assert.ok(candidates.every(c => c.title.trim() !== c.summary.trim())); +}); + +test('every candidate has a non-empty summary (fallback caption needs it)', () => { + const {candidates} = collect(new Date(Date.UTC(2026, 6, 15))); + assert.ok(candidates.length > 0); + assert.ok(candidates.every(c => c.summary && c.summary.trim()), 'no candidate may have an empty summary'); +}); + +// ======================================================================== +// select +// ======================================================================== + +const cand = (ref, slug, score, over = {}) => ({ + id: ref, ref, collection: 'news', year: 2026, slug, + title: slug, summary: 's', date: '2026-07-01T00:00:00.000Z', + coverAbsPath: `/x/${slug}.png`, coverExt: 'png', topics: [], score, ...over, +}); + +test('no-op when only evergreens and budget is full', () => { + const current = [ + {src: '/data/slides/nels.png', alt: 'NeLS', caption: 'c', evergreen: true}, + {src: '/data/slides/rdm.png', alt: 'RDM', caption: 'c', evergreen: true}, + ]; + const {slides, changed} = selectSlides({current, candidates: [cand('news/2026/x', 'x', 0.1)]}); + assert.equal(changed, true); // one free slot gets filled + assert.equal(slides[0].evergreen, true); +}); + +test('caps fresh additions at MAX_SWAPS (2)', () => { + const current = []; + const candidates = ['a', 'b', 'c', 'd'].map((s, i) => cand(`news/2026/${s}`, s, 1 - i * 0.1)); + const {slides} = selectSlides({current, candidates}); + assert.equal(slides.filter(s => s._candidate).length, 2); +}); + +test('unchanged selection reports changed=false', () => { + const current = [{src: '/data/slides/2026-a.png', alt: 'A', caption: 'c', sourceArticle: 'news/2026/a'}]; + const candidates = [cand('news/2026/a', 'a', 0.9)]; + const {changed} = selectSlides({current, candidates}); + assert.equal(changed, false); +}); + +test('incumbent keeps its slot unless a challenger beats it by the hysteresis margin', () => { + const evergreens = ['a', 'b', 'c', 'd', 'e'].map(s => ({src: `/data/slides/${s}.png`, alt: s.toUpperCase(), caption: 'c', evergreen: true})); + const incumbent = {src: '/data/slides/2026-inc.png', alt: 'Inc', caption: 'c', sourceArticle: 'news/2026/inc'}; + // budget = 6 - 5 evergreens = 1 bot slot. incumbent eff = 0.5 * 1.15 = 0.575. + const near = selectSlides({current: [...evergreens, incumbent], candidates: [cand('news/2026/inc', 'inc', 0.5), cand('news/2026/new', 'new', 0.55)]}); + assert.equal(near.slides.at(-1).sourceArticle, 'news/2026/inc'); // 0.55 < 0.575 -> incumbent stays + const beats = selectSlides({current: [...evergreens, incumbent], candidates: [cand('news/2026/inc', 'inc', 0.5), cand('news/2026/new', 'new', 0.58)]}); + assert.equal(beats.slides.at(-1).sourceArticle, 'news/2026/new'); // 0.58 > 0.575 -> challenger wins +}); + +test('swap cap admits the top 2 fresh and backfills freed slots from displaced incumbents', () => { + const incs = [1, 2, 3, 4, 5].map(i => ({src: `/data/slides/2026-i${i}.png`, alt: `I${i}`, caption: 'c', sourceArticle: `news/2026/i${i}`})); + const incCands = [1, 2, 3, 4, 5].map(i => cand(`news/2026/i${i}`, `i${i}`, 0.5 - i * 0.01)); // i1 highest .49 .. i5 .45 + const fresh = ['a', 'b', 'c', 'd', 'e'].map((s, i) => cand(`news/2026/${s}`, s, 0.9 - i * 0.05)); // a .9 .. e .7 (all outrank incumbents) + const {slides} = selectSlides({current: incs, candidates: [...incCands, ...fresh]}); + const news = slides.filter(s => s._candidate).map(s => s.sourceArticle).sort(); + assert.deepEqual(news, ['news/2026/a', 'news/2026/b']); // only top-2 fresh admitted + const survivors = slides.filter(s => s.sourceArticle && !s._candidate).map(s => s.sourceArticle); + assert.equal(survivors.length, 4); // 4 slots backfilled from incumbents + assert.ok(!survivors.includes('news/2026/i5')); // lowest-scored incumbent dropped +}); + +test('the swap cap keeps the two highest-scored fresh, not any two', () => { + const candidates = ['a', 'b', 'c', 'd'].map((s, i) => cand(`news/2026/${s}`, s, 1 - i * 0.1)); // a highest + const {slides} = selectSlides({current: [], candidates}); + const news = slides.filter(s => s._candidate).map(s => s.sourceArticle).sort(); + assert.deepEqual(news, ['news/2026/a', 'news/2026/b']); // top two by score, not c/d +}); + +test('retains an untracked (CMS-added) current entry and tags it evergreen', () => { + const current = [ + {src: '/data/slides/nels.png', alt: 'NeLS', caption: 'c', evergreen: true}, + {src: '/data/slides/human-added.png', alt: 'Human highlight', caption: 'Added via CMS'}, + ]; + const {slides} = selectSlides({current, candidates: []}); + const human = slides.find(s => s.src === '/data/slides/human-added.png'); + assert.ok(human, 'untracked entry must survive'); + assert.equal(human.evergreen, true, 'untracked entry must be tagged evergreen'); +}); + +test('refuses to act on a dual-key entry instead of silently resolving it', () => { + // Stripping the redundant key would only defer the problem: the ref stops + // being claimed, and the next run picks the article up again as fresh. + const current = [ + {src: '/data/slides/eosc.png', alt: 'EOSC', caption: 'c', evergreen: true, sourceArticle: 'news/2026/a'}, + ]; + const {slides, changed, blocked} = selectSlides({current, candidates: [cand('news/2026/a', 'a', 0.9)]}); + assert.ok(blocked, 'ambiguous ownership must be reported, not guessed at'); + assert.equal(changed, false); + assert.deepEqual(slides, current); +}); + +test('refuses to act on a sourceArticle that is not a ref string', () => { + const current = [{src: '/data/slides/x.png', alt: 'X', caption: 'c', sourceArticle: {ref: 'news/2026/a'}}]; + const {blocked, changed} = selectSlides({current, candidates: [cand('news/2026/a', 'a', 0.9)]}); + assert.ok(blocked, 'a non-string ref must be named, not crash the comparator'); + assert.equal(changed, false); +}); + +test('refuses to act when two incumbents name the same article', () => { + // Which of the two to keep is the same unanswerable question as a dual-key + // entry. Picking one silently deletes the other slide and its image. + const dupes = [ + {src: '/data/slides/news-2026-a.png', alt: 'A', caption: 'c', sourceArticle: 'news/2026/a'}, + {src: '/data/slides/news-2026-a-copy.png', alt: 'A copy', caption: 'c', sourceArticle: 'news/2026/a'}, + ]; + const {slides, changed, blocked} = selectSlides({current: dupes, candidates: [cand('news/2026/a', 'a', 0.9)]}); + assert.ok(blocked); + assert.equal(changed, false); + assert.deepEqual(slides, dupes); +}); + +test('two candidates that would generate one filename cannot both be selected', () => { + // Same collection, slug and date-year, different refs: reachable because the + // year comes from the frontmatter date rather than the directory. + const candidates = [ + cand('news/2025/foo', 'foo', 0.9, {year: 2025}), + cand('news/2024/foo', 'foo', 0.8, {year: 2025}), + ]; + const {slides} = selectSlides({current: [], candidates}); + assert.equal(new Set(slides.map(s => s.src)).size, slides.length); + assert.equal(slides.length, 1); +}); + +test('reports bot slides dropped for want of a slot rather than claiming a no-op', () => { + const pins = [1, 2, 3, 4, 5, 6].map(i => ({src: `/data/slides/p${i}.png`, alt: `P${i}`, caption: 'c', evergreen: true})); + const inc = {src: '/data/slides/news-2026-a.png', alt: 'A', caption: 'c', sourceArticle: 'news/2026/a'}; + const {budget, dropped} = selectSlides({current: [...pins, inc], candidates: [cand('news/2026/a', 'a', 0.9)]}); + assert.equal(budget, 0); + assert.equal(dropped, 1, 'the purge must be visible to the caller'); +}); + +test('filenames stay unique when two collections share a slug and year', () => { + const candidates = [ + cand('news/2025/x', 'x', 0.9, {collection: 'news', year: 2025}), + cand('events/2025/x', 'x', 0.8, {collection: 'events', year: 2025}), + ]; + const {slides} = selectSlides({current: [], candidates}); + assert.equal(new Set(slides.map(s => s.src)).size, 2); +}); + +test('skips a candidate whose generated filename is already claimed by a pin', () => { + const current = [{src: '/data/slides/news-2025-x.png', alt: 'Human pin', caption: 'c', evergreen: true}]; + const {slides} = selectSlides({current, candidates: [cand('news/2025/x', 'x', 0.9, {year: 2025})]}); + assert.equal(slides.length, 1, 'the pin must not be shadowed by a same-named bot slide'); + assert.equal(slides[0].alt, 'Human pin'); +}); + +test('refuses to act when pins alone exceed MAX_SLIDES rather than emitting an invalid set', () => { + const current = [ + ...Array.from({length: 7}, (_, i) => ({src: `/data/slides/p${i}.png`, alt: `P${i}`, caption: 'c', evergreen: true})), + {src: '/data/slides/news-2026-a.png', alt: 'A', caption: 'c', sourceArticle: 'news/2026/a'}, + ]; + const {slides, changed, blocked} = selectSlides({current, candidates: [cand('news/2026/a', 'a', 0.9)]}); + assert.ok(blocked, 'over-pinned state must be reported, not written'); + assert.equal(changed, false, 'must not drop the bot slide or write an over-length set'); + assert.equal(slides.length, current.length); +}); + +test('stamping an untracked entry counts as a change so the tag is written back', () => { + const current = [ + {src: '/data/slides/nels.png', alt: 'NeLS', caption: 'c', evergreen: true}, + {src: '/data/slides/human-added.png', alt: 'Human highlight', caption: 'Added via CMS'}, + ]; + // src/alt/caption are all identical to current; only the new evergreen tag + // differs. Reporting no-op here would strand the entry untracked forever. + const {changed} = selectSlides({current, candidates: []}); + assert.equal(changed, true); +}); + +// ======================================================================== +// caption-agent +// ======================================================================== + +const newSlide = (id, title, summary) => ({ + src: `/data/slides/2026-${id}.png`, alt: null, caption: null, + sourceArticle: `news/2026/${id}`, + _candidate: {id: `news/2026/${id}`, title, summary}, +}); + +test('falls back to summary/title when the agent returns nothing', async () => { + const s = [newSlide('x', 'GDI go-live', 'ELIXIR Norway deploys GDI infrastructure.')]; + const out = await writeCaptions(s, {runAgent: async () => ''}); + assert.equal(out[0].alt, 'GDI go-live'); + assert.equal(out[0].caption, 'ELIXIR Norway deploys GDI infrastructure.'); +}); + +test('uses valid agent text', async () => { + const s = [newSlide('x', 'GDI go-live', 'ELIXIR Norway deploys GDI infrastructure.')]; + const agent = async () => JSON.stringify([{id: 'news/2026/x', alt: 'A network diagram', caption: 'ELIXIR Norway deploys GDI infrastructure across Europe.'}]); + const out = await writeCaptions(s, {runAgent: agent}); + assert.equal(out[0].alt, 'A network diagram'); +}); + +test('rejects hallucinated proper nouns', () => { + assert.equal(properNounsOk('Written by Jane Doe', {title: 'GDI', summary: 'about gdi'}), false); + assert.equal(properNounsOk('About the GDI project', {title: 'GDI project', summary: 'the GDI project'}), true); +}); + +// ======================================================================== +// apply-slides +// ======================================================================== + +test('cleanEntry strips transient fields', () => { + const e = cleanEntry({src: '/data/slides/2026-x.png', alt: 'A', caption: 'C', sourceArticle: 'news/2026/x', _candidate: {}, _new: true}); + assert.deepEqual(e, {src: '/data/slides/2026-x.png', alt: 'A', caption: 'C', sourceArticle: 'news/2026/x'}); +}); + +test('staleBotFiles only targets bot-named unreferenced files', () => { + const referenced = referencedBasenames([{src: '/data/slides/news-2026-keep.png'}, {src: '/data/slides/nels.png'}]); + const existing = ['news-2026-keep.png', 'events-2025-drop.jpeg', 'nels.png', 'rdm-promotion.png']; + assert.deepEqual(staleBotFiles(existing, referenced), ['events-2025-drop.jpeg']); +}); + +test('staleBotFiles spares a CMS upload that merely starts with a year', () => { + // The CMS names uploads by slugifying the alt text, so "2025 All Hands" + // becomes 2025-all-hands.png. That must never look bot-owned. + assert.deepEqual(staleBotFiles(['2025-all-hands.png'], new Set()), []); +}); diff --git a/scripts/slides/validate-slides.mjs b/scripts/slides/validate-slides.mjs deleted file mode 100644 index 3bd39581..00000000 --- a/scripts/slides/validate-slides.mjs +++ /dev/null @@ -1,96 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import {execFileSync} from 'node:child_process'; -import { - SLIDES_JSON, SLIDES_DIR, MAX_SLIDES, MIN_SLIDES, SRC_RE, BOT_FILE_RE, - MAX_CAPTION, MAX_ALT, MIN_IMG_WIDTH, MIN_ASPECT, MAX_IMG_BYTES, ILLEGAL_TEXT_RE, -} from './constants.mjs'; -import {probeImage} from './image-probe.mjs'; - -const EXT_FORMAT = {png: 'png', jpg: 'jpeg', jpeg: 'jpeg', webp: 'webp'}; - -export const extensionMatches = (img, ext) => EXT_FORMAT[ext] === img.format; - -// The acceptance rules live here so producers can check themselves against the -// same predicate the gate enforces. `collect-candidates` screens covers with -// imageQualityIssues, `caption-agent` screens model output with textIssues; if -// either drifted from the gate the pipeline would pick work it then rejects. -export function imageQualityIssues({width, height, bytes}) { - const issues = []; - if (width < MIN_IMG_WIDTH) issues.push(`width ${width} < ${MIN_IMG_WIDTH}`); - if (width / height < MIN_ASPECT) issues.push(`not landscape (${width}x${height})`); - if (bytes > MAX_IMG_BYTES) issues.push(`file too large (${bytes} > ${MAX_IMG_BYTES})`); - return issues; -} - -export function textIssues(alt, caption) { - const issues = []; - for (const [field, val, max] of [['caption', caption, MAX_CAPTION], ['alt', alt, MAX_ALT]]) { - if (typeof val !== 'string' || !val.trim()) {issues.push(`${field} empty`); continue;} - if (val.length > max) issues.push(`${field} too long (${val.length} > ${max})`); - if (ILLEGAL_TEXT_RE.test(val)) issues.push(`${field} has illegal characters`); - } - if (typeof alt === 'string' && alt.trim() === (caption || '').trim()) issues.push('alt equals caption'); - return issues; -} - -export function validateSlides(slides, {slidesDir = SLIDES_DIR} = {}) { - const v = []; - if (!Array.isArray(slides)) return ['slides.json is not an array']; - if (slides.length < MIN_SLIDES || slides.length > MAX_SLIDES) - v.push(`slide count ${slides.length} outside ${MIN_SLIDES}..${MAX_SLIDES}`); - - const seen = new Set(); - const seenRefs = new Set(); - for (const [i, s] of slides.entries()) { - const at = `slide[${i}]`; - if (!SRC_RE.test(s.src || '')) {v.push(`${at} src invalid: ${s.src}`); continue;} - if (seen.has(s.src)) v.push(`${at} duplicate src: ${s.src}`); - seen.add(s.src); - - // Mirrors what select.mjs halts on, so a human PR cannot land a state - // that would stop the bot on its next run. - if (s.evergreen === true && s.sourceArticle) v.push(`${at} has both evergreen and sourceArticle`); - else if (!(s.evergreen === true) && !s.sourceArticle) v.push(`${at} untracked (no evergreen/sourceArticle)`); - else if (s.sourceArticle && typeof s.sourceArticle !== 'string') v.push(`${at} sourceArticle is not a string`); - else if (s.sourceArticle) { - if (seenRefs.has(s.sourceArticle)) v.push(`${at} duplicate sourceArticle: ${s.sourceArticle}`); - seenRefs.add(s.sourceArticle); - } - - for (const issue of textIssues(s.alt, s.caption)) v.push(`${at} ${issue}`); - - const abs = path.join(slidesDir, path.basename(s.src)); - if (!fs.existsSync(abs)) {v.push(`${at} image missing: ${abs}`); continue;} - try { - const img = probeImage(abs); - const ext = path.extname(abs).slice(1).toLowerCase(); - if (!extensionMatches(img, ext)) v.push(`${at} format ${img.format} != extension .${ext}`); - // Quality gates apply only to bot-created images (-.). - // Legacy/human pins predate the automation and are grandfathered. - if (BOT_FILE_RE.test(path.basename(abs))) - for (const issue of imageQualityIssues(img)) v.push(`${at} ${issue}`); - } catch (e) { - v.push(`${at} image probe failed: ${e.message}`); - } - } - return v; -} - -export function diffScopeViolations() { - const out = execFileSync('git', ['diff', '--name-only', 'HEAD'], {encoding: 'utf8'}); - return out.split('\n').map(s => s.trim()).filter(Boolean) - .filter(p => p !== 'src/data/slides.json' && !p.startsWith('src/data/slides/')) - .map(p => `out-of-scope change: ${p}`); -} - -if (import.meta.url === `file://${process.argv[1]}`) { - const slides = JSON.parse(fs.readFileSync(SLIDES_JSON, 'utf8')); - const v = validateSlides(slides); - if (process.argv.includes('--diff-scope')) v.push(...diffScopeViolations()); - if (v.length) { - console.error('Slide validation failed:\n' + v.map(m => ' - ' + m).join('\n')); - process.exit(1); - } - console.log(`Slides valid (${slides.length}).`); -} diff --git a/scripts/slides/validate-slides.test.mjs b/scripts/slides/validate-slides.test.mjs deleted file mode 100644 index 0ec68aac..00000000 --- a/scripts/slides/validate-slides.test.mjs +++ /dev/null @@ -1,78 +0,0 @@ -import {test} from 'node:test'; -import assert from 'node:assert/strict'; -import os from 'node:os'; -import fs from 'node:fs'; -import path from 'node:path'; -import {validateSlides} from './validate-slides.mjs'; -import {SLIDES_DIR} from './constants.mjs'; - -const ok = {src: '/data/slides/nels.png', alt: 'NeLS landing page', caption: 'The Norwegian e-Infrastructure for Life Sciences.', evergreen: true}; - -test('flags empty slide set', () => { - const v = validateSlides([], {slidesDir: SLIDES_DIR}); - assert.ok(v.some(m => /count/i.test(m))); -}); - -test('flags a bad src and a too-long caption', () => { - const v = validateSlides([ - {src: '/data/slides/BAD NAME.png', alt: 'a', caption: 'c', evergreen: true}, - {...ok, caption: 'x'.repeat(400)}, - ], {slidesDir: SLIDES_DIR}); - assert.ok(v.some(m => /src/i.test(m))); - assert.ok(v.some(m => /caption/i.test(m))); -}); - -test('accepts a valid evergreen slide backed by a real image', () => { - const v = validateSlides([ok], {slidesDir: SLIDES_DIR}); - assert.deepEqual(v, []); -}); - -test('grandfathers a large legacy-named evergreen image (quality gates are bot-only)', () => { - const bigLegacy = { - src: '/data/slides/elixir-no-all-hands-2025.jpg', - alt: 'Group photo for ELIXIR Norway All Hands 2025', - caption: "This year's ELIXIR Norway All Hands was organised physically in Ås!", - evergreen: true, - }; - // 3.37MB and a legacy filename (no - prefix) → exempt from size/width/aspect. - assert.deepEqual(validateSlides([bigLegacy], {slidesDir: SLIDES_DIR}), []); -}); - -test('rejects two slides naming the same article', () => { - const v = validateSlides([ - {...ok, evergreen: undefined, sourceArticle: 'news/2026/a'}, - {...ok, src: '/data/slides/rdm-promotion.png', evergreen: undefined, sourceArticle: 'news/2026/a'}, - ], {slidesDir: SLIDES_DIR}); - assert.ok(v.some(m => /duplicate sourceArticle/i.test(m)), v.join('; ')); -}); - -test('rejects a sourceArticle that is not a ref string', () => { - const v = validateSlides([{...ok, evergreen: undefined, sourceArticle: {ref: 'news/2026/a'}}], - {slidesDir: SLIDES_DIR}); - assert.ok(v.some(m => /sourceArticle/i.test(m)), v.join('; ')); -}); - -test('rejects a slide carrying both ownership tags', () => { - const v = validateSlides([{...ok, sourceArticle: 'news/2026/x'}], {slidesDir: SLIDES_DIR}); - assert.ok(v.some(m => /both/i.test(m)), v.join('; ')); -}); - -test('still enforces quality gates on a bot-named image (guard is not a blanket exemption)', () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'slides-validate-')); - try { - // The 3.37MB image copied under a bot-style name (BOT_FILE_RE matches), - // so the size gate must fire even though the same bytes are exempt under - // the legacy filename. - fs.copyFileSync(path.join(SLIDES_DIR, 'elixir-no-all-hands-2025.jpg'), path.join(dir, 'news-2025-all-hands.jpg')); - const slide = { - src: '/data/slides/news-2025-all-hands.jpg', - alt: 'A group photo', - caption: 'A caption about the meeting.', - sourceArticle: 'news/2025/all-hands', - }; - const violations = validateSlides([slide], {slidesDir: dir}); - assert.ok(violations.some(m => /file too large/.test(m)), violations.join('; ')); - } finally { - fs.rmSync(dir, {recursive: true, force: true}); - } -}); From db8dff29ff4c101eb6be42256f76cb6ebff7982c Mon Sep 17 00:00:00 2001 From: Yasin Date: Wed, 29 Jul 2026 12:17:28 +0200 Subject: [PATCH 31/42] fix(slides): tell the caption model what to write "Here is the input. Return only the JSON array" reads as "echo the array you were given", and that is what came back: {id, title, summary} unchanged, every time. validAgentText then rejected it for missing alt and caption, so every run silently used summary text and the agent looked like it was working. Verified against opencode/big-pickle: the old prompt echoes the input, the new one returns {id, alt, caption} that passes the gate. --- scripts/slides/slides.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/slides/slides.js b/scripts/slides/slides.js index 9965f038..d1c44c40 100644 --- a/scripts/slides/slides.js +++ b/scripts/slides/slides.js @@ -568,7 +568,10 @@ export function extractJsonArray(text) { export function defaultRunAgent(inputJson) { const model = process.env.SLIDES_AGENT_MODEL; if (!model || process.env.SLIDES_AGENT === 'off') return Promise.resolve(''); - const prompt = `Here is the input. Return only the JSON array.\n${inputJson}`; + // State the task, not just the format. "Return only the JSON array" alone + // reads as "echo the array you were given", and small models do exactly that. + const prompt = 'Write alt and caption for every slide below, following your ' + + `rules. Return only the JSON array of {id, alt, caption}.\n${inputJson}`; const r = spawnSync('opencode', ['run', '--model', model, prompt], {cwd: HERE, encoding: 'utf8', timeout: 120_000, maxBuffer: 4 << 20}); return Promise.resolve(r.status === 0 ? (r.stdout || '') : ''); From 4d0424ef5e365f104e23942986254a1e3556a5ac Mon Sep 17 00:00:00 2001 From: Yasin Date: Wed, 5 Aug 2026 13:32:21 +0200 Subject: [PATCH 32/42] docs(slides): fix two comments pointing at deleted files The module consolidation left a reference to select.mjs and a filename shape that predates the collection prefix. --- scripts/slides/slides.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/slides/slides.js b/scripts/slides/slides.js index d1c44c40..70a749aa 100644 --- a/scripts/slides/slides.js +++ b/scripts/slides/slides.js @@ -330,7 +330,7 @@ export function validateSlides(slides, {slidesDir = SLIDES_DIR} = {}) { if (seen.has(s.src)) v.push(`${at} duplicate src: ${s.src}`); seen.add(s.src); - // Mirrors what select.mjs halts on, so a human PR cannot land a state + // Mirrors what selectSlides halts on, so a human PR cannot land a state // that would stop the bot on its next run. if (s.evergreen === true && s.sourceArticle) v.push(`${at} has both evergreen and sourceArticle`); else if (!(s.evergreen === true) && !s.sourceArticle) v.push(`${at} untracked (no evergreen/sourceArticle)`); @@ -348,7 +348,7 @@ export function validateSlides(slides, {slidesDir = SLIDES_DIR} = {}) { const img = probeImage(abs); const ext = path.extname(abs).slice(1).toLowerCase(); if (!extensionMatches(img, ext)) v.push(`${at} format ${img.format} != extension .${ext}`); - // Quality gates apply only to bot-created images (-.). + // Quality gates apply only to bot-created images (BOT_FILE_RE). // Legacy/human pins predate the automation and are grandfathered. if (BOT_FILE_RE.test(path.basename(abs))) for (const issue of imageQualityIssues(img)) v.push(`${at} ${issue}`); From 6f7e9457151f55e0bb7ca2cf10eb9c240ff5c75a Mon Sep 17 00:00:00 2001 From: Yasin Date: Wed, 5 Aug 2026 13:45:59 +0200 Subject: [PATCH 33/42] fix(slides): name the offending slides on halt and catch untracked strays Two halt states told an operator less than the docs promised. Exceeding the pin limit reported only a count, and a duplicated ref named the article but not the two slides claiming it, so resolving either meant hunting through slides.json by hand. Naming the src is the entire point of halting instead of guessing. Tests now assert it so the messages cannot quietly regress. The diff-scope guard ran on `git diff`, which cannot see untracked files, so a stray file written outside src/data/slides* passed a check named for catching exactly that. --- scripts/slides/slides.js | 12 ++++++++---- scripts/slides/slides.test.js | 4 ++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/scripts/slides/slides.js b/scripts/slides/slides.js index 70a749aa..ad5b331e 100644 --- a/scripts/slides/slides.js +++ b/scripts/slides/slides.js @@ -360,8 +360,10 @@ export function validateSlides(slides, {slidesDir = SLIDES_DIR} = {}) { } export function diffScopeViolations() { - const out = execFileSync('git', ['diff', '--name-only', 'HEAD'], {encoding: 'utf8'}); - return out.split('\n').map(s => s.trim()).filter(Boolean) + // `git status`, not `git diff`, which cannot see untracked files: a stray + // temp file written outside the slides paths is exactly what this guards. + const out = execFileSync('git', ['status', '--porcelain', '--untracked-files=all'], {encoding: 'utf8'}); + return out.split('\n').map(s => s.slice(3).trim()).filter(Boolean) .filter(p => p !== 'src/data/slides.json' && !p.startsWith('src/data/slides/')) .map(p => `out-of-scope change: ${p}`); } @@ -463,7 +465,8 @@ export function selectSlides({current, candidates}) { .filter(s => s.evergreen === true || !s.sourceArticle) .map(s => (s.evergreen === true ? s : {...s, evergreen: true})); if (evergreens.length > MAX_SLIDES) - return halt(`${evergreens.length} pinned slides exceed the ${MAX_SLIDES} slot limit; unpin one`); + return halt(`${evergreens.length} pinned slides exceed the ${MAX_SLIDES} slot limit; ` + + `unpin one of ${evergreens.map(s => s.src).join(', ')}`); const budget = MAX_SLIDES - evergreens.length; const botIncumbents = current.filter(s => s.sourceArticle); @@ -473,7 +476,8 @@ export function selectSlides({current, candidates}) { // entry, so it gets the same answer. if (claimedRefs.size < botIncumbents.length) { const dupe = botIncumbents.find((s, i) => botIncumbents.findIndex(o => o.sourceArticle === s.sourceArticle) < i); - return halt(`two slides name ${dupe.sourceArticle}; remove one`); + const pair = botIncumbents.filter(s => s.sourceArticle === dupe.sourceArticle).map(s => s.src); + return halt(`${pair.join(' and ')} both name ${dupe.sourceArticle}; remove one`); } // One slide per file: a generated filename already taken by a pin, or by a diff --git a/scripts/slides/slides.test.js b/scripts/slides/slides.test.js index 1c5a3679..a3ad5d70 100644 --- a/scripts/slides/slides.test.js +++ b/scripts/slides/slides.test.js @@ -362,6 +362,9 @@ test('refuses to act when two incumbents name the same article', () => { ]; const {slides, changed, blocked} = selectSlides({current: dupes, candidates: [cand('news/2026/a', 'a', 0.9)]}); assert.ok(blocked); + // Naming both srcs is the whole value of halting: the operator has to find + // the pair by hand otherwise. + for (const s of dupes) assert.match(blocked, new RegExp(s.src)); assert.equal(changed, false); assert.deepEqual(slides, dupes); }); @@ -409,6 +412,7 @@ test('refuses to act when pins alone exceed MAX_SLIDES rather than emitting an i ]; const {slides, changed, blocked} = selectSlides({current, candidates: [cand('news/2026/a', 'a', 0.9)]}); assert.ok(blocked, 'over-pinned state must be reported, not written'); + assert.match(blocked, /\/data\/slides\/p0\.png/, 'the pins to choose between must be named'); assert.equal(changed, false, 'must not drop the bot slide or write an over-length set'); assert.equal(slides.length, current.length); }); From 7d4d8ea1faa14122fd29d639fb698cedfebdd86b Mon Sep 17 00:00:00 2001 From: Yasin Date: Wed, 5 Aug 2026 13:46:07 +0200 Subject: [PATCH 34/42] fix(slides): stop a transient API failure orphaning the refresh branch A bare assignment from a command substitution carries that command's exit status, so under set -e a hiccup listing open PRs ended the step outright. It sat between the branch push and the PR creation, and the cleanup lives in the create failure path, so the branch stayed on the remote with no PR and nothing to ever collect it. Reading the list before the push means a failure there leaves nothing behind. Closing a superseded PR can also fail for an ordinary reason: someone merged it by hand in the seconds since the list was read. That aborted the step and filed a failure issue while a perfectly good PR sat open, so a failed close now warns instead. Failures above these steps went unreported entirely, since a plain if is implicitly ANDed with success(). A broken install stops the carousel just as dead as a broken pipeline, so failure() now reports it, with GH_REPO set because a failure before checkout leaves gh no remote to read the repo from. Also bound the PR list, which silently pages at 30. --- .github/workflows/refresh-highlights.yml | 30 +++++++++++++++++------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/.github/workflows/refresh-highlights.yml b/.github/workflows/refresh-highlights.yml index dc23d09e..3e49250b 100644 --- a/.github/workflows/refresh-highlights.yml +++ b/.github/workflows/refresh-highlights.yml @@ -73,18 +73,22 @@ jobs: git checkout -b "$BRANCH" git add src/data/slides.json src/data/slides git commit -m "chore(slides): refresh homepage highlights" - git push origin "$BRANCH" - # Read before creating, or the new PR supersedes itself. - SUPERSEDED=$(gh pr list --state open --base main --json number,headRefName \ + # Read before creating, or the new PR supersedes itself. This runs + # before the push because a bare assignment from a command + # substitution carries its exit status: if the list call fails, + # set -e ends the step here, and there is no pushed branch to orphan. + SUPERSEDED=$(gh pr list --state open --base main --limit 100 --json number,headRefName \ --jq '.[] | select(.headRefName | startswith("bot/slides-refresh-")) | .number') + git push origin "$BRANCH" + cat > "$RUNNER_TEMP/pr-body.md" <<'EOF' Automated highlights refresh, open for review. - Slide selection and captions were regenerated from recent content; - `pnpm slides:validate` and `pnpm build` passed in the workflow run that - opened this. + Slide selection and captions were regenerated from recent content. The + pipeline validated the result after writing it, and `pnpm build` passed, + both in the workflow run that opened this. Merge it or close it, but do not leave it sitting. The branch holds a whole `slides.json` written against main as of this run, so the next @@ -103,15 +107,25 @@ jobs: # a different answer to the same question; merging both can put back a # slide the newer run deliberately dropped. Closed PRs keep a Restore # branch button, so nothing is lost. + # A close that fails must not turn this run red: the new PR is already + # open and mergeable, and someone merging the old one by hand in the + # seconds since the list above is a normal way to get here. for N in $SUPERSEDED; do gh pr close "$N" --delete-branch \ - --comment "Superseded by ${PR_URL}, computed from the current main." + --comment "Superseded by ${PR_URL}, computed from the current main." \ + || echo "::warning::could not close superseded PR #${N}; close it by hand" done - name: Report failure - if: steps.refresh.outcome == 'failure' || steps.build.outcome == 'failure' || steps.pr.outcome == 'failure' + # failure() catches the steps above these three: a broken `pnpm install` + # stops the carousel refreshing just as effectively as a broken pipeline, + # and would otherwise only show up as a red run nobody is watching. + if: failure() || steps.refresh.outcome == 'failure' || steps.build.outcome == 'failure' || steps.pr.outcome == 'failure' env: GH_TOKEN: ${{ github.token }} + # gh reads the repo from the git remote, and a failure before checkout + # leaves no remote to read. + GH_REPO: ${{ github.repository }} run: | set -euo pipefail TITLE="Highlights refresh failed" From 1b84dd1246653614570bf69035fe5c5cbc603b36 Mon Sep 17 00:00:00 2001 From: Yasin Date: Wed, 5 Aug 2026 13:46:14 +0200 Subject: [PATCH 35/42] fix(slides): stop the caption agent describing images it never sees The rules told the model that alt describes what the image shows, while the rule above it said to derive every word from the title and summary. Only the title and summary are ever sent, so the first instruction was unfollowable and the model resolved the contradiction by inventing. It captioned a text infographic as a group photo of 59 people, and every gate passed it, because nothing in the pipeline can check a claim about an image against the image. Alt now names the subject from the title, and the rule says outright that the image is not part of the input. --- scripts/slides/slides.AGENTS.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/slides/slides.AGENTS.md b/scripts/slides/slides.AGENTS.md index b611cae1..c4aa25c9 100644 --- a/scripts/slides/slides.AGENTS.md +++ b/scripts/slides/slides.AGENTS.md @@ -27,7 +27,9 @@ One object per input slide, same `id`. 4. Include a person's name only if it appears verbatim in the summary. 5. Plain text only, no HTML, markdown, emoji, backticks, or line breaks. `caption` ≤ 280 characters, `alt` ≤ 125 characters. -6. `alt` describes what the image shows; never copy the caption; do not start - with "image of" / "photo of". +6. You never receive the image, only `title` and `summary`. So `alt` names the + slide's subject in a few words drawn from the `title`: never describe visual + detail, never guess who or what is pictured, and never open with "image of", + "photo of" or "group photo of". Never copy the caption. 7. Neutral institutional English. No superlatives, marketing, or speculation. 8. Keep Norwegian characters (Å, å, Ø, ø, Æ, æ) intact. From ccd290d5736b938d1709be2da258d258456bfb85 Mon Sep 17 00:00:00 2001 From: Yasin Date: Wed, 5 Aug 2026 13:46:15 +0200 Subject: [PATCH 36/42] docs(slides): correct why nothing in funding-and-projects can surface The blocker is not the collection schema. Frontmatter is read straight off the file with gray-matter and never goes through Astro, so the schema has no say; the entries simply have no cover. Adding one is enough, and dateless entries already fall back to editorial weight. --- scripts/slides/README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/scripts/slides/README.md b/scripts/slides/README.md index 7f9e884a..94a062b3 100644 --- a/scripts/slides/README.md +++ b/scripts/slides/README.md @@ -13,9 +13,11 @@ Every slide entry carries exactly one signal: the bot. Set this to protect a slide. - `"sourceArticle": "collection/year/slug"`, bot-managed. Scored from that article each run; rotated by recency + editorial weight; dropped when it ages - out. `funding-and-projects` refs have two segments (no year), and no entry in - that collection can surface until its schema gains a `cover` field: the - selector only considers articles that have one. + out. `funding-and-projects` refs have two segments (no year), and nothing in + that collection surfaces today because no entry declares a `cover`: the + selector only considers articles that have one. Frontmatter is read straight + off the file with gray-matter rather than through the collection schema, so + adding a `cover` to an entry is all it takes. - Neither key, treated as evergreen (fail closed) and stamped `evergreen: true` on the next run, so the tag shows up in that run's PR diff. Should not occur after bootstrap. From 3ed2d94422c1c9f0c9ba3442bf62ed3ff1e19548 Mon Sep 17 00:00:00 2001 From: Yasin Date: Wed, 5 Aug 2026 13:53:07 +0200 Subject: [PATCH 37/42] fix(cms): pin slides created in the admin editor Add Slide seeded a new entry as {src, alt, caption}, which carries no ownership key, and slide validation rejects exactly that. Every PR the editor opened for a new slide would have failed CI on a rule the editor itself broke. A slide added by hand is a human's pick, so evergreen is the honest tag, and it matches what the refresh job would stamp on it anyway. The Slide interface was missing both keys as well: they survived a round trip only because the editor spreads the object it was given. --- src/components/admin/SlidesEditor.tsx | 6 ++++-- src/components/admin/schema.ts | 4 ++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/components/admin/SlidesEditor.tsx b/src/components/admin/SlidesEditor.tsx index 6ff7c70e..02fc693b 100644 --- a/src/components/admin/SlidesEditor.tsx +++ b/src/components/admin/SlidesEditor.tsx @@ -148,8 +148,10 @@ export default function SlidesEditor({ token, username, branchOverride, onBack } } if (editing !== null) { - const slide = editing === 'new' - ? { src: '', alt: '', caption: '' } + const slide: Slide = editing === 'new' + // Pinned on creation: a slide added here is a human's choice, and an + // entry with no ownership key fails validation on the PR it opens. + ? { src: '', alt: '', caption: '', evergreen: true } : slides[editing]; return ( diff --git a/src/components/admin/schema.ts b/src/components/admin/schema.ts index d99b893a..517f9f86 100644 --- a/src/components/admin/schema.ts +++ b/src/components/admin/schema.ts @@ -193,6 +193,10 @@ export interface Slide { src: string; alt: string; caption?: string; + // Exactly one of these. `evergreen` pins a slide; `sourceArticle` marks one + // the refresh job owns. An entry carrying neither fails slide validation. + evergreen?: true; + sourceArticle?: string; } export const ELIXIR_GROUPS = [ From a06d6393f7cefa23c8bab5974b90d8f7dd65c6f4 Mon Sep 17 00:00:00 2001 From: Yasin Date: Wed, 5 Aug 2026 13:53:15 +0200 Subject: [PATCH 38/42] fix(slides): five defects found auditing the pipeline Order was rebuilt every run as pins, then survivors, then new. The CMS offers up/down reordering and advertises that order matters, so promoting a bot slide above a pin was undone twice a week by a PR whose only content was the reversion. Retained entries now hold the position they had. A stable selection was taken as proof of a sound file: refresh returned success without validating whenever nothing rotated, so a deleted image or an emptied caption would have reported healthy indefinitely. It now validates on that path too, which is the one thing this job is best placed to notice. A null array element threw a TypeError out of both the validator and the selector. Both are contracted to name the offending slide, and `[null]` is valid JSON that a hand-edit can produce. Agent text was judged before trimming but stored after, so a trailing newline, the commonest artifact in model output, silently discarded good text in favour of the fallback. The JSON extractor spanned the first bracket to the last, so any preamble containing one made the whole batch fall back. It now scans for a balanced array, honouring strings. --- scripts/slides/README.md | 9 ++++- scripts/slides/slides.js | 75 ++++++++++++++++++++++++++++------- scripts/slides/slides.test.js | 40 ++++++++++++++++++- 3 files changed, 107 insertions(+), 17 deletions(-) diff --git a/scripts/slides/README.md b/scripts/slides/README.md index 94a062b3..6e94f319 100644 --- a/scripts/slides/README.md +++ b/scripts/slides/README.md @@ -19,8 +19,9 @@ Every slide entry carries exactly one signal: off the file with gray-matter rather than through the collection schema, so adding a `cover` to an entry is all it takes. - Neither key, treated as evergreen (fail closed) and stamped `evergreen: true` - on the next run, so the tag shows up in that run's PR diff. Should not occur - after bootstrap. + on the next run, so the tag shows up in that run's PR diff. Should not occur: + the CMS pins the slides it creates, and `slides:validate` rejects an untagged + entry, so only a hand-edit gets here. ## The bot never guesses @@ -58,6 +59,10 @@ The `/admin` SlidesEditor seeds its form with `useState({ ...slide })` and edits only `alt`/`caption`/`src`, so `sourceArticle`/`evergreen` survive both editing and reordering. Ownership keys are preserved end to end; no action required. +Carousel order survives too. Everything the bot retains keeps the position it +had, so an arrangement made with the up/down buttons stands, and new slides are +appended after it. + ## Layout Four files, and `README.md`: diff --git a/scripts/slides/slides.js b/scripts/slides/slides.js index ad5b331e..86c0da9b 100644 --- a/scripts/slides/slides.js +++ b/scripts/slides/slides.js @@ -326,6 +326,7 @@ export function validateSlides(slides, {slidesDir = SLIDES_DIR} = {}) { const seenRefs = new Set(); for (const [i, s] of slides.entries()) { const at = `slide[${i}]`; + if (!s || typeof s !== 'object') {v.push(`${at} is not an object`); continue;} if (!SRC_RE.test(s.src || '')) {v.push(`${at} src invalid: ${s.src}`); continue;} if (seen.has(s.src)) v.push(`${at} duplicate src: ${s.src}`); seen.add(s.src); @@ -455,15 +456,17 @@ export function selectSlides({current, candidates}) { // Which key wins is a guess either way, and guessing defers the problem: // dropping sourceArticle unclaims the ref, so the next run picks the same // article up again and shows it twice. + const notAnEntry = current.findIndex(s => !s || typeof s !== 'object'); + if (notAnEntry !== -1) return halt(`slide[${notAnEntry}] is not an object`); + const ambiguous = current.find(s => s.evergreen === true && s.sourceArticle); if (ambiguous) return halt(`${ambiguous.src} carries both evergreen and sourceArticle; remove one`); const malformed = current.find(s => s.sourceArticle && typeof s.sourceArticle !== 'string'); if (malformed) return halt(`${malformed.src} has a non-string sourceArticle`); - const evergreens = current - .filter(s => s.evergreen === true || !s.sourceArticle) - .map(s => (s.evergreen === true ? s : {...s, evergreen: true})); + const pinned = current.filter(s => s.evergreen === true || !s.sourceArticle); + const evergreens = pinned.map(s => (s.evergreen === true ? s : {...s, evergreen: true})); if (evergreens.length > MAX_SLIDES) return halt(`${evergreens.length} pinned slides exceed the ${MAX_SLIDES} slot limit; ` + `unpin one of ${evergreens.map(s => s.src).join(', ')}`); @@ -512,7 +515,6 @@ export function selectSlides({current, candidates}) { chosen = chosen.slice(0, budget); } - // Order: surviving incumbents in current order, then new ones by score. // Matched by identity, not by ref: two entries can share a sourceArticle. const survivors = botIncumbents.filter(s => chosen.some(p => p.entry === s)); const news = chosen @@ -522,7 +524,12 @@ export function selectSlides({current, candidates}) { alt: null, caption: null, sourceArticle: p.cand.ref, _candidate: p.cand, })); - const slides = [...evergreens, ...survivors, ...news]; + // Everything retained keeps the position it already had, pins included. The + // CMS offers up/down reordering, and hoisting pins to the front would undo + // an editor's arrangement twice a week, in a PR that does nothing else. + const replacement = new Map(pinned.map((s, i) => [s, evergreens[i]])); + for (const s of survivors) replacement.set(s, s); + const slides = [...current.filter(s => replacement.has(s)).map(s => replacement.get(s)), ...news]; return { slides, changed: !sameSeq(current, slides), budget, dropped: botIncumbents.length - survivors.length, @@ -556,15 +563,42 @@ export function validAgentText(alt, caption, cand) { return properNounsOk(caption, cand) && properNounsOk(alt, cand); } +const parseArray = s => { + try { + const v = JSON.parse(s); + return Array.isArray(v) ? v : null; + } catch { + return null; + } +}; + export function extractJsonArray(text) { const t = String(text || '').trim(); if (!t) return null; - for (const candidate of [t, (t.match(/\[[\s\S]*\]/) || [])[0]]) { - if (!candidate) continue; - try { - const v = JSON.parse(candidate); - if (Array.isArray(v)) return v; - } catch { /* try next */ } + const whole = parseArray(t); + if (whole) return whole; + + // Balanced scan rather than first `[` to last `]`: a model preamble often + // carries a stray bracket, and that greedy span then parses as nothing. + for (let i = 0; i < t.length; i++) { + if (t[i] !== '[') continue; + let depth = 0, inString = false, escaped = false; + for (let j = i; j < t.length; j++) { + const ch = t[j]; + if (inString) { + if (escaped) escaped = false; + else if (ch === '\\') escaped = true; + else if (ch === '"') inString = false; + continue; + } + if (ch === '"') inString = true; + else if (ch === '[') depth++; + else if (ch === ']' && --depth === 0) { + const v = parseArray(t.slice(i, j + 1)); + if (v) return v; + break; + } + } } return null; } @@ -598,9 +632,14 @@ export async function writeCaptions(slides, {runAgent = defaultRunAgent} = {}) { for (const s of news) { const c = s._candidate; const a = byId.get(c.id); - if (a && validAgentText(a.alt, a.caption, c)) { - s.alt = a.alt.trim(); - s.caption = a.caption.trim(); + // Trim first, then judge what will actually be stored. A trailing + // newline is the commonest thing in model output, and validating the + // raw string threw away otherwise good text for a character we strip. + const alt = typeof a?.alt === 'string' ? a.alt.trim() : a?.alt; + const caption = typeof a?.caption === 'string' ? a.caption.trim() : a?.caption; + if (a && validAgentText(alt, caption, c)) { + s.alt = alt; + s.caption = caption; } else { const fb = fallbackText(c); s.alt = fb.alt; @@ -673,6 +712,14 @@ export async function refresh({diffScope = false} = {}) { : 'Every slot is pinned; the bot has nothing to rotate.'); } if (!changed) { + // Selection being stable says nothing about the file being sound. A + // deleted image or an emptied caption would otherwise report healthy + // forever, and this job is the thing best placed to notice. + const standing = validateSlides(slides); + if (standing.length) { + console.error('Slides are unchanged but invalid:\n' + standing.map(m => ' - ' + m).join('\n')); + return 1; + } console.log('No slide changes needed.'); setOutput('noop'); return 0; diff --git a/scripts/slides/slides.test.js b/scripts/slides/slides.test.js index a3ad5d70..03676519 100644 --- a/scripts/slides/slides.test.js +++ b/scripts/slides/slides.test.js @@ -4,7 +4,7 @@ import os from 'node:os'; import path from 'node:path'; import {test} from 'node:test'; import { - cleanEntry, collect, fallbackText, listArticles, parseArticleDate, probeImage, + cleanEntry, collect, extractJsonArray, fallbackText, listArticles, parseArticleDate, probeImage, properNounsOk, rankCandidates, readCurrent, referencedBasenames, resolveArticle, scoreArticle, selectSlides, SLIDES_DIR, staleBotFiles, usableCandidate, usableCover, validateSlides, withCover, writeCaptions, @@ -346,6 +346,24 @@ test('refuses to act on a dual-key entry instead of silently resolving it', () = assert.deepEqual(slides, current); }); +test('reports a malformed entry instead of throwing at the operator', () => { + // `[null]` is valid JSON and hand-editable. A stack trace names no slide. + assert.deepEqual(validateSlides([null]), ['slide[0] is not an object']); + assert.match(selectSlides({current: [null], candidates: []}).blocked, /slide\[0\]/); +}); + +test('leaves a human arrangement alone instead of hoisting pins', () => { + // The CMS offers up/down reordering. Rebuilding the order every run would + // undo an editor twice a week, in a PR whose only content is the reversion. + const current = [ + {src: '/data/slides/news-2026-a.png', alt: 'A', caption: 'c', sourceArticle: 'news/2026/a'}, + {src: '/data/slides/nels.png', alt: 'NeLS', caption: 'c', evergreen: true}, + ]; + const {slides, changed} = selectSlides({current, candidates: [cand('news/2026/a', 'a', 0.9)]}); + assert.deepEqual(slides.map(s => s.src), current.map(s => s.src)); + assert.equal(changed, false, 'a pure reordering must not open a PR'); +}); + test('refuses to act on a sourceArticle that is not a ref string', () => { const current = [{src: '/data/slides/x.png', alt: 'X', caption: 'c', sourceArticle: {ref: 'news/2026/a'}}]; const {blocked, changed} = selectSlides({current, candidates: [cand('news/2026/a', 'a', 0.9)]}); @@ -457,6 +475,26 @@ test('rejects hallucinated proper nouns', () => { assert.equal(properNounsOk('About the GDI project', {title: 'GDI project', summary: 'the GDI project'}), true); }); +test('accepts agent text that only needs trimming', async () => { + // A trailing newline is the commonest artifact in model output. Judging the + // untrimmed string threw away good text over a character we strip anyway. + const s = [newSlide('x', 'GDI go-live', 'ELIXIR Norway deploys GDI infrastructure.')]; + const agent = async () => JSON.stringify([{id: 'news/2026/x', alt: 'A network diagram\n', caption: 'ELIXIR Norway deploys GDI infrastructure.\n'}]); + const out = await writeCaptions(s, {runAgent: agent}); + assert.equal(out[0].alt, 'A network diagram'); + assert.equal(out[0].caption, 'ELIXIR Norway deploys GDI infrastructure.'); +}); + +test('finds the array even when the model wrote a bracket before it', () => { + // First-`[`-to-last-`]` spans the preamble too and parses as nothing, which + // silently fell back for the whole batch. + assert.deepEqual(extractJsonArray('Here [is] your answer: [{"id":"x"}]'), [{id: 'x'}]); + assert.deepEqual(extractJsonArray('```json\n[{"id":"x"}]\n```'), [{id: 'x'}]); + assert.deepEqual(extractJsonArray('{"slides":[{"id":"x"}]}'), [{id: 'x'}]); + assert.deepEqual(extractJsonArray('note: [{"id":"a]b"}]'), [{id: 'a]b'}]); + assert.equal(extractJsonArray('no array here [nope'), null); +}); + // ======================================================================== // apply-slides // ======================================================================== From 924537f3d10046a8946029d51141bd4a2ac0a2e3 Mon Sep 17 00:00:00 2001 From: Yasin Date: Wed, 5 Aug 2026 13:57:07 +0200 Subject: [PATCH 39/42] fix(slides): parse git status without quoting surprises Porcelain C-quotes any path holding a space or a non-ASCII byte, wrapping it in double quotes, and a quoted path matches none of the prefixes the scope guard checks. A slide image with a space in its name would have been reported as an out-of-scope change and failed the run. -z turns quoting off. Also move the comment about which ownership key wins back above the check it explains, and drop a long dash. --- scripts/slides/slides.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/scripts/slides/slides.js b/scripts/slides/slides.js index 86c0da9b..d32f75ae 100644 --- a/scripts/slides/slides.js +++ b/scripts/slides/slides.js @@ -363,8 +363,10 @@ export function validateSlides(slides, {slidesDir = SLIDES_DIR} = {}) { export function diffScopeViolations() { // `git status`, not `git diff`, which cannot see untracked files: a stray // temp file written outside the slides paths is exactly what this guards. - const out = execFileSync('git', ['status', '--porcelain', '--untracked-files=all'], {encoding: 'utf8'}); - return out.split('\n').map(s => s.slice(3).trim()).filter(Boolean) + // -z because porcelain otherwise C-quotes any path holding a space or a + // non-ASCII byte, and a quoted path matches no prefix here. + const out = execFileSync('git', ['status', '--porcelain', '-z', '--untracked-files=all'], {encoding: 'utf8'}); + return out.split('\0').map(s => s.slice(3).trim()).filter(Boolean) .filter(p => p !== 'src/data/slides.json' && !p.startsWith('src/data/slides/')) .map(p => `out-of-scope change: ${p}`); } @@ -450,15 +452,15 @@ export function selectSlides({current, candidates}) { // Evergreen pins AND untracked entries (e.g. a slide freshly added via the // CMS, which has no ownership key yet) are retained in place. Untracked ones // are stamped `evergreen: true` so they are protected and self-heal their - // tag — never dropped. This is the spec's fail-closed rule. + // tag, never dropped. This is the spec's fail-closed rule. const halt = reason => ({slides: current, changed: false, budget: 0, dropped: 0, blocked: reason}); - // Which key wins is a guess either way, and guessing defers the problem: - // dropping sourceArticle unclaims the ref, so the next run picks the same - // article up again and shows it twice. const notAnEntry = current.findIndex(s => !s || typeof s !== 'object'); if (notAnEntry !== -1) return halt(`slide[${notAnEntry}] is not an object`); + // Which key wins is a guess either way, and guessing defers the problem: + // dropping sourceArticle unclaims the ref, so the next run picks the same + // article up again and shows it twice. const ambiguous = current.find(s => s.evergreen === true && s.sourceArticle); if (ambiguous) return halt(`${ambiguous.src} carries both evergreen and sourceArticle; remove one`); From 8122e91f44e395e9adec30106dc5cfdde7585cef Mon Sep 17 00:00:00 2001 From: Yasin Date: Wed, 5 Aug 2026 14:09:51 +0200 Subject: [PATCH 40/42] fix(slides): reject alt text that claims to describe the picture The caption agent is sent a title and a summary, never the image, so any alt asserting what is pictured is invented whatever the article says. It captioned a text infographic as a group photo of 59 people, and when the rules banned that opening it wrote "Group photo from" instead. Banning phrasings is a game of whack-a-mole, so the check now lives in code: alt cannot use photo, image, picture or shown unless the source itself does, which leaves an article about a photo competition free to say photo. Proper nouns were guarded for people only, and only against the summary, while the check behind it reads title and summary both. It now covers cities, countries, organisations and projects, which is where an invented name is likeliest and hardest to spot. --- scripts/slides/slides.AGENTS.md | 12 ++++++++---- scripts/slides/slides.js | 12 ++++++++++++ scripts/slides/slides.test.js | 14 +++++++++++++- 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/scripts/slides/slides.AGENTS.md b/scripts/slides/slides.AGENTS.md index c4aa25c9..534f6b38 100644 --- a/scripts/slides/slides.AGENTS.md +++ b/scripts/slides/slides.AGENTS.md @@ -24,12 +24,16 @@ One object per input slide, same `id`. 2. Use only the provided `id` values. Never invent slides, ids, images, or paths. 3. Derive all wording solely from that slide's `title` and `summary`. Do not add outside facts, numbers, dates, or claims. -4. Include a person's name only if it appears verbatim in the summary. +4. Every proper noun you write, whether a person, city, country, organisation or + project, must appear verbatim in that slide's `title` or `summary`. A single + invented place name is the easiest mistake to make here and the hardest to + catch, so when in doubt leave the name out and describe the thing generically. 5. Plain text only, no HTML, markdown, emoji, backticks, or line breaks. `caption` ≤ 280 characters, `alt` ≤ 125 characters. 6. You never receive the image, only `title` and `summary`. So `alt` names the - slide's subject in a few words drawn from the `title`: never describe visual - detail, never guess who or what is pictured, and never open with "image of", - "photo of" or "group photo of". Never copy the caption. + slide's subject in a few words drawn from the `title`, and asserts nothing + about what the picture shows. Do not use "photo", "image", "picture" or + "shown" at all unless the word is already in the title or summary. Never + copy the caption. 7. Neutral institutional English. No superlatives, marketing, or speculation. 8. Keep Norwegian characters (Å, å, Ø, ø, Æ, æ) intact. diff --git a/scripts/slides/slides.js b/scripts/slides/slides.js index d32f75ae..92790975 100644 --- a/scripts/slides/slides.js +++ b/scripts/slides/slides.js @@ -560,8 +560,20 @@ export function properNounsOk(text, cand) { return runs.every(r => src.includes(r)); } +// The agent is sent title and summary, never the image, so any alt asserting +// what the picture shows is unfounded by construction. Allowed only when the +// source itself uses the word, as an article about a photo competition would. +const IMAGE_CLAIM_RE = /\b(photo|photograph|picture|image|pictured|depicts?|depicted|shown)\w*/i; + +export function imageClaimOk(alt, cand) { + const claim = String(alt || '').match(IMAGE_CLAIM_RE); + if (!claim) return true; + return `${cand.title} ${cand.summary}`.toLowerCase().includes(claim[0].toLowerCase()); +} + export function validAgentText(alt, caption, cand) { if (textIssues(alt, caption).length) return false; + if (!imageClaimOk(alt, cand)) return false; return properNounsOk(caption, cand) && properNounsOk(alt, cand); } diff --git a/scripts/slides/slides.test.js b/scripts/slides/slides.test.js index 03676519..5c786575 100644 --- a/scripts/slides/slides.test.js +++ b/scripts/slides/slides.test.js @@ -4,7 +4,7 @@ import os from 'node:os'; import path from 'node:path'; import {test} from 'node:test'; import { - cleanEntry, collect, extractJsonArray, fallbackText, listArticles, parseArticleDate, probeImage, + cleanEntry, collect, extractJsonArray, fallbackText, imageClaimOk, listArticles, parseArticleDate, probeImage, properNounsOk, rankCandidates, readCurrent, referencedBasenames, resolveArticle, scoreArticle, selectSlides, SLIDES_DIR, staleBotFiles, usableCandidate, usableCover, validateSlides, withCover, writeCaptions, @@ -475,6 +475,18 @@ test('rejects hallucinated proper nouns', () => { assert.equal(properNounsOk('About the GDI project', {title: 'GDI project', summary: 'the GDI project'}), true); }); +test('rejects alt that claims to describe the picture', () => { + // The agent is never sent the image, so "Group photo of ..." is invented + // whatever the article is. It stands only when the source uses the word. + const cand = {title: 'GDI Node Hackathon', summary: 'Developers met in Lapland.'}; + assert.equal(imageClaimOk('Group photo from the GDI Node Hackathon', cand), false); + assert.equal(imageClaimOk('Participants shown at the hackathon', cand), false); + assert.equal(imageClaimOk('GDI Node Hackathon in Lapland', cand), true); + assert.equal( + imageClaimOk('Winning photo', {title: 'Photo competition', summary: 'Our photo competition.'}), + true, 'a source that talks about photos may say photo'); +}); + test('accepts agent text that only needs trimming', async () => { // A trailing newline is the commonest artifact in model output. Judging the // untrimmed string threw away good text over a character we strip anyway. From 137772565b361426242c590c9622b5e3451e0199 Mon Sep 17 00:00:00 2001 From: Yasin Date: Wed, 5 Aug 2026 15:12:16 +0200 Subject: [PATCH 41/42] feat(slides): report why a candidate was refused An article with a cover that fails the gates was dropped without a word, so an editor who uploaded one saw the bot ignore their post and had nothing to act on. Twenty-four articles are in that state today, and the reasons are all mundane: a photo straight off a camera is portrait and eight times the size limit, older covers are under 800px wide, two point at SVG logos. collect now carries a rejected list with the reason per article. The gates already computed these strings and threw them away. usableCandidate is now the empty case of that list rather than a parallel implementation, so the two cannot drift. --- scripts/slides/README.md | 3 +++ scripts/slides/slides.js | 39 +++++++++++++++++++++++++++++++++------ 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/scripts/slides/README.md b/scripts/slides/README.md index 6e94f319..74b243c4 100644 --- a/scripts/slides/README.md +++ b/scripts/slides/README.md @@ -81,6 +81,9 @@ Four files, and `README.md`: ## Operator commands - `pnpm slides:collect`, print the ranked candidate pool + current state (dry). + Its `rejected` list names every article that has a cover but cannot be used, + and why. Check it first when an article you expected on the homepage never + appears: a phone photo straight off a camera fails on both size and aspect. - `pnpm slides:refresh`, run the full pipeline locally (writes files). - `pnpm slides:validate`, run the sanity gate against the working tree. - `pnpm slides:test`, the unit suite. diff --git a/scripts/slides/slides.js b/scripts/slides/slides.js index 92790975..690d43ec 100644 --- a/scripts/slides/slides.js +++ b/scripts/slides/slides.js @@ -397,14 +397,37 @@ export function usableCover(a) { // The captions an article would get if the agent is off or rejected must // themselves pass the gate. Without this an article whose summary repeats its // title yields alt === caption, which fails validation after every apply. -export function usableCandidate(a) { - if (!usableCover(a)) return false; +// Why an article with a cover still cannot be used. Editors hit this by +// uploading a photo straight off a camera, and a silent rejection reads as the +// bot ignoring them, so `collect` reports these rather than dropping them. +export function candidateIssues(a) { + if (!a.coverAbsPath) return ['no cover in frontmatter']; + // Checked before probing so an SVG logo reports as unsupported rather than + // as a corrupt raster, which sends the reader hunting for a broken file. + if (!EXT_FORMAT[a.coverExt]) return [`unsupported cover format .${a.coverExt}`]; + let img; + try { + img = probeImage(a.coverAbsPath); + } catch (e) { + return [`cover unreadable: ${e.message}`]; + } + const issues = []; + if (!extensionMatches(img, a.coverExt)) issues.push(`format ${img.format} != extension .${a.coverExt}`); + issues.push(...imageQualityIssues(img)); // fallbackText would otherwise caption it with the title. Typed rather than // truthy: YAML yields a number for an unquoted `summary: 2024`, and the bot // runs before the build that would reject it. - if (typeof a.summary !== 'string' || !a.summary.trim()) return false; - const {alt, caption} = fallbackText(a); - return !textIssues(alt, caption).length; + if (typeof a.summary !== 'string' || !a.summary.trim()) { + issues.push('no summary'); + } else { + const {alt, caption} = fallbackText(a); + issues.push(...textIssues(alt, caption)); + } + return issues; +} + +export function usableCandidate(a) { + return !candidateIssues(a).length; } function toCandidate(a) { @@ -426,7 +449,11 @@ export function collect(now = new Date(), {current = readCurrent()} = {}) { if (a && a.coverAbsPath) byRef.set(a.ref, {...a, score: scoreArticle(a, now), topics: topicsOf(a)}); } } - return {current, candidates: [...byRef.values()].map(toCandidate)}; + const rejected = withCover(listArticles()) + .filter(a => !byRef.has(a.ref)) + .map(a => ({ref: a.ref, issues: candidateIssues(a)})) + .filter(r => r.issues.length); + return {current, candidates: [...byRef.values()].map(toCandidate), rejected}; } // ======================================================================== From f3a3528f1de73e69f7b60f90b9a7c4d5b0dd9e0a Mon Sep 17 00:00:00 2001 From: Yasin Date: Wed, 5 Aug 2026 15:12:16 +0200 Subject: [PATCH 42/42] chore(slides): unpin the two time-bound highlights Pinning every slide at bootstrap left one rotating slot in six, so the swap cap and the hysteresis margin could never come into play, and twelve candidates queued for a single tile. EOSC-ENTRUST and the All Hands 2025 group photo were pinned because they happened to be there, not because they are timeless. A pinned event cannot correct itself: the 2025 photo has been on the homepage for four months since the 2026 All Hands. NeLS, GDI and RDMkit stay, since those are standing service promos. The two images stay on disk. They match no bot filename, so nothing can delete them, and either can be pinned again by adding the entry back. --- src/data/slides.json | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/data/slides.json b/src/data/slides.json index ff27a716..e3606be8 100644 --- a/src/data/slides.json +++ b/src/data/slides.json @@ -1,16 +1,4 @@ [ - { - "src": "/data/slides/eosc-entrust.png", - "alt": "EOSC-ENTRUST", - "caption": "Pål Sætrom and Miikka Kallberg co-led the 2nd TRE Evaluation Workshop, bringing together 30 stakeholders to advance the TRE Blueprint and strengthen Trusted Research Environments across Europe. Organizers included Ingeborg Winge, Christine Stansberg, and Stefanie Kirschenmann.", - "evergreen": true - }, - { - "src": "/data/slides/elixir-no-all-hands-2025.jpg", - "alt": "Group photo for ELIXIR Norway All Hands 2025", - "caption": "This year's ELIXIR Norway All Hands was organised physically in Ås!", - "evergreen": true - }, { "src": "/data/slides/nels.png", "alt": "NeLS Landing Page",