@geekity/cms 0.3.0 → 0.4.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 (637) hide show
  1. package/README.md +2091 -214
  2. package/admin/components/fields.njk +165 -0
  3. package/admin/layouts/base.njk +1 -1
  4. package/admin/layouts/settings-page.njk +26 -0
  5. package/admin/layouts/shell.njk +17 -3
  6. package/admin/pages/account/forgot.njk +47 -0
  7. package/admin/pages/account/login.njk +26 -0
  8. package/admin/pages/account/reset.njk +38 -0
  9. package/admin/pages/account/setup.njk +28 -0
  10. package/admin/pages/appearance/themes.njk +69 -0
  11. package/admin/pages/comments/all.njk +133 -0
  12. package/admin/{layouts/dashboard.njk → pages/dashboard/home.njk} +8 -15
  13. package/admin/{layouts/document-conflict.njk → pages/documents/conflict.njk} +1 -0
  14. package/admin/pages/documents/editor.njk +196 -0
  15. package/admin/{layouts/document-list.njk → pages/documents/list.njk} +19 -2
  16. package/admin/pages/documents/taxonomy.njk +95 -0
  17. package/admin/pages/federation/followers.njk +238 -0
  18. package/admin/pages/media/library.njk +160 -0
  19. package/admin/pages/messages/all.njk +110 -0
  20. package/admin/{layouts → pages}/placeholder.njk +5 -1
  21. package/admin/pages/settings/discussion.njk +99 -0
  22. package/admin/pages/settings/email.njk +203 -0
  23. package/admin/pages/settings/federation.njk +75 -0
  24. package/admin/pages/settings/general.njk +29 -0
  25. package/admin/pages/settings/permalinks.njk +50 -0
  26. package/admin/pages/settings/reading.njk +72 -0
  27. package/admin/pages/users/edit.njk +239 -0
  28. package/admin/pages/users/list.njk +48 -0
  29. package/admin/pages/users/new.njk +41 -0
  30. package/admin/static/admin.css +498 -3
  31. package/admin/static/copy.js +52 -0
  32. package/admin/static/editor.js +16 -16
  33. package/admin/static/slug.js +61 -0
  34. package/dist/admin/accounts.d.ts +413 -0
  35. package/dist/admin/accounts.d.ts.map +1 -0
  36. package/dist/admin/accounts.js +680 -0
  37. package/dist/admin/accounts.js.map +1 -0
  38. package/dist/admin/appearance.d.ts +80 -0
  39. package/dist/admin/appearance.d.ts.map +1 -0
  40. package/dist/admin/appearance.js +136 -0
  41. package/dist/admin/appearance.js.map +1 -0
  42. package/dist/admin/comments.d.ts +90 -0
  43. package/dist/admin/comments.d.ts.map +1 -0
  44. package/dist/admin/comments.js +244 -0
  45. package/dist/admin/comments.js.map +1 -0
  46. package/dist/admin/credentials.d.ts +26 -2
  47. package/dist/admin/credentials.d.ts.map +1 -1
  48. package/dist/admin/credentials.js +35 -2
  49. package/dist/admin/credentials.js.map +1 -1
  50. package/dist/admin/documents.d.ts +127 -10
  51. package/dist/admin/documents.d.ts.map +1 -1
  52. package/dist/admin/documents.js +360 -41
  53. package/dist/admin/documents.js.map +1 -1
  54. package/dist/admin/federation.d.ts +151 -50
  55. package/dist/admin/federation.d.ts.map +1 -1
  56. package/dist/admin/federation.js +225 -93
  57. package/dist/admin/federation.js.map +1 -1
  58. package/dist/admin/formatting.d.ts +20 -0
  59. package/dist/admin/formatting.d.ts.map +1 -0
  60. package/dist/admin/formatting.js +72 -0
  61. package/dist/admin/formatting.js.map +1 -0
  62. package/dist/admin/headers.d.ts +65 -0
  63. package/dist/admin/headers.d.ts.map +1 -0
  64. package/dist/admin/headers.js +104 -0
  65. package/dist/admin/headers.js.map +1 -0
  66. package/dist/admin/index.d.ts +38 -12
  67. package/dist/admin/index.d.ts.map +1 -1
  68. package/dist/admin/index.js +25 -7
  69. package/dist/admin/index.js.map +1 -1
  70. package/dist/admin/media.d.ts +168 -0
  71. package/dist/admin/media.d.ts.map +1 -0
  72. package/dist/admin/media.js +295 -0
  73. package/dist/admin/media.js.map +1 -0
  74. package/dist/admin/menu.d.ts +79 -0
  75. package/dist/admin/menu.d.ts.map +1 -0
  76. package/dist/admin/menu.js +126 -0
  77. package/dist/admin/menu.js.map +1 -0
  78. package/dist/admin/messages.d.ts +80 -0
  79. package/dist/admin/messages.d.ts.map +1 -0
  80. package/dist/admin/messages.js +152 -0
  81. package/dist/admin/messages.js.map +1 -0
  82. package/dist/admin/preview.d.ts.map +1 -1
  83. package/dist/admin/preview.js +31 -7
  84. package/dist/admin/preview.js.map +1 -1
  85. package/dist/admin/recovery.d.ts +92 -0
  86. package/dist/admin/recovery.d.ts.map +1 -0
  87. package/dist/admin/recovery.js +229 -0
  88. package/dist/admin/recovery.js.map +1 -0
  89. package/dist/admin/routes.d.ts +0 -20
  90. package/dist/admin/routes.d.ts.map +1 -1
  91. package/dist/admin/routes.js +155 -105
  92. package/dist/admin/routes.js.map +1 -1
  93. package/dist/admin/settings-discussion.d.ts +37 -0
  94. package/dist/admin/settings-discussion.d.ts.map +1 -0
  95. package/dist/admin/settings-discussion.js +117 -0
  96. package/dist/admin/settings-discussion.js.map +1 -0
  97. package/dist/admin/settings-email.d.ts +54 -0
  98. package/dist/admin/settings-email.d.ts.map +1 -0
  99. package/dist/admin/settings-email.js +189 -0
  100. package/dist/admin/settings-email.js.map +1 -0
  101. package/dist/admin/settings-federation.d.ts +39 -0
  102. package/dist/admin/settings-federation.d.ts.map +1 -0
  103. package/dist/admin/settings-federation.js +103 -0
  104. package/dist/admin/settings-federation.js.map +1 -0
  105. package/dist/admin/settings-general.d.ts +12 -0
  106. package/dist/admin/settings-general.d.ts.map +1 -0
  107. package/dist/admin/settings-general.js +48 -0
  108. package/dist/admin/settings-general.js.map +1 -0
  109. package/dist/admin/settings-page.d.ts +102 -0
  110. package/dist/admin/settings-page.d.ts.map +1 -0
  111. package/dist/admin/settings-page.js +126 -0
  112. package/dist/admin/settings-page.js.map +1 -0
  113. package/dist/admin/settings-pages.d.ts +19 -0
  114. package/dist/admin/settings-pages.d.ts.map +1 -0
  115. package/dist/admin/settings-pages.js +33 -0
  116. package/dist/admin/settings-pages.js.map +1 -0
  117. package/dist/admin/settings-permalinks.d.ts +16 -0
  118. package/dist/admin/settings-permalinks.d.ts.map +1 -0
  119. package/dist/admin/settings-permalinks.js +32 -0
  120. package/dist/admin/settings-permalinks.js.map +1 -0
  121. package/dist/admin/settings-reading.d.ts +33 -0
  122. package/dist/admin/settings-reading.d.ts.map +1 -0
  123. package/dist/admin/settings-reading.js +48 -0
  124. package/dist/admin/settings-reading.js.map +1 -0
  125. package/dist/admin/settings.d.ts +325 -96
  126. package/dist/admin/settings.d.ts.map +1 -1
  127. package/dist/admin/settings.js +650 -308
  128. package/dist/admin/settings.js.map +1 -1
  129. package/dist/admin/store.d.ts +606 -152
  130. package/dist/admin/store.d.ts.map +1 -1
  131. package/dist/admin/store.js +863 -268
  132. package/dist/admin/store.js.map +1 -1
  133. package/dist/admin/taxonomy.d.ts +114 -0
  134. package/dist/admin/taxonomy.d.ts.map +1 -0
  135. package/dist/admin/taxonomy.js +289 -0
  136. package/dist/admin/taxonomy.js.map +1 -0
  137. package/dist/admin/templates.d.ts +44 -14
  138. package/dist/admin/templates.d.ts.map +1 -1
  139. package/dist/admin/templates.js +44 -14
  140. package/dist/admin/templates.js.map +1 -1
  141. package/dist/admin/throttle.d.ts +84 -0
  142. package/dist/admin/throttle.d.ts.map +1 -0
  143. package/dist/admin/throttle.js +143 -0
  144. package/dist/admin/throttle.js.map +1 -0
  145. package/dist/admin/uploads.d.ts +28 -2
  146. package/dist/admin/uploads.d.ts.map +1 -1
  147. package/dist/admin/uploads.js +43 -4
  148. package/dist/admin/uploads.js.map +1 -1
  149. package/dist/admin/users.d.ts +100 -13
  150. package/dist/admin/users.d.ts.map +1 -1
  151. package/dist/admin/users.js +412 -39
  152. package/dist/admin/users.js.map +1 -1
  153. package/dist/cache.d.ts +141 -0
  154. package/dist/cache.d.ts.map +1 -0
  155. package/dist/cache.js +207 -0
  156. package/dist/cache.js.map +1 -0
  157. package/dist/cli.d.ts +14 -2
  158. package/dist/cli.d.ts.map +1 -1
  159. package/dist/cli.js +424 -21
  160. package/dist/cli.js.map +1 -1
  161. package/dist/comments/akismet.d.ts +130 -0
  162. package/dist/comments/akismet.d.ts.map +1 -0
  163. package/dist/comments/akismet.js +274 -0
  164. package/dist/comments/akismet.js.map +1 -0
  165. package/dist/comments/conversation.d.ts +29 -0
  166. package/dist/comments/conversation.d.ts.map +1 -0
  167. package/dist/comments/conversation.js +60 -0
  168. package/dist/comments/conversation.js.map +1 -0
  169. package/dist/comments/form.d.ts +75 -0
  170. package/dist/comments/form.d.ts.map +1 -0
  171. package/dist/comments/form.js +64 -0
  172. package/dist/comments/form.js.map +1 -0
  173. package/dist/comments/index.d.ts +15 -0
  174. package/dist/comments/index.d.ts.map +1 -0
  175. package/dist/comments/index.js +9 -0
  176. package/dist/comments/index.js.map +1 -0
  177. package/dist/comments/markdown.d.ts +8 -0
  178. package/dist/comments/markdown.d.ts.map +1 -0
  179. package/dist/comments/markdown.js +85 -0
  180. package/dist/comments/markdown.js.map +1 -0
  181. package/dist/comments/moderate.d.ts +71 -0
  182. package/dist/comments/moderate.d.ts.map +1 -0
  183. package/dist/comments/moderate.js +88 -0
  184. package/dist/comments/moderate.js.map +1 -0
  185. package/dist/comments/policy.d.ts +57 -0
  186. package/dist/comments/policy.d.ts.map +1 -0
  187. package/dist/comments/policy.js +80 -0
  188. package/dist/comments/policy.js.map +1 -0
  189. package/dist/comments/records.d.ts +266 -0
  190. package/dist/comments/records.d.ts.map +1 -0
  191. package/dist/comments/records.js +487 -0
  192. package/dist/comments/records.js.map +1 -0
  193. package/dist/comments/routes.d.ts +58 -0
  194. package/dist/comments/routes.d.ts.map +1 -0
  195. package/dist/comments/routes.js +177 -0
  196. package/dist/comments/routes.js.map +1 -0
  197. package/dist/comments/submission.d.ts +263 -0
  198. package/dist/comments/submission.d.ts.map +1 -0
  199. package/dist/comments/submission.js +241 -0
  200. package/dist/comments/submission.js.map +1 -0
  201. package/dist/config.d.ts +190 -5
  202. package/dist/config.d.ts.map +1 -1
  203. package/dist/config.js +118 -6
  204. package/dist/config.js.map +1 -1
  205. package/dist/contact/delivery.d.ts +60 -0
  206. package/dist/contact/delivery.d.ts.map +1 -0
  207. package/dist/contact/delivery.js +87 -0
  208. package/dist/contact/delivery.js.map +1 -0
  209. package/dist/contact/form.d.ts +130 -0
  210. package/dist/contact/form.d.ts.map +1 -0
  211. package/dist/contact/form.js +163 -0
  212. package/dist/contact/form.js.map +1 -0
  213. package/dist/contact/index.d.ts +17 -0
  214. package/dist/contact/index.d.ts.map +1 -0
  215. package/dist/contact/index.js +13 -0
  216. package/dist/contact/index.js.map +1 -0
  217. package/dist/contact/records.d.ts +124 -0
  218. package/dist/contact/records.d.ts.map +1 -0
  219. package/dist/contact/records.js +218 -0
  220. package/dist/contact/records.js.map +1 -0
  221. package/dist/contact/routes.d.ts +17 -0
  222. package/dist/contact/routes.d.ts.map +1 -0
  223. package/dist/contact/routes.js +127 -0
  224. package/dist/contact/routes.js.map +1 -0
  225. package/dist/contact/submission.d.ts +103 -0
  226. package/dist/contact/submission.d.ts.map +1 -0
  227. package/dist/contact/submission.js +166 -0
  228. package/dist/contact/submission.js.map +1 -0
  229. package/dist/content/document.d.ts +7 -2
  230. package/dist/content/document.d.ts.map +1 -1
  231. package/dist/content/document.js +1 -0
  232. package/dist/content/document.js.map +1 -1
  233. package/dist/content/index.d.ts +7 -2
  234. package/dist/content/index.d.ts.map +1 -1
  235. package/dist/content/index.js +4 -1
  236. package/dist/content/index.js.map +1 -1
  237. package/dist/content/markdown.d.ts.map +1 -1
  238. package/dist/content/markdown.js +24 -2
  239. package/dist/content/markdown.js.map +1 -1
  240. package/dist/content/parser.d.ts.map +1 -1
  241. package/dist/content/parser.js +11 -4
  242. package/dist/content/parser.js.map +1 -1
  243. package/dist/content/save.d.ts +5 -0
  244. package/dist/content/save.d.ts.map +1 -1
  245. package/dist/content/save.js +44 -1
  246. package/dist/content/save.js.map +1 -1
  247. package/dist/content/schedule.d.ts +125 -0
  248. package/dist/content/schedule.d.ts.map +1 -0
  249. package/dist/content/schedule.js +207 -0
  250. package/dist/content/schedule.js.map +1 -0
  251. package/dist/content/search.d.ts +64 -0
  252. package/dist/content/search.d.ts.map +1 -0
  253. package/dist/content/search.js +136 -0
  254. package/dist/content/search.js.map +1 -0
  255. package/dist/content/store.d.ts +202 -7
  256. package/dist/content/store.d.ts.map +1 -1
  257. package/dist/content/store.js +374 -67
  258. package/dist/content/store.js.map +1 -1
  259. package/dist/content/sync.d.ts +7 -1
  260. package/dist/content/sync.d.ts.map +1 -1
  261. package/dist/content/sync.js +18 -10
  262. package/dist/content/sync.js.map +1 -1
  263. package/dist/content/time.d.ts +56 -0
  264. package/dist/content/time.d.ts.map +1 -0
  265. package/dist/content/time.js +141 -0
  266. package/dist/content/time.js.map +1 -0
  267. package/dist/content/writer.d.ts.map +1 -1
  268. package/dist/content/writer.js +3 -0
  269. package/dist/content/writer.js.map +1 -1
  270. package/dist/env.d.ts +58 -4
  271. package/dist/env.d.ts.map +1 -1
  272. package/dist/federation/actor.d.ts +95 -41
  273. package/dist/federation/actor.d.ts.map +1 -1
  274. package/dist/federation/actor.js +180 -53
  275. package/dist/federation/actor.js.map +1 -1
  276. package/dist/federation/article.d.ts +44 -17
  277. package/dist/federation/article.d.ts.map +1 -1
  278. package/dist/federation/article.js +98 -46
  279. package/dist/federation/article.js.map +1 -1
  280. package/dist/federation/delivery.d.ts +63 -15
  281. package/dist/federation/delivery.d.ts.map +1 -1
  282. package/dist/federation/delivery.js +154 -80
  283. package/dist/federation/delivery.js.map +1 -1
  284. package/dist/federation/federation.d.ts +22 -17
  285. package/dist/federation/federation.d.ts.map +1 -1
  286. package/dist/federation/federation.js +98 -77
  287. package/dist/federation/federation.js.map +1 -1
  288. package/dist/federation/followers.d.ts +3 -2
  289. package/dist/federation/followers.d.ts.map +1 -1
  290. package/dist/federation/followers.js +6 -5
  291. package/dist/federation/followers.js.map +1 -1
  292. package/dist/federation/import-wordpress.d.ts +128 -0
  293. package/dist/federation/import-wordpress.d.ts.map +1 -0
  294. package/dist/federation/import-wordpress.js +426 -0
  295. package/dist/federation/import-wordpress.js.map +1 -0
  296. package/dist/federation/inbox.d.ts +36 -13
  297. package/dist/federation/inbox.d.ts.map +1 -1
  298. package/dist/federation/inbox.js +120 -49
  299. package/dist/federation/inbox.js.map +1 -1
  300. package/dist/federation/index.d.ts +22 -10
  301. package/dist/federation/index.d.ts.map +1 -1
  302. package/dist/federation/index.js +13 -8
  303. package/dist/federation/index.js.map +1 -1
  304. package/dist/federation/keys.d.ts +98 -20
  305. package/dist/federation/keys.d.ts.map +1 -1
  306. package/dist/federation/keys.js +269 -39
  307. package/dist/federation/keys.js.map +1 -1
  308. package/dist/federation/mount.d.ts +42 -10
  309. package/dist/federation/mount.d.ts.map +1 -1
  310. package/dist/federation/mount.js +266 -26
  311. package/dist/federation/mount.js.map +1 -1
  312. package/dist/federation/paths.d.ts +23 -35
  313. package/dist/federation/paths.d.ts.map +1 -1
  314. package/dist/federation/paths.js +26 -39
  315. package/dist/federation/paths.js.map +1 -1
  316. package/dist/federation/records.d.ts +223 -0
  317. package/dist/federation/records.d.ts.map +1 -0
  318. package/dist/federation/records.js +506 -0
  319. package/dist/federation/records.js.map +1 -0
  320. package/dist/federation/relays.d.ts +114 -0
  321. package/dist/federation/relays.d.ts.map +1 -0
  322. package/dist/federation/relays.js +290 -0
  323. package/dist/federation/relays.js.map +1 -0
  324. package/dist/federation/replies.d.ts +80 -0
  325. package/dist/federation/replies.d.ts.map +1 -0
  326. package/dist/federation/replies.js +166 -0
  327. package/dist/federation/replies.js.map +1 -0
  328. package/dist/federation/wordpress.d.ts +131 -0
  329. package/dist/federation/wordpress.d.ts.map +1 -0
  330. package/dist/federation/wordpress.js +341 -0
  331. package/dist/federation/wordpress.js.map +1 -0
  332. package/dist/files/atomic.d.ts +103 -0
  333. package/dist/files/atomic.d.ts.map +1 -0
  334. package/dist/files/atomic.js +150 -0
  335. package/dist/files/atomic.js.map +1 -0
  336. package/dist/files/index.d.ts +3 -0
  337. package/dist/files/index.d.ts.map +1 -0
  338. package/dist/files/index.js +2 -0
  339. package/dist/files/index.js.map +1 -0
  340. package/dist/forms/protection.d.ts +85 -0
  341. package/dist/forms/protection.d.ts.map +1 -0
  342. package/dist/forms/protection.js +116 -0
  343. package/dist/forms/protection.js.map +1 -0
  344. package/dist/images/icons.d.ts +59 -0
  345. package/dist/images/icons.d.ts.map +1 -0
  346. package/dist/images/icons.js +131 -0
  347. package/dist/images/icons.js.map +1 -0
  348. package/dist/images/index.d.ts +7 -0
  349. package/dist/images/index.d.ts.map +1 -0
  350. package/dist/images/index.js +4 -0
  351. package/dist/images/index.js.map +1 -0
  352. package/dist/images/markup.d.ts +47 -0
  353. package/dist/images/markup.d.ts.map +1 -0
  354. package/dist/images/markup.js +175 -0
  355. package/dist/images/markup.js.map +1 -0
  356. package/dist/images/paths.d.ts +34 -0
  357. package/dist/images/paths.d.ts.map +1 -0
  358. package/dist/images/paths.js +41 -0
  359. package/dist/images/paths.js.map +1 -0
  360. package/dist/images/variants.d.ts +104 -0
  361. package/dist/images/variants.d.ts.map +1 -0
  362. package/dist/images/variants.js +320 -0
  363. package/dist/images/variants.js.map +1 -0
  364. package/dist/index.d.ts +123 -19
  365. package/dist/index.d.ts.map +1 -1
  366. package/dist/index.js +327 -25
  367. package/dist/index.js.map +1 -1
  368. package/dist/init.d.ts +18 -0
  369. package/dist/init.d.ts.map +1 -1
  370. package/dist/init.js +34 -3
  371. package/dist/init.js.map +1 -1
  372. package/dist/mail/brevo.d.ts +35 -0
  373. package/dist/mail/brevo.d.ts.map +1 -0
  374. package/dist/mail/brevo.js +85 -0
  375. package/dist/mail/brevo.js.map +1 -0
  376. package/dist/mail/credentials.d.ts +62 -0
  377. package/dist/mail/credentials.d.ts.map +1 -0
  378. package/dist/mail/credentials.js +86 -0
  379. package/dist/mail/credentials.js.map +1 -0
  380. package/dist/mail/index.d.ts +15 -0
  381. package/dist/mail/index.d.ts.map +1 -0
  382. package/dist/mail/index.js +8 -0
  383. package/dist/mail/index.js.map +1 -0
  384. package/dist/mail/memory.d.ts +35 -0
  385. package/dist/mail/memory.d.ts.map +1 -0
  386. package/dist/mail/memory.js +29 -0
  387. package/dist/mail/memory.js.map +1 -0
  388. package/dist/mail/provider.d.ts +80 -0
  389. package/dist/mail/provider.d.ts.map +1 -0
  390. package/dist/mail/provider.js +23 -0
  391. package/dist/mail/provider.js.map +1 -0
  392. package/dist/mail/service.d.ts +156 -0
  393. package/dist/mail/service.d.ts.map +1 -0
  394. package/dist/mail/service.js +265 -0
  395. package/dist/mail/service.js.map +1 -0
  396. package/dist/mail/smtp.d.ts +39 -0
  397. package/dist/mail/smtp.d.ts.map +1 -0
  398. package/dist/mail/smtp.js +58 -0
  399. package/dist/mail/smtp.js.map +1 -0
  400. package/dist/mail/templates.d.ts +91 -0
  401. package/dist/mail/templates.d.ts.map +1 -0
  402. package/dist/mail/templates.js +69 -0
  403. package/dist/mail/templates.js.map +1 -0
  404. package/dist/notifications/comments.d.ts +66 -0
  405. package/dist/notifications/comments.d.ts.map +1 -0
  406. package/dist/notifications/comments.js +151 -0
  407. package/dist/notifications/comments.js.map +1 -0
  408. package/dist/notifications/digest.d.ts +135 -0
  409. package/dist/notifications/digest.d.ts.map +1 -0
  410. package/dist/notifications/digest.js +287 -0
  411. package/dist/notifications/digest.js.map +1 -0
  412. package/dist/notifications/index.d.ts +13 -0
  413. package/dist/notifications/index.d.ts.map +1 -0
  414. package/dist/notifications/index.js +8 -0
  415. package/dist/notifications/index.js.map +1 -0
  416. package/dist/notifications/links.d.ts +60 -0
  417. package/dist/notifications/links.d.ts.map +1 -0
  418. package/dist/notifications/links.js +63 -0
  419. package/dist/notifications/links.js.map +1 -0
  420. package/dist/notifications/optouts.d.ts +48 -0
  421. package/dist/notifications/optouts.d.ts.map +1 -0
  422. package/dist/notifications/optouts.js +93 -0
  423. package/dist/notifications/optouts.js.map +1 -0
  424. package/dist/notifications/preferences.d.ts +158 -0
  425. package/dist/notifications/preferences.d.ts.map +1 -0
  426. package/dist/notifications/preferences.js +195 -0
  427. package/dist/notifications/preferences.js.map +1 -0
  428. package/dist/notifications/routes.d.ts +28 -0
  429. package/dist/notifications/routes.d.ts.map +1 -0
  430. package/dist/notifications/routes.js +184 -0
  431. package/dist/notifications/routes.js.map +1 -0
  432. package/dist/notifications/tokens.d.ts +60 -0
  433. package/dist/notifications/tokens.d.ts.map +1 -0
  434. package/dist/notifications/tokens.js +125 -0
  435. package/dist/notifications/tokens.js.map +1 -0
  436. package/dist/notify.d.ts +84 -0
  437. package/dist/notify.d.ts.map +1 -0
  438. package/dist/notify.js +149 -0
  439. package/dist/notify.js.map +1 -0
  440. package/dist/web/archive.d.ts +49 -0
  441. package/dist/web/archive.d.ts.map +1 -0
  442. package/dist/web/archive.js +59 -0
  443. package/dist/web/archive.js.map +1 -0
  444. package/dist/web/assets.d.ts +6 -5
  445. package/dist/web/assets.d.ts.map +1 -1
  446. package/dist/web/assets.js +5 -7
  447. package/dist/web/assets.js.map +1 -1
  448. package/dist/web/authors.d.ts +143 -0
  449. package/dist/web/authors.d.ts.map +1 -0
  450. package/dist/web/authors.js +178 -0
  451. package/dist/web/authors.js.map +1 -0
  452. package/dist/web/comments.d.ts +60 -0
  453. package/dist/web/comments.d.ts.map +1 -0
  454. package/dist/web/comments.js +207 -0
  455. package/dist/web/comments.js.map +1 -0
  456. package/dist/web/context.d.ts +162 -28
  457. package/dist/web/context.d.ts.map +1 -1
  458. package/dist/web/context.js +123 -26
  459. package/dist/web/context.js.map +1 -1
  460. package/dist/web/conversation.d.ts +209 -0
  461. package/dist/web/conversation.d.ts.map +1 -0
  462. package/dist/web/conversation.js +557 -0
  463. package/dist/web/conversation.js.map +1 -0
  464. package/dist/web/documents.d.ts +36 -6
  465. package/dist/web/documents.d.ts.map +1 -1
  466. package/dist/web/documents.js +52 -10
  467. package/dist/web/documents.js.map +1 -1
  468. package/dist/web/feed-atom.d.ts +22 -0
  469. package/dist/web/feed-atom.d.ts.map +1 -0
  470. package/dist/web/feed-atom.js +83 -0
  471. package/dist/web/feed-atom.js.map +1 -0
  472. package/dist/web/feed-item.d.ts +145 -0
  473. package/dist/web/feed-item.d.ts.map +1 -0
  474. package/dist/web/feed-item.js +120 -0
  475. package/dist/web/feed-item.js.map +1 -0
  476. package/dist/web/feed-json.d.ts +79 -0
  477. package/dist/web/feed-json.d.ts.map +1 -0
  478. package/dist/web/feed-json.js +53 -0
  479. package/dist/web/feed-json.js.map +1 -0
  480. package/dist/web/feed-rss.d.ts +26 -0
  481. package/dist/web/feed-rss.d.ts.map +1 -0
  482. package/dist/web/feed-rss.js +203 -0
  483. package/dist/web/feed-rss.js.map +1 -0
  484. package/dist/web/feed-source.d.ts +244 -0
  485. package/dist/web/feed-source.d.ts.map +1 -0
  486. package/dist/web/feed-source.js +201 -0
  487. package/dist/web/feed-source.js.map +1 -0
  488. package/dist/web/feed-xml.d.ts +63 -0
  489. package/dist/web/feed-xml.d.ts.map +1 -0
  490. package/dist/web/feed-xml.js +116 -0
  491. package/dist/web/feed-xml.js.map +1 -0
  492. package/dist/web/feeds.d.ts +31 -96
  493. package/dist/web/feeds.d.ts.map +1 -1
  494. package/dist/web/feeds.js +94 -198
  495. package/dist/web/feeds.js.map +1 -1
  496. package/dist/web/health.d.ts +31 -0
  497. package/dist/web/health.d.ts.map +1 -0
  498. package/dist/web/health.js +44 -0
  499. package/dist/web/health.js.map +1 -0
  500. package/dist/web/index.d.ts +30 -10
  501. package/dist/web/index.d.ts.map +1 -1
  502. package/dist/web/index.js +18 -7
  503. package/dist/web/index.js.map +1 -1
  504. package/dist/web/navigation.d.ts +73 -0
  505. package/dist/web/navigation.d.ts.map +1 -0
  506. package/dist/web/navigation.js +110 -0
  507. package/dist/web/navigation.js.map +1 -0
  508. package/dist/web/negotiate.d.ts +9 -0
  509. package/dist/web/negotiate.d.ts.map +1 -1
  510. package/dist/web/negotiate.js +6 -1
  511. package/dist/web/negotiate.js.map +1 -1
  512. package/dist/web/recent.d.ts +43 -0
  513. package/dist/web/recent.d.ts.map +1 -0
  514. package/dist/web/recent.js +37 -0
  515. package/dist/web/recent.js.map +1 -0
  516. package/dist/web/render.d.ts +197 -9
  517. package/dist/web/render.d.ts.map +1 -1
  518. package/dist/web/render.js +334 -17
  519. package/dist/web/render.js.map +1 -1
  520. package/dist/web/routes.d.ts +25 -8
  521. package/dist/web/routes.d.ts.map +1 -1
  522. package/dist/web/routes.js +845 -93
  523. package/dist/web/routes.js.map +1 -1
  524. package/dist/web/sanitize.d.ts +19 -0
  525. package/dist/web/sanitize.d.ts.map +1 -0
  526. package/dist/web/sanitize.js +272 -0
  527. package/dist/web/sanitize.js.map +1 -0
  528. package/dist/web/search.d.ts +79 -0
  529. package/dist/web/search.d.ts.map +1 -0
  530. package/dist/web/search.js +94 -0
  531. package/dist/web/search.js.map +1 -0
  532. package/dist/web/sitemap.d.ts +120 -0
  533. package/dist/web/sitemap.d.ts.map +1 -0
  534. package/dist/web/sitemap.js +199 -0
  535. package/dist/web/sitemap.js.map +1 -0
  536. package/dist/web/taxonomy.d.ts +152 -0
  537. package/dist/web/taxonomy.d.ts.map +1 -0
  538. package/dist/web/taxonomy.js +237 -0
  539. package/dist/web/taxonomy.js.map +1 -0
  540. package/dist/web/templates.d.ts +54 -16
  541. package/dist/web/templates.d.ts.map +1 -1
  542. package/dist/web/templates.js +109 -28
  543. package/dist/web/templates.js.map +1 -1
  544. package/dist/web/themes.d.ts +205 -0
  545. package/dist/web/themes.d.ts.map +1 -0
  546. package/dist/web/themes.js +277 -0
  547. package/dist/web/themes.js.map +1 -0
  548. package/dist/webmention/discovery.d.ts +51 -0
  549. package/dist/webmention/discovery.d.ts.map +1 -0
  550. package/dist/webmention/discovery.js +176 -0
  551. package/dist/webmention/discovery.js.map +1 -0
  552. package/dist/webmention/html.d.ts +71 -0
  553. package/dist/webmention/html.d.ts.map +1 -0
  554. package/dist/webmention/html.js +285 -0
  555. package/dist/webmention/html.js.map +1 -0
  556. package/dist/webmention/index.d.ts +12 -0
  557. package/dist/webmention/index.d.ts.map +1 -0
  558. package/dist/webmention/index.js +8 -0
  559. package/dist/webmention/index.js.map +1 -0
  560. package/dist/webmention/links.d.ts +18 -0
  561. package/dist/webmention/links.d.ts.map +1 -0
  562. package/dist/webmention/links.js +64 -0
  563. package/dist/webmention/links.js.map +1 -0
  564. package/dist/webmention/microformats.d.ts +93 -0
  565. package/dist/webmention/microformats.d.ts.map +1 -0
  566. package/dist/webmention/microformats.js +376 -0
  567. package/dist/webmention/microformats.js.map +1 -0
  568. package/dist/webmention/receive.d.ts +141 -0
  569. package/dist/webmention/receive.d.ts.map +1 -0
  570. package/dist/webmention/receive.js +264 -0
  571. package/dist/webmention/receive.js.map +1 -0
  572. package/dist/webmention/routes.d.ts +37 -0
  573. package/dist/webmention/routes.d.ts.map +1 -0
  574. package/dist/webmention/routes.js +75 -0
  575. package/dist/webmention/routes.js.map +1 -0
  576. package/dist/webmention/service.d.ts +94 -0
  577. package/dist/webmention/service.d.ts.map +1 -0
  578. package/dist/webmention/service.js +207 -0
  579. package/dist/webmention/service.js.map +1 -0
  580. package/package.json +11 -6
  581. package/templates/site/content/_data/site.json +1 -0
  582. package/templates/site/content/posts/2026-01-01-hello-world.md +5 -3
  583. package/templates/site/geekity.config.ts +11 -1
  584. package/templates/site/tsconfig.json +1 -1
  585. package/themes/default/README.md +1096 -50
  586. package/themes/default/layouts/404.njk +2 -3
  587. package/themes/default/layouts/author.njk +37 -0
  588. package/themes/default/layouts/base.njk +172 -31
  589. package/themes/default/layouts/category.njk +27 -0
  590. package/themes/default/layouts/front-page.njk +57 -0
  591. package/themes/default/layouts/home.njk +17 -1
  592. package/themes/default/layouts/page.njk +57 -5
  593. package/themes/default/layouts/post.njk +78 -19
  594. package/themes/default/layouts/search.njk +57 -0
  595. package/themes/default/layouts/tag.njk +3 -6
  596. package/themes/default/mail/comment-digest.html.njk +37 -0
  597. package/themes/default/mail/comment-digest.subject.njk +3 -0
  598. package/themes/default/mail/comment-digest.txt.njk +40 -0
  599. package/themes/default/mail/comment-pending.html.njk +27 -0
  600. package/themes/default/mail/comment-pending.subject.njk +2 -0
  601. package/themes/default/mail/comment-pending.txt.njk +24 -0
  602. package/themes/default/mail/comment-reply.html.njk +21 -0
  603. package/themes/default/mail/comment-reply.subject.njk +2 -0
  604. package/themes/default/mail/comment-reply.txt.njk +22 -0
  605. package/themes/default/mail/contact-message.html.njk +24 -0
  606. package/themes/default/mail/contact-message.subject.njk +5 -0
  607. package/themes/default/mail/contact-message.txt.njk +20 -0
  608. package/themes/default/mail/password-changed.html.njk +18 -0
  609. package/themes/default/mail/password-changed.subject.njk +2 -0
  610. package/themes/default/mail/password-changed.txt.njk +17 -0
  611. package/themes/default/mail/password-reset.html.njk +22 -0
  612. package/themes/default/mail/password-reset.subject.njk +2 -0
  613. package/themes/default/mail/password-reset.txt.njk +18 -0
  614. package/themes/default/mail/test.html.njk +14 -0
  615. package/themes/default/mail/test.subject.njk +2 -0
  616. package/themes/default/mail/test.txt.njk +12 -0
  617. package/themes/default/partials/archive.njk +27 -0
  618. package/themes/default/partials/bio.njk +77 -0
  619. package/themes/default/partials/comment-form.njk +109 -0
  620. package/themes/default/partials/contact-form.njk +88 -0
  621. package/themes/default/partials/conversation.njk +139 -0
  622. package/themes/default/partials/feeds.njk +26 -0
  623. package/themes/default/partials/jsonld.njk +120 -0
  624. package/themes/default/partials/pagination.njk +12 -6
  625. package/themes/default/partials/post-list.njk +51 -18
  626. package/themes/default/partials/search-form.njk +17 -0
  627. package/themes/default/partials/tags.njk +26 -5
  628. package/themes/default/static/highlight.js +4 -0
  629. package/themes/default/static/style.css +1119 -194
  630. package/themes/default/theme.json +5 -0
  631. package/admin/layouts/document-editor.njk +0 -167
  632. package/admin/layouts/federation.njk +0 -145
  633. package/admin/layouts/login.njk +0 -23
  634. package/admin/layouts/settings.njk +0 -116
  635. package/admin/layouts/setup.njk +0 -27
  636. package/admin/layouts/users.njk +0 -91
  637. /package/admin/{layouts/_flash.njk → components/flash.njk} +0 -0
