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:
2026-09-03 14:56:53 +02:00
parent c64528a1d3
commit cec8018c18
35 changed files with 4095 additions and 2111 deletions
+1
View File
@@ -1,3 +1,4 @@
node_modules/
dist/
.astro/
.DS_Store
+12 -12
View File
@@ -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
+59
View File
@@ -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
View File
@@ -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 },
},
},
},
});
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+7 -6
View File
@@ -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
+3
View File
@@ -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
View File
@@ -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;
}
+326 -1904
View File
File diff suppressed because it is too large Load Diff
+4 -9
View File
@@ -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"
}
}
+1
View File
@@ -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 }
+15 -5
View File
@@ -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
View File
@@ -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

+3418
View File
File diff suppressed because one or more lines are too long
+16
View File
@@ -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>
+2 -2
View File
@@ -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
View File
@@ -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>
+11
View File
@@ -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
View File
@@ -4,7 +4,8 @@ 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">
<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
@@ -17,4 +18,5 @@ import Base from '../layouts/Base.astro';
<button class="btn" type="submit">Search</button>
</form>
</article>
</div>
</Base>
+6 -4
View File
@@ -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
View File
@@ -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>
+6 -20
View File
@@ -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">
+6 -4
View File
@@ -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
+5 -1
View File
@@ -11,7 +11,10 @@ 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">
{/* 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>
@@ -20,4 +23,5 @@ const { Content } = await render(page);
<Content />
</div>
</article>
</div>
</Base>
+1 -1
View File
@@ -7,7 +7,7 @@ const esc = (s = '') =>
s.replace(/[<>&'"]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', "'": '&apos;', '"': '&quot;' })[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);
+7 -6
View File
@@ -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}`),
+1
View File
@@ -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
View File
File diff suppressed because one or more lines are too long
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "astro/tsconfigs/base"
}