astro check restored: tsconfig excludes public/ (the 2 MB vendored CMS bundle was what exhausted the heap) and extends tsconfigs/strict; type annotations in template scripts, z from astro/zod; exact version pins; compressHTML true (lossless whitespace, no words glued to links) and explicit trailingSlash/build.format; README: upgrading section

This commit is contained in:
2026-09-06 15:12:56 +02:00
parent e8b47ac888
commit bd95bacfc5
10 changed files with 53 additions and 25 deletions
+2 -1
View File
@@ -29,4 +29,5 @@ presentation. Pages are `.astro` templates (plain HTML).
(astro.config.mjs) — the default minifier breaks `animation-timeline`. (astro.config.mjs) — the default minifier breaks `animation-timeline`.
- `--header-h` drives the sticky-header offsets (anchors, TOC rail, reading - `--header-h` drives the sticky-header offsets (anchors, TOC rail, reading
progress); change the header height only through the token. progress); change the header height only through the token.
- Deterministic gate before pushing: `npm run check` (build + link check). - Deterministic gate before pushing: `npm run check` (types + build + link
check, a few seconds).
+18 -1
View File
@@ -50,9 +50,12 @@ git clone https://astro-git.isl-dev.grid.cyfronet.pl/ctao/content.git ../ctao-co
./scripts/link-content.sh # copies news/pages/uploads into the dev tree ./scripts/link-content.sh # copies news/pages/uploads into the dev tree
npm run dev # http://localhost:4321 npm run dev # http://localhost:4321
npm run build # static output in dist/ npm run build # static output in dist/
npm run check # deterministic gate: build + internal-link check npm run check # deterministic gate: astro check (types) + build + internal-link check
``` ```
`tsconfig.json` excludes `public/` on purpose: the vendored CMS bundle there is
2 MB of minified JS and would make `astro check` run out of memory.
## Layout ## Layout
| Path | What | | Path | What |
@@ -76,3 +79,17 @@ Saving commits to `main`; the machine polls and republishes automatically
Note for styling work: `public/admin/preview.css` mirrors the `.prose` rules Note for styling work: `public/admin/preview.css` mirrors the `.prose` rules
from `global.css` so the editor preview matches the site 1:1 — keep them in from `global.css` so the editor preview matches the site 1:1 — keep them in
sync (both files carry a KEEP IN SYNC comment). sync (both files carry a KEEP IN SYNC comment).
## Upgrading
Versions are pinned exactly; an upgrade is a deliberate change, never a side
effect of `npm install`. Astro ships security fixes only for the current and
one previous major, so plan one upgrade per major rather than skipping several.
```
npx @astrojs/upgrade # moves astro and official integrations together
npm run check # types + build + links must pass
npm run preview # verify the real dist/ output, not the dev server
```
No experimental flags in `astro.config.mjs`: they can change in minor releases.
+8
View File
@@ -6,6 +6,14 @@ export default defineConfig({
// Public origin — used to build absolute URLs (canonical, og:*, RSS, // Public origin — used to build absolute URLs (canonical, og:*, RSS,
// sitemap). Change when the portal moves to its production domain. // sitemap). Change when the portal moves to its production domain.
site: 'https://astro.isl-dev.grid.cyfronet.pl', site: 'https://astro.isl-dev.grid.cyfronet.pl',
// URL contract, pinned explicitly (these are the defaults): changing either
// later rewrites every public URL. nginx serves /path/ via try_files $uri/.
trailingSlash: 'ignore',
build: { format: 'directory' },
// Lossless whitespace handling instead of the v7 default 'jsx', which drops
// line breaks around inline elements and silently glues words to links when
// a template line wraps before <a>. Templates render as written.
compressHTML: true,
// Built-in prefetch on every internal link (no per-link attributes needed). // Built-in prefetch on every internal link (no per-link attributes needed).
// Default 'hover' strategy: near-instant navigation without the bandwidth // Default 'hover' strategy: near-instant navigation without the bandwidth
// cost of 'viewport' on a 24-card grid; auto-falls back to 'tap' on // cost of 'viewport' on a 24-card grid; auto-falls back to 'tap' on
+3 -3
View File
@@ -8,11 +8,11 @@
"name": "ctao-portal", "name": "ctao-portal",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"astro": "^7.2.10" "astro": "7.2.10"
}, },
"devDependencies": { "devDependencies": {
"@astrojs/check": "^0.9.10", "@astrojs/check": "0.9.10",
"typescript": "^6.0.3" "typescript": "6.0.3"
} }
}, },
"node_modules/@astrojs/check": { "node_modules/@astrojs/check": {
+4 -4
View File
@@ -7,13 +7,13 @@
"dev": "astro dev", "dev": "astro dev",
"build": "astro build", "build": "astro build",
"preview": "astro preview", "preview": "astro preview",
"check": "astro build && node scripts/check-links.mjs" "check": "astro check && astro build && node scripts/check-links.mjs"
}, },
"dependencies": { "dependencies": {
"astro": "^7.2.10" "astro": "7.2.10"
}, },
"devDependencies": { "devDependencies": {
"@astrojs/check": "^0.9.10", "@astrojs/check": "0.9.10",
"typescript": "^6.0.3" "typescript": "6.0.3"
} }
} }
+2 -1
View File
@@ -1,4 +1,5 @@
import { defineCollection, z } from 'astro:content'; import { defineCollection } from 'astro:content';
import { z } from 'astro/zod';
import { glob } from 'astro/loaders'; import { glob } from 'astro/loaders';
// News/articles live as Markdown files in src/content/news/*.md. // News/articles live as Markdown files in src/content/news/*.md.
+1 -1
View File
@@ -11,7 +11,7 @@ const canonical = new URL(Astro.url.pathname, site);
// LinkedIn) don't render SVG, so those articles fall back to the brand card. // LinkedIn) don't render SVG, so those articles fall back to the brand card.
const ogImage = new URL(image && !image.endsWith('.svg') ? image : '/brand/og-card.jpg', site); const ogImage = new URL(image && !image.endsWith('.svg') ? image : '/brand/og-card.jpg', site);
const path = Astro.url.pathname; const path = Astro.url.pathname;
const isActive = (href) => (href === '/' ? path === '/' : path.startsWith(href)); const isActive = (href: string) => (href === '/' ? path === '/' : path.startsWith(href));
// Navigation = services from the Science Portal spec (REQUIREMENTS.md §2/§5). // Navigation = services from the Science Portal spec (REQUIREMENTS.md §2/§5).
// Order: serial-position effect (NN/g) — News (universal entry) first, Support // Order: serial-position effect (NN/g) — News (universal entry) first, Support
// (help convention) last; Data/Proposals by task frequency; Dashboard is the // (help convention) last; Data/Proposals by task frequency; Dashboard is the
+2 -2
View File
@@ -16,7 +16,7 @@ export async function getStaticPaths() {
const chunks = [posts.slice(0, FIRST)]; const chunks = [posts.slice(0, FIRST)];
for (let i = FIRST; i < posts.length; i += REST) chunks.push(posts.slice(i, i + REST)); for (let i = FIRST; i < posts.length; i += REST) chunks.push(posts.slice(i, i + REST));
const lastPage = chunks.length; const lastPage = chunks.length;
const hrefOf = (n) => (n === 1 ? '/news' : `/news/${n}`); const hrefOf = (n: number) => (n === 1 ? '/news' : `/news/${n}`);
return chunks.map((data, i) => { return chunks.map((data, i) => {
const n = i + 1; const n = i + 1;
return { return {
@@ -42,7 +42,7 @@ for (let n = 1; n <= page.lastPage; n++) {
if (n === 1 || n === page.lastPage || Math.abs(n - page.currentPage) <= 1) nums.push(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); else if (nums.at(-1) !== 0) nums.push(0);
} }
const hrefFor = (n) => (n === 1 ? '/news' : `/news/${n}`); const hrefFor = (n: number) => (n === 1 ? '/news' : `/news/${n}`);
--- ---
<Base <Base
title={first ? 'CTAO Science Portal - News' : `CTAO Science Portal - News, page ${page.currentPage}`} title={first ? 'CTAO Science Portal - News' : `CTAO Science Portal - News, page ${page.currentPage}`}
+11 -10
View File
@@ -96,10 +96,10 @@ const readMin = readMinOf(post);
// enters it is mapped back to its governing h2/h3, whose TOC link(s) get // 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). // aria-current="true" (set in BOTH renderings — inline <details> and rail).
// Progressive enhancement: without JS the TOC is plain working anchors. // Progressive enhancement: without JS the TOC is plain working anchors.
const links = [...document.querySelectorAll('.toc a[href^="#"]')]; const links = [...document.querySelectorAll<HTMLAnchorElement>('.toc a[href^="#"]')];
if (links.length) { if (links.length) {
let current = []; let current: HTMLAnchorElement[] = [];
const setCurrent = (id) => { const setCurrent = (id: string | null) => {
const next = id ? links.filter((a) => decodeURIComponent(a.hash.slice(1)) === id) : []; const next = id ? links.filter((a) => decodeURIComponent(a.hash.slice(1)) === id) : [];
if (next[0] === current[0]) return; if (next[0] === current[0]) return;
for (const a of current) a.removeAttribute('aria-current'); for (const a of current) a.removeAttribute('aria-current');
@@ -108,35 +108,36 @@ const readMin = readMinOf(post);
}; };
const blocks = [...document.querySelectorAll('.prose > *')]; const blocks = [...document.querySelectorAll('.prose > *')];
// Governing heading = the block itself or the nearest h2/h3[id] above it // Governing heading = the block itself or the nearest h2/h3[id] above it
const headingFor = (el) => { const headingFor = (el: Element) => {
for (let i = blocks.indexOf(el); i >= 0; i--) { for (let i = blocks.indexOf(el); i >= 0; i--) {
if (blocks[i].matches('h2[id], h3[id]')) return blocks[i].id; if (blocks[i].matches('h2[id], h3[id]')) return blocks[i].id;
} }
return null; // intro before the first heading — nothing highlighted return null; // intro before the first heading — nothing highlighted
}; };
const onIntersect = (entries) => { const onIntersect = (entries: IntersectionObserverEntry[]) => {
for (const e of entries) { for (const e of entries) {
if (e.isIntersecting) { setCurrent(headingFor(e.target)); break; } if (e.isIntersecting) { setCurrent(headingFor(e.target)); break; }
} }
}; };
let observer; let observer: IntersectionObserver | undefined;
const observe = () => { const observe = () => {
observer?.disconnect(); observer?.disconnect();
// Narrow band below the sticky header (--header-h 56px + 12px slack; // 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 // JS can't read the token cheaply, keep in sync) — Starlight's rootMargin trick
const top = 68, band = 64; const top = 68, band = 64;
observer = new IntersectionObserver(onIntersect, { const io = new IntersectionObserver(onIntersect, {
rootMargin: `-${top}px 0px ${top + band - document.documentElement.clientHeight}px`, rootMargin: `-${top}px 0px ${top + band - document.documentElement.clientHeight}px`,
}); });
blocks.forEach((b) => observer.observe(b)); observer = io;
blocks.forEach((b) => io.observe(b));
}; };
observe(); observe();
let timer; let timer: ReturnType<typeof setTimeout> | undefined;
addEventListener('resize', () => { clearTimeout(timer); timer = setTimeout(observe, 200); }); addEventListener('resize', () => { clearTimeout(timer); timer = setTimeout(observe, 200); });
// Short final section: at page bottom, the last heading wins // Short final section: at page bottom, the last heading wins
addEventListener('scroll', () => { addEventListener('scroll', () => {
if (innerHeight + scrollY >= document.documentElement.scrollHeight - 4) { if (innerHeight + scrollY >= document.documentElement.scrollHeight - 4) {
setCurrent(headingFor(blocks.at(-1))); setCurrent(headingFor(blocks[blocks.length - 1]));
} }
}, { passive: true }); }, { passive: true });
} }
+2 -2
View File
@@ -1,5 +1,5 @@
{ {
"extends": "astro/tsconfigs/base", "extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"], "include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"] "exclude": ["dist", "public"]
} }