@agent-cms/agentcms 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/LICENSE +143 -0
  2. package/README.md +1119 -2
  3. package/changelog.json +31 -0
  4. package/dist/admin/admin-files.json +48 -0
  5. package/dist/admin/assets/CodeEditor-Wx1WlF3h.js +24 -0
  6. package/dist/admin/assets/flexweg-logo-CdtuqAFA.png +0 -0
  7. package/dist/admin/assets/index-DFVZw8E0.js +5460 -0
  8. package/dist/admin/assets/index-DMcGogR2.css +1 -0
  9. package/dist/admin/assets/sql-wasm-Bfkbn6Oa.js +2 -0
  10. package/dist/admin/assets/sql-wasm-C1U8OeUW.wasm +0 -0
  11. package/dist/admin/assets/sql-wasm-V5jTDcrT.js +1 -0
  12. package/dist/admin/config.js +11 -0
  13. package/dist/admin/external.default.json +72 -0
  14. package/dist/admin/index.html +116 -0
  15. package/dist/admin/plugins/core-seo/bundle.js +55 -0
  16. package/dist/admin/plugins/core-seo/manifest.json +7 -0
  17. package/dist/admin/plugins/flexweg-archives/bundle.js +1414 -0
  18. package/dist/admin/plugins/flexweg-archives/manifest.json +7 -0
  19. package/dist/admin/plugins/flexweg-multilang/bundle.js +1187 -0
  20. package/dist/admin/plugins/flexweg-multilang/manifest.json +7 -0
  21. package/dist/admin/plugins/flexweg-rss/bundle.js +1354 -0
  22. package/dist/admin/plugins/flexweg-rss/manifest.json +7 -0
  23. package/dist/admin/plugins/flexweg-search/bundle.js +1243 -0
  24. package/dist/admin/plugins/flexweg-search/manifest.json +7 -0
  25. package/dist/admin/plugins/flexweg-sitemaps/bundle.js +1293 -0
  26. package/dist/admin/plugins/flexweg-sitemaps/manifest.json +7 -0
  27. package/dist/admin/runtime/cms-runtime.js +164 -0
  28. package/dist/admin/runtime/react-dom-client.js +13 -0
  29. package/dist/admin/runtime/react-dom.js +16 -0
  30. package/dist/admin/runtime/react-i18next.js +21 -0
  31. package/dist/admin/runtime/react-jsx-runtime.js +17 -0
  32. package/dist/admin/runtime/react.js +55 -0
  33. package/dist/admin/themes/corporate/bundle.js +14465 -0
  34. package/dist/admin/themes/corporate/manifest.json +7 -0
  35. package/dist/admin/themes/corporate/theme.css +2 -0
  36. package/dist/admin/themes/magazine/bundle.js +10229 -0
  37. package/dist/admin/themes/magazine/manifest.json +7 -0
  38. package/dist/admin/themes/magazine/theme.css +2 -0
  39. package/dist/admin/themes/marketplace-core/bundle.js +11213 -0
  40. package/dist/admin/themes/marketplace-core/manifest.json +7 -0
  41. package/dist/admin/themes/marketplace-core/theme.css +1613 -0
  42. package/dist/admin/themes/portfolio/bundle.js +9195 -0
  43. package/dist/admin/themes/portfolio/manifest.json +7 -0
  44. package/dist/admin/themes/portfolio/theme.css +2 -0
  45. package/dist/admin/themes/storefront/bundle.js +13172 -0
  46. package/dist/admin/themes/storefront/manifest.json +7 -0
  47. package/dist/admin/themes/storefront/theme.css +2 -0
  48. package/dist/agent/agentcms.mjs +1711 -0
  49. package/dist/agent/chunks/hosting-BftlDdsL.mjs +117 -0
  50. package/dist/agent/chunks/index-BG05qslv.mjs +3608 -0
  51. package/dist/agent/chunks/install-DnmgOT3F.mjs +577 -0
  52. package/dist/agent/chunks/plan-BrM8ztEp.mjs +245 -0
  53. package/dist/agent/chunks/registry-C9TSnZ77.mjs +53517 -0
  54. package/dist/agent/chunks/siteAdd-BcXhN9fk.mjs +118 -0
  55. package/dist/agent/chunks/sql-wasm-GjJDiefO.mjs +10 -0
  56. package/dist/agent/chunks/sqljs-DjSCStJ0.mjs +20 -0
  57. package/dist/agent/vendor/sql-wasm.cjs +188 -0
  58. package/dist/theme-assets/corporate-menu.js +266 -0
  59. package/dist/theme-assets/corporate-posts.js +571 -0
  60. package/dist/theme-assets/corporate.css +2 -0
  61. package/dist/theme-assets/default-menu.js +266 -0
  62. package/dist/theme-assets/default-posts.js +378 -0
  63. package/dist/theme-assets/default.css +1 -0
  64. package/dist/theme-assets/magazine-menu.js +227 -0
  65. package/dist/theme-assets/magazine-posts.js +361 -0
  66. package/dist/theme-assets/magazine.css +2 -0
  67. package/dist/theme-assets/portfolio-filters.js +104 -0
  68. package/dist/theme-assets/portfolio-menu.js +240 -0
  69. package/dist/theme-assets/portfolio-posts.js +358 -0
  70. package/dist/theme-assets/portfolio.css +2 -0
  71. package/dist/theme-assets/storefront-catalog.js +504 -0
  72. package/dist/theme-assets/storefront-menu.js +239 -0
  73. package/dist/theme-assets/storefront-posts.js +404 -0
  74. package/dist/theme-assets/storefront.css +2 -0
  75. package/dist/version.json +9 -0
  76. package/package.json +101 -4
  77. package/plugin/.claude-plugin/plugin.json +16 -0
  78. package/plugin/skills/agentcms-install/SKILL.md +110 -0
  79. package/plugin/skills/agentcms-install/references/cloudflare.md +15 -0
  80. package/plugin/skills/agentcms-install/references/flexweg.md +11 -0
  81. package/plugin/skills/agentcms-install/references/github.md +67 -0
  82. package/plugin/skills/agentcms-install/references/gitlab.md +15 -0
  83. package/plugin/skills/agentcms-install/references/s3.md +81 -0
  84. package/plugin/skills/agentcms-manage/SKILL.md +92 -0
