Merge pull request #4 from andwati/astro-migration
Astro migration: nav/layout polish, SEO, a11y, LaTeX, Lighthouse CI
@@ -1,7 +1,12 @@
|
||||
public
|
||||
node_modules
|
||||
/public
|
||||
**/node_modules
|
||||
.git
|
||||
manimations/*.*
|
||||
/zola
|
||||
/zola-v*.tar.gz
|
||||
/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
|
||||
manimations/*.*
|
||||
/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.
|
||||
|
||||
## 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
|
||||
|
||||
`.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
|
||||
|
||||
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,
|
||||
# then serve the static output with static-web-server.
|
||||
#
|
||||
# This is the Dokploy/self-hosted deployment path (the only one - the prior
|
||||
# Cloudflare Pages/Workers path has been retired).
|
||||
# Production image for the Astro site. Build from the workspace root:
|
||||
# docker build -t andwati-site .
|
||||
|
||||
FROM node:22-slim AS llms
|
||||
WORKDIR /project
|
||||
COPY . .
|
||||
RUN node scripts/generate-llms.mjs
|
||||
FROM node:24-bookworm-slim AS build
|
||||
RUN corepack enable
|
||||
WORKDIR /app
|
||||
|
||||
FROM ghcr.io/getzola/zola:v0.23.1 AS zola
|
||||
WORKDIR /project
|
||||
COPY . .
|
||||
COPY --from=llms /project/static/llms.txt static/llms.txt
|
||||
RUN ["/zola", "build"]
|
||||
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
|
||||
COPY apps/site apps/site
|
||||
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
|
||||
COPY --from=zola /project/public /public
|
||||
COPY --from=build /app/apps/site/dist /public
|
||||
COPY sws.toml /sws.toml
|
||||
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>
|
||||