From 4afe4a3bf307bfecc167bf5868f437809b451f4e Mon Sep 17 00:00:00 2001 From: Yasin Date: Wed, 15 Jul 2026 12:58:10 +0200 Subject: [PATCH 01/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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'); +});