package/README.md CHANGED
@@ -1,3 +1,1120 @@
1
- # Temporary Holding Version
1
+ # AgentCMS
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A WordPress-style content management system that publishes a 100% static site to any static host — [Flexweg](https://www.flexweg.com), GitHub Pages, Cloudflare Pages, GitLab Pages, an S3-compatible bucket, your own PHP server, a local folder or a ZIP you upload by hand. The admin is a React + TypeScript single-page app with **four interchangeable data backends**, chosen at install time:
4
+
5
+ - **Firebase** — Firestore + Firebase Auth. Real-time via `onSnapshot`. Free tier covers most small/medium sites.
6
+ - **Flexweg SQLite** — Per-site SQLite file backed by Flexweg's `/api/v1/sqlite/*` endpoints + a full Auth API (bcrypt, opaque session tokens). No external service. Real-time via polling (~4 s).
7
+ - **Markdown in a Git repository** — posts as `.md` files on GitHub / GitLab (or a local clone). Each editor signs in with their own access token; every change is a commit. No database, multi-editor, multi-machine.
8
+ - **This browser** — single user, no login, IndexedDB. For an admin running on your own computer.
9
+
10
+ On publish, the active theme renders posts with `react-dom/server.renderToStaticMarkup` in the browser and the active **publish target** uploads the resulting files. Data backend and publish target are independent choices — see [docs/publish-targets.md](docs/publish-targets.md) for every option, its setup and its constraints.
11
+
12
+ No Node.js, PHP, or database runs on the public site — only the static HTML/CSS/JS the admin pushes there.
13
+
14
+ ## Install with an AI agent
15
+
16
+ The fastest way: let your coding agent install the site for you. It asks where to store the content, then where to host the site, and walks you through every key or token.
17
+
18
+ - **Claude Code**
19
+ ```
20
+ /plugin marketplace add fleox/agentcms
21
+ /plugin install agentcms@agentcms
22
+ ```
23
+ then ask: *"Install a new AgentCMS site"*.
24
+ - **Any other agent** (Codex, Cursor, Windsurf…): ask it to follow [AGENTS.md](AGENTS.md).
25
+ - **Without an agent**: `npx -y @agent-cms/agentcms serve` (Node.js 20+) starts the admin on your computer — open the printed URL and follow the setup wizard.
26
+
27
+ The same `agentcms` CLI works by hand (`npx -y @agent-cms/agentcms help`):
28
+
29
+ | Command | What it does |
30
+ |---|---|
31
+ | `status` | This tool vs the latest release, and every registered site's admin version |
32
+ | `options` | Data backends, hostings and the install plan format |
33
+ | `credentials set <type>:<name>` | Stores a token / key per account (hidden prompt, `--from-gh`, `--from-aws-profile`, `--from-env`) |
34
+ | `install --plan plan.json` | Installs a site (content store, publish target, theme assets, admin) — `--dry-run` checks only |
35
+ | `site list \| add \| use \| rename \| forget` | The sites this computer manages |
36
+ | `doctor --site <alias>` | Health check with a fix for each problem |
37
+ | `deploy-admin --site <alias>` | Updates the hosted admin to this release (prints the plan; `--yes` applies) |
38
+ | `serve` | Runs the admin locally for the setup wizard |
39
+
40
+ ## How it works
41
+
42
+ ```
43
+ ┌───────────────────────────┐
44
+ │ Admin SPA at /admin/ │
45
+ │ (React + Firebase + TS) │
46
+ └─────┬───────────┬─────────┘
47
+ │ │
48
+ Firebase Auth + Firestore Flexweg Files API
49
+ (posts, pages, terms, (HTML, CSS, images)
50
+ media metadata, settings)
51
+ │ │
52
+ ▼ ▼
53
+ ┌──────────────────────────┐
54
+ │ Public static site at / │
55
+ │ index.html │
56
+ │ category/article.html │
57
+ │ theme-assets/*.css │
58
+ │ media/yyyy/mm/*.jpg │
59
+ └──────────────────────────┘
60
+ ```
61
+
62
+ Posts are stored as Markdown in Firestore. When an admin clicks **Publish**, the SPA:
63
+
64
+ 1. Loads the active theme (React components + a pre-compiled CSS bundle).
65
+ 2. Converts Markdown to safe HTML.
66
+ 3. Renders the active template tree to a static HTML string (`react-dom/server.renderToStaticMarkup`).
67
+ 4. Uploads the file to its target path on Flexweg via `POST /api/v1/files/upload`.
68
+ 5. Re-uploads any pages whose listing depends on the change (home, category archive).
69
+
70
+ ## Project layout
71
+
72
+ ```
73
+ agentcms/
74
+ ├── src/
75
+ │ ├── core/ CMS-wide infrastructure (types, slug, render, markdown, plugin registry)
76
+ │ ├── services/ Firebase + Flexweg API clients, publisher
77
+ │ ├── context/ React contexts (auth, data, theme)
78
+ │ ├── components/ Admin UI building blocks
79
+ │ ├── pages/ Admin pages (one file per route)
80
+ │ ├── themes/ Public-site themes (one folder per theme)
81
+ │ │ ├── default/ Default theme — SCSS-based, base/home/single/category/author/404 + components
82
+ │ │ └── magazine/ Magazine theme — Tailwind + Material 3 palette, editorial/long-form layout
83
+ │ ├── plugins/ WordPress-style plugins, toggleable via /admin/#/plugins
84
+ │ │ ├── core-seo/ Built-in SEO plugin (Twitter cards + generator hint)
85
+ │ │ ├── flexweg-sitemaps/ Built-in plugin: yearly sitemaps, sitemap-index, optional News, robots.txt
86
+ │ │ ├── flexweg-rss/ Built-in plugin: RSS feed at /rss.xml + per-category feeds
87
+ │ │ └── flexweg-search/ Built-in plugin: static search-index.json + runtime modal for [data-cms-search] triggers
88
+ │ ├── mu-plugins/ Must-use plugins — same API as plugins/, but always active (cannot be disabled)
89
+ │ │ ├── flexweg-favicon/ Favicon generator (PNG, ICO, SVG, PWA manifest)
90
+ │ │ ├── flexweg-blocks/ Extra editor blocks (Custom HTML, Columns, …)
91
+ │ │ ├── flexweg-custom-code/ Site-wide HTML/CSS/JS injection (head + body end)
92
+ │ │ └── flexweg-embeds/ YouTube / Vimeo / Twitter / Spotify embed blocks
93
+ │ ├── i18n/ Admin UI translations (en + fr)
94
+ │ └── lib/ Small utilities (date format, hashing, classnames)
95
+ ├── scripts/
96
+ │ ├── build-theme-tailwind.mjs Compiles each Tailwind-based theme's CSS before vite build (prebuild + predev)
97
+ │ └── build-themes.mjs Copies theme.compiled.css or compiles theme.scss into dist/theme-assets/<id>.css
98
+ └── public/
99
+ ```
100
+
101
+ ## Setup
102
+
103
+ There are two ways to get the admin running, multiplied by two backend choices. Pick whichever fits your workflow:
104
+
105
+ - **Local build path** — clone the repo, fill `.env`, run `npm run build`, upload `dist/admin/` to Flexweg. Best when you're iterating on theme or plugin code. *Currently Firebase-only — `.env` does not configure SQLite mode.*
106
+ - **No-build path** — drop a pre-built `dist/admin/` on Flexweg, open `/admin/`, and fill the in-app **first-run setup form**. Best when handing the admin to a non-technical user — they don't need Node.js, npm, or a clone of the repo. The form lets the user pick **Firebase** or **Flexweg SQLite** at install time. SQLite mode is the only way to deploy without an external service.
107
+
108
+ Both paths converge on the same runtime: the form just writes a populated `/admin/config.js` to Flexweg so the next reload boots exactly like a build with `.env` baked in. The single command `npm run build` covers both — there is no separate "portable" build target.
109
+
110
+ ### The two backends
111
+
112
+ The form's **Backend** step lets the user pick:
113
+
114
+ - **Firebase** — collects 6 web-app config fields + bootstrap admin email + password + Flexweg API key + site URL. Validates credentials in-form, writes `config/flexweg` to Firestore, uploads `config.js` to Flexweg. Subsequent boots load Firestore listeners (real-time `onSnapshot`).
115
+ - **Flexweg SQLite** — collects the master Flexweg API key + site URL + SQLite path + bootstrap admin email + password. Calls `POST /api/v1/sqlite/auth/install` to exchange the master key for a **scoped Sqlite token** (bound to the chosen path only — limited blast radius if leaked). Creates the SQLite tables, registers the bootstrap admin (first user → admin server-side), persists the master Flexweg API key in the SQLite `config` table (last use of the key before discarding), and uploads `config.js`. Subsequent boots talk to `/api/v1/sqlite/*` and use polling (~4 s) for real-time updates.
116
+
117
+ **The master Flexweg API key has the same security posture in both backends**: it's stored where the active backend keeps its app data (Firestore `config/flexweg` or SQLite `config` table) and is readable by any authenticated user via devtools. Documented compromise for an internal-tool deployment. Switching backends from Settings doesn't migrate the key — re-enter it in the new backend's Settings → Flexweg API panel.
118
+
119
+ ### Path A — Local build with `.env`
120
+
121
+ 1. Clone the repo and install dependencies:
122
+
123
+ ```bash
124
+ npm install
125
+ ```
126
+
127
+ 2. Copy the env template and fill in your Firebase project credentials:
128
+
129
+ ```bash
130
+ cp .env.example .env
131
+ ```
132
+
133
+ Required variables:
134
+
135
+ - `VITE_FIREBASE_API_KEY`, `VITE_FIREBASE_AUTH_DOMAIN`, `VITE_FIREBASE_PROJECT_ID`, `VITE_FIREBASE_STORAGE_BUCKET`, `VITE_FIREBASE_MESSAGING_SENDER_ID`, `VITE_FIREBASE_APP_ID`
136
+ - `VITE_ADMIN_EMAIL` (**optional, legacy**) — email of the bootstrap administrator. The current bootstrap-admin flow detects the admin via a Firestore rules probe (`config/admin`), so this env var is no longer written into `/admin/config.js` and is not required. Existing dev environments that have it set continue to work as a backward-compat fallback. The pinned admin email **must** still be set in your Firestore security rules.
137
+
138
+ 3. Start the dev server:
139
+
140
+ ```bash
141
+ npm run dev
142
+ ```
143
+
144
+ The admin runs on `http://localhost:5173`.
145
+
146
+ 4. Configure Firestore security rules (see [Firestore security rules](#firestore-security-rules) below).
147
+
148
+ 5. Sign in with the bootstrap admin email/password (created in Firebase Auth), open **Settings**, fill in:
149
+
150
+ - Site title, language (BCP-47, e.g. `en` or `fr-FR`), public site URL.
151
+ - Flexweg API key + site URL. The API key is stored in `config/flexweg` in Firestore.
152
+
153
+ ### Path B — No-build deployment (in-app first-run setup)
154
+
155
+ 1. Run `npm run build` once on any machine that has Node.js — the resulting `dist/admin/` works on any Flexweg site, regardless of which Firebase project it ends up pointing at. (You can skip this step entirely if someone else has already produced the `dist/admin/` for you.)
156
+
157
+ 2. Upload `dist/admin/` to your Flexweg site under `/admin/`. The bundle ships with `/admin/config.js` set to a null stub.
158
+
159
+ 3. **Before** opening `/admin/`, configure Firestore security rules with the bootstrap admin email you'll use (see [Firestore security rules](#firestore-security-rules) below). The setup form needs to write to `config/flexweg` — without rules in place, the write fails and the form surfaces a clear "Firestore rejected the configuration write" message pointing back to this section.
160
+
161
+ 4. Create the bootstrap admin user from **Firebase Console → Authentication → Users → Add user** (email + password).
162
+
163
+ 5. Open `https://your-site.flexweg.com/admin/`. The admin renders a **first-run setup form** asking for:
164
+
165
+ - Firebase: API key, auth domain, project ID, storage bucket, messaging sender ID, app ID.
166
+ - Bootstrap administrator: email + password (the user from step 4).
167
+ - Flexweg: API key, site URL, API base URL.
168
+
169
+ 6. Click **Save & continue**. The form runs through five steps, surfacing errors inline at each one:
170
+
171
+ 1. Sign in to Firebase with the entered credentials.
172
+ 2. Verify the signed-in email matches the admin email.
173
+ 2b. Verify the signed-in email is verified (`emailVerified == true`). If not, the form sends a Firebase verification email to the admin address and surfaces a clear "click the link, then submit again" message — required because the Firestore rules pin `email_verified == true` on the bootstrap admin.
174
+ 3. Test the Flexweg API key (a HEAD-like ping on `/files/storage-limits`).
175
+ 4. Write `config/flexweg` to Firestore.
176
+ 5. Upload a populated `/admin/config.js` to Flexweg via the API.
177
+
178
+ On success the admin reloads. The freshly-uploaded `config.js` synchronously sets `window.__FLEXWEG_CONFIG__` before the bundle loads, the resolver picks the values up, and the admin boots into the normal sign-in flow. The setup form never shows again unless someone manually empties `config.js` on Flexweg.
179
+
180
+ Each failure mode has a specific error message: invalid Firebase credentials, mismatched admin email, rejected Flexweg API key, Firestore rules not yet configured (with the email to pin in the rules), and upload failure.
181
+
182
+ ## Firestore security rules
183
+
184
+ Paste the following into **Firebase Console → Firestore → Rules** and replace `you@example.com` with the address you'll use as bootstrap administrator. The rules are now the **only** place the admin email lives — the SetupForm no longer writes it into `/admin/config.js`, and `VITE_ADMIN_EMAIL` is a deprecated legacy fallback only used by older deployments mid-migration.
185
+
186
+ ```
187
+ rules_version = '2';
188
+
189
+ service cloud.firestore {
190
+ match /databases/{database}/documents {
191
+
192
+ // ─── Helpers ──────────────────────────────────────────────────────────
193
+ // Bootstrap admin: email pinned here. The single source of truth.
194
+ function bootstrapAdminEmail() {
195
+ return "you@example.com";
196
+ }
197
+
198
+ function isSignedIn() {
199
+ return request.auth != null;
200
+ }
201
+
202
+ // Bootstrap admin: signed in, email matches the pinned address,
203
+ // AND email is verified. The email_verified check matters because
204
+ // Firebase Email/Password sign-in returns `email_verified: false`
205
+ // by default — the SetupForm enforces verification on first run,
206
+ // and these rules pin it server-side so an unverified token can
207
+ // never sign in as bootstrap admin (defends against future bypass).
208
+ function isBootstrapAdmin() {
209
+ return isSignedIn()
210
+ && request.auth.token.email != null
211
+ && request.auth.token.email_verified == true
212
+ && request.auth.token.email.lower() == bootstrapAdminEmail();
213
+ }
214
+
215
+ // Reads /users/{uid} for the caller. Returns null if no record yet.
216
+ function selfRecord() {
217
+ return exists(/databases/$(database)/documents/users/$(request.auth.uid))
218
+ ? get(/databases/$(database)/documents/users/$(request.auth.uid)).data
219
+ : null;
220
+ }
221
+
222
+ function isDisabled() {
223
+ let rec = selfRecord();
224
+ return !isBootstrapAdmin() && rec != null && rec.disabled == true;
225
+ }
226
+
227
+ function isAdmin() {
228
+ let rec = selfRecord();
229
+ return isBootstrapAdmin() || (rec != null && rec.role == "admin" && rec.disabled != true);
230
+ }
231
+
232
+ function isEditor() {
233
+ let rec = selfRecord();
234
+ return isBootstrapAdmin()
235
+ || (rec != null && (rec.role == "admin" || rec.role == "editor") && rec.disabled != true);
236
+ }
237
+
238
+ // ─── users/{uid} ──────────────────────────────────────────────────────
239
+ // - Any signed-in editor can read the list (Users page + author lookup).
240
+ // - First login: self-create allowed if uid matches and role is "editor".
241
+ // - Self update: only `preferences.adminLocale` may change.
242
+ // - Role/disabled changes: admin only.
243
+ match /users/{uid} {
244
+ allow read: if isEditor();
245
+
246
+ // Self-create on first login. Regular users may only create
247
+ // themselves with role "editor". The bootstrap admin (verified
248
+ // email + matching pinned address) may self-create with role
249
+ // "admin" so the UI shows admin features immediately, no manual
250
+ // promotion step needed. Either branch enforces email match and
251
+ // disabled=false.
252
+ allow create: if isSignedIn()
253
+ && request.auth.uid == uid
254
+ && request.resource.data.email == request.auth.token.email.lower()
255
+ && request.resource.data.disabled == false
256
+ && (
257
+ (isBootstrapAdmin() && request.resource.data.role == "admin")
258
+ || (!isBootstrapAdmin() && request.resource.data.role == "editor")
259
+ );
260
+
261
+ allow update: if isAdmin()
262
+ || (
263
+ isSignedIn()
264
+ && request.auth.uid == uid
265
+ && !isDisabled()
266
+ && request.resource.data.diff(resource.data).affectedKeys()
267
+ .hasOnly(["preferences", "firstName", "lastName", "bio", "avatarMediaId"])
268
+ && (
269
+ !("preferences" in request.resource.data.diff(resource.data).affectedKeys())
270
+ || request.resource.data.preferences.adminLocale in ["en", "fr", "de", "es", "nl", "pt", "ko"]
271
+ )
272
+ );
273
+
274
+ allow delete: if isAdmin();
275
+ }
276
+
277
+ // ─── posts/{id} (posts + pages) ───────────────────────────────────────
278
+ match /posts/{id} {
279
+ allow read, write: if isEditor();
280
+ }
281
+
282
+ // ─── terms/{id} (categories + tags) ───────────────────────────────────
283
+ match /terms/{id} {
284
+ allow read, write: if isEditor();
285
+ }
286
+
287
+ // ─── media/{id} ───────────────────────────────────────────────────────
288
+ match /media/{id} {
289
+ allow read, write: if isEditor();
290
+ }
291
+
292
+ // ─── settings/site ────────────────────────────────────────────────────
293
+ match /settings/{docId} {
294
+ allow read: if isEditor();
295
+ allow write: if isEditor();
296
+ }
297
+
298
+ // ─── config/admin ─────────────────────────────────────────────────────
299
+ // Empty placeholder probed by the admin client to detect bootstrap-
300
+ // admin status WITHOUT carrying the email in /admin/config.js. The
301
+ // doc itself doesn't need to exist — what matters is the rule: a
302
+ // get() succeeds only when isBootstrapAdmin() returns true.
303
+ match /config/admin {
304
+ allow read: if isBootstrapAdmin();
305
+ allow write: if isAdmin();
306
+ }
307
+
308
+ // ─── config/flexweg + other config docs ──────────────────────────────
309
+ // API key — read by every editor (publisher needs it), write admin-only.
310
+ // The `docId != "admin"` guard ensures editors do not read the probe
311
+ // doc above (Firestore rules OR-merge across all matching paths, so
312
+ // without this guard editors would silently pass the bootstrap probe).
313
+ match /config/{docId} {
314
+ allow read: if docId != "admin" && isEditor();
315
+ allow write: if docId != "admin" && isAdmin();
316
+ }
317
+
318
+ // ─── Default deny ─────────────────────────────────────────────────────
319
+ match /{document=**} {
320
+ allow read, write: if false;
321
+ }
322
+ }
323
+ }
324
+ ```
325
+
326
+ ### How the rules work
327
+
328
+ - **Bootstrap admin**: a user whose email matches `bootstrapAdminEmail()` is treated as admin even without a `users/{uid}` document. This solves the chicken-and-egg problem of the first login (no record exists yet to grant the admin role).
329
+ - **Self-create on first login**: any signed-in user can create their own `users/{uid}` document, but only with `role: "editor"` and `disabled: false` — this prevents privilege escalation. The admin promotes editors via the Users page afterwards.
330
+ - **Self update is restricted to profile fields**: a regular user can change their own `preferences` (admin language), `firstName`, `lastName`, `bio`, and `avatarMediaId` — the editable surface of the **Settings → Profile** form. They cannot touch their own role or disabled flag. Admins can update any field on any record.
331
+ - **`config/flexweg` requires admin to write**: editors can read the API key (the publisher needs it), but only admins can rotate it.
332
+ - **Each rule call performs one extra read** via `selfRecord()`. Acceptable for an internal tool; if you need to optimize later, switch to [Firebase Auth custom claims](https://firebase.google.com/docs/auth/admin/custom-claims) (requires an admin SDK backend to set them).
333
+
334
+ If you want only admins to manage themes/plugins/menus, change `allow write: if isEditor();` to `allow write: if isAdmin();` on `match /settings/{docId}`.
335
+
336
+ ## Firestore indexes
337
+
338
+ **You only need this section if you have a very large site** and want to switch the admin to **Server-side paginated** mode in **Settings → General → Performance**.
339
+
340
+ The default mode is **Standard**, which subscribes to the entire `posts` collection in one shot (`orderBy(createdAt)` on a single field — already covered by Firestore's automatic single-field index). No setup needed; works on the free Spark plan out of the box. List pages, search, dashboard counts and pickers all run from the in-memory snapshot. Recommended ceiling: ~5 000 posts before the initial fetch starts to feel slow.
341
+
342
+ The **Server-side paginated** mode subscribes to one cursor-paginated page at a time. This requires the two composite indexes documented below. The admin's [`FirestoreSetupGate`](src/components/FirestoreSetupGate.tsx) detects the indexes the moment you flip the toggle and walks you through creating them with one click — so even Option 3 below (the admin-guided flow) needs no terminal access.
343
+
344
+ ### Indexes to create
345
+
346
+ | Use case | Fields | Order |
347
+ |---|---|---|
348
+ | All posts/pages, paginated | `type` (asc), `createdAt` (desc) | desc on `createdAt` |
349
+ | Posts/pages by status, paginated | `type` (asc), `status` (asc), `createdAt` (desc) | desc on `createdAt` |
350
+
351
+ ### Option 1 — gcloud CLI (recommended)
352
+
353
+ Install the [gcloud CLI](https://cloud.google.com/sdk/docs/install), authenticate (`gcloud auth login`), then set the active project:
354
+
355
+ ```bash
356
+ gcloud config set project <YOUR_FIREBASE_PROJECT_ID>
357
+ ```
358
+
359
+ Create the two indexes:
360
+
361
+ ```bash
362
+ # Index 1: type + createdAt — covers the "all" tab
363
+ gcloud firestore indexes composite create \
364
+ --collection-group=posts \
365
+ --query-scope=COLLECTION \
366
+ --field-config=field-path=type,order=ASCENDING \
367
+ --field-config=field-path=createdAt,order=DESCENDING
368
+
369
+ # Index 2: type + status + createdAt — covers the draft / online tabs
370
+ gcloud firestore indexes composite create \
371
+ --collection-group=posts \
372
+ --query-scope=COLLECTION \
373
+ --field-config=field-path=type,order=ASCENDING \
374
+ --field-config=field-path=status,order=ASCENDING \
375
+ --field-config=field-path=createdAt,order=DESCENDING
376
+ ```
377
+
378
+ Each command finishes synchronously and reports the index id. The index itself takes 1–5 minutes to build on the backend depending on collection size — Firebase Console → Firestore → Indexes shows the progress.
379
+
380
+ ### Option 2 — `firestore.indexes.json` (Firebase CLI)
381
+
382
+ Drop the following at your project root, then run `firebase deploy --only firestore:indexes`:
383
+
384
+ ```json
385
+ {
386
+ "indexes": [
387
+ {
388
+ "collectionGroup": "posts",
389
+ "queryScope": "COLLECTION",
390
+ "fields": [
391
+ { "fieldPath": "type", "order": "ASCENDING" },
392
+ { "fieldPath": "createdAt", "order": "DESCENDING" }
393
+ ]
394
+ },
395
+ {
396
+ "collectionGroup": "posts",
397
+ "queryScope": "COLLECTION",
398
+ "fields": [
399
+ { "fieldPath": "type", "order": "ASCENDING" },
400
+ { "fieldPath": "status", "order": "ASCENDING" },
401
+ { "fieldPath": "createdAt", "order": "DESCENDING" }
402
+ ]
403
+ }
404
+ ],
405
+ "fieldOverrides": []
406
+ }
407
+ ```
408
+
409
+ ### Option 3 — let the admin guide you (zero config, recommended for non-CLI users)
410
+
411
+ When you flip the toggle to **Server-side paginated** in **Settings → General → Performance**, the admin SPA pings the two composite-index queries on the next render. If the indexes are missing it shows a setup screen with direct one-click links to the Firebase Console. Each link opens the index-creation form pre-filled — you click "Create" inside Firebase, wait 1–5 minutes for the build, then click "Retry" in the admin. Once both indexes report ready, the gate disappears.
412
+
413
+ This works on Firestore's **free tier (Spark plan)** without any additional configuration. The detection result is cached in `localStorage` so subsequent reloads skip the ping.
414
+
415
+ In **Standard** mode (the default) the gate is a pure pass-through — no ping, no setup screen. You only see this flow if you actually opt into paginated mode.
416
+
417
+ ### Verifying
418
+
419
+ After the indexes finish building (status: `READY` in the console), the post / page list pages load in under a second regardless of corpus size, and the dashboard's stat cards return their counts via aggregation queries (single read each).
420
+
421
+ ## Building & deploying
422
+
423
+ ```bash
424
+ npm run build
425
+ ```
426
+
427
+ This produces two folders inside `dist/`, each mapping 1:1 to a Flexweg path:
428
+
429
+ ```
430
+ dist/
431
+ ├── admin/ → upload to /admin/ on Flexweg
432
+ │ ├── index.html
433
+ │ └── assets/
434
+ └── theme-assets/ → upload to /theme-assets/ on Flexweg (site root)
435
+ └── <theme-id>.css one CSS file per theme directory
436
+ ```
437
+
438
+ The split mirrors the public site layout: every published page contains a `<link rel="stylesheet" href="/theme-assets/<id>.css">` pointing to the site root, **never** to `/admin/...`. Keeping `theme-assets/` at the top level of `dist/` makes the deployment story obvious: drop each folder where its name says it belongs.
439
+
440
+ **First deploy** — upload both folders to your Flexweg site (zip and drop via the dashboard, or use the Files API).
441
+
442
+ **Subsequent deploys** — re-upload `dist/admin/` whenever you ship admin code changes, then open **Themes → Sync theme assets** to push the new theme CSS to the public root. The CSS is embedded inside the admin bundle (Vite `?inline`), so the sync button always uploads the version that shipped with the current admin — no need to manually re-upload `dist/theme-assets/`.
443
+
444
+ After that, every publish action from the admin uploads HTML/asset files directly to your public Flexweg site — no further deploys required for content updates.
445
+
446
+ ## Internationalisation
447
+
448
+ The admin UI ships with translations for seven locales:
449
+
450
+ | Code | Language | |
451
+ |---|---|---|
452
+ | `en` | English | 🇬🇧 |
453
+ | `fr` | Français | 🇫🇷 |
454
+ | `de` | Deutsch | 🇩🇪 |
455
+ | `es` | Español | 🇪🇸 |
456
+ | `nl` | Nederlands | 🇳🇱 |
457
+ | `pt` | Português | 🇵🇹 |
458
+ | `ko` | 한국어 | 🇰🇷 |
459
+
460
+ Each user picks their preferred admin language from the **Topbar** language selector or in **Settings → Profile**. The choice is saved on the user's Firestore profile (so it follows them across devices) and in `localStorage` (so reloads don't flash the wrong language). English is the source of truth — `src/i18n/en.json` is the canonical bundle and i18next falls back to it for any missing key in the other locales, so the admin never breaks if a translation is incomplete.
461
+
462
+ Plugins / mu-plugins / themes ship their own bundled translations alongside their manifest. They're loaded into a dedicated i18next namespace named after the plugin or theme id (e.g. `theme-default`, `flexweg-rss`). All seven admin locales are supported across every built-in plugin and theme.
463
+
464
+ The site language for the public output is configured separately in **Settings → Site → Site language** (BCP-47), and is injected as `<html lang="…">` on every published page. The bundled `flexweg-sitemaps` plugin also localises the styled sitemap-viewer (the HTML rendering of the XML sitemaps in a browser) for the same seven languages — pick the right one when you upload the XSL stylesheets.
465
+
466
+ ### Multi-language public sites (`flexweg-multilang`)
467
+
468
+ For sites that publish content in more than one language, install the bundled `flexweg-multilang` plugin (shipped under `external/plugins/flexweg-multilang/`, pre-installed on a fresh deployment via `external.default.json`). It turns Flexweg into a fully multilingual CMS while keeping the static-publishing model:
469
+
470
+ - **Per-language URL layout** — primary language at root (`/news/hello.html`), secondary languages prefixed (`/fr/actualites/bonjour.html`). Configure the primary language and the list of secondary languages under **Settings → Multi-language**.
471
+ - **Per-post / per-page editor** — when at least one secondary language is enabled, the post editor grows a language tab strip above the canvas. Switching tabs swaps the full editor state (title, slug, body, blocks, excerpt, SEO) for the selected language while keeping the same Tiptap instance alive (so blocks and drag-and-drop survive language switches).
472
+ - **Per-term name + slug + description + SEO** — categories and tags expose the per-language fields inline on the Categories & Tags page (chevron to expand). Empty fields fall back to the primary language.
473
+ - **Per-language menu labels** — the Menus page renders one extra input per secondary language under each item's primary label. Filled-in values get projected into `/menu.json` as a `labels` map; the runtime menu loader picks the right one based on `<html lang>`.
474
+ - **SEO** — every localised page emits its own `<title>`, `<meta name="description">`, `og:locale` + alternates, and a clean canonical (no `/index.html` suffix). Reciprocal `<link rel="alternate" hreflang="…">` tags are emitted in the head of every variant (including the primary). The sitemap entries carry `<xhtml:link rel="alternate" hreflang="…">` alternates, and `flexweg-rss` emits one feed per locale.
475
+ - **Per-locale homes + category archives + author archives** — each locale gets its own `/<lang>/index.html`, `/<lang>/<category>/index.html`, etc. The publisher resolves them through a "shadow context" pass so themes don't need any multilang-specific code — they just render whatever props the publisher hands them.
476
+
477
+ The plugin is backend-agnostic (works on both Firebase and Flexweg SQLite). Configure it under **Plugins → Multi-language → Settings**.
478
+
479
+ ### Per-term SEO meta tags
480
+
481
+ Every category and tag carries an optional `Term.seo = { title?, description?, ogImage? }` block, edited via the **SEO** button on each row of the Categories & Tags page (between the slug input and Save). On the category archive page:
482
+
483
+ - `term.seo.title` (or the term name as fallback) drives the `<title>` element
484
+ - `term.seo.description` (or the term description as fallback) drives `<meta name="description">`
485
+ - `term.seo.ogImage` drives `<meta property="og:image">`
486
+
487
+ When `flexweg-multilang` is enabled, the per-language SEO overrides live inside the expanded translations section on each row (one collapsible block per secondary language carries the localized `seoTitle` + `seoDescription`).
488
+
489
+ ### Discourage search engines from indexing
490
+
491
+ A WordPress-style toggle lives under **Settings → Site → Search-engine indexing → Discourage search engines from indexing this site**. When enabled:
492
+
493
+ - Every published HTML page receives `<meta name="robots" content="noindex,nofollow">`
494
+ - The bundled `flexweg-sitemaps` plugin forces `robots.txt` to `User-agent: *\nDisallow: /`, overriding any user-supplied robots content
495
+
496
+ Use it during the staging phase of a new site so search engines don't index draft URLs. Toggle it off (and click **Regenerate ▾ → Everything**) before going live.
497
+
498
+ ### Updating Firestore security rules
499
+
500
+ The example Firestore rules above lock the `preferences.adminLocale` field to a closed list:
501
+
502
+ ```
503
+ && request.resource.data.preferences.adminLocale in ["en", "fr", "de", "es", "nl", "pt", "ko"]
504
+ ```
505
+
506
+ If you upgraded from an older deployment that allowed only `["en", "fr"]`, copy the updated list into your Firebase Console → Firestore → Rules tab; otherwise users won't be able to save the new locales.
507
+
508
+ ## URL strategy
509
+
510
+ - Top-level pages: `/<page-slug>.html`
511
+ - Posts without a category: `/<post-slug>.html`
512
+ - Posts with a category: `/<category-slug>/<post-slug>.html`
513
+ - Category archives: `/<category-slug>/index.html`
514
+ - Home: `/index.html`
515
+ - 404 fallback: `/404.html`
516
+
517
+ Tags do not appear in URLs. All slugs are lower-case ASCII, dash-separated.
518
+
519
+ ## Slug uniqueness
520
+
521
+ Two posts / pages / categories must never share the same public URL — Flexweg paths are case-sensitive and a duplicate would silently overwrite the other on the next publish.
522
+
523
+ - **Auto-generated slugs (new entities)**: when the title produces a slug already in use, the admin appends `-2`, `-3`, … until the resulting path is free. You never think about it for the common case.
524
+ - **User-edited slugs**: validated live against existing entities. If you type a slug that collides, an inline error appears under the field naming the conflicting entity, and the **Save** button is disabled.
525
+
526
+ Collision is computed against the **final URL path**, not just the raw slug:
527
+
528
+ | Entity A | Entity B | Same slug? | Same path? | Collision? |
529
+ |---|---|---|---|---|
530
+ | Post `news` (no category) | Page `news` | yes | both `/news.html` | **yes** |
531
+ | Post `2026` in `news` | Post `2026` in `events` | yes | `/news/2026.html` vs `/events/2026.html` | no |
532
+ | Post `news` (no category) | Category `news` | yes | `/news.html` vs `/news/index.html` | no |
533
+ | Tag `news` | Category `news` | yes | tag has no public URL | no |
534
+
535
+ Helpers live in [src/core/slug.ts](src/core/slug.ts): `findAvailableSlug`, `detectPathCollision`, `detectTermSlugCollision`.
536
+
537
+ ## Stale path cleanup
538
+
539
+ Each `Post` carries two fields used by the publisher to keep the public site free of orphaned files:
540
+
541
+ - `lastPublishedPath` — the path the post is currently live at on Flexweg.
542
+ - `previousPublishedPaths[]` — paths the publisher tried but **failed** to delete during a previous publish (e.g. a transient API error).
543
+
544
+ On every publish, before uploading the new file, the publisher attempts to delete every entry in `[lastPublishedPath, ...previousPublishedPaths]` that isn't the new path. Failed deletions (non-404) are persisted back into `previousPublishedPaths` and retried on the next publish.
545
+
546
+ This guarantees:
547
+ - Changing a post's slug or primary category never leaves the old URL accessible.
548
+ - A transient Flexweg API hiccup is recovered automatically next publish.
549
+ - Unpublishing wipes every known historical path, not just the most recent one.
550
+
551
+ ## Error handling & toasts
552
+
553
+ Every Flexweg API call goes through a single error funnel that surfaces failures both as thrown `FlexwegApiError`s (so calling code can react locally — e.g. retry buttons on the upload card) **and** as flash toasts in the top-right corner of the admin. Toasts include translated, status-aware messages:
554
+
555
+ | Status | Toast message |
556
+ |---|---|
557
+ | 401 / 403 | "Authentication failed. Check your Flexweg API key in Settings." |
558
+ | 404 | "Target not found on Flexweg." (suppressed for delete calls — 404 is treated as success) |
559
+ | 413 | "File too large for your Flexweg plan." |
560
+ | 429 | "Too many requests, please retry in a moment." |
561
+ | 5xx | "Flexweg server error ({{status}}). Please retry." |
562
+ | Network / CORS | "Network error or CORS blocked. Check your connection." |
563
+ | Other | Generic message including the status and any error payload returned by Flexweg. |
564
+
565
+ To emit a toast from anywhere in the codebase:
566
+
567
+ ```ts
568
+ import { toast } from "../lib/toast";
569
+
570
+ toast.success("Saved.");
571
+ toast.error("Something broke.");
572
+ toast.warn("Heads up.");
573
+ toast.info("FYI.");
574
+ ```
575
+
576
+ Toasts auto-dismiss after 3.5 s (info / success), 5 s (warn) or 6 s (error). Pass a custom `durationMs` (or `0` for sticky) when needed: `showToast({ level: "error", message, durationMs: 0 })`.
577
+
578
+ ## Dynamic menus
579
+
580
+ The header / footer menus configured under **Menus** in the admin are loaded **at runtime** by every published page, not baked into the HTML. This means changing a menu item, adding a new page to the menu, or moving a category around does **not** require regenerating every page on the site — the change becomes visible on the next page load anywhere.
581
+
582
+ ### How it works
583
+
584
+ 1. Saving in `MenusPage` (or any publish/unpublish that may have moved a referenced post / term) triggers `publishMenuJson`, which writes a small `/menu.json` file to the public site root with the resolved menu structure.
585
+ 2. The active theme's `BaseLayout` ships a `<script src="/theme-assets/<id>-menu.js" defer>`. That loader fetches `/menu.json` on `DOMContentLoaded` and populates every `[data-cms-menu="header|footer"]` container present on the page.
586
+ 3. The default theme's loader also wires the burger toggle (`aria-expanded` flip + class toggle for the off-canvas overlay) and sets `aria-current="page"` on the link whose href matches the current URL (so themes can style the active item via CSS).
587
+
588
+ ### Per-language menu labels
589
+
590
+ Each menu item carries an optional `translations: Record<<lang>, { label? }>` map (edited inline in `MenusPage` when at least one secondary language is enabled in `flexweg-multilang`). The resolver projects non-empty entries into a `labels` map on each resolved item in `/menu.json`:
591
+
592
+ ```json
593
+ { "id": "…", "label": "Care", "href": "/care/", "labels": { "fr": "Soins" } }
594
+ ```
595
+
596
+ Every theme's runtime menu loader reads `document.documentElement.lang` (set per-page by `BaseLayout` based on `currentLocale`), looks up `item.labels[lang]`, and falls back to `item.label` for any locale without an override. A region-stripped fallback handles `<html lang="en-US">` → `en`. On mono-lingual sites the `labels` field is omitted entirely to keep `menu.json` small.
597
+
598
+ After adding labels: click **Save & generate** on the Menus page (writes the new `menu.json`), then **Regenerate ▾ → Sync theme assets** if the per-language loader code is newer than what's currently on the public site.
599
+
600
+ ### DOM contract for new themes
601
+
602
+ Any theme that wants dynamic menus exposes empty containers and a companion JS file (declared via `jsText` on the manifest):
603
+
604
+ ```html
605
+ <nav class="…" data-cms-menu="header"><ul></ul></nav>
606
+ <nav class="…" data-cms-menu="footer"><ul></ul></nav>
607
+ ```
608
+
609
+ The loader fills the inner `<ul>`. If `/menu.json` is unreachable, the containers stay empty and the loader hides them via the `hidden` attribute — no error UI, no empty space.
610
+
611
+ ### Failure mode
612
+
613
+ A failed menu.json publish toasts an error (the same Flexweg error funnel as everything else) but **does not** abort the surrounding action (post publish / save). The Firestore state remains the source of truth; the next successful publish retries the menu upload.
614
+
615
+ ## Image handling
616
+
617
+ When a user uploads an image, the admin runs the file entirely through a browser-side pipeline (no server):
618
+
619
+ 1. Validates the extension against the active theme's `inputFormats`.
620
+ 2. Decodes the file via `createImageBitmap`.
621
+ 3. For each variant declared by the active theme **plus** three admin-only formats (`admin-thumb`, `admin-preview`, `admin-original`), renders a resized canvas and re-encodes it as WebP (or whatever `outputFormat` the theme picked).
622
+ 4. Uploads each variant to Flexweg under `media/<yyyy>/<mm>/<slug>-<hex>/<variant>.webp`.
623
+ 5. Persists a single `media/{id}` Firestore document referencing every variant by name.
624
+
625
+ The uploaded source file is **not stored verbatim**. Instead, the pipeline produces an `admin-original` variant: the source resized so the longest axis is capped at 2000 px (no crop, ratio preserved, never upscaled) and re-encoded at quality 90 — purpose-built as a high-fidelity source for future re-crops, not for display. This keeps storage bounded while preserving enough resolution that adding a new theme variant later can regenerate from `admin-original` rather than upscaling a smaller display variant.
626
+
627
+ A single `ImageFormat` may carry an optional `quality` override on the 0..100 scale. The `admin-original` format uses this to bump from the theme's global quality (default 80) to 90 without affecting display variants. Other variants honour the override transparently if they want it.
628
+
629
+ ### Declaring image formats in a theme
630
+
631
+ Themes export an `imageFormats` field on their manifest:
632
+
633
+ ```ts
634
+ // src/themes/<id>/manifest.ts
635
+ export const manifest: ThemeManifest = {
636
+ id: "my-theme",
637
+ // …
638
+ imageFormats: {
639
+ inputFormats: [".jpg", ".jpeg", ".png", ".webp"],
640
+ outputFormat: "webp",
641
+ quality: 80,
642
+ formats: {
643
+ small: { width: 480, height: 480, fit: "cover" },
644
+ medium: { width: 800, height: 800, fit: "cover" },
645
+ large: { width: 1600, height: 900, fit: "cover" },
646
+ },
647
+ defaultFormat: "medium",
648
+ },
649
+ };
650
+ ```
651
+
652
+ `fit: "cover"` crops the source to fill the box; `fit: "contain"` shrinks the source to fit inside without cropping (no upscaling).
653
+
654
+ ### Using formats in templates
655
+
656
+ Theme code receives `MediaView` objects from the publisher. Pick a format with the helper:
657
+
658
+ ```tsx
659
+ import { pickFormat } from "../../../core/media";
660
+
661
+ <img src={pickFormat(hero, "large")} alt={hero.alt ?? ""} />
662
+ <img src={pickFormat(hero)} /> // uses defaultFormat (e.g. "medium")
663
+ ```
664
+
665
+ `pickFormat` falls back through a chain: requested format → `defaultFormat` → largest available → empty string. This means switching to a theme that asks for a format an old upload doesn't have still renders the image at the next-best size, never broken. For new uploads the chain naturally lands on `admin-original` (2000 px) when nothing else fits — so re-cropping headroom exists for any theme variant up to that bound.
666
+
667
+ ### Storage layout & cleanup
668
+
669
+ ```
670
+ media/
671
+ 2026/
672
+ 05/
673
+ photo-de-vacances-a3f7b2/
674
+ admin-thumb.webp
675
+ admin-preview.webp
676
+ admin-original.webp
677
+ small.webp
678
+ medium.webp
679
+ large.webp
680
+ ```
681
+
682
+ Filenames are normalised (lower-case ASCII, dash-separated) and suffixed with a 6-character random hex string to guarantee uniqueness across uploads. Deleting a media item triggers a single `DELETE /files/delete-folder` call that wipes the whole asset folder atomically.
683
+
684
+ ### Backward compatibility
685
+
686
+ Media uploaded before the multi-variant pipeline keeps its original `{ url, storagePath }` shape. The helpers (`pickFormat`, `pickMediaUrl`, `mediaToView`) read either shape transparently — no migration step required.
687
+
688
+ Media uploaded between the multi-variant rollout and the `admin-original` addition will not have the `admin-original` key in their `formats` map; nothing breaks (the existing fallback chain handles it) but those assets cannot be re-cropped at full headroom — only the variants that exist on disk are available.
689
+
690
+ ## Creating a new theme
691
+
692
+ You can start from either built-in theme:
693
+
694
+ - **`default/`** — SCSS-based pipeline. Variables + manual `@layer` blocks.
695
+ - **`magazine/`** — Tailwind-based pipeline. `tailwind.config.cjs` + `theme.css` with `@tailwind` directives + Material 3 palette as RGB triplets.
696
+
697
+ Steps:
698
+
699
+ 1. Copy `src/themes/default/` (SCSS) or `src/themes/magazine/` (Tailwind) to `src/themes/<your-theme-id>/`.
700
+ 2. Update `manifest.ts` with a unique `id`, `name`, `version`.
701
+ 3. Edit `templates/*.tsx` and the stylesheet as needed. Components receive only serialisable props — don't import Firestore hooks from theme code.
702
+ 4. Register the theme by appending `<your-theme-id>Manifest` to `THEMES` in `src/themes/index.ts`.
703
+ 5. Run `npm run build`, then sync theme assets from the admin.
704
+
705
+ For Tailwind themes, `scripts/build-theme-tailwind.mjs` runs before Vite (wired into `prebuild` and `predev`) and compiles each theme's `tailwind.config.cjs` + `theme.css` into `theme.compiled.css`, which the manifest then `?inline`-imports. For iterative work, run `npx tailwindcss -c <config> -i <input> -o <output> --watch` in a separate terminal — Vite HMR picks up the rewritten file. SCSS themes go through the Sass pipeline in `build-themes.mjs` instead.
706
+
707
+ The active theme is selected per-site in **Themes**. Switching activates a "Regenerate site" workflow that re-publishes every online post.
708
+
709
+ ### Theme settings page
710
+
711
+ Each theme can ship its own configuration page reachable at `/admin/#/theme-settings`. The sidebar entry **Theme settings** appears automatically when the active theme declares one — switch themes and the entry follows.
712
+
713
+ ```ts
714
+ import type { ThemeManifest, ThemeSettingsPageProps } from "../types";
715
+
716
+ interface MyThemeConfig {
717
+ accentColor: string;
718
+ }
719
+
720
+ const DEFAULT_CONFIG: MyThemeConfig = { accentColor: "#476558" };
721
+
722
+ function MyThemeSettingsPage({ config, save }: ThemeSettingsPageProps<MyThemeConfig>) {
723
+ // `config` is already merged with defaultConfig.
724
+ // `save(next)` writes the whole blob to settings/site.themeConfigs.<id>.
725
+ return (
726
+ <input
727
+ value={config.accentColor}
728
+ onChange={(e) => save({ ...config, accentColor: e.target.value })}
729
+ />
730
+ );
731
+ }
732
+
733
+ export const manifest: ThemeManifest<MyThemeConfig> = {
734
+ id: "my-theme",
735
+ // …
736
+ settings: {
737
+ navLabelKey: "title", // i18n key in the theme's namespace
738
+ defaultConfig: DEFAULT_CONFIG,
739
+ component: MyThemeSettingsPage,
740
+ },
741
+ i18n: {
742
+ en: { title: "My theme settings", colorLabel: "Accent color" },
743
+ fr: { title: "Réglages du thème", colorLabel: "Couleur d'accent" },
744
+ },
745
+ };
746
+ ```
747
+
748
+ Storage lives at `settings/site.themeConfigs.<theme-id>` in Firestore — same real-time subscription as the rest of the admin. Inside theme components rendered at publish time, the resolved config is exposed as `site.themeConfig` on `SiteContext`.
749
+
750
+ Translations are loaded into a dedicated i18next namespace named `theme-<id>` so settings pages call `useTranslation("theme-<id>")` without colliding with admin keys.
751
+
752
+ Tabs inside the settings page are a theme concern — render a tab strip in your component the way the default theme does (single `useState` for the active tab + conditional sections).
753
+
754
+ ### Default theme — Logo upload
755
+
756
+ The default theme exposes a **General** tab with a logo upload. Workflow:
757
+
758
+ 1. Admin opens **Theme settings → General** and uploads a JPG / PNG / WebP image.
759
+ 2. The image is resized client-side (canvas, no server) into a 480 × 144 WebP and pushed to `theme-assets/default-logo.webp` on Flexweg.
760
+ 3. The theme config saves `{ logoEnabled: true, logoUpdatedAt: <ms> }` and the admin re-publishes `data/menu.json` with a new `branding.logoUrl` (the URL is cache-busted via `?v=<logoUpdatedAt>`).
761
+ 4. The runtime `menu-loader.js` already loaded on every published page picks up the branding block on the next page load and replaces the text wordmark inside `[data-cms-brand]` with an `<img>`.
762
+
763
+ So a logo change pushes only **one** small JSON file (`menu.json`) plus the logo binary — no need to re-publish every post HTML. Removing the logo runs the same flow with `logoEnabled: false` and best-effort deletes the WebP file.
764
+
765
+ ### Default theme — Style tab (colors, fonts, spacing, radius)
766
+
767
+ The **Style** tab in the default theme's settings page lets admins customize the entire design token set without re-rendering posts. Click **Save & apply** to push a regenerated CSS file to Flexweg.
768
+
769
+ Editable surface (22 tokens + 2 fonts):
770
+
771
+ - **Surfaces**: `--color-bg`, `--color-surface`, `--color-surface-{low,mid,high,highest}` — page background and tonal layers
772
+ - **Foreground**: `--color-on-surface`, `--color-on-surface-variant`, `--color-on-surface-muted` — text colors
773
+ - **Outlines**: `--color-outline`, `--color-outline-variant`
774
+ - **Accent**: `--color-primary`, `--color-on-primary`, `--color-secondary`, `--color-secondary-container`, `--color-on-secondary-container`
775
+ - **Spacing & layout**: `--container-max`, `--gutter`, `--section-gap`
776
+ - **Corner radius**: `--radius-{sm,,lg,xl}`
777
+ - **Typography**: serif and sans-serif Google Fonts, picked from a curated list (Newsreader, Lora, Playfair Display, EB Garamond, Source Serif 4, Cormorant Garamond / Inter, Plus Jakarta Sans, Outfit, Manrope, DM Sans, Work Sans)
778
+
779
+ How it works under the hood:
780
+
781
+ 1. The original compiled CSS is bundled into the admin via Vite's `?inline` import (`manifest.cssText`).
782
+ 2. The fonts are loaded via `@import url(...)` at the **top of the SCSS file** (not the HTML), so swapping fonts means rewriting one line in the CSS and re-uploading — no HTML republish.
783
+ 3. `buildCustomCss(baseCssText, style)` (in `src/themes/default/style.ts`) takes the bundled CSS, replaces the `@import url(...)` with the URL for the chosen font pair, and appends a new `:root { ... }` block with the user's variable overrides. Cascade resolves later declarations as winners on equal specificity.
784
+ 4. The result is uploaded over `theme-assets/default.css` — the same path every published page references. Browsers may serve a cached copy until a hard refresh.
785
+ 5. **`compileCss` hook on the manifest** wires this transformation into the **Themes → Sync theme assets** flow so syncing won't wipe customizations: the bundled CSS is the *baseline*, the regenerator is the *live state*.
786
+
787
+ Reset to defaults: clears the `style` config and pushes the baseline CSS untouched.
788
+
789
+ ## Creating a plugin
790
+
791
+ Plugins are bundled into the admin and toggled on/off per site in **Plugins** (the **Plugins** tab on `/admin/#/plugins`). Each plugin exports a manifest with a `register(api)` function that hooks into the registry. Optionally, a plugin can also ship its own settings page and translations.
792
+
793
+ ```ts
794
+ // src/plugins/my-plugin/manifest.ts
795
+ import type { PluginManifest } from "../index";
796
+
797
+ export const manifest: PluginManifest = {
798
+ id: "my-plugin",
799
+ name: "My plugin",
800
+ version: "1.0.0",
801
+ description: "What it does.",
802
+ register(api) {
803
+ api.addFilter<string>("page.head.extra", (head) => head + "<meta name=\"x\" />");
804
+ api.addAction("publish.complete", (post, ctx) => {
805
+ console.log("published", post, "site has", ctx.posts.length, "posts");
806
+ });
807
+ },
808
+ };
809
+ ```
810
+
811
+ Then add it to `PLUGINS` in `src/plugins/index.ts`.
812
+
813
+ ### Available hooks
814
+
815
+ | Hook | Type | Payload |
816
+ |---|---|---|
817
+ | `post.markdown.before` | filter | `(markdown, post)` — modify Markdown before rendering. |
818
+ | `post.html.body` | filter | `(html, post)` — modify rendered post HTML. |
819
+ | `post.template.props` | filter | `(props, post)` — modify the props passed to the active template. |
820
+ | `page.head.extra` | filter (sync) | `(html, baseProps)` — inject extra `<head>` markup. |
821
+ | `page.body.end` | filter (sync) | `(html, baseProps)` — inject extra markup right before `</body>`. Use for tracking scripts, deferred widgets, etc. |
822
+ | `menu.json.resolved` | filter | `(menu, ctx)` — mutate the resolved `{ header, footer }` shape just before `/menu.json` is uploaded. `ctx` exposes `settings`, `posts`, `pages`, `terms`. |
823
+ | `publish.before` | action | `(post, ctx)` |
824
+ | `publish.after` | action | `(post, ctx)` |
825
+ | `publish.complete` | action | `(post, ctx)` — fires after the post is uploaded and listings refreshed. |
826
+ | `post.unpublished` | action | `(post, ctx)` — fires after `unpublishPost` wipes the post's files. |
827
+ | `post.deleted` | action | `(post, ctx)` — fires after `deletePostAndUnpublish` removes the post. |
828
+
829
+ `ctx` is a `PublishContext` (exported from `src/services/publisher.ts`) containing the up-to-date `posts`, `pages`, `terms`, `media`, and `settings` snapshots — already patched to reflect the just-completed transition. Plugins can read it to recompute derived files (sitemaps, search indexes, RSS feeds, …).
830
+
831
+ ### Plugin settings pages
832
+
833
+ A plugin can expose a config page reachable at `/admin/#/settings/plugin/<plugin-id>`. The plugins list shows a **Configure** link on the matching card, and a tab is added to the Settings layout's tab strip for every enabled plugin that declares one.
834
+
835
+ ```ts
836
+ import type { PluginManifest, PluginSettingsPageProps } from "../index";
837
+
838
+ interface MyConfig {
839
+ greeting: string;
840
+ }
841
+
842
+ const DEFAULT_CONFIG: MyConfig = { greeting: "hello" };
843
+
844
+ function MySettingsPage({ config, save }: PluginSettingsPageProps<MyConfig>) {
845
+ // `config` is already merged with `defaultConfig` — every field is set.
846
+ // `save(next)` writes the whole blob to settings/site.pluginConfigs.<id>.
847
+ return (
848
+ <input
849
+ value={config.greeting}
850
+ onChange={(e) => save({ ...config, greeting: e.target.value })}
851
+ />
852
+ );
853
+ }
854
+
855
+ export const manifest: PluginManifest<MyConfig> = {
856
+ id: "my-plugin",
857
+ // …
858
+ settings: {
859
+ navLabelKey: "title", // i18n key in the plugin's namespace
860
+ defaultConfig: DEFAULT_CONFIG,
861
+ component: MySettingsPage,
862
+ },
863
+ };
864
+ ```
865
+
866
+ The config blob is stored under `settings/site.pluginConfigs.<plugin-id>` in Firestore, so the same real-time subscription that drives the rest of the admin reaches plugin settings — no extra listeners. Inside an action-hook handler, read the live config from `ctx.settings.pluginConfigs?.[<plugin-id>]`.
867
+
868
+ ### Plugin translations
869
+
870
+ Plugins ship their own translations as a manifest field. They land in a dedicated i18next namespace named after the plugin id, so plugin UI uses `useTranslation('<plugin-id>')` without colliding with admin keys:
871
+
872
+ ```ts
873
+ // src/plugins/my-plugin/manifest.ts
874
+ export const manifest: PluginManifest = {
875
+ // …
876
+ i18n: {
877
+ en: { title: "My plugin", help: "Does something useful." },
878
+ fr: { title: "Mon plugin", help: "Fait quelque chose d'utile." },
879
+ },
880
+ };
881
+
882
+ // inside the settings page component
883
+ const { t } = useTranslation("my-plugin");
884
+ return <h2>{t("title")}</h2>;
885
+ ```
886
+
887
+ Bundles are loaded once at module import and persist regardless of plugin enable state — translations are cheap and orthogonal to the runtime hook registration.
888
+
889
+ ## Must-use plugins (mu-plugins)
890
+
891
+ A plugin placed under `src/mu-plugins/` instead of `src/plugins/` is **always active** — there is no enable / disable toggle in the admin and no entry in `settings.enabledPlugins`. Use this folder for behaviour the site cannot meaningfully run without (favicons, future features that ship as plugins for code organisation but aren't optional for users). The mechanism mirrors WordPress's [must-use plugins](https://wordpress.org/documentation/article/must-use-plugins/) directory.
892
+
893
+ The manifest shape, hook API, settings page support, and i18n bundles are **identical** to regular plugins — a must-use plugin is just a plugin that lives in a different folder. To convert between the two, move the folder and update the import in `src/mu-plugins/index.ts` or `src/plugins/index.ts`.
894
+
895
+ ```ts
896
+ // src/mu-plugins/my-mu-plugin/manifest.ts
897
+ import type { PluginManifest } from "../../plugins";
898
+
899
+ export const manifest: PluginManifest = {
900
+ id: "my-mu-plugin",
901
+ name: "My must-use plugin",
902
+ version: "1.0.0",
903
+ register(api) {
904
+ api.addFilter<string>("page.head.extra", (head) => head + "<meta name=\"x\" />");
905
+ },
906
+ };
907
+ ```
908
+
909
+ Then add it to `MU_PLUGINS` in `src/mu-plugins/index.ts`. The plugin loader registers MU plugins **before** regular plugins on every `applyPluginRegistration()` pass, regardless of the enabled-flags map; settings pages declared by MU plugins appear in the **Settings** tab strip alongside regular-plugin tabs.
910
+
911
+ In the admin at `/admin/#/plugins`, two tabs split the UI:
912
+
913
+ - **Plugins** — regular plugins, with the standard Enable / Disable button.
914
+ - **Must-use** — MU plugins. Each card shows a discreet **Must-use** badge and no toggle. Configure / Learn more remain available.
915
+
916
+ ## Built-in plugins
917
+
918
+ ### `core-seo`
919
+
920
+ Adds Twitter Card meta tags and a `<meta name="generator">` hint to every published page. Hooks into `page.head.extra`. No settings — toggle it off in **Plugins** if you don't want the extra markup.
921
+
922
+ ### `flexweg-sitemaps`
923
+
924
+ Generates and maintains the public site's sitemaps and `robots.txt`. Configure under **Settings → Sitemaps** (tab visible only when the plugin is enabled).
925
+
926
+ Files produced on the public site (relative to your `baseUrl`):
927
+
928
+ - `sitemap-<year>.xml` — one per year that has at least one online post (and page, when configured). Built from `createdAt`; `<lastmod>` reflects `updatedAt`.
929
+ - `sitemap-index.xml` — index referencing every yearly sitemap that's currently populated, plus the News sitemap when enabled.
930
+ - `sitemap-news.xml` — Google News urlset of articles modified within the configured window (default 2 days, range 1–30). Optional.
931
+ - `sitemap-news-<lang>.xml` — per-language Google News urlset, ONE per enabled secondary language when both News (in this plugin) AND the `flexweg-multilang` plugin (with at least one secondary language) are active. Each file lists only the translated URLs of recent posts that have a translation in that locale, with `<news:language>` set to the locale code. Referenced from `sitemap-index.xml`. When News is toggled off in this plugin's settings, ALL per-language news sitemaps disappear from the index too — they're built and indexed together via the `sitemap.news.locales` hook so file payloads and `<sitemap>` references can never drift out of sync.
932
+ - `robots.txt` — user-supplied; defaults to `User-agent: *` + `Allow: /` + `Disallow: /admin/` + a `Sitemap:` line per generated sitemap. The `Disallow: /admin/` line keeps the admin SPA out of search-engine indexes (the admin's `index.html` also ships with `<meta name="robots" content="noindex, nofollow, noarchive">` as a fallback for crawlers that ignore robots.txt).
933
+ - `sitemap.xsl` and `sitemap-news.xsl` — XSL stylesheets referenced by every sitemap file via an `<?xml-stylesheet?>` processing instruction. When a browser opens a sitemap URL, these transform the raw XML into a styled HTML table for human inspection. Crawlers ignore the PI entirely. Labels honor `settings.language` (English / French baked in).
934
+
935
+ Configuration options:
936
+
937
+ - **Content types** — sitemaps include posts only, or posts and pages.
938
+ - **Generate sitemap-news.xml** — toggle. When enabled, sets the News window in days.
939
+ - **robots.txt content** — full editable textarea. **Insert default** repopulates with the auto-generated body. **Save & regenerate robots.txt** persists the config and re-uploads `robots.txt` in one click.
940
+ - **Upload stylesheets** — uploads `sitemap.xsl` (and `sitemap-news.xsl` when News is enabled) to the public site root. Run this once after installing the plugin, and again whenever the public site language changes.
941
+ - **Force regenerate sitemaps** — re-derives every yearly sitemap, the index, the News sitemap, the XSL stylesheets, and `robots.txt` from the current corpus. Use after toggling content types or after a bulk import.
942
+
943
+ Incremental regeneration is automatic: every `publish.complete`, `post.unpublished`, and `post.deleted` rebuilds the year sitemap that contains the touched post, plus the index, plus News. Years that empty out have their sitemap deleted from the public site so the index never points to a stale file.
944
+
945
+ The lifecycle hooks **do not** re-upload the XSL stylesheets — those change rarely. After installing the plugin or changing `settings.language`, click **Upload stylesheets** (or **Force regenerate**) once to refresh them; subsequent content publishes don't pay that cost.
946
+
947
+ ### `flexweg-rss`
948
+
949
+ Generates RSS 2.0 feeds for the site and optionally per category. Configure under **Settings → RSS feeds**.
950
+
951
+ Files produced on the public site (relative to your `baseUrl`):
952
+
953
+ - `rss.xml` — site-wide feed of the latest online posts (count configurable, default 20). Categories can be excluded individually.
954
+ - `<category-slug>/<category-slug>.xml` — one per category feed enabled via the settings page. Slugs match the category archive folder so a reader can guess the URL from `/<category>/`.
955
+ - `rss.xsl` — shared stylesheet referenced by every feed file via an `<?xml-stylesheet?>` PI. Renders feeds as a styled HTML table when a human opens the URL in a browser; RSS readers ignore the PI and parse XML directly.
956
+
957
+ Configuration options:
958
+
959
+ - **Site feed** — toggle on/off, item count (1–100), excluded categories (multi-select), "Show in footer" checkbox.
960
+ - **Category feeds** — pick a category from an autocomplete combobox to add a new feed; per-feed item count + footer toggle. Removing a feed deletes its file on the next save.
961
+ - **Save settings** — persists the config, deletes files for disabled or removed feeds, and re-publishes `/menu.json` so the footer toggles take effect immediately.
962
+ - **Upload stylesheet** — uploads `rss.xsl` only. Run after a public-language change.
963
+ - **Force regenerate all feeds** — saves config + uploads XSL + regenerates every enabled feed + re-publishes the menu.
964
+
965
+ Incremental regeneration is automatic on every `publish.complete`, `post.unpublished`, and `post.deleted`: only the impacted feeds are rebuilt — the site feed (when the post's category isn't excluded) and the matching category feed (when one exists for `post.primaryTermId`). The plugin persists `lastPublishedPath` after each upload so a later category-slug rename can clean up the old file. The XSL is **not** re-uploaded by lifecycle hooks; refresh it via **Upload stylesheet** or **Force regenerate**.
966
+
967
+ Item descriptions use `post.excerpt` when set, otherwise the first ~300 characters of the markdown rendered to plaintext. Sort order is `publishedAt DESC` (with `updatedAt` / `createdAt` fallbacks).
968
+
969
+ Cover images: when a post has a `heroMediaId`, the item gets an RSS 2.0 `<enclosure url="..." length="..." type="..."/>` element. Most modern readers (Feedly, Inoreader, NetNewsWire, Reeder) display this as the article thumbnail. Variant selection prefers `large`, falls back to the asset's `defaultFormat`, then any available variant — same chain as the publisher's `og:image`. Legacy single-URL media is supported transparently. `length` is the variant's stored byte count (`0` when unknown, which readers tolerate); `type` is the asset's `contentType` or guessed from the URL extension.
970
+
971
+ Footer items injected by this plugin appear at the **end** of the resolved footer in `/menu.json`. Labels are auto-generated: `RSS` for the site feed, `RSS — <category name>` for per-category feeds. To control placement or labels manually, leave the addToFooter checkboxes off and add custom items via **Menus** with the feed URL as an external link.
972
+
973
+ ### `flexweg-favicon` (must-use)
974
+
975
+ Lives under `src/mu-plugins/` — always active, cannot be disabled in the admin (every site benefits from a favicon by default).
976
+
977
+ Generates the full set of favicon files plus a Progressive Web App manifest from a single uploaded image. Configure under **Settings → Favicons**.
978
+
979
+ Files produced under `/favicon/` on the public site:
980
+
981
+ - `favicon-96x96.png` — modern browser favicon (96×96)
982
+ - `apple-touch-icon.png` — iOS home-screen icon (180×180, white background to avoid Safari masking transparency)
983
+ - `web-app-manifest-192x192.png` and `web-app-manifest-512x512.png` — PWA install icons (purpose: maskable)
984
+ - `favicon.ico` — multi-size legacy icon (16/32/48 PNG payloads packed via a pure-JS encoder under `src/mu-plugins/flexweg-favicon/icoEncoder.ts`)
985
+ - `favicon.svg` — vector variant. Only generated when the source upload is itself an SVG (passthrough); skipped silently for raster sources since rasterizing a PNG into SVG produces fake-vector output.
986
+ - `site.webmanifest` — PWA manifest JSON
987
+
988
+ Generation happens entirely in the admin browser via `createImageBitmap` + canvas resize (cover-crop to square). Source image accepts PNG, JPG, WebP and SVG.
989
+
990
+ Configuration options:
991
+
992
+ - **Upload + replace** — file picker with live preview of the largest generated PNG.
993
+ - **Status grid** — checklist showing which favicon files are currently published.
994
+ - **PWA manifest** — `name` (default `settings.title`), `short_name` (default first 12 chars), `theme_color`, `background_color`, `display` mode (`standalone` / `browser` / `fullscreen` / `minimal-ui`).
995
+ - **Save & regenerate manifest** — pushes only `site.webmanifest` (no need to re-process the source image when only colors / names change).
996
+ - **Remove all** — wipes every file under `/favicon/`.
997
+
998
+ Head injection happens automatically via the `page.head.extra` filter — every published page receives the appropriate `<link rel="icon">` / `<link rel="apple-touch-icon">` / `<link rel="manifest">` tags plus a `<meta name="theme-color">` when PWA is configured. Tags only emit for files actually present on the site (the per-format flags in the plugin config drive this), so partial setups don't produce broken links. Cache-busting via `?v=<uploadedAt>` ensures browsers pick up replacements without manual refresh.
999
+
1000
+ ### `flexweg-blocks` (must-use)
1001
+
1002
+ Lives under `src/mu-plugins/` — always active. Ships first-party editor blocks the post / page editor relies on:
1003
+
1004
+ - **Custom HTML** — paste arbitrary HTML into a block. Inspector hosts a CodeMirror-backed editor with line numbers, syntax highlighting, and a fullscreen modal for long blobs.
1005
+ - **Columns** — multi-column layout block (configurable count) for side-by-side content.
1006
+
1007
+ Disabling these would silently strip blocks from existing posts on the next render — they're must-use to prevent that footgun. New blocks belong in this plugin (or in their own dedicated plugin if they're optional).
1008
+
1009
+ ### `flexweg-custom-code` (must-use)
1010
+
1011
+ Lives under `src/mu-plugins/` — always active. Configure under **Settings → Custom code**.
1012
+
1013
+ Two free-form code zones injected into every published page:
1014
+
1015
+ - **Head** — appended to the bottom of `<head>`. Use for analytics (Google Analytics, Plausible), web fonts, custom `<style>` overrides.
1016
+ - **Body end** — appended just before `</body>`. Use for deferred scripts (chat widgets, late analytics) that need the DOM parsed.
1017
+
1018
+ Both editors share the CodeMirror component used by the Custom HTML block (lazy-loaded so the bundle ships only once). Code is injected **as-is** — no sanitisation. A broken `<script>` can take down the whole site until you fix it; the editor surfaces a warning to that effect.
1019
+
1020
+ Hooks: `page.head.extra` and `page.body.end`. Reading the live config from the resolved `BaseLayoutProps` keeps the filter handlers stateless.
1021
+
1022
+ ### `flexweg-embeds` (must-use)
1023
+
1024
+ Lives under `src/mu-plugins/` — always active. Adds editor blocks for **YouTube**, **Vimeo**, **Twitter / X**, and **Spotify**. Each block is an atom Tiptap node that round-trips through Markdown as `<div data-cms-embed="<provider>" data-id="<x>" data-url="<y>"></div>`, so embeds survive editor saves without losing the source URL.
1025
+
1026
+ At publish time the publisher rewrites each marker into the provider's actual iframe / blockquote (via the `post.html.body` filter), and per-page detection injects a single `<style>` tag plus any provider-specific scripts (Twitter's `widgets.js` is the only one needing JS today — YouTube / Vimeo / Spotify are iframe-only). Pages without embeds pay zero overhead.
1027
+
1028
+ Adding a new provider = one entry in [src/mu-plugins/flexweg-embeds/providers.ts](src/mu-plugins/flexweg-embeds/providers.ts) + one i18n key pair. Disabling this plugin would silently strip embeds from existing posts on the next render — must-use to prevent that footgun.
1029
+
1030
+ ## Plugin / theme architecture
1031
+
1032
+ The CMS now ships every plugin (except mu-plugins) and every theme except `default` as **separate ESM bundles** loaded at runtime — same mechanism as user-installed externals. This means admins can uninstall any of the bundled plugins/themes they don't want, with the same UI used for third-party installs.
1033
+
1034
+ **Three categories**:
1035
+
1036
+ - **Mu-plugins** (`src/mu-plugins/`) — bundled into the admin SPA, always-on. These are foundational pieces (favicon, custom code injection, embeds, blocks, metrics, **flexweg-import**) that the admin can't meaningfully run without. Cannot be uninstalled or disabled.
1037
+ - **In-tree externalised plugins / themes** — source lives in `src/plugins/<id>/` and `src/themes/<id>/`. At `npm run build` time, [scripts/build-bundled-externals.mjs](scripts/build-bundled-externals.mjs) compiles each into its own ESM bundle under `dist/admin/plugins/<id>/` (or `dist/admin/themes/<id>/`) and lists them in `dist/admin/external.json`. The admin loads them at boot via the runtime loader. Includes: `core-seo`, `flexweg-sitemaps`, `flexweg-rss`, `flexweg-archives`, `flexweg-search`, `magazine` theme, `corporate` theme.
1038
+ - **User-uploaded externals** — third-party plugins/themes installed via the admin's upload UI (or by dropping a folder into `/admin/<kind>/<id>/` on Flexweg). Same runtime mechanism.
1039
+
1040
+ The **`default` theme** stays bundled inside the admin SPA as a fallback — when a user uninstalls every external theme, the admin falls back to `default` so the public site never ends up unrendered.
1041
+
1042
+ **Restoring bundled defaults**: if a user uninstalls a plugin/theme that was bundled with their admin version (e.g. `core-seo`) and later wants it back, the **Plugins / Themes → Install** modal shows a "Bundled defaults available" section with a one-click **Restore** button. This reads `dist/admin/external.default.json` (a build-time snapshot of what shipped) and re-adds the missing entries to the runtime registry. Only the registry is touched; the bundle files are already on Flexweg from the most recent admin deploy.
1043
+
1044
+ **Where the runtime registry lives**: the **list of currently-loaded externals** is stored in Firestore at `settings/externalRegistry`, NOT as a file on Flexweg. This means the entire `dist/admin/` folder is fully re-deployable — re-uploading the build (e.g. for an admin upgrade) never overwrites the user's install/uninstall state. The on-disk `external.default.json` is the immutable baseline (what shipped with the build); the legacy on-disk `external.json` is read-only-from-the-CMS and exists only as a one-time migration source for installs that pre-date the Firestore move.
1045
+
1046
+ **Boot fallback chain**: on every cold load, the registry resolves in this order — Firestore → legacy `/admin/external.json` → `/admin/external.default.json`. The first non-empty result is materialised back into Firestore so subsequent boots short-circuit at the Firestore step.
1047
+
1048
+ ## External plugins & themes
1049
+
1050
+ In addition to in-tree plugins (committed under `src/plugins/`) and in-tree themes (under `src/themes/`), the admin supports **external** packages that ship as a `.zip`, install via an in-admin upload UI (or by dropping a folder directly via Flexweg), and load at runtime — no rebuild of the admin required.
1051
+
1052
+ - **Install** — admin **Plugins** / **Themes** → click **Install plugin** / **Install theme** → pick a `.zip`. The admin extracts client-side, uploads the contents to `/admin/plugins/<id>/` or `/admin/themes/<id>/` on Flexweg, appends an entry to `/admin/external.json`, and reloads. The new entry appears alongside the built-ins.
1053
+ - **Uninstall** — same modal lists installed external entries with an **Uninstall** button. Files are deleted from Flexweg + the entry is removed from `external.json`.
1054
+ - **Manual install** (no upload UI) — drop the unzipped folder under `/admin/<kind>/<id>/` via Flexweg's file manager, then add an entry to `/admin/external.json` (or create the file if it doesn't exist).
1055
+
1056
+ External bundles use the **same hook API and manifest shapes** as in-tree plugins/themes. The admin's `index.html` ships an [import-map](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap) that redirects `react`, `react-dom`, `react-i18next`, and `@flexweg/cms-runtime` to runtime stubs in `/admin/runtime/` — bundles externalise these specifiers at build time and at runtime get redirected to the live admin instance, guaranteeing one React copy across admin and bundle (required for hooks integrity).
1057
+
1058
+ Authoring docs and ready-to-build scaffolds:
1059
+
1060
+ - [docs/creating-a-plugin.md](docs/creating-a-plugin.md) — full guide for external plugin authoring.
1061
+ - [docs/creating-a-theme.md](docs/creating-a-theme.md) — full guide for external theme authoring.
1062
+ - [docs/runtime-api-reference.md](docs/runtime-api-reference.md) — public API surface (`@flexweg/cms-runtime` exports, hook list, manifest shapes).
1063
+ - [external/plugins/hello-plugin/](external/plugins/hello-plugin/) — minimal plugin scaffold (head meta tag + dashboard card).
1064
+ - [external/plugins/flexweg-multilang/](external/plugins/flexweg-multilang/) — reference multi-language plugin. Per-locale URLs, per-language editor tabs (post title / slug / body / SEO), per-term name + slug + description + SEO, per-language menu labels, hreflang + canonical reciprocity, sitemap `<xhtml:link>` alternates, per-language RSS feeds. Built and bundled into `dist/admin/plugins/flexweg-multilang/` by `npm run build`; pre-installed on fresh deployments via `external.default.json`. See the [authoring guide](docs/creating-a-plugin.md) for the variant-provider pattern used here.
1065
+ - [external/themes/minimal-theme/](external/themes/minimal-theme/) — minimal theme scaffold (six templates + hand-written CSS).
1066
+ - [external/themes/marketplace-core/](external/themes/marketplace-core/) — full marketplace theme (custom blocks, settings page, palette + font overrides).
1067
+
1068
+ Compatibility: external bundles declare an `apiVersion` in their `manifest.json`; the admin checks it against `[FLEXWEG_API_MIN_VERSION, FLEXWEG_API_VERSION]` at install + at every boot, and skips out-of-range bundles with a console warning. Browser support: import-maps are native in Chrome 89+, Firefox 108+, Safari 16.4+ (covers virtually every admin user).
1069
+
1070
+ ## Tests
1071
+
1072
+ ```bash
1073
+ npm test # runs the Vitest suite once
1074
+ npm run typecheck # TypeScript strict mode
1075
+ ```
1076
+
1077
+ Critical units have unit tests:
1078
+
1079
+ - `core/slug` — URL building, slugification, validation.
1080
+ - `core/pluginRegistry` — filter/action ordering, sync vs. async semantics.
1081
+
1082
+ ## Renaming the admin folder for obscurity
1083
+
1084
+ By default the admin SPA is deployed to `/admin/` on Flexweg. You can rename that folder to anything you want — for example a random-looking string like `/erf34f654GH3/` — to make it slightly harder for crawlers and casual scanners to find it.
1085
+
1086
+ The admin auto-detects its folder name from the URL on every load (via `window.location.pathname`) and uses it as the prefix for every Flexweg API upload (`config.js`, `external.json`, `plugins/<id>/`, `themes/<id>/`). No code change, no rebuild — just rename the folder via Flexweg's file manager and re-open the admin from the new URL. All install / uninstall / setup operations work transparently.
1087
+
1088
+ Two rules:
1089
+
1090
+ - **Don't deploy at the site root.** The setup form refuses to run if the admin is being served at `/`. Mixing admin assets (`config.js`, plugin folders, theme bundles) with the public site's HTML pages is a footgun that's easier to prevent than to recover from. Always put the admin under a subfolder, even if you don't care about obscurity.
1091
+ - **Rename atomically.** Use Flexweg's file-manager "rename folder" action so all the admin files move together. If you copy + delete instead, you risk leaving orphaned files at the old path that the loader would then ignore — but they'd still be reachable via direct URL.
1092
+
1093
+ Note that this is **security through obscurity** — the random folder name only stops naive HTTP-level crawlers. The real protection of the admin is still: the `<meta name="robots">` tag, `Disallow: /admin/` in `robots.txt`, the bootstrap admin email + password, and the Firestore security rules. Adjust your `robots.txt` to match the new folder name when renaming, otherwise crawlers ignore the disallow rule.
1094
+
1095
+ ## Admin privacy
1096
+
1097
+ The admin SPA at `/admin/` is internal-only — it should not appear in search results. Two layers keep it out of indexes:
1098
+
1099
+ 1. **`/admin/index.html` ships with three meta tags**: `<meta name="robots" content="noindex, nofollow, noarchive">`, the same with `name="googlebot"`, and `<meta name="google" content="notranslate">`. These cover crawlers that read meta but ignore robots.txt.
1100
+ 2. **`robots.txt` defaults to `Disallow: /admin/`** when the bundled `flexweg-sitemaps` plugin generates it. Crawlers that honour robots.txt skip the path entirely.
1101
+
1102
+ If you've customised `robots.txt`, make sure to keep the `Disallow: /admin/` line. The plugin's **Insert default** button re-injects it.
1103
+
1104
+ `/admin/config.js` carries the Firebase web config — which is **public by design** (security comes from Firestore rules + the auth-domain allowlist in Firebase Console, not from the secrecy of these values). The bootstrap admin email is **no longer written into `config.js`**; the client detects bootstrap-admin status via a Firestore probe on `config/admin` whose rule grants read access only to the matching pinned email with `email_verified == true`. The pinned email lives only in the Firestore rules. The Flexweg API key is **not** in `config.js` either; it lives in Firestore at `config/flexweg`, readable only after a successful Firebase Auth sign-in.
1105
+
1106
+ Legacy deployments may still have an `adminEmail` field in their `/admin/config.js` (older builds wrote it there). The current admin reads it as an opt-in fallback for backward compatibility but never writes it back — re-running the SetupForm or replacing `config.js` with the new shape removes the field permanently. After updating the Firestore rules to the current template (with the `config/admin` probe rule + `email_verified` check), legacy callers are fully covered by the rules-driven path.
1107
+
1108
+ ## Limitations
1109
+
1110
+ - The Flexweg API key sits in Firestore. Any signed-in admin can read it via devtools — fine for an internal tool, do not deploy this pattern to a multi-tenant or public-facing app.
1111
+ - The publisher serialises uploads to avoid hitting API rate limits; very large sites (hundreds of pages) take a while when "Regenerate site" runs.
1112
+ - Content is single-language per site (the public language). Admins choose their own UI language separately. Multi-language content can be added later as a plugin.
1113
+
1114
+ ## License
1115
+
1116
+ Source-available under the [PolyForm Noncommercial License 1.0.0](./LICENSE). Personal, hobbyist, educational, research, and other noncommercial use is permitted free of charge; you can fork, modify, and redistribute under the same terms.
1117
+
1118
+ **Commercial use — including selling the software or hosting it as a paid service — requires explicit written authorization.** For commercial licensing inquiries, contact: contact@flexweg.com
1119
+
1120
+ See [LICENSE](./LICENSE) for the full terms.