@geekity/cms 0.29.0 → 0.31.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 (1393) hide show
  1. package/README.md +26 -4214
  2. package/admin/pages/settings/privacy.njk +1 -1
  3. package/dist/access-log.d.ts +2 -10
  4. package/dist/access-log.d.ts.map +1 -1
  5. package/dist/access-log.js +1 -1
  6. package/dist/access-log.js.map +1 -1
  7. package/dist/{web → admin}/admin-bar.d.ts +3 -2
  8. package/dist/admin/admin-bar.d.ts.map +1 -0
  9. package/dist/{web → admin}/admin-bar.js +20 -17
  10. package/dist/admin/admin-bar.js.map +1 -0
  11. package/dist/{indieauth → admin}/app-activity.d.ts +2 -2
  12. package/dist/admin/app-activity.d.ts.map +1 -0
  13. package/dist/{indieauth → admin}/app-activity.js +5 -5
  14. package/dist/admin/app-activity.js.map +1 -0
  15. package/dist/admin/appearance.d.ts +3 -3
  16. package/dist/admin/appearance.d.ts.map +1 -1
  17. package/dist/admin/appearance.js +12 -6
  18. package/dist/admin/appearance.js.map +1 -1
  19. package/dist/admin/assets.d.ts +1 -8
  20. package/dist/admin/assets.d.ts.map +1 -1
  21. package/dist/admin/assets.js +2 -9
  22. package/dist/admin/assets.js.map +1 -1
  23. package/dist/admin/comments.d.ts +2 -2
  24. package/dist/admin/comments.d.ts.map +1 -1
  25. package/dist/admin/comments.js +16 -17
  26. package/dist/admin/comments.js.map +1 -1
  27. package/dist/{indieauth → admin}/connected-apps.d.ts +2 -2
  28. package/dist/admin/connected-apps.d.ts.map +1 -0
  29. package/dist/{indieauth → admin}/connected-apps.js +18 -19
  30. package/dist/admin/connected-apps.js.map +1 -0
  31. package/dist/admin/credentials.js +1 -1
  32. package/dist/admin/credentials.js.map +1 -1
  33. package/dist/admin/dev-mode.d.ts +1 -1
  34. package/dist/admin/dev-mode.d.ts.map +1 -1
  35. package/dist/admin/documents.d.ts +16 -301
  36. package/dist/admin/documents.d.ts.map +1 -1
  37. package/dist/admin/documents.js +74 -1032
  38. package/dist/admin/documents.js.map +1 -1
  39. package/dist/admin/editor-actions.d.ts +3 -2
  40. package/dist/admin/editor-actions.d.ts.map +1 -1
  41. package/dist/admin/editor-actions.js +8 -8
  42. package/dist/admin/editor-actions.js.map +1 -1
  43. package/dist/admin/editor-fields.d.ts +45 -0
  44. package/dist/admin/editor-fields.d.ts.map +1 -0
  45. package/dist/admin/editor-fields.js +71 -0
  46. package/dist/admin/editor-fields.js.map +1 -0
  47. package/dist/admin/editor-layout.d.ts +2 -10
  48. package/dist/admin/editor-layout.d.ts.map +1 -1
  49. package/dist/admin/editor-layout.js +1 -1
  50. package/dist/admin/editor-layout.js.map +1 -1
  51. package/dist/admin/federation.d.ts +5 -5
  52. package/dist/admin/federation.d.ts.map +1 -1
  53. package/dist/admin/federation.js +16 -18
  54. package/dist/admin/federation.js.map +1 -1
  55. package/dist/admin/flash.d.ts +2 -2
  56. package/dist/admin/flash.d.ts.map +1 -1
  57. package/dist/admin/headers.d.ts +1 -1
  58. package/dist/admin/headers.d.ts.map +1 -1
  59. package/dist/admin/headers.js +1 -1
  60. package/dist/admin/headers.js.map +1 -1
  61. package/dist/admin/index.d.ts +38 -20
  62. package/dist/admin/index.d.ts.map +1 -1
  63. package/dist/admin/index.js +27 -13
  64. package/dist/admin/index.js.map +1 -1
  65. package/dist/{indieauth/consent.d.ts → admin/indieauth-consent.d.ts} +4 -4
  66. package/dist/admin/indieauth-consent.d.ts.map +1 -0
  67. package/dist/{indieauth/consent.js → admin/indieauth-consent.js} +17 -18
  68. package/dist/admin/indieauth-consent.js.map +1 -0
  69. package/dist/admin/legacy-migrations.d.ts +131 -0
  70. package/dist/admin/legacy-migrations.d.ts.map +1 -0
  71. package/dist/admin/legacy-migrations.js +235 -0
  72. package/dist/admin/legacy-migrations.js.map +1 -0
  73. package/dist/admin/media.d.ts +4 -108
  74. package/dist/admin/media.d.ts.map +1 -1
  75. package/dist/admin/media.js +19 -187
  76. package/dist/admin/media.js.map +1 -1
  77. package/dist/admin/menu.d.ts.map +1 -1
  78. package/dist/admin/menu.js +7 -7
  79. package/dist/admin/menu.js.map +1 -1
  80. package/dist/admin/messages.d.ts +1 -1
  81. package/dist/admin/messages.d.ts.map +1 -1
  82. package/dist/admin/messages.js +10 -12
  83. package/dist/admin/messages.js.map +1 -1
  84. package/dist/admin/navigation.d.ts +3 -3
  85. package/dist/admin/navigation.d.ts.map +1 -1
  86. package/dist/admin/navigation.js +9 -12
  87. package/dist/admin/navigation.js.map +1 -1
  88. package/dist/admin/personal-data.d.ts +1 -1
  89. package/dist/admin/personal-data.d.ts.map +1 -1
  90. package/dist/admin/personal-data.js +3 -6
  91. package/dist/admin/personal-data.js.map +1 -1
  92. package/dist/admin/plugins.d.ts +1 -1
  93. package/dist/admin/plugins.d.ts.map +1 -1
  94. package/dist/admin/plugins.js +11 -15
  95. package/dist/admin/plugins.js.map +1 -1
  96. package/dist/admin/preview.d.ts +2 -1
  97. package/dist/admin/preview.d.ts.map +1 -1
  98. package/dist/admin/preview.js +28 -32
  99. package/dist/admin/preview.js.map +1 -1
  100. package/dist/admin/recovery.d.ts +3 -3
  101. package/dist/admin/recovery.d.ts.map +1 -1
  102. package/dist/admin/recovery.js +10 -12
  103. package/dist/admin/recovery.js.map +1 -1
  104. package/dist/admin/routes.d.ts +1 -3
  105. package/dist/admin/routes.d.ts.map +1 -1
  106. package/dist/admin/routes.js +28 -26
  107. package/dist/admin/routes.js.map +1 -1
  108. package/dist/admin/settings-discussion.d.ts.map +1 -1
  109. package/dist/admin/settings-discussion.js +7 -4
  110. package/dist/admin/settings-discussion.js.map +1 -1
  111. package/dist/admin/settings-email.d.ts +1 -1
  112. package/dist/admin/settings-email.d.ts.map +1 -1
  113. package/dist/admin/settings-email.js +13 -10
  114. package/dist/admin/settings-email.js.map +1 -1
  115. package/dist/admin/settings-general.d.ts.map +1 -1
  116. package/dist/admin/settings-general.js +10 -7
  117. package/dist/admin/settings-general.js.map +1 -1
  118. package/dist/admin/settings-page.d.ts +3 -4
  119. package/dist/admin/settings-page.d.ts.map +1 -1
  120. package/dist/admin/settings-page.js +5 -6
  121. package/dist/admin/settings-page.js.map +1 -1
  122. package/dist/admin/settings-pages.d.ts +1 -1
  123. package/dist/admin/settings-pages.d.ts.map +1 -1
  124. package/dist/admin/settings-permalinks.js +1 -1
  125. package/dist/admin/settings-permalinks.js.map +1 -1
  126. package/dist/admin/settings-reading.d.ts +2 -2
  127. package/dist/admin/settings-reading.d.ts.map +1 -1
  128. package/dist/admin/settings-reading.js +4 -3
  129. package/dist/admin/settings-reading.js.map +1 -1
  130. package/dist/admin/settings.d.ts +0 -579
  131. package/dist/admin/settings.d.ts.map +1 -1
  132. package/dist/admin/settings.js +1 -1155
  133. package/dist/admin/settings.js.map +1 -1
  134. package/dist/admin/syndication-targets.d.ts +1 -1
  135. package/dist/admin/syndication-targets.d.ts.map +1 -1
  136. package/dist/admin/syndication-targets.js +11 -15
  137. package/dist/admin/syndication-targets.js.map +1 -1
  138. package/dist/admin/taxonomy.d.ts +2 -2
  139. package/dist/admin/taxonomy.d.ts.map +1 -1
  140. package/dist/admin/taxonomy.js +8 -11
  141. package/dist/admin/taxonomy.js.map +1 -1
  142. package/dist/admin/templates.js +1 -1
  143. package/dist/admin/templates.js.map +1 -1
  144. package/dist/admin/tools.d.ts +4 -4
  145. package/dist/admin/tools.d.ts.map +1 -1
  146. package/dist/admin/tools.js +3 -6
  147. package/dist/admin/tools.js.map +1 -1
  148. package/dist/admin/uploads.d.ts +1 -106
  149. package/dist/admin/uploads.d.ts.map +1 -1
  150. package/dist/admin/uploads.js +2 -191
  151. package/dist/admin/uploads.js.map +1 -1
  152. package/dist/admin/users.d.ts +20 -15
  153. package/dist/admin/users.d.ts.map +1 -1
  154. package/dist/admin/users.js +83 -80
  155. package/dist/admin/users.js.map +1 -1
  156. package/dist/avatars/avatars.d.ts +2 -2
  157. package/dist/avatars/avatars.d.ts.map +1 -1
  158. package/dist/avatars/avatars.js +6 -9
  159. package/dist/avatars/avatars.js.map +1 -1
  160. package/dist/avatars/index.d.ts +0 -1
  161. package/dist/avatars/index.d.ts.map +1 -1
  162. package/dist/avatars/index.js +0 -1
  163. package/dist/avatars/index.js.map +1 -1
  164. package/dist/cache.d.ts +4 -2
  165. package/dist/cache.d.ts.map +1 -1
  166. package/dist/cache.js +11 -5
  167. package/dist/cache.js.map +1 -1
  168. package/dist/cli.d.ts +2 -33
  169. package/dist/cli.d.ts.map +1 -1
  170. package/dist/cli.js +245 -267
  171. package/dist/cli.js.map +1 -1
  172. package/dist/cms/app.d.ts +11 -0
  173. package/dist/cms/app.d.ts.map +1 -0
  174. package/dist/cms/app.js +115 -0
  175. package/dist/cms/app.js.map +1 -0
  176. package/dist/cms/boot.d.ts +14 -0
  177. package/dist/cms/boot.d.ts.map +1 -0
  178. package/dist/cms/boot.js +107 -0
  179. package/dist/cms/boot.js.map +1 -0
  180. package/dist/cms/create.d.ts +248 -0
  181. package/dist/cms/create.d.ts.map +1 -0
  182. package/dist/cms/create.js +167 -0
  183. package/dist/cms/create.js.map +1 -0
  184. package/dist/cms/lifecycle.d.ts +11 -0
  185. package/dist/cms/lifecycle.d.ts.map +1 -0
  186. package/dist/cms/lifecycle.js +15 -0
  187. package/dist/cms/lifecycle.js.map +1 -0
  188. package/dist/{web/errors.d.ts → cms/server-error.d.ts} +2 -8
  189. package/dist/cms/server-error.d.ts.map +1 -0
  190. package/dist/{web/errors.js → cms/server-error.js} +4 -14
  191. package/dist/cms/server-error.js.map +1 -0
  192. package/dist/cms/services.d.ts +55 -0
  193. package/dist/cms/services.d.ts.map +1 -0
  194. package/dist/cms/services.js +353 -0
  195. package/dist/cms/services.js.map +1 -0
  196. package/dist/cms/site-records.d.ts +4 -0
  197. package/dist/cms/site-records.d.ts.map +1 -0
  198. package/dist/cms/site-records.js +15 -0
  199. package/dist/cms/site-records.js.map +1 -0
  200. package/dist/comments/akismet.d.ts +1 -1
  201. package/dist/comments/akismet.d.ts.map +1 -1
  202. package/dist/comments/akismet.js +1 -3
  203. package/dist/comments/akismet.js.map +1 -1
  204. package/dist/comments/conversation.d.ts +334 -0
  205. package/dist/comments/conversation.d.ts.map +1 -0
  206. package/dist/comments/conversation.js +51 -0
  207. package/dist/comments/conversation.js.map +1 -0
  208. package/dist/comments/form.d.ts +2 -2
  209. package/dist/comments/form.d.ts.map +1 -1
  210. package/dist/comments/form.js +2 -2
  211. package/dist/comments/form.js.map +1 -1
  212. package/dist/comments/index.d.ts +12 -6
  213. package/dist/comments/index.d.ts.map +1 -1
  214. package/dist/comments/index.js +7 -4
  215. package/dist/comments/index.js.map +1 -1
  216. package/dist/comments/intake.d.ts +147 -0
  217. package/dist/comments/intake.d.ts.map +1 -0
  218. package/dist/comments/intake.js +161 -0
  219. package/dist/comments/intake.js.map +1 -0
  220. package/dist/comments/markdown.d.ts.map +1 -1
  221. package/dist/comments/markdown.js +2 -9
  222. package/dist/comments/markdown.js.map +1 -1
  223. package/dist/comments/migrations.d.ts +45 -0
  224. package/dist/comments/migrations.d.ts.map +1 -0
  225. package/dist/comments/migrations.js +194 -0
  226. package/dist/comments/migrations.js.map +1 -0
  227. package/dist/comments/moderate.d.ts +2 -2
  228. package/dist/comments/moderate.d.ts.map +1 -1
  229. package/dist/comments/moderate.js +3 -13
  230. package/dist/comments/moderate.js.map +1 -1
  231. package/dist/comments/page-form.d.ts +52 -0
  232. package/dist/comments/page-form.d.ts.map +1 -0
  233. package/dist/comments/page-form.js +62 -0
  234. package/dist/comments/page-form.js.map +1 -0
  235. package/dist/comments/policy.d.ts +1 -9
  236. package/dist/comments/policy.d.ts.map +1 -1
  237. package/dist/comments/policy.js +3 -18
  238. package/dist/comments/policy.js.map +1 -1
  239. package/dist/comments/records.d.ts +25 -173
  240. package/dist/comments/records.d.ts.map +1 -1
  241. package/dist/comments/records.js +69 -265
  242. package/dist/comments/records.js.map +1 -1
  243. package/dist/comments/reply-notices.d.ts +5 -5
  244. package/dist/comments/reply-notices.d.ts.map +1 -1
  245. package/dist/comments/reply-notices.js +6 -6
  246. package/dist/comments/reply-notices.js.map +1 -1
  247. package/dist/comments/reply-post.d.ts +2 -2
  248. package/dist/comments/reply-post.d.ts.map +1 -1
  249. package/dist/comments/reply-post.js +7 -5
  250. package/dist/comments/reply-post.js.map +1 -1
  251. package/dist/comments/routes.d.ts +3 -3
  252. package/dist/comments/routes.d.ts.map +1 -1
  253. package/dist/comments/routes.js +22 -34
  254. package/dist/comments/routes.js.map +1 -1
  255. package/dist/comments/submission.d.ts +5 -100
  256. package/dist/comments/submission.d.ts.map +1 -1
  257. package/dist/comments/submission.js +5 -20
  258. package/dist/comments/submission.js.map +1 -1
  259. package/dist/comments/viewer.d.ts +1 -25
  260. package/dist/comments/viewer.d.ts.map +1 -1
  261. package/dist/comments/viewer.js +1 -44
  262. package/dist/comments/viewer.js.map +1 -1
  263. package/dist/config-loader.d.ts +35 -0
  264. package/dist/config-loader.d.ts.map +1 -0
  265. package/dist/config-loader.js +116 -0
  266. package/dist/config-loader.js.map +1 -0
  267. package/dist/config.d.ts +72 -50
  268. package/dist/config.d.ts.map +1 -1
  269. package/dist/config.js +132 -180
  270. package/dist/config.js.map +1 -1
  271. package/dist/contact/delivery.d.ts +1 -1
  272. package/dist/contact/delivery.d.ts.map +1 -1
  273. package/dist/contact/delivery.js +5 -13
  274. package/dist/contact/delivery.js.map +1 -1
  275. package/dist/contact/form.d.ts +2 -4
  276. package/dist/contact/form.d.ts.map +1 -1
  277. package/dist/contact/form.js +3 -4
  278. package/dist/contact/form.js.map +1 -1
  279. package/dist/contact/index.d.ts +2 -2
  280. package/dist/contact/index.d.ts.map +1 -1
  281. package/dist/contact/index.js +2 -2
  282. package/dist/contact/index.js.map +1 -1
  283. package/dist/contact/records.d.ts.map +1 -1
  284. package/dist/contact/records.js +8 -11
  285. package/dist/contact/records.js.map +1 -1
  286. package/dist/contact/submission.d.ts +2 -2
  287. package/dist/contact/submission.d.ts.map +1 -1
  288. package/dist/contact/submission.js +7 -16
  289. package/dist/contact/submission.js.map +1 -1
  290. package/dist/{images → content}/alt-text.d.ts +1 -1
  291. package/dist/content/alt-text.d.ts.map +1 -0
  292. package/dist/{images → content}/alt-text.js +3 -5
  293. package/dist/content/alt-text.js.map +1 -0
  294. package/dist/content/authors.d.ts +79 -0
  295. package/dist/content/authors.d.ts.map +1 -0
  296. package/dist/content/authors.js +106 -0
  297. package/dist/content/authors.js.map +1 -0
  298. package/dist/content/citation.d.ts +3 -0
  299. package/dist/content/citation.d.ts.map +1 -1
  300. package/dist/content/citation.js +3 -0
  301. package/dist/content/citation.js.map +1 -1
  302. package/dist/content/comments.d.ts +3 -0
  303. package/dist/content/comments.d.ts.map +1 -0
  304. package/dist/content/comments.js +3 -0
  305. package/dist/content/comments.js.map +1 -0
  306. package/dist/content/contact.d.ts +3 -0
  307. package/dist/content/contact.d.ts.map +1 -0
  308. package/dist/content/contact.js +3 -0
  309. package/dist/content/contact.js.map +1 -0
  310. package/dist/content/document-form.d.ts +195 -0
  311. package/dist/content/document-form.d.ts.map +1 -0
  312. package/dist/content/document-form.js +201 -0
  313. package/dist/content/document-form.js.map +1 -0
  314. package/dist/content/document-kind.d.ts +55 -0
  315. package/dist/content/document-kind.d.ts.map +1 -0
  316. package/dist/content/document-kind.js +30 -0
  317. package/dist/content/document-kind.js.map +1 -0
  318. package/dist/{admin/enclosure-field.d.ts → content/enclosure-form.d.ts} +14 -34
  319. package/dist/content/enclosure-form.d.ts.map +1 -0
  320. package/dist/{admin/enclosure-field.js → content/enclosure-form.js} +39 -76
  321. package/dist/content/enclosure-form.js.map +1 -0
  322. package/dist/content/enclosure.d.ts.map +1 -1
  323. package/dist/content/enclosure.js +3 -4
  324. package/dist/content/enclosure.js.map +1 -1
  325. package/dist/{admin/event-field.d.ts → content/event-form.d.ts} +6 -5
  326. package/dist/content/event-form.d.ts.map +1 -0
  327. package/dist/{admin/event-field.js → content/event-form.js} +45 -12
  328. package/dist/content/event-form.js.map +1 -0
  329. package/dist/content/excerpt.d.ts +25 -0
  330. package/dist/content/excerpt.d.ts.map +1 -0
  331. package/dist/content/excerpt.js +43 -0
  332. package/dist/content/excerpt.js.map +1 -0
  333. package/dist/content/form-error.d.ts +10 -0
  334. package/dist/content/form-error.d.ts.map +1 -0
  335. package/dist/content/form-error.js +2 -0
  336. package/dist/content/form-error.js.map +1 -0
  337. package/dist/{web → content}/guids.d.ts +14 -19
  338. package/dist/content/guids.d.ts.map +1 -0
  339. package/dist/{web → content}/guids.js +30 -19
  340. package/dist/content/guids.js.map +1 -0
  341. package/dist/content/image-tags.d.ts +25 -0
  342. package/dist/content/image-tags.d.ts.map +1 -0
  343. package/dist/content/image-tags.js +55 -0
  344. package/dist/content/image-tags.js.map +1 -0
  345. package/dist/content/index.d.ts +9 -4
  346. package/dist/content/index.d.ts.map +1 -1
  347. package/dist/content/index.js +5 -2
  348. package/dist/content/index.js.map +1 -1
  349. package/dist/content/language.d.ts +19 -0
  350. package/dist/content/language.d.ts.map +1 -0
  351. package/dist/content/language.js +19 -0
  352. package/dist/content/language.js.map +1 -0
  353. package/dist/content/license.d.ts.map +1 -0
  354. package/dist/content/license.js.map +1 -0
  355. package/dist/{web → content}/locale.d.ts +0 -6
  356. package/dist/content/locale.d.ts.map +1 -0
  357. package/dist/{web → content}/locale.js +1 -6
  358. package/dist/content/locale.js.map +1 -0
  359. package/dist/content/location-form.d.ts +17 -0
  360. package/dist/content/location-form.d.ts.map +1 -0
  361. package/dist/{admin/location-field.js → content/location-form.js} +2 -23
  362. package/dist/content/location-form.js.map +1 -0
  363. package/dist/content/location.d.ts.map +1 -1
  364. package/dist/content/location.js +1 -3
  365. package/dist/content/location.js.map +1 -1
  366. package/dist/content/markdown-links.d.ts +11 -0
  367. package/dist/content/markdown-links.d.ts.map +1 -0
  368. package/dist/content/markdown-links.js +223 -0
  369. package/dist/content/markdown-links.js.map +1 -0
  370. package/dist/content/markdown.d.ts +2 -1
  371. package/dist/content/markdown.d.ts.map +1 -1
  372. package/dist/content/markdown.js +1 -1
  373. package/dist/content/markdown.js.map +1 -1
  374. package/dist/content/migrated.js +1 -1
  375. package/dist/content/migrated.js.map +1 -1
  376. package/dist/content/parser.d.ts.map +1 -1
  377. package/dist/content/parser.js +1 -12
  378. package/dist/content/parser.js.map +1 -1
  379. package/dist/content/permalinks.d.ts +34 -0
  380. package/dist/content/permalinks.d.ts.map +1 -0
  381. package/dist/{web/documents.js → content/permalinks.js} +6 -56
  382. package/dist/content/permalinks.js.map +1 -0
  383. package/dist/content/photo-form.d.ts +28 -0
  384. package/dist/content/photo-form.d.ts.map +1 -0
  385. package/dist/{admin/photo-field.js → content/photo-form.js} +36 -36
  386. package/dist/content/photo-form.js.map +1 -0
  387. package/dist/content/photo.d.ts +1 -1
  388. package/dist/content/photo.d.ts.map +1 -1
  389. package/dist/content/photo.js +1 -1
  390. package/dist/content/photo.js.map +1 -1
  391. package/dist/content/post-fields.d.ts +40 -0
  392. package/dist/content/post-fields.d.ts.map +1 -0
  393. package/dist/content/post-fields.js +256 -0
  394. package/dist/content/post-fields.js.map +1 -0
  395. package/dist/{admin/read-field.d.ts → content/read-form.d.ts} +6 -4
  396. package/dist/content/read-form.d.ts.map +1 -0
  397. package/dist/content/read-form.js +99 -0
  398. package/dist/content/read-form.js.map +1 -0
  399. package/dist/content/read.d.ts.map +1 -1
  400. package/dist/content/read.js +7 -11
  401. package/dist/content/read.js.map +1 -1
  402. package/dist/content/redirects.d.ts +84 -0
  403. package/dist/content/redirects.d.ts.map +1 -0
  404. package/dist/content/redirects.js +234 -0
  405. package/dist/content/redirects.js.map +1 -0
  406. package/dist/content/sanitize.d.ts +13 -0
  407. package/dist/content/sanitize.d.ts.map +1 -0
  408. package/dist/{web → content}/sanitize.js +4 -71
  409. package/dist/content/sanitize.js.map +1 -0
  410. package/dist/content/save.d.ts +5 -8
  411. package/dist/content/save.d.ts.map +1 -1
  412. package/dist/content/save.js +5 -26
  413. package/dist/content/save.js.map +1 -1
  414. package/dist/content/schedule.d.ts +2 -2
  415. package/dist/content/schedule.d.ts.map +1 -1
  416. package/dist/content/schedule.js +1 -3
  417. package/dist/content/schedule.js.map +1 -1
  418. package/dist/content/served.d.ts +58 -0
  419. package/dist/content/served.d.ts.map +1 -0
  420. package/dist/content/served.js +65 -0
  421. package/dist/content/served.js.map +1 -0
  422. package/dist/content/site-links.d.ts +72 -0
  423. package/dist/content/site-links.d.ts.map +1 -0
  424. package/dist/content/site-links.js +191 -0
  425. package/dist/content/site-links.js.map +1 -0
  426. package/dist/content/sqlite/archive.d.ts +4 -0
  427. package/dist/content/sqlite/archive.d.ts.map +1 -0
  428. package/dist/content/sqlite/archive.js +96 -0
  429. package/dist/content/sqlite/archive.js.map +1 -0
  430. package/dist/content/sqlite/backlinks.d.ts +4 -0
  431. package/dist/content/sqlite/backlinks.d.ts.map +1 -0
  432. package/dist/content/sqlite/backlinks.js +46 -0
  433. package/dist/content/sqlite/backlinks.js.map +1 -0
  434. package/dist/content/sqlite/catalog.d.ts +4 -0
  435. package/dist/content/sqlite/catalog.d.ts.map +1 -0
  436. package/dist/content/sqlite/catalog.js +62 -0
  437. package/dist/content/sqlite/catalog.js.map +1 -0
  438. package/dist/content/sqlite/clauses.d.ts +57 -0
  439. package/dist/content/sqlite/clauses.d.ts.map +1 -0
  440. package/dist/content/sqlite/clauses.js +76 -0
  441. package/dist/content/sqlite/clauses.js.map +1 -0
  442. package/dist/content/sqlite/database.d.ts +26 -0
  443. package/dist/content/sqlite/database.d.ts.map +1 -0
  444. package/dist/content/sqlite/database.js +27 -0
  445. package/dist/content/sqlite/database.js.map +1 -0
  446. package/dist/content/sqlite/documents.d.ts +12 -0
  447. package/dist/content/sqlite/documents.d.ts.map +1 -0
  448. package/dist/content/sqlite/documents.js +40 -0
  449. package/dist/content/sqlite/documents.js.map +1 -0
  450. package/dist/content/sqlite/lookups.d.ts +6 -0
  451. package/dist/content/sqlite/lookups.d.ts.map +1 -0
  452. package/dist/content/sqlite/lookups.js +25 -0
  453. package/dist/content/sqlite/lookups.js.map +1 -0
  454. package/dist/content/sqlite/open.d.ts +8 -0
  455. package/dist/content/sqlite/open.d.ts.map +1 -0
  456. package/dist/content/sqlite/open.js +63 -0
  457. package/dist/content/sqlite/open.js.map +1 -0
  458. package/dist/content/sqlite/replies.d.ts +4 -0
  459. package/dist/content/sqlite/replies.d.ts.map +1 -0
  460. package/dist/content/sqlite/replies.js +21 -0
  461. package/dist/content/sqlite/replies.js.map +1 -0
  462. package/dist/content/sqlite/rows.d.ts +22 -0
  463. package/dist/content/sqlite/rows.d.ts.map +1 -0
  464. package/dist/content/sqlite/rows.js +93 -0
  465. package/dist/content/sqlite/rows.js.map +1 -0
  466. package/dist/content/sqlite/schedule.d.ts +4 -0
  467. package/dist/content/sqlite/schedule.d.ts.map +1 -0
  468. package/dist/content/sqlite/schedule.js +16 -0
  469. package/dist/content/sqlite/schedule.js.map +1 -0
  470. package/dist/content/sqlite/schema.d.ts +10 -0
  471. package/dist/content/sqlite/schema.d.ts.map +1 -0
  472. package/dist/content/sqlite/schema.js +209 -0
  473. package/dist/content/sqlite/schema.js.map +1 -0
  474. package/dist/content/sqlite/search.d.ts +4 -0
  475. package/dist/content/sqlite/search.d.ts.map +1 -0
  476. package/dist/content/sqlite/search.js +54 -0
  477. package/dist/content/sqlite/search.js.map +1 -0
  478. package/dist/content/sqlite/spellings.d.ts +6 -0
  479. package/dist/content/sqlite/spellings.d.ts.map +1 -0
  480. package/dist/content/sqlite/spellings.js +80 -0
  481. package/dist/content/sqlite/spellings.js.map +1 -0
  482. package/dist/content/sqlite/taxonomy.d.ts +4 -0
  483. package/dist/content/sqlite/taxonomy.d.ts.map +1 -0
  484. package/dist/content/sqlite/taxonomy.js +75 -0
  485. package/dist/content/sqlite/taxonomy.js.map +1 -0
  486. package/dist/content/sqlite/writes.d.ts +9 -0
  487. package/dist/content/sqlite/writes.d.ts.map +1 -0
  488. package/dist/content/sqlite/writes.js +164 -0
  489. package/dist/content/sqlite/writes.js.map +1 -0
  490. package/dist/content/store.d.ts +142 -137
  491. package/dist/content/store.d.ts.map +1 -1
  492. package/dist/content/store.js +0 -1050
  493. package/dist/content/store.js.map +1 -1
  494. package/dist/content/sync.d.ts +12 -4
  495. package/dist/content/sync.d.ts.map +1 -1
  496. package/dist/content/sync.js +24 -41
  497. package/dist/content/sync.js.map +1 -1
  498. package/dist/content/syndicate-to.d.ts +25 -0
  499. package/dist/content/syndicate-to.d.ts.map +1 -0
  500. package/dist/content/syndicate-to.js +8 -0
  501. package/dist/content/syndicate-to.js.map +1 -0
  502. package/dist/{web → content}/taxonomy.d.ts +1 -1
  503. package/dist/content/taxonomy.d.ts.map +1 -0
  504. package/dist/{web → content}/taxonomy.js +2 -2
  505. package/dist/content/taxonomy.js.map +1 -0
  506. package/dist/content/time.d.ts +2 -2
  507. package/dist/content/time.d.ts.map +1 -1
  508. package/dist/content/time.js +3 -3
  509. package/dist/content/time.js.map +1 -1
  510. package/dist/content/trash.d.ts +35 -0
  511. package/dist/content/trash.d.ts.map +1 -0
  512. package/dist/content/trash.js +83 -0
  513. package/dist/content/trash.js.map +1 -0
  514. package/dist/content/uploads.d.ts +24 -0
  515. package/dist/content/uploads.d.ts.map +1 -0
  516. package/dist/content/uploads.js +27 -0
  517. package/dist/content/uploads.js.map +1 -0
  518. package/dist/content/video.d.ts.map +1 -1
  519. package/dist/content/video.js +3 -5
  520. package/dist/content/video.js.map +1 -1
  521. package/dist/content/write.d.ts +84 -0
  522. package/dist/content/write.d.ts.map +1 -0
  523. package/dist/content/write.js +566 -0
  524. package/dist/content/write.js.map +1 -0
  525. package/dist/content/writer.d.ts.map +1 -1
  526. package/dist/content/writer.js +1 -2
  527. package/dist/content/writer.js.map +1 -1
  528. package/dist/dev-mode.d.ts +5 -3
  529. package/dist/dev-mode.d.ts.map +1 -1
  530. package/dist/dev-mode.js +3 -3
  531. package/dist/dev-mode.js.map +1 -1
  532. package/dist/drain.d.ts +2 -3
  533. package/dist/drain.d.ts.map +1 -1
  534. package/dist/drain.js +2 -1
  535. package/dist/drain.js.map +1 -1
  536. package/dist/exports.d.ts +26 -0
  537. package/dist/exports.d.ts.map +1 -0
  538. package/dist/exports.js +8 -0
  539. package/dist/exports.js.map +1 -0
  540. package/dist/federation/actor.d.ts +1 -1
  541. package/dist/federation/actor.d.ts.map +1 -1
  542. package/dist/federation/actor.js +6 -13
  543. package/dist/federation/actor.js.map +1 -1
  544. package/dist/federation/article.d.ts +4 -14
  545. package/dist/federation/article.d.ts.map +1 -1
  546. package/dist/federation/article.js +19 -32
  547. package/dist/federation/article.js.map +1 -1
  548. package/dist/federation/backfill.d.ts +4 -4
  549. package/dist/federation/backfill.d.ts.map +1 -1
  550. package/dist/federation/backfill.js +12 -13
  551. package/dist/federation/backfill.js.map +1 -1
  552. package/dist/federation/citations.d.ts.map +1 -1
  553. package/dist/federation/citations.js.map +1 -1
  554. package/dist/federation/cited-post.d.ts.map +1 -1
  555. package/dist/federation/cited-post.js +2 -4
  556. package/dist/federation/cited-post.js.map +1 -1
  557. package/dist/federation/delivery-plan.d.ts +85 -0
  558. package/dist/federation/delivery-plan.d.ts.map +1 -0
  559. package/dist/federation/delivery-plan.js +137 -0
  560. package/dist/federation/delivery-plan.js.map +1 -0
  561. package/dist/federation/delivery.d.ts +7 -32
  562. package/dist/federation/delivery.d.ts.map +1 -1
  563. package/dist/federation/delivery.js +64 -201
  564. package/dist/federation/delivery.js.map +1 -1
  565. package/dist/federation/federation.d.ts +5 -5
  566. package/dist/federation/federation.d.ts.map +1 -1
  567. package/dist/federation/federation.js +7 -6
  568. package/dist/federation/federation.js.map +1 -1
  569. package/dist/federation/followers.d.ts +1 -1
  570. package/dist/federation/followers.d.ts.map +1 -1
  571. package/dist/federation/inbox.d.ts +1 -1
  572. package/dist/federation/inbox.d.ts.map +1 -1
  573. package/dist/federation/inbox.js +5 -4
  574. package/dist/federation/inbox.js.map +1 -1
  575. package/dist/federation/index.d.ts +10 -8
  576. package/dist/federation/index.d.ts.map +1 -1
  577. package/dist/federation/index.js +7 -5
  578. package/dist/federation/index.js.map +1 -1
  579. package/dist/federation/keys.d.ts +8 -27
  580. package/dist/federation/keys.d.ts.map +1 -1
  581. package/dist/federation/keys.js +4 -47
  582. package/dist/federation/keys.js.map +1 -1
  583. package/dist/federation/mount.d.ts +2 -2
  584. package/dist/federation/mount.d.ts.map +1 -1
  585. package/dist/federation/mount.js +9 -7
  586. package/dist/federation/mount.js.map +1 -1
  587. package/dist/federation/paths.d.ts +1 -9
  588. package/dist/federation/paths.d.ts.map +1 -1
  589. package/dist/federation/paths.js +2 -12
  590. package/dist/federation/paths.js.map +1 -1
  591. package/dist/federation/profiles.d.ts +1 -1
  592. package/dist/federation/profiles.d.ts.map +1 -1
  593. package/dist/federation/profiles.js +5 -8
  594. package/dist/federation/profiles.js.map +1 -1
  595. package/dist/federation/quotes.d.ts +2 -2
  596. package/dist/federation/quotes.d.ts.map +1 -1
  597. package/dist/federation/quotes.js +4 -4
  598. package/dist/federation/quotes.js.map +1 -1
  599. package/dist/federation/records.d.ts +2 -2
  600. package/dist/federation/records.d.ts.map +1 -1
  601. package/dist/federation/records.js +12 -19
  602. package/dist/federation/records.js.map +1 -1
  603. package/dist/federation/relays.d.ts +2 -3
  604. package/dist/federation/relays.d.ts.map +1 -1
  605. package/dist/federation/relays.js +22 -30
  606. package/dist/federation/relays.js.map +1 -1
  607. package/dist/federation/site-loaders.js +1 -1
  608. package/dist/federation/site-loaders.js.map +1 -1
  609. package/dist/federation/webfinger.d.ts +23 -0
  610. package/dist/federation/webfinger.d.ts.map +1 -0
  611. package/dist/federation/webfinger.js +70 -0
  612. package/dist/federation/webfinger.js.map +1 -0
  613. package/dist/files/atomic.d.ts.map +1 -1
  614. package/dist/files/atomic.js +4 -9
  615. package/dist/files/atomic.js.map +1 -1
  616. package/dist/files/static-files.d.ts +21 -0
  617. package/dist/files/static-files.d.ts.map +1 -0
  618. package/dist/files/static-files.js +88 -0
  619. package/dist/files/static-files.js.map +1 -0
  620. package/dist/forms/checker.d.ts +93 -0
  621. package/dist/forms/checker.d.ts.map +1 -0
  622. package/dist/forms/checker.js +2 -0
  623. package/dist/forms/checker.js.map +1 -0
  624. package/dist/forms/protection.d.ts +3 -1
  625. package/dist/forms/protection.d.ts.map +1 -1
  626. package/dist/forms/protection.js +3 -1
  627. package/dist/forms/protection.js.map +1 -1
  628. package/dist/images/icons.js +2 -2
  629. package/dist/images/icons.js.map +1 -1
  630. package/dist/images/index.d.ts +2 -1
  631. package/dist/images/index.d.ts.map +1 -1
  632. package/dist/images/index.js +2 -1
  633. package/dist/images/index.js.map +1 -1
  634. package/dist/images/markup.d.ts +0 -20
  635. package/dist/images/markup.d.ts.map +1 -1
  636. package/dist/images/markup.js +5 -64
  637. package/dist/images/markup.js.map +1 -1
  638. package/dist/images/paths.d.ts +0 -10
  639. package/dist/images/paths.d.ts.map +1 -1
  640. package/dist/images/paths.js +3 -11
  641. package/dist/images/paths.js.map +1 -1
  642. package/dist/images/variants.d.ts +2 -2
  643. package/dist/images/variants.d.ts.map +1 -1
  644. package/dist/images/variants.js +4 -3
  645. package/dist/images/variants.js.map +1 -1
  646. package/dist/index.d.ts +3 -291
  647. package/dist/index.d.ts.map +1 -1
  648. package/dist/index.js +2 -838
  649. package/dist/index.js.map +1 -1
  650. package/dist/indexnow.d.ts.map +1 -1
  651. package/dist/indexnow.js +21 -26
  652. package/dist/indexnow.js.map +1 -1
  653. package/dist/indieauth/client.d.ts +1 -1
  654. package/dist/indieauth/client.d.ts.map +1 -1
  655. package/dist/indieauth/client.js +1 -1
  656. package/dist/indieauth/client.js.map +1 -1
  657. package/dist/indieauth/grants.d.ts +1 -1
  658. package/dist/indieauth/grants.d.ts.map +1 -1
  659. package/dist/indieauth/identity.d.ts +2 -2
  660. package/dist/indieauth/identity.d.ts.map +1 -1
  661. package/dist/indieauth/identity.js +5 -7
  662. package/dist/indieauth/identity.js.map +1 -1
  663. package/dist/indieauth/redeem.d.ts +1 -1
  664. package/dist/indieauth/redeem.d.ts.map +1 -1
  665. package/dist/indieauth/redeem.js.map +1 -1
  666. package/dist/indieauth/request.d.ts +5 -1
  667. package/dist/indieauth/request.d.ts.map +1 -1
  668. package/dist/indieauth/request.js +6 -2
  669. package/dist/indieauth/request.js.map +1 -1
  670. package/dist/indieauth/tokens.d.ts +1 -1
  671. package/dist/indieauth/tokens.d.ts.map +1 -1
  672. package/dist/indieauth/tokens.js +2 -2
  673. package/dist/indieauth/tokens.js.map +1 -1
  674. package/dist/mail/index.d.ts +1 -5
  675. package/dist/mail/index.d.ts.map +1 -1
  676. package/dist/mail/index.js +0 -2
  677. package/dist/mail/index.js.map +1 -1
  678. package/dist/mail/provider.d.ts +7 -0
  679. package/dist/mail/provider.d.ts.map +1 -1
  680. package/dist/maintenance.d.ts +1 -1
  681. package/dist/maintenance.d.ts.map +1 -1
  682. package/dist/media/library.d.ts +99 -0
  683. package/dist/media/library.d.ts.map +1 -0
  684. package/dist/media/library.js +157 -0
  685. package/dist/media/library.js.map +1 -0
  686. package/dist/media/uploads.d.ts +106 -0
  687. package/dist/media/uploads.d.ts.map +1 -0
  688. package/dist/media/uploads.js +192 -0
  689. package/dist/media/uploads.js.map +1 -0
  690. package/dist/micropub/content.js +1 -1
  691. package/dist/micropub/content.js.map +1 -1
  692. package/dist/micropub/create.d.ts +7 -15
  693. package/dist/micropub/create.d.ts.map +1 -1
  694. package/dist/micropub/create.js +48 -184
  695. package/dist/micropub/create.js.map +1 -1
  696. package/dist/micropub/endpoint.d.ts +2 -2
  697. package/dist/micropub/endpoint.d.ts.map +1 -1
  698. package/dist/micropub/endpoint.js +29 -40
  699. package/dist/micropub/endpoint.js.map +1 -1
  700. package/dist/micropub/media.d.ts +1 -1
  701. package/dist/micropub/media.d.ts.map +1 -1
  702. package/dist/micropub/media.js +6 -6
  703. package/dist/micropub/media.js.map +1 -1
  704. package/dist/micropub/update.d.ts +6 -14
  705. package/dist/micropub/update.d.ts.map +1 -1
  706. package/dist/micropub/update.js +37 -90
  707. package/dist/micropub/update.js.map +1 -1
  708. package/dist/notifications/comments.d.ts +4 -4
  709. package/dist/notifications/comments.d.ts.map +1 -1
  710. package/dist/notifications/comments.js +6 -14
  711. package/dist/notifications/comments.js.map +1 -1
  712. package/dist/notifications/digest.d.ts +4 -4
  713. package/dist/notifications/digest.d.ts.map +1 -1
  714. package/dist/notifications/digest.js +6 -16
  715. package/dist/notifications/digest.js.map +1 -1
  716. package/dist/notifications/index.d.ts +6 -3
  717. package/dist/notifications/index.d.ts.map +1 -1
  718. package/dist/notifications/index.js +3 -2
  719. package/dist/notifications/index.js.map +1 -1
  720. package/dist/notifications/links.d.ts.map +1 -1
  721. package/dist/notifications/links.js +2 -2
  722. package/dist/notifications/links.js.map +1 -1
  723. package/dist/{mail/service.d.ts → notifications/mail-service.d.ts} +7 -13
  724. package/dist/notifications/mail-service.d.ts.map +1 -0
  725. package/dist/{mail/service.js → notifications/mail-service.js} +33 -54
  726. package/dist/notifications/mail-service.js.map +1 -0
  727. package/dist/{mail/templates.d.ts → notifications/mail-templates.d.ts} +4 -2
  728. package/dist/notifications/mail-templates.d.ts.map +1 -0
  729. package/dist/{mail/templates.js → notifications/mail-templates.js} +5 -3
  730. package/dist/notifications/mail-templates.js.map +1 -0
  731. package/dist/notifications/tokens.d.ts +2 -2
  732. package/dist/notifications/tokens.d.ts.map +1 -1
  733. package/dist/notifications/tokens.js +2 -2
  734. package/dist/notifications/tokens.js.map +1 -1
  735. package/dist/notify.d.ts.map +1 -1
  736. package/dist/notify.js +12 -24
  737. package/dist/notify.js.map +1 -1
  738. package/dist/plugin.d.ts +48 -2
  739. package/dist/plugin.d.ts.map +1 -1
  740. package/dist/plugin.js +28 -2
  741. package/dist/plugin.js.map +1 -1
  742. package/dist/plugins/enabled.d.ts.map +1 -1
  743. package/dist/plugins/enabled.js +2 -4
  744. package/dist/plugins/enabled.js.map +1 -1
  745. package/dist/plugins/fetch.d.ts +1 -1
  746. package/dist/plugins/fetch.d.ts.map +1 -1
  747. package/dist/plugins/fetch.js +1 -1
  748. package/dist/plugins/fetch.js.map +1 -1
  749. package/dist/plugins/folder.d.ts.map +1 -1
  750. package/dist/plugins/folder.js +1 -3
  751. package/dist/plugins/folder.js.map +1 -1
  752. package/dist/plugins/install.d.ts.map +1 -1
  753. package/dist/plugins/install.js +7 -3
  754. package/dist/plugins/install.js.map +1 -1
  755. package/dist/plugins/manage.d.ts +1 -1
  756. package/dist/plugins/manage.d.ts.map +1 -1
  757. package/dist/plugins/registry.d.ts.map +1 -1
  758. package/dist/plugins/registry.js +7 -9
  759. package/dist/plugins/registry.js.map +1 -1
  760. package/dist/plugins/settings.d.ts.map +1 -1
  761. package/dist/plugins/settings.js +2 -3
  762. package/dist/plugins/settings.js.map +1 -1
  763. package/dist/plugins/site.d.ts +1 -1
  764. package/dist/plugins/site.d.ts.map +1 -1
  765. package/dist/plugins/site.js +10 -4
  766. package/dist/plugins/site.js.map +1 -1
  767. package/dist/plugins/upgrade.d.ts.map +1 -1
  768. package/dist/plugins/upgrade.js +4 -3
  769. package/dist/plugins/upgrade.js.map +1 -1
  770. package/dist/privacy/erase.d.ts +1 -1
  771. package/dist/privacy/erase.d.ts.map +1 -1
  772. package/dist/privacy/retention.d.ts +2 -2
  773. package/dist/privacy/retention.d.ts.map +1 -1
  774. package/dist/privacy/retention.js +9 -11
  775. package/dist/privacy/retention.js.map +1 -1
  776. package/dist/site/admin-paths.d.ts +23 -0
  777. package/dist/site/admin-paths.d.ts.map +1 -0
  778. package/dist/site/admin-paths.js +25 -0
  779. package/dist/site/admin-paths.js.map +1 -0
  780. package/dist/site/admin-theme.d.ts.map +1 -0
  781. package/dist/site/admin-theme.js.map +1 -0
  782. package/dist/{web → site}/authors.d.ts +1 -72
  783. package/dist/site/authors.d.ts.map +1 -0
  784. package/dist/site/authors.js +107 -0
  785. package/dist/site/authors.js.map +1 -0
  786. package/dist/site/feed-settings.d.ts +28 -0
  787. package/dist/site/feed-settings.d.ts.map +1 -0
  788. package/dist/site/feed-settings.js +22 -0
  789. package/dist/site/feed-settings.js.map +1 -0
  790. package/dist/{web/indexnow.d.ts → site/indexnow-key.d.ts} +1 -1
  791. package/dist/site/indexnow-key.d.ts.map +1 -0
  792. package/dist/{web/indexnow.js → site/indexnow-key.js} +1 -1
  793. package/dist/site/indexnow-key.js.map +1 -0
  794. package/dist/{web → site}/navigation.d.ts +7 -1
  795. package/dist/site/navigation.d.ts.map +1 -0
  796. package/dist/{web → site}/navigation.js +18 -0
  797. package/dist/site/navigation.js.map +1 -0
  798. package/dist/{notifications/preferences.d.ts → site/notification-preferences.d.ts} +2 -2
  799. package/dist/site/notification-preferences.d.ts.map +1 -0
  800. package/dist/{notifications/preferences.js → site/notification-preferences.js} +1 -1
  801. package/dist/site/notification-preferences.js.map +1 -0
  802. package/dist/site/passwords.d.ts.map +1 -0
  803. package/dist/site/passwords.js.map +1 -0
  804. package/dist/{privacy/policy.d.ts → site/retention-policy.d.ts} +1 -1
  805. package/dist/site/retention-policy.d.ts.map +1 -0
  806. package/dist/{privacy/policy.js → site/retention-policy.js} +1 -1
  807. package/dist/site/retention-policy.js.map +1 -0
  808. package/dist/site/robots.d.ts +62 -0
  809. package/dist/site/robots.d.ts.map +1 -0
  810. package/dist/site/robots.js +130 -0
  811. package/dist/site/robots.js.map +1 -0
  812. package/dist/site/setting-rows.d.ts +46 -0
  813. package/dist/site/setting-rows.d.ts.map +1 -0
  814. package/dist/site/setting-rows.js +480 -0
  815. package/dist/site/setting-rows.js.map +1 -0
  816. package/dist/site/setting-values.d.ts +63 -0
  817. package/dist/site/setting-values.d.ts.map +1 -0
  818. package/dist/site/setting-values.js +170 -0
  819. package/dist/site/setting-values.js.map +1 -0
  820. package/dist/site/settings-form.d.ts +30 -0
  821. package/dist/site/settings-form.d.ts.map +1 -0
  822. package/dist/site/settings-form.js +58 -0
  823. package/dist/site/settings-form.js.map +1 -0
  824. package/dist/site/settings.d.ts +345 -0
  825. package/dist/site/settings.d.ts.map +1 -0
  826. package/dist/site/settings.js +107 -0
  827. package/dist/site/settings.js.map +1 -0
  828. package/dist/site/site-json.d.ts +69 -0
  829. package/dist/site/site-json.d.ts.map +1 -0
  830. package/dist/site/site-json.js +113 -0
  831. package/dist/site/site-json.js.map +1 -0
  832. package/dist/{admin/accounts.d.ts → site/users.d.ts} +25 -41
  833. package/dist/site/users.d.ts.map +1 -0
  834. package/dist/{admin/accounts.js → site/users.js} +49 -155
  835. package/dist/site/users.js.map +1 -0
  836. package/dist/store/admin-store.d.ts +72 -0
  837. package/dist/store/admin-store.d.ts.map +1 -0
  838. package/dist/store/admin-store.js +62 -0
  839. package/dist/store/admin-store.js.map +1 -0
  840. package/dist/store/comments.d.ts +253 -0
  841. package/dist/store/comments.d.ts.map +1 -0
  842. package/dist/store/comments.js +234 -0
  843. package/dist/store/comments.js.map +1 -0
  844. package/dist/store/conversation.d.ts +28 -0
  845. package/dist/store/conversation.d.ts.map +1 -0
  846. package/dist/store/conversation.js +16 -0
  847. package/dist/store/conversation.js.map +1 -0
  848. package/dist/store/deliveries.d.ts +92 -0
  849. package/dist/store/deliveries.d.ts.map +1 -0
  850. package/dist/store/deliveries.js +88 -0
  851. package/dist/store/deliveries.js.map +1 -0
  852. package/dist/store/followers.d.ts +91 -0
  853. package/dist/store/followers.d.ts.map +1 -0
  854. package/dist/store/followers.js +79 -0
  855. package/dist/store/followers.js.map +1 -0
  856. package/dist/{federation/replies.d.ts → store/inbox-replies.d.ts} +2 -2
  857. package/dist/store/inbox-replies.d.ts.map +1 -0
  858. package/dist/{federation/replies.js → store/inbox-replies.js} +2 -4
  859. package/dist/store/inbox-replies.js.map +1 -0
  860. package/dist/store/inbox.d.ts +132 -0
  861. package/dist/store/inbox.d.ts.map +1 -0
  862. package/dist/store/inbox.js +157 -0
  863. package/dist/store/inbox.js.map +1 -0
  864. package/dist/store/profiles.d.ts +49 -0
  865. package/dist/store/profiles.d.ts.map +1 -0
  866. package/dist/store/profiles.js +71 -0
  867. package/dist/store/profiles.js.map +1 -0
  868. package/dist/store/relays.d.ts +66 -0
  869. package/dist/store/relays.d.ts.map +1 -0
  870. package/dist/store/relays.js +69 -0
  871. package/dist/store/relays.js.map +1 -0
  872. package/dist/store/rows.d.ts +33 -0
  873. package/dist/store/rows.d.ts.map +1 -0
  874. package/dist/store/rows.js +44 -0
  875. package/dist/store/rows.js.map +1 -0
  876. package/dist/store/schema.d.ts +14 -0
  877. package/dist/store/schema.d.ts.map +1 -0
  878. package/dist/store/schema.js +588 -0
  879. package/dist/store/schema.js.map +1 -0
  880. package/dist/store/sessions.d.ts +90 -0
  881. package/dist/store/sessions.d.ts.map +1 -0
  882. package/dist/store/sessions.js +107 -0
  883. package/dist/store/sessions.js.map +1 -0
  884. package/dist/store/state.d.ts +18 -0
  885. package/dist/store/state.d.ts.map +1 -0
  886. package/dist/store/state.js +19 -0
  887. package/dist/store/state.js.map +1 -0
  888. package/dist/store/tokens.d.ts +75 -0
  889. package/dist/store/tokens.d.ts.map +1 -0
  890. package/dist/store/tokens.js +82 -0
  891. package/dist/store/tokens.js.map +1 -0
  892. package/dist/store/webmentions.d.ts +66 -0
  893. package/dist/store/webmentions.d.ts.map +1 -0
  894. package/dist/store/webmentions.js +84 -0
  895. package/dist/store/webmentions.js.map +1 -0
  896. package/dist/supervisor/primary.d.ts +1 -1
  897. package/dist/supervisor/primary.d.ts.map +1 -1
  898. package/dist/supervisor/primary.js +31 -11
  899. package/dist/supervisor/primary.js.map +1 -1
  900. package/dist/supervisor/worker.d.ts +6 -2
  901. package/dist/supervisor/worker.d.ts.map +1 -1
  902. package/dist/supervisor/worker.js +4 -4
  903. package/dist/supervisor/worker.js.map +1 -1
  904. package/dist/testing.d.ts +8 -0
  905. package/dist/testing.d.ts.map +1 -0
  906. package/dist/testing.js +6 -0
  907. package/dist/testing.js.map +1 -0
  908. package/dist/{web → theme}/assets.d.ts +1 -32
  909. package/dist/theme/assets.d.ts.map +1 -0
  910. package/dist/{web → theme}/assets.js +2 -100
  911. package/dist/theme/assets.js.map +1 -0
  912. package/dist/theme/site-data.d.ts +187 -0
  913. package/dist/theme/site-data.d.ts.map +1 -0
  914. package/dist/theme/site-data.js +160 -0
  915. package/dist/theme/site-data.js.map +1 -0
  916. package/dist/{web → theme}/templates.d.ts +2 -0
  917. package/dist/theme/templates.d.ts.map +1 -0
  918. package/dist/{web → theme}/templates.js +9 -32
  919. package/dist/theme/templates.js.map +1 -0
  920. package/dist/{web → theme}/themes.d.ts +1 -0
  921. package/dist/theme/themes.d.ts.map +1 -0
  922. package/dist/{web → theme}/themes.js +6 -3
  923. package/dist/theme/themes.js.map +1 -0
  924. package/dist/util/client-id.d.ts.map +1 -0
  925. package/dist/util/client-id.js.map +1 -0
  926. package/dist/util/clock.d.ts +9 -0
  927. package/dist/util/clock.d.ts.map +1 -0
  928. package/dist/util/clock.js +2 -0
  929. package/dist/util/clock.js.map +1 -0
  930. package/dist/util/errors.d.ts +3 -0
  931. package/dist/util/errors.d.ts.map +1 -0
  932. package/dist/util/errors.js +5 -0
  933. package/dist/util/errors.js.map +1 -0
  934. package/dist/util/files.d.ts +5 -0
  935. package/dist/util/files.d.ts.map +1 -0
  936. package/dist/util/files.js +38 -0
  937. package/dist/util/files.js.map +1 -0
  938. package/dist/util/forms.d.ts +5 -0
  939. package/dist/util/forms.d.ts.map +1 -0
  940. package/dist/util/forms.js +17 -0
  941. package/dist/util/forms.js.map +1 -0
  942. package/dist/util/guards.d.ts +4 -0
  943. package/dist/util/guards.d.ts.map +1 -0
  944. package/dist/util/guards.js +12 -0
  945. package/dist/util/guards.js.map +1 -0
  946. package/dist/{webmention/html.d.ts → util/html-tree.d.ts} +2 -2
  947. package/dist/util/html-tree.d.ts.map +1 -0
  948. package/dist/{webmention/html.js → util/html-tree.js} +3 -64
  949. package/dist/util/html-tree.js.map +1 -0
  950. package/dist/util/html.d.ts +4 -0
  951. package/dist/util/html.d.ts.map +1 -0
  952. package/dist/util/html.js +34 -0
  953. package/dist/util/html.js.map +1 -0
  954. package/dist/util/public-address.d.ts.map +1 -0
  955. package/dist/util/public-address.js.map +1 -0
  956. package/dist/util/queue.d.ts +7 -0
  957. package/dist/util/queue.d.ts.map +1 -0
  958. package/dist/util/queue.js +23 -0
  959. package/dist/util/queue.js.map +1 -0
  960. package/dist/util/retry.d.ts +11 -0
  961. package/dist/util/retry.d.ts.map +1 -0
  962. package/dist/util/retry.js +14 -0
  963. package/dist/util/retry.js.map +1 -0
  964. package/dist/util/url.d.ts +11 -0
  965. package/dist/util/url.d.ts.map +1 -0
  966. package/dist/util/url.js +20 -0
  967. package/dist/util/url.js.map +1 -0
  968. package/dist/web/absolute-urls.js +1 -1
  969. package/dist/web/absolute-urls.js.map +1 -1
  970. package/dist/web/archive.js +2 -2
  971. package/dist/web/archive.js.map +1 -1
  972. package/dist/web/asset-routes.d.ts +48 -0
  973. package/dist/web/asset-routes.d.ts.map +1 -0
  974. package/dist/web/asset-routes.js +107 -0
  975. package/dist/web/asset-routes.js.map +1 -0
  976. package/dist/{avatars/routes.d.ts → web/avatar-routes.d.ts} +2 -2
  977. package/dist/web/avatar-routes.d.ts.map +1 -0
  978. package/dist/{avatars/routes.js → web/avatar-routes.js} +3 -3
  979. package/dist/web/avatar-routes.js.map +1 -0
  980. package/dist/web/comment-routes.d.ts +18 -0
  981. package/dist/web/comment-routes.d.ts.map +1 -0
  982. package/dist/web/comment-routes.js +177 -0
  983. package/dist/web/comment-routes.js.map +1 -0
  984. package/dist/web/compression.d.ts.map +1 -1
  985. package/dist/web/compression.js +1 -2
  986. package/dist/web/compression.js.map +1 -1
  987. package/dist/{contact/routes.d.ts → web/contact-routes.d.ts} +2 -2
  988. package/dist/web/contact-routes.d.ts.map +1 -0
  989. package/dist/{contact/routes.js → web/contact-routes.js} +14 -17
  990. package/dist/web/contact-routes.js.map +1 -0
  991. package/dist/web/context.d.ts +1 -187
  992. package/dist/web/context.d.ts.map +1 -1
  993. package/dist/web/context.js +5 -171
  994. package/dist/web/context.js.map +1 -1
  995. package/dist/web/conversation.d.ts +6 -333
  996. package/dist/web/conversation.d.ts.map +1 -1
  997. package/dist/web/conversation.js +280 -220
  998. package/dist/web/conversation.js.map +1 -1
  999. package/dist/web/document-page.d.ts +13 -0
  1000. package/dist/web/document-page.d.ts.map +1 -0
  1001. package/dist/web/document-page.js +136 -0
  1002. package/dist/web/document-page.js.map +1 -0
  1003. package/dist/web/document-site.d.ts +14 -0
  1004. package/dist/web/document-site.d.ts.map +1 -0
  1005. package/dist/web/document-site.js +28 -0
  1006. package/dist/web/document-site.js.map +1 -0
  1007. package/dist/web/embed.d.ts +13 -0
  1008. package/dist/web/embed.d.ts.map +1 -0
  1009. package/dist/web/embed.js +66 -0
  1010. package/dist/web/embed.js.map +1 -0
  1011. package/dist/{env.d.ts → web/env.d.ts} +27 -26
  1012. package/dist/web/env.d.ts.map +1 -0
  1013. package/dist/web/env.js.map +1 -0
  1014. package/dist/web/federation-routes.d.ts +33 -0
  1015. package/dist/web/federation-routes.d.ts.map +1 -0
  1016. package/dist/web/federation-routes.js +280 -0
  1017. package/dist/web/federation-routes.js.map +1 -0
  1018. package/dist/web/feed-atom.d.ts.map +1 -1
  1019. package/dist/web/feed-atom.js +4 -3
  1020. package/dist/web/feed-atom.js.map +1 -1
  1021. package/dist/web/feed-item.d.ts +4 -39
  1022. package/dist/web/feed-item.d.ts.map +1 -1
  1023. package/dist/web/feed-item.js +8 -72
  1024. package/dist/web/feed-item.js.map +1 -1
  1025. package/dist/web/feed-json.js +2 -2
  1026. package/dist/web/feed-json.js.map +1 -1
  1027. package/dist/web/feed-routes.d.ts +36 -0
  1028. package/dist/web/feed-routes.d.ts.map +1 -0
  1029. package/dist/web/feed-routes.js +124 -0
  1030. package/dist/web/feed-routes.js.map +1 -0
  1031. package/dist/web/feed-rss.d.ts.map +1 -1
  1032. package/dist/web/feed-rss.js +9 -6
  1033. package/dist/web/feed-rss.js.map +1 -1
  1034. package/dist/web/feed-source.d.ts +13 -40
  1035. package/dist/web/feed-source.d.ts.map +1 -1
  1036. package/dist/web/feed-source.js +14 -43
  1037. package/dist/web/feed-source.js.map +1 -1
  1038. package/dist/web/feeds.d.ts +4 -2
  1039. package/dist/web/feeds.d.ts.map +1 -1
  1040. package/dist/web/feeds.js +8 -5
  1041. package/dist/web/feeds.js.map +1 -1
  1042. package/dist/web/health.d.ts +1 -1
  1043. package/dist/web/health.d.ts.map +1 -1
  1044. package/dist/web/index.d.ts +53 -29
  1045. package/dist/web/index.d.ts.map +1 -1
  1046. package/dist/web/index.js +37 -18
  1047. package/dist/web/index.js.map +1 -1
  1048. package/dist/{indieauth/activity-log.d.ts → web/indieauth-activity-log.d.ts} +3 -3
  1049. package/dist/web/indieauth-activity-log.d.ts.map +1 -0
  1050. package/dist/{indieauth/activity-log.js → web/indieauth-activity-log.js} +5 -6
  1051. package/dist/web/indieauth-activity-log.js.map +1 -0
  1052. package/dist/{indieauth/bearer.d.ts → web/indieauth-bearer.d.ts} +5 -5
  1053. package/dist/web/indieauth-bearer.d.ts.map +1 -0
  1054. package/dist/{indieauth/bearer.js → web/indieauth-bearer.js} +4 -4
  1055. package/dist/web/indieauth-bearer.js.map +1 -0
  1056. package/dist/{indieauth/discovery.d.ts → web/indieauth-discovery.d.ts} +2 -7
  1057. package/dist/web/indieauth-discovery.d.ts.map +1 -0
  1058. package/dist/{indieauth/discovery.js → web/indieauth-discovery.js} +6 -9
  1059. package/dist/web/indieauth-discovery.js.map +1 -0
  1060. package/dist/{indieauth/token.d.ts → web/indieauth-token.d.ts} +2 -2
  1061. package/dist/web/indieauth-token.d.ts.map +1 -0
  1062. package/dist/{indieauth/token.js → web/indieauth-token.js} +7 -7
  1063. package/dist/web/indieauth-token.js.map +1 -0
  1064. package/dist/web/listing-pages.d.ts +19 -0
  1065. package/dist/web/listing-pages.d.ts.map +1 -0
  1066. package/dist/web/listing-pages.js +148 -0
  1067. package/dist/web/listing-pages.js.map +1 -0
  1068. package/dist/web/listing-subject.d.ts +58 -0
  1069. package/dist/web/listing-subject.d.ts.map +1 -0
  1070. package/dist/web/listing-subject.js +89 -0
  1071. package/dist/web/listing-subject.js.map +1 -0
  1072. package/dist/web/llms.d.ts +16 -15
  1073. package/dist/web/llms.d.ts.map +1 -1
  1074. package/dist/web/llms.js +23 -35
  1075. package/dist/web/llms.js.map +1 -1
  1076. package/dist/web/maintenance.d.ts +1 -1
  1077. package/dist/web/maintenance.d.ts.map +1 -1
  1078. package/dist/web/maintenance.js +7 -13
  1079. package/dist/web/maintenance.js.map +1 -1
  1080. package/dist/web/manifest.d.ts +1 -1
  1081. package/dist/web/manifest.d.ts.map +1 -1
  1082. package/dist/web/manifest.js +2 -1
  1083. package/dist/web/manifest.js.map +1 -1
  1084. package/dist/web/markdown-page.d.ts +76 -0
  1085. package/dist/web/markdown-page.d.ts.map +1 -0
  1086. package/dist/web/markdown-page.js +79 -0
  1087. package/dist/web/markdown-page.js.map +1 -0
  1088. package/dist/web/markdown-representation.d.ts +15 -0
  1089. package/dist/web/markdown-representation.d.ts.map +1 -0
  1090. package/dist/web/markdown-representation.js +130 -0
  1091. package/dist/web/markdown-representation.js.map +1 -0
  1092. package/dist/web/negotiate.d.ts +5 -12
  1093. package/dist/web/negotiate.d.ts.map +1 -1
  1094. package/dist/web/negotiate.js +11 -18
  1095. package/dist/web/negotiate.js.map +1 -1
  1096. package/dist/{notifications/routes.d.ts → web/notification-routes.d.ts} +2 -2
  1097. package/dist/web/notification-routes.d.ts.map +1 -0
  1098. package/dist/{notifications/routes.js → web/notification-routes.js} +24 -29
  1099. package/dist/web/notification-routes.js.map +1 -0
  1100. package/dist/web/oembed.d.ts +2 -2
  1101. package/dist/web/oembed.d.ts.map +1 -1
  1102. package/dist/web/oembed.js +5 -4
  1103. package/dist/web/oembed.js.map +1 -1
  1104. package/dist/web/opensearch.d.ts.map +1 -1
  1105. package/dist/web/opensearch.js +2 -1
  1106. package/dist/web/opensearch.js.map +1 -1
  1107. package/dist/web/page-sources.d.ts +15 -0
  1108. package/dist/web/page-sources.d.ts.map +1 -0
  1109. package/dist/web/page-sources.js +44 -0
  1110. package/dist/web/page-sources.js.map +1 -0
  1111. package/dist/web/pagination.d.ts +1 -0
  1112. package/dist/web/pagination.d.ts.map +1 -1
  1113. package/dist/web/pagination.js +4 -1
  1114. package/dist/web/pagination.js.map +1 -1
  1115. package/dist/web/paths.d.ts +52 -0
  1116. package/dist/web/paths.d.ts.map +1 -0
  1117. package/dist/web/paths.js +208 -0
  1118. package/dist/web/paths.js.map +1 -0
  1119. package/dist/web/pingback-routes.d.ts +5 -0
  1120. package/dist/web/pingback-routes.d.ts.map +1 -0
  1121. package/dist/web/pingback-routes.js +100 -0
  1122. package/dist/web/pingback-routes.js.map +1 -0
  1123. package/dist/{plugins/federation.d.ts → web/plugin-federation.d.ts} +3 -3
  1124. package/dist/web/plugin-federation.d.ts.map +1 -0
  1125. package/dist/{plugins/federation.js → web/plugin-federation.js} +5 -5
  1126. package/dist/web/plugin-federation.js.map +1 -0
  1127. package/dist/{plugins/mount.d.ts → web/plugin-routes.d.ts} +3 -3
  1128. package/dist/web/plugin-routes.d.ts.map +1 -0
  1129. package/dist/{plugins/mount.js → web/plugin-routes.js} +2 -2
  1130. package/dist/web/plugin-routes.js.map +1 -0
  1131. package/dist/web/redirects.d.ts +1 -84
  1132. package/dist/web/redirects.d.ts.map +1 -1
  1133. package/dist/web/redirects.js +0 -232
  1134. package/dist/web/redirects.js.map +1 -1
  1135. package/dist/web/render.d.ts +43 -7
  1136. package/dist/web/render.d.ts.map +1 -1
  1137. package/dist/web/render.js +96 -48
  1138. package/dist/web/render.js.map +1 -1
  1139. package/dist/web/request.d.ts +44 -0
  1140. package/dist/web/request.d.ts.map +1 -0
  1141. package/dist/web/request.js +82 -0
  1142. package/dist/web/request.js.map +1 -0
  1143. package/dist/web/robots.d.ts +1 -46
  1144. package/dist/web/robots.d.ts.map +1 -1
  1145. package/dist/web/robots.js +5 -131
  1146. package/dist/web/robots.js.map +1 -1
  1147. package/dist/web/routes.d.ts +5 -39
  1148. package/dist/web/routes.d.ts.map +1 -1
  1149. package/dist/web/routes.js +112 -1373
  1150. package/dist/web/routes.js.map +1 -1
  1151. package/dist/web/search.d.ts.map +1 -1
  1152. package/dist/web/search.js +2 -9
  1153. package/dist/web/search.js.map +1 -1
  1154. package/dist/{admin → web}/session.d.ts +1 -5
  1155. package/dist/web/session.d.ts.map +1 -0
  1156. package/dist/{admin → web}/session.js +0 -4
  1157. package/dist/web/session.js.map +1 -0
  1158. package/dist/web/share-image.d.ts.map +1 -1
  1159. package/dist/web/share-image.js +3 -2
  1160. package/dist/web/share-image.js.map +1 -1
  1161. package/dist/web/signed-in.d.ts +27 -0
  1162. package/dist/web/signed-in.d.ts.map +1 -0
  1163. package/dist/web/signed-in.js +45 -0
  1164. package/dist/web/signed-in.js.map +1 -0
  1165. package/dist/web/site-index.d.ts +13 -0
  1166. package/dist/web/site-index.d.ts.map +1 -0
  1167. package/dist/web/site-index.js +143 -0
  1168. package/dist/web/site-index.js.map +1 -0
  1169. package/dist/web/site-view.d.ts +45 -0
  1170. package/dist/web/site-view.d.ts.map +1 -0
  1171. package/dist/web/site-view.js +67 -0
  1172. package/dist/web/site-view.js.map +1 -0
  1173. package/dist/web/sitemap.d.ts.map +1 -1
  1174. package/dist/web/sitemap.js +5 -7
  1175. package/dist/web/sitemap.js.map +1 -1
  1176. package/dist/{admin → web}/throttle.d.ts +3 -4
  1177. package/dist/web/throttle.d.ts.map +1 -0
  1178. package/dist/{admin → web}/throttle.js +1 -2
  1179. package/dist/web/throttle.js.map +1 -0
  1180. package/dist/web/webmention-routes.d.ts +11 -0
  1181. package/dist/web/webmention-routes.d.ts.map +1 -0
  1182. package/dist/web/webmention-routes.js +51 -0
  1183. package/dist/web/webmention-routes.js.map +1 -0
  1184. package/dist/web/well-known.d.ts +2 -2
  1185. package/dist/web/well-known.d.ts.map +1 -1
  1186. package/dist/web/well-known.js +5 -14
  1187. package/dist/web/well-known.js.map +1 -1
  1188. package/dist/webmention/cited-picture.d.ts +1 -2
  1189. package/dist/webmention/cited-picture.d.ts.map +1 -1
  1190. package/dist/webmention/cited-picture.js +4 -6
  1191. package/dist/webmention/cited-picture.js.map +1 -1
  1192. package/dist/webmention/discovery.d.ts +2 -13
  1193. package/dist/webmention/discovery.d.ts.map +1 -1
  1194. package/dist/webmention/discovery.js +18 -73
  1195. package/dist/webmention/discovery.js.map +1 -1
  1196. package/dist/webmention/endpoint.d.ts +27 -0
  1197. package/dist/webmention/endpoint.d.ts.map +1 -0
  1198. package/dist/webmention/endpoint.js +26 -0
  1199. package/dist/webmention/endpoint.js.map +1 -0
  1200. package/dist/webmention/fetch-public.d.ts +18 -1
  1201. package/dist/webmention/fetch-public.d.ts.map +1 -1
  1202. package/dist/webmention/fetch-public.js +57 -23
  1203. package/dist/webmention/fetch-public.js.map +1 -1
  1204. package/dist/webmention/index.d.ts +11 -9
  1205. package/dist/webmention/index.d.ts.map +1 -1
  1206. package/dist/webmention/index.js +7 -6
  1207. package/dist/webmention/index.js.map +1 -1
  1208. package/dist/webmention/links.d.ts +0 -55
  1209. package/dist/webmention/links.d.ts.map +1 -1
  1210. package/dist/webmention/links.js +2 -185
  1211. package/dist/webmention/links.js.map +1 -1
  1212. package/dist/webmention/microformats.d.ts +1 -1
  1213. package/dist/webmention/microformats.d.ts.map +1 -1
  1214. package/dist/webmention/microformats.js +1 -1
  1215. package/dist/webmention/microformats.js.map +1 -1
  1216. package/dist/webmention/pingback.d.ts +12 -4
  1217. package/dist/webmention/pingback.d.ts.map +1 -1
  1218. package/dist/webmention/pingback.js +4 -102
  1219. package/dist/webmention/pingback.js.map +1 -1
  1220. package/dist/webmention/receive.d.ts +7 -4
  1221. package/dist/webmention/receive.d.ts.map +1 -1
  1222. package/dist/webmention/receive.js +34 -42
  1223. package/dist/webmention/receive.js.map +1 -1
  1224. package/dist/webmention/reply-context/fediverse.d.ts +29 -0
  1225. package/dist/webmention/reply-context/fediverse.d.ts.map +1 -0
  1226. package/dist/webmention/reply-context/fediverse.js +59 -0
  1227. package/dist/webmention/reply-context/fediverse.js.map +1 -0
  1228. package/dist/webmention/reply-context/json-ld.d.ts +5 -0
  1229. package/dist/webmention/reply-context/json-ld.d.ts.map +1 -0
  1230. package/dist/webmention/reply-context/json-ld.js +78 -0
  1231. package/dist/webmention/reply-context/json-ld.js.map +1 -0
  1232. package/dist/webmention/reply-context/oembed.d.ts +21 -0
  1233. package/dist/webmention/reply-context/oembed.d.ts.map +1 -0
  1234. package/dist/webmention/reply-context/oembed.js +137 -0
  1235. package/dist/webmention/reply-context/oembed.js.map +1 -0
  1236. package/dist/webmention/reply-context/page.d.ts +30 -0
  1237. package/dist/webmention/reply-context/page.d.ts.map +1 -0
  1238. package/dist/webmention/reply-context/page.js +134 -0
  1239. package/dist/webmention/reply-context/page.js.map +1 -0
  1240. package/dist/webmention/reply-context/source.d.ts +29 -0
  1241. package/dist/webmention/reply-context/source.d.ts.map +1 -0
  1242. package/dist/webmention/reply-context/source.js +46 -0
  1243. package/dist/webmention/reply-context/source.js.map +1 -0
  1244. package/dist/webmention/reply-context.d.ts +5 -34
  1245. package/dist/webmention/reply-context.d.ts.map +1 -1
  1246. package/dist/webmention/reply-context.js +9 -432
  1247. package/dist/webmention/reply-context.js.map +1 -1
  1248. package/dist/webmention/reply-contexts.d.ts +3 -3
  1249. package/dist/webmention/reply-contexts.d.ts.map +1 -1
  1250. package/dist/webmention/reply-contexts.js +7 -10
  1251. package/dist/webmention/reply-contexts.js.map +1 -1
  1252. package/dist/webmention/routes.d.ts +1 -1
  1253. package/dist/webmention/routes.d.ts.map +1 -1
  1254. package/dist/webmention/routes.js +5 -8
  1255. package/dist/webmention/routes.js.map +1 -1
  1256. package/dist/webmention/service.d.ts +7 -7
  1257. package/dist/webmention/service.d.ts.map +1 -1
  1258. package/dist/webmention/service.js +49 -61
  1259. package/dist/webmention/service.js.map +1 -1
  1260. package/dist/webmention/syndication.d.ts +3 -26
  1261. package/dist/webmention/syndication.d.ts.map +1 -1
  1262. package/dist/webmention/syndication.js +6 -20
  1263. package/dist/webmention/syndication.js.map +1 -1
  1264. package/package.json +5 -1
  1265. package/themes/default/README.md +36 -2329
  1266. package/themes/default/layouts/base.njk +5 -4
  1267. package/themes/default/layouts/comment.njk +3 -3
  1268. package/themes/default/layouts/front-page.njk +4 -3
  1269. package/themes/default/layouts/page.njk +2 -0
  1270. package/themes/default/partials/comment-form.njk +1 -1
  1271. package/themes/default/partials/comment.njk +5 -5
  1272. package/themes/default/partials/contact-form.njk +1 -2
  1273. package/themes/default/partials/conversation.njk +1 -2
  1274. package/themes/default/partials/markdown-footer.md.njk +24 -0
  1275. package/themes/default/partials/markdown-header.md.njk +31 -0
  1276. package/themes/default/src/style.css +6 -6
  1277. package/dist/admin/accounts.d.ts.map +0 -1
  1278. package/dist/admin/accounts.js.map +0 -1
  1279. package/dist/admin/admin-theme.d.ts.map +0 -1
  1280. package/dist/admin/admin-theme.js.map +0 -1
  1281. package/dist/admin/enclosure-field.d.ts.map +0 -1
  1282. package/dist/admin/enclosure-field.js.map +0 -1
  1283. package/dist/admin/event-field.d.ts.map +0 -1
  1284. package/dist/admin/event-field.js.map +0 -1
  1285. package/dist/admin/location-field.d.ts +0 -27
  1286. package/dist/admin/location-field.d.ts.map +0 -1
  1287. package/dist/admin/location-field.js.map +0 -1
  1288. package/dist/admin/passwords.d.ts.map +0 -1
  1289. package/dist/admin/passwords.js.map +0 -1
  1290. package/dist/admin/photo-field.d.ts +0 -38
  1291. package/dist/admin/photo-field.d.ts.map +0 -1
  1292. package/dist/admin/photo-field.js.map +0 -1
  1293. package/dist/admin/read-field.d.ts.map +0 -1
  1294. package/dist/admin/read-field.js +0 -61
  1295. package/dist/admin/read-field.js.map +0 -1
  1296. package/dist/admin/session.d.ts.map +0 -1
  1297. package/dist/admin/session.js.map +0 -1
  1298. package/dist/admin/store.d.ts +0 -998
  1299. package/dist/admin/store.d.ts.map +0 -1
  1300. package/dist/admin/store.js +0 -1588
  1301. package/dist/admin/store.js.map +0 -1
  1302. package/dist/admin/throttle.d.ts.map +0 -1
  1303. package/dist/admin/throttle.js.map +0 -1
  1304. package/dist/avatars/routes.d.ts.map +0 -1
  1305. package/dist/avatars/routes.js.map +0 -1
  1306. package/dist/contact/routes.d.ts.map +0 -1
  1307. package/dist/contact/routes.js.map +0 -1
  1308. package/dist/env.d.ts.map +0 -1
  1309. package/dist/env.js.map +0 -1
  1310. package/dist/federation/replies.d.ts.map +0 -1
  1311. package/dist/federation/replies.js.map +0 -1
  1312. package/dist/images/alt-text.d.ts.map +0 -1
  1313. package/dist/images/alt-text.js.map +0 -1
  1314. package/dist/indieauth/activity-log.d.ts.map +0 -1
  1315. package/dist/indieauth/activity-log.js.map +0 -1
  1316. package/dist/indieauth/app-activity.d.ts.map +0 -1
  1317. package/dist/indieauth/app-activity.js.map +0 -1
  1318. package/dist/indieauth/bearer.d.ts.map +0 -1
  1319. package/dist/indieauth/bearer.js.map +0 -1
  1320. package/dist/indieauth/client-id.d.ts.map +0 -1
  1321. package/dist/indieauth/client-id.js.map +0 -1
  1322. package/dist/indieauth/connected-apps.d.ts.map +0 -1
  1323. package/dist/indieauth/connected-apps.js.map +0 -1
  1324. package/dist/indieauth/consent.d.ts.map +0 -1
  1325. package/dist/indieauth/consent.js.map +0 -1
  1326. package/dist/indieauth/discovery.d.ts.map +0 -1
  1327. package/dist/indieauth/discovery.js.map +0 -1
  1328. package/dist/indieauth/token.d.ts.map +0 -1
  1329. package/dist/indieauth/token.js.map +0 -1
  1330. package/dist/mail/service.d.ts.map +0 -1
  1331. package/dist/mail/service.js.map +0 -1
  1332. package/dist/mail/templates.d.ts.map +0 -1
  1333. package/dist/mail/templates.js.map +0 -1
  1334. package/dist/notifications/preferences.d.ts.map +0 -1
  1335. package/dist/notifications/preferences.js.map +0 -1
  1336. package/dist/notifications/routes.d.ts.map +0 -1
  1337. package/dist/notifications/routes.js.map +0 -1
  1338. package/dist/plugins/federation.d.ts.map +0 -1
  1339. package/dist/plugins/federation.js.map +0 -1
  1340. package/dist/plugins/mount.d.ts.map +0 -1
  1341. package/dist/plugins/mount.js.map +0 -1
  1342. package/dist/privacy/policy.d.ts.map +0 -1
  1343. package/dist/privacy/policy.js.map +0 -1
  1344. package/dist/web/admin-bar.d.ts.map +0 -1
  1345. package/dist/web/admin-bar.js.map +0 -1
  1346. package/dist/web/assets.d.ts.map +0 -1
  1347. package/dist/web/assets.js.map +0 -1
  1348. package/dist/web/authors.d.ts.map +0 -1
  1349. package/dist/web/authors.js +0 -211
  1350. package/dist/web/authors.js.map +0 -1
  1351. package/dist/web/documents.d.ts +0 -81
  1352. package/dist/web/documents.d.ts.map +0 -1
  1353. package/dist/web/documents.js.map +0 -1
  1354. package/dist/web/errors.d.ts.map +0 -1
  1355. package/dist/web/errors.js.map +0 -1
  1356. package/dist/web/guids.d.ts.map +0 -1
  1357. package/dist/web/guids.js.map +0 -1
  1358. package/dist/web/indexnow.d.ts.map +0 -1
  1359. package/dist/web/indexnow.js.map +0 -1
  1360. package/dist/web/license.d.ts.map +0 -1
  1361. package/dist/web/license.js.map +0 -1
  1362. package/dist/web/locale.d.ts.map +0 -1
  1363. package/dist/web/locale.js.map +0 -1
  1364. package/dist/web/navigation.d.ts.map +0 -1
  1365. package/dist/web/navigation.js.map +0 -1
  1366. package/dist/web/recent.d.ts +0 -43
  1367. package/dist/web/recent.d.ts.map +0 -1
  1368. package/dist/web/recent.js +0 -37
  1369. package/dist/web/recent.js.map +0 -1
  1370. package/dist/web/sanitize.d.ts +0 -29
  1371. package/dist/web/sanitize.d.ts.map +0 -1
  1372. package/dist/web/sanitize.js.map +0 -1
  1373. package/dist/web/taxonomy.d.ts.map +0 -1
  1374. package/dist/web/taxonomy.js.map +0 -1
  1375. package/dist/web/templates.d.ts.map +0 -1
  1376. package/dist/web/templates.js.map +0 -1
  1377. package/dist/web/themes.d.ts.map +0 -1
  1378. package/dist/web/themes.js.map +0 -1
  1379. package/dist/webmention/html.d.ts.map +0 -1
  1380. package/dist/webmention/html.js.map +0 -1
  1381. package/dist/webmention/public-address.d.ts.map +0 -1
  1382. package/dist/webmention/public-address.js.map +0 -1
  1383. /package/dist/{web → content}/license.d.ts +0 -0
  1384. /package/dist/{web → content}/license.js +0 -0
  1385. /package/dist/{admin → site}/admin-theme.d.ts +0 -0
  1386. /package/dist/{admin → site}/admin-theme.js +0 -0
  1387. /package/dist/{admin → site}/passwords.d.ts +0 -0
  1388. /package/dist/{admin → site}/passwords.js +0 -0
  1389. /package/dist/{indieauth → util}/client-id.d.ts +0 -0
  1390. /package/dist/{indieauth → util}/client-id.js +0 -0
  1391. /package/dist/{webmention → util}/public-address.d.ts +0 -0
  1392. /package/dist/{webmention → util}/public-address.js +0 -0
  1393. /package/dist/{env.js → web/env.js} +0 -0
package/README.md CHANGED
@@ -1,13 +1,10 @@
1
1
  # @geekity/cms
2
2
 
3
- A file-first CMS. Content is Markdown on disk, laid out so Eleventy can build
4
- the same directory unchanged. One Node process is both the editor and the public
3
+ A file-first CMS. Content is Markdown on disk, in a layout that Eleventy can
4
+ build unchanged. One Node process serves both the editor and the public
5
5
  website. SQLite holds a derived index plus the data that has no natural file
6
6
  form.
7
7
 
8
- This is the guide for someone building a site on the package. The repository's
9
- own [README](https://github.com/geekitycom/cms#readme) is the contributor guide.
10
-
11
8
  Requires Node 24 or newer, the active LTS line.
12
9
 
13
10
  ## A new site
@@ -19,38 +16,9 @@ pnpm install
19
16
  pnpm dev
20
17
  ```
21
18
 
22
- That is a running site on <http://localhost:3000> with a post, an About page, a
23
- starter privacy page and the packaged theme. `geekity init` refuses a directory that already has anything in
24
- it, so it can never write over a site you already have.
25
-
26
- What it writes:
27
-
28
- ```
29
- package.json depends on @geekity/cms; scripts dev, start and sync
30
- geekity.config.ts port, directories, base URL
31
- server.ts the entry file; where your own routes go
32
- tsconfig.json so the site type checks against the package
33
- content/
34
- posts/ Markdown posts, plus posts.json for Eleventy
35
- pages/ about.md, privacy.md, plus pages.json
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
39
- .gitignore node_modules, data and .env
40
- ```
41
-
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.
50
-
51
- The generated `package.json` pins `@geekity/cms` to the version of the CLI that
52
- wrote it, so a site is never scaffolded against a version it has not been tested
53
- with.
19
+ The site runs on <http://localhost:3000> with a post, an About page, a
20
+ starter privacy page and the packaged theme. `geekity init` refuses a directory
21
+ that is not empty, so it never overwrites an existing site.
54
22
 
55
23
  ## An existing project
56
24
 
@@ -59,7 +27,7 @@ pnpm add @geekity/cms
59
27
  ```
60
28
 
61
29
  Then write a `geekity.config.ts` and a `content/` directory, and either add an
62
- entry file or run the bin. Nothing else is required.
30
+ entry file or run `geekity serve`.
63
31
 
64
32
  ## Upgrading
65
33
 
@@ -67,4184 +35,28 @@ entry file or run the bin. Nothing else is required.
67
35
  pnpm up @geekity/cms
68
36
  ```
69
37
 
70
- Database migrations ship inside the package and run on boot, so a version bump
71
- is the whole upgrade. The config schema, `createCms`, the template context, the
72
- JSON representation and the content format are all covered by semver: a
73
- breaking change to any of them is a major with a migration note in the
74
- changelog.
75
-
76
- Upgrading to the release that renamed the session cookie signs everybody out
77
- of an https site once. See [Session hardening](#session-hardening).
78
-
79
- Upgrading never deletes a reader's data. The release that added
80
- [retention](#retention) keeps every commenter email, address hash and contact
81
- message on a site whose `site.json` has not set a period.
82
-
83
- ## The `geekity` command
84
-
85
- `geekity` is installed as a bin, so `pnpm geekity <command>` runs it inside a
86
- site (or `npx geekity`, or a `package.json` script, which is how the generated
87
- `sync` script calls it).
88
-
89
- | Command | What it does |
90
- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
91
- | `geekity serve` | Boot from the config file and listen, in a worker the command supervises: a change to the plugins folder replaces it, and a crash respawns it. The default when no command is given. |
92
- | `geekity init <dir>` | Create a new site in `<dir>`. Refuses a directory that is not empty. |
93
- | `geekity sync` | Rebuild the content index once and exit. Exits non-zero if any file could not be parsed. |
94
- | `geekity rebuild` | Delete `data/geekity.db` and build it again from the files. |
95
- | `geekity resend --all`, `<slug>...` | Send announced posts to every follower and relay again, as they now read. See [Quote posts](#quote-posts). |
96
- | `geekity maintenance on`, `off`, `status` | Take the public site down on purpose with a 503 and `Retry-After`, or bring it back, without a restart. `on --until <time>` names when it should be back. |
97
- | `geekity dev-mode on`, `off`, `status` | Hold everything the site would send to the outside world while it is built or migrated, or take it live. See [Dev mode](#dev-mode). |
98
- | `geekity strip-metadata` | Remove location and camera metadata from files already in `content/uploads`. See [The media library](#the-media-library). |
99
- | `geekity user add <name>` | Create an admin account, so a site can get its first login without the setup screen. |
100
- | `geekity plugin add <package>`, `remove` | Install a plugin package's bundle from the npm registry into the plugins folder (`GEEKITY_PLUGINS_DIR`), or delete a disabled one. A running `geekity serve` reloads with the change. |
101
- | `geekity --help`, `-h` | The same table, on the terminal. |
102
- | `geekity --version` | The installed version. |
103
-
104
- An installed plugin may add commands of its own, which `geekity --help` lists
105
- under _Plugin commands_ and which run whether or not the plugin is enabled.
106
- That includes a plugin in the plugins folder. The repository README's
107
- _Plugins_ section describes `geekity plugin add` and writing a plugin.
108
-
109
- `serve`, `sync`, `rebuild`, `resend`, `maintenance`, `dev-mode`, `strip-metadata`, `user add`, `plugin` and every plugin command take
110
- `--config <file>`; without it they look for `geekity.config.ts`, then
111
- `geekity.config.js`, then `geekity.config.mjs` in the working directory, and run
112
- on defaults if there is none.
113
-
114
- `sync` prints what the scan did and forces watching off whatever the config
115
- says, because a one-shot scan that then sat in a watcher would never exit:
116
-
117
- ```sh
118
- $ geekity sync
119
- Scanned 12: 2 created, 1 updated, 0 removed, 9 unchanged, 0 failed
120
- ```
121
-
122
- A file that will not parse is logged, left out of the index and counted in
123
- `failed`; the command then exits `1` so a deploy step notices. Everything else
124
- in the directory is still indexed.
125
-
126
- ### Starting the database again
127
-
128
- `geekity rebuild` deletes `data/geekity.db` (with its `-wal` and `-shm`) and
129
- builds a new one exactly as a boot does — the same migrations, the same scan of
130
- `content/`, the same read of `content/_data/federation/` — then says what it
131
- indexed:
132
-
133
- ```sh
134
- $ geekity rebuild
135
- Rebuilt /srv/blog/data/geekity.db from the files.
136
- Scanned 214: 214 created, 0 updated, 0 removed, 0 unchanged, 0 failed
137
- Indexed 37 followers and 1,204 inbox activities.
138
- ```
139
-
140
- Nothing in the database is anything but a reading of `content/` and `data/`, so
141
- this is a command with no undo and nothing to lose; [what a rebuild does cost
142
- is listed below](#two-directories-content-and-data). It is also the way past a
143
- database this version refuses to open — a damaged one, or one written by a newer
144
- `@geekity/cms`.
145
-
146
- It refuses while the site is running, because deleting the file under a live
147
- server would leave it writing to a database nothing can find. Stop the site
148
- first. A file that will not parse is reported and exits `1`, as with `sync`.
149
-
150
- That is why it is not the usual repair. **Tools > Content index** in the admin
151
- empties the index and reads every file again in place, on the live connections:
152
- no downtime, nobody signed out, and the delivery log, the relay handshakes and
153
- the scheduler's watermark all kept. It is behind a confirm step, because the
154
- site answers 404 for its documents between the emptying and the end of the
155
- scan, and it federates nothing — a change a scan makes carries `origin: 'scan'`,
156
- which delivery, the webmentions and the feed pings ignore. Use the command for
157
- the one case the screen cannot be the door for: a database this version refuses
158
- to open, where there is no site running to press a button in.
159
-
160
- ### Creating an admin from the command line
161
-
162
- `geekity user add <username>` writes a user straight into `data/users.json`,
163
- which is how a site that cannot reach `/admin/setup` from a browser — a
164
- headless deploy, a server behind a bastion — gets its first login. It is also
165
- the way back into a site that has locked everybody out and cannot send email.
166
- It enforces exactly the rules the setup form does: a username of 1 to 64
167
- letters, digits, dots, dashes or underscores, and a password of at least 8
168
- characters.
169
-
170
- ```sh
171
- $ geekity user add ada
172
- Password for ada:
173
- Created admin user ada. Sign in at /admin/login.
174
- ```
175
-
176
- The prompt does not echo. When standard input is not a terminal the command
177
- prints no prompt and reads the password as a single line, so a script can pipe
178
- one in:
179
-
180
- ```sh
181
- printf '%s\n' "$ADMIN_PASSWORD" | geekity user add ada
182
- ```
183
-
184
- `--password <pw>` passes it inline instead. That is the least private of the
185
- three: a password on the command line is visible in the process list and lands
186
- in shell history.
187
-
188
- `--email <address>` puts an address on the new user, which is optional and is
189
- what [password recovery](#forgotten-passwords) works through:
190
-
191
- ```sh
192
- $ geekity user add ada --email ada@example.com
193
- Password for ada:
194
- Created admin user ada (ada@example.com). Sign in at /admin/login.
195
- ```
196
-
197
- A name that is already taken is refused and nothing is written; so are an
198
- illegal username, a short password and an address that is not one. Every
199
- refusal prints why and exits `1`.
200
-
201
- Once somebody can sign in, the [`/admin/users` screen](#the-admin) is the
202
- easier door: it adds users, generates passwords and deletes them, and each
203
- user's own screen under it is where the rest of an account is edited.
204
-
205
- ### How a TypeScript config is loaded
206
-
207
- `geekity serve` and `geekity sync` import `geekity.config.ts` directly. That is
208
- all it takes on Node 24, which strips types without a flag. For a config using
209
- syntax stripping cannot erase, such as `enum`, the
210
- CLI falls back to registering the **tsx installed in your own site**, which is
211
- what `geekity init` puts in `devDependencies` (it is what runs `server.ts` too).
212
- A site that wants neither can write `geekity.config.js` instead; it is in the
213
- lookup list for exactly that reason. Nothing else is searched: the tsx has to be
214
- the site's, so a copy that happens to be elsewhere on the machine is never used.
215
-
216
- ## Using the package from an entry file
217
-
218
- An entry file exists so a site can add routes and middleware of its own. A site
219
- that adds nothing can delete `server.ts` and run `geekity serve`.
220
-
221
- ```ts
222
- import { createCms } from '@geekity/cms';
223
-
224
- import config from './geekity.config.ts';
225
-
226
- const cms = createCms(config);
227
-
228
- cms.app.get('/hello/', (c) => c.text('a route of my own'));
229
-
230
- await cms.serve();
231
- ```
232
-
233
- `createCms(config)` returns a `Cms`:
234
-
235
- | Member | What it is |
236
- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------- |
237
- | `app` | The Hono app. Add routes and middleware before serving; handlers reach `c.var.store`, `c.var.config` and `c.var.renderer`. |
238
- | `config` | The config after defaults and environment overrides, every path absolute. |
239
- | `store` | The content index. |
240
- | `events` | Index changes as they happen. |
241
- | `onDocumentChange(hook)` | Subscribe to every change; returns the unsubscribe. |
242
- | `onPublish(hook)` | Subscribe to documents becoming visible; returns the unsubscribe. |
243
- | `sync()` | One full scan of the content directory. |
244
- | `serve()` | Scan, start watching, listen. Resolves with the port actually bound, which matters when the configured port is `0`. |
245
- | `close()` | Stop the watcher, the server and the index. Safe to call twice, and required, because the index holds an open database. |
246
-
247
- Routes a site registers always win over the content index. Documents are
248
- resolved in the not-found handler, after every registered route has failed to
249
- match, so a post whose permalink is `/hello/` cannot shadow the `/hello/` route
250
- above — and neither can it shadow the admin and federation routes of later
251
- milestones.
252
-
253
- ## Configuration
254
-
255
- Declare config in `geekity.config.ts`; `defineConfig` gives it type checking.
256
-
257
- ```ts
258
- import { defineConfig } from '@geekity/cms';
259
-
260
- export default defineConfig({
261
- port: 3000,
262
- contentDir: 'content',
263
- dataDir: 'data',
264
- themesDir: 'themes',
265
- baseUrl: 'http://localhost:3000',
266
- watch: true,
267
- sessionLifetime: 60 * 60 * 24 * 14,
268
- loginAttempts: 5,
269
- loginLockout: 15 * 60,
270
- trustProxy: false,
271
- });
272
- ```
273
-
274
- Every field is optional. Relative directories resolve against the working
275
- directory; absolute ones are used as given.
276
-
277
- | Field | Default | Environment override | Meaning |
278
- | ------------------ | ------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
279
- | `port` | `3000` | `GEEKITY_PORT`, then `PORT` | Port the HTTP server listens on. `0` picks a free one. |
280
- | `contentDir` | `<cwd>/content` | `GEEKITY_CONTENT_DIR` | Markdown content. |
281
- | `dataDir` | `<cwd>/data` | `GEEKITY_DATA_DIR` | Derived state — the SQLite index, the image variants — and the files that are not derived and must be backed up, such as `users.json`, the actor key pairs under `keys/` and `locations.json`. [Two directories](#two-directories-content-and-data) lists them. |
282
- | `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. |
283
- | `baseUrl` | `http://localhost:<port>` | `GEEKITY_BASE_URL` | Public origin for canonical URLs, feeds and ActivityPub ids. A trailing slash is stripped. |
284
- | `watch` | `true` | `GEEKITY_WATCH` | Watch `contentDir` while serving and keep the index in step. |
285
- | `sessionLifetime` | `1209600` (14 days) | `GEEKITY_SESSION_LIFETIME` | How long an admin login lasts, in seconds. |
286
- | `loginAttempts` | `5` | `GEEKITY_LOGIN_ATTEMPTS` | Failed sign-ins a username or an address may make before it is locked out. |
287
- | `loginLockout` | `900` (15 minutes) | `GEEKITY_LOGIN_LOCKOUT` | How long the first lockout lasts, in seconds. See [Login hardening](#login-hardening). |
288
- | `trustProxy` | `false` | `GEEKITY_TRUST_PROXY` | Believe `X-Forwarded-For` when deciding which address a sign-in came from. |
289
- | `accessLog` | `false`; `true` under `geekity serve` | `GEEKITY_ACCESS_LOG` | One line per request on stdout: method, path with query, status, duration. See [The access log](#the-access-log). |
290
- | `accessLogAddress` | `false` | `GEEKITY_ACCESS_LOG_ADDRESS` | Put the client address on the end of each access-log line. `trustProxy` decides which address that is. |
291
- | `accessLogWriter` | stdout | — | Where the lines go instead. See [The access log](#the-access-log). |
292
- | `maintenance` | `false` | `GEEKITY_MAINTENANCE` | Keep the site in maintenance mode, answering 503, until a restart without it. |
293
- | `devMode` | `false` | `GEEKITY_DEV_MODE` | Start the site in dev mode, which holds every outbound side effect until `geekity dev-mode off`. See [Dev mode](#dev-mode). |
294
- | `compression` | `true` | `GEEKITY_COMPRESSION` | Compress text responses with brotli or gzip. Turn it off behind a proxy that compresses. See [Compression](#compression). |
295
- | `securityHeaders` | [see below](#security-headers) | — | Headers every response carries. A string replaces a default or adds a header, `false` removes one. |
296
- | `onDocumentChange` | none | — | Hook run for every change to the index. See [Hooks](#hooks). |
297
- | `onPublish` | none | — | Hook run when a document becomes visible. See [Hooks](#hooks). |
298
- | `federation` | `{}` | — | Federation stores and guards. See [Federation](#federation). |
299
- | `commentChecker` | Akismet | — | A spam checker of the site's own, which wins over the key in `data/akismet.json`. See [Akismet](#akismet). |
300
- | `mail` | `{}` | — | Mail provider, retries, backoff and logger. See [Email](#email). |
301
- | `plugins` | `[]` | — | The installed plugins. Which run is the `plugins` key of `site.json`, set under Admin > Plugins. A plugin's secret settings take an environment variable each; see below. |
302
-
303
- Precedence is environment variable, then config file, then default, so a host
304
- can override anything without editing the site.
305
-
306
- A plugin's secret settings, such as an API key, can come from the environment
307
- too, which is how a Docker site keeps a key in `.env`. The variable is the
308
- plugin's package name, then `__`, then the setting's key, each upper-cased with
309
- every run of other characters, underscores included, written as one `_`:
310
-
311
- | Package | Setting | Variable |
312
- | --------------------- | --------- | ----------------------------- |
313
- | `@geekity/plugin-llm` | `api_key` | `GEEKITY_PLUGIN_LLM__API_KEY` |
314
- | `@acme/plugin-llm` | `api_key` | `ACME_PLUGIN_LLM__API_KEY` |
315
-
316
- A set variable wins over the value stored in
317
- `data/plugins/<package name>/secrets.json`, and the plugin's screen shows the
318
- setting as set by the environment, with no box to change it. The screen prints
319
- each secret's exact variable name. Only plugin variables contain `__`, so none
320
- can shadow a core one. Two installed packages whose names convert to the same
321
- prefix, such as `@a/b-c` and `@a-b/c`, both refuse to load, and Admin > Plugins
322
- names the two. A boolean environment variable
323
- takes `true`, `1`, `yes` and `on`, or their opposites; anything else is an error
324
- rather than a silent `false`.
325
-
326
- ## The access log
327
-
328
- `geekity serve` writes one line per request to stdout. `createCms` does not:
329
- a CMS mounted inside somebody else's server has no business writing to its
330
- stdout uninvited, so an embedder asks for it with `accessLog: true` — in the
331
- config, no environment variable needed:
332
-
333
- ```ts
334
- const cms = createCms({ accessLog: true });
335
- ```
336
-
337
- The line is the method, the path with its query string, the status and how
338
- long the request took, in that order, separated by single spaces:
339
-
340
- ```
341
- GET / 200 4.2ms
342
- GET /.well-known/webfinger?resource=acct:ada@blog.example 200 1.9ms
343
- GET /nothing-here 404 2.1ms
344
- POST /admin/login 303 41.3ms
345
- ```
346
-
347
- Every route is on it — `/healthz`, the federation endpoints, the admin and the
348
- public site — and a request whose handler threw gets its `500` line. Nothing
349
- but the request line is ever read: no body is touched, so a password posted to
350
- the login form cannot reach the log, and no cookie or `Authorization` header
351
- is looked at either.
352
-
353
- With `accessLogAddress: true` the client address goes on the end, after the
354
- duration. It is off by default because an address is personal data. Which
355
- address is right is `trustProxy`'s answer — the leftmost `X-Forwarded-For`
356
- entry behind a proxy that sets it, the socket's own address otherwise — the
357
- same one the login throttle counts a failed sign-in against.
358
-
359
- `accessLogWriter` takes the lines somewhere else, one call per line, without
360
- the newline:
361
-
362
- ```ts
363
- const lines: string[] = [];
364
- const cms = createCms({
365
- accessLog: true,
366
- accessLogWriter: (line) => lines.push(line),
367
- });
368
- ```
369
-
370
- ## Two directories: `content/` and `data/`
371
-
372
- Everything a site cannot afford to lose is a file, and every one of those files
373
- is in one of two directories. Nothing else needs backing up, and nothing else
374
- needs carrying across a deploy.
375
-
376
- `content/` is what the site publishes. It belongs in git, an Eleventy build of
377
- the same directory reads all of it, and everything in it is meant to be public:
378
-
379
- | Path | What it holds |
380
- | -------------------------------------------------- | ------------------------------------------------------------------------ |
381
- | `content/posts/`, `content/pages/` | The Markdown documents, `_trash/` included. |
382
- | `content/uploads/` | Uploaded files, with their location and camera metadata removed. |
383
- | `content/_data/site.json` | Every site setting. |
384
- | `content/_data/federation/followers.json` | Who follows the site. |
385
- | `content/_data/federation/inbox/{yyyy}-{mm}.jsonl` | Every activity the inbox was handed, one per line. |
386
- | `content/_data/comments/{slug}.json` | The comments left on that post, whatever a moderator has done with them. |
387
-
388
- `data/` is private. It is gitignored, and it is the half to copy somewhere safe:
389
-
390
- | Path | What it holds |
391
- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
392
- | `data/users.json` | Usernames and argon2id password hashes. Mode `0600`. |
393
- | `data/keys/` | Each user's actor key pairs as JWK files. Mode `0600`. **Losing these breaks federation.** |
394
- | `data/locations.json` | Where each post was written, keyed by permalink (decision-29). Mode `0600`. |
395
- | `data/comment-salt` | What hides commenters' addresses in the published comment files. Mode `0600`. |
396
- | `data/comments/{slug}.json` | The emails of that post's commenters, and whether each asked to hear about replies, keyed by comment id. Mode `0600`. |
397
- | `data/akismet.json` | The Akismet key, and what `verify-key` last said about it. Mode `0600`. |
398
- | `data/mail.json` | The mail credential: a Brevo API key, an SMTP connection, or both. Mode `0600`. |
399
- | `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. |
400
- | `data/comment-optouts.json` | The addresses that have unsubscribed from reply notices. Mode `0600`. |
401
- | `data/indieauth-tokens.json` | A SHA-256 hash of each IndieAuth access and refresh token, with its user, app, scopes and expiry (decision-24). Mode `0600`. |
402
- | `data/notification-digests.json` | When each user was last sent a digest. Mode `0600`. Losing it sends one digest early and nothing worse. |
403
- | `data/plugins/<package name>/` | Each plugin's private folder. Its secret settings are in `secrets.json`, mode `0600`; the rest is the plugin's to say. |
404
-
405
- Three things under `data/` may be deleted whenever the site is stopped, and
406
- nothing else in either directory may:
407
-
408
- | Path | What it is |
409
- | ----------------- | ----------------------------------------------------------------------------------------------- |
410
- | `data/geekity.db` | The SQLite cache, with its `-wal` and `-shm`. `geekity rebuild` deletes and rebuilds it. |
411
- | `data/images/` | Variants derived from `content/uploads/`, with their `image.json` sidecars. `rm -r` it. |
412
- | `data/avatars/` | Remote avatars served from `/_geekity/avatars/` (TASK-134). `rm -r` it; they are fetched again. |
413
-
414
- The next boot builds the database out of the files with no manual step, and a
415
- request for a variant that is not there derives it and serves it.
416
-
417
- ### What is in the database, and what a rebuild costs
418
-
419
- No table holds anything that is not either read back from the files or
420
- something a site is told it may lose:
421
-
422
- | Table | Where it comes back from |
423
- | --------------------------------------------------- | ---------------------------------------------------------------------------- |
424
- | `documents`, `document_tags`, `document_categories` | The boot scan of `content/`. |
425
- | `followers`, `ap_inbox` | `content/_data/federation/`, emptied and read back on every boot. |
426
- | `comments` | `content/_data/comments/`, emptied and read back on every boot. |
427
- | `sessions` | Nothing. Everybody signed in is signed out. |
428
- | `ap_deliveries` | Nothing. The federation screen shows its posts with "Nothing recorded." |
429
- | `ap_relays` | The relay list in `site.json`: boot sends each of them a fresh `Follow`. |
430
- | `password_resets`, `spent_tokens` | Nothing. Every reset link and every one-click moderation link stops working. |
431
- | `cms_state` | Nothing. The scheduler's watermark and its first run. |
432
- | `migrations`, `admin_migrations` | The package. They record which schema versions have run. |
433
-
434
- So four things are actually lost:
435
-
436
- - **Logins.** Everybody signed in has to sign in again. The accounts themselves
437
- are in `data/users.json` and are untouched.
438
- - **Whether a one-click link had been used.** The links themselves are signed
439
- rather than stored, so they go on working; a rebuild only forgets which of
440
- them had already been spent. Every action they perform is idempotent, so the
441
- worst that costs is a moderation link that works a second time. A password
442
- reset link, which _is_ stored, stops working altogether.
443
- - **Relay handshakes.** A relay named in the settings that the database has
444
- never heard of is followed on boot, so a rebuild sends every listed relay a
445
- new `Follow` and the reason one gave for rejecting the last is gone.
446
- - **A scheduled post that came due while the site was down.** The scheduler
447
- treats an absent watermark as "start from here", so that a rebuilt database
448
- cannot re-announce the archive to every follower. Such a post is public on the
449
- next boot but no `Create` is delivered for it; resend it from
450
- `/admin/federation` if it should have gone out.
451
-
452
- The one thing the file-first design gives up is narrower than any of those: a
453
- post whose **file is gone entirely** can no longer be withdrawn from followers'
454
- timelines, because the permalink a `Delete` names was in the file. Trashing a
455
- post in the admin keeps the file under `_trash/`, so the ordinary way of
456
- unpublishing still sends the `Delete`.
457
-
458
- ### A database this version will not open
459
-
460
- Two cases refuse the boot rather than being cleaned up behind your back, and
461
- both name the file and say what to do about it:
462
-
463
- - **Written by a newer `@geekity/cms`**, meaning its migration ledger records a
464
- schema version this package does not ship. Upgrading the package back is
465
- usually what was meant; `geekity rebuild` throws it away if it was not.
466
- - **Damaged**, meaning SQLite will not open it. `geekity rebuild` is the fix.
467
-
468
- A database _older_ than the oldest migration the package still ships is the one
469
- case thrown away and rebuilt without asking: there is by definition no path
470
- forward from it, and nothing in it is anything but a reading of the files.
471
-
472
- ## Federation
473
-
474
- **Every user is an ActivityPub actor**, served by [Fedify] at their author URL:
475
- `{baseUrl}/author/{username}/` is the archive in a browser and the `Person` to
476
- a peer asking for `application/activity+json`, exactly as a post's permalink is
477
- its page and its object. The collections are that URL's children —
478
- `inbox/`, `outbox/`, `followers/` and `following/` — and the instance-wide
479
- shared inbox is `/inbox/`. `author` and `inbox` are reserved top-level paths
480
- for that reason: an actor id a settings field could move would be a different
481
- account to everybody holding it.
482
-
483
- There is no site actor. A post is announced by the actor of the user its
484
- `author` names, its `outbox` is that user's archive as activities, and its
485
- followers are that user's own.
486
-
487
- Fedify's stores have defaults that suit a single process, and `federation` is
488
- where a site that has outgrown them says so:
489
-
490
- | Field | Default | Meaning |
491
- | --------------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
492
- | `kv` | `MemoryKvStore` | Fedify's cache and idempotence store. Nothing that has to survive a restart lives in it. |
493
- | `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. |
494
- | `allowPrivateAddress` | `false` | Whether Fedify may fetch private and loopback addresses. Leave it off: turning it on removes an SSRF guard. It exists for tests. |
495
-
496
- Followers and the log of what the inbox was told are the CMS's own, and they
497
- are files the site publishes (decision-9):
498
- `content/_data/federation/{username}/followers.json` holds one object per
499
- follower of that user — actor id, inbox, shared inbox, handle, name, icon,
500
- profile URL and follow time, oldest follow first — and
501
- `content/_data/federation/inbox/{yyyy}-{mm}.jsonl` holds one compact JSON-LD
502
- activity per line, with the `receivedAt` the log stamped it with and the
503
- `recipient` it was addressed to in front of it. The log stays one chronological
504
- record because it is a record of what this server was told; each line says
505
- whose it was. Both are in git beside the posts, and an Eleventy build of the
506
- same content directory sees them as `federation.{username}.followers` and
507
- `federation.inbox`. A `Follow` appends to the first, an `Undo(Follow)` or an
508
- actor's own `Delete` takes the entry out, and a like, a boost or a reply
509
- appends a line to the second; each write updates the SQLite index in the same
510
- step, behind a lock on the file, so the two can never disagree. The `followers` and `ap_inbox` tables are that
511
- index and nothing more: every boot empties them and reads them back from the
512
- files, which is what makes editing `followers.json` by hand and restarting a
513
- supported thing to do, and what makes deleting the database cost a site
514
- nothing. `cms.admin.replaceFollowers` and `replaceInboxActivities` are what the
515
- rebuild calls; `rebuildFederationIndexes({ admin, contentDir })` is the rebuild
516
- itself. The delivery outcomes stay in SQLite as a cache of what happened; the
517
- activities themselves are not stored at all, because they are rebuilt from the
518
- posts' files whenever they are wanted again.
519
-
520
- Each user's key pairs are the CMS's own too, but they are the one
521
- thing a site can never regenerate, so they live in files: one JWK per algorithm
522
- per user under `dataDir/keys` (`ada.rsassa-pkcs1-v1_5.jwk` and
523
- `ada.ed25519.jwk`), written `0600` in a `0700` directory, each holding the
524
- private key alone because the public half is derived from it. They are
525
- generated on the first request that needs them and read back on every one after
526
- that. The actor publishes the RSA key as `publicKey` at `{actor}#main-key`,
527
- which is what a peer verifies an HTTP Signature with, and both as
528
- `assertionMethod` multikeys at `{actor}#multikey-0` and `#multikey-1`.
529
-
530
- A key file that is there and will not import stops the boot, with a message
531
- naming it. That is deliberate: replacing it would give the site a new identity
532
- and every follower's cached public key would stop verifying, and Fedify's own
533
- answer — an actor document with no `publicKey` at all — is worse still, because
534
- peers cache it. Restore the file from a backup, or delete it to ask for a new
535
- key on purpose.
536
-
537
- ### Delivery
538
-
539
- Publishing federates. The delivery service listens to the index, so a post
540
- edited on disk reaches the followers exactly as one saved through the editor
541
- does:
542
-
543
- | What happened to the post | What the followers get |
544
- | ------------------------------------------------------------------------- | ------------------------- |
545
- | became published — written live, or undrafted, or restored from the trash | `Create` of its object |
546
- | edited while published | `Update` of its object |
547
- | drafted, trashed, or its file deleted | `Delete` of a `Tombstone` |
548
-
549
- Nothing is delivered for a full scan, the boot scan included: a rebuilt index
550
- reports the whole archive as new, and announcing it again is not what deleting
551
- `data/geekity.db` should mean.
552
-
553
- A post's ActivityStreams object id is its permalink, absolute on the site's
554
- base URL. One URL answers both audiences — a browser gets the page, a peer
555
- asking for `application/activity+json` gets the post's object — so a shared
556
- link, a feed item, an object id and a reply's `inReplyTo` all name the same
557
- thing. Nothing else has to be minted or kept in step.
558
-
559
- That object is a `Note` or an `Article`, whichever the post's discovered type
560
- is: a post with no title, or whose text opens with its title, is a `Note`, and
561
- any other is an `Article`. Mastodon shows the two differently. An `Article`
562
- arrives as its title, its excerpt and a link, so it sends `name` and a
563
- `summary`. A `Note` arrives as its `content` and nothing else, and Mastodon
564
- would show a `summary` as a content warning, so a note sends neither and puts a
565
- title its text does not open with at the top of its content.
566
-
567
- Both carry their text twice: `content` as it always was, and `contentMap` keyed
568
- by the post's language, with `summaryMap` beside an `Article`'s `summary`. The
569
- language is the post's `lang` front matter when it names one, and the site's
570
- `language` otherwise, so a client can filter and translate by it. Mastodon
571
- reads a status's language from that key.
572
-
573
- An author can choose the type for one post in its front matter:
574
-
575
- ```yaml
576
- activitypub:
577
- type: Note
578
- ```
579
-
580
- `Note` and `Article` are the two values. Anything else is logged as a warning
581
- and the derived type is sent instead. The CMS never rewrites the key. Changing
582
- it on a post already announced sends an `Update`, but a remote server may keep
583
- showing the post the way it first arrived.
584
-
585
- The first activity about a post writes one key, `activitypub.published`, into
586
- its front matter: the record that the post has been announced and when, which
587
- is what decides `Create` against `Update` and what a resend reads. Restoring a
588
- trashed post reuses it too.
589
-
590
- A post whose file already names an `activitypub.id` keeps it as its object id
591
- for the life of the post — that is how a post migrated from WordPress keeps the
592
- `https://example.com/?p=813` its followers, its replies and its RSS subscribers
593
- already hold. The CMS serves the post's object at that URL on an ActivityStreams
594
- request, redirects a browser from it to the permalink, and names it in every
595
- `Update` and `Delete`.
596
-
597
- The CMS writes one itself in one case: when a published post's slug or
598
- permalink is changed in the editor. A fediverse server cannot rename an object
599
- it holds, so the URL the post is leaving becomes its stored `activitypub.id`.
600
- Followers get an `Update` of the object they already have, a peer at the old
601
- URL still gets the object, and a browser there is redirected to the new
602
- permalink. The object's `url` is the new permalink. See
603
- [Moved URLs](#moved-urls).
604
-
605
- One POST serves a whole instance — the shared inbox is preferred — but the
606
- outcome is recorded per recipient, so an admin can see which one did not get it
607
- and send the post again:
608
-
609
- ```ts
610
- const report = await cms.delivery.resend(slug);
611
- report?.deliveries; // one row per follower and relay: inbox, status, error, time
612
- ```
613
-
614
- `resend` is "send this post as it now reads", not "send that activity again":
615
- the activity is built from the file at the moment it is called, so a follower
616
- whose server was down ends up with the post as it stands. Which activity that
617
- is follows the same table as a save — a published post nobody has been told
618
- about is a `Create` and is stamped, a published post they hold is an `Update`
619
- under a fresh id, a draft or a trashed one is a `Delete` of a `Tombstone` — and
620
- `undefined` comes back when there is no post by that name and nothing to
621
- withdraw.
622
-
623
- Nothing else is stored. What SQLite keeps about anything the site has sent is
624
- one outcome row per recipient — the activity's id, type, object id and slug,
625
- the inbox used, how it went, why not, and when — and that is a cache which may
626
- be empty. `cms.admin.lastDeliveryToObject(objectId)` is the newest outcome
627
- about one post, and `listDeliveries(activityId)` and
628
- `countDeliveriesByStatus(activityId)` say how one activity landed with each
629
- recipient. A status is `sent` (the inbox took it), `queued` (handed to Fedify's
630
- queue, which retries out of band) or `failed`.
631
-
632
- The one capability given up is tombstoning a post whose file is gone entirely
633
- rather than in the trash: there is no file left to build the `Tombstone` from.
634
-
635
- ### Quote posts
636
-
637
- Mastodon 4.5 lets people quote a post only when the post says who may, and
638
- shows a quote only once the quoted author's server has approved it
639
- ([FEP-044f]). Every post this site federates is public, so every `Note` and
640
- `Article` carries `interactionPolicy.canQuote` with `automaticApproval` set to
641
- the public collection: anybody may quote it, and nobody has to approve it by
642
- hand. Pages, drafts, scheduled and trashed posts do not federate, so nobody
643
- can quote them.
644
-
645
- A quoting server sends a `QuoteRequest` to the post's author. The inbox, and
646
- any plugin inbox that hands its deliveries to the site's own handlers, answers
647
- it:
648
-
649
- - For a post the site federates, whose quote lives on the requester's own
650
- server, it stores an approval and replies `Accept` with a
651
- `QuoteAuthorization` as its `result`. The authorization names the quote
652
- (`interactingObject`), the post (`interactionTarget`) and the author
653
- (`attributedTo`).
654
- - For anything else it replies `Reject` and stores nothing.
655
-
656
- Approvals are files, like followers:
657
- `content/_data/federation/{username}/quotes.json` holds one object per quote of
658
- that user's posts. Each is served unsigned, as ActivityStreams JSON, at
659
- `{baseUrl}/author/{username}/quotes/{id}/`, for as long as the post is still
660
- published. An `Undo` of the `QuoteRequest`, or a `Delete` of the quoting post,
661
- removes the approval, and its URL answers 404 after that. A quote does not
662
- notify the author.
663
-
664
- Mastodon keeps the copy of a post it fetched first, so a post federated before
665
- the quote policy existed is not quotable until its followers receive an
666
- `Update`. Editing the post sends one. To update every announced post at once,
667
- run `geekity resend --all` in the site directory:
668
-
669
- ```sh
670
- $ geekity resend --all
671
- hello-world: Update to 3 of 3 inboxes
672
- another-post: Update to 3 of 3 inboxes
673
- ```
674
-
675
- `geekity resend <slug>...` does the same for named posts. Each is what the
676
- Resend button on the federation screen does: an `Update` for a post still
677
- published, a `Delete` for one that has been withdrawn, and a `Create` for a
678
- published post that was never announced. `--all` only reaches posts that were
679
- announced. The command delivers with no queue, so every activity has been
680
- posted before it exits, and it exits non-zero when a named slug has nothing to
681
- resend. It works while the server runs, because both read the same files and
682
- database.
683
-
684
- [FEP-044f]: https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md
685
-
686
- ### Replies to fediverse replies
687
-
688
- Somebody who answers a fediverse reply on one of the site's posts addresses
689
- the person they answer, not the site, so their reply never reaches the inbox.
690
- Most servers list a note's replies in its `replies` collection, and the site
691
- reads those collections to bring such replies in. The sweep starts when
692
- the site serves and runs every hour, within these limits:
693
-
694
- | Limit | Value | Meaning |
695
- | -------- | -------- | ----------------------------------------------------------------------------------------------------- |
696
- | Interval | 6 hours | How long a thread is left alone after it was read. |
697
- | Age | 30 days | How old a post gets before its thread is no longer read, by the post's date. |
698
- | Depth | 4 levels | How far below the post a fediverse reply may sit and still have its collection read. |
699
- | Pages | 3 | How many pages of one collection are read. Mastodon's first page holds the author's own replies only. |
700
-
701
- Every request is signed as the site's first account, as the actor profile
702
- fetches are. A reply is kept only when it answers the note whose collection
703
- listed it, is addressed to the public, and is written by an actor on the
704
- note's own server. A note on the site's own origin is never fetched, because
705
- the site already knows what it published.
706
-
707
- A reply found this way is logged as the `Create` nobody delivered: a line in
708
- `content/_data/federation/inbox/{yyyy}-{mm}.jsonl` with `"fetched": true` in
709
- front of it, an `actor` taken from the note's `attributedTo` and the id
710
- `{note id}#fetched`. From there it is a reply like any other. It is threaded
711
- under the reply it answers with its own URL, counted, given a `/replies/` feed
712
- and listed on the federation screen, and its author's `Delete` takes it back.
713
- A note held once is never logged twice. The sweep skips a note the inbox
714
- already holds, and a delivery of a note the sweep fetched first replaces the
715
- fetched line.
716
-
717
- The site removes a fetched reply, with the fetched replies under it, when its
718
- server answers 404 or 410 for it, or when a collection read to its end no
719
- longer lists it. A delivered reply is never removed this way. A note with no
720
- `replies` collection, a server that answers with any other error, and a
721
- collection cut off by the page limit change nothing. The thread keeps what it
722
- holds, and the sweep goes on to the next reply.
723
-
724
- The site reads `replies` and not the conversation a note names in `context`
725
- ([FEP-7888]), because Mastodon publishes `replies` and few servers publish a
726
- `context` collection yet.
727
-
728
- [FEP-7888]: https://codeberg.org/fediverse/fep/src/branch/main/fep/7888/fep-7888.md
729
-
730
- ### Relays
731
-
732
- A [Mastodon-style relay][fepae0c] boosts every public activity it is sent on to
733
- the instances subscribed to it, which is how a site nobody follows yet reaches
734
- people. `relays` is the setting: one relay inbox per line on
735
- `/admin/federation/settings`, kept in `site.json` like every other setting, and
736
- `https://tags.pub/user/_____relay_____/inbox` is one worth knowing about — it
737
- boosts any public post carrying a hashtag it tracks, which every `Article` this
738
- CMS builds already carries one of per tag and per category.
739
-
740
- Adding a line sends that inbox a `Follow` whose object is the ActivityStreams
741
- Public collection, signed by the site's first account — a relay subscription is
742
- an instance-wide agreement rather than one person's, and the first account is
743
- the one actor that can be chosen without asking — and carrying the Linked Data
744
- signature a Mastodon-style relay verifies. The relay answers `Accept` or
745
- `Reject` — possibly days later, because a subscription may need a human to
746
- approve it — and that answer arrives in the site's inbox and moves the
747
- subscription. Removing the line sends `Undo` of the same `Follow`.
748
-
749
- Only an accepted relay is delivered to. From there it is one more inbox in the
750
- fan-out: every `Create`, `Update` and `Delete` for a post, whoever wrote it,
751
- and the actor `Update` a profile change sends, goes to it as well as to the
752
- author's followers, its
753
- outcome recorded in the delivery log against its actor id and its inbox exactly
754
- as a follower's is — which is why Resend reaches relays too.
755
-
756
- ```ts
757
- cms.admin.listRelays(); // inbox, actor, state, reason, follow id, times
758
- cms.relays.sync(); // reconcile the records with the setting
759
- await cms.relays.retry(inboxId); // send the follow again
760
- ```
761
-
762
- The records live in `ap_relays`, keyed by inbox, and are operational state
763
- rather than a source: the setting is. A relay the settings name that has no
764
- record — a rebuilt database, a restored content directory — is followed again
765
- on the next boot.
766
-
767
- [fepae0c]: https://w3id.org/fep/ae0c
768
-
769
- ### A user's profile
770
-
771
- An actor's `name`, `summary`, `icon` and `attachment` are the display name, the
772
- bio, the avatar and the links on their user record, edited on
773
- `/admin/users/<id>` and stored in `data/users.json` — the same profile the
774
- author archive is headed with, so a page and an actor cannot say different
775
- things about somebody. Their `preferredUsername` is their login, and
776
- `alsoKnownAs` lists every URL they answer to: the actor id, the archive and
777
- `/@{username}`. The profile holds two more fields the actor does not carry — a
778
- job title and a location — which are there for a theme to print beside the
779
- name.
780
-
781
- The avatar is a path like `/uploads/2026/09/me.png`, stored with the site's
782
- other uploads. The actor carries it as an absolute URL resolved against the
783
- base URL in effect, because a peer has no site to resolve a path against; one
784
- that is already an absolute URL is left alone, which is how a profile picture
785
- goes on a CDN.
786
-
787
- Saving a profile delivers an `Update` of that user's actor to their followers,
788
- so the profile a peer cached is refreshed rather than left showing last year's
789
- picture. Nothing on the settings screen does: the site is not an actor.
790
- `cms.delivery.updateActor(user)` sends one from code. Its outcome is recorded
791
- like every other activity's, with the actor's id as its object and no slug, so
792
- it is not a row in the federation screen's per-post delivery table, which is
793
- built from the posts the content index holds.
794
-
795
- ### WebFinger
796
-
797
- `/.well-known/webfinger` is the CMS's own route rather than Fedify's, because
798
- Fedify computes its `self` link and its `aliases` from the dispatcher path and
799
- neither can be added to. It answers for every spelling of the same person — the
800
- `acct:{username}@{host}` handle, the bare `{username}@{host}`, the author URL,
801
- `/@{username}` and the stored actor id of somebody who has one — with the actor
802
- id as `self`, the archive as `profile-page`, and all of the person's URLs as
803
- `aliases`. A username nobody has is a 404. `/@{username}` itself is a 301 to
804
- the archive of the user of exactly that name, and a 404 for a handle nobody
805
- answers to: it is an alias of somebody, so a handle that is not anybody's is
806
- nowhere rather than a redirect to an archive that does not exist either.
807
-
808
- ### A user's stored actor id
809
-
810
- A user record in `data/users.json` may carry an `actorId`: the ActivityStreams
811
- id that person was published under somewhere else, such as the
812
- `https://example.com/?author=2` the WordPress ActivityPub plugin publishes.
813
- That is identity, not cache — a follower's server keys the account by the URL
814
- it first saw — so the CMS serves the person under it for the life of the
815
- account, the way a post keeps its `activitypub.id`:
816
-
817
- ```json
818
- { "id": 2, "username": "ada", "actorId": "https://example.com/?author=2" }
819
- ```
820
-
821
- The actor document's `id` is then that URL and its keys hang off it
822
- (`…?author=2#main-key`), every activity the user sends names it as `actor` and
823
- is signed with a key id under it, and the URL itself — query string and all —
824
- answers with the `Person` for a peer and a 301 to the author archive for a
825
- browser. WebFinger publishes it as `self` and resolves a lookup by it, and the
826
- actor lists it in `alsoKnownAs` beside the archive and `/@{username}`. Nothing
827
- else moves: `url` is still the archive and the collections are still the
828
- archive's children, because a peer refetches those.
829
-
830
- The CMS never mints one and no screen writes one. It arrives with a plugin's
831
- import, such as the WordPress plugin's, or is typed into the file by hand; `/admin/users/<id>` shows it
832
- read-only beside the account, and a value that is not an absolute URL is
833
- ignored.
834
-
835
- ### Moving a site from the WordPress ActivityPub plugin
836
-
837
- A site that moved here from the WordPress ActivityPub plugin keeps its people's
838
- actor ids and its posts' object ids, which are identity and are the CMS's own
839
- (above). What it needs besides is its content, the plugin's old paths, which
840
- followers' servers keep delivering to until they refetch the actor, and an
841
- import that brings each person's key pair and followers across. All three are the
842
- [`@geekity/plugin-wordpress`](../plugin-wordpress/README.md) package, which a
843
- site born on the CMS never installs. decision-14's switch is that plugin now:
844
- where the cutover used to say "turn the switch on", it says install and enable
845
- the WordPress plugin.
846
-
847
- #### The cutover, end to end
848
-
849
- 1. **Start the new site in dev mode.** Set `GEEKITY_DEV_MODE=true` before
850
- its first boot (see [Dev mode](#dev-mode)). Followers, relays, links and
851
- mail are all real from here on, and dev mode keeps every one of them from
852
- hearing about the import, the edits or a scheduled post until step 4.
853
- 2. **Export, while the WordPress site is still up.** Each person's key pair,
854
- and the followers if the old site is going away before the import runs. The
855
- plugin's README says how. Note the actor id and the numeric actor id off
856
- `https://example.com/?author=2`, take a WXR export under Tools > Export,
857
- and copy `wp-content/uploads`, which the export does not carry.
858
- 3. **Install the WordPress plugin and import.** Add `@geekity/plugin-wordpress`
859
- to the site's dependencies and to `plugins` in its config, then run
860
- `geekity import wordpress example.WordPress.xml --uploads wp-content/uploads`
861
- for the content and media, and
862
- `geekity import wordpress-actor ada --actor-id … --wordpress-id … --keypair ada.keypair.json`
863
- for each person. Both work before the plugin is enabled. Check the reports:
864
- every post should be written, every conflict or clash resolved, every
865
- follower added, and any follower that was skipped re-run once its server
866
- answers. Tools > Dev mode shows that the imports sent nothing. Both imports
867
- are safe to run again: run them over a fresh export just before step 4 to
868
- pick up what WordPress received in the meantime.
869
- 4. **Enable the WordPress plugin, move the DNS and go live.** Enable it under
870
- `/admin/plugins`, then move the DNS. Remove `GEEKITY_DEV_MODE` and run
871
- `geekity dev-mode off`: nothing held during the migration is sent.
872
- Followers' servers go on delivering to the plugin's old inbox paths until
873
- they next refetch the actor, and the plugin is what catches those
874
- deliveries. Once WordPress no longer answers, run both imports one last time
875
- over its final export: they merge what arrived between the export and the
876
- DNS move, and leave alone whatever this site wrote or edited since.
877
- 5. **Watch.** `/admin/federation` lists the users with their actor ids and
878
- followers; the plugin's own screen under Plugins lists each old path with
879
- the instant it was last asked for. Deliveries should thin out as each
880
- follower's server refetches the actor and learns the new endpoints.
881
- 6. **Disable it.** Once every path says _Never_ again for long enough — weeks
882
- rather than days, since a quiet instance refetches rarely — disable the
883
- plugin. Disabling it also ends the redirects from the old
884
- `/wp-content/uploads/` media URLs, so a site that still gets links to
885
- those keeps it enabled. The paths go away on the very next request, with nothing restarted,
886
- and if something was still using them you can enable it again just as
887
- quickly.
888
-
889
- Retiring the stored actor id itself is a separate, optional step by the Move
890
- protocol, and is not part of the cutover: not every follower's software honours
891
- a `Move`, and the ones that do not would simply stop following.
892
-
893
- ### The federation screen
894
-
895
- `/admin/federation` is all of that with a page in front of it. It shows one
896
- panel per user — their avatar, their `@username@host` handle, the summary their
897
- profile carries, their actor id, and the followers each of them has with
898
- avatars and follow dates — the recent likes, boosts and replies out of the
899
- inbox log, each linked to the remote object and to the post it was about, and
900
- one row per post that has been federated: who announced it, its last activity,
901
- when it went, and how many recipients it reached, is queued for, or failed
902
- for. Each of those rows has a
903
- Resend button, which is `cms.delivery.resend` behind a form: it builds the post
904
- again from its file, sends it to every follower and every accepted relay the
905
- site has now, and says what came of it.
906
-
907
- Which posts are listed is a question for the files — the posts carrying an
908
- `activitypub.published`, the trash included, which is the same thing as the
909
- posts some follower holds a copy of — and how each landed is a question for the outcome
910
- cache. So a site that has just deleted `data/geekity.db` sees every federated
911
- post listed with nothing recorded against it, and can press Resend on any of
912
- them.
913
-
914
- A Relays panel sits between the followers and the inbox log: one row per
915
- subscription with its inbox, whether it is waiting, accepted or rejected, when
916
- it last moved, why it was refused if it was, and the last activity delivered
917
- there with how that went. A subscription still waiting has a Retry, which sends
918
- the `Follow` again under a fresh id.
919
-
920
- ### Testing federation against a real Mastodon account
921
-
922
- Federation is the one part of the CMS that cannot be finished on localhost.
923
- Mastodon has to be able to reach the site to fetch the actor, verify a
924
- signature and deliver a `Follow`, and `http://localhost:3000` is not an
925
- address it can reach. [`fedify tunnel`] gives the local server a public HTTPS
926
- address for as long as the command runs, which is enough to do the whole round
927
- trip by hand:
928
-
929
- ```sh
930
- pnpm dlx @fedify/cli tunnel 3000
931
- # The ephemeral public address is up and running:
932
- # https://quiet-sun-42.serveo.net
933
- ```
934
-
935
- Leave that running and boot the site with `baseUrl` set to the address it
936
- printed, because `baseUrl` is what every ActivityStreams id is minted from — a
937
- site booted on `http://localhost:3000` serves actors whose ids are
938
- `http://localhost:3000/author/{username}/`, and no remote instance can
939
- dereference those:
940
-
941
- ```sh
942
- GEEKITY_BASE_URL=https://quiet-sun-42.serveo.net geekity serve
943
- ```
944
-
945
- Then, from a Mastodon account:
946
-
947
- 1. Search for `@ada@quiet-sun-42.serveo.net` — the handle is a username at the
948
- tunnel's host. The profile should come back with that user's display name,
949
- bio and avatar.
950
- 2. Follow it. The `Follow` lands on `/author/ada/inbox/` (or on `/inbox/`), the
951
- CMS answers `Accept`, and the account appears on `/admin/federation` within
952
- a second or two.
953
- 3. Publish a post, from the editor or by writing a file into `content/posts/`.
954
- A `Create` is delivered, and the post shows up in the follower's
955
- home timeline; `/admin/federation` records the outcome, with a Resend
956
- button if it did not land.
957
- 4. Edit the post, then set `draft: true` on it, to see the `Update` and the
958
- `Delete` arrive.
959
-
960
- Two things to know before relying on what a tunnel run shows you.
961
-
962
- The tunnel address changes every time the command starts, and the followers
963
- gathered under one are throwaway: their instances hold an actor id on a host
964
- that no longer exists, so they cannot be migrated to the real domain. The
965
- admin has no button for removing a follower yet, so run tunnel rehearsals
966
- against a scratch `dataDir` rather than the one the real site will keep.
967
-
968
- The key pairs live in `dataDir/keys`, not in the base URL, so they
969
- survive across tunnel sessions. That is usually what you want; it also means a
970
- `data` directory copied from one deployment to another brings the other's
971
- identity with it.
972
-
973
- `pnpm fed:smoke`, in this repository, is the automated half of the same idea:
974
- it boots the CMS on a free port, runs `fedify lookup` against a user's actor
975
- and a post object, reads WebFinger, and delivers a `Create(Article)` to two real inboxes over
976
- loopback. It needs no tunnel and no network, and it is a job in CI. See
977
- `packages/cms/scripts/fed-smoke.ts`, whose header explains why the `Follow`
978
- half is sent by a peer built in the script rather than by `fedify inbox`.
979
-
980
- [Fedify]: https://fedify.dev/
981
- [`fedify tunnel`]: https://fedify.dev/cli
982
-
983
- ## Dev mode
984
-
985
- Building or migrating a site means running it with production data: the real
986
- base URL, the real actor and keys, real followers, real links. Dev mode keeps
987
- that site quiet until you take it live (decision-35). While it is on, nothing
988
- the site does reaches the outside world:
989
-
990
- - No ActivityPub delivery to followers or relays, no relay `Follow` or `Undo`,
991
- and no `Accept` or `Reject` answering an inbox. This holds whatever caused
992
- the change: the watcher, the admin, the scheduler, `geekity resend` or a
993
- reply context arriving.
994
- - No webmention. Endpoint discovery is skipped too.
995
- - No rssCloud or WebSub ping.
996
- - No IndexNow submission.
997
- - No email.
998
-
999
- Reads still go out: reply contexts and cited pages, actor profiles, oEmbed,
1000
- avatars, the check of an incoming webmention, and a plugin's `host.fetch`. A
1001
- read tells nobody about the site, and without them it could not show its
1002
- replies or profiles. Inbound federation is accepted: follows, replies, likes
1003
- and webmentions are stored as on a live site, and the `Accept` a follow would
1004
- get is held. Plugins need no switch of their own. A plugin's federation hands
1005
- what it receives to the site's inbox, so its answers are held the same way.
1006
-
1007
- Each held side effect is a line in `data/dev-mode.jsonl`: when, what kind,
1008
- what would have been sent, and to whom. Tools > Dev mode in the admin lists
1009
- them, newest first. Every admin screen shows a banner while the mode is on,
1010
- and `geekity serve` says so when it boots.
1011
-
1012
- ```sh
1013
- $ geekity dev-mode on
1014
- Dev mode is on: nothing the site does is sent until `geekity dev-mode off`.
1015
- $ geekity dev-mode status
1016
- Dev mode is on.
1017
- $ geekity dev-mode off
1018
- Dev mode is off: the site is live. Nothing held while it was on will be sent.
1019
- ```
1020
-
1021
- `devMode: true` in the config, or `GEEKITY_DEV_MODE=true`, starts a site in
1022
- dev mode: boot writes `data/dev-mode.json`, and the file keeps the mode on
1023
- from then on. Losing the variable does not take the site live. Only
1024
- `geekity dev-mode off` does, and it refuses while the config or the
1025
- environment still asks for dev mode, because the next boot would turn it on
1026
- again. The running site checks the file before every send, so going live
1027
- needs no restart, unless the server was started with the variable: that
1028
- process stays in dev mode until it restarts without it. Going live is a line
1029
- in the record too.
1030
-
1031
- Going live replays nothing. A held side effect is dropped, not queued, and no
1032
- delivery or webmention is recorded as failed, so neither the Federation screen
1033
- nor `geekity resend` offers it again. A post announced while the mode was on
1034
- counts as announced, so its next edit goes out as an `Update`. A relay listed
1035
- while the mode was on stays pending: press Retry on the Federation screen once
1036
- the site is live.
1037
-
1038
- Password recovery mail is held too, so in dev mode a password is reset from
1039
- the shell. A peer that followed while the mode was on never got its `Accept`,
1040
- and has to follow again.
1041
-
1042
- ### Dev mode on Docker
1043
-
1044
- Set the variable in the service's environment before the first boot, so the
1045
- container never starts live:
1046
-
1047
- ```yaml
1048
- services:
1049
- geekity:
1050
- environment:
1051
- GEEKITY_DEV_MODE: 'true'
1052
- ```
1053
-
1054
- To go live, remove the variable, recreate the container (the file in the
1055
- `data` volume keeps the mode on through the restart), then run:
1056
-
1057
- ```sh
1058
- docker compose exec geekity geekity dev-mode off
1059
- ```
1060
-
1061
- `geekity dev-mode status` in the same container says which state the site is
1062
- in. The record is in the `data` volume at `data/dev-mode.jsonl`.
1063
-
1064
- ## Comments
1065
-
1066
- A reader can answer a post on the page, and what they leave joins the same
1067
- thread as the fediverse replies rather than sitting in a section of its own.
1068
-
1069
- A comment is a file. `content/_data/comments/{slug}.json` holds one post's
1070
- comments (a slug that is not plain ASCII is percent-encoded in the name, so
1071
- `i-♥-rss` is `i-%E2%99%A5-rss.json`) — id, source, kind, status, author, the Markdown and the HTML it
1072
- rendered to, when it was submitted, a salted hash of the address, and what it
1073
- answers — so they are in git beside the posts, an Eleventy build of the same
1074
- directory shows them, and the `comments` table is an index emptied and read
1075
- back on every boot. The commenter's email is not in that file: it is in
1076
- `data/comments/{slug}.json` (mode `0600`), keyed by comment id, because
1077
- everything under `content/` is published and goes into git. A site upgrading
1078
- from a version that kept the email in the comment file has it moved there on
1079
- the next boot, and so does an email somebody types into a comment file by
1080
- hand.
1081
-
1082
- Three settings and one front-matter key decide whether a post is still taking
1083
- them, and they are read in one place so the form and the endpoint can never
1084
- disagree:
1085
-
1086
- | Where | What it says |
1087
- | ------------------------------------------ | ---------------------------------------------------------------------------------- |
1088
- | `comments` in `site.json` | Whether the site takes comments at all. On by default; off is off everywhere. |
1089
- | `commentsCloseAfterDays` in `site.json` | Days after a post's `date` that it closes. `14` by default; `0` never closes. |
1090
- | `comments: true` / `false` in front matter | The post's own answer, which beats both, in both directions. The editor offers it. |
1091
- | The document's type | A page is closed unless it says otherwise. |
1092
-
1093
- None of it touches the fediverse: a reply, a like or a boost arrives because a
1094
- remote server sent it, and a closed post shows every one of them.
1095
-
1096
- A comment is held for a moderator unless the same **name and email** have had
1097
- one approved before — WordPress's rule. In front of that are a honeypot field,
1098
- a minimum time between the form being rendered and submitted, and a per-address
1099
- rate limit. Comments are rendered from Markdown with raw HTML off, images
1100
- unembedded and every link marked `rel="nofollow ugc"`.
1101
-
1102
- A site can hand the whole thing to a service:
1103
-
1104
- ```ts
1105
- export default defineConfig({
1106
- commentChecker: {
1107
- async check(submission) {
1108
- // submission carries the comment, the post's URL, and the commenter's
1109
- // address, user agent and referrer — everything Akismet asks for.
1110
- return 'spam' | 'discard' | 'ham' | 'unknown';
1111
- },
1112
- async reportSpam(report) {}, // a moderator filed something as spam
1113
- async reportHam(report) {}, // …or let something out of the spam list
1114
- },
1115
- });
1116
- ```
1117
-
1118
- A checker that throws is treated as having no opinion and logged, so a service
1119
- that is down never stops a site taking comments. `/admin/comments` is the
1120
- moderation queue and the dashboard carries the number waiting.
1121
-
1122
- ### A comment's own page
1123
-
1124
- A comment left through the form has a page of its own at `/comment/{id}/`,
1125
- the comment's id percent-encoded. That page is the comment's URL in the thread
1126
- and in both comments feeds, so a site that replies to the comment fetches the
1127
- comment rather than the whole post. The `#comment-{id}` anchor on the post
1128
- stays, so an old link still scrolls to it. A webmention or a fediverse reply
1129
- has no such page: its URL is where it came from.
38
+ Database migrations ship inside the package and run on boot. To upgrade, bump
39
+ the version. [Upgrading](https://github.com/geekitycom/cms/blob/main/docs/getting-started.md#upgrading)
40
+ lists what semver covers.
1130
41
 
1131
- The page holds the comment as its one `h-entry`, with `u-in-reply-to` the post
1132
- or the comment it answers. Above it is the thread from the post down, each step
1133
- a nested `u-in-reply-to h-cite`, and below it the replies to it at every depth.
1134
- An ancestor a reader may not see is a placeholder saying the comment is no
1135
- longer shown, and the thread on the post prints the same placeholder above an
1136
- approved reply to a pending, spam or deleted comment. The page answers only an approved comment, and only as the post
1137
- would: 410 once the post is deleted, 404 while the post is not public or not
1138
- showing its conversation. It is `noindex` and absent from the sitemap, the post
1139
- feeds and search. Backlog doc-6 has the rules.
42
+ ## Documentation
1140
43
 
1141
- ### Replying while signed in
44
+ The guides live in the repository's
45
+ [`docs/`](https://github.com/geekitycom/cms/blob/main/docs/README.md) folder:
1142
46
 
1143
- A comment is something somebody else says on the site. A post is something a
1144
- signed-in user says. So a reply written by somebody signed in to the site, in
1145
- the thread or in the Reply box on the moderation screen, is a reply post, not a
1146
- comment. It is one file under `posts/`, saved
1147
- the way the editor and Micropub save one, and the thread shows it inline under
1148
- what it answers. Its `in-reply-to` is what it answers:
47
+ - [Getting started](https://github.com/geekitycom/cms/blob/main/docs/getting-started.md)
48
+ - [The `geekity` command](https://github.com/geekitycom/cms/blob/main/docs/cli.md)
49
+ - [Configuration](https://github.com/geekitycom/cms/blob/main/docs/configuration.md)
50
+ - [Content](https://github.com/geekitycom/cms/blob/main/docs/content.md)
51
+ - [The admin](https://github.com/geekitycom/cms/blob/main/docs/admin.md)
52
+ - [The public site](https://github.com/geekitycom/cms/blob/main/docs/public-site.md)
53
+ - [Federation](https://github.com/geekitycom/cms/blob/main/docs/federation.md)
54
+ - [Feeds](https://github.com/geekitycom/cms/blob/main/docs/feeds.md)
55
+ - [Plugins and hooks](https://github.com/geekitycom/cms/blob/main/docs/plugins.md)
56
+ - [Themes](https://github.com/geekitycom/cms/blob/main/docs/themes.md)
57
+ - [Deploying](https://github.com/geekitycom/cms/blob/main/docs/deploying.md)
58
+ - [Building the same content with Eleventy](https://github.com/geekitycom/cms/blob/main/docs/eleventy.md)
1149
59
 
1150
- | Answering | `in-reply-to` | It also |
1151
- | ------------------ | --------------------------------- | -------------------------------------------------------- |
1152
- | The post | The post's permalink | |
1153
- | A native comment | The comment's `/comment/{id}/` | Emails the commenter once, if they asked about replies. |
1154
- | A webmention reply | The page the webmention came from | Sends that page a webmention. |
1155
- | A fediverse reply | The note's id | Federates in reply to the note, addressed to its author. |
1156
- | A reply post | The reply post's permalink | |
1157
-
1158
- The signed-in form and the moderation screen's Reply box have an "Include in
1159
- posts and feeds" checkbox, unchecked by default. Unchecked, the reply post is [unlisted](#unlisted-posts): its own page
1160
- and the thread, but no listing or post feed. Checked, it is public. It reaches
1161
- whoever it answers either way. A visitor's form has no such box.
1162
-
1163
- Somebody signed in gets a Reply link on every reply in a thread, webmentions
1164
- and fediverse replies included. A visitor gets one on native comments only. A
1165
- visitor's reply is always a native comment, so a visitor answering a fediverse
1166
- reply sends nothing over ActivityPub.
1167
-
1168
- A reply post written in the editor or over Micropub joins a thread the same way
1169
- when its `in-reply-to` names the post or anything in its thread: a comment's
1170
- page or `#comment-` anchor, a webmention's page, a note's id. Its reply context
1171
- quotes that comment from the site's own records instead of the page it is on,
1172
- and says "In reply to a comment by" its writer. A reply post answering one of
1173
- the site's own posts or pages takes that post's title, words and author from
1174
- the index too, so the site never fetches its own page. The commenter's email
1175
- goes once for a reply post from any of these doors, sent when the reply post is
1176
- first published and never again when it is edited, trashed and restored, or the
1177
- site restarts. In the thread, a reply post's author link carries no
1178
- `rel="nofollow ugc"`, which is kept for visitors, webmentions and the
1179
- fediverse. A
1180
- webmention or a fediverse reply to a reply post threads under it in the
1181
- original thread too. Comments written before this stay comments. Backlog doc-6
1182
- and decision-47 have the rules.
1183
-
1184
- ### Being told about one
1185
-
1186
- With mail configured, a comment or a webmention entering the queue emails every
1187
- user who has an address and has not switched the notice off on their
1188
- `/admin/users/<id>` screen. The message carries the comment, the post it is on,
1189
- and three links — approve, spam, delete — that work without a login. Nothing is
1190
- sent about a comment Akismet filed as spam or told the site to discard: only
1191
- what is actually waiting for a person.
1192
-
1193
- Each link lands on a small page with a button on it, and only the button acts.
1194
- That is not politeness. Mail readers, spam filters and corporate link scanners
1195
- fetch the URLs in a message as a matter of course, and a link that moderated on
1196
- being opened would mean a mail gateway silently deleting this site's comments.
1197
- A link is signed rather than stored — the secret is `data/notification-secret`,
1198
- so a link goes on working across a `geekity rebuild` — is bound to one action
1199
- on one comment, lasts a week, and is spent the first time it is used.
1200
-
1201
- Commenters get the other half. The form offers "email me when somebody replies
1202
- to this", but only on a site that can actually send mail; ticking it stores
1203
- `notify: true` beside the commenter's address in `data/comments/{slug}.json`, and
1204
- nothing about either is ever rendered — not on the page, not in the JSON or
1205
- Markdown representations, and not in the comments feeds. When a reply to that
1206
- comment is **approved**, one message goes out with the reply in it and an
1207
- unsubscribe link at the bottom. Unsubscribing is site-wide by address: the
1208
- address goes into `data/comment-optouts.json` and this site writes to it about
1209
- replies again. The comments themselves are untouched.
1210
-
1211
- ### One message an hour instead of one a comment
1212
-
1213
- Beside each switch on a user's `/admin/users/<id>` screen is how often that
1214
- notice should arrive: as they arrive, an hourly digest, or a daily one. It is
1215
- per user, and it starts as "as they arrive", so nothing changes for anybody who
1216
- does not touch it.
1217
-
1218
- On a digest, a comment entering the queue sends nothing at all. Instead, once a
1219
- window has passed, one message goes out listing everything **still waiting**,
1220
- each item with its own approve, spam and delete links. A digest is built from
1221
- the queue as it stands rather than from a list of things that happened, so an
1222
- item somebody moderated in the meantime is simply not in it, and there is no
1223
- queue to keep true across a restart. A user with nothing waiting is not written
1224
- to at all, and their window does not start until something is: the promise a
1225
- mode makes is at most one message per window, and a quiet site sends none.
1226
-
1227
- A very long queue — a spam wave Akismet let through — is listed up to a hundred
1228
- items, with the rest counted in a line pointing at `/admin/comments`.
1229
-
1230
- The only thing written down is when each user last had one, in
1231
- `data/notification-digests.json`. It is a file rather than a row because
1232
- decision-9 lets a site delete the database whenever it is stopped, and a
1233
- forgotten timestamp would mean either a window silently skipped or a digest
1234
- sent twice. It is its own file rather than a field in `data/users.json` because
1235
- that file answers "who may sign in", and a timestamp rewritten every hour is
1236
- bookkeeping rather than anything about the person.
1237
-
1238
- Reply notices to commenters are not affected: those are one message about one
1239
- reply, and they go when the reply is approved.
1240
-
1241
- With no mail configured, none of this happens and nothing breaks: the box is
1242
- not offered, no notice is sent, no digest runs, and the moderation screen is
1243
- the notification it was before.
1244
-
1245
- Notices are per user and keyed by event name, so a later one — a new follower,
1246
- a digest of failed deliveries — is one entry in the registry in
1247
- `src/notifications/preferences.ts` and a checkbox that appears by itself; an
1248
- entry that says `batched: true` gets the how-often select beside it too. An
1249
- event a user has said nothing about is at its default, which is why turning one
1250
- on for a site does not need anybody to visit the users screen.
1251
-
1252
- ### Akismet
1253
-
1254
- The one checker the package ships. It is off until a site has a key, and the
1255
- key is a credential rather than a setting: it lives in `data/akismet.json` at
1256
- mode `0600` beside the password hashes and the users' private keys, never in
1257
- `content/_data/site.json`, which is public, in git and published with the site.
1258
-
1259
- Paste it into **Spam checking** on `/admin/settings/discussion`. It is checked with
1260
- Akismet's `verify-key` before it is stored, and the panel then reads Connected,
1261
- "Akismet does not recognise this key", "Akismet could not be reached", or Not
1262
- connected. The key is never printed back — the last four characters are, so it
1263
- is possible to tell which key is in there. A **Remove key** button turns Akismet
1264
- off again.
1265
-
1266
- The file is read on every call, so a key saved on that screen filters the next
1267
- comment and a removed one stops filtering at once. Neither needs a restart.
1268
-
1269
- A `commentChecker` in the config wins outright: the Akismet checker is built
1270
- only when the site named none.
1271
-
1272
- Every comment and every incoming webmention that gets past the honeypot, the
1273
- form's age and the rate limit goes to `comment-check` with `blog`, `user_ip`,
1274
- `user_agent`, `referrer`, `permalink`, `comment_type` (`comment` or
1275
- `webmention`), `comment_author`, `comment_author_email`, `comment_author_url`,
1276
- `comment_content`, `comment_date_gmt`, `blog_lang`, `blog_charset` and, for a
1277
- form submission, `honeypot_field_name`. Fediverse replies are never sent: they
1278
- do not reach the checker seam at all.
1279
-
1280
- | Akismet says | What happens |
1281
- | ----------------------------------------- | ---------------------------------------------------------------------------------------------- |
1282
- | `true` | Filed as spam, where a moderator can still see it and change its mind. |
1283
- | `true` with `X-akismet-pro-tip: discard` | Dropped without a queue entry. |
1284
- | `false` | The site's own rules decide: approved for a name and email approved before, pending otherwise. |
1285
- | `invalid`, a non-200, a timeout, no route | The same as `false`, and the failure is logged with whatever Akismet said was wrong. |
1286
-
1287
- `false` is deliberately not a positive opinion. Nothing Akismet does can lose a
1288
- comment: every failure is no opinion, no opinion is the queue, and a call is
1289
- abandoned after ten seconds.
1290
-
1291
- Marking something spam or not spam on `/admin/comments` posts `submit-spam` or
1292
- `submit-ham` with the same fields, minus `user_ip`, `user_agent` and `referrer`
1293
- — a stored comment keeps a salted hash of the address and nothing else.
1294
-
1295
- ## Webmentions
1296
-
1297
- The open web's version of what ActivityPub does: one page telling another that
1298
- it linked to it. Both directions are on by default and each has a switch on the
1299
- Discussion settings page.
1300
-
1301
- | Setting in `site.json` | What it does |
1302
- | ---------------------- | -------------------------------------------------------------------------- |
1303
- | `webmentionsSend` | Tell the external pages a post links to, when it is published or edited. |
1304
- | `webmentionsReceive` | Advertise the endpoints and accept webmentions and pingbacks sent to them. |
1305
-
1306
- **Sending** runs off the same index changes federation does, so a post saved in
1307
- the editor and one edited on disk are one thing, and a full scan sends nothing.
1308
- Every external link in the rendered body is asked whether it advertises a
1309
- Webmention endpoint — the `Link` header first, then the first `<link>` or `<a>`
1310
- with `rel="webmention"` — and told if it does. Both versions of an edited post
1311
- are read, so a page that has just been _unlinked_ is told too and can drop what
1312
- it was showing. One outcome is recorded per link (`sent`, `none` for a page that
1313
- takes none, or `failed`), shown per post on `/admin/federation`, and the Resend
1314
- button there sends them again from the file as it now reads.
1315
-
1316
- **Links to the site's own pages** send no webmention. A post that cites another
1317
- post on the same site does not go through moderation, does not add to a comment
1318
- count and does not make an HTTP request to the site itself. The index records
1319
- the link instead. The linked post or page then lists the linking one among its
1320
- backlinks, which the default theme prints as "Linked from" under it, apart from
1321
- the comments. A link counts for the document the site serves at its URL: a link
1322
- to `/` counts for the page the Reading setting makes the homepage, and a link
1323
- to a URL the site redirects, such as an imported `/?p=123`, counts for the
1324
- document the redirect leads to. Redirects are followed when the backlinks are
1325
- read, so editing a redirect file moves a backlink without touching the post
1326
- that holds the link. A draft, future-dated, unlisted, private or trashed post is never
1327
- a backlink. See `themes/default/README.md` for the `backlinks` context.
1328
-
1329
- **Receiving** advertises `/_geekity/webmention` two ways — a `Link` header on
1330
- every representation of a document, and a `<link rel="webmention">` in the head
1331
- — and takes a form `POST` of `source` and `target`. It answers `202` for
1332
- anything worth checking and `400` for anything that is not, and does the looking
1333
- afterwards: it fetches the source, checks it really links to the target, reads
1334
- its microformats (`h-entry`, `h-card`, and whether it is a reply, a like, a
1335
- repost or a plain mention), and files the result as a **pending comment** with
1336
- `source: "webmention"` in the same file, the same thread and the same moderation
1337
- queue as everything else — through the same `commentChecker` seam, so a checker
1338
- can tell one from a form submission by its `source`. Akismet is told it is a
1339
- `webmention` rather than a `comment`.
1340
-
1341
- A reply whose `u-in-reply-to` names a reply already on the post (a comment by
1342
- its own page or its `#comment-{id}` anchor, an earlier webmention by the page it
1343
- was sent from, a fediverse reply by its url or id) threads under that reply, and
1344
- moves with it when the source is edited and sent again. A webmention sent to a
1345
- comment's page, `/comment/{id}/`, lands on that comment's post.
60
+ ## License
1346
61
 
1347
- The source URL is its identity. A page that sends its webmention again updates
1348
- what it left rather than adding a second, and one whose link has gone — or which
1349
- answers 404 or 410 — takes it away. Closing rules do not apply: a post that
1350
- stopped taking comments still hears about a page that links to it, exactly as it
1351
- still hears a fediverse reply.
1352
-
1353
- **Pingbacks** are how most WordPress sites say they linked here. The same
1354
- switch takes them at `/_geekity/pingback`, advertised as an absolute URL in an
1355
- `X-Pingback` header and a `<link rel="pingback">` on every post and on every
1356
- page that takes comments. A `pingback.ping(source, target)` XML-RPC call goes
1357
- through the same check, verification, spam checker and moderation queue as a
1358
- webmention and is filed as the same comment, so a link pinged and a link
1359
- mentioned are one entry. Because XML-RPC wants an answer, the source is read
1360
- before the endpoint replies, and a failure is one of the spec's fault codes:
1361
- `0x0010` for a source that does not exist, `0x0011` for one that does not link
1362
- here, `0x0020` for a target that does not exist, `0x0021` for a target that
1363
- takes no pingbacks, and `0x0030` for a ping sent again, which still rewrites
1364
- the entry from the source as it now reads. A body over 16 KB, XML that is not
1365
- well formed, and any document type declaration are refused with `-32700`
1366
- before anything is resolved. The CMS does not send pingbacks.
1367
-
1368
- ### Syndication targets
1369
-
1370
- Some services copy a post when it links to them and sends them a webmention,
1371
- and answer with the address of the copy. IndieNews lists posts about the
1372
- IndieWeb; Bridgy Publish posts to Mastodon, Bluesky, GitHub and Flickr. The
1373
- CMS knows none of them by name. A site lists the ones it uses on the admin's
1374
- **Posts > Syndication** screen (`/admin/syndication`), which reads
1375
- and writes `content/_data/syndicationTargets.json`. The file stays the source of
1376
- truth, so it can still be edited by hand, and it looks like this:
1377
-
1378
- ```json
1379
- [
1380
- {
1381
- "id": "indienews",
1382
- "name": "IndieNews",
1383
- "url": "https://news.indieweb.org/en",
1384
- "tag": "indienews"
1385
- },
1386
- {
1387
- "id": "mastodon",
1388
- "name": "Mastodon",
1389
- "url": "https://brid.gy/publish/mastodon"
1390
- }
1391
- ]
1392
- ```
1393
-
1394
- | Key | What it is |
1395
- | ----------- | --------------------------------------------------------------------------------------------- |
1396
- | `id` | What a post's `syndicate-to` lists. Letters, digits, `.`, `-` and `_`. |
1397
- | `name` | What the editor's checkbox and the post's link say. |
1398
- | `url` | The page the post links to and sends its webmention to. May hold `{lang}`. |
1399
- | `tag` | Optional. A post carrying this tag is sent to the target without listing it. |
1400
- | `languages` | Optional. The language tags the target takes posts in. A post in any other language skips it. |
1401
-
1402
- An entry missing any of the first three, repeating an id, or with a `languages`
1403
- value that is not a list of language tags, is ignored. It is reported in the log
1404
- when the site starts, and the Syndication screen shows it with its problem so it
1405
- can be fixed or removed there. No file means no targets.
1406
-
1407
- The screen has a panel for each entry, in file order, and an **Add a target**
1408
- form. A save is checked by the same rules the file is read by: a field that
1409
- breaks one is marked on the form and the file is left as it was. A save that
1410
- passes is written atomically, and the editor's **Syndicate to** checkboxes and
1411
- Micropub's `q=syndicate-to` offer the change on the next request. A save or
1412
- remove is refused when the entry changed in the file since the page was drawn.
1413
- A file that is not a JSON list is shown with its problem and never saved over.
1414
- To keep the default theme's old IndieNews link, add a target with the id
1415
- `indienews`, the url `https://news.indieweb.org/{lang}` and the tag `indienews`.
1416
-
1417
- Some targets have a page per language. IndieNews has `news.indieweb.org/en`,
1418
- `/de`, `/fr` and more. Declare one target and put `{lang}` where the language
1419
- goes:
1420
-
1421
- ```json
1422
- {
1423
- "id": "indienews",
1424
- "name": "IndieNews",
1425
- "url": "https://news.indieweb.org/{lang}",
1426
- "tag": "indienews",
1427
- "languages": ["en", "de", "fr"]
1428
- }
1429
- ```
1430
-
1431
- The CMS fills `{lang}` with the post's language: its `lang` front matter, or the
1432
- site's language when the post names none. With `languages`, a post in a
1433
- language the list does not name neither links to the target nor notifies it. A
1434
- regional tag counts as its language, so a `de-AT` post goes to
1435
- `news.indieweb.org/de`, and `{lang}` takes the tag from the list. Without
1436
- `languages`, `{lang}` takes the post's own tag as written, region and all.
1437
-
1438
- A post selects targets in one of two ways:
1439
-
1440
- - **By listing them.** The post editor has a **Syndicate to** checkbox for each
1441
- target, and saving writes the ticked ids into the front matter as
1442
- `syndicate-to: [indienews, mastodon]`. An id no target declares is kept as
1443
- written.
1444
- - **By its tags.** A post tagged `indienews` is sent to the target whose `tag`
1445
- is `indienews`, as the WordPress IndieNews plugin did.
1446
-
1447
- The default theme links to each selected target at the start of the Published
1448
- line, inside the post's `h-entry`, as `<a class="u-syndication"
1449
- href="https://news.indieweb.org/en">IndieNews</a>`. That link is what IndieNews
1450
- and Bridgy Publish check for when they fetch the post. When the post is
1451
- published or updated, the sender adds each selected target to the pages it
1452
- notifies, the same way it adds the post a reply answers. Nothing is sent while a
1453
- page is served.
1454
-
1455
- A target that answers `201` or `202` with a `Location` header has made a copy.
1456
- The address is kept in `content/_data/syndication.json`, keyed by the post's
1457
- permalink (decision-26), and the theme prints it after the dates as **Also on**
1458
- with a `u-syndication` link. A copy made by hand goes in the post's own front
1459
- matter, and is printed the same way:
1460
-
1461
- ```yaml
1462
- syndication:
1463
- - https://mastodon.social/@me/113000000000000000
1464
- ```
1465
-
1466
- Removing a target from `syndicate-to`, or removing its tag, removes the link.
1467
- The sender then tells the target, as it tells any page a post stops linking to,
1468
- and the copy is dropped from `syndication.json`. IndieNews takes the post down
1469
- when it finds the link gone. Drafting or trashing the post does the same.
1470
-
1471
- The posts table on `/admin/federation` lists each selected target with how its
1472
- last webmention went and a link to its copy, and **Resend** sends to the targets
1473
- again.
1474
-
1475
- ## Email
1476
-
1477
- The CMS sends its mail through one service and one seam. Everything that will
1478
- ever email — the Send test email button, and the password resets, moderation
1479
- notices and contact messages built on it — calls `cms.mail.send()`, and what
1480
- carries the message is decided in one place.
1481
-
1482
- **A site with no mail configuration still works.** `send` writes a line in the
1483
- log saying the message was not sent and resolves successfully, so nothing that
1484
- emails is a feature that breaks without a mail account.
1485
-
1486
- ### Configuring it
1487
-
1488
- Two halves, kept apart for the reason the Akismet key is kept out of
1489
- `site.json`:
1490
-
1491
- | Where | What |
1492
- | ------------------------- | --------------------------------------------------------------------------------- |
1493
- | `content/_data/site.json` | `mailProvider`, `mailFromName`, `mailFromAddress`, `mailReplyTo`, `contactEmail`. |
1494
- | `data/mail.json` | The Brevo API key, and the SMTP host, port, TLS flag, user and password. |
1495
-
1496
- `site.json` is public, in git and published with the site; `data/mail.json` is
1497
- private, mode `0600`, and sits beside the password hashes and the users'
1498
- private keys. No key or password is ever written to `site.json` and none is
1499
- ever printed back into the settings screen — the panel shows the last four
1500
- characters of an API key and the host and user of an SMTP connection, which is
1501
- enough to tell what is stored and not enough to use it. Leaving a secret field
1502
- blank keeps the one already stored, so the SMTP port can be corrected without
1503
- retyping the password. **Remove credentials** forgets both.
1504
-
1505
- The panel draws the boxes of the provider that is saved and no others: an API
1506
- key on a Brevo site, a host, port, TLS flag, user and password on an SMTP one,
1507
- and nothing at all on a site sending through neither. It follows what is in
1508
- `site.json` rather than what the Provider select above it is showing, so
1509
- choosing a different provider there takes a **Save settings** before its boxes
1510
- appear — the panel says as much. A credential stored for the provider that is
1511
- not the chosen one still gets a line of its own, because it is still on disk
1512
- and **Remove credentials** is still what forgets it.
1513
-
1514
- Both files are read on every send, so a credential pasted into the settings
1515
- screen sends the next message and one removed stops the message after it.
1516
- Neither needs a restart.
1517
-
1518
- `mailFromAddress` should be an address the provider has verified; empty falls
1519
- back to `no-reply@` at the site's host, which most providers will refuse.
1520
-
1521
- ### The two providers
1522
-
1523
- | `mailProvider` | What it is |
1524
- | -------------- | ------------------------------------------------------------------------------------- |
1525
- | `none` | Nothing is sent. The default. |
1526
- | `brevo` | `POST https://api.brevo.com/v3/smtp/email` with an `api-key` header. No open sockets. |
1527
- | `smtp` | Any SMTP server, through nodemailer — including Brevo's own relay. |
1528
-
1529
- ### Send test email
1530
-
1531
- The Email settings page has an address field and a button that sends the `test`
1532
- message through the whole chain — the template, the From line, the provider and
1533
- the retry — and reports what came back, the provider's own words and its
1534
- message id included. It is the one thing that proves mail works before somebody
1535
- needs it to.
1536
-
1537
- It is on the page only where the site can actually send — a provider chosen and
1538
- its credential stored. Without one the only thing the button could report is
1539
- that it did nothing.
1540
-
1541
- ### Queue, retries and the log
1542
-
1543
- Messages go on one queue and are sent one at a time, so a provider that hangs
1544
- delays the next message and nothing else. A refused message is tried three
1545
- times, waiting two seconds, then eight; every attempt is logged with its
1546
- outcome and, when it went, the provider's message id, so a message somebody
1547
- never received can be traced to the attempt that carried it. `send` never
1548
- throws — a failure is a result with `ok: false` and the provider's own words in
1549
- `error`.
1550
-
1551
- ### Messages
1552
-
1553
- Messages are Nunjucks templates under `mail/` in the theme, resolved the way
1554
- every other template is: the theme the site has chosen first, the packaged theme
1555
- second, file by file. Each is up to three files — `mail/<name>.subject.njk`,
1556
- `mail/<name>.txt.njk` (required) and `mail/<name>.html.njk` — so a site can
1557
- replace the text of a message and keep the subject the package ships, or add a
1558
- message the package never had. The full table is in
1559
- [`themes/default/README.md`](./themes/default/README.md).
1560
-
1561
- ### From an entry file, and from a test
1562
-
1563
- ```ts
1564
- const result = await cms.mail.send({
1565
- to: 'ada@example.com',
1566
- template: 'test',
1567
- data: { name: 'Ada' },
1568
- });
1569
- result.ok; // true, whether it went or the site sends no mail
1570
- result.skipped; // true when the site sends no mail
1571
- ```
1572
-
1573
- `sendRaw({ to, subject, text, html })` is the same thing for a message the
1574
- caller built itself. Both resolve when the message has been sent, given up on
1575
- or skipped; a caller that does not care drops the promise with `void`.
1576
-
1577
- A test names a provider in the config and reads back what would have gone out.
1578
- It wins outright over the settings and `data/mail.json`, the way a
1579
- `commentChecker` wins over the Akismet key:
1580
-
1581
- ```ts
1582
- import { createCms, createMemoryMailProvider } from '@geekity/cms';
1583
-
1584
- const provider = createMemoryMailProvider();
1585
- const cms = createCms({
1586
- mail: { provider, backoffMs: () => 0 },
1587
- });
1588
-
1589
- // …
1590
- provider.sent[0].subject;
1591
- provider.failNext(2); // watch the retry without waiting for it
1592
- ```
1593
-
1594
- `mail` also takes `attempts`, `backoffMs` and `logger`, which is how a test
1595
- proves the retry without spending ten seconds on it.
1596
-
1597
- ## The archive page
1598
-
1599
- A page carrying `archive: true` in its front matter lists every published post
1600
- under its own words, grouped by the month it was published in, newest month
1601
- first and newest post first inside a month. Nothing paginates it: an archive
1602
- page is a way of finding one piece of writing rather than a listing to read
1603
- through. Drafts and posts whose date has not arrived are absent, as they are
1604
- everywhere else.
1605
-
1606
- The default theme draws it from `partials/archive.njk`, and the CMS hands the
1607
- layout `archiveMonths` — `{ month, posts }` per month, the month already read
1608
- through the site's `timezone` so the heading and the dates agree. Eleventy
1609
- ignores the key, and a static build of the same directory publishes the page as
1610
- an ordinary page.
1611
-
1612
- ## The contact form
1613
-
1614
- A page carrying `contact: true` in its front matter renders a contact form
1615
- under its content — name, email, subject, message. The editor offers it as a
1616
- **Contact form** checkbox on the pages editor, and the key is honoured wherever
1617
- it is written, so a theme that includes `partials/contact-form.njk` in its post
1618
- layout gets one there too.
1619
-
1620
- It works with JavaScript switched off, and there is no script on it. The form
1621
- posts to `/_geekity/contact`, and a message that is stored redirects back to
1622
- the page with `?contact=sent`, which draws a thank-you where the form was — so
1623
- a refresh sends nothing twice.
1624
-
1625
- **Where the message goes never appears in the HTML.** The contact address is
1626
- read when a submission arrives; it is not on any render context, so no theme
1627
- can print it and no form carries it.
1628
-
1629
- ### Where a message goes
1630
-
1631
- | Where | What |
1632
- | ----------------------------------- | --------------------------------------------------------------------------- |
1633
- | `data/contact/<id>.json`, mode 0600 | Every message, written **before** anything is emailed. |
1634
- | The `contactEmail` setting | Where the `contact-message` email is sent, reply-to the sender. |
1635
- | `/admin/messages` | The inbox: read, mark read, delete. The dashboard carries the unread count. |
1636
-
1637
- `contactEmail` is on the Settings screen under Email. Empty falls back to the
1638
- first admin with an email address, by username, so a fresh site with a mail
1639
- credential takes messages without anybody visiting the field.
1640
-
1641
- The file is written first and the mail goes out behind the redirect, which is
1642
- the whole point of the design: a provider that is down, a key that has expired
1643
- or an address that bounces costs a notification rather than the message. **With
1644
- no mail configured at all, submissions are still stored and still shown on the
1645
- Messages screen** — it is the notification, exactly as `/admin/comments` was
1646
- before there was any email.
1647
-
1648
- Messages live under `data/` rather than `content/` because they carry the
1649
- sender's address and were never meant to be published, and there is no SQLite
1650
- index over them: the screen reads the directory to sort it anyway, and a second
1651
- copy of the truth would only be a second thing to keep true. One JSON file per
1652
- message, named by an id that begins with the instant it arrived, so `ls` is the
1653
- inbox in order. Deleting a message on the screen deletes its file.
1654
-
1655
- ### What is in a message file
1656
-
1657
- ```json
1658
- {
1659
- "id": "20260920T120000000Z-1a2b3c4d",
1660
- "received": "2026-09-20T12:00:00.000Z",
1661
- "status": "received",
1662
- "read": false,
1663
- "page": { "slug": "contact", "permalink": "/contact/", "title": "Say hello" },
1664
- "from": { "name": "Ada Lovelace", "email": "ada@example.com" },
1665
- "subject": "About the analytical engine",
1666
- "message": "It is a lovely machine. Please write back.",
1667
- "addressHash": "9f2c…"
1668
- }
1669
- ```
1670
-
1671
- `addressHash` is the same salted hash a comment keeps — the salt is
1672
- `data/comment-salt` — so a run of submissions from one machine is visible
1673
- across both without a reader's address being written down.
1674
-
1675
- ### The defences in front of it
1676
-
1677
- The same four a comment goes through, out of the same modules, so the two
1678
- public forms cannot drift into different rules:
1679
-
1680
- | Defence | What it refuses |
1681
- | ------------------- | ----------------------------------------------------------------------------------------------------------------- |
1682
- | The honeypot | A hidden field a person never fills in. A submission that filled it gets the thank-you and is dropped in silence. |
1683
- | The form's age | Submitted under three seconds after the page loaded, or from a form rendered more than a day ago. |
1684
- | The rate limit | Five messages per address per ten minutes, on the same limiter the login form uses. The sixth is a 429. |
1685
- | The comment checker | The `commentChecker` seam, told `comment_type: contact-form`. With an Akismet key stored, that is Akismet. |
1686
-
1687
- A checker's `discard` stores nothing at all. Its `spam` **stores the message**,
1688
- on the Spam list of the Messages screen, and does not email it: a false
1689
- positive on a contact form is somebody's message vanishing, which is a worse
1690
- failure than a spam list to glance at. A checker that is down or throwing is no
1691
- opinion, and the message is stored as it would have been before anybody had
1692
- one.
1693
-
1694
- ## Personal data
1695
-
1696
- This is every piece of personal data the CMS stores, where it is, and how long
1697
- it stays. "Kept" means until somebody deletes it, unless a retention period
1698
- says otherwise.
1699
-
1700
- | What | Where | How long |
1701
- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
1702
- | A commenter's name, website and words | `content/_data/comments/{slug}.json`, public and in git | Kept. Erasing on request replaces the name and drops the website. |
1703
- | A commenter's email, and whether they asked to be told about replies | `data/comments/{slug}.json`, mode `0600`, keyed by comment id | `commentEmailRetentionDays`: forever unless set, 180 on a new site. |
1704
- | A salted hash of the address a comment, webmention or contact message came from (the address itself is never stored) | The comment file, or the contact message file | `addressHashRetentionDays`: forever unless set, 30 on a new site. |
1705
- | A webmention's author name, website, avatar URL and the source page's words | `content/_data/comments/{slug}.json` | Kept, as a copy of a page that is public already. |
1706
- | A contact message: the sender's name, email, subject and message | `data/contact/{id}.json`, mode `0600` | `contactMessageRetentionDays`: forever unless set, 365 on a new site. |
1707
- | The addresses that unsubscribed from reply notices | `data/comment-optouts.json`, mode `0600` | Kept, so the site goes on not writing to them. Erasing on request removes one. |
1708
- | Followers: actor id, handle, display name, avatar URL, profile URL | `content/_data/federation/{username}/followers.json`, in git | Until they unfollow. |
1709
- | Inbound likes, boosts, replies and quotes, with the actor who sent them | `content/_data/federation/inbox/{yyyy}-{mm}.jsonl`, in git | Kept. |
1710
- | What a reply shows of the post it answers: its title, words and author | `content/_data/replyContexts.json`, in git | Kept. |
1711
- | Other actors who liked, boosted, answered or quoted: handle, display name, avatar URL, profile URL | `data/geekity.db`, fetched from their actor document | A cache. Refetched weekly while the inbox log names them. |
1712
- | Remote avatars, shrunk | `data/avatars/` | Deleted by the avatar sweep once nothing shown names them. |
1713
- | Users: username, email, argon2id password hash, profile | `data/users.json`, mode `0600` | Until the user is deleted. |
1714
- | The apps a user signed in to with IndieAuth: app, scopes, hashes of its tokens, when they expire | `data/indieauth-tokens.json`, mode `0600` | Until the user is deleted, or 60 days after the app last refreshed. |
1715
- | Recent IndieAuth and Micropub requests: app, username, what it sent (no secrets, values cut short), the answer | `data/indieauth-activity.json`, mode `0600` | The last 100 requests, none older than 14 days. |
1716
- | Plugins added, updated and removed on Admin > Plugins: the username, when, the package and its versions | `data/plugin-changes.json`, mode `0600` | The last 200 changes. |
1717
- | An index of all of the above, and sessions, reset tokens and spent link tokens | `data/geekity.db` | A cache of the files. Sessions and tokens are pruned when they expire. |
1718
- | Client addresses in the rate limits | Memory | Until the window passes or the site restarts. |
1719
- | Where and when a photo or video was taken, and the camera or phone (EXIF, XMP, IPTC, a video's `©xyz`) | Removed from `content/uploads/` on upload | Never stored. Older uploads keep it until `geekity strip-metadata`; git history keeps the old bytes until rewritten. |
1720
- | Where the author wrote a post or checked in: coordinates, accuracy, and the place's name, locality, region and country | `data/locations.json`, mode `0600`, keyed by permalink | Kept until the author clears it. Published only as far as **Settings > Privacy** allows, nothing by default. |
1721
- | Micropub properties a post was sent that the site does not understand, such as an RSVP | `data/kept-properties.json`, mode `0600`, keyed by permalink | Kept until an update deletes them. Never published. |
1722
- | Client addresses in the access log | stdout, and whatever collects it | Only with `accessLogAddress` on. The collector keeps them. |
1723
-
1724
- Two services outside the site see personal data when a site turns them on.
1725
- Akismet is sent a commenter's or sender's address, user agent, referrer, name,
1726
- email, website and words. The mail provider is sent every address a message
1727
- goes to.
1728
-
1729
- ### The starter privacy page
1730
-
1731
- `geekity init` writes `content/pages/privacy.md`, served at `/privacy/` and
1732
- linked from the footer by a `Privacy | /privacy/` line in `menus.footer`. It
1733
- says in plain language what the table above says, marks the contact form,
1734
- reply emails and Akismet as optional, and quotes the retention periods a new
1735
- site starts with. It is a starting point written from what the software does,
1736
- not legal advice, and a note at its top says so to the owner: review it, add
1737
- whatever the site does beyond the CMS (analytics, a host's logs, a theme that
1738
- loads fonts from elsewhere), change the numbers when a retention period
1739
- changes, and remove the menu line if the page is deleted.
1740
-
1741
- The link is a menu line rather than something the theme prints, because the
1742
- footer prints `menus.footer` and nothing of its own (TASK-105). A site that
1743
- upgrades gets no page and no link; it can copy the file from
1744
- `templates/site/content/pages/privacy.md` in the package.
1745
-
1746
- ### Retention
1747
-
1748
- Three settings in `content/_data/site.json`, on **Settings > Discussion**,
1749
- decide how long the site keeps what readers hand it. Each counts whole days from
1750
- when the data arrived, and `0` keeps it forever:
1751
-
1752
- | Setting | Recommended | What goes when it runs out |
1753
- | ----------------------------- | ----------- | ----------------------------------------------------------------- |
1754
- | `commentEmailRetentionDays` | `180` | The email on a comment, and with it the reply subscription. |
1755
- | `addressHashRetentionDays` | `30` | The address hash on a comment, a webmention or a contact message. |
1756
- | `contactMessageRetentionDays` | `365` | The whole contact message. |
1757
-
1758
- **A site keeps everything until its owner sets a period.** A `site.json`
1759
- without these keys reads as `0` for all three, so an existing site that
1760
- upgrades loses nothing, and the Discussion screen says "Kept forever" beside
1761
- each one. A new site from `geekity init` starts with the recommended periods
1762
- written into its `site.json`. An existing site opts in by typing them on
1763
- Settings > Discussion and saving once.
1764
-
1765
- They are settings rather than config because how long a site keeps people's
1766
- data is the owner's decision, and a privacy notice quotes it; `site.json` is
1767
- public, which suits a policy. The recommended periods are long enough for a
1768
- reply notice to arrive, a run of spam to be spotted and a year of
1769
- correspondence to be answered, and no longer. The first sweep after a site
1770
- sets a period removes whatever is already past it.
1771
-
1772
- A sweep runs when the site starts serving and every six hours after that, and
1773
- `cms.retention.sweep()` runs one now. It reads the files rather than the index,
1774
- rewrites each comment file and message inside the same lock every other writer
1775
- takes, and puts each changed comment back into the index in the same step. A
1776
- second sweep finds nothing left to remove and writes nothing.
1777
-
1778
- A comment that loses its email keeps its id, its place in the thread, its
1779
- status, its words and its name. It gains `"redacted": ["email"]`, which is how
1780
- the index tells an email removed from one never given: a name whose email was
1781
- removed does not count toward auto-approval, so nobody can type that name and
1782
- skip the queue. Its author is no longer told about replies, and their next
1783
- comment waits for a moderator. A moderation link in an inbox names the comment
1784
- by id, so it goes on working.
1785
-
1786
- The sweep changes the files as they are now. The emails are in `data/`, which
1787
- is not in git, so removing one removes it. Emails from before they moved out of
1788
- the comment files (TASK-182) were committed with those files, and they stay in
1789
- the repository's history until that history is rewritten, whatever the sweep or
1790
- an erasure does to the files today.
1791
-
1792
- ### Erasing one person's data
1793
-
1794
- **Tools > Personal data** erases what the site holds for one email address.
1795
- It shows what it found first: how many comments carry the address, how many
1796
- contact messages came from it, and whether it is on the opt-out list. Erasing
1797
- then signs each of those comments `Anonymous`, removes its email, website,
1798
- avatar and address hash, and marks it `"redacted": ["email", "addressHash",
1799
- "author"]`; the comment keeps its place in the thread, its status and its words.
1800
- The contact messages are deleted and the address comes off the opt-out list.
1801
-
1802
- A comment is found by its email, so one whose email the sweep has already
1803
- removed, a webmention and a fediverse reply are not found there. The Comments
1804
- screen deletes those one at a time.
1805
-
1806
- ## Keeping the index in step
1807
-
1808
- Booting scans `contentDir`, indexes every Markdown file under `posts/` and
1809
- `pages/`, and drops index rows whose file has gone. After that a watcher keeps
1810
- the two in step, so an edit on disk reaches the live site in about a second
1811
- without a restart.
1812
-
1813
- ```ts
1814
- await cms.sync(); // one full scan; what `serve()` runs on boot
1815
- ```
1816
-
1817
- Set `watch: false` (or `GEEKITY_WATCH=false`) for a host whose content cannot
1818
- change under the process. `geekity sync` sets it for you.
1819
-
1820
- ## Dates and the timezone setting
1821
-
1822
- Every date the CMS writes into a file — `date`, `updated` and
1823
- `activitypub.published` — is a UTC instant ending in `Z`. The `timezone`
1824
- setting is not stored with the date; it is the lens the instant is read
1825
- through.
1826
-
1827
- That means one rule in three places:
1828
-
1829
- - **The editor** shows a stored instant as the clock reads in the site's zone,
1830
- with the zone named under the field (`Wall-clock time in America/Chicago
1831
- (CDT). Stored as UTC.`), and reads what you type back in the same zone. Type
1832
- `2026-09-04 09:00` in a site set to `America/Chicago` and the file gets
1833
- `date: '2026-09-04T14:00:00Z'`. A value you write with an offset of its own —
1834
- `2026-09-04T09:00:00+02:00` — already names an instant, so it is kept as one.
1835
- - **The theme's `date` filter** renders `readable`, `html` and `year` in the
1836
- site's zone; `iso` stays the instant, because that is what a `<time
1837
- datetime>` and a feed want. Changing the setting changes what every page
1838
- shows on the very next request, with no file touched and no rebuild.
1839
- - **A new post's filename day and its `/{yyyy}/{mm}/` permalink** come from the
1840
- calendar day the site's zone was on at that instant, so a post published at
1841
- half past midnight on 1 October in Berlin is filed under October and not
1842
- under the 30 September that UTC was still on.
1843
-
1844
- Changing the setting later never moves a URL. The permalink is written
1845
- explicitly into every file the CMS saves, and the day a post is filed under is
1846
- the one in its filename — decided once, when it was written, where no setting
1847
- can reach it.
1848
-
1849
- Files written by hand keep working. A `date` with an offset, or one YAML parses
1850
- into a timestamp by itself, is read for the instant it names, sorts and
1851
- schedules by that instant, and is rewritten as UTC the next time the CMS saves
1852
- that file — never by a mere scan. A hand-written file with no `permalink` still
1853
- resolves to the URL Eleventy gives it, which is cut from the date as the file
1854
- spells it.
1855
-
1856
- Feeds, the sitemap and the ActivityStreams objects emit instants and are
1857
- unaffected by the setting, and a scheduled post fires at its instant whatever
1858
- the zone is set to.
1859
-
1860
- ## Scheduling
1861
-
1862
- A post whose `date` is in the future is written now and published then, the way
1863
- WordPress schedules one. Save it with Publish; the editor answers `Scheduled:
1864
- {title}` and says when it goes out, in the site's `timezone` setting.
1865
-
1866
- Until that moment the post is not public in any sense: it is off the home page,
1867
- off every archive, out of all three feeds and out of the ActivityPub outbox,
1868
- its permalink 404s in every representation, and no follower has been told about
1869
- it. When the moment comes the running server publishes it — no restart, no
1870
- build — and the same delivery that a live publish runs sends the
1871
- `Create` and pings the notify server.
1872
-
1873
- There is no `scheduled` key and no state to keep: a scheduled post is an
1874
- ordinary published one whose date has not arrived, which is a question asked of
1875
- the clock on every query. So moving the date moves the publication, ticking
1876
- Draft cancels it, and a date pushed into the future withdraws a post that was
1877
- already out — with a `Delete` to the followers, exactly as drafting it would.
1878
-
1879
- A post whose date passed while the server was down is public the moment the
1880
- next boot has scanned, and is federated once on that boot. The CMS remembers
1881
- how far through the calendar it has got, so a restart never announces the same
1882
- post twice and a deleted database never announces the archive.
1883
-
1884
- The admin lists scheduled posts under their own Scheduled filter, with a
1885
- Scheduled status beside them. The public permalink 404s for a signed-in admin
1886
- too: the public site has no session, deliberately — every response it gives is
1887
- cacheable, and a URL that answered differently for one viewer would be cached
1888
- and served to the rest. Use the editor's Preview button, which renders the post
1889
- through the theme's own layout at an admin URL.
1890
-
1891
- ## Unlisted posts
1892
-
1893
- An unlisted post is published at its URL and left off every list. Choose
1894
- Unlisted in the editor's Visibility field, send `visibility=unlisted` from a
1895
- Micropub app, or write it into the front matter:
1896
-
1897
- ```yaml
1898
- visibility: unlisted
1899
- ```
1900
-
1901
- Public is the key's absence, and the editor removes the key when you choose
1902
- Public again. The site has no private posts. A value other than `public` or
1903
- `unlisted`, such as a hand-typed `visibility: private`, hides the post the way
1904
- `draft: true` does: its URL answers 404 to anybody not signed in, it is on no
1905
- list, and a post the followers already hold is withdrawn with a `Delete`. The
1906
- editor shows the value as it is stored, marks the post Hidden in the list, and
1907
- keeps the value through a save until you choose Public or Unlisted.
1908
-
1909
- The rule works for pages too.
1910
-
1911
- | Where | An unlisted post |
1912
- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
1913
- | Its permalink, in every representation | 200, with `X-Robots-Tag: noindex`. The default theme also prints `<meta name="robots" content="noindex">`. |
1914
- | The home page, tag, category and author archives, the `archive: true` page | Absent, and not counted in a pager or a tag count. |
1915
- | Previous and next links under other posts, a front page's `newestPosts` | Absent. |
1916
- | Every RSS, Atom and JSON feed, and the site-wide comments feed | Absent. Saving one pings no notify server. An unlisted reply post shown in a thread is still in that thread's comments feeds. |
1917
- | The sitemap, search (HTML and JSON) and `llms.txt` | Absent. |
1918
- | IndexNow | Not submitted. A public post that becomes unlisted is submitted once, so a search engine reads the `noindex`. |
1919
- | The ActivityPub outbox and featured collection | Absent. |
1920
- | Followers | Get the `Create` and every `Update`, addressed `to` the followers with Public in `cc`, which Mastodon shows as unlisted. Relays get nothing. |
1921
- | Webmentions | Sent as for any post: the page is public. |
1922
-
1923
- Changing a published post between public and unlisted sends its followers an
1924
- `Update` with the new addressing.
1925
-
1926
- In code, `isServed(document)` says whether the site serves a document at its
1927
- URL and `isListed(document)` whether it also lists it. The index answers the
1928
- same two rules in SQL. An Eleventy build with the example config reads the key
1929
- too: an unlisted post gets its page, `noindex`, and no place in any collection,
1930
- and an unrecognized value builds no page. See
1931
- [Building the same content with Eleventy](#building-the-same-content-with-eleventy).
1932
-
1933
- ## Hooks
1934
-
1935
- Two hooks let a site do something of its own when content changes. Both are
1936
- config options, and both are also methods, for a site that would rather
1937
- subscribe from its entry file:
1938
-
1939
- ```ts
1940
- export default defineConfig({
1941
- onPublish: (change) => {
1942
- console.log(`${change.next?.title ?? ''} went live`);
1943
- },
1944
- });
1945
- ```
1946
-
1947
- ```ts
1948
- const stop = cms.onDocumentChange((change) => {
1949
- console.log(change.type, change.path, change.origin);
1950
- });
1951
- stop(); // the method returns the unsubscribe
1952
- ```
1953
-
1954
- `onDocumentChange` runs for every `created`, `updated` and `deleted`.
1955
- `onPublish` runs when a document becomes visible on the public site: created
1956
- already published, a draft published, or a document restored from the trash. A
1957
- hook may be `async`; the CMS never waits for it, and a hook that throws or
1958
- rejects is reported and does not stop the scan or wedge the watcher.
1959
-
1960
- **Mind the origin.** Every change carries `origin`: `watch` for a live edit,
1961
- `admin` for a write the editor made, and `scan` for a full scan — _including
1962
- the one on boot_. A cold index reports its whole first scan as `created`, and
1963
- every published document as `published`, so a hook that must not re-fire when
1964
- the index is rebuilt has to say so:
1965
-
1966
- ```ts
1967
- cms.onPublish((change) => {
1968
- if (change.origin === 'scan') return; // not a rebuilt index
1969
- void announce(change.next);
1970
- });
1971
- ```
1972
-
1973
- Deleting `data/geekity.db` is a supported thing to do, so this is not a corner
1974
- case: it is what happens on the next boot.
1975
-
1976
- For anything the two hooks do not cover, subscribe to the events directly. An
1977
- `async` listener registered this way _is_ waited for, unlike the two hooks, so
1978
- it can finish writing before the change is called done — and can also hold up
1979
- the watcher if it is slow.
1980
-
1981
- ```ts
1982
- cms.events.on('unpublished', (change) => {
1983
- console.log(`${change.previous?.title ?? ''} came down`);
1984
- });
1985
- ```
1986
-
1987
- | Event | When |
1988
- | ------------- | -------------------------------------------------------------------------------------------------- |
1989
- | `created` | A file was indexed for the first time. |
1990
- | `updated` | An indexed file's content changed. |
1991
- | `deleted` | An indexed file is gone. |
1992
- | `published` | A document became visible: created live, a draft published, or a document restored from the trash. |
1993
- | `unpublished` | The reverse: published to draft, trashed, or deleted while live. |
1994
- | `change` | Every `created`, `updated` and `deleted`. |
1995
-
1996
- Each one carries the document before and after the change (`previous` and
1997
- `next`, either of which may be `undefined`), the content-relative `path`, and
1998
- `origin`. `on()` returns the function that unsubscribes; there is also `once()`
1999
- and `off()`.
2000
-
2001
- ## The admin
2002
-
2003
- Booting also mounts the admin at `/admin`. It is server-rendered from templates
2004
- that ship inside the package, deliberately outside the theme search path: a
2005
- site's theme may override any public template, and must not be able to
2006
- shadow the login form.
2007
-
2008
- | Route | What it does |
2009
- | ------------------------------------------------ | --------------------------------------------------------------------------------------------- |
2010
- | `/admin` | The dashboard: counts, the five most recent posts, the follower count. |
2011
- | `/admin/posts`, `/admin/pages` | The listings and the editors. |
2012
- | `/admin/tags`, `/admin/categories` | Every term in use, with rename, merge and delete. |
2013
- | `/admin/tags/rename`, `/admin/categories/rename` | `POST` only. Renames a term, or merges it into one that exists. |
2014
- | `/admin/tags/delete`, `/admin/categories/delete` | `POST` only. Takes a term out of every file. |
2015
- | `/admin/navigation` | Navigation > Menus: every menu the site holds. `POST` saves one menu's items. |
2016
- | `/admin/navigation/add` | `POST` only. Adds an empty menu under the name the form gives. |
2017
- | `/admin/navigation/delete` | `POST` only. Deletes one menu the active theme renders nowhere. |
2018
- | `/admin/media` | Everything under `content/uploads`, with the URL, the Markdown and what uses it. |
2019
- | `/admin/media/upload` | `POST` only. Stores one file by the rules the editor's upload enforces. |
2020
- | `/admin/media/delete` | `POST` only. Deletes one upload, asking first when a document points at it. |
2021
- | `/admin/media/alt` | `POST` only. Sets a picture's alt text, or marks it decorative, in `media.json`. |
2022
- | `/admin/comments` | Pending, approved and spam, with approve, spam, delete and reply. |
2023
- | `/admin/comments/moderate` | `POST` only. Approves one comment, files it as spam, or deletes it. |
2024
- | `/admin/comments/reply` | `POST` only. Posts an approved reply under the comment it answers. |
2025
- | `/admin/messages` | The contact form's inbox, with a Spam list beside it. |
2026
- | `/admin/messages/read` | `POST` only. Marks one message read, or unread again. |
2027
- | `/admin/messages/delete` | `POST` only. Deletes one message, and its file with it. |
2028
- | `/admin/appearance/themes` | Appearance > Themes: the themes on disk. `POST` activates the one named. |
2029
- | `/admin/plugins` | Plugins: every installed plugin, and Enable and Disable. |
2030
- | `/admin/plugins/<package name>` | A running plugin's own screen, such as `/admin/plugins/@geekity/plugin-wordpress`. |
2031
- | `/admin/tools` | Tools > Content index: what the index holds, and the button that rebuilds it. |
2032
- | `/admin/tools/rebuild-index` | `POST` only. Offers the rebuild, then reads every file again on the live site. |
2033
- | `/admin/settings` | Settings > General: title, tagline, author, base URL, time zone, language, locale, site icon. |
2034
- | `/admin/settings/reading` | What the homepage displays, posts per page, the notify server. |
2035
- | `/admin/settings/permalinks` | The tag and category bases, and the archive redirects already recorded. |
2036
- | `/admin/settings/discussion` | Comments and the closing window, webmentions, and the Akismet key. |
2037
- | `/admin/settings/akismet` | `POST` only. Saves the Akismet key, or forgets it. |
2038
- | `/admin/settings/email` | The mail provider, the From line, the reply-to and the contact address. |
2039
- | `/admin/settings/mail` | `POST` only. Saves a mail credential, or forgets every one of them. |
2040
- | `/admin/settings/mail/test` | `POST` only. Sends the theme's test message through the whole chain. |
2041
- | `/admin/users` | Who may sign in: a row each, with Edit and Delete. Nothing on it edits anybody. |
2042
- | `/admin/users/new` | Users > Add new: the add form. `POST` adds one. |
2043
- | `/admin/users/<id>` | One user: the account, the profile, the notices, your password, and the delete. |
2044
- | `/admin/users/password` | `POST` only. Changes the signed-in admin's own password. |
2045
- | `/admin/users/email` | `POST` only. Sets or clears the email address on the user the form names. |
2046
- | `/admin/users/profile` | `POST` only. Saves the whole public profile of the user the form names. |
2047
- | `/admin/users/notifications` | `POST` only. Turns one notice on or off for the user the form names. |
2048
- | `/admin/users/notifications/mode` | `POST` only. Sets how often that notice arrives: as they arrive, hourly or daily. |
2049
- | `/admin/users/delete` | `POST` only. Deletes the user the form names. |
2050
- | `/admin/federation` | The actors, their followers, the inbox log, and per-post delivery. |
2051
- | `/admin/federation/settings` | Federation > Settings: the relays the site subscribes to. |
2052
- | `/admin/federation/resend` | `POST` only. Sends one post to the followers again, as its file now reads. |
2053
- | `/admin/setup` | First run: creates the first admin. Closed once a user exists. |
2054
- | `/admin/login` | Username and password. |
2055
- | `/admin/logout` | `POST` only. Deletes the session row and sends `Clear-Site-Data`. |
2056
- | `/admin/_static/*` | The admin's own stylesheet and scripts, cached for an hour. |
2057
-
2058
- The screens behind the login share one layout: a bar across the top with the
2059
- site name and a link to the public site, the menu down the left, and a place for
2060
- flash messages. A message queued with `flash(c, 'notice', '…')` is kept on the
2061
- session row, shown on the next page the browser asks for, and cleared as it is
2062
- read, so it survives exactly one redirect.
2063
-
2064
- ### The admin's look
2065
-
2066
- The admin is drawn in DaisyUI 5 on Tailwind v4 (decision-30). Its stylesheet,
2067
- `admin/static/admin.css`, is compiled at build time from `admin/src/admin.css`
2068
- and ships in the package like the default theme's. It carries every built-in
2069
- DaisyUI theme, with `light` as the default and `dark` when the system prefers
2070
- dark. A site cannot override it or add a theme of its own.
2071
-
2072
- The admin bar is the same component on the admin and across the top of every
2073
- public page a signed-in user reads: a `<geekity-admin-bar>` element holding a
2074
- declarative shadow root, so neither the site theme nor the admin's theme
2075
- reaches inside it. It draws in a light or a dark palette of its own, following
2076
- the user's admin theme. On a public page it pushes the page down by
2077
- `--geekity-admin-bar-height` and adds the `geekity-admin-bar` class to
2078
- `<html>` for a theme that needs to make room of its own.
2079
-
2080
- Each user picks the theme the admin is drawn in for themselves, on their own
2081
- screen under Users: any built-in DaisyUI theme, or Follow the system. The
2082
- choice is stored as `adminTheme` on the user in `data/users.json`, and every
2083
- admin page drawn for that user carries it as `data-theme` on `<html>`. Following
2084
- the system stores nothing and puts no `data-theme` on the page. The login,
2085
- setup, forgot-password and reset screens never carry one.
2086
-
2087
- ### The menu
2088
-
2089
- The menu is WordPress classic: twelve sections — Dashboard, Posts, Pages,
2090
- Navigation, Media, Comments, Messages, Appearance, Users, Tools, Settings,
2091
- Federation — each a heading over one or more children. Clicking a heading opens the section and lands on its first
2092
- child; the open section shows its children and the one you are on carries
2093
- `aria-current="page"`, so Users tells you that you are on Users > All users.
2094
- Tags and categories are children of Posts, because a tag with no post on it is
2095
- nothing. Every section has at least one child even when it has one screen, so
2096
- the rule never needs an exception and a second child can appear later without
2097
- the menu changing shape. There is no JavaScript in it: the server knows which
2098
- section is open, so expanding one is a page rather than a script.
2099
-
2100
- The whole menu is `ADMIN_SECTIONS` in `src/admin/menu.ts`. A site's own screen
2101
- joins it in two steps:
2102
-
2103
- ```ts
2104
- // 1. One entry in the section's children, naming the screen and its URL.
2105
- { child: 'imports', label: 'Imports', url: '/admin/imports' }
2106
-
2107
- // 2. The same pair on whatever that screen renders.
2108
- render(c, 'layouts/imports.njk', { section: 'posts', child: 'imports' });
2109
- ```
2110
-
2111
- Nothing else changes: the heading, the landing URL and the marking all follow.
2112
- A screen naming a section or a child the registry does not hold throws
2113
- `UnknownAdminScreenError` rather than rendering a menu expanded around nothing,
2114
- so the mistake is a failed test instead of a menu that quietly marks nothing.
2115
-
2116
- Who may sign in is `data/users.json`: one entry per user with an id, a
2117
- username, an argon2 hash and a created time — and, for a user who has them, an
2118
- email address, notification preferences, a public profile and the
2119
- [stored actor id](#a-users-stored-actor-id) they were published under
2120
- elsewhere. Written atomically with `0600` permissions and serialised against
2121
- itself, so two admins adding the same name at once cannot both succeed. It is in `data/` rather than `content/` because
2122
- `content/` is published with the site, and it is one of the files under
2123
- `dataDir` that must be backed up.
2124
-
2125
- Sessions stay in SQLite, where the rest of the cache lives. They name a user by
2126
- id and are joined against the file on every admin request, so a session whose
2127
- user is no longer there is not a login: deleting `data/geekity.db` signs
2128
- everybody out and loses nothing else, and restoring an old one cannot bring
2129
- back an account that was deleted or a password that was changed.
2130
-
2131
- Passwords are hashed with argon2id through `node:crypto`, so there is no native
2132
- module to build. The cost parameters travel with each hash, which means raising
2133
- them later leaves every password already stored verifiable.
2134
-
2135
- A session id is 256 random bits in a `HttpOnly; SameSite=Lax; Path=/` cookie.
2136
- When `baseUrl` is an `https` URL the cookie is `Secure` and named
2137
- `__Host-geekity_session`; over plain http it is `geekity_session`, as
2138
- [Session hardening](#session-hardening) explains. It lasts `sessionLifetime`;
2139
- an expired session is deleted rather than merely ignored. Every mutating admin
2140
- form carries a per-session CSRF token, and a `POST` without a valid one is
2141
- refused with 403 — including the login and setup forms,
2142
- which get the token from a short anonymous session created when the form is
2143
- first rendered. Logging in throws that session away and starts a new one, so a
2144
- planted session id cannot become a logged-in one.
2145
-
2146
- The first admin comes from `/admin/setup` or from
2147
- [`geekity user add`](#creating-an-admin-from-the-command-line). Every one after
2148
- that comes from `/admin/users`, which lists who may sign in and adds a user
2149
- with a password you supply or one it generates and shows once. The list is a
2150
- listing and nothing more: a username with the archive URL under it, the email
2151
- address, when the account was made, and Edit and Delete. Every field lives on
2152
- that person's own screen at `/admin/users/<id>`, where each box has a label
2153
- beside it — the account and its email address, the public profile, what this
2154
- person is emailed about, your own password on your own page, and the delete or
2155
- the sentence saying why this account cannot go. Each Save lands back on that
2156
- screen, so it shows its own result. The last remaining user cannot be deleted,
2157
- and nobody may delete their own account.
2158
-
2159
- An email address is optional on a user and can be set on anybody's screen,
2160
- since with one role every user already has every power there is. It buys
2161
- [password recovery](#forgotten-passwords) and the notices
2162
- [a comment sets off](#being-told-about-one), and it never appears on the public
2163
- site. Beside it is which of those notices go to it: one switch per event, on by
2164
- default, stored in `data/users.json` only when somebody turns one off.
2165
-
2166
- A site whose database was written by a version that kept the accounts in a
2167
- `users` table has those rows written into `data/users.json` on the first boot
2168
- of this one — ids and hashes as they stand, so nobody has to log in again — and
2169
- the table is dropped. A `users.json` that is already there wins and is left
2170
- alone.
2171
-
2172
- Creating one from your own code, which is what the CLI does:
2173
-
2174
- ```ts
2175
- import { createUser } from '@geekity/cms';
2176
-
2177
- await createUser({
2178
- dataDir: 'data',
2179
- username: 'ada',
2180
- password: process.env.PASSWORD ?? '',
2181
- });
2182
- ```
2183
-
2184
- ### Settings
2185
-
2186
- `content/_data/site.json` is the source of truth for a site's settings. The
2187
- pages under `/admin/settings` — General, Reading, Permalinks, Discussion and
2188
- Email — each read that file, validate what was typed and write it back; nothing
2189
- else remembers a setting, and `data/geekity.db` holds none of them. Federation's
2190
- page is a sixth one of exactly the same kind, filed under its own section at
2191
- `/admin/federation/settings` rather than under Settings, because the relays are
2192
- about the section that holds the followers.
2193
-
2194
- Each page saves its own fields and no others, onto the file as re-read inside
2195
- the write, so two people saving two different pages at the same moment both
2196
- land, and each page validates only what it shows: a refused save comes back on
2197
- the page it was sent from and writes nothing at all.
2198
-
2199
- The write is atomic and serialised: the bytes go to a temporary file beside the
2200
- real one and are renamed over it, and the read of what the file already held
2201
- and the write of what it says next are one step nothing else writing that file
2202
- can get between. So a reader — an Eleventy build, the CMS's own site data
2203
- source, another process entirely — always sees one whole version, and two saves
2204
- at once cannot each keep half of what the other kept.
2205
-
2206
- The file carries `title`, `tagline`, `url`, `author` (a username, when the
2207
- site has one author), `postsPerPage`, `homepage`, `postsPage`,
2208
- `timezone`, `language`, `locale` (when it is set), `icon` (when it is set),
2209
- `license` and `licenseName` (when a license is chosen; see below), `tagBase`,
2210
- `categoryBase`, `notifyServer`, `feedUpdatePeriod`, `feedUpdateFrequency`,
2211
- `webmentionsSend`, `webmentionsReceive`,
2212
- `mailProvider`, `mailFromName`, `mailFromAddress`, `mailReplyTo`,
2213
- `contactEmail`, `securityContacts`, `securityPolicy`, `securityLanguages`,
2214
- `relays`, `menus` and `taxonomyRedirects`,
2215
- and every other key it already had is kept, `feedSize` and anything a site put
2216
- there included. A key it does not carry is the default, and a key of the wrong
2217
- type is the default too: a hand-edited `site.json` cannot take the site down.
2218
-
2219
- `homepage` and `postsPage` are the only two written just when they have a
2220
- value: the slug of the page served at `/` and the slug of the page whose own
2221
- URL carries the post listing, absent altogether on a site that shows its latest
2222
- posts at `/`. A `postsPage` without a `homepage` is
2223
- ignored, the listing being at `/` already, and a slug naming no published page
2224
- is a site back on its latest posts. See [The front page](#the-front-page).
2225
-
2226
- Because it is the source rather than a copy, editing it by hand while the
2227
- server runs is picked up on the next request — on the public site, in the
2228
- feeds and on the settings screen alike — with nothing
2229
- to restart and nothing to tell. Put the file in git and a checkout of it is the
2230
- site's settings.
2231
-
2232
- The one exception is `url`. A base URL decides the absolute URLs in the feeds,
2233
- the ActivityStreams ids and whether the session cookie is `Secure`, so
2234
- `GEEKITY_BASE_URL` and a `baseUrl` in the config file both win over the file's,
2235
- and the General settings page renders the field read-only and says which value
2236
- is in effect and why. When neither names one, the file's `url` becomes the base URL
2237
- at boot — at boot rather than on save, so an `https` base URL cannot log out
2238
- the admin who submitted it over `http`.
2239
-
2240
- Two things on that screen are not settings and are not in that file: the
2241
- Akismet key and the mail credential. Both are credentials, both live under
2242
- `data/` at mode `0600`, and each is its own pair of forms — save and remove —
2243
- so a key typed wrong cannot lose an edit to the title. See
2244
- [Akismet](#akismet) and [Email](#email).
2245
-
2246
- A site whose database was written by a version that kept the settings in SQLite
2247
- has those rows written into `site.json` on the first boot of this one, and the
2248
- table is dropped. If the file was written after the rows were — a hand edit, or
2249
- a content directory restored from git — the file wins outright.
2250
-
2251
- ### The front page
2252
-
2253
- `/` is the site's latest posts until the Reading settings say otherwise. The
2254
- choice is WordPress's own, and so are its two answers:
2255
-
2256
- - **Your latest posts.** The archive at `/`, paginated at `/page/N/`. This is
2257
- the default, and it is what an empty `homepage` means.
2258
- - **A static page.** The page is served at `/`, its own permalink answers `301`
2259
- to `/` so the front page has one URL, and the menu links it at `/`. The
2260
- default theme draws it with `layouts/front-page.njk`, which prints the
2261
- page's own title and words and nothing else (decision-44). It lists no
2262
- posts, prints no links and prints no bio.
2263
-
2264
- A site that wants more on its front page, such as its newest posts or links
2265
- to its archives, overrides `layouts/front-page.njk` in its own theme. The
2266
- front page's context carries `newestPosts(count)`, a function that returns the
2267
- newest `count` published posts as the same entries a listing prints. Drafts,
2268
- future-dated, unlisted and trashed posts are left out. The theme chooses the
2269
- count, and the query runs only when the theme calls it. For example, this
2270
- override lists the five newest posts under the page's words:
2271
-
2272
- ```njk
2273
- {% extends "layouts/base.njk" %}
2274
-
2275
- {% block content %}
2276
- <div class="page-body e-content">{{ content | safe }}</div>
2277
-
2278
- <h2 class="section-title">Recent Posts</h2>
2279
- {% set posts = newestPosts(5) %}
2280
- {% set feedHeading = 3 %}
2281
- {% include "partials/post-list.njk" %}
2282
- {% endblock %}
2283
- ```
2284
-
2285
- On a solo author site the default theme's base layout puts the author's
2286
- `rel="me"` claims in the head of `/` as `<link rel="me">` elements, one for
2287
- their author archive and one for each profile link, whatever the front page
2288
- prints (decision-45). A static front page prints no h-card. A site that wants
2289
- its homepage to carry a representative h-card writes one in the homepage's
2290
- words, or includes `partials/bio.njk` with `bioHome` set in its override, as
2291
- the [theme README](./themes/default/README.md#the-front-page) shows.
2292
-
2293
- With a homepage set, a second pick gives the listing a page of its own: the
2294
- **posts page**. Its permalink carries the listing, with the page's own title
2295
- and words above the posts, paginated beneath it at `{permalink}page/N/`;
2296
- `/page/N/` at the root redirects there, and the theme's `layouts/posts-page.njk` is
2297
- the theme's override, falling back to the listing layout. A posts page with no
2298
- homepage is refused, as WordPress refuses it, and so is one page picked as
2299
- both. With a homepage and no posts page the listing has no page of its own,
2300
- which is WordPress's answer too.
2301
-
2302
- The feeds do not move: `/feed/`, `/feed/atom/` and `/feed/json/` syndicate the
2303
- site's posts wherever the listing is read, and every page advertises them. The
2304
- sitemap follows the site — `/` once, and the listing's pages under the posts
2305
- page — and the pages list marks the two rows **Front Page** and **Posts Page**.
2306
-
2307
- Both settings are page slugs in `content/_data/site.json`, so an Eleventy build
2308
- of the same directory shows the same front page:
2309
- `docs/eleventy.config.example.js` puts the homepage at `/` and flags the posts
2310
- page on the context as `isPostsPage`. A slug whose page is drafted, trashed or
2311
- deleted names nothing published, and the site is back to its latest posts with
2312
- the pick kept: publishing the page again puts the front page back.
2313
-
2314
- ### The media library
2315
-
2316
- `/admin/media` lists every file under `content/uploads`, newest first by
2317
- modification time, with a thumbnail for a picture and its extension at the same
2318
- size for anything else, the size in bytes, the date, and — for a library that
2319
- has grown — a pager, twenty-four files to a page.
2320
-
2321
- The list is a walk of the directory on every request rather than a query.
2322
- Nothing records that an upload exists: the filesystem is the truth
2323
- (decision-1, decision-9), so a file copied in over ssh, pulled in by git, or
2324
- written by an Eleventy build is on the screen without a restart, and one
2325
- removed the same way is off it. The only question the index is asked is which
2326
- documents mention a URL, which is the one thing a directory cannot answer.
2327
-
2328
- Each row carries the public URL and the ready-made Markdown — `![alt](url)`
2329
- for a picture, with its alt text from the library or empty when it has none or
2330
- is decorative, and `[name](url)` for anything else, from the same function the
2331
- editor's upload control pastes from — as readonly text fields, which select and
2332
- copy on their own. `admin/static/copy.js` reveals a Copy button beside each and
2333
- does nothing else, so the screen is complete before it loads and a browser
2334
- without the clipboard API is not shown a button that would fail.
2335
-
2336
- The upload form is `storeUpload`, the same function behind `POST /admin/uploads`
2337
- alike: one allowlist, one signature check, one
2338
- `{yyyy}/{mm}/{slug}{ext}` naming rule, and one set of refusals, shown as a flash
2339
- on the next page rather than as the editor's JSON.
2340
-
2341
- Delete asks when it should. A file nothing points at is deleted on the first
2342
- click. A file a document still mentions — anywhere in its body, as Markdown, as
2343
- HTML or in prose — brings back a confirmation naming every document that does,
2344
- linked to its editor and marked when it is in the trash, and only a form
2345
- carrying `confirm` goes through. The trash counts because a trashed post can be
2346
- restored, and restoring one whose picture went in the meantime is a broken post
2347
- nobody was warned about. The submitted path is resolved against
2348
- `content/uploads` and refused when it lands outside, so a form field cannot
2349
- name a file elsewhere in the content directory.
2350
-
2351
- `deleteUpload` takes a `removeDerived` hook, called with the same
2352
- content-relative path once the original is gone. The original under
2353
- `content/uploads` is the only source of truth (decision-10) and anything
2354
- generated from it is derived state that must not outlive it; the hook defaults
2355
- to `removeImageVariants`, which takes the file's derived images with it. The
2356
- file's entry in `content/_data/media.json` goes too.
2357
-
2358
- A picture's row also carries its alt text: a text field, a Decorative checkbox
2359
- that wins over the text, and a line saying which of the two the library holds
2360
- or that it holds neither. The form posts to `/admin/media/alt`, which writes
2361
- `content/_data/media.json` atomically, keeping every other entry. The file is
2362
- the truth (decision-9) and is read on each request, so an entry written by hand
2363
- shows up without a restart. `readAltTexts(contentDir)` and
2364
- `undescribedImages(html, library)` in `src/images/alt-text.ts` are what the
2365
- editor's publish check and the federated `Image` attachments read.
2366
-
2367
- #### Location and camera metadata
2368
-
2369
- A phone photo carries where it was taken, when, and on what camera, and the
2370
- original under `content/uploads` is what the site serves at `/uploads/…`, puts
2371
- in every `<picture>` as the fallback, and federates as a photo post's
2372
- attachment. So `storeUpload` removes that metadata before the file is written,
2373
- for every upload: the editor's control, the media library, the Micropub media
2374
- endpoint and Micropub photos. It is not a setting.
2375
-
2376
- | Format | What goes | What stays |
2377
- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- |
2378
- | JPEG | EXIF (GPS, camera, lens, serial, time), XMP, IPTC, comments, other APPn segments, data after the image | JFIF, the ICC profile, Adobe's colour flag, the orientation |
2379
- | PNG | `eXIf`, `tEXt`, `zTXt`, `iTXt` (XMP), `tIME` and private chunks | Image and colour chunks, APNG animation, the orientation |
2380
- | WebP | `EXIF`, `XMP ` and unknown chunks | The bitstream, alpha, animation, ICC profile, the orientation |
2381
- | AVIF | The contents of the Exif and XMP items, overwritten with an empty EXIF block and an empty XMP packet | The picture, and its `irot`/`imir` orientation |
2382
- | GIF | Comment extensions and application extensions other than looping and ICC (XMP lives in one) | Frames, timing, looping |
2383
- | MP4, M4V | `udta` (the `©xyz` location, camera), `meta` (the QuickTime location key) and `uuid` (XMP) boxes, in the movie, its tracks and at the top level | Every other box |
2384
-
2385
- Nothing is re-encoded. Segments, chunks and blocks are cut out and the pixel
2386
- data is copied byte for byte, so a stored JPEG or PNG decodes to the same
2387
- pixels as the upload. A picture taken sideways keeps a minimal EXIF block
2388
- holding its orientation and nothing else, so it still shows the right way up.
2389
- AVIF and MP4 keep their layout exactly: the metadata is overwritten in place
2390
- and a removed box becomes a zero-filled `free` box of the same size, so no
2391
- offset inside the file moves. A file whose structure cannot be followed far
2392
- enough to find its metadata is refused with a 415 rather than stored as it is.
2393
-
2394
- Audio (MP3, M4A, Ogg), WebM, PDF and the text formats are stored as they
2395
- arrive. An episode's tags are its title and artwork, not where it was
2396
- recorded.
2397
-
2398
- Files uploaded before this version still carry their metadata. Strip them
2399
- with
2400
-
2401
- ```sh
2402
- geekity strip-metadata
2403
- ```
2404
-
2405
- which rewrites each file under `content/uploads` that has something to remove,
2406
- prints what it removed, keeps the file's modification time (the library sorts
2407
- by it), and leaves alone, and names, a file it cannot read, exiting `1`. It is
2408
- safe to run again: a clean file is left as it is, and a second run reports
2409
- every file already clean. The derived variants under `data/images` never
2410
- carried metadata.
2411
-
2412
- A site that keeps `content/` in git still has the old bytes in its history,
2413
- and so does every clone and fork of it. Removing them means rewriting that
2414
- history. One way: run `geekity strip-metadata`, copy `content/uploads`
2415
- somewhere outside the repository, remove it from every commit with
2416
- [`git filter-repo`](https://github.com/newren/git-filter-repo)
2417
- `--path content/uploads --invert-paths`, copy the stripped files back, commit
2418
- them, and force-push. That changes every commit hash, so anybody else with a
2419
- clone has to clone again.
2420
-
2421
- ### Image variants
2422
-
2423
- `generateImageVariants(config, uploadPath)` derives one upload's copies with
2424
- sharp and writes them, plus an `image.json` sidecar, into
2425
- `<dataDir>/images/<the upload's path>/`. It runs inside `storeUpload`, so the
2426
- editor's control and the media library both produce the same
2427
- variants for the same file and the encoding is paid for by whoever uploaded it.
2428
- It answers `undefined`, having written nothing, for a GIF, an animation, a PDF,
2429
- a text file, an upload that is not there, and a site with `imageOptimization`
2430
- off — every one of which means "serve the original", which is what the site
2431
- would have done anyway.
2432
-
2433
- | Function | What it is for |
2434
- | ---------------------------------- | ------------------------------------------------------------------------------------ |
2435
- | `generateImageVariants` | Derive the whole set and write the sidecar. Two callers at once share one encode. |
2436
- | `describeImage` | The record, synchronously and cached, for a renderer that must not open a file. |
2437
- | `findImageVariant` | One derived file by request path, generating it when it is missing. |
2438
- | `removeImageVariants` | Take a source's derived directory away. |
2439
- | `responsiveImages(html, describe)` | Rewrite `<img src="/uploads/…">` as `<picture>`. Pure: hand it any lookup. |
2440
- | `siteImageMarkup(config, html)` | The same, over one site's own records, deriving in the background for a record miss. |
2441
- | `siteIcons(config, setting)` | The head's icon links for a site's icon setting: `{ rel, sizes, href }`, or none. |
2442
-
2443
- `describeImage` is synchronous and cached because it is called once per image
2444
- while a page renders, and decision-10 forbids probing an image file at render
2445
- time. Its cache entry outlives the sidecar on purpose: `data/images/` is
2446
- disposable, so a page whose variants have been swept away keeps rendering the
2447
- markup that asks for them back, and the first request for each one rebuilds it.
2448
-
2449
- The site's icons are derived copies of the same kind, and `siteIcons` is how a
2450
- head gets at them: three square PNGs, `icon` at 32 and 16 pixels and
2451
- `apple-touch-icon` at 180, cropped from the middle of the site's icon (the
2452
- `icon` that Settings > General writes, else a hand-set `avatar`) and
2453
- encoded the first time a browser asks for one. It answers an empty list — and
2454
- the packaged theme then links no icon at all — when the site has no icon, when
2455
- its icon is a file no icon can be made of, or when `imageOptimization` is off,
2456
- so a head never advertises a URL this server would answer 404 for.
2457
-
2458
- `documentContext(document, images)` is where the rewrite is applied. Passing the
2459
- config is what turns the page's `content` into `<picture>` markup; the feeds,
2460
- `documentJson`, the Markdown representation and `postObject` all read
2461
- `document.html` directly and so keep the plain `<img>` by construction.
2462
-
2463
- ### Managing tags and categories
2464
-
2465
- `/admin/tags` and `/admin/categories` list every term in use with two counts and
2466
- a link to its archive: **Posts**, what the public site lists under it, and
2467
- **Files**, every file carrying it — drafts, scheduled posts and the trash
2468
- included. The second is the one to look at before acting, because it is what an
2469
- action is about to rewrite.
2470
-
2471
- Three things can be done to a term, and all three are the same operation. Typing
2472
- a new name into a row's field and pressing Rename rewrites the front matter of
2473
- every file carrying it. Renaming onto a term that already exists is a **merge**:
2474
- the screen comes back with a question naming both counts, and only a confirmed
2475
- form goes through, because a merge cannot be undone by renaming back. A file
2476
- that carried both terms keeps the target once, in the place it already had.
2477
- Delete takes the term out of every file and leaves everything else alone.
2478
-
2479
- Each of them says how many files it wrote. The files are the truth (doc-1), so
2480
- each file is re-read and re-parsed from disk before it is rewritten rather than
2481
- being taken from the index: the index can be a moment behind a hand edit, and a
2482
- bulk rewrite that trusted it would put that edit back. A file that has gone or
2483
- will not parse is skipped, counted, and named in the message.
2484
-
2485
- Every rewrite is announced the way an editor save is, so the index, the feeds,
2486
- the notify server and the fediverse all follow — one `Update` per
2487
- affected published post, because the hashtags on those posts have just changed.
2488
- A term on many published posts is therefore many deliveries; that is the point,
2489
- but it is worth knowing before renaming a tag that half the archive carries.
2490
-
2491
- A rename moves an archive, and an archive is a URL somebody may have linked to,
2492
- so the site records the move in a `taxonomyRedirects` list — `{ taxonomy, from,
2493
- to }` — in `content/_data/site.json` with the rest of the settings. The
2494
- old archive URL and its feed then answer `301` to the new ones. Chains are
2495
- collapsed as they are recorded, so `a → b` followed by `b → c` is stored as
2496
- `a → c` and answered in one hop; a term that comes back into use is served
2497
- rather than redirected; and deleting a term drops every record pointing at it,
2498
- because a redirect to a 404 is worse than the 404.
2499
-
2500
- ### Login hardening
2501
-
2502
- `/admin/login` counts failed sign-ins against two keys: the username that was
2503
- typed, and the address the request came from. Once either has failed
2504
- `loginAttempts` times — five by default — the admin refuses further attempts
2505
- from that key for `loginLockout` seconds, fifteen minutes by default, and
2506
- answers `429` with a `Retry-After` header and a message saying how long is
2507
- left.
2508
-
2509
- Three things about that are deliberate:
2510
-
2511
- - **The lockout is checked before the password is.** A correct password during
2512
- a lockout is refused too. Verifying first would hand the site to whoever
2513
- guessed right on the attempt that tripped the limit.
2514
- - **The wait doubles each time the lockout is tripped again**, up to sixteen
2515
- times the first one — four hours at the default. There is no artificial pause
2516
- on an individual attempt: a `sleep` in the handler holds a connection open for
2517
- as long as it lasts, so it would cost the server more than the attacker.
2518
- - **The message says nothing about who exists.** A username nobody has is
2519
- counted and locked out exactly as a real one is, so the lockout cannot be used
2520
- to enumerate accounts, and a wrong password and an unknown username still read
2521
- the same.
2522
-
2523
- A sign-in that works clears both keys, and so does time: a key that stops
2524
- failing is forgotten. The counts live in memory, so a restart clears them.
2525
- That is the point — a failed login writes nothing an anonymous caller could
2526
- grow, and the run of guesses this defends against happens over minutes, which
2527
- no restart interrupts. Both refusals are logged with the username and the
2528
- address.
2529
-
2530
- `trustProxy` decides where the address comes from. It is `false` by default and
2531
- the address is the socket's own, because anybody may send `X-Forwarded-For`: on
2532
- a site reached directly, believing it would let one attacker put every guess on
2533
- a different make-believe address and never be locked out by address at all. Turn
2534
- it on — and only on — when a reverse proxy in front of the site sets that
2535
- header; the leftmost entry is then used. With neither available, which is what
2536
- happens when the app is driven in process rather than served, the username is
2537
- the only key.
2538
-
2539
- ### Session hardening
2540
-
2541
- Three things protect a signed-in session beyond the cookie flags and the CSRF
2542
- token.
2543
-
2544
- **The cookie is `__Host-geekity_session` under https.** A browser accepts a
2545
- cookie with the `__Host-` prefix only when it is `Secure`, has `Path=/` and
2546
- names no `Domain`. That binds it to the exact host, so a page on a sibling
2547
- subdomain cannot set or overwrite it to plant a session. The prefix needs
2548
- `Secure`, and a browser drops a `Secure` cookie on a plain-http origin, so a
2549
- site whose `baseUrl` is `http://localhost:3000` keeps the name
2550
- `geekity_session` and local development works as before.
2551
-
2552
- **The upgrade signs people out once.** A session made before this change is in
2553
- a cookie named `geekity_session`. Under https that name is never accepted as a
2554
- login again, because accepting it would give a sibling subdomain the way in
2555
- that the prefix closes. The first admin request that carries only the old
2556
- cookie deletes its session, expires the old cookie, and sends the browser to
2557
- the login form with a message that the sign-in cookie was renamed and asks the
2558
- person to sign in again. On the public site the old cookie is ignored, so a
2559
- signed-in commenter sees the stranger's form until they sign in again.
2560
-
2561
- **Fetch Metadata refuses cross-site writes.** A browser sends
2562
- `Sec-Fetch-Site` with every request, and a page cannot change it. Every
2563
- request under `/admin` other than `GET`, `HEAD` and `OPTIONS`, including the
2564
- login and setup forms, is refused with 403 when that header says `cross-site`
2565
- or `same-site`, before the session is read or a handler runs. A comment posted
2566
- by somebody signed in gets the same check. `same-origin` and `none` pass. A
2567
- request with no `Sec-Fetch-Site`, from an older browser or a script, falls back
2568
- to the CSRF token, which every such request still has to carry. Nothing else
2569
- is checked: the ActivityPub inboxes, the WordPress-compatible inbox,
2570
- webmentions, the contact form, a stranger's comment, and the one-click
2571
- moderate and unsubscribe links in an email take cross-site `POST`s by design,
2572
- and they act on no session.
2573
-
2574
- **Logout clears the site's data in the browser.** The logout response sends
2575
- `Clear-Site-Data: "cache", "cookies", "storage"`, so a shared computer does not
2576
- keep admin pages in its cache or anything the site stored. Browsers honour it
2577
- only on a secure origin, which includes `http://localhost`. The `cookies`
2578
- directive clears cookies for the whole registrable domain, so signing out of
2579
- `blog.example.com` also signs the browser out of other sites under
2580
- `example.com`.
2581
-
2582
- ### Forgotten passwords
2583
-
2584
- `/admin/login` carries a **Forgotten your password?** link to `/admin/forgot`,
2585
- which takes a username _or_ an email address and always answers with the same
2586
- sentence — whether the name matched, did not match, or matched somebody who has
2587
- no address on their account. That is the point of the screen: an answer that
2588
- varied with what it found would be a list of which accounts the site has,
2589
- handed to anybody who asked for it.
2590
-
2591
- A match with an address is sent the theme's `password-reset` message, carrying
2592
- a link to `/admin/reset?token=…`. The token is 256 random bits; the database
2593
- keeps only its SHA-256, in a table beside the sessions, so a copy of
2594
- `data/geekity.db` is not a stack of working links, and the token is never
2595
- rendered into the page of the browser that asked for it. It lasts an hour and
2596
- works once.
2597
-
2598
- Setting a password through the link holds it to the same rules every other door
2599
- does, then closes everything else: the link that was used, every other reset
2600
- that user had outstanding, and every session they were signed in on. The
2601
- theme's `password-changed` message goes out afterwards, with no link back in,
2602
- so a reset somebody did not ask for is something they hear about.
2603
-
2604
- Requests are rate limited by username and by address exactly as sign-ins are —
2605
- same limits, same growing lockout, same `429` and `Retry-After` — on a throttle
2606
- of its own. Sharing the login one would let a stranger lock somebody out of
2607
- signing in by asking for their password to be reset over and over.
2608
-
2609
- Reset tokens live in the database, which is a cache: a restart, a
2610
- `geekity rebuild` or a deleted `data/geekity.db` invalidates every link in
2611
- flight, and the cost of that is a second click on Forgot password.
2612
-
2613
- With no mail provider or credential, `/admin/forgot` offers no form at all. It
2614
- says the site cannot send email and points at
2615
- [`geekity user add`](#creating-an-admin-from-the-command-line), which is how a
2616
- site nobody can sign in to gets a fresh admin from a shell. Saving a credential
2617
- under [Settings → Email](#email) turns the form on for the next visitor, with
2618
- no restart.
2619
-
2620
- ### Security headers
2621
-
2622
- Every response the CMS sends carries `X-Content-Type-Options: nosniff`, and
2623
- `Strict-Transport-Security: max-age=31536000; includeSubDomains` when `baseUrl`
2624
- is an `https` URL — the same test that decides whether the session cookie is
2625
- `Secure`, so the two cannot disagree. Neither can be turned off.
2626
-
2627
- Every response also carries these, public pages, feeds, uploads, ActivityPub
2628
- JSON, redirects, 404s, the maintenance 503 and the 500 page alike:
2629
-
2630
- | Header | Default |
2631
- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
2632
- | `Referrer-Policy` | `strict-origin-when-cross-origin` |
2633
- | `Content-Security-Policy` | `frame-ancestors 'self'` |
2634
- | `X-Frame-Options` | `SAMEORIGIN` |
2635
- | `Permissions-Policy` | `browsing-topics=(), camera=(), display-capture=(), geolocation=(), hid=(), microphone=(), midi=(), payment=(), serial=(), usb=(), xr-spatial-tracking=()` |
2636
- | `Cross-Origin-Opener-Policy` | `same-origin` |
2637
-
2638
- None of them limits what a page may load, and that is the line the CMS holds
2639
- on the public site. There is no content policy, no `default-src`, `script-src`
2640
- or `img-src`, because a theme is somebody else's HTML and the CMS has no
2641
- business deciding what it may reference. `frame-ancestors` is the only
2642
- directive in the policy, and it is about who may put the page in a frame, not
2643
- what is in it. Each header does something else:
2644
-
2645
- - `Referrer-Policy` sends only the origin, not the full URL, when a reader
2646
- follows a link to another site, so the path and query of the page they were
2647
- on stay on the site.
2648
- - `frame-ancestors 'self'` and `X-Frame-Options: SAMEORIGIN` stop another site
2649
- from framing a page. That matters most for a signed-in user, whose pages carry
2650
- a comment form that posts as them, but it covers every page because the CMS
2651
- cannot tell a framed reader from a framed user.
2652
- - `Permissions-Policy` turns off browser features a blog has no use for. It
2653
- leaves alone what an embedded video player asks for: autoplay, fullscreen,
2654
- `encrypted-media`, picture-in-picture, and the motion sensors behind a
2655
- 360-degree video.
2656
- The post editor is the one response that allows more: it sends the site's
2657
- policy with `geolocation=()` changed to `geolocation=(self)`, so its Use my
2658
- location button can ask the browser where it is.
2659
- - `Cross-Origin-Opener-Policy: same-origin` keeps a window the site opens, or a
2660
- window that opens the site, from reaching into it through `window.opener`.
2661
-
2662
- Two of them can get in the way of something a site does on purpose. A site that
2663
- is meant to be framed elsewhere, such as in a portfolio or a slide deck, has to
2664
- name that host in `frame-ancestors` and remove `X-Frame-Options`, which cannot
2665
- name another host. A theme that signs readers in or takes payment through a
2666
- popup on another origin needs `same-origin-allow-popups` for COOP. The CMS
2667
- itself does neither.
2668
-
2669
- One response is framed elsewhere on purpose: the embed view at
2670
- `/_geekity/embed?url=…`, the card that the oEmbed `html` frames so that
2671
- WordPress keeps it. It sends a complete policy of its own instead of the
2672
- default: `frame-ancestors *`, `default-src 'none'`, and its one inline style and
2673
- one inline script by hash. It never sends `X-Frame-Options`, even one set in
2674
- `securityHeaders`. It shows nothing from a session, so a signed-in user who is
2675
- framed sees the same card as anyone else. Every other response keeps the
2676
- framing headers above.
2677
-
2678
- A site changes them with `securityHeaders` in its config. Names are matched
2679
- without regard to case. A string replaces a default or adds a header of the
2680
- site's own, and `false` removes a default:
2681
-
2682
- ```ts
2683
- export default defineConfig({
2684
- securityHeaders: {
2685
- 'Content-Security-Policy':
2686
- "frame-ancestors 'self' https://portfolio.example",
2687
- 'X-Frame-Options': false,
2688
- 'Cross-Origin-Opener-Policy': 'same-origin-allow-popups',
2689
- },
2690
- });
2691
- ```
2692
-
2693
- A name that is not a header name, or a value that is empty or has a line break
2694
- in it, stops the site at boot. There is no environment variable for these.
2695
-
2696
- A policy that limits scripts has to allow whatever the site's theme adds to
2697
- every page. For the Umami and Google Analytics snippets, the theme README's
2698
- [Adding to every page](./themes/default/README.md#adding-to-every-page) lists
2699
- the directives each one needs.
2700
-
2701
- Every response under `/admin`, static files and redirects included, gets three
2702
- stricter values in their place, whatever `securityHeaders` says:
2703
-
2704
- | Header | Value |
2705
- | ------------------------- | --------------------------------------------- |
2706
- | `Referrer-Policy` | `same-origin` |
2707
- | `X-Frame-Options` | `SAMEORIGIN` |
2708
- | `Content-Security-Policy` | `default-src 'self'` and the directives below |
2709
-
2710
- The policy is `'self'` almost everywhere: the stylesheet and the editor bundle
2711
- come from `/admin/_static/`, the editor's preview and upload calls are
2712
- same-origin `fetch`, and the preview frame renders the site's own theme and
2713
- uploads. Four directives say more than that:
2714
-
2715
- - `style-src 'self' 'nonce-…'` — a fresh nonce on every response. CodeMirror 6
2716
- mounts its themes as `<style>` elements at runtime, which is an inline style
2717
- however it is written. The nonce is put on the editor bundle's `<script>` tag
2718
- and read back through the element's `nonce` property, which a browser keeps
2719
- after blanking the attribute, and handed to CodeMirror through
2720
- `EditorView.cspNonce`. So the admin never says `'unsafe-inline'`.
2721
- - `script-src 'self'` — no nonce and no `'unsafe-inline'`, because there is no
2722
- inline script in the admin at all. The slug and permalink auto-fill lives in
2723
- `/admin/_static/slug.js`.
2724
- - `img-src 'self' data:` — a preview of a post somebody is writing may hold a
2725
- data URI image, and refusing it would make the preview lie about what
2726
- publishing would show.
2727
- - `frame-ancestors 'self'` rather than `'none'` — the editor's preview is a
2728
- sandboxed `srcdoc` iframe, which inherits this policy, and its one ancestor is
2729
- the admin page itself.
2730
-
2731
- ### Compression
2732
-
2733
- Text responses are compressed on the way out: HTML, CSS, JavaScript, feeds,
2734
- JSON, Markdown, SVG, sitemaps and anything else whose media type is `text/*`,
2735
- `+json` or `+xml`. A client that accepts brotli (`br`) gets brotli, at quality
2736
- 5 because every page is built per request; one that accepts only gzip gets
2737
- gzip; one that accepts neither gets the plain bytes.
2738
-
2739
- Some responses go out as they are:
2740
-
2741
- - Images, video, WOFF and WOFF2 fonts and other formats that carry their own
2742
- compression. Encoding them again costs CPU and can make them bigger.
2743
- - Bodies under 1024 bytes, where the saving is smaller than the cost.
2744
- - A response that already has a `Content-Encoding`, or whose `Cache-Control`
2745
- says `no-transform`.
2746
- - A response that may hold a secret: anything whose `Cache-Control` says
2747
- `private` or `no-store`, which every page drawn for somebody signed in does,
2748
- and every admin screen under `/admin`, the setup and login forms included.
2749
- These carry CSRF tokens next to text a visitor can influence, and compressing
2750
- them would let an attacker who watches response sizes recover the token a
2751
- byte at a time (the BREACH attack). Their `Vary` is left as it was. The
2752
- admin's static files under `/admin/_static/` hold no secret and are
2753
- compressed. Error pages say `no-store`, so they go out plain too.
2754
-
2755
- Every other text response carries `Vary: Accept-Encoding`, whether it was
2756
- compressed or not, and so does every `304`. A cache in front of the site
2757
- therefore keeps a copy per encoding and never hands brotli to a client that
2758
- cannot decode it.
2759
-
2760
- A compressed response's `ETag` is the plain response's with `W/` in front: the
2761
- bytes differ, but the representation is the same. `If-None-Match` compares
2762
- weakly, so a browser holding the brotli copy and a feed reader holding the gzip
2763
- copy both revalidate to `304`, and each gets back the validator it sent.
2764
-
2765
- `compression: false`, or `GEEKITY_COMPRESSION=false`, turns all of this off.
2766
- Turn it off when a reverse proxy or CDN in front of the site compresses
2767
- responses itself (Caddy's `encode`, nginx's `gzip on`, Cloudflare), so the work
2768
- is done once and the proxy's settings apply. Leave it on when nothing in front
2769
- compresses, as in the Docker deployment the repository documents.
2770
-
2771
- ### security.txt and change-password
2772
-
2773
- `/.well-known/security.txt` ([RFC 9116](https://www.rfc-editor.org/rfc/rfc9116))
2774
- tells somebody who finds a vulnerability in the site where to report it. It is
2775
- built from three settings on the Email settings page, under Security contact,
2776
- which `content/_data/site.json` holds:
2777
-
2778
- | Setting | `site.json` key | Default | What it becomes |
2779
- | ------------------- | ------------------- | ------- | ----------------------------------------------------------------------------------------------------------------- |
2780
- | Contacts | `securityContacts` | `[]` | One `Contact:` line each, in order. An email address, stored as `mailto:…`, an `https://` URL or a `tel:` number. |
2781
- | Disclosure policy | `securityPolicy` | `""` | A `Policy:` line. An `https://` URL, or empty for no line. |
2782
- | Preferred languages | `securityLanguages` | `""` | A `Preferred-Languages:` line. Language tags separated by commas, or empty for no line. |
2783
-
2784
- With no contact the path is a 404: the RFC requires at least one. The
2785
- `contactEmail` setting is never used in its place, because that address is
2786
- promised never to appear on the public site.
2787
-
2788
- The file is built on every request and served as `text/plain; charset=utf-8`.
2789
- `Expires` is 30 days after the request, so it never goes stale however long the
2790
- site runs, and `Canonical` is the path under the base URL in effect. There is no
2791
- config-file key or environment variable for any of these: like `contactEmail`,
2792
- they are site settings a person edits on the settings screen.
2793
-
2794
- `/.well-known/change-password` is where a password manager sends somebody who
2795
- wants to change a saved password. It redirects with a `302` and
2796
- `Cache-Control: no-store`: to `/admin/users/{id}#change-password`, the form on
2797
- the signed-in user's own page, or to the login form when nobody is signed in.
2798
-
2799
- Both paths answer as usual in maintenance mode. A researcher may need the
2800
- security contact most while the site is down, and change-password only points
2801
- into the admin, which maintenance mode leaves open.
2802
-
2803
- ## The public site
2804
-
2805
- Booting mounts the public site on the app. The routes are:
2806
-
2807
- | Route | What it serves |
2808
- | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
2809
- | `/` | Published posts, newest first. |
2810
- | `/page/2/` and up | Later pages of the same archive. |
2811
- | a document's permalink | The post or the page, through the theme. |
2812
- | `/tag/{tag}/` | Everything published carrying that tag, paginated at `/tag/{tag}/page/2/`. |
2813
- | `/category/{name}/` | The second taxonomy, paginated the same way. |
2814
- | `/author/{username}/` | One user's published posts, headed by their profile, paginated the same way. |
2815
- | `/feed/`, `/feed/atom/`, `/feed/json/` | The recent posts as RSS 2.0, Atom and JSON Feed. |
2816
- | `/tag/{tag}/feed/` and its two siblings | The same, for one tag; `/category/{name}/feed/` likewise. |
2817
- | `/author/{username}/feed/` and siblings | The same, for one person. |
2818
- | `/comments/feed/` | Every reply the inbox has been sent, as RSS 2.0. |
2819
- | `{permalink}feed/` | One post's replies, the same way. |
2820
- | `/comment/{id}/` | One comment left through the form, on a page of its own: see [Comments](#comments). Noindex and on no list. |
2821
- | `/sitemap.xml` | Every public URL, for a search engine. |
2822
- | `/sitemap-{n}.xml` | One file of a sitemap too big to be a single one. |
2823
- | `/robots.txt` | What a crawler may have, and where the sitemap is. |
2824
- | `/opensearch.xml` | The OpenSearch description, so a browser can offer the site's search from its address bar. |
2825
- | `/_geekity/oembed?url=…` | The oEmbed card of the post or page at `url`, as JSON or with `format=xml`; a 404 for anything else. |
2826
- | `/_geekity/embed?url=…` | The same card as a page any site may frame, which the oEmbed `html` puts in an iframe; a 404 for anything else. |
2827
- | `/llms.txt` | The site's pages and recent posts for a language model, each linked to its Markdown. See [below](#llmstxt). |
2828
- | `/{key}.txt` | The IndexNow key, while the site has IndexNow on. See [below](#indexnow). |
2829
- | `/.well-known/security.txt` | Where to report a vulnerability; a 404 until a security contact is set. See [above](#securitytxt-and-change-password). |
2830
- | `/.well-known/change-password` | A redirect to the signed-in user's change-password form, or to the login form. |
2831
- | `/_geekity/comments` | `POST` only. Where the comment form under a post submits. |
2832
- | `/_geekity/contact` | `POST` only. Where the contact form on a page submits. |
2833
- | `/_geekity/webmention` | `POST` only. Where a webmention is sent; advertised on every document. |
2834
- | `/_geekity/moderate` | Where an approve, spam or delete link from a notification lands. `GET` shows a button; `POST` acts. |
2835
- | `/_geekity/unsubscribe` | Where the unsubscribe link in a reply notice lands. Same two steps. |
2836
- | `/theme/…` | The theme's own files, from its `static/` directory. |
2837
- | `/uploads/…` | A file from `content/uploads/`, byte for byte as it was stored. |
2838
- | `/uploads/_/…` | One derived copy of an uploaded image, generated on the spot if missing. |
2839
- | anything else | The theme's 404. |
2840
-
2841
- The federation routes go on before it, and answer only their own paths:
2842
-
2843
- | Route | What it serves |
2844
- | ------------------------------- | ------------------------------------------------------------------------------------- |
2845
- | `/author/{username}/` | The `Person` on an ActivityStreams request; the archive above to anything else. |
2846
- | `/author/{username}/inbox/` | `POST` only. That user's inbox, signature-verified by Fedify. |
2847
- | `/author/{username}/outbox/` | Their published posts as `Create` activities, paged 20 at a time. |
2848
- | `/author/{username}/followers/` | Who follows them, paged 20 at a time. |
2849
- | `/author/{username}/following/` | Always empty: a relay is a subscription rather than a relationship. |
2850
- | `/inbox/` | `POST` only. The instance-wide shared inbox; the addressee is read out of the body. |
2851
- | `/@{username}` | A 301 to their archive; a 404 for a handle nobody answers to. |
2852
- | `/.well-known/webfinger` | The handle, the author URL or `/@{username}`, all resolving to the same actor. |
2853
- | `/.well-known/nodeinfo` | A link to the NodeInfo document. |
2854
- | `/nodeinfo/2.1` | What software this is, how many users and how many posts. |
2855
- | a post's permalink | The `Article` on an ActivityStreams request (decision-13); the page to anything else. |
2856
-
2857
- Drafts, documents in the trash and posts whose date has not arrived are not on
2858
- the public site: their URLs 404, and they are in no listing.
2859
-
2860
- Trailing slashes are canonical. A request that arrives without one redirects
2861
- 301 to the URL with it, but only when that URL resolves — a genuinely missing
2862
- address 404s straight away rather than bouncing first. `/page/1/` redirects to
2863
- `/`, because the first page of a listing lives at the listing's own URL.
2864
-
2865
- A document is resolved after every registered route has failed to match, so
2866
- routes a site adds always win over a permalink that happens to collide with
2867
- them. The taxonomy archives are resolved there too, ahead of the documents: a
2868
- route table is fixed when the app is built and the archive bases are a setting,
2869
- so `/{tagBase}/{tag}/` is matched against whatever the site holds at the moment
2870
- of the request.
2871
-
2872
- `tag` and `category` are the bases a site has until it says otherwise —
2873
- WordPress's own, so a site imported from it keeps every archive URL it
2874
- published. `tagBase` and `categoryBase` on the Permalinks settings page move
2875
- them, and
2876
- the routes, the paging, the canonical redirects, the tag feeds, the theme's
2877
- links and the ActivityStreams hashtags all follow on the next request. A base
2878
- is one URL-safe path segment: no slashes, and not a path the site already
2879
- answers on (`page`, `feed`, `comments`, `admin`, `theme`, `uploads`,
2880
- `nodeinfo`, `author`, `inbox`), and
2881
- the two may not be the same word. Both are `tagBase` and `categoryBase` in
2882
- `content/_data/site.json`, so an Eleventy build of the same content directory
2883
- can put its archives at the same URLs.
2884
-
2885
- ### Navigation
2886
-
2887
- A menu is a named thing the site stores and the theme asks for.
2888
-
2889
- **The site stores menus by name.** `content/_data/site.json` holds a `menus`
2890
- object keyed by the name of each menu, each an ordered list of items:
2891
-
2892
- ```json
2893
- {
2894
- "menus": {
2895
- "primary": [{ "label": "About", "url": "/about/" }],
2896
- "footer": [
2897
- { "label": "Colophon", "url": "/colophon/" },
2898
- { "label": "Mastodon", "url": "https://example.social/@me", "rel": "me" }
2899
- ]
2900
- }
2901
- }
2902
- ```
2903
-
2904
- An item is a label, a URL and the `rel` values it carries. The URL is a
2905
- site-root path or an absolute `http(s)` URL; anything else is refused. `rel` is
2906
- one string holding every value — `"me"`, `"nofollow noopener"` — present only
2907
- when there is one, lower case and each value said once, and a theme prints it
2908
- as the whole of the link's `rel` attribute. The values are whatever HTML link
2909
- types the site wants; `me` is the one most menus have, because it is how
2910
- Mastodon and the rest of the IndieWeb verify that the site and the profile it
2911
- links are the same person. An item written `"me": true`, the spelling before
2912
- `rel` held a list, is still read as `"rel": "me"`.
2913
-
2914
- **The theme declares the areas it renders.** `theme.json` carries an `areas`
2915
- list, each entry a `name` — the key under `menus` — and a `label` a person
2916
- reads:
2917
-
2918
- ```json
2919
- { "areas": [{ "name": "primary", "label": "Site menu" }] }
2920
- ```
2921
-
2922
- The packaged theme declares `primary` ("Site menu") and `footer` ("Footer
2923
- links"). A theme that wants a third declares it; an `areas` that is missing or
2924
- unreadable leaves that theme declaring none rather than failing to load it, and
2925
- the Navigation screen then offers the packaged theme's — a site theme is laid
2926
- over the packaged one a file at a time, so a theme that has declared nothing has
2927
- not overridden the declaration, and its inherited `layouts/base.njk` really is
2928
- still rendering `menus.primary` and `menus.footer`.
2929
-
2930
- **A theme renders a menu by name.** Every template gets `menus`, keyed by the
2931
- same names, each item already marked `current` for the path being rendered.
2932
- `{% for item in menus.footer %}` is the whole interface, so a site theme can
2933
- render a menu the packaged theme has never heard of. Every stored menu is on
2934
- the context, not only the declared ones: the declaration is what the screen
2935
- that edits menus reads, and a menu nothing loops over is rendered nowhere and
2936
- kept — which is how somebody writes the menu a theme will use before switching
2937
- to that theme.
2938
-
2939
- **The menus are managed on `/admin/navigation`.** It is a top-level section
2940
- after Pages rather than a settings page: a menu is content a site arranges, the
2941
- way its pages are, and the shape of the screen comes from the active theme.
2942
- The screen is two lists. First the areas that theme declares, in the theme's
2943
- order and under its labels — an area the site has never filled in is an empty
2944
- box, not a missing one. Then every other stored menu, under a heading saying
2945
- this theme renders them nowhere; those are still editable, which is how the
2946
- menu a theme will use is written before the site switches to it, and each has
2947
- the Delete that is the only way a menu is removed. An area the theme declares
2948
- is emptied rather than deleted: the theme goes on asking for the name, and the
2949
- box has to stay for the next thing typed into it.
2950
-
2951
- Each menu is one box of `Label | URL` lines, in the order they are typed, and a
2952
- line may end in the `rel` values the link carries, a word each — `Mastodon |
2953
- https://example.social/@me | me`, `A source | https://example.com/thing |
2954
- nofollow noopener`. The values come off the end of the line only while the last
2955
- bar-separated part reads as a list of them, so a URL holding a bar survives:
2956
- `Odd | /odd/?a=1|2` keeps its query string, because `2` is not a word. A line
2957
- that is not an item is refused by name, with the box still holding every line of
2958
- what was typed, and nothing written. It is the same line the Links box on a
2959
- profile takes.
2960
-
2961
- **A menu name** is lower-case ASCII letters, digits and underscores, starting
2962
- with a letter, at most 32 characters. It is the word a theme writes after the
2963
- dot — `{% for item in menus.footer %}` — so a dash is refused: after a dot it
2964
- is a minus sign, and `menus.top-bar` renders nothing and raises nothing. Lower
2965
- case is the rest of the rule and it is what refuses the confusable spellings:
2966
- `Footer` is refused rather than quietly lowered, because `footer` and `Footer`
2967
- would be two menus on one screen that nothing could tell apart. A name the site
2968
- already holds is refused too, and the message says its box is already on the
2969
- page. The rule governs a name somebody types; a name a `theme.json` declares is
2970
- a menu name by declaration and is shown as the theme spells it, because a
2971
- screen that hid a declared area over a spelling would leave somebody unable to
2972
- fill in a menu their site renders.
2973
-
2974
- There is no second source. A page cannot put itself in a menu, so there is one
2975
- screen to edit it on, one order, and no way for the same link to appear twice.
2976
- A page that should be linked is linked by typing a line for it — including the
2977
- page a site serves as its front page, which is typed `Home | /`, the URL a
2978
- reader lands on, rather than at the permalink that redirects there.
2979
-
2980
- Because the menus live in `site.json`, an Eleventy build renders the same ones;
2981
- the example config assembles them as `collections.menus`. A `menus` in a
2982
- hand-edited `site.json` that is not an object of lists of items yields no menu
2983
- rather than an error, exactly as a bad archive base falls back rather than
2984
- taking the site down.
2985
-
2986
- ### Moved URLs
2987
-
2988
- Changing the slug or the permalink of a published post or page in the editor
2989
- moves it, and the URL it leaves is written into its file:
2990
-
2991
- ```yaml
2992
- permalink: /2026/01/new-name/
2993
- redirect_from:
2994
- - /2026/01/first-name/
2995
- - /2026/01/second-name/
2996
- ```
2997
-
2998
- Every URL in `redirect_from` answers `301 Moved Permanently` with the current
2999
- permalink, and so do its `.md` and `.json` spellings. Each entry points at the
3000
- document itself, not at the next rename, so no chain of redirects builds up.
3001
- The list is in the file, so deleting `data/geekity.db` keeps it working.
3002
-
3003
- A URL in the list only redirects while nothing else lives there: a new document
3004
- given that URL takes it over. Moving a document back to a URL in its list takes
3005
- that URL off the list. A draft that was never published and has no comments
3006
- moves without leaving anything behind; one with comments was read at its URL,
3007
- so it keeps a `redirect_from` like a published one. Correcting a published
3008
- post's date files it under the new day but keeps its URL.
3009
-
3010
- A moved document keeps its conversation. Its comments and stored webmentions
3011
- move to its new slug, a reply post answering any URL in its `redirect_from`
3012
- stays in its thread, and its feeds keep their guid: a post keeps its object id
3013
- as its `activitypub.id`, and a page keeps the URL it left as its `guid`, so the
3014
- `/replies/` feed a reader subscribed to keeps answering. The record of the
3015
- webmentions a post has sent moves with it too. A move made by hand is followed
3016
- the same way when the file names the old URL in `redirect_from`; a permalink
3017
- changed by hand with no `redirect_from` leaves the old URL answering 404 and its
3018
- comments where they were, until the file names it.
3019
-
3020
- ### Declared redirects
3021
-
3022
- A site that moves to this CMS, or reorganises, lists the old URLs that no
3023
- document owns in `content/_data/redirects.json`. The file is a JSON list:
3024
-
3025
- ```json
3026
- [
3027
- { "from": "/?p=123", "to": "/2026/01/hello/" },
3028
- { "from": "/old-section/", "to": "/new-section/", "status": 301 },
3029
- { "from": "/sale/", "to": "/shop/", "status": 302 },
3030
- { "from": "/forum/", "to": "https://forum.example.com/", "status": 308 }
3031
- ]
3032
- ```
3033
-
3034
- - `from` is a path on this site. It starts with `/` and can carry a query
3035
- string.
3036
- - `to` is a path on this site, or an absolute `http` or `https` URL.
3037
- - `status` is `301` or `308` for a permanent redirect, and `302` or `307` for a
3038
- temporary one. It defaults to `301`.
3039
-
3040
- A `from` without a query string matches that path with any query, or none. The
3041
- request's query goes on to a `to` that has no query of its own, so
3042
- `/old-section/?page=2` lands on `/new-section/?page=2`. The path must match
3043
- exactly, except that a `from` ending in `/` also answers the same path without
3044
- the slash, in one hop.
3045
-
3046
- A `from` with a query string matches only that query. The order of the
3047
- parameters does not matter, so `/archives/?cat=4&paged=2` also matches
3048
- `/archives/?paged=2&cat=4`. Nothing else in the request's query may differ:
3049
- `/?p=123` does not match `/?p=123&replytocom=9`. This is how old WordPress
3050
- `?p=` links reach their posts.
3051
-
3052
- A declared redirect never hides something real. A path-only `from` is answered
3053
- only after every document, archive, feed and moved URL has had its turn, and
3054
- just before the 404 page, so a document given that URL later takes it over. A
3055
- `from` with a query string is answered first, because no document is addressed
3056
- by its query. The admin, the federation endpoints and the health check answer
3057
- before any declared redirect.
3058
-
3059
- The file is read on the next request after it changes, with no restart. An
3060
- entry that cannot be served is logged as a warning at boot, and again on the
3061
- first request after the file changes, and it is skipped. The following entries are skipped:
3062
-
3063
- - An entry with a missing or malformed `from`, `to` or `status`.
3064
- - A second entry with the same `from`. The first entry wins.
3065
- - Every entry that leads into a loop, such as `/a/` to `/b/` and `/b/` back to
3066
- `/a/`.
3067
-
3068
- A file that is not a JSON list serves no redirects and is reported the same
3069
- way.
3070
-
3071
- #### More than one file
3072
-
3073
- Every `*.json` file in `content/_data/redirects/` is read after
3074
- `redirects.json`, in name order, and served the same way. A tool that generates
3075
- redirects, such as an import, writes a file of its own there and never touches
3076
- the list a person keeps. A file there, or `redirects.json` itself, may also be
3077
- an object that maps each `from` to its `to`, every one a `301`:
3078
-
3079
- ```json
3080
- {
3081
- "/?p=123": "/2026/01/hello/",
3082
- "/old-section/": "/new-section/"
3083
- }
3084
- ```
3085
-
3086
- Where two files declare the same `from`, the one read first wins, so
3087
- `redirects.json` wins over every file in the folder, and the other declaration
3088
- is reported. A loop across files is found and skipped as within one.
3089
-
3090
- Every redirect the CMS sends carries `X-Redirect-By: Geekity CMS`. This
3091
- includes the declared redirects, moved URLs, trailing slashes, feed spellings
3092
- and the admin's redirects. Someone who traces a chain of redirects through a
3093
- proxy can see which layer sent each one.
3094
-
3095
- ## Content negotiation
3096
-
3097
- Every public URL is one resource with more than one body. A post or a page has
3098
- three; a listing has two.
3099
-
3100
- | Media type | Extension | Body |
3101
- | ------------------ | --------- | -------------------------------------------------------- |
3102
- | `text/html` | — | The document through the theme. The default. |
3103
- | `text/markdown` | `.md` | The file exactly as it is stored, front matter included. |
3104
- | `application/json` | `.json` | The document as data, see below. |
3105
-
3106
- Listings — `/`, `/page/N/`, `/tag/{tag}/` and `/tag/{tag}/page/N/` — offer
3107
- HTML and JSON only. There is no Markdown file behind a listing to serve.
3108
-
3109
- The representation is chosen like this:
3110
-
3111
- 1. A `.md` or `.json` suffix on the path wins outright and `Accept` is ignored.
3112
- Both spellings work: `/2026/09/hello/index.md` (what the `Link` header
3113
- advertises) and the shorter `/2026/09/hello.md`.
3114
- 2. Otherwise `Accept` is matched with q-values. The highest q wins; a tie goes
3115
- to the more specific range; what is still tied goes to HTML.
3116
- 3. No `Accept` header, an empty one, or `*/*` means HTML, so a browser and a
3117
- `curl` with no arguments both get a page.
3118
- 4. A request that accepts none of the representations gets `406` with a JSON
3119
- body listing what it could have asked for.
3120
-
3121
- The feeds are fixed routes and are not negotiated: feed readers do not send
3122
- useful `Accept` headers. See [Feeds](#feeds).
3123
-
3124
- ```sh
3125
- curl -H 'Accept: text/markdown' https://example.com/2026/09/hello/
3126
- curl https://example.com/2026/09/hello/index.json
3127
- curl 'https://example.com/index.json?full=1'
3128
- ```
3129
-
3130
- ### The JSON shape
3131
-
3132
- ```json
3133
- {
3134
- "schema": 1,
3135
- "url": "https://example.com/2026/09/hello/",
3136
- "frontMatter": {
3137
- "title": "Hello, World!",
3138
- "date": "2026-09-02T09:00:00Z",
3139
- "permalink": "/2026/09/hello/",
3140
- "tags": ["introductions"]
3141
- },
3142
- "markdown": "A *file-first* CMS.",
3143
- "html": "<p>A <em>file-first</em> CMS.</p>\n"
3144
- }
3145
- ```
3146
-
3147
- `schema` is the version of this shape; keys are only added within a version.
3148
- `url` is absolute, built on the configured `baseUrl`. `frontMatter` is the
3149
- document's front matter exactly as the `.md` representation carries it — the
3150
- same block the writer emits, unknown keys included — so the two cannot drift.
3151
- `markdown` is the body without the front matter, and `html` is that body
3152
- rendered.
3153
-
3154
- A listing's JSON is a plain array of the same objects with `markdown` and
3155
- `html` left out, because an archive page should not carry every post in full.
3156
- `?full=1` puts them back.
3157
-
3158
- ```json
3159
- [
3160
- {
3161
- "schema": 1,
3162
- "url": "https://example.com/one/",
3163
- "frontMatter": { "title": "One", "…": "…" }
3164
- },
3165
- {
3166
- "schema": 1,
3167
- "url": "https://example.com/two/",
3168
- "frontMatter": { "title": "Two", "…": "…" }
3169
- }
3170
- ]
3171
- ```
3172
-
3173
- A 406 body names the alternates:
3174
-
3175
- ```json
3176
- {
3177
- "error": "not_acceptable",
3178
- "message": "This URL is not available in any of the media types you accept.",
3179
- "alternates": [
3180
- { "type": "text/html", "url": "/2026/09/hello/" },
3181
- { "type": "text/markdown", "url": "/2026/09/hello/index.md" },
3182
- { "type": "application/json", "url": "/2026/09/hello/index.json" }
3183
- ]
3184
- }
3185
- ```
3186
-
3187
- ### Headers
3188
-
3189
- Every negotiated response carries `Vary: Accept` and a `Link` header pointing
3190
- at the representations it is not:
3191
-
3192
- ```
3193
- Link: </2026/09/hello/index.md>; rel="alternate"; type="text/markdown",
3194
- </2026/09/hello/index.json>; rel="alternate"; type="application/json"
3195
- ```
3196
-
3197
- It also carries `ETag` — derived from the document's content hash and the
3198
- representation, so a client holding the JSON is never told its Markdown is
3199
- unchanged — plus `Last-Modified` from the document's `updated`, falling back to
3200
- its `date`, and `Cache-Control: no-cache` so a client revalidates rather than
3201
- guessing how long the page stays fresh. `If-None-Match` and `If-Modified-Since`
3202
- are honoured and answered with `304`.
3203
-
3204
- One exception: while `watch` is on, the HTML representation is served without
3205
- validators. Only the document is hashed, and a template edit changes the page
3206
- without changing the document, so a development server would otherwise answer
3207
- `304` with a page that had already moved on. The `.md` and `.json`
3208
- representations are validated either way.
3209
-
3210
- Every representation but the HTML carries `X-Robots-Tag: noindex`, whether it
3211
- was asked for by extension or by `Accept`. They say what the page says, and a
3212
- search engine that indexed them would list one page two or three times.
3213
-
3214
- Adding a representation — an ActivityStreams object, say — means adding it to
3215
- `Representation` in `src/web/negotiate.ts` with its media type and, if it wants
3216
- one, its extension. The selection, the `Link` alternates, the `ETag` and the
3217
- 406 body all follow from those two tables. ActivityPub types are claimed by the
3218
- federation middleware before the negotiator sees them.
3219
-
3220
- ## Feeds
3221
-
3222
- The recent posts are syndicated in three formats, at the URLs WordPress uses,
3223
- so a site moving off it keeps every subscriber it had:
3224
-
3225
- | URL | Format |
3226
- | ------------------------ | ------------------------------------------------ |
3227
- | `/feed/` | RSS 2.0 (`application/rss+xml`) |
3228
- | `/feed/atom/` | Atom 1.0 (`application/atom+xml`) |
3229
- | `/feed/json/` | JSON Feed 1.1 (`application/feed+json`) |
3230
- | `/tag/{tag}/feed/` | One tag archive, in RSS; `atom/` and `json/` too |
3231
- | `/category/{name}/feed/` | One category archive, the same three |
3232
- | `/comments/feed/` | Every reply the site has been sent, in RSS |
3233
- | `{permalink}feed/` | One post's replies, in RSS |
3234
- | `/replies/{id or key}/` | One item's direct replies, in RSS |
3235
-
3236
- `/feed/` is RSS because that is the format nearly every existing subscriber
3237
- holds. The two archive bases are settings (`tagBase`, `categoryBase`), so the
3238
- per-archive feeds follow wherever the archives live.
3239
-
3240
- WordPress's older spellings redirect `301` rather than 404: `/feed/rss/` to
3241
- `/feed/`, and the query forms `?feed=rss2`, `?feed=rss`, `?feed=atom` and
3242
- `?feed=json` on any listing to that listing's feed, and `?feed=rss2` or
3243
- `?feed=rss` on a post's permalink to that post's comments feed. A feed URL that
3244
- arrives without its trailing slash redirects to the canonical one in a single
3245
- hop.
3246
-
3247
- A feed holds the newest published posts, newest first. Drafts, documents in the
3248
- trash and pages are never in one, and a term nothing published carries 404s
3249
- rather than serving an empty feed. `feedSize` in `content/_data/site.json` sets
3250
- how many entries a feed holds; without it a feed holds 20, which is deliberately
3251
- more than an archive page, so a reader that polls once a day does not miss a
3252
- post on a site that publishes several.
3253
-
3254
- ```json
3255
- { "title": "My Site", "tagline": "Notes", "author": "me", "feedSize": 20 }
3256
- ```
3257
-
3258
- Every RSS feed, the comments feeds included, tells a reader how often to poll
3259
- it with the Syndication module's `sy:updatePeriod` and `sy:updateFrequency`.
3260
- Settings → Reading sets both, as `feedUpdatePeriod` (`hourly`, `daily`,
3261
- `weekly`, `monthly` or `yearly`) and `feedUpdateFrequency` (a whole number, 1 or
3262
- more) in `site.json`. The default is `hourly` and `1`, which is what WordPress
3263
- declares, so the subscribers of a migrated site keep polling as they did. A
3264
- reader may back off from a feed that stays quiet much longer than it says, so
3265
- a site that posts weekly does better to say `weekly`. Atom and JSON Feed have no
3266
- element for it.
3267
-
3268
- ### What every format says about a post
3269
-
3270
- The three formats render one **feed item**, derived once per post, rather than
3271
- each reading the file for itself. So they agree about the three things a reader
3272
- actually keys on.
3273
-
3274
- **Identity.** A post is named by the first of three things it has: the `guid`
3275
- its front matter sets, when that parses as a URL; else the `activitypub.id`
3276
- the front matter stores for a post migrated from elsewhere; else its permalink
3277
- (see [Federation](#federation)). That name is RSS's `guid`, Atom's `id` and
3278
- JSON Feed's `id`. The permalink is always the link: RSS's `link`, Atom's
3279
- `link rel="alternate"` and JSON Feed's `url`. The two are the same URL for
3280
- every post born on this CMS, and differ only for one carrying a stored id or a
3281
- `guid` — which is what lets a migrated post keep the name its WordPress
3282
- subscribers already hold. RSS's `isPermaLink` says which of the two a `guid`
3283
- is: `true` only when it equals the permalink, `false` for anything else, such
3284
- as a stored `?p=813`.
3285
-
3286
- **Terms.** Every format lists the post's categories and then its tags, in file
3287
- order, as one flat list: RSS's `category` elements, Atom's `category term`
3288
- attributes, JSON Feed's `tags`. None of the three can say which vocabulary a
3289
- term came from, which is how WordPress publishes both too.
3290
-
3291
- **Summary.** Every format summarises a post the same way: the `description`
3292
- front matter when the post has one — it is what the author wrote for exactly
3293
- this — and otherwise the first paragraph of the rendered body, stripped to
3294
- plain text and cut at 55 words, WordPress's own excerpt length. It is RSS's
3295
- `description`, Atom's `summary` and JSON Feed's `summary`, and it is left out
3296
- only when there is nothing to summarise at all. The whole post goes in the
3297
- content element beside it, so a reader that shows both has something to choose
3298
- between.
3299
-
3300
- **Replies.** A post whose `in-reply-to` front matter is an absolute `http` or
3301
- `https` URL is a reply, and Atom and JSON Feed name the URL it answers. An Atom
3302
- entry carries RFC 4685's `<thr:in-reply-to ref="…" href="…"/>`, with the target
3303
- URL as both its identity and its location, and every Atom feed declares
3304
- `xmlns:thr="http://purl.org/syndication/thread/1.0"`. JSON Feed 1.1 has no reply
3305
- field, so a JSON Feed item carries the target in an extension object, which the
3306
- spec allows under any key that starts with an underscore and tells readers that
3307
- do not know it to ignore:
3308
-
3309
- ```json
3310
- {
3311
- "id": "…",
3312
- "url": "…",
3313
- "content_html": "…",
3314
- "_geekity": { "in_reply_to": "https://example.net/notes/1" }
3315
- }
3316
- ```
3317
-
3318
- The key is `_geekity` because an extension is named after its publisher, and
3319
- `in_reply_to` spells the microformats `in-reply-to` property the JSON way. A
3320
- post that is not a reply, or whose `in-reply-to` is not such a URL, carries
3321
- neither. RSS 2.0 has no equivalent element, so an RSS item carries the target
3322
- as the [source namespace][source-ns]'s `<source:inReplyTo>`.
3323
-
3324
- **Languages.** Every feed declares the site's `language` once: RSS as the
3325
- channel's `language`, Atom as the feed's `xml:lang` and JSON Feed as its
3326
- `language`. A post whose `lang` front matter names another language says so on
3327
- its own item: RSS as `dc:language`, Atom as `xml:lang` on the `entry` and JSON
3328
- Feed as the item's `language`. A post in the site's language, or whose `lang`
3329
- is not a language tag, adds nothing.
3330
-
3331
- **Photos.** A post's `photo` front matter prints in every format's content,
3332
- before the body, which is where the page prints it too. Each photo is a plain
3333
- `<figure><img src alt></figure>` of the original upload, never the responsive
3334
- variants, which a reader cannot resolve (decision-10). Its `src` is absolute on
3335
- the site's base URL. Its `alt` is the post's own text, else the media library's,
3336
- else empty. So a photo-only post reads in a feed reader as its photos. JSON Feed
3337
- also names the item's main image as `image`: the post's `image` front matter,
3338
- else its first photo. A photo is never an RSS `enclosure`, an Atom
3339
- `rel="enclosure"` link or a JSON Feed attachment. Those carry a recording only.
3340
-
3341
- ### RSS 2.0
3342
-
3343
- The channel carries `title`, `link`, `description` (the tagline), `language`
3344
- (the `language` setting, `en` by default), `lastBuildDate`, `generator`, an
3345
- `atom:link rel="self"`, the three notify-server elements described under
3346
- [Real-time notification](#real-time-notification), and an `image` built from the
3347
- avatar when the site has one.
3348
-
3349
- An item carries `title`, `link`, `guid`, `pubDate` in RFC 822, `dc:creator` from
3350
- the post's author or the site's, one `category` per term, `description` holding
3351
- the summary, `content:encoded` holding the whole rendered post, and
3352
- `source:markdown` holding the Markdown the post was written from. A reply also
3353
- carries `source:inReplyTo` holding the URL it answers. The `guid`, the terms
3354
- and the summary are the ones described above.
3355
-
3356
- `source:markdown` is Dave Winer's [source namespace][source-ns]: a reader that
3357
- understands Markdown should render from it rather than from `content:encoded`.
3358
- It is the same text the ActivityStreams `Article` carries as its `source`.
3359
-
3360
- Every item also says where its comments are: `comments` is the page to read
3361
- them on and `wfw:commentRss` (the [Well-Formed Web][wfw-ns] comment API) is the
3362
- feed of the whole thread to poll. A post with replies also carries
3363
- `source:comments`, whose `count` is how many replies answer the post directly
3364
- and whose `feedUrl` is its `/replies/{key}/` feed holding only those (see
3365
- [Walking a thread](#walking-a-thread)). A post nobody answered carries no
3366
- `source:comments`.
3367
-
3368
- When the site has a license (Settings > General, or `license` in `site.json`),
3369
- the channel carries a `creativeCommons:license` holding its URL, and every item
3370
- carries one for its own license: the post's `license` front matter, else the
3371
- site's. A post whose front matter says `license: none` carries none. The
3372
- element is the [Creative Commons RSS module][cc-rss], declared as
3373
- `xmlns:creativeCommons` only when some license is printed. It names a license
3374
- by URL, Creative Commons or not, which a reader can act on; `dc:rights` would be
3375
- free text. Nothing about a license is printed on a site and posts without one.
3376
- RSS has no element that says "no license", so a post saying `license: none`
3377
- on a licensed site carries no item license while the channel still names the
3378
- site's; a reader that applies the channel's license to every item reads that
3379
- post as under it.
3380
-
3381
- [source-ns]: https://source.scripting.com/
3382
- [wfw-ns]: http://wellformedweb.org/CommentAPI/
3383
- [cc-rss]: https://cyber.harvard.edu/rss/creativeCommonsRssModule.html
3384
-
3385
- ### Comments
3386
-
3387
- The comments feeds publish a document's conversation, at the URLs WordPress
3388
- publishes its own at: `{permalink}feed/` for one post or page, and
3389
- `/comments/feed/` for the whole site. Both are read from the same conversation
3390
- the page draws under the post, so they carry what a reader sees there:
3391
-
3392
- - native comments left through the form, once a moderator approves them;
3393
- - webmention replies and mentions;
3394
- - fediverse replies, a `Create` of a `Note` whose `inReplyTo` names the post
3395
- or a reply under it (see [Federation](#federation)), and the quotes of a
3396
- post its author approved;
3397
- - reply posts written on this site that answer the post or a reply under it.
3398
-
3399
- Likes, boosts and reposts are counted on the page and are in no feed.
3400
-
3401
- Both are RSS 2.0 and nothing else: a comments feed has no Atom or JSON spelling
3402
- here, so `{permalink}feed/atom/` 404s. A post has a comments feed whether or
3403
- not it still takes comments. A page has one while it takes comments
3404
- (`comments: true` in its front matter, and comments on for the site), which is
3405
- when it shows its conversation; any other page's `{permalink}feed/` 404s, and
3406
- its comments are left out of `/comments/feed/` too. A document with no replies
3407
- answers an empty feed rather than a 404, because it exists and a reader that
3408
- subscribed before anybody answered should keep polling. A permalink that is no
3409
- published document 404s like any other. The page advertises its own feed with
3410
- a `<link rel="alternate" type="application/rss+xml">` in its `<head>`.
3411
-
3412
- A channel carries the usual `title` (`Comments on: {post}`), `link`,
3413
- `description`, `language`, `lastBuildDate`, `generator`, `atom:link rel="self"`
3414
- and the same notify-server elements every other feed carries. An item carries the author's name as its `title` and `dc:creator`,
3415
- the reply's `url` as its `link` (a comment left through the form links to its
3416
- own page at `/comment/{id}/`), a `guid`, the `published` time the reply gave (or
3417
- when it arrived, if it gave none), `description` holding a plain-text excerpt
3418
- and `content:encoded` holding the reply. The `guid` of a comment left through the
3419
- form is its page, `isPermaLink="true"`; any other reply's is its own id: a
3420
- fediverse note's id, a webmention's stored id, a reply post's object id. A
3421
- comment imported from WordPress keeps the `guid` WordPress's comments feed
3422
- published, so a reader sees nothing new after the move.
3423
- `isPermaLink` is `true` exactly when the `guid` is the `link`.
3424
-
3425
- Each item also carries, from the [source namespace][source-ns]:
3426
-
3427
- - `source:inReplyTo`, the `guid` of what it answers in these feeds: the post's
3428
- feed `guid` for an answer to the post, else the `guid` of the reply it
3429
- answers. A reply under a comment a reader may not see (one waiting for a
3430
- moderator, spam or deleted) names the nearest reply above it a reader can
3431
- see, or the post, and is counted as that one's reply.
3432
- - `source:comments`, only on an item with replies: `count` is how many answer
3433
- it directly and `feedUrl` its `/replies/` feed.
3434
- - `<source url="…">Name</source>`, the core RSS element, when the site knows a
3435
- feed for the author: a webmention author's `h-card` URL or site, or a site
3436
- user's author feed `/author/{username}/feed/`. A comment left through the
3437
- form and a fediverse reply carry none.
3438
-
3439
- On the site-wide feed the title names the post or page as well: `{author} on
3440
- {post}`, or `{author} replying to {parent author} on {post}` for an answer to
3441
- another reply a reader can see.
3442
-
3443
- A fediverse reply's author is named by the profile the site stored when that
3444
- actor followed it, and otherwise the `@user@host` the actor's own URL implies. Naming them
3445
- properly would mean dereferencing the actor, which is a network round trip per
3446
- comment shown.
3447
-
3448
- **A reply's HTML is sanitised before it is published.** It is markup a
3449
- stranger wrote, so it is tokenised and rebuilt from an allowlist rather than
3450
- passed through: `p`, `br`, `a`, `em`, `strong`, `del`, `code`, `pre`,
3451
- `blockquote` and the list elements survive; `script` and `style` are dropped
3452
- along with their contents; every other element is unwrapped, keeping its text;
3453
- every attribute goes except an `a`'s `href`, which must be `http`, `https` or
3454
- `mailto` and is marked `rel="nofollow noopener noreferrer"`. `sanitizeCommentHtml`
3455
- is exported for a site that shows comments in its own templates.
3456
-
3457
- A reply to a document that has since been unpublished or moved to the trash
3458
- disappears from `/comments/feed/`, and that document's own feed 404s with it.
3459
- `feedSize` caps both feeds.
3460
-
3461
- ### Walking a thread
3462
-
3463
- `{permalink}feed/` is the whole thread, flat, which is what WordPress
3464
- publishes and `wfw:commentRss` points at. A program that wants the tree
3465
- (Dave Winer's [rss.chat][rss-chat] reads conversations this way) walks it one
3466
- level at a time instead: from a post item's `source:comments` to a feed of the
3467
- replies that answer the post directly, and from each of those items' own
3468
- `source:comments` to the replies that answer it, until an item has none.
3469
- Every reply a reader can see is reached once.
3470
-
3471
- Each of those feeds is at `/replies/{segment}/`, RSS 2.0, newest first, its
3472
- items in the same shape as the comments feeds:
3473
-
3474
- - `/replies/{id}/` for a comment left through the form, by the same id as its
3475
- page at `/comment/{id}/`, percent-encoded.
3476
- - `/replies/{key}/` for everything else: a post or a page, a webmention, a
3477
- fediverse reply or a reply post. A document has no id of its own and slugs
3478
- are not unique across posts and pages, so the key is the first 16 hex digits
3479
- of the SHA-256 of the item's feed `guid`.
3480
-
3481
- A segment of exactly 16 lowercase hex digits is a key, and anything else is a
3482
- comment id. A comment whose id happens to look like a key is named by its key
3483
- instead, so no segment can name two items. An item nobody answered answers an
3484
- empty feed; a key or an id that names nothing a reader can see 404s.
3485
-
3486
- [rss-chat]: https://github.com/scripting/rss.chat
3487
-
3488
- ### Atom and JSON Feed
3489
-
3490
- An Atom entry carries `id` (the post's object id), `title`, `updated`,
3491
- `published`, `link rel="alternate"` (the permalink), an `author` when the front
3492
- matter names one, a `category` per term, a `summary`, and the whole rendered
3493
- post as `content type="html"`. The feed itself carries `id`, `title`,
3494
- `subtitle` from the site's tagline, `updated`, `link rel="self"`,
3495
- `link rel="alternate"` to the HTML page, a `generator`, an
3496
- `xml:lang` from the `language` setting, and — when the site names a notify
3497
- server — a `source:cloud` and a `link rel="hub"`.
3498
-
3499
- With a license, the feed carries an [RFC 4946][rfc4946] `link rel="license"`
3500
- to the site's license, its name as the `title`, and every entry carries one for
3501
- its own license, the post's `license` front matter else the site's. An entry
3502
- states its own because RFC 4946 does not let an entry inherit the feed's. JSON
3503
- Feed has no field for a license and says nothing about one.
3504
-
3505
- [rfc4946]: https://www.rfc-editor.org/rfc/rfc4946
3506
-
3507
- A JSON Feed item carries `id` (the object id), `url` (the permalink), `title`,
3508
- `content_html`, `image` (see Photos above), `summary`, `date_published`, `date_modified`, `tags` (the
3509
- terms) and `authors`; the feed carries `version`, `title`, `home_page_url`,
3510
- `feed_url`, `description`, `authors` and `hubs`. Keys with nothing behind them
3511
- are left out rather than sent empty.
3512
-
3513
- The XML is written by this package rather than by a library. Text is escaped;
3514
- `content:encoded` and `source:markdown` are CDATA sections, with any `]]>` in
3515
- the text split across two sections so it cannot end one early.
3516
-
3517
- ### A recording on a post
3518
-
3519
- A post can carry one recording: a spoken version of an article, or an episode
3520
- now and then. The site is a blog that sometimes has audio, not a podcast host,
3521
- so the feeds carry what a podcast app needs to play, describe and caption an
3522
- item, and leave the podcast directory tags out. The front matter spells it the
3523
- way RSS does:
3524
-
3525
- ```yaml
3526
- enclosure:
3527
- url: /uploads/2026/10/episode-12.mp3 # an upload; written by the editor
3528
- type: audio/mpeg # from the upload table; written by the editor
3529
- length: 23456789 # bytes, from the file on disk; written by the editor
3530
- duration: 1834 # seconds, optional
3531
- transcript: # optional
3532
- url: /uploads/2026/10/episode-12.vtt # an upload or an http(s) URL
3533
- type: text/vtt # text/vtt, application/x-subrip, text/html or text/plain
3534
- alternates: # optional, any number
3535
- - url: /uploads/2026/10/episode-12.mp4
3536
- type: video/mp4
3537
- length: 98765432
3538
- title: Video # optional, at most 32 characters
3539
- height: 720 # optional, for video
3540
- lang: en # optional
3541
- - url: https://cdn.example.com/episode-12-low.mp3
3542
- type: audio/mpeg # required for a link
3543
- title: Low bandwidth # a link's length is optional
3544
- ```
3545
-
3546
- **The main file is always an upload** under `/uploads/`, because RSS requires
3547
- its `url`, `length` and `type`, and an upload is the only file whose length the
3548
- site knows. A main file without all three is no recording at all. A bad
3549
- alternate version or transcript is dropped on its own, so one wrong line in a
3550
- hand-edited file costs that line and not the episode. `enclosureOf(extra)` is
3551
- the one reading of the key, and the feeds, the theme and the federation read
3552
- only what it returns.
3553
-
3554
- **The post editor** has a Recording section on posts. The main file is a select
3555
- of the audio and video in the media library, and None removes the whole key,
3556
- transcript and alternate versions included. The duration takes seconds, `30:34`
3557
- or `1:02:03`. The transcript is an upload (`.vtt`, `.srt` or `.txt`) or an
3558
- `https` address with its type. Alternate versions are numbered rows with one
3559
- blank row at the end, so a plain form can add, edit and remove them; emptying a
3560
- row's address removes it. On save, the type and length of every upload are read
3561
- from the upload table and from the file on disk, never from the form. A main
3562
- file that is not an audio or video upload, a linked version without an `http`
3563
- or `https` address or a media type, or a title over 32 characters is refused
3564
- with a message, and nothing is written.
3565
-
3566
- **What the feeds print, and when.** A post with no recording prints none of
3567
- this, and a feed with no recording in it declares neither namespace, so its
3568
- bytes and its ETag are what they were before recordings existed.
3569
-
3570
- | Element | Format | Printed when |
3571
- | ----------------------------------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
3572
- | `<enclosure url length type/>` | RSS item | The post has a main file. `url` is absolute. |
3573
- | `<podcast:alternateEnclosure>` with `<podcast:source uri/>` | RSS item | Once per alternate version, with `type`, and `length`, `title`, `height` and `lang` when known. |
3574
- | `<podcast:transcript url type/>` | RSS item | The post has a transcript, with `rel="captions"` for WebVTT or SubRip. |
3575
- | `<itunes:duration>` | RSS item | The duration is known, in whole seconds. |
3576
- | `<podcast:medium>blog</podcast:medium>` | RSS channel | Any item in the feed has a recording. Without it an app assumes `podcast`; `blog` is the spec's word for articles that sometimes have audio. |
3577
- | `<link rel="enclosure" type length href/>` | Atom entry | The post has a main file. Atom has no way to say the others are versions of it. |
3578
- | `attachments` | JSON Feed | The main file and then each alternate version, with `url`, `mime_type`, `size_in_bytes` and `title` when known, and `duration_in_seconds` on audio or video when the duration is known. |
3579
-
3580
- `xmlns:podcast="https://podcastindex.org/namespace/1.0"` is declared only on a
3581
- feed that prints a podcast element, and
3582
- `xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"` only on one that
3583
- prints `itunes:duration`.
3584
-
3585
- **Left out on purpose:** the Apple directory tags (`itunes:image`,
3586
- `itunes:category`, `itunes:explicit`, `itunes:owner`), and `podcast:guid`,
3587
- `podcast:locked`, `podcast:person`, `podcast:season`, `podcast:episode`,
3588
- `podcast:value` and `podcast:chapters`. Each one matters only to a podcast
3589
- directory or a dedicated show. Adding them is a follow-up for a site that wants
3590
- to be listed in Apple Podcasts, and so is `podcast:license`, which could reuse
3591
- the [content license](#rss-20).
3592
-
3593
- **The fediverse** gets the main file as an `Audio` or `Video` attachment with
3594
- its absolute `url`, its `mediaType`, and the post's title, or a note's first
3595
- words, as its `name`. It goes before the post's `Image` attachments, because
3596
- Mastodon shows a player or a gallery by the type of the first attachment and
3597
- keeps only four. Alternate versions and linked files stay out: a remote server
3598
- fetches and re-encodes what it is given, and a link to another host is a type
3599
- this site cannot check. `playsAsVideo(type)` decides `Video` against `Audio`,
3600
- and the default theme's `<video>` against `<audio>`, so the two never disagree.
3601
-
3602
- The default theme plays the main file on the post page; see its README.
3603
-
3604
- ### Real-time notification
3605
-
3606
- A feed reader that polls hears about a post when it next polls. An [rssCloud][]
3607
- or [WebSub][] server turns that around: the reader registers with the server,
3608
- the site tells the server when a feed changes, and the server tells every
3609
- subscriber at once.
3610
-
3611
- One setting, `notifyServer`, is the whole of it. It defaults to
3612
- `https://rpc.rsscloud.io`, which speaks both protocols, and everything else is
3613
- derived from it: `{notifyServer}/pleaseNotify` is where a subscriber registers,
3614
- `{notifyServer}/websub` is the hub, and `{notifyServer}/ping` is where this site
3615
- says a feed changed. Emptying the setting removes every element, every header
3616
- and every ping; a different URL moves all of them.
3617
-
3618
- **Advertising.** An RSS channel carries all three spellings, so a reader of any
3619
- age finds one it understands:
3620
-
3621
- ```xml
3622
- <cloud domain="rpc.rsscloud.io" port="80" path="/pleaseNotify"
3623
- registerProcedure="" protocol="http-post"/>
3624
- <source:cloud>https://rpc.rsscloud.io/pleaseNotify</source:cloud>
3625
- <atom:link rel="hub" href="https://rpc.rsscloud.io/websub"/>
3626
- ```
3627
-
3628
- The port and protocol of the legacy `<cloud>` are `80` and `http-post` whatever
3629
- scheme the server is actually reached over, because that is what
3630
- [rpc.rsscloud.io's quick start][quick-start] prescribes and what a 2001
3631
- aggregator expects to read. An Atom feed carries the `source:cloud` and the
3632
- `rel="hub"` link but no `<cloud>`, which is an RSS 2.0 element with no namespace.
3633
- A JSON Feed carries JSON Feed 1.1's `hubs`:
3634
-
3635
- ```json
3636
- { "hubs": [{ "type": "WebSub", "url": "https://rpc.rsscloud.io/websub" }] }
3637
- ```
3638
-
3639
- And every feed response in every format — the site's, each archive's and both
3640
- comments feeds — carries WebSub's discovery header, on the `304` as well as on
3641
- the body, because a poller mostly gets the `304`:
3642
-
3643
- ```
3644
- Link: <https://rpc.rsscloud.io/websub>; rel="hub", <https://example.com/feed/>; rel="self"
3645
- ```
3646
-
3647
- **Pinging.** The server notifies nobody until it hears the feed changed. So when
3648
- a post is published, edited or withdrawn — the same index changes that drive
3649
- ActivityPub delivery, and never a full scan — the site posts
3650
- `url={feed}` to `{notifyServer}/ping`, once for each feed whose contents moved:
3651
- the three site feeds, and the three feeds of every tag and category the post
3652
- carried before _and_ after the change, because a post that left a tag changed
3653
- that tag's feed as much as the one it joined.
3654
-
3655
- Pings are best effort. They run one after another on a queue of their own, a
3656
- repeated URL is sent once, each is given ten seconds, and a refusal is logged
3657
- rather than thrown: a notify server that is down costs a line in the log, never
3658
- a save. A site can ping a feed of its own:
3659
-
3660
- ```js
3661
- await cms.notifyFeeds(['https://example.com/podcast/feed/']);
3662
- ```
3663
-
3664
- `cms.notifier` is the service behind it — `notify(urls)`, `feedsFor(change)`,
3665
- `handle(change)` and `settled()` — already subscribed to the index.
3666
-
3667
- [rsscloud]: https://rpc.rsscloud.io/docs
3668
- [websub]: https://www.w3.org/TR/websub/
3669
- [quick-start]: https://rpc.rsscloud.io/docs/quick-start
3670
-
3671
- ### Caching and discovery
3672
-
3673
- Every feed carries `ETag`, `Last-Modified` and `Cache-Control: no-cache`, and
3674
- answers `If-None-Match` and `If-Modified-Since` with `304`. The validator covers
3675
- the feed's metadata as well as its entries, and each format and each scope gets
3676
- its own, so a reader holding the RSS feed is never told the Atom one is
3677
- unchanged. Feeds are validated even while `watch` is on, because a feed is not
3678
- rendered through the theme.
3679
-
3680
- The validator also covers the revision of the item format itself, so a release
3681
- that changes what a feed says about a post moves every post feed's `ETag` once
3682
- rather than leaving a polling reader with a `304` that hides the new bytes.
3683
-
3684
- ```sh
3685
- curl -i https://example.com/feed/
3686
- curl -i https://example.com/tag/releases/feed/json/
3687
- ```
3688
-
3689
- Every page of the default theme advertises all three feeds in its `<head>`, RSS
3690
- first, then the site's comments feed; an archive advertises that archive's three
3691
- as well, and a post its own comments feed (the renderer puts that URL in
3692
- `commentsFeed`, so a layout that overrides `post.njk` keeps it). A theme that
3693
- does not extend `layouts/base.njk` should emit them itself:
3694
-
3695
- ```html
3696
- <link
3697
- rel="alternate"
3698
- type="application/rss+xml"
3699
- title="My Site"
3700
- href="/feed/"
3701
- />
3702
- <link
3703
- rel="alternate"
3704
- type="application/atom+xml"
3705
- title="My Site"
3706
- href="/feed/atom/"
3707
- />
3708
- <link
3709
- rel="alternate"
3710
- type="application/feed+json"
3711
- title="My Site"
3712
- href="/feed/json/"
3713
- />
3714
- <link
3715
- rel="alternate"
3716
- type="application/rss+xml"
3717
- title="My Site comments"
3718
- href="/comments/feed/"
3719
- />
3720
- ```
3721
-
3722
- The builders are exported, so a site can write a feed of its own — one
3723
- category, one author — without reimplementing any of the three formats:
3724
-
3725
- ```ts
3726
- import { atomFeed, createRenderer, jsonFeed, rssFeed } from '@geekity/cms';
3727
-
3728
- const site = createRenderer({ config: cms.config }).site();
3729
-
3730
- const source = {
3731
- site,
3732
- documents: cms.store.listByTag('releases', { limit: 20 }),
3733
- title: `${site.title}: releases`,
3734
- href: '/tag/releases/',
3735
- feedHref: '/tag/releases/feed/',
3736
- baseUrl: cms.config.baseUrl,
3737
- };
3738
-
3739
- rssFeed(source); // a string
3740
- atomFeed(source); // a string
3741
- jsonFeed(source); // a JSON Feed object
3742
- ```
3743
-
3744
- `commentsRssFeed` is the same for comments, and what goes in it comes from the
3745
- conversation reader — the one thing that reads the inbox log and the comment
3746
- index to show somebody what has been said, so a feed and the page under the
3747
- post carry the same entries:
3748
-
3749
- ```ts
3750
- import {
3751
- commentsRssFeed,
3752
- createConversation,
3753
- feedComments,
3754
- } from '@geekity/cms';
3755
-
3756
- const conversation = createConversation({
3757
- admin: cms.admin,
3758
- store: cms.store,
3759
- baseUrl: cms.config.baseUrl,
3760
- });
3761
-
3762
- commentsRssFeed({
3763
- site,
3764
- comments: feedComments(conversation.latest(20), {
3765
- baseUrl: cms.config.baseUrl,
3766
- limit: 20,
3767
- }),
3768
- title: `${site.title}: comments`,
3769
- href: '/',
3770
- feedHref: '/comments/feed/',
3771
- baseUrl: cms.config.baseUrl,
3772
- });
3773
- ```
3774
-
3775
- `conversation.thread(document)` is the same reading for one post — the shape the
3776
- theme's `conversation.njk` is handed — `spokenIn` flattens it into the entries a
3777
- feed carries, each with the reply it sits under, `conversation.counts(documents)`
3778
- is the number of direct replies `source:comments` puts beside each item of a
3779
- post feed, and `conversation.repliesTo(segment)` is what one `/replies/` feed
3780
- holds.
3781
-
3782
- ## Sitemap and robots.txt
3783
-
3784
- Two more fixed routes, at the two paths a crawler goes looking for on its own.
3785
-
3786
- `/sitemap.xml` is every URL the public site publishes, in the
3787
- [sitemap protocol's](https://www.sitemaps.org/protocol.html) `<urlset>`:
3788
-
3789
- - the home archive and each of its pages, `/`, `/page/2/` and so on;
3790
- - every published post and every page, at its permalink;
3791
- - every tag archive and every category archive, with each of their pages,
3792
- under whatever bases the site is configured with.
3793
-
3794
- Each `<loc>` is absolute, built on `baseUrl`. `<lastmod>` is a document's
3795
- `updated` when it has one and its `date` otherwise, and for a listing page it
3796
- is the newest of those among the documents on that page. Something nothing
3797
- dates — a page with no date in its front matter — carries no `<lastmod>`
3798
- rather than an invented one.
3799
-
3800
- Nothing hidden reaches it. The sitemap is drawn from the same queries the
3801
- listings are and checked against the same `isListed`, so drafts, the trash and
3802
- posts whose date has not arrived are absent for the same reason their
3803
- permalinks 404, and an [unlisted post](#unlisted-posts) is absent for the same
3804
- reason it is off the home page.
3805
-
3806
- The protocol caps one file at 50,000 URLs. Past that, `/sitemap.xml` becomes a
3807
- `<sitemapindex>` naming `/sitemap-1.xml`, `/sitemap-2.xml` and so on, each
3808
- holding its own slice and dated by the newest URL in it. The address a search
3809
- engine holds does not change, which is the point of the index living there.
3810
- While the whole sitemap fits in one file the children name nothing and 404.
3811
-
3812
- `/robots.txt` starts short:
3813
-
3814
- ```
3815
- User-agent: *
3816
- Disallow: /admin/
3817
-
3818
- Sitemap: https://example.com/sitemap.xml
3819
- ```
3820
-
3821
- Everything public is crawlable; only the admin is not. The actors and their
3822
- collections are deliberately left open — an actor and an object exist to be
3823
- fetched, they carry the same
3824
- content as the pages that link to them, and a crawler that follows one gets
3825
- JSON it will ignore.
3826
-
3827
- The Crawlers section of Settings → Reading adds to it, and writes what it sets
3828
- to `site.json`:
3829
-
3830
- | Setting | `site.json` key | What the file gains |
3831
- | --------------------- | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
3832
- | AI crawlers | `aiCrawlers` | `allow` (the default) adds nothing. `block-training` adds a `Disallow: /` group for each training crawler: GPTBot, ClaudeBot, anthropic-ai, Google-Extended, Applebot-Extended, Bytespider, CCBot and meta-externalagent. `block-all` also blocks the retrieval crawlers: OAI-SearchBot, ChatGPT-User, PerplexityBot, Perplexity-User, Claude-SearchBot, Claude-User, meta-webindexer and meta-externalfetcher. |
3833
- | Content signals | `contentSignalSearch`, `contentSignalAiInput`, `contentSignalAiTrain` | Each `yes`, `no` or empty. The ones set become one line, such as `Content-Signal: search=yes, ai-input=yes, ai-train=no`. |
3834
- | More robots.txt rules | `robotsRules` | Groups of the site's own, one line per array entry: `User-agent`, then `Allow`, `Disallow`, `Crawl-delay`, `Content-Signal` or `Sitemap` lines. |
3835
-
3836
- A crawler obeys only the one group that names it (RFC 9309), so the CMS puts
3837
- `Disallow: /admin/` and the content signal into every group, its own and the
3838
- site's, unless the group already disallows everything or carries a signal of
3839
- its own. The form refuses a line that is not a robots.txt field, a rule with no
3840
- `User-agent` above it, and an `Allow` under `/admin/`; a hand edit of
3841
- `site.json` that does any of these loses that line. The site's own `Sitemap:`
3842
- line is always there.
3843
-
3844
- For example, `block-training`, `ai-train` set to `no` and a rule of
3845
- `User-agent: SlowBot` / `Crawl-delay: 10` serve:
3846
-
3847
- ```
3848
- User-agent: *
3849
- Disallow: /admin/
3850
- Content-Signal: ai-train=no
3851
-
3852
- User-agent: GPTBot
3853
- Disallow: /
3854
-
3855
- …one group per training crawler…
3856
-
3857
- User-agent: SlowBot
3858
- Disallow: /admin/
3859
- Crawl-delay: 10
3860
- Content-Signal: ai-train=no
3861
-
3862
- Sitemap: https://example.com/sitemap.xml
3863
- ```
3864
-
3865
- Both are registered routes rather than anything resolved from the content
3866
- index, so a document permalinked at `/sitemap.xml` cannot take the URL a
3867
- search engine polls, and both carry an `ETag` and answer a conditional request
3868
- with 304 the way the feeds do — the sitemap with a `Last-Modified` as well,
3869
- robots without one, because nothing dates it.
3870
-
3871
- Nothing links the sitemap from a page: `robots.txt` names it, which is where a
3872
- crawler looks.
3873
-
3874
- ## llms.txt
3875
-
3876
- `/llms.txt` describes the site for a language model, in the
3877
- [llms.txt](https://llmstxt.org/) format. It holds the site's title as a heading
3878
- and its tagline as a quote. Then it lists every public page by title and the
3879
- posts the feeds carry, newest first. Each entry links to the document's
3880
- Markdown, which is what a model reads best:
3881
-
3882
- ```markdown
3883
- # Field Notes
3884
-
3885
- > Notes from the field.
3886
-
3887
- ## Pages
3888
-
3889
- - [About](https://example.com/about/index.md): Who writes this.
3890
-
3891
- ## Recent posts
3892
-
3893
- - [Third post](https://example.com/third/index.md)
3894
- ```
3895
-
3896
- A document's `description` follows its link. A static homepage is listed at
3897
- `/index.md`, because `/` is where it is published. The file is served as
3898
- `text/markdown` and carries an `ETag` and a `Last-Modified`, the newest date
3899
- among its entries. It answers a conditional request with 304, as the sitemap
3900
- does.
3901
-
3902
- The home page advertises the file twice. The response carries
3903
- `Link: </llms.txt>; rel="describedby"; type="text/markdown"`, for a tool that
3904
- reads no HTML. The default theme's head carries
3905
- `<link rel="describedby" type="text/markdown" href="/llms.txt">`. A theme
3906
- gets the path as `llmsTxt` while the site serves the file.
3907
-
3908
- To write the file yourself, put it at `content/llms.txt`. The CMS serves it
3909
- exactly as written in place of the generated one, dated by the file. To serve
3910
- no file at all, clear **Serve /llms.txt** under Settings → Reading. This
3911
- writes `"llmsTxt": false` to `site.json`. `/llms.txt` then answers 404, even
3912
- when `content/llms.txt` exists, and the home page stops advertising it.
3913
-
3914
- ## IndexNow
3915
-
3916
- [IndexNow](https://www.indexnow.org/) gets a changed URL recrawled by Bing,
3917
- Yandex, Naver, Seznam and the other engines that share it within minutes,
3918
- instead of on their next visit. It is off by default, because it sends the
3919
- site's URLs to a third party. To turn it on, select **Submit changes to
3920
- IndexNow** under Settings → Reading.
3921
-
3922
- The first time you turn it on, the CMS generates a key of 32 hex digits and
3923
- writes it to `site.json` as `indexNowKey`, beside `"indexNow": true`. The site
3924
- then serves the key at `/{key}.txt` as plain text, which is how an engine
3925
- checks that the submissions are yours. Turning IndexNow off takes the file away
3926
- and keeps the key, so turning it back on reuses the key the engines already
3927
- checked. To change the key, write a new one of 8 to 128 letters, digits or
3928
- dashes to `indexNowKey`. A key that does not fit that pattern counts as none,
3929
- and the site sends nothing.
3930
-
3931
- When a post or a page is published, changed, moved or deleted, the CMS submits
3932
- its URL. A move submits the old URL and the new one. Unpublishing, trashing and
3933
- deleting submit the URL that went away. The changes of the next ten seconds go
3934
- out together as one `POST` to `https://api.indexnow.org/indexnow`:
3935
-
3936
- ```json
3937
- {
3938
- "host": "example.com",
3939
- "key": "0123456789abcdef0123456789abcdef",
3940
- "keyLocation": "https://example.com/0123456789abcdef0123456789abcdef.txt",
3941
- "urlList": ["https://example.com/hello/"]
3942
- }
3943
- ```
3944
-
3945
- A network failure, a 429 or a 5xx answer is tried again after ten seconds, then
3946
- after forty. Any other refusal, such as a 403 for a key the engine could not
3947
- fetch, is not. A batch that still fails is logged as a warning, and the post
3948
- stays published.
3949
-
3950
- Nothing is sent for a draft or a scheduled post, for the boot scan, or while
3951
- the base URL's host is local: `localhost`, a `.local` name, or a private IP
3952
- address.
3953
-
3954
- A site can change how the CMS sends with `indexNow` in its config. Every field
3955
- is optional:
3956
-
3957
- ```ts
3958
- export default defineConfig({
3959
- indexNow: {
3960
- fetch, // what submits the URLs; a test names one
3961
- endpoint: 'https://www.bing.com/indexnow',
3962
- batchMs: 10_000,
3963
- attempts: 3,
3964
- backoffMs: (attempt) => attempt * attempt * 10_000,
3965
- logger: console,
3966
- },
3967
- });
3968
- ```
3969
-
3970
- ## Theme overrides
3971
-
3972
- A site's themes live one directory per theme under `themesDir` (`themes/` by
3973
- default), each with a `theme.json` naming it:
3974
-
3975
- ```json
3976
- {
3977
- "name": "Midnight",
3978
- "kind": "site",
3979
- "description": "Dark, quiet, mostly type."
3980
- }
3981
- ```
3982
-
3983
- `name` is what a person reads, `kind` is `site` — the only kind there is, and
3984
- the field is in the file so another can be added later without the format
3985
- changing — and `description` is optional. The directory name is the theme's id.
3986
-
3987
- One setting in `content/_data/site.json`, `theme`, says which one the site is
3988
- wearing, and that id is what it holds:
3989
-
3990
- ```json
3991
- { "theme": "midnight" }
3992
- ```
3993
-
3994
- **Appearance > Themes** in the admin is where the choice is made. It lists the
3995
- theme inside this package and every folder under `themesDir` with its name and
3996
- description, marks the one in use, and Activate on another writes the setting —
3997
- or, on the packaged theme, takes the key out of `site.json` again, which is why
3998
- a site wearing it has no `theme` key rather than an empty one. A folder whose
3999
- `theme.json` is missing, unparseable or names another kind is listed under
4000
- "Not themes" with the reason, so a typo is something you can see on the screen
4001
- that would otherwise have shown the theme. Activating takes effect on the next
4002
- request, with no restart. Editing the setting by hand does exactly the same
4003
- thing.
4004
-
4005
- A template is then looked up in that theme first and in the theme that ships
4006
- inside this package second, file by file. Sites override one template at a time
4007
- and keep receiving updates to the rest. Files under `/theme/` resolve the same
4008
- way, so `themes/midnight/static/style.css` replaces the packaged stylesheet,
4009
- and the [email messages](#messages) under `mail/` resolve the same way too.
4010
-
4011
- Neither the setting nor `themesDir` need exist. A site that names no theme
4012
- serves every page, the 404 and the stylesheet out of the packaged theme, and an
4013
- unchosen theme sitting in `themes/` changes nothing. A `theme` naming a
4014
- directory that is not there, or one whose manifest will not read, falls back to
4015
- the packaged theme with a warning in the log rather than a broken site.
4016
-
4017
- A theme that ships only
4018
-
4019
- ```
4020
- themes/midnight/layouts/post.njk
4021
- ```
4022
-
4023
- replaces the post layout; the base layout, the archive, the tag pages and the
4024
- 404 all still come from the package. Templates are Nunjucks, and an override
4025
- can extend a packaged one by name:
4026
-
4027
- ```njk
4028
- {% extends "layouts/base.njk" %}
4029
-
4030
- {% block content %}
4031
- <h1>{{ title }}</h1>
4032
- {{ content | safe }}
4033
- {% endblock %}
4034
- ```
4035
-
4036
- The admin is not themed. Its templates and its static files live in a tree of
4037
- their own with a loader of their own, deliberately off this search path, so no
4038
- theme can shadow the login form or the CSRF field inside it.
4039
-
4040
- Analytics and other markup that goes on every page need no layout at all. The
4041
- packaged `layouts/base.njk` includes `partials/head-end.njk` last in `<head>`
4042
- and `partials/body-end.njk` last in `<body>`, both empty in the package, so a
4043
- theme of `theme.json` and `partials/head-end.njk` holding the Umami or Google
4044
- Analytics snippet adds it to every public HTML page and keeps every other
4045
- packaged template. Neither is printed in a feed, a Markdown, text or JSON
4046
- representation, mail, the admin or the editor's preview. The theme README's
4047
- [Adding to every page](./themes/default/README.md#adding-to-every-page) has the
4048
- Umami and GA4 example, the Docker volume, and what a Content-Security-Policy
4049
- has to allow for them.
4050
-
4051
- The packaged theme is the Paper design (doc-9) on the andrewshell.org shell
4052
- (decision-16). Its stylesheet is written in Tailwind v4 in
4053
- `themes/default/src/style.css` and compiled to `themes/default/static/style.css`
4054
- when the package is built (decision-22), so what ships is one plain CSS file; a
4055
- site theme's own `static/style.css` replaces it wholesale and needs no
4056
- Tailwind. Every colour is a `--color-*` custom property a site can override.
4057
- `base.njk` is its shell: a skip link, a `.global-wrapper` carrying `data-is-root-path="true"`
4058
- at `/` only, a header that is the site title and tagline on the front page and a
4059
- small link home on every other — with `menus.primary` inside it on every page
4060
- — `<main id="main">`, and a footer with the copyright year, the site author's
4061
- display name (the site title on a site with several authors), the colophon and
4062
- `menus.footer`. The footer reads nothing off an account: it
4063
- used to print an RSS link and one `rel="me"` link per link on the site author's
4064
- profile, and a site that wants either types it into its footer menu. Nothing
4065
- particular to one site is in it — webrings and badges belong in a site theme's
4066
- `footer` block. Its head
4067
- carries a description, Open Graph and Twitter card tags, icons derived from the
4068
- site's avatar, and one JSON-LD `@graph` from `partials/jsonld.njk`: that partial
4069
- is all the structured data the theme emits, there is no Microdata anywhere, and
4070
- a site that wants a different graph replaces the one file. The stylesheet
4071
- is a serif body and sans headings at an 18px root, one column at 42rem, warm
4072
- paper with a rust primary and a blue secondary, and a second scheme under
4073
- `prefers-color-scheme: dark` that redefines the same `--color-*` tokens on dark
4074
- paper. Both meet WCAG 2.2 AA, checked by a test that reads the custom properties
4075
- out of the stylesheet and computes the ratios.
4076
-
4077
- The context mirrors what an Eleventy layout receives — `title`, `date`, `tags`,
4078
- `content`, `page.url`, and every front matter key the file carried — plus
4079
- `site`, which is `content/_data/site.json`. It is part of the semver contract.
4080
- The one key that is not the front matter's own string is `author`: it is the
4081
- person the file names, resolved against the site's users, with `author.name` to
4082
- print and `author.url` — their archive at `/author/{username}/` — to link to
4083
- when the name is one of them. `siteAuthor` is the same object answering a
4084
- different question — who the page in front of the reader is by, which is the
4085
- entry's author on a document, the archive's person on an author archive and the
4086
- profile behind the site's `author` setting everywhere else — so the bio, the
4087
- `rel="me"` links and any structured data a theme emits all read one profile.
4088
- The full table of context keys, blocks and
4089
- filters is in [`themes/default/README.md`](./themes/default/README.md).
4090
-
4091
- Rendering is also callable without a request:
4092
-
4093
- ```ts
4094
- import { createRenderer, resolveConfig } from '@geekity/cms';
4095
-
4096
- const renderer = createRenderer({
4097
- config: resolveConfig({ baseUrl: 'https://example.com' }),
4098
- });
4099
- renderer.renderDocument(document); // the HTML the site would serve
4100
- renderer.site(); // content/_data/site.json, defaults filled in
4101
- ```
4102
-
4103
- Handlers reach the same renderer as `c.var.renderer`.
4104
-
4105
- ### Moving a `theme/` directory
4106
-
4107
- Before named themes a site had one `theme/` directory, always on the search
4108
- path, pointed at by `themeDir` and `GEEKITY_THEME_DIR`. Both are gone and
4109
- neither is aliased, so a site carrying one moves it:
4110
-
4111
- ```sh
4112
- mkdir -p themes/mine
4113
- git mv theme/* themes/mine/
4114
- rmdir theme
4115
- ```
4116
-
4117
- Then write `themes/mine/theme.json` with a `name` and `"kind": "site"`, choose
4118
- it on **Appearance > Themes** (or put `"theme": "mine"` in
4119
- `content/_data/site.json`), and drop `themeDir` from `geekity.config.ts` and
4120
- `GEEKITY_THEME_DIR` from the environment — a config naming `themeDir` is now a
4121
- type error and the variable is ignored. Nothing inside the theme changes: the
4122
- layouts, partials, `mail/` and `static/` keep their names and their meaning.
4123
- Until it is chosen the site serves the packaged theme, so the move and the
4124
- choice belong in the same deploy.
4125
-
4126
- ## Building the same content with Eleventy
4127
-
4128
- The content directory is an [Eleventy](https://www.11ty.dev) 3 input directory.
4129
- The same files this CMS serves build into a static site at the same URLs, so
4130
- leaving is a build step rather than a migration. That is a promise, and it is
4131
- kept by a test rather than by good intentions.
4132
-
4133
- Copy
4134
- [`docs/eleventy.config.example.js`](https://github.com/geekitycom/cms/blob/main/packages/cms/docs/eleventy.config.example.js)
4135
- into your site as `eleventy.config.js` and run Eleventy:
4136
-
4137
- ```sh
4138
- npm install --save-dev @11ty/eleventy
4139
- npx @11ty/eleventy
4140
- ```
4141
-
4142
- It is a plain ESM config with no dependency on this package, and it writes out
4143
- the rules the CMS follows that Eleventy does not know about on its own:
4144
-
4145
- | Rule | How the config does it |
4146
- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
4147
- | `draft: true` hides a document | An `addPreprocessor` that returns `false` for it. `BUILD_DRAFTS=1` builds drafts anyway, for a local preview. |
4148
- | `visibility: unlisted` is listed nowhere | An `addPreprocessor` that sets `eleventyExcludeFromCollections` on the document, so a feed, sitemap or archive drawn from collections leaves it out, and sets `noindex` for the layout to print as `<meta name="robots" content="noindex">`. Any value other than `public` or `unlisted` returns `false`, as a draft does. |
4149
- | 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. |
4150
- | `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/`. |
4151
- | `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. |
4152
- | `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`. |
4153
- | `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. |
4154
- | 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. |
4155
- | 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. |
4156
-
4157
- It also builds two collections Eleventy has no notion of. `collections.categories`
4158
- is the second taxonomy, one entry of `{ name, posts }` per category in use, for
4159
- paginating into archives at `/{{ site.categoryBase }}/{name}/`.
4160
- `collections.menus` is the site's named menus — the `menus` object of
4161
- `site.json`, and nothing else — each an ordered list of `{ label, url }`
4162
- entries, plus `rel` on a link that carries `rel` values; a layout
4163
- marks the current one itself by comparing `item.url` with `page.url`, because a
4164
- collection is built once for the whole site.
4165
-
4166
- The `date` filter is the one that needs a word. The files hold UTC instants and
4167
- the `timezone` setting decides how they read, so the config's filter takes the
4168
- zone from `content/_data/site.json` — the file the settings screen writes —
4169
- and renders with `Intl`, which keeps the file free of dependencies. If your site
4170
- already uses [Luxon](https://moment.github.io/luxon/), which Eleventy ships
4171
- anyway, the same filter is shorter:
4172
-
4173
- ```js
4174
- import { DateTime } from 'luxon';
4175
-
4176
- const zone =
4177
- JSON.parse(readFileSync('content/_data/site.json', 'utf8')).timezone ?? 'utc';
4178
-
4179
- eleventyConfig.addFilter('date', (value, format = 'readable') => {
4180
- const at = DateTime.fromJSDate(
4181
- value instanceof Date ? value : new Date(value),
4182
- ).setZone(zone);
4183
- if (!at.isValid) return '';
4184
-
4185
- // `iso` is the instant, never the zone: a <time datetime> and a feed want
4186
- // UTC and must not move when a setting does.
4187
- if (format === 'iso') return at.toUTC().toISO();
4188
- if (format === 'html') return at.toFormat('yyyy-MM-dd');
4189
- if (format === 'year') return at.toFormat('yyyy');
4190
- return at.toFormat('d LLLL yyyy');
4191
- });
4192
- ```
4193
-
4194
- Either way it is the CMS's own filter, format for format, so a layout moved
4195
- across prints the same dates.
4196
-
4197
- It also turns the template engine off for Markdown
4198
- (`markdownTemplateEngine: false`), because the CMS renders Markdown with
4199
- markdown-it and nothing else. Leaving Liquid or Nunjucks on would make `{{ … }}`
4200
- in a post mean one thing on the live site and another in the build.
4201
-
4202
- Layouts are yours. The directory data files name them — `posts.json` says
4203
- `"layout": "post"` and `pages.json` says `"layout": "page"` — so Eleventy wants
4204
- `content/_includes/post.njk` and `content/_includes/page.njk`. The context an
4205
- Eleventy layout receives is the one this package's theme mirrors, so a layout
4206
- can often be moved across with only its `{% extends %}` removed.
4207
-
4208
- The default theme's own layouts and partials build too, copied into
4209
- `content/_includes/` as `layouts/` and `partials/`: the config adds the theme's
4210
- other filters (`absoluteUrl`, `asset`, `host`, `fediverseHandle`) and the
4211
- `newestPosts(count)` a front page override lists posts with, so a site theme's
4212
- `front-page.njk` calling `{% set posts = newestPosts(5) %}` builds. They get
4213
- only the context the config gives, though: `collections.menus` rather than
4214
- `menus`, the `conversation` filter rather than the key, and no comment form,
4215
- search or listing pagination.
4216
-
4217
- Some things do not carry over. `/feed/`, `/feed/atom/` and `/feed/json/` are
4218
- generated in code here, not by a template, so an Eleventy build needs its own;
4219
- `/sitemap.xml` and `/robots.txt` are the same story, for the same reason. A
4220
- comment's own page at `/comment/{id}/` is not written, since a build has no
4221
- server to take a comment, so a comment links to its `#comment-{id}` anchor on
4222
- the post. The site's own reply posts are not threaded under what they answer,
4223
- and a document's backlinks are not listed: both read the CMS's index of every
4224
- document, which the config's filters do not have. The config's header lists
4225
- the same.
4226
-
4227
- ### The compatibility test
4228
-
4229
- ```sh
4230
- pnpm --filter @geekity/cms test:11ty # or, from the repository root, pnpm test:11ty
4231
- ```
4232
-
4233
- `test:11ty` builds `test/fixtures/` with Eleventy 3 and the example config, then
4234
- checks every document against what the CMS computed for the same file: the
4235
- output path equals the permalink plus `index.html`, drafts and the trash are
4236
- absent, and uploads are copied through byte for byte. The fixtures deliberately
4237
- cover a post whose permalink is not the default, a hand-authored post and page
4238
- with no permalink at all, an accented title, a draft and a trashed file.
4239
-
4240
- It is a separate script from `pnpm test` on purpose. The unit suite stays fast
4241
- and free of a build tool; `test:11ty` pulls in Eleventy, writes a temporary
4242
- directory and is the slow one. CI runs both. Neither `docs/` nor `test/` is in
4243
- the published tarball — the example config lives in the repository, which is
4244
- where you copy it from.
4245
-
4246
- The repository's demo site makes the same comparison over content that reads
4247
- like a real site rather than like fixtures, with the two layouts above in
4248
- `apps/demo/content/_includes/`, so `pnpm test:11ty` from the root runs both.
4249
-
4250
- ## License
62
+ MIT