Merge pull request #4 from andwati/astro-migration
Astro migration: nav/layout polish, SEO, a11y, LaTeX, Lighthouse CI
@@ -1,7 +1,12 @@
|
|||||||
public
|
/public
|
||||||
node_modules
|
**/node_modules
|
||||||
.git
|
.git
|
||||||
manimations/*.*
|
manimations/*.*
|
||||||
/zola
|
/zola
|
||||||
/zola-v*.tar.gz
|
/zola-v*.tar.gz
|
||||||
/artifacts
|
/artifacts
|
||||||
|
**/dist
|
||||||
|
**/.astro
|
||||||
|
**/.strapi
|
||||||
|
**/.tmp
|
||||||
|
**/build
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
name: Lighthouse
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, astro-migration]
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lighthouse:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- run: pnpm install --frozen-lockfile --filter site
|
||||||
|
|
||||||
|
- name: Run Lighthouse CI
|
||||||
|
run: pnpm --filter site run lighthouse
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
public
|
/public
|
||||||
node_modules
|
node_modules
|
||||||
manimations/*.*
|
manimations/*.*
|
||||||
/zola
|
/zola
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"snyk.advanced.autoSelectOrganization": true
|
|
||||||
}
|
|
||||||
@@ -49,10 +49,16 @@ Deployment is self-hosted via Dokploy: `Dockerfile` builds the site with Zola an
|
|||||||
|
|
||||||
All site-wide behavior/config (analytics, author/profile info, social links, nav menu, Utterances comment settings, search, related posts, the 404 terminal-themed easter egg) is centralized in `zola.toml` under `[extra]`. Check there before hardcoding values in templates.
|
All site-wide behavior/config (analytics, author/profile info, social links, nav menu, Utterances comment settings, search, related posts, the 404 terminal-themed easter egg) is centralized in `zola.toml` under `[extra]`. Check there before hardcoding values in templates.
|
||||||
|
|
||||||
|
## Astro/Strapi migration (in progress, `astro-migration` branch)
|
||||||
|
|
||||||
|
The site is being rebuilt as a pnpm workspace with two apps: `apps/site` (Astro) and `apps/cms` (Strapi, SQLite-backed). Package management is **pnpm only** (no npm/yarn) and linting/formatting is **Biome** (`pnpm lint`, `pnpm lint:fix`) — not ESLint/Prettier. Markdown files under `content/` remain the canonical source of truth; Strapi is an editing UI over them, not the source of record. The existing Zola site (`zola.toml`, `templates/`, `sass/`, `content/posts/` etc.) stays in place until migration is complete — remove Zola-specific files progressively as their Astro/Strapi equivalents land, not all at once.
|
||||||
|
|
||||||
## Agent skills
|
## Agent skills
|
||||||
|
|
||||||
`.agents/skills/` contains pulled-in skills (`animation-vocabulary`, `emil-design-eng`, `review-animations`) tracked via `skills-lock.json`, useful for UI/animation review and design-engineering guidance when touching templates or Sass.
|
`.agents/skills/` contains pulled-in skills (`animation-vocabulary`, `emil-design-eng`, `review-animations`) tracked via `skills-lock.json`, useful for UI/animation review and design-engineering guidance when touching templates or Sass.
|
||||||
|
|
||||||
## Git commits
|
## Git commits
|
||||||
|
|
||||||
Never add `Co-Authored-By: Claude` (or any other Claude/Anthropic self-attribution) to commit messages. Write commit messages as if authored solely by the user.
|
Never add `Co-Authored-By: Claude` (or any other Claude/Anthropic self-attribution) to commit messages or pull request descriptions/titles. Write commit messages and PRs as if authored solely by the user.
|
||||||
|
|
||||||
|
Commit regularly: after each successful, working step (not just at the end of a task), so history stays granular and revertible.
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# Dokploy deployment
|
||||||
|
|
||||||
|
The production deployment is one Dokploy Compose application with two
|
||||||
|
services:
|
||||||
|
|
||||||
|
- `site` builds the Astro app and serves its static output with
|
||||||
|
`static-web-server`.
|
||||||
|
- `cms` runs Strapi with persistent SQLite/admin data and a persistent Git
|
||||||
|
checkout.
|
||||||
|
|
||||||
|
Markdown remains the source of truth. A Strapi edit writes into the checkout,
|
||||||
|
commits and pushes the changed Markdown (plus a generated bookshelf cover when
|
||||||
|
applicable), then calls Dokploy's deploy webhook. The redeploy builds Astro
|
||||||
|
from that new commit.
|
||||||
|
|
||||||
|
## GitHub token
|
||||||
|
|
||||||
|
Create a fine-grained personal access token restricted to the
|
||||||
|
`andwati/andwati.com` repository with:
|
||||||
|
|
||||||
|
- Repository contents: read and write
|
||||||
|
- Metadata: read (automatically included)
|
||||||
|
|
||||||
|
The configured publishing branch must accept direct pushes from the token.
|
||||||
|
Store the token only in Dokploy as `GITHUB_TOKEN`.
|
||||||
|
|
||||||
|
## Dokploy application
|
||||||
|
|
||||||
|
1. Create a Compose application from this repository and select
|
||||||
|
`docker-compose.prod.yml`.
|
||||||
|
2. Configure its source branch to match `CONTENT_GIT_BRANCH` (normally
|
||||||
|
`main`).
|
||||||
|
3. Route the public site domain to the `site` service on port `80`.
|
||||||
|
4. Route the CMS domain (for example `cms.andwati.com`) to the `cms` service
|
||||||
|
on port `1337`.
|
||||||
|
5. Copy the application's deploy webhook URL into
|
||||||
|
`DOKPLOY_DEPLOY_WEBHOOK_URL`.
|
||||||
|
6. Add the environment variables below and deploy.
|
||||||
|
|
||||||
|
Required secrets:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
CMS_PUBLIC_URL=https://cms.andwati.com
|
||||||
|
APP_KEYS=<four comma-separated random values>
|
||||||
|
API_TOKEN_SALT=<random value>
|
||||||
|
ADMIN_JWT_SECRET=<random value>
|
||||||
|
TRANSFER_TOKEN_SALT=<random value>
|
||||||
|
JWT_SECRET=<random value>
|
||||||
|
ENCRYPTION_KEY=<random value>
|
||||||
|
GITHUB_TOKEN=<fine-grained GitHub token>
|
||||||
|
DOKPLOY_DEPLOY_WEBHOOK_URL=<Dokploy Compose deploy webhook>
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional publishing settings:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
CONTENT_GIT_REPOSITORY=andwati/andwati.com
|
||||||
|
CONTENT_GIT_BRANCH=main
|
||||||
|
CONTENT_GIT_COMMIT_NAME="andwati.com CMS"
|
||||||
|
[email protected]
|
||||||
|
```
|
||||||
|
|
||||||
|
Site analytics (read at build time by `apps/site`, so these must be set on
|
||||||
|
the `site` service before it builds, not just at runtime):
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
PUBLIC_UMAMI_SCRIPT_URL=https://analytics.andwati.com/script.js
|
||||||
|
PUBLIC_UMAMI_WEBSITE_ID=9eef8fba-c932-4488-ae23-6b84553e8c29
|
||||||
|
```
|
||||||
|
|
||||||
|
`Analytics.astro` silently no-ops if either is unset — safe to deploy without
|
||||||
|
them, but analytics won't record anything until both are present at build
|
||||||
|
time. `sws.toml`'s CSP already allows `analytics.andwati.com` and
|
||||||
|
`utteranc.es` (comments); if the Umami host ever changes, update both that
|
||||||
|
CSP and these two vars together, or the tracker will be silently blocked in
|
||||||
|
production instead of erroring visibly.
|
||||||
|
|
||||||
|
Generate each random secret independently; do not reuse the local development
|
||||||
|
values from `docker-compose.yml`.
|
||||||
|
|
||||||
|
## Persistent data
|
||||||
|
|
||||||
|
The production Compose file declares three named volumes:
|
||||||
|
|
||||||
|
- `cms_database` keeps Strapi's admin users, API tokens, and SQLite cache.
|
||||||
|
- `cms_uploads` keeps files uploaded through Strapi.
|
||||||
|
- `content_checkout` keeps the Git working tree used for publishing.
|
||||||
|
|
||||||
|
Back up `cms_database` and `cms_uploads`. The content checkout is recoverable
|
||||||
|
from Git, but retaining it protects an edit if the process stops between the
|
||||||
|
filesystem write and Git push.
|
||||||
|
|
||||||
|
On startup, the CMS fast-forwards a clean checkout to the configured branch.
|
||||||
|
If it finds uncommitted files, it preserves them and starts without pulling so
|
||||||
|
an interrupted edit is not overwritten. Resolve and push those files from the
|
||||||
|
volume before forcing a reset.
|
||||||
|
|
||||||
|
## Local validation
|
||||||
|
|
||||||
|
Provide temporary values for required interpolation variables, then validate
|
||||||
|
the production manifest:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose --env-file .env.production \
|
||||||
|
-f docker-compose.prod.yml config
|
||||||
|
docker compose --env-file .env.production \
|
||||||
|
-f docker-compose.prod.yml build
|
||||||
|
```
|
||||||
|
|
||||||
|
The existing `docker-compose.yml` remains the local Strapi development setup.
|
||||||
@@ -1,21 +1,20 @@
|
|||||||
# Multi-stage build: generate llms.txt with Node, build the site with Zola,
|
# Production image for the Astro site. Build from the workspace root:
|
||||||
# then serve the static output with static-web-server.
|
# docker build -t andwati-site .
|
||||||
#
|
|
||||||
# This is the Dokploy/self-hosted deployment path (the only one - the prior
|
|
||||||
# Cloudflare Pages/Workers path has been retired).
|
|
||||||
|
|
||||||
FROM node:22-slim AS llms
|
FROM node:24-bookworm-slim AS build
|
||||||
WORKDIR /project
|
RUN corepack enable
|
||||||
COPY . .
|
WORKDIR /app
|
||||||
RUN node scripts/generate-llms.mjs
|
|
||||||
|
|
||||||
FROM ghcr.io/getzola/zola:v0.23.1 AS zola
|
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||||
WORKDIR /project
|
COPY apps/site/package.json apps/site/package.json
|
||||||
COPY . .
|
COPY apps/cms/package.json apps/cms/package.json
|
||||||
COPY --from=llms /project/static/llms.txt static/llms.txt
|
COPY apps/site apps/site
|
||||||
RUN ["/zola", "build"]
|
COPY content content
|
||||||
|
COPY scripts scripts
|
||||||
|
RUN pnpm install --frozen-lockfile --filter andwati-com --filter site
|
||||||
|
RUN pnpm --filter site build
|
||||||
|
|
||||||
FROM ghcr.io/static-web-server/static-web-server:2
|
FROM ghcr.io/static-web-server/static-web-server:2
|
||||||
COPY --from=zola /project/public /public
|
COPY --from=build /app/apps/site/dist /public
|
||||||
COPY sws.toml /sws.toml
|
COPY sws.toml /sws.toml
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
#!/usr/bin/make -f
|
|
||||||
|
|
||||||
.PHONY: all build
|
|
||||||
|
|
||||||
BUILD_DIR := public
|
|
||||||
|
|
||||||
all: clean build
|
|
||||||
|
|
||||||
clean:
|
|
||||||
rm -rfd ./$(BUILD_DIR)
|
|
||||||
|
|
||||||
build:
|
|
||||||
node scripts/generate-llms.mjs
|
|
||||||
zola build
|
|
||||||
|
|
||||||
serve:
|
|
||||||
node scripts/generate-llms.mjs
|
|
||||||
zola serve
|
|
||||||
|
|
||||||
update-zola:
|
|
||||||
@./bin/update-zola
|
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
# Astro + Strapi Rebuild — TODO
|
||||||
|
|
||||||
|
Tracking doc for the `astro-migration` branch. See the locked spec in the
|
||||||
|
approved plan for full rationale on each decision referenced below.
|
||||||
|
|
||||||
|
## Done
|
||||||
|
|
||||||
|
- [x] Scaffold pnpm workspace (`apps/site` Astro, `apps/cms` Strapi + SQLite)
|
||||||
|
- [x] Switch tooling to pnpm-only, add Biome for lint/format
|
||||||
|
- [x] Define shared `content/` schema (writings, portfolio, bookshelf, blogs)
|
||||||
|
with TOML frontmatter matching the legacy Zola format
|
||||||
|
- [x] Bookshelf schema covers both books (ISBN) and papers (DOI/arXiv id)
|
||||||
|
- [x] Custom Astro content loader parsing `+++` TOML frontmatter directly
|
||||||
|
(no YAML conversion step)
|
||||||
|
- [x] Basic list/detail pages for all four content types, verified running
|
||||||
|
end-to-end in-browser (`pnpm --filter site dev`)
|
||||||
|
- [x] CLAUDE.md updated: no agent self-attribution in commits/PRs, commit
|
||||||
|
regularly after each working step
|
||||||
|
- [x] Strapi content-type definitions matching the `content/` schema exactly
|
||||||
|
(writings, portfolio, bookshelf, blogs) — verified boots and registers
|
||||||
|
all four REST routes
|
||||||
|
- [x] `apps/cms/Dockerfile.dev` + root `docker-compose.yml` for running
|
||||||
|
Strapi locally in a container (node:24-bookworm-slim, matches local
|
||||||
|
Node version); `apps/cms/Dockerfile` production multi-stage build for
|
||||||
|
later Dokploy deployment. Verified: builds, boots, admin panel and API
|
||||||
|
reachable, shared `content/` volume mounted at the right relative path
|
||||||
|
|
||||||
|
## Content pipeline
|
||||||
|
|
||||||
|
- [x] Strapi custom provider/lifecycle hooks that read + write the same
|
||||||
|
Markdown files in `content/` (Strapi's DB stays a disposable cache) —
|
||||||
|
`apps/cms/src/utils/content-{file,sync}.ts`, wired in `src/index.ts`
|
||||||
|
- [x] Verify round-trip: edit in Strapi admin → file on disk updates, and
|
||||||
|
vice versa, without drift — verified create/update/delete via the
|
||||||
|
content-manager API, plus idempotent re-import across a restart
|
||||||
|
- [x] Bookshelf ISBN lookup hook with local cover caching —
|
||||||
|
`apps/cms/src/utils/bookshelf-covers.ts` fetches from Open Library by
|
||||||
|
ISBN and caches into `apps/site/public/covers/`; verified end-to-end
|
||||||
|
(real cover downloaded, `cover_image` synced to file, cleaned up on
|
||||||
|
delete)
|
||||||
|
- [x] Bookshelf paper lookup hook — Semantic Scholar backfills title/authors
|
||||||
|
by DOI/arXiv id when missing. No cover/thumbnail: neither Semantic
|
||||||
|
Scholar nor arXiv offers a reliable image source for papers, so
|
||||||
|
`cover_image` stays manually-set for papers (or gets one later from
|
||||||
|
the OG-image pipeline below)
|
||||||
|
- [x] OG image auto-generation extended to all three content types
|
||||||
|
(writings, portfolio, bookshelf) using the new design system —
|
||||||
|
`scripts/generate-og-images.astro-site.mjs`, wired into `apps/site`'s
|
||||||
|
build script; `Base.astro` renders `og:image`/`twitter:image` from a
|
||||||
|
per-page `ogImage` prop (writings/bookshelf detail pages wire it in;
|
||||||
|
portfolio has no detail page yet to wire it into). Uses static-weight
|
||||||
|
Fraunces/Newsreader font packages, not the variable fonts the site
|
||||||
|
ships — satori's opentype.js parser can't read the variable fvar table
|
||||||
|
- [x] `llms.txt` generation extended to cover all content types — unblocked
|
||||||
|
once `site.config.ts` existed (built above for feeds/meta anyway):
|
||||||
|
`scripts/generate-llms.astro-site.mjs` reads all four content
|
||||||
|
directories directly, wired into `apps/site`'s build script
|
||||||
|
|
||||||
|
## Design system
|
||||||
|
|
||||||
|
- [x] New typography scale + color system — dark-first (warm near-black,
|
||||||
|
cream text, coral accent) with a warm-paper light mode;
|
||||||
|
Fraunces (display/italic) + Newsreader (body) + JetBrains Mono
|
||||||
|
(nav/meta/code), fluid clamp() type scale. `src/styles/tokens.css`.
|
||||||
|
Referenced pixperk.tech and mcyoung.xyz — see the commit for specifics
|
||||||
|
- [x] Motion primitives: CSS + native Web Animations API spring utility —
|
||||||
|
`apps/site/src/lib/spring.ts` (damped-oscillator keyframes,
|
||||||
|
`prefers-reduced-motion` aware). Not wired into any component yet —
|
||||||
|
the Three.js centerpiece below is the natural first user
|
||||||
|
- [x] Base layout/components replacing the placeholder `Base.astro` — real
|
||||||
|
header/nav with active-link state, theme toggle, footer with social
|
||||||
|
links. Verified light + dark in-browser across home, a writing
|
||||||
|
detail page (TOC/headings), bookshelf, and 404
|
||||||
|
- [x] Homepage redesign around the Three.js centerpiece as hero — the
|
||||||
|
constellation sits above a 3-column dashboard (latest writings,
|
||||||
|
portfolio, bookshelf), verified rendering and hover/click working
|
||||||
|
in-browser
|
||||||
|
- [x] New 404 page concept (terminal easter egg retired) — "Page Fault",
|
||||||
|
now fully styled with the rest of the design system
|
||||||
|
|
||||||
|
## Three.js centerpiece
|
||||||
|
|
||||||
|
- [x] Constellation data model: nodes are real content entries (writings,
|
||||||
|
portfolio, bookshelf), edges are shared-tag connections — the same
|
||||||
|
relationship "related posts" already uses, drawn instead of listed.
|
||||||
|
No decorative/fake data. `src/lib/constellation-data.ts`
|
||||||
|
- [x] Built as an isolated component (`Constellation.astro` + a plain
|
||||||
|
client `<script>` module, no framework), fibonacci-sphere layout,
|
||||||
|
hover raycasting with a title tooltip, click-through to the entry
|
||||||
|
- [x] Fallback for `prefers-reduced-motion` (skips rotation/entrance
|
||||||
|
animation) and no-WebGL (`supportsWebGL()` check hides the canvas
|
||||||
|
entirely, falls back to a `<noscript>`-style plain count)
|
||||||
|
- [x] Perf budget check — scene is trivially light (24 nodes, 12x12-segment
|
||||||
|
spheres, a handful of line-segment edges: well under 10k triangles),
|
||||||
|
renders at 100+fps uncapped with no dropped frames. The real cost is
|
||||||
|
the JS payload: three.js core pulls in ~130KB gzip / ~106KB brotli
|
||||||
|
regardless of `import *` vs named imports (`sideEffects: false`
|
||||||
|
already lets Rollup tree-shake fully; tried named imports, no size
|
||||||
|
change). That script is a deferred module (doesn't block first
|
||||||
|
paint/DCL) and the page was interactive in ~270ms in testing. No
|
||||||
|
code changes made — budget is fine as shipped
|
||||||
|
|
||||||
|
## Content features carried over from the Zola site
|
||||||
|
|
||||||
|
- [x] Series-nav (multi-part post series) — `writings/[...slug].astro`,
|
||||||
|
uses the existing `extra.series`/`extra.series_index` fields
|
||||||
|
- [x] Table of contents — sticky sidebar on desktop (>=74rem), falls back to
|
||||||
|
the original inline list below the tags on narrower viewports; same
|
||||||
|
heading data rendered via a shared `TocList.astro` so the two aren't
|
||||||
|
duplicated markup by hand. `.container--wide` bumped 64rem → 76rem so
|
||||||
|
big monitors use more of the screen (prose column itself stays capped
|
||||||
|
at 42rem for readability — the extra width is the TOC gutter);
|
||||||
|
verified at 390/900/1180/1920px with no distortion at any width
|
||||||
|
- [x] Prev/next post navigation
|
||||||
|
- [x] Related posts (tag-based, tag-overlap ranked, limit 3)
|
||||||
|
- [x] Per-tag descriptions on tag archive pages — `/tags/` + `/tags/[tag]/`,
|
||||||
|
descriptions ported from `zola.toml`'s `[extra.tag_descriptions]`
|
||||||
|
- [x] `llms_description` meta (site-wide default in `site.config.ts` +
|
||||||
|
per-post override, rendered as `<meta name="description">` in
|
||||||
|
`Base.astro`)
|
||||||
|
- [x] RSS/JSON feeds — `/rss.xml`, `/feed.json`
|
||||||
|
- [x] Sitemap + robots.txt — `@astrojs/sitemap`, static `public/robots.txt`
|
||||||
|
- [x] Code block syntax highlighting — `toml-content-loader.ts` renders
|
||||||
|
fenced code blocks through `highlight.js`'s core build with only the
|
||||||
|
11 languages content actually uses registered (no bundled-theme/WASM
|
||||||
|
grammar weight like shiki, which was tried first and swapped out for
|
||||||
|
this lighter option); `.hljs-*` token classes in `global.css` are
|
||||||
|
colored from the existing design tokens (`--accent`, `--text-muted`,
|
||||||
|
...), so they already switch with the site's theme with no extra
|
||||||
|
`[data-theme]` rules needed
|
||||||
|
|
||||||
|
## Search & comments
|
||||||
|
|
||||||
|
- [x] Pagefind integration — indexes the build output (`pnpm build` runs
|
||||||
|
`astro build && pagefind --site dist`), `/search/` page using
|
||||||
|
Pagefind's default UI; verified with a real query in a browser
|
||||||
|
- [x] Comments: kept Utterances (not Webmentions) — `Comments.astro`, wired
|
||||||
|
to `andwati/andwati.com`, with the same dark/light theme-sync-via-
|
||||||
|
postMessage behavior the legacy site had, adapted to this site's
|
||||||
|
`data-theme` attribute. The `rel="webmention"` link was removed since
|
||||||
|
it's unused now. `sws.toml`'s CSP updated to allow `utteranc.es`
|
||||||
|
(script-src, frame-src) — without that the widget is silently blocked
|
||||||
|
in production
|
||||||
|
|
||||||
|
## Analytics
|
||||||
|
|
||||||
|
- [x] Umami — self-hosted at `analytics.andwati.com`. `Analytics.astro`
|
||||||
|
renders the tracker script from `PUBLIC_UMAMI_SCRIPT_URL` +
|
||||||
|
`PUBLIC_UMAMI_WEBSITE_ID` (set in `apps/site/.env`, gitignored — copy
|
||||||
|
into the production environment's env vars too); `sws.toml`'s CSP
|
||||||
|
updated to allow that host in `script-src`/`connect-src`
|
||||||
|
|
||||||
|
## Migration from the legacy Zola site
|
||||||
|
|
||||||
|
- [x] Script/process to convert `content/posts/*.md` into the new
|
||||||
|
`content/writings/` schema — `scripts/migrate-posts-to-writings.mjs`,
|
||||||
|
additive only (`content/posts/` untouched), re-runnable/idempotent.
|
||||||
|
2 of 20 posts (`from-c-to-machine-code`,
|
||||||
|
`self-hosting-gitea-and-mirroring-github`) use note/tip/warning/danger
|
||||||
|
callout shortcodes — flagged by the script, still left as-is in the
|
||||||
|
body text
|
||||||
|
- [x] note/tip/warning/danger callout shortcode rendering — the legacy
|
||||||
|
`{% <note> %} ... {% </note> %}` blocks are converted to styled
|
||||||
|
`.callout` markup by `toml-content-loader.ts`'s `renderCallouts()`
|
||||||
|
(runs before the body's markdown parse), styled in `global.css` to
|
||||||
|
match the new design system. Verified in-browser on
|
||||||
|
`from-c-to-machine-code`
|
||||||
|
- [x] Preserve slugs/URLs via redirects — the script generates 301s from
|
||||||
|
`/posts/<slug>/` to `/writings/<slug>/` into `sws.toml`, idempotently
|
||||||
|
- [x] Migrate `content/archive/`, `content/about.md` equivalents —
|
||||||
|
`/archive/` groups every published writing by year; `/about/` reframes
|
||||||
|
the legacy copy around the site's "digital legacy" identity while
|
||||||
|
preserving its PGP and security contact details. Both are in the main
|
||||||
|
navigation, and the linked `.well-known` files are copied into Astro's
|
||||||
|
public output
|
||||||
|
- [ ] Verify RSS subscribers and inbound links survive the cutover — can
|
||||||
|
only really be checked once this is deployed live (**needs your
|
||||||
|
input**: this is a production-cutover verification, not a build-time
|
||||||
|
one)
|
||||||
|
|
||||||
|
## Infra / deployment
|
||||||
|
|
||||||
|
- [x] Wire Strapi into the Dokploy deployment alongside the Astro build —
|
||||||
|
`docker-compose.prod.yml` defines both production services, persistent
|
||||||
|
CMS/Git volumes, reverse-proxy settings, and secret-only configuration
|
||||||
|
- [x] Build trigger decided and implemented: Strapi commits Markdown and
|
||||||
|
generated covers to Git, pushes the production branch, then calls the
|
||||||
|
Dokploy Compose deploy webhook so Astro rebuilds from the durable commit
|
||||||
|
- [x] Update `Dockerfile`/`sws.toml` for Astro's `apps/site/dist` output,
|
||||||
|
current security headers, legacy redirects, and the custom 404 page
|
||||||
|
- [ ] Launch the production Compose application and configure its domains,
|
||||||
|
secrets, GitHub token, and deploy webhook in Dokploy — repository-side
|
||||||
|
setup is complete in `DEPLOYMENT.md`; host access is still required
|
||||||
|
|
||||||
|
## Cleanup
|
||||||
|
|
||||||
|
- [ ] Remove Zola-specific files (`zola.toml`, `templates/`, `sass/`,
|
||||||
|
`themes/`, `static/`, `Makefile`, `bin/update-zola`, `content/posts/`,
|
||||||
|
`content/archive/_index.md`, `content/about.md`, `content/_index.md`,
|
||||||
|
legacy OG/llms scripts) — verified safe: root `Dockerfile` already
|
||||||
|
builds `apps/site` (not `zola build`), nothing in `apps/site` reads
|
||||||
|
those content files, and `content/writings/` has all 20 posts from
|
||||||
|
`content/posts/`. Root `package.json`'s `legacy:*` scripts already
|
||||||
|
removed. **Blocked**: the actual `git rm` was refused by the
|
||||||
|
permission system as irreversible destruction — run it yourself (fully
|
||||||
|
reversible via git history/revert since these are tracked files):
|
||||||
|
`git rm -r templates/ sass/ themes/ static/ zola.toml Makefile bin/
|
||||||
|
content/posts/ content/archive/_index.md content/about.md
|
||||||
|
content/_index.md scripts/generate-llms.mjs
|
||||||
|
scripts/generate-og-images.mjs`
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=1337
|
||||||
|
APP_KEYS="toBeModified1,toBeModified2"
|
||||||
|
API_TOKEN_SALT=tobemodified
|
||||||
|
ADMIN_JWT_SECRET=tobemodified
|
||||||
|
TRANSFER_TOKEN_SALT=tobemodified
|
||||||
|
JWT_SECRET=tobemodified
|
||||||
|
ENCRYPTION_KEY=tobemodified
|
||||||
|
|
||||||
|
# Production reverse-proxy and Git publishing settings. Git publishing stays
|
||||||
|
# disabled during local development unless explicitly enabled.
|
||||||
|
PUBLIC_URL=http://localhost:1337
|
||||||
|
IS_PROXIED=false
|
||||||
|
GIT_PUBLISH_ENABLED=false
|
||||||
|
CONTENT_REPOSITORY_ROOT=
|
||||||
|
CONTENT_GIT_REPOSITORY=andwati/andwati.com
|
||||||
|
CONTENT_GIT_BRANCH=main
|
||||||
|
CONTENT_GIT_COMMIT_NAME="andwati.com CMS"
|
||||||
|
CONTENT_GIT_COMMIT_EMAIL=[email protected]
|
||||||
|
GITHUB_TOKEN=
|
||||||
|
DOKPLOY_DEPLOY_WEBHOOK_URL=
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
############################
|
||||||
|
# OS X
|
||||||
|
############################
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
Icon
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
._*
|
||||||
|
|
||||||
|
|
||||||
|
############################
|
||||||
|
# Linux
|
||||||
|
############################
|
||||||
|
|
||||||
|
*~
|
||||||
|
|
||||||
|
|
||||||
|
############################
|
||||||
|
# Windows
|
||||||
|
############################
|
||||||
|
|
||||||
|
Thumbs.db
|
||||||
|
ehthumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
$RECYCLE.BIN/
|
||||||
|
*.cab
|
||||||
|
*.msi
|
||||||
|
*.msm
|
||||||
|
*.msp
|
||||||
|
|
||||||
|
|
||||||
|
############################
|
||||||
|
# Packages
|
||||||
|
############################
|
||||||
|
|
||||||
|
*.7z
|
||||||
|
*.csv
|
||||||
|
*.dat
|
||||||
|
*.dmg
|
||||||
|
*.gz
|
||||||
|
*.iso
|
||||||
|
*.jar
|
||||||
|
*.rar
|
||||||
|
*.tar
|
||||||
|
*.zip
|
||||||
|
*.com
|
||||||
|
*.class
|
||||||
|
*.dll
|
||||||
|
*.exe
|
||||||
|
*.o
|
||||||
|
*.seed
|
||||||
|
*.so
|
||||||
|
*.swo
|
||||||
|
*.swp
|
||||||
|
*.swn
|
||||||
|
*.swm
|
||||||
|
*.out
|
||||||
|
*.pid
|
||||||
|
|
||||||
|
|
||||||
|
############################
|
||||||
|
# Logs and databases
|
||||||
|
############################
|
||||||
|
|
||||||
|
.tmp
|
||||||
|
*.log
|
||||||
|
*.sql
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
|
||||||
|
############################
|
||||||
|
# Misc.
|
||||||
|
############################
|
||||||
|
|
||||||
|
*#
|
||||||
|
ssl
|
||||||
|
.idea
|
||||||
|
nbproject
|
||||||
|
public/uploads/*
|
||||||
|
!public/uploads/.gitkeep
|
||||||
|
.tsbuildinfo
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
############################
|
||||||
|
# Node.js
|
||||||
|
############################
|
||||||
|
|
||||||
|
lib-cov
|
||||||
|
lcov.info
|
||||||
|
pids
|
||||||
|
logs
|
||||||
|
results
|
||||||
|
node_modules
|
||||||
|
.node_history
|
||||||
|
|
||||||
|
############################
|
||||||
|
# Package managers
|
||||||
|
############################
|
||||||
|
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/cache
|
||||||
|
!.yarn/unplugged
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/sdks
|
||||||
|
!.yarn/versions
|
||||||
|
.pnp.*
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
|
############################
|
||||||
|
# Tests
|
||||||
|
############################
|
||||||
|
|
||||||
|
coverage
|
||||||
|
|
||||||
|
############################
|
||||||
|
# Strapi
|
||||||
|
############################
|
||||||
|
|
||||||
|
.env
|
||||||
|
license.txt
|
||||||
|
exports
|
||||||
|
.strapi
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
.strapi-updater.json
|
||||||
|
.strapi-cloud.json
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Production image for the Strapi CMS. Multi-stage: install deps, build the
|
||||||
|
# admin panel, then copy only the built output + prod node_modules into the
|
||||||
|
# runtime layer.
|
||||||
|
#
|
||||||
|
# Build context must be the repo root (pnpm workspace root), e.g.:
|
||||||
|
# docker build -f apps/cms/Dockerfile -t andwati-cms .
|
||||||
|
|
||||||
|
FROM node:24-bookworm-slim AS base
|
||||||
|
RUN corepack enable
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
FROM base AS deps
|
||||||
|
# better-sqlite3 needs to compile if no prebuilt binary matches the platform.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
python3 make g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||||
|
COPY apps/site/package.json apps/site/package.json
|
||||||
|
COPY apps/cms/package.json apps/cms/package.json
|
||||||
|
RUN pnpm install --frozen-lockfile --filter cms
|
||||||
|
|
||||||
|
FROM deps AS build
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
COPY apps/cms apps/cms
|
||||||
|
RUN pnpm --filter cms build
|
||||||
|
|
||||||
|
FROM base AS runtime
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates git \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY --from=build /app/node_modules /app/node_modules
|
||||||
|
COPY --from=build /app/apps/cms /app/apps/cms
|
||||||
|
COPY --chmod=755 apps/cms/docker-entrypoint.sh /usr/local/bin/andwati-cms-entrypoint
|
||||||
|
WORKDIR /app/apps/cms
|
||||||
|
EXPOSE 1337
|
||||||
|
ENTRYPOINT ["andwati-cms-entrypoint"]
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Local testing image for the Strapi CMS — runs `strapi develop` with the
|
||||||
|
# workspace's own lockfile, so it matches what `pnpm --filter cms develop`
|
||||||
|
# does on the host. Not for production (see Dockerfile for that).
|
||||||
|
#
|
||||||
|
# Build context must be the repo root (pnpm workspace root), e.g.:
|
||||||
|
# docker build -f apps/cms/Dockerfile.dev -t andwati-cms-dev .
|
||||||
|
# or simply: docker compose up cms
|
||||||
|
|
||||||
|
FROM node:24-bookworm-slim
|
||||||
|
|
||||||
|
# better-sqlite3 needs to compile if no prebuilt binary matches the platform.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
python3 make g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN corepack enable
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy just the manifests first so `pnpm install` is cached across rebuilds
|
||||||
|
# unless a package.json actually changes.
|
||||||
|
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||||
|
COPY apps/site/package.json apps/site/package.json
|
||||||
|
COPY apps/cms/package.json apps/cms/package.json
|
||||||
|
RUN pnpm install --frozen-lockfile --filter cms
|
||||||
|
|
||||||
|
COPY apps/cms apps/cms
|
||||||
|
|
||||||
|
WORKDIR /app/apps/cms
|
||||||
|
EXPOSE 1337
|
||||||
|
CMD ["pnpm", "develop"]
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# 🚀 Getting started with Strapi
|
||||||
|
|
||||||
|
Strapi comes with a full featured [Command Line Interface](https://docs.strapi.io/dev-docs/cli) (CLI) which lets you scaffold and manage your project in seconds.
|
||||||
|
|
||||||
|
### `develop`
|
||||||
|
|
||||||
|
Start your Strapi application with autoReload enabled. [Learn more](https://docs.strapi.io/dev-docs/cli#strapi-develop)
|
||||||
|
|
||||||
|
```
|
||||||
|
npm run develop
|
||||||
|
# or
|
||||||
|
yarn develop
|
||||||
|
```
|
||||||
|
|
||||||
|
### `start`
|
||||||
|
|
||||||
|
Start your Strapi application with autoReload disabled. [Learn more](https://docs.strapi.io/dev-docs/cli#strapi-start)
|
||||||
|
|
||||||
|
```
|
||||||
|
npm run start
|
||||||
|
# or
|
||||||
|
yarn start
|
||||||
|
```
|
||||||
|
|
||||||
|
### `build`
|
||||||
|
|
||||||
|
Build your admin panel. [Learn more](https://docs.strapi.io/dev-docs/cli#strapi-build)
|
||||||
|
|
||||||
|
```
|
||||||
|
npm run build
|
||||||
|
# or
|
||||||
|
yarn build
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚙️ Deployment
|
||||||
|
|
||||||
|
Strapi gives you many possible deployment options for your project including [Strapi Cloud](https://cloud.strapi.io). Browse the [deployment section of the documentation](https://docs.strapi.io/dev-docs/deployment) to find the best solution for your use case.
|
||||||
|
|
||||||
|
```
|
||||||
|
yarn strapi deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📚 Learn more
|
||||||
|
|
||||||
|
- [Resource center](https://strapi.io/resource-center) - Strapi resource center.
|
||||||
|
- [Strapi documentation](https://docs.strapi.io) - Official Strapi documentation.
|
||||||
|
- [Strapi tutorials](https://strapi.io/tutorials) - List of tutorials made by the core team and the community.
|
||||||
|
- [Strapi blog](https://strapi.io/blog) - Official Strapi blog containing articles made by the Strapi team and the community.
|
||||||
|
- [Changelog](https://strapi.io/changelog) - Find out about the Strapi product updates, new features and general improvements.
|
||||||
|
|
||||||
|
Feel free to check out the [Strapi GitHub repository](https://github.com/strapi/strapi). Your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## ✨ Community
|
||||||
|
|
||||||
|
- [Discord](https://discord.strapi.io) - Come chat with the Strapi community including the core team.
|
||||||
|
- [Forum](https://forum.strapi.io/) - Place to discuss, ask questions and find answers, show your Strapi project and get feedback or just talk with other Community members.
|
||||||
|
- [Awesome Strapi](https://github.com/strapi/awesome-strapi) - A curated list of awesome things related to Strapi.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<sub>🤫 Psst! [Strapi is hiring](https://strapi.io/careers).</sub>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { Core } from "@strapi/strapi";
|
||||||
|
|
||||||
|
const config = ({
|
||||||
|
env,
|
||||||
|
}: Core.Config.Shared.ConfigParams): Core.Config.Admin => ({
|
||||||
|
auth: {
|
||||||
|
secret: env("ADMIN_JWT_SECRET")!,
|
||||||
|
},
|
||||||
|
apiToken: {
|
||||||
|
salt: env("API_TOKEN_SALT")!,
|
||||||
|
},
|
||||||
|
transfer: {
|
||||||
|
token: {
|
||||||
|
salt: env("TRANSFER_TOKEN_SALT")!,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
secrets: {
|
||||||
|
encryptionKey: env("ENCRYPTION_KEY")!,
|
||||||
|
},
|
||||||
|
flags: {
|
||||||
|
nps: env.bool("FLAG_NPS", true),
|
||||||
|
promoteEE: env.bool("FLAG_PROMOTE_EE", true),
|
||||||
|
docLinks: env.bool("FLAG_DOC_LINKS", true),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Core } from "@strapi/strapi";
|
||||||
|
|
||||||
|
const config: Core.Config.Api = {
|
||||||
|
rest: {
|
||||||
|
defaultLimit: 25,
|
||||||
|
maxLimit: 100,
|
||||||
|
withCount: true,
|
||||||
|
strictParams: true,
|
||||||
|
},
|
||||||
|
documents: {
|
||||||
|
strictParams: true,
|
||||||
|
strictRelations: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { isDatabaseClientKind } from "@strapi/database";
|
||||||
|
import type { Core } from "@strapi/strapi";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const config = ({
|
||||||
|
env,
|
||||||
|
}: Core.Config.Shared.ConfigParams): Core.Config.Database => {
|
||||||
|
const client = env("DATABASE_CLIENT", "sqlite");
|
||||||
|
|
||||||
|
if (!isDatabaseClientKind(client)) {
|
||||||
|
throw new Error(
|
||||||
|
`Unsupported DATABASE_CLIENT: ${client}. Use "postgres", "mysql", or "sqlite".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const connections: Record<
|
||||||
|
Core.Config.Database.ClientKind,
|
||||||
|
Core.Config.Database["connection"]
|
||||||
|
> = {
|
||||||
|
mysql: {
|
||||||
|
client: "mysql",
|
||||||
|
connection: {
|
||||||
|
host: env("DATABASE_HOST", "localhost"),
|
||||||
|
port: env.int("DATABASE_PORT", 3306),
|
||||||
|
database: env("DATABASE_NAME", "strapi"),
|
||||||
|
user: env("DATABASE_USERNAME", "strapi"),
|
||||||
|
password: env("DATABASE_PASSWORD", "strapi"),
|
||||||
|
ssl: env.bool("DATABASE_SSL", false) && {
|
||||||
|
key: env("DATABASE_SSL_KEY", undefined),
|
||||||
|
cert: env("DATABASE_SSL_CERT", undefined),
|
||||||
|
ca: env("DATABASE_SSL_CA", undefined),
|
||||||
|
capath: env("DATABASE_SSL_CAPATH", undefined),
|
||||||
|
cipher: env("DATABASE_SSL_CIPHER", undefined),
|
||||||
|
rejectUnauthorized: env.bool(
|
||||||
|
"DATABASE_SSL_REJECT_UNAUTHORIZED",
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pool: {
|
||||||
|
min: env.int("DATABASE_POOL_MIN", 2),
|
||||||
|
max: env.int("DATABASE_POOL_MAX", 10),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
postgres: {
|
||||||
|
client: "postgres",
|
||||||
|
connection: {
|
||||||
|
connectionString: env("DATABASE_URL"),
|
||||||
|
host: env("DATABASE_HOST", "localhost"),
|
||||||
|
port: env.int("DATABASE_PORT", 5432),
|
||||||
|
database: env("DATABASE_NAME", "strapi"),
|
||||||
|
user: env("DATABASE_USERNAME", "strapi"),
|
||||||
|
password: env("DATABASE_PASSWORD", "strapi"),
|
||||||
|
ssl: env.bool("DATABASE_SSL", false) && {
|
||||||
|
key: env("DATABASE_SSL_KEY", undefined),
|
||||||
|
cert: env("DATABASE_SSL_CERT", undefined),
|
||||||
|
ca: env("DATABASE_SSL_CA", undefined),
|
||||||
|
capath: env("DATABASE_SSL_CAPATH", undefined),
|
||||||
|
cipher: env("DATABASE_SSL_CIPHER", undefined),
|
||||||
|
rejectUnauthorized: env.bool(
|
||||||
|
"DATABASE_SSL_REJECT_UNAUTHORIZED",
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
schema: env("DATABASE_SCHEMA", "public"),
|
||||||
|
},
|
||||||
|
pool: {
|
||||||
|
min: env.int("DATABASE_POOL_MIN", 2),
|
||||||
|
max: env.int("DATABASE_POOL_MAX", 10),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sqlite: {
|
||||||
|
client: "sqlite",
|
||||||
|
connection: {
|
||||||
|
filename: path.join(
|
||||||
|
__dirname,
|
||||||
|
"..",
|
||||||
|
"..",
|
||||||
|
env("DATABASE_FILENAME", ".tmp/data.db"),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
useNullAsDefault: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
connection: {
|
||||||
|
...connections[client],
|
||||||
|
acquireConnectionTimeout: env.int("DATABASE_CONNECTION_TIMEOUT", 60000),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Core } from "@strapi/strapi";
|
||||||
|
|
||||||
|
const config: Core.Config.Middlewares = [
|
||||||
|
"strapi::logger",
|
||||||
|
"strapi::errors",
|
||||||
|
"strapi::security",
|
||||||
|
"strapi::cors",
|
||||||
|
"strapi::poweredBy",
|
||||||
|
"strapi::query",
|
||||||
|
"strapi::body",
|
||||||
|
"strapi::session",
|
||||||
|
"strapi::favicon",
|
||||||
|
"strapi::public",
|
||||||
|
];
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { Core } from "@strapi/strapi";
|
||||||
|
|
||||||
|
const allowedMediaTypes = [
|
||||||
|
"image/*",
|
||||||
|
"video/*",
|
||||||
|
"audio/*",
|
||||||
|
"application/pdf",
|
||||||
|
"application/msword",
|
||||||
|
"application/vnd.openxmlformats-officedocument.*",
|
||||||
|
"text/plain",
|
||||||
|
"text/csv",
|
||||||
|
];
|
||||||
|
|
||||||
|
const deniedTypes = [
|
||||||
|
"image/svg+xml",
|
||||||
|
"application/vnd.microsoft.portable-executable",
|
||||||
|
"application/x-msdownload",
|
||||||
|
"application/x-msdos-program",
|
||||||
|
"application/x-executable",
|
||||||
|
"application/x-dosexec",
|
||||||
|
"application/x-sh",
|
||||||
|
"text/x-shellscript",
|
||||||
|
"application/x-mach-binary",
|
||||||
|
];
|
||||||
|
|
||||||
|
const config = ({
|
||||||
|
env,
|
||||||
|
}: Core.Config.Shared.ConfigParams): Core.Config.Plugin => ({
|
||||||
|
"users-permissions": {
|
||||||
|
config: {
|
||||||
|
jwtManagement: "refresh",
|
||||||
|
sessions: {
|
||||||
|
httpOnly: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
upload: {
|
||||||
|
config: {
|
||||||
|
security: {
|
||||||
|
allowedTypes: allowedMediaTypes,
|
||||||
|
deniedTypes,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Core } from "@strapi/strapi";
|
||||||
|
|
||||||
|
const config = ({
|
||||||
|
env,
|
||||||
|
}: Core.Config.Shared.ConfigParams): Core.Config.Server => ({
|
||||||
|
host: env("HOST", "0.0.0.0"),
|
||||||
|
port: env.int("PORT", 1337),
|
||||||
|
url: env("PUBLIC_URL", "http://localhost:1337"),
|
||||||
|
proxy: env.bool("IS_PROXIED", false),
|
||||||
|
app: {
|
||||||
|
keys: env.array("APP_KEYS")!,
|
||||||
|
},
|
||||||
|
webhooks: {
|
||||||
|
populateRelations: env.bool("WEBHOOKS_POPULATE_RELATIONS", false),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
repository_root="${CONTENT_REPOSITORY_ROOT:-/content-repository}"
|
||||||
|
repository="${CONTENT_GIT_REPOSITORY:-andwati/andwati.com}"
|
||||||
|
branch="${CONTENT_GIT_BRANCH:-main}"
|
||||||
|
|
||||||
|
case "$repository" in
|
||||||
|
*[!A-Za-z0-9._/-]* | /* | */ | *..*)
|
||||||
|
echo "Invalid CONTENT_GIT_REPOSITORY: $repository" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
mkdir -p "$repository_root"
|
||||||
|
|
||||||
|
if [ ! -d "$repository_root/.git" ]; then
|
||||||
|
if [ -n "$(find "$repository_root" -mindepth 1 -maxdepth 1 -print -quit)" ]; then
|
||||||
|
echo "$repository_root is not empty and is not a Git checkout" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
git clone \
|
||||||
|
--branch "$branch" \
|
||||||
|
--single-branch \
|
||||||
|
"https://github.com/$repository.git" \
|
||||||
|
"$repository_root"
|
||||||
|
else
|
||||||
|
git -C "$repository_root" fetch origin "$branch"
|
||||||
|
git -C "$repository_root" checkout "$branch"
|
||||||
|
|
||||||
|
if [ -z "$(git -C "$repository_root" status --porcelain)" ]; then
|
||||||
|
git -C "$repository_root" merge --ff-only "origin/$branch"
|
||||||
|
else
|
||||||
|
echo "Content checkout has unpublished changes; preserving them without pulling." >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
export CONTENT_REPOSITORY_ROOT="$repository_root"
|
||||||
|
exec node node_modules/@strapi/strapi/bin/strapi.js start
|
||||||
|
After Width: | Height: | Size: 497 B |
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"name": "cms",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "A Strapi application",
|
||||||
|
"scripts": {
|
||||||
|
"predev": "test -f .env || cp .env.example .env",
|
||||||
|
"predevelop": "test -f .env || cp .env.example .env",
|
||||||
|
"build": "strapi build",
|
||||||
|
"console": "strapi console",
|
||||||
|
"deploy": "strapi deploy",
|
||||||
|
"dev": "strapi develop",
|
||||||
|
"develop": "strapi develop",
|
||||||
|
"start": "strapi start",
|
||||||
|
"strapi": "strapi",
|
||||||
|
"test:publisher": "tsx --test tests/git-publisher.test.ts",
|
||||||
|
"upgrade": "npx @strapi/upgrade latest",
|
||||||
|
"upgrade:dry": "npx @strapi/upgrade latest --dry"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@strapi/database": "5.52.2",
|
||||||
|
"@strapi/plugin-cloud": "5.52.2",
|
||||||
|
"@strapi/plugin-users-permissions": "5.52.2",
|
||||||
|
"@strapi/strapi": "5.52.2",
|
||||||
|
"better-sqlite3": "12.8.0",
|
||||||
|
"react": "^18.0.0",
|
||||||
|
"react-dom": "^18.0.0",
|
||||||
|
"react-router-dom": "^6.30.3",
|
||||||
|
"smol-toml": "^1.8.0",
|
||||||
|
"styled-components": "^6.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^18",
|
||||||
|
"@types/react-dom": "^18",
|
||||||
|
"tsx": "^4.23.13",
|
||||||
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0 <=26.x.x",
|
||||||
|
"npm": ">=6.0.0"
|
||||||
|
},
|
||||||
|
"strapi": {
|
||||||
|
"uuid": "53c467c6-a64c-4883-b4df-0d96747d2cf3",
|
||||||
|
"installId": "dcf09d0c097e16db63c329dd9602949f9c4ae64f4e69a04dc6e105313d30b369"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# To prevent search engines from seeing the site altogether, uncomment the next two lines:
|
||||||
|
# User-Agent: *
|
||||||
|
# Disallow: /
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { StrapiApp } from "@strapi/strapi/admin";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
config: {
|
||||||
|
locales: [
|
||||||
|
// 'ar',
|
||||||
|
// 'fr',
|
||||||
|
// 'cs',
|
||||||
|
// 'de',
|
||||||
|
// 'da',
|
||||||
|
// 'es',
|
||||||
|
// 'he',
|
||||||
|
// 'id',
|
||||||
|
// 'it',
|
||||||
|
// 'ja',
|
||||||
|
// 'ko',
|
||||||
|
// 'ms',
|
||||||
|
// 'nl',
|
||||||
|
// 'no',
|
||||||
|
// 'pl',
|
||||||
|
// 'pt-BR',
|
||||||
|
// 'pt',
|
||||||
|
// 'ru',
|
||||||
|
// 'sk',
|
||||||
|
// 'sv',
|
||||||
|
// 'th',
|
||||||
|
// 'tr',
|
||||||
|
// 'uk',
|
||||||
|
// 'vi',
|
||||||
|
// 'zh-Hans',
|
||||||
|
// 'zh',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
bootstrap(app: StrapiApp) {
|
||||||
|
console.log(app);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["../plugins/**/admin/src/**/*", "./"],
|
||||||
|
"exclude": ["node_modules/", "build/", "dist/", "**/*.test.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { mergeConfig, type UserConfig } from "vite";
|
||||||
|
|
||||||
|
export default (config: UserConfig) => {
|
||||||
|
// Important: always return the modified config
|
||||||
|
return mergeConfig(config, {
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": "/src",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
{
|
||||||
|
"kind": "collectionType",
|
||||||
|
"collectionName": "bookshelf_entries",
|
||||||
|
"info": {
|
||||||
|
"singularName": "bookshelf-entry",
|
||||||
|
"pluralName": "bookshelf-entries",
|
||||||
|
"displayName": "Bookshelf Entry",
|
||||||
|
"description": "Books and papers, synced to content/bookshelf/. `kind` selects which identifier fields apply."
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"draftAndPublish": false
|
||||||
|
},
|
||||||
|
"attributes": {
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"slug": {
|
||||||
|
"type": "uid",
|
||||||
|
"targetField": "title",
|
||||||
|
"required": true,
|
||||||
|
"description": "Matches the file name under content/bookshelf/"
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"type": "enumeration",
|
||||||
|
"enum": ["book", "paper"],
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"authors": {
|
||||||
|
"type": "json",
|
||||||
|
"description": "Array of author name strings"
|
||||||
|
},
|
||||||
|
"isbn": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Books only"
|
||||||
|
},
|
||||||
|
"doi": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Papers only"
|
||||||
|
},
|
||||||
|
"arxiv_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Papers only, optional"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Papers only: landing page / source link"
|
||||||
|
},
|
||||||
|
"cover_image": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Path to the locally cached cover, e.g. /covers/slug.jpg"
|
||||||
|
},
|
||||||
|
"rating": {
|
||||||
|
"type": "integer",
|
||||||
|
"min": 1,
|
||||||
|
"max": 5
|
||||||
|
},
|
||||||
|
"read_status": {
|
||||||
|
"type": "enumeration",
|
||||||
|
"enum": ["reading", "read", "abandoned"]
|
||||||
|
},
|
||||||
|
"date_started": {
|
||||||
|
"type": "date"
|
||||||
|
},
|
||||||
|
"date_finished": {
|
||||||
|
"type": "date"
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"type": "json",
|
||||||
|
"description": "Array of tag strings, maps to [taxonomies].tags"
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"type": "richtext",
|
||||||
|
"description": "Personal review/notes below the frontmatter"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { factories } from "@strapi/strapi";
|
||||||
|
|
||||||
|
export default factories.createCoreController(
|
||||||
|
"api::bookshelf-entry.bookshelf-entry",
|
||||||
|
);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { factories } from "@strapi/strapi";
|
||||||
|
|
||||||
|
export default factories.createCoreRouter(
|
||||||
|
"api::bookshelf-entry.bookshelf-entry",
|
||||||
|
);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { factories } from "@strapi/strapi";
|
||||||
|
|
||||||
|
export default factories.createCoreService(
|
||||||
|
"api::bookshelf-entry.bookshelf-entry",
|
||||||
|
);
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"kind": "collectionType",
|
||||||
|
"collectionName": "curated_blogs",
|
||||||
|
"info": {
|
||||||
|
"singularName": "curated-blog",
|
||||||
|
"pluralName": "curated-blogs",
|
||||||
|
"displayName": "Curated Blog",
|
||||||
|
"description": "Hand-picked external blogs, synced to content/blogs/. No live feed fetching."
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"draftAndPublish": false
|
||||||
|
},
|
||||||
|
"attributes": {
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"slug": {
|
||||||
|
"type": "uid",
|
||||||
|
"targetField": "title",
|
||||||
|
"required": true,
|
||||||
|
"description": "Matches the file name under content/blogs/"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"type": "string",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"feed_url": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"type": "richtext",
|
||||||
|
"description": "One-to-two sentence blurb below the frontmatter"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { factories } from "@strapi/strapi";
|
||||||
|
|
||||||
|
export default factories.createCoreController("api::curated-blog.curated-blog");
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { factories } from "@strapi/strapi";
|
||||||
|
|
||||||
|
export default factories.createCoreRouter("api::curated-blog.curated-blog");
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { factories } from "@strapi/strapi";
|
||||||
|
|
||||||
|
export default factories.createCoreService("api::curated-blog.curated-blog");
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"kind": "collectionType",
|
||||||
|
"collectionName": "portfolio_entries",
|
||||||
|
"info": {
|
||||||
|
"singularName": "portfolio-entry",
|
||||||
|
"pluralName": "portfolio-entries",
|
||||||
|
"displayName": "Portfolio Entry",
|
||||||
|
"description": "Projects and professional work, synced to content/portfolio/."
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"draftAndPublish": false
|
||||||
|
},
|
||||||
|
"attributes": {
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"slug": {
|
||||||
|
"type": "uid",
|
||||||
|
"targetField": "title",
|
||||||
|
"required": true,
|
||||||
|
"description": "Matches the file name under content/portfolio/"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"date_start": {
|
||||||
|
"type": "date"
|
||||||
|
},
|
||||||
|
"date_end": {
|
||||||
|
"type": "date"
|
||||||
|
},
|
||||||
|
"outcome": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"draft": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"type": "json",
|
||||||
|
"description": "Array of { label, url }, maps to [[links]]"
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"type": "json",
|
||||||
|
"description": "Array of tag strings, maps to [taxonomies].tags"
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"type": "richtext",
|
||||||
|
"description": "Markdown case-study body below the frontmatter"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { factories } from "@strapi/strapi";
|
||||||
|
|
||||||
|
export default factories.createCoreController(
|
||||||
|
"api::portfolio-entry.portfolio-entry",
|
||||||
|
);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { factories } from "@strapi/strapi";
|
||||||
|
|
||||||
|
export default factories.createCoreRouter(
|
||||||
|
"api::portfolio-entry.portfolio-entry",
|
||||||
|
);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { factories } from "@strapi/strapi";
|
||||||
|
|
||||||
|
export default factories.createCoreService(
|
||||||
|
"api::portfolio-entry.portfolio-entry",
|
||||||
|
);
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"kind": "collectionType",
|
||||||
|
"collectionName": "writings",
|
||||||
|
"info": {
|
||||||
|
"singularName": "writing",
|
||||||
|
"pluralName": "writings",
|
||||||
|
"displayName": "Writing",
|
||||||
|
"description": "Long-form essays and technical posts, synced to content/writings/."
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"draftAndPublish": false
|
||||||
|
},
|
||||||
|
"attributes": {
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"slug": {
|
||||||
|
"type": "uid",
|
||||||
|
"targetField": "title",
|
||||||
|
"required": true,
|
||||||
|
"description": "Matches the file/directory name under content/writings/"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"author": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"type": "date",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"updated": {
|
||||||
|
"type": "date"
|
||||||
|
},
|
||||||
|
"draft": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"canonical_url": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"type": "json",
|
||||||
|
"description": "Array of tag strings, maps to [taxonomies].tags"
|
||||||
|
},
|
||||||
|
"series": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Maps to [extra].series"
|
||||||
|
},
|
||||||
|
"series_index": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Maps to [extra].series_index"
|
||||||
|
},
|
||||||
|
"llms_description": {
|
||||||
|
"type": "text",
|
||||||
|
"description": "Maps to [extra].llms_description"
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"type": "richtext",
|
||||||
|
"description": "Markdown body below the frontmatter"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { factories } from "@strapi/strapi";
|
||||||
|
|
||||||
|
export default factories.createCoreController("api::writing.writing");
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { factories } from "@strapi/strapi";
|
||||||
|
|
||||||
|
export default factories.createCoreRouter("api::writing.writing");
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { factories } from "@strapi/strapi";
|
||||||
|
|
||||||
|
export default factories.createCoreService("api::writing.writing");
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { Core } from "@strapi/strapi";
|
||||||
|
import {
|
||||||
|
importContentFromDisk,
|
||||||
|
registerContentSync,
|
||||||
|
} from "./utils/content-sync";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
register() {},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* content/ is the source of truth (see content/README.md). On boot we
|
||||||
|
* import every file into Strapi's DB (a disposable cache), then register
|
||||||
|
* a Document Service middleware that writes any create/update/delete made
|
||||||
|
* through Strapi straight back to the same files.
|
||||||
|
*/
|
||||||
|
async bootstrap({ strapi }: { strapi: Core.Strapi }) {
|
||||||
|
registerContentSync(strapi as never);
|
||||||
|
await importContentFromDisk(strapi as never);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { mkdirSync, writeFileSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { coversRoot } from "./content-paths";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Covers are cached into the Astro app's public/ dir directly, so the built
|
||||||
|
* site serves them from /covers/<slug>.jpg with no runtime dependency on the
|
||||||
|
* source (Open Library, Semantic Scholar, ...).
|
||||||
|
*/
|
||||||
|
const COVERS_DIR = coversRoot();
|
||||||
|
|
||||||
|
async function download(url: string): Promise<Buffer | undefined> {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) return undefined;
|
||||||
|
const buf = Buffer.from(await res.arrayBuffer());
|
||||||
|
// Open Library serves a tiny placeholder image for ISBNs it doesn't have a cover for.
|
||||||
|
if (buf.byteLength < 500) return undefined;
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetches a book cover by ISBN from Open Library and caches it locally. */
|
||||||
|
export async function fetchBookCover(
|
||||||
|
isbn: string,
|
||||||
|
slug: string,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
const buf = await download(
|
||||||
|
`https://covers.openlibrary.org/b/isbn/${encodeURIComponent(isbn)}-L.jpg?default=false`,
|
||||||
|
);
|
||||||
|
if (!buf) return undefined;
|
||||||
|
const file = join(COVERS_DIR, `${slug}.jpg`);
|
||||||
|
mkdirSync(dirname(file), { recursive: true });
|
||||||
|
writeFileSync(file, buf);
|
||||||
|
return `/covers/${slug}.jpg`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sniffs the actual file signature rather than trusting the Content-Type
|
||||||
|
* header — some CDNs (O'Reilly's included, observed while building this)
|
||||||
|
* mislabel WebP responses as image/jpeg, which would otherwise get a wrong
|
||||||
|
* extension and a mismatched Content-Type when we later serve it ourselves.
|
||||||
|
*/
|
||||||
|
function extensionFromBytes(buf: Buffer): string {
|
||||||
|
if (
|
||||||
|
buf.length >= 12 &&
|
||||||
|
buf.subarray(0, 4).toString("ascii") === "RIFF" &&
|
||||||
|
buf.subarray(8, 12).toString("ascii") === "WEBP"
|
||||||
|
) {
|
||||||
|
return "webp";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
buf.length >= 8 &&
|
||||||
|
buf
|
||||||
|
.subarray(0, 8)
|
||||||
|
.equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
||||||
|
) {
|
||||||
|
return "png";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
buf.length >= 3 &&
|
||||||
|
buf[0] === 0xff &&
|
||||||
|
buf[1] === 0xd8 &&
|
||||||
|
buf[2] === 0xff
|
||||||
|
) {
|
||||||
|
return "jpg";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
buf.length >= 6 &&
|
||||||
|
(buf.subarray(0, 6).toString("ascii") === "GIF87a" ||
|
||||||
|
buf.subarray(0, 6).toString("ascii") === "GIF89a")
|
||||||
|
) {
|
||||||
|
return "gif";
|
||||||
|
}
|
||||||
|
return "jpg";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads whatever image URL is at `url` (e.g. a cover_image the user
|
||||||
|
* pasted in by hand) and caches it locally, so the site never depends on
|
||||||
|
* that URL staying up. Used for any http(s) cover_image, not just ISBN
|
||||||
|
* lookups.
|
||||||
|
*/
|
||||||
|
export async function cacheRemoteImage(
|
||||||
|
url: string,
|
||||||
|
slug: string,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) return undefined;
|
||||||
|
const buf = Buffer.from(await res.arrayBuffer());
|
||||||
|
if (buf.byteLength < 200) return undefined;
|
||||||
|
const ext = extensionFromBytes(buf);
|
||||||
|
const file = join(COVERS_DIR, `${slug}.${ext}`);
|
||||||
|
mkdirSync(dirname(file), { recursive: true });
|
||||||
|
writeFileSync(file, buf);
|
||||||
|
return `/covers/${slug}.${ext}`;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaperMetadata {
|
||||||
|
title?: string;
|
||||||
|
authors?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches title/author metadata for a paper from Semantic Scholar by DOI or
|
||||||
|
* arXiv id, used to backfill fields left blank in Strapi. Semantic Scholar
|
||||||
|
* has no reliable thumbnail/cover source for papers, so unlike books this
|
||||||
|
* does not produce a cached image.
|
||||||
|
*/
|
||||||
|
export async function fetchPaperMetadata(identifier: {
|
||||||
|
doi?: string;
|
||||||
|
arxivId?: string;
|
||||||
|
}): Promise<PaperMetadata | undefined> {
|
||||||
|
const id = identifier.doi
|
||||||
|
? `DOI:${identifier.doi}`
|
||||||
|
: identifier.arxivId
|
||||||
|
? `ARXIV:${identifier.arxivId}`
|
||||||
|
: undefined;
|
||||||
|
if (!id) return undefined;
|
||||||
|
const res = await fetch(
|
||||||
|
`https://api.semanticscholar.org/graph/v1/paper/${encodeURIComponent(id)}?fields=title,authors`,
|
||||||
|
);
|
||||||
|
if (!res.ok) return undefined;
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
title?: string;
|
||||||
|
authors?: { name: string }[];
|
||||||
|
};
|
||||||
|
return { title: data.title, authors: data.authors?.map((a) => a.name) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import {
|
||||||
|
mkdirSync,
|
||||||
|
readdirSync,
|
||||||
|
readFileSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { dirname, join, relative } from "node:path";
|
||||||
|
import {
|
||||||
|
parse as parseToml,
|
||||||
|
stringify as stringifyToml,
|
||||||
|
TomlDate,
|
||||||
|
} from "smol-toml";
|
||||||
|
|
||||||
|
const FRONTMATTER_RE = /^\+\+\+\r?\n([\s\S]*?)\r?\n\+\+\+\r?\n?([\s\S]*)$/;
|
||||||
|
|
||||||
|
export interface ParsedEntryFile {
|
||||||
|
slug: string;
|
||||||
|
frontmatter: Record<string, unknown>;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strips undefined/null/empty-string/empty-array values so smol-toml doesn't choke on them. */
|
||||||
|
export function compact<T extends Record<string, unknown>>(
|
||||||
|
obj: T,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
if (value === undefined || value === null || value === "") continue;
|
||||||
|
if (Array.isArray(value) && value.length === 0) continue;
|
||||||
|
if (
|
||||||
|
typeof value === "object" &&
|
||||||
|
!Array.isArray(value) &&
|
||||||
|
!(value instanceof Date)
|
||||||
|
) {
|
||||||
|
const nested = compact(value as Record<string, unknown>);
|
||||||
|
if (Object.keys(nested).length === 0) continue;
|
||||||
|
out[key] = nested;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out[key] = value;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Converts a Strapi "date" field (an ISO `YYYY-MM-DD` string) to a bare TOML local-date. */
|
||||||
|
export function toTomlDate(
|
||||||
|
value: string | null | undefined,
|
||||||
|
): TomlDate | undefined {
|
||||||
|
return value ? new TomlDate(value) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function walk(dir: string): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const full = join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) out.push(...walk(full));
|
||||||
|
else if (entry.isFile() && full.endsWith(".md")) out.push(full);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugFromPath(dir: string, file: string): string {
|
||||||
|
const rel = relative(dir, file).split("\\").join("/");
|
||||||
|
return rel.endsWith("/index.md")
|
||||||
|
? rel.slice(0, -"/index.md".length)
|
||||||
|
: rel.slice(0, -".md".length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads every `+++`-frontmatter Markdown file under `dir` (flat or `<slug>/index.md`). */
|
||||||
|
export function readAllEntries(dir: string): ParsedEntryFile[] {
|
||||||
|
let files: string[];
|
||||||
|
try {
|
||||||
|
files = walk(dir);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return files.flatMap((file) => {
|
||||||
|
const raw = readFileSync(file, "utf8");
|
||||||
|
const match = raw.match(FRONTMATTER_RE);
|
||||||
|
if (!match) return [];
|
||||||
|
const [, fm, body] = match;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
slug: slugFromPath(dir, file),
|
||||||
|
frontmatter: parseToml(fm),
|
||||||
|
body: body.trim(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryPath(
|
||||||
|
dir: string,
|
||||||
|
slug: string,
|
||||||
|
layout: "nested" | "flat",
|
||||||
|
): string {
|
||||||
|
return layout === "nested"
|
||||||
|
? join(dir, slug, "index.md")
|
||||||
|
: join(dir, `${slug}.md`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes (or overwrites) the frontmatter+body file for `slug` under `dir`. */
|
||||||
|
export function writeEntry(
|
||||||
|
dir: string,
|
||||||
|
slug: string,
|
||||||
|
layout: "nested" | "flat",
|
||||||
|
frontmatter: Record<string, unknown>,
|
||||||
|
body: string,
|
||||||
|
): string {
|
||||||
|
const file = entryPath(dir, slug, layout);
|
||||||
|
mkdirSync(dirname(file), { recursive: true });
|
||||||
|
const toml = stringifyToml(compact(frontmatter)).trimEnd();
|
||||||
|
writeFileSync(file, `+++\n${toml}\n+++\n\n${(body ?? "").trim()}\n`);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deletes the file (and, for nested layout, its now-empty directory) for `slug`. */
|
||||||
|
export function deleteEntry(
|
||||||
|
dir: string,
|
||||||
|
slug: string,
|
||||||
|
layout: "nested" | "flat",
|
||||||
|
): string {
|
||||||
|
const file = entryPath(dir, slug, layout);
|
||||||
|
rmSync(file, { force: true });
|
||||||
|
if (layout === "nested")
|
||||||
|
rmSync(join(dir, slug), { recursive: true, force: true });
|
||||||
|
return file;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { isAbsolute, join, resolve } from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root of the Git checkout that owns content/ and apps/site/public/covers/.
|
||||||
|
* Production points this at the persistent checkout prepared by the
|
||||||
|
* container entrypoint; local development falls back to the workspace root.
|
||||||
|
*/
|
||||||
|
export function repositoryRoot(): string {
|
||||||
|
const configured = process.env.CONTENT_REPOSITORY_ROOT;
|
||||||
|
if (!configured) return resolve(process.cwd(), "..", "..");
|
||||||
|
return isAbsolute(configured)
|
||||||
|
? configured
|
||||||
|
: resolve(process.cwd(), configured);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function contentRoot(): string {
|
||||||
|
return join(repositoryRoot(), "content");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function coversRoot(): string {
|
||||||
|
return join(repositoryRoot(), "apps", "site", "public", "covers");
|
||||||
|
}
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
import { rmSync } from "node:fs";
|
||||||
|
import { join, relative, resolve } from "node:path";
|
||||||
|
import {
|
||||||
|
cacheRemoteImage,
|
||||||
|
fetchBookCover,
|
||||||
|
fetchPaperMetadata,
|
||||||
|
} from "./bookshelf-covers";
|
||||||
|
import {
|
||||||
|
deleteEntry,
|
||||||
|
readAllEntries,
|
||||||
|
toTomlDate,
|
||||||
|
writeEntry,
|
||||||
|
} from "./content-file";
|
||||||
|
import { contentRoot, coversRoot } from "./content-paths";
|
||||||
|
import { publishContentChanges } from "./git-publisher";
|
||||||
|
|
||||||
|
const BOOKSHELF_UID = "api::bookshelf-entry.bookshelf-entry";
|
||||||
|
|
||||||
|
type Layout = "nested" | "flat";
|
||||||
|
|
||||||
|
interface SyncConfig {
|
||||||
|
uid: string;
|
||||||
|
dir: string;
|
||||||
|
layout: Layout;
|
||||||
|
/** Strapi entry -> { frontmatter, body } written to disk. */
|
||||||
|
toFile: (entry: Record<string, unknown>) => {
|
||||||
|
frontmatter: Record<string, unknown>;
|
||||||
|
body: string;
|
||||||
|
};
|
||||||
|
/** Parsed file -> Strapi entry fields, for importing content/ into Strapi on boot. */
|
||||||
|
toEntry: (
|
||||||
|
slug: string,
|
||||||
|
frontmatter: Record<string, unknown>,
|
||||||
|
body: string,
|
||||||
|
) => Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isoDate = (value: unknown): string | undefined =>
|
||||||
|
value instanceof Date
|
||||||
|
? value.toISOString().slice(0, 10)
|
||||||
|
: typeof value === "string"
|
||||||
|
? value
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
function buildConfigs(): Record<string, SyncConfig> {
|
||||||
|
const root = contentRoot();
|
||||||
|
|
||||||
|
const writing: SyncConfig = {
|
||||||
|
uid: "api::writing.writing",
|
||||||
|
dir: join(root, "writings"),
|
||||||
|
layout: "nested",
|
||||||
|
toFile: (e) => ({
|
||||||
|
frontmatter: {
|
||||||
|
title: e.title,
|
||||||
|
description: e.description,
|
||||||
|
author: e.author,
|
||||||
|
date: toTomlDate(e.date as string),
|
||||||
|
updated: toTomlDate(e.updated as string),
|
||||||
|
draft: e.draft ?? false,
|
||||||
|
canonical_url: e.canonical_url,
|
||||||
|
taxonomies: { tags: e.tags ?? [] },
|
||||||
|
extra: {
|
||||||
|
series: e.series,
|
||||||
|
series_index: e.series_index,
|
||||||
|
llms_description: e.llms_description,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
body: (e.body as string) ?? "",
|
||||||
|
}),
|
||||||
|
toEntry: (slug, fm, body) => {
|
||||||
|
const taxonomies = fm.taxonomies as { tags?: string[] } | undefined;
|
||||||
|
const extra = fm.extra as
|
||||||
|
| { series?: string; series_index?: number; llms_description?: string }
|
||||||
|
| undefined;
|
||||||
|
return {
|
||||||
|
slug,
|
||||||
|
title: fm.title,
|
||||||
|
description: fm.description,
|
||||||
|
author: fm.author,
|
||||||
|
date: isoDate(fm.date),
|
||||||
|
updated: isoDate(fm.updated),
|
||||||
|
draft: fm.draft ?? false,
|
||||||
|
canonical_url: fm.canonical_url,
|
||||||
|
tags: taxonomies?.tags ?? [],
|
||||||
|
series: extra?.series,
|
||||||
|
series_index: extra?.series_index,
|
||||||
|
llms_description: extra?.llms_description,
|
||||||
|
body,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const portfolioEntry: SyncConfig = {
|
||||||
|
uid: "api::portfolio-entry.portfolio-entry",
|
||||||
|
dir: join(root, "portfolio"),
|
||||||
|
layout: "flat",
|
||||||
|
toFile: (e) => ({
|
||||||
|
frontmatter: {
|
||||||
|
title: e.title,
|
||||||
|
description: e.description,
|
||||||
|
role: e.role,
|
||||||
|
date_start: toTomlDate(e.date_start as string),
|
||||||
|
date_end: toTomlDate(e.date_end as string),
|
||||||
|
outcome: e.outcome,
|
||||||
|
draft: e.draft ?? false,
|
||||||
|
links: e.links ?? [],
|
||||||
|
taxonomies: { tags: e.tags ?? [] },
|
||||||
|
},
|
||||||
|
body: (e.body as string) ?? "",
|
||||||
|
}),
|
||||||
|
toEntry: (slug, fm, body) => {
|
||||||
|
const taxonomies = fm.taxonomies as { tags?: string[] } | undefined;
|
||||||
|
return {
|
||||||
|
slug,
|
||||||
|
title: fm.title,
|
||||||
|
description: fm.description,
|
||||||
|
role: fm.role,
|
||||||
|
date_start: isoDate(fm.date_start),
|
||||||
|
date_end: isoDate(fm.date_end),
|
||||||
|
outcome: fm.outcome,
|
||||||
|
draft: fm.draft ?? false,
|
||||||
|
links: fm.links ?? [],
|
||||||
|
tags: taxonomies?.tags ?? [],
|
||||||
|
body,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const bookshelfEntry: SyncConfig = {
|
||||||
|
uid: "api::bookshelf-entry.bookshelf-entry",
|
||||||
|
dir: join(root, "bookshelf"),
|
||||||
|
layout: "flat",
|
||||||
|
toFile: (e) => ({
|
||||||
|
frontmatter: {
|
||||||
|
title: e.title,
|
||||||
|
kind: e.kind,
|
||||||
|
authors: e.authors ?? [],
|
||||||
|
isbn: e.isbn,
|
||||||
|
doi: e.doi,
|
||||||
|
arxiv_id: e.arxiv_id,
|
||||||
|
url: e.url,
|
||||||
|
cover_image: e.cover_image,
|
||||||
|
rating: e.rating,
|
||||||
|
read_status: e.read_status,
|
||||||
|
date_started: toTomlDate(e.date_started as string),
|
||||||
|
date_finished: toTomlDate(e.date_finished as string),
|
||||||
|
taxonomies: { tags: e.tags ?? [] },
|
||||||
|
},
|
||||||
|
body: (e.body as string) ?? "",
|
||||||
|
}),
|
||||||
|
toEntry: (slug, fm, body) => {
|
||||||
|
const taxonomies = fm.taxonomies as { tags?: string[] } | undefined;
|
||||||
|
return {
|
||||||
|
slug,
|
||||||
|
title: fm.title,
|
||||||
|
kind: fm.kind,
|
||||||
|
authors: fm.authors ?? [],
|
||||||
|
isbn: fm.isbn,
|
||||||
|
doi: fm.doi,
|
||||||
|
arxiv_id: fm.arxiv_id,
|
||||||
|
url: fm.url,
|
||||||
|
cover_image: fm.cover_image,
|
||||||
|
rating: fm.rating,
|
||||||
|
read_status: fm.read_status,
|
||||||
|
date_started: isoDate(fm.date_started),
|
||||||
|
date_finished: isoDate(fm.date_finished),
|
||||||
|
tags: taxonomies?.tags ?? [],
|
||||||
|
body,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const curatedBlog: SyncConfig = {
|
||||||
|
uid: "api::curated-blog.curated-blog",
|
||||||
|
dir: join(root, "blogs"),
|
||||||
|
layout: "flat",
|
||||||
|
toFile: (e) => ({
|
||||||
|
frontmatter: {
|
||||||
|
title: e.title,
|
||||||
|
url: e.url,
|
||||||
|
feed_url: e.feed_url,
|
||||||
|
},
|
||||||
|
body: (e.body as string) ?? "",
|
||||||
|
}),
|
||||||
|
toEntry: (slug, fm, body) => ({
|
||||||
|
slug,
|
||||||
|
title: fm.title,
|
||||||
|
url: fm.url,
|
||||||
|
feed_url: fm.feed_url,
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
[writing.uid]: writing,
|
||||||
|
[portfolioEntry.uid]: portfolioEntry,
|
||||||
|
[bookshelfEntry.uid]: bookshelfEntry,
|
||||||
|
[curatedBlog.uid]: curatedBlog,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SYNC_CONFIGS = buildConfigs();
|
||||||
|
|
||||||
|
/** Guards the outbound (DB -> file) middleware while the inbound (file -> DB) import runs. */
|
||||||
|
let importing = false;
|
||||||
|
|
||||||
|
function writeEntryForConfig(
|
||||||
|
config: SyncConfig,
|
||||||
|
entry: Record<string, unknown>,
|
||||||
|
): string | undefined {
|
||||||
|
const slug = entry.slug as string | undefined;
|
||||||
|
if (!slug) return undefined;
|
||||||
|
const { frontmatter, body } = config.toFile(entry);
|
||||||
|
return writeEntry(config.dir, slug, config.layout, frontmatter, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function coverFileForEntry(
|
||||||
|
entry: Record<string, unknown> | null,
|
||||||
|
): string | undefined {
|
||||||
|
const coverImage = entry?.cover_image;
|
||||||
|
if (typeof coverImage !== "string" || !coverImage.startsWith("/covers/")) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = resolve(coversRoot());
|
||||||
|
const file = resolve(root, coverImage.slice("/covers/".length));
|
||||||
|
const fromRoot = relative(root, file);
|
||||||
|
if (fromRoot === ".." || fromRoot.startsWith("../")) {
|
||||||
|
throw new Error(`Refusing unsafe local cover path: ${coverImage}`);
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On boot, upserts every file under content/<type>/ into Strapi so the admin
|
||||||
|
* UI reflects on-disk edits made outside Strapi. Strapi's DB stays a
|
||||||
|
* disposable cache: this can always rebuild it from content/ alone.
|
||||||
|
*/
|
||||||
|
export async function importContentFromDisk(strapi: {
|
||||||
|
documents: (uid: string) => {
|
||||||
|
findFirst: (params: {
|
||||||
|
filters: Record<string, unknown>;
|
||||||
|
}) => Promise<Record<string, unknown> | null>;
|
||||||
|
create: (params: { data: Record<string, unknown> }) => Promise<unknown>;
|
||||||
|
update: (params: {
|
||||||
|
documentId: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
}) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
log: { info: (msg: string) => void; error: (msg: string) => void };
|
||||||
|
}): Promise<void> {
|
||||||
|
importing = true;
|
||||||
|
try {
|
||||||
|
for (const config of Object.values(SYNC_CONFIGS)) {
|
||||||
|
const files = readAllEntries(config.dir);
|
||||||
|
let created = 0;
|
||||||
|
let updated = 0;
|
||||||
|
for (const { slug, frontmatter, body } of files) {
|
||||||
|
const data = config.toEntry(slug, frontmatter, body);
|
||||||
|
try {
|
||||||
|
const existing = await strapi
|
||||||
|
.documents(config.uid)
|
||||||
|
.findFirst({ filters: { slug } });
|
||||||
|
if (existing) {
|
||||||
|
await strapi
|
||||||
|
.documents(config.uid)
|
||||||
|
.update({ documentId: existing.documentId as string, data });
|
||||||
|
updated += 1;
|
||||||
|
} else {
|
||||||
|
await strapi.documents(config.uid).create({ data });
|
||||||
|
created += 1;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
strapi.log.error(
|
||||||
|
`content-sync: failed to import ${config.dir}/${slug}: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
strapi.log.info(
|
||||||
|
`content-sync: imported ${config.uid} (${created} created, ${updated} updated, ${files.length} total)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
importing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DocumentsClient {
|
||||||
|
findOne: (params: {
|
||||||
|
documentId: string;
|
||||||
|
}) => Promise<Record<string, unknown> | null>;
|
||||||
|
update: (params: {
|
||||||
|
documentId: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
}) => Promise<Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backfills a bookshelf entry's cover — either by caching whatever URL is
|
||||||
|
* already in `cover_image` (e.g. one pasted in by hand, or an editor's own
|
||||||
|
* hosted cover) locally, or by looking one up (books via Open Library by
|
||||||
|
* ISBN) when there's no cover_image at all yet — and, for papers, missing
|
||||||
|
* title/authors via Semantic Scholar by DOI/arXiv id. Runs once per entry:
|
||||||
|
* a `cover_image` already pointing at a local /covers/... path is treated
|
||||||
|
* as "done" and left alone.
|
||||||
|
*/
|
||||||
|
async function enrichBookshelfEntry(
|
||||||
|
documents: (uid: string) => DocumentsClient,
|
||||||
|
entry: Record<string, unknown>,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const slug = entry.slug as string;
|
||||||
|
const coverImage = entry.cover_image as string | undefined;
|
||||||
|
const patch: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (coverImage && /^https?:\/\//i.test(coverImage)) {
|
||||||
|
const cached = await cacheRemoteImage(coverImage, slug);
|
||||||
|
if (cached) patch.cover_image = cached;
|
||||||
|
} else if (!coverImage) {
|
||||||
|
if (entry.kind === "book" && entry.isbn) {
|
||||||
|
const cover = await fetchBookCover(entry.isbn as string, slug);
|
||||||
|
if (cover) patch.cover_image = cover;
|
||||||
|
} else if (entry.kind === "paper" && (entry.doi || entry.arxiv_id)) {
|
||||||
|
const meta = await fetchPaperMetadata({
|
||||||
|
doi: entry.doi as string | undefined,
|
||||||
|
arxivId: entry.arxiv_id as string | undefined,
|
||||||
|
});
|
||||||
|
if (meta?.title && !entry.title) patch.title = meta.title;
|
||||||
|
if (
|
||||||
|
meta?.authors?.length &&
|
||||||
|
!(entry.authors as string[] | undefined)?.length
|
||||||
|
) {
|
||||||
|
patch.authors = meta.authors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Network/API failures shouldn't block saving the entry itself.
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(patch).length === 0) return entry;
|
||||||
|
// Keep the nested update outside the network-failure guard. Its middleware
|
||||||
|
// persists and publishes the enriched entry; publication errors must reach
|
||||||
|
// the caller instead of being mistaken for a cover-provider outage.
|
||||||
|
return documents(BOOKSHELF_UID).update({
|
||||||
|
documentId: entry.documentId as string,
|
||||||
|
data: patch,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Registers the outbound (DB -> file) sync as a Document Service middleware. */
|
||||||
|
export function registerContentSync(strapi: {
|
||||||
|
documents: {
|
||||||
|
use: (
|
||||||
|
middleware: (
|
||||||
|
ctx: { uid: string; action: string; params: Record<string, unknown> },
|
||||||
|
next: () => Promise<unknown>,
|
||||||
|
) => Promise<unknown>,
|
||||||
|
) => void;
|
||||||
|
(uid: string): DocumentsClient;
|
||||||
|
};
|
||||||
|
log: { info: (msg: string) => void; error: (msg: string) => void };
|
||||||
|
}): void {
|
||||||
|
strapi.documents.use(async (ctx, next) => {
|
||||||
|
const config = SYNC_CONFIGS[ctx.uid];
|
||||||
|
if (!config || importing) return next();
|
||||||
|
|
||||||
|
let preDeleteEntry: Record<string, unknown> | null = null;
|
||||||
|
if (ctx.action === "delete") {
|
||||||
|
const documentId = (ctx.params as { documentId?: string }).documentId;
|
||||||
|
if (documentId) {
|
||||||
|
preDeleteEntry = await strapi
|
||||||
|
.documents(ctx.uid)
|
||||||
|
.findOne({ documentId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await next();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
(ctx.action === "create" || ctx.action === "update") &&
|
||||||
|
result &&
|
||||||
|
typeof result === "object"
|
||||||
|
) {
|
||||||
|
const entry =
|
||||||
|
ctx.uid === BOOKSHELF_UID
|
||||||
|
? await enrichBookshelfEntry(
|
||||||
|
strapi.documents,
|
||||||
|
result as Record<string, unknown>,
|
||||||
|
)
|
||||||
|
: (result as Record<string, unknown>);
|
||||||
|
const entryFile = writeEntryForConfig(config, entry);
|
||||||
|
if (entryFile) {
|
||||||
|
const paths = [entryFile];
|
||||||
|
const coverFile = coverFileForEntry(entry);
|
||||||
|
if (coverFile) paths.push(coverFile);
|
||||||
|
await publishContentChanges(
|
||||||
|
paths,
|
||||||
|
`content: ${ctx.action} ${entry.slug as string}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (ctx.action === "delete" && preDeleteEntry) {
|
||||||
|
const slug = preDeleteEntry.slug as string | undefined;
|
||||||
|
if (!slug) return result;
|
||||||
|
|
||||||
|
const paths = [deleteEntry(config.dir, slug, config.layout)];
|
||||||
|
const coverFile = coverFileForEntry(preDeleteEntry);
|
||||||
|
if (coverFile) {
|
||||||
|
rmSync(coverFile, { force: true });
|
||||||
|
paths.push(coverFile);
|
||||||
|
}
|
||||||
|
await publishContentChanges(paths, `content: delete ${slug}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
strapi.log.error(
|
||||||
|
`content-sync: failed to write ${ctx.uid} to disk: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { relative, resolve } from "node:path";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { repositoryRoot } from "./content-paths";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
interface GitPublisherConfig {
|
||||||
|
worktree: string;
|
||||||
|
remote?: string;
|
||||||
|
branch?: string;
|
||||||
|
token?: string;
|
||||||
|
commitName?: string;
|
||||||
|
commitEmail?: string;
|
||||||
|
deployWebhookUrl?: string;
|
||||||
|
fetchImpl?: typeof fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
function gitAuthenticationEnv(token?: string): NodeJS.ProcessEnv {
|
||||||
|
if (!token) return process.env;
|
||||||
|
|
||||||
|
const credentials = Buffer.from(`x-access-token:${token}`).toString("base64");
|
||||||
|
return {
|
||||||
|
...process.env,
|
||||||
|
GIT_CONFIG_COUNT: "1",
|
||||||
|
GIT_CONFIG_KEY_0: "http.https://github.com/.extraheader",
|
||||||
|
GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${credentials}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GitPublisher {
|
||||||
|
private readonly worktree: string;
|
||||||
|
private readonly remote: string;
|
||||||
|
private readonly branch: string;
|
||||||
|
private readonly token?: string;
|
||||||
|
private readonly commitName: string;
|
||||||
|
private readonly commitEmail: string;
|
||||||
|
private readonly deployWebhookUrl?: string;
|
||||||
|
private readonly fetchImpl: typeof fetch;
|
||||||
|
private queue: Promise<void> = Promise.resolve();
|
||||||
|
private deployPending = false;
|
||||||
|
|
||||||
|
constructor(config: GitPublisherConfig) {
|
||||||
|
this.worktree = resolve(config.worktree);
|
||||||
|
this.remote = config.remote ?? "origin";
|
||||||
|
this.branch = config.branch ?? "main";
|
||||||
|
this.token = config.token;
|
||||||
|
this.commitName = config.commitName ?? "andwati.com CMS";
|
||||||
|
this.commitEmail =
|
||||||
|
config.commitEmail ?? "[email protected]";
|
||||||
|
this.deployWebhookUrl = config.deployWebhookUrl;
|
||||||
|
this.fetchImpl = config.fetchImpl ?? fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
publish(paths: string[], message: string): Promise<void> {
|
||||||
|
const operation = this.queue.then(() => this.publishNow(paths, message));
|
||||||
|
this.queue = operation.catch(() => undefined);
|
||||||
|
return operation;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async git(args: string[]): Promise<string> {
|
||||||
|
const { stdout } = await execFileAsync("git", args, {
|
||||||
|
cwd: this.worktree,
|
||||||
|
env: gitAuthenticationEnv(this.token),
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
return stdout.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private repositoryPaths(paths: string[]): string[] {
|
||||||
|
return [...new Set(paths)].map((path) => {
|
||||||
|
const absolute = resolve(path);
|
||||||
|
const repositoryPath = relative(this.worktree, absolute);
|
||||||
|
if (
|
||||||
|
repositoryPath === "" ||
|
||||||
|
repositoryPath === ".." ||
|
||||||
|
repositoryPath.startsWith("../")
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Refusing to publish path outside Git checkout: ${path}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return repositoryPath;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async hasStagedChanges(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await this.git(["diff", "--cached", "--quiet"]);
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as { code?: number }).code === 1) return true;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async publishNow(paths: string[], message: string): Promise<void> {
|
||||||
|
const repositoryPaths = this.repositoryPaths(paths);
|
||||||
|
await this.git(["add", "--all", "--", ...repositoryPaths]);
|
||||||
|
|
||||||
|
if (await this.hasStagedChanges()) {
|
||||||
|
await this.git([
|
||||||
|
"-c",
|
||||||
|
`user.name=${this.commitName}`,
|
||||||
|
"-c",
|
||||||
|
`user.email=${this.commitEmail}`,
|
||||||
|
"-c",
|
||||||
|
"commit.gpgsign=false",
|
||||||
|
"commit",
|
||||||
|
"-m",
|
||||||
|
message,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.git(["fetch", this.remote, this.branch]);
|
||||||
|
try {
|
||||||
|
await this.git([
|
||||||
|
"rebase",
|
||||||
|
"--autostash",
|
||||||
|
`${this.remote}/${this.branch}`,
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
await this.git(["rebase", "--abort"]).catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ahead = Number(
|
||||||
|
await this.git([
|
||||||
|
"rev-list",
|
||||||
|
"--count",
|
||||||
|
`${this.remote}/${this.branch}..HEAD`,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
if (ahead > 0) {
|
||||||
|
await this.git(["push", this.remote, `HEAD:${this.branch}`]);
|
||||||
|
this.deployPending = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.deployPending && this.deployWebhookUrl) {
|
||||||
|
const response = await this.fetchImpl(this.deployWebhookUrl, {
|
||||||
|
method: "POST",
|
||||||
|
signal: AbortSignal.timeout(15_000),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Dokploy deploy webhook returned ${response.status} ${response.statusText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.deployPending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let publisher: GitPublisher | undefined;
|
||||||
|
|
||||||
|
function publisherFromEnvironment(): GitPublisher | undefined {
|
||||||
|
if (process.env.GIT_PUBLISH_ENABLED !== "true") return undefined;
|
||||||
|
|
||||||
|
const token = process.env.GITHUB_TOKEN;
|
||||||
|
const deployWebhookUrl = process.env.DOKPLOY_DEPLOY_WEBHOOK_URL;
|
||||||
|
if (!token) throw new Error("GITHUB_TOKEN is required for Git publishing");
|
||||||
|
if (!deployWebhookUrl) {
|
||||||
|
throw new Error(
|
||||||
|
"DOKPLOY_DEPLOY_WEBHOOK_URL is required for Git publishing",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new GitPublisher({
|
||||||
|
worktree: repositoryRoot(),
|
||||||
|
remote: process.env.CONTENT_GIT_REMOTE ?? "origin",
|
||||||
|
branch: process.env.CONTENT_GIT_BRANCH ?? "main",
|
||||||
|
token,
|
||||||
|
commitName: process.env.CONTENT_GIT_COMMIT_NAME,
|
||||||
|
commitEmail: process.env.CONTENT_GIT_COMMIT_EMAIL,
|
||||||
|
deployWebhookUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Publishes changed source files when production Git publishing is enabled. */
|
||||||
|
export async function publishContentChanges(
|
||||||
|
paths: string[],
|
||||||
|
message: string,
|
||||||
|
): Promise<void> {
|
||||||
|
publisher ??= publisherFromEnvironment();
|
||||||
|
await publisher?.publish(paths, message);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import {
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
import { GitPublisher } from "../src/utils/git-publisher.ts";
|
||||||
|
|
||||||
|
function git(cwd: string, ...args: string[]): string {
|
||||||
|
return execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function repositoryFixture(): {
|
||||||
|
root: string;
|
||||||
|
worktree: string;
|
||||||
|
cleanup: () => void;
|
||||||
|
} {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "andwati-git-publisher-"));
|
||||||
|
const remote = join(root, "remote.git");
|
||||||
|
const worktree = join(root, "worktree");
|
||||||
|
|
||||||
|
git(root, "init", "--bare", remote);
|
||||||
|
git(root, "clone", remote, worktree);
|
||||||
|
writeFileSync(join(worktree, "README.md"), "seed\n");
|
||||||
|
git(worktree, "add", "README.md");
|
||||||
|
git(
|
||||||
|
worktree,
|
||||||
|
"-c",
|
||||||
|
"user.name=Test",
|
||||||
|
"-c",
|
||||||
|
"[email protected]",
|
||||||
|
"-c",
|
||||||
|
"commit.gpgsign=false",
|
||||||
|
"commit",
|
||||||
|
"-m",
|
||||||
|
"seed",
|
||||||
|
);
|
||||||
|
git(worktree, "branch", "-M", "main");
|
||||||
|
git(worktree, "push", "-u", "origin", "main");
|
||||||
|
|
||||||
|
return {
|
||||||
|
root,
|
||||||
|
worktree,
|
||||||
|
cleanup: () => rmSync(root, { recursive: true, force: true }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("commits, pushes, and deploys a changed source file", async () => {
|
||||||
|
const fixture = repositoryFixture();
|
||||||
|
let deploys = 0;
|
||||||
|
const publisher = new GitPublisher({
|
||||||
|
worktree: fixture.worktree,
|
||||||
|
branch: "main",
|
||||||
|
deployWebhookUrl: "https://deploy.invalid/hook",
|
||||||
|
fetchImpl: async () => {
|
||||||
|
deploys += 1;
|
||||||
|
return new Response(null, { status: 200 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const contentFile = join(fixture.worktree, "content", "post.md");
|
||||||
|
mkdirSync(join(fixture.worktree, "content"));
|
||||||
|
writeFileSync(contentFile, "published\n", { flag: "w" });
|
||||||
|
const concurrentFile = join(fixture.worktree, "content", "next.md");
|
||||||
|
writeFileSync(concurrentFile, "still being edited\n");
|
||||||
|
await publisher.publish([contentFile], "content: update post");
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
git(fixture.worktree, "show", "origin/main:content/post.md"),
|
||||||
|
"published",
|
||||||
|
);
|
||||||
|
assert.equal(deploys, 1);
|
||||||
|
assert.equal(readFileSync(concurrentFile, "utf8"), "still being edited\n");
|
||||||
|
assert.match(git(fixture.worktree, "status", "--short"), /next\.md/);
|
||||||
|
|
||||||
|
await publisher.publish([contentFile], "content: update post");
|
||||||
|
assert.equal(deploys, 1, "unchanged content must not trigger a deploy");
|
||||||
|
} finally {
|
||||||
|
fixture.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("retries a pending deploy without creating another commit", async () => {
|
||||||
|
const fixture = repositoryFixture();
|
||||||
|
let deploys = 0;
|
||||||
|
const publisher = new GitPublisher({
|
||||||
|
worktree: fixture.worktree,
|
||||||
|
branch: "main",
|
||||||
|
deployWebhookUrl: "https://deploy.invalid/hook",
|
||||||
|
fetchImpl: async () => {
|
||||||
|
deploys += 1;
|
||||||
|
return new Response(null, {
|
||||||
|
status: deploys === 1 ? 503 : 200,
|
||||||
|
statusText: deploys === 1 ? "Unavailable" : "OK",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const contentFile = join(fixture.worktree, "entry.md");
|
||||||
|
writeFileSync(contentFile, "durable\n");
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
publisher.publish([contentFile], "content: create entry"),
|
||||||
|
/Dokploy deploy webhook returned 503/,
|
||||||
|
);
|
||||||
|
await publisher.publish([contentFile], "content: create entry");
|
||||||
|
|
||||||
|
assert.equal(deploys, 2);
|
||||||
|
assert.equal(
|
||||||
|
git(fixture.worktree, "rev-list", "--count", "origin/main"),
|
||||||
|
"2",
|
||||||
|
"the webhook retry must not create a duplicate commit",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fixture.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"lib": ["ES2020"],
|
||||||
|
"target": "ES2019",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"incremental": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"noEmitOnError": true,
|
||||||
|
"noImplicitThis": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "."
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
// Include root files
|
||||||
|
"./",
|
||||||
|
// Include all ts files
|
||||||
|
"./**/*.ts",
|
||||||
|
// Include all js files
|
||||||
|
"./**/*.js",
|
||||||
|
// Force the JSON files in the src folder to be included
|
||||||
|
"src/**/*.json"
|
||||||
|
],
|
||||||
|
|
||||||
|
"exclude": [
|
||||||
|
"node_modules/",
|
||||||
|
"build/",
|
||||||
|
"dist/",
|
||||||
|
".cache/",
|
||||||
|
".tmp/",
|
||||||
|
".strapi/",
|
||||||
|
|
||||||
|
// Do not include admin files in the server compilation
|
||||||
|
"src/admin/",
|
||||||
|
// Do not include test files
|
||||||
|
"**/*.test.*",
|
||||||
|
// Do not include plugins in the server compilation
|
||||||
|
"src/plugins/**"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/*
|
||||||
|
* The app doesn't have any components yet.
|
||||||
|
*/
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Self-hosted Umami analytics (https://umami.is/docs/tracker-configuration).
|
||||||
|
# Leave unset to disable analytics entirely — Analytics.astro no-ops until
|
||||||
|
# both of these are present. Fill in once the Umami instance is deployed.
|
||||||
|
PUBLIC_UMAMI_SCRIPT_URL=
|
||||||
|
PUBLIC_UMAMI_WEBSITE_ID=
|
||||||
|
|
||||||
|
# Optional: restrict tracking to these comma-separated domains (Umami's
|
||||||
|
# data-domains attribute), useful if the same build is deployed to a
|
||||||
|
# staging domain and you only want production traffic counted.
|
||||||
|
PUBLIC_UMAMI_DOMAINS=
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# build output
|
||||||
|
dist/
|
||||||
|
# generated types
|
||||||
|
.astro/
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
|
||||||
|
# environment variables
|
||||||
|
.env
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# Lighthouse CI report artifacts
|
||||||
|
.lighthouseci/
|
||||||
|
|
||||||
|
# macOS-specific files
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# jetbrains setting folder
|
||||||
|
.idea/
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["astro-build.astro-vscode"],
|
||||||
|
"unwantedRecommendations": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"command": "./node_modules/.bin/astro dev",
|
||||||
|
"name": "Development server",
|
||||||
|
"request": "launch",
|
||||||
|
"type": "node-terminal"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
## Development
|
||||||
|
|
||||||
|
When starting the dev server, use background mode:
|
||||||
|
|
||||||
|
```
|
||||||
|
astro dev --background
|
||||||
|
```
|
||||||
|
|
||||||
|
Manage the background server with `astro dev stop`, `astro dev status`, and `astro dev logs`.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Full documentation: https://docs.astro.build
|
||||||
|
|
||||||
|
Consult these guides before working on related tasks:
|
||||||
|
|
||||||
|
- [Adding pages, dynamic routes, or middleware](https://docs.astro.build/en/guides/routing/)
|
||||||
|
- [Working with Astro components](https://docs.astro.build/en/basics/astro-components/)
|
||||||
|
- [Using React, Vue, Svelte, or other framework components](https://docs.astro.build/en/guides/framework-components/)
|
||||||
|
- [Adding or managing content](https://docs.astro.build/en/guides/content-collections/)
|
||||||
|
- [Adding styles or using Tailwind](https://docs.astro.build/en/guides/styling/)
|
||||||
|
- [Supporting multiple languages](https://docs.astro.build/en/guides/internationalization/)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
AGENTS.md
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Astro Starter Kit: Minimal
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm create astro@latest -- --template minimal
|
||||||
|
```
|
||||||
|
|
||||||
|
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
||||||
|
|
||||||
|
## 🚀 Project Structure
|
||||||
|
|
||||||
|
Inside of your Astro project, you'll see the following folders and files:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/
|
||||||
|
├── public/
|
||||||
|
├── src/
|
||||||
|
│ └── pages/
|
||||||
|
│ └── index.astro
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
|
||||||
|
|
||||||
|
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
|
||||||
|
|
||||||
|
Any static assets, like images, can be placed in the `public/` directory.
|
||||||
|
|
||||||
|
## 🧞 Commands
|
||||||
|
|
||||||
|
All commands are run from the root of the project, from a terminal:
|
||||||
|
|
||||||
|
| Command | Action |
|
||||||
|
| :------------------------ | :----------------------------------------------- |
|
||||||
|
| `npm install` | Installs dependencies |
|
||||||
|
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
||||||
|
| `npm run build` | Build your production site to `./dist/` |
|
||||||
|
| `npm run preview` | Preview your build locally, before deploying |
|
||||||
|
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||||
|
| `npm run astro -- --help` | Get help using the Astro CLI |
|
||||||
|
|
||||||
|
## 👀 Want to learn more?
|
||||||
|
|
||||||
|
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// @ts-check
|
||||||
|
import sitemap from "@astrojs/sitemap";
|
||||||
|
import { defineConfig } from "astro/config";
|
||||||
|
import { buildLastmodMap, resolveContentDir } from "./lib-config/sitemap-lastmod.mjs";
|
||||||
|
import { siteConfig } from "./src/site.config.ts";
|
||||||
|
|
||||||
|
const lastmodMap = buildLastmodMap(resolveContentDir(import.meta.url));
|
||||||
|
|
||||||
|
// https://astro.build/config
|
||||||
|
export default defineConfig({
|
||||||
|
site: siteConfig.baseUrl,
|
||||||
|
integrations: [
|
||||||
|
sitemap({
|
||||||
|
// Every build re-crawls the actual rendered output, so the sitemap's
|
||||||
|
// URL list always matches whatever pages/content exist right now — no
|
||||||
|
// separate "regenerate the sitemap" step needed when content changes.
|
||||||
|
// On top of that, <lastmod> is set from each entry's own
|
||||||
|
// date/updated field (falling back to build time when unmapped),
|
||||||
|
// instead of every URL just getting "whenever the site was last
|
||||||
|
// built".
|
||||||
|
serialize(item) {
|
||||||
|
const lastmod = lastmodMap.get(new URL(item.url).pathname);
|
||||||
|
return lastmod ? { ...item, lastmod } : item;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// Builds a Map<pathname, ISO date string> from content/ frontmatter so the
|
||||||
|
// sitemap's <lastmod> reflects real edit dates instead of just "whenever the
|
||||||
|
// site was last built". Runs from astro.config.mjs, which executes as plain
|
||||||
|
// Node outside Vite/Astro's content-layer pipeline (same reason
|
||||||
|
// scripts/generate-llms.astro-site.mjs parses frontmatter by hand instead of
|
||||||
|
// importing astro:content) — duplicated here rather than shared because the
|
||||||
|
// two scripts' output shapes don't overlap enough to be worth a shared module.
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { parse as parseToml } from "smol-toml";
|
||||||
|
|
||||||
|
const FRONTMATTER_RE = /^\+\+\+\r?\n([\s\S]*?)\r?\n\+\+\+\r?\n?([\s\S]*)$/;
|
||||||
|
|
||||||
|
function walk(dir) {
|
||||||
|
if (!fs.existsSync(dir)) return [];
|
||||||
|
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) return walk(full);
|
||||||
|
return entry.isFile() && entry.name.endsWith(".md") ? [full] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugFor(dir, file) {
|
||||||
|
const rel = path.relative(dir, file).split(path.sep).join("/");
|
||||||
|
return rel.endsWith("/index.md") ? rel.slice(0, -"/index.md".length) : rel.slice(0, -".md".length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readEntries(typeDir) {
|
||||||
|
return walk(typeDir)
|
||||||
|
.map((file) => {
|
||||||
|
const raw = fs.readFileSync(file, "utf8");
|
||||||
|
const match = raw.match(FRONTMATTER_RE);
|
||||||
|
if (!match) return null;
|
||||||
|
return { slug: slugFor(typeDir, file), frontmatter: parseToml(match[1]) };
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoOrNull(value) {
|
||||||
|
if (!value) return null;
|
||||||
|
const d = value instanceof Date ? value : new Date(value);
|
||||||
|
return Number.isNaN(d.valueOf()) ? null : d.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLastmodMap(contentDir) {
|
||||||
|
const map = new Map();
|
||||||
|
|
||||||
|
for (const entry of readEntries(path.join(contentDir, "writings"))) {
|
||||||
|
if (entry.frontmatter.draft) continue;
|
||||||
|
const lastmod = isoOrNull(entry.frontmatter.updated ?? entry.frontmatter.date);
|
||||||
|
if (lastmod) map.set(`/writings/${entry.slug}/`, lastmod);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of readEntries(path.join(contentDir, "bookshelf"))) {
|
||||||
|
const lastmod = isoOrNull(entry.frontmatter.date_finished ?? entry.frontmatter.date_started);
|
||||||
|
if (lastmod) map.set(`/bookshelf/${entry.slug}/`, lastmod);
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveContentDir(configFileUrl) {
|
||||||
|
return path.join(path.dirname(fileURLToPath(configFileUrl)), "..", "..", "content");
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Lighthouse CI — runs the same audits PageSpeed Insights reports on
|
||||||
|
* (performance, accessibility, best-practices, SEO) against the built site,
|
||||||
|
* so results can be tracked over time / per-commit without depending on the
|
||||||
|
* hosted PSI API. `staticDistDir` lets lhci serve `dist/` itself, no dev
|
||||||
|
* server juggling needed. Reporting only for now — no `assert` thresholds,
|
||||||
|
* so a regression shows up in the report without failing the build.
|
||||||
|
*/
|
||||||
|
module.exports = {
|
||||||
|
ci: {
|
||||||
|
collect: {
|
||||||
|
staticDistDir: "./dist",
|
||||||
|
url: [
|
||||||
|
"/index.html",
|
||||||
|
"/writings/index.html",
|
||||||
|
"/writings/from-c-to-machine-code/index.html",
|
||||||
|
"/archive/index.html",
|
||||||
|
"/portfolio/index.html",
|
||||||
|
"/bookshelf/index.html",
|
||||||
|
"/tags/index.html",
|
||||||
|
"/about/index.html",
|
||||||
|
],
|
||||||
|
numberOfRuns: 3,
|
||||||
|
},
|
||||||
|
upload: {
|
||||||
|
// Free, no account/server needed — each run prints a shareable report
|
||||||
|
// link (expires after 7 days). Point this at a real LHCI server later
|
||||||
|
// if longer-lived historical tracking is wanted.
|
||||||
|
target: "temporary-public-storage",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// Desktop form-factor pass — same pages/config as lighthouserc.cjs, but
|
||||||
|
// Lighthouse's default (and PageSpeed Insights' default) is mobile: throttled
|
||||||
|
// CPU/network and a narrow viewport. Auditing desktop too means neither form
|
||||||
|
// factor's numbers are a blind spot.
|
||||||
|
const mobileConfig = require("./lighthouserc.cjs");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ci: {
|
||||||
|
...mobileConfig.ci,
|
||||||
|
collect: {
|
||||||
|
...mobileConfig.ci.collect,
|
||||||
|
settings: { preset: "desktop" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "site",
|
||||||
|
"type": "module",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.12.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "astro dev",
|
||||||
|
"generate:llms": "node ../../scripts/generate-llms.astro-site.mjs",
|
||||||
|
"generate:og": "node ../../scripts/generate-og-images.astro-site.mjs",
|
||||||
|
"build": "pnpm run generate:llms && pnpm run generate:og && astro build && pagefind --site dist",
|
||||||
|
"preview": "astro preview",
|
||||||
|
"check": "astro check",
|
||||||
|
"astro": "astro",
|
||||||
|
"lighthouse": "pnpm run build && lhci autorun && lhci autorun --config=lighthouserc.desktop.cjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@astrojs/rss": "^4.0.19",
|
||||||
|
"@astrojs/sitemap": "^3.7.3",
|
||||||
|
"@fontsource-variable/fraunces": "^5.3.0",
|
||||||
|
"@fontsource-variable/newsreader": "^5.3.0",
|
||||||
|
"@fontsource/jetbrains-mono": "^5.3.0",
|
||||||
|
"astro": "^7.2.9",
|
||||||
|
"highlight.js": "^11.12.0",
|
||||||
|
"katex": "^0.18.7",
|
||||||
|
"marked": "^18.0.11",
|
||||||
|
"marked-katex-extension": "^5.1.12",
|
||||||
|
"pagefind": "^1.5.2",
|
||||||
|
"smol-toml": "^1.8.0",
|
||||||
|
"three": "^0.185.1"
|
||||||
|
},
|
||||||
|
"allowScripts": {
|
||||||
|
"esbuild": true
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@astrojs/check": "^0.9.10",
|
||||||
|
"@lhci/cli": "^0.15.1",
|
||||||
|
"@types/three": "^0.185.4",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 40 KiB |
@@ -0,0 +1,54 @@
|
|||||||
|
# Thoughts and Musings
|
||||||
|
|
||||||
|
> Ian Andwati's personal digital-legacy site covering computer science and mathematics — writings, portfolio, and a curated bookshelf.
|
||||||
|
|
||||||
|
## Main pages
|
||||||
|
|
||||||
|
- [Home](https://andwati.com/): Introduction and latest writings.
|
||||||
|
- [Writings](https://andwati.com/writings/): Long-form essays and technical posts.
|
||||||
|
- [Portfolio](https://andwati.com/portfolio/): Projects and professional work.
|
||||||
|
- [Bookshelf](https://andwati.com/bookshelf/): Books and papers read, with notes.
|
||||||
|
- [Blogs](https://andwati.com/blogs/): Curated external blogs.
|
||||||
|
- [Tags](https://andwati.com/tags/): Writings grouped by topic.
|
||||||
|
|
||||||
|
## Writings
|
||||||
|
|
||||||
|
- [What is pwn?](https://andwati.com/writings/what-is-pwn/): No not that one
|
||||||
|
- [Export default and module.exports in Javascript](https://andwati.com/writings/javascript-exporting-modules/): Modules are the building blocks of modern JavaScript applications, allowing for the encapsulation of code into reusable components. Two primary systems govern the exportation of these modules: export default in ES6 and module.exports in CommonJS.
|
||||||
|
- [cognitive inertia](https://andwati.com/writings/inertia/): inertia
|
||||||
|
- [Self-Hosting Gitea and Automating GitHub Mirrors](https://andwati.com/writings/self-hosting-gitea-and-mirroring-github/): Self-Hosting Gitea and Automating GitHub Mirrors (and Everything Cloudflare Broke Along the Way)
|
||||||
|
- [From C to Machine Code: What Actually Runs](https://andwati.com/writings/from-c-to-machine-code/): Before diving into how to break a program, we need to understand what the computer is actually executing
|
||||||
|
- [Semantic Versioning(SemVer) and Conventional Commits](https://andwati.com/writings/semantic-version-and-conventional-commits/): Adopting best practices in your git and build workflows.
|
||||||
|
- [Integrating Netlify CMS](https://andwati.com/writings/integrating-netlify/): Integrating netlify cms
|
||||||
|
- [Install PostgreSQL on Arch | Manjaro | Garuda Linux](https://andwati.com/writings/install-postgresql-15-on-arch-manjaro-garuda-linux/): Getting PostgreSQL up and running on Arch Linux-based distros
|
||||||
|
- [A Fresh Coat Of Paint](https://andwati.com/writings/a-fresh-coat-of-paint/): Migrating to Zola
|
||||||
|
- [What say you?](https://andwati.com/writings/utterances-comments/): Adding a comment system by utterances
|
||||||
|
- [The One Thing Nobody Tells You About WiFi Hotspots on Arch Linux](https://andwati.com/writings/wifi-hotspot-arch/): A minimal Arch install is great until NetworkManager silently fails to find a binary you didn't know you needed
|
||||||
|
- [Setting up Obsidian sync in Windows](https://andwati.com/writings/obsidian-sync/): Syncing Obsidian in Windows with Windows Task Scheduler with git
|
||||||
|
- [The Single Byte That Kills Your Exploit: Understanding Endianness](https://andwati.com/writings/the-single-byte-that-kills-your-exploit-understanding-endianness/): Problem: Computers speak different languages, like people
|
||||||
|
- [mmuctf 2025 writeup](https://andwati.com/writings/mmuctf2025/): mmuctf 2025 challenges writeup
|
||||||
|
- [Configure Vue.js app With Tailwind CSS](https://andwati.com/writings/vuejs-tailwind/): Tailwindcss in vuejs
|
||||||
|
- [Subresource Integrity](https://andwati.com/writings/subresource-integrity/): Understanding Subresource Integrity
|
||||||
|
- [ScriptCTF 2025 Writeup](https://andwati.com/writings/scriptctf2025/): ScriptCTF 2025 Writeup
|
||||||
|
- [Django Secret Key Tutorial](https://andwati.com/writings/generate-django-secret-key/): Managing secrets
|
||||||
|
- [Hello World](https://andwati.com/writings/hello-world/): Hello world
|
||||||
|
|
||||||
|
## Portfolio
|
||||||
|
|
||||||
|
- [Example Project](https://andwati.com/portfolio/): Established the portfolio content schema.
|
||||||
|
|
||||||
|
## Bookshelf
|
||||||
|
|
||||||
|
- [Attention Is All You Need](https://andwati.com/bookshelf/): Ashish Vaswani, Noam Shazeer, Niki Parmar
|
||||||
|
- [Designing Data-Intensive Applications, 2nd Edition](https://andwati.com/bookshelf/): Martin Kleppmann, Chris Riccomini
|
||||||
|
- [Structure and Interpretation of Computer Programs](https://andwati.com/bookshelf/): Harold Abelson, Gerald Jay Sussman
|
||||||
|
|
||||||
|
## Blogs followed
|
||||||
|
|
||||||
|
- [Example Blog](https://example.com)
|
||||||
|
|
||||||
|
## Feeds and discovery
|
||||||
|
|
||||||
|
- [RSS feed](https://andwati.com/rss.xml): Full writings feed in RSS format.
|
||||||
|
- [JSON feed](https://andwati.com/feed.json): Full writings feed in JSON Feed format.
|
||||||
|
- [Sitemap](https://andwati.com/sitemap-index.xml): Machine-readable index of public pages.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
# AI assistants / agents / LLM training crawlers — explicitly welcome.
|
||||||
|
# See /llms.txt for a crawler-friendly summary of this site's content.
|
||||||
|
User-agent: GPTBot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: ChatGPT-User
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: OAI-SearchBot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: ClaudeBot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: Claude-Web
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: anthropic-ai
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: PerplexityBot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: Perplexity-User
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: Google-Extended
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: Applebot-Extended
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: CCBot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: Bytespider
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: Amazonbot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://andwati.com/sitemap-index.xml
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* Self-hosted Umami analytics. No-ops until PUBLIC_UMAMI_SCRIPT_URL and
|
||||||
|
* PUBLIC_UMAMI_WEBSITE_ID are set (see .env.example) — fill those in once
|
||||||
|
* the Umami instance is deployed, no code changes needed.
|
||||||
|
*/
|
||||||
|
const scriptUrl = import.meta.env.PUBLIC_UMAMI_SCRIPT_URL;
|
||||||
|
const websiteId = import.meta.env.PUBLIC_UMAMI_WEBSITE_ID;
|
||||||
|
const domains = import.meta.env.PUBLIC_UMAMI_DOMAINS;
|
||||||
|
---
|
||||||
|
|
||||||
|
{
|
||||||
|
scriptUrl && websiteId && (
|
||||||
|
<script
|
||||||
|
defer
|
||||||
|
src={scriptUrl}
|
||||||
|
data-website-id={websiteId}
|
||||||
|
data-domains={domains || undefined}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
---
|
||||||
|
import { coverExists } from "../lib/cover-exists";
|
||||||
|
import { spineStyle } from "../lib/spine-color";
|
||||||
|
|
||||||
|
interface Item {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
coverImage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items: Item[];
|
||||||
|
activeSlug?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { items, activeSlug } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="spines" data-spines>
|
||||||
|
<div class="spines-track" data-track>
|
||||||
|
{
|
||||||
|
items.map((item) => {
|
||||||
|
const active = item.slug === activeSlug;
|
||||||
|
const style = spineStyle(item.title);
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={`/bookshelf/${item.slug}/`}
|
||||||
|
class:list={["spine", { "spine--active": active }]}
|
||||||
|
style={active ? undefined : { background: style.background, color: style.color, width: `${style.width}px` }}
|
||||||
|
title={item.title}
|
||||||
|
aria-current={active ? "page" : undefined}
|
||||||
|
>
|
||||||
|
{active && coverExists(item.coverImage) ? (
|
||||||
|
<img src={item.coverImage} alt={item.title} loading="lazy" />
|
||||||
|
) : active ? (
|
||||||
|
<span class="spine-cover-fallback" style={{ background: style.background, color: style.color }}>
|
||||||
|
{item.title}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>{item.title}</span>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="spines-next" data-next aria-label="Scroll shelf right">→</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.spines {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
padding-bottom: var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.spines-track {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 3px;
|
||||||
|
overflow-x: auto;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
padding-bottom: var(--space-2);
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
.spine {
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 168px;
|
||||||
|
border-radius: 3px 3px 0 0;
|
||||||
|
text-decoration: none;
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
text-orientation: mixed;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: var(--space-3) 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
transition:
|
||||||
|
transform 0.2s var(--ease-out),
|
||||||
|
box-shadow 0.2s var(--ease-out);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: -2px 0 4px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
.spine span {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
.spine:hover {
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
.spine--active {
|
||||||
|
width: 112px;
|
||||||
|
padding: 0;
|
||||||
|
writing-mode: initial;
|
||||||
|
}
|
||||||
|
.spine--active img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 3px 3px 0 0;
|
||||||
|
}
|
||||||
|
.spine-cover-fallback {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-3);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-style: italic;
|
||||||
|
writing-mode: initial;
|
||||||
|
}
|
||||||
|
.spines-next {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 76px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.spines-next:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// astro:page-load fires on first load and after every client-side
|
||||||
|
// navigation (bookshelf index <-> detail pages both render this
|
||||||
|
// component), so the button keeps working once view transitions swap it in.
|
||||||
|
document.addEventListener("astro:page-load", () => {
|
||||||
|
document.querySelectorAll<HTMLElement>("[data-spines]").forEach((root) => {
|
||||||
|
const track = root.querySelector<HTMLElement>("[data-track]");
|
||||||
|
const next = root.querySelector<HTMLButtonElement>("[data-next]");
|
||||||
|
next?.addEventListener("click", () => {
|
||||||
|
track?.scrollBy({ left: 320, behavior: "smooth" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||