@geekity/cms 0.3.0

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 (238) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +870 -0
  3. package/admin/layouts/_flash.njk +5 -0
  4. package/admin/layouts/base.njk +27 -0
  5. package/admin/layouts/dashboard.njk +78 -0
  6. package/admin/layouts/document-conflict.njk +49 -0
  7. package/admin/layouts/document-editor.njk +167 -0
  8. package/admin/layouts/document-list.njk +92 -0
  9. package/admin/layouts/federation.njk +145 -0
  10. package/admin/layouts/login.njk +23 -0
  11. package/admin/layouts/placeholder.njk +10 -0
  12. package/admin/layouts/settings.njk +116 -0
  13. package/admin/layouts/setup.njk +27 -0
  14. package/admin/layouts/shell.njk +37 -0
  15. package/admin/layouts/users.njk +91 -0
  16. package/admin/static/admin.css +726 -0
  17. package/admin/static/editor.js +30 -0
  18. package/dist/admin/assets.d.ts +26 -0
  19. package/dist/admin/assets.d.ts.map +1 -0
  20. package/dist/admin/assets.js +38 -0
  21. package/dist/admin/assets.js.map +1 -0
  22. package/dist/admin/credentials.d.ts +28 -0
  23. package/dist/admin/credentials.d.ts.map +1 -0
  24. package/dist/admin/credentials.js +39 -0
  25. package/dist/admin/credentials.js.map +1 -0
  26. package/dist/admin/documents.d.ts +134 -0
  27. package/dist/admin/documents.d.ts.map +1 -0
  28. package/dist/admin/documents.js +613 -0
  29. package/dist/admin/documents.js.map +1 -0
  30. package/dist/admin/federation.d.ts +194 -0
  31. package/dist/admin/federation.d.ts.map +1 -0
  32. package/dist/admin/federation.js +299 -0
  33. package/dist/admin/federation.js.map +1 -0
  34. package/dist/admin/flash.d.ts +24 -0
  35. package/dist/admin/flash.d.ts.map +1 -0
  36. package/dist/admin/flash.js +31 -0
  37. package/dist/admin/flash.js.map +1 -0
  38. package/dist/admin/index.d.ts +23 -0
  39. package/dist/admin/index.d.ts.map +1 -0
  40. package/dist/admin/index.js +15 -0
  41. package/dist/admin/index.js.map +1 -0
  42. package/dist/admin/passwords.d.ts +49 -0
  43. package/dist/admin/passwords.d.ts.map +1 -0
  44. package/dist/admin/passwords.js +122 -0
  45. package/dist/admin/passwords.js.map +1 -0
  46. package/dist/admin/preview.d.ts +18 -0
  47. package/dist/admin/preview.d.ts.map +1 -0
  48. package/dist/admin/preview.js +83 -0
  49. package/dist/admin/preview.js.map +1 -0
  50. package/dist/admin/routes.d.ts +54 -0
  51. package/dist/admin/routes.d.ts.map +1 -0
  52. package/dist/admin/routes.js +383 -0
  53. package/dist/admin/routes.js.map +1 -0
  54. package/dist/admin/session.d.ts +44 -0
  55. package/dist/admin/session.d.ts.map +1 -0
  56. package/dist/admin/session.js +69 -0
  57. package/dist/admin/session.js.map +1 -0
  58. package/dist/admin/settings.d.ts +194 -0
  59. package/dist/admin/settings.d.ts.map +1 -0
  60. package/dist/admin/settings.js +470 -0
  61. package/dist/admin/settings.js.map +1 -0
  62. package/dist/admin/store.d.ts +407 -0
  63. package/dist/admin/store.d.ts.map +1 -0
  64. package/dist/admin/store.js +704 -0
  65. package/dist/admin/store.js.map +1 -0
  66. package/dist/admin/templates.d.ts +36 -0
  67. package/dist/admin/templates.d.ts.map +1 -0
  68. package/dist/admin/templates.js +43 -0
  69. package/dist/admin/templates.js.map +1 -0
  70. package/dist/admin/uploads.d.ts +96 -0
  71. package/dist/admin/uploads.d.ts.map +1 -0
  72. package/dist/admin/uploads.js +179 -0
  73. package/dist/admin/uploads.js.map +1 -0
  74. package/dist/admin/users.d.ts +95 -0
  75. package/dist/admin/users.d.ts.map +1 -0
  76. package/dist/admin/users.js +233 -0
  77. package/dist/admin/users.js.map +1 -0
  78. package/dist/cli.d.ts +85 -0
  79. package/dist/cli.d.ts.map +1 -0
  80. package/dist/cli.js +424 -0
  81. package/dist/cli.js.map +1 -0
  82. package/dist/config.d.ts +172 -0
  83. package/dist/config.d.ts.map +1 -0
  84. package/dist/config.js +184 -0
  85. package/dist/config.js.map +1 -0
  86. package/dist/content/document.d.ts +66 -0
  87. package/dist/content/document.d.ts.map +1 -0
  88. package/dist/content/document.js +13 -0
  89. package/dist/content/document.js.map +1 -0
  90. package/dist/content/index.d.ts +17 -0
  91. package/dist/content/index.d.ts.map +1 -0
  92. package/dist/content/index.js +10 -0
  93. package/dist/content/index.js.map +1 -0
  94. package/dist/content/markdown.d.ts +3 -0
  95. package/dist/content/markdown.d.ts.map +1 -0
  96. package/dist/content/markdown.js +39 -0
  97. package/dist/content/markdown.js.map +1 -0
  98. package/dist/content/media.d.ts +65 -0
  99. package/dist/content/media.d.ts.map +1 -0
  100. package/dist/content/media.js +143 -0
  101. package/dist/content/media.js.map +1 -0
  102. package/dist/content/parser.d.ts +24 -0
  103. package/dist/content/parser.d.ts.map +1 -0
  104. package/dist/content/parser.js +165 -0
  105. package/dist/content/parser.js.map +1 -0
  106. package/dist/content/save.d.ts +60 -0
  107. package/dist/content/save.d.ts.map +1 -0
  108. package/dist/content/save.js +103 -0
  109. package/dist/content/save.js.map +1 -0
  110. package/dist/content/slug.d.ts +25 -0
  111. package/dist/content/slug.d.ts.map +1 -0
  112. package/dist/content/slug.js +75 -0
  113. package/dist/content/slug.js.map +1 -0
  114. package/dist/content/store.d.ts +133 -0
  115. package/dist/content/store.d.ts.map +1 -0
  116. package/dist/content/store.js +411 -0
  117. package/dist/content/store.js.map +1 -0
  118. package/dist/content/sync.d.ts +146 -0
  119. package/dist/content/sync.d.ts.map +1 -0
  120. package/dist/content/sync.js +421 -0
  121. package/dist/content/sync.js.map +1 -0
  122. package/dist/content/writer.d.ts +37 -0
  123. package/dist/content/writer.d.ts.map +1 -0
  124. package/dist/content/writer.js +97 -0
  125. package/dist/content/writer.js.map +1 -0
  126. package/dist/env.d.ts +48 -0
  127. package/dist/env.d.ts.map +1 -0
  128. package/dist/env.js +2 -0
  129. package/dist/env.js.map +1 -0
  130. package/dist/federation/actor.d.ts +55 -0
  131. package/dist/federation/actor.d.ts.map +1 -0
  132. package/dist/federation/actor.js +101 -0
  133. package/dist/federation/actor.js.map +1 -0
  134. package/dist/federation/article.d.ts +83 -0
  135. package/dist/federation/article.d.ts.map +1 -0
  136. package/dist/federation/article.js +169 -0
  137. package/dist/federation/article.js.map +1 -0
  138. package/dist/federation/delivery.d.ts +90 -0
  139. package/dist/federation/delivery.d.ts.map +1 -0
  140. package/dist/federation/delivery.js +289 -0
  141. package/dist/federation/delivery.js.map +1 -0
  142. package/dist/federation/federation.d.ts +75 -0
  143. package/dist/federation/federation.d.ts.map +1 -0
  144. package/dist/federation/federation.js +190 -0
  145. package/dist/federation/federation.js.map +1 -0
  146. package/dist/federation/followers.d.ts +43 -0
  147. package/dist/federation/followers.d.ts.map +1 -0
  148. package/dist/federation/followers.js +69 -0
  149. package/dist/federation/followers.js.map +1 -0
  150. package/dist/federation/inbox.d.ts +68 -0
  151. package/dist/federation/inbox.d.ts.map +1 -0
  152. package/dist/federation/inbox.js +171 -0
  153. package/dist/federation/inbox.js.map +1 -0
  154. package/dist/federation/index.d.ts +14 -0
  155. package/dist/federation/index.d.ts.map +1 -0
  156. package/dist/federation/index.js +10 -0
  157. package/dist/federation/index.js.map +1 -0
  158. package/dist/federation/keys.d.ts +27 -0
  159. package/dist/federation/keys.d.ts.map +1 -0
  160. package/dist/federation/keys.js +60 -0
  161. package/dist/federation/keys.js.map +1 -0
  162. package/dist/federation/mount.d.ts +23 -0
  163. package/dist/federation/mount.d.ts.map +1 -0
  164. package/dist/federation/mount.js +71 -0
  165. package/dist/federation/mount.js.map +1 -0
  166. package/dist/federation/paths.d.ts +85 -0
  167. package/dist/federation/paths.d.ts.map +1 -0
  168. package/dist/federation/paths.js +97 -0
  169. package/dist/federation/paths.js.map +1 -0
  170. package/dist/index.d.ts +106 -0
  171. package/dist/index.d.ts.map +1 -0
  172. package/dist/index.js +148 -0
  173. package/dist/index.js.map +1 -0
  174. package/dist/init.d.ts +71 -0
  175. package/dist/init.d.ts.map +1 -0
  176. package/dist/init.js +125 -0
  177. package/dist/init.js.map +1 -0
  178. package/dist/web/assets.d.ts +65 -0
  179. package/dist/web/assets.d.ts.map +1 -0
  180. package/dist/web/assets.js +152 -0
  181. package/dist/web/assets.js.map +1 -0
  182. package/dist/web/context.d.ts +121 -0
  183. package/dist/web/context.d.ts.map +1 -0
  184. package/dist/web/context.js +112 -0
  185. package/dist/web/context.js.map +1 -0
  186. package/dist/web/documents.d.ts +28 -0
  187. package/dist/web/documents.d.ts.map +1 -0
  188. package/dist/web/documents.js +39 -0
  189. package/dist/web/documents.js.map +1 -0
  190. package/dist/web/feeds.d.ts +122 -0
  191. package/dist/web/feeds.d.ts.map +1 -0
  192. package/dist/web/feeds.js +229 -0
  193. package/dist/web/feeds.js.map +1 -0
  194. package/dist/web/index.d.ts +17 -0
  195. package/dist/web/index.d.ts.map +1 -0
  196. package/dist/web/index.js +10 -0
  197. package/dist/web/index.js.map +1 -0
  198. package/dist/web/negotiate.d.ts +219 -0
  199. package/dist/web/negotiate.d.ts.map +1 -0
  200. package/dist/web/negotiate.js +409 -0
  201. package/dist/web/negotiate.js.map +1 -0
  202. package/dist/web/pagination.d.ts +48 -0
  203. package/dist/web/pagination.d.ts.map +1 -0
  204. package/dist/web/pagination.js +30 -0
  205. package/dist/web/pagination.js.map +1 -0
  206. package/dist/web/render.d.ts +72 -0
  207. package/dist/web/render.d.ts.map +1 -0
  208. package/dist/web/render.js +73 -0
  209. package/dist/web/render.js.map +1 -0
  210. package/dist/web/routes.d.ts +31 -0
  211. package/dist/web/routes.d.ts.map +1 -0
  212. package/dist/web/routes.js +366 -0
  213. package/dist/web/routes.js.map +1 -0
  214. package/dist/web/templates.d.ts +34 -0
  215. package/dist/web/templates.d.ts.map +1 -0
  216. package/dist/web/templates.js +106 -0
  217. package/dist/web/templates.js.map +1 -0
  218. package/package.json +68 -0
  219. package/templates/site/content/_data/site.json +8 -0
  220. package/templates/site/content/pages/about.md +11 -0
  221. package/templates/site/content/pages/pages.json +3 -0
  222. package/templates/site/content/posts/2026-01-01-hello-world.md +34 -0
  223. package/templates/site/content/posts/posts.json +4 -0
  224. package/templates/site/geekity.config.ts +11 -0
  225. package/templates/site/gitignore +3 -0
  226. package/templates/site/server.ts +20 -0
  227. package/templates/site/tsconfig.json +18 -0
  228. package/themes/default/README.md +148 -0
  229. package/themes/default/layouts/404.njk +9 -0
  230. package/themes/default/layouts/base.njk +59 -0
  231. package/themes/default/layouts/home.njk +9 -0
  232. package/themes/default/layouts/page.njk +13 -0
  233. package/themes/default/layouts/post.njk +30 -0
  234. package/themes/default/layouts/tag.njk +19 -0
  235. package/themes/default/partials/pagination.njk +14 -0
  236. package/themes/default/partials/post-list.njk +24 -0
  237. package/themes/default/partials/tags.njk +12 -0
  238. package/themes/default/static/style.css +327 -0
