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