handoff pass: vendored sveltia bundle, astro pinned ^7.2.10, root README + refreshed deploy runbook, review fixes (coverless card grid, shared NewsCard/lib, real site origin, nginx security headers, linger step, dead config removed, preview.css sync, tsconfig)

This commit is contained in:
2026-09-03 14:56:53 +02:00
parent c64528a1d3
commit cec8018c18
35 changed files with 4095 additions and 2111 deletions
+16
View File
@@ -0,0 +1,16 @@
---
// The one news card — used by the home page and the /news archive.
// `featured` (archive page 1, first item) switches the wide lead variant and
// eager-loads its cover. Cover alt stays empty by design: the image is
// decorative next to the always-visible title.
import { fmt, iso, readMin } from '../lib/news.js';
const { post, featured = false } = Astro.props;
---
<article class={featured ? 'card card--featured' : 'card'}>
{post.data.cover && <img class="cover" src={encodeURI(post.data.cover)} alt="" loading={featured ? 'eager' : 'lazy'} />}
<div class="body">
<h3><a href={`/news/${post.id}`}>{post.data.title}</a></h3>
<p class="desc">{post.data.description}</p>
<div class="meta"><span><svg class="ico" aria-hidden="true" viewBox="0 0 24 24"><rect x="4" y="5" width="16" height="16" rx="2" /><path d="M4 10h16M8 3v4M16 3v4" /></svg><time datetime={iso(post.data.date)}>{fmt(post.data.date)}</time></span><span><svg class="ico" aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></svg>{readMin(post)} min read</span></div>
</div>
</article>
+2 -2
View File
@@ -15,8 +15,8 @@ const news = defineCollection({
// Public-path string, e.g. "/uploads/foo.jpg" — Sveltia uploads media to
// public/uploads, which Astro's image() helper can't validate (src/ only).
cover: z.string().optional(),
// BCP-47 tag when an article is not in English (e.g. "pl") — carried to
// <html lang> so screen readers pick the right voice.
// BCP-47 tag when an article is not in English (e.g. "pl") — set as lang
// on the <article> element so screen readers pick the right voice.
lang: z.string().optional(),
draft: z.boolean().default(false),
}),
+10 -9
View File
@@ -2,10 +2,10 @@
import '../styles/global.css';
// `ambient` opts a page into the whole-page drifting brand wash (home only — see DESIGN.md)
// `type`/`image` feed the social meta: articles pass type="article" + their cover.
const { title = 'CTAO Science Portal', description = 'CTAO Science Portal — demo', ambient = false, type = 'website', image, lang = 'en' } = Astro.props;
// Absolute URLs for canonical/OG/RSS (head pattern from the official Astro blog
// template). `site` is a placeholder domain until the portal has a real one.
const site = Astro.site ?? new URL('https://portal.ctao.org');
const { title = 'CTAO Science Portal', description = 'CTAO Science Portal — demo', ambient = false, type = 'website', image } = Astro.props;
// Absolute URLs for canonical/OG/RSS (head pattern from the official Astro
// blog template). `site` comes from astro.config.mjs and is always set.
const site = Astro.site;
const canonical = new URL(Astro.url.pathname, site);
// SVG covers never reach og:image — link-preview crawlers (Slack/Teams/
// LinkedIn) don't render SVG, so those articles fall back to the brand card.
@@ -26,7 +26,9 @@ const links = [
];
---
<!doctype html>
<html lang={lang}>
{/* Always "en": the chrome (nav/footer) is English; a non-English article
sets lang on its <article> element instead ([slug].astro). */}
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -49,10 +51,9 @@ const links = [
{/* The home hero photo is a CSS background, which browsers discover late —
preloading it moves the page's LCP element to the front of the queue. */}
{path === '/' && <link rel="preload" as="image" href="/brand/hero.jpg" fetchpriority="high" />}
{/* Brand typography (BRAND D.3): Inter (body) + Space Grotesk (headlines),
self-hosted latin woff2 (variable) — @font-face lives in global.css. */}
{/* Both brand fonts ship inlined in the render-blocking stylesheet —
no font swap exists anywhere (see global.css @font-face). */}
{/* Brand typography (BRAND D.3): Inter (body) + Space Grotesk (headlines).
Both ship inlined as data: URIs in the render-blocking stylesheet —
no font request, no swap (see global.css @font-face). */}
</head>
<body class={ambient ? 'has-ambient' : undefined}>
<a class="skip" href="#main">Skip to content</a>
+11
View File
@@ -0,0 +1,11 @@
// Shared news helpers — single source for card metadata and archive chunking.
export const fmt = (d) => new Intl.DateTimeFormat('en-GB', { dateStyle: 'long' }).format(d);
export const iso = (d) => d.toISOString().slice(0, 10);
// Reading time from the Markdown body (~220 wpm) — differentiating card metadata;
// category/author are identical across all articles, so cards omit them (NN/g).
export const readMin = (p) => Math.max(1, Math.round((p.body ?? '').split(/\s+/).length / 220));
// Archive chunking: page 1 holds 25 items (1 featured lead + 24 grid = even
// 3-column rows), later pages 24. Used by BOTH the /news/[...page] route and
// sitemap.xml.js — the page counts cannot drift apart.
export const PAGE_FIRST = 25;
export const PAGE_REST = 24;
+15 -13
View File
@@ -4,17 +4,19 @@ import Base from '../layouts/Base.astro';
<Base title="CTAO Science Portal — Page not found" description="This page does not exist">
{/* Document archetype (DESIGN.md): the document IS the page — h1, one line,
recovery links, and a real GET search form as the way forward. */}
<article class="container article">
<h1>Page not found</h1>
<div class="prose">
<p>The address may be mistyped or the page may have moved — go to the
<a href="/">home page</a> or browse <a href="/news">all news</a>.</p>
</div>
<form class="search-form" role="search" action="/search" method="get">
<label class="sr-only" for="nf-q">Search news</label>
<svg class="ico search-ico" aria-hidden="true" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7" /><path d="m20 20-4.3-4.3" /></svg>
<input id="nf-q" name="q" type="search" placeholder="Search news…" autocomplete="off" />
<button class="btn" type="submit">Search</button>
</form>
</article>
<div class="container article-layout">
<article class="article">
<h1>Page not found</h1>
<div class="prose">
<p>The address may be mistyped or the page may have moved — go to the
<a href="/">home page</a> or browse <a href="/news">all news</a>.</p>
</div>
<form class="search-form" role="search" action="/search" method="get">
<label class="sr-only" for="nf-q">Search news</label>
<svg class="ico search-ico" aria-hidden="true" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7" /><path d="m20 20-4.3-4.3" /></svg>
<input id="nf-q" name="q" type="search" placeholder="Search news…" autocomplete="off" />
<button class="btn" type="submit">Search</button>
</form>
</article>
</div>
</Base>
+6 -4
View File
@@ -1,8 +1,10 @@
---
// Editor entry point (/admin). Sveltia CMS loads its config from ./config.yml
// (served statically from public/admin/config.yml). The external bundle is kept
// inline so Astro doesn't try to process/bundle it. CTAO branding on the sign-in
// screen comes from the officially supported `logo_url` config option.
// (served statically from public/admin/config.yml). The bundle is vendored in
// public/vendor/ so the editor CODE never auto-updates from a CDN (updates are
// deliberate — see README); note the bundle still fetches its own UI fonts
// from jsDelivr at runtime. Kept is:inline so Astro doesn't process/bundle it.
// CTAO branding on the sign-in screen comes from the documented `logo` option.
---
<!doctype html>
<html lang="en">
@@ -25,7 +27,7 @@
</head>
<body>
<noscript><p>The CTAO content editor requires JavaScript.<br />Please enable it, or return to the <a href="/">Science Portal</a>.</p></noscript>
<script is:inline src="https://unpkg.com/@sveltia/cms/dist/sveltia-cms.js"></script>
<script is:inline src="/vendor/sveltia-cms.js"></script>
<script is:inline>
// Officially supported API: article previews rendered with portal-like styles.
window.CMS?.registerPreviewStyle?.('/admin/preview.css');
+2 -15
View File
@@ -1,15 +1,11 @@
---
import { getCollection } from 'astro:content';
import Base from '../layouts/Base.astro';
import NewsCard from '../components/NewsCard.astro';
const posts = (await getCollection('news', ({ data }) => !data.draft))
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
.slice(0, 3);
const fmt = (d) => new Intl.DateTimeFormat('en-GB', { dateStyle: 'long' }).format(d);
const iso = (d) => d.toISOString().slice(0, 10);
// Reading time from the Markdown body (~220 wpm) — differentiating card metadata;
// category/author are identical across all articles, so cards omit them (NN/g).
const readMin = (p) => Math.max(1, Math.round((p.body ?? '').split(/\s+/).length / 220));
// Services per the Science Portal spec. Tiles carry no status chrome — honesty
// lives at the interaction point (REQUIREMENTS §5): each target page opens with
// its own whisper badge. Planned services stay in the grid but subdued (muted,
@@ -91,16 +87,7 @@ const planned = ['Scheduling', 'Science alerts'];
<a class="more" href="/news">All news →</a>
</div>
<div class="grid">
{posts.map((post) => (
<article class="card">
{post.data.cover && <img class="cover" src={encodeURI(post.data.cover)} alt="" loading="lazy" />}
<div class="body">
<h3><a href={`/news/${post.id}`}>{post.data.title}</a></h3>
<p class="desc">{post.data.description}</p>
<div class="meta"><span><svg class="ico" aria-hidden="true" viewBox="0 0 24 24"><rect x="4" y="5" width="16" height="16" rx="2" /><path d="M4 10h16M8 3v4M16 3v4" /></svg><time datetime={iso(post.data.date)}>{fmt(post.data.date)}</time></span><span><svg class="ico" aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></svg>{readMin(post)} min read</span></div>
</div>
</article>
))}
{posts.map((post) => <NewsCard post={post} />)}
</div>
</section>
</Base>
+6 -20
View File
@@ -1,16 +1,18 @@
---
import { getCollection } from 'astro:content';
import Base from '../../layouts/Base.astro';
import NewsCard from '../../components/NewsCard.astro';
import { PAGE_FIRST as FIRST, PAGE_REST as REST } from '../../lib/news.js';
// Paginated archive; the [...page] rest route makes page 1 the bare /news URL,
// then /news/2 … /news/N. Custom slicing instead of paginate(): page 1 holds
// 25 items (1 featured lead + 24 grid = even 3-column rows), later pages 24 —
// paginate() cannot vary page size.
// FIRST items (1 featured lead + 24 grid = even 3-column rows), later pages
// REST — paginate() cannot vary page size. Chunk sizes live in lib/news.js,
// shared with sitemap.xml.js.
export async function getStaticPaths() {
const posts = (await getCollection('news', ({ data }) => !data.draft)).sort(
(a, b) => b.data.date.valueOf() - a.data.date.valueOf(),
);
const FIRST = 25, REST = 24;
const chunks = [posts.slice(0, FIRST)];
for (let i = FIRST; i < posts.length; i += REST) chunks.push(posts.slice(i, i + REST));
const lastPage = chunks.length;
@@ -34,11 +36,6 @@ export async function getStaticPaths() {
const { page } = Astro.props;
const first = page.currentPage === 1;
const fmt = (d) => new Intl.DateTimeFormat('en-GB', { dateStyle: 'long' }).format(d);
const iso = (d) => d.toISOString().slice(0, 10);
// Reading time from the Markdown body (~220 wpm) — differentiating card metadata;
// category/author are identical across all articles, so cards omit them (NN/g).
const readMin = (p) => Math.max(1, Math.round((p.body ?? '').split(/\s+/).length / 220));
// Windowed page list — 1 … n-1 n n+1 … last (0 marks an ellipsis)
const nums = [];
for (let n = 1; n <= page.lastPage; n++) {
@@ -61,18 +58,7 @@ const hrefFor = (n) => (n === 1 ? '/news' : `/news/${n}`);
<section class="container page">
<h2 class="sr-only">{first ? 'All news' : `All news — page ${page.currentPage}`}</h2>
<div class="grid">
{page.data.map((post, i) => (
<article class={first && i === 0 ? 'card card--featured' : 'card'}>
{post.data.cover && (
<img class="cover" src={encodeURI(post.data.cover)} alt="" loading={first && i === 0 ? 'eager' : 'lazy'} />
)}
<div class="body">
<h3><a href={`/news/${post.id}`}>{post.data.title}</a></h3>
<p class="desc">{post.data.description}</p>
<div class="meta"><span><svg class="ico" aria-hidden="true" viewBox="0 0 24 24"><rect x="4" y="5" width="16" height="16" rx="2" /><path d="M4 10h16M8 3v4M16 3v4" /></svg><time datetime={iso(post.data.date)}>{fmt(post.data.date)}</time></span><span><svg class="ico" aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></svg>{readMin(post)} min read</span></div>
</div>
</article>
))}
{page.data.map((post, i) => <NewsCard post={post} featured={first && i === 0} />)}
</div>
{page.lastPage > 1 && (
<nav class="pagination" aria-label="News pages">
+6 -4
View File
@@ -1,6 +1,7 @@
---
import { getCollection, render } from 'astro:content';
import Base from '../../layouts/Base.astro';
import { fmt, readMin as readMinOf } from '../../lib/news.js';
export async function getStaticPaths() {
// Date-sorted so each article knows its sequential neighbours (prev/next
@@ -17,17 +18,18 @@ export async function getStaticPaths() {
const { post, newer, older } = Astro.props;
const { Content, headings } = await render(post);
const toc = headings.filter((h) => h.depth === 2 || h.depth === 3);
const fmt = (d) => new Intl.DateTimeFormat('en-GB', { dateStyle: 'long' }).format(d);
const readMin = Math.max(1, Math.round((post.body ?? '').split(/\s+/).length / 220));
const readMin = readMinOf(post);
---
<Base title={`${post.data.title} — CTAO`} description={post.data.description} type="article" image={post.data.cover && encodeURI(post.data.cover)} lang={post.data.lang}>
<Base title={`${post.data.title} — CTAO`} description={post.data.description} type="article" image={post.data.cover && encodeURI(post.data.cover)}>
{/* Reading progress (scroll-driven CSS, no JS) — styled only where
animation-timeline is supported; elsewhere it stays an empty div. */}
<div class="read-progress" aria-hidden="true"></div>
{/* TOC pattern (MDN/Stripe/NN-g): sticky right rail ≥1200px, collapsed <details>
under the title below that. CSS shows exactly one of the two (DESIGN.md). */}
{/* lang sits on the article, not <html>: the chrome (nav/footer) stays
English for screen readers even when the article body is not. */}
<div class="container article-layout">
<article class="article">
<article class="article" lang={post.data.lang}>
<a class="back" href="/news">← All news</a>
{/* Editorial anatomy (Guardian/BBC/Reuters convention): headline →
standfirst → byline/meta → lead image → body. The standfirst is the
+13 -9
View File
@@ -11,13 +11,17 @@ const { page } = Astro.props;
const { Content } = await render(page);
---
<Base title={`${page.data.title} — CTAO Science Portal`} description={page.data.description}>
<article class="container article">
{/* Static pages are footer-reached documents; same back affordance as
articles ("← All news") so deep links aren't dead ends. */}
<a class="back" href="/">← Home</a>
<h1>{page.data.title}</h1>
<div class="prose">
<Content />
</div>
</article>
{/* Same wrapper pattern as news/[slug] (never .container and .article on
one element — both set max-width and would depend on rule order). */}
<div class="container article-layout">
<article class="article">
{/* Static pages are footer-reached documents; same back affordance as
articles ("← All news") so deep links aren't dead ends. */}
<a class="back" href="/">← Home</a>
<h1>{page.data.title}</h1>
<div class="prose">
<Content />
</div>
</article>
</div>
</Base>
+1 -1
View File
@@ -7,7 +7,7 @@ const esc = (s = '') =>
s.replace(/[<>&'"]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', "'": '&apos;', '"': '&quot;' })[c]);
export async function GET(context) {
const site = context.site ?? new URL('https://portal.ctao.org');
const site = context.site; // always set in astro.config.mjs
const posts = (await getCollection('news', ({ data }) => !data.draft))
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
.slice(0, 30);
+7 -6
View File
@@ -1,19 +1,20 @@
// Sitemap — hand-rolled static endpoint (no @astrojs/sitemap dependency; our URL
// set is fully known at build time). /admin is intentionally excluded.
import { getCollection } from 'astro:content';
import { PAGE_FIRST, PAGE_REST } from '../lib/news.js';
// Same chunk math as src/pages/news/[...page].astro (keep in sync): page 1
// holds 25 items (featured lead + 24), later pages 24 — so the page count
// here can never drift from the archive's.
const FIRST = 25, REST = 24;
const newsPageCount = (n) => (n <= FIRST ? 1 : 1 + Math.ceil((n - FIRST) / REST));
// Chunk sizes are imported from lib/news.js — the same values the
// /news/[...page] route slices with, so the page count here cannot drift.
const newsPageCount = (n) => (n <= PAGE_FIRST ? 1 : 1 + Math.ceil((n - PAGE_FIRST) / PAGE_REST));
export async function GET(context) {
const site = context.site ?? new URL('https://portal.ctao.org');
const site = context.site; // always set in astro.config.mjs
const news = (await getCollection('news', ({ data }) => !data.draft))
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
const pages = await getCollection('pages');
// Go-live note: /login and /dashboard are mock pages — drop them from this
// list (or gate them) before robots.txt stops disallowing everything.
const urls = [
'/', '/news', '/proposals', '/dashboard', '/support', '/search', '/login',
...Array.from({ length: newsPageCount(news.length) - 1 }, (_, i) => `/news/${i + 2}`),
+1
View File
@@ -18,6 +18,7 @@ const channels = [
{/* One page-level whisper badge — the tiles below stay clean (they are
not interactive; honesty lives here, at the point users arrive). */}
<span class="badge-mock">Mock — target: SUSS User Support system</span>
<h2 class="sr-only">Support channels</h2>
<div class="grid">
{channels.map((c) => (
<article class="card tile">
+20 -23
View File
File diff suppressed because one or more lines are too long