package/README.md CHANGED
@@ -33,13 +33,20 @@ tsconfig.json so the site type checks against the package
33
33
  content/
34
34
  posts/ Markdown posts, plus posts.json for Eleventy
35
35
  pages/ Markdown pages, plus pages.json
36
- _data/site.json title, tagline, author, page and feed sizes
36
+ _data/site.json the settings: title, tagline, author, page and feed sizes
37
+ _data/federation/ once the site federates: followers.json and the inbox log
38
+ _data/comments/ once somebody comments: one JSON file per post
37
39
  .gitignore node_modules, data and .env
38
40
  ```
39
41
 
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.
42
+ `data/` and `themes/` are not written. The index under `data/` is created on
43
+ first boot and is safe to delete; `themes/` is optional and only exists once
44
+ you write a theme of your own — one folder, `themes/<name>/`, with a
45
+ `theme.json` in it, chosen on **Appearance > Themes** in the admin. Until then
46
+ every page comes from the theme inside the package. [Theme
47
+ overrides](#theme-overrides) has the whole of it, and the comment beside
48
+ `themesDir` in the generated `geekity.config.ts` says it where you will see
49
+ it.
43
50
 
44
51
  The generated `package.json` pins `@geekity/cms` to the version of the CLI that
45
52
  wrote it, so a site is never scaffolded against a version it has not been tested
@@ -72,18 +79,21 @@ changelog.
72
79
  site (or `npx geekity`, or a `package.json` script, which is how the generated
73
80
  `sync` script calls it).
74
81
 
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.
82
+ | Command | What it does |
83
+ | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
84
+ | `geekity serve` | Boot from the config file and listen. The default when no command is given. |
85
+ | `geekity init <dir>` | Create a new site in `<dir>`. Refuses a directory that is not empty. |
86
+ | `geekity sync` | Rebuild the content index once and exit. Exits non-zero if any file could not be parsed. |
87
+ | `geekity rebuild` | Delete `data/geekity.db` and build it again from the files. |
88
+ | `geekity user add <name>` | Create an admin account, so a site can get its first login without the setup screen. |
89
+ | `geekity import wordpress-actor <name>` | Bring one person across from the WordPress ActivityPub plugin: their key pair, the actor id their followers hold, the plugin's numeric actor id, and their followers. |
90
+ | `geekity --help`, `-h` | The same table, on the terminal. |
91
+ | `geekity --version` | The installed version. |
92
+
93
+ `serve`, `sync`, `rebuild`, `user add` and `import wordpress-actor` take
94
+ `--config <file>`; without it they look for `geekity.config.ts`, then
95
+ `geekity.config.js`, then `geekity.config.mjs` in the working directory, and run
96
+ on defaults if there is none.
87
97
 
88
98
  `sync` prints what the scan did and forces watching off whatever the config
89
99
  says, because a one-shot scan that then sat in a watcher would never exit:
@@ -97,13 +107,39 @@ A file that will not parse is logged, left out of the index and counted in
97
107
  `failed`; the command then exits `1` so a deploy step notices. Everything else
98
108
  in the directory is still indexed.
99
109
 
110
+ ### Starting the database again
111
+
112
+ `geekity rebuild` deletes `data/geekity.db` (with its `-wal` and `-shm`) and
113
+ builds a new one exactly as a boot does — the same migrations, the same scan of
114
+ `content/`, the same read of `content/_data/federation/` — then says what it
115
+ indexed:
116
+
117
+ ```sh
118
+ $ geekity rebuild
119
+ Rebuilt /srv/blog/data/geekity.db from the files.
120
+ Scanned 214: 214 created, 0 updated, 0 removed, 0 unchanged, 0 failed
121
+ Indexed 37 followers and 1,204 inbox activities.
122
+ ```
123
+
124
+ Nothing in the database is anything but a reading of `content/` and `data/`, so
125
+ this is a command with no undo and nothing to lose; [what a rebuild does cost
126
+ is listed below](#two-directories-content-and-data). It is also the way past a
127
+ database this version refuses to open — a damaged one, or one written by a newer
128
+ `@geekity/cms`.
129
+
130
+ It refuses while the site is running, because deleting the file under a live
131
+ server would leave it writing to a database nothing can find. Stop the site
132
+ first. A file that will not parse is reported and exits `1`, as with `sync`.
133
+
100
134
  ### Creating an admin from the command line
101
135
 
102
- `geekity user add <username>` writes a user straight into the site's database,
136
+ `geekity user add <username>` writes a user straight into `data/users.json`,
103
137
  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.
138
+ headless deploy, a server behind a bastion — gets its first login. It is also
139
+ the way back into a site that has locked everybody out and cannot send email.
140
+ It enforces exactly the rules the setup form does: a username of 1 to 64
141
+ letters, digits, dots, dashes or underscores, and a password of at least 8
142
+ characters.
107
143
 
108
144
  ```sh
109
145
  $ geekity user add ada
@@ -123,12 +159,22 @@ printf '%s\n' "$ADMIN_PASSWORD" | geekity user add ada
123
159
  three: a password on the command line is visible in the process list and lands
124
160
  in shell history.
125
161
 
