CTAO Science Portal: static Astro site with git-based CMS (Sveltia + Gitea)

Portal code only; editorial content lives in the ctao/content repo on the
demo machine's Gitea and is overlaid at build time (see README.md and
deploy/README.md). Live demo: https://astro.isl-dev.grid.cyfronet.pl
This commit is contained in:
2026-09-08 13:49:21 +02:00
parent 845c1ccf0d
commit 2817353c46
50 changed files with 11564 additions and 44 deletions
+11 -44
View File
@@ -1,50 +1,17 @@
# These are some examples of commonly ignored file patterns.
# You should customize this list as applicable to your project.
# Learn more about .gitignore:
# https://www.atlassian.com/git/tutorials/saving-changes/gitignore
# Node artifact files
node_modules/
dist/
# Compiled Java class files
*.class
# Compiled Python bytecode
*.py[cod]
# Log files
*.log
# Package files
*.jar
# Maven
target/
dist/
# JetBrains IDE
.idea/
# Unit test reports
TEST*.xml
# Generated by MacOS
.astro/
.DS_Store
# Generated by Windows
Thumbs.db
# Content lives in the ctao/content repo (build overlays it; scripts/link-content.sh locally)
src/content/news/
src/content/pages/
public/uploads/
# Applications
*.app
*.exe
*.war
# Large media files
*.mp4
*.tiff
*.avi
*.flv
*.mov
*.wmv
# Session scratch
.tmp/
# Local secrets and editor state
.env*
.vscode/
.idea/
+38
View File
@@ -0,0 +1,38 @@
# Design handoff
All styling lives in **`src/styles/global.css`** — design tokens in `:root`
(colors, type scale, spacing, radius, shadows, `--header-h`) + plain-CSS
components below them. No frameworks, no preprocessors, no runtime JS for
presentation. Pages are `.astro` templates (plain HTML).
## Brand (official: CTAO Brand Guidelines, April 2024 — PDF in the project docs)
- Colors: Galaxy Blue `#00004A`, Cherenkov Blue `#00E4D8`, Moon Gray
`#F5F5F5`, Cosmic Azure `#007AFF` (focus rings only; links use the
darkened `--link` so they pass AA on white), Interstellar Indigo `#00009C`.
Cherenkov is an ACCENT (brand book rations it — currently: primary CTA,
hero subtitle, logo flash); it fails AA as text on white.
- Comments in the code cite `SPEC §…`, `BRAND D.x` and `REQUIREMENTS`: those
are the CTAO specification, the brand guidelines PDF and the team's
consolidated requirements, kept in the project's document folder at
Cyfronet, not in this repo.
- Type: **Inter** for everything, **Space Grotesk** (weight 500) only for
communicative headlines (h1/h2). Sizes come from the `--fs-*` scale.
- Logo files in `public/brand/`; always "CTAO", min 100px wide, no effects.
## Practical notes (the non-obvious ones)
- **`public/admin/preview.css` mirrors the `.prose` styles** so the CMS
editor preview matches articles 1:1 — when you touch `.prose` or its
tokens, update the mirror (both files carry a KEEP IN SYNC comment).
- Fonts ship **inlined as data: URIs** in global.css (no font requests, no
swap flash). The woff2 files in `public/fonts/` look unused but feed the
CMS preview — keep them.
- All `animation`/`transition` rules sit in the single
`prefers-reduced-motion: no-preference` block at the end of global.css.
- Scroll-driven effects need `vite.build.cssMinify: 'esbuild'`
(astro.config.mjs) — the default minifier breaks `animation-timeline`.
- `--header-h` drives the sticky-header offsets (anchors, TOC rail, reading
progress); change the header height only through the token.
- Deterministic gate before pushing: `npm run check` (types + build + link
check, a few seconds).
+100
View File
@@ -0,0 +1,100 @@
# 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> ·
Gitea on the demo machine: <https://astro-git.isl-dev.grid.cyfronet.pl>
## Repository layout & deployment
Code and content are SEPARATE repositories, so editorial commits never mix
with development:
| Repo | Holds | Source of truth | Who commits |
|---|---|---|---|
| `ctao-portal` (this one, Bitbucket) | Templates, CSS, deploy | development | the team |
| `ctao/content` (Gitea on the demo machine) | `news/`, `pages/`, `uploads/` | editorial | Sveltia CMS / editors |
The demo machine polls ITS Gitea (`ctao/portal` mirror + `ctao/content`) and
republishes on a push to either — so a push to Bitbucket does NOT deploy;
deploying code = pushing it to the machine's Gitea (a deliberate step). The
build overlays content onto code (`deploy/build.sh`); the content paths are
gitignored here.
## 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
Prerequisites: Node 22.19 or newer (the machine builds on Node 24), and
network access to the demo machine's Gitea (Cyfronet network / VPN) for the
content clone below. Without content the site still builds, but with no
articles and `npm run check` reports the footer's page links as broken.
```
npm ci
git clone https://astro-git.isl-dev.grid.cyfronet.pl/ctao/content.git ../ctao-content
./scripts/link-content.sh # copies news/pages/uploads into the dev tree
npm run dev # http://localhost:4321
npm run build # static output in dist/
npm run check # deterministic gate: astro check (types) + build + internal-link check
```
`tsconfig.json` excludes `public/` on purpose: the vendored CMS bundle there is
2 MB of minified JS and would make `astro check` run out of memory.
## Layout
| 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 (from the content repo — gitignored here; schema in `src/content.config.ts`) |
| `src/styles/global.css` | All CSS: tokens + components + prose |
| `DESIGN.md` | Design handoff: where the tokens live, brand basics, practical notes |
| `public/admin/` | CMS config (`config.yml`) + editor preview styles (`preview.css`) |
| `public/uploads/` | Editor-uploaded media (from the content repo — gitignored here) |
| `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).
## Upgrading
Versions are pinned exactly; an upgrade is a deliberate change, never a side
effect of `npm install`. Astro ships security fixes only for the current and
one previous major, so plan one upgrade per major rather than skipping several.
```
npx @astrojs/upgrade # moves astro and official integrations together
npm run check # types + build + links must pass
npm run preview # verify the real dist/ output, not the dev server
```
No experimental flags in `astro.config.mjs`: they can change in minor releases.
+31
View File
@@ -0,0 +1,31 @@
import { defineConfig } from 'astro/config';
// Static output (zero runtime) — the whole point of the git-based approach.
export default defineConfig({
output: 'static',
// 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',
// URL contract, pinned explicitly (these are the defaults): changing either
// later rewrites every public URL. nginx serves /path/ via try_files $uri/.
trailingSlash: 'ignore',
build: { format: 'directory' },
// Lossless whitespace handling instead of the v7 default 'jsx', which drops
// line breaks around inline elements and silently glues words to links when
// a template line wraps before <a>. Templates render as written.
compressHTML: true,
// Built-in prefetch on every internal link (no per-link attributes needed).
// Default 'hover' strategy: near-instant navigation without the bandwidth
// cost of 'viewport' on a 24-card grid; auto-falls back to 'tap' on
// data-saver / slow connections (Astro handles that).
prefetch: { prefetchAll: true },
vite: {
build: {
// Lightning CSS (the default minifier) illegally folds animation-timeline
// INTO the `animation` shorthand (`animation: ... view()`), which browsers
// reject — silently killing every scroll-driven effect in production.
// esbuild minifies without property merging.
cssMinify: 'esbuild',
},
},
});
+8
View File
@@ -0,0 +1,8 @@
# 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 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
RUN apk add --no-cache git
+127
View File
@@ -0,0 +1,127 @@
# Deploy — CTAO portal demo on strapi-experimental.cyfronet
Everything runs rootless as user `strapi`. Footprint on the machine:
ONE directory (`~/ctao-portal-demo/`) + 4 unit files in `~/.config/`.
Uninstall restores the machine exactly (see bottom). No secrets in any file.
Two Gitea repos by design — `ctao/portal` (code, developed by the team) and
`ctao/content` (Markdown + uploads, committed by the CMS). The build overlays
content onto code; a push to EITHER republishes the site.
```
~/ctao-portal-demo/
├── gitea-data/ # Gitea state (repos, SQLite, accounts) ← the ONLY thing worth backing up
├── gitea-config/ # Gitea app.ini (generated from env)
├── repo/ # clone of the code repo (created by first build)
├── content/ # clone of the content repo (created by first build)
├── releases/ # <code>-<content>/ dirs + `current` symlink (what nginx serves)
├── state/ # last-built release id
├── npm-cache/ # npm cache for the build container (created by build.sh)
├── bin/build.sh # copied from deploy/ (source of truth stays in the code repo)
└── config/nginx.conf
```
Host prerequisites: `podman`, `curl`, `jq` (build.sh checks and says which is
missing). Everything else runs inside containers.
| Port | What | Exposed as |
|---|---|---|
| 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]** `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/`
- `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 .`
(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`
7. **[W]** In the Gitea UI: create org `ctao` with repos `portal` and
`content` (both public read). Then, from your workstation, push both over
the vhost with a repo-scoped token:
`git push https://<user>:<token>@astro-git.isl-dev.grid.cyfronet.pl/ctao/portal.git main`
`git push https://<user>:<token>@astro-git.isl-dev.grid.cyfronet.pl/ctao/content.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).
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).
Editor accounts need WRITE access to `ctao/content` (Gitea → repo →
Collaborators, or a team) — public read alone lets them sign in but every
save fails.
## CMS sign-in (Sveltia ↔ Gitea OAuth)
- 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`.
## Uninstall (leaves only podman's own storage metadata)
```
systemctl --user disable --now ctao-portal-build.timer
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 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
- A release that fails to build is not retried until either repo gets a new
commit (`state/last-failed` guards against a 10 s rebuild loop). To force a
retry of the SAME release (e.g. after fixing `bin/build.sh` itself):
`rm ~/ctao-portal-demo/state/last-failed`.
- **Only pushes to the machine's Gitea auto-deploy** (code and content repos
alike). 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;
the build container is capped at 1 GB via `podman run --memory`. Watch the
first `npm ci` + build in the journal — if it OOMs inside its cgroup
(contained, just retries), raise the cap.
- Build isolation: build containers join the Gitea container's network
namespace (`--network=container:ctao-demo-gitea`) — repo/npm code sees
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) and repo-scoped push tokens (managed in Gitea) — that's the
complete list. Build/poll/serve use none.
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env bash
# CTAO portal — poll the code AND content repos, rebuild on any change,
# publish atomically. Triggered every 10 s by ctao-portal-build.timer; systemd
# oneshot semantics guarantee runs never overlap. The 99.9% case is two local
# curls and exit 0. No secrets anywhere: both repos are public-read on the
# local Gitea.
#
# Two repositories by design: code (templates/CSS, developed by the team) and
# content (Markdown + uploads, committed by the CMS). The build overlays
# content onto code, so an editor publishing an article and a developer
# shipping CSS never mix histories — either change republishes the site.
set -euo pipefail
# Defaults match the machine; every var is env-overridable so the whole
# pipeline can be tested locally against a sandbox dir + local Gitea.
BASE="${BASE:-$HOME/ctao-portal-demo}"
GITEA_URL="${GITEA_URL:-http://localhost:3000}" # published by ctao-demo-gitea
CODE_REPO="${CODE_REPO:-ctao/portal}" # owner/repo in Gitea
CONTENT_REPO="${CONTENT_REPO:-ctao/content}"
BRANCH="${BRANCH:-main}"
BUILD_IMAGE="${BUILD_IMAGE:-localhost/ctao-portal-build:1}"
# The build joins the Gitea container's network namespace: localhost inside
# the build = Gitea's loopback (port 3000), host loopback stays unreachable.
# (A netavark bridge would be equivalent, but rootless bridges need the
# ip_tables kernel module, absent on the machine — pasta needs nothing.)
BUILD_NETNS="${BUILD_NETNS:-container:ctao-demo-gitea}"
CODE_URL="${CODE_URL:-http://localhost:3000/$CODE_REPO.git}"
CONTENT_URL="${CONTENT_URL:-http://localhost:3000/$CONTENT_REPO.git}"
KEEP="${KEEP:-3}" # previous releases kept besides `current` (rollback targets)
mkdir -p "$BASE/repo" "$BASE/content" "$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: both branch heads via the local Gitea API ---
head_of() {
curl -fsS --max-time 5 "$GITEA_URL/api/v1/repos/$1/branches/$BRANCH" \
| jq -r '.commit.id' || true
}
code_sha=$(head_of "$CODE_REPO")
content_sha=$(head_of "$CONTENT_REPO")
# Gitea down/unreachable is a transient, not a unit failure — exit 0 quietly
# instead of painting the journal red every 10 s. Name the repo: a 404 here
# also means "repo/branch missing or renamed", not just "Gitea down".
[[ "$code_sha" =~ ^[0-9a-f]{40}$ ]] || { echo "poll failed for $CODE_REPO@$BRANCH (gitea down, or repo/branch missing) — skipping"; exit 0; }
[[ "$content_sha" =~ ^[0-9a-f]{40}$ ]] || { echo "poll failed for $CONTENT_REPO@$BRANCH (gitea down, or repo/branch missing) — skipping"; exit 0; }
release="${code_sha:0:12}-${content_sha:0:12}" # code+content pin the release
# Skip only if this pair is both recorded AND still present in releases/
# (a deleted release dir must trigger a rebuild, not an eternal skip).
[[ "$release" == "$(cat "$BASE/state/last-built" 2>/dev/null)" \
&& -d "$BASE/releases/$release" ]] && exit 0
# A release that already failed is not retried until either repo moves —
# otherwise one bad commit (e.g. broken frontmatter) turns into a full
# rebuild every 10 s on a shared VM. The failure is loud once, then quiet.
if [[ "$release" == "$(cat "$BASE/state/last-failed" 2>/dev/null)" ]]; then
echo "skipping $release — build failed before; push a fix to retry"
exit 0
fi
echo "building code=$code_sha content=$content_sha"
t0=$(date +%s)
# --- 2. Build in the ephemeral container (git + pinned node live there).
# SECURITY: the container runs npm lifecycle scripts from the repo, so it is
# confined to Gitea's netns — it reaches Gitea on localhost:3000 and the
# internet (for `npm ci` when the lockfile changed), but NOT the host's
# loopback services. Never use --network=host here.
# node_modules and .deps-hash are untracked, so they survive checkouts.
podman run --rm --network="$BUILD_NETNS" --memory=1g \
-e ASTRO_TELEMETRY_DISABLED=1 \
-e CODE_SHA="$code_sha" -e CODE_URL="$CODE_URL" \
-e CONTENT_SHA="$content_sha" -e CONTENT_URL="$CONTENT_URL" \
-e RELEASE="$release" -e BRANCH="$BRANCH" \
-v "$BASE/repo:/work/repo:z" \
-v "$BASE/content:/work/content:z" \
-v "$BASE/releases:/work/releases:z" \
-v "$BASE/npm-cache:/root/.npm:z" \
-w /work "$BUILD_IMAGE" sh -ec '
git config --global safe.directory "/work/repo"
git config --global --add safe.directory "/work/content"
sync_clone() { # $1 dir $2 url $3 sha
[ -d "$1/.git" ] || git clone --branch "$BRANCH" "$2" "$1"
git -C "$1" remote set-url origin "$2" # self-heal if the URL changes
git -C "$1" fetch --quiet origin "$BRANCH"
# --force: the working copy is disposable; a stray tracked-file edit
# must not wedge every future build.
git -C "$1" checkout --quiet --force "$3"
}
sync_clone repo "$CODE_URL" "$CODE_SHA"
sync_clone content "$CONTENT_URL" "$CONTENT_SHA"
# Overlay content onto code (these paths are gitignored in the code repo).
# mkdir -p: checkout prunes the emptied parent dirs, cp needs them back.
rm -rf repo/src/content/news repo/src/content/pages repo/public/uploads
mkdir -p repo/src/content repo/public
cp -a content/news repo/src/content/news
cp -a content/pages repo/src/content/pages
cp -a content/uploads repo/public/uploads
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) 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
npm run build
rm -rf "../releases/$RELEASE"
cp -a dist "../releases/$RELEASE"
' || { echo "$release" > "$BASE/state/last-failed"; echo "BUILD FAILED for $release (see above) — will not retry until a new commit"; exit 1; }
rm -f "$BASE/state/last-failed"
# --- 3. Atomic publish: symlink flip via rename(2) — no half-published moment.
# mv -T is GNU (the target host is Rocky); when testing on macOS put a
# coreutils `mv` (gmv) first in PATH.
rm -f "$BASE/releases/".current.* # stale temps from a crash mid-flip
ln -s "$release" "$BASE/releases/.current.$$"
mv -Tf "$BASE/releases/.current.$$" "$BASE/releases/current"
echo "$release" > "$BASE/state/last-built"
# --- 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"
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
echo "published $release in $(( $(date +%s) - t0 ))s"
+62
View File
@@ -0,0 +1,62 @@
# CTAO portal demo — Gitea (CMS backend: content repo, editor accounts, OAuth for Sveltia)
# Quadlet unit. Install: copy to ~/.config/containers/systemd/ on the machine,
# then `systemctl --user daemon-reload` → service name: ctao-demo-gitea.service.
# Rootless image + SQLite by design: one container, no DB server, all state
# lives in ~/ctao-portal-demo/gitea-{data,config} — nothing else on the machine.
[Unit]
Description=CTAO portal demo — Gitea
Wants=network-online.target
After=network-online.target
[Container]
ContainerName=ctao-demo-gitea
# Pinned by digest at install (2026-07-28); tag kept for readability.
Image=docker.io/gitea/gitea:1.27-rootless@sha256:36cce26be71609091e1236d5b5de2c66a81fb8a7d45756a5fd3b7a28c11733b7
# The rootless image runs as uid 1000 inside; keep-id maps it to the host user
# so the bind-mounted dirs stay owned by `strapi` (no chown, no root anywhere).
UserNS=keep-id:uid=1000,gid=1000
# Networking: default rootless pasta (userspace — the machine's kernel lacks
# ip_tables for rootless netavark bridges, and we have no sudo). The build
# container joins THIS container's netns (--network=container:ctao-demo-gitea),
# 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.
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
# 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 7).
Environment=GITEA__repository__DEFAULT_PRIVATE=public
# 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
# exists). Authorization must be listed in HEADERS — the default set
# (Content-Type,User-Agent) would block Sveltia's authenticated API calls.
Environment=GITEA__cors__ENABLED=true
Environment=GITEA__cors__ALLOW_DOMAIN=https://astro.isl-dev.grid.cyfronet.pl
Environment=GITEA__cors__HEADERS=Authorization,Content-Type,User-Agent
Environment=GITEA__cors__METHODS=GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS
[Service]
Restart=on-failure
# Shared 3.6 GB VM (Outline + Strapi live here too) — keep Gitea on a leash.
MemoryHigh=400M
MemoryMax=600M
[Install]
WantedBy=default.target
+28
View File
@@ -0,0 +1,28 @@
# CTAO portal demo — static web server. Serves ~/ctao-portal-demo/releases/current
# (a symlink the build flips atomically). Read-only mounts: nginx can only read.
# Quadlet unit → service name: ctao-demo-web.service.
[Unit]
Description=CTAO portal demo — static web server (nginx)
Wants=network-online.target
After=network-online.target
[Container]
ContainerName=ctao-demo-web
# Pinned by digest at install (2026-07-28); tag kept for readability.
Image=docker.io/library/nginx:stable-alpine@sha256:97d490c12ba55b4946b01546d1c3ed324e8d41ab1c9fcb2a616aa470620e5b46
# releases/ is shared with the build container (:z shared label);
# 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
+15
View File
@@ -0,0 +1,15 @@
# CTAO portal demo — rebuild-on-new-commit worker (fired by the .timer).
# Install: copy to ~/.config/systemd/user/ → systemctl --user daemon-reload.
# Type=oneshot + timer means runs can never overlap: the timer will not
# activate a service that is still running.
[Unit]
Description=CTAO portal demo — rebuild if the code or content repo has new commits
[Service]
Type=oneshot
ExecStart=%h/ctao-portal-demo/bin/build.sh
# Polite neighbour on the shared VM:
Nice=10
# First-ever npm ci on 2 vCPU can be slow; normal rebuilds are seconds.
TimeoutStartSec=900
+18
View File
@@ -0,0 +1,18 @@
# CTAO portal demo — poll cadence for the build worker.
# OnUnitInactiveSec: next tick 10 s after the previous run FINISHES (it is
# deactivation-relative; OnUnitActiveSec would be start-relative). Overlap is
# impossible either way — a timer never activates a still-running unit.
# OnActiveSec covers `systemctl --user enable --now` (first tick 5 s later);
# OnBootSec covers reboot. Enable: systemctl --user enable --now ctao-portal-build.timer
[Unit]
Description=CTAO portal demo — poll the code and content repos every 10 s
[Timer]
OnActiveSec=5
OnBootSec=30
OnUnitInactiveSec=10
AccuracySec=1s
[Install]
WantedBy=timers.target
+49
View File
@@ -0,0 +1,49 @@
# CTAO portal demo — static serving. The cache split is what makes the
# 10-second publish loop feel instant: HTML is revalidated on every request
# (a symlink flip shows up on the next refresh), fingerprinted assets are
# cached forever.
server {
listen 80;
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" 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" 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" 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 application/xml;
}
+5104
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "ctao-portal",
"type": "module",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"check": "astro check && astro build && node scripts/check-links.mjs"
},
"dependencies": {
"astro": "7.2.10"
},
"devDependencies": {
"@astrojs/check": "0.9.10",
"typescript": "6.0.3"
},
"engines": {
"node": ">=22.19.0"
}
}
+80
View File
@@ -0,0 +1,80 @@
# Sveltia CMS config — backend: Gitea behind the Cyfronet ingress (HTTPS
# vhost). Editors log in via Gitea OAuth (PKCE, no client secret); edits are
# committed to the ctao/content repo. Gitea's ROOT_URL and CORS are pinned to
# these origins in deploy/ctao-demo-gitea.container.
backend:
name: gitea
# Content lives in its OWN repo (editorial history separate from code —
# the build overlays it onto the portal code, see deploy/build.sh).
repo: ctao/content
branch: main
base_url: https://astro-git.isl-dev.grid.cyfronet.pl
api_root: https://astro-git.isl-dev.grid.cyfronet.pl/api/v1
app_id: cf1c44ac-ebe6-4a97-bf2f-f7f26ef1126c
# OAuth only — hides the "access token" sign-in option (documented
# auth_methods). The "Work with Local Repository" button needs no config:
# it renders only on localhost (source-verified), so it vanishes once the
# portal is served from a real domain.
auth_methods: [oauth]
# CTAO branding on the sign-in screen and browser tab (documented Sveltia
# options: app_title replaces the "Sveltia CMS" label; logo.src is the
# current form of the deprecated logo_url).
app_title: CTAO Content Editor
logo:
src: /brand/CTAO_Logo_login.svg
media_folder: "uploads"
public_folder: "/uploads"
# Keep the git repo lean: images are stored as plain git blobs (no LFS), so
# every upload is converted client-side to WebP q85 and capped at 2048px
# (documented media_libraries transformations). A phone photo lands in the
# repo as ~100-300 KB instead of 5-10 MB.
media_libraries:
default:
config:
slugify_filename: true
transformations:
raster_image:
format: webp
quality: 85
width: 2048
height: 2048
svg:
optimize: true
collections:
- name: news
label: "News"
label_singular: "Article"
folder: "news"
create: true
slug: "{{slug}}"
extension: md
format: frontmatter
summary: "{{title}} · {{date}}"
fields:
- { name: title, label: "Title", widget: string }
- { name: description, label: "Short description", widget: text }
- { name: date, label: "Date", widget: datetime, date_format: "YYYY-MM-DD", time_format: false }
- { 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 }
- name: pages
label: "Pages"
label_singular: "Page"
folder: "pages"
create: true
slug: "{{slug}}"
extension: md
format: frontmatter
summary: "{{title}}"
fields:
- { name: title, label: "Title", widget: string }
- { name: description, label: "Description", widget: text, required: false }
- { name: body, label: "Body", widget: markdown }
+71
View File
@@ -0,0 +1,71 @@
/* Entry-preview styles for the Sveltia editor, registered via the documented
CMS.registerPreviewStyle API (bare element selectors, per the official docs
example). This file MIRRORS the article styles 1:1 — the design tokens from
:root and the `.prose` block in src/styles/global.css — with `.prose`
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 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 */
--galaxy: #00004a;
--moon: #f5f5f5;
--text: #101228;
--muted: #46536a;
--border: #e2e5ee;
--link: #0057c2;
--font-body: "Inter", Arial, system-ui, sans-serif;
--font-display: "Space Grotesk", var(--font-body);
--fs-s: 0.9rem;
--fs-l: 1.125rem;
--fs-xl: 1.5rem;
--fs-h2: clamp(1.75rem, 3.5vw, 2.5rem);
--radius: 16px;
}
/* preview shell ≈ article column */
body {
font-family: var(--font-body);
color: var(--text);
max-width: 70ch;
margin: 0 auto;
padding: 24px;
}
/* = global.css base heading/link rules = */
h1, h2 { font-family: var(--font-display); font-weight: 500; letter-spacing: -0.025em; line-height: 1.1; }
h3 { font-weight: 600; }
h1, h2, h3 { overflow-wrap: break-word; text-wrap: balance; }
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; }
a { overflow-wrap: anywhere; }
h1 { color: var(--galaxy); font-size: var(--fs-h2); } /* mirrors `.article 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; }
ul, ol { margin: 0 0 16px; padding-left: 22px; }
li { margin: 6px 0; }
blockquote {
margin: 22px 0; padding: 12px 20px; border-left: 3px solid var(--galaxy);
background: var(--moon); color: var(--text);
}
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); }
th { text-align: left; font-weight: 600; color: var(--galaxy); }
th, td { padding: 10px 12px; border-bottom: 1px solid var(--border); }
thead th { border-bottom: 2px solid var(--galaxy); }
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

