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
145 lines
7.0 KiB
Plaintext
145 lines
7.0 KiB
Plaintext
---
|
|
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>
|