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 @@ + + + + +Created by potrace 1.14, written by Peter Selinger 2001-2017 + + + + + 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 && ( + + + ) +} 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.kind ?? "Article"} + +
+
+
+

+ {article.title} +

+ {article.authors.length > 0 &&

{listNames(article.authors.map((a) => a.name))}

} + {article.description &&

{article.description}

} +
+ { + !compact && article.image && ( +
+ + {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 && ( +
+ {pdf && ( + + + )} + {others.length > 0 && ( +
+ + {pdf ? "More formats" : "Download"} + + +
+ )} +
+ ) +} diff --git a/apps/site/src/components/Footer.astro b/apps/site/src/components/Footer.astro new file mode 100644 index 00000000..77187d5e --- /dev/null +++ b/apps/site/src/components/Footer.astro @@ -0,0 +1,52 @@ +--- +import { navigation, site } from "~/lib/settings"; +import Logo from "./Logo.astro"; +import SocialIcon from "./SocialIcon.astro"; +--- + +
+
+
+ + + Center of
Trial & Error
+
+

{site.description}

+
    + { + navigation.socials.map((social) => ( +
  • + + + +
  • + )) + } +
+
+
+ { + navigation.footer.map((column) => ( +
+

{column.title}

+ +
+ )) + } +
+
+
+

+ © {new Date().getFullYear()} Stichting Center of Trial and Error. Content is CC BY 4.0 unless noted otherwise. + Source code. +

+
+
diff --git a/apps/site/src/components/Header.astro b/apps/site/src/components/Header.astro new file mode 100644 index 00000000..68667145 --- /dev/null +++ b/apps/site/src/components/Header.astro @@ -0,0 +1,110 @@ +--- +import { Icon } from "astro-icon/components"; +import { navigation } from "~/lib/settings"; +import Logo from "./Logo.astro"; + +const path = Astro.url.pathname; +const isCurrent = (url?: string) => !!url && url.startsWith("/") && url !== "/" && path.startsWith(url.split("#")[0]); +--- + +
+
+ + + + + + Center of
Trial & Error +
+
+ + +
+
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"}`; +--- + +
+
+ { + issue.image ? ( + + ) : ( +

+ {issue.rolling ? "Rolling" : issue.title} + {issue.rolling && Articles as they're ready} +

+ ) + } + {issue.rolling && Ongoing} +
+
+

+ {issue.rolling ? "Ongoing" : issue.date.slice(0, 4)} · {count} +

+

+ + {issue.title} + +

+
+
diff --git a/apps/site/src/components/JournalNav.astro b/apps/site/src/components/JournalNav.astro new file mode 100644 index 00000000..bc486c27 --- /dev/null +++ b/apps/site/src/components/JournalNav.astro @@ -0,0 +1,89 @@ +--- +import { Icon } from "astro-icon/components"; +import { type Journal, journal as defaultJournal } from "~/lib/sources/journal"; + +/* + * The journal's own menu: a blue bar under the header on every /journal + * page. Its links are set in settings/navigation.yaml in site-content (or /admin → Menus). + * Other journals bring their own links, see src/lib/sources/journal.ts. + */ + +interface Props { + journal?: Journal; +} + +const source = Astro.props.journal ?? defaultJournal(); +const { config } = source; +// Links to the journal's PubPub pages go to this site's copy once the export has it +const localize = async (url?: string) => (url ? source.localUrl(url) : url); +const menu = await Promise.all( + config.menu.map(async (item) => ({ + ...item, + url: await localize(item.url), + children: item.children && (await Promise.all(item.children.map(async (child) => ({ ...child, url: await localize(child.url) })))), + })), +); +const base = config.path; +const path = Astro.url.pathname.replace(/\/$/, "") || "/"; +// "Articles" (/journal) is current on the article list and its filters; other links on their own pages +const isCurrent = (url?: string) => { + if (!url?.startsWith("/")) return false; + if (url === base) return path === base || path.startsWith(`${base}/kind/`); + return path === url || (url === `${base}/issues` && path.startsWith(`${base}/issue/`)); +}; +const external = (url?: string) => !!url && /^https?:/.test(url); +--- + + + + diff --git a/apps/site/src/components/Logo.astro b/apps/site/src/components/Logo.astro new file mode 100644 index 00000000..d8f045f5 --- /dev/null +++ b/apps/site/src/components/Logo.astro @@ -0,0 +1,12 @@ +--- +interface Props { + class?: string; +} +const { class: className } = Astro.props; +--- + + diff --git a/apps/site/src/components/Markdown.astro b/apps/site/src/components/Markdown.astro new file mode 100644 index 00000000..f1d2827f --- /dev/null +++ b/apps/site/src/components/Markdown.astro @@ -0,0 +1,13 @@ +--- +import { marked } from "marked"; + +interface Props { + text: string; + class?: string; +} + +const { text, class: className } = Astro.props; +const html = await marked.parse(text); +--- + +
diff --git a/apps/site/src/components/Pagination.astro b/apps/site/src/components/Pagination.astro new file mode 100644 index 00000000..b0d9e342 --- /dev/null +++ b/apps/site/src/components/Pagination.astro @@ -0,0 +1,36 @@ +--- +interface Props { + current: number; + total: number; + /** URL of page n */ + href: (page: number) => string; +} + +const { current, total, href } = Astro.props; +--- + +{ + total > 1 && ( + + ) +} diff --git a/apps/site/src/components/PersonCard.astro b/apps/site/src/components/PersonCard.astro new file mode 100644 index 00000000..149fd9d1 --- /dev/null +++ b/apps/site/src/components/PersonCard.astro @@ -0,0 +1,69 @@ +--- +import { personHref } from "~/lib/people"; +import type { Person } from "~/lib/types"; +import Picture from "./Picture.astro"; +import SocialIcon from "./SocialIcon.astro"; + +interface Props { + person: Person; + /** Show the full bio rather than a short one */ + full?: boolean; + link?: boolean; +} + +const { person, full = false, link = true } = Astro.props; +const href = link ? await personHref(person, Astro.isPrerendered) : undefined; +const initials = person.name + .split(" ") + .map((part) => part[0]) + .slice(0, 2) + .join(""); +--- + +
+
+ { + person.image ? ( + + ) : ( + {initials} + ) + } +
+
+

+ {href ? {person.name} : person.name} +

+ {person.position &&

{person.position}

} + {person.summary &&
} +
    + { + person.orcid && ( +
  • + + + +
  • + ) + } + { + person.links.map((l) => ( +
  • + + + +
  • + )) + } + { + person.email && ( +
  • + + + +
  • + ) + } +
+
+
diff --git a/apps/site/src/components/Picture.astro b/apps/site/src/components/Picture.astro new file mode 100644 index 00000000..9671bbde --- /dev/null +++ b/apps/site/src/components/Picture.astro @@ -0,0 +1,37 @@ +--- +import { Image, inferRemoteSize } from "astro:assets"; +import { Icon } from "astro-icon/components"; +import type { Picture } from "~/lib/types"; + +interface Props { + image?: Picture; + /** Width to render at; the file is resized to this at build time */ + width: number; + class?: string; + eager?: boolean; + alt?: string; +} + +const { image, width, class: className, eager = false, alt } = Astro.props; +// Measure the image when its source didn't say how big it is, so it keeps its proportions +const size = image && !(image.width && image.height) ? await inferRemoteSize(image.url).catch(() => undefined) : image; +const height = size?.width && size?.height ? Math.round((size.height / size.width) * width) : undefined; +const attributes = { alt: alt ?? image?.alt ?? "", width, class: className, loading: eager ? ("eager" as const) : ("lazy" as const) }; +--- + +{ + image ? ( + // A local image (site-content's uploads) is resized from its file, a remote one from its address + image.src ? ( + + ) : height ? ( + + ) : ( + + ) + ) : ( +
+
+ ) +} diff --git a/apps/site/src/components/PostCard.astro b/apps/site/src/components/PostCard.astro new file mode 100644 index 00000000..3a6c8865 --- /dev/null +++ b/apps/site/src/components/PostCard.astro @@ -0,0 +1,34 @@ +--- +import { Icon } from "astro-icon/components"; +import { formatDate, listNames } from "~/lib/format"; +import type { Post } from "~/lib/types"; +import Picture from "./Picture.astro"; + +interface Props { + post: Post; + /** Big horizontal card for the top post */ + wide?: boolean; + eager?: boolean; +} + +const { post, wide = false, eager = false } = Astro.props; +const href = `/blog/${post.slug}`; +--- + +
+
+ + {post.category && {post.category.title}} +
+
+ +

+ {post.title} +

+ {post.authors.length > 0 &&

By {listNames(post.authors.map((a) => a.name))}

} + {post.excerpt &&

{post.excerpt}

} + + Read +
+
diff --git a/apps/site/src/components/PostList.astro b/apps/site/src/components/PostList.astro new file mode 100644 index 00000000..512f8e7c --- /dev/null +++ b/apps/site/src/components/PostList.astro @@ -0,0 +1,18 @@ +--- +import type { Post } from "~/lib/types"; +import PostCard from "./PostCard.astro"; + +interface Props { + posts: Post[]; + /** Show the first post as a wide card */ + featureFirst?: boolean; +} + +const { posts, featureFirst = false } = Astro.props; +const [first, ...rest] = posts; +--- + +{featureFirst && first && } +
+ {(featureFirst ? rest : posts).map((post) => )} +
diff --git a/apps/site/src/components/Sections.astro b/apps/site/src/components/Sections.astro new file mode 100644 index 00000000..67fc96a2 --- /dev/null +++ b/apps/site/src/components/Sections.astro @@ -0,0 +1,37 @@ +--- +import type { Section } from "~/content.config"; +import Buttons from "./sections/Buttons.astro"; +import Features from "./sections/Features.astro"; +import Hero from "./sections/Hero.astro"; +import LatestArticles from "./sections/LatestArticles.astro"; +import LatestPosts from "./sections/LatestPosts.astro"; +import Newsletter from "./sections/Newsletter.astro"; +import Team from "./sections/Team.astro"; +import Text from "./sections/Text.astro"; + +interface Props { + sections: Section[]; +} + +// To add a section type: make a component in ./sections, add it here, +// and describe its fields in src/content.config.ts and src/admin/config.yml +const components = { + hero: Hero, + text: Text, + features: Features, + "latest-posts": LatestPosts, + "latest-articles": LatestArticles, + team: Team, + newsletter: Newsletter, + buttons: Buttons, +} satisfies Record; + +const { sections } = Astro.props; +--- + +{ + sections.map((section) => { + const Component = components[section.type] as any; + return ; + }) +} diff --git a/apps/site/src/components/SocialIcon.astro b/apps/site/src/components/SocialIcon.astro new file mode 100644 index 00000000..f6e223bc --- /dev/null +++ b/apps/site/src/components/SocialIcon.astro @@ -0,0 +1,28 @@ +--- +import { Icon } from "astro-icon/components"; + +interface Props { + icon: string; + class?: string; +} + +// Maps the names used in navigation.yaml and on people to icon sets +const names: Record = { + bluesky: "simple-icons:bluesky", + github: "simple-icons:github", + mastodon: "simple-icons:mastodon", + linkedin: "simple-icons:linkedin", + instagram: "simple-icons:instagram", + facebook: "simple-icons:facebook", + twitter: "simple-icons:x", + orcid: "simple-icons:orcid", + substack: "simple-icons:substack", + email: "lucide:mail", + rss: "lucide:rss", + website: "lucide:globe", +}; + +const { icon, class: className = "size-5" } = Astro.props; +--- + +