From 67b07634ae88fb50014aa5c17aa08c7dbf4ea47a Mon Sep 17 00:00:00 2001 From: Oleks Date: Wed, 20 May 2026 11:19:00 +0300 Subject: [PATCH] initial scaffold: emdash catalog, helm chart, woodpecker pipeline, ddev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app/: Emdash scaffold (Astro 6, node target) with cmses/plugins/pages collections - app/seed/seed.json: WordPress→Emdash parity for kotkanagrilli.fi (~30 entries) - Dockerfile + docker/entrypoint.sh: multi-stage build, single PVC at /app/state - deploy/helm/: chart mirroring emdash-kotkanagrilli (single-replica, sqlite, kotkan) - deploy/fleet-overlay/: HelmRelease/source/image-automation templates for anton-helm-workloads (staging + production) - .woodpecker/container.yaml: arm64 build, three OCI tags per push (immutable 0.1. + floating + -latest) - .ddev/: local dev with nginx proxy to emdash on :4321 - README/DEPLOYMENT/ARCHITECTURE/CLAUDE: docs covering the three-repo pipeline (cms-plugins + anton-helm-workloads + Gitea OCI registry) --- .ddev/config.yaml | 19 + .ddev/docker-compose.emdash.yaml | 48 ++ .ddev/web-entrypoint.d/00-emdash-proxy.sh | 49 ++ .dockerignore | 16 + .gitignore | 45 ++ .markdownlint.json | 8 + .woodpecker/container.yaml | 96 +++ .yamllint | 14 + ARCHITECTURE.md | 128 ++++ CLAUDE.md | 92 +++ DEPLOYMENT.md | 257 ++++++++ Dockerfile | 49 ++ README.md | 80 +++ app/astro.config.mjs | 43 ++ app/package.json | 36 ++ app/seed/seed.json | 572 ++++++++++++++++++ app/src/components/PluginCard.astro | 26 + app/src/components/StatusBadge.astro | 18 + app/src/components/StatusLegend.astro | 17 + app/src/layouts/Base.astro | 67 ++ app/src/live.config.ts | 6 + app/src/pages/404.astro | 7 + app/src/pages/[slug].astro | 20 + app/src/pages/cms/[slug].astro | 36 ++ app/src/pages/cms/index.astro | 30 + app/src/pages/index.astro | 61 ++ app/src/pages/plugins/[slug].astro | 48 ++ app/src/styles/global.css | 146 +++++ app/tsconfig.json | 7 + deploy/README.md | 14 + deploy/fleet-overlay/README.md | 50 ++ .../cms-plugins-production/helmrelease.yaml | 48 ++ .../image-automation.yaml | 78 +++ .../cms-plugins-production/kustomization.yaml | 7 + .../cms-plugins-production/secrets.yaml | 46 ++ .../cms-plugins-production/source.yaml | 19 + .../cms-plugins-staging/helmrelease.yaml | 46 ++ .../cms-plugins-staging/image-automation.yaml | 78 +++ .../cms-plugins-staging/kustomization.yaml | 7 + .../cms-plugins-staging/secrets.yaml | 46 ++ .../cms-plugins-staging/source.yaml | 19 + deploy/helm/.helmignore | 8 + deploy/helm/Chart.yaml | 14 + deploy/helm/templates/_helpers.tpl | 34 ++ deploy/helm/templates/deployment.yaml | 100 +++ deploy/helm/templates/ingress.yaml | 27 + deploy/helm/templates/pvc.yaml | 21 + deploy/helm/templates/service.yaml | 15 + deploy/helm/values-production.yaml | 19 + deploy/helm/values-staging.yaml | 17 + deploy/helm/values.yaml | 88 +++ docker/entrypoint.sh | 14 + 52 files changed, 2856 insertions(+) create mode 100644 .ddev/config.yaml create mode 100644 .ddev/docker-compose.emdash.yaml create mode 100755 .ddev/web-entrypoint.d/00-emdash-proxy.sh create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 .markdownlint.json create mode 100644 .woodpecker/container.yaml create mode 100644 .yamllint create mode 100644 ARCHITECTURE.md create mode 100644 CLAUDE.md create mode 100644 DEPLOYMENT.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/astro.config.mjs create mode 100644 app/package.json create mode 100644 app/seed/seed.json create mode 100644 app/src/components/PluginCard.astro create mode 100644 app/src/components/StatusBadge.astro create mode 100644 app/src/components/StatusLegend.astro create mode 100644 app/src/layouts/Base.astro create mode 100644 app/src/live.config.ts create mode 100644 app/src/pages/404.astro create mode 100644 app/src/pages/[slug].astro create mode 100644 app/src/pages/cms/[slug].astro create mode 100644 app/src/pages/cms/index.astro create mode 100644 app/src/pages/index.astro create mode 100644 app/src/pages/plugins/[slug].astro create mode 100644 app/src/styles/global.css create mode 100644 app/tsconfig.json create mode 100644 deploy/README.md create mode 100644 deploy/fleet-overlay/README.md create mode 100644 deploy/fleet-overlay/cms-plugins-production/helmrelease.yaml create mode 100644 deploy/fleet-overlay/cms-plugins-production/image-automation.yaml create mode 100644 deploy/fleet-overlay/cms-plugins-production/kustomization.yaml create mode 100644 deploy/fleet-overlay/cms-plugins-production/secrets.yaml create mode 100644 deploy/fleet-overlay/cms-plugins-production/source.yaml create mode 100644 deploy/fleet-overlay/cms-plugins-staging/helmrelease.yaml create mode 100644 deploy/fleet-overlay/cms-plugins-staging/image-automation.yaml create mode 100644 deploy/fleet-overlay/cms-plugins-staging/kustomization.yaml create mode 100644 deploy/fleet-overlay/cms-plugins-staging/secrets.yaml create mode 100644 deploy/fleet-overlay/cms-plugins-staging/source.yaml create mode 100644 deploy/helm/.helmignore create mode 100644 deploy/helm/Chart.yaml create mode 100644 deploy/helm/templates/_helpers.tpl create mode 100644 deploy/helm/templates/deployment.yaml create mode 100644 deploy/helm/templates/ingress.yaml create mode 100644 deploy/helm/templates/pvc.yaml create mode 100644 deploy/helm/templates/service.yaml create mode 100644 deploy/helm/values-production.yaml create mode 100644 deploy/helm/values-staging.yaml create mode 100644 deploy/helm/values.yaml create mode 100755 docker/entrypoint.sh diff --git a/.ddev/config.yaml b/.ddev/config.yaml new file mode 100644 index 0000000..cb6f585 --- /dev/null +++ b/.ddev/config.yaml @@ -0,0 +1,19 @@ +name: cms-plugins +type: generic +docroot: "" +disable_settings_management: true + +# DDEV's web container is used purely as a reverse proxy in front of +# the emdash service (defined in docker-compose.emdash.yaml). nginx +# is rewritten via .ddev/web-entrypoint.d/00-emdash-proxy.sh. +webserver_type: nginx-fpm + +# No DB service — emdash uses SQLite inside its own container. +omit_containers: + - db + +use_dns_when_possible: true + +hooks: + post-start: + - exec: "echo 'cms-plugins up at https://${DDEV_PROJECT}.ddev.site/'" diff --git a/.ddev/docker-compose.emdash.yaml b/.ddev/docker-compose.emdash.yaml new file mode 100644 index 0000000..c22d55e --- /dev/null +++ b/.ddev/docker-compose.emdash.yaml @@ -0,0 +1,48 @@ +# Development container for the emdash app. Bind-mounts ../app from +# the host and runs `npx emdash dev` so source edits hot-reload. The +# production image is built separately from the root Dockerfile and +# consumed by deploy/helm/ — DDEV is *not* a prod-parity smoke test. + +services: + emdash: + container_name: ddev-${DDEV_SITENAME}-emdash + image: node:22-bookworm-slim + working_dir: /app + restart: "no" + expose: + - "4321" + env_file: + - ../app/.env + environment: + HOST: "0.0.0.0" + PORT: "4321" + NODE_ENV: "development" + EMDASH_ALLOWED_ORIGINS: "https://cms-plugins.ddev.site" + EMDASH_SITE_URL: "https://cms-plugins.ddev.site" + volumes: + - ../app:/app:cached + # Anonymous-ish named volume for node_modules so the host's + # (possibly empty / wrong-arch) directory doesn't shadow what + # the container installs. + - emdash-node-modules:/app/node_modules + command: + - sh + - -c + - | + set -eu + if [ ! -d node_modules ] || [ -z "$$(ls -A node_modules 2>/dev/null)" ]; then + echo "[emdash] installing deps" + apt-get update && apt-get install -y --no-install-recommends python3 make g++ ca-certificates >/dev/null + npm install + fi + if [ ! -f data.db ]; then + echo "[emdash] running emdash init" + node_modules/.bin/emdash init + fi + exec npx emdash dev + labels: + com.ddev.site-name: ${DDEV_SITENAME} + com.ddev.approot: $DDEV_APPROOT + +volumes: + emdash-node-modules: diff --git a/.ddev/web-entrypoint.d/00-emdash-proxy.sh b/.ddev/web-entrypoint.d/00-emdash-proxy.sh new file mode 100755 index 0000000..07f8f2f --- /dev/null +++ b/.ddev/web-entrypoint.d/00-emdash-proxy.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# Runs in DDEV's web container after start.sh has copied +# /mnt/ddev_config/nginx_full/ → /etc/nginx/sites-enabled/, but before +# supervisord boots nginx. We rewrite the generated site config to +# reverse-proxy everything to the emdash service. +set -eu + +cat > /etc/nginx/sites-enabled/nginx-site.conf <<'NGINX' +# Forward `Connection: upgrade` only when the client is actually +# upgrading (Vite's HMR WebSocket lives at wss:///?token=...). +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 80 default_server; + listen 443 ssl default_server; + + ssl_certificate /etc/ssl/certs/master.crt; + ssl_certificate_key /etc/ssl/certs/master.key; + + include /etc/nginx/monitoring.conf; + + sendfile off; + error_log /dev/stdout info; + access_log /var/log/nginx/access.log; + + proxy_buffering off; + proxy_http_version 1.1; + client_max_body_size 100m; + + location / { + proxy_pass http://ddev-cms-plugins-emdash:4321; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + } +} +NGINX + +rm -f /etc/nginx/sites-enabled/seconddocroot.conf.example \ + /etc/nginx/sites-enabled/README.nginx_full.txt \ + /etc/nginx/conf.d/connection-upgrade.conf diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..95412f9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +**/node_modules +**/.astro +**/dist +**/.env +**/.env.* +!**/.env.example +app/data.db +app/data.db-shm +app/data.db-wal +app/uploads +.git +.claude +.ddev +docs +deploy +*.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..df40177 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Dependencies +node_modules/ + +# Build output +**/dist/ +**/.astro/ +**/build/ + +# Astro generated types +**/emdash-env.d.ts + +# Environment +.env +.env.* +!.env.example +!**/.env.example + +# Local DB / uploads (emdash data) +**/data.db +**/data.db-shm +**/data.db-wal +**/uploads/ + +# DDEV +.ddev/.tarballs/ +.ddev/db_snapshots/ +.ddev/.global_commands/ +.ddev/.imported-assets/ +.ddev/import-db/ +.ddev/import.yaml +.ddev/.bgsync* +.ddev/.ddev-docker-compose-base.yaml +.ddev/.ddev-docker-compose-full.yaml +.ddev/.bgsync-config.yaml + +# Logs / OS / editor +*.log +npm-debug.log* +.DS_Store +.idea/ +.vscode/ +*.swp + +# patch-package +patches/*.patch.bak diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..15564ba --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,8 @@ +{ + "default": true, + "MD013": false, + "MD032": false, + "MD033": false, + "MD040": false, + "MD060": false +} diff --git a/.woodpecker/container.yaml b/.woodpecker/container.yaml new file mode 100644 index 0000000..3d6df8b --- /dev/null +++ b/.woodpecker/container.yaml @@ -0,0 +1,96 @@ +# Build + push the cms-plugins container image. +# Triggered on push to develop / staging / production. Each push publishes +# three refs: 0.1. (immutable, audit), (the floating +# pointer Flux's ImagePolicy tracks → digest rewritten into the fleet +# repo → pod rolls), and -latest (same image; chart image.tag +# fallback). Only staging/production have an ImagePolicy, so only those +# move pods. + +labels: + # kotkan (the deploy target) is an arm64 host, so we build natively on + # arm64 — no cross-compile needed. + arch: arm64 + +when: + - event: push + branch: [develop, staging, production] + +clone: + - name: clone + image: woodpeckerci/plugin-git + environment: + CI_NETRC_MACHINE: git.oleks.space + CI_NETRC_USERNAME: oleks + CI_NETRC_PASSWORD: + from_secret: gitea_clone_token + PLUGIN_TAGS: "false" + PLUGIN_DEPTH: "1" + +steps: + - name: build-and-push + image: git.oleks.space/oleks/nix-ci:latest-arm64 + environment: + REGISTRY_TOKEN: + from_secret: registry_token + commands: + - BRANCH="$CI_COMMIT_BRANCH" + - SHA=$(echo "$CI_COMMIT_SHA" | cut -c1-12) + # Semver-shaped immutable tag, one per build. First two components + # stay 0.1 (no real semver discipline yet); patch is the pipeline + # number, monotonic across the whole repo. + - VERSION="0.1.$CI_PIPELINE_NUMBER" + - IMAGE="git.oleks.space/oleks/cms-plugins" + - 'echo "Building $IMAGE:$VERSION (branch=$BRANCH sha=$SHA linux/arm64)"' + + # Wait for the in-cluster buildkit to be reachable (it can be cold). + - | + BUILDER_HOST="buildkit-rootless-arm64.infra.svc.cluster.local" + BUILDER_PORT="1234" + echo "Waiting for buildkit at $BUILDER_HOST:$BUILDER_PORT..." + for i in $(seq 1 30); do + if echo >/dev/tcp/$BUILDER_HOST/$BUILDER_PORT 2>/dev/null; then + echo "Builder ready"; break + fi + [ "$i" -eq 30 ] && echo "Builder not available" && exit 1 + sleep 10 + done + + - echo "$REGISTRY_TOKEN" | docker login git.oleks.space -u oleks --password-stdin + - docker buildx create --name cms-plugins-builder --driver remote "tcp://$BUILDER_HOST:$BUILDER_PORT" + + # Tagging scheme — every build pushes three refs: + # $VERSION — semver-shaped, one per build, immutable (audit). + # $BRANCH — floating channel pointer. THIS is what Flux's + # ImagePolicy tracks (filterTags `^staging$` / + # `^production$`, digestReflectionPolicy: Always); + # retagging it onto the new image is what makes + # ImageUpdateAutomation rewrite the pinned digest + # in the workloads repo and roll the pod. + # $BRANCH-latest — same image, kept only so the chart's cosmetic + # `image.tag` fallback (used when image.digest is + # unset) resolves to a real ref. + # All branches publish all three; only staging/production have an + # ImagePolicy, so only those actually move pods. + - | + TAGS="-t $IMAGE:$VERSION -t $IMAGE:$BRANCH -t $IMAGE:$BRANCH-latest" + docker buildx build \ + --builder cms-plugins-builder \ + --platform linux/arm64 \ + $TAGS \ + --push \ + . + - 'echo "Pushed $IMAGE:$VERSION + floated $IMAGE:$BRANCH and $IMAGE:$BRANCH-latest"' + + backend_options: + kubernetes: + nodeSelector: + kubernetes.io/arch: arm64 + resources: + requests: + memory: 4Gi + limits: + memory: 4Gi + labels: + commit-branch: "${CI_COMMIT_BRANCH}" + commit-sha: "${CI_COMMIT_SHA}" + pipeline-number: "${CI_PIPELINE_NUMBER}" diff --git a/.yamllint b/.yamllint new file mode 100644 index 0000000..4ed8fef --- /dev/null +++ b/.yamllint @@ -0,0 +1,14 @@ +extends: default + +rules: + line-length: disable + truthy: disable + document-start: disable + comments-indentation: disable + braces: disable + brackets: disable + indentation: disable + +ignore: | + deploy/helm/templates/ + **/secrets.enc.yaml diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..c072700 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,128 @@ +# Architecture + +This file is the contract between the build (Dockerfile, Woodpecker, Helm chart) +and the FluxCD reconciliation in `anton-helm-workloads`. See `DEPLOYMENT.md` for +the operational walkthrough. + +## Repo shape + +``` +cms-plugins/ +├── app/ ← Emdash/Astro source +│ ├── astro.config.mjs ← node target, sqlite() + local() +│ ├── package.json +│ ├── src/ ← pages, layouts, components +│ └── seed/seed.json ← collections + kotkanagrilli plugin entries +├── Dockerfile ← multi-stage build, single PVC at /app/state +├── docker/entrypoint.sh ← runs `emdash init` on first boot +├── deploy/ +│ ├── helm/ ← chart consumed by Flux directly from this repo +│ └── fleet-overlay/ ← HelmRelease/source/image-automation templates +│ ├── cms-plugins-staging/ ← copy → anton-helm-workloads/cms-plugins-staging/ +│ └── cms-plugins-production/ ← copy → anton-helm-workloads/cms-plugins-production/ +├── .woodpecker/container.yaml ← build pipeline +└── .ddev/ ← local dev (DDEV nginx → emdash service on :4321) +``` + +## Build pipeline + +A push to `develop` / `staging` / `production` runs +`.woodpecker/container.yaml`. The pipeline labels itself `arch: arm64` +because the deploy target (`kotkan`) is an arm64 node, so building +natively avoids a cross-compile step. + +For each build the pipeline pushes three OCI tags to +`git.oleks.space/oleks/cms-plugins`: + +| Tag | Mutability | Purpose | +|---|---|---| +| `0.1.` | Immutable | Audit trail. Use as a rollback target. | +| `` | Floating | Watched by FluxCD's `ImagePolicy`. Retagged on every build. | +| `-latest` | Floating | Cosmetic. Resolves the chart's `image.tag` fallback to a real ref when the digest path is unset. | + +Only the `staging` and `production` branches have an `ImagePolicy`, so +only those move pods. + +## Container shape + +Multi-stage Dockerfile: + +- `deps` — `node:22-bookworm-slim` + `python3 make g++` (better-sqlite3 + build deps). Falls back to `npm install` if there's no committed lockfile. +- `build` — `npm run build`. +- `runtime` — `node:22-bookworm-slim` + `tini`. Runs as uid 1001. Persistent + state symlinked from `/app/data.db` and `/app/uploads` → `/app/state/...` + so the one PVC mounted at `/app/state` covers both SQLite and uploaded + media. + +Entrypoint runs `emdash init` on first boot (idempotent), then `exec`s +the Astro node server on port 4321. + +## Helm chart + +`deploy/helm/` ships with the app. The chart is **not** packaged or pushed +to an OCI registry — FluxCD's `GitRepository` source pulls it directly from +the matching branch of this repo. The `ignore` rule in `source.yaml` +restricts reconciliation to `/deploy/helm`, so app-source pushes don't +trigger chart re-reconcile. + +Per-env values overlay `values.yaml` via `values-staging.yaml` / +`values-production.yaml` (for `helm upgrade` direct use) and via the +`values:` block in the FluxCD `HelmRelease`. + +Key shape decisions: + +- **`replicas: 1`, `strategy: Recreate`.** SQLite is single-writer. +- **`nodeSelector: kotkan`.** `local-path` PV is sticky to one node; emdash + is colocated with the legacy kotkanagrilli WP install. +- **Pinned by digest, not tag.** The HelmRelease sets + `values.image.digest: "" # {"$imagepolicy": ...}` so that + `helm upgrade` detects a change when the floating tag is reassigned. + Without digest pinning, `staging` stays the literal string `"staging"` + through every build and Helm sees no spec change. +- **Single PVC, `helm.sh/resource-policy: keep`.** Losing the PVC means + losing all CMS data — uninstalls do not delete it. + +## Flux contract + +Two repos cooperate: + +- **cms-plugins** (this one) exposes the chart at `deploy/helm/` on each + branch. +- **anton-helm-workloads** runs the show: `GitRepository` points at the + matching branch of cms-plugins, `HelmRelease` references + `chart: ./deploy/helm`, `ImagePolicy` + `ImageUpdateAutomation` watch + the floating tag and rewrite the digest setter back into + `helmrelease.yaml`. + +For the workloads repo to write digest commits, a `GitRepository` named +`anton-workloads-image-automation` in `flux-system` namespace must exist +with write access to `anton-helm-workloads:main`. The emdash-kotkanagrilli +analogue is `oleks-fleet-image-automation` in the personal fleet repo. + +## Data model + +Three Emdash collections (`app/seed/seed.json`): + +- **cmses** — CMS platforms. Seeded with WordPress + Emdash. +- **plugins** — one entry per plugin. Fields: title, purpose, source_cms, + target_cms, category, status (`port`/`built-in`/`saas`/`drop`/`gated`/`done`/`proposed`), + source_repo_url, target_repo_url, notes (portableText). +- **pages** — static content (About, Submit, etc.). + +The seeded plugin entries come from +`~/projects/emdash.kotkanagrilli.fi/docs/parity.md` and the +`mu-plugins/` / `plugins-custom/` directories in +`~/projects/kotkanagrilli.fi/`. New entries are added through the Emdash +admin (`/_emdash/admin`). + +## What this does NOT solve + +- **Cloudflare Workers target.** The kotkanagrilli sister project has + Cloudflare boundary files (`src/worker.ts`, `wrangler.jsonc`) for a + later flip; this repo skips them. They can be added back from the + emdash-kotkanagrilli pattern when needed. +- **Authentication on the admin.** Emdash's default passkey auth runs; + the first visit to `/_emdash/admin` redirects to `/setup` and creates + the first admin. +- **Multi-language.** No i18n — the catalog is English-only by default. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8e017bc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,92 @@ +# CLAUDE.md + +Guidance for Claude Code agents working in this repository. + +## What this repo is + +A browseable catalog of CMS plugins built on [Emdash](https://github.com/emdash-cms/emdash). +Phase 0 — scaffold + chart + pipeline are in place, no live deploy yet. +Seeded with the WordPress → Emdash plugin parity matrix from +`~/projects/kotkanagrilli.fi/` and `~/projects/emdash.kotkanagrilli.fi/docs/parity.md`. + +## Linked repos + +- `~/projects/kotkanagrilli.fi/` — legacy WordPress site. **Do not edit + from here.** Source of the seeded plugin entries. +- `~/projects/emdash.kotkanagrilli.fi/` — Emdash replacement for that site. + The Dockerfile, Helm chart, Woodpecker pipeline, and DDEV setup in this + repo are deliberate copies of that one. Treat it as the reference + implementation; deviations should be justified. +- `~/projects/servers/anton/anton-helm-workloads/` — where the FluxCD + `HelmRelease`s for `cms-plugins-{staging,production}.kotkanagrilli.fi` + live. `deploy/fleet-overlay/` in this repo is the template set to copy + into that repo. **Do not commit to anton-helm-workloads without + explicit confirmation** — secrets need sops-encryption with the right + age recipients. +- `~/projects/servers/fleet/` — personal fleet repo. Owns + `emdash-kotkanagrilli-{staging,production}` but NOT cms-plugins. + +## Layout + +- `app/` — Emdash scaffold (node target, no i18n, no Cloudflare boundary). + Three collections in `seed/seed.json`: `cmses`, `plugins`, `pages`. +- `Dockerfile` + `docker/entrypoint.sh` — production image. +- `deploy/helm/` — the chart Flux pulls from `./deploy/helm` on the + matching branch. +- `deploy/fleet-overlay/cms-plugins-{staging,production}/` — HelmRelease + + GitRepository + image-automation + secrets templates ready to drop + into `anton-helm-workloads`. +- `.woodpecker/container.yaml` — build pipeline (arm64; deploy target is + `kotkan`). +- `.ddev/` — local dev. +- `DEPLOYMENT.md` — full pipeline walkthrough. +- `ARCHITECTURE.md` — chart / image / Flux contracts. + +## Common commands + +```bash +# Local dev +ddev start # https://cms-plugins.ddev.site/ +# or, without DDEV: +cd app && npm install && npm run bootstrap && npx emdash dev + +# Build production image +docker build -t cms-plugins:dev . + +# Typecheck +cd app && npm run typecheck +``` + +## Architectural constraints to respect + +- **One repo, one app, node target.** No Cloudflare boundary files yet; + adding them is a deliberate Phase-N call, not casual work. +- **SQLite single-writer.** One replica, pinned to `kotkan`, `local-path` + PVC. No StatefulSet, no horizontal scale. +- **Chart pulled directly from git by Flux** — no `helm push` step. Chart + changes ship in the same commit as the code that needs them. +- **Image is pinned by digest in the HelmRelease.** `ImageUpdateAutomation` + rewrites the digest setter; `helm upgrade` only sees a change because + of that. The floating `` tag alone wouldn't roll the pod. +- **The legacy WP site keeps running.** This repo doesn't migrate + kotkanagrilli.fi — it's a catalog ABOUT plugins, not the site itself. + +## Emdash gotchas (from the kotkanagrilli reference) + +- All content pages must be server-rendered (`output: "server"`); no + `getStaticPaths()` for CMS content. +- `entry.id` is the slug (URLs); `entry.data.id` is the database ULID + (used for API calls / cross-collection refs). +- Image fields are `{ src, alt }` objects, not strings. +- Always `Astro.cache.set(cacheHint)` on pages that query content. +- Taxonomy names in queries match `seed.json`'s `name` field exactly. + +## What NOT to do + +- Don't `npm install` random kotkanagrilli plugins (the e-commerce ones). + This is a catalog, not a store. +- Don't push to `anton-helm-workloads` without explicit confirmation — + unencrypted secrets would leak. +- Don't change branch promotion semantics (fast-forward only across + `develop` → `staging` → `production`). Mirroring emdash-kotkanagrilli's + flow is intentional. diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..1c7a5dc --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,257 @@ +# Deployment + +How a code change in this repo becomes a running pod at +`cms-plugins-{staging,production}.kotkanagrilli.fi`. + +## Repos involved + +| Repo | Lives at | Owns | +|---|---|---| +| **cms-plugins** (this one) | `git.oleks.space/oleks/cms-plugins` | App source, Dockerfile, Helm chart, Woodpecker pipeline | +| **anton-helm-workloads** | `git.oleks.space/anton/helm-workloads` | The FluxCD `HelmRelease`s for each subdomain on `kotkanagrilli.fi`. The legacy WP `kotkanagrilli/` and the dev `hello-kotkan/` releases also live here. | +| Gitea OCI registry | `git.oleks.space/oleks/cms-plugins` (image) | Container images pushed by CI | + +The `cms-plugins` repo and `anton-helm-workloads` repo are linked by: + +1. **The Helm chart path.** The HelmRelease in `anton-helm-workloads` + references `chart: ./deploy/helm` from a `GitRepository` source that + points back at the `staging` or `production` branch of `cms-plugins`. + Flux pulls the chart directly from this repo — there is no + "publish chart" step in CI. +2. **The image policy.** A FluxCD `ImagePolicy` in `anton-helm-workloads` + watches the `staging` / `production` floating tag in the Gitea OCI + registry and rewrites the resolved digest into `helmrelease.yaml`, + which is what makes `helm upgrade` see a change when CI retags. + +## Branch → environment + +Three branches, three environments. Promotion is fast-forward only — no PR +ceremony. + +| Branch | Environment | URL | Where it runs | +|---|---|---|---| +| `develop` | dev | `https://cms-plugins.ddev.site/` | Local DDEV | +| `staging` | staging | `https://cms-plugins-staging.kotkanagrilli.fi/` | k3s on `kotkan` | +| `production` | production | `https://cms-plugins-production.kotkanagrilli.fi/` | k3s on `kotkan` | + +## What happens on a push + +A push to `staging` or `production` triggers `.woodpecker/container.yaml`: + +1. Spins up a remote buildkit at `buildkit-rootless-arm64.infra.svc.cluster.local:1234`. +2. Builds the root `Dockerfile`. +3. Publishes **three refs** for each build: + - `git.oleks.space/oleks/cms-plugins:0.1.` — immutable audit tag. + - `git.oleks.space/oleks/cms-plugins:` — floating channel pointer; this is what FluxCD's `ImagePolicy` watches. + - `git.oleks.space/oleks/cms-plugins:-latest` — cosmetic; falls back to a real ref if the chart's `image.tag` is consulted (which the digest-pinning path normally bypasses). + +FluxCD then takes over (it polls every minute): + +1. The `ImagePolicy` in `anton-helm-workloads/cms-plugins-/image-automation.yaml` resolves the new digest behind the `` tag. +2. `ImageUpdateAutomation` writes that digest back into `helmrelease.yaml` and commits/pushes to `anton-helm-workloads:main`. +3. The `GitRepository` in `anton-helm-workloads/cms-plugins-/source.yaml` syncs the `cms-plugins` branch and exposes `deploy/helm/`. +4. The `HelmRelease` reconciles: `helm upgrade` sees a new digest in `values.image.digest` and rolls the Deployment. + +End-to-end deploy time: ≈4 min (build ≈3 min + Flux interval ≈1 min). + +## Local development + +```bash +cd ~/projects/cms-plugins +ddev start +# → https://cms-plugins.ddev.site/ (admin at /_emdash/admin) +``` + +This is **not** a prod-parity smoke test — DDEV runs `npx emdash dev` with +hot reload; production runs the built `node ./dist/server/entry.mjs`. The +shared artifact is the Astro source under `app/`. To smoke-test the +production image standalone: + +```bash +docker build -t cms-plugins:dev . +docker run --rm -p 4321:4321 \ + -e EMDASH_ENCRYPTION_KEY=$(openssl rand -hex 32) \ + -v cms-plugins-state:/app/state \ + cms-plugins:dev +``` + +## Promoting develop → staging + +```bash +git switch staging +git merge --ff-only develop +git push origin staging +``` + +The Woodpecker pipeline triggers automatically. Watch it at the Woodpecker +UI; once green, Flux reconciles within ≤1 min. + +## Promoting staging → production + +After staging looks good: + +```bash +git switch production +git merge --ff-only staging +git push origin production +``` + +If the fast-forward fails, something is out of order — investigate before +forcing. + +## Rolling back + +The chart pins by **digest** (set by FluxCD's +`ImageUpdateAutomation`), with `-latest` as a fallback tag. To +roll back: + +1. Identify the previous good build's immutable tag from the Gitea + registry — it is `0.1.` where `` is the pipeline number. +2. In `anton-helm-workloads/cms-plugins-/helmrelease.yaml`, hard-code + the previous digest under `values.image.digest` and remove the + `{"$imagepolicy": ...}` marker (so IUA stops overwriting it). Commit + and push. +3. Flux reconciles in ≤1 min. + +To resume tracking the floating tag, restore the marker and the +auto-update resumes. + +## Secrets + +All secrets are sops-encrypted in `anton-helm-workloads`: + +- `anton-helm-workloads/cms-plugins-staging/secrets.yaml` +- `anton-helm-workloads/cms-plugins-production/secrets.yaml` + +Each env has two distinct Secrets: + +| Secret | Namespace | Purpose | +|---|---|---| +| `cms-plugins-deploy-key` | `flux-system` | SSH deploy key for Flux to clone `cms-plugins` (one pair shared between staging + production — same key reads both branches). | +| `cms-plugins--secrets` | `kotkan` | Env vars the pod consumes via `existingSecret`. Required key: `EMDASH_ENCRYPTION_KEY`. | + +To rotate a credential: + +```bash +cd ~/projects/servers/anton/anton-helm-workloads/cms-plugins-staging +sops secrets.yaml # opens decrypted in $EDITOR +git add . && git commit -m "rotate cms-plugins staging credential" +git push # Flux re-applies in ≤1 min +``` + +The Deployment does NOT restart on Secret change (k8s `envFrom` doesn't +watch). Force a roll after rotating: + +```bash +kubectl -n kotkan rollout restart deployment/cms-plugins-staging +``` + +## First-time setup checklist + +This is the once-per-environment dance to bring staging or production +online. + +### A. In this (cms-plugins) repo + +1. Push to `git.oleks.space/oleks/cms-plugins` with three branches: + `develop`, `staging`, `production`. The initial commit lives on + `develop`; the other two are fast-forward copies. +2. Add the Woodpecker secrets at Repo → Settings → Secrets in the + Woodpecker UI (`ci.oleks.space`), scoped to event `push`: + + | Secret | Purpose | + |---|---| + | `gitea_clone_token` | Gitea PAT for cloning during the build step. | + | `registry_token` | Gitea PAT with write access to `git.oleks.space/oleks/cms-plugins` (container packages). | + +### B. In the anton-helm-workloads repo + +1. Generate an SSH deploy key (one pair, shared across envs): + + ```bash + ssh-keygen -t ed25519 -f /tmp/cms-plugins-deploy -N "" + ``` + +2. Upload `/tmp/cms-plugins-deploy.pub` to the `cms-plugins` repo on + Gitea: Settings → Deploy Keys → "cms-plugins Flux deploy", + **read-only**. +3. Generate one `EMDASH_ENCRYPTION_KEY` per env (do NOT reuse): + + ```bash + openssl rand -hex 32 # staging + openssl rand -hex 32 # production + ``` + +4. Copy this repo's `deploy/fleet-overlay/cms-plugins-staging/` and + `cms-plugins-production/` directories into the root of + `anton-helm-workloads`, then edit each `secrets.yaml` to put the + real key material in, and sops-encrypt: + + ```bash + cd anton-helm-workloads/cms-plugins-staging + sops --encrypt --age secrets.yaml > secrets.enc.yaml + mv secrets.enc.yaml secrets.yaml + ``` + +5. Add both directories to `anton-helm-workloads/kustomization.yaml`: + + ```yaml + resources: + - hello-kotkan + - kotkanagrilli + - max-openclaw + - terminal-agent + - cms-plugins-staging # ← add + - cms-plugins-production # ← add + ``` + +6. Ensure a writable `GitRepository` named + `anton-workloads-image-automation` exists in `flux-system` for the + `ImageUpdateAutomation` to push digest commits to + `anton-helm-workloads:main`. If it doesn't exist yet, create it + (this is a one-time setup for the whole workloads repo). The + `emdash-kotkanagrilli` analogue is `oleks-fleet-image-automation`. +7. Commit and push `anton-helm-workloads`. Flux picks it up in ≤1 min. + +### C. DNS + +```bash +dig +short cms-plugins-staging.kotkanagrilli.fi +dig +short cms-plugins-production.kotkanagrilli.fi +``` + +Both should resolve to the `kotkan` ingress IP (the same one +`emdash-staging.kotkanagrilli.fi` already uses). + +### D. First deploy + +1. Push to `staging` to trigger the first build. Watch Woodpecker. +2. Once the image lands, Flux reconciles. Watch the rollout: + + ```bash + kubectl -n kotkan get pods -w | grep cms-plugins + flux get all -n flux-system | grep cms-plugins + ``` + +3. Once `cms-plugins-staging.kotkanagrilli.fi` returns a 200, fast-forward + `production` to `staging` and push. + +## Why this shape + +- **Single replica, pinned to `kotkan`.** Emdash uses local SQLite + (single-writer); horizontal scaling isn't a thing here. The PVC is + `local-path`, so the pod can only run where the volume lives. +- **Mutable `` tag.** The sha-tagged ref (`0.1.`) gives + audit trail; the mutable `` tag is what Flux watches. Together + they give visibility without forcing an `anton-helm-workloads` commit + per deploy (the `ImageUpdateAutomation` does that automatically). +- **Flux pulls the chart from this repo (no OCI step).** Keeps the chart + in the same git history as the app code; a chart change rolls with the + same release. +- **HelmRelease lives in `anton-helm-workloads`, not in fleet.** Matches + the convention that the kotkanagrilli.fi subdomain pool (legacy + `kotkanagrilli/`, `hello-kotkan/`, future subdomains) is reconciled + out of the workloads repo, not the personal fleet repo. +- **kotkan, not armer.** The legacy `kotkanagrilli.fi` site and the + emdash replacement both live on `kotkan`; colocating reduces DNS / + ingress drift while the WP → Emdash story plays out. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a9f489a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,49 @@ +# syntax=docker/dockerfile:1.7 + +FROM node:22-bookworm-slim AS deps +WORKDIR /app +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3 make g++ ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY app/package.json app/package-lock.json* ./ +# package-lock.json may not exist on the first commit — fall back to `npm install` +# so the image still builds; once a lockfile is committed, npm ci kicks in. +RUN if [ -f package-lock.json ]; then npm ci --include=dev; else npm install --include=dev; fi + +FROM deps AS build +WORKDIR /app +COPY app/ ./ +RUN rm -f data.db data.db-shm data.db-wal && rm -rf uploads +RUN npm run build + +FROM node:22-bookworm-slim AS runtime +WORKDIR /app +ENV NODE_ENV=production \ + HOST=0.0.0.0 \ + PORT=4321 +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates tini \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --system --uid 1001 --home /app emdash \ + && mkdir -p /app/state/uploads \ + && chown -R emdash:emdash /app + +COPY --from=build --chown=emdash:emdash /app/package.json ./ +COPY --from=build --chown=emdash:emdash /app/node_modules ./node_modules +COPY --from=build --chown=emdash:emdash /app/dist ./dist +COPY --from=build --chown=emdash:emdash /app/seed ./seed + +# Persistent state lives in /app/state (single PVC in k3s). +# data.db and uploads/ are symlinked from the working directory so the +# default emdash paths (./data.db, ./uploads) resolve into the volume. +RUN ln -s /app/state/data.db /app/data.db \ + && ln -s /app/state/uploads /app/uploads + +COPY --chown=emdash:emdash docker/entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +USER emdash +EXPOSE 4321 +VOLUME ["/app/state"] +ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/entrypoint.sh"] +CMD ["node", "./dist/server/entry.mjs"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..f7dbe1c --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +# cms-plugins + +A browseable catalog of CMS plugins and how they map across platforms, +built on [Emdash](https://github.com/emdash-cms/emdash) (Astro 6 + +TypeScript + SQLite). The seed dataset is the WordPress → Emdash plugin +parity matrix from `kotkanagrilli.fi`; new entries can be added through +the Emdash admin. + +| Repo | URL | Role | +|---|---|---| +| **cms-plugins** (this repo) | `git@git.oleks.space:oleks/cms-plugins.git` | App, Dockerfile, Helm chart, Woodpecker pipeline | +| **emdash.kotkanagrilli.fi** | `git@git.oleks.space:oleks/emdash-kotkanagrilli.git` | Reference Emdash deployment whose pattern this repo mirrors | +| **kotkanagrilli.fi** (WordPress) | `git@git.oleks.space:oleks/kotkanagrilli.fi.git` (+ `github.com:retif/kotkanagrilli.fi.git`) | Source of the seeded plugin entries (parity matrix) | +| **anton-helm-workloads** | `https://git.oleks.space/anton/helm-workloads` | Houses the FluxCD `HelmRelease`s for each environment (kotkanagrilli.fi subdomains) | + +## Status + +Phase 0 — Emdash scaffold + Helm chart + Woodpecker pipeline. No content +beyond the seeded WordPress plugin entries. Not yet running anywhere. + +## What's in here + +- `app/` — Emdash scaffold (Astro 6, node target). Three collections: `cmses`, + `plugins`, `pages`. Seed at `app/seed/seed.json`. +- `Dockerfile` + `docker/entrypoint.sh` — production image. Single PVC at + `/app/state` holds `data.db` and `uploads/`. +- `deploy/helm/` — the Helm chart that runs the pod in k3s. Same shape as + `~/projects/emdash.kotkanagrilli.fi/deploy/helm/`. +- `deploy/fleet-overlay/` — `HelmRelease` + `GitRepository` + image-automation + templates ready to drop into the `anton-helm-workloads` repo. Not consumed + from this repo directly. +- `.woodpecker/container.yaml` — build + push pipeline (develop / staging / + production branches). +- `.ddev/` — DDEV setup for local development (`https://cms-plugins.ddev.site/`). +- `DEPLOYMENT.md` — full pipeline from `git push` to a running pod, plus + the first-time setup checklist. +- `ARCHITECTURE.md` — chart / image / Flux contracts. +- `docs/` — additional notes. + +## Quickstart (local) + +```bash +cd ~/projects/cms-plugins +ddev start +# → https://cms-plugins.ddev.site/ (admin at /_emdash/admin) +``` + +Or without DDEV: + +```bash +cd app +npm install +npm run bootstrap # emdash init: migrations + seed +npx emdash dev # http://localhost:4321/ +``` + +Reset to a blank state: + +```bash +rm -f app/data.db app/data.db-shm app/data.db-wal +rm -rf app/uploads +``` + +## Deploying + +Push to the matching branch: + +| Branch | Environment | URL | +|---|---|---| +| `develop` | dev (local DDEV only) | `https://cms-plugins.ddev.site/` | +| `staging` | staging | `https://cms-plugins-staging.kotkanagrilli.fi/` | +| `production` | production | `https://cms-plugins-production.kotkanagrilli.fi/` | + +Woodpecker builds + pushes the image to `git.oleks.space/oleks/cms-plugins`, +FluxCD reconciles the chart from `deploy/helm/` against the matching +`HelmRelease` in `anton-helm-workloads/cms-plugins-{staging,production}/`. + +See **[DEPLOYMENT.md](./DEPLOYMENT.md)** for the full flow, the relationship +between this repo and `anton-helm-workloads`, the first-time setup +checklist, and how rollbacks work. diff --git a/app/astro.config.mjs b/app/astro.config.mjs new file mode 100644 index 0000000..9552fb8 --- /dev/null +++ b/app/astro.config.mjs @@ -0,0 +1,43 @@ +import node from "@astrojs/node"; +import react from "@astrojs/react"; +import { defineConfig, memoryCache } from "astro/config"; +import emdash, { local } from "emdash/astro"; +import { sqlite } from "emdash/db"; + +// Persistent state directory. Defaults to the working directory for dev/DDEV +// (so data.db + uploads/ stay where you'd expect them). The k8s deploy sets +// STATE_DIR=/app/state and mounts a PVC there so SQLite + uploads survive +// pod replacement. +const stateDir = process.env.STATE_DIR ?? "."; + +export default defineConfig({ + output: "server", + adapter: node({ mode: "standalone" }), + trailingSlash: "ignore", + server: { host: true, port: 4321 }, + vite: { + server: { + // Dev runs behind DDEV's nginx (https://cms-plugins.ddev.site/). + // Vite's host check must allow the public hostname. + allowedHosts: ["cms-plugins.ddev.site", ".ddev.site"], + }, + }, + image: { + layout: "constrained", + responsiveStyles: true, + }, + integrations: [ + react(), + emdash({ + database: sqlite({ url: `file:${stateDir}/data.db` }), + storage: local({ + directory: `${stateDir}/uploads`, + baseUrl: "/_emdash/api/media/file", + }), + }), + ], + experimental: { + cache: { provider: memoryCache() }, + }, + devToolbar: { enabled: false }, +}); diff --git a/app/package.json b/app/package.json new file mode 100644 index 0000000..bb0dc58 --- /dev/null +++ b/app/package.json @@ -0,0 +1,36 @@ +{ + "name": "cms-plugins-app", + "version": "0.1.0", + "private": true, + "type": "module", + "emdash": { + "label": "CMS plugins catalog", + "seed": "seed/seed.json" + }, + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview", + "start": "node ./dist/server/entry.mjs", + "bootstrap": "emdash init", + "typecheck": "astro check" + }, + "dependencies": { + "@astrojs/node": "^10.0.5", + "@astrojs/react": "^5.0.0", + "astro": "~6.2.2", + "better-sqlite3": "^12.8.0", + "emdash": "^0.10.0", + "react": "19.2.4", + "react-dom": "19.2.4" + }, + "devDependencies": { + "@astrojs/check": "^0.9.7" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "better-sqlite3", + "esbuild" + ] + } +} diff --git a/app/seed/seed.json b/app/seed/seed.json new file mode 100644 index 0000000..ab16a1e --- /dev/null +++ b/app/seed/seed.json @@ -0,0 +1,572 @@ +{ + "$schema": "https://emdashcms.com/seed.schema.json", + "version": "1", + "meta": { + "name": "CMS plugins catalog", + "description": "WordPress → Emdash plugin parity catalog, seeded with kotkanagrilli.fi entries", + "author": "oleks" + }, + "settings": { + "title": "CMS plugins catalog", + "tagline": "WordPress → Emdash plugin parity, one entry at a time" + }, + "collections": [ + { + "slug": "cmses", + "label": "CMS platforms", + "labelSingular": "CMS platform", + "urlPattern": "/cms/{slug}", + "supports": ["search", "seo"], + "fields": [ + { "slug": "title", "label": "Name", "type": "string", "required": true, "searchable": true }, + { "slug": "website", "label": "Website URL", "type": "string" }, + { "slug": "description", "label": "Description", "type": "text", "searchable": true } + ] + }, + { + "slug": "plugins", + "label": "Plugins", + "labelSingular": "Plugin", + "urlPattern": "/plugins/{slug}", + "supports": ["drafts", "search", "seo"], + "fields": [ + { "slug": "title", "label": "Plugin name", "type": "string", "required": true, "searchable": true }, + { "slug": "purpose", "label": "Purpose", "type": "text", "searchable": true, + "description": "One-line description of what the plugin does." }, + { "slug": "source_cms", "label": "Source CMS", "type": "string", "required": true, "searchable": true, + "description": "Name of the CMS this plugin runs on today (e.g. WordPress)." }, + { "slug": "target_cms", "label": "Target CMS", "type": "string", "searchable": true, + "description": "Name of the CMS we're porting to, if applicable." }, + { "slug": "category", "label": "Category", "type": "string", + "description": "e-commerce, SEO, content, performance, etc." }, + { + "slug": "status", + "label": "Migration status", + "type": "select", + "required": true, + "defaultValue": "proposed", + "searchable": true, + "options": [ + { "value": "port", "label": "Port — reimplement on target" }, + { "value": "built-in", "label": "Built-in — covered by target core" }, + { "value": "saas", "label": "SaaS — replaced by external service" }, + { "value": "drop", "label": "Drop — not needed" }, + { "value": "gated", "label": "Gated — pending decision" }, + { "value": "done", "label": "Done — ported and shipped" }, + { "value": "proposed", "label": "Proposed — newly submitted" } + ] + }, + { "slug": "source_repo_url", "label": "Source repo URL", "type": "string" }, + { "slug": "target_repo_url", "label": "Target repo URL", "type": "string" }, + { "slug": "notes", "label": "Migration notes", "type": "portableText", "searchable": true } + ] + }, + { + "slug": "pages", + "label": "Pages", + "labelSingular": "Page", + "urlPattern": "/{slug}", + "supports": ["drafts", "revisions", "search", "seo"], + "fields": [ + { "slug": "title", "label": "Title", "type": "string", "required": true, "searchable": true }, + { "slug": "content", "label": "Content", "type": "portableText", "searchable": true }, + { "slug": "excerpt", "label": "Excerpt", "type": "text" } + ] + } + ], + "content": { + "cmses": [ + { + "slug": "wordpress", + "data": { + "title": "WordPress", + "website": "https://wordpress.org", + "description": "PHP/MariaDB CMS with the largest plugin ecosystem. Source platform for the kotkanagrilli.fi migration." + } + }, + { + "slug": "emdash", + "data": { + "title": "Emdash", + "website": "https://github.com/emdash-cms/emdash", + "description": "TypeScript/Astro/SQLite CMS positioned as a spiritual successor to WordPress. Target platform for the kotkanagrilli.fi migration." + } + } + ], + "pages": [ + { + "slug": "about", + "data": { + "title": "About this catalog", + "excerpt": "What this catalog is and how it's organized.", + "content": [ + { "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "This catalog tracks plugins from one CMS and how they map to another. The seeded entries come from the kotkanagrilli.fi WordPress → Emdash migration, where ~30 third-party and custom plugins needed to be classified before the rebuild." }] }, + { "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Each entry has a migration status (port, built-in, saas, drop, gated, done, proposed) and free-form notes. New entries can be added through the Emdash admin at /_emdash/admin." }] } + ] + } + } + ], + "plugins": [ + { + "slug": "woocommerce", + "data": { + "title": "WooCommerce", + "purpose": "E-commerce / order management for WordPress.", + "source_cms": "WordPress", + "target_cms": "Emdash", + "category": "e-commerce", + "status": "gated", + "source_repo_url": "https://wordpress.org/plugins/woocommerce/", + "notes": [ + { "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Fate depends on the kotkanagrilli WooCommerce decision gate: (a) keep WP on a subdomain for ordering, (b) build an Emdash orders+SumUp plugin, (c) outsource to an ordering SaaS." }] } + ] + } + }, + { + "slug": "woocommerce-payments", + "data": { + "title": "WooCommerce Payments", + "purpose": "WC native payment gateway.", + "source_cms": "WordPress", + "target_cms": "Emdash", + "category": "e-commerce", + "status": "gated", + "notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Tied to the WooCommerce decision." }] }] + } + }, + { + "slug": "sumup-payment-gateway-for-woocommerce", + "data": { + "title": "SumUp Payment Gateway for WooCommerce", + "purpose": "SumUp checkout integration for WC.", + "source_cms": "WordPress", + "target_cms": "Emdash", + "category": "e-commerce", + "status": "gated", + "notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Tied to WC decision. SumUp REST API is callable directly from a custom Emdash plugin if option (b) is chosen." }] }] + } + }, + { + "slug": "fluid-checkout", + "data": { + "title": "Fluid Checkout", + "purpose": "Improved WooCommerce checkout UX.", + "source_cms": "WordPress", + "target_cms": "Emdash", + "category": "e-commerce", + "status": "gated" + } + }, + { + "slug": "woo-checkout-field-editor-pro", + "data": { + "title": "Woo Checkout Field Editor Pro", + "purpose": "Custom checkout fields for WooCommerce.", + "source_cms": "WordPress", + "target_cms": "Emdash", + "category": "e-commerce", + "status": "gated" + } + }, + { + "slug": "woocommerce-customizer", + "data": { + "title": "WooCommerce Customizer", + "purpose": "Miscellaneous WooCommerce tweaks.", + "source_cms": "WordPress", + "target_cms": "Emdash", + "category": "e-commerce", + "status": "gated" + } + }, + { + "slug": "woc-open-close", + "data": { + "title": "WooCommerce Open/Close", + "purpose": "Show/hide the store as open or closed.", + "source_cms": "WordPress", + "target_cms": "Emdash", + "category": "e-commerce", + "status": "port", + "notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Reimplement as a small Emdash plugin or Astro middleware reading a business-hours config. Not actually WC-coupled despite the name." }] }] + } + }, + { + "slug": "google-listings-and-ads", + "data": { + "title": "Google Listings & Ads", + "purpose": "Merchant Center feed for WooCommerce.", + "source_cms": "WordPress", + "target_cms": "Emdash", + "category": "marketing", + "status": "gated" + } + }, + { + "slug": "polylang", + "data": { + "title": "Polylang", + "purpose": "FI/EN translation for WordPress.", + "source_cms": "WordPress", + "target_cms": "Emdash", + "category": "i18n", + "status": "port", + "notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Native Emdash collections + locale routing in Astro (src/pages/[lang]/...) replaces Polylang." }] }] + } + }, + { + "slug": "connect-polylang-elementor", + "data": { + "title": "Connect Polylang for Elementor", + "purpose": "Glue between Polylang and Elementor.", + "source_cms": "WordPress", + "target_cms": "Emdash", + "category": "i18n", + "status": "drop", + "notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Dropped together with Elementor." }] }] + } + }, + { + "slug": "elementor", + "data": { + "title": "Elementor", + "purpose": "Visual page builder for WordPress.", + "source_cms": "WordPress", + "target_cms": "Emdash", + "category": "page-builder", + "status": "drop", + "notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Rebuild pages as Astro components / Emdash portable-text blocks." }] }] + } + }, + { + "slug": "cafe-eatery", + "data": { + "title": "Cafe Eatery", + "purpose": "WordPress block theme (parent of the kotkanagrilli child theme).", + "source_cms": "WordPress", + "target_cms": "Emdash", + "category": "theme", + "status": "drop", + "notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Rebuild as an Astro theme (Phase 2)." }] }] + } + }, + { + "slug": "autoptimize", + "data": { + "title": "Autoptimize", + "purpose": "Asset minification + concatenation.", + "source_cms": "WordPress", + "target_cms": "Emdash", + "category": "performance", + "status": "built-in", + "notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Astro/Vite handles bundling natively." }] }] + } + }, + { + "slug": "jetpack-boost", + "data": { + "title": "Jetpack Boost", + "purpose": "Performance hints for WordPress.", + "source_cms": "WordPress", + "target_cms": "Emdash", + "category": "performance", + "status": "built-in", + "notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Astro + Cloudflare handle this." }] }] + } + }, + { + "slug": "ewww-image-optimizer", + "data": { + "title": "EWWW Image Optimizer", + "purpose": "Image optimization for WordPress media.", + "source_cms": "WordPress", + "target_cms": "Emdash", + "category": "performance", + "status": "built-in", + "notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Astro Image integration + responsive styles already wired in astro.config.mjs." }] }] + } + }, + { + "slug": "jetpack", + "data": { + "title": "Jetpack", + "purpose": "Stats, hardening, related-posts, and more.", + "source_cms": "WordPress", + "target_cms": "Emdash", + "category": "ops", + "status": "saas", + "notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Replaced by Cloudflare analytics + WAF." }] }] + } + }, + { + "slug": "redis-cache", + "data": { + "title": "Redis Object Cache", + "purpose": "PHP object cache for WordPress.", + "source_cms": "WordPress", + "target_cms": "Emdash", + "category": "performance", + "status": "drop", + "notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "No PHP, no need; Astro + Emdash memoryCache provider already wired." }] }] + } + }, + { + "slug": "wp-mail-smtp", + "data": { + "title": "WP Mail SMTP", + "purpose": "Outbound mail via external SMTP.", + "source_cms": "WordPress", + "target_cms": "Emdash", + "category": "mail", + "status": "saas", + "notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "External SMTP configured at the platform layer, or via an Emdash mailer plugin." }] }] + } + }, + { + "slug": "cb-change-mail-sender", + "data": { + "title": "CB Change Mail Sender", + "purpose": "Override the sender address on outgoing mail.", + "source_cms": "WordPress", + "target_cms": "Emdash", + "category": "mail", + "status": "port", + "notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Handled by the same Emdash mailer plugin that replaces wp-mail-smtp." }] }] + } + }, + { + "slug": "wpforms-lite", + "data": { + "title": "WPForms Lite", + "purpose": "Contact form builder.", + "source_cms": "WordPress", + "target_cms": "Emdash", + "category": "forms", + "status": "port", + "notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Small Astro form action posting to a server endpoint, or a Cloudflare Worker on the CF target." }] }] + } + }, + { + "slug": "wp-google-maps", + "data": { + "title": "WP Google Maps", + "purpose": "Map embed for posts/pages.", + "source_cms": "WordPress", + "target_cms": "Emdash", + "category": "content", + "status": "port", + "notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Static iframe embed in the theme — trivial port." }] }] + } + }, + { + "slug": "sticky-chat-widget", + "data": { + "title": "Sticky Chat Widget", + "purpose": "Floating WhatsApp/chat button.", + "source_cms": "WordPress", + "target_cms": "Emdash", + "category": "content", + "status": "port", + "notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Static component in the theme — trivial port." }] }] + } + }, + { + "slug": "shortcodes-ultimate", + "data": { + "title": "Shortcodes Ultimate", + "purpose": "Shortcode library for the WP editor.", + "source_cms": "WordPress", + "target_cms": "Emdash", + "category": "content", + "status": "drop", + "notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Astro components / portable-text block types replace it." }] }] + } + }, + { + "slug": "tinymce-advanced", + "data": { + "title": "TinyMCE Advanced", + "purpose": "Editor enhancement for WordPress.", + "source_cms": "WordPress", + "target_cms": "Emdash", + "category": "editor", + "status": "drop", + "notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Emdash has its own editor." }] }] + } + }, + { + "slug": "lara-google-analytics", + "data": { + "title": "Lara Google Analytics", + "purpose": "GA4 tag for WordPress.", + "source_cms": "WordPress", + "target_cms": "Emdash", + "category": "analytics", + "status": "port", + "notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Static