CTAO Science Portal: static Astro site with git-based CMS (Sveltia + Gitea)

Portal code only; editorial content lives in the ctao/content repo on the
demo machine's Gitea and is overlaid at build time (see README.md and
deploy/README.md). Live demo: https://astro.isl-dev.grid.cyfronet.pl
This commit is contained in:
2026-09-08 13:53:30 +02:00
parent 845c1ccf0d
commit 0772462742
50 changed files with 11564 additions and 44 deletions
+144
View File
@@ -0,0 +1,144 @@
---
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
// pattern of every editorial site — keeps readers in the content flow).
const posts = (await getCollection('news', ({ data }) => !data.draft)).sort(
(a, b) => b.data.date.valueOf() - a.data.date.valueOf(),
);
return posts.map((post, i) => ({
params: { slug: post.id },
props: { post, newer: posts[i - 1] ?? null, older: posts[i + 1] ?? null },
}));
}
const { post, newer, older } = Astro.props;
const { Content, headings } = await render(post);
const toc = headings.filter((h) => h.depth === 2 || h.depth === 3);
const readMin = readMinOf(post);
---
<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. */}
{/* 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" 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
description made visible — cards already show it, the article should too. */}
<h1>{post.data.title}</h1>
<p class="standfirst">{post.data.description}</p>
{/* Meta — one quiet muted line: category · author · date · reading time */}
<div class="meta">
<span class="cat"><svg class="ico" aria-hidden="true" viewBox="0 0 24 24"><path d="M20 13.2 13.2 20a2 2 0 0 1-2.9 0L4 13.7V4h9.7l6.3 6.3a2 2 0 0 1 0 2.9Z" stroke-linejoin="round" /><circle cx="8.5" cy="8.5" r="1" /></svg>{post.data.category}</span>·
<span><svg class="ico" aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="8" r="4" /><path d="M4.5 20c1.6-3.8 4.6-5.5 7.5-5.5s5.9 1.7 7.5 5.5" /></svg>{post.data.author}</span>·
<time datetime={post.data.date.toISOString().slice(0, 10)}><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>{fmt(post.data.date)}</time>·
<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} min read</span>
</div>
{post.data.cover && <img class="cover" src={encodeURI(post.data.cover)} alt="" />}
{/* TOC sits between lead image and body (Wikipedia/GOV.UK contents
position): the press head above stays unbroken, and the list is
adjacent to the content it indexes. */}
{toc.length >= 3 && (
<details class="toc toc--inline">
<summary>On this page <svg class="ico" aria-hidden="true" viewBox="0 0 24 24"><path d="m5 9 7 7 7-7" /></svg></summary>
<ol>
{toc.map((h) => (
<li class={h.depth === 3 ? 'd3' : undefined}><a href={`#${h.slug}`}>{h.text}</a></li>
))}
</ol>
</details>
)}
<div class="prose">
<Content />
</div>
{(newer || older) && (
<nav class="post-nav" aria-label="More news">
{newer && (
<a class="post-nav-prev" rel="prev" href={`/news/${newer.id}`}>
<small>← Newer</small>
<span class="post-nav-title">{newer.data.title}</span>
</a>
)}
{older && (
<a class="post-nav-next" rel="next" href={`/news/${older.id}`}>
<small>Older →</small>
<span class="post-nav-title">{older.data.title}</span>
</a>
)}
</nav>
)}
</article>
{toc.length >= 3 && (
<nav class="toc toc--rail" aria-label="On this page">
<strong>On this page</strong>
<ol>
{toc.map((h) => (
<li class={h.depth === 3 ? 'd3' : undefined}><a href={`#${h.slug}`}>{h.text}</a></li>
))}
</ol>
</nav>
)}
</div>
</Base>
<script>
// TOC scrollspy — minimal vanilla take on Starlight's starlight-toc.ts:
// an IntersectionObserver band near the viewport top; whichever prose block
// enters it is mapped back to its governing h2/h3, whose TOC link(s) get
// aria-current="true" (set in BOTH renderings — inline <details> and rail).
// Progressive enhancement: without JS the TOC is plain working anchors.
const links = [...document.querySelectorAll<HTMLAnchorElement>('.toc a[href^="#"]')];
if (links.length) {
let current: HTMLAnchorElement[] = [];
const setCurrent = (id: string | null) => {
const next = id ? links.filter((a) => decodeURIComponent(a.hash.slice(1)) === id) : [];
if (next[0] === current[0]) return;
for (const a of current) a.removeAttribute('aria-current');
for (const a of next) a.setAttribute('aria-current', 'true');
current = next;
};
const blocks = [...document.querySelectorAll('.prose > *')];
// Governing heading = the block itself or the nearest h2/h3[id] above it
const headingFor = (el: Element) => {
for (let i = blocks.indexOf(el); i >= 0; i--) {
if (blocks[i].matches('h2[id], h3[id]')) return blocks[i].id;
}
return null; // intro before the first heading — nothing highlighted
};
const onIntersect = (entries: IntersectionObserverEntry[]) => {
for (const e of entries) {
if (e.isIntersecting) { setCurrent(headingFor(e.target)); break; }
}
};
let observer: IntersectionObserver | undefined;
const observe = () => {
observer?.disconnect();
// Narrow band below the sticky header (--header-h 56px + 12px slack;
// JS can't read the token cheaply, keep in sync) — Starlight's rootMargin trick
const top = 68, band = 64;
const io = new IntersectionObserver(onIntersect, {
rootMargin: `-${top}px 0px ${top + band - document.documentElement.clientHeight}px`,
});
observer = io;
blocks.forEach((b) => io.observe(b));
};
observe();
let timer: ReturnType<typeof setTimeout> | undefined;
addEventListener('resize', () => { clearTimeout(timer); timer = setTimeout(observe, 200); });
// Short final section: at page bottom, the last heading wins
addEventListener('scroll', () => {
if (innerHeight + scrollY >= document.documentElement.scrollHeight - 4) {
setCurrent(headingFor(blocks[blocks.length - 1]));
}
}, { passive: true });
}
</script>