@@ -0,0 +1,129 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 28.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Ebene_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 153 32" style="enable-background:new 0 0 153 32;" xml:space="preserve">
<style type="text/css">
.st0{display:none;}
.st1{display:inline;}
.st2{display:inline;clip-path:url(#SVGID_00000021077498389242353310000002629454631782585475_);}
.st3{fill:#FFFFFF;}
.st4{fill:#00E5D9;}
.st5{fill:#00E4D8;}
</style>
<g class="st0">
<defs>
<rect id="SVGID_1_" x="0.9" width="151.4" height="32"/>
</defs>
<clipPath id="SVGID_00000068637063279286702420000005043763006986931103_" class="st1">
<use xlink:href="#SVGID_1_" style="overflow:visible;"/>
</clipPath>
<g style="display:inline;clip-path:url(#SVGID_00000068637063279286702420000005043763006986931103_);">
<path class="st3" d="M19.3,0c10.9,0,17.1,4.8,18,12.6h-9.1c-0.6-2.8-3.4-5.1-8.8-5.1c-6.3,0-9.4,3.2-9.4,8.5
c0,5.3,3.1,8.6,9.4,8.6c5.5,0,8.3-2.4,8.8-5.2h9.1c-0.9,7.9-7,12.7-18,12.7C7.4,32,0.9,26.4,0.9,16C0.9,5.6,7.4,0,19.3,0z"/>
<path class="st3" d="M62.3,8.4c0.2-0.1,0.4-0.1,0.6-0.1c4,0,8,0,12.1,0c0.2,0,0.3,0,0.5,0c0.1,0,0.2,0,0.3-0.1c0-0.1,0-0.2,0-0.3
c0-2.3,0-4.6,0-6.9c0-0.1,0-0.2,0-0.3c-0.2,0-0.5,0-0.7,0c-11.4,0-22.8,0-34.3,0c-0.2,0-0.3,0-0.4,0c-0.1,0-0.2,0-0.3,0.1
c0,0.2,0,0.4,0,0.5c0,0.8,0,1.6,0,2.4c0,1.3,0,2.6,0,3.9c0,0.2,0,0.4,0.1,0.5c0.2,0.1,0.4,0.1,0.6,0.1c4,0,8.1,0,12.1,0
c0.2,0,0.3,0,0.5,0c0.1,0,0.2,0,0.3,0.1c0,0.2,0,0.5,0,0.7c0,6.6,0,13.1,0,19.7c0,0.2,0,0.4,0,0.5c0,0.1,0,0.2,0,0.3
c0.1,0,0.1,0,0.1,0c2.7-0.5,5.4-1.1,8.1-1.6c0.1,0,0.2-0.1,0.3-0.1c0-0.2,0-0.4,0-0.6c0-6,0-12,0-18.1c0-0.2,0-0.4,0-0.5
C62.2,8.6,62.3,8.5,62.3,8.4L62.3,8.4z M53.7,30.2c0,0.3-0.1,0.6,0,0.8c0.4,0.1,6.6,0.1,8.2,0c0.1,0,0.2,0,0.3,0
c0-0.1,0.1-0.1,0.1-0.2c0-0.6,0-1.3,0-1.9c0,0,0-0.1-0.1-0.2C59.4,29.2,56.5,29.7,53.7,30.2L53.7,30.2z"/>
<path class="st3" d="M151.5,11.4c0.1,0,0.2,0,0.2,0c0.1,0.1,0.1,0.2,0.1,0.3c0.1,0.6,0.2,1.3,0.3,1.9c0.2,1.3,0.2,2.6,0.1,3.8
c-0.1,2-0.6,4-1.5,5.9c-1,2.2-2.6,3.9-4.5,5.3c-1.2,0.8-2.5,1.5-3.9,2c-1.1,0.4-2.3,0.7-3.4,0.9c-0.5,0.1-1.1,0.2-1.6,0.3
c-0.5,0.1-0.9,0.1-1.4,0.1c-0.1,0-0.1,0-0.2,0c-0.7,0.1-1.5,0.1-2.2,0.1c-0.7,0-1.5,0-2.2,0c-0.3,0-0.5,0-0.8-0.1
c-0.1,0-0.3,0-0.4,0c-2.2-0.2-4.3-0.6-6.4-1.3c-1.4-0.5-2.7-1.2-4-2c-2.7-1.9-4.5-4.5-5.4-7.7c-0.1-0.5-0.3-1.1-0.3-1.6
c0-0.1,0-0.1,0-0.2c0.1-0.1,0.2-0.1,0.4-0.2c0.5-0.1,1.1-0.2,1.6-0.3c0.9-0.2,1.8-0.4,2.7-0.5c0.9-0.2,1.8-0.4,2.7-0.6
c0.3-0.1,0.5-0.1,0.8-0.2c0.2,0,0.3-0.1,0.5-0.1c0.1,0,0.1,0.1,0.2,0.2c0,0.4,0.1,0.8,0.2,1.2c0.2,0.9,0.6,1.8,1.2,2.6
c0.6,0.8,1.3,1.4,2.2,1.9c0.7,0.4,1.5,0.7,2.4,0.9c0.6,0.1,1.2,0.3,1.8,0.3c0.8,0.1,1.7,0.2,2.5,0.2c0.4,0,0.7,0,1.1,0
c0.1,0,0.3,0,0.4,0c1.3-0.1,2.5-0.3,3.7-0.7c0.5-0.2,1-0.4,1.5-0.7c1.6-0.9,2.7-2.3,3.2-4.1c0.2-0.6,0.3-1.2,0.3-1.8
c0-0.5,0.1-1,0.1-1.4c0-0.7-0.1-1.4-0.2-2.1c0-0.1,0-0.2,0-0.3c0,0,0-0.1,0-0.1c0,0,0.1,0,0.1-0.1c0.1,0,0.3-0.1,0.4-0.1
c1.5-0.3,2.9-0.6,4.4-0.9c0.7-0.1,1.5-0.3,2.2-0.4c0.2,0,0.4-0.1,0.6-0.1c0.1,0,0.1-0.1,0.2-0.1c0,0,0,0,0-0.1
C151.2,11.3,151.3,11.3,151.5,11.4C151.5,11.4,151.5,11.4,151.5,11.4L151.5,11.4z"/>
<path class="st3" d="M151.1,11.5c-0.4,0-0.7,0.1-1.1,0.1c-0.7,0.1-1.4,0.2-2.1,0.4c-0.7,0.1-1.3,0.2-2,0.4c-0.7,0.1-1.3,0.2-2,0.3
c-0.1,0-0.3,0.1-0.4,0.1c-0.1,0-0.3,0-0.4,0c-0.1-0.2-0.2-0.3-0.2-0.5c-0.5-1.4-1.4-2.4-2.6-3.2c-0.5-0.3-1-0.6-1.5-0.8
c-0.7-0.3-1.5-0.5-2.3-0.6c-1.3-0.2-2.6-0.3-3.8-0.3c-0.7,0-1.4,0.1-2.1,0.2c-0.8,0.1-1.6,0.3-2.4,0.5c-0.5,0.2-1,0.4-1.4,0.6
c-1.8,1-3,2.5-3.5,4.5c-0.2,0.7-0.3,1.5-0.3,2.2c0,0.2,0,0.4,0,0.7c0,0.1,0,0.2-0.1,0.2c-0.1,0.1-0.3,0.1-0.4,0.1
c-0.6,0.1-1.3,0.2-1.9,0.3c-0.6,0.1-1.3,0.2-1.9,0.3c-0.7,0.1-1.4,0.2-2.1,0.4c-0.7,0.1-1.3,0.2-2,0.4c-0.1,0-0.3,0.1-0.4,0
c-0.1,0-0.1-0.1-0.1-0.2c0-0.1,0-0.3,0-0.4c0-0.9-0.1-1.8,0-2.7c0.1-2.3,0.7-4.5,1.8-6.6c1.1-2,2.6-3.6,4.4-4.8
c1.3-0.9,2.7-1.5,4.2-2c1.1-0.4,2.3-0.6,3.4-0.8c0.6-0.1,1.3-0.2,1.9-0.3c0.6-0.1,1.3-0.1,1.9-0.1c1.2,0,2.4,0,3.6,0
c0.2,0,0.4,0,0.5,0c0.1,0,0.2,0,0.3,0c0.8,0,1.6,0.2,2.5,0.3c1.3,0.2,2.5,0.5,3.7,0.9c1.4,0.5,2.8,1.1,4.1,2
c1.9,1.3,3.4,2.9,4.5,5c0.4,0.8,0.8,1.7,1,2.6c0,0.1,0,0.2,0.1,0.2c0,0,0,0.1,0,0.1c-0.1,0-0.1,0.1-0.2,0.1l0,0
C151.4,11.5,151.2,11.4,151.1,11.5L151.1,11.5L151.1,11.5z"/>
<path class="st3" d="M151,11.5c0.1-0.1,0.1-0.1,0.2-0.1c0.1,0,0.2,0,0.3,0C151.4,11.5,151.2,11.5,151,11.5L151,11.5z"/>
<path class="st3" d="M84.5,23.5C84.5,23.4,84.6,23.4,84.5,23.5c2.7-5.4,5.3-10.8,8-16.1c0,0,0-0.1,0.1-0.1c0,0,0-0.1,0.1-0.1
c0,0,0,0,0,0c0,0,0,0,0.1,0c0.1,0.1,0.2,0.3,0.2,0.4c0.9,1.8,1.7,3.5,2.6,5.3c1.2,2.4,2.4,4.8,3.6,7.2c0.1,0.2,0.1,0.3,0.3,0.5
c0.6-0.1,1.2-0.2,1.8-0.3c1.1-0.2,2.1-0.4,3.2-0.5c1-0.2,2-0.3,3-0.5c0.1,0,0.2-0.1,0.3-0.1c-0.1-0.2-0.2-0.3-0.2-0.5
c-1.4-2.8-2.8-5.6-4.2-8.4c-1.5-2.9-2.9-5.8-4.4-8.7c-0.1-0.2-0.1-0.4-0.3-0.5c-0.1,0-0.1,0-0.2,0c-3.8,0-7.6,0-11.4,0
c-0.1,0-0.1,0-0.2,0c0,0,0,0-0.1,0c0,0,0,0-0.1,0C82.6,9,78.5,17.2,74.4,25.3c0,0,0,0.1,0,0.1c0,0,0.1,0.1,0.1,0
C77.8,24.8,81.2,24.1,84.5,23.5z M100,21.8c0.3,0.7,4.4,9,4.6,9.3c0.1,0,0.2,0,0.3,0c2.9,0,5.8,0,8.7,0c0,0,0,0,0.1,0
c0,0,0,0,0.1-0.1c0-0.1-0.1-0.2-0.1-0.2c-0.1-0.2-0.3-0.5-0.4-0.8c-1.6-3.1-3.2-6.3-4.7-9.4c-0.1-0.2-0.1-0.3-0.3-0.5
C105.5,20.7,102.8,21.2,100,21.8L100,21.8z M73.7,26.8c-0.7,1.4-1.4,2.7-2,4.1c0,0.1,0,0.2-0.1,0.3h9.1c0.2-0.3,2.6-5,2.9-5.8
c0-0.1,0-0.1,0.1-0.2c-0.1,0-0.2,0-0.3,0C80.2,25.6,76.9,26.2,73.7,26.8L73.7,26.8z M93.8,29C93.8,29,93.8,29,93.8,29
c0.1-0.5,0.1-1,0-1.5c0-0.5-0.1-1.1,0-1.6c0-0.3,0.1-0.7,0.2-1c0.3-1.2,1.1-2,2.1-2.4c0.5-0.2,1.1-0.4,1.6-0.6
c0.4-0.2,0.9-0.3,1.3-0.5c-0.1-0.1-0.2-0.1-0.3-0.1c-1,0-1.9,0-2.9,0c-0.9,0-1.7-0.4-2.4-1c-0.4-0.4-0.8-0.9-1-1.5
c-0.3-0.8-0.6-1.6-0.8-2.4c0-0.1,0-0.1-0.2-0.1c0,0.2,0,0.3,0,0.5c0,0.8,0,1.7,0,2.5c0,0.8-0.2,1.5-0.7,2.1
c-0.5,0.7-1.1,1.1-1.8,1.3c-0.8,0.3-1.6,0.6-2.4,0.9c-0.1,0-0.1,0.1-0.2,0.1c0.1,0.1,0.3,0.1,0.4,0.1c0.9,0,1.8,0,2.8,0
c0.2,0,0.4,0,0.6,0.1c1.3,0.3,2.1,1.1,2.6,2.3c0.1,0.2,0.2,0.5,0.3,0.7C93.3,27.6,93.5,28.3,93.8,29L93.8,29z"/>
<path class="st4" d="M93.8,29C93.8,29,93.8,29,93.8,29c0.1-0.5,0.1-1,0-1.5c0-0.5-0.1-1.1,0-1.6c0-0.3,0.1-0.7,0.2-1
c0.3-1.2,1.1-2,2.1-2.4c0.5-0.2,1.1-0.4,1.6-0.6c0.4-0.2,0.8-0.3,1.3-0.5c-0.1-0.1-0.2-0.1-0.3-0.1c-1,0-1.9,0-2.9,0
c-0.9,0-1.7-0.4-2.4-1c-0.4-0.4-0.8-0.9-1-1.5c-0.3-0.8-0.6-1.6-0.8-2.4c0-0.1,0-0.1-0.2-0.1c0,0.2,0,0.3,0,0.5c0,0.8,0,1.7,0,2.5
c0,0.8-0.2,1.5-0.7,2.1c-0.5,0.7-1.1,1.1-1.8,1.3c-0.8,0.3-1.6,0.6-2.4,0.9c-0.1,0-0.1,0.1-0.2,0.1c0.1,0.1,0.3,0.1,0.4,0.1
c0.9,0,1.8,0,2.8,0c0.2,0,0.4,0,0.6,0.1c1.3,0.3,2.1,1.1,2.6,2.3c0.1,0.2,0.2,0.5,0.3,0.7C93.3,27.6,93.5,28.3,93.8,29L93.8,29z"
/>
</g>
</g>
<g id="Logotype" class="st0">
<g class="st1">
<g>
<polygon class="st3" points="100.4,22.3 104.7,30.8 113.9,30.8 108.6,20.6 "/>
<g>
<path class="st3" d="M122.9,16.1c0,0,0-0.1,0-0.1c0-5.2,2.9-8.3,10.2-8.3c5.7,0,8.8,1.9,9.8,5.2l8.9-1.4
c-1.9-7-8.3-11.1-18.7-11.1c-12.5,0-19.3,5.8-19.3,15.6c0,0.5,0,1.1,0.1,1.6L122.9,16.1L122.9,16.1z"/>
<path class="st3" d="M143.1,13.3c0.2,0.8,0.3,1.7,0.3,2.6c0,5.2-3,8.4-10.3,8.4c-6.5,0-9.6-2.5-10.1-6.8l-8.8,1.8
c1.4,7.8,8,12.2,18.9,12.2c12.4,0,19.3-5.9,19.3-15.7c0-1.6-0.2-3.1-0.5-4.5L143.1,13.3L143.1,13.3z"/>
</g>
<g>
<polygon class="st3" points="73.5,27 71.5,30.8 80.8,30.8 83.5,25.4 "/>
<polygon class="st3" points="98.6,1.2 86.8,1.2 74.6,24.7 84.9,22.6 92.7,7.2 99.2,19.9 107.6,18.6 "/>
</g>
<g>
<g>
<polygon class="st3" points="53.6,30.2 53.6,30.8 62.2,30.8 62.2,28.8 "/>
<polygon class="st3" points="53.6,29.1 62.2,27.3 62.2,8.5 75.7,8.5 75.7,1.2 40.1,1.2 40.1,8.5 53.6,8.5 "/>
</g>
<path class="st3" d="M19.3,0.4c10.9,0,17.1,4.7,18,12.3h-9.1c-0.6-2.7-3.4-5-8.8-5c-6.3,0-9.4,3.1-9.4,8.3
c0,5.2,3.1,8.4,9.4,8.4c5.5,0,8.3-2.3,8.8-5.1h9.1c-0.9,7.7-7.1,12.4-18,12.4C7.4,31.6,0.9,26.1,0.9,16S7.4,0.4,19.3,0.4
L19.3,0.4z"/>
</g>
</g>
<path class="st5" d="M94.7,23.8c1-1.4,3.3-2.1,5.2-2.7c-2,0.1-4.4,0.3-5.8-0.6c-1.4-1-2.1-3.3-2.7-5.2c0.1,2,0.3,4.4-0.6,5.8
c-1,1.4-3.3,2.1-5.2,2.7c2-0.1,4.4-0.3,5.8,0.6c1.4,1,2.1,3.3,2.7,5.2C93.9,27.6,93.7,25.2,94.7,23.8z"/>
</g>
</g>
<g id="Logotype_00000134236662009793237430000016451412871672361145_">
<g>
<g>
<polygon class="st3" points="100,22.3 104.3,30.7 113.5,30.7 108.2,20.5 "/>
<g>
<path class="st3" d="M122.4,16.1c0,0,0-0.1,0-0.1c0-5.2,2.9-8.2,10.2-8.2c5.7,0,8.7,1.9,9.8,5.2l8.8-1.4c-1.8-7-8.2-11-18.6-11
c-12.4,0-19.1,5.7-19.1,15.5c0,0.5,0,1.1,0.1,1.6L122.4,16.1L122.4,16.1z"/>
<path class="st3" d="M142.5,13.4c0.2,0.8,0.3,1.7,0.3,2.6c0,5.1-3,8.3-10.2,8.3c-6.4,0-9.5-2.5-10.1-6.7l-8.7,1.8
c1.4,7.7,7.9,12.2,18.8,12.2c12.4,0,19.1-5.8,19.1-15.6c0-1.6-0.2-3.1-0.5-4.4L142.5,13.4L142.5,13.4z"/>
</g>
<g>
<polygon class="st3" points="73.4,26.9 71.4,30.7 80.6,30.7 83.3,25.3 "/>
<polygon class="st3" points="98.4,1.3 86.6,1.3 74.5,24.7 84.7,22.5 92.5,7.3 98.9,19.9 107.2,18.6 "/>
</g>
<g>
<g>
<polygon class="st3" points="53.7,30.1 53.7,30.7 62.2,30.7 62.2,28.7 "/>
<polygon class="st3" points="53.7,29 62.2,27.3 62.2,8.5 75.6,8.5 75.6,1.3 40.2,1.3 40.2,8.5 53.7,8.5 "/>
</g>
<path class="st3" d="M19.6,0.5c10.8,0,17,4.7,17.8,12.2h-9.1c-0.6-2.7-3.3-5-8.8-5c-6.3,0-9.4,3.1-9.4,8.2s3.1,8.3,9.4,8.3
c5.4,0,8.2-2.3,8.8-5.1h9.1c-0.9,7.6-7,12.3-17.8,12.3C7.8,31.5,1.3,26.1,1.3,16S7.8,0.5,19.6,0.5L19.6,0.5z"/>
</g>
</g>
<path class="st3" d="M94.4,23.7c1-1.4,3.3-2.1,5.1-2.7c-2,0.1-4.4,0.3-5.8-0.6c-1.4-1-2.1-3.3-2.7-5.1c0.1,2,0.3,4.4-0.6,5.8
c-1,1.4-3.3,2.1-5.1,2.7c2-0.1,4.4-0.3,5.8,0.6c1.4,1,2.1,3.3,2.7,5.1C93.6,27.5,93.4,25.1,94.4,23.7z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.6 KiB

+18
View File
@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 189 68" role="img" aria-label="CTAO">
<rect width="189" height="68" rx="12" fill="#00004A"/>
<g fill="#FFFFFF" transform="translate(18 18)">
<polygon points="100,22.3 104.3,30.7 113.5,30.7 108.2,20.5"/>
<path d="M122.4,16.1c0,0,0-0.1,0-0.1c0-5.2,2.9-8.2,10.2-8.2c5.7,0,8.7,1.9,9.8,5.2l8.8-1.4c-1.8-7-8.2-11-18.6-11
c-12.4,0-19.1,5.7-19.1,15.5c0,0.5,0,1.1,0.1,1.6L122.4,16.1L122.4,16.1z"/>
<path d="M142.5,13.4c0.2,0.8,0.3,1.7,0.3,2.6c0,5.1-3,8.3-10.2,8.3c-6.4,0-9.5-2.5-10.1-6.7l-8.7,1.8
c1.4,7.7,7.9,12.2,18.8,12.2c12.4,0,19.1-5.8,19.1-15.6c0-1.6-0.2-3.1-0.5-4.4L142.5,13.4L142.5,13.4z"/>
<polygon points="73.4,26.9 71.4,30.7 80.6,30.7 83.3,25.3"/>
<polygon points="98.4,1.3 86.6,1.3 74.5,24.7 84.7,22.5 92.5,7.3 98.9,19.9 107.2,18.6"/>
<polygon points="53.7,30.1 53.7,30.7 62.2,30.7 62.2,28.7"/>
<polygon points="53.7,29 62.2,27.3 62.2,8.5 75.6,8.5 75.6,1.3 40.2,1.3 40.2,8.5 53.7,8.5"/>
<path d="M19.6,0.5c10.8,0,17,4.7,17.8,12.2h-9.1c-0.6-2.7-3.3-5-8.8-5c-6.3,0-9.4,3.1-9.4,8.2s3.1,8.3,9.4,8.3
c5.4,0,8.2-2.3,8.8-5.1h9.1c-0.9,7.6-7,12.3-17.8,12.3C7.8,31.5,1.3,26.1,1.3,16S7.8,0.5,19.6,0.5L19.6,0.5z"/>
<path fill="#00E4D8" d="M94.4,23.7c1-1.4,3.3-2.1,5.1-2.7c-2,0.1-4.4,0.3-5.8-0.6c-1.4-1-2.1-3.3-2.7-5.1c0.1,2,0.3,4.4-0.6,5.8
c-1,1.4-3.3,2.1-5.1,2.7c2-0.1,4.4-0.3,5.8,0.6c1.4,1,2.1,3.3,2.7,5.1C93.6,27.5,93.4,25.1,94.4,23.7z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+129
View File
@@ -0,0 +1,129 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 28.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Ebene_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 153 32" style="enable-background:new 0 0 153 32;" xml:space="preserve">
<style type="text/css">
.st0{display:none;}
.st1{display:inline;}
.st2{display:inline;clip-path:url(#SVGID_00000021077498389242353310000002629454631782585475_);}
.st3{fill:#00004A;}
.st4{fill:#00E5D9;}
.st5{fill:#00E4D8;}
</style>
<g class="st0">
<defs>
<rect id="SVGID_1_" x="0.9" width="151.4" height="32"/>
</defs>
<clipPath id="SVGID_00000068637063279286702420000005043763006986931103_" class="st1">
<use xlink:href="#SVGID_1_" style="overflow:visible;"/>
</clipPath>
<g style="display:inline;clip-path:url(#SVGID_00000068637063279286702420000005043763006986931103_);">
<path class="st3" d="M19.3,0c10.9,0,17.1,4.8,18,12.6h-9.1c-0.6-2.8-3.4-5.1-8.8-5.1c-6.3,0-9.4,3.2-9.4,8.5
c0,5.3,3.1,8.6,9.4,8.6c5.5,0,8.3-2.4,8.8-5.2h9.1c-0.9,7.9-7,12.7-18,12.7C7.4,32,0.9,26.4,0.9,16C0.9,5.6,7.4,0,19.3,0z"/>
<path class="st3" d="M62.3,8.4c0.2-0.1,0.4-0.1,0.6-0.1c4,0,8,0,12.1,0c0.2,0,0.3,0,0.5,0c0.1,0,0.2,0,0.3-0.1c0-0.1,0-0.2,0-0.3
c0-2.3,0-4.6,0-6.9c0-0.1,0-0.2,0-0.3c-0.2,0-0.5,0-0.7,0c-11.4,0-22.8,0-34.3,0c-0.2,0-0.3,0-0.4,0c-0.1,0-0.2,0-0.3,0.1
c0,0.2,0,0.4,0,0.5c0,0.8,0,1.6,0,2.4c0,1.3,0,2.6,0,3.9c0,0.2,0,0.4,0.1,0.5c0.2,0.1,0.4,0.1,0.6,0.1c4,0,8.1,0,12.1,0
c0.2,0,0.3,0,0.5,0c0.1,0,0.2,0,0.3,0.1c0,0.2,0,0.5,0,0.7c0,6.6,0,13.1,0,19.7c0,0.2,0,0.4,0,0.5c0,0.1,0,0.2,0,0.3
c0.1,0,0.1,0,0.1,0c2.7-0.5,5.4-1.1,8.1-1.6c0.1,0,0.2-0.1,0.3-0.1c0-0.2,0-0.4,0-0.6c0-6,0-12,0-18.1c0-0.2,0-0.4,0-0.5
C62.2,8.6,62.3,8.5,62.3,8.4L62.3,8.4z M53.7,30.2c0,0.3-0.1,0.6,0,0.8c0.4,0.1,6.6,0.1,8.2,0c0.1,0,0.2,0,0.3,0
c0-0.1,0.1-0.1,0.1-0.2c0-0.6,0-1.3,0-1.9c0,0,0-0.1-0.1-0.2C59.4,29.2,56.5,29.7,53.7,30.2L53.7,30.2z"/>
<path class="st3" d="M151.5,11.4c0.1,0,0.2,0,0.2,0c0.1,0.1,0.1,0.2,0.1,0.3c0.1,0.6,0.2,1.3,0.3,1.9c0.2,1.3,0.2,2.6,0.1,3.8
c-0.1,2-0.6,4-1.5,5.9c-1,2.2-2.6,3.9-4.5,5.3c-1.2,0.8-2.5,1.5-3.9,2c-1.1,0.4-2.3,0.7-3.4,0.9c-0.5,0.1-1.1,0.2-1.6,0.3
c-0.5,0.1-0.9,0.1-1.4,0.1c-0.1,0-0.1,0-0.2,0c-0.7,0.1-1.5,0.1-2.2,0.1c-0.7,0-1.5,0-2.2,0c-0.3,0-0.5,0-0.8-0.1
c-0.1,0-0.3,0-0.4,0c-2.2-0.2-4.3-0.6-6.4-1.3c-1.4-0.5-2.7-1.2-4-2c-2.7-1.9-4.5-4.5-5.4-7.7c-0.1-0.5-0.3-1.1-0.3-1.6
c0-0.1,0-0.1,0-0.2c0.1-0.1,0.2-0.1,0.4-0.2c0.5-0.1,1.1-0.2,1.6-0.3c0.9-0.2,1.8-0.4,2.7-0.5c0.9-0.2,1.8-0.4,2.7-0.6
c0.3-0.1,0.5-0.1,0.8-0.2c0.2,0,0.3-0.1,0.5-0.1c0.1,0,0.1,0.1,0.2,0.2c0,0.4,0.1,0.8,0.2,1.2c0.2,0.9,0.6,1.8,1.2,2.6
c0.6,0.8,1.3,1.4,2.2,1.9c0.7,0.4,1.5,0.7,2.4,0.9c0.6,0.1,1.2,0.3,1.8,0.3c0.8,0.1,1.7,0.2,2.5,0.2c0.4,0,0.7,0,1.1,0
c0.1,0,0.3,0,0.4,0c1.3-0.1,2.5-0.3,3.7-0.7c0.5-0.2,1-0.4,1.5-0.7c1.6-0.9,2.7-2.3,3.2-4.1c0.2-0.6,0.3-1.2,0.3-1.8
c0-0.5,0.1-1,0.1-1.4c0-0.7-0.1-1.4-0.2-2.1c0-0.1,0-0.2,0-0.3c0,0,0-0.1,0-0.1c0,0,0.1,0,0.1-0.1c0.1,0,0.3-0.1,0.4-0.1
c1.5-0.3,2.9-0.6,4.4-0.9c0.7-0.1,1.5-0.3,2.2-0.4c0.2,0,0.4-0.1,0.6-0.1c0.1,0,0.1-0.1,0.2-0.1c0,0,0,0,0-0.1
C151.2,11.3,151.3,11.3,151.5,11.4C151.5,11.4,151.5,11.4,151.5,11.4L151.5,11.4z"/>
<path class="st3" d="M151.1,11.5c-0.4,0-0.7,0.1-1.1,0.1c-0.7,0.1-1.4,0.2-2.1,0.4c-0.7,0.1-1.3,0.2-2,0.4c-0.7,0.1-1.3,0.2-2,0.3
c-0.1,0-0.3,0.1-0.4,0.1c-0.1,0-0.3,0-0.4,0c-0.1-0.2-0.2-0.3-0.2-0.5c-0.5-1.4-1.4-2.4-2.6-3.2c-0.5-0.3-1-0.6-1.5-0.8
c-0.7-0.3-1.5-0.5-2.3-0.6c-1.3-0.2-2.6-0.3-3.8-0.3c-0.7,0-1.4,0.1-2.1,0.2c-0.8,0.1-1.6,0.3-2.4,0.5c-0.5,0.2-1,0.4-1.4,0.6
c-1.8,1-3,2.5-3.5,4.5c-0.2,0.7-0.3,1.5-0.3,2.2c0,0.2,0,0.4,0,0.7c0,0.1,0,0.2-0.1,0.2c-0.1,0.1-0.3,0.1-0.4,0.1
c-0.6,0.1-1.3,0.2-1.9,0.3c-0.6,0.1-1.3,0.2-1.9,0.3c-0.7,0.1-1.4,0.2-2.1,0.4c-0.7,0.1-1.3,0.2-2,0.4c-0.1,0-0.3,0.1-0.4,0
c-0.1,0-0.1-0.1-0.1-0.2c0-0.1,0-0.3,0-0.4c0-0.9-0.1-1.8,0-2.7c0.1-2.3,0.7-4.5,1.8-6.6c1.1-2,2.6-3.6,4.4-4.8
c1.3-0.9,2.7-1.5,4.2-2c1.1-0.4,2.3-0.6,3.4-0.8c0.6-0.1,1.3-0.2,1.9-0.3c0.6-0.1,1.3-0.1,1.9-0.1c1.2,0,2.4,0,3.6,0
c0.2,0,0.4,0,0.5,0c0.1,0,0.2,0,0.3,0c0.8,0,1.6,0.2,2.5,0.3c1.3,0.2,2.5,0.5,3.7,0.9c1.4,0.5,2.8,1.1,4.1,2
c1.9,1.3,3.4,2.9,4.5,5c0.4,0.8,0.8,1.7,1,2.6c0,0.1,0,0.2,0.1,0.2c0,0,0,0.1,0,0.1c-0.1,0-0.1,0.1-0.2,0.1l0,0
C151.4,11.5,151.2,11.4,151.1,11.5L151.1,11.5L151.1,11.5z"/>
<path class="st3" d="M151,11.5c0.1-0.1,0.1-0.1,0.2-0.1c0.1,0,0.2,0,0.3,0C151.4,11.5,151.2,11.5,151,11.5L151,11.5z"/>
<path class="st3" d="M84.5,23.5C84.5,23.4,84.6,23.4,84.5,23.5c2.7-5.4,5.3-10.8,8-16.1c0,0,0-0.1,0.1-0.1c0,0,0-0.1,0.1-0.1
c0,0,0,0,0,0c0,0,0,0,0.1,0c0.1,0.1,0.2,0.3,0.2,0.4c0.9,1.8,1.7,3.5,2.6,5.3c1.2,2.4,2.4,4.8,3.6,7.2c0.1,0.2,0.1,0.3,0.3,0.5
c0.6-0.1,1.2-0.2,1.8-0.3c1.1-0.2,2.1-0.4,3.2-0.5c1-0.2,2-0.3,3-0.5c0.1,0,0.2-0.1,0.3-0.1c-0.1-0.2-0.2-0.3-0.2-0.5
c-1.4-2.8-2.8-5.6-4.2-8.4c-1.5-2.9-2.9-5.8-4.4-8.7c-0.1-0.2-0.1-0.4-0.3-0.5c-0.1,0-0.1,0-0.2,0c-3.8,0-7.6,0-11.4,0
c-0.1,0-0.1,0-0.2,0c0,0,0,0-0.1,0c0,0,0,0-0.1,0C82.6,9,78.5,17.2,74.4,25.3c0,0,0,0.1,0,0.1c0,0,0.1,0.1,0.1,0
C77.8,24.8,81.2,24.1,84.5,23.5z M100,21.8c0.3,0.7,4.4,9,4.6,9.3c0.1,0,0.2,0,0.3,0c2.9,0,5.8,0,8.7,0c0,0,0,0,0.1,0
c0,0,0,0,0.1-0.1c0-0.1-0.1-0.2-0.1-0.2c-0.1-0.2-0.3-0.5-0.4-0.8c-1.6-3.1-3.2-6.3-4.7-9.4c-0.1-0.2-0.1-0.3-0.3-0.5
C105.5,20.7,102.8,21.2,100,21.8L100,21.8z M73.7,26.8c-0.7,1.4-1.4,2.7-2,4.1c0,0.1,0,0.2-0.1,0.3h9.1c0.2-0.3,2.6-5,2.9-5.8
c0-0.1,0-0.1,0.1-0.2c-0.1,0-0.2,0-0.3,0C80.2,25.6,76.9,26.2,73.7,26.8L73.7,26.8z M93.8,29C93.8,29,93.8,29,93.8,29
c0.1-0.5,0.1-1,0-1.5c0-0.5-0.1-1.1,0-1.6c0-0.3,0.1-0.7,0.2-1c0.3-1.2,1.1-2,2.1-2.4c0.5-0.2,1.1-0.4,1.6-0.6
c0.4-0.2,0.9-0.3,1.3-0.5c-0.1-0.1-0.2-0.1-0.3-0.1c-1,0-1.9,0-2.9,0c-0.9,0-1.7-0.4-2.4-1c-0.4-0.4-0.8-0.9-1-1.5
c-0.3-0.8-0.6-1.6-0.8-2.4c0-0.1,0-0.1-0.2-0.1c0,0.2,0,0.3,0,0.5c0,0.8,0,1.7,0,2.5c0,0.8-0.2,1.5-0.7,2.1
c-0.5,0.7-1.1,1.1-1.8,1.3c-0.8,0.3-1.6,0.6-2.4,0.9c-0.1,0-0.1,0.1-0.2,0.1c0.1,0.1,0.3,0.1,0.4,0.1c0.9,0,1.8,0,2.8,0
c0.2,0,0.4,0,0.6,0.1c1.3,0.3,2.1,1.1,2.6,2.3c0.1,0.2,0.2,0.5,0.3,0.7C93.3,27.6,93.5,28.3,93.8,29L93.8,29z"/>
<path class="st4" d="M93.8,29C93.8,29,93.8,29,93.8,29c0.1-0.5,0.1-1,0-1.5c0-0.5-0.1-1.1,0-1.6c0-0.3,0.1-0.7,0.2-1
c0.3-1.2,1.1-2,2.1-2.4c0.5-0.2,1.1-0.4,1.6-0.6c0.4-0.2,0.8-0.3,1.3-0.5c-0.1-0.1-0.2-0.1-0.3-0.1c-1,0-1.9,0-2.9,0
c-0.9,0-1.7-0.4-2.4-1c-0.4-0.4-0.8-0.9-1-1.5c-0.3-0.8-0.6-1.6-0.8-2.4c0-0.1,0-0.1-0.2-0.1c0,0.2,0,0.3,0,0.5c0,0.8,0,1.7,0,2.5
c0,0.8-0.2,1.5-0.7,2.1c-0.5,0.7-1.1,1.1-1.8,1.3c-0.8,0.3-1.6,0.6-2.4,0.9c-0.1,0-0.1,0.1-0.2,0.1c0.1,0.1,0.3,0.1,0.4,0.1
c0.9,0,1.8,0,2.8,0c0.2,0,0.4,0,0.6,0.1c1.3,0.3,2.1,1.1,2.6,2.3c0.1,0.2,0.2,0.5,0.3,0.7C93.3,27.6,93.5,28.3,93.8,29L93.8,29z"
/>
</g>
</g>
<g id="Logotype" class="st0">
<g class="st1">
<g>
<polygon class="st3" points="100.4,22.3 104.7,30.8 113.9,30.8 108.6,20.6 "/>
<g>
<path class="st3" d="M122.9,16.1c0,0,0-0.1,0-0.1c0-5.2,2.9-8.3,10.2-8.3c5.7,0,8.8,1.9,9.8,5.2l8.9-1.4
c-1.9-7-8.3-11.1-18.7-11.1c-12.5,0-19.3,5.8-19.3,15.6c0,0.5,0,1.1,0.1,1.6L122.9,16.1L122.9,16.1z"/>
<path class="st3" d="M143.1,13.3c0.2,0.8,0.3,1.7,0.3,2.6c0,5.2-3,8.4-10.3,8.4c-6.5,0-9.6-2.5-10.1-6.8l-8.8,1.8
c1.4,7.8,8,12.2,18.9,12.2c12.4,0,19.3-5.9,19.3-15.7c0-1.6-0.2-3.1-0.5-4.5L143.1,13.3L143.1,13.3z"/>
</g>
<g>
<polygon class="st3" points="73.5,27 71.5,30.8 80.8,30.8 83.5,25.4 "/>
<polygon class="st3" points="98.6,1.2 86.8,1.2 74.6,24.7 84.9,22.6 92.7,7.2 99.2,19.9 107.6,18.6 "/>
</g>
<g>
<g>
<polygon class="st3" points="53.6,30.2 53.6,30.8 62.2,30.8 62.2,28.8 "/>
<polygon class="st3" points="53.6,29.1 62.2,27.3 62.2,8.5 75.7,8.5 75.7,1.2 40.1,1.2 40.1,8.5 53.6,8.5 "/>
</g>
<path class="st3" d="M19.3,0.4c10.9,0,17.1,4.7,18,12.3h-9.1c-0.6-2.7-3.4-5-8.8-5c-6.3,0-9.4,3.1-9.4,8.3
c0,5.2,3.1,8.4,9.4,8.4c5.5,0,8.3-2.3,8.8-5.1h9.1c-0.9,7.7-7.1,12.4-18,12.4C7.4,31.6,0.9,26.1,0.9,16S7.4,0.4,19.3,0.4
L19.3,0.4z"/>
</g>
</g>
<path class="st5" d="M94.7,23.8c1-1.4,3.3-2.1,5.2-2.7c-2,0.1-4.4,0.3-5.8-0.6c-1.4-1-2.1-3.3-2.7-5.2c0.1,2,0.3,4.4-0.6,5.8
c-1,1.4-3.3,2.1-5.2,2.7c2-0.1,4.4-0.3,5.8,0.6c1.4,1,2.1,3.3,2.7,5.2C93.9,27.6,93.7,25.2,94.7,23.8z"/>
</g>
</g>
<g id="Logotype_00000134236662009793237430000016451412871672361145_">
<g>
<g>
<polygon class="st3" points="100,22.3 104.3,30.7 113.5,30.7 108.2,20.5 "/>
<g>
<path class="st3" d="M122.4,16.1c0,0,0-0.1,0-0.1c0-5.2,2.9-8.2,10.2-8.2c5.7,0,8.7,1.9,9.8,5.2l8.8-1.4c-1.8-7-8.2-11-18.6-11
c-12.4,0-19.1,5.7-19.1,15.5c0,0.5,0,1.1,0.1,1.6L122.4,16.1L122.4,16.1z"/>
<path class="st3" d="M142.5,13.4c0.2,0.8,0.3,1.7,0.3,2.6c0,5.1-3,8.3-10.2,8.3c-6.4,0-9.5-2.5-10.1-6.7l-8.7,1.8
c1.4,7.7,7.9,12.2,18.8,12.2c12.4,0,19.1-5.8,19.1-15.6c0-1.6-0.2-3.1-0.5-4.4L142.5,13.4L142.5,13.4z"/>
</g>
<g>
<polygon class="st3" points="73.4,26.9 71.4,30.7 80.6,30.7 83.3,25.3 "/>
<polygon class="st3" points="98.4,1.3 86.6,1.3 74.5,24.7 84.7,22.5 92.5,7.3 98.9,19.9 107.2,18.6 "/>
</g>
<g>
<g>
<polygon class="st3" points="53.7,30.1 53.7,30.7 62.2,30.7 62.2,28.7 "/>
<polygon class="st3" points="53.7,29 62.2,27.3 62.2,8.5 75.6,8.5 75.6,1.3 40.2,1.3 40.2,8.5 53.7,8.5 "/>
</g>
<path class="st3" d="M19.6,0.5c10.8,0,17,4.7,17.8,12.2h-9.1c-0.6-2.7-3.3-5-8.8-5c-6.3,0-9.4,3.1-9.4,8.2s3.1,8.3,9.4,8.3
c5.4,0,8.2-2.3,8.8-5.1h9.1c-0.9,7.6-7,12.3-17.8,12.3C7.8,31.5,1.3,26.1,1.3,16S7.8,0.5,19.6,0.5L19.6,0.5z"/>
</g>
</g>
<path class="st3" d="M94.4,23.7c1-1.4,3.3-2.1,5.1-2.7c-2,0.1-4.4,0.3-5.8-0.6c-1.4-1-2.1-3.3-2.7-5.1c0.1,2,0.3,4.4-0.6,5.8
c-1,1.4-3.3,2.1-5.1,2.7c2-0.1,4.4-0.3,5.8,0.6c1.4,1,2.1,3.3,2.7,5.1C93.6,27.5,93.4,25.1,94.4,23.7z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 955 B

Binary file not shown.
Binary file not shown.
+4
View File
@@ -0,0 +1,4 @@
# Demo instance — keep out of search indexes until this becomes the real
# portal on its final domain (then: Allow: / and restore the Sitemap line).
User-agent: *
Disallow: /
+164
View File
@@ -0,0 +1,164 @@
// Shared client-side news search over /search.json (no dependencies, no modules).
// Used by the header search (field + mobile reveal) and /search (full results).
// Focused-empty state per Baymard autocomplete guidance: recent searches (local
// only — localStorage, no network) plus the newest articles, never a void.
(function () {
const RKEY = 'ctao-recent-searches';
function recents() {
try { return JSON.parse(localStorage.getItem(RKEY) || '[]'); } catch { return []; }
}
function remember(q) {
q = (q || '').trim();
if (q.length < 2) return;
try {
const r = [q, ...recents().filter((x) => x.toLowerCase() !== q.toLowerCase())].slice(0, 4);
localStorage.setItem(RKEY, JSON.stringify(r));
} catch { /* private mode — recents are a nicety */ }
}
window.newsSearch = function (opts) {
const input = document.getElementById(opts.input);
const list = document.getElementById(opts.list);
const status = document.getElementById(opts.status);
// Null-safe: a page may render only some search surfaces — bail silently
// (returning a no-op) so one missing input can't kill later inits.
if (!input || !list || !status) return function () {};
let index = null;
let timer;
async function ensureIndex() {
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
// from text nodes, never innerHTML.
function highlight(text, q) {
const i = text.toLowerCase().indexOf(q);
if (i < 0) return [text];
const mark = document.createElement('mark');
mark.textContent = text.slice(i, i + q.length);
return [text.slice(0, i), mark, text.slice(i + q.length)];
}
function label(text) {
const li = document.createElement('li');
li.className = 'suggest-label';
li.setAttribute('aria-hidden', 'true'); // the status line announces it
li.textContent = text;
return li;
}
function row(children, href, q) {
const li = document.createElement('li');
const a = document.createElement('a');
a.href = href;
if (q) a.dataset.q = q;
a.append(...children);
li.append(a);
return li;
}
// Dropdowns close with a "Press Enter for all results" row — a real list
// element (was a CSS ::after), aria-hidden like the group labels so
// screen-reader exposure stays consistent (the status line announces).
function hint() {
if (!opts.dismiss || !list.firstChild) return;
const li = document.createElement('li');
li.className = 'suggest-hint';
li.setAttribute('aria-hidden', 'true');
li.textContent = 'Press Enter for all results';
list.append(li);
}
// Focused-empty state: up to 4 recent queries, topped up to 5 rows with
// the newest articles from the same index.
async function idle() {
if (input.value.trim().length >= 2) return;
// Build off-DOM, swap atomically — clearing before the await shrinks the
// page mid-flight and the browser clamps the scroll position ("jumps up").
const frag = document.createDocumentFragment();
const r = recents();
if (r.length) {
frag.append(label('Recent'));
for (const term of r) frag.append(row([term], '/search?q=' + encodeURIComponent(term), term));
}
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) {
const li = row([p.title], '/news/' + encodeURIComponent(p.slug));
const small = document.createElement('small');
small.textContent = p.date;
li.append(small);
frag.append(li);
}
list.replaceChildren(frag);
hint();
status.textContent = opts.hint || (r.length ? 'Showing recent searches and latest news' : 'Showing latest news');
}
async function run() {
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);
const unit = opts.unit || 'result';
status.textContent = hits.length
? hits.length + ' ' + unit + (hits.length === 1 ? '' : 's') + ' for “' + input.value.trim() + '”'
: 'No results for “' + input.value.trim() + '”. Try a shorter or different term.';
// Off-DOM build + atomic swap (no transient page-height collapse)
const frag = document.createDocumentFragment();
for (const p of hits) {
const li = row(highlight(p.title, q), '/news/' + encodeURIComponent(p.slug));
const small = document.createElement('small');
small.textContent = p.date;
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();
}
input.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(run, 150); });
input.addEventListener('focus', () => { if (input.value.trim().length < 2) idle(); });
// Remember successful queries: Enter (form submit) and suggestion clicks
if (input.form) input.form.addEventListener('submit', () => remember(input.value));
list.addEventListener('click', (e) => {
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). 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.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(); });
input.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && list.firstChild) { hide(); e.stopPropagation(); } // panel Escape stays a second step
});
}
return run; // callers may run() immediately (e.g. /search?q=…)
};
})();
+3418
View File
File diff suppressed because one or more lines are too long
+38
View File
@@ -0,0 +1,38 @@
// Deterministic internal-link check over the built site (zero dependencies).
// Run AFTER `astro build`: scans dist/**/*.html for root-relative href/src
// values and fails (exit 1) if any target has no file in dist/. External
// URLs, mailto:, data: and pure-#fragment links are out of scope.
import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs';
import { join } from 'node:path';
const DIST = new URL('../dist', import.meta.url).pathname;
if (!existsSync(DIST)) { console.error('dist/ not found — run `npm run build` first'); process.exit(1); }
const htmlFiles = [];
(function walk(dir) {
for (const name of readdirSync(dir)) {
const p = join(dir, name);
if (statSync(p).isDirectory()) walk(p);
else if (name.endsWith('.html')) htmlFiles.push(p);
}
})(DIST);
const resolves = (path) => {
const clean = decodeURI(path.split(/[?#]/)[0]);
if (clean === '/') return true;
return ['', '.html', '/index.html'].some((suffix) =>
existsSync(join(DIST, clean.replace(/\/$/, '') + suffix)));
};
let broken = 0;
for (const file of htmlFiles) {
const html = readFileSync(file, 'utf8');
for (const [, , url] of html.matchAll(/\s(href|src)="(\/[^"]*)"/g)) {
if (!resolves(url)) {
console.error(`broken: ${url} (in ${file.slice(DIST.length + 1)})`);
broken++;
}
}
}
console.log(broken ? `${broken} broken internal link(s)` : `OK — ${htmlFiles.length} pages, all internal links resolve`);
process.exit(broken ? 1 : 0);
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Local dev: put the content repo's files where the build expects them.
# Usage: ./scripts/link-content.sh [path-to-content-clone] (default ../ctao-content)
# Copies (not symlinks) so the dev tree behaves exactly like the machine
# build overlay; re-run after pulling content changes.
set -euo pipefail
cd "$(dirname "$0")/.."
src="${1:-../ctao-content}"
for d in news pages uploads; do
[ -d "$src/$d" ] || { echo "content clone incomplete: $src/$d missing (git clone <gitea>/ctao/content.git first)"; exit 1; }
done
rm -rf src/content/news src/content/pages public/uploads
mkdir -p src/content public
cp -a "$src/news" src/content/news
cp -a "$src/pages" src/content/pages
cp -a "$src/uploads" public/uploads
echo "content linked from $src"
+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>
+41
View File
@@ -0,0 +1,41 @@
import { defineCollection } from 'astro:content';
import { z } from 'astro/zod';
import { glob } from 'astro/loaders';
// News/articles live as Markdown files in src/content/news/*.md.
// This is exactly what Sveltia CMS edits — the CMS writes these files, Astro
// renders them to static HTML at build time.
const news = defineCollection({
// Top-level only ('*.md', not '**'): entry ids feed a non-rest [slug]
// route, and an id from a subfolder would contain '/' and break it.
loader: glob({ pattern: '*.md', base: './src/content/news' }),
schema: z.object({
title: z.string(),
description: z.string(),
date: z.coerce.date(),
category: z.string().default('news'),
author: z.string().default('CTAO'),
// Public-path string, e.g. "/uploads/foo.jpg" — media lives in the
// content repo's uploads/, overlaid to public/uploads at build time,
// 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") — set as lang
// on the <article> element so screen readers pick the right voice.
lang: z.string().optional(),
draft: z.boolean().default(false),
}),
});
// Static portal pages (privacy, disclaimer, contact…) — separate collection
// because the content type differs (no dates/covers/categories), and editors
// get a distinct "Pages" section in the CMS.
const pages = defineCollection({
// Top-level only, same reason as news: the [slug] route is non-rest.
loader: glob({ pattern: '*.md', base: './src/content/pages' }),
schema: z.object({
title: z.string(),
description: z.string().optional(),
}),
});
export const collections = { news, pages };
+191
View File
@@ -0,0 +1,191 @@
---
import '../styles/global.css';
// `ambient` opts a page into the whole-page drifting brand wash (home only)
// `type`/`image` feed the social meta: articles pass type="article" + their cover.
const { title = 'CTAO Science Portal', description = 'CTAO Science Portal demo', ambient = false, type = 'website', image } = Astro.props;
// Absolute URLs for canonical/OG/RSS (head pattern from the official Astro
// blog template). `site` 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.
const ogImage = new URL(image && !image.endsWith('.svg') ? image : '/brand/og-card.jpg', site);
const path = Astro.url.pathname;
const isActive = (href: string) => (href === '/' ? path === '/' : path.startsWith(href));
// Navigation = services from the Science Portal spec (REQUIREMENTS.md §2/§5).
// Order: serial-position effect (NN/g) — News (universal entry) first, Support
// (help convention) last; Data/Proposals by task frequency; Dashboard is the
// personal area, placed late next to the Sign-in cluster. External systems
// built by other teams are links out, clearly marked.
const links = [
{ href: '/news', label: 'News' },
{ href: 'https://padc-ctao-data-explorer.obspm.fr/', label: 'Data', ext: true },
{ href: '/proposals', label: 'Proposals' },
{ href: '/dashboard', label: 'Dashboard' },
{ href: '/support', label: 'Support' },
];
---
<!doctype html>
{/* 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" />
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
{/* Social cards (Open Graph + Twitter) — article cover when present, brand card otherwise */}
<meta property="og:site_name" content="CTAO Science Portal" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:type" content={type} />
<meta property="og:url" content={canonical} />
<meta property="og:image" content={ogImage} />
<meta name="twitter:card" content="summary_large_image" />
<link rel="alternate" type="application/rss+xml" title="CTAO Science Portal News" href={new URL('/rss.xml', site)} />
<link rel="sitemap" href="/sitemap.xml" />
<link rel="icon" href="/brand/CTAO_Logo_positive.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="theme-color" content="#00004a" />
{/* 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).
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>
<header class="site-header">
<div class="container nav">
<a class="brand" href="/" aria-label="CTAO Science Portal, home">
<img src="/brand/AAFF_CTAO_Logo_RGB_Monochrome-negative.svg" alt="CTAO" />
<span class="brand-sub">Science&nbsp;Portal</span>
</a>
<nav aria-label="Main navigation">
{links.map((l) => l.ext ? (
<a class="navlink external" href={l.href} target="_blank" rel="noopener">{l.label}</a>
) : (
<a class="navlink" href={l.href} aria-current={isActive(l.href) ? 'page' : undefined}>{l.label}</a>
))}
</nav>
<span class="spacer"></span>
{/* Site search. Desktop ≥1025px: a VISIBLE quiet field — NN/g's
magnifying-glass-icon research: hiding search behind an icon
measurably reduces discoverability and usage; a visible input is
recommended when search matters (200+ articles, spec-required).
Plain GET form (works without JS); suggestions via search-client.js. */}
<form class="nav-search" role="search" aria-label="Site search" action="/search" method="get">
<label class="sr-only" for="hdr-q">Search news</label>
<svg class="ico search-ico" aria-hidden="true" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7" /><path d="m20 20-4.3-4.3" /></svg>
<input id="hdr-q" name="q" type="search" placeholder="Search…" autocomplete="off" />
<ul class="suggest" id="hdr-suggest"></ul>
<p class="sr-only" id="hdr-suggest-status" role="status" aria-live="polite"></p>
</form>
{/* ≤1024px: the magnifier reveals a search row under the header (NN/g
mobile-search pattern). name="header-panel" pairs it with the menu as
a native exclusive group — opening one closes the other, no JS. */}
<details class="search-pop" name="header-panel">
<summary aria-label="Search">
<svg class="ico" aria-hidden="true" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7" /><path d="m20 20-4.3-4.3" /></svg>
</summary>
<form role="search" aria-label="Site search" action="/search" method="get">
<label class="sr-only" for="pop-q">Search news</label>
<input id="pop-q" name="q" type="search" placeholder="Search news…" autocomplete="off" />
<button class="btn" type="submit">Search</button>
<ul class="suggest" id="pop-suggest"></ul>
<p class="sr-only" id="pop-suggest-status" role="status" aria-live="polite"></p>
</form>
</details>
<a class="login-mock" href="/login">Sign in</a>
{/* Mobile disclosure menu — native <details>/<summary>, no JS; desktop hides it via CSS */}
<details class="menu" name="header-panel">
<summary aria-label="Menu">
<svg class="ico" aria-hidden="true" viewBox="0 0 24 24"><path class="i-menu" d="M3 6h18M3 12h18M3 18h18" /><path class="i-close" d="M5 5l14 14M19 5 5 19" /></svg>
<span class="menu-label">Menu</span>
</summary>
<nav aria-label="Main navigation">
{links.map((l) => l.ext ? (
<a class="external" href={l.href} target="_blank" rel="noopener">{l.label}</a>
) : (
<a href={l.href} aria-current={isActive(l.href) ? 'page' : undefined}>{l.label}</a>
))}
</nav>
</details>
</div>
</header>
{/* Search enhancement is deferred — a synchronous script here would stall
the parser mid-body on every navigation (blank below the header until
it arrives). `defer` + the inline `type="module"` init join the same
after-parse in-order queue (HTML spec), so newsSearch is defined first.
The forms are plain GET /search, so nothing user-facing waits on JS. */}
<script is:inline src="/search-client.js" defer></script>
<script is:inline type="module">
// Header search suggestions (desktop field + mobile reveal); the forms
// are plain GET /search, so everything below is progressive enhancement.
newsSearch({ input: 'hdr-q', list: 'hdr-suggest', status: 'hdr-suggest-status', limit: 6, unit: 'suggestion', dismiss: true });
newsSearch({ input: 'pop-q', list: 'pop-suggest', status: 'pop-suggest-status', limit: 6, unit: 'suggestion', dismiss: true });
var pop = document.querySelector('.search-pop');
if (pop) pop.addEventListener('toggle', function () {
var q = document.getElementById('pop-q');
if (pop.open && q) q.focus();
});
// Light dismiss for the header panels (menu + search reveal) — scrim
// convention (Material/iOS, NN/g error prevention): a tap outside an
// open panel ONLY closes it and must never activate what's underneath.
// <details> has no native light dismiss; the Popover API was evaluated
// and rejected here — its light dismiss deliberately lets the outside
// click THROUGH (::backdrop is spec-forced pointer-events: none), the
// exact behavior research ruled out. Canceling pointerdown doesn't
// cancel the later click (Pointer Events spec), hence the click guard.
var swallow = false;
document.addEventListener('pointerdown', function (e) {
var open = document.querySelector('details[name="header-panel"][open]');
swallow = !!(open && !open.contains(e.target));
if (swallow) { open.open = false; e.preventDefault(); }
}, true);
document.addEventListener('click', function (e) {
if (swallow) { swallow = false; e.preventDefault(); e.stopPropagation(); }
}, true);
// Escape closes the open panel and returns focus to its trigger button
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape') return;
var open = document.querySelector('details[name="header-panel"][open]');
if (open) { open.open = false; open.querySelector('summary').focus(); }
});
</script>
<main id="main">
<slot />
</main>
<footer class="footer">
{/* Bottom menu required by SPEC §3.3.1: disclaimers, privacy, contact, settings, search.
Site settings has no target yet — placeholder link = <a> without href. */}
<div class="container">
<div class="foot-top">
<img src="/brand/AAFF_CTAO_Logo_RGB_Monochrome-negative.svg" alt="CTAO" />
<p>CTAO Science Portal demo by Cyfronet (SUSS-PORT team).</p>
</div>
{/* Order: action/utility first, legal middle, meta last (GOV.UK/NN-g
footer anatomy). Spec §3.3.1 requires the five items, not an order. */}
<nav class="foot-links" aria-label="Footer">
<a href="/pages/contact">Contact</a>
<a href="/search">Search</a>
<a href="/rss.xml">RSS</a>
<a href="/pages/disclaimer">Disclaimer</a>
<a href="/pages/privacy">Privacy</a>
<a>Site settings</a>
{/* Internal tooling stays out of the main nav (NN/g: navigation reflects
user tasks); the editor is a discreet utility link for the demo. */}
<a href="/admin/">Content editor</a>
</nav>
<div class="foot-note">
{/* The real brand flash — path taken from the official logo SVG (BRAND D.4).
Sole decorative accent in this view; static by design. */}
<svg class="flash" viewBox="85.3 15 15 15" aria-hidden="true"><path fill="currentColor" d="M94.7,23.8c1-1.4,3.3-2.1,5.2-2.7c-2,0.1-4.4,0.3-5.8-0.6c-1.4-1-2.1-3.3-2.7-5.2c0.1,2,0.3,4.4-0.6,5.8c-1,1.4-3.3,2.1-5.2,2.7c2-0.1,4.4-0.3,5.8,0.6c1.4,1,2.1,3.3,2.7,5.2C93.9,27.6,93.7,25.2,94.7,23.8z"/></svg>
<span>© 2026 CTAO. Demonstration, not an official service.</span>
</div>
</div>
</footer>
</body>
</html>
+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;
+24
View File
@@ -0,0 +1,24 @@
---
import Base from '../layouts/Base.astro';
---
<Base title="CTAO Science Portal - Page not found" description="This page does not exist">
{/* Document archetype: the document IS the page — h1, one line,
recovery links, and a real GET search form as the way forward. */}
<div class="container article-layout">
<article class="article">
<h1>Page not found</h1>
<div class="prose">
{/* Keep "the <a>" on one line: the compiler drops the whitespace at a
line break before a tag, which glued "the" to the link text. */}
<p>The address may be mistyped or the page may have moved.
Go to the <a href="/">home page</a> or browse <a href="/news">all news</a>.</p>
</div>
<form class="search-form" role="search" action="/search" method="get">
<label class="sr-only" for="nf-q">Search news</label>
<svg class="ico search-ico" aria-hidden="true" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7" /><path d="m20 20-4.3-4.3" /></svg>
<input id="nf-q" name="q" type="search" placeholder="Search news…" autocomplete="off" />
<button class="btn" type="submit">Search</button>
</form>
</article>
</div>
</Base>
+36
View File
@@ -0,0 +1,36 @@
---
// Editor entry point (/admin). Sveltia CMS loads its config from ./config.yml
// (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">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CTAO Content Editor</title>
<meta name="theme-color" content="#00004A" />
<link rel="icon" href="/brand/CTAO_Logo_positive.svg" type="image/svg+xml" />
<style>
/* Wrapper-page styling only — never injected into the Sveltia app.
Galaxy-Blue backdrop makes the load-in feel intentional. */
body { margin: 0; background: #00004a; }
noscript p {
color: #fff; font-family: Inter, Arial, system-ui, sans-serif;
text-align: center; margin: 40vh 24px 0; line-height: 1.6;
}
noscript a { color: #00e4d8; }
</style>
</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="/vendor/sveltia-cms.js"></script>
<script is:inline>
// Officially supported API: article previews rendered with portal-like styles.
window.CMS?.registerPreviewStyle?.('/admin/preview.css');
</script>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
---
import Base from '../layouts/Base.astro';
// Per SPEC §3.3.2 the dashboard aggregates proposal statuses, data products
// and help-desk tickets. No integrations exist yet, so panels show empty
// states instead of fabricated sample rows (source-documents-only rule).
---
<Base title="CTAO Science Portal - Dashboard" description="User dashboard (mock)">
<section class="band--moon">
<header class="container page-head">
<h1>Dashboard</h1>
<p class="standfirst">Your proposals, data products and support tickets in one place.</p>
</header>
<div class="container page">
<div class="dash-grid">
<div class="panel">
<h2 class="panel-title">My proposals</h2>
<p class="notice">Proposal statuses will appear here once the Proposal Handling System (APC team) is connected.</p>
</div>
<div class="panel">
<h2 class="panel-title">Recent data products</h2>
<p class="notice">Your data products will appear here. Search and download in the <a href="https://padc-ctao-data-explorer.obspm.fr/" target="_blank" rel="noopener" class="external">Data Explorer</a> (external, LUX team).</p>
</div>
<div class="panel">
<h2 class="panel-title">Support tickets</h2>
<p class="notice">Help-desk tickets will appear here once User Support is connected.</p>
</div>
</div>
</div>
</section>
</Base>
+93
View File
@@ -0,0 +1,93 @@
---
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);
// Services per the Science Portal spec (REQUIREMENTS §5). Tiles carry no
// status chrome; the footer's demo note is the single mock disclosure.
// Planned services stay in the grid but subdued (muted, no link) with
// availability folded into the description text.
// Tile icons: hand-drawn stroke glyphs in the header-icon family (.ico —
// currentColor, round caps), aria-hidden. NN/g icon research: icons alone are
// ambiguous, but NEXT TO an always-visible label they differentiate otherwise
// uniform tiles and speed up grid scanning.
const services = [
{ title: 'Proposals', href: '/proposals', desc: 'Submit observation proposals, including ToO / MWL requests.',
icon: '<path d="M13.5 3H7a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V7.5z"/><path d="M13.5 3v4.5H18"/><path d="M9 12.5h6M9 16h4"/>' },
{ title: 'Data Explorer', href: 'https://padc-ctao-data-explorer.obspm.fr/', ext: true, desc: 'Search and download CTAO science data products.',
icon: '<ellipse cx="12" cy="5.5" rx="7" ry="2.5"/><path d="M5 5.5V18c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5V5.5"/><path d="M5 11.8c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5"/>' },
{ title: 'Dashboard', href: '/dashboard', desc: 'Your proposals, data products and support tickets in one place.',
icon: '<rect x="4" y="4" width="7" height="7" rx="1.5"/><rect x="13" y="4" width="7" height="7" rx="1.5"/><rect x="4" y="13" width="7" height="7" rx="1.5"/><rect x="13" y="13" width="7" height="7" rx="1.5"/>' },
{ title: 'User Support', href: '/support', desc: 'Help desk, FAQ, user forum and mailing lists.',
icon: '<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="4"/><path d="m5.7 5.7 3.5 3.5m5.6 0 3.5-3.5m0 12.6-3.5-3.5m-5.6 0-3.5 3.5"/>' },
// Two subdued planned tiles complete the 2×3 grid (an orphan 4th tile in a
// 3-col grid reads broken); the remaining TBD services stay on the quiet line.
{ title: 'Software', planned: true, desc: 'Landing page for CTAO software (planned).',
icon: '<rect x="3" y="4.5" width="18" height="15" rx="2"/><path d="m7 9.5 3 2.5-3 2.5M12.5 15H17"/>' },
{ title: 'Documentation', planned: true, desc: 'CTAO user documentation (planned).',
icon: '<path d="M12 6.7C10.6 5.2 8.6 4.5 5.7 4.5H4v13.6h1.7c2.9 0 4.9.7 6.3 2.2 1.4-1.5 3.4-2.2 6.3-2.2H20V4.5h-1.7c-2.9 0-4.9.7-6.3 2.2z"/><path d="M12 6.7v13.6"/>' },
];
const planned = ['Scheduling', 'Science alerts'];
---
<Base title="CTAO Science Portal" description="News, observation proposals, data and user services of the Cherenkov Telescope Array Observatory" ambient>
{/* Photographic hero: LST-1 under the La Palma night sky (copied from the
content library to /brand/hero.jpg) beneath the Galaxy scrim — text
contrast computed worst-case at design time. Three elements only:
headline, brand subtitle, CTA pair (Apple hero restraint). */}
<section class="hero-band">
<div class="band-bg" aria-hidden="true"></div>
<div class="container hero">
<div class="eyebrow">Cherenkov Telescope Array Observatory</div>
<h1>CTAO Science Portal</h1>
<p>Exploring the Universe at the Highest Energies</p>
{/* Hero actions = the two REAL things a logged-out visitor can do today:
news (SPEC §3.1 lists general information/announcements FIRST; §3.3.1
"displays the main information"; calls for proposals arrive VIA news)
and the external Data Explorer. Proposals stays in nav + tiles — a
mock doesn't earn hero billing. Search lives in the header. */}
<div class="hero-cta">
<a class="btn" href="/news">Browse news &amp; announcements</a>
<a class="btn btn--ghost external" href="https://padc-ctao-data-explorer.obspm.fr/" target="_blank" rel="noopener">Explore the data</a>
</div>
</div>
</section>
{/* Section order is task-first: Services directly under the hero, news after.
NN/g homepage principles — the homepage must give clear starting points
for the user's top tasks; for scientists that is proposals/data/support,
not reading news. Freshness is still signalled above the fold by the
one-line hero teaser, so the 3-card news section can sit below.
Surface rhythm: services on Moon Gray, editorial news on white. */}
<section class="band--moon">
<div class="container page">
<div class="section-head"><h2>Services</h2></div>
<div class="grid">
{services.map((s) => (
<article class={s.planned ? 'card tile tile--planned' : 'card tile'}>
<svg class="ico" aria-hidden="true" viewBox="0 0 24 24" set:html={s.icon} />
<h3>{s.planned
? s.title
: (s.ext
? <a class="external" href={s.href} target="_blank" rel="noopener">{s.title}</a>
: <a href={s.href}>{s.title}</a>)}</h3>
<p class="desc">{s.desc}</p>
</article>
))}
</div>
<p class="planned-note">Planned services: {planned.join(' · ')}</p>
</div>
</section>
<section class="container page">
<div class="section-head">
<h2>Latest news</h2>
<a class="more" href="/news">All news →</a>
</div>
<div class="grid">
{posts.map((post) => <NewsCard post={post} />)}
</div>
</section>
</Base>
+16
View File
@@ -0,0 +1,16 @@
---
import Base from '../layouts/Base.astro';
---
<Base title="CTAO Science Portal - Sign in" description="Sign in via CTAO AAI (mock)">
<section class="auth-band">
<div class="band-bg" aria-hidden="true"></div>
<div class="auth-card">
<img class="auth-logo" src="/brand/CTAO_Logo_positive.svg" alt="CTAO" />
<h1>Sign in</h1>
{/* Auth pages are scanned, not read — one line, no reassurance prose */}
<p class="muted">Use your CTAO account (single sign-on via CTAO AAI).</p>
<button class="btn btn-wide" type="button">Continue with your CTAO account</button>
<p class="notice">No account? <a>Register via CTAO AAI</a></p>
</div>
</section>
</Base>
+83
View File
@@ -0,0 +1,83 @@
---
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
// 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 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;
const hrefOf = (n: number) => (n === 1 ? '/news' : `/news/${n}`);
return chunks.map((data, i) => {
const n = i + 1;
return {
params: { page: n === 1 ? undefined : String(n) },
props: { page: {
data,
currentPage: n,
lastPage,
url: {
prev: n > 1 ? hrefOf(n - 1) : undefined,
next: n < lastPage ? hrefOf(n + 1) : undefined,
},
} },
};
});
}
const { page } = Astro.props;
const first = page.currentPage === 1;
// Windowed page list — 1 … n-1 n n+1 … last (0 marks an ellipsis)
const nums = [];
for (let n = 1; n <= page.lastPage; n++) {
if (n === 1 || n === page.lastPage || Math.abs(n - page.currentPage) <= 1) nums.push(n);
else if (nums.at(-1) !== 0) nums.push(0);
}
const hrefFor = (n: number) => (n === 1 ? '/news' : `/news/${n}`);
---
<Base
title={first ? 'CTAO Science Portal - News' : `CTAO Science Portal - News, page ${page.currentPage}`}
description={first ? 'News and announcements of the CTAO' : `News and announcements of the CTAO, page ${page.currentPage} of ${page.lastPage}`}
>
{/* Section-landing archetype: white head, display Galaxy h1 + standfirst
(the navy band is reserved for / and /login). */}
<header class="container page-head">
<h1>News &amp; Announcements</h1>
<p class="standfirst">Science highlights and updates from the observatory and its partners.</p>
</header>
<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) => <NewsCard post={post} featured={first && i === 0} />)}
</div>
{page.lastPage > 1 && (
<nav class="pagination" aria-label="News pages">
{page.url.prev
? <a class="page-link page-step" href={page.url.prev} rel="prev">← Newer</a>
: <span class="page-link page-step" aria-hidden="true">← Newer</span>}
<ol>
{nums.map((n) => (
<li>
{n === 0
? <span class="page-gap" aria-hidden="true">…</span>
: <a class="page-link" href={hrefFor(n)} aria-current={n === page.currentPage ? 'page' : undefined}><span class="sr-only">Page </span>{n}</a>}
</li>
))}
</ol>
{page.url.next
? <a class="page-link page-step" href={page.url.next} rel="next">Older →</a>
: <span class="page-link page-step" aria-hidden="true">Older →</span>}
</nav>
)}
</section>
</Base>
+144
View File
@@ -0,0 +1,144 @@
---
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
// pattern of every editorial site — keeps readers in the content flow).
const posts = (await getCollection('news', ({ data }) => !data.draft)).sort(
(a, b) => b.data.date.valueOf() - a.data.date.valueOf(),
);
return posts.map((post, i) => ({
params: { slug: post.id },
props: { post, newer: posts[i - 1] ?? null, older: posts[i + 1] ?? null },
}));
}
const { post, newer, older } = Astro.props;
const { Content, headings } = await render(post);
const toc = headings.filter((h) => h.depth === 2 || h.depth === 3);
const readMin = readMinOf(post);
---
<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. */}
{/* 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" 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
description made visible — cards already show it, the article should too. */}
<h1>{post.data.title}</h1>
<p class="standfirst">{post.data.description}</p>
{/* Meta — one quiet muted line: category · author · date · reading time */}
<div class="meta">
<span class="cat"><svg class="ico" aria-hidden="true" viewBox="0 0 24 24"><path d="M20 13.2 13.2 20a2 2 0 0 1-2.9 0L4 13.7V4h9.7l6.3 6.3a2 2 0 0 1 0 2.9Z" stroke-linejoin="round" /><circle cx="8.5" cy="8.5" r="1" /></svg>{post.data.category}</span>·
<span><svg class="ico" aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="8" r="4" /><path d="M4.5 20c1.6-3.8 4.6-5.5 7.5-5.5s5.9 1.7 7.5 5.5" /></svg>{post.data.author}</span>·
<time datetime={post.data.date.toISOString().slice(0, 10)}><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>{fmt(post.data.date)}</time>·
<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} min read</span>
</div>
{post.data.cover && <img class="cover" src={encodeURI(post.data.cover)} alt="" />}
{/* TOC sits between lead image and body (Wikipedia/GOV.UK contents
position): the press head above stays unbroken, and the list is
adjacent to the content it indexes. */}
{toc.length >= 3 && (
<details class="toc toc--inline">
<summary>On this page <svg class="ico" aria-hidden="true" viewBox="0 0 24 24"><path d="m5 9 7 7 7-7" /></svg></summary>
<ol>
{toc.map((h) => (
<li class={h.depth === 3 ? 'd3' : undefined}><a href={`#${h.slug}`}>{h.text}</a></li>
))}
</ol>
</details>
)}
<div class="prose">
<Content />
</div>
{(newer || older) && (
<nav class="post-nav" aria-label="More news">
{newer && (
<a class="post-nav-prev" rel="prev" href={`/news/${newer.id}`}>
<small>← Newer</small>
<span class="post-nav-title">{newer.data.title}</span>
</a>
)}
{older && (
<a class="post-nav-next" rel="next" href={`/news/${older.id}`}>
<small>Older →</small>
<span class="post-nav-title">{older.data.title}</span>
</a>
)}
</nav>
)}
</article>
{toc.length >= 3 && (
<nav class="toc toc--rail" aria-label="On this page">
<strong>On this page</strong>
<ol>
{toc.map((h) => (
<li class={h.depth === 3 ? 'd3' : undefined}><a href={`#${h.slug}`}>{h.text}</a></li>
))}
</ol>
</nav>
)}
</div>
</Base>
<script>
// TOC scrollspy — minimal vanilla take on Starlight's starlight-toc.ts:
// an IntersectionObserver band near the viewport top; whichever prose block
// enters it is mapped back to its governing h2/h3, whose TOC link(s) get
// aria-current="true" (set in BOTH renderings — inline <details> and rail).
// Progressive enhancement: without JS the TOC is plain working anchors.
const links = [...document.querySelectorAll<HTMLAnchorElement>('.toc a[href^="#"]')];
if (links.length) {
let current: HTMLAnchorElement[] = [];
const setCurrent = (id: string | null) => {
const next = id ? links.filter((a) => decodeURIComponent(a.hash.slice(1)) === id) : [];
if (next[0] === current[0]) return;
for (const a of current) a.removeAttribute('aria-current');
for (const a of next) a.setAttribute('aria-current', 'true');
current = next;
};
const blocks = [...document.querySelectorAll('.prose > *')];
// Governing heading = the block itself or the nearest h2/h3[id] above it
const headingFor = (el: Element) => {
for (let i = blocks.indexOf(el); i >= 0; i--) {
if (blocks[i].matches('h2[id], h3[id]')) return blocks[i].id;
}
return null; // intro before the first heading — nothing highlighted
};
const onIntersect = (entries: IntersectionObserverEntry[]) => {
for (const e of entries) {
if (e.isIntersecting) { setCurrent(headingFor(e.target)); break; }
}
};
let observer: IntersectionObserver | undefined;
const observe = () => {
observer?.disconnect();
// Narrow band below the sticky header (--header-h 56px + 12px slack;
// JS can't read the token cheaply, keep in sync) — Starlight's rootMargin trick
const top = 68, band = 64;
const io = new IntersectionObserver(onIntersect, {
rootMargin: `-${top}px 0px ${top + band - document.documentElement.clientHeight}px`,
});
observer = io;
blocks.forEach((b) => io.observe(b));
};
observe();
let timer: ReturnType<typeof setTimeout> | undefined;
addEventListener('resize', () => { clearTimeout(timer); timer = setTimeout(observe, 200); });
// Short final section: at page bottom, the last heading wins
addEventListener('scroll', () => {
if (innerHeight + scrollY >= document.documentElement.scrollHeight - 4) {
setCurrent(headingFor(blocks[blocks.length - 1]));
}
}, { passive: true });
}
</script>
+27
View File
@@ -0,0 +1,27 @@
---
import { getCollection, render } from 'astro:content';
import Base from '../../layouts/Base.astro';
export async function getStaticPaths() {
const pages = await getCollection('pages');
return pages.map((page) => ({ params: { slug: page.id }, props: { page } }));
}
const { page } = Astro.props;
const { Content } = await render(page);
---
<Base title={`${page.data.title} - CTAO Science Portal`} description={page.data.description}>
{/* Same wrapper pattern as news/[slug] (never .container and .article on
one element — both set max-width and would depend on rule order). */}
<div class="container article-layout">
<article class="article">
{/* Static pages are footer-reached documents; same back affordance as
articles ("← All news") so deep links aren't dead ends. */}
<a class="back" href="/">← Home</a>
<h1>{page.data.title}</h1>
<div class="prose">
<Content />
</div>
</article>
</div>
</Base>
+28
View File
@@ -0,0 +1,28 @@
---
import Base from '../layouts/Base.astro';
---
<Base title="CTAO Science Portal - Proposals" description="Observation proposals: Proposal Handling System integration (planned)">
<section class="band--moon">
<header class="container page-head">
<h1>Observation proposals</h1>
<p class="standfirst">Submit observation proposals, including ToO and MWL requests.</p>
</header>
<div class="container page">
{/* Integration landing, not a form: the PHS is APC's product — the spec
(§3.3.3) scopes this portal to integrating it, so we describe the
hand-off instead of imitating their submission UI. */}
<div class="panel panel--narrow">
<h2 class="panel-title">Proposal Handling System</h2>
<p>Submitting observation proposals, including Target of Opportunity (ToO)
and multi-wavelength (MWL) requests, happens in the Proposal Handling
System. That system is built by the APC team and integrated into this portal.</p>
<p>What the portal contributes:</p>
<ul class="panel-list">
<li>This menu entry, with a single sign-on hand-off (CTAO AAI).</li>
<li>Proposal status feedback in your <a href="/dashboard">Dashboard</a>.</li>
</ul>
<button class="btn" type="button" disabled>Open the Proposal Handling System (integration planned)</button>
</div>
</div>
</section>
</Base>
+43
View File
@@ -0,0 +1,43 @@
// RSS 2.0 feed — hand-rolled static endpoint like search.json.js (no @astrojs/rss
// dependency; the spec is 20 lines of XML). Newest 30 items, absolute URLs from
// the configured `site`.
import { getCollection } from 'astro:content';
const esc = (s = '') =>
s.replace(/[<>&'"]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', "'": '&apos;', '"': '&quot;' })[c]);
export async function GET(context) {
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);
const items = posts
.map((p) => {
const url = new URL(`/news/${p.id}`, site).href;
return ` <item>
<title>${esc(p.data.title)}</title>
<link>${url}</link>
<guid isPermaLink="true">${url}</guid>
<pubDate>${p.data.date.toUTCString()}</pubDate>
<description>${esc(p.data.description)}</description>
<category>${esc(p.data.category)}</category>
</item>`;
})
.join('\n');
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>CTAO Science Portal News</title>
<link>${new URL('/news', site).href}</link>
<description>News and announcements of the Cherenkov Telescope Array Observatory</description>
<language>en</language>
<lastBuildDate>${(posts[0]?.data.date ?? new Date()).toUTCString()}</lastBuildDate>
<atom:link href="${new URL('/rss.xml', site).href}" rel="self" type="application/rss+xml"/>
${items}
</channel>
</rss>
`;
return new Response(xml, { headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' } });
}
+36
View File
@@ -0,0 +1,36 @@
---
import Base from '../layouts/Base.astro';
---
<Base title="CTAO Science Portal - Search" description="Search CTAO news and announcements">
{/* Utility archetype: ONE surface per page — Moon canvas from the landing
head down (borderless cards/panels pop against it); editorial pages stay
all-white. No mid-page surface seams. */}
<section class="band--moon">
<header class="container page-head">
<h1>Search</h1>
<p class="standfirst">Find news and announcements.</p>
</header>
<div class="container page">
<form class="search-form" role="search" action="/search" method="get">
<label class="sr-only" for="q">Search news</label>
<svg class="ico search-ico" aria-hidden="true" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7" /><path d="m20 20-4.3-4.3" /></svg>
<input id="q" name="q" type="search" placeholder="e.g. LST camera, Council, data…" autocomplete="off" />
<button class="btn" type="submit">Search</button>
</form>
<p class="notice" id="search-status" role="status" aria-live="polite">Type to search the news archive.</p>
<ol class="search-results" id="search-results"></ol>
<noscript><p class="notice">Search requires JavaScript. Browse all news on the <a href="/news">news page</a>.</p></noscript>
</div>
</section>
</Base>
{/* search-client.js is loaded once (deferred) by the Base layout; this inline
module runs after it in the shared after-parse queue — order guaranteed. */}
<script is:inline type="module">
const run = newsSearch({ input: 'q', list: 'search-results', status: 'search-status', limit: 20, hint: 'Type at least 2 characters.' });
// Support /search?q=… deep links (hero search form, footer, bookmarks)
const q0 = new URLSearchParams(location.search).get('q');
const q = document.getElementById('q');
if (q0) { q.value = q0; run(); }
q.focus();
</script>
+19
View File
@@ -0,0 +1,19 @@
// Build-time search index (Astro static endpoint — no server, no dependencies).
// Kept lean: title/description/category/date/slug only (~60 KB for 200+ articles).
import { getCollection } from 'astro:content';
export async function GET() {
const posts = await getCollection('news', ({ data }) => !data.draft);
const index = posts
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
.map((p) => ({
slug: p.id,
title: p.data.title,
description: p.data.description,
category: p.data.category,
date: p.data.date.toISOString().slice(0, 10),
}));
return new Response(JSON.stringify(index), {
headers: { 'Content-Type': 'application/json; charset=utf-8' },
});
}
+40
View File
@@ -0,0 +1,40 @@
// 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';
// 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; // 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');
// Static pages come from the actual route files, so a new src/pages/*.astro
// can never be forgotten here. 404 is excluded; /admin (subdirectory) is
// intentionally unlisted. Go-live note: /login and /dashboard are mock
// pages — filter them out before robots.txt stops disallowing everything.
const staticPages = Object.keys(import.meta.glob('./*.astro'))
.map((p) => p.slice(1, -'.astro'.length))
.filter((p) => p !== '/404')
.map((p) => (p === '/index' ? '/' : p))
.sort();
const urls = [
...staticPages, '/news',
...Array.from({ length: newsPageCount(news.length) - 1 }, (_, i) => `/news/${i + 2}`),
...pages.map((p) => `/pages/${p.id}`),
].map((path) => ` <url><loc>${new URL(path, site).href}</loc></url>`);
const articles = news.map(
(p) => ` <url><loc>${new URL(`/news/${p.id}`, site).href}</loc><lastmod>${p.data.date.toISOString().slice(0, 10)}</lastmod></url>`,
);
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${[...urls, ...articles].join('\n')}
</urlset>
`;
return new Response(xml, { headers: { 'Content-Type': 'application/xml; charset=utf-8' } });
}
+29
View File
@@ -0,0 +1,29 @@
---
import Base from '../layouts/Base.astro';
// User support channels per the Science Portal spec — all mock for now.
const channels = [
{ title: 'Help desk', desc: 'Submit and track support tickets.' },
{ title: 'FAQ & troubleshooting', desc: 'Common questions and step-by-step guides.' },
{ title: 'User forum', desc: 'Discuss with the CTAO user community.' },
{ title: 'Mailing lists', desc: 'Announcements and topical mailing lists.' },
];
---
<Base title="CTAO Science Portal - Support" description="User support: help desk, FAQ, forum, mailing lists (mock)">
<section class="band--moon">
<header class="container page-head">
<h1>User Support</h1>
<p class="standfirst">Help desk, FAQ, user forum and mailing lists.</p>
</header>
<div class="container page">
<h2 class="sr-only">Support channels</h2>
<div class="grid">
{channels.map((c) => (
<article class="card tile">
<h3>{c.title}</h3>
<p class="desc">{c.desc}</p>
</article>
))}
</div>
</div>
</section>
</Base>
File diff suppressed because one or more lines are too long
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist", "public"]
}