162
+ `--email <address>` puts an address on the new user, which is optional and is
163
+ what [password recovery](#forgotten-passwords) works through:
164
+
165
+ ```sh
166
+ $ geekity user add ada --email ada@example.com
167
+ Password for ada:
168
+ Created admin user ada (ada@example.com). Sign in at /admin/login.
169
+ ```
170
+
126
171
  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`.
172
+ illegal username, a short password and an address that is not one. Every
173
+ refusal prints why and exits `1`.
128
174
 
129
175
  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.
176
+ easier door: it adds users, generates passwords and deletes them, and each
177
+ user's own screen under it is where the rest of an account is edited.
132
178
 
133
179
  ### How a TypeScript config is loaded
134
180
 
@@ -189,39 +235,157 @@ export default defineConfig({
189
235
  port: 3000,
190
236
  contentDir: 'content',
191
237
  dataDir: 'data',
192
- themeDir: 'theme',
238
+ themesDir: 'themes',
193
239
  baseUrl: 'http://localhost:3000',
194
240
  watch: true,
195
241
  sessionLifetime: 60 * 60 * 24 * 14,
242
+ loginAttempts: 5,
243
+ loginLockout: 15 * 60,
244
+ trustProxy: false,
196
245
  });
197
246
  ```
198
247
 
199
248
  Every field is optional. Relative directories resolve against the working
200
249
  directory; absolute ones are used as given.
201
250
 
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). |
251
+ | Field | Default | Environment override | Meaning |
252
+ | ------------------ | ------------------------- | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
253
+ | `port` | `3000` | `GEEKITY_PORT`, then `PORT` | Port the HTTP server listens on. `0` picks a free one. |
254
+ | `contentDir` | `<cwd>/content` | `GEEKITY_CONTENT_DIR` | Markdown content. |
255
+ | `dataDir` | `<cwd>/data` | `GEEKITY_DATA_DIR` | Derived state — the SQLite index, the image variants — and the two things in it that are not derived and must be backed up: `users.json` and, under `keys/`, each user's actor key pairs. |
256
+ | `themesDir` | `<cwd>/themes` | `GEEKITY_THEMES_DIR` | The site's themes, one directory per theme, each with a `theme.json`. Which one is in use is the `theme` setting in `site.json`, not a path. Need not exist. |
257
+ | `baseUrl` | `http://localhost:<port>` | `GEEKITY_BASE_URL` | Public origin for canonical URLs, feeds and ActivityPub ids. A trailing slash is stripped. |
258
+ | `watch` | `true` | `GEEKITY_WATCH` | Watch `contentDir` while serving and keep the index in step. |
259
+ | `sessionLifetime` | `1209600` (14 days) | `GEEKITY_SESSION_LIFETIME` | How long an admin login lasts, in seconds. |
260
+ | `loginAttempts` | `5` | `GEEKITY_LOGIN_ATTEMPTS` | Failed sign-ins a username or an address may make before it is locked out. |
261
+ | `loginLockout` | `900` (15 minutes) | `GEEKITY_LOGIN_LOCKOUT` | How long the first lockout lasts, in seconds. See [Login hardening](#login-hardening). |
262
+ | `trustProxy` | `false` | `GEEKITY_TRUST_PROXY` | Believe `X-Forwarded-For` when deciding which address a sign-in came from. |
263
+ | `onDocumentChange` | none | — | Hook run for every change to the index. See [Hooks](#hooks). |
264
+ | `onPublish` | none | — | Hook run when a document becomes visible. See [Hooks](#hooks). |
265
+ | `federation` | `{}` | — | Federation stores and guards. See [Federation](#federation). |
266
+ | `commentChecker` | Akismet | — | A spam checker of the site's own, which wins over the key in `data/akismet.json`. See [Akismet](#akismet). |
267
+ | `mail` | `{}` | — | Mail provider, retries, backoff and logger. See [Email](#email). |
214
268
 
215
269
  Precedence is environment variable, then config file, then default, so a host
216
270
  can override anything without editing the site. A boolean environment variable
217
271
  takes `true`, `1`, `yes` and `on`, or their opposites; anything else is an error
218
272
  rather than a silent `false`.
219
273
 
274
+ ## Two directories: `content/` and `data/`
275
+
276
+ Everything a site cannot afford to lose is a file, and every one of those files
277
+ is in one of two directories. Nothing else needs backing up, and nothing else
278
+ needs carrying across a deploy.
279
+
280
+ `content/` is what the site publishes. It belongs in git, an Eleventy build of
281
+ the same directory reads all of it, and everything in it is meant to be public:
282
+
283
+ | Path | What it holds |
284
+ | -------------------------------------------------- | ------------------------------------------------------------------------ |
285
+ | `content/posts/`, `content/pages/` | The Markdown documents, `_trash/` included. |
286
+ | `content/uploads/` | Uploaded files exactly as they arrived. |
287
+ | `content/_data/site.json` | Every site setting. |
288
+ | `content/_data/federation/followers.json` | Who follows the site. |
289
+ | `content/_data/federation/inbox/{yyyy}-{mm}.jsonl` | Every activity the inbox was handed, one per line. |
290
+ | `content/_data/comments/{slug}.json` | The comments left on that post, whatever a moderator has done with them. |
291
+
292
+ `data/` is private. It is gitignored, and it is the half to copy somewhere safe:
293
+
294
+ | Path | What it holds |
295
+ | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
296
+ | `data/users.json` | Usernames and argon2id password hashes. Mode `0600`. |
297
+ | `data/keys/` | Each user's actor key pairs as JWK files. Mode `0600`. **Losing these breaks federation.** |
298
+ | `data/comment-salt` | What hides commenters' addresses in the published comment files. Mode `0600`. |
299
+ | `data/akismet.json` | The Akismet key, and what `verify-key` last said about it. Mode `0600`. |
300
+ | `data/mail.json` | The mail credential: a Brevo API key, an SMTP connection, or both. Mode `0600`. |
301
+ | `data/notification-secret` | What signs the one-click links in a notification. Mode `0600`. Losing it kills every link already in an inbox and nothing else. |
302
+ | `data/comment-optouts.json` | The addresses that have unsubscribed from reply notices. Mode `0600`. |
303
+ | `data/notification-digests.json` | When each user was last sent a digest. Mode `0600`. Losing it sends one digest early and nothing worse. |
304
+ | `data/wordpress-activitypub.json` | When each WordPress compatibility path was last asked for. Losing it resets the answer the switch is watched by. |
305
+
306
+ Two things under `data/` may be deleted whenever the site is stopped, and
307
+ nothing else in either directory may:
308
+
309
+ | Path | What it is |
310
+ | ----------------- | ---------------------------------------------------------------------------------------- |
311
+ | `data/geekity.db` | The SQLite cache, with its `-wal` and `-shm`. `geekity rebuild` deletes and rebuilds it. |
312
+ | `data/images/` | Variants derived from `content/uploads/`, with their `image.json` sidecars. `rm -r` it. |
313
+
314
+ The next boot builds the database out of the files with no manual step, and a
315
+ request for a variant that is not there derives it and serves it.
316
+
317
+ ### What is in the database, and what a rebuild costs
318
+
319
+ No table holds anything that is not either read back from the files or
320
+ something a site is told it may lose:
321
+
322
+ | Table | Where it comes back from |
323
+ | --------------------------------------------------- | ---------------------------------------------------------------------------- |
324
+ | `documents`, `document_tags`, `document_categories` | The boot scan of `content/`. |
325
+ | `followers`, `ap_inbox` | `content/_data/federation/`, emptied and read back on every boot. |
326
+ | `comments` | `content/_data/comments/`, emptied and read back on every boot. |
327
+ | `sessions` | Nothing. Everybody signed in is signed out. |
328
+ | `ap_deliveries` | Nothing. The federation screen shows its posts with "Nothing recorded." |
329
+ | `ap_relays` | The relay list in `site.json`: boot sends each of them a fresh `Follow`. |
330
+ | `password_resets`, `spent_tokens` | Nothing. Every reset link and every one-click moderation link stops working. |
331
+ | `cms_state` | Nothing. One key, the scheduler's watermark. |
332
+ | `migrations`, `admin_migrations` | The package. They record which schema versions have run. |
333
+
334
+ So four things are actually lost:
335
+
336
+ - **Logins.** Everybody signed in has to sign in again. The accounts themselves
337
+ are in `data/users.json` and are untouched.
338
+ - **Whether a one-click link had been used.** The links themselves are signed
339
+ rather than stored, so they go on working; a rebuild only forgets which of
340
+ them had already been spent. Every action they perform is idempotent, so the
341
+ worst that costs is a moderation link that works a second time. A password
342
+ reset link, which _is_ stored, stops working altogether.
343
+ - **Relay handshakes.** A relay named in the settings that the database has
344
+ never heard of is followed on boot, so a rebuild sends every listed relay a
345
+ new `Follow` and the reason one gave for rejecting the last is gone.
346
+ - **A scheduled post that came due while the site was down.** The scheduler
347
+ treats an absent watermark as "start from here", so that a rebuilt database
348
+ cannot re-announce the archive to every follower. Such a post is public on the
349
+ next boot but no `Create` is delivered for it; resend it from
350
+ `/admin/federation` if it should have gone out.
351
+
352
+ The one thing the file-first design gives up is narrower than any of those: a
353
+ post whose **file is gone entirely** can no longer be withdrawn from followers'
354
+ timelines, because the permalink a `Delete` names was in the file. Trashing a
355
+ post in the admin keeps the file under `_trash/`, so the ordinary way of
356
+ unpublishing still sends the `Delete`.
357
+
358
+ ### A database this version will not open
359
+
360
+ Two cases refuse the boot rather than being cleaned up behind your back, and
361
+ both name the file and say what to do about it:
362
+
363
+ - **Written by a newer `@geekity/cms`**, meaning its migration ledger records a
364
+ schema version this package does not ship. Upgrading the package back is
365
+ usually what was meant; `geekity rebuild` throws it away if it was not.
366
+ - **Damaged**, meaning SQLite will not open it. `geekity rebuild` is the fix.
367
+
368
+ A database _older_ than the oldest migration the package still ships is the one
369
+ case thrown away and rebuilt without asking: there is by definition no path
370
+ forward from it, and nothing in it is anything but a reading of the files.
371
+
220
372
  ## Federation
221
373
 
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:
374
+ **Every user is an ActivityPub actor**, served by [Fedify] at their author URL:
375
+ `{baseUrl}/author/{username}/` is the archive in a browser and the `Person` to
376
+ a peer asking for `application/activity+json`, exactly as a post's permalink is
377
+ its page and its object. The collections are that URL's children —
378
+ `inbox/`, `outbox/`, `followers/` and `following/` — and the instance-wide
379
+ shared inbox is `/inbox/`. `author` and `inbox` are reserved top-level paths
380
+ for that reason: an actor id a settings field could move would be a different
381
+ account to everybody holding it.
382
+
383
+ There is no site actor. A post is announced by the actor of the user its
384
+ `author` names, its `outbox` is that user's archive as activities, and its
385
+ followers are that user's own.
386
+
387
+ Fedify's stores have defaults that suit a single process, and `federation` is
388
+ where a site that has outgrown them says so:
225
389
 
226
390
  | Field | Default | Meaning |
227
391
  | --------------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -229,9 +393,46 @@ outgrown them says so:
229
393
  | `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
394
  | `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
395
 
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.
396
+ Followers and the log of what the inbox was told are the CMS's own, and they
397
+ are files the site publishes (decision-9):
398
+ `content/_data/federation/{username}/followers.json` holds one object per
399
+ follower of that user — actor id, inbox, shared inbox, handle, name, icon,
400
+ profile URL and follow time, oldest follow first — and
401
+ `content/_data/federation/inbox/{yyyy}-{mm}.jsonl` holds one compact JSON-LD
402
+ activity per line, with the `receivedAt` the log stamped it with and the
403
+ `recipient` it was addressed to in front of it. The log stays one chronological
404
+ record because it is a record of what this server was told; each line says
405
+ whose it was. Both are in git beside the posts, and an Eleventy build of the
406
+ same content directory sees them as `federation.{username}.followers` and
407
+ `federation.inbox`. A `Follow` appends to the first, an `Undo(Follow)` or an
408
+ actor's own `Delete` takes the entry out, and a like, a boost or a reply
409
+ appends a line to the second; each write updates the SQLite index in the same
410
+ step, behind a lock on the file, so the two can never disagree. The `followers` and `ap_inbox` tables are that
411
+ index and nothing more: every boot empties them and reads them back from the
412
+ files, which is what makes editing `followers.json` by hand and restarting a
413
+ supported thing to do, and what makes deleting the database cost a site
414
+ nothing. `cms.admin.replaceFollowers` and `replaceInboxActivities` are what the
415
+ rebuild calls; `rebuildFederationIndexes({ admin, contentDir })` is the rebuild
416
+ itself. The delivery outcomes stay in SQLite as a cache of what happened; the
417
+ activities themselves are not stored at all, because they are rebuilt from the
418
+ posts' files whenever they are wanted again.
419
+
420
+ Each user's key pairs are the CMS's own too, but they are the one
421
+ thing a site can never regenerate, so they live in files: one JWK per algorithm
422
+ per user under `dataDir/keys` (`ada.rsassa-pkcs1-v1_5.jwk` and
423
+ `ada.ed25519.jwk`), written `0600` in a `0700` directory, each holding the
424
+ private key alone because the public half is derived from it. They are
425
+ generated on the first request that needs them and read back on every one after
426
+ that. The actor publishes the RSA key as `publicKey` at `{actor}#main-key`,
427
+ which is what a peer verifies an HTTP Signature with, and both as
428
+ `assertionMethod` multikeys at `{actor}#multikey-0` and `#multikey-1`.
429
+
430
+ A key file that is there and will not import stops the boot, with a message
431
+ naming it. That is deliberate: replacing it would give the site a new identity
432
+ and every follower's cached public key would stop verifying, and Fedify's own
433
+ answer — an actor document with no `publicKey` at all — is worse still, because
434
+ peers cache it. Restore the file from a backup, or delete it to ask for a new
435
+ key on purpose.
235
436
 
236
437
  ### Delivery
237
438
 
@@ -249,60 +450,317 @@ Nothing is delivered for a full scan, the boot scan included: a rebuilt index
249
450
  reports the whole archive as new, and announcing it again is not what deleting
250
451
  `data/geekity.db` should mean.
251
452
 
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.
453
+ A post's ActivityStreams object id is its permalink, absolute on the site's
454
+ base URL. One URL answers both audiences — a browser gets the page, a peer
455
+ asking for `application/activity+json` gets the `Article` — so a shared link, a
456
+ feed item, an object id and a reply's `inReplyTo` all name the same thing.
457
+ Nothing else has to be minted or kept in step.
458
+
459
+ The first activity about a post writes one key, `activitypub.published`, into
460
+ its front matter: the record that the post has been announced and when, which
461
+ is what decides `Create` against `Update` and what a resend reads. Restoring a
462
+ trashed post reuses it too.
463
+
464
+ Because that URL is a promise, the editor keeps it: **a published post's slug
465
+ and permalink cannot be changed**. Both fields still move freely on a draft,
466
+ and a site that really means to move a published post can edit the file,
467
+ knowing that its followers will be handed a second object.
468
+
469
+ A post whose file already names an `activitypub.id` keeps it as its object id
470
+ for the life of the post — that is how a post migrated from WordPress keeps the
471
+ `https://example.com/?p=813` its followers, its replies and its RSS subscribers
472
+ already hold. The CMS serves the `Article` at that URL on an ActivityStreams
473
+ request, redirects a browser from it to the permalink, and names it in every
474
+ `Update` and `Delete`. The CMS never writes one itself.
257
475
 
258
476
  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:
477
+ outcome is recorded per recipient, so an admin can see which one did not get it
478
+ and send the post again:
261
479
 
262
480
  ```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.
481
+ const report = await cms.delivery.resend(slug);
482
+ report?.deliveries; // one row per follower and relay: inbox, status, error, time
483
+ ```
484
+
485
+ `resend` is "send this post as it now reads", not "send that activity again":
486
+ the activity is built from the file at the moment it is called, so a follower
487
+ whose server was down ends up with the post as it stands. Which activity that
488
+ is follows the same table as a save — a published post nobody has been told
489
+ about is a `Create` and is stamped, a published post they hold is an `Update`
490
+ under a fresh id, a draft or a trashed one is a `Delete` of a `Tombstone` — and
491
+ `undefined` comes back when there is no post by that name and nothing to
492
+ withdraw.
493
+
494
+ Nothing else is stored. What SQLite keeps about anything the site has sent is
495
+ one outcome row per recipient — the activity's id, type, object id and slug,
496
+ the inbox used, how it went, why not, and when — and that is a cache which may
497
+ be empty. `cms.admin.lastDeliveryToObject(objectId)` is the newest outcome
498
+ about one post, and `listDeliveries(activityId)` and
499
+ `countDeliveriesByStatus(activityId)` say how one activity landed with each
500
+ recipient. A status is `sent` (the inbox took it), `queued` (handed to Fedify's
501
+ queue, which retries out of band) or `failed`.
502
+
503
+ The one capability given up is tombstoning a post whose file is gone entirely
504
+ rather than in the trash: there is no file left to build the `Tombstone` from.
505
+
506
+ ### Relays
507
+
508
+ A [Mastodon-style relay][fepae0c] boosts every public activity it is sent on to
509
+ the instances subscribed to it, which is how a site nobody follows yet reaches
510
+ people. `relays` is the setting: one relay inbox per line on
511
+ `/admin/settings/federation`, kept in `site.json` like every other setting, and
512
+ `https://tags.pub/user/_____relay_____/inbox` is one worth knowing about — it
513
+ boosts any public post carrying a hashtag it tracks, which every `Article` this
514
+ CMS builds already carries one of per tag and per category.
515
+
516
+ Adding a line sends that inbox a `Follow` whose object is the ActivityStreams
517
+ Public collection, signed by the site's first account — a relay subscription is
518
+ an instance-wide agreement rather than one person's, and the first account is
519
+ the one actor that can be chosen without asking — and carrying the Linked Data
520
+ signature a Mastodon-style relay verifies. The relay answers `Accept` or
521
+ `Reject` — possibly days later, because a subscription may need a human to
522
+ approve it — and that answer arrives in the site's inbox and moves the
523
+ subscription. Removing the line sends `Undo` of the same `Follow`.
524
+
525
+ Only an accepted relay is delivered to. From there it is one more inbox in the
526
+ fan-out: every `Create`, `Update` and `Delete` for a post, whoever wrote it,
527
+ and the actor `Update` a profile change sends, goes to it as well as to the
528
+ author's followers, its
529
+ outcome recorded in the delivery log against its actor id and its inbox exactly
530
+ as a follower's is — which is why Resend reaches relays too.
531
+
532
+ ```ts
533
+ cms.admin.listRelays(); // inbox, actor, state, reason, follow id, times
534
+ cms.relays.sync(); // reconcile the records with the setting
535
+ await cms.relays.retry(inboxId); // send the follow again
536
+ ```
537
+
538
+ The records live in `ap_relays`, keyed by inbox, and are operational state
539
+ rather than a source: the setting is. A relay the settings name that has no
540
+ record — a rebuilt database, a restored content directory — is followed again
541
+ on the next boot.
542
+
543
+ [fepae0c]: https://w3id.org/fep/ae0c
544
+
545
+ ### A user's profile
546
+
547
+ An actor's `name`, `summary`, `icon` and `attachment` are the display name, the
548
+ bio, the avatar and the links on their user record, edited on
549
+ `/admin/users/<id>` and stored in `data/users.json` — the same profile the
550
+ author archive is headed with, so a page and an actor cannot say different
551
+ things about somebody. Their `preferredUsername` is their login, and
552
+ `alsoKnownAs` lists every URL they answer to: the actor id, the archive and
553
+ `/@{username}`. The profile holds two more fields the actor does not carry — a
554
+ job title and a location — which are there for a theme to print beside the
555
+ name.
556
+
557
+ The avatar is a path like `/uploads/2026/09/me.png`, stored with the site's
558
+ other uploads. The actor carries it as an absolute URL resolved against the
559
+ base URL in effect, because a peer has no site to resolve a path against; one
560
+ that is already an absolute URL is left alone, which is how a profile picture
561
+ goes on a CDN.
562
+
563
+ Saving a profile delivers an `Update` of that user's actor to their followers,
564
+ so the profile a peer cached is refreshed rather than left showing last year's
565
+ picture. Nothing on the settings screen does: the site is not an actor.
566
+ `cms.delivery.updateActor(user)` sends one from code. Its outcome is recorded
567
+ like every other activity's, with the actor's id as its object and no slug, so
568
+ it is not a row in the federation screen's per-post delivery table, which is
569
+ built from the posts the content index holds.
570
+
571
+ ### WebFinger
572
+
573
+ `/.well-known/webfinger` is the CMS's own route rather than Fedify's, because
574
+ Fedify computes its `self` link and its `aliases` from the dispatcher path and
575
+ neither can be added to. It answers for every spelling of the same person — the
576
+ `acct:{username}@{host}` handle, the bare `{username}@{host}`, the author URL,
577
+ `/@{username}` and the stored actor id of somebody who has one — with the actor
578
+ id as `self`, the archive as `profile-page`, and all of the person's URLs as
579
+ `aliases`. A username nobody has is a 404. `/@{username}` itself is a 301 to
580
+ the archive.
581
+
582
+ ### A user's stored actor id
583
+
584
+ A user record in `data/users.json` may carry an `actorId`: the ActivityStreams
585
+ id that person was published under somewhere else, such as the
586
+ `https://example.com/?author=2` the WordPress ActivityPub plugin publishes.
587
+ That is identity, not cache — a follower's server keys the account by the URL
588
+ it first saw — so the CMS serves the person under it for the life of the
589
+ account, the way a post keeps its `activitypub.id`:
590
+
591
+ ```json
592
+ { "id": 2, "username": "ada", "actorId": "https://example.com/?author=2" }
593
+ ```
594
+
595
+ The actor document's `id` is then that URL and its keys hang off it
596
+ (`…?author=2#main-key`), every activity the user sends names it as `actor` and
597
+ is signed with a key id under it, and the URL itself — query string and all —
598
+ answers with the `Person` for a peer and a 301 to the author archive for a
599
+ browser. WebFinger publishes it as `self` and resolves a lookup by it, and the
600
+ actor lists it in `alsoKnownAs` beside the archive and `/@{username}`. Nothing
601
+ else moves: `url` is still the archive and the collections are still the
602
+ archive's children, because a peer refetches those.
603
+
604
+ The CMS never mints one and no screen writes one. It arrives with the WordPress
605
+ import, or is typed into the file by hand; `/admin/users/<id>` shows it
606
+ read-only beside the account, and a value that is not an absolute URL is
607
+ ignored.
608
+
609
+ ### WordPress ActivityPub compatibility
610
+
611
+ A site that moved here from the WordPress ActivityPub plugin has followers
612
+ whose servers still hold the plugin's endpoints — `/wp-json/activitypub/1.0/actors/2/inbox`,
613
+ the shared `/wp-json/activitypub/1.0/inbox`, and the collections beside them.
614
+ Those are cache rather than identity: a follower's server replaces them the
615
+ next time it refetches the actor. So the CMS can carry them for a while, behind
616
+ a switch, and is meant to stop.
617
+
618
+ Turn **WordPress ActivityPub compatibility** on under
619
+ `/admin/settings/federation`. It is off by default and `site.json` says nothing
620
+ about it until it is on. It needs one thing on the user record in
621
+ `data/users.json` besides the stored actor id above: the number WordPress gave
622
+ that person, which is what its paths are built from.
623
+
624
+ ```json
625
+ {
626
+ "id": 2,
627
+ "username": "ada",
628
+ "actorId": "https://example.com/?author=2",
629
+ "wordpressActorId": 2
630
+ }
631
+ ```
632
+
633
+ `geekity import wordpress-actor` writes both; no screen does. With the switch
634
+ on, those paths are real inbox routes, signature-verified exactly as the
635
+ canonical ones are — an unsigned or badly signed delivery is refused — plus GET
636
+ routes for the actor, its outbox, its followers and its following. What a peer
637
+ reads back is always the canonical document: the same `id`, the same key, and
638
+ the _canonical_ inbox and collections, so a follower refetching the actor at
639
+ the old URL is the follower that learns the new endpoints. A user with no
640
+ `wordpressActorId` is not reachable through any of it.
641
+
642
+ Every one of those paths records the instant it was last asked for, per user,
643
+ in `data/wordpress-activitypub.json` — a file, so a deleted database does not
644
+ forget it — and the settings page lists each path beside the switch with that
645
+ instant, or _Never_. That is what the switch is watched by: once every
646
+ follower's server has refetched the actor, nothing asks any more, and the
647
+ switch can go off. Clearing it takes the paths away on the very next request,
648
+ with no restart.
649
+
650
+ ### Moving a site off the WordPress ActivityPub plugin
651
+
652
+ `geekity import wordpress-actor` brings one person across. It writes the three
653
+ things the CMS cannot mint for itself — the RSA key pair the followers have
654
+ cached, the actor id they key the account by, and the followers — and it is a
655
+ command rather than a screen because a stored actor id is identity for the life
656
+ of the account.
657
+
658
+ ```sh
659
+ geekity import wordpress-actor ada \
660
+ --actor-id 'https://example.com/?author=2' \
661
+ --wordpress-id 2 \
662
+ --keypair ada.keypair.json
663
+ ```
664
+
665
+ It writes `data/keys/ada.rsassa-pkcs1-v1_5.jwk` (and mints the Ed25519 pair
666
+ beside it, which WordPress never had), puts `actorId` and `wordpressActorId` on
667
+ the user in `data/users.json`, and fetches the plugin's public followers
668
+ collection into `content/_data/federation/ada/followers.json`, dereferencing
669
+ each follower for its inbox, shared inbox, handle, name, avatar and profile URL.
670
+
671
+ | Option | What it is |
672
+ | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
673
+ | `--actor-id <url>` | The id WordPress published, query string and all. The one thing the import cannot work out for itself. |
674
+ | `--wordpress-id <n>` | The WordPress user id, which is the number in the plugin's paths. |
675
+ | `--keypair <file>` | The JSON the plugin's option holds: `{"private_key": …, "public_key": …}`. |
676
+ | `--private-key <file>`, `--public-key <file>` | The two PEMs instead, for a site still on the legacy user meta. The public key is only checked against the private half; it is derived, never stored. |
677
+ | `--followers <url\|file\|none>` | Where the followers come from. Left off, the plugin's own collection on the actor id's origin. |
678
+ | `--force` | Import over a key pair the user already has. |
679
+
680
+ **Exporting the key pair with wp-cli.** The plugin keeps a user's pair in a
681
+ WordPress option named after their login:
682
+
683
+ ```sh
684
+ # On the WordPress server. The login, not the user id, is what names the option.
685
+ wp option get activitypub_keypair_for_ada --format=json > ada.keypair.json
686
+ ```
687
+
688
+ A site old enough to still be on the plugin's legacy storage has the pair in
689
+ user meta instead, one PEM per key:
690
+
691
+ ```sh
692
+ wp user meta get 2 magic_sig_private_key > ada.private.pem
693
+ wp user meta get 2 magic_sig_public_key > ada.public.pem
694
+ ```
695
+
696
+ Either export is accepted, and either PEM encoding — `-----BEGIN PRIVATE
697
+ KEY-----` (PKCS#8) or `-----BEGIN RSA PRIVATE KEY-----` (PKCS#1). Treat the
698
+ files the way you would treat a password: the private key is the account.
699
+
700
+ **Running it twice changes nothing**, and says so, which is what makes it safe
701
+ to run again after an instance that was down comes back — a follower already in
702
+ the file keeps its place and its follow time. A follower whose server will not
703
+ answer is listed and skipped rather than failing the import. A user who already
704
+ has a _different_ key pair is refused: importing over one would change that
705
+ person's identity, and every follower has cached the public half of the key
706
+ that is there. `--force` is the way past that, and is only right when you are
707
+ certain the pair being imported is the one the followers hold.
708
+
709
+ #### The cutover, end to end
710
+
711
+ 1. **Export, while the WordPress site is still up.** The key pair, as above.
712
+ The followers can be saved too — `curl -H 'Accept: application/activity+json'
713
+ 'https://example.com/wp-json/activitypub/1.0/actors/2/followers?page=1' >
714
+ followers.json` — which is worth doing if the old site is going away before
715
+ the import runs. Note the actor id and the numeric actor id off
716
+ `https://example.com/?author=2`, and bring the content across.
717
+ 2. **Import.** `geekity import wordpress-actor ada --actor-id … --wordpress-id
718
+ … --keypair ada.keypair.json`, pointing `--followers` at the saved file if
719
+ you took one. Check the report: every follower should be added, and any that
720
+ were skipped should be re-run once their servers answer.
721
+ 3. **Switch on.** Turn **WordPress ActivityPub compatibility** on under
722
+ `/admin/settings/federation`, then move the DNS. Followers' servers go on
723
+ delivering to the plugin's old inbox paths until they next refetch the
724
+ actor, and the switch is what catches those deliveries.
725
+ 4. **Watch.** `/admin/federation` lists the users with their actor ids and
726
+ followers; `/admin/settings/federation` lists each compatibility path with
727
+ the instant it was last asked for. Deliveries should thin out as each
728
+ follower's server refetches the actor and learns the new endpoints.
729
+ 5. **Switch off.** Once every path says _Never_ again for long enough — weeks
730
+ rather than days, since a quiet instance refetches rarely — clear the switch.
731
+ The paths go away on the very next request, with nothing restarted, and if
732
+ something was still using them you can turn it back on just as quickly.
733
+
734
+ Retiring the stored actor id itself is a separate, optional step by the Move
735
+ protocol, and is not part of the cutover: not every follower's software honours
736
+ a `Move`, and the ones that do not would simply stop following.
294
737
 
295
738
  ### The federation screen
296
739
 
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.
740
+ `/admin/federation` is all of that with a page in front of it. It shows one
741
+ panel per user — their avatar, their `@username@host` handle, the summary their
742
+ profile carries, their actor id, and the followers each of them has with
743
+ avatars and follow dates — the recent likes, boosts and replies out of the
744
+ inbox log, each linked to the remote object and to the post it was about, and
745
+ one row per post that has been federated: who announced it, its last activity,
746
+ when it went, and how many recipients it reached, is queued for, or failed
747
+ for. Each of those rows has a
748
+ Resend button, which is `cms.delivery.resend` behind a form: it builds the post
749
+ again from its file, sends it to every follower and every accepted relay the
750
+ site has now, and says what came of it.
751
+
752
+ Which posts are listed is a question for the files — the posts carrying an
753
+ `activitypub.published`, the trash included, which is the same thing as the
754
+ posts some follower holds a copy of — and how each landed is a question for the outcome
755
+ cache. So a site that has just deleted `data/geekity.db` sees every federated
756
+ post listed with nothing recorded against it, and can press Resend on any of
757
+ them.
758
+
759
+ A Relays panel sits between the followers and the inbox log: one row per
760
+ subscription with its inbox, whether it is waiting, accepted or rejected, when
761
+ it last moved, why it was refused if it was, and the last activity delivered
762
+ there with how that went. A subscription still waiting has a Retry, which sends
763
+ the `Follow` again under a fresh id.
306
764
 
307
765
  ### Testing federation against a real Mastodon account
308
766
 
@@ -321,8 +779,9 @@ pnpm dlx @fedify/cli tunnel 3000
321
779
 
322
780
  Leave that running and boot the site with `baseUrl` set to the address it
323
781
  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:
782
+ site booted on `http://localhost:3000` serves actors whose ids are
783
+ `http://localhost:3000/author/{username}/`, and no remote instance can
784
+ dereference those:
326
785
 
327
786
  ```sh
328
787
  GEEKITY_BASE_URL=https://quiet-sun-42.serveo.net geekity serve
@@ -330,15 +789,15 @@ GEEKITY_BASE_URL=https://quiet-sun-42.serveo.net geekity serve
330
789
 
331
790
  Then, from a Mastodon account:
332
791
 
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.
792
+ 1. Search for `@ada@quiet-sun-42.serveo.net` — the handle is a username at the
793
+ tunnel's host. The profile should come back with that user's display name,
794
+ bio and avatar.
795
+ 2. Follow it. The `Follow` lands on `/author/ada/inbox/` (or on `/inbox/`), the
796
+ CMS answers `Accept`, and the account appears on `/admin/federation` within
797
+ a second or two.
339
798
  3. Publish a post, from the editor or by writing a file into `content/posts/`.
340
799
  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
800
+ home timeline; `/admin/federation` records the outcome, with a Resend
342
801
  button if it did not land.
343
802
  4. Edit the post, then set `draft: true` on it, to see the `Update` and the
344
803
  `Delete` arrive.
@@ -351,14 +810,14 @@ that no longer exists, so they cannot be migrated to the real domain. The
351
810
  admin has no button for removing a follower yet, so run tunnel rehearsals
352
811
  against a scratch `dataDir` rather than the one the real site will keep.
353
812
 
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.
813
+ The key pairs live in `dataDir/keys`, not in the base URL, so they
814
+ survive across tunnel sessions. That is usually what you want; it also means a
815
+ `data` directory copied from one deployment to another brings the other's
816
+ identity with it.
358
817
 
359
818
  `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
819
+ it boots the CMS on a free port, runs `fedify lookup` against a user's actor
820
+ and a post object, reads WebFinger, and delivers a `Create(Article)` to two real inboxes over
362
821
  loopback. It needs no tunnel and no network, and it is a job in CI. See
363
822
  `packages/cms/scripts/fed-smoke.ts`, whose header explains why the `Follow`
364
823
  half is sent by a peer built in the script rather than by `fedify inbox`.
@@ -366,6 +825,427 @@ half is sent by a peer built in the script rather than by `fedify inbox`.
366
825
  [Fedify]: https://fedify.dev/
367
826
  [`fedify tunnel`]: https://fedify.dev/cli
368
827
 
828
+ ## Comments
829
+
830
+ A reader can answer a post on the page, and what they leave joins the same
831
+ thread as the fediverse replies rather than sitting in a section of its own.
832
+
833
+ A comment is a file. `content/_data/comments/{slug}.json` holds one post's
834
+ comments — id, source, kind, status, author, the Markdown and the HTML it
835
+ rendered to, when it was submitted, a salted hash of the address, and what it
836
+ answers — so they are in git beside the posts, an Eleventy build of the same
837
+ directory shows them, and the `comments` table is an index emptied and read
838
+ back on every boot.
839
+
840
+ Three settings and one front-matter key decide whether a post is still taking
841
+ them, and they are read in one place so the form and the endpoint can never
842
+ disagree:
843
+
844
+ | Where | What it says |
845
+ | ------------------------------------------ | ---------------------------------------------------------------------------------- |
846
+ | `comments` in `site.json` | Whether the site takes comments at all. On by default; off is off everywhere. |
847
+ | `commentsCloseAfterDays` in `site.json` | Days after a post's `date` that it closes. `14` by default; `0` never closes. |
848
+ | `comments: true` / `false` in front matter | The post's own answer, which beats both, in both directions. The editor offers it. |
849
+ | The document's type | A page is closed unless it says otherwise. |
850
+
851
+ None of it touches the fediverse: a reply, a like or a boost arrives because a
852
+ remote server sent it, and a closed post shows every one of them.
853
+
854
+ A comment is held for a moderator unless the same **name and email** have had
855
+ one approved before — WordPress's rule. In front of that are a honeypot field,
856
+ a minimum time between the form being rendered and submitted, and a per-address
857
+ rate limit. Comments are rendered from Markdown with raw HTML off, images
858
+ unembedded and every link marked `rel="nofollow ugc"`.
859
+
860
+ A site can hand the whole thing to a service:
861
+
862
+ ```ts
863
+ export default defineConfig({
864
+ commentChecker: {
865
+ async check(submission) {
866
+ // submission carries the comment, the post's URL, and the commenter's
867
+ // address, user agent and referrer — everything Akismet asks for.
868
+ return 'spam' | 'discard' | 'ham' | 'unknown';
869
+ },
870
+ async reportSpam(report) {}, // a moderator filed something as spam
871
+ async reportHam(report) {}, // …or let something out of the spam list
872
+ },
873
+ });
874
+ ```
875
+
876
+ A checker that throws is treated as having no opinion and logged, so a service
877
+ that is down never stops a site taking comments. `/admin/comments` is the
878
+ moderation queue and the dashboard carries the number waiting.
879
+
880
+ ### Being told about one
881
+
882
+ With mail configured, a comment or a webmention entering the queue emails every
883
+ user who has an address and has not switched the notice off on their
884
+ `/admin/users/<id>` screen. The message carries the comment, the post it is on,
885
+ and three links — approve, spam, delete — that work without a login. Nothing is
886
+ sent about a comment Akismet filed as spam or told the site to discard: only
887
+ what is actually waiting for a person.
888
+
889
+ Each link lands on a small page with a button on it, and only the button acts.
890
+ That is not politeness. Mail readers, spam filters and corporate link scanners
891
+ fetch the URLs in a message as a matter of course, and a link that moderated on
892
+ being opened would mean a mail gateway silently deleting this site's comments.
893
+ A link is signed rather than stored — the secret is `data/notification-secret`,
894
+ so a link goes on working across a `geekity rebuild` — is bound to one action
895
+ on one comment, lasts a week, and is spent the first time it is used.
896
+
897
+ Commenters get the other half. The form offers "email me when somebody replies
898
+ to this", but only on a site that can actually send mail; ticking it stores
899
+ `notify: true` beside the address that is already in the comment file, and
900
+ nothing about either is ever rendered — not on the page, not in the JSON or
901
+ Markdown representations, and not in the comments feeds. When a reply to that
902
+ comment is **approved**, one message goes out with the reply in it and an
903
+ unsubscribe link at the bottom. Unsubscribing is site-wide by address: the
904
+ address goes into `data/comment-optouts.json` and this site writes to it about
905
+ replies again. The comments themselves are untouched.
906
+
907
+ ### One message an hour instead of one a comment
908
+
909
+ Beside each switch on a user's `/admin/users/<id>` screen is how often that
910
+ notice should arrive: as they arrive, an hourly digest, or a daily one. It is
911
+ per user, and it starts as "as they arrive", so nothing changes for anybody who
912
+ does not touch it.
913
+
914
+ On a digest, a comment entering the queue sends nothing at all. Instead, once a
915
+ window has passed, one message goes out listing everything **still waiting**,
916
+ each item with its own approve, spam and delete links. A digest is built from
917
+ the queue as it stands rather than from a list of things that happened, so an
918
+ item somebody moderated in the meantime is simply not in it, and there is no
919
+ queue to keep true across a restart. A user with nothing waiting is not written
920
+ to at all, and their window does not start until something is: the promise a
921
+ mode makes is at most one message per window, and a quiet site sends none.
922
+
923
+ A very long queue — a spam wave Akismet let through — is listed up to a hundred
924
+ items, with the rest counted in a line pointing at `/admin/comments`.
925
+
926
+ The only thing written down is when each user last had one, in
927
+ `data/notification-digests.json`. It is a file rather than a row because
928
+ decision-9 lets a site delete the database whenever it is stopped, and a
929
+ forgotten timestamp would mean either a window silently skipped or a digest
930
+ sent twice. It is its own file rather than a field in `data/users.json` because
931
+ that file answers "who may sign in", and a timestamp rewritten every hour is
932
+ bookkeeping rather than anything about the person.
933
+
934
+ Reply notices to commenters are not affected: those are one message about one
935
+ reply, and they go when the reply is approved.
936
+
937
+ With no mail configured, none of this happens and nothing breaks: the box is
938
+ not offered, no notice is sent, no digest runs, and the moderation screen is
939
+ the notification it was before.
940
+
941
+ Notices are per user and keyed by event name, so a later one — a new follower,
942
+ a digest of failed deliveries — is one entry in the registry in
943
+ `src/notifications/preferences.ts` and a checkbox that appears by itself; an
944
+ entry that says `batched: true` gets the how-often select beside it too. An
945
+ event a user has said nothing about is at its default, which is why turning one
946
+ on for a site does not need anybody to visit the users screen.
947
+
948
+ ### Akismet
949
+
950
+ The one checker the package ships. It is off until a site has a key, and the
951
+ key is a credential rather than a setting: it lives in `data/akismet.json` at
952
+ mode `0600` beside the password hashes and the users' private keys, never in
953
+ `content/_data/site.json`, which is public, in git and published with the site.
954
+
955
+ Paste it into **Spam checking** on `/admin/settings/discussion`. It is checked with
956
+ Akismet's `verify-key` before it is stored, and the panel then reads Connected,
957
+ "Akismet does not recognise this key", "Akismet could not be reached", or Not
958
+ connected. The key is never printed back — the last four characters are, so it
959
+ is possible to tell which key is in there. A **Remove key** button turns Akismet
960
+ off again.
961
+
962
+ The file is read on every call, so a key saved on that screen filters the next
963
+ comment and a removed one stops filtering at once. Neither needs a restart.
964
+
965
+ A `commentChecker` in the config wins outright: the Akismet checker is built
966
+ only when the site named none.
967
+
968
+ Every comment and every incoming webmention that gets past the honeypot, the
969
+ form's age and the rate limit goes to `comment-check` with `blog`, `user_ip`,
970
+ `user_agent`, `referrer`, `permalink`, `comment_type` (`comment` or
971
+ `webmention`), `comment_author`, `comment_author_email`, `comment_author_url`,
972
+ `comment_content`, `comment_date_gmt`, `blog_lang`, `blog_charset` and, for a
973
+ form submission, `honeypot_field_name`. Fediverse replies are never sent: they
974
+ do not reach the checker seam at all.
975
+
976
+ | Akismet says | What happens |
977
+ | ----------------------------------------- | ---------------------------------------------------------------------------------------------- |
978
+ | `true` | Filed as spam, where a moderator can still see it and change its mind. |
979
+ | `true` with `X-akismet-pro-tip: discard` | Dropped without a queue entry. |
980
+ | `false` | The site's own rules decide: approved for a name and email approved before, pending otherwise. |
981
+ | `invalid`, a non-200, a timeout, no route | The same as `false`, and the failure is logged with whatever Akismet said was wrong. |
982
+
983
+ `false` is deliberately not a positive opinion. Nothing Akismet does can lose a
984
+ comment: every failure is no opinion, no opinion is the queue, and a call is
985
+ abandoned after ten seconds.
986
+
987
+ Marking something spam or not spam on `/admin/comments` posts `submit-spam` or
988
+ `submit-ham` with the same fields, minus `user_ip`, `user_agent` and `referrer`
989
+ — a stored comment keeps a salted hash of the address and nothing else.
990
+
991
+ ## Webmentions
992
+
993
+ The open web's version of what ActivityPub does: one page telling another that
994
+ it linked to it. Both directions are on by default and each has a switch on the
995
+ Discussion settings page.
996
+
997
+ | Setting in `site.json` | What it does |
998
+ | ---------------------- | ------------------------------------------------------------------------ |
999
+ | `webmentionsSend` | Tell the external pages a post links to, when it is published or edited. |
1000
+ | `webmentionsReceive` | Advertise the endpoint on every page and accept what is sent to it. |
1001
+
1002
+ **Sending** runs off the same index changes federation does, so a post saved in
1003
+ the editor and one edited on disk are one thing, and a full scan sends nothing.
1004
+ Every external link in the rendered body is asked whether it advertises a
1005
+ Webmention endpoint — the `Link` header first, then the first `<link>` or `<a>`
1006
+ with `rel="webmention"` — and told if it does. Both versions of an edited post
1007
+ are read, so a page that has just been _unlinked_ is told too and can drop what
1008
+ it was showing. One outcome is recorded per link (`sent`, `none` for a page that
1009
+ takes none, or `failed`), shown per post on `/admin/federation`, and the Resend
1010
+ button there sends them again from the file as it now reads.
1011
+
1012
+ **Receiving** advertises `/_geekity/webmention` two ways — a `Link` header on
1013
+ every representation of a document, and a `<link rel="webmention">` in the head
1014
+ — and takes a form `POST` of `source` and `target`. It answers `202` for
1015
+ anything worth checking and `400` for anything that is not, and does the looking
1016
+ afterwards: it fetches the source, checks it really links to the target, reads
1017
+ its microformats (`h-entry`, `h-card`, and whether it is a reply, a like, a
1018
+ repost or a plain mention), and files the result as a **pending comment** with
1019
+ `source: "webmention"` in the same file, the same thread and the same moderation
1020
+ queue as everything else — through the same `commentChecker` seam, so a checker
1021
+ can tell one from a form submission by its `source`. Akismet is told it is a
1022
+ `webmention` rather than a `comment`.
1023
+
1024
+ The source URL is its identity. A page that sends its webmention again updates
1025
+ what it left rather than adding a second, and one whose link has gone — or which
1026
+ answers 404 or 410 — takes it away. Closing rules do not apply: a post that
1027
+ stopped taking comments still hears about a page that links to it, exactly as it
1028
+ still hears a fediverse reply.
1029
+
1030
+ ## Email
1031
+
1032
+ The CMS sends its mail through one service and one seam. Everything that will
1033
+ ever email — the Send test email button, and the password resets, moderation
1034
+ notices and contact messages built on it — calls `cms.mail.send()`, and what
1035
+ carries the message is decided in one place.
1036
+
1037
+ **A site with no mail configuration still works.** `send` writes a line in the
1038
+ log saying the message was not sent and resolves successfully, so nothing that
1039
+ emails is a feature that breaks without a mail account.
1040
+
1041
+ ### Configuring it
1042
+
1043
+ Two halves, kept apart for the reason the Akismet key is kept out of
1044
+ `site.json`:
1045
+
1046
+ | Where | What |
1047
+ | ------------------------- | --------------------------------------------------------------------------------- |
1048
+ | `content/_data/site.json` | `mailProvider`, `mailFromName`, `mailFromAddress`, `mailReplyTo`, `contactEmail`. |
1049
+ | `data/mail.json` | The Brevo API key, and the SMTP host, port, TLS flag, user and password. |
1050
+
1051
+ `site.json` is public, in git and published with the site; `data/mail.json` is
1052
+ private, mode `0600`, and sits beside the password hashes and the users'
1053
+ private keys. No key or password is ever written to `site.json` and none is
1054
+ ever printed back into the settings screen — the panel shows the last four
1055
+ characters of an API key and the host and user of an SMTP connection, which is
1056
+ enough to tell what is stored and not enough to use it. Leaving a secret field
1057
+ blank keeps the one already stored, so the SMTP port can be corrected without
1058
+ retyping the password. **Remove credentials** forgets both.
1059
+
1060
+ The panel draws the boxes of the provider that is saved and no others: an API
1061
+ key on a Brevo site, a host, port, TLS flag, user and password on an SMTP one,
1062
+ and nothing at all on a site sending through neither. It follows what is in
1063
+ `site.json` rather than what the Provider select above it is showing, so
1064
+ choosing a different provider there takes a **Save settings** before its boxes
1065
+ appear — the panel says as much. A credential stored for the provider that is
1066
+ not the chosen one still gets a line of its own, because it is still on disk
1067
+ and **Remove credentials** is still what forgets it.
1068
+
1069
+ Both files are read on every send, so a credential pasted into the settings
1070
+ screen sends the next message and one removed stops the message after it.
1071
+ Neither needs a restart.
1072
+
1073
+ `mailFromAddress` should be an address the provider has verified; empty falls
1074
+ back to `no-reply@` at the site's host, which most providers will refuse.
1075
+
1076
+ ### The two providers
1077
+
1078
+ | `mailProvider` | What it is |
1079
+ | -------------- | ------------------------------------------------------------------------------------- |
1080
+ | `none` | Nothing is sent. The default. |
1081
+ | `brevo` | `POST https://api.brevo.com/v3/smtp/email` with an `api-key` header. No open sockets. |
1082
+ | `smtp` | Any SMTP server, through nodemailer — including Brevo's own relay. |
1083
+
1084
+ ### Send test email
1085
+
1086
+ The Email settings page has an address field and a button that sends the `test`
1087
+ message through the whole chain — the template, the From line, the provider and
1088
+ the retry — and reports what came back, the provider's own words and its
1089
+ message id included. It is the one thing that proves mail works before somebody
1090
+ needs it to.
1091
+
1092
+ It is on the page only where the site can actually send — a provider chosen and
1093
+ its credential stored. Without one the only thing the button could report is
1094
+ that it did nothing.
1095
+
1096
+ ### Queue, retries and the log
1097
+
1098
+ Messages go on one queue and are sent one at a time, so a provider that hangs
1099
+ delays the next message and nothing else. A refused message is tried three
1100
+ times, waiting two seconds, then eight; every attempt is logged with its
1101
+ outcome and, when it went, the provider's message id, so a message somebody
1102
+ never received can be traced to the attempt that carried it. `send` never
1103
+ throws — a failure is a result with `ok: false` and the provider's own words in
1104
+ `error`.
1105
+
1106
+ ### Messages
1107
+
1108
+ Messages are Nunjucks templates under `mail/` in the theme, resolved the way
1109
+ every other template is: the theme the site has chosen first, the packaged theme
1110
+ second, file by file. Each is up to three files — `mail/<name>.subject.njk`,
1111
+ `mail/<name>.txt.njk` (required) and `mail/<name>.html.njk` — so a site can
1112
+ replace the text of a message and keep the subject the package ships, or add a
1113
+ message the package never had. The full table is in
1114
+ [`themes/default/README.md`](./themes/default/README.md).
1115
+
1116
+ ### From an entry file, and from a test
1117
+
1118
+ ```ts
1119
+ const result = await cms.mail.send({
1120
+ to: 'ada@example.com',
1121
+ template: 'test',
1122
+ data: { name: 'Ada' },
1123
+ });
1124
+ result.ok; // true, whether it went or the site sends no mail
1125
+ result.skipped; // true when the site sends no mail
1126
+ ```
1127
+
1128
+ `sendRaw({ to, subject, text, html })` is the same thing for a message the
1129
+ caller built itself. Both resolve when the message has been sent, given up on
1130
+ or skipped; a caller that does not care drops the promise with `void`.
1131
+
1132
+ A test names a provider in the config and reads back what would have gone out.
1133
+ It wins outright over the settings and `data/mail.json`, the way a
1134
+ `commentChecker` wins over the Akismet key:
1135
+
1136
+ ```ts
1137
+ import { createCms, createMemoryMailProvider } from '@geekity/cms';
1138
+
1139
+ const provider = createMemoryMailProvider();
1140
+ const cms = createCms({
1141
+ mail: { provider, backoffMs: () => 0 },
1142
+ });
1143
+
1144
+ // …
1145
+ provider.sent[0].subject;
1146
+ provider.failNext(2); // watch the retry without waiting for it
1147
+ ```
1148
+
1149
+ `mail` also takes `attempts`, `backoffMs` and `logger`, which is how a test
1150
+ proves the retry without spending ten seconds on it.
1151
+
1152
+ ## The archive page
1153
+
1154
+ A page carrying `archive: true` in its front matter lists every published post
1155
+ under its own words, grouped by the month it was published in, newest month
1156
+ first and newest post first inside a month. Nothing paginates it: an archive
1157
+ page is a way of finding one piece of writing rather than a listing to read
1158
+ through. Drafts and posts whose date has not arrived are absent, as they are
1159
+ everywhere else.
1160
+
1161
+ The default theme draws it from `partials/archive.njk`, and the CMS hands the
1162
+ layout `archiveMonths` — `{ month, posts }` per month, the month already read
1163
+ through the site's `timezone` so the heading and the dates agree. Eleventy
1164
+ ignores the key, and a static build of the same directory publishes the page as
1165
+ an ordinary page.
1166
+
1167
+ ## The contact form
1168
+
1169
+ A page carrying `contact: true` in its front matter renders a contact form
1170
+ under its content — name, email, subject, message. The editor offers it as a
1171
+ **Contact form** checkbox on the pages editor, and the key is honoured wherever
1172
+ it is written, so a theme that includes `partials/contact-form.njk` in its post
1173
+ layout gets one there too.
1174
+
1175
+ It works with JavaScript switched off, and there is no script on it. The form
1176
+ posts to `/_geekity/contact`, and a message that is stored redirects back to
1177
+ the page with `?contact=sent`, which draws a thank-you where the form was — so
1178
+ a refresh sends nothing twice.
1179
+
1180
+ **Where the message goes never appears in the HTML.** The contact address is
1181
+ read when a submission arrives; it is not on any render context, so no theme
1182
+ can print it and no form carries it.
1183
+
1184
+ ### Where a message goes
1185
+
1186
+ | Where | What |
1187
+ | ----------------------------------- | --------------------------------------------------------------------------- |
1188
+ | `data/contact/<id>.json`, mode 0600 | Every message, written **before** anything is emailed. |
1189
+ | The `contactEmail` setting | Where the `contact-message` email is sent, reply-to the sender. |
1190
+ | `/admin/messages` | The inbox: read, mark read, delete. The dashboard carries the unread count. |
1191
+
1192
+ `contactEmail` is on the Settings screen under Email. Empty falls back to the
1193
+ first admin with an email address, by username, so a fresh site with a mail
1194
+ credential takes messages without anybody visiting the field.
1195
+
1196
+ The file is written first and the mail goes out behind the redirect, which is
1197
+ the whole point of the design: a provider that is down, a key that has expired
1198
+ or an address that bounces costs a notification rather than the message. **With
1199
+ no mail configured at all, submissions are still stored and still shown on the
1200
+ Messages screen** — it is the notification, exactly as `/admin/comments` was
1201
+ before there was any email.
1202
+
1203
+ Messages live under `data/` rather than `content/` because they carry the
1204
+ sender's address and were never meant to be published, and there is no SQLite
1205
+ index over them: the screen reads the directory to sort it anyway, and a second
1206
+ copy of the truth would only be a second thing to keep true. One JSON file per
1207
+ message, named by an id that begins with the instant it arrived, so `ls` is the
1208
+ inbox in order. Deleting a message on the screen deletes its file.
1209
+
1210
+ ### What is in a message file
1211
+
1212
+ ```json
1213
+ {
1214
+ "id": "20260920T120000000Z-1a2b3c4d",
1215
+ "received": "2026-09-20T12:00:00.000Z",
1216
+ "status": "received",
1217
+ "read": false,
1218
+ "page": { "slug": "contact", "permalink": "/contact/", "title": "Say hello" },
1219
+ "from": { "name": "Ada Lovelace", "email": "ada@example.com" },
1220
+ "subject": "About the analytical engine",
1221
+ "message": "It is a lovely machine. Please write back.",
1222
+ "addressHash": "9f2c…"
1223
+ }
1224
+ ```
1225
+
1226
+ `addressHash` is the same salted hash a comment keeps — the salt is
1227
+ `data/comment-salt` — so a run of submissions from one machine is visible
1228
+ across both without a reader's address being written down.
1229
+
1230
+ ### The defences in front of it
1231
+
1232
+ The same four a comment goes through, out of the same modules, so the two
1233
+ public forms cannot drift into different rules:
1234
+
1235
+ | Defence | What it refuses |
1236
+ | ------------------- | ----------------------------------------------------------------------------------------------------------------- |
1237
+ | The honeypot | A hidden field a person never fills in. A submission that filled it gets the thank-you and is dropped in silence. |
1238
+ | The form's age | Submitted under three seconds after the page loaded, or from a form rendered more than a day ago. |
1239
+ | The rate limit | Five messages per address per ten minutes, on the same limiter the login form uses. The sixth is a 429. |
1240
+ | The comment checker | The `commentChecker` seam, told `comment_type: contact-form`. With an Akismet key stored, that is Akismet. |
1241
+
1242
+ A checker's `discard` stores nothing at all. Its `spam` **stores the message**,
1243
+ on the Spam list of the Messages screen, and does not email it: a false
1244
+ positive on a contact form is somebody's message vanishing, which is a worse
1245
+ failure than a spam list to glance at. A checker that is down or throwing is no
1246
+ opinion, and the message is stored as it would have been before anybody had
1247
+ one.
1248
+
369
1249
  ## Keeping the index in step
370
1250
 
371
1251
  Booting scans `contentDir`, indexes every Markdown file under `posts/` and
@@ -380,6 +1260,77 @@ await cms.sync(); // one full scan; what `serve()` runs on boot
380
1260
  Set `watch: false` (or `GEEKITY_WATCH=false`) for a host whose content cannot
381
1261
  change under the process. `geekity sync` sets it for you.
382
1262
 
1263
+ ## Dates and the timezone setting
1264
+
1265
+ Every date the CMS writes into a file — `date`, `updated` and
1266
+ `activitypub.published` — is a UTC instant ending in `Z`. The `timezone`
1267
+ setting is not stored with the date; it is the lens the instant is read
1268
+ through.
1269
+
1270
+ That means one rule in three places:
1271
+
1272
+ - **The editor** shows a stored instant as the clock reads in the site's zone,
1273
+ with the zone named under the field (`Wall-clock time in America/Chicago
1274
+ (CDT). Stored as UTC.`), and reads what you type back in the same zone. Type
1275
+ `2026-09-04 09:00` in a site set to `America/Chicago` and the file gets
1276
+ `date: '2026-09-04T14:00:00Z'`. A value you write with an offset of its own —
1277
+ `2026-09-04T09:00:00+02:00` — already names an instant, so it is kept as one.
1278
+ - **The theme's `date` filter** renders `readable`, `html` and `year` in the
1279
+ site's zone; `iso` stays the instant, because that is what a `<time
1280
+ datetime>` and a feed want. Changing the setting changes what every page
1281
+ shows on the very next request, with no file touched and no rebuild.
1282
+ - **A new post's filename day and its `/{yyyy}/{mm}/` permalink** come from the
1283
+ calendar day the site's zone was on at that instant, so a post published at
1284
+ half past midnight on 1 October in Berlin is filed under October and not
1285
+ under the 30 September that UTC was still on.
1286
+
1287
+ Changing the setting later never moves a URL. The permalink is written
1288
+ explicitly into every file the CMS saves, and the day a post is filed under is
1289
+ the one in its filename — decided once, when it was written, where no setting
1290
+ can reach it.
1291
+
1292
+ Files written by hand keep working. A `date` with an offset, or one YAML parses
1293
+ into a timestamp by itself, is read for the instant it names, sorts and
1294
+ schedules by that instant, and is rewritten as UTC the next time the CMS saves
1295
+ that file — never by a mere scan. A hand-written file with no `permalink` still
1296
+ resolves to the URL Eleventy gives it, which is cut from the date as the file
1297
+ spells it.
1298
+
1299
+ Feeds, the sitemap and the ActivityStreams objects emit instants and are
1300
+ unaffected by the setting, and a scheduled post fires at its instant whatever
1301
+ the zone is set to.
1302
+
1303
+ ## Scheduling
1304
+
1305
+ A post whose `date` is in the future is written now and published then, the way
1306
+ WordPress schedules one. Save it with Publish; the editor answers `Scheduled:
1307
+ {title}` and says when it goes out, in the site's `timezone` setting.
1308
+
1309
+ Until that moment the post is not public in any sense: it is off the home page,
1310
+ off every archive, out of all three feeds and out of the ActivityPub outbox,
1311
+ its permalink 404s in every representation, and no follower has been told about
1312
+ it. When the moment comes the running server publishes it — no restart, no
1313
+ build — and the same delivery that a live publish runs sends the
1314
+ `Create(Article)` and pings the notify server.
1315
+
1316
+ There is no `scheduled` key and no state to keep: a scheduled post is an
1317
+ ordinary published one whose date has not arrived, which is a question asked of
1318
+ the clock on every query. So moving the date moves the publication, ticking
1319
+ Draft cancels it, and a date pushed into the future withdraws a post that was
1320
+ already out — with a `Delete` to the followers, exactly as drafting it would.
1321
+
1322
+ A post whose date passed while the server was down is public the moment the
1323
+ next boot has scanned, and is federated once on that boot. The CMS remembers
1324
+ how far through the calendar it has got, so a restart never announces the same
1325
+ post twice and a deleted database never announces the archive.
1326
+
1327
+ The admin lists scheduled posts under their own Scheduled filter, with a
1328
+ Scheduled status beside them. The public permalink 404s for a signed-in admin
1329
+ too: the public site has no session, deliberately — every response it gives is
1330
+ cacheable, and a URL that answered differently for one viewer would be cached
1331
+ and served to the rest. Use the editor's Preview button, which renders the post
1332
+ through the theme's own layout at an admin URL.
1333
+
383
1334
  ## Hooks
384
1335
 
385
1336
  Two hooks let a site do something of its own when content changes. Both are
@@ -452,37 +1403,100 @@ and `off()`.
452
1403
 
453
1404
  Booting also mounts the admin at `/admin`. It is server-rendered from templates
454
1405
  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
1406
+ site's theme may override any public template, and must not be able to
456
1407
  shadow the login form.
457
1408
 
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. |
1409
+ | Route | What it does |
1410
+ | ------------------------------------------------ | --------------------------------------------------------------------------------- |
1411
+ | `/admin` | The dashboard: counts, the five most recent posts, the follower count. |
1412
+ | `/admin/posts`, `/admin/pages` | The listings and the editors. |
1413
+ | `/admin/tags`, `/admin/categories` | Every term in use, with rename, merge and delete. |
1414
+ | `/admin/tags/rename`, `/admin/categories/rename` | `POST` only. Renames a term, or merges it into one that exists. |
1415
+ | `/admin/tags/delete`, `/admin/categories/delete` | `POST` only. Takes a term out of every file. |
1416
+ | `/admin/media` | Everything under `content/uploads`, with the URL, the Markdown and what uses it. |
1417
+ | `/admin/media/upload` | `POST` only. Stores one file by the rules the editor's upload enforces. |
1418
+ | `/admin/media/delete` | `POST` only. Deletes one upload, asking first when a document points at it. |
1419
+ | `/admin/comments` | Pending, approved and spam, with approve, spam, delete and reply. |
1420
+ | `/admin/comments/moderate` | `POST` only. Approves one comment, files it as spam, or deletes it. |
1421
+ | `/admin/comments/reply` | `POST` only. Posts an approved reply under the comment it answers. |
1422
+ | `/admin/messages` | The contact form's inbox, with a Spam list beside it. |
1423
+ | `/admin/messages/read` | `POST` only. Marks one message read, or unread again. |
1424
+ | `/admin/messages/delete` | `POST` only. Deletes one message, and its file with it. |
1425
+ | `/admin/appearance/themes` | Appearance > Themes: the themes on disk. `POST` activates the one named. |
1426
+ | `/admin/settings` | Settings > General: title, tagline, author, base URL, time zone, language. |
1427
+ | `/admin/settings/reading` | Posts per page, the site menu, the notify server. |
1428
+ | `/admin/settings/permalinks` | The tag and category bases, and the archive redirects already recorded. |
1429
+ | `/admin/settings/discussion` | Comments and the closing window, webmentions, and the Akismet key. |
1430
+ | `/admin/settings/akismet` | `POST` only. Saves the Akismet key, or forgets it. |
1431
+ | `/admin/settings/email` | The mail provider, the From line, the reply-to and the contact address. |
1432
+ | `/admin/settings/mail` | `POST` only. Saves a mail credential, or forgets every one of them. |
1433
+ | `/admin/settings/mail/test` | `POST` only. Sends the theme's test message through the whole chain. |
1434
+ | `/admin/settings/federation` | The relays the site subscribes to, and the WordPress compatibility switch. |
1435
+ | `/admin/users` | Who may sign in: a row each, with Edit and Delete. Nothing on it edits anybody. |
1436
+ | `/admin/users/new` | Users > Add new: the add form. `POST` adds one. |
1437
+ | `/admin/users/<id>` | One user: the account, the profile, the notices, your password, and the delete. |
1438
+ | `/admin/users/password` | `POST` only. Changes the signed-in admin's own password. |
1439
+ | `/admin/users/email` | `POST` only. Sets or clears the email address on the user the form names. |
1440
+ | `/admin/users/profile` | `POST` only. Saves the whole public profile of the user the form names. |
1441
+ | `/admin/users/notifications` | `POST` only. Turns one notice on or off for the user the form names. |
1442
+ | `/admin/users/notifications/mode` | `POST` only. Sets how often that notice arrives: as they arrive, hourly or daily. |
1443
+ | `/admin/users/delete` | `POST` only. Deletes the user the form names. |
1444
+ | `/admin/federation` | The actors, their followers, the inbox log, and per-post delivery. |
1445
+ | `/admin/federation/resend` | `POST` only. Sends one post to the followers again, as its file now reads. |
1446
+ | `/admin/setup` | First run: creates the first admin. Closed once a user exists. |
1447
+ | `/admin/login` | Username and password. |
1448
+ | `/admin/logout` | `POST` only. Deletes the session row. |
1449
+ | `/admin/_static/*` | The admin's own stylesheet and scripts, cached for an hour. |
474
1450
 
475
1451
  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.
1452
+ site name and a link to the public site, the menu down the left, and a place for
1453
+ flash messages. A message queued with `flash(c, 'notice', '…')` is kept on the
1454
+ session row, shown on the next page the browser asks for, and cleared as it is
1455
+ read, so it survives exactly one redirect.
1456
+
1457
+ ### The menu
1458
+
1459
+ The menu is WordPress classic: ten sections — Dashboard, Posts, Pages, Media,
1460
+ Comments, Messages, Appearance, Users, Settings, Federation — each a heading
1461
+ over one or more children. Clicking a heading opens the section and lands on its first
1462
+ child; the open section shows its children and the one you are on carries
1463
+ `aria-current="page"`, so Users tells you that you are on Users > All users.
1464
+ Tags and categories are children of Posts, because a tag with no post on it is
1465
+ nothing. Every section has at least one child even when it has one screen, so
1466
+ the rule never needs an exception and a second child can appear later without
1467
+ the menu changing shape. There is no JavaScript in it: the server knows which
1468
+ section is open, so expanding one is a page rather than a script.
1469
+
1470
+ The whole menu is `ADMIN_SECTIONS` in `src/admin/menu.ts`. A site's own screen
1471
+ joins it in two steps:
481
1472
 
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.
1473
+ ```ts
1474
+ // 1. One entry in the section's children, naming the screen and its URL.
1475
+ { child: 'imports', label: 'Imports', url: '/admin/imports' }
1476
+
1477
+ // 2. The same pair on whatever that screen renders.
1478
+ render(c, 'layouts/imports.njk', { section: 'posts', child: 'imports' });
1479
+ ```
1480
+
1481
+ Nothing else changes: the heading, the landing URL and the marking all follow.
1482
+ A screen naming a section or a child the registry does not hold throws
1483
+ `UnknownAdminScreenError` rather than rendering a menu expanded around nothing,
1484
+ so the mistake is a failed test instead of a menu that quietly marks nothing.
1485
+
1486
+ Who may sign in is `data/users.json`: one entry per user with an id, a
1487
+ username, an argon2 hash and a created time — and, for a user who has them, an
1488
+ email address, notification preferences, a public profile and the
1489
+ [stored actor id](#a-users-stored-actor-id) they were published under
1490
+ elsewhere. Written atomically with `0600` permissions and serialised against
1491
+ itself, so two admins adding the same name at once cannot both succeed. It is in `data/` rather than `content/` because
1492
+ `content/` is published with the site, and it is one of the two things under
1493
+ `dataDir` that must be backed up — the users' actor keys are the other.
1494
+
1495
+ Sessions stay in SQLite, where the rest of the cache lives. They name a user by
1496
+ id and are joined against the file on every admin request, so a session whose
1497
+ user is no longer there is not a login: deleting `data/geekity.db` signs
1498
+ everybody out and loses nothing else, and restoring an old one cannot bring
1499
+ back an account that was deleted or a password that was changed.
486
1500
 
487
1501
  Passwords are hashed with argon2id through `node:crypto`, so there is no native
488
1502
  module to build. The cost parameters travel with each hash, which means raising
@@ -499,40 +1513,426 @@ planted session id cannot become a logged-in one.
499
1513
 
500
1514
  The first admin comes from `/admin/setup` or from
501
1515
  [`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.
1516
+ that comes from `/admin/users`, which lists who may sign in and adds a user
1517
+ with a password you supply or one it generates and shows once. The list is a
1518
+ listing and nothing more: a username with the archive URL under it, the email
1519
+ address, when the account was made, and Edit and Delete. Every field lives on
1520
+ that person's own screen at `/admin/users/<id>`, where each box has a label
1521
+ beside it — the account and its email address, the public profile, what this
1522
+ person is emailed about, your own password on your own page, and the delete or
1523
+ the sentence saying why this account cannot go. Each Save lands back on that
1524
+ screen, so it shows its own result. The last remaining user cannot be deleted,
1525
+ and nobody may delete their own account.
1526
+
1527
+ An email address is optional on a user and can be set on anybody's screen,
1528
+ since with one role every user already has every power there is. It buys
1529
+ [password recovery](#forgotten-passwords) and the notices
1530
+ [a comment sets off](#being-told-about-one), and it never appears on the public
1531
+ site. Beside it is which of those notices go to it: one switch per event, on by
1532
+ default, stored in `data/users.json` only when somebody turns one off.
1533
+
1534
+ A site whose database was written by a version that kept the accounts in a
1535
+ `users` table has those rows written into `data/users.json` on the first boot
1536
+ of this one — ids and hashes as they stand, so nobody has to log in again — and
1537
+ the table is dropped. A `users.json` that is already there wins and is left
1538
+ alone.
508
1539
 
509
1540
  Creating one from your own code, which is what the CLI does:
510
1541
 
511
1542
  ```ts
512
- import { openAdminStore } from '@geekity/cms';
1543
+ import { createUser } from '@geekity/cms';
513
1544
 
514
- const admin = openAdminStore({ dataDir: 'data' });
515
- admin.createUser({ username: 'ada', password: process.env.PASSWORD ?? '' });
516
- admin.close();
1545
+ await createUser({
1546
+ dataDir: 'data',
1547
+ username: 'ada',
1548
+ password: process.env.PASSWORD ?? '',
1549
+ });
517
1550
  ```
518
1551
 
1552
+ ### Settings
1553
+
1554
+ `content/_data/site.json` is the source of truth for a site's settings. The
1555
+ pages under `/admin/settings` — General, Reading, Permalinks, Discussion, Email
1556
+ and Federation — each read that file, validate what was typed and write it
1557
+ back; nothing else remembers a setting, and `data/geekity.db` holds none of
1558
+ them.
1559
+
1560
+ Each page saves its own fields and no others, onto the file as re-read inside
1561
+ the write, so two people saving two different pages at the same moment both
1562
+ land, and each page validates only what it shows: a refused save comes back on
1563
+ the page it was sent from and writes nothing at all.
1564
+
1565
+ The write is atomic and serialised: the bytes go to a temporary file beside the
1566
+ real one and are renamed over it, and the read of what the file already held
1567
+ and the write of what it says next are one step nothing else writing that file
1568
+ can get between. So a reader — an Eleventy build, the CMS's own site data
1569
+ source, another process entirely — always sees one whole version, and two saves
1570
+ at once cannot each keep half of what the other kept.
1571
+
1572
+ The file carries `title`, `tagline`, `url`, `author`, `postsPerPage`,
1573
+ `homepage`, `postsPage`,
1574
+ `timezone`, `language`, `tagBase`,
1575
+ `categoryBase`, `notifyServer`, `webmentionsSend`, `webmentionsReceive`,
1576
+ `mailProvider`, `mailFromName`, `mailFromAddress`, `mailReplyTo`,
1577
+ `contactEmail`, `relays`, `navigation` and `taxonomyRedirects`,
1578
+ and every other key it already had is kept, `feedSize` and anything a site put
1579
+ there included. A key it does not carry is the default, and a key of the wrong
1580
+ type is the default too: a hand-edited `site.json` cannot take the site down.
1581
+
1582
+ `homepage`, `postsPage` and `wordpressActivityPub` are the only three written
1583
+ just when they have a value. The first two are WordPress's Reading choice, the
1584
+ slug of the page served at `/` and the slug of the page whose own URL carries
1585
+ the post listing, absent altogether on a site that shows its latest posts at
1586
+ `/`; the third is the
1587
+ [WordPress ActivityPub compatibility](#wordpress-activitypub-compatibility)
1588
+ switch, absent until somebody turns it on and absent again when they turn it
1589
+ off. A `postsPage` without a `homepage` is
1590
+ ignored, the listing being at `/` already, and a slug naming no published page
1591
+ is a site back on its latest posts. See [The front page](#the-front-page).
1592
+
1593
+ Because it is the source rather than a copy, editing it by hand while the
1594
+ server runs is picked up on the next request — on the public site, in the
1595
+ feeds and on the settings screen alike — with nothing
1596
+ to restart and nothing to tell. Put the file in git and a checkout of it is the
1597
+ site's settings.
1598
+
1599
+ The one exception is `url`. A base URL decides the absolute URLs in the feeds,
1600
+ the ActivityStreams ids and whether the session cookie is `Secure`, so
1601
+ `GEEKITY_BASE_URL` and a `baseUrl` in the config file both win over the file's,
1602
+ and the General settings page renders the field read-only and says which value
1603
+ is in effect and why. When neither names one, the file's `url` becomes the base URL
1604
+ at boot — at boot rather than on save, so an `https` base URL cannot log out
1605
+ the admin who submitted it over `http`.
1606
+
1607
+ Two things on that screen are not settings and are not in that file: the
1608
+ Akismet key and the mail credential. Both are credentials, both live under
1609
+ `data/` at mode `0600`, and each is its own pair of forms — save and remove —
1610
+ so a key typed wrong cannot lose an edit to the title. See
1611
+ [Akismet](#akismet) and [Email](#email).
1612
+
1613
+ A site whose database was written by a version that kept the settings in SQLite
1614
+ has those rows written into `site.json` on the first boot of this one, and the
1615
+ table is dropped. If the file was written after the rows were — a hand edit, or
1616
+ a content directory restored from git — the file wins outright.
1617
+
1618
+ ### The front page
1619
+
1620
+ `/` is the site's latest posts until the Reading settings say otherwise. The
1621
+ choice is WordPress's own, and so are its two answers:
1622
+
1623
+ - **Your latest posts.** The archive at `/`, paginated at `/page/N/`. This is
1624
+ the default, and it is what an empty `homepage` means.
1625
+ - **A static page.** The page is served at `/`, its own permalink answers `301`
1626
+ to `/` so the front page has one URL, and the menu links it at `/`. The
1627
+ default theme draws it with `layouts/front-page.njk` — the page's own words,
1628
+ then `Recent Posts`, then the line of links and the bio — and a theme lays it
1629
+ out differently by overriding that one file.
1630
+
1631
+ With a homepage set, a second pick gives the listing a page of its own: the
1632
+ **posts page**. Its permalink carries the listing, with the page's own title
1633
+ and words above the posts, paginated beneath it at `{permalink}page/N/`;
1634
+ `/page/N/` at the root redirects there, and the theme's `layouts/posts-page.njk` is
1635
+ the theme's override, falling back to the listing layout. A posts page with no
1636
+ homepage is refused, as WordPress refuses it, and so is one page picked as
1637
+ both. With a homepage and no posts page the listing has no page of its own,
1638
+ which is WordPress's answer too.
1639
+
1640
+ The feeds do not move: `/feed/`, `/feed/atom/` and `/feed/json/` syndicate the
1641
+ site's posts wherever the listing is read, and every page advertises them. The
1642
+ sitemap follows the site — `/` once, and the listing's pages under the posts
1643
+ page — and the pages list marks the two rows **Front Page** and **Posts Page**.
1644
+
1645
+ Both settings are page slugs in `content/_data/site.json`, so an Eleventy build
1646
+ of the same directory shows the same front page:
1647
+ `docs/eleventy.config.example.js` puts the homepage at `/` and flags the posts
1648
+ page on the context as `isPostsPage`. A slug whose page is drafted, trashed or
1649
+ deleted names nothing published, and the site is back to its latest posts with
1650
+ the pick kept: publishing the page again puts the front page back.
1651
+
1652
+ ### The media library
1653
+
1654
+ `/admin/media` lists every file under `content/uploads`, newest first by
1655
+ modification time, with a thumbnail for a picture and its extension at the same
1656
+ size for anything else, the size in bytes, the date, and — for a library that
1657
+ has grown — a pager, twenty-four files to a page.
1658
+
1659
+ The list is a walk of the directory on every request rather than a query.
1660
+ Nothing records that an upload exists: the filesystem is the truth
1661
+ (decision-1, decision-9), so a file copied in over ssh, pulled in by git, or
1662
+ written by an Eleventy build is on the screen without a restart, and one
1663
+ removed the same way is off it. The only question the index is asked is which
1664
+ documents mention a URL, which is the one thing a directory cannot answer.
1665
+
1666
+ Each row carries the public URL and the ready-made Markdown — `![name](url)`
1667
+ for a picture and `[name](url)` for anything else, from the same function the
1668
+ editor's upload control pastes from — as readonly text fields, which select and
1669
+ copy on their own. `admin/static/copy.js` reveals a Copy button beside each and
1670
+ does nothing else, so the screen is complete before it loads and a browser
1671
+ without the clipboard API is not shown a button that would fail.
1672
+
1673
+ The upload form is `storeUpload`, the same function behind `POST /admin/uploads`
1674
+ alike: one allowlist, one signature check, one
1675
+ `{yyyy}/{mm}/{slug}{ext}` naming rule, and one set of refusals, shown as a flash
1676
+ on the next page rather than as the editor's JSON.
1677
+
1678
+ Delete asks when it should. A file nothing points at is deleted on the first
1679
+ click. A file a document still mentions — anywhere in its body, as Markdown, as
1680
+ HTML or in prose — brings back a confirmation naming every document that does,
1681
+ linked to its editor and marked when it is in the trash, and only a form
1682
+ carrying `confirm` goes through. The trash counts because a trashed post can be
1683
+ restored, and restoring one whose picture went in the meantime is a broken post
1684
+ nobody was warned about. The submitted path is resolved against
1685
+ `content/uploads` and refused when it lands outside, so a form field cannot
1686
+ name a file elsewhere in the content directory.
1687
+
1688
+ `deleteUpload` takes a `removeDerived` hook, called with the same
1689
+ content-relative path once the original is gone. The original under
1690
+ `content/uploads` is the only source of truth (decision-10) and anything
1691
+ generated from it is derived state that must not outlive it; the hook defaults
1692
+ to `removeImageVariants`, which takes the file's derived images with it.
1693
+
1694
+ ### Image variants
1695
+
1696
+ `generateImageVariants(config, uploadPath)` derives one upload's copies with
1697
+ sharp and writes them, plus an `image.json` sidecar, into
1698
+ `<dataDir>/images/<the upload's path>/`. It runs inside `storeUpload`, so the
1699
+ editor's control and the media library both produce the same
1700
+ variants for the same file and the encoding is paid for by whoever uploaded it.
1701
+ It answers `undefined`, having written nothing, for a GIF, an animation, a PDF,
1702
+ a text file, an upload that is not there, and a site with `imageOptimization`
1703
+ off — every one of which means "serve the original", which is what the site
1704
+ would have done anyway.
1705
+
1706
+ | Function | What it is for |
1707
+ | ---------------------------------- | ------------------------------------------------------------------------------------ |
1708
+ | `generateImageVariants` | Derive the whole set and write the sidecar. Two callers at once share one encode. |
1709
+ | `describeImage` | The record, synchronously and cached, for a renderer that must not open a file. |
1710
+ | `findImageVariant` | One derived file by request path, generating it when it is missing. |
1711
+ | `removeImageVariants` | Take a source's derived directory away. |
1712
+ | `responsiveImages(html, describe)` | Rewrite `<img src="/uploads/…">` as `<picture>`. Pure: hand it any lookup. |
1713
+ | `siteImageMarkup(config, html)` | The same, over one site's own records, deriving in the background for a record miss. |
1714
+ | `siteIcons(config, avatar)` | The head's icon links for a site's avatar: `{ rel, sizes, href }`, or none at all. |
1715
+
1716
+ `describeImage` is synchronous and cached because it is called once per image
1717
+ while a page renders, and decision-10 forbids probing an image file at render
1718
+ time. Its cache entry outlives the sidecar on purpose: `data/images/` is
1719
+ disposable, so a page whose variants have been swept away keeps rendering the
1720
+ markup that asks for them back, and the first request for each one rebuilds it.
1721
+
1722
+ The site's icons are derived copies of the same kind, and `siteIcons` is how a
1723
+ head gets at them: three square PNGs, `icon` at 32 and 16 pixels and
1724
+ `apple-touch-icon` at 180, cropped from the middle of the site's avatar and
1725
+ encoded the first time a browser asks for one. It answers an empty list — and
1726
+ the packaged theme then links no icon at all — when the site has no avatar, when
1727
+ its avatar is a file no icon can be made of, or when `imageOptimization` is off,
1728
+ so a head never advertises a URL this server would answer 404 for.
1729
+
1730
+ `documentContext(document, images)` is where the rewrite is applied. Passing the
1731
+ config is what turns the page's `content` into `<picture>` markup; the feeds,
1732
+ `documentJson`, the Markdown representation and `postArticle` all read
1733
+ `document.html` directly and so keep the plain `<img>` by construction.
1734
+
1735
+ ### Managing tags and categories
1736
+
1737
+ `/admin/tags` and `/admin/categories` list every term in use with two counts and
1738
+ a link to its archive: **Posts**, what the public site lists under it, and
1739
+ **Files**, every file carrying it — drafts, scheduled posts and the trash
1740
+ included. The second is the one to look at before acting, because it is what an
1741
+ action is about to rewrite.
1742
+
1743
+ Three things can be done to a term, and all three are the same operation. Typing
1744
+ a new name into a row's field and pressing Rename rewrites the front matter of
1745
+ every file carrying it. Renaming onto a term that already exists is a **merge**:
1746
+ the screen comes back with a question naming both counts, and only a confirmed
1747
+ form goes through, because a merge cannot be undone by renaming back. A file
1748
+ that carried both terms keeps the target once, in the place it already had.
1749
+ Delete takes the term out of every file and leaves everything else alone.
1750
+
1751
+ Each of them says how many files it wrote. The files are the truth (doc-1), so
1752
+ each file is re-read and re-parsed from disk before it is rewritten rather than
1753
+ being taken from the index: the index can be a moment behind a hand edit, and a
1754
+ bulk rewrite that trusted it would put that edit back. A file that has gone or
1755
+ will not parse is skipped, counted, and named in the message.
1756
+
1757
+ Every rewrite is announced the way an editor save is, so the index, the feeds,
1758
+ the notify server and the fediverse all follow — one `Update(Article)` per
1759
+ affected published post, because the hashtags on those posts have just changed.
1760
+ A term on many published posts is therefore many deliveries; that is the point,
1761
+ but it is worth knowing before renaming a tag that half the archive carries.
1762
+
1763
+ A rename moves an archive, and an archive is a URL somebody may have linked to,
1764
+ so the site records the move in a `taxonomyRedirects` list — `{ taxonomy, from,
1765
+ to }` — in `content/_data/site.json` with the rest of the settings. The
1766
+ old archive URL and its feed then answer `301` to the new ones. Chains are
1767
+ collapsed as they are recorded, so `a → b` followed by `b → c` is stored as
1768
+ `a → c` and answered in one hop; a term that comes back into use is served
1769
+ rather than redirected; and deleting a term drops every record pointing at it,
1770
+ because a redirect to a 404 is worse than the 404.
1771
+
1772
+ ### Login hardening
1773
+
1774
+ `/admin/login` counts failed sign-ins against two keys: the username that was
1775
+ typed, and the address the request came from. Once either has failed
1776
+ `loginAttempts` times — five by default — the admin refuses further attempts
1777
+ from that key for `loginLockout` seconds, fifteen minutes by default, and
1778
+ answers `429` with a `Retry-After` header and a message saying how long is
1779
+ left.
1780
+
1781
+ Three things about that are deliberate:
1782
+
1783
+ - **The lockout is checked before the password is.** A correct password during
1784
+ a lockout is refused too. Verifying first would hand the site to whoever
1785
+ guessed right on the attempt that tripped the limit.
1786
+ - **The wait doubles each time the lockout is tripped again**, up to sixteen
1787
+ times the first one — four hours at the default. There is no artificial pause
1788
+ on an individual attempt: a `sleep` in the handler holds a connection open for
1789
+ as long as it lasts, so it would cost the server more than the attacker.
1790
+ - **The message says nothing about who exists.** A username nobody has is
1791
+ counted and locked out exactly as a real one is, so the lockout cannot be used
1792
+ to enumerate accounts, and a wrong password and an unknown username still read
1793
+ the same.
1794
+
1795
+ A sign-in that works clears both keys, and so does time: a key that stops
1796
+ failing is forgotten. The counts live in memory, so a restart clears them.
1797
+ That is the point — a failed login writes nothing an anonymous caller could
1798
+ grow, and the run of guesses this defends against happens over minutes, which
1799
+ no restart interrupts. Both refusals are logged with the username and the
1800
+ address.
1801
+
1802
+ `trustProxy` decides where the address comes from. It is `false` by default and
1803
+ the address is the socket's own, because anybody may send `X-Forwarded-For`: on
1804
+ a site reached directly, believing it would let one attacker put every guess on
1805
+ a different make-believe address and never be locked out by address at all. Turn
1806
+ it on — and only on — when a reverse proxy in front of the site sets that
1807
+ header; the leftmost entry is then used. With neither available, which is what
1808
+ happens when the app is driven in process rather than served, the username is
1809
+ the only key.
1810
+
1811
+ ### Forgotten passwords
1812
+
1813
+ `/admin/login` carries a **Forgotten your password?** link to `/admin/forgot`,
1814
+ which takes a username _or_ an email address and always answers with the same
1815
+ sentence — whether the name matched, did not match, or matched somebody who has
1816
+ no address on their account. That is the point of the screen: an answer that
1817
+ varied with what it found would be a list of which accounts the site has,
1818
+ handed to anybody who asked for it.
1819
+
1820
+ A match with an address is sent the theme's `password-reset` message, carrying
1821
+ a link to `/admin/reset?token=…`. The token is 256 random bits; the database
1822
+ keeps only its SHA-256, in a table beside the sessions, so a copy of
1823
+ `data/geekity.db` is not a stack of working links, and the token is never
1824
+ rendered into the page of the browser that asked for it. It lasts an hour and
1825
+ works once.
1826
+
1827
+ Setting a password through the link holds it to the same rules every other door
1828
+ does, then closes everything else: the link that was used, every other reset
1829
+ that user had outstanding, and every session they were signed in on. The
1830
+ theme's `password-changed` message goes out afterwards, with no link back in,
1831
+ so a reset somebody did not ask for is something they hear about.
1832
+
1833
+ Requests are rate limited by username and by address exactly as sign-ins are —
1834
+ same limits, same growing lockout, same `429` and `Retry-After` — on a throttle
1835
+ of its own. Sharing the login one would let a stranger lock somebody out of
1836
+ signing in by asking for their password to be reset over and over.
1837
+
1838
+ Reset tokens live in the database, which is a cache: a restart, a
1839
+ `geekity rebuild` or a deleted `data/geekity.db` invalidates every link in
1840
+ flight, and the cost of that is a second click on Forgot password.
1841
+
1842
+ With no mail provider or credential, `/admin/forgot` offers no form at all. It
1843
+ says the site cannot send email and points at
1844
+ [`geekity user add`](#creating-an-admin-from-the-command-line), which is how a
1845
+ site nobody can sign in to gets a fresh admin from a shell. Saving a credential
1846
+ under [Settings → Email](#email) turns the form on for the next visitor, with
1847
+ no restart.
1848
+
1849
+ ### Security headers
1850
+
1851
+ Every response the CMS sends carries `X-Content-Type-Options: nosniff`, and
1852
+ `Strict-Transport-Security: max-age=31536000; includeSubDomains` when `baseUrl`
1853
+ is an `https` URL — the same test that decides whether the session cookie is
1854
+ `Secure`, so the two cannot disagree. That is all the public site gets: a theme
1855
+ is somebody else's HTML and the CMS has no business deciding what it may
1856
+ reference.
1857
+
1858
+ Every response under `/admin`, static files and redirects included, carries
1859
+ three more:
1860
+
1861
+ | Header | Value |
1862
+ | ------------------------- | --------------------------------------------- |
1863
+ | `Referrer-Policy` | `same-origin` |
1864
+ | `X-Frame-Options` | `SAMEORIGIN` |
1865
+ | `Content-Security-Policy` | `default-src 'self'` and the directives below |
1866
+
1867
+ The policy is `'self'` almost everywhere: the stylesheet and the editor bundle
1868
+ come from `/admin/_static/`, the editor's preview and upload calls are
1869
+ same-origin `fetch`, and the preview frame renders the site's own theme and
1870
+ uploads. Four directives say more than that:
1871
+
1872
+ - `style-src 'self' 'nonce-…'` — a fresh nonce on every response. CodeMirror 6
1873
+ mounts its themes as `<style>` elements at runtime, which is an inline style
1874
+ however it is written. The nonce is put on the editor bundle's `<script>` tag
1875
+ and read back through the element's `nonce` property, which a browser keeps
1876
+ after blanking the attribute, and handed to CodeMirror through
1877
+ `EditorView.cspNonce`. So the admin never says `'unsafe-inline'`.
1878
+ - `script-src 'self'` — no nonce and no `'unsafe-inline'`, because there is no
1879
+ inline script in the admin at all. The slug and permalink auto-fill lives in
1880
+ `/admin/_static/slug.js`.
1881
+ - `img-src 'self' data:` — a preview of a post somebody is writing may hold a
1882
+ data URI image, and refusing it would make the preview lie about what
1883
+ publishing would show.
1884
+ - `frame-ancestors 'self'` rather than `'none'` — the editor's preview is a
1885
+ sandboxed `srcdoc` iframe, which inherits this policy, and its one ancestor is
1886
+ the admin page itself.
1887
+
519
1888
  ## The public site
520
1889
 
521
1890
  Booting mounts the public site on the app. The routes are:
522
1891
 
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.
1892
+ | Route | What it serves |
1893
+ | --------------------------------------- | --------------------------------------------------------------------------------------------------- |
1894
+ | `/` | Published posts, newest first. |
1895
+ | `/page/2/` and up | Later pages of the same archive. |
1896
+ | a document's permalink | The post or the page, through the theme. |
1897
+ | `/tag/{tag}/` | Everything published carrying that tag, paginated at `/tag/{tag}/page/2/`. |
1898
+ | `/category/{name}/` | The second taxonomy, paginated the same way. |
1899
+ | `/author/{username}/` | One user's published posts, headed by their profile, paginated the same way. |
1900
+ | `/feed/`, `/feed/atom/`, `/feed/json/` | The recent posts as RSS 2.0, Atom and JSON Feed. |
1901
+ | `/tag/{tag}/feed/` and its two siblings | The same, for one tag; `/category/{name}/feed/` likewise. |
1902
+ | `/author/{username}/feed/` and siblings | The same, for one person. |
1903
+ | `/comments/feed/` | Every reply the inbox has been sent, as RSS 2.0. |
1904
+ | `{permalink}feed/` | One post's replies, the same way. |
1905
+ | `/sitemap.xml` | Every public URL, for a search engine. |
1906
+ | `/sitemap-{n}.xml` | One file of a sitemap too big to be a single one. |
1907
+ | `/robots.txt` | What a crawler may have, and where the sitemap is. |
1908
+ | `/_geekity/comments` | `POST` only. Where the comment form under a post submits. |
1909
+ | `/_geekity/contact` | `POST` only. Where the contact form on a page submits. |
1910
+ | `/_geekity/webmention` | `POST` only. Where a webmention is sent; advertised on every document. |
1911
+ | `/_geekity/moderate` | Where an approve, spam or delete link from a notification lands. `GET` shows a button; `POST` acts. |
1912
+ | `/_geekity/unsubscribe` | Where the unsubscribe link in a reply notice lands. Same two steps. |
1913
+ | `/theme/…` | The theme's own files, from its `static/` directory. |
1914
+ | `/uploads/…` | A file from `content/uploads/`, byte for byte as it was stored. |
1915
+ | `/uploads/_/…` | One derived copy of an uploaded image, generated on the spot if missing. |
1916
+ | anything else | The theme's 404. |
1917
+
1918
+ The federation routes go on before it, and answer only their own paths:
1919
+
1920
+ | Route | What it serves |
1921
+ | ------------------------------- | ------------------------------------------------------------------------------------- |
1922
+ | `/author/{username}/` | The `Person` on an ActivityStreams request; the archive above to anything else. |
1923
+ | `/author/{username}/inbox/` | `POST` only. That user's inbox, signature-verified by Fedify. |
1924
+ | `/author/{username}/outbox/` | Their published posts as `Create` activities, paged 20 at a time. |
1925
+ | `/author/{username}/followers/` | Who follows them, paged 20 at a time. |
1926
+ | `/author/{username}/following/` | Always empty: a relay is a subscription rather than a relationship. |
1927
+ | `/inbox/` | `POST` only. The instance-wide shared inbox; the addressee is read out of the body. |
1928
+ | `/@{username}` | A 301 to their archive. |
1929
+ | `/.well-known/webfinger` | The handle, the author URL or `/@{username}`, all resolving to the same actor. |
1930
+ | `/.well-known/nodeinfo` | A link to the NodeInfo document. |
1931
+ | `/nodeinfo/2.1` | What software this is, how many users and how many posts. |
1932
+ | a post's permalink | The `Article` on an ActivityStreams request (decision-13); the page to anything else. |
1933
+
1934
+ Drafts, documents in the trash and posts whose date has not arrived are not on
1935
+ the public site: their URLs 404, and they are in no listing.
536
1936
 
537
1937
  Trailing slashes are canonical. A request that arrives without one redirects
538
1938
  301 to the URL with it, but only when that URL resolves — a genuinely missing
@@ -541,7 +1941,57 @@ address 404s straight away rather than bouncing first. `/page/1/` redirects to
541
1941
 
542
1942
  A document is resolved after every registered route has failed to match, so
543
1943
  routes a site adds always win over a permalink that happens to collide with
544
- them.
1944
+ them. The taxonomy archives are resolved there too, ahead of the documents: a
1945
+ route table is fixed when the app is built and the archive bases are a setting,
1946
+ so `/{tagBase}/{tag}/` is matched against whatever the site holds at the moment
1947
+ of the request.
1948
+
1949
+ `tag` and `category` are the bases a site has until it says otherwise —
1950
+ WordPress's own, so a site imported from it keeps every archive URL it
1951
+ published. `tagBase` and `categoryBase` on the Permalinks settings page move
1952
+ them, and
1953
+ the routes, the paging, the canonical redirects, the tag feeds, the theme's
1954
+ links and the ActivityStreams hashtags all follow on the next request. A base
1955
+ is one URL-safe path segment: no slashes, and not a path the site already
1956
+ answers on (`page`, `feed`, `comments`, `admin`, `theme`, `uploads`,
1957
+ `nodeinfo`, `author`, `inbox`), and
1958
+ the two may not be the same word. Both are `tagBase` and `categoryBase` in
1959
+ `content/_data/site.json`, so an Eleventy build of the same content directory
1960
+ can put its archives at the same URLs.
1961
+
1962
+ ### Navigation
1963
+
1964
+ Every page carries the site menu, which the theme renders in the header. It is
1965
+ two things joined:
1966
+
1967
+ 1. The `navigation` setting, edited on `/admin/settings/reading` as one
1968
+ `Label | URL`
1969
+ per line — `About | /about/`, `Mastodon | https://example.social/@me` — in
1970
+ the order it is typed. The URL is a site-root path or an absolute
1971
+ `http(s)` URL; anything else is refused with the offending line quoted.
1972
+ 2. Every published page whose front matter says `navigation: true`, ordered by
1973
+ `navigationOrder` and then by title. A page that names no order sorts after
1974
+ every page that does, and the flagged pages always come after the items the
1975
+ setting names.
1976
+
1977
+ The editor writes both keys: **Show in navigation** on a page's editor writes
1978
+ `navigation: true`, **Menu order** writes `navigationOrder`, and clearing the
1979
+ box takes both back out of the file. Posts have neither field — a post is in
1980
+ the archive and in the feeds, which is where a post belongs.
1981
+
1982
+ Templates read it as `menu`, a list of `{ label, url, current }`, with
1983
+ `current` true for the item whose path is the one being rendered. It is `menu`
1984
+ rather than `navigation` because `navigation` is the front-matter key a page
1985
+ opts in with, and a document's own front matter goes on top of the globals as
1986
+ Eleventy's data cascade does. The scheduled, drafted and trashed pages are not
1987
+ in it, for the same reason they are not on the site.
1988
+
1989
+ The setting is `navigation` in `content/_data/site.json`, a list of
1990
+ `{ label, url }`, so an Eleventy build renders the same menu; the example
1991
+ config assembles it as `collections.menu`. A `navigation` in a hand-edited
1992
+ `site.json` that is not a list of items yields an empty menu rather than an
1993
+ error, exactly as a bad archive base falls back rather than taking the site
1994
+ down.
545
1995
 
546
1996
  ## Content negotiation
547
1997
 
@@ -554,7 +2004,7 @@ three; a listing has two.
554
2004
  | `text/markdown` | `.md` | The file exactly as it is stored, front matter included. |
555
2005
  | `application/json` | `.json` | The document as data, see below. |
556
2006
 
557
- Listings — `/`, `/page/N/`, `/tags/{tag}/` and `/tags/{tag}/page/N/` — offer
2007
+ Listings — `/`, `/page/N/`, `/tag/{tag}/` and `/tag/{tag}/page/N/` — offer
558
2008
  HTML and JSON only. There is no Markdown file behind a listing to serve.
559
2009
 
560
2010
  The representation is chosen like this:
@@ -666,17 +2116,32 @@ federation middleware before the negotiator sees them.
666
2116
 
667
2117
  ## Feeds
668
2118
 
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 |
2119
+ The recent posts are syndicated in three formats, at the URLs WordPress uses,
2120
+ so a site moving off it keeps every subscriber it had:
2121
+
2122
+ | URL | Format |
2123
+ | ------------------------ | ------------------------------------------------ |
2124
+ | `/feed/` | RSS 2.0 (`application/rss+xml`) |
2125
+ | `/feed/atom/` | Atom 1.0 (`application/atom+xml`) |
2126
+ | `/feed/json/` | JSON Feed 1.1 (`application/feed+json`) |
2127
+ | `/tag/{tag}/feed/` | One tag archive, in RSS; `atom/` and `json/` too |
2128
+ | `/category/{name}/feed/` | One category archive, the same three |
2129
+ | `/comments/feed/` | Every reply the site has been sent, in RSS |
2130
+ | `{permalink}feed/` | One post's replies, in RSS |
2131
+
2132
+ `/feed/` is RSS because that is the format nearly every existing subscriber
2133
+ holds. The two archive bases are settings (`tagBase`, `categoryBase`), so the
2134
+ per-archive feeds follow wherever the archives live.
2135
+
2136
+ WordPress's older spellings redirect `301` rather than 404: `/feed/rss/` to
2137
+ `/feed/`, and the query forms `?feed=rss2`, `?feed=rss`, `?feed=atom` and
2138
+ `?feed=json` on any listing to that listing's feed, and `?feed=rss2` or
2139
+ `?feed=rss` on a post's permalink to that post's comments feed. A feed URL that
2140
+ arrives without its trailing slash redirects to the canonical one in a single
2141
+ hop.
677
2142
 
678
2143
  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
2144
+ trash and pages are never in one, and a term nothing published carries 404s
680
2145
  rather than serving an empty feed. `feedSize` in `content/_data/site.json` sets
681
2146
  how many entries a feed holds; without it a feed holds 20, which is deliberately
682
2147
  more than an archive page, so a reader that polls once a day does not miss a
@@ -686,56 +2151,248 @@ post on a site that publishes several.
686
2151
  { "title": "My Site", "tagline": "Notes", "author": "Me", "feedSize": 20 }
687
2152
  ```
688
2153
 
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.
2154
+ ### What every format says about a post
2155
+
2156
+ The three formats render one **feed item**, derived once per post, rather than
2157
+ each reading the file for itself. So they agree about the three things a reader
2158
+ actually keys on.
2159
+
2160
+ **Identity.** A post is named by its ActivityStreams object id, which is its
2161
+ permalink (see [Federation](#federation)) or the `activitypub.id` the front
2162
+ matter stores for a post migrated from elsewhere. That id is RSS's `guid`,
2163
+ Atom's `id` and JSON Feed's `id`. The permalink is always the link: RSS's
2164
+ `link`, Atom's `link rel="alternate"` and JSON Feed's `url`. The two are the
2165
+ same URL for every post born on this CMS, and differ only for one carrying a
2166
+ stored id — which is what lets a migrated post keep the name its WordPress
2167
+ subscribers already hold. RSS's `isPermaLink` says which of the two a `guid`
2168
+ is: `true` when it is the permalink, `false` for a stored id like `?p=813`.
2169
+
2170
+ **Terms.** Every format lists the post's categories and then its tags, in file
2171
+ order, as one flat list: RSS's `category` elements, Atom's `category term`
2172
+ attributes, JSON Feed's `tags`. None of the three can say which vocabulary a
2173
+ term came from, which is how WordPress publishes both too.
2174
+
2175
+ **Summary.** Every format summarises a post the same way: the `description`
2176
+ front matter when the post has one — it is what the author wrote for exactly
2177
+ this — and otherwise the first paragraph of the rendered body, stripped to
2178
+ plain text and cut at 55 words, WordPress's own excerpt length. It is RSS's
2179
+ `description`, Atom's `summary` and JSON Feed's `summary`, and it is left out
2180
+ only when there is nothing to summarise at all. The whole post goes in the
2181
+ content element beside it, so a reader that shows both has something to choose
2182
+ between.
2183
+
2184
+ ### RSS 2.0
2185
+
2186
+ The channel carries `title`, `link`, `description` (the tagline), `language`
2187
+ (the `language` setting, `en` by default), `lastBuildDate`, `generator`, an
2188
+ `atom:link rel="self"`, the three notify-server elements described under
2189
+ [Real-time notification](#real-time-notification), and an `image` built from the
2190
+ avatar when the site has one.
2191
+
2192
+ An item carries `title`, `link`, `guid`, `pubDate` in RFC 822, `dc:creator` from
2193
+ the post's author or the site's, one `category` per term, `description` holding
2194
+ the summary, `content:encoded` holding the whole rendered post, and
2195
+ `source:markdown` holding the Markdown the post was written from. The `guid`,
2196
+ the terms and the summary are the ones described above.
2197
+
2198
+ `source:markdown` is Dave Winer's [source namespace][source-ns]: a reader that
2199
+ understands Markdown should render from it rather than from `content:encoded`.
2200
+ It is the same text the ActivityStreams `Article` carries as its `source`.
2201
+
2202
+ Every item also says where its comments are, three ways: `comments` is the page
2203
+ to read them on, `wfw:commentRss` (the [Well-Formed Web][wfw-ns] comment API) is
2204
+ the feed to poll, and `source:comments` is that same feed with a `count`
2205
+ attribute, so a reader can say "3 comments" without fetching anything.
2206
+
2207
+ [source-ns]: https://source.scripting.com/
2208
+ [wfw-ns]: http://wellformedweb.org/CommentAPI/
2209
+
2210
+ ### Comments
2211
+
2212
+ This CMS stores no comments of its own. What it has instead is the fediverse
2213
+ replies its inbox has been sent: a `Create` of a `Note` whose `inReplyTo` names
2214
+ a post's ActivityStreams object id (see [Federation](#federation)). Those are
2215
+ what the comments feeds publish, at the URLs WordPress publishes its own at —
2216
+ `{permalink}feed/` for one post, `/comments/feed/` for the whole site.
2217
+
2218
+ Both are RSS 2.0 and nothing else: a comments feed has no Atom or JSON spelling
2219
+ here, so `{permalink}feed/atom/` 404s. A post with no replies answers an empty
2220
+ feed rather than a 404 — it exists, and a reader that subscribed before anybody
2221
+ answered should keep polling — while a permalink that is no published post 404s
2222
+ like any other. A page has no comments feed at all: only posts federate, so
2223
+ nothing can ever have replied to one.
2224
+
2225
+ A channel carries the usual `title` (`Comments on: {post}`), `link`,
2226
+ `description`, `language`, `lastBuildDate`, `generator`, `atom:link rel="self"`
2227
+ and the same notify-server elements every other feed carries. An item carries the author's name as its `title` and `dc:creator`,
2228
+ the reply's `url` as its `link` and its id as `guid isPermaLink="false"`, the
2229
+ `published` time the note gave (or when it arrived, if it gave none),
2230
+ `description` holding a plain-text excerpt and `content:encoded` holding the
2231
+ note. On the site-wide feed the title names the post as well: `{author} on
2232
+ {post}`.
2233
+
2234
+ The author's name is the profile the site stored when that actor followed it,
2235
+ and otherwise the `@user@host` the actor's own URL implies. Naming them
2236
+ properly would mean dereferencing the actor, which is a network round trip per
2237
+ comment shown.
2238
+
2239
+ **The note's HTML is sanitised before it is published.** It is markup a
2240
+ stranger wrote, so it is tokenised and rebuilt from an allowlist rather than
2241
+ passed through: `p`, `br`, `a`, `em`, `strong`, `del`, `code`, `pre`,
2242
+ `blockquote` and the list elements survive; `script` and `style` are dropped
2243
+ along with their contents; every other element is unwrapped, keeping its text;
2244
+ every attribute goes except an `a`'s `href`, which must be `http`, `https` or
2245
+ `mailto` and is marked `rel="nofollow noopener noreferrer"`. `sanitizeCommentHtml`
2246
+ is exported for a site that shows comments in its own templates.
2247
+
2248
+ A reply to a post that has since been unpublished or moved to the trash
2249
+ disappears from `/comments/feed/`, and that post's own feed 404s with the post.
2250
+ `feedSize` caps both feeds.
2251
+
2252
+ ### Atom and JSON Feed
2253
+
2254
+ An Atom entry carries `id` (the post's object id), `title`, `updated`,
2255
+ `published`, `link rel="alternate"` (the permalink), an `author` when the front
2256
+ matter names one, a `category` per term, a `summary`, and the whole rendered
2257
+ post as `content type="html"`. The feed itself carries `id`, `title`,
2258
+ `subtitle` from the site's tagline, `updated`, `link rel="self"`,
2259
+ `link rel="alternate"` to the HTML page, a `generator`, an
2260
+ `xml:lang` from the `language` setting, and — when the site names a notify
2261
+ server — a `source:cloud` and a `link rel="hub"`.
2262
+
2263
+ A JSON Feed item carries `id` (the object id), `url` (the permalink), `title`,
2264
+ `content_html`, `summary`, `date_published`, `date_modified`, `tags` (the
2265
+ terms) and `authors`; the feed carries `version`, `title`, `home_page_url`,
2266
+ `feed_url`, `description`, `authors` and `hubs`. Keys with nothing behind them
2267
+ are left out rather than sent empty.
2268
+
2269
+ The XML is written by this package rather than by a library. Text is escaped;
2270
+ `content:encoded` and `source:markdown` are CDATA sections, with any `]]>` in
2271
+ the text split across two sections so it cannot end one early.
2272
+
2273
+ ### Real-time notification
2274
+
2275
+ A feed reader that polls hears about a post when it next polls. An [rssCloud][]
2276
+ or [WebSub][] server turns that around: the reader registers with the server,
2277
+ the site tells the server when a feed changes, and the server tells every
2278
+ subscriber at once.
2279
+
2280
+ One setting, `notifyServer`, is the whole of it. It defaults to
2281
+ `https://rpc.rsscloud.io`, which speaks both protocols, and everything else is
2282
+ derived from it: `{notifyServer}/pleaseNotify` is where a subscriber registers,
2283
+ `{notifyServer}/websub` is the hub, and `{notifyServer}/ping` is where this site
2284
+ says a feed changed. Emptying the setting removes every element, every header
2285
+ and every ping; a different URL moves all of them.
2286
+
2287
+ **Advertising.** An RSS channel carries all three spellings, so a reader of any
2288
+ age finds one it understands:
2289
+
2290
+ ```xml
2291
+ <cloud domain="rpc.rsscloud.io" port="80" path="/pleaseNotify"
2292
+ registerProcedure="" protocol="http-post"/>
2293
+ <source:cloud>https://rpc.rsscloud.io/pleaseNotify</source:cloud>
2294
+ <atom:link rel="hub" href="https://rpc.rsscloud.io/websub"/>
2295
+ ```
697
2296
 
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.
2297
+ The port and protocol of the legacy `<cloud>` are `80` and `http-post` whatever
2298
+ scheme the server is actually reached over, because that is what
2299
+ [rpc.rsscloud.io's quick start][quick-start] prescribes and what a 2001
2300
+ aggregator expects to read. An Atom feed carries the `source:cloud` and the
2301
+ `rel="hub"` link but no `<cloud>`, which is an RSS 2.0 element with no namespace.
2302
+ A JSON Feed carries JSON Feed 1.1's `hubs`:
702
2303
 
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.
2304
+ ```json
2305
+ { "hubs": [{ "type": "WebSub", "url": "https://rpc.rsscloud.io/websub" }] }
2306
+ ```
2307
+
2308
+ And every feed response in every format — the site's, each archive's and both
2309
+ comments feeds — carries WebSub's discovery header, on the `304` as well as on
2310
+ the body, because a poller mostly gets the `304`:
2311
+
2312
+ ```
2313
+ Link: <https://rpc.rsscloud.io/websub>; rel="hub", <https://example.com/feed/>; rel="self"
2314
+ ```
2315
+
2316
+ **Pinging.** The server notifies nobody until it hears the feed changed. So when
2317
+ a post is published, edited or withdrawn — the same index changes that drive
2318
+ ActivityPub delivery, and never a full scan — the site posts
2319
+ `url={feed}` to `{notifyServer}/ping`, once for each feed whose contents moved:
2320
+ the three site feeds, and the three feeds of every tag and category the post
2321
+ carried before _and_ after the change, because a post that left a tag changed
2322
+ that tag's feed as much as the one it joined.
2323
+
2324
+ Pings are best effort. They run one after another on a queue of their own, a
2325
+ repeated URL is sent once, each is given ten seconds, and a refusal is logged
2326
+ rather than thrown: a notify server that is down costs a line in the log, never
2327
+ a save. A site can ping a feed of its own:
2328
+
2329
+ ```js
2330
+ await cms.notifyFeeds(['https://example.com/podcast/feed/']);
2331
+ ```
2332
+
2333
+ `cms.notifier` is the service behind it — `notify(urls)`, `feedsFor(change)`,
2334
+ `handle(change)` and `settled()` — already subscribed to the index.
2335
+
2336
+ [rsscloud]: https://rpc.rsscloud.io/docs
2337
+ [websub]: https://www.w3.org/TR/websub/
2338
+ [quick-start]: https://rpc.rsscloud.io/docs/quick-start
2339
+
2340
+ ### Caching and discovery
2341
+
2342
+ Every feed carries `ETag`, `Last-Modified` and `Cache-Control: no-cache`, and
2343
+ answers `If-None-Match` and `If-Modified-Since` with `304`. The validator covers
2344
+ the feed's metadata as well as its entries, and each format and each scope gets
2345
+ its own, so a reader holding the RSS feed is never told the Atom one is
2346
+ unchanged. Feeds are validated even while `watch` is on, because a feed is not
2347
+ rendered through the theme.
2348
+
2349
+ The validator also covers the revision of the item format itself, so a release
2350
+ that changes what a feed says about a post moves every post feed's `ETag` once
2351
+ rather than leaving a polling reader with a `304` that hides the new bytes.
709
2352
 
710
2353
  ```sh
711
- curl -i https://example.com/feed.xml
712
- curl -i https://example.com/tags/releases/feed.json
2354
+ curl -i https://example.com/feed/
2355
+ curl -i https://example.com/tag/releases/feed/json/
713
2356
  ```
714
2357
 
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:
2358
+ Every page of the default theme advertises all three feeds in its `<head>`, RSS
2359
+ first, then the site's comments feed; an archive advertises that archive's three
2360
+ as well, and a post its own comments feed (the renderer puts that URL in
2361
+ `commentsFeed`, so a layout that overrides `post.njk` keeps it). A theme that
2362
+ does not extend `layouts/base.njk` should emit them itself:
718
2363
 
719
2364
  ```html
2365
+ <link
2366
+ rel="alternate"
2367
+ type="application/rss+xml"
2368
+ title="My Site"
2369
+ href="/feed/"
2370
+ />
720
2371
  <link
721
2372
  rel="alternate"
722
2373
  type="application/atom+xml"
723
2374
  title="My Site"
724
- href="/feed.xml"
2375
+ href="/feed/atom/"
725
2376
  />
726
2377
  <link
727
2378
  rel="alternate"
728
2379
  type="application/feed+json"
729
2380
  title="My Site"
730
- href="/feed.json"
2381
+ href="/feed/json/"
2382
+ />
2383
+ <link
2384
+ rel="alternate"
2385
+ type="application/rss+xml"
2386
+ title="My Site comments"
2387
+ href="/comments/feed/"
731
2388
  />
732
2389
  ```
733
2390
 
734
2391
  The builders are exported, so a site can write a feed of its own — one
735
- category, one author — without reimplementing either format:
2392
+ category, one author — without reimplementing any of the three formats:
736
2393
 
737
2394
  ```ts
738
- import { atomFeed, createRenderer, jsonFeed } from '@geekity/cms';
2395
+ import { atomFeed, createRenderer, jsonFeed, rssFeed } from '@geekity/cms';
739
2396
 
740
2397
  const site = createRenderer({ config: cms.config }).site();
741
2398
 
@@ -743,31 +2400,156 @@ const source = {
743
2400
  site,
744
2401
  documents: cms.store.listByTag('releases', { limit: 20 }),
745
2402
  title: `${site.title}: releases`,
746
- href: '/tags/releases/',
747
- feedHref: '/tags/releases/feed.xml',
2403
+ href: '/tag/releases/',
2404
+ feedHref: '/tag/releases/feed/',
748
2405
  baseUrl: cms.config.baseUrl,
749
2406
  };
750
2407
 
2408
+ rssFeed(source); // a string
751
2409
  atomFeed(source); // a string
752
2410
  jsonFeed(source); // a JSON Feed object
753
2411
  ```
754
2412
 
2413
+ `commentsRssFeed` is the same for comments, and what goes in it comes from the
2414
+ conversation reader — the one thing that reads the inbox log and the comment
2415
+ index to show somebody what has been said, so a feed and the page under the
2416
+ post carry the same entries:
2417
+
2418
+ ```ts
2419
+ import {
2420
+ commentsRssFeed,
2421
+ createConversation,
2422
+ feedComments,
2423
+ } from '@geekity/cms';
2424
+
2425
+ const conversation = createConversation({
2426
+ admin: cms.admin,
2427
+ store: cms.store,
2428
+ baseUrl: cms.config.baseUrl,
2429
+ });
2430
+
2431
+ commentsRssFeed({
2432
+ site,
2433
+ comments: feedComments(conversation.latest(20), {
2434
+ baseUrl: cms.config.baseUrl,
2435
+ limit: 20,
2436
+ }),
2437
+ title: `${site.title}: comments`,
2438
+ href: '/',
2439
+ feedHref: '/comments/feed/',
2440
+ baseUrl: cms.config.baseUrl,
2441
+ });
2442
+ ```
2443
+
2444
+ `conversation.thread(document)` is the same reading for one post — the shape the
2445
+ theme's `conversation.njk` is handed — `spokenIn` flattens it into the entries a
2446
+ feed carries, and `conversation.counts(documents)` is the number `source:comments`
2447
+ puts beside each item of a post feed.
2448
+
2449
+ ## Sitemap and robots.txt
2450
+
2451
+ Two more fixed routes, at the two paths a crawler goes looking for on its own.
2452
+
2453
+ `/sitemap.xml` is every URL the public site publishes, in the
2454
+ [sitemap protocol's](https://www.sitemaps.org/protocol.html) `<urlset>`:
2455
+
2456
+ - the home archive and each of its pages, `/`, `/page/2/` and so on;
2457
+ - every published post and every page, at its permalink;
2458
+ - every tag archive and every category archive, with each of their pages,
2459
+ under whatever bases the site is configured with.
2460
+
2461
+ Each `<loc>` is absolute, built on `baseUrl`. `<lastmod>` is a document's
2462
+ `updated` when it has one and its `date` otherwise, and for a listing page it
2463
+ is the newest of those among the documents on that page. Something nothing
2464
+ dates — a page with no date in its front matter — carries no `<lastmod>`
2465
+ rather than an invented one.
2466
+
2467
+ Nothing hidden reaches it. The sitemap is drawn from the same queries the
2468
+ listings are and checked against the same `isPublicDocument`, so drafts, the
2469
+ trash and posts whose date has not arrived are absent for the same reason
2470
+ their permalinks 404.
2471
+
2472
+ The protocol caps one file at 50,000 URLs. Past that, `/sitemap.xml` becomes a
2473
+ `<sitemapindex>` naming `/sitemap-1.xml`, `/sitemap-2.xml` and so on, each
2474
+ holding its own slice and dated by the newest URL in it. The address a search
2475
+ engine holds does not change, which is the point of the index living there.
2476
+ While the whole sitemap fits in one file the children name nothing and 404.
2477
+
2478
+ `/robots.txt` is short:
2479
+
2480
+ ```
2481
+ User-agent: *
2482
+ Disallow: /admin/
2483
+
2484
+ Sitemap: https://example.com/sitemap.xml
2485
+ ```
2486
+
2487
+ Everything public is crawlable; only the admin is not. The actors and their
2488
+ collections are deliberately left open — an actor and an object exist to be
2489
+ fetched, they carry the same
2490
+ content as the pages that link to them, and a crawler that follows one gets
2491
+ JSON it will ignore.
2492
+
2493
+ Both are registered routes rather than anything resolved from the content
2494
+ index, so a document permalinked at `/sitemap.xml` cannot take the URL a
2495
+ search engine polls, and both carry an `ETag` and answer a conditional request
2496
+ with 304 the way the feeds do — the sitemap with a `Last-Modified` as well,
2497
+ robots without one, because nothing dates it.
2498
+
2499
+ Nothing links the sitemap from a page: `robots.txt` names it, which is where a
2500
+ crawler looks.
2501
+
755
2502
  ## Theme overrides
756
2503
 
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
2504
+ A site's themes live one directory per theme under `themesDir` (`themes/` by
2505
+ default), each with a `theme.json` naming it:
2506
+
2507
+ ```json
2508
+ {
2509
+ "name": "Midnight",
2510
+ "kind": "site",
2511
+ "description": "Dark, quiet, mostly type."
2512
+ }
2513
+ ```
2514
+
2515
+ `name` is what a person reads, `kind` is `site` — the only kind there is, and
2516
+ the field is in the file so another can be added later without the format
2517
+ changing — and `description` is optional. The directory name is the theme's id.
2518
+
2519
+ One setting in `content/_data/site.json`, `theme`, says which one the site is
2520
+ wearing, and that id is what it holds:
2521
+
2522
+ ```json
2523
+ { "theme": "midnight" }
2524
+ ```
2525
+
2526
+ **Appearance > Themes** in the admin is where the choice is made. It lists the
2527
+ theme inside this package and every folder under `themesDir` with its name and
2528
+ description, marks the one in use, and Activate on another writes the setting —
2529
+ or, on the packaged theme, takes the key out of `site.json` again, which is why
2530
+ a site wearing it has no `theme` key rather than an empty one. A folder whose
2531
+ `theme.json` is missing, unparseable or names another kind is listed under
2532
+ "Not themes" with the reason, so a typo is something you can see on the screen
2533
+ that would otherwise have shown the theme. Activating takes effect on the next
2534
+ request, with no restart. Editing the setting by hand does exactly the same
2535
+ thing.
2536
+
2537
+ A template is then looked up in that theme first and in the theme that ships
2538
+ inside this package second, file by file. Sites override one template at a time
759
2539
  and keep receiving updates to the rest. Files under `/theme/` resolve the same
760
- way, so `theme/static/style.css` replaces the packaged stylesheet.
2540
+ way, so `themes/midnight/static/style.css` replaces the packaged stylesheet,
2541
+ and the [email messages](#messages) under `mail/` resolve the same way too.
761
2542
 
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.
2543
+ Neither the setting nor `themesDir` need exist. A site that names no theme
2544
+ serves every page, the 404 and the stylesheet out of the packaged theme, and an
2545
+ unchosen theme sitting in `themes/` changes nothing. A `theme` naming a
2546
+ directory that is not there, or one whose manifest will not read, falls back to
2547
+ the packaged theme with a warning in the log rather than a broken site.
766
2548
 
767
- A site that ships only
2549
+ A theme that ships only
768
2550
 
769
2551
  ```
770
- theme/layouts/post.njk
2552
+ themes/midnight/layouts/post.njk
771
2553
  ```
772
2554
 
773
2555
  replaces the post layout; the base layout, the archive, the tag pages and the
@@ -783,11 +2565,40 @@ can extend a packaged one by name:
783
2565
  {% endblock %}
784
2566
  ```
785
2567
 
2568
+ The admin is not themed. Its templates and its static files live in a tree of
2569
+ their own with a loader of their own, deliberately off this search path, so no
2570
+ theme can shadow the login form or the CSRF field inside it.
2571
+
2572
+ The packaged theme is the andrewshell.org design (decision-16). `base.njk` is
2573
+ its shell: a skip link, a `.global-wrapper` carrying `data-is-root-path="true"`
2574
+ at `/` only, a header that is the site title and tagline on the front page and a
2575
+ small link home on every other, `<main id="main">`, and a footer with the
2576
+ copyright year, the site author, the colophon, an RSS link and one `rel="me"`
2577
+ link per link on the site author's profile. Nothing particular to one site is in
2578
+ it — webrings and badges belong in a site theme's `footer` block. Its head
2579
+ carries a description, Open Graph and Twitter card tags, icons derived from the
2580
+ site's avatar, and one JSON-LD `@graph` from `partials/jsonld.njk`: that partial
2581
+ is all the structured data the theme emits, there is no Microdata anywhere, and
2582
+ a site that wants a different graph replaces the one file. The stylesheet
2583
+ is a serif body and sans headings at an 18px root, one column at 42rem, warm
2584
+ paper with a rust primary and a blue secondary, and a second scheme under
2585
+ `prefers-color-scheme: dark` that redefines the same `--color-*` tokens on dark
2586
+ paper. Both meet WCAG 2.2 AA, checked by a test that reads the custom properties
2587
+ out of the stylesheet and computes the ratios.
2588
+
786
2589
  The context mirrors what an Eleventy layout receives — `title`, `date`, `tags`,
787
2590
  `content`, `page.url`, and every front matter key the file carried — plus
788
2591
  `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).
2592
+ The one key that is not the front matter's own string is `author`: it is the
2593
+ person the file names, resolved against the site's users, with `author.name` to
2594
+ print and `author.url` — their archive at `/author/{username}/` — to link to
2595
+ when the name is one of them. `siteAuthor` is the same object answering a
2596
+ different question — who the page in front of the reader is by, which is the
2597
+ entry's author on a document, the archive's person on an author archive and the
2598
+ profile behind the site's `author` setting everywhere else — so the bio, the
2599
+ `rel="me"` links and any structured data a theme emits all read one profile.
2600
+ The full table of context keys, blocks and
2601
+ filters is in [`themes/default/README.md`](./themes/default/README.md).
791
2602
 
792
2603
  Rendering is also callable without a request:
793
2604
 
@@ -803,6 +2614,27 @@ renderer.site(); // content/_data/site.json, defaults filled in
803
2614
 
804
2615
  Handlers reach the same renderer as `c.var.renderer`.
805
2616
 
2617
+ ### Moving a `theme/` directory
2618
+
2619
+ Before named themes a site had one `theme/` directory, always on the search
2620
+ path, pointed at by `themeDir` and `GEEKITY_THEME_DIR`. Both are gone and
2621
+ neither is aliased, so a site carrying one moves it:
2622
+
2623
+ ```sh
2624
+ mkdir -p themes/mine
2625
+ git mv theme/* themes/mine/
2626
+ rmdir theme
2627
+ ```
2628
+
2629
+ Then write `themes/mine/theme.json` with a `name` and `"kind": "site"`, choose
2630
+ it on **Appearance > Themes** (or put `"theme": "mine"` in
2631
+ `content/_data/site.json`), and drop `themeDir` from `geekity.config.ts` and
2632
+ `GEEKITY_THEME_DIR` from the environment — a config naming `themeDir` is now a
2633
+ type error and the variable is ignored. Nothing inside the theme changes: the
2634
+ layouts, partials, `mail/` and `static/` keep their names and their meaning.
2635
+ Until it is chosen the site serves the packaged theme, so the move and the
2636
+ choice belong in the same deploy.
2637
+
806
2638
  ## Building the same content with Eleventy
807
2639
 
808
2640
  The content directory is an [Eleventy](https://www.11ty.dev) 3 input directory.
@@ -820,14 +2652,58 @@ npx @11ty/eleventy
820
2652
  ```
821
2653
 
822
2654
  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:
2655
+ the rules the CMS follows that Eleventy does not know about on its own:
2656
+
2657
+ | Rule | How the config does it |
2658
+ | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2659
+ | `draft: true` hides a document | An `addPreprocessor` that returns `false` for it. `BUILD_DRAFTS=1` builds drafts anyway, for a local preview. |
2660
+ | 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. |
2661
+ | `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/`. |
2662
+ | `content/_data/federation/` is data | `followers.json` is an ordinary data file in a namespaced `_data` subdirectory, so Eleventy hands it over as `federation.followers` without being told. The monthly inbox logs are JSON Lines, which Eleventy has no reader for, so the config registers one with `addDataExtension('jsonl', …)`: each month becomes an entry of `federation.inbox`, keyed by its `{yyyy}-{mm}` name. |
2663
+ | `content/_data/comments/` is the same thread | The conversation under a post is not only the inbox log: the approved comments in `content/_data/comments/{slug}.json` thread with it. The `conversation` filter takes the post's slug as a second argument and reads that file directly, rather than through the data cascade — a namespaced `_data/comments/` directory would arrive as a global called `comments`, and `comments: true` in a post's front matter would shadow it on exactly the pages that need it. The slug is on the context as `geekitySlug`. |
2664
+ | `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. |
2665
+ | Dates are shown in the site's `timezone` | A `date` filter with the CMS's four formats: `readable`, `html` and `year` are the calendar the site's zone was on at the instant, `iso` is the instant in UTC. The zone is read from `content/_data/site.json`, which the settings screen writes, so a zone changed in the CMS changes the built pages too. |
2666
+ | A future `date` holds a post back | An `addPreprocessor` that returns `false` for it. This is the one rule that cannot be exactly the same in both places: a build has no clock, only a moment. A scheduled post is left out of the build that runs before its date and is in the next build after it, so a scheduled site needs a build on a schedule; the CMS publishes it on the date by itself. `BUILD_SCHEDULED=1` builds them anyway. |
2667
+
2668
+ It also builds two collections Eleventy has no notion of. `collections.categories`
2669
+ is the second taxonomy, one entry of `{ name, posts }` per category in use, for
2670
+ paginating into archives at `/{{ site.categoryBase }}/{name}/`.
2671
+ `collections.menu` is the site menu — the `navigation` array of `site.json`
2672
+ followed by the pages whose front matter says `navigation: true` — as
2673
+ `{ label, url }` entries in the order the header should render them; a layout
2674
+ marks the current one itself by comparing `item.url` with `page.url`, because a
2675
+ collection is built once for the whole site.
2676
+
2677
+ The `date` filter is the one that needs a word. The files hold UTC instants and
2678
+ the `timezone` setting decides how they read, so the config's filter takes the
2679
+ zone from `content/_data/site.json` — the file the settings screen writes —
2680
+ and renders with `Intl`, which keeps the file free of dependencies. If your site
2681
+ already uses [Luxon](https://moment.github.io/luxon/), which Eleventy ships
2682
+ anyway, the same filter is shorter:
2683
+
2684
+ ```js
2685
+ import { DateTime } from 'luxon';
2686
+
2687
+ const zone =
2688
+ JSON.parse(readFileSync('content/_data/site.json', 'utf8')).timezone ?? 'utc';
2689
+
2690
+ eleventyConfig.addFilter('date', (value, format = 'readable') => {
2691
+ const at = DateTime.fromJSDate(
2692
+ value instanceof Date ? value : new Date(value),
2693
+ ).setZone(zone);
2694
+ if (!at.isValid) return '';
2695
+
2696
+ // `iso` is the instant, never the zone: a <time datetime> and a feed want
2697
+ // UTC and must not move when a setting does.
2698
+ if (format === 'iso') return at.toUTC().toISO();
2699
+ if (format === 'html') return at.toFormat('yyyy-MM-dd');
2700
+ if (format === 'year') return at.toFormat('yyyy');
2701
+ return at.toFormat('d LLLL yyyy');
2702
+ });
2703
+ ```
824
2704
 
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. |
2705
+ Either way it is the CMS's own filter, format for format, so a layout moved
2706
+ across prints the same dates.
831
2707
 
832
2708
  It also turns the template engine off for Markdown
833
2709
  (`markdownTemplateEngine: false`), because the CMS renders Markdown with
@@ -840,9 +2716,10 @@ Layouts are yours. The directory data files name them — `posts.json` says
840
2716
  Eleventy layout receives is the one this package's theme mirrors, so a layout
841
2717
  can often be moved across with only its `{% extends %}` removed.
842
2718
 
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.
2719
+ The feeds are the one thing that does not carry over: `/feed/`, `/feed/atom/`
2720
+ and `/feed/json/` are generated in code here, not by a template, so an Eleventy
2721
+ build needs its own. `/sitemap.xml` and `/robots.txt` are the same story, and
2722
+ for the same reason. Everything else is the same directory.
846
2723
 
847
2724
  ### The compatibility test
848
2725