package/README.md ADDED
@@ -0,0 +1,870 @@
1
+ # @geekity/cms
2
+
3
+ A file-first CMS. Content is Markdown on disk, laid out so Eleventy can build
4
+ the same directory unchanged. One Node process is both the editor and the public
5
+ website. SQLite holds a derived index plus the data that has no natural file
6
+ form.
7
+
8
+ This is the guide for someone building a site on the package. The repository's
9
+ own [README](https://github.com/geekitycom/cms#readme) is the contributor guide.
10
+
11
+ Requires Node 24 or newer, the active LTS line.
12
+
13
+ ## A new site
14
+
15
+ ```sh
16
+ pnpm dlx @geekity/cms init my-site
17
+ cd my-site
18
+ pnpm install
19
+ pnpm dev
20
+ ```
21
+
22
+ That is a running site on <http://localhost:3000> with a post, a page and the
23
+ packaged theme. `geekity init` refuses a directory that already has anything in
24
+ it, so it can never write over a site you already have.
25
+
26
+ What it writes:
27
+
28
+ ```
29
+ package.json depends on @geekity/cms; scripts dev, start and sync
30
+ geekity.config.ts port, directories, base URL
31
+ server.ts the entry file; where your own routes go
32
+ tsconfig.json so the site type checks against the package
33
+ content/
34
+ posts/ Markdown posts, plus posts.json for Eleventy
35
+ pages/ Markdown pages, plus pages.json
36
+ _data/site.json title, tagline, author, page and feed sizes
37
+ .gitignore node_modules, data and .env
38
+ ```
39
+
40
+ `data/` and `theme/` are not written. The index under `data/` is created on
41
+ first boot and is safe to delete; `theme/` is optional and only exists once you
42
+ override a template.
43
+
44
+ The generated `package.json` pins `@geekity/cms` to the version of the CLI that
45
+ wrote it, so a site is never scaffolded against a version it has not been tested
46
+ with.
47
+
48
+ ## An existing project
49
+
50
+ ```sh
51
+ pnpm add @geekity/cms
52
+ ```
53
+
54
+ Then write a `geekity.config.ts` and a `content/` directory, and either add an
55
+ entry file or run the bin. Nothing else is required.
56
+
57
+ ## Upgrading
58
+
59
+ ```sh
60
+ pnpm up @geekity/cms
61
+ ```
62
+
63
+ Database migrations ship inside the package and run on boot, so a version bump
64
+ is the whole upgrade. The config schema, `createCms`, the template context, the
65
+ JSON representation and the content format are all covered by semver: a
66
+ breaking change to any of them is a major with a migration note in the
67
+ changelog.
68
+
69
+ ## The `geekity` command
70
+
71
+ `geekity` is installed as a bin, so `pnpm geekity <command>` runs it inside a
72
+ site (or `npx geekity`, or a `package.json` script, which is how the generated
73
+ `sync` script calls it).
74
+
75
+ | Command | What it does |
76
+ | ------------------------- | ---------------------------------------------------------------------------------------- |
77
+ | `geekity serve` | Boot from the config file and listen. The default when no command is given. |
78
+ | `geekity init <dir>` | Create a new site in `<dir>`. Refuses a directory that is not empty. |
79
+ | `geekity sync` | Rebuild the content index once and exit. Exits non-zero if any file could not be parsed. |
80
+ | `geekity user add <name>` | Create an admin account, so a site can get its first login without the setup screen. |
81
+ | `geekity --help`, `-h` | The same table, on the terminal. |
82
+ | `geekity --version` | The installed version. |
83
+
84
+ `serve`, `sync` and `user add` take `--config <file>`; without it they look for
85
+ `geekity.config.ts`, then `geekity.config.js`, then `geekity.config.mjs` in the
86
+ working directory, and run on defaults if there is none.
87
+
88
+ `sync` prints what the scan did and forces watching off whatever the config
89
+ says, because a one-shot scan that then sat in a watcher would never exit:
90
+
91
+ ```sh
92
+ $ geekity sync
93
+ Scanned 12: 2 created, 1 updated, 0 removed, 9 unchanged, 0 failed
94
+ ```
95
+
96
+ A file that will not parse is logged, left out of the index and counted in
97
+ `failed`; the command then exits `1` so a deploy step notices. Everything else
98
+ in the directory is still indexed.
99
+
100
+ ### Creating an admin from the command line
101
+
102
+ `geekity user add <username>` writes a user straight into the site's database,
103
+ which is how a site that cannot reach `/admin/setup` from a browser — a
104
+ headless deploy, a server behind a bastion — gets its first login. It enforces
105
+ exactly the rules the setup form does: a username of 1 to 64 letters, digits,
106
+ dots, dashes or underscores, and a password of at least 8 characters.
107
+
108
+ ```sh
109
+ $ geekity user add ada
110
+ Password for ada:
111
+ Created admin user ada. Sign in at /admin/login.
112
+ ```
113
+
114
+ The prompt does not echo. When standard input is not a terminal the command
115
+ prints no prompt and reads the password as a single line, so a script can pipe
116
+ one in:
117
+
118
+ ```sh
119
+ printf '%s\n' "$ADMIN_PASSWORD" | geekity user add ada
120
+ ```
121
+
122
+ `--password <pw>` passes it inline instead. That is the least private of the
123
+ three: a password on the command line is visible in the process list and lands
124
+ in shell history.
125
+
126
+ A name that is already taken is refused and nothing is written; so are an
127
+ illegal username and a short password. Every refusal prints why and exits `1`.
128
+
129
+ Once somebody can sign in, the [`/admin/users` screen](#the-admin) is the
130
+ easier door: it adds users, generates passwords, deletes them, and is where an
131
+ admin changes their own password.
132
+
133
+ ### How a TypeScript config is loaded
134
+
135
+ `geekity serve` and `geekity sync` import `geekity.config.ts` directly. That is
136
+ all it takes on Node 24, which strips types without a flag. For a config using
137
+ syntax stripping cannot erase, such as `enum`, the
138
+ CLI falls back to registering the **tsx installed in your own site**, which is
139
+ what `geekity init` puts in `devDependencies` (it is what runs `server.ts` too).
140
+ A site that wants neither can write `geekity.config.js` instead; it is in the
141
+ lookup list for exactly that reason. Nothing else is searched: the tsx has to be
142
+ the site's, so a copy that happens to be elsewhere on the machine is never used.
143
+
144
+ ## Using the package from an entry file
145
+
146
+ An entry file exists so a site can add routes and middleware of its own. A site
147
+ that adds nothing can delete `server.ts` and run `geekity serve`.
148
+
149
+ ```ts
150
+ import { createCms } from '@geekity/cms';
151
+
152
+ import config from './geekity.config.ts';
153
+
154
+ const cms = createCms(config);
155
+
156
+ cms.app.get('/hello/', (c) => c.text('a route of my own'));
157
+
158
+ await cms.serve();
159
+ ```
160
+
161
+ `createCms(config)` returns a `Cms`:
162
+
163
+ | Member | What it is |
164
+ | ------------------------ | -------------------------------------------------------------------------------------------------------------------------- |
165
+ | `app` | The Hono app. Add routes and middleware before serving; handlers reach `c.var.store`, `c.var.config` and `c.var.renderer`. |
166
+ | `config` | The config after defaults and environment overrides, every path absolute. |
167
+ | `store` | The content index. |
168
+ | `events` | Index changes as they happen. |
169
+ | `onDocumentChange(hook)` | Subscribe to every change; returns the unsubscribe. |
170
+ | `onPublish(hook)` | Subscribe to documents becoming visible; returns the unsubscribe. |
171
+ | `sync()` | One full scan of the content directory. |
172
+ | `serve()` | Scan, start watching, listen. Resolves with the port actually bound, which matters when the configured port is `0`. |
173
+ | `close()` | Stop the watcher, the server and the index. Safe to call twice, and required, because the index holds an open database. |
174
+
175
+ Routes a site registers always win over the content index. Documents are
176
+ resolved in the not-found handler, after every registered route has failed to
177
+ match, so a post whose permalink is `/hello/` cannot shadow the `/hello/` route
178
+ above — and neither can it shadow the admin and federation routes of later
179
+ milestones.
180
+
181
+ ## Configuration
182
+
183
+ Declare config in `geekity.config.ts`; `defineConfig` gives it type checking.
184
+
185
+ ```ts
186
+ import { defineConfig } from '@geekity/cms';
187
+
188
+ export default defineConfig({
189
+ port: 3000,
190
+ contentDir: 'content',
191
+ dataDir: 'data',
192
+ themeDir: 'theme',
193
+ baseUrl: 'http://localhost:3000',
194
+ watch: true,
195
+ sessionLifetime: 60 * 60 * 24 * 14,
196
+ });
197
+ ```
198
+
199
+ Every field is optional. Relative directories resolve against the working
200
+ directory; absolute ones are used as given.
201
+
202
+ | Field | Default | Environment override | Meaning |
203
+ | ------------------ | ------------------------- | --------------------------- | ------------------------------------------------------------------------------------------ |
204
+ | `port` | `3000` | `GEEKITY_PORT`, then `PORT` | Port the HTTP server listens on. `0` picks a free one. |
205
+ | `contentDir` | `<cwd>/content` | `GEEKITY_CONTENT_DIR` | Markdown content. |
206
+ | `dataDir` | `<cwd>/data` | `GEEKITY_DATA_DIR` | Derived state, including the SQLite index. |
207
+ | `themeDir` | `<cwd>/theme` | `GEEKITY_THEME_DIR` | Site template overrides, resolved before the packaged default theme. Need not exist. |
208
+ | `baseUrl` | `http://localhost:<port>` | `GEEKITY_BASE_URL` | Public origin for canonical URLs, feeds and ActivityPub ids. A trailing slash is stripped. |
209
+ | `watch` | `true` | `GEEKITY_WATCH` | Watch `contentDir` while serving and keep the index in step. |
210
+ | `sessionLifetime` | `1209600` (14 days) | `GEEKITY_SESSION_LIFETIME` | How long an admin login lasts, in seconds. |
211
+ | `onDocumentChange` | none | — | Hook run for every change to the index. See [Hooks](#hooks). |
212
+ | `onPublish` | none | — | Hook run when a document becomes visible. See [Hooks](#hooks). |
213
+ | `federation` | `{}` | — | Federation stores and guards. See [Federation](#federation). |
214
+
215
+ Precedence is environment variable, then config file, then default, so a host
216
+ can override anything without editing the site. A boolean environment variable
217
+ takes `true`, `1`, `yes` and `on`, or their opposites; anything else is an error
218
+ rather than a silent `false`.
219
+
220
+ ## Federation
221
+
222
+ The site is one ActivityPub actor, served by [Fedify]. Its stores have
223
+ defaults that suit a single process, and `federation` is where a site that has
224
+ outgrown them says so:
225
+
226
+ | Field | Default | Meaning |
227
+ | --------------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
228
+ | `kv` | `MemoryKvStore` | Fedify's cache and idempotence store. Nothing that has to survive a restart lives in it. |
229
+ | `queue` | `InProcessMessageQueue` | The delivery and inbox queue. `null` means no queue: activities are handled and delivered inside the request that carried them, with no retry. |
230
+ | `allowPrivateAddress` | `false` | Whether Fedify may fetch private and loopback addresses. Leave it off: turning it on removes an SSRF guard. It exists for tests. |
231
+
232
+ Followers, the actor's key pairs, the inbound activity log and the delivery
233
+ log are the CMS's own and live in SQLite whatever those are set to, so a
234
+ restart never costs a site a follower.
235
+
236
+ ### Delivery
237
+
238
+ Publishing federates. The delivery service listens to the index, so a post
239
+ edited on disk reaches the followers exactly as one saved through the editor
240
+ does:
241
+
242
+ | What happened to the post | What the followers get |
243
+ | ------------------------------------------------------------------------- | ------------------------- |
244
+ | became published — written live, or undrafted, or restored from the trash | `Create(Article)` |
245
+ | edited while published | `Update(Article)` |
246
+ | drafted, trashed, or its file deleted | `Delete` of a `Tombstone` |
247
+
248
+ Nothing is delivered for a full scan, the boot scan included: a rebuilt index
249
+ reports the whole archive as new, and announcing it again is not what deleting
250
+ `data/geekity.db` should mean.
251
+
252
+ The first activity about a post writes `activitypub.id` and
253
+ `activitypub.published` into its front matter. That id is what every follower
254
+ now holds, so it is the id the post keeps: renaming the slug afterwards sends
255
+ an `Update` rather than a second post, and `/ap/posts/{old-slug}` goes on
256
+ answering. Restoring a trashed post reuses it too.
257
+
258
+ One POST serves a whole instance — the shared inbox is preferred — but the
259
+ outcome is recorded per follower, so an admin can see which one did not get it
260
+ and send the activity again:
261
+
262
+ ```ts
263
+ const report = await cms.delivery.redeliver(activityId);
264
+ report?.deliveries; // one row per follower: inbox, status, error, time
265
+ ```
266
+
267
+ `cms.admin.listOutboundActivities()` lists what has been sent, newest first,
268
+ and `listDeliveries(activityId)` and `countDeliveriesByStatus(activityId)`
269
+ say how each one landed. A status is `sent` (the inbox took it), `queued`
270
+ (handed to Fedify's queue, which retries out of band) or `failed`.
271
+
272
+ ### The site's avatar
273
+
274
+ The actor's `icon` is an image uploaded on `/admin/settings`. It is stored with
275
+ the site's other uploads, under `content/uploads/{yyyy}/{mm}/`, and the public
276
+ path it is served at — `/uploads/2026/09/me.png` — is the `avatar` setting,
277
+ mirrored into `content/_data/site.json` like the rest of them. The actor
278
+ carries it as an absolute URL, resolved against the base URL in effect, because
279
+ a peer has no site to resolve a path against; an `avatar` that is already an
280
+ absolute URL is left alone, which is how a site puts its avatar on a CDN.
281
+
282
+ Saving or removing it delivers an `Update` of the actor to every follower, so
283
+ the profile a peer cached is refreshed rather than left showing last year's
284
+ picture. The same goes for the title, the tagline, the base URL and the actor's
285
+ handle and type: those are the fields the profile is built from, and a save
286
+ that moves one of them tells the followers. A save that only moves the time
287
+ zone or the page size tells nobody, and neither does anything at all on a site
288
+ that has no followers yet.
289
+
290
+ The `Update` is recorded in the delivery log like every other activity, with
291
+ the actor's id as its object and no slug, and `cms.delivery.updateActor()`
292
+ sends one from code. Because it is about no post, it is not a row in the
293
+ federation screen's per-post delivery table.
294
+
295
+ ### The federation screen
296
+
297
+ `/admin/federation` is all of that with a page in front of it. It shows the
298
+ site's own actor — its avatar, handle and type, the name and summary the
299
+ profile carries, and how many actors follow it — the follower list with avatars and
300
+ follow dates, the recent likes, boosts and replies out of the inbox log, each
301
+ linked to the remote object and to the post it was about, and one row per post
302
+ that has been federated: its latest activity, when it went, and how many
303
+ followers it reached, is queued for, or failed for. Each of those rows has a
304
+ Redeliver button, which is `cms.delivery.redeliver` behind a form: it sends
305
+ that activity to every follower the site has now and says what came of it.
306
+
307
+ ### Testing federation against a real Mastodon account
308
+
309
+ Federation is the one part of the CMS that cannot be finished on localhost.
310
+ Mastodon has to be able to reach the site to fetch the actor, verify a
311
+ signature and deliver a `Follow`, and `http://localhost:3000` is not an
312
+ address it can reach. [`fedify tunnel`] gives the local server a public HTTPS
313
+ address for as long as the command runs, which is enough to do the whole round
314
+ trip by hand:
315
+
316
+ ```sh
317
+ pnpm dlx @fedify/cli tunnel 3000
318
+ # The ephemeral public address is up and running:
319
+ # https://quiet-sun-42.serveo.net
320
+ ```
321
+
322
+ Leave that running and boot the site with `baseUrl` set to the address it
323
+ printed, because `baseUrl` is what every ActivityStreams id is minted from — a
324
+ site booted on `http://localhost:3000` serves an actor whose id is
325
+ `http://localhost:3000/ap/actor`, and no remote instance can dereference that:
326
+
327
+ ```sh
328
+ GEEKITY_BASE_URL=https://quiet-sun-42.serveo.net geekity serve
329
+ ```
330
+
331
+ Then, from a Mastodon account:
332
+
333
+ 1. Search for `@blog@quiet-sun-42.serveo.net` — the handle is the
334
+ `actorHandle` setting (`blog` unless it has been changed) at the tunnel's
335
+ host. The profile should come back with the site title, tagline and avatar.
336
+ 2. Follow it. The `Follow` lands on `/ap/actor/inbox`, the CMS answers
337
+ `Accept`, and the account appears on `/admin/federation` within a second or
338
+ two.
339
+ 3. Publish a post, from the editor or by writing a file into `content/posts/`.
340
+ A `Create(Article)` is delivered, and the post shows up in the follower's
341
+ home timeline; `/admin/federation` records the outcome, with a Redeliver
342
+ button if it did not land.
343
+ 4. Edit the post, then set `draft: true` on it, to see the `Update` and the
344
+ `Delete` arrive.
345
+
346
+ Two things to know before relying on what a tunnel run shows you.
347
+
348
+ The tunnel address changes every time the command starts, and the followers
349
+ gathered under one are throwaway: their instances hold an actor id on a host
350
+ that no longer exists, so they cannot be migrated to the real domain. The
351
+ admin has no button for removing a follower yet, so run tunnel rehearsals
352
+ against a scratch `dataDir` rather than the one the real site will keep.
353
+
354
+ The actor's key pairs live in the SQLite database under `dataDir`, not in the
355
+ base URL, so they survive across tunnel sessions. That is usually what you
356
+ want; it also means a database copied from one deployment to another brings
357
+ the other's identity with it.
358
+
359
+ `pnpm fed:smoke`, in this repository, is the automated half of the same idea:
360
+ it boots the CMS on a free port, runs `fedify lookup` against the actor and a
361
+ post object, and delivers a `Create(Article)` to two real inboxes over
362
+ loopback. It needs no tunnel and no network, and it is a job in CI. See
363
+ `packages/cms/scripts/fed-smoke.ts`, whose header explains why the `Follow`
364
+ half is sent by a peer built in the script rather than by `fedify inbox`.
365
+
366
+ [Fedify]: https://fedify.dev/
367
+ [`fedify tunnel`]: https://fedify.dev/cli
368
+
369
+ ## Keeping the index in step
370
+
371
+ Booting scans `contentDir`, indexes every Markdown file under `posts/` and
372
+ `pages/`, and drops index rows whose file has gone. After that a watcher keeps
373
+ the two in step, so an edit on disk reaches the live site in about a second
374
+ without a restart.
375
+
376
+ ```ts
377
+ await cms.sync(); // one full scan; what `serve()` runs on boot
378
+ ```
379
+
380
+ Set `watch: false` (or `GEEKITY_WATCH=false`) for a host whose content cannot
381
+ change under the process. `geekity sync` sets it for you.
382
+
383
+ ## Hooks
384
+
385
+ Two hooks let a site do something of its own when content changes. Both are
386
+ config options, and both are also methods, for a site that would rather
387
+ subscribe from its entry file:
388
+
389
+ ```ts
390
+ export default defineConfig({
391
+ onPublish: (change) => {
392
+ console.log(`${change.next?.title ?? ''} went live`);
393
+ },
394
+ });
395
+ ```
396
+
397
+ ```ts
398
+ const stop = cms.onDocumentChange((change) => {
399
+ console.log(change.type, change.path, change.origin);
400
+ });
401
+ stop(); // the method returns the unsubscribe
402
+ ```
403
+
404
+ `onDocumentChange` runs for every `created`, `updated` and `deleted`.
405
+ `onPublish` runs when a document becomes visible on the public site: created
406
+ already published, a draft published, or a document restored from the trash. A
407
+ hook may be `async`; the CMS never waits for it, and a hook that throws or
408
+ rejects is reported and does not stop the scan or wedge the watcher.
409
+
410
+ **Mind the origin.** Every change carries `origin`: `watch` for a live edit,
411
+ `admin` for a write the editor made, and `scan` for a full scan — _including
412
+ the one on boot_. A cold index reports its whole first scan as `created`, and
413
+ every published document as `published`, so a hook that must not re-fire when
414
+ the index is rebuilt has to say so:
415
+
416
+ ```ts
417
+ cms.onPublish((change) => {
418
+ if (change.origin === 'scan') return; // not a rebuilt index
419
+ void announce(change.next);
420
+ });
421
+ ```
422
+
423
+ Deleting `data/geekity.db` is a supported thing to do, so this is not a corner
424
+ case: it is what happens on the next boot.
425
+
426
+ For anything the two hooks do not cover, subscribe to the events directly. An
427
+ `async` listener registered this way _is_ waited for, unlike the two hooks, so
428
+ it can finish writing before the change is called done — and can also hold up
429
+ the watcher if it is slow.
430
+
431
+ ```ts
432
+ cms.events.on('unpublished', (change) => {
433
+ console.log(`${change.previous?.title ?? ''} came down`);
434
+ });
435
+ ```
436
+
437
+ | Event | When |
438
+ | ------------- | -------------------------------------------------------------------------------------------------- |
439
+ | `created` | A file was indexed for the first time. |
440
+ | `updated` | An indexed file's content changed. |
441
+ | `deleted` | An indexed file is gone. |
442
+ | `published` | A document became visible: created live, a draft published, or a document restored from the trash. |
443
+ | `unpublished` | The reverse: published to draft, trashed, or deleted while live. |
444
+ | `change` | Every `created`, `updated` and `deleted`. |
445
+
446
+ Each one carries the document before and after the change (`previous` and
447
+ `next`, either of which may be `undefined`), the content-relative `path`, and
448
+ `origin`. `on()` returns the function that unsubscribes; there is also `once()`
449
+ and `off()`.
450
+
451
+ ## The admin
452
+
453
+ Booting also mounts the admin at `/admin`. It is server-rendered from templates
454
+ that ship inside the package, deliberately outside the theme search path: a
455
+ site's `theme/` may override any public template, and must not be able to
456
+ shadow the login form.
457
+
458
+ | Route | What it does |
459
+ | ------------------------------ | --------------------------------------------------------------------- |
460
+ | `/admin` | The dashboard: counts, the five most recent posts, a quick draft. |
461
+ | `/admin/quick-draft` | `POST` only. Writes a draft post and redirects to its editor. |
462
+ | `/admin/posts`, `/admin/pages` | The listings and the editors. |
463
+ | `/admin/settings` | Site title, tagline, base URL, time zone, posts per page, the actor. |
464
+ | `/admin/settings/avatar` | `POST` only. Uploads the site's avatar, or removes it. |
465
+ | `/admin/users` | Who may sign in. `POST` adds one. |
466
+ | `/admin/users/password` | `POST` only. Changes the signed-in admin's own password. |
467
+ | `/admin/users/delete` | `POST` only. Deletes the user the form names. |
468
+ | `/admin/federation` | The actor, the followers, the inbox log, and per-post delivery. |
469
+ | `/admin/federation/redeliver` | `POST` only. Sends one post's latest activity to the followers again. |
470
+ | `/admin/setup` | First run: creates the first admin. Closed once a user exists. |
471
+ | `/admin/login` | Username and password. |
472
+ | `/admin/logout` | `POST` only. Deletes the session row. |
473
+ | `/admin/_static/*` | The admin's own stylesheet, cached for an hour. |
474
+
475
+ The screens behind the login share one layout: a bar across the top with the
476
+ site name and a link to the public site, the sections down the left with the
477
+ current one marked, and a place for flash messages. A message queued with
478
+ `flash(c, 'notice', '…')` is kept on the session row, shown on the next page the
479
+ browser asks for, and cleared as it is read, so it survives exactly one
480
+ redirect.
481
+
482
+ Users and sessions live in the same SQLite file as the content index, in tables
483
+ of their own. That is the half of the database that is _not_ derived from the
484
+ content directory: deleting the file loses every login, while everything else
485
+ in it is rebuilt on the next boot.
486
+
487
+ Passwords are hashed with argon2id through `node:crypto`, so there is no native
488
+ module to build. The cost parameters travel with each hash, which means raising
489
+ them later leaves every password already stored verifiable.
490
+
491
+ A session id is 256 random bits in a `HttpOnly; SameSite=Lax; Path=/admin`
492
+ cookie, with `Secure` added when `baseUrl` is an `https` URL. It lasts
493
+ `sessionLifetime`; an expired session is deleted rather than merely ignored.
494
+ Every mutating admin form carries a per-session CSRF token, and a `POST`
495
+ without a valid one is refused with 403 — including the login and setup forms,
496
+ which get the token from a short anonymous session created when the form is
497
+ first rendered. Logging in throws that session away and starts a new one, so a
498
+ planted session id cannot become a logged-in one.
499
+
500
+ The first admin comes from `/admin/setup` or from
501
+ [`geekity user add`](#creating-an-admin-from-the-command-line). Every one after
502
+ that comes from `/admin/users`, which lists who may sign in, adds a user with a
503
+ password you supply or one it generates and shows once, deletes another user,
504
+ and changes your own password — which signs out every other browser holding
505
+ that login and leaves the one you are using alone. There is a single role, so a
506
+ row has nothing else to edit. The last remaining user cannot be deleted, and
507
+ nobody may delete their own account.
508
+
509
+ Creating one from your own code, which is what the CLI does:
510
+
511
+ ```ts
512
+ import { openAdminStore } from '@geekity/cms';
513
+
514
+ const admin = openAdminStore({ dataDir: 'data' });
515
+ admin.createUser({ username: 'ada', password: process.env.PASSWORD ?? '' });
516
+ admin.close();
517
+ ```
518
+
519
+ ## The public site
520
+
521
+ Booting mounts the public site on the app. The routes are:
522
+
523
+ | Route | What it serves |
524
+ | ----------------------------------------------- | --------------------------------------------------------------------------- |
525
+ | `/` | Published posts, newest first. |
526
+ | `/page/2/` and up | Later pages of the same archive. |
527
+ | a document's permalink | The post or the page, through the theme. |
528
+ | `/tags/{tag}/` | Everything published carrying that tag, paginated at `/tags/{tag}/page/2/`. |
529
+ | `/feed.xml`, `/feed.json` | The recent posts as Atom and JSON Feed. |
530
+ | `/tags/{tag}/feed.xml`, `/tags/{tag}/feed.json` | The same, for one tag. |
531
+ | `/theme/…` | The theme's own files, from its `static/` directory. |
532
+ | anything else | The theme's 404. |
533
+
534
+ Drafts and documents in the trash are not on the public site: their URLs 404,
535
+ and they are in no listing.
536
+
537
+ Trailing slashes are canonical. A request that arrives without one redirects
538
+ 301 to the URL with it, but only when that URL resolves — a genuinely missing
539
+ address 404s straight away rather than bouncing first. `/page/1/` redirects to
540
+ `/`, because the first page of a listing lives at the listing's own URL.
541
+
542
+ A document is resolved after every registered route has failed to match, so
543
+ routes a site adds always win over a permalink that happens to collide with
544
+ them.
545
+
546
+ ## Content negotiation
547
+
548
+ Every public URL is one resource with more than one body. A post or a page has
549
+ three; a listing has two.
550
+
551
+ | Media type | Extension | Body |
552
+ | ------------------ | --------- | -------------------------------------------------------- |
553
+ | `text/html` | — | The document through the theme. The default. |
554
+ | `text/markdown` | `.md` | The file exactly as it is stored, front matter included. |
555
+ | `application/json` | `.json` | The document as data, see below. |
556
+
557
+ Listings — `/`, `/page/N/`, `/tags/{tag}/` and `/tags/{tag}/page/N/` — offer
558
+ HTML and JSON only. There is no Markdown file behind a listing to serve.
559
+
560
+ The representation is chosen like this:
561
+
562
+ 1. A `.md` or `.json` suffix on the path wins outright and `Accept` is ignored.
563
+ Both spellings work: `/2026/09/hello/index.md` (what the `Link` header
564
+ advertises) and the shorter `/2026/09/hello.md`.
565
+ 2. Otherwise `Accept` is matched with q-values. The highest q wins; a tie goes
566
+ to the more specific range; what is still tied goes to HTML.
567
+ 3. No `Accept` header, an empty one, or `*/*` means HTML, so a browser and a
568
+ `curl` with no arguments both get a page.
569
+ 4. A request that accepts none of the representations gets `406` with a JSON
570
+ body listing what it could have asked for.
571
+
572
+ The feeds are fixed routes and are not negotiated: feed readers do not send
573
+ useful `Accept` headers. See [Feeds](#feeds).
574
+
575
+ ```sh
576
+ curl -H 'Accept: text/markdown' https://example.com/2026/09/hello/
577
+ curl https://example.com/2026/09/hello/index.json
578
+ curl 'https://example.com/index.json?full=1'
579
+ ```
580
+
581
+ ### The JSON shape
582
+
583
+ ```json
584
+ {
585
+ "schema": 1,
586
+ "url": "https://example.com/2026/09/hello/",
587
+ "frontMatter": {
588
+ "title": "Hello, World!",
589
+ "date": "2026-09-02T09:00:00Z",
590
+ "permalink": "/2026/09/hello/",
591
+ "tags": ["introductions"]
592
+ },
593
+ "markdown": "A *file-first* CMS.",
594
+ "html": "<p>A <em>file-first</em> CMS.</p>\n"
595
+ }
596
+ ```
597
+
598
+ `schema` is the version of this shape; keys are only added within a version.
599
+ `url` is absolute, built on the configured `baseUrl`. `frontMatter` is the
600
+ document's front matter exactly as the `.md` representation carries it — the
601
+ same block the writer emits, unknown keys included — so the two cannot drift.
602
+ `markdown` is the body without the front matter, and `html` is that body
603
+ rendered.
604
+
605
+ A listing's JSON is a plain array of the same objects with `markdown` and
606
+ `html` left out, because an archive page should not carry every post in full.
607
+ `?full=1` puts them back.
608
+
609
+ ```json
610
+ [
611
+ {
612
+ "schema": 1,
613
+ "url": "https://example.com/one/",
614
+ "frontMatter": { "title": "One", "…": "…" }
615
+ },
616
+ {
617
+ "schema": 1,
618
+ "url": "https://example.com/two/",
619
+ "frontMatter": { "title": "Two", "…": "…" }
620
+ }
621
+ ]
622
+ ```
623
+
624
+ A 406 body names the alternates:
625
+
626
+ ```json
627
+ {
628
+ "error": "not_acceptable",
629
+ "message": "This URL is not available in any of the media types you accept.",
630
+ "alternates": [
631
+ { "type": "text/html", "url": "/2026/09/hello/" },
632
+ { "type": "text/markdown", "url": "/2026/09/hello/index.md" },
633
+ { "type": "application/json", "url": "/2026/09/hello/index.json" }
634
+ ]
635
+ }
636
+ ```
637
+
638
+ ### Headers
639
+
640
+ Every negotiated response carries `Vary: Accept` and a `Link` header pointing
641
+ at the representations it is not:
642
+
643
+ ```
644
+ Link: </2026/09/hello/index.md>; rel="alternate"; type="text/markdown",
645
+ </2026/09/hello/index.json>; rel="alternate"; type="application/json"
646
+ ```
647
+
648
+ It also carries `ETag` — derived from the document's content hash and the
649
+ representation, so a client holding the JSON is never told its Markdown is
650
+ unchanged — plus `Last-Modified` from the document's `updated`, falling back to
651
+ its `date`, and `Cache-Control: no-cache` so a client revalidates rather than
652
+ guessing how long the page stays fresh. `If-None-Match` and `If-Modified-Since`
653
+ are honoured and answered with `304`.
654
+
655
+ One exception: while `watch` is on, the HTML representation is served without
656
+ validators. Only the document is hashed, and a template edit changes the page
657
+ without changing the document, so a development server would otherwise answer
658
+ `304` with a page that had already moved on. The `.md` and `.json`
659
+ representations are validated either way.
660
+
661
+ Adding a representation — an ActivityStreams object, say — means adding it to
662
+ `Representation` in `src/web/negotiate.ts` with its media type and, if it wants
663
+ one, its extension. The selection, the `Link` alternates, the `ETag` and the
664
+ 406 body all follow from those two tables. ActivityPub types are claimed by the
665
+ federation middleware before the negotiator sees them.
666
+
667
+ ## Feeds
668
+
669
+ The recent posts are syndicated in two formats, at fixed URLs:
670
+
671
+ | URL | Format |
672
+ | ----------------------- | --------------------------------------- |
673
+ | `/feed.xml` | Atom 1.0 (`application/atom+xml`) |
674
+ | `/feed.json` | JSON Feed 1.1 (`application/feed+json`) |
675
+ | `/tags/{tag}/feed.xml` | The same tag archive, as Atom |
676
+ | `/tags/{tag}/feed.json` | The same tag archive, as JSON Feed |
677
+
678
+ A feed holds the newest published posts, newest first. Drafts, documents in the
679
+ trash and pages are never in one, and a tag nothing published carries 404s
680
+ rather than serving an empty feed. `feedSize` in `content/_data/site.json` sets
681
+ how many entries a feed holds; without it a feed holds 20, which is deliberately
682
+ more than an archive page, so a reader that polls once a day does not miss a
683
+ post on a site that publishes several.
684
+
685
+ ```json
686
+ { "title": "My Site", "tagline": "Notes", "author": "Me", "feedSize": 20 }
687
+ ```
688
+
689
+ An Atom entry carries `id` (the post's absolute URL), `title`, `updated`,
690
+ `published`, `link rel="alternate"`, an `author` when the front matter names
691
+ one, a `category` per tag, a `summary` when the front matter has a
692
+ `description`, and the whole rendered post as `content type="html"`. The feed
693
+ itself carries `id`, `title`, `subtitle` from the site's tagline, `updated`,
694
+ `link rel="self"`, `link rel="alternate"` to the HTML page and a `generator`.
695
+ The XML is written by this package rather than by a library, and everything
696
+ that goes into it is escaped.
697
+
698
+ A JSON Feed item carries `id`, `url`, `title`, `content_html`, `summary`,
699
+ `date_published`, `date_modified`, `tags` and `authors`; the feed carries
700
+ `version`, `title`, `home_page_url`, `feed_url`, `description` and `authors`.
701
+ Keys with nothing behind them are left out rather than sent empty.
702
+
703
+ Both feeds carry `ETag`, `Last-Modified` and `Cache-Control: no-cache`, and
704
+ answer `If-None-Match` and `If-Modified-Since` with `304`. The validator covers
705
+ the feed's metadata as well as its entries, and the two formats and the two
706
+ scopes each get their own, so a reader holding the Atom feed is never told the
707
+ JSON one is unchanged. Feeds are validated even while `watch` is on, because a
708
+ feed is not rendered through the theme.
709
+
710
+ ```sh
711
+ curl -i https://example.com/feed.xml
712
+ curl -i https://example.com/tags/releases/feed.json
713
+ ```
714
+
715
+ Every page of the default theme advertises both feeds in its `<head>`, and a
716
+ tag archive advertises that tag's two feeds as well. A theme that does not
717
+ extend `layouts/base.njk` should emit them itself:
718
+
719
+ ```html
720
+ <link
721
+ rel="alternate"
722
+ type="application/atom+xml"
723
+ title="My Site"
724
+ href="/feed.xml"
725
+ />
726
+ <link
727
+ rel="alternate"
728
+ type="application/feed+json"
729
+ title="My Site"
730
+ href="/feed.json"
731
+ />
732
+ ```
733
+
734
+ The builders are exported, so a site can write a feed of its own — one
735
+ category, one author — without reimplementing either format:
736
+
737
+ ```ts
738
+ import { atomFeed, createRenderer, jsonFeed } from '@geekity/cms';
739
+
740
+ const site = createRenderer({ config: cms.config }).site();
741
+
742
+ const source = {
743
+ site,
744
+ documents: cms.store.listByTag('releases', { limit: 20 }),
745
+ title: `${site.title}: releases`,
746
+ href: '/tags/releases/',
747
+ feedHref: '/tags/releases/feed.xml',
748
+ baseUrl: cms.config.baseUrl,
749
+ };
750
+
751
+ atomFeed(source); // a string
752
+ jsonFeed(source); // a JSON Feed object
753
+ ```
754
+
755
+ ## Theme overrides
756
+
757
+ A template is looked up in the site's `themeDir` first, then in the theme that
758
+ ships inside this package, file by file. Sites override one template at a time
759
+ and keep receiving updates to the rest. Files under `/theme/` resolve the same
760
+ way, so `theme/static/style.css` replaces the packaged stylesheet.
761
+
762
+ `themeDir` need not exist. A site of nothing but `geekity.config.ts` and
763
+ `content/` serves every page, the 404 and the stylesheet out of the packaged
764
+ theme; creating `theme/` is how you start replacing pieces of it, not a
765
+ condition of running.
766
+
767
+ A site that ships only
768
+
769
+ ```
770
+ theme/layouts/post.njk
771
+ ```
772
+
773
+ replaces the post layout; the base layout, the archive, the tag pages and the
774
+ 404 all still come from the package. Templates are Nunjucks, and an override
775
+ can extend a packaged one by name:
776
+
777
+ ```njk
778
+ {% extends "layouts/base.njk" %}
779
+
780
+ {% block content %}
781
+ <h1>{{ title }}</h1>
782
+ {{ content | safe }}
783
+ {% endblock %}
784
+ ```
785
+
786
+ The context mirrors what an Eleventy layout receives — `title`, `date`, `tags`,
787
+ `content`, `page.url`, and every front matter key the file carried — plus
788
+ `site`, which is `content/_data/site.json`. It is part of the semver contract.
789
+ The full table of context keys, blocks and filters is in
790
+ [`themes/default/README.md`](./themes/default/README.md).
791
+
792
+ Rendering is also callable without a request:
793
+
794
+ ```ts
795
+ import { createRenderer, resolveConfig } from '@geekity/cms';
796
+
797
+ const renderer = createRenderer({
798
+ config: resolveConfig({ baseUrl: 'https://example.com' }),
799
+ });
800
+ renderer.renderDocument(document); // the HTML the site would serve
801
+ renderer.site(); // content/_data/site.json, defaults filled in
802
+ ```
803
+
804
+ Handlers reach the same renderer as `c.var.renderer`.
805
+
806
+ ## Building the same content with Eleventy
807
+
808
+ The content directory is an [Eleventy](https://www.11ty.dev) 3 input directory.
809
+ The same files this CMS serves build into a static site at the same URLs, so
810
+ leaving is a build step rather than a migration. That is a promise, and it is
811
+ kept by a test rather than by good intentions.
812
+
813
+ Copy
814
+ [`docs/eleventy.config.example.js`](https://github.com/geekitycom/cms/blob/main/packages/cms/docs/eleventy.config.example.js)
815
+ into your site as `eleventy.config.js` and run Eleventy:
816
+
817
+ ```sh
818
+ npm install --save-dev @11ty/eleventy
819
+ npx @11ty/eleventy
820
+ ```
821
+
822
+ It is a plain ESM config with no dependency on this package, and it writes out
823
+ the four rules the CMS follows that Eleventy does not know about on its own:
824
+
825
+ | Rule | How the config does it |
826
+ | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
827
+ | `draft: true` hides a document | An `addPreprocessor` that returns `false` for it. `BUILD_DRAFTS=1` builds drafts anyway, for a local preview. |
828
+ | A file with no `permalink` gets the CMS default | An `addPreprocessor` that fills in `/{yyyy}/{mm}/{slug}/` for a post and `/{slug}/` for a page, slugifying the title exactly as the CMS does. Front matter always wins; the CMS writes `permalink` into every file it saves, so this only matters for hand-authored files. |
829
+ | `content/uploads/` is served at `/uploads/` | `addPassthroughCopy({ 'content/uploads': 'uploads' })`, plus an `ignores` entry for the same path. Without the ignore, an upload that happens to be Markdown would be copied _and_ rendered as a page; the CMS only ever indexes `posts/` and `pages/`. |
830
+ | `content/_trash/` is not published | `ignores.add('content/_trash/**')`. Eleventy skips `_includes` and `_data` because they are configured directories, not because of the underscore, so the trash has to be named. |
831
+
832
+ It also turns the template engine off for Markdown
833
+ (`markdownTemplateEngine: false`), because the CMS renders Markdown with
834
+ markdown-it and nothing else. Leaving Liquid or Nunjucks on would make `{{ … }}`
835
+ in a post mean one thing on the live site and another in the build.
836
+
837
+ Layouts are yours. The directory data files name them — `posts.json` says
838
+ `"layout": "post"` and `pages.json` says `"layout": "page"` — so Eleventy wants
839
+ `content/_includes/post.njk` and `content/_includes/page.njk`. The context an
840
+ Eleventy layout receives is the one this package's theme mirrors, so a layout
841
+ can often be moved across with only its `{% extends %}` removed.
842
+
843
+ The feeds are the one thing that does not carry over: `/feed.xml` and
844
+ `/feed.json` are generated in code here, not by a template, so an Eleventy build
845
+ needs its own. Everything else is the same directory.
846
+
847
+ ### The compatibility test
848
+
849
+ ```sh
850
+ pnpm --filter @geekity/cms test:11ty # or, from the repository root, pnpm test:11ty
851
+ ```
852
+
853
+ `test:11ty` builds `test/fixtures/` with Eleventy 3 and the example config, then
854
+ checks every document against what the CMS computed for the same file: the
855
+ output path equals the permalink plus `index.html`, drafts and the trash are
856
+ absent, and uploads are copied through byte for byte. The fixtures deliberately
857
+ cover a post whose permalink is not the default, a hand-authored post and page
858
+ with no permalink at all, an accented title, a draft and a trashed file.
859
+
860
+ It is a separate script from `pnpm test` on purpose. The unit suite stays fast
861
+ and free of a build tool; `test:11ty` pulls in Eleventy, writes a temporary
862
+ directory and is the slow one. CI runs both. Neither `docs/` nor `test/` is in
863
+ the published tarball — the example config lives in the repository, which is
864
+ where you copy it from.
865
+
866
+ The repository's demo site makes the same comparison over content that reads
867
+ like a real site rather than like fixtures, with the two layouts above in
868
+ `apps/demo/content/_includes/`, so `pnpm test:11ty` from the root runs both.
869
+
870
+ ## License