handoff pass: vendored sveltia bundle, astro pinned ^7.2.10, root README + refreshed deploy runbook, review fixes (coverless card grid, shared NewsCard/lib, real site origin, nginx security headers, linger step, dead config removed, preview.css sync, tsconfig)
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.astro/
|
||||
.DS_Store
|
||||
|
||||
@@ -43,17 +43,17 @@ Type scale — the ONLY font sizes in the codebase (no ad-hoc rem values):
|
||||
| `--fs-h1` | clamp(2.25–3.5rem) | Page/article headlines |
|
||||
| `--fs-display` | clamp(2.5–4.75rem) | Home hero only |
|
||||
|
||||
Always end `font-family` with a generic (`sans-serif`) — lint requirement.
|
||||
Font tokens end with a generic (`sans-serif`); use them bare —
|
||||
`var(--font-body), sans-serif` doubles the generic.
|
||||
|
||||
Font loading (decided after the navigation-blink incident): self-hosted latin
|
||||
woff2, preloaded in `Base.astro`, `font-display: swap` + metric-matched Arial
|
||||
fallbacks for BOTH families (`Inter Fallback`, `Space Grotesk Fallback` —
|
||||
size-adjust/ascent/descent computed from the real font metrics, capsize
|
||||
method). Never `optional`: its ~100ms window loses to per-navigation
|
||||
revalidation (dev/preview serve `Cache-Control: no-cache`), which randomly
|
||||
committed whole pageviews to Arial — the "fonts flash between navigations"
|
||||
bug. With swap + matched metrics every page converges on brand fonts and the
|
||||
swap is layout-neutral.
|
||||
Font loading (prevents any navigation blink): BOTH families are inlined as
|
||||
base64 `data:` URIs inside the render-blocking `global.css` — no separate
|
||||
font fetch exists, so there is no swap window and no font flash between
|
||||
navigations. Metric-matched Arial fallbacks (`Inter Fallback`,
|
||||
`Space Grotesk Fallback` — size-adjust/ascent/descent computed from the real
|
||||
font metrics, capsize method) cover only the pre-CSS paint. The standalone
|
||||
`public/fonts/*.woff2` files are referenced ONLY by
|
||||
`public/admin/preview.css` (CMS entry preview) — they look unused but aren't.
|
||||
|
||||
## Spacing scale (fluid; mobile ≈ min, desktop ≈ max)
|
||||
|
||||
@@ -305,8 +305,8 @@ inventing a section order. The navy band is reserved for `/` and `/login`.
|
||||
|
||||
## Serving the demo
|
||||
|
||||
Phone/tunnel demos run the **production build**: `npm run demo`
|
||||
(= `astro build && astro preview`, same port 4321 so the tunnel URL keeps
|
||||
Phone/tunnel demos run the **production build**:
|
||||
`npm run build && npm run preview` (same port 4321 so the tunnel URL keeps
|
||||
working). `astro dev` through the tunnel adds HMR + dev-toolbar module
|
||||
traffic and serves everything `no-cache` — every navigation re-negotiates
|
||||
assets over the tunnel, which reads as blinking/slow paints that do NOT
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# CTAO Science Portal — static site + git-based CMS
|
||||
|
||||
Public portal for CTAO news, built as a fully static site. Editors get a
|
||||
browser WYSIWYG editor (Sveltia CMS) that commits Markdown to a Gitea repo;
|
||||
a build job turns commits into static HTML. No application or database runs
|
||||
on the public path.
|
||||
|
||||
Live demo: <https://astro.isl-dev.grid.cyfronet.pl> ·
|
||||
content repo (Gitea): <https://astro-git.isl-dev.grid.cyfronet.pl/ctao/portal>
|
||||
|
||||
## Stack
|
||||
|
||||
- **Astro** (static output). Pages are `.astro` templates (plain HTML with a
|
||||
JS frontmatter block). No React/Vue/Svelte components, no client-side
|
||||
framework.
|
||||
- **Plain CSS** — everything lives in `src/styles/global.css` as design
|
||||
tokens (custom properties) + rules. No Tailwind, no preprocessor.
|
||||
The design system (tokens, type scale, spacing, motion policy) is
|
||||
documented in `DESIGN.md` — read it before touching styles.
|
||||
- **Sveltia CMS** — a single prebuilt JS bundle, vendored in
|
||||
`public/vendor/sveltia-cms.js` and loaded on `/admin/`
|
||||
(`src/pages/admin/index.astro`), configured by `public/admin/config.yml`.
|
||||
It runs entirely in the editor's browser and talks to the Gitea API
|
||||
(OAuth PKCE). There is no CMS server. To update it:
|
||||
`curl -sL https://unpkg.com/@sveltia/cms/dist/sveltia-cms.js -o public/vendor/sveltia-cms.js`
|
||||
- Runtime dependencies: none beyond Astro. This is deliberate — keep it
|
||||
that way.
|
||||
|
||||
## Develop
|
||||
|
||||
```
|
||||
npm ci
|
||||
npm run dev # http://localhost:4321
|
||||
npm run build # static output in dist/
|
||||
```
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | What |
|
||||
|---|---|
|
||||
| `src/pages/` | Routes (`.astro` templates), incl. `news/`, `search`, RSS/sitemap |
|
||||
| `src/layouts/Base.astro` | HTML shell: head, header/nav, footer |
|
||||
| `src/content/news/*.md` | Articles — Markdown + frontmatter (schema in `src/content.config.ts`) |
|
||||
| `src/styles/global.css` | All CSS: tokens + components + prose |
|
||||
| `DESIGN.md` | Design-system rules the CSS implements |
|
||||
| `public/admin/` | CMS config (`config.yml`) + editor preview styles (`preview.css`) |
|
||||
| `public/uploads/` | Editor-uploaded media (committed as WebP by the CMS) |
|
||||
| `deploy/` | Runbook + container/systemd units for the demo machine — see `deploy/README.md` |
|
||||
|
||||
## Editing content
|
||||
|
||||
Editors use `/admin/` (link in the footer) and sign in with a Gitea account.
|
||||
Saving commits to `main`; the machine polls and republishes automatically
|
||||
(seconds). Full history/rollback = git history in Gitea. Articles with
|
||||
`draft: true` are excluded from the build.
|
||||
|
||||
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
|
||||
sync (both files carry a KEEP IN SYNC comment).
|
||||
+3
-10
@@ -3,9 +3,9 @@ import { defineConfig } from 'astro/config';
|
||||
// Static output (zero runtime) — the whole point of the git-based approach.
|
||||
export default defineConfig({
|
||||
output: 'static',
|
||||
// Placeholder domain until the real one exists — only used to build absolute
|
||||
// URLs (canonical, og:*, RSS, sitemap). Swap once the portal has a home.
|
||||
site: 'https://portal.ctao.org',
|
||||
// Public origin — used to build absolute URLs (canonical, og:*, RSS,
|
||||
// sitemap). Change when the portal moves to its production domain.
|
||||
site: 'https://astro.isl-dev.grid.cyfronet.pl',
|
||||
// Built-in prefetch on every internal link (no per-link attributes needed).
|
||||
// Default 'hover' strategy: near-instant navigation without the bandwidth
|
||||
// cost of 'viewport' on a 24-card grid; auto-falls back to 'tap' on
|
||||
@@ -24,13 +24,6 @@ export default defineConfig({
|
||||
// Vite blocks unknown Host headers by default; the demo is viewed through an
|
||||
// ephemeral cloudflared quick tunnel, so allow any *.trycloudflare.com host.
|
||||
allowedHosts: ['.trycloudflare.com'],
|
||||
// Same-origin path for the Sveltia/Decap local backend proxy (default port
|
||||
// moved to 8082 to avoid colliding with Metro on 8081). Proxying it here
|
||||
// means the CMS admin talks to one origin, so editing also works through the
|
||||
// cloudflared tunnel from the phone.
|
||||
proxy: {
|
||||
'/api/v1': { target: 'http://localhost:8082', changeOrigin: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Build image for the CTAO portal demo: pinned Node + git.
|
||||
# Why: the host has node 24 but NO git (and we have no sudo); a 2-line image
|
||||
# keeps the whole toolchain pinned and independent of host packages.
|
||||
# keeps node pinned and independent of host packages (git comes from the
|
||||
# alpine repo unpinned — alpine drops old package versions, pinning is moot).
|
||||
# Built ONCE at install, never pulled again at runtime:
|
||||
# podman build -t localhost/ctao-portal-build:1 -f Containerfile.build .
|
||||
FROM docker.io/library/node:24-alpine@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd
|
||||
|
||||
+54
-39
@@ -11,62 +11,68 @@ Uninstall restores the machine exactly (see bottom). No secrets in any file.
|
||||
├── repo/ # clone of the portal repo (created by first build)
|
||||
├── releases/ # <sha>/ dirs + `current` symlink (what nginx serves)
|
||||
├── state/ # last-built SHA
|
||||
├── npm-cache/ # npm cache for the build container (created by build.sh)
|
||||
├── bin/build.sh # copied from deploy/ (source of truth stays in the repo)
|
||||
└── config/nginx.conf
|
||||
```
|
||||
|
||||
| Port | What | Exposed how |
|
||||
Host prerequisites: `podman`, `curl`, `jq` (build.sh checks and says which is
|
||||
missing). Everything else runs inside containers.
|
||||
|
||||
| Port | What | Exposed as |
|
||||
|---|---|---|
|
||||
| 3000 | Gitea | ingress vhost (TODO: ask Hubert) |
|
||||
| 8080 | portal (nginx, static) | ingress vhost (TODO: ask Hubert) |
|
||||
| 3000 | Gitea | https://astro-git.isl-dev.grid.cyfronet.pl (ingress vhost) |
|
||||
| 8080 | portal (nginx, static) | https://astro.isl-dev.grid.cyfronet.pl (ingress vhost) |
|
||||
|
||||
## Install (each step reviewed before running; [W] = writes to the machine)
|
||||
|
||||
1. **[W]** `mkdir -p ~/ctao-portal-demo/{gitea-data,gitea-config,releases,state,bin,config} ~/.config/containers/systemd ~/.config/systemd/user`
|
||||
2. **[W]** Copy files from this dir (scp from the Mac):
|
||||
1. **[W]** `loginctl enable-linger $USER` — without lingering every user unit
|
||||
dies at logout and nothing starts after a reboot. Verify:
|
||||
`loginctl show-user $USER -p Linger` → `Linger=yes`.
|
||||
2. **[W]** `mkdir -p ~/ctao-portal-demo/{gitea-data,gitea-config,releases,state,bin,config} ~/.config/containers/systemd ~/.config/systemd/user`
|
||||
3. **[W]** Copy files from this dir (scp):
|
||||
- `ctao-demo-gitea.container`, `ctao-demo-web.container` → `~/.config/containers/systemd/`
|
||||
- `ctao-portal-build.service`, `ctao-portal-build.timer` → `~/.config/systemd/user/`
|
||||
- `build.sh` → `~/ctao-portal-demo/bin/` (`chmod +x`)
|
||||
- `nginx.conf` → `~/ctao-portal-demo/config/`
|
||||
3. **[W]** Pull + pin images (one-time, needs internet):
|
||||
`podman pull docker.io/gitea/gitea:1.27-rootless docker.io/library/nginx:stable-alpine docker.io/library/node:24-alpine`
|
||||
- `Containerfile.build` → anywhere (needed once, for the next step)
|
||||
4. **[W]** Pull the images at the digests pinned in the unit files (one-time,
|
||||
needs internet) and build the build image:
|
||||
`podman pull docker.io/gitea/gitea@sha256:<digest from ctao-demo-gitea.container>`
|
||||
`podman pull docker.io/library/nginx@sha256:<digest from ctao-demo-web.container>`
|
||||
`podman build -t localhost/ctao-portal-build:1 -f Containerfile.build .`
|
||||
Then `podman images --digests` → paste the sha256 digests into both `.container` files.
|
||||
4. **[W]** `systemctl --user daemon-reload && systemctl --user start ctao-demo-gitea`
|
||||
5. **[W]** Create the Gitea admin — run YOURSELF in your own terminal (password
|
||||
is prompted/printed there only; never goes through chat or shell history):
|
||||
(Upgrading later = pick new digests deliberately, update the pins in the
|
||||
`.container` files / `Containerfile.build`, re-pull, re-build.)
|
||||
5. **[W]** `systemctl --user daemon-reload && systemctl --user start ctao-demo-gitea`
|
||||
6. **[W]** Create the Gitea admin — run interactively in a terminal so the
|
||||
password never lands in a file or shell history:
|
||||
`podman exec -it ctao-demo-gitea gitea admin user create --admin --username <you> --email <you@…> --random-password`
|
||||
6. **[W]** In the Gitea UI: create org `ctao`, repo `portal` (public read).
|
||||
Push from the Mac through an SSH port-forward:
|
||||
`ssh -L 3300:localhost:3000 strapi-experimental.cyfronet` then
|
||||
`git remote add machine http://localhost:3300/ctao/portal.git && git push machine main`
|
||||
7. **[W]** `systemctl --user enable --now ctao-portal-build.timer` — first run
|
||||
7. **[W]** In the Gitea UI: create org `ctao`, repo `portal` (public read).
|
||||
Then, from your workstation, push the portal repo over the vhost with a
|
||||
repo-scoped token:
|
||||
`git push https://<user>:<token>@astro-git.isl-dev.grid.cyfronet.pl/ctao/portal.git main`
|
||||
8. **[W]** `systemctl --user enable --now ctao-portal-build.timer` — first run
|
||||
clones + `npm ci` + builds (minutes); later runs are seconds. Wait until
|
||||
`journalctl --user -u ctao-portal-build -n 5` shows `published <sha>`
|
||||
(starting nginx earlier just serves 404s until the first build lands).
|
||||
8. **[W]** `systemctl --user start ctao-demo-web`
|
||||
9. **[R]** Verify: `curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/`
|
||||
9. **[W]** `systemctl --user start ctao-demo-web`
|
||||
10. **[R]** Verify: `curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/`
|
||||
and `journalctl --user -u ctao-portal-build -n 20` (shows measured build times).
|
||||
|
||||
## After Hubert assigns the vhosts
|
||||
## CMS sign-in (Sveltia ↔ Gitea OAuth)
|
||||
|
||||
1. Replace both `TODO(vhost)` values in `ctao-demo-gitea.container`
|
||||
(ROOT_URL → gitea vhost, CORS `*` → portal origin, add
|
||||
`GITEA__cors__SCHEME=https`); `systemctl --user daemon-reload && systemctl --user restart ctao-demo-gitea`.
|
||||
2. In Gitea UI: Settings → Applications → new OAuth2 app for Sveltia
|
||||
(redirect: `https://<portal-vhost>/admin/`), PKCE, no client secret.
|
||||
3. Put the Gitea vhost URL + client id into `public/admin/config.yml` in the
|
||||
portal repo, commit, push — the timer publishes it like any other change.
|
||||
- Gitea OAuth2 app (PKCE, `confidential_client=false`, no secret) with
|
||||
redirect `https://astro.isl-dev.grid.cyfronet.pl/admin/`; its client id is
|
||||
the `app_id` in `public/admin/config.yml`.
|
||||
- CORS is pinned in `ctao-demo-gitea.container`: `ALLOW_DOMAIN` takes the
|
||||
FULL portal origin with scheme (a bare hostname silently disables CORS in
|
||||
Gitea 1.27; there is no `SCHEME` key) and `HEADERS` must include
|
||||
`Authorization` or authenticated API calls from the browser fail.
|
||||
- OAuth requires a secure context: the vhosts must stay HTTPS.
|
||||
- Changing origins later = edit the quadlet env + the OAuth app's redirect
|
||||
URI + `config.yml`, then `systemctl --user daemon-reload && systemctl --user restart ctao-demo-gitea`.
|
||||
|
||||
## Ask Hubert (one message)
|
||||
|
||||
1. Two ingress vhosts → `192.168.10.15:8080` (portal) and `:3000` (gitea) —
|
||||
same mechanism as `strapi.isl-dev…:1337`.
|
||||
2. Are vhosts public-internet or VPN-scopable? (Gitea preferably VPN-only.)
|
||||
3. Does Cyfronet offer static-file hosting on the ingress itself? If yes, we
|
||||
drop our nginx container entirely and rsync builds there instead.
|
||||
|
||||
## Uninstall (leaves zero traces)
|
||||
## Uninstall (leaves only podman's own storage metadata)
|
||||
|
||||
```
|
||||
systemctl --user disable --now ctao-portal-build.timer
|
||||
@@ -74,14 +80,22 @@ systemctl --user stop ctao-demo-web ctao-demo-gitea
|
||||
rm ~/.config/containers/systemd/ctao-demo-*.container \
|
||||
~/.config/systemd/user/ctao-portal-build.{service,timer}
|
||||
systemctl --user daemon-reload
|
||||
podman rmi localhost/ctao-portal-build:1 docker.io/gitea/gitea:1.27-rootless docker.io/library/nginx:stable-alpine
|
||||
podman rmi localhost/ctao-portal-build:1 docker.io/gitea/gitea:1.27-rootless \
|
||||
docker.io/library/nginx:stable-alpine docker.io/library/node:24-alpine
|
||||
rm -rf ~/ctao-portal-demo
|
||||
loginctl disable-linger $USER # only if nothing else of yours should survive logout
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Publish latency = poll (≤10 s) + build (measured 1 s on M-series; expect
|
||||
4–8 s on the 2 vCPU VM — every build's time lands in the journal).
|
||||
- **Only site content auto-deploys.** Changes to `deploy/*` need a manual
|
||||
re-copy: `build.sh` → `bin/`, `nginx.conf` → `config/` +
|
||||
`systemctl --user restart ctao-demo-web`, unit files →
|
||||
`~/.config/…` + `systemctl --user daemon-reload` (+ restart). This is
|
||||
deliberate: the build pipeline must not execute host-side code straight
|
||||
from the content repo.
|
||||
- Publish latency = poll (≤10 s) + build (measured: astro build 5–7 s on the
|
||||
2 vCPU VM, whole build.sh 16–21 s; every build's time lands in the journal).
|
||||
- Internet needed only for: image pulls (install) and `npm ci` when the
|
||||
lockfile changes. Routine rebuilds are fully offline.
|
||||
- Memory caps (`MemoryHigh`) keep us polite next to Outline + Strapi;
|
||||
@@ -93,4 +107,5 @@ rm -rf ~/ctao-portal-demo
|
||||
Gitea on localhost:3000 but cannot reach host loopback services. (Rootless
|
||||
netavark bridges don't work here: no `ip_tables` kernel module, no sudo.)
|
||||
- Secrets inventory: Gitea admin password (typed interactively, lives only
|
||||
in Gitea's DB) — that's the complete list. Build/poll/serve use none.
|
||||
in Gitea's DB) and repo-scoped push tokens (managed in Gitea) — that's the
|
||||
complete list. Build/poll/serve use none.
|
||||
|
||||
+23
-9
@@ -21,6 +21,12 @@ REPO_INTERNAL="${REPO_INTERNAL:-http://localhost:3000/$REPO.git}"
|
||||
KEEP="${KEEP:-3}" # released builds to retain
|
||||
mkdir -p "$BASE/repo" "$BASE/releases" "$BASE/state" "$BASE/npm-cache"
|
||||
|
||||
# Host prerequisites (everything else runs inside containers). Fail loud —
|
||||
# a missing tool is permanent, unlike a Gitea hiccup below.
|
||||
for tool in curl jq podman; do
|
||||
command -v "$tool" >/dev/null || { echo "missing host tool: $tool"; exit 1; }
|
||||
done
|
||||
|
||||
# --- 1. Cheap poll: branch head via the local Gitea API (host curl + jq) ---
|
||||
sha=$(curl -fsS --max-time 5 "$GITEA_URL/api/v1/repos/$REPO/branches/$BRANCH" \
|
||||
| jq -r '.commit.id' || true)
|
||||
@@ -30,7 +36,10 @@ if [[ ! "$sha" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "poll failed (gitea unreachable?) — skipping this tick"
|
||||
exit 0
|
||||
fi
|
||||
[[ "$sha" == "$(cat "$BASE/state/last-built" 2>/dev/null)" ]] && exit 0
|
||||
# Skip only if this sha is both recorded AND still present in releases/
|
||||
# (a deleted release dir must trigger a rebuild, not an eternal skip).
|
||||
[[ "$sha" == "$(cat "$BASE/state/last-built" 2>/dev/null)" \
|
||||
&& -d "$BASE/releases/$sha" ]] && exit 0
|
||||
|
||||
echo "building $sha"
|
||||
t0=$(date +%s)
|
||||
@@ -48,18 +57,21 @@ podman run --rm --network="$BUILD_NETNS" --memory=1g \
|
||||
-v "$BASE/releases:/work/releases:z" \
|
||||
-v "$BASE/npm-cache:/root/.npm:z" \
|
||||
-w /work "$BUILD_IMAGE" sh -ec '
|
||||
git config --global safe.directory "*"
|
||||
git config --global safe.directory /work/repo
|
||||
[ -d repo/.git ] || git clone --branch "$BRANCH" "$REPO_URL" repo
|
||||
git -C repo remote set-url origin "$REPO_URL" # self-heal if the URL changes
|
||||
git -C repo fetch --quiet origin "$BRANCH"
|
||||
git -C repo checkout --quiet "$SHA"
|
||||
# --force: the working copy is disposable; a stray tracked-file edit must
|
||||
# not wedge every future build.
|
||||
git -C repo checkout --quiet --force "$SHA"
|
||||
cd repo
|
||||
lock=$(sha256sum package-lock.json | cut -d" " -f1)
|
||||
if [ ! -d node_modules ] || [ "$lock" != "$(cat .deps-hash 2>/dev/null)" ]; then
|
||||
# --ignore-scripts: (1) kills the malicious-postinstall vector from npm
|
||||
# deps entirely, (2) avoids the esbuild ETXTBSY postinstall race in
|
||||
# rootless containers. esbuild ships its binary as an optional dep, so
|
||||
# nothing needed here actually requires lifecycle scripts.
|
||||
# --ignore-scripts: (1) removes the install-time postinstall vector from
|
||||
# npm deps (build-time repo code still runs `npm run build` below — the
|
||||
# netns confinement is the control for that), (2) avoids the esbuild
|
||||
# ETXTBSY postinstall race in rootless containers. esbuild ships its
|
||||
# binary as an optional dep, so nothing here needs lifecycle scripts.
|
||||
npm ci --ignore-scripts --no-audit --no-fund
|
||||
echo "$lock" > .deps-hash
|
||||
fi
|
||||
@@ -76,11 +88,13 @@ ln -s "$sha" "$BASE/releases/.current.$$"
|
||||
mv -Tf "$BASE/releases/.current.$$" "$BASE/releases/current"
|
||||
echo "$sha" > "$BASE/state/last-built"
|
||||
|
||||
# --- 4. Prune old releases (never touches `current` — it is always newest).
|
||||
# --- 4. Prune old releases. `current`'s target is excluded explicitly —
|
||||
# mtime ordering makes it newest today, but nothing should depend on that.
|
||||
# `|| true`: an empty match must not fail the unit after a successful publish
|
||||
# (grep exits 1 under pipefail when there is nothing to prune).
|
||||
cd "$BASE/releases"
|
||||
ls -1t | grep -vx current | tail -n +"$((KEEP + 1))" | while read -r old; do
|
||||
cur=$(readlink current || true)
|
||||
ls -1t | grep -vx current | grep -vx -- "$cur" | tail -n +"$((KEEP + 1))" | while read -r old; do
|
||||
rm -rf -- "$old"
|
||||
done || true
|
||||
|
||||
|
||||
@@ -22,25 +22,26 @@ UserNS=keep-id:uid=1000,gid=1000
|
||||
# so it sees Gitea on localhost:3000 while the HOST loopback stays invisible.
|
||||
Volume=%h/ctao-portal-demo/gitea-data:/var/lib/gitea:Z
|
||||
Volume=%h/ctao-portal-demo/gitea-config:/etc/gitea:Z
|
||||
# Bound on all interfaces DELIBERATELY: the ingress that terminates the
|
||||
# public vhost runs on a separate box and reaches this VM over the network —
|
||||
# a 127.0.0.1 bind would cut it off.
|
||||
PublishPort=3000:3000
|
||||
|
||||
# Env-driven config — re-applied on every start, no hand-edited app.ini
|
||||
# (see .skills/gitea/SKILL.md). Secrets: none here; the admin account is
|
||||
# created interactively after first start (README).
|
||||
Environment=GITEA__server__HTTP_PORT=3000
|
||||
# Public URL via the Cyfronet ingress (vhost by Hubert, 2026-07-29).
|
||||
# Public URL via the Cyfronet ingress vhost.
|
||||
Environment=GITEA__server__ROOT_URL=https://astro-git.isl-dev.grid.cyfronet.pl/
|
||||
Environment=GITEA__server__DISABLE_SSH=true
|
||||
Environment=GITEA__database__DB_TYPE=sqlite3
|
||||
Environment=GITEA__security__INSTALL_LOCK=true
|
||||
Environment=GITEA__service__DISABLE_REGISTRATION=true
|
||||
Environment=GITEA__mailer__ENABLED=false
|
||||
# First `git push` auto-creates the repo (no UI step); public so the build
|
||||
# pipeline can clone anonymously — content is the public site anyway.
|
||||
Environment=GITEA__repository__ENABLE_PUSH_CREATE_USER=true
|
||||
# Repos default to public so the build pipeline can clone anonymously —
|
||||
# content is the public site anyway. The `ctao/portal` repo itself is
|
||||
# created in the UI (README step 6).
|
||||
Environment=GITEA__repository__DEFAULT_PRIVATE=public
|
||||
# Push-created repos have their OWN default (true = private) — learned the hard way:
|
||||
Environment=GITEA__repository__DEFAULT_PUSH_CREATE_PRIVATE=false
|
||||
# Sveltia is served from the portal vhost and calls the Gitea API cross-origin
|
||||
# — CORS locked to exactly that origin. ALLOW_DOMAIN takes FULL origins with
|
||||
# scheme (verified in the 1.27 config cheat sheet; a SCHEME key no longer
|
||||
|
||||
@@ -15,11 +15,14 @@ Image=docker.io/library/nginx:stable-alpine@sha256:97d490c12ba55b4946b01546d1c3e
|
||||
# the config file is exclusive to nginx (:Z).
|
||||
Volume=%h/ctao-portal-demo/releases:/srv/releases:ro,z
|
||||
Volume=%h/ctao-portal-demo/config/nginx.conf:/etc/nginx/conf.d/default.conf:ro,Z
|
||||
# All-interfaces bind is deliberate — the ingress box reaches us over the
|
||||
# network (see the matching note in ctao-demo-gitea.container).
|
||||
PublishPort=8080:80
|
||||
|
||||
[Service]
|
||||
Restart=on-failure
|
||||
MemoryHigh=64M
|
||||
MemoryMax=128M
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
|
||||
+19
-4
@@ -7,28 +7,43 @@ server {
|
||||
server_name _;
|
||||
root /srv/releases/current;
|
||||
charset utf-8;
|
||||
server_tokens off;
|
||||
error_page 404 /404.html; # Astro emits 404.html at the site root
|
||||
# Directory redirects (/admin -> /admin/) must stay relative: an absolute
|
||||
# redirect is built from listen port 80 and loses the real port whenever the
|
||||
# site is reached through a tunnel or a proxy on a non-default port.
|
||||
absolute_redirect off;
|
||||
|
||||
# Security headers are REPEATED in every location on purpose: nginx
|
||||
# `add_header` inheritance is all-or-nothing — any add_header in a location
|
||||
# discards ALL server-level ones, so server-level headers would silently
|
||||
# vanish. `always` keeps them on error responses (404) too.
|
||||
|
||||
# Fingerprinted build assets (/_astro/<name>.<hash>.*) — immutable
|
||||
location /_astro/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
}
|
||||
|
||||
# Editor-uploaded media (stable paths, may be re-uploaded) — short cache
|
||||
location /uploads/ {
|
||||
add_header Cache-Control "public, max-age=3600";
|
||||
add_header Cache-Control "public, max-age=3600" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
}
|
||||
|
||||
# Everything else: HTML pages, feeds, /admin (Sveltia is static files too)
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
add_header Cache-Control "no-cache";
|
||||
add_header Cache-Control "no-cache" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_types text/css application/javascript application/json image/svg+xml application/rss+xml text/xml;
|
||||
gzip_types text/css application/javascript application/json image/svg+xml application/rss+xml text/xml application/xml;
|
||||
}
|
||||
|
||||
Generated
+326
-1904
File diff suppressed because it is too large
Load Diff
+4
-9
@@ -1,19 +1,14 @@
|
||||
{
|
||||
"name": "ctao-demo-git-cms",
|
||||
"name": "ctao-portal",
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "astro dev --port 4321 --host",
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview --port 4321 --host",
|
||||
"demo": "astro build && astro preview --port 4321 --host",
|
||||
"cms-proxy": "PORT=8082 decap-server"
|
||||
"preview": "astro preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"astro": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"decap-server": "latest"
|
||||
"astro": "^7.2.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ collections:
|
||||
- { name: category, label: "Category", widget: string, default: "news" }
|
||||
- { name: author, label: "Author", widget: string, default: "CTAO" }
|
||||
- { name: cover, label: "Cover image", widget: image, required: false }
|
||||
- { name: lang, label: "Language (if not English, e.g. pl)", widget: string, required: false }
|
||||
- { name: draft, label: "Draft (unpublished)", widget: boolean, default: false }
|
||||
- { name: body, label: "Body", widget: markdown }
|
||||
|
||||
|
||||
@@ -5,9 +5,17 @@
|
||||
dropped from selectors (the preview pane has no such wrapper).
|
||||
KEEP IN SYNC: when you touch `.prose` or the tokens in global.css, update
|
||||
the corresponding value here (a matching reminder sits next to `.prose`).
|
||||
Fonts: same families as the site (site inlines them; the preview loads them
|
||||
from Google Fonts — editors' browsers are online by definition). */
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400..700&family=Space+Grotesk:wght@500..700&display=swap");
|
||||
Fonts: same self-hosted woff2 files the site uses, same origin. (The
|
||||
Sveltia bundle still loads its own UI fonts from jsDelivr — only the
|
||||
PREVIEW pane is CDN-free.) */
|
||||
@font-face {
|
||||
font-family: "Inter"; font-weight: 400 700; font-display: swap;
|
||||
src: url("/fonts/inter-latin.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Space Grotesk"; font-weight: 500 700; font-display: swap;
|
||||
src: url("/fonts/space-grotesk-latin.woff2") format("woff2");
|
||||
}
|
||||
|
||||
:root {
|
||||
/* tokens copied from global.css :root */
|
||||
@@ -22,6 +30,7 @@
|
||||
--fs-s: 0.9rem;
|
||||
--fs-l: 1.125rem;
|
||||
--fs-xl: 1.5rem;
|
||||
--fs-h1: clamp(2.25rem, 5vw, 3.5rem);
|
||||
--radius: 16px;
|
||||
}
|
||||
|
||||
@@ -42,7 +51,8 @@ a { color: var(--link); text-decoration: underline; text-underline-offset: 3px;
|
||||
|
||||
/* = global.css `.prose` rules, selectors unwrapped = */
|
||||
body { line-height: 1.65; overflow-wrap: break-word; }
|
||||
h1 { color: var(--galaxy); }
|
||||
a { overflow-wrap: anywhere; }
|
||||
h1 { color: var(--galaxy); font-size: var(--fs-h1); }
|
||||
h2 { color: var(--galaxy); margin: 44px 0 12px; font-size: var(--fs-xl); }
|
||||
h3 { color: var(--galaxy); margin: 30px 0 8px; font-size: var(--fs-l); }
|
||||
p { margin: 0 0 16px; text-wrap: pretty; }
|
||||
@@ -52,7 +62,7 @@ blockquote {
|
||||
margin: 22px 0; padding: 12px 20px; border-left: 3px solid var(--galaxy);
|
||||
background: var(--moon); color: var(--text);
|
||||
}
|
||||
img { max-width: 100%; height: auto; border: 1px solid var(--border); border-radius: var(--radius); }
|
||||
img { display: block; max-width: 100%; height: auto; border: 1px solid var(--border); border-radius: var(--radius); }
|
||||
img + em, p > em:only-child { color: var(--muted); font-size: var(--fs-s); }
|
||||
hr { border: 0; border-top: 1px solid var(--border); margin: 32px 0; }
|
||||
table { display: block; width: 100%; overflow-x: auto; border-collapse: collapse; margin: 0 0 16px; font-size: var(--fs-s); }
|
||||
|
||||
+29
-6
@@ -26,7 +26,15 @@
|
||||
let index = null;
|
||||
let timer;
|
||||
async function ensureIndex() {
|
||||
if (!index) index = await (await fetch('/search.json')).json();
|
||||
if (index) return index;
|
||||
try {
|
||||
index = await (await fetch('/search.json')).json();
|
||||
} catch {
|
||||
// Network hiccup: say so instead of failing silently; index stays
|
||||
// null so the next keystroke retries.
|
||||
status.textContent = 'Search is unavailable right now.';
|
||||
return null;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
// First title match wrapped in <mark> (Starlight search pattern) — built
|
||||
@@ -77,7 +85,9 @@
|
||||
frag.append(label('Recent'));
|
||||
for (const term of r) frag.append(row([term], '/search?q=' + encodeURIComponent(term), term));
|
||||
}
|
||||
const fresh = (await ensureIndex()).slice(0, Math.max(1, 5 - r.length));
|
||||
const idx = await ensureIndex();
|
||||
if (!idx) return;
|
||||
const fresh = idx.slice(0, Math.max(1, 5 - r.length));
|
||||
if (input.value.trim().length >= 2) return; // typed meanwhile — run() owns the list
|
||||
frag.append(label('Latest news'));
|
||||
for (const p of fresh) {
|
||||
@@ -95,6 +105,7 @@
|
||||
const q = input.value.trim().toLowerCase();
|
||||
if (q.length < 2) { list.replaceChildren(); idle(); return; }
|
||||
const idx = await ensureIndex();
|
||||
if (!idx) { list.replaceChildren(); return; }
|
||||
const hits = idx
|
||||
.filter((p) => (p.title + ' ' + p.description + ' ' + p.category).toLowerCase().includes(q))
|
||||
.slice(0, opts.limit);
|
||||
@@ -111,6 +122,17 @@
|
||||
li.append(small);
|
||||
frag.append(li);
|
||||
}
|
||||
// Dropdown only: an empty list would collapse (.suggest:empty) and the
|
||||
// panel would just vanish — show the no-results line sighted users too
|
||||
// (the status line above is sr-only in the header; /search shows a
|
||||
// visible notice instead).
|
||||
if (!hits.length && opts.dismiss) {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'suggest-hint';
|
||||
li.setAttribute('aria-hidden', 'true'); // the status line announces it
|
||||
li.textContent = 'No results for “' + input.value.trim() + '”';
|
||||
frag.append(li);
|
||||
}
|
||||
list.replaceChildren(frag);
|
||||
hint();
|
||||
}
|
||||
@@ -122,13 +144,14 @@
|
||||
const a = e.target.closest && e.target.closest('a');
|
||||
remember((a && a.dataset.q) || input.value);
|
||||
});
|
||||
// Dropdown dismiss (opt-in — /search keeps its results list). A suggestion
|
||||
// list is a NON-MODAL combobox popup (ARIA combobox pattern): interacting
|
||||
// outside the field+list closes it but the interaction is NOT swallowed —
|
||||
// Dropdown dismiss (opt-in — /search keeps its results list). The
|
||||
// suggestion list is a NON-MODAL popup (combobox-style; plain
|
||||
// Tab-reachable links, no roving arrow-key focus): interacting outside
|
||||
// the field+list closes it but the interaction is NOT swallowed —
|
||||
// unlike the modal header panels, which scrim-dismiss in Base.astro.
|
||||
if (opts.dismiss) {
|
||||
const box = input.closest('form') || input.parentElement;
|
||||
const hide = () => { clearTimeout(timer); list.innerHTML = ''; status.textContent = ''; };
|
||||
const hide = () => { clearTimeout(timer); list.replaceChildren(); status.textContent = ''; };
|
||||
document.addEventListener('pointerdown', (e) => { if (list.firstChild && !box.contains(e.target)) hide(); });
|
||||
// relatedTarget may be null mid-click on our own links — pointerdown covers that path
|
||||
box.addEventListener('focusout', (e) => { if (e.relatedTarget && !box.contains(e.relatedTarget)) hide(); });
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 357 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 177 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 829 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 122 KiB |
Vendored
+3418
File diff suppressed because one or more lines are too long
@@ -0,0 +1,16 @@
|
||||
---
|
||||
// The one news card — used by the home page and the /news archive.
|
||||
// `featured` (archive page 1, first item) switches the wide lead variant and
|
||||
// eager-loads its cover. Cover alt stays empty by design: the image is
|
||||
// decorative next to the always-visible title.
|
||||
import { fmt, iso, readMin } from '../lib/news.js';
|
||||
const { post, featured = false } = Astro.props;
|
||||
---
|
||||
<article class={featured ? 'card card--featured' : 'card'}>
|
||||
{post.data.cover && <img class="cover" src={encodeURI(post.data.cover)} alt="" loading={featured ? 'eager' : 'lazy'} />}
|
||||
<div class="body">
|
||||
<h3><a href={`/news/${post.id}`}>{post.data.title}</a></h3>
|
||||
<p class="desc">{post.data.description}</p>
|
||||
<div class="meta"><span><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><time datetime={iso(post.data.date)}>{fmt(post.data.date)}</time></span><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(post)} min read</span></div>
|
||||
</div>
|
||||
</article>
|
||||
@@ -15,8 +15,8 @@ const news = defineCollection({
|
||||
// Public-path string, e.g. "/uploads/foo.jpg" — Sveltia uploads media to
|
||||
// public/uploads, which Astro's image() helper can't validate (src/ only).
|
||||
cover: z.string().optional(),
|
||||
// BCP-47 tag when an article is not in English (e.g. "pl") — carried to
|
||||
// <html lang> so screen readers pick the right voice.
|
||||
// BCP-47 tag when an article is not in English (e.g. "pl") — set as lang
|
||||
// on the <article> element so screen readers pick the right voice.
|
||||
lang: z.string().optional(),
|
||||
draft: z.boolean().default(false),
|
||||
}),
|
||||
|
||||
+10
-9
@@ -2,10 +2,10 @@
|
||||
import '../styles/global.css';
|
||||
// `ambient` opts a page into the whole-page drifting brand wash (home only — see DESIGN.md)
|
||||
// `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, lang = 'en' } = 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 { 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` comes from astro.config.mjs and is always set.
|
||||
const site = Astro.site;
|
||||
const canonical = new URL(Astro.url.pathname, site);
|
||||
// SVG covers never reach og:image — link-preview crawlers (Slack/Teams/
|
||||
// LinkedIn) don't render SVG, so those articles fall back to the brand card.
|
||||
@@ -26,7 +26,9 @@ const links = [
|
||||
];
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang={lang}>
|
||||
{/* Always "en": the chrome (nav/footer) is English; a non-English article
|
||||
sets lang on its <article> element instead ([slug].astro). */}
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
@@ -49,10 +51,9 @@ const links = [
|
||||
{/* The home hero photo is a CSS background, which browsers discover late —
|
||||
preloading it moves the page's LCP element to the front of the queue. */}
|
||||
{path === '/' && <link rel="preload" as="image" href="/brand/hero.jpg" fetchpriority="high" />}
|
||||
{/* Brand typography (BRAND D.3): Inter (body) + Space Grotesk (headlines),
|
||||
self-hosted latin woff2 (variable) — @font-face lives in global.css. */}
|
||||
{/* Both brand fonts ship inlined in the render-blocking stylesheet —
|
||||
no font swap exists anywhere (see global.css @font-face). */}
|
||||
{/* Brand typography (BRAND D.3): Inter (body) + Space Grotesk (headlines).
|
||||
Both ship inlined as data: URIs in the render-blocking stylesheet —
|
||||
no font request, no swap (see global.css @font-face). */}
|
||||
</head>
|
||||
<body class={ambient ? 'has-ambient' : undefined}>
|
||||
<a class="skip" href="#main">Skip to content</a>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// Shared news helpers — single source for card metadata and archive chunking.
|
||||
export const fmt = (d) => new Intl.DateTimeFormat('en-GB', { dateStyle: 'long' }).format(d);
|
||||
export const iso = (d) => d.toISOString().slice(0, 10);
|
||||
// Reading time from the Markdown body (~220 wpm) — differentiating card metadata;
|
||||
// category/author are identical across all articles, so cards omit them (NN/g).
|
||||
export const readMin = (p) => Math.max(1, Math.round((p.body ?? '').split(/\s+/).length / 220));
|
||||
// Archive chunking: page 1 holds 25 items (1 featured lead + 24 grid = even
|
||||
// 3-column rows), later pages 24. Used by BOTH the /news/[...page] route and
|
||||
// sitemap.xml.js — the page counts cannot drift apart.
|
||||
export const PAGE_FIRST = 25;
|
||||
export const PAGE_REST = 24;
|
||||
+15
-13
@@ -4,17 +4,19 @@ import Base from '../layouts/Base.astro';
|
||||
<Base title="CTAO Science Portal — Page not found" description="This page does not exist">
|
||||
{/* Document archetype (DESIGN.md): the document IS the page — h1, one line,
|
||||
recovery links, and a real GET search form as the way forward. */}
|
||||
<article class="container article">
|
||||
<h1>Page not found</h1>
|
||||
<div class="prose">
|
||||
<p>The address may be mistyped or the page may have moved — go to the
|
||||
<a href="/">home page</a> or browse <a href="/news">all news</a>.</p>
|
||||
</div>
|
||||
<form class="search-form" role="search" action="/search" method="get">
|
||||
<label class="sr-only" for="nf-q">Search news</label>
|
||||
<svg class="ico search-ico" aria-hidden="true" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7" /><path d="m20 20-4.3-4.3" /></svg>
|
||||
<input id="nf-q" name="q" type="search" placeholder="Search news…" autocomplete="off" />
|
||||
<button class="btn" type="submit">Search</button>
|
||||
</form>
|
||||
</article>
|
||||
<div class="container article-layout">
|
||||
<article class="article">
|
||||
<h1>Page not found</h1>
|
||||
<div class="prose">
|
||||
<p>The address may be mistyped or the page may have moved — go to the
|
||||
<a href="/">home page</a> or browse <a href="/news">all news</a>.</p>
|
||||
</div>
|
||||
<form class="search-form" role="search" action="/search" method="get">
|
||||
<label class="sr-only" for="nf-q">Search news</label>
|
||||
<svg class="ico search-ico" aria-hidden="true" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7" /><path d="m20 20-4.3-4.3" /></svg>
|
||||
<input id="nf-q" name="q" type="search" placeholder="Search news…" autocomplete="off" />
|
||||
<button class="btn" type="submit">Search</button>
|
||||
</form>
|
||||
</article>
|
||||
</div>
|
||||
</Base>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
---
|
||||
// Editor entry point (/admin). Sveltia CMS loads its config from ./config.yml
|
||||
// (served statically from public/admin/config.yml). The external bundle is kept
|
||||
// inline so Astro doesn't try to process/bundle it. CTAO branding on the sign-in
|
||||
// screen comes from the officially supported `logo_url` config option.
|
||||
// (served statically from public/admin/config.yml). The bundle is vendored in
|
||||
// public/vendor/ so the editor CODE never auto-updates from a CDN (updates are
|
||||
// deliberate — see README); note the bundle still fetches its own UI fonts
|
||||
// from jsDelivr at runtime. Kept is:inline so Astro doesn't process/bundle it.
|
||||
// CTAO branding on the sign-in screen comes from the documented `logo` option.
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
@@ -25,7 +27,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<noscript><p>The CTAO content editor requires JavaScript.<br />Please enable it, or return to the <a href="/">Science Portal</a>.</p></noscript>
|
||||
<script is:inline src="https://unpkg.com/@sveltia/cms/dist/sveltia-cms.js"></script>
|
||||
<script is:inline src="/vendor/sveltia-cms.js"></script>
|
||||
<script is:inline>
|
||||
// Officially supported API: article previews rendered with portal-like styles.
|
||||
window.CMS?.registerPreviewStyle?.('/admin/preview.css');
|
||||
|
||||
+2
-15
@@ -1,15 +1,11 @@
|
||||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
import Base from '../layouts/Base.astro';
|
||||
import NewsCard from '../components/NewsCard.astro';
|
||||
|
||||
const posts = (await getCollection('news', ({ data }) => !data.draft))
|
||||
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
|
||||
.slice(0, 3);
|
||||
const fmt = (d) => new Intl.DateTimeFormat('en-GB', { dateStyle: 'long' }).format(d);
|
||||
const iso = (d) => d.toISOString().slice(0, 10);
|
||||
// Reading time from the Markdown body (~220 wpm) — differentiating card metadata;
|
||||
// category/author are identical across all articles, so cards omit them (NN/g).
|
||||
const readMin = (p) => Math.max(1, Math.round((p.body ?? '').split(/\s+/).length / 220));
|
||||
// Services per the Science Portal spec. Tiles carry no status chrome — honesty
|
||||
// lives at the interaction point (REQUIREMENTS §5): each target page opens with
|
||||
// its own whisper badge. Planned services stay in the grid but subdued (muted,
|
||||
@@ -91,16 +87,7 @@ const planned = ['Scheduling', 'Science alerts'];
|
||||
<a class="more" href="/news">All news →</a>
|
||||
</div>
|
||||
<div class="grid">
|
||||
{posts.map((post) => (
|
||||
<article class="card">
|
||||
{post.data.cover && <img class="cover" src={encodeURI(post.data.cover)} alt="" loading="lazy" />}
|
||||
<div class="body">
|
||||
<h3><a href={`/news/${post.id}`}>{post.data.title}</a></h3>
|
||||
<p class="desc">{post.data.description}</p>
|
||||
<div class="meta"><span><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><time datetime={iso(post.data.date)}>{fmt(post.data.date)}</time></span><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(post)} min read</span></div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{posts.map((post) => <NewsCard post={post} />)}
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import NewsCard from '../../components/NewsCard.astro';
|
||||
import { PAGE_FIRST as FIRST, PAGE_REST as REST } from '../../lib/news.js';
|
||||
|
||||
// Paginated archive; the [...page] rest route makes page 1 the bare /news URL,
|
||||
// then /news/2 … /news/N. Custom slicing instead of paginate(): page 1 holds
|
||||
// 25 items (1 featured lead + 24 grid = even 3-column rows), later pages 24 —
|
||||
// paginate() cannot vary page size.
|
||||
// FIRST items (1 featured lead + 24 grid = even 3-column rows), later pages
|
||||
// REST — paginate() cannot vary page size. Chunk sizes live in lib/news.js,
|
||||
// shared with sitemap.xml.js.
|
||||
export async function getStaticPaths() {
|
||||
const posts = (await getCollection('news', ({ data }) => !data.draft)).sort(
|
||||
(a, b) => b.data.date.valueOf() - a.data.date.valueOf(),
|
||||
);
|
||||
const FIRST = 25, REST = 24;
|
||||
const chunks = [posts.slice(0, FIRST)];
|
||||
for (let i = FIRST; i < posts.length; i += REST) chunks.push(posts.slice(i, i + REST));
|
||||
const lastPage = chunks.length;
|
||||
@@ -34,11 +36,6 @@ export async function getStaticPaths() {
|
||||
|
||||
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);
|
||||
// Reading time from the Markdown body (~220 wpm) — differentiating card metadata;
|
||||
// category/author are identical across all articles, so cards omit them (NN/g).
|
||||
const readMin = (p) => Math.max(1, Math.round((p.body ?? '').split(/\s+/).length / 220));
|
||||
// Windowed page list — 1 … n-1 n n+1 … last (0 marks an ellipsis)
|
||||
const nums = [];
|
||||
for (let n = 1; n <= page.lastPage; n++) {
|
||||
@@ -61,18 +58,7 @@ const hrefFor = (n) => (n === 1 ? '/news' : `/news/${n}`);
|
||||
<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">
|
||||
<h3><a href={`/news/${post.id}`}>{post.data.title}</a></h3>
|
||||
<p class="desc">{post.data.description}</p>
|
||||
<div class="meta"><span><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><time datetime={iso(post.data.date)}>{fmt(post.data.date)}</time></span><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(post)} min read</span></div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{page.data.map((post, i) => <NewsCard post={post} featured={first && i === 0} />)}
|
||||
</div>
|
||||
{page.lastPage > 1 && (
|
||||
<nav class="pagination" aria-label="News pages">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
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
|
||||
@@ -17,17 +18,18 @@ export async function getStaticPaths() {
|
||||
const { post, newer, older } = Astro.props;
|
||||
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);
|
||||
const readMin = Math.max(1, Math.round((post.body ?? '').split(/\s+/).length / 220));
|
||||
const readMin = readMinOf(post);
|
||||
---
|
||||
<Base title={`${post.data.title} — CTAO`} description={post.data.description} type="article" image={post.data.cover && encodeURI(post.data.cover)} lang={post.data.lang}>
|
||||
<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 (DESIGN.md). */}
|
||||
{/* 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">
|
||||
<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
|
||||
|
||||
@@ -11,13 +11,17 @@ const { page } = Astro.props;
|
||||
const { Content } = await render(page);
|
||||
---
|
||||
<Base title={`${page.data.title} — CTAO Science Portal`} description={page.data.description}>
|
||||
<article class="container article">
|
||||
{/* Static pages are footer-reached documents; same back affordance as
|
||||
articles ("← All news") so deep links aren't dead ends. */}
|
||||
<a class="back" href="/">← Home</a>
|
||||
<h1>{page.data.title}</h1>
|
||||
<div class="prose">
|
||||
<Content />
|
||||
</div>
|
||||
</article>
|
||||
{/* Same wrapper pattern as news/[slug] (never .container and .article on
|
||||
one element — both set max-width and would depend on rule order). */}
|
||||
<div class="container article-layout">
|
||||
<article class="article">
|
||||
{/* Static pages are footer-reached documents; same back affordance as
|
||||
articles ("← All news") so deep links aren't dead ends. */}
|
||||
<a class="back" href="/">← Home</a>
|
||||
<h1>{page.data.title}</h1>
|
||||
<div class="prose">
|
||||
<Content />
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</Base>
|
||||
|
||||
@@ -7,7 +7,7 @@ const esc = (s = '') =>
|
||||
s.replace(/[<>&'"]/g, (c) => ({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' })[c]);
|
||||
|
||||
export async function GET(context) {
|
||||
const site = context.site ?? new URL('https://portal.ctao.org');
|
||||
const site = context.site; // always set in astro.config.mjs
|
||||
const posts = (await getCollection('news', ({ data }) => !data.draft))
|
||||
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
|
||||
.slice(0, 30);
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
// 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';
|
||||
import { PAGE_FIRST, PAGE_REST } from '../lib/news.js';
|
||||
|
||||
// Same chunk math as src/pages/news/[...page].astro (keep in sync): page 1
|
||||
// holds 25 items (featured lead + 24), later pages 24 — so the page count
|
||||
// here can never drift from the archive's.
|
||||
const FIRST = 25, REST = 24;
|
||||
const newsPageCount = (n) => (n <= FIRST ? 1 : 1 + Math.ceil((n - FIRST) / REST));
|
||||
// Chunk sizes are imported from lib/news.js — the same values the
|
||||
// /news/[...page] route slices with, so the page count here cannot drift.
|
||||
const newsPageCount = (n) => (n <= PAGE_FIRST ? 1 : 1 + Math.ceil((n - PAGE_FIRST) / PAGE_REST));
|
||||
|
||||
export async function GET(context) {
|
||||
const site = context.site ?? new URL('https://portal.ctao.org');
|
||||
const site = context.site; // always set in astro.config.mjs
|
||||
const news = (await getCollection('news', ({ data }) => !data.draft))
|
||||
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
|
||||
const pages = await getCollection('pages');
|
||||
|
||||
// Go-live note: /login and /dashboard are mock pages — drop them from this
|
||||
// list (or gate them) before robots.txt stops disallowing everything.
|
||||
const urls = [
|
||||
'/', '/news', '/proposals', '/dashboard', '/support', '/search', '/login',
|
||||
...Array.from({ length: newsPageCount(news.length) - 1 }, (_, i) => `/news/${i + 2}`),
|
||||
|
||||
@@ -18,6 +18,7 @@ const channels = [
|
||||
{/* One page-level whisper badge — the tiles below stay clean (they are
|
||||
not interactive; honesty lives here, at the point users arrive). */}
|
||||
<span class="badge-mock">Mock — target: SUSS User Support system</span>
|
||||
<h2 class="sr-only">Support channels</h2>
|
||||
<div class="grid">
|
||||
{channels.map((c) => (
|
||||
<article class="card tile">
|
||||
|
||||
+20
-23
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/base"
|
||||
}
|
||||
Reference in New Issue
Block a user