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:
@@ -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>
|
||||
Reference in New Issue
Block a user