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
@@ -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/
|
||||
|
||||
@@ -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 (the 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, so 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).
|
||||
@@ -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.
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
@@ -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 it 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`. The 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.
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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); }
|
||||
|
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 |
@@ -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 |
@@ -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 |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 955 B |
@@ -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: /
|
||||
@@ -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=…)
|
||||
};
|
||||
})();
|
||||
@@ -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);
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
@@ -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 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>
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 & 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>
|
||||
@@ -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>
|
||||
@@ -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 & 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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) => ({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' })[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' } });
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
@@ -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' } });
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist", "public"]
|
||||
}
|
||||