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/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
.astro/
|
.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-h1` | clamp(2.25–3.5rem) | Page/article headlines |
|
||||||
| `--fs-display` | clamp(2.5–4.75rem) | Home hero only |
|
| `--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
|
Font loading (prevents any navigation blink): BOTH families are inlined as
|
||||||
woff2, preloaded in `Base.astro`, `font-display: swap` + metric-matched Arial
|
base64 `data:` URIs inside the render-blocking `global.css` — no separate
|
||||||
fallbacks for BOTH families (`Inter Fallback`, `Space Grotesk Fallback` —
|
font fetch exists, so there is no swap window and no font flash between
|
||||||
size-adjust/ascent/descent computed from the real font metrics, capsize
|
navigations. Metric-matched Arial fallbacks (`Inter Fallback`,
|
||||||
method). Never `optional`: its ~100ms window loses to per-navigation
|
`Space Grotesk Fallback` — size-adjust/ascent/descent computed from the real
|
||||||
revalidation (dev/preview serve `Cache-Control: no-cache`), which randomly
|
font metrics, capsize method) cover only the pre-CSS paint. The standalone
|
||||||
committed whole pageviews to Arial — the "fonts flash between navigations"
|
`public/fonts/*.woff2` files are referenced ONLY by
|
||||||
bug. With swap + matched metrics every page converges on brand fonts and the
|
`public/admin/preview.css` (CMS entry preview) — they look unused but aren't.
|
||||||
swap is layout-neutral.
|
|
||||||
|
|
||||||
## Spacing scale (fluid; mobile ≈ min, desktop ≈ max)
|
## 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
|
## Serving the demo
|
||||||
|
|
||||||
Phone/tunnel demos run the **production build**: `npm run demo`
|
Phone/tunnel demos run the **production build**:
|
||||||
(= `astro build && astro preview`, same port 4321 so the tunnel URL keeps
|
`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
|
working). `astro dev` through the tunnel adds HMR + dev-toolbar module
|
||||||
traffic and serves everything `no-cache` — every navigation re-negotiates
|
traffic and serves everything `no-cache` — every navigation re-negotiates
|
||||||
assets over the tunnel, which reads as blinking/slow paints that do NOT
|
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.
|
// Static output (zero runtime) — the whole point of the git-based approach.
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
output: 'static',
|
output: 'static',
|
||||||
// Placeholder domain until the real one exists — only used to build absolute
|
// Public origin — used to build absolute URLs (canonical, og:*, RSS,
|
||||||
// URLs (canonical, og:*, RSS, sitemap). Swap once the portal has a home.
|
// sitemap). Change when the portal moves to its production domain.
|
||||||
site: 'https://portal.ctao.org',
|
site: 'https://astro.isl-dev.grid.cyfronet.pl',
|
||||||
// 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
|
||||||
@@ -24,13 +24,6 @@ export default defineConfig({
|
|||||||
// Vite blocks unknown Host headers by default; the demo is viewed through an
|
// Vite blocks unknown Host headers by default; the demo is viewed through an
|
||||||
// ephemeral cloudflared quick tunnel, so allow any *.trycloudflare.com host.
|
// ephemeral cloudflared quick tunnel, so allow any *.trycloudflare.com host.
|
||||||
allowedHosts: ['.trycloudflare.com'],
|
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.
|
# 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
|
# 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:
|
# Built ONCE at install, never pulled again at runtime:
|
||||||
# podman build -t localhost/ctao-portal-build:1 -f Containerfile.build .
|
# podman build -t localhost/ctao-portal-build:1 -f Containerfile.build .
|
||||||
FROM docker.io/library/node:24-alpine@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd
|
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)
|
├── repo/ # clone of the portal repo (created by first build)
|
||||||
├── releases/ # <sha>/ dirs + `current` symlink (what nginx serves)
|
├── releases/ # <sha>/ dirs + `current` symlink (what nginx serves)
|
||||||
├── state/ # last-built SHA
|
├── 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)
|
├── bin/build.sh # copied from deploy/ (source of truth stays in the repo)
|
||||||
└── config/nginx.conf
|
└── 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) |
|
| 3000 | Gitea | https://astro-git.isl-dev.grid.cyfronet.pl (ingress vhost) |
|
||||||
| 8080 | portal (nginx, static) | ingress vhost (TODO: ask Hubert) |
|
| 8080 | portal (nginx, static) | https://astro.isl-dev.grid.cyfronet.pl (ingress vhost) |
|
||||||
|
|
||||||
## Install (each step reviewed before running; [W] = writes to the machine)
|
## 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`
|
1. **[W]** `loginctl enable-linger $USER` — without lingering every user unit
|
||||||
2. **[W]** Copy files from this dir (scp from the Mac):
|
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-demo-gitea.container`, `ctao-demo-web.container` → `~/.config/containers/systemd/`
|
||||||
- `ctao-portal-build.service`, `ctao-portal-build.timer` → `~/.config/systemd/user/`
|
- `ctao-portal-build.service`, `ctao-portal-build.timer` → `~/.config/systemd/user/`
|
||||||
- `build.sh` → `~/ctao-portal-demo/bin/` (`chmod +x`)
|
- `build.sh` → `~/ctao-portal-demo/bin/` (`chmod +x`)
|
||||||
- `nginx.conf` → `~/ctao-portal-demo/config/`
|
- `nginx.conf` → `~/ctao-portal-demo/config/`
|
||||||
3. **[W]** Pull + pin images (one-time, needs internet):
|
- `Containerfile.build` → anywhere (needed once, for the next step)
|
||||||
`podman pull docker.io/gitea/gitea:1.27-rootless docker.io/library/nginx:stable-alpine docker.io/library/node:24-alpine`
|
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 .`
|
`podman build -t localhost/ctao-portal-build:1 -f Containerfile.build .`
|
||||||
Then `podman images --digests` → paste the sha256 digests into both `.container` files.
|
(Upgrading later = pick new digests deliberately, update the pins in the
|
||||||
4. **[W]** `systemctl --user daemon-reload && systemctl --user start ctao-demo-gitea`
|
`.container` files / `Containerfile.build`, re-pull, re-build.)
|
||||||
5. **[W]** Create the Gitea admin — run YOURSELF in your own terminal (password
|
5. **[W]** `systemctl --user daemon-reload && systemctl --user start ctao-demo-gitea`
|
||||||
is prompted/printed there only; never goes through chat or shell history):
|
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`
|
`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).
|
7. **[W]** In the Gitea UI: create org `ctao`, repo `portal` (public read).
|
||||||
Push from the Mac through an SSH port-forward:
|
Then, from your workstation, push the portal repo over the vhost with a
|
||||||
`ssh -L 3300:localhost:3000 strapi-experimental.cyfronet` then
|
repo-scoped token:
|
||||||
`git remote add machine http://localhost:3300/ctao/portal.git && git push machine main`
|
`git push https://<user>:<token>@astro-git.isl-dev.grid.cyfronet.pl/ctao/portal.git main`
|
||||||
7. **[W]** `systemctl --user enable --now ctao-portal-build.timer` — first run
|
8. **[W]** `systemctl --user enable --now ctao-portal-build.timer` — first run
|
||||||
clones + `npm ci` + builds (minutes); later runs are seconds. Wait until
|
clones + `npm ci` + builds (minutes); later runs are seconds. Wait until
|
||||||
`journalctl --user -u ctao-portal-build -n 5` shows `published <sha>`
|
`journalctl --user -u ctao-portal-build -n 5` shows `published <sha>`
|
||||||
(starting nginx earlier just serves 404s until the first build lands).
|
(starting nginx earlier just serves 404s until the first build lands).
|
||||||
8. **[W]** `systemctl --user start ctao-demo-web`
|
9. **[W]** `systemctl --user start ctao-demo-web`
|
||||||
9. **[R]** Verify: `curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/`
|
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).
|
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`
|
- Gitea OAuth2 app (PKCE, `confidential_client=false`, no secret) with
|
||||||
(ROOT_URL → gitea vhost, CORS `*` → portal origin, add
|
redirect `https://astro.isl-dev.grid.cyfronet.pl/admin/`; its client id is
|
||||||
`GITEA__cors__SCHEME=https`); `systemctl --user daemon-reload && systemctl --user restart ctao-demo-gitea`.
|
the `app_id` in `public/admin/config.yml`.
|
||||||
2. In Gitea UI: Settings → Applications → new OAuth2 app for Sveltia
|
- CORS is pinned in `ctao-demo-gitea.container`: `ALLOW_DOMAIN` takes the
|
||||||
(redirect: `https://<portal-vhost>/admin/`), PKCE, no client secret.
|
FULL portal origin with scheme (a bare hostname silently disables CORS in
|
||||||
3. Put the Gitea vhost URL + client id into `public/admin/config.yml` in the
|
Gitea 1.27; there is no `SCHEME` key) and `HEADERS` must include
|
||||||
portal repo, commit, push — the timer publishes it like any other change.
|
`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)
|
## Uninstall (leaves only podman's own storage metadata)
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
```
|
```
|
||||||
systemctl --user disable --now ctao-portal-build.timer
|
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 \
|
rm ~/.config/containers/systemd/ctao-demo-*.container \
|
||||||
~/.config/systemd/user/ctao-portal-build.{service,timer}
|
~/.config/systemd/user/ctao-portal-build.{service,timer}
|
||||||
systemctl --user daemon-reload
|
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
|
rm -rf ~/ctao-portal-demo
|
||||||
|
loginctl disable-linger $USER # only if nothing else of yours should survive logout
|
||||||
```
|
```
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Publish latency = poll (≤10 s) + build (measured 1 s on M-series; expect
|
- **Only site content auto-deploys.** Changes to `deploy/*` need a manual
|
||||||
4–8 s on the 2 vCPU VM — every build's time lands in the journal).
|
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
|
- Internet needed only for: image pulls (install) and `npm ci` when the
|
||||||
lockfile changes. Routine rebuilds are fully offline.
|
lockfile changes. Routine rebuilds are fully offline.
|
||||||
- Memory caps (`MemoryHigh`) keep us polite next to Outline + Strapi;
|
- 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
|
Gitea on localhost:3000 but cannot reach host loopback services. (Rootless
|
||||||
netavark bridges don't work here: no `ip_tables` kernel module, no sudo.)
|
netavark bridges don't work here: no `ip_tables` kernel module, no sudo.)
|
||||||
- Secrets inventory: Gitea admin password (typed interactively, lives only
|
- 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
|
KEEP="${KEEP:-3}" # released builds to retain
|
||||||
mkdir -p "$BASE/repo" "$BASE/releases" "$BASE/state" "$BASE/npm-cache"
|
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) ---
|
# --- 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" \
|
sha=$(curl -fsS --max-time 5 "$GITEA_URL/api/v1/repos/$REPO/branches/$BRANCH" \
|
||||||
| jq -r '.commit.id' || true)
|
| jq -r '.commit.id' || true)
|
||||||
@@ -30,7 +36,10 @@ if [[ ! "$sha" =~ ^[0-9a-f]{40}$ ]]; then
|
|||||||
echo "poll failed (gitea unreachable?) — skipping this tick"
|
echo "poll failed (gitea unreachable?) — skipping this tick"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
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"
|
echo "building $sha"
|
||||||
t0=$(date +%s)
|
t0=$(date +%s)
|
||||||
@@ -48,18 +57,21 @@ podman run --rm --network="$BUILD_NETNS" --memory=1g \
|
|||||||
-v "$BASE/releases:/work/releases:z" \
|
-v "$BASE/releases:/work/releases:z" \
|
||||||
-v "$BASE/npm-cache:/root/.npm:z" \
|
-v "$BASE/npm-cache:/root/.npm:z" \
|
||||||
-w /work "$BUILD_IMAGE" sh -ec '
|
-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
|
[ -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 remote set-url origin "$REPO_URL" # self-heal if the URL changes
|
||||||
git -C repo fetch --quiet origin "$BRANCH"
|
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
|
cd repo
|
||||||
lock=$(sha256sum package-lock.json | cut -d" " -f1)
|
lock=$(sha256sum package-lock.json | cut -d" " -f1)
|
||||||
if [ ! -d node_modules ] || [ "$lock" != "$(cat .deps-hash 2>/dev/null)" ]; then
|
if [ ! -d node_modules ] || [ "$lock" != "$(cat .deps-hash 2>/dev/null)" ]; then
|
||||||
# --ignore-scripts: (1) kills the malicious-postinstall vector from npm
|
# --ignore-scripts: (1) removes the install-time postinstall vector from
|
||||||
# deps entirely, (2) avoids the esbuild ETXTBSY postinstall race in
|
# npm deps (build-time repo code still runs `npm run build` below — the
|
||||||
# rootless containers. esbuild ships its binary as an optional dep, so
|
# netns confinement is the control for that), (2) avoids the esbuild
|
||||||
# nothing needed here actually requires lifecycle scripts.
|
# 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
|
npm ci --ignore-scripts --no-audit --no-fund
|
||||||
echo "$lock" > .deps-hash
|
echo "$lock" > .deps-hash
|
||||||
fi
|
fi
|
||||||
@@ -76,11 +88,13 @@ ln -s "$sha" "$BASE/releases/.current.$$"
|
|||||||
mv -Tf "$BASE/releases/.current.$$" "$BASE/releases/current"
|
mv -Tf "$BASE/releases/.current.$$" "$BASE/releases/current"
|
||||||
echo "$sha" > "$BASE/state/last-built"
|
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
|
# `|| true`: an empty match must not fail the unit after a successful publish
|
||||||
# (grep exits 1 under pipefail when there is nothing to prune).
|
# (grep exits 1 under pipefail when there is nothing to prune).
|
||||||
cd "$BASE/releases"
|
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"
|
rm -rf -- "$old"
|
||||||
done || true
|
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.
|
# 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-data:/var/lib/gitea:Z
|
||||||
Volume=%h/ctao-portal-demo/gitea-config:/etc/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
|
PublishPort=3000:3000
|
||||||
|
|
||||||
# Env-driven config — re-applied on every start, no hand-edited app.ini
|
# 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
|
# (see .skills/gitea/SKILL.md). Secrets: none here; the admin account is
|
||||||
# created interactively after first start (README).
|
# created interactively after first start (README).
|
||||||
Environment=GITEA__server__HTTP_PORT=3000
|
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__ROOT_URL=https://astro-git.isl-dev.grid.cyfronet.pl/
|
||||||
Environment=GITEA__server__DISABLE_SSH=true
|
Environment=GITEA__server__DISABLE_SSH=true
|
||||||
Environment=GITEA__database__DB_TYPE=sqlite3
|
Environment=GITEA__database__DB_TYPE=sqlite3
|
||||||
Environment=GITEA__security__INSTALL_LOCK=true
|
Environment=GITEA__security__INSTALL_LOCK=true
|
||||||
Environment=GITEA__service__DISABLE_REGISTRATION=true
|
Environment=GITEA__service__DISABLE_REGISTRATION=true
|
||||||
Environment=GITEA__mailer__ENABLED=false
|
Environment=GITEA__mailer__ENABLED=false
|
||||||
# First `git push` auto-creates the repo (no UI step); public so the build
|
# Repos default to public so the build pipeline can clone anonymously —
|
||||||
# pipeline can clone anonymously — content is the public site anyway.
|
# content is the public site anyway. The `ctao/portal` repo itself is
|
||||||
Environment=GITEA__repository__ENABLE_PUSH_CREATE_USER=true
|
# created in the UI (README step 6).
|
||||||
Environment=GITEA__repository__DEFAULT_PRIVATE=public
|
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
|
# 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
|
# — 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
|
# 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).
|
# the config file is exclusive to nginx (:Z).
|
||||||
Volume=%h/ctao-portal-demo/releases:/srv/releases:ro,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
|
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
|
PublishPort=8080:80
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
MemoryHigh=64M
|
MemoryHigh=64M
|
||||||
|
MemoryMax=128M
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=default.target
|
WantedBy=default.target
|
||||||
|
|||||||
+19
-4
@@ -7,28 +7,43 @@ server {
|
|||||||
server_name _;
|
server_name _;
|
||||||
root /srv/releases/current;
|
root /srv/releases/current;
|
||||||
charset utf-8;
|
charset utf-8;
|
||||||
|
server_tokens off;
|
||||||
error_page 404 /404.html; # Astro emits 404.html at the site root
|
error_page 404 /404.html; # Astro emits 404.html at the site root
|
||||||
# Directory redirects (/admin -> /admin/) must stay relative: an absolute
|
# Directory redirects (/admin -> /admin/) must stay relative: an absolute
|
||||||
# redirect is built from listen port 80 and loses the real port whenever the
|
# 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.
|
# site is reached through a tunnel or a proxy on a non-default port.
|
||||||
absolute_redirect off;
|
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
|
# Fingerprinted build assets (/_astro/<name>.<hash>.*) — immutable
|
||||||
location /_astro/ {
|
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
|
# Editor-uploaded media (stable paths, may be re-uploaded) — short cache
|
||||||
location /uploads/ {
|
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)
|
# Everything else: HTML pages, feeds, /admin (Sveltia is static files too)
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ =404;
|
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 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",
|
"type": "module",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev --port 4321 --host",
|
"dev": "astro dev",
|
||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
"preview": "astro preview --port 4321 --host",
|
"preview": "astro preview"
|
||||||
"demo": "astro build && astro preview --port 4321 --host",
|
|
||||||
"cms-proxy": "PORT=8082 decap-server"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"astro": "latest"
|
"astro": "^7.2.10"
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"decap-server": "latest"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ collections:
|
|||||||
- { name: category, label: "Category", widget: string, default: "news" }
|
- { name: category, label: "Category", widget: string, default: "news" }
|
||||||
- { name: author, label: "Author", widget: string, default: "CTAO" }
|
- { name: author, label: "Author", widget: string, default: "CTAO" }
|
||||||
- { name: cover, label: "Cover image", widget: image, required: false }
|
- { 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: draft, label: "Draft (unpublished)", widget: boolean, default: false }
|
||||||
- { name: body, label: "Body", widget: markdown }
|
- { name: body, label: "Body", widget: markdown }
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,17 @@
|
|||||||
dropped from selectors (the preview pane has no such wrapper).
|
dropped from selectors (the preview pane has no such wrapper).
|
||||||
KEEP IN SYNC: when you touch `.prose` or the tokens in global.css, update
|
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`).
|
the corresponding value here (a matching reminder sits next to `.prose`).
|
||||||
Fonts: same families as the site (site inlines them; the preview loads them
|
Fonts: same self-hosted woff2 files the site uses, same origin. (The
|
||||||
from Google Fonts — editors' browsers are online by definition). */
|
Sveltia bundle still loads its own UI fonts from jsDelivr — only the
|
||||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400..700&family=Space+Grotesk:wght@500..700&display=swap");
|
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 {
|
:root {
|
||||||
/* tokens copied from global.css :root */
|
/* tokens copied from global.css :root */
|
||||||
@@ -22,6 +30,7 @@
|
|||||||
--fs-s: 0.9rem;
|
--fs-s: 0.9rem;
|
||||||
--fs-l: 1.125rem;
|
--fs-l: 1.125rem;
|
||||||
--fs-xl: 1.5rem;
|
--fs-xl: 1.5rem;
|
||||||
|
--fs-h1: clamp(2.25rem, 5vw, 3.5rem);
|
||||||
--radius: 16px;
|
--radius: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +51,8 @@ a { color: var(--link); text-decoration: underline; text-underline-offset: 3px;
|
|||||||
|
|
||||||
/* = global.css `.prose` rules, selectors unwrapped = */
|
/* = global.css `.prose` rules, selectors unwrapped = */
|
||||||
body { line-height: 1.65; overflow-wrap: break-word; }
|
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); }
|
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); }
|
h3 { color: var(--galaxy); margin: 30px 0 8px; font-size: var(--fs-l); }
|
||||||
p { margin: 0 0 16px; text-wrap: pretty; }
|
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);
|
margin: 22px 0; padding: 12px 20px; border-left: 3px solid var(--galaxy);
|
||||||
background: var(--moon); color: var(--text);
|
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); }
|
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; }
|
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); }
|
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 index = null;
|
||||||
let timer;
|
let timer;
|
||||||
async function ensureIndex() {
|
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;
|
return index;
|
||||||
}
|
}
|
||||||
// First title match wrapped in <mark> (Starlight search pattern) — built
|
// First title match wrapped in <mark> (Starlight search pattern) — built
|
||||||
@@ -77,7 +85,9 @@
|
|||||||
frag.append(label('Recent'));
|
frag.append(label('Recent'));
|
||||||
for (const term of r) frag.append(row([term], '/search?q=' + encodeURIComponent(term), term));
|
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
|
if (input.value.trim().length >= 2) return; // typed meanwhile — run() owns the list
|
||||||
frag.append(label('Latest news'));
|
frag.append(label('Latest news'));
|
||||||
for (const p of fresh) {
|
for (const p of fresh) {
|
||||||
@@ -95,6 +105,7 @@
|
|||||||
const q = input.value.trim().toLowerCase();
|
const q = input.value.trim().toLowerCase();
|
||||||
if (q.length < 2) { list.replaceChildren(); idle(); return; }
|
if (q.length < 2) { list.replaceChildren(); idle(); return; }
|
||||||
const idx = await ensureIndex();
|
const idx = await ensureIndex();
|
||||||
|
if (!idx) { list.replaceChildren(); return; }
|
||||||
const hits = idx
|
const hits = idx
|
||||||
.filter((p) => (p.title + ' ' + p.description + ' ' + p.category).toLowerCase().includes(q))
|
.filter((p) => (p.title + ' ' + p.description + ' ' + p.category).toLowerCase().includes(q))
|
||||||
.slice(0, opts.limit);
|
.slice(0, opts.limit);
|
||||||
@@ -111,6 +122,17 @@
|
|||||||
li.append(small);
|
li.append(small);
|
||||||
frag.append(li);
|
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);
|
list.replaceChildren(frag);
|
||||||
hint();
|
hint();
|
||||||
}
|
}
|
||||||
@@ -122,13 +144,14 @@
|
|||||||
const a = e.target.closest && e.target.closest('a');
|
const a = e.target.closest && e.target.closest('a');
|
||||||
remember((a && a.dataset.q) || input.value);
|
remember((a && a.dataset.q) || input.value);
|
||||||
});
|
});
|
||||||
// Dropdown dismiss (opt-in — /search keeps its results list). A suggestion
|
// Dropdown dismiss (opt-in — /search keeps its results list). The
|
||||||
// list is a NON-MODAL combobox popup (ARIA combobox pattern): interacting
|
// suggestion list is a NON-MODAL popup (combobox-style; plain
|
||||||
// outside the field+list closes it but the interaction is NOT swallowed —
|
// 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.
|
// unlike the modal header panels, which scrim-dismiss in Base.astro.
|
||||||
if (opts.dismiss) {
|
if (opts.dismiss) {
|
||||||
const box = input.closest('form') || input.parentElement;
|
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(); });
|
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
|
// 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(); });
|
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-path string, e.g. "/uploads/foo.jpg" — Sveltia uploads media to
|
||||||
// public/uploads, which Astro's image() helper can't validate (src/ only).
|
// public/uploads, which Astro's image() helper can't validate (src/ only).
|
||||||
cover: z.string().optional(),
|
cover: z.string().optional(),
|
||||||
// BCP-47 tag when an article is not in English (e.g. "pl") — carried to
|
// BCP-47 tag when an article is not in English (e.g. "pl") — set as lang
|
||||||
// <html lang> so screen readers pick the right voice.
|
// on the <article> element so screen readers pick the right voice.
|
||||||
lang: z.string().optional(),
|
lang: z.string().optional(),
|
||||||
draft: z.boolean().default(false),
|
draft: z.boolean().default(false),
|
||||||
}),
|
}),
|
||||||
|
|||||||
+10
-9
@@ -2,10 +2,10 @@
|
|||||||
import '../styles/global.css';
|
import '../styles/global.css';
|
||||||
// `ambient` opts a page into the whole-page drifting brand wash (home only — see DESIGN.md)
|
// `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.
|
// `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;
|
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
|
// Absolute URLs for canonical/OG/RSS (head pattern from the official Astro
|
||||||
// template). `site` is a placeholder domain until the portal has a real one.
|
// blog template). `site` comes from astro.config.mjs and is always set.
|
||||||
const site = Astro.site ?? new URL('https://portal.ctao.org');
|
const site = Astro.site;
|
||||||
const canonical = new URL(Astro.url.pathname, site);
|
const canonical = new URL(Astro.url.pathname, site);
|
||||||
// SVG covers never reach og:image — link-preview crawlers (Slack/Teams/
|
// 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.
|
// LinkedIn) don't render SVG, so those articles fall back to the brand card.
|
||||||
@@ -26,7 +26,9 @@ const links = [
|
|||||||
];
|
];
|
||||||
---
|
---
|
||||||
<!doctype html>
|
<!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>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<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 —
|
{/* 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. */}
|
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" />}
|
{path === '/' && <link rel="preload" as="image" href="/brand/hero.jpg" fetchpriority="high" />}
|
||||||
{/* Brand typography (BRAND D.3): Inter (body) + Space Grotesk (headlines),
|
{/* Brand typography (BRAND D.3): Inter (body) + Space Grotesk (headlines).
|
||||||
self-hosted latin woff2 (variable) — @font-face lives in global.css. */}
|
Both ship inlined as data: URIs in the render-blocking stylesheet —
|
||||||
{/* Both brand fonts ship inlined in the render-blocking stylesheet —
|
no font request, no swap (see global.css @font-face). */}
|
||||||
no font swap exists anywhere (see global.css @font-face). */}
|
|
||||||
</head>
|
</head>
|
||||||
<body class={ambient ? 'has-ambient' : undefined}>
|
<body class={ambient ? 'has-ambient' : undefined}>
|
||||||
<a class="skip" href="#main">Skip to content</a>
|
<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;
|
||||||
+3
-1
@@ -4,7 +4,8 @@ import Base from '../layouts/Base.astro';
|
|||||||
<Base title="CTAO Science Portal — Page not found" description="This page does not exist">
|
<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,
|
{/* Document archetype (DESIGN.md): the document IS the page — h1, one line,
|
||||||
recovery links, and a real GET search form as the way forward. */}
|
recovery links, and a real GET search form as the way forward. */}
|
||||||
<article class="container article">
|
<div class="container article-layout">
|
||||||
|
<article class="article">
|
||||||
<h1>Page not found</h1>
|
<h1>Page not found</h1>
|
||||||
<div class="prose">
|
<div class="prose">
|
||||||
<p>The address may be mistyped or the page may have moved — go to the
|
<p>The address may be mistyped or the page may have moved — go to the
|
||||||
@@ -17,4 +18,5 @@ import Base from '../layouts/Base.astro';
|
|||||||
<button class="btn" type="submit">Search</button>
|
<button class="btn" type="submit">Search</button>
|
||||||
</form>
|
</form>
|
||||||
</article>
|
</article>
|
||||||
|
</div>
|
||||||
</Base>
|
</Base>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
---
|
---
|
||||||
// Editor entry point (/admin). Sveltia CMS loads its config from ./config.yml
|
// Editor entry point (/admin). Sveltia CMS loads its config from ./config.yml
|
||||||
// (served statically from public/admin/config.yml). The external bundle is kept
|
// (served statically from public/admin/config.yml). The bundle is vendored in
|
||||||
// inline so Astro doesn't try to process/bundle it. CTAO branding on the sign-in
|
// public/vendor/ so the editor CODE never auto-updates from a CDN (updates are
|
||||||
// screen comes from the officially supported `logo_url` config option.
|
// 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>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -25,7 +27,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<noscript><p>The CTAO content editor requires JavaScript.<br />Please enable it, or return to the <a href="/">Science Portal</a>.</p></noscript>
|
<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>
|
<script is:inline>
|
||||||
// Officially supported API: article previews rendered with portal-like styles.
|
// Officially supported API: article previews rendered with portal-like styles.
|
||||||
window.CMS?.registerPreviewStyle?.('/admin/preview.css');
|
window.CMS?.registerPreviewStyle?.('/admin/preview.css');
|
||||||
|
|||||||
+2
-15
@@ -1,15 +1,11 @@
|
|||||||
---
|
---
|
||||||
import { getCollection } from 'astro:content';
|
import { getCollection } from 'astro:content';
|
||||||
import Base from '../layouts/Base.astro';
|
import Base from '../layouts/Base.astro';
|
||||||
|
import NewsCard from '../components/NewsCard.astro';
|
||||||
|
|
||||||
const posts = (await getCollection('news', ({ data }) => !data.draft))
|
const posts = (await getCollection('news', ({ data }) => !data.draft))
|
||||||
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
|
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
|
||||||
.slice(0, 3);
|
.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
|
// Services per the Science Portal spec. Tiles carry no status chrome — honesty
|
||||||
// lives at the interaction point (REQUIREMENTS §5): each target page opens with
|
// 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,
|
// 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>
|
<a class="more" href="/news">All news →</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
{posts.map((post) => (
|
{posts.map((post) => <NewsCard post={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>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</Base>
|
</Base>
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
---
|
---
|
||||||
import { getCollection } from 'astro:content';
|
import { getCollection } from 'astro:content';
|
||||||
import Base from '../../layouts/Base.astro';
|
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,
|
// 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
|
// 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 —
|
// FIRST items (1 featured lead + 24 grid = even 3-column rows), later pages
|
||||||
// paginate() cannot vary page size.
|
// REST — paginate() cannot vary page size. Chunk sizes live in lib/news.js,
|
||||||
|
// shared with sitemap.xml.js.
|
||||||
export async function getStaticPaths() {
|
export async function getStaticPaths() {
|
||||||
const posts = (await getCollection('news', ({ data }) => !data.draft)).sort(
|
const posts = (await getCollection('news', ({ data }) => !data.draft)).sort(
|
||||||
(a, b) => b.data.date.valueOf() - a.data.date.valueOf(),
|
(a, b) => b.data.date.valueOf() - a.data.date.valueOf(),
|
||||||
);
|
);
|
||||||
const FIRST = 25, REST = 24;
|
|
||||||
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;
|
||||||
@@ -34,11 +36,6 @@ export async function getStaticPaths() {
|
|||||||
|
|
||||||
const { page } = Astro.props;
|
const { page } = Astro.props;
|
||||||
const first = page.currentPage === 1;
|
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)
|
// Windowed page list — 1 … n-1 n n+1 … last (0 marks an ellipsis)
|
||||||
const nums = [];
|
const nums = [];
|
||||||
for (let n = 1; n <= page.lastPage; n++) {
|
for (let n = 1; n <= page.lastPage; n++) {
|
||||||
@@ -61,18 +58,7 @@ const hrefFor = (n) => (n === 1 ? '/news' : `/news/${n}`);
|
|||||||
<section class="container page">
|
<section class="container page">
|
||||||
<h2 class="sr-only">{first ? 'All news' : `All news — page ${page.currentPage}`}</h2>
|
<h2 class="sr-only">{first ? 'All news' : `All news — page ${page.currentPage}`}</h2>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
{page.data.map((post, i) => (
|
{page.data.map((post, i) => <NewsCard post={post} featured={first && i === 0} />)}
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
{page.lastPage > 1 && (
|
{page.lastPage > 1 && (
|
||||||
<nav class="pagination" aria-label="News pages">
|
<nav class="pagination" aria-label="News pages">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
import { getCollection, render } from 'astro:content';
|
import { getCollection, render } from 'astro:content';
|
||||||
import Base from '../../layouts/Base.astro';
|
import Base from '../../layouts/Base.astro';
|
||||||
|
import { fmt, readMin as readMinOf } from '../../lib/news.js';
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
export async function getStaticPaths() {
|
||||||
// Date-sorted so each article knows its sequential neighbours (prev/next
|
// 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 { post, newer, older } = Astro.props;
|
||||||
const { Content, headings } = await render(post);
|
const { Content, headings } = await render(post);
|
||||||
const toc = headings.filter((h) => h.depth === 2 || h.depth === 3);
|
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 = readMinOf(post);
|
||||||
const readMin = Math.max(1, Math.round((post.body ?? '').split(/\s+/).length / 220));
|
|
||||||
---
|
---
|
||||||
<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
|
{/* Reading progress (scroll-driven CSS, no JS) — styled only where
|
||||||
animation-timeline is supported; elsewhere it stays an empty div. */}
|
animation-timeline is supported; elsewhere it stays an empty div. */}
|
||||||
<div class="read-progress" aria-hidden="true"></div>
|
<div class="read-progress" aria-hidden="true"></div>
|
||||||
{/* TOC pattern (MDN/Stripe/NN-g): sticky right rail ≥1200px, collapsed <details>
|
{/* 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). */}
|
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">
|
<div class="container article-layout">
|
||||||
<article class="article">
|
<article class="article" lang={post.data.lang}>
|
||||||
<a class="back" href="/news">← All news</a>
|
<a class="back" href="/news">← All news</a>
|
||||||
{/* Editorial anatomy (Guardian/BBC/Reuters convention): headline →
|
{/* Editorial anatomy (Guardian/BBC/Reuters convention): headline →
|
||||||
standfirst → byline/meta → lead image → body. The standfirst is the
|
standfirst → byline/meta → lead image → body. The standfirst is the
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ const { page } = Astro.props;
|
|||||||
const { Content } = await render(page);
|
const { Content } = await render(page);
|
||||||
---
|
---
|
||||||
<Base title={`${page.data.title} — CTAO Science Portal`} description={page.data.description}>
|
<Base title={`${page.data.title} — CTAO Science Portal`} description={page.data.description}>
|
||||||
<article class="container 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
|
{/* Static pages are footer-reached documents; same back affordance as
|
||||||
articles ("← All news") so deep links aren't dead ends. */}
|
articles ("← All news") so deep links aren't dead ends. */}
|
||||||
<a class="back" href="/">← Home</a>
|
<a class="back" href="/">← Home</a>
|
||||||
@@ -20,4 +23,5 @@ const { Content } = await render(page);
|
|||||||
<Content />
|
<Content />
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
</div>
|
||||||
</Base>
|
</Base>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const esc = (s = '') =>
|
|||||||
s.replace(/[<>&'"]/g, (c) => ({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' })[c]);
|
s.replace(/[<>&'"]/g, (c) => ({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' })[c]);
|
||||||
|
|
||||||
export async function GET(context) {
|
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))
|
const posts = (await getCollection('news', ({ data }) => !data.draft))
|
||||||
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
|
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
|
||||||
.slice(0, 30);
|
.slice(0, 30);
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
// Sitemap — hand-rolled static endpoint (no @astrojs/sitemap dependency; our URL
|
// Sitemap — hand-rolled static endpoint (no @astrojs/sitemap dependency; our URL
|
||||||
// set is fully known at build time). /admin is intentionally excluded.
|
// set is fully known at build time). /admin is intentionally excluded.
|
||||||
import { getCollection } from 'astro:content';
|
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
|
// Chunk sizes are imported from lib/news.js — the same values the
|
||||||
// holds 25 items (featured lead + 24), later pages 24 — so the page count
|
// /news/[...page] route slices with, so the page count here cannot drift.
|
||||||
// here can never drift from the archive's.
|
const newsPageCount = (n) => (n <= PAGE_FIRST ? 1 : 1 + Math.ceil((n - PAGE_FIRST) / PAGE_REST));
|
||||||
const FIRST = 25, REST = 24;
|
|
||||||
const newsPageCount = (n) => (n <= FIRST ? 1 : 1 + Math.ceil((n - FIRST) / REST));
|
|
||||||
|
|
||||||
export async function GET(context) {
|
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))
|
const news = (await getCollection('news', ({ data }) => !data.draft))
|
||||||
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
|
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
|
||||||
const pages = await getCollection('pages');
|
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 = [
|
const urls = [
|
||||||
'/', '/news', '/proposals', '/dashboard', '/support', '/search', '/login',
|
'/', '/news', '/proposals', '/dashboard', '/support', '/search', '/login',
|
||||||
...Array.from({ length: newsPageCount(news.length) - 1 }, (_, i) => `/news/${i + 2}`),
|
...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
|
{/* One page-level whisper badge — the tiles below stay clean (they are
|
||||||
not interactive; honesty lives here, at the point users arrive). */}
|
not interactive; honesty lives here, at the point users arrive). */}
|
||||||
<span class="badge-mock">Mock — target: SUSS User Support system</span>
|
<span class="badge-mock">Mock — target: SUSS User Support system</span>
|
||||||
|
<h2 class="sr-only">Support channels</h2>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
{channels.map((c) => (
|
{channels.map((c) => (
|
||||||
<article class="card tile">
|
<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