@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.
- package/LICENSE +21 -0
- package/README.md +870 -0
- package/admin/layouts/_flash.njk +5 -0
- package/admin/layouts/base.njk +27 -0
- package/admin/layouts/dashboard.njk +78 -0
- package/admin/layouts/document-conflict.njk +49 -0
- package/admin/layouts/document-editor.njk +167 -0
- package/admin/layouts/document-list.njk +92 -0
- package/admin/layouts/federation.njk +145 -0
- package/admin/layouts/login.njk +23 -0
- package/admin/layouts/placeholder.njk +10 -0
- package/admin/layouts/settings.njk +116 -0
- package/admin/layouts/setup.njk +27 -0
- package/admin/layouts/shell.njk +37 -0
- package/admin/layouts/users.njk +91 -0
- package/admin/static/admin.css +726 -0
- package/admin/static/editor.js +30 -0
- package/dist/admin/assets.d.ts +26 -0
- package/dist/admin/assets.d.ts.map +1 -0
- package/dist/admin/assets.js +38 -0
- package/dist/admin/assets.js.map +1 -0
- package/dist/admin/credentials.d.ts +28 -0
- package/dist/admin/credentials.d.ts.map +1 -0
- package/dist/admin/credentials.js +39 -0
- package/dist/admin/credentials.js.map +1 -0
- package/dist/admin/documents.d.ts +134 -0
- package/dist/admin/documents.d.ts.map +1 -0
- package/dist/admin/documents.js +613 -0
- package/dist/admin/documents.js.map +1 -0
- package/dist/admin/federation.d.ts +194 -0
- package/dist/admin/federation.d.ts.map +1 -0
- package/dist/admin/federation.js +299 -0
- package/dist/admin/federation.js.map +1 -0
- package/dist/admin/flash.d.ts +24 -0
- package/dist/admin/flash.d.ts.map +1 -0
- package/dist/admin/flash.js +31 -0
- package/dist/admin/flash.js.map +1 -0
- package/dist/admin/index.d.ts +23 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/admin/index.js +15 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/admin/passwords.d.ts +49 -0
- package/dist/admin/passwords.d.ts.map +1 -0
- package/dist/admin/passwords.js +122 -0
- package/dist/admin/passwords.js.map +1 -0
- package/dist/admin/preview.d.ts +18 -0
- package/dist/admin/preview.d.ts.map +1 -0
- package/dist/admin/preview.js +83 -0
- package/dist/admin/preview.js.map +1 -0
- package/dist/admin/routes.d.ts +54 -0
- package/dist/admin/routes.d.ts.map +1 -0
- package/dist/admin/routes.js +383 -0
- package/dist/admin/routes.js.map +1 -0
- package/dist/admin/session.d.ts +44 -0
- package/dist/admin/session.d.ts.map +1 -0
- package/dist/admin/session.js +69 -0
- package/dist/admin/session.js.map +1 -0
- package/dist/admin/settings.d.ts +194 -0
- package/dist/admin/settings.d.ts.map +1 -0
- package/dist/admin/settings.js +470 -0
- package/dist/admin/settings.js.map +1 -0
- package/dist/admin/store.d.ts +407 -0
- package/dist/admin/store.d.ts.map +1 -0
- package/dist/admin/store.js +704 -0
- package/dist/admin/store.js.map +1 -0
- package/dist/admin/templates.d.ts +36 -0
- package/dist/admin/templates.d.ts.map +1 -0
- package/dist/admin/templates.js +43 -0
- package/dist/admin/templates.js.map +1 -0
- package/dist/admin/uploads.d.ts +96 -0
- package/dist/admin/uploads.d.ts.map +1 -0
- package/dist/admin/uploads.js +179 -0
- package/dist/admin/uploads.js.map +1 -0
- package/dist/admin/users.d.ts +95 -0
- package/dist/admin/users.d.ts.map +1 -0
- package/dist/admin/users.js +233 -0
- package/dist/admin/users.js.map +1 -0
- package/dist/cli.d.ts +85 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +424 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +172 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +184 -0
- package/dist/config.js.map +1 -0
- package/dist/content/document.d.ts +66 -0
- package/dist/content/document.d.ts.map +1 -0
- package/dist/content/document.js +13 -0
- package/dist/content/document.js.map +1 -0
- package/dist/content/index.d.ts +17 -0
- package/dist/content/index.d.ts.map +1 -0
- package/dist/content/index.js +10 -0
- package/dist/content/index.js.map +1 -0
- package/dist/content/markdown.d.ts +3 -0
- package/dist/content/markdown.d.ts.map +1 -0
- package/dist/content/markdown.js +39 -0
- package/dist/content/markdown.js.map +1 -0
- package/dist/content/media.d.ts +65 -0
- package/dist/content/media.d.ts.map +1 -0
- package/dist/content/media.js +143 -0
- package/dist/content/media.js.map +1 -0
- package/dist/content/parser.d.ts +24 -0
- package/dist/content/parser.d.ts.map +1 -0
- package/dist/content/parser.js +165 -0
- package/dist/content/parser.js.map +1 -0
- package/dist/content/save.d.ts +60 -0
- package/dist/content/save.d.ts.map +1 -0
- package/dist/content/save.js +103 -0
- package/dist/content/save.js.map +1 -0
- package/dist/content/slug.d.ts +25 -0
- package/dist/content/slug.d.ts.map +1 -0
- package/dist/content/slug.js +75 -0
- package/dist/content/slug.js.map +1 -0
- package/dist/content/store.d.ts +133 -0
- package/dist/content/store.d.ts.map +1 -0
- package/dist/content/store.js +411 -0
- package/dist/content/store.js.map +1 -0
- package/dist/content/sync.d.ts +146 -0
- package/dist/content/sync.d.ts.map +1 -0
- package/dist/content/sync.js +421 -0
- package/dist/content/sync.js.map +1 -0
- package/dist/content/writer.d.ts +37 -0
- package/dist/content/writer.d.ts.map +1 -0
- package/dist/content/writer.js +97 -0
- package/dist/content/writer.js.map +1 -0
- package/dist/env.d.ts +48 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +2 -0
- package/dist/env.js.map +1 -0
- package/dist/federation/actor.d.ts +55 -0
- package/dist/federation/actor.d.ts.map +1 -0
- package/dist/federation/actor.js +101 -0
- package/dist/federation/actor.js.map +1 -0
- package/dist/federation/article.d.ts +83 -0
- package/dist/federation/article.d.ts.map +1 -0
- package/dist/federation/article.js +169 -0
- package/dist/federation/article.js.map +1 -0
- package/dist/federation/delivery.d.ts +90 -0
- package/dist/federation/delivery.d.ts.map +1 -0
- package/dist/federation/delivery.js +289 -0
- package/dist/federation/delivery.js.map +1 -0
- package/dist/federation/federation.d.ts +75 -0
- package/dist/federation/federation.d.ts.map +1 -0
- package/dist/federation/federation.js +190 -0
- package/dist/federation/federation.js.map +1 -0
- package/dist/federation/followers.d.ts +43 -0
- package/dist/federation/followers.d.ts.map +1 -0
- package/dist/federation/followers.js +69 -0
- package/dist/federation/followers.js.map +1 -0
- package/dist/federation/inbox.d.ts +68 -0
- package/dist/federation/inbox.d.ts.map +1 -0
- package/dist/federation/inbox.js +171 -0
- package/dist/federation/inbox.js.map +1 -0
- package/dist/federation/index.d.ts +14 -0
- package/dist/federation/index.d.ts.map +1 -0
- package/dist/federation/index.js +10 -0
- package/dist/federation/index.js.map +1 -0
- package/dist/federation/keys.d.ts +27 -0
- package/dist/federation/keys.d.ts.map +1 -0
- package/dist/federation/keys.js +60 -0
- package/dist/federation/keys.js.map +1 -0
- package/dist/federation/mount.d.ts +23 -0
- package/dist/federation/mount.d.ts.map +1 -0
- package/dist/federation/mount.js +71 -0
- package/dist/federation/mount.js.map +1 -0
- package/dist/federation/paths.d.ts +85 -0
- package/dist/federation/paths.d.ts.map +1 -0
- package/dist/federation/paths.js +97 -0
- package/dist/federation/paths.js.map +1 -0
- package/dist/index.d.ts +106 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +148 -0
- package/dist/index.js.map +1 -0
- package/dist/init.d.ts +71 -0
- package/dist/init.d.ts.map +1 -0
- package/dist/init.js +125 -0
- package/dist/init.js.map +1 -0
- package/dist/web/assets.d.ts +65 -0
- package/dist/web/assets.d.ts.map +1 -0
- package/dist/web/assets.js +152 -0
- package/dist/web/assets.js.map +1 -0
- package/dist/web/context.d.ts +121 -0
- package/dist/web/context.d.ts.map +1 -0
- package/dist/web/context.js +112 -0
- package/dist/web/context.js.map +1 -0
- package/dist/web/documents.d.ts +28 -0
- package/dist/web/documents.d.ts.map +1 -0
- package/dist/web/documents.js +39 -0
- package/dist/web/documents.js.map +1 -0
- package/dist/web/feeds.d.ts +122 -0
- package/dist/web/feeds.d.ts.map +1 -0
- package/dist/web/feeds.js +229 -0
- package/dist/web/feeds.js.map +1 -0
- package/dist/web/index.d.ts +17 -0
- package/dist/web/index.d.ts.map +1 -0
- package/dist/web/index.js +10 -0
- package/dist/web/index.js.map +1 -0
- package/dist/web/negotiate.d.ts +219 -0
- package/dist/web/negotiate.d.ts.map +1 -0
- package/dist/web/negotiate.js +409 -0
- package/dist/web/negotiate.js.map +1 -0
- package/dist/web/pagination.d.ts +48 -0
- package/dist/web/pagination.d.ts.map +1 -0
- package/dist/web/pagination.js +30 -0
- package/dist/web/pagination.js.map +1 -0
- package/dist/web/render.d.ts +72 -0
- package/dist/web/render.d.ts.map +1 -0
- package/dist/web/render.js +73 -0
- package/dist/web/render.js.map +1 -0
- package/dist/web/routes.d.ts +31 -0
- package/dist/web/routes.d.ts.map +1 -0
- package/dist/web/routes.js +366 -0
- package/dist/web/routes.js.map +1 -0
- package/dist/web/templates.d.ts +34 -0
- package/dist/web/templates.d.ts.map +1 -0
- package/dist/web/templates.js +106 -0
- package/dist/web/templates.js.map +1 -0
- package/package.json +68 -0
- package/templates/site/content/_data/site.json +8 -0
- package/templates/site/content/pages/about.md +11 -0
- package/templates/site/content/pages/pages.json +3 -0
- package/templates/site/content/posts/2026-01-01-hello-world.md +34 -0
- package/templates/site/content/posts/posts.json +4 -0
- package/templates/site/geekity.config.ts +11 -0
- package/templates/site/gitignore +3 -0
- package/templates/site/server.ts +20 -0
- package/templates/site/tsconfig.json +18 -0
- package/themes/default/README.md +148 -0
- package/themes/default/layouts/404.njk +9 -0
- package/themes/default/layouts/base.njk +59 -0
- package/themes/default/layouts/home.njk +9 -0
- package/themes/default/layouts/page.njk +13 -0
- package/themes/default/layouts/post.njk +30 -0
- package/themes/default/layouts/tag.njk +19 -0
- package/themes/default/partials/pagination.njk +14 -0
- package/themes/default/partials/post-list.njk +24 -0
- package/themes/default/partials/tags.njk +12 -0
- 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
|