feat: astro built-ins — news pagination, prefetch, RSS, OG/SEO meta, TOC scrollspy (Starlight-pattern)
This commit is contained in:
+18
-1
@@ -1,7 +1,13 @@
|
||||
---
|
||||
import '../styles/global.css';
|
||||
// `ambient` opts a page into the whole-page drifting brand wash (home only — see DESIGN.md)
|
||||
const { title = 'CTAO Science Portal', description = 'CTAO Science Portal — demo', ambient = false } = Astro.props;
|
||||
// `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 } = 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 canonical = new URL(Astro.url.pathname, site);
|
||||
const ogImage = new URL(image || '/brand/og-card.jpg', site);
|
||||
const path = Astro.url.pathname;
|
||||
const isActive = (href) => (href === '/' ? path === '/' : path.startsWith(href));
|
||||
// Navigation = services from the Science Portal spec (REQUIREMENTS.md §2/§5).
|
||||
@@ -21,6 +27,17 @@ const links = [
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
<link rel="canonical" href={canonical} />
|
||||
{/* Social cards (Open Graph + Twitter) — article cover when present, brand card otherwise */}
|
||||
<meta property="og:site_name" content="CTAO Science Portal" />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:type" content={type} />
|
||||
<meta property="og:url" content={canonical} />
|
||||
<meta property="og:image" content={ogImage} />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<link rel="alternate" type="application/rss+xml" title="CTAO Science Portal — News" href={new URL('/rss.xml', site)} />
|
||||
<link rel="sitemap" href="/sitemap.xml" />
|
||||
<link rel="icon" href="/brand/CTAO_Logo_positive.svg" type="image/svg+xml" />
|
||||
{/* Brand typography (BRAND D.3): Inter (body) + Space Grotesk (headlines),
|
||||
self-hosted latin woff2 (variable) — @font-face lives in global.css. */}
|
||||
|
||||
@@ -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' } });
|
||||
}
|
||||
+28
-1
@@ -236,7 +236,7 @@ img { max-width: 100%; height: auto; display: block; }
|
||||
}
|
||||
.suggest:empty { display: none; }
|
||||
.suggest li { display: flex; align-items: center; gap: var(--space-s); padding: 0 12px; border-radius: calc(var(--radius) - 8px); }
|
||||
.suggest li:hover { background: var(--moon); }
|
||||
.suggest li:hover, .suggest li:focus-within { background: var(--moon); }
|
||||
.suggest a {
|
||||
flex: 1; display: flex; align-items: center; min-height: 44px;
|
||||
color: var(--text); font-weight: 500; font-size: 0.94rem; text-decoration: none;
|
||||
@@ -310,6 +310,25 @@ img { max-width: 100%; height: auto; display: block; }
|
||||
.card--featured .desc { -webkit-line-clamp: 3; line-clamp: 3; }
|
||||
}
|
||||
|
||||
/* Pagination (news archive) — prev/next + windowed page numbers; ≥44px targets.
|
||||
Pattern mined from AstroPaper/Astro blog templates, numbers added for 9 pages. */
|
||||
.pagination {
|
||||
display: flex; align-items: center; justify-content: center; flex-wrap: wrap;
|
||||
gap: 4px var(--space-s); margin-top: var(--space-xl);
|
||||
}
|
||||
.pagination ol { display: flex; flex-wrap: wrap; justify-content: center; list-style: none; margin: 0; padding: 0; gap: 4px; }
|
||||
.page-link {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
min-width: 44px; min-height: 44px; padding: 0 12px;
|
||||
border-radius: var(--radius-pill); text-decoration: none; font-weight: 500; color: var(--text);
|
||||
}
|
||||
a.page-link:hover { background: var(--moon); color: var(--indigo); }
|
||||
.page-link[aria-current="page"] { background: var(--galaxy); color: #fff; }
|
||||
.page-step { font-weight: 600; }
|
||||
a.page-step { color: var(--link); }
|
||||
span.page-step { color: var(--muted); } /* disabled end stop — non-interactive */
|
||||
.page-gap { display: inline-flex; align-items: center; justify-content: center; min-width: 24px; min-height: 44px; color: var(--muted); }
|
||||
|
||||
/* Service tiles — card tokens, text-first */
|
||||
.tile { padding: var(--space-m); gap: 8px; }
|
||||
.tile h3 { margin: 0; }
|
||||
@@ -347,6 +366,8 @@ img { max-width: 100%; height: auto; display: block; }
|
||||
.toc .d3 { padding-left: var(--space-s); }
|
||||
.toc a { text-decoration: none; display: inline-flex; align-items: center; min-height: 24px; }
|
||||
.toc a:hover { text-decoration: underline; }
|
||||
/* Scrollspy state (set by the [slug].astro script) — functional, not motion */
|
||||
.toc a[aria-current="true"] { color: var(--galaxy); font-weight: 600; }
|
||||
.toc--inline { margin-bottom: var(--space-l); }
|
||||
.toc--rail { display: none; }
|
||||
@media (min-width: 1200px) {
|
||||
@@ -413,6 +434,9 @@ img { max-width: 100%; height: auto; display: block; }
|
||||
color: var(--muted); white-space: nowrap; font-size: 0.78rem;
|
||||
background: var(--moon); border-radius: var(--radius-pill); padding: 2px 10px;
|
||||
}
|
||||
/* Matched-term highlight in results/suggestions (Starlight search pattern) —
|
||||
brand tint only, no new hues */
|
||||
mark { background: var(--tint-cherenkov); color: inherit; border-radius: calc(var(--radius) - 12px); padding: 0 2px; }
|
||||
|
||||
/* Mock/status badges — every mock must be labelled (REQUIREMENTS.md §5) */
|
||||
.badge-mock, .badge-ext {
|
||||
@@ -513,6 +537,9 @@ img { max-width: 100%; height: auto; display: block; }
|
||||
/* Hover feedback */
|
||||
.card, .search-results li { transition: border-color 0.15s ease, box-shadow 0.2s ease, transform 0.2s ease; }
|
||||
.card:hover { transform: translateY(-2px); }
|
||||
/* Editorial cover micro-zoom on card hover (AstroWind pattern; card clips it) */
|
||||
.card .cover { transition: transform 0.3s ease; }
|
||||
.card:hover .cover, .card:focus-within .cover { transform: scale(1.03); }
|
||||
.btn, .login-mock, .navlink { transition: background-color 0.15s ease, color 0.15s ease; }
|
||||
}
|
||||
@keyframes nebula { /* inert unless applied inside the block above */
|
||||
|
||||
Reference in New Issue
Block a user