diff --git a/.changeset/config.json b/.changeset/config.json
index 7ad52953..2c877d65 100644
--- a/.changeset/config.json
+++ b/.changeset/config.json
@@ -1,6 +1,6 @@
{
"$schema": "https://unpkg.com/@changesets/config@4.0.1/schema.json",
- "changelog": ["@changesets/changelog-github", { "repo": "TrialAndErrorOrg/parsers" }],
+ "changelog": ["@changesets/changelog-github", { "repo": "TrialAndErrorOrg/trialanderror" }],
"commit": false,
"fixed": [],
"linked": [],
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b7eea1ec..7363b7a6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -47,7 +47,11 @@ jobs:
- run: pnpm format:check
- - run: pnpm turbo run build typecheck test --cache-dir=.turbo/cache
+ # The site is built and deployed by site.yml: its build reads Strapi, Underlay and
+ # site-content. Here it's only type-checked.
+ - run: pnpm turbo run build typecheck test --cache-dir=.turbo/cache --filter='!site'
+
+ - run: pnpm turbo run typecheck --cache-dir=.turbo/cache --filter=site
# Non-blocking reminder: list packages that changed without a changeset.
- name: Changeset status
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index fc78908a..9301d6dc 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -6,7 +6,7 @@
# -> build, pack and publish them via npm trusted publishing (OIDC)
#
# No npm token is used anywhere. Each package must have a trusted publisher configured on npmjs.com
-# pointing at TrialAndErrorOrg/parsers + this file (release.yml). See docs/releasing.md.
+# pointing at TrialAndErrorOrg/trialanderror + this file (release.yml). See docs/releasing.md.
#
# Layout follows https://changesets.dev/guide/automating (trusted publishing variant): only the
# `publish` job gets `id-token: write`.
diff --git a/.github/workflows/site.yml b/.github/workflows/site.yml
new file mode 100644
index 00000000..2f2b7db2
--- /dev/null
+++ b/.github/workflows/site.yml
@@ -0,0 +1,142 @@
+# Builds the website (apps/site) with the content from TrialAndErrorOrg/site-content, publishes it
+# as ghcr.io/trialanderrororg/site and asks Coolify to deploy it. Fast because it keeps every cache
+# between runs: pnpm's store, turbo's cache (the libraries the site uses) and the site's own caches
+# of resized images, share images and journal article HTML. The image only packages the built
+# site, see apps/site/Dockerfile.
+#
+# Secret: OP_TOKEN, a 1Password service account token. varlock reads everything else from the
+# Website vault (apps/site/.env.schema). See apps/site/docs/deploying.md.
+name: site
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - apps/site/**
+ - libs/sveltia-gateway/**
+ - pnpm-lock.yaml
+ - .github/workflows/site.yml
+ # Sent by Strapi when something is published. site-content starts the workflow directly instead
+ repository_dispatch:
+ types: [content-updated]
+ # New journal articles reach Underlay without telling anyone
+ schedule:
+ - cron: '17 4 * * *'
+ workflow_dispatch:
+
+permissions: {}
+
+# Only the newest content matters: a save cancels the build of the one before
+concurrency:
+ group: site
+ cancel-in-progress: true
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+ env:
+ IMAGE: ghcr.io/trialanderrororg/site
+ CONTENT_DIR: ${{ github.workspace }}/site-content
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ persist-credentials: false
+
+ # What editors change in /admin, see apps/site/src/integrations/content.ts
+ - uses: actions/checkout@v7
+ with:
+ repository: TrialAndErrorOrg/site-content
+ path: site-content
+ persist-credentials: false
+
+ - uses: pnpm/action-setup@v6
+ with:
+ version: 11.3.0
+
+ - uses: actions/setup-node@v7
+ with:
+ node-version: 24
+ cache: pnpm
+
+ - run: pnpm install --frozen-lockfile
+
+ - name: Restore turbo cache
+ uses: actions/cache@v6
+ with:
+ path: .turbo/cache
+ key: turbo-site-${{ runner.os }}-${{ github.sha }}
+ restore-keys: |
+ turbo-site-${{ runner.os }}-
+ turbo-${{ runner.os }}-main-
+
+ # Content-addressed, so restoring the newest copy is always safe; each run saves a fresh one
+ - name: Restore the site's image and article caches
+ uses: actions/cache@v6
+ with:
+ path: |
+ apps/site/node_modules/.astro
+ apps/site/node_modules/.cache
+ key: site-assets-${{ github.run_id }}
+ restore-keys: site-assets-
+
+ # The site's build script runs through varlock, which reads the Strapi and Underlay tokens
+ # from 1Password and hides them in the log
+ - name: Build
+ run: pnpm turbo run build --filter=site... --cache-dir=.turbo/cache
+ env:
+ OP_TOKEN: ${{ secrets.OP_TOKEN }}
+
+ # The built site plus only its production dependencies, with the musl builds of native
+ # packages for the Alpine image
+ - name: Package
+ run: pnpm --filter site deploy --prod --legacy --os=linux --cpu=x64 --libc=musl "$RUNNER_TEMP/site"
+
+ - name: Build the image
+ run: |
+ docker build "$RUNNER_TEMP/site" -f apps/site/Dockerfile \
+ --label org.opencontainers.image.source=https://github.com/${{ github.repository }} \
+ --label org.opencontainers.image.revision=${{ github.sha }} \
+ -t "$IMAGE:latest" -t "$IMAGE:${{ github.sha }}"
+
+ # Every route the server renders itself, so a runtime package missing from the image's
+ # dependencies fails here instead of on the live site
+ - name: Smoke test the image
+ run: |
+ docker run -d --name smoke -p 4321:4321 -e ONEPASSWORD=false -e ADMIN_AUTH=microsoft \
+ -e ADMIN_SESSION_SECRET="$(openssl rand -hex 32)" "$IMAGE:${{ github.sha }}"
+ trap 'docker logs smoke; docker rm -f smoke >/dev/null' EXIT
+ for _ in $(seq 1 30); do curl -sf -o /dev/null localhost:4321/ && break; sleep 1; done
+ expect() {
+ status=$(curl -s -o /dev/null -w '%{http_code}' "localhost:4321$2")
+ [ "$status" = "$1" ] || { echo "::error::$2 answered $status, expected $1"; exit 1; }
+ }
+ expect 200 /
+ expect 200 /admin/
+ expect 200 /admin/config.yml
+ expect 401 /admin/api/v3/user
+ expect 401 /preview/blog/x
+ expect 401 /preview/page/x
+ expect 401 '/api/draft?secret=x&slug=x'
+ expect 200 '/_image?href=%2Ffavicon-32x32.png&w=16&h=16&f=webp'
+
+ - name: Push the image
+ run: |
+ echo "$GITHUB_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
+ docker push --all-tags "$IMAGE"
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ # Skipped until COOLIFY_DEPLOY_URL is set in apps/site/.env.schema
+ - name: Deploy
+ working-directory: apps/site
+ run: |
+ # shellcheck disable=SC2016 # expanded by the inner shell, after varlock has filled them in
+ pnpm exec varlock run -- sh -c '
+ if [ -z "$COOLIFY_DEPLOY_URL" ]; then echo "No COOLIFY_DEPLOY_URL, not deploying"; exit 0; fi
+ curl --fail --silent --show-error -H "Authorization: Bearer $COOLIFY_TOKEN" "$COOLIFY_DEPLOY_URL"
+ '
+ env:
+ OP_TOKEN: ${{ secrets.OP_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 74abbcb5..0fd3cb9e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -356,10 +356,14 @@ thebiggestfile.ts
.env
.env.*
+# Examples and varlock schemas hold no values, so they belong in the repository
+!.env.example
+!.env.schema
graph
# typedoc scratch output (scripts/readme.ts); hand-written docs/*.md are tracked
**/docs/*
!/docs/*.md
+!/apps/site/docs/*.md
.sveltekit
diff --git a/.oxfmtrc.json b/.oxfmtrc.json
index 48792fab..ec266856 100644
--- a/.oxfmtrc.json
+++ b/.oxfmtrc.json
@@ -8,6 +8,7 @@
"sortPackageJson": false,
"ignorePatterns": [
"dist",
+ "apps/site",
"deprecated",
"coverage",
".next",
diff --git a/README.md b/README.md
index b4a17544..6550d6e6 100644
--- a/README.md
+++ b/README.md
@@ -74,7 +74,7 @@ Packages marked _private_ are not published.
| Package | Description |
|---|---|
-| [`crossref-json`](libs/citations/crossref-json) | Defined in: [index.ts:38](https://github.com/TrialAndErrorOrg/parsers/blob/main/libs/citations/crossref-json/src/index.ts |
+| [`crossref-json`](libs/citations/crossref-json) | Defined in: [index.ts:38](https://github.com/TrialAndErrorOrg/trialanderror/blob/main/libs/citations/crossref-json/src/index.ts |
| [`crossref-to-csl`](libs/citations/crossref-to-csl) | crossrefToCsl(item: CrossrefJSON): CSL; |
| [`csl-consolidate`](libs/citations/csl-consolidate) | Try to resolve a list of CSL data with crossref metadata |
| [`csl-to-biblatex`](libs/citations/csl-to-biblatex) | Somewhat jank CSL-JSON to biblatex converter |
@@ -192,13 +192,13 @@ Plugin tries to locate an "abstract" section in a `jast` tree and moves it to th
[unifiedgh]: https://github.com/unifiedjs/unified
[xast-from-xml]: https://github.com/syntax-tree/xast-util-from-xml
[rehype]: https://github.com/rehypejs/rehype
-[rejour]: https://github.com/TrialAndErrorOrg/parsers/tree/main/libs/rejour
-[rejour-parse]: https://github.com/TrialAndErrorOrg/parsers/tree/main/libs/rejour-parse
-[rejour-stringify]: https://github.com/TrialAndErrorOrg/parsers/tree/main/libs/rejour-stringify
-[rejour-move-abstract]: https://github.com/TrialAndErrorOrg/parsers/tree/main/libs/rejour-move-abstract
-[rejour-meta]: https://github.com/TrialAndErrorOrg/parsers/tree/main/libs/rejour-meta
-[jast]: https://github.com/TrialAndErrorOrg/parsers/tree/main/libs/jast
-[jastscript]: https://github.com/TrialAndErrorOrg/parsers/tree/main/libs/jastscript
+[rejour]: https://github.com/TrialAndErrorOrg/trialanderror/tree/main/libs/rejour
+[rejour-parse]: https://github.com/TrialAndErrorOrg/trialanderror/tree/main/libs/rejour-parse
+[rejour-stringify]: https://github.com/TrialAndErrorOrg/trialanderror/tree/main/libs/rejour-stringify
+[rejour-move-abstract]: https://github.com/TrialAndErrorOrg/trialanderror/tree/main/libs/rejour-move-abstract
+[rejour-meta]: https://github.com/TrialAndErrorOrg/trialanderror/tree/main/libs/rejour-meta
+[jast]: https://github.com/TrialAndErrorOrg/trialanderror/tree/main/libs/jast
+[jastscript]: https://github.com/TrialAndErrorOrg/trialanderror/tree/main/libs/jastscript
[hast]: https://github.com/syntax-tree/hast
[xast]: https://github.com/syntax-tree/xast
[mdast]: https://github.com/syntax-tree/mdast
diff --git a/apps/site/.env.schema b/apps/site/.env.schema
new file mode 100644
index 00000000..5b5794bd
--- /dev/null
+++ b/apps/site/.env.schema
@@ -0,0 +1,87 @@
+# Environment for the website. Read by varlock (https://varlock.dev), which fills the secrets from
+# 1Password. Nothing secret is stored here, only references to the Website vault.
+#
+# Locally, `pnpm dev` and `pnpm build` run through `varlock run`, and the 1Password desktop app
+# asks once per session. Without access to the vault, put ONEPASSWORD=false and your own values
+# in .env.local (gitignored), which wins over this file.
+#
+# In CI and where the site runs (Coolify), OP_TOKEN is the only secret: a 1Password service account
+# token that can read the Website vault and nothing else. The image starts the server through
+# `varlock run`, so it reads its own secrets at start. See docs/deploying.md.
+# Astro checks every value again in astro.config.mjs (`env.schema`).
+#
+# @plugin(@varlock/1password-plugin)
+# @initOp(token=$OP_TOKEN, allowAppAuth=not($VARLOCK_IS_CI), account=trialanderror)
+# @defaultSensitive=true
+# @defaultRequired=false
+# ---
+
+# 1Password service account token, for CI. Locally the desktop app is used instead.
+# @type=opServiceAccountToken
+OP_TOKEN=
+
+# Read secrets from 1Password. Set to false in .env.local to use your own values instead.
+# @sensitive=false @type=boolean
+ONEPASSWORD=true
+
+# Where site-content is checked out, relative to apps/site. Defaults to ../../../site-content, next
+# to this repository (src/integrations/content.ts).
+# @sensitive=false
+CONTENT_DIR=
+
+# --- Build: where blog posts, authors, team and journal articles come from ---
+
+# @sensitive=false
+STRAPI_URL=https://cms.trialanderror.org
+# Read-only Strapi API token. Needed for drafts, optional for published content.
+STRAPI_TOKEN=if($ONEPASSWORD, op("op://Website/Strapi TrialAndError CI Read Token/credential"))
+
+# @sensitive=false
+UNDERLAY_URL=https://www.underlay.org
+# Without it Underlay reads are anonymous and rate limited to 60 requests a minute.
+UNDERLAY_TOKEN=if($ONEPASSWORD, op("op://Website/Underlay/credential"))
+
+# Where the blog comes from: content (site-content, edited in /admin), strapi, or underlay
+# (UNDERLAY_BLOG_COLLECTION, filled by scripts/blog-to-underlay.ts).
+# @sensitive=false @type=enum(content, strapi, underlay)
+BLOG_SOURCE=content
+# @sensitive=false
+UNDERLAY_BLOG_COLLECTION=centeroftrialanderror/blog
+
+# --- Runtime: what the server needs where the site runs ---
+
+# Shared secret for Strapi draft previews. Must equal DRAFT_SECRET in Strapi.
+DRAFT_SECRET=
+
+# How editors sign in to /admin: github (their own GitHub account), microsoft (Entra, no GitHub
+# account needed) or dev (a test editor, pnpm dev only). See docs/admin-sign-in.md. The sign-in
+# secrets below are only read with microsoft.
+# @sensitive=false @type=enum(github, microsoft, dev)
+ADMIN_AUTH=github
+# @sensitive=false @type=boolean
+ADMIN_READ_ONLY=false
+
+ADMIN_SESSION_SECRET=if(and($ONEPASSWORD, eq($ADMIN_AUTH, microsoft)), op("op://Website/trialanderror admin Entra app/session secret"))
+# @sensitive=false
+ENTRA_TENANT_ID=if(and($ONEPASSWORD, eq($ADMIN_AUTH, microsoft)), op("op://Website/trialanderror admin Entra app/tenant id"))
+# @sensitive=false
+ENTRA_CLIENT_ID=if(and($ONEPASSWORD, eq($ADMIN_AUTH, microsoft)), op("op://Website/trialanderror admin Entra app/username"))
+ENTRA_CLIENT_SECRET=if(and($ONEPASSWORD, eq($ADMIN_AUTH, microsoft)), op("op://Website/trialanderror admin Entra app/credential"))
+# @sensitive=false
+GITHUB_APP_ID=if(and($ONEPASSWORD, eq($ADMIN_AUTH, microsoft)), op("op://Website/Admin CMS Write Github App/username"))
+GITHUB_APP_PRIVATE_KEY=if(and($ONEPASSWORD, eq($ADMIN_AUTH, microsoft)), op("op://Website/Admin CMS Write Github App/credential"))
+# Local testing only: a token that stands in for the GitHub App, e.g. $(gh auth token)
+GITHUB_CONTENT_TOKEN=
+
+# Unsplash in /admin's image picker: the Access Key of the Unsplash app (unsplash.com/oauth/applications).
+# It ends up in every editor's browser, as Unsplash intends; the Secret Key (credential) is never needed.
+# @sensitive=false
+UNSPLASH_ACCESS_KEY=if($ONEPASSWORD, op("op://Website/Unsplash app/username"))
+
+# --- CI only: telling Coolify to deploy the new image ---
+
+# The app's deploy webhook (Coolify, the app, Webhooks). Deploying is skipped while it's empty.
+# @sensitive=false
+COOLIFY_DEPLOY_URL=https://app.coolify.io/api/v1/deploy?uuid=avytsqsicyojwuklygs7jiyw&force=false
+# A Coolify API token with deploy rights
+COOLIFY_TOKEN=if(and($VARLOCK_IS_CI, not(isEmpty($COOLIFY_DEPLOY_URL))), op("op://Website/Coolify Website Deploy Token/credential"))
diff --git a/apps/site/.gitignore b/apps/site/.gitignore
new file mode 100644
index 00000000..f90ddf00
--- /dev/null
+++ b/apps/site/.gitignore
@@ -0,0 +1,7 @@
+dist/
+.astro/
+node_modules/
+.env
+dist-*/
+underlay-export/
+.env.local
diff --git a/apps/site/Dockerfile b/apps/site/Dockerfile
new file mode 100644
index 00000000..0673d44f
--- /dev/null
+++ b/apps/site/Dockerfile
@@ -0,0 +1,31 @@
+# Packaging only. CI (.github/workflows/site.yml) builds the site and runs
+# `pnpm --filter site deploy --prod
`, which leaves the built site with
+# just its production dependencies. This image copies that directory in, so
+# it builds in seconds and holds no source or build tools.
+#
+# Production dependencies are only what the server loads at runtime: Astro
+# bundles itself into dist/server, so astro and everything used only while
+# building are devDependencies in package.json. A package the server imports
+# has to be a dependency, or the image fails CI's smoke test.
+#
+# The base is Alpine, so the packages with native code (sharp, varlock) need
+# their musl builds, which pnpm only installs when asked:
+#
+# Locally, the same: pnpm turbo run build --filter=site...
+# pnpm --filter site deploy --prod --legacy --os=linux --cpu=x64 --libc=musl /tmp/site
+# docker build --platform linux/amd64 -f apps/site/Dockerfile /tmp/site
+# then run `pnpm install` again: the deploy leaves the workspace's own install
+# marked as production-only.
+#
+# The image holds no secrets. It starts the server through varlock, which reads
+# them from 1Password (.env.schema) with the OP_TOKEN it is given.
+FROM node:24-alpine
+ENV NODE_ENV=production HOST=0.0.0.0 PORT=4321
+WORKDIR /app
+COPY --chown=node:node . .
+USER node
+EXPOSE 4321
+# Node asks, so the check doesn't depend on what the base image ships
+HEALTHCHECK --interval=30s --timeout=5s --start-period=30s \
+ CMD ["node", "-e", "fetch('http://127.0.0.1:4321/').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"]
+CMD ["node_modules/.bin/varlock", "run", "--", "node", "dist/server/entry.mjs"]
diff --git a/apps/site/README.md b/apps/site/README.md
new file mode 100644
index 00000000..95874a1c
--- /dev/null
+++ b/apps/site/README.md
@@ -0,0 +1,104 @@
+# trialanderror.org
+
+The website of the Center of Trial and Error: the main site, the blog (formerly
+blog.trialanderror.org) and the Journal of Trial and Error's articles, in one
+Astro site.
+
+Everything is built into plain HTML files ahead of time. The only parts that run
+on a server are draft previews (`/preview/...`, `/api/draft`) and `/admin`'s
+sign-in.
+
+## Where things come from
+
+| What | Where it lives | How to change it |
+| --- | --- | --- |
+| Homepage, About and other section-built pages | site-content: `pages/*.yaml` | `/admin`, or edit the file |
+| Menus, footer, social links | site-content: `settings/navigation.yaml` | `/admin` → Settings → Menus |
+| Site name, newsletter, analytics | site-content: `settings/site.yaml` | `/admin` → Settings → Site |
+| Images and files uploaded in `/admin` | site-content: `uploads/` | `/admin` |
+| Colours, fonts, borders, shadows | `src/styles/theme.css` | edit the file |
+| Blog posts, categories, tags | site-content: `blog/*.md`, `categories/` | `/admin` → Blog |
+| Team members and blog authors | site-content: `people/*.yaml` | `/admin` → People |
+| Free-text pages (donate, events, award calls) | Strapi → Pages | Strapi |
+| Journal articles | Underlay ([centeroftrialanderror/jote](https://www.underlay.org/centeroftrialanderror/jote)), exported from PubPub | PubPub |
+
+site-content is its own repository,
+[TrialAndErrorOrg/site-content](https://github.com/TrialAndErrorOrg/site-content), so
+saving in `/admin` doesn't add commits to this one. A page in site-content wins
+over a Strapi page with the same slug.
+
+## Docs
+
+| | |
+| --- | --- |
+| [docs/editing.md](docs/editing.md) | For editors: blog posts, pages, signing in to `/admin` |
+| [docs/development.md](docs/development.md) | Running it, secrets, restyling, new section types, previews |
+| [docs/admin-sign-in.md](docs/admin-sign-in.md) | Microsoft sign-in for `/admin`: how it works, the Entra app and GitHub App, adding editors |
+| [docs/deploying.md](docs/deploying.md) | GitHub Actions, rebuilds, 1Password, Coolify, redirects |
+
+Quick start, from the repository root, with site-content cloned next to it:
+`pnpm install && pnpm turbo run dev --filter=site`.
+
+## Layout
+
+```
+src/
+ content.config.ts the section types and their fields
+ integrations/ content.ts (reads site-content), pagefind.ts (search index)
+ styles/theme.css every colour, font and size token
+ styles/global.css shared component classes (.card, .button, .label, .prose-site...)
+ components/ Header, Footer, cards; sections/ holds one component per section type
+ layouts/ Base (every page), Post (blog post), Page (CMS page), BlogList
+ lib/sources/ one module per content source: blog (site-content, or Strapi), journal (Underlay), pages
+ lib/admin.ts /admin's sign-in (sveltia-gateway), pages/admin/ its routes
+ pages/ routes
+ admin/config.yml the /admin editing interface
+.env.schema every environment variable, secrets as 1Password references
+Dockerfile packages the built site, see docs/deploying.md
+```
+
+site-content holds `pages/` (section-built pages, `index.yaml` is the
+homepage), `blog/` (posts, Markdown), `people/`, `categories/`, `settings/`
+(`site.yaml`, `navigation.yaml`) and `uploads/`.
+
+## What's where
+
+### Journal
+
+`/journal` lists the articles from Underlay, filtered by type
+(`/journal/kind/...`) and issue (`/journal/issue/...`). `/journal/` renders
+the latest release's HTML export with downloads (PDF, EPUB, Word, JATS, LaTeX)
+and citation metadata. Set `ARTICLE_PAGES = false` in
+`src/lib/sources/journal.ts` to link to PubPub instead and skip those pages.
+Article HTML is cached by hash in `node_modules/.cache/underlay`.
+
+`/jrn` is the archive of the Journal for Reproducibility in Neuroscience
+([centeroftrialanderror/jrn](https://www.underlay.org/centeroftrialanderror/jrn)),
+which is no longer updated. It uses the same article pages, in grey. Each
+journal is an entry in `JOURNALS` in `src/lib/sources/journal.ts` (its
+collection, path, article types and issue tags), and `journal("jrn")` gives
+its loaders.
+
+### People
+
+`/people/` has a page for every team member and blog author, with their
+posts and journal articles (`src/lib/people.ts`). Journal articles join a
+person on ORCID iD, or on the exact full name when the iDs don't conflict.
+`/blog/author/` redirects there, from a person's slug and their old ones
+(`aliases`).
+
+### Built at build time, not by services
+
+- Share images (`/og/...png`) are drawn with satori in `src/lib/og.ts`, using
+ the colours from `theme.css`. Cached in `node_modules/.astro/og`.
+- Feeds: `/rss.xml` (blog) and `/journal/rss.xml`.
+- Search is [Pagefind](https://pagefind.app): an index written after the build
+ (`src/integrations/pagefind.ts`) and searched in the browser at `/search`.
+ Only elements marked `data-pagefind-body` are indexed.
+
+### Secrets and hosting
+
+Secrets live in the 1Password Website vault. varlock reads them for `dev`,
+`build` and the running server (`.env.schema`), so `OP_TOKEN` is the only
+secret GitHub or Coolify hold. GitHub Actions builds the image, Coolify runs
+it ([docs/deploying.md](docs/deploying.md)).
diff --git a/apps/site/astro.config.mjs b/apps/site/astro.config.mjs
new file mode 100644
index 00000000..7b8ef9fe
--- /dev/null
+++ b/apps/site/astro.config.mjs
@@ -0,0 +1,76 @@
+import node from "@astrojs/node";
+import sitemap from "@astrojs/sitemap";
+import tailwindcss from "@tailwindcss/vite";
+import { defineConfig, envField, fontProviders } from "astro/config";
+import icon from "astro-icon";
+import content from "./src/integrations/content";
+import pagefind from "./src/integrations/pagefind";
+
+export default defineConfig({
+ site: "https://trialanderror.org",
+ trailingSlash: "ignore",
+ // Behind a proxy (Coolify), trust its Host and X-Forwarded-* headers for these domains only, so
+ // the server knows its public address. /admin's sign-in and the CMS config are built from it.
+ security: {
+ allowedDomains: [
+ { hostname: "trialanderror.org", protocol: "https" },
+ { hostname: "*.trialanderror.org", protocol: "https" },
+ ],
+ },
+ // Lets parallel builds write to separate folders
+ outDir: process.env.ASTRO_OUT_DIR ?? "./dist",
+ // Every page is prebuilt. Only routes that set `prerender = false` (the
+ // draft previews) need the server. Swap this adapter for another host's
+ // (netlify, vercel, cloudflare) without touching anything else.
+ adapter: node({ mode: "standalone" }),
+ // /blog/author/* only redirects to /people/*
+ integrations: [content(), icon(), sitemap({ filter: (page) => !page.includes("/preview/") && !page.includes("/blog/author/") }), pagefind()],
+ vite: {
+ plugins: [tailwindcss()],
+ },
+ image: {
+ // Remote images from these hosts are downloaded and optimised at build
+ // time, so the built site does not depend on them at runtime.
+ domains: ["cms.trialanderror.org", "assets.pubpub.org", "www.underlay.org", "images.unsplash.com"],
+ },
+ fonts: [
+ {
+ provider: fontProviders.google(),
+ name: "Overpass",
+ cssVariable: "--font-overpass",
+ weights: [400, 600, 700, 800, 900],
+ fallbacks: ["sans-serif"],
+ },
+ {
+ provider: fontProviders.google(),
+ name: "Open Sans",
+ cssVariable: "--font-open-sans",
+ weights: [400, 500, 600, 700],
+ styles: ["normal", "italic"],
+ fallbacks: ["sans-serif"],
+ },
+ ],
+ env: {
+ schema: {
+ STRAPI_URL: envField.string({ context: "server", access: "secret", default: "https://cms.trialanderror.org" }),
+ STRAPI_TOKEN: envField.string({ context: "server", access: "secret", optional: true }),
+ DRAFT_SECRET: envField.string({ context: "server", access: "secret", optional: true }),
+ UNDERLAY_URL: envField.string({ context: "server", access: "secret", default: "https://www.underlay.org" }),
+ UNDERLAY_TOKEN: envField.string({ context: "server", access: "secret", optional: true }),
+ UNDERLAY_BLOG_COLLECTION: envField.string({ context: "server", access: "secret", default: "centeroftrialanderror/blog" }),
+ BLOG_SOURCE: envField.enum({ context: "server", access: "secret", values: ["content", "strapi", "underlay"], default: "content" }),
+ // How editors sign in to /admin: their own GitHub account, or Microsoft (see src/lib/admin)
+ ADMIN_AUTH: envField.enum({ context: "server", access: "secret", values: ["github", "microsoft", "dev"], default: "github" }),
+ ADMIN_SESSION_SECRET: envField.string({ context: "server", access: "secret", optional: true }),
+ ADMIN_READ_ONLY: envField.boolean({ context: "server", access: "secret", default: false }),
+ ENTRA_TENANT_ID: envField.string({ context: "server", access: "secret", optional: true }),
+ ENTRA_CLIENT_ID: envField.string({ context: "server", access: "secret", optional: true }),
+ ENTRA_CLIENT_SECRET: envField.string({ context: "server", access: "secret", optional: true }),
+ GITHUB_APP_ID: envField.string({ context: "server", access: "secret", optional: true }),
+ GITHUB_APP_PRIVATE_KEY: envField.string({ context: "server", access: "secret", optional: true }),
+ GITHUB_CONTENT_TOKEN: envField.string({ context: "server", access: "secret", optional: true }),
+ // Unsplash in /admin's image picker. The Access Key, which Unsplash means for browsers
+ UNSPLASH_ACCESS_KEY: envField.string({ context: "server", access: "secret", optional: true }),
+ },
+ },
+});
diff --git a/apps/site/docs/admin-sign-in.md b/apps/site/docs/admin-sign-in.md
new file mode 100644
index 00000000..f31b1a60
--- /dev/null
+++ b/apps/site/docs/admin-sign-in.md
@@ -0,0 +1,76 @@
+# Microsoft sign-in for /admin
+
+Sveltia CMS normally talks to GitHub straight from the browser, with the
+editor's own GitHub token. With `ADMIN_AUTH=microsoft` the site stands in
+between, through `sveltia-gateway` (`libs/sveltia-gateway` in this repository,
+see its README):
+
+- `/admin/auth` sends the editor to Microsoft and `/admin/callback` takes them
+ back, then hands the CMS a signed session instead of a GitHub token.
+- `/admin/api/...` is what the CMS uses as GitHub's API. It answers "who am I"
+ from the session and passes the rest to GitHub as a GitHub App. Editors can
+ only commit to the branch in `src/admin/config.yml` and only change the
+ folders the CMS edits (`src/lib/admin.ts` reads them from that file).
+- `/admin/config.yml` is generated from `src/admin/config.yml` and points the
+ CMS at those routes, with only the sign-in button (`auth_methods: [oauth]`).
+ Commit messages carry the editor's name.
+- Sveltia calls the button "Sign In with GitHub" and has no setting for it, so
+ `src/pages/admin/index.astro` swaps the text for "Sign in with Microsoft".
+ Check it still does after updating Sveltia.
+
+## What it needs
+
+Set up for trialanderror.org on 2026-10-14.
+
+**Entra app** "trialanderror admin" (entra.microsoft.com, App registrations):
+
+- Supported account types: this organisational directory only.
+- Redirect URIs, type Web: `https://trialanderror.org/admin/callback`, plus
+ `http://localhost:4321/admin/callback` to test with `pnpm dev`. Every other
+ address the site runs on (a test subdomain, say) needs its own entry.
+- A client secret. It expires (the current one on 2028-10-11): make a new one
+ before then and update 1Password.
+- API permissions: the default `User.Read` is enough. No implicit grant, no
+ public client flows.
+- Enterprise applications, the app, Properties: **Assignment required** yes.
+ Only people assigned to the app can sign in.
+
+**GitHub App** `admin-cms-write` in the TrialAndErrorOrg organisation:
+repository permissions Contents and Pull requests (read and write), no webhook,
+installed only on `TrialAndErrorOrg/site-content`. Don't install it anywhere
+else: its key lives on the web server, and an installation gets every
+permission the app has.
+
+**1Password**, Website vault (`.env.schema` reads them):
+
+- "trialanderror admin Entra app": `username` is the client ID, `credential`
+ the client secret, `tenant id`, and `session secret` (32 or more random
+ characters, e.g. `openssl rand -base64 48`; changing it signs everyone out).
+- "Admin CMS Write Github App": `username` is the app ID, `credential` the
+ private key (the .pem as downloaded, newlines may be written as `\n`).
+
+**Where the site runs:** `ADMIN_AUTH=microsoft`. The rest is read from
+1Password when the server starts ([deploying.md](deploying.md)).
+
+## Adding an editor
+
+Entra admin center, Enterprise applications, "trialanderror admin", Users and
+groups, Add user/group. Assigning a group instead of people needs Entra ID P1.
+Taking someone off the list stops their next sign-in. A session already open
+lasts until it expires (8 hours).
+
+## Behind a proxy
+
+The server only learns its public address from the proxy's headers for the
+domains in `security.allowedDomains` (`astro.config.mjs`: trialanderror.org and
+its subdomains). On any other domain, add it there, or the sign-in sends
+Microsoft the wrong callback address.
+
+## Trying it locally
+
+With the vault: `ADMIN_AUTH=microsoft` in `apps/site/.env.local`, then
+`pnpm turbo run dev --filter=site` and open http://localhost:4321/admin.
+
+Without either registration: `ADMIN_AUTH=dev` signs everyone in as a test
+editor (only in `pnpm dev`), `GITHUB_CONTENT_TOKEN=$(gh auth token)` stands in
+for the app, and `ADMIN_READ_ONLY=true` refuses every write.
diff --git a/apps/site/docs/deploying.md b/apps/site/docs/deploying.md
new file mode 100644
index 00000000..54fceacb
--- /dev/null
+++ b/apps/site/docs/deploying.md
@@ -0,0 +1,110 @@
+# Deploying
+
+```
+push, site-content save, Strapi publish, daily
+ └▶ .github/workflows/site.yml: build with secrets from 1Password
+ └▶ ghcr.io/trialanderrororg/site:latest
+ └▶ Coolify pulls it and restarts, the server reads its secrets from 1Password
+```
+
+## The workflow
+
+`.github/workflows/site.yml` builds the site on GitHub Actions and publishes
+the image `ghcr.io/trialanderrororg/site` (`:latest` and `:`), then
+calls Coolify's deploy webhook. It checks out site-content next to the code.
+A newer run cancels an older one.
+
+It is quick because it keeps every cache between runs: pnpm's store, turbo's
+cache for the workspace packages, and the site's own caches of resized images,
+share images and journal article HTML (`node_modules/.astro`,
+`node_modules/.cache`). The site's turbo `build` task is never cached itself,
+since its pages come from services turbo can't see. The `Dockerfile` only
+packages the build: `pnpm deploy --prod` leaves the built site with just its
+production dependencies, and the image (Alpine, about 300 MB) copies that in.
+
+Production dependencies are only what the server loads while it runs: Astro
+bundles itself into `dist/server`, so `astro` and everything used only while
+building are `devDependencies`. A package the server does import at runtime
+must be in `dependencies`. If one is missing, the workflow's smoke test fails:
+it runs the image and requests every route the server renders itself, before
+anything is pushed.
+
+## Rebuilding
+
+Content is fetched at build time, so the site has to rebuild when it changes:
+
+- `/admin`: every save is a commit to site-content, whose `rebuild` workflow
+ starts `site.yml` (`gh workflow run`) with "Site rebuild token", a
+ fine-grained token that may only start workflows in this repository
+ (Actions: read and write).
+- Strapi: a webhook on entry publish, unpublish, update and delete that sends
+ a `content-updated` repository dispatch to this repository (GitHub's
+ `POST /repos/TrialAndErrorOrg/trialanderror/dispatches`, with a token that
+ may trigger workflows).
+- Journal: a daily run picks up new articles from Underlay.
+- Code: a push to `main` that touches the site.
+
+## Secrets
+
+One secret, `OP_TOKEN`, a 1Password service account token that can read the
+Website vault and nothing else. Everything else is in that vault and named in
+`apps/site/.env.schema`, which varlock reads:
+
+- **In CI**, `OP_TOKEN` is an organisation secret shared with this repository
+ and site-content. The build and deploy steps run through varlock, which hides
+ the values in the log. No other step gets the token.
+- **Where the site runs**, `OP_TOKEN` is set in Coolify. The image starts the
+ server through `varlock run`, so it reads the secrets when it starts and a
+ changed secret only needs a restart. The image itself holds no secrets.
+- Anything set in the environment directly wins over 1Password.
+
+A service account's vault access can't be changed after it is made. For
+another vault, make a new service account and replace `OP_TOKEN`.
+
+## Coolify
+
+The app is a plain Docker image, so Coolify builds nothing and needs no access
+to GitHub.
+
+1. **Make the image public**, once, after the first run of `site.yml`: GitHub,
+ TrialAndErrorOrg, Packages, `site`, Package settings, Change visibility,
+ Public. (It holds the built site and its server, nothing secret.) Or keep it
+ private and give Coolify registry credentials with `read:packages`.
+2. **Create the app**: a project, its environment, **New**, **Docker Image**,
+ image `ghcr.io/trialanderrororg/site`, tag `latest`.
+3. **General**:
+ - Domains: `https://trialanderror.org`. To try it first, use a subdomain such
+ as `https://new.trialanderror.org` (a DNS record, and its own redirect URI
+ in the Entra app, see [admin-sign-in.md](admin-sign-in.md)). Before moving
+ the main domain, take it off the old Next.js app.
+ - Ports Exposes: `4321`.
+4. **Environment variables**, not build variables (nothing is built here):
+ - `OP_TOKEN`: the service account token (1Password IT).
+ - `ADMIN_AUTH=microsoft`.
+5. **Healthcheck**: enabled, type command,
+ `node -e "fetch('http://127.0.0.1:4321/').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"`
+ (the image's own `HEALTHCHECK` is the same). An HTTP check on `/`, port
+ 4321, works too: Alpine has `wget`. With a health check,
+ Coolify keeps the old container until the new one answers: if 1Password
+ can't be reached, the new one never starts and the site stays up.
+6. **Deploy** once by hand and check the site, `/admin` and a save.
+7. **Deploy webhook**: the app's Webhooks page. Put its URL in
+ `COOLIFY_DEPLOY_URL` in `apps/site/.env.schema` and commit it (it only works
+ with the token). From then on `site.yml` deploys every build, using
+ "Coolify Website Deploy Token" (a Coolify API token with only the deploy
+ permission) from the vault.
+
+## Redirects to set up when this goes live
+
+- `blog.trialanderror.org/*` → `trialanderror.org/blog/*` (keeps old links and
+ blog DOIs working; `/blog/api/draft` exists for the old preview URL).
+- `blog.trialanderror.org/author/*` → `trialanderror.org/blog/author/*` falls
+ out of the rule above.
+- The old blog's page list (`blog.trialanderror.org/blog/2`) would land on
+ `/blog/blog/2`; redirect `/blog/blog/*` to `/blog/page/*` if that matters.
+
+## Elsewhere
+
+Any host that runs Node or Docker works: give it `OP_TOKEN`, or the variables
+from `.env.schema` directly. So does any host with an Astro adapter (replace
+`@astrojs/node` in `astro.config.mjs`).
diff --git a/apps/site/docs/development.md b/apps/site/docs/development.md
new file mode 100644
index 00000000..8829b948
--- /dev/null
+++ b/apps/site/docs/development.md
@@ -0,0 +1,91 @@
+# Working on the site
+
+## Running it
+
+The site is `apps/site` in the `trialanderror` monorepo. Its content is in
+site-content, checked out next to it. From the repository root:
+
+```sh
+git clone https://github.com/TrialAndErrorOrg/site-content ../site-content # or set CONTENT_DIR
+pnpm install
+pnpm turbo run dev --filter=site # http://localhost:4321
+pnpm turbo run build --filter=site... # into apps/site/dist, then `pnpm --filter site start`
+```
+
+Turbo builds the workspace packages the site uses (`sveltia-gateway`) first.
+The site is not formatted by the repository's `oxfmt`, which can't read
+`.astro` files. It keeps its own style (tabs, double quotes).
+
+## Secrets
+
+`dev`, `build` and `start` run through [varlock](https://varlock.dev), which
+reads `.env.schema`: plain settings with their defaults, and the tokens as
+references to the 1Password Website vault. The 1Password desktop app asks once
+per session.
+
+Without access to the vault, put `ONEPASSWORD=false` and your own values in
+`apps/site/.env.local`. Most tokens are optional (published content is
+public). `pnpm exec varlock load` in `apps/site` shows what was resolved, with
+secrets hidden.
+
+## Content
+
+`src/integrations/content.ts` finds site-content. Pages are read from
+`/pages`, settings are imported as `@content/settings/*.yaml`,
+and `uploads/` is served at `/uploads` in dev and copied into the build.
+
+## Restyling
+
+Change the values in `src/styles/theme.css`. Components only use those tokens
+(`bg-accent`, `border-ink`, `shadow-hard`, `font-heading`) and the classes in
+`global.css`, so a new palette or font is a one-file change. To change how all
+cards or buttons look, edit `.card` or `.button` in `global.css`.
+
+## Adding a section type
+
+1. Make `src/components/sections/MySection.astro`.
+2. Add its fields to the `section` union in `src/content.config.ts`.
+3. Register it in `src/components/Sections.astro`.
+4. Describe its fields under `sections.types` in `src/admin/config.yml`, so
+ editors can use it.
+
+## The blog
+
+Posts, people and categories are content collections (`src/content.config.ts`)
+read from site-content by `lib/sources/blog-content.ts`. Images under
+`/uploads` are found with an `import.meta.glob` over `@content/uploads`, so
+Astro resizes them like remote ones. Links to Unsplash are remote images.
+
+They came out of Strapi with `scripts/blog-to-content.ts` (2026-10-14), which
+only reads Strapi and can be run again to start over. It turned the HTML, much
+of it pasted from Word, into Markdown, merged team members and guest authors
+into one file per person, and made the images WebP. The 170 MB lecture video in
+`video_professor_peter_bergeijk_economics` was too large and still plays from
+Strapi.
+
+The live preview in `/admin` is `src/admin/preview.ts`: a copy of the top of
+`layouts/Post.astro`, drawn with React (which Sveltia provides) and the site's
+CSS. A change to the post page's header belongs in both.
+
+Components only see the types in `src/lib/types.ts`. Each source converts its
+data into those. `BLOG_SOURCE=strapi` reads Strapi again, and `underlay` reads
+`blog-underlay.ts`.
+
+## Previews
+
+Blog drafts are previewed in `/admin` for now. The routes below are for
+Strapi's preview button, and only matter while something is still in Strapi.
+
+`/api/draft?secret=...&slug=...` is what Strapi's preview button calls. It
+checks the secret against `DRAFT_SECRET`, sets a cookie for an hour and
+redirects to `/preview/blog/` or `/preview/page/`, which render
+the draft from Strapi on request. To preview another kind of content later, add
+a `src/pages/preview//[slug].astro` with `export const prerender = false`.
+
+## Build caches
+
+Share images (`/og/...png`) are drawn with satori in `src/lib/og.ts`, cached in
+`node_modules/.astro/og`. Journal article HTML is cached by hash in
+`node_modules/.cache/underlay`. Keep `node_modules/.astro` and
+`node_modules/.cache` between builds: a cold build downloads and resizes every
+image and draws every share image again.
diff --git a/apps/site/docs/editing.md b/apps/site/docs/editing.md
new file mode 100644
index 00000000..37037ef1
--- /dev/null
+++ b/apps/site/docs/editing.md
@@ -0,0 +1,63 @@
+# Editing the site
+
+## Publishing a blog post
+
+In `/admin`, **Blog**, **New Post**. Fill in the title, date and authors, write
+the text, and **Save**. Saving makes a draft: nothing changes on the site yet.
+
+Drafts move through **Workflow** (the icon in the top bar): Draft, In Review,
+Ready. Someone else can read and edit a draft there. **Publish** puts it on
+the site, which rebuilds in a few minutes. Changing a published post goes the
+same way: the change is a draft until it's published.
+
+The preview next to the fields shows the post as it will look on the site and
+follows along as you type. It leaves out what the post page adds around it,
+like the table of contents and the author bios.
+
+Authors are picked from **People**. Someone new writes for the blog? Add them
+under People first (Team member off).
+
+### Images
+
+Upload photos as they are: they are turned into WebP of at most 2400 pixels
+before they are saved, so a 15 MB camera photo becomes a few hundred KB. The
+image picker can also search **Unsplash**; those photos are linked, not copied.
+Give the cover image a description for people who can't see it.
+
+Everything uploaded is public, on the site and in site-content's history, the
+moment it's saved. Deleting a file in `/admin` takes it off the site, but its
+history stays: to remove something for good, ask someone who manages the
+repository.
+
+## Changing the homepage or another page
+
+Go to `/admin` and sign in. Pages are lists of **sections**, drawn top to
+bottom. You can add, remove, reorder and edit sections:
+
+- **Big intro**: the large title block. Each line of the title gets its own box.
+- **Text**: a block of text, optionally with a title and a coloured background.
+- **Columns**: two or three boxes side by side (like "What do we do?").
+- **Latest blog posts** / **Latest journal articles**: filled in automatically.
+- **Team**: everyone under People marked as team member who has a photo.
+- **Newsletter sign-up**
+- **Buttons**
+
+Text fields take Markdown: `**bold**`, `*italic*`, `[a link](https://...)`.
+
+To make a new page, click **New Page** in `/admin`. Its address is its title
+in lowercase with dashes (`Open Positions` becomes `/open-positions`).
+
+Saving in `/admin` makes a commit to site-content, which triggers a rebuild.
+
+## Signing in to /admin
+
+With `ADMIN_AUTH=microsoft`, editors sign in with their Center Microsoft
+account and need no GitHub account: **Sign in with Microsoft** opens a
+Microsoft sign-in in a popup. Editors have to be assigned to the "trialanderror admin" Entra app first
+([admin-sign-in.md](admin-sign-in.md#adding-an-editor)), or Microsoft refuses
+with `AADSTS50105`.
+
+Otherwise (`ADMIN_AUTH=github`) they need a GitHub account with write access to
+`TrialAndErrorOrg/site-content` and sign in with a
+[personal access token](https://github.com/settings/tokens) (fine-grained,
+repository contents read and write).
diff --git a/apps/site/package.json b/apps/site/package.json
new file mode 100644
index 00000000..2b001fe5
--- /dev/null
+++ b/apps/site/package.json
@@ -0,0 +1,50 @@
+{
+ "name": "site",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "files": [
+ "dist",
+ ".env.schema"
+ ],
+ "scripts": {
+ "dev": "varlock run -- astro dev",
+ "build": "varlock run -- astro build",
+ "preview": "astro preview",
+ "start": "varlock run -- node dist/server/entry.mjs",
+ "typecheck": "astro check"
+ },
+ "dependencies": {
+ "@varlock/1password-plugin": "^2.0.5",
+ "github-slugger": "^2.0.0",
+ "sharp": "^0.35.5",
+ "sveltia-gateway": "workspace:^",
+ "varlock": "^1.22.0",
+ "yaml": "^2.9.1"
+ },
+ "devDependencies": {
+ "@astrojs/check": "^0.9.10",
+ "@astrojs/node": "^11.1.7",
+ "@astrojs/rss": "^4.0.19",
+ "@astrojs/sitemap": "^3.7.4",
+ "@iconify-json/lucide": "^1.2.143",
+ "@iconify-json/simple-icons": "^1.2.100",
+ "@tailwindcss/typography": "^0.5.20",
+ "@tailwindcss/vite": "^4.3.3",
+ "@types/hast": "catalog:",
+ "@types/node": "^24.19.2",
+ "astro": "^7.3.8",
+ "astro-icon": "^1.2.0",
+ "hast-util-to-html": "^9.0.5",
+ "marked": "^18.1.1",
+ "pagefind": "^1.5.2",
+ "rehype-parse": "catalog:",
+ "rehype-remark": "catalog:",
+ "remark-gfm": "catalog:",
+ "remark-stringify": "catalog:",
+ "satori": "^0.51.0",
+ "tailwindcss": "^4.3.3",
+ "typescript": "~5.9.3",
+ "unified": "catalog:"
+ }
+}
diff --git a/apps/site/public/android-chrome-192x192.png b/apps/site/public/android-chrome-192x192.png
new file mode 100644
index 00000000..56b18e09
Binary files /dev/null and b/apps/site/public/android-chrome-192x192.png differ
diff --git a/apps/site/public/android-chrome-512x512.png b/apps/site/public/android-chrome-512x512.png
new file mode 100644
index 00000000..d10160a3
Binary files /dev/null and b/apps/site/public/android-chrome-512x512.png differ
diff --git a/apps/site/public/apple-touch-icon.png b/apps/site/public/apple-touch-icon.png
new file mode 100644
index 00000000..3c231e11
Binary files /dev/null and b/apps/site/public/apple-touch-icon.png differ
diff --git a/apps/site/public/browserconfig.xml b/apps/site/public/browserconfig.xml
new file mode 100644
index 00000000..b3930d0f
--- /dev/null
+++ b/apps/site/public/browserconfig.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+ #da532c
+
+
+
diff --git a/apps/site/public/favicon-16x16.png b/apps/site/public/favicon-16x16.png
new file mode 100644
index 00000000..a496b4d9
Binary files /dev/null and b/apps/site/public/favicon-16x16.png differ
diff --git a/apps/site/public/favicon-32x32.png b/apps/site/public/favicon-32x32.png
new file mode 100644
index 00000000..de4f912f
Binary files /dev/null and b/apps/site/public/favicon-32x32.png differ
diff --git a/apps/site/public/favicon.ico b/apps/site/public/favicon.ico
new file mode 100644
index 00000000..e714df47
Binary files /dev/null and b/apps/site/public/favicon.ico differ
diff --git a/apps/site/public/favicon.svg b/apps/site/public/favicon.svg
new file mode 100644
index 00000000..916df470
--- /dev/null
+++ b/apps/site/public/favicon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/site/public/faviconn.svg b/apps/site/public/faviconn.svg
new file mode 100644
index 00000000..542f90ae
--- /dev/null
+++ b/apps/site/public/faviconn.svg
@@ -0,0 +1,11 @@
+
diff --git a/apps/site/public/mstile-150x150.png b/apps/site/public/mstile-150x150.png
new file mode 100644
index 00000000..ce9e2ddc
Binary files /dev/null and b/apps/site/public/mstile-150x150.png differ
diff --git a/apps/site/public/safari-pinned-tab.svg b/apps/site/public/safari-pinned-tab.svg
new file mode 100644
index 00000000..66c95cd8
--- /dev/null
+++ b/apps/site/public/safari-pinned-tab.svg
@@ -0,0 +1,15 @@
+
+
+
diff --git a/apps/site/public/site.webmanifest b/apps/site/public/site.webmanifest
new file mode 100644
index 00000000..b20abb7c
--- /dev/null
+++ b/apps/site/public/site.webmanifest
@@ -0,0 +1,19 @@
+{
+ "name": "",
+ "short_name": "",
+ "icons": [
+ {
+ "src": "/android-chrome-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ },
+ {
+ "src": "/android-chrome-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png"
+ }
+ ],
+ "theme_color": "#ffffff",
+ "background_color": "#ffffff",
+ "display": "standalone"
+}
diff --git a/apps/site/scripts/blog-to-content.ts b/apps/site/scripts/blog-to-content.ts
new file mode 100644
index 00000000..4136066f
--- /dev/null
+++ b/apps/site/scripts/blog-to-content.ts
@@ -0,0 +1,354 @@
+/*
+ * Copies the blog out of Strapi into site-content, where /admin edits it:
+ *
+ * blog/.md posts, Markdown with the fields as frontmatter
+ * people/.yaml team members and guest authors, one file per person
+ * categories/.yaml
+ * uploads/blog// each post's cover and inline images
+ * uploads/people/ portraits
+ *
+ * pnpm exec varlock run -- node scripts/blog-to-content.ts # into ../../../site-content
+ * pnpm exec varlock run -- node scripts/blog-to-content.ts
+ *
+ * Only reads Strapi. Run it again to start over: it overwrites what it wrote.
+ * Strapi keeps team members and guest authors apart, and some people are both;
+ * they become one person (same ORCID iD, or else the same name), whose other
+ * slugs are kept as `aliases` so /blog/author/ still redirects.
+ * Post bodies were HTML, much of it pasted from Word; they become Markdown.
+ * Images become WebP of at most 2400 pixels, as uploads in /admin do.
+ */
+
+import { mkdir, rm, writeFile } from "node:fs/promises";
+import { dirname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import type { Element, Nodes as HastNodes } from "hast";
+import { toHtml } from "hast-util-to-html";
+import rehypeParse from "rehype-parse";
+import rehypeRemark from "rehype-remark";
+import remarkGfm from "remark-gfm";
+import remarkStringify from "remark-stringify";
+import sharp from "sharp";
+import { unified } from "unified";
+import { stringify } from "yaml";
+
+const STRAPI_URL = process.env.STRAPI_URL ?? "https://cms.trialanderror.org";
+const STRAPI_TOKEN = process.env.STRAPI_TOKEN;
+const here = dirname(fileURLToPath(import.meta.url));
+const out = resolve(process.argv[2] ?? join(here, "../../../../site-content"));
+
+// Files this large stay where they are (GitHub refuses files over 100 MB, and video doesn't belong in git)
+const MAX_COPY = 25 * 1024 * 1024;
+
+async function strapiAll(path: string, params: Record) {
+ const all: any[] = [];
+ for (let page = 1; ; page++) {
+ const url = new URL(`/api/${path}`, STRAPI_URL);
+ for (const [k, v] of Object.entries({ ...params, "pagination[page]": String(page), "pagination[pageSize]": "100" })) url.searchParams.set(k, v);
+ const res = await fetch(url, { headers: STRAPI_TOKEN ? { Authorization: `Bearer ${STRAPI_TOKEN}` } : {}, signal: AbortSignal.timeout(60_000) });
+ if (!res.ok) throw new Error(`Strapi ${res.status} for ${url}`);
+ const json = await res.json();
+ all.push(...json.data.map(flatten));
+ if (page >= json.meta.pagination.pageCount) return all;
+ }
+}
+
+function flatten(value: any): any {
+ if (Array.isArray(value)) return value.map(flatten);
+ if (value === null || typeof value !== "object") return value;
+ if ("data" in value && Object.keys(value).length <= 2) return flatten(value.data);
+ if ("attributes" in value && "id" in value) return flatten({ id: value.id, ...value.attributes });
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, flatten(v)]));
+}
+
+const nameKey = (name: string) =>
+ name
+ .normalize("NFKD")
+ .replace(/\p{M}/gu, "")
+ .toLowerCase()
+ .split(/\s+/)
+ .filter(Boolean)
+ .join(" ");
+const slugify = (text: string) =>
+ nameKey(text)
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-|-$/g, "");
+const orcidId = (orcid?: string) => orcid?.match(/\d{4}-\d{4}-\d{4}-\d{3}[\dX]/i)?.[0].toUpperCase();
+const clean = >(o: T) =>
+ Object.fromEntries(Object.entries(o).filter(([, v]) => v !== null && v !== undefined && v !== "" && !(Array.isArray(v) && !v.length))) as T;
+
+/** Alt text Strapi filled with the file name says nothing */
+const altText = (alt?: string | null) => (alt && !/\.(?:jpe?g|png|gif|webp|avif|tiff?)$/i.test(alt.trim()) ? alt.trim() : undefined);
+
+const copied = new Map();
+const skipped: string[] = [];
+
+/** Downloads a Strapi file into uploads// and returns its path on the site, or undefined when it stays remote */
+async function copy(url: string, folder: string, name?: string): Promise {
+ const absolute = new URL(url, STRAPI_URL).href.replace(/\?.*$/, "");
+ const known = copied.get(absolute);
+ if (known) return known;
+ const res = await fetch(absolute, { signal: AbortSignal.timeout(120_000) });
+ if (!res.ok) throw new Error(`${res.status} for ${absolute}`);
+ const size = Number(res.headers.get("content-length") ?? 0);
+ if (size > MAX_COPY) {
+ await res.body?.cancel();
+ skipped.push(`${absolute} (${Math.round(size / 1024 / 1024)} MB)`);
+ return undefined;
+ }
+ const bytes = Buffer.from(await res.arrayBuffer());
+ const base = slugify((name ?? absolute.split("/").pop()!).replace(/\.[^.]+$/, "").replace(/_[0-9a-f]{10}$/, "")) || "file";
+ const type = res.headers.get("content-type") ?? "";
+ let file: string;
+ let data: Buffer = bytes;
+ if (/^image\/(?:jpeg|png|webp|avif|tiff)/.test(type)) {
+ file = `${base}.webp`;
+ data = await sharp(bytes).rotate().resize({ width: 2400, height: 2400, fit: "inside", withoutEnlargement: true }).webp({ quality: 82 }).toBuffer();
+ } else {
+ file = `${base}${absolute.match(/\.[a-z0-9]+$/i)?.[0].toLowerCase() ?? ""}`;
+ }
+ await mkdir(join(out, "uploads", folder), { recursive: true });
+ await writeFile(join(out, "uploads", folder, file), data);
+ const path = `/uploads/${folder}/${file}`;
+ copied.set(absolute, path);
+ return path;
+}
+
+const linkKeys: Record = {
+ bluesky: "bluesky",
+ mastodon: "mastodon",
+ github: "github",
+ linkedin: "linkedin",
+ linkedIn: "linkedin",
+ personalWebsite: "website",
+ twitter: "twitter",
+};
+
+/** Elements Markdown has no syntax for, kept as HTML */
+const keepAsHtml = new Set(["video", "iframe", "sup", "sub"]);
+
+async function toMarkdown(html: string, folder: string) {
+ const tree = unified().use(rehypeParse, { fragment: true }).parse(html);
+ // Download Strapi files the body points at, and drop Word's leftovers
+ const visit = async (node: HastNodes) => {
+ if (node.type === "text") node.value = node.value.replace(/\u00a0/g, " ");
+ if (node.type === "element" || node.type === "root") {
+ // Two line breaks in a row are a new paragraph
+ node.children = node.children.flatMap((child) => (child.type === "element" && child.tagName === "p" ? splitParagraph(child) : [child]));
+ // Word leaves paragraphs that hold nothing but spaces and line breaks, and line breaks that end paragraphs
+ node.children = node.children.filter((child) => !(child.type === "element" && child.tagName === "p" && isBlank(child)));
+ // Word's footnote links point at anchors Markdown doesn't keep
+ node.children = node.children.flatMap((child) =>
+ child.type === "element" && child.tagName === "a" && String(child.properties.href ?? "").startsWith("#_ftn") ? child.children : [child],
+ );
+ }
+ if (node.type === "element") {
+ while (node.tagName === "p" && isBreak(node.children.at(-1))) node.children.pop();
+ if (node.tagName === "video") {
+ const { src, poster } = node.properties;
+ node.properties = { src, poster, controls: true };
+ }
+ for (const attr of ["src", "href"] as const) {
+ const value = node.properties[attr];
+ if (typeof value === "string" && value.startsWith(`${STRAPI_URL}/uploads/`)) {
+ node.properties[attr] = (await copy(value, folder)) ?? value;
+ }
+ }
+ delete node.properties.className;
+ delete node.properties.style;
+ }
+ if ("children" in node) for (const child of node.children) await visit(child as HastNodes);
+ };
+ unwrapSpans(tree);
+ await visit(tree);
+ const asHtml = (_state: unknown, node: Element) => ({ type: "html" as const, value: toHtml(node) });
+ const mdast = await unified()
+ .use(rehypeRemark, {
+ handlers: {
+ ...Object.fromEntries([...keepAsHtml].map((tag) => [tag, asHtml])),
+ div: (state: any, node: Element) => (node.children.some((c) => c.type === "element" && keepAsHtml.has(c.tagName)) ? asHtml(state, node) : state.all(node)),
+ },
+ })
+ .run(tree as any);
+ liftSpaces(mdast as any);
+ const markdown = unified().use(remarkGfm).use(remarkStringify, { bullet: "-", emphasis: "*", strong: "*", rule: "-" }).stringify(mdast as any);
+ return String(markdown)
+ .replace(/\u00a0/g, " ")
+ .replace(/\n{3,}/g, "\n\n")
+ .trim();
+}
+
+/** Word's spans only carried styles, which are gone */
+function unwrapSpans(node: HastNodes) {
+ if (!("children" in node)) return;
+ for (const child of node.children) unwrapSpans(child as HastNodes);
+ node.children = node.children.flatMap((child: any) => (child.type === "element" && ["span", "font", "o:p"].includes(child.tagName) ? child.children : [child]));
+}
+
+function splitParagraph(p: Element): Element[] {
+ const parts: Element["children"][] = [[]];
+ const children = p.children;
+ for (let i = 0; i < children.length; i++) {
+ const child = children[i];
+ if (child.type === "element" && child.tagName === "br") {
+ let j = i + 1;
+ while (j < children.length && children[j].type === "text" && !(children[j] as { value: string }).value.trim()) j++;
+ const next = children[j];
+ if (next?.type === "element" && next.tagName === "br") {
+ parts.push([]);
+ i = j;
+ continue;
+ }
+ }
+ parts.at(-1)!.push(child);
+ }
+ return parts.map((children) => ({ ...p, children }));
+}
+
+const isBreak = (node?: HastNodes) => !!node && ((node.type === "element" && node.tagName === "br") || (node.type === "text" && !node.value.trim()));
+const isBlank = (node: Element): boolean =>
+ node.children.every((child) => isBreak(child) || (child.type === "element" && child.tagName !== "img" && isBlank(child)));
+
+/**
+ * Moves spaces at the edges of emphasis and bold outside them: "*please *"
+ * isn't valid Markdown, and would be written as "*please *"
+ */
+function liftSpaces(node: { type: string; value?: string; children?: any[] }) {
+ if (!node.children) return;
+ for (let i = 0; i < node.children.length; i++) {
+ const child = node.children[i];
+ liftSpaces(child);
+ if (!["emphasis", "strong", "delete"].includes(child.type) || !child.children.length) continue;
+ const first = child.children[0];
+ const last = child.children.at(-1);
+ if (first.type === "text" && /^\s/.test(first.value)) {
+ first.value = first.value.trimStart();
+ node.children.splice(i, 0, { type: "text", value: " " });
+ i++;
+ }
+ if (last.type === "text" && /\s$/.test(last.value)) {
+ last.value = last.value.trimEnd();
+ node.children.splice(i + 1, 0, { type: "text", value: " " });
+ }
+ if (child.children.every((c: any) => c.type === "text" && !c.value)) node.children.splice(i, 1);
+ }
+}
+
+interface PersonFile {
+ slug: string;
+ aliases: string[];
+ data: Record;
+ team: boolean;
+}
+
+async function main() {
+ const posts = await strapiAll("blog-posts", {
+ "populate[image]": "*",
+ "populate[category]": "*",
+ "populate[blog_tags]": "*",
+ "populate[related]": "*",
+ "populate[seo]": "*",
+ "populate[team_members]": "*",
+ "populate[blog_authors]": "*",
+ });
+ const team = await strapiAll("team-members", { "populate[image]": "*" });
+ const guests = await strapiAll("blog-authors", { "populate[image]": "*" });
+ const categories = await strapiAll("categories", {});
+
+ for (const folder of ["blog", "people", "categories", "uploads/blog", "uploads/people"]) await rm(join(out, folder), { recursive: true, force: true });
+ for (const folder of ["blog", "people", "categories"]) await mkdir(join(out, folder), { recursive: true });
+
+ // People: team records first, so a merged person keeps the team details
+ const people: (PersonFile & { orcid?: string; name: string; records: string[] })[] = [];
+ const recordToPerson = new Map();
+ // Guests who wrote nothing are leftovers (Strapi has a "Test Test-slug")
+ const wrote = new Set(posts.flatMap((p) => (p.blog_authors ?? []).map((a: any) => a.id)));
+ const records = [
+ ...team.map((raw) => ({ raw, kind: "team" as const })),
+ ...guests.filter((raw) => wrote.has(raw.id)).map((raw) => ({ raw, kind: "guest" as const })),
+ ];
+ for (const { raw, kind } of records) {
+ const name = [raw.firstName, raw.lastName].filter(Boolean).join(" ").trim();
+ const orcid = orcidId(raw.orcid);
+ // Strapi made slugs like `a-1` when one was missing; the name says more
+ const own = raw.slug && !/^[a-z]-\d+$/.test(raw.slug) ? raw.slug : slugify(name);
+ let person = people.find((p) => (orcid && p.orcid === orcid) || (nameKey(p.name) === nameKey(name) && !(orcid && p.orcid && p.orcid !== orcid)));
+ if (!person) {
+ person = { slug: own, aliases: [], name, orcid, team: false, records: [], data: {} };
+ people.push(person);
+ }
+ person.records.push(own);
+ if (raw.slug && raw.slug !== own) person.records.push(raw.slug);
+ person.orcid ??= orcid;
+ person.team ||= kind === "team";
+ const d = person.data;
+ d.firstName ??= raw.firstName?.trim();
+ d.lastName ??= raw.lastName?.trim();
+ d.position ??= raw.position || undefined;
+ if (raw.show_pronouns) d.pronouns ??= raw.pronouns || undefined;
+ if (kind === "team") d.email ??= raw.email || undefined;
+ const summary = raw.summary || raw.bio;
+ if (summary && !d.summary) d.summary = await toMarkdown(summary, "people");
+ if (raw.image?.url && !d.image) d.image = await copy(raw.image.url, "people", own);
+ const links = (d.links as { icon: string; url: string }[] | undefined) ?? [];
+ for (const [key, icon] of Object.entries(linkKeys)) {
+ if (raw[key] && !links.some((l) => l.icon === icon)) links.push({ icon, url: String(raw[key]).trim() });
+ }
+ d.links = links;
+ recordToPerson.set(`${kind}-${raw.id}`, person);
+ }
+ const taken = new Set();
+ for (const person of people) {
+ const slugs = [...new Set(person.records)];
+ // The shortest slug, so `hobma` wins over Strapi's de-duplicated `hobma-1`
+ // Strapi de-duplicated slugs with a number (`hobma-1`); the plain one is nicer when it's free
+ person.slug =
+ [...new Set([...slugs.map((s) => s.replace(/-\d+$/, "")), ...slugs])]
+ .filter((s) => s && !/^[a-z](?:-\d+)?$/.test(s))
+ .sort((a, b) => a.length - b.length)
+ .find((s) => !taken.has(s)) ?? slugify(person.name);
+ person.aliases = slugs.filter((s) => s !== person.slug);
+ for (const s of [person.slug, ...person.aliases]) taken.add(s);
+ const { firstName, lastName, ...rest } = person.data;
+ const file = clean({
+ firstName,
+ lastName,
+ team: person.team,
+ ...rest,
+ orcid: person.orcid ? `https://orcid.org/${person.orcid}` : undefined,
+ aliases: person.aliases,
+ });
+ await writeFile(join(out, "people", `${person.slug}.yaml`), stringify(file, { lineWidth: 0 }));
+ }
+
+ for (const c of categories) {
+ await writeFile(join(out, "categories", `${c.slug ?? slugify(c.title)}.yaml`), stringify({ title: c.title }));
+ }
+
+ for (const p of posts) {
+ const folder = `blog/${p.slug}`;
+ const frontmatter = clean({
+ title: p.title.trim(),
+ // To the minute, as /admin's date field writes it, so opening a post doesn't change it
+ date: `${new Date(p.publishDate ?? p.publishedAt).toISOString().slice(0, 16)}:00Z`,
+ excerpt: p.excerpt?.trim(),
+ description: p.seo?.metaDescription?.trim(),
+ image: p.image?.url ? await copy(p.image.url, folder, p.image.name) : undefined,
+ imageAlt: altText(p.image?.alternativeText),
+ category: p.category ? (p.category.slug ?? slugify(p.category.title)) : undefined,
+ tags: (p.blog_tags ?? []).map((t: any) => t.title.trim()),
+ authors: [
+ ...(p.team_members ?? []).map((a: any) => recordToPerson.get(`team-${a.id}`)?.slug),
+ ...(p.blog_authors ?? []).map((a: any) => recordToPerson.get(`guest-${a.id}`)?.slug),
+ ].filter((s, i, all): s is string => !!s && all.indexOf(s) === i),
+ doi: p.doi?.trim(),
+ related: (p.related ?? []).map((r: any) => r.slug).filter(Boolean),
+ });
+ const body = p.body ? await toMarkdown(p.body, folder) : "";
+ await writeFile(join(out, "blog", `${p.slug}.md`), `---\n${stringify(frontmatter, { lineWidth: 0 })}---\n\n${body}\n`);
+ }
+
+ console.log(`${posts.length} posts, ${people.length} people (from ${records.length} Strapi records), ${categories.length} categories, ${copied.size} files into ${out}`);
+ if (skipped.length) console.log(`Left on Strapi, too large to copy:\n ${skipped.join("\n ")}`);
+}
+
+await main();
diff --git a/apps/site/scripts/blog-to-underlay.ts b/apps/site/scripts/blog-to-underlay.ts
new file mode 100644
index 00000000..75d3b994
--- /dev/null
+++ b/apps/site/scripts/blog-to-underlay.ts
@@ -0,0 +1,250 @@
+/*
+ * Copies the blog from Strapi to an Underlay collection, so the site can read
+ * it from there (BLOG_SOURCE=underlay) and the blog has a versioned public
+ * archive like the journal.
+ *
+ * node --env-file=.env scripts/blog-to-underlay.ts # dry run, writes ./underlay-export
+ * node --env-file=.env scripts/blog-to-underlay.ts --push # publishes a new version
+ *
+ * --push needs UNDERLAY_TOKEN (a write key) and UNDERLAY_BLOG_COLLECTION
+ * (owner/slug, e.g. centeroftrialanderror/blog; the collection must exist).
+ * Images are uploaded as Underlay files. Images inside post bodies keep
+ * pointing at Strapi.
+ */
+
+import { createHash } from "node:crypto";
+import { mkdir, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+
+const STRAPI_URL = process.env.STRAPI_URL ?? "https://cms.trialanderror.org";
+const STRAPI_TOKEN = process.env.STRAPI_TOKEN;
+const UNDERLAY_URL = process.env.UNDERLAY_URL ?? "https://www.underlay.org";
+const UNDERLAY_TOKEN = process.env.UNDERLAY_TOKEN;
+const COLLECTION = process.env.UNDERLAY_BLOG_COLLECTION;
+const push = process.argv.includes("--push");
+const outDir = "underlay-export";
+
+type Rec = { id: string; type: string; data: Record };
+
+const fileRef = { type: "object", required: ["$file"], properties: { $file: { type: "string" }, fileName: { type: "string" }, mimeType: { type: "string" }, originalUrl: { type: "string" }, width: { type: "number" }, height: { type: "number" }, alt: { type: "string" } } };
+const str = { type: "string" };
+const strs = { type: "array", items: str };
+
+export const schemas = {
+ BlogPost: {
+ type: "object",
+ required: ["slug", "title", "publishedAt"],
+ properties: {
+ slug: str,
+ title: str,
+ excerpt: str,
+ description: str,
+ body: { type: "string", description: "HTML" },
+ publishedAt: { type: "string", format: "date-time" },
+ updatedAt: { type: "string", format: "date-time" },
+ doi: str,
+ license: str,
+ image: fileRef,
+ categoryId: { type: "string", "x-ref-type": "Category" },
+ tagIds: { type: "array", items: { type: "string", "x-ref-type": "Tag" } },
+ authorIds: { type: "array", items: { type: "string", "x-ref-type": "Person" } },
+ relatedIds: { type: "array", items: { type: "string", "x-ref-type": "BlogPost" } },
+ },
+ },
+ Person: {
+ type: "object",
+ required: ["name", "kind"],
+ properties: {
+ slug: str,
+ name: str,
+ firstName: str,
+ lastName: str,
+ kind: { type: "string", enum: ["team", "guest"] },
+ position: str,
+ summary: { type: "string", description: "HTML" },
+ orcid: str,
+ email: str,
+ image: fileRef,
+ links: { type: "array", items: { type: "object", properties: { icon: str, url: str } } },
+ },
+ },
+ Category: { type: "object", required: ["slug", "title"], properties: { slug: str, title: str } },
+ Tag: { type: "object", required: ["slug", "title"], properties: { slug: str, title: str } },
+};
+
+async function strapiAll(path: string, params: Record) {
+ const out: any[] = [];
+ for (let page = 1; ; page++) {
+ const url = new URL(`/api/${path}`, STRAPI_URL);
+ for (const [k, v] of Object.entries({ ...params, "pagination[page]": String(page), "pagination[pageSize]": "100" })) url.searchParams.set(k, v);
+ const res = await fetch(url, { headers: STRAPI_TOKEN ? { Authorization: `Bearer ${STRAPI_TOKEN}` } : {} });
+ if (!res.ok) throw new Error(`Strapi ${res.status} for ${url}`);
+ const json = await res.json();
+ out.push(...json.data.map(flatten));
+ if (page >= json.meta.pagination.pageCount) return out;
+ }
+}
+
+function flatten(value: any): any {
+ if (Array.isArray(value)) return value.map(flatten);
+ if (value === null || typeof value !== "object") return value;
+ if ("data" in value && Object.keys(value).length <= 2) return flatten(value.data);
+ if ("attributes" in value && "id" in value) return flatten({ id: value.id, ...value.attributes });
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, flatten(v)]));
+}
+
+const slugify = (t: string) => t.toLowerCase().normalize("NFKD").replace(/[^\w\s-]/g, "").trim().replace(/[\s_]+/g, "-");
+const clean = >(o: T) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== null && v !== undefined && v !== "")) as T;
+
+const files = new Map();
+
+async function toFile(image: any) {
+ if (!image?.url) return undefined;
+ const res = await fetch(image.url);
+ if (!res.ok) throw new Error(`Image ${res.status} for ${image.url}`);
+ const bytes = Buffer.from(await res.arrayBuffer());
+ const hash = createHash("sha256").update(bytes).digest("hex");
+ const mimeType = res.headers.get("content-type") ?? image.mime ?? "application/octet-stream";
+ files.set(hash, { bytes, mimeType });
+ return clean({ $file: `sha256:${hash}`, fileName: image.name, mimeType, originalUrl: image.url, width: image.width, height: image.height, alt: image.alternativeText });
+}
+
+const linkKeys: Record = { bluesky: "bluesky", mastodon: "mastodon", github: "github", linkedin: "linkedin", linkedIn: "linkedin", personalWebsite: "website", twitter: "twitter" };
+
+async function toPerson(raw: any, kind: "team" | "guest"): Promise {
+ return {
+ id: `${kind}-${raw.id}`,
+ type: "Person",
+ data: clean({
+ slug: raw.slug,
+ name: [raw.firstName, raw.lastName].filter(Boolean).join(" "),
+ firstName: raw.firstName,
+ lastName: raw.lastName,
+ kind,
+ position: raw.position,
+ summary: raw.summary || raw.bio,
+ orcid: raw.orcid,
+ email: kind === "team" ? raw.email : undefined,
+ image: await toFile(raw.image),
+ links: Object.entries(linkKeys).filter(([k]) => raw[k]).map(([k, icon]) => ({ icon, url: raw[k] })),
+ }),
+ };
+}
+
+async function main() {
+ const posts = await strapiAll("blog-posts", { "populate[image]": "*", "populate[category]": "*", "populate[blog_tags]": "*", "populate[related]": "*", "populate[seo]": "*", "populate[team_members][populate][image]": "*", "populate[blog_authors][populate][image]": "*" });
+ const team = await strapiAll("team-members", { "populate[image]": "*" });
+ const guests = await strapiAll("blog-authors", { "populate[image]": "*" });
+ const categories = await strapiAll("categories", {});
+ const tags = await strapiAll("tags", {});
+
+ const records: Rec[] = [];
+ for (const c of categories) records.push({ id: String(c.id), type: "Category", data: { slug: c.slug ?? slugify(c.title), title: c.title } });
+ for (const t of tags) records.push({ id: String(t.id), type: "Tag", data: { slug: t.slug ?? slugify(t.title), title: t.title } });
+ for (const p of team) records.push(await toPerson(p, "team"));
+ for (const p of guests) records.push(await toPerson(p, "guest"));
+ for (const p of posts) {
+ records.push({
+ id: String(p.id),
+ type: "BlogPost",
+ data: clean({
+ slug: p.slug,
+ title: p.title,
+ excerpt: p.excerpt?.trim(),
+ description: p.seo?.metaDescription,
+ body: p.body,
+ publishedAt: p.publishDate ?? p.publishedAt,
+ updatedAt: p.updatedAt,
+ doi: p.doi,
+ license: "CC-BY-4.0",
+ image: await toFile(p.image),
+ categoryId: p.category ? String(p.category.id) : undefined,
+ tagIds: (p.blog_tags ?? []).map((t: any) => String(t.id)),
+ authorIds: [...(p.team_members ?? []).map((a: any) => `team-${a.id}`), ...(p.blog_authors ?? []).map((a: any) => `guest-${a.id}`)],
+ relatedIds: (p.related ?? []).map((r: any) => String(r.id)),
+ }),
+ });
+ }
+
+ await mkdir(join(outDir, "files"), { recursive: true });
+ await writeFile(join(outDir, "schemas.json"), JSON.stringify(schemas, null, 2));
+ await writeFile(join(outDir, "records.ndjson"), records.map((r) => JSON.stringify(r)).join("\n") + "\n");
+ for (const [hash, { bytes }] of files) await writeFile(join(outDir, "files", hash), bytes);
+ console.log(`${records.length} records and ${files.size} files written to ${outDir}/`);
+
+ if (!push) {
+ console.log("Dry run. Pass --push to publish.");
+ return;
+ }
+ if (!UNDERLAY_TOKEN || !COLLECTION) throw new Error("--push needs UNDERLAY_TOKEN and UNDERLAY_BLOG_COLLECTION");
+
+ const api = (path: string) => new URL(`/api/collections/${COLLECTION}${path}`, UNDERLAY_URL);
+ const auth = { Authorization: `Bearer ${UNDERLAY_TOKEN}` };
+
+ const head = await fetch(api("/versions/latest"), { headers: auth });
+ const base = head.status === 404 ? null : (await head.json()).semver;
+
+ // The site's export is the whole blog, so anything not in it is deleted
+ const deletes: { type: string; id: string }[] = [];
+ if (base) {
+ const current = new Set(records.map((r) => `${r.type}\u0000${r.id}`));
+ let cursor: string | undefined;
+ do {
+ const url = api(`/versions/${base}/manifest`);
+ if (cursor) url.searchParams.set("cursor", cursor);
+ const manifest = await (await fetch(url, { headers: auth })).json();
+ for (const r of manifest.records) if (!current.has(`${r.type}\u0000${r.id}`)) deletes.push({ type: r.type, id: r.id });
+ cursor = manifest.pagination.hasMore ? manifest.pagination.nextCursor : undefined;
+ } while (cursor);
+ }
+
+ const open = await fetch(api("/push"), {
+ method: "POST",
+ headers: { ...auth, "Content-Type": "application/json" },
+ body: JSON.stringify({ base, message: `Blog export ${new Date().toISOString().slice(0, 10)}`, app_id: "trialanderror-site", schemas }),
+ });
+ if (!open.ok) throw new Error(`Opening push: ${open.status} ${await open.text()}`);
+ const session = await open.json();
+
+ for (const hash of session.needed_files ?? []) {
+ const file = files.get(hash);
+ if (!file) continue;
+ const res = await fetch(api(`/files/${hash}`), { method: "PUT", headers: { ...auth, "Content-Type": "application/octet-stream" }, body: new Uint8Array(file.bytes) });
+ if (!res.ok) throw new Error(`Uploading ${hash}: ${res.status}`);
+ }
+ for (const hash of files.keys()) {
+ const has = await fetch(api(`/files/${hash}`), { method: "HEAD", headers: auth });
+ if (has.ok) continue;
+ const file = files.get(hash)!;
+ const res = await fetch(api(`/files/${hash}`), { method: "PUT", headers: { ...auth, "Content-Type": "application/octet-stream" }, body: new Uint8Array(file.bytes) });
+ if (!res.ok) throw new Error(`Uploading ${hash}: ${res.status}`);
+ }
+
+ const batch = 500;
+ for (let i = 0; i < records.length; i += batch) {
+ const res = await fetch(api(`/push/${session.session_id}/records`), {
+ method: "POST",
+ headers: { ...auth, "Content-Type": "application/x-ndjson" },
+ body: records.slice(i, i + batch).map((r) => JSON.stringify(r)).join("\n"),
+ });
+ if (!res.ok) throw new Error(`Uploading records: ${res.status} ${await res.text()}`);
+ }
+ if (deletes.length) {
+ const res = await fetch(api(`/push/${session.session_id}/deletes`), {
+ method: "POST",
+ headers: { ...auth, "Content-Type": "application/x-ndjson" },
+ body: deletes.map((d) => JSON.stringify(d)).join("\n"),
+ });
+ if (!res.ok) throw new Error(`Uploading deletes: ${res.status} ${await res.text()}`);
+ }
+
+ const commit = await fetch(api(`/push/${session.session_id}/commit`), { method: "POST", headers: auth });
+ if (commit.status === 409) {
+ console.log("No changes since the last version.");
+ return;
+ }
+ if (!commit.ok) throw new Error(`Commit: ${commit.status} ${await commit.text()}`);
+ console.log("Published", await commit.json());
+}
+
+await main();
diff --git a/apps/site/src/admin/config.yml b/apps/site/src/admin/config.yml
new file mode 100644
index 00000000..186ca74c
--- /dev/null
+++ b/apps/site/src/admin/config.yml
@@ -0,0 +1,333 @@
+# Editing interface for the site, served at /admin (through src/pages/admin/config.yml.ts,
+# which points sign-in at Microsoft when ADMIN_AUTH=microsoft). Editors can only change the
+# folders named here (src/lib/admin.ts).
+# Every save is a commit to TrialAndErrorOrg/site-content, which asks this repository to rebuild
+# the site (site-content's .github/workflows/rebuild.yml).
+# Field definitions here mirror src/content.config.ts and src/lib/settings.ts.
+
+backend:
+ name: github
+ repo: TrialAndErrorOrg/site-content
+ branch: main
+ # Who made a change. With Microsoft sign-in every commit is made by the GitHub App, so the
+ # editor's name is only in the message.
+ commit_messages:
+ create: "Create {{collection}} “{{slug}}” (by {{author-name}})"
+ update: "Update {{collection}} “{{slug}}” (by {{author-name}})"
+ delete: "Delete {{collection}} “{{slug}}” (by {{author-name}})"
+ uploadMedia: "Upload “{{path}}” (by {{author-name}})"
+ deleteMedia: "Delete “{{path}}” (by {{author-name}})"
+
+site_url: https://trialanderror.org
+display_url: https://trialanderror.org
+logo_url: /favicon.svg
+
+# Saving makes a draft: a branch with a pull request, which goes Draft, In Review, Ready, and is
+# published (merged into main, which rebuilds the site) from the Workflow view
+publish_mode: editorial_workflow
+
+# Leave empty optional fields out of the files, rather than writing `doi: ""` and the like.
+# An empty description would otherwise replace the excerpt the site falls back on.
+output:
+ omit_empty_optional_fields: true
+
+media_folder: uploads
+public_folder: /uploads
+media_libraries:
+ default:
+ config:
+ max_file_size: 26214400
+ slugify_filename: true
+ # Photos become WebP of at most 2400 pixels before they are committed, so the repository stays small
+ transformations:
+ raster_image: { format: webp, quality: 82, width: 2400, height: 2400 }
+ # Unsplash photos are linked, not copied (Unsplash's rules). The key is filled in by src/pages/admin/index.astro
+ stock_assets:
+ providers: [unsplash]
+
+collections:
+ - name: pages
+ label: Pages
+ label_singular: Page
+ description: Pages built from sections. "index" is the homepage. Free-text pages (donate, events) still live in Strapi.
+ folder: pages
+ extension: yaml
+ format: yaml
+ create: true
+ slug: "{{slug}}"
+ identifier_field: title
+ fields:
+ - { name: title, label: Title, widget: string }
+ - { name: description, label: Description (for search engines and link previews), widget: text, required: false }
+ - { name: showTitle, label: Show the title as a big heading, widget: boolean, default: false }
+ - name: sections
+ label: Sections
+ label_singular: Section
+ widget: list
+ typeKey: type
+ types:
+ - name: hero
+ label: Big intro
+ widget: object
+ summary: "{{title}}"
+ fields:
+ - { name: title, label: Title (each line gets its own box), widget: text }
+ - { name: subtitle, label: Subtitle, widget: string, required: false }
+ - { name: text, label: Text, widget: markdown, required: false }
+ - &buttons
+ name: buttons
+ label: Buttons
+ widget: list
+ required: false
+ fields:
+ - { name: label, label: Label, widget: string }
+ - { name: url, label: Link, widget: string }
+ - { name: style, label: Style, widget: select, options: [accent, plain], default: accent }
+ - name: text
+ label: Text
+ widget: object
+ summary: "{{title}}"
+ fields:
+ - { name: title, label: Title, widget: string, required: false }
+ - { name: id, label: Anchor (for links like /about#mission), widget: string, required: false }
+ - { name: body, label: Text, widget: markdown }
+ - { name: background, label: Background, widget: select, options: [paper, accent, accent-soft, journal, journal-soft], default: paper }
+ - name: features
+ label: Columns
+ widget: object
+ summary: "{{title}}"
+ fields:
+ - { name: title, label: Title, widget: string, required: false }
+ - { name: id, label: Anchor, widget: string, required: false }
+ - name: items
+ label: Columns
+ widget: list
+ fields:
+ - { name: title, label: Title, widget: string }
+ - { name: body, label: Text, widget: markdown }
+ - name: link
+ label: Link
+ widget: object
+ required: false
+ fields:
+ - { name: label, label: Label, widget: string }
+ - { name: url, label: Link, widget: string }
+ - name: latest-posts
+ label: Latest blog posts
+ widget: object
+ summary: "{{title}}"
+ fields:
+ - { name: title, label: Title, widget: string, default: From the Blog }
+ - { name: count, label: How many, widget: number, value_type: int, default: 4, min: 1 }
+ - name: latest-articles
+ label: Latest journal articles
+ widget: object
+ summary: "{{title}}"
+ fields:
+ - { name: title, label: Title, widget: string, default: From the Journal }
+ - { name: count, label: How many, widget: number, value_type: int, default: 4, min: 1 }
+ - name: team
+ label: Team
+ widget: object
+ summary: "{{title}}"
+ fields:
+ - { name: title, label: Title, widget: string, default: Meet the Team }
+ - { name: id, label: Anchor, widget: string, required: false }
+ - name: newsletter
+ label: Newsletter sign-up
+ widget: object
+ fields: []
+ - name: buttons
+ label: Buttons
+ widget: object
+ fields:
+ - *buttons
+
+ - name: blog
+ label: Blog
+ label_singular: Post
+ folder: blog
+ extension: md
+ format: frontmatter
+ create: true
+ slug: "{{slug}}"
+ summary: "{{title}}"
+ sortable_fields: [date, title]
+ media_folder: /uploads/blog
+ public_folder: /uploads/blog
+ fields:
+ - { name: title, label: Title, widget: string }
+ # Picked in the editor's own time, stored in UTC to the minute (2019-09-30T11:44:00Z).
+ # Without output_utc it would store local time with no zone, which the server reads as UTC
+ - { name: date, label: Publish date, widget: datetime, output_utc: true }
+ - name: authors
+ label: Authors
+ widget: relation
+ collection: people
+ value_field: "{{slug}}"
+ search_fields: [firstName, lastName]
+ display_fields: ["{{firstName}} {{lastName}}"]
+ multiple: true
+ - { name: category, label: Category, widget: relation, collection: categories, value_field: "{{slug}}", search_fields: [title], display_fields: [title], required: false }
+ - { name: tags, label: Tags, widget: list, required: false, hint: "Lowercase, like: open science" }
+ - { name: excerpt, label: Excerpt (shown on cards and at the top of the post), widget: text, required: false }
+ - { name: image, label: Cover image, widget: image, required: false }
+ - { name: imageAlt, label: Cover image description (for screen readers), widget: string, required: false }
+ - { name: body, label: Text, widget: markdown }
+ - { name: doi, label: DOI, widget: string, required: false, hint: "Only the DOI, like 10.36850/abcd-1234" }
+ - { name: related, label: Related posts, widget: relation, collection: blog, value_field: "{{slug}}", search_fields: [title], display_fields: [title], multiple: true, required: false }
+ - { name: description, label: Description for search engines (if not the excerpt), widget: text, required: false }
+
+ - name: people
+ label: People
+ label_singular: Person
+ description: Team members and blog authors. "Team" puts someone in the team list.
+ folder: people
+ extension: yaml
+ format: yaml
+ create: true
+ slug: "{{firstName}}-{{lastName}}"
+ identifier_field: lastName
+ summary: "{{firstName}} {{lastName}}"
+ sortable_fields: [lastName, firstName]
+ media_folder: /uploads/people
+ public_folder: /uploads/people
+ fields:
+ - { name: firstName, label: First name, widget: string, required: false }
+ - { name: lastName, label: Last name, widget: string }
+ - { name: team, label: Team member, widget: boolean, default: false }
+ - { name: position, label: Position, widget: string, required: false }
+ - { name: pronouns, label: Pronouns (only if they want them shown), widget: string, required: false }
+ - { name: summary, label: About, widget: markdown, required: false }
+ - { name: image, label: Photo, widget: image, required: false }
+ - { name: email, label: Email (shown for team members), widget: string, required: false }
+ - { name: orcid, label: ORCID iD, widget: string, required: false, hint: "Like https://orcid.org/0000-0002-1825-0097" }
+ - name: links
+ label: Links
+ widget: list
+ required: false
+ summary: "{{icon}}"
+ fields:
+ - { name: icon, label: Kind, widget: select, options: [website, bluesky, mastodon, linkedin, github, twitter] }
+ - { name: url, label: Link, widget: string }
+ - { name: aliases, label: Old addresses (leave as they are), widget: list, required: false, hint: "Earlier /people/... names, which keep redirecting here" }
+
+ - name: categories
+ label: Blog categories
+ label_singular: Category
+ folder: categories
+ extension: yaml
+ format: yaml
+ create: true
+ slug: "{{title}}"
+ identifier_field: title
+ fields:
+ - { name: title, label: Name, widget: string }
+
+ - name: settings
+ label: Settings
+ editor:
+ preview: false
+ files:
+ - name: site
+ label: Site
+ file: settings/site.yaml
+ fields:
+ - { name: name, label: Site name, widget: string }
+ - { name: description, label: Description, widget: text }
+ - { name: url, label: Address, widget: string }
+ - { name: email, label: Contact email, widget: string }
+ - name: blog
+ label: Blog
+ widget: object
+ fields:
+ - { name: title, label: Title, widget: string }
+ - { name: description, label: Description, widget: text }
+ - { name: postsPerPage, label: Posts per page, widget: number, value_type: int }
+ - { name: pitchEmail, label: Email for blog pitches, widget: string, required: false }
+ - name: journal
+ label: Journal
+ widget: object
+ fields:
+ - { name: title, label: Title, widget: string }
+ - { name: description, label: Description, widget: text }
+ - { name: url, label: Address, widget: string }
+ - name: newsletter
+ label: Newsletter
+ widget: object
+ fields:
+ - { name: title, label: Title, widget: string }
+ - { name: text, label: Text, widget: text }
+ - { name: action, label: Mailchimp form address, widget: string }
+ - name: analytics
+ label: Analytics
+ widget: object
+ required: false
+ fields:
+ - { name: script, label: Script address, widget: string }
+ - { name: websiteId, label: Website ID, widget: string }
+ - name: adTracking
+ label: Google Ads conversion tracking
+ widget: object
+ required: false
+ fields:
+ - { name: id, label: Google Ads ID, widget: string }
+ - { name: pages, label: Pages (slugs) that track, widget: list }
+ - name: navigation
+ label: Menus
+ file: settings/navigation.yaml
+ fields:
+ - name: header
+ label: Header menu
+ widget: list
+ summary: "{{title}}"
+ fields:
+ - { name: title, label: Title, widget: string }
+ - { name: url, label: Link (leave empty for a dropdown), widget: string, required: false }
+ - { name: description, label: Description, widget: string, required: false }
+ - { name: color, label: Tab colour, widget: select, options: [{ label: Orange, value: accent }, { label: Light orange, value: accent-soft }, { label: Journal blue, value: journal }], required: false }
+ - name: children
+ label: Dropdown links
+ widget: list
+ required: false
+ summary: "{{title}}"
+ fields:
+ - { name: title, label: Title, widget: string }
+ - { name: url, label: Link, widget: string }
+ - { name: description, label: Description, widget: string, required: false }
+ - name: journal
+ label: Journal menu
+ widget: list
+ summary: "{{title}}"
+ fields:
+ - { name: title, label: Title, widget: string }
+ - { name: url, label: Link (leave empty for a dropdown), widget: string, required: false }
+ - { name: button, label: Show as a button, widget: boolean, required: false }
+ - name: children
+ label: Dropdown links
+ widget: list
+ required: false
+ summary: "{{title}}"
+ fields:
+ - { name: title, label: Title, widget: string }
+ - { name: url, label: Link, widget: string }
+ - name: footer
+ label: Footer columns
+ widget: list
+ summary: "{{title}}"
+ fields:
+ - { name: title, label: Column title, widget: string }
+ - name: children
+ label: Links
+ widget: list
+ summary: "{{title}}"
+ fields:
+ - { name: title, label: Title, widget: string }
+ - { name: url, label: Link, widget: string }
+ - name: socials
+ label: Social links
+ widget: list
+ summary: "{{icon}}"
+ fields:
+ - { name: icon, label: Icon, widget: select, options: [bluesky, github, mastodon, linkedin, instagram, facebook, email, rss, substack, twitter] }
+ - { name: url, label: Link, widget: string }
diff --git a/apps/site/src/admin/preview.css b/apps/site/src/admin/preview.css
new file mode 100644
index 00000000..9f917f96
--- /dev/null
+++ b/apps/site/src/admin/preview.css
@@ -0,0 +1,5 @@
+/*
+ * The site's styles inside /admin's preview pane, loaded by preview.ts. Only
+ * the pane gets them: Sveltia shows each preview in a frame of its own.
+ */
+@import "../styles/global.css";
diff --git a/apps/site/src/admin/preview.ts b/apps/site/src/admin/preview.ts
new file mode 100644
index 00000000..1dc44453
--- /dev/null
+++ b/apps/site/src/admin/preview.ts
@@ -0,0 +1,111 @@
+import { marked } from "marked";
+import { formatDate, listNames, readingMinutes } from "~/lib/format";
+import { prepareBody } from "~/lib/html";
+import siteStyle from "./preview.css?inline";
+
+/*
+ * The live preview of a blog post in /admin, drawn like layouts/Post.astro
+ * with the site's own styles (preview.css). It follows the post page by hand,
+ * so a change to the page's layout belongs here too.
+ *
+ * Sveltia renders previews with React, which it provides as CMS.React, and
+ * passes the entry being edited as an Immutable.js map (Decap's API). The
+ * preview brings its own styles rather than CMS.registerPreviewStyle(), which
+ * would also restyle the other collections' plain previews.
+ */
+
+interface Entry {
+ get(key: string): any;
+ getIn(path: string[]): any;
+}
+interface PreviewProps {
+ entry: Entry;
+ getAsset(path: string): { toString(): string } | undefined;
+ getCollection(name: string): Promise;
+}
+
+const CMS = (window as any).CMS;
+const React = CMS.React;
+const h = React.createElement;
+
+// The site's fonts, from Astro's in admin/index.astro
+const setup = document.getElementById("preview-setup") as HTMLTemplateElement;
+const fonts = [...setup.content.querySelectorAll("style")].map((style) => style.textContent ?? "").join("\n");
+
+/** Titles of people and categories by slug, loaded once per preview */
+function useTitles(getCollection: PreviewProps["getCollection"]) {
+ const [titles, setTitles] = React.useState({ people: new Map(), categories: new Map() });
+ React.useEffect(() => {
+ Promise.all([getCollection("people"), getCollection("categories")]).then(([people, categories]) =>
+ setTitles({
+ people: new Map(people.map((p) => [p.get("slug"), [p.getIn(["data", "firstName"]), p.getIn(["data", "lastName"])].filter(Boolean).join(" ")])),
+ categories: new Map(categories.map((c) => [c.get("slug"), c.getIn(["data", "title"])])),
+ }),
+ );
+ // Once: Sveltia passes a new getCollection with every keystroke
+ // oxlint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+ return titles;
+}
+
+/** An image the editor picked: a new upload, a file in site-content or a link (Unsplash) */
+const assetUrl = (getAsset: PreviewProps["getAsset"], path: string) => getAsset(path)?.toString() || path;
+
+function BlogPreview({ entry, getAsset, getCollection }: PreviewProps) {
+ const titles = useTitles(getCollection);
+ const data = (key: string) => entry.getIn(["data", key]);
+ const list = (key: string): string[] => data(key)?.toJS?.() ?? [];
+
+ const markdown = marked.parse(data("body") ?? "", { async: false });
+ const { body } = prepareBody(markdown, { dropCap: true });
+ // Images in the text are shown from wherever Sveltia has them, also before they're saved
+ const html = body.replace(/(]*?\ssrc=")([^"]+)"/g, (_, start: string, src: string) => `${start}${assetUrl(getAsset, src)}"`);
+
+ const date = new Date(data("date") ?? "");
+ const category = data("category");
+ const authors = list("authors").map((slug) => titles.people.get(slug) ?? slug);
+ const tags = list("tags");
+ const image = data("image");
+
+ return h(
+ "div",
+ null,
+ h("style", null, siteStyle),
+ h("style", null, fonts),
+ image &&
+ h(
+ "div",
+ { className: "max-h-[60vh] overflow-hidden border-b-frame border-ink" },
+ h("img", { src: assetUrl(getAsset, image), alt: data("imageAlt") ?? "", className: "size-full max-h-[60vh] object-cover" }),
+ ),
+ h(
+ "article",
+ { className: "page py-10" },
+ h(
+ "header",
+ { className: "flex flex-col gap-4" },
+ h(
+ "div",
+ { className: "flex flex-wrap items-center gap-3 text-sm font-semibold" },
+ category && h("span", { className: "label" }, titles.categories.get(category) ?? category),
+ !Number.isNaN(date.getTime()) && h("time", null, formatDate(date.toISOString())),
+ h("span", { "aria-hidden": true }, "·"),
+ h("span", null, `${readingMinutes(markdown)} min read`),
+ ),
+ h("h1", { className: "font-heading text-4xl leading-[1.05] font-black tracking-tighter md:text-6xl" }, data("title") || "Untitled"),
+ authors.length > 0 && h("p", { className: "text-lg" }, "By ", h("span", { className: "font-bold" }, listNames(authors))),
+ data("doi") &&
+ h("p", { className: "text-sm" }, "DOI: ", h("span", { className: "font-mono font-semibold underline" }, `https://doi.org/${data("doi")}`)),
+ tags.length > 0 &&
+ h(
+ "ul",
+ { className: "flex flex-wrap gap-2" },
+ tags.map((tag) => h("li", { key: tag }, h("span", { className: "tag" }, `#${tag}`))),
+ ),
+ ),
+ h("div", { className: "prose-site mt-10", dangerouslySetInnerHTML: { __html: html } }),
+ ),
+ );
+}
+
+CMS.registerPreviewTemplate("blog", BlogPreview);
diff --git a/apps/site/src/assets/fonts/OpenSans-Italic.ttf b/apps/site/src/assets/fonts/OpenSans-Italic.ttf
new file mode 100644
index 00000000..b0884744
Binary files /dev/null and b/apps/site/src/assets/fonts/OpenSans-Italic.ttf differ
diff --git a/apps/site/src/assets/fonts/OpenSans-Regular.ttf b/apps/site/src/assets/fonts/OpenSans-Regular.ttf
new file mode 100644
index 00000000..1dc226dd
Binary files /dev/null and b/apps/site/src/assets/fonts/OpenSans-Regular.ttf differ
diff --git a/apps/site/src/assets/fonts/Overpass-Black.ttf b/apps/site/src/assets/fonts/Overpass-Black.ttf
new file mode 100644
index 00000000..bd4eb7d0
Binary files /dev/null and b/apps/site/src/assets/fonts/Overpass-Black.ttf differ
diff --git a/apps/site/src/assets/fonts/Overpass-Bold.ttf b/apps/site/src/assets/fonts/Overpass-Bold.ttf
new file mode 100644
index 00000000..a3b2b46e
Binary files /dev/null and b/apps/site/src/assets/fonts/Overpass-Bold.ttf differ
diff --git a/apps/site/src/assets/fonts/Overpass-SemiBold.ttf b/apps/site/src/assets/fonts/Overpass-SemiBold.ttf
new file mode 100644
index 00000000..71db7c5f
Binary files /dev/null and b/apps/site/src/assets/fonts/Overpass-SemiBold.ttf differ
diff --git a/apps/site/src/components/AdConsent.astro b/apps/site/src/components/AdConsent.astro
new file mode 100644
index 00000000..09999ca7
--- /dev/null
+++ b/apps/site/src/components/AdConsent.astro
@@ -0,0 +1,58 @@
+---
+import { site } from "~/lib/settings";
+
+// Asks for consent, then loads Google Ads conversion tracking. Used only on
+// the pages listed under adTracking in settings/site.yaml in site-content.
+const id = site.adTracking?.id;
+---
+
+{
+ id && (
+
+
Cookie notice
+
+ We use cookies to track conversions from our advertising campaigns. This helps us understand which ads are effective. This only applies to pages
+ related to donations, such as this one.
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/site/src/components/ArticleCard.astro b/apps/site/src/components/ArticleCard.astro
new file mode 100644
index 00000000..1525c213
--- /dev/null
+++ b/apps/site/src/components/ArticleCard.astro
@@ -0,0 +1,48 @@
+---
+import { Icon } from "astro-icon/components";
+import { formatDate, listNames } from "~/lib/format";
+import type { Article } from "~/lib/types";
+import Picture from "./Picture.astro";
+
+interface Props {
+ article: Article;
+ /** Leave out the small cover image */
+ compact?: boolean;
+}
+
+/*
+ * A journal article, made to look like a paper rather than a blog post: a
+ * blue running head with its type and date, then title, authors and
+ * abstract. The cover is only a small thumbnail.
+ */
+const { article, compact = false } = Astro.props;
+const external = /^https?:/.test(article.url);
+---
+
+
+
+
+ {article.doi ? doi:{article.doi} : }
+
+ Read
+
+
+
+
diff --git a/apps/site/src/components/Downloads.astro b/apps/site/src/components/Downloads.astro
new file mode 100644
index 00000000..69dc17a7
--- /dev/null
+++ b/apps/site/src/components/Downloads.astro
@@ -0,0 +1,46 @@
+---
+import { Icon } from "astro-icon/components";
+import type { Download } from "~/lib/journal-article";
+
+interface Props {
+ downloads: Download[];
+ /** Which edge the formats menu lines up with */
+ align?: "left" | "right";
+ class?: string;
+}
+
+/* The PDF as the main button, every other format in a menu next to it */
+const { downloads, align = "left", class: className } = Astro.props;
+const pdf = downloads.find((d) => d.format === "pdf");
+const others = downloads.filter((d) => d !== pdf);
+---
+
+{
+ downloads.length > 0 && (
+
+
diff --git a/apps/site/src/components/IssueCard.astro b/apps/site/src/components/IssueCard.astro
new file mode 100644
index 00000000..d0f0f672
--- /dev/null
+++ b/apps/site/src/components/IssueCard.astro
@@ -0,0 +1,43 @@
+---
+import type { Issue } from "~/lib/sources/journal";
+import Picture from "./Picture.astro";
+
+interface Props {
+ issue: Issue;
+ /** Where the issue's page is */
+ href: string;
+}
+
+/*
+ * An issue, drawn as its cover on a small stack of printed copies, with the
+ * title underneath like on a shelf. Rolling issues get a plain blue cover.
+ */
+const { issue, href } = Astro.props;
+const count = `${issue.articles.length} ${issue.articles.length === 1 ? "article" : "articles"}`;
+---
+
+
+
+
diff --git a/apps/site/src/components/sections/Hero.astro b/apps/site/src/components/sections/Hero.astro
new file mode 100644
index 00000000..2a6df809
--- /dev/null
+++ b/apps/site/src/components/sections/Hero.astro
@@ -0,0 +1,39 @@
+---
+import type { Section } from "~/content.config";
+import Markdown from "../Markdown.astro";
+
+type Props = Extract;
+const { title, subtitle, text, buttons } = Astro.props;
+
+// Each line of the title gets its own box, like the old blog header
+const lines = title.split("\n").filter(Boolean);
+---
+
+
+
$1');
+ }
+
+ return { body, headings };
+}
diff --git a/apps/site/src/lib/journal-article.ts b/apps/site/src/lib/journal-article.ts
new file mode 100644
index 00000000..ea1f3b85
--- /dev/null
+++ b/apps/site/src/lib/journal-article.ts
@@ -0,0 +1,230 @@
+import { getImage, inferRemoteSize } from "astro:assets";
+import { stripHtml } from "./format";
+import type { Heading } from "./html";
+import { decodeEntities, type Journal, journal, replaceElements, type Release, type Rich } from "./sources/journal";
+import type { UnderlayFile } from "./sources/underlay";
+import type { Article } from "./types";
+
+/*
+ * Everything an article page needs beyond the Article itself: the HTML export
+ * of its latest release, made to fit the site, its downloads and its links to
+ * reviews, commentaries and other pubs.
+ */
+
+export interface Download {
+ format: string;
+ label: string;
+ url: string;
+}
+
+export interface Connection {
+ /** How the other pub relates to this one, e.g. "Peer review" or "Commentary on" */
+ label: string;
+ title: string;
+ url: string;
+ article?: Article;
+}
+
+export interface ArticleDetails {
+ body: string;
+ headings: Heading[];
+ /** The export has no text, so the PDF is the article */
+ empty: boolean;
+ keywords: string[];
+ downloads: Download[];
+ connections: Connection[];
+ /** Date of the latest release, when it came well after the first */
+ updated?: string;
+}
+
+const DOWNLOADS: Record = { pdf: "PDF", epub: "EPUB", docx: "Word", jats: "JATS XML", tex: "LaTeX" };
+
+/** Labels for PubPub's pub connections, seen from the parent and from the child */
+const RELATIONS: Record = {
+ review: ["Peer review", "Review of"],
+ comment: ["Comment", "Comment on"],
+ commentary: ["Commentary", "Commentary on"],
+ reply: ["Reply", "Reply to"],
+ supplement: ["Supplement", "Supplement to"],
+ preprint: ["Preprint", "Preprint of"],
+ version: ["Version", "Version of"],
+ rejoinder: ["Rejoinder", "Rejoinder to"],
+};
+
+const DAY = 24 * 60 * 60 * 1000;
+
+/** Widest image the article column ever needs, in pixels */
+const IMAGE_WIDTH = 1600;
+
+export async function getArticleDetails(article: Article, source: Journal = journal()): Promise {
+ const [release, html] = await Promise.all([source.getRelease(article), source.getArticleHtml(article)]);
+ const assets = new Map((release?.assets ?? []).map((file) => [hash(file.$file), file]));
+ const { body, headings, text } = await prepareArticle(html ?? "", assets, source.journalFileUrl);
+
+ return {
+ body,
+ headings,
+ empty: text.length < 200,
+ keywords: keywords(html ?? ""),
+ downloads: downloads(release, source.journalFileUrl),
+ connections: await connections(article, source),
+ updated: release && Date.parse(release.createdAt) - Date.parse(article.date) > 2 * DAY ? release.createdAt : undefined,
+ };
+}
+
+/** A PubPub page or issue intro, fitted into the site like an article's text */
+export async function getRichBody(rich: Rich, source: Journal = journal()) {
+ const assets = new Map(rich.assets.map((file) => [hash(file.$file), file]));
+ const { body, headings } = await prepareArticle(rich.html, assets, source.journalFileUrl);
+ return { body, headings };
+}
+
+/** The release's exports, with the typeset PDF in place of the generated one when there is one */
+function downloads(release: Release | undefined, journalFileUrl: FileUrl): Download[] {
+ const files = (release?.exports ?? [])
+ .filter((file) => DOWNLOADS[file.format])
+ .sort((a, b) => Object.keys(DOWNLOADS).indexOf(a.format) - Object.keys(DOWNLOADS).indexOf(b.format))
+ .map((file) => ({ format: file.format, label: DOWNLOADS[file.format], url: journalFileUrl(file) }));
+ if (!release?.formatted) return files;
+ return [{ format: "pdf", label: "PDF", url: journalFileUrl(release.formatted) }, ...files.filter((file) => file.format !== "pdf")];
+}
+
+/** Warm the download cache for every article at once, instead of one page at a time */
+export const prefetchArticles = async (articles: Article[], source: Journal = journal()) => {
+ await Promise.all(articles.map((article) => source.getArticleHtml(article).catch(() => undefined)));
+};
+
+const hash = (ref: string) => ref.replace(/^sha256:/, "");
+
+/** Where a journal's files can be downloaded */
+type FileUrl = (file: UnderlayFile) => string;
+
+async function connections(article: Article, source: Journal): Promise {
+ const records = await source.getJournalRecords();
+ const articles = new Map((await source.getArticles()).map((a) => [a.id, a]));
+ const result: Connection[] = [];
+
+ for (const { data: edge } of records.get("PubEdge") ?? []) {
+ const [parent, child] = edge.pubIsParent ? [edge.sourcePubId, edge.targetPubId] : [edge.targetPubId, edge.sourcePubId];
+ if (parent !== article.id && child !== article.id) continue;
+
+ const labels = RELATIONS[edge.relationType] ?? [edge.relationType, edge.relationType];
+ const label = parent === article.id ? labels[0] : labels[1];
+ const other = articles.get(parent === article.id ? child : parent);
+ if (other) result.push({ label, title: other.title, url: other.url, article: other });
+ else if (edge.externalUrl) result.push({ label, title: (edge.externalTitle ?? edge.externalUrl).trim(), url: edge.externalUrl });
+ }
+ return result;
+}
+
+function keywords(html: string) {
+ const line = html.match(/
]*>\s*(?:<[^>]+>\s*)*Key ?words?\s*:?([\s\S]*?)<\/p>/i)?.[1];
+ if (!line) return [];
+ return decodeEntities(stripHtml(line))
+ .replace(/^:\s*/, "")
+ .split(/[,;]/)
+ .map((k) => k.trim().replace(/\.$/, ""))
+ .filter(Boolean);
+}
+
+/**
+ * Fit PubPub's HTML export into the site: drop the parts the page renders
+ * itself, move headings one level down under the page's h1, serve images
+ * from this site and point attachments at their Underlay file.
+ */
+async function prepareArticle(html: string, assets: Map, journalFileUrl: FileUrl) {
+ let body = html
+ .replace(/^\s*|<\/article>\s*$/g, "")
+ .replace(/