diff --git a/.github/workflows/refresh-highlights.yml b/.github/workflows/refresh-highlights.yml new file mode 100644 index 00000000..3ec1b505 --- /dev/null +++ b/.github/workflows/refresh-highlights.yml @@ -0,0 +1,101 @@ +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 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: | + 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.") + # 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' || 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 // empty' || 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, build, or merge failed + if: steps.refresh.outcome == 'failure' || steps.build.outcome == 'failure' || steps.pr.outcome == 'failure' + run: exit 1 diff --git a/package.json b/package.json index 15754da1..67b4abd8 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,10 @@ "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", + "slides:refresh": "node scripts/slides/refresh.mjs", + "slides:validate": "node scripts/slides/validate-slides.mjs" }, "dependencies": { "@astrojs/check": "^0.9.0", @@ -46,6 +49,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/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. 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']); +}); 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/collect-candidates.mjs b/scripts/slides/collect-candidates.mjs new file mode 100644 index 00000000..028d2b6f --- /dev/null +++ b/scripts/slides/collect-candidates.mjs @@ -0,0 +1,57 @@ +import fs from 'node:fs'; +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, + 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(); + // 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)) { + 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..86f69041 --- /dev/null +++ b/scripts/slides/collect-candidates.test.mjs @@ -0,0 +1,43 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +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))); + 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', () => { + 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('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 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 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); +}); 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)); +}); 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'))); +}); 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/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); +}); 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);}); +} diff --git a/scripts/slides/select.mjs b/scripts/slides/select.mjs new file mode 100644 index 00000000..b4726eb1 --- /dev/null +++ b/scripts/slides/select.mjs @@ -0,0 +1,59 @@ +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; + + // 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); + 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..8d1ba65c --- /dev/null +++ b/scripts/slides/select.test.mjs @@ -0,0 +1,73 @@ +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'); +}); 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. diff --git a/scripts/slides/validate-slides.mjs b/scripts/slides/validate-slides.mjs new file mode 100644 index 00000000..84fca961 --- /dev/null +++ b/scripts/slides/validate-slides.mjs @@ -0,0 +1,72 @@ +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, +} 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}`); + // 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}`); + } + } + 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..75aa8e27 --- /dev/null +++ b/scripts/slides/validate-slides.test.mjs @@ -0,0 +1,59 @@ +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('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}); + } +}); 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 } ]