feat: astro built-ins — news pagination, prefetch, RSS, OG/SEO meta, TOC scrollspy (Starlight-pattern)
This commit is contained in:
@@ -38,7 +38,7 @@ const services = [
|
||||
|
||||
<script is:inline src="/search-client.js"></script>
|
||||
<script is:inline>
|
||||
newsSearch({ input: 'home-q', list: 'home-suggest', status: 'home-suggest-status', limit: 6, unit: 'suggestion(s)' });
|
||||
newsSearch({ input: 'home-q', list: 'home-suggest', status: 'home-suggest-status', limit: 6, unit: 'suggestion' });
|
||||
</script>
|
||||
|
||||
{/* Surface rhythm (DESIGN.md): services on Moon Gray, editorial news on white */}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
import Base from '../../layouts/Base.astro';
|
||||
|
||||
// Paginated archive via Astro's built-in paginate(): the [...page] rest route
|
||||
// makes page 1 the bare /news URL, then /news/2 … /news/N.
|
||||
export async function getStaticPaths({ paginate }) {
|
||||
const posts = (await getCollection('news', ({ data }) => !data.draft)).sort(
|
||||
(a, b) => b.data.date.valueOf() - a.data.date.valueOf(),
|
||||
);
|
||||
return paginate(posts, { pageSize: 24 });
|
||||
}
|
||||
|
||||
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);
|
||||
// Windowed page list — 1 … n-1 n n+1 … last (0 marks an ellipsis)
|
||||
const nums = [];
|
||||
for (let n = 1; n <= page.lastPage; n++) {
|
||||
if (n === 1 || n === page.lastPage || Math.abs(n - page.currentPage) <= 1) nums.push(n);
|
||||
else if (nums.at(-1) !== 0) nums.push(0);
|
||||
}
|
||||
const hrefFor = (n) => (n === 1 ? '/news' : `/news/${n}`);
|
||||
---
|
||||
<Base
|
||||
title={first ? 'CTAO Science Portal — News' : `CTAO Science Portal — News, page ${page.currentPage}`}
|
||||
description={first ? 'News and announcements of the CTAO' : `News and announcements of the CTAO — page ${page.currentPage} of ${page.lastPage}`}
|
||||
>
|
||||
<section class="hero-band">
|
||||
<div class="band-bg" aria-hidden="true"></div>
|
||||
<div class="container hero">
|
||||
<div class="eyebrow">Cherenkov Telescope Array Observatory</div>
|
||||
<h1>News & Announcements</h1>
|
||||
<p>Exploring the Universe at the Highest Energies</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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">
|
||||
<span class="cat">{post.data.category}</span>
|
||||
<h3><a href={`/news/${post.id}`}>{post.data.title}</a></h3>
|
||||
<p class="desc">{post.data.description}</p>
|
||||
<div class="meta">{post.data.author} · <time datetime={iso(post.data.date)}>{fmt(post.data.date)}</time></div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
{page.lastPage > 1 && (
|
||||
<nav class="pagination" aria-label="News pages">
|
||||
{page.url.prev
|
||||
? <a class="page-link page-step" href={page.url.prev} rel="prev">← Newer</a>
|
||||
: <span class="page-link page-step" aria-hidden="true">← Newer</span>}
|
||||
<ol>
|
||||
{nums.map((n) => (
|
||||
<li>
|
||||
{n === 0
|
||||
? <span class="page-gap" aria-hidden="true">…</span>
|
||||
: <a class="page-link" href={hrefFor(n)} aria-current={n === page.currentPage ? 'page' : undefined}><span class="sr-only">Page </span>{n}</a>}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{page.url.next
|
||||
? <a class="page-link page-step" href={page.url.next} rel="next">Older →</a>
|
||||
: <span class="page-link page-step" aria-hidden="true">Older →</span>}
|
||||
</nav>
|
||||
)}
|
||||
</section>
|
||||
</Base>
|
||||
@@ -12,7 +12,7 @@ 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);
|
||||
---
|
||||
<Base title={`${post.data.title} — CTAO`} description={post.data.description}>
|
||||
<Base title={`${post.data.title} — CTAO`} description={post.data.description} type="article" image={post.data.cover && encodeURI(post.data.cover)}>
|
||||
{/* 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). */}
|
||||
<div class="container article-layout">
|
||||
@@ -47,3 +47,54 @@ const fmt = (d) => new Intl.DateTimeFormat('en-GB', { dateStyle: 'long' }).forma
|
||||
)}
|
||||
</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('.toc a[href^="#"]')];
|
||||
if (links.length) {
|
||||
let current = [];
|
||||
const setCurrent = (id) => {
|
||||
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) => {
|
||||
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) => {
|
||||
for (const e of entries) {
|
||||
if (e.isIntersecting) { setCurrent(headingFor(e.target)); break; }
|
||||
}
|
||||
};
|
||||
let observer;
|
||||
const observe = () => {
|
||||
observer?.disconnect();
|
||||
// Narrow band below the sticky header (64px) — Starlight's rootMargin trick
|
||||
const top = 76, band = 64;
|
||||
observer = new IntersectionObserver(onIntersect, {
|
||||
rootMargin: `-${top}px 0px ${top + band - document.documentElement.clientHeight}px`,
|
||||
});
|
||||
blocks.forEach((b) => observer.observe(b));
|
||||
};
|
||||
observe();
|
||||
let timer;
|
||||
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.at(-1)));
|
||||
}
|
||||
}, { passive: true });
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
import Base from '../../layouts/Base.astro';
|
||||
|
||||
const posts = (await getCollection('news', ({ data }) => !data.draft)).sort(
|
||||
(a, b) => b.data.date.valueOf() - a.data.date.valueOf(),
|
||||
);
|
||||
const fmt = (d) => new Intl.DateTimeFormat('en-GB', { dateStyle: 'long' }).format(d);
|
||||
const iso = (d) => d.toISOString().slice(0, 10);
|
||||
---
|
||||
<Base title="CTAO Science Portal — News" description="News and announcements of the CTAO">
|
||||
<section class="hero-band">
|
||||
<div class="band-bg" aria-hidden="true"></div>
|
||||
<div class="container hero">
|
||||
<div class="eyebrow">Cherenkov Telescope Array Observatory</div>
|
||||
<h1>News & Announcements</h1>
|
||||
<p>Exploring the Universe at the Highest Energies</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="container page">
|
||||
<h2 class="sr-only">All news</h2>
|
||||
<div class="grid">
|
||||
{posts.map((post, i) => (
|
||||
<article class={i === 0 ? 'card card--featured' : 'card'}>
|
||||
{post.data.cover && (
|
||||
<img class="cover" src={encodeURI(post.data.cover)} alt="" loading={i === 0 ? 'eager' : 'lazy'} />
|
||||
)}
|
||||
<div class="body">
|
||||
<span class="cat">{post.data.category}</span>
|
||||
<h3><a href={`/news/${post.id}`}>{post.data.title}</a></h3>
|
||||
<p class="desc">{post.data.description}</p>
|
||||
<div class="meta">{post.data.author} · <time datetime={iso(post.data.date)}>{fmt(post.data.date)}</time></div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
@@ -0,0 +1,43 @@
|
||||
// RSS 2.0 feed — hand-rolled static endpoint like search.json.js (no @astrojs/rss
|
||||
// dependency; the spec is 20 lines of XML). Newest 30 items, absolute URLs from
|
||||
// the configured `site`.
|
||||
import { getCollection } from 'astro:content';
|
||||
|
||||
const esc = (s = '') =>
|
||||
s.replace(/[<>&'"]/g, (c) => ({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' })[c]);
|
||||
|
||||
export async function GET(context) {
|
||||
const site = context.site ?? new URL('https://portal.ctao.org');
|
||||
const posts = (await getCollection('news', ({ data }) => !data.draft))
|
||||
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
|
||||
.slice(0, 30);
|
||||
|
||||
const items = posts
|
||||
.map((p) => {
|
||||
const url = new URL(`/news/${p.id}`, site).href;
|
||||
return ` <item>
|
||||
<title>${esc(p.data.title)}</title>
|
||||
<link>${url}</link>
|
||||
<guid isPermaLink="true">${url}</guid>
|
||||
<pubDate>${p.data.date.toUTCString()}</pubDate>
|
||||
<description>${esc(p.data.description)}</description>
|
||||
<category>${esc(p.data.category)}</category>
|
||||
</item>`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title>CTAO Science Portal — News</title>
|
||||
<link>${new URL('/news', site).href}</link>
|
||||
<description>News and announcements of the Cherenkov Telescope Array Observatory</description>
|
||||
<language>en</language>
|
||||
<lastBuildDate>${(posts[0]?.data.date ?? new Date()).toUTCString()}</lastBuildDate>
|
||||
<atom:link href="${new URL('/rss.xml', site).href}" rel="self" type="application/rss+xml"/>
|
||||
${items}
|
||||
</channel>
|
||||
</rss>
|
||||
`;
|
||||
return new Response(xml, { headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' } });
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// 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';
|
||||
|
||||
const PAGE_SIZE = 24; // keep in sync with src/pages/news/[...page].astro
|
||||
|
||||
export async function GET(context) {
|
||||
const site = context.site ?? new URL('https://portal.ctao.org');
|
||||
const news = (await getCollection('news', ({ data }) => !data.draft))
|
||||
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
|
||||
const pages = await getCollection('pages');
|
||||
|
||||
const urls = [
|
||||
'/', '/news', '/proposals', '/dashboard', '/support', '/search', '/login',
|
||||
...Array.from({ length: Math.ceil(news.length / PAGE_SIZE) - 1 }, (_, i) => `/news/${i + 2}`),
|
||||
...pages.map((p) => `/pages/${p.id}`),
|
||||
].map((path) => ` <url><loc>${new URL(path, site).href}</loc></url>`);
|
||||
const articles = news.map(
|
||||
(p) => ` <url><loc>${new URL(`/news/${p.id}`, site).href}</loc><lastmod>${p.data.date.toISOString().slice(0, 10)}</lastmod></url>`,
|
||||
);
|
||||
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${[...urls, ...articles].join('\n')}
|
||||
</urlset>
|
||||
`;
|
||||
return new Response(xml, { headers: { 'Content-Type': 'application/xml; charset=utf-8' } });
|
||||
}
|
||||
Reference in New Issue
Block a user