@geekity/cms 0.3.0 → 0.5.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 (660) hide show
  1. package/README.md +2217 -214
  2. package/admin/components/fields.njk +165 -0
  3. package/admin/layouts/base.njk +1 -1
  4. package/admin/layouts/settings-page.njk +26 -0
  5. package/admin/layouts/shell.njk +17 -3
  6. package/admin/pages/account/forgot.njk +47 -0
  7. package/admin/pages/account/login.njk +26 -0
  8. package/admin/pages/account/reset.njk +38 -0
  9. package/admin/pages/account/setup.njk +28 -0
  10. package/admin/pages/appearance/themes.njk +69 -0
  11. package/admin/pages/comments/all.njk +133 -0
  12. package/admin/{layouts/dashboard.njk → pages/dashboard/home.njk} +8 -15
  13. package/admin/{layouts/document-conflict.njk → pages/documents/conflict.njk} +1 -0
  14. package/admin/{layouts/document-editor.njk → pages/documents/editor.njk} +75 -59
  15. package/admin/{layouts/document-list.njk → pages/documents/list.njk} +19 -2
  16. package/admin/pages/documents/taxonomy.njk +95 -0
  17. package/admin/pages/federation/followers.njk +238 -0
  18. package/admin/pages/federation/settings.njk +77 -0
  19. package/admin/pages/media/library.njk +160 -0
  20. package/admin/pages/messages/all.njk +110 -0
  21. package/admin/pages/navigation/menus.njk +129 -0
  22. package/admin/{layouts → pages}/placeholder.njk +5 -1
  23. package/admin/pages/settings/discussion.njk +99 -0
  24. package/admin/pages/settings/email.njk +203 -0
  25. package/admin/pages/settings/general.njk +29 -0
  26. package/admin/pages/settings/permalinks.njk +50 -0
  27. package/admin/pages/settings/reading.njk +64 -0
  28. package/admin/pages/tools/content-index.njk +89 -0
  29. package/admin/pages/users/edit.njk +239 -0
  30. package/admin/pages/users/list.njk +51 -0
  31. package/admin/pages/users/new.njk +41 -0
  32. package/admin/static/admin.css +558 -3
  33. package/admin/static/copy.js +52 -0
  34. package/admin/static/editor.js +16 -16
  35. package/admin/static/slug.js +61 -0
  36. package/dist/access-log.d.ts +44 -0
  37. package/dist/access-log.d.ts.map +1 -0
  38. package/dist/access-log.js +71 -0
  39. package/dist/access-log.js.map +1 -0
  40. package/dist/admin/accounts.d.ts +413 -0
  41. package/dist/admin/accounts.d.ts.map +1 -0
  42. package/dist/admin/accounts.js +680 -0
  43. package/dist/admin/accounts.js.map +1 -0
  44. package/dist/admin/appearance.d.ts +80 -0
  45. package/dist/admin/appearance.d.ts.map +1 -0
  46. package/dist/admin/appearance.js +136 -0
  47. package/dist/admin/appearance.js.map +1 -0
  48. package/dist/admin/comments.d.ts +90 -0
  49. package/dist/admin/comments.d.ts.map +1 -0
  50. package/dist/admin/comments.js +244 -0
  51. package/dist/admin/comments.js.map +1 -0
  52. package/dist/admin/credentials.d.ts +26 -2
  53. package/dist/admin/credentials.d.ts.map +1 -1
  54. package/dist/admin/credentials.js +35 -2
  55. package/dist/admin/credentials.js.map +1 -1
  56. package/dist/admin/documents.d.ts +117 -10
  57. package/dist/admin/documents.d.ts.map +1 -1
  58. package/dist/admin/documents.js +328 -41
  59. package/dist/admin/documents.js.map +1 -1
  60. package/dist/admin/federation.d.ts +161 -50
  61. package/dist/admin/federation.d.ts.map +1 -1
  62. package/dist/admin/federation.js +235 -93
  63. package/dist/admin/federation.js.map +1 -1
  64. package/dist/admin/formatting.d.ts +20 -0
  65. package/dist/admin/formatting.d.ts.map +1 -0
  66. package/dist/admin/formatting.js +72 -0
  67. package/dist/admin/formatting.js.map +1 -0
  68. package/dist/admin/headers.d.ts +65 -0
  69. package/dist/admin/headers.d.ts.map +1 -0
  70. package/dist/admin/headers.js +104 -0
  71. package/dist/admin/headers.js.map +1 -0
  72. package/dist/admin/index.d.ts +38 -12
  73. package/dist/admin/index.d.ts.map +1 -1
  74. package/dist/admin/index.js +25 -7
  75. package/dist/admin/index.js.map +1 -1
  76. package/dist/admin/media.d.ts +168 -0
  77. package/dist/admin/media.d.ts.map +1 -0
  78. package/dist/admin/media.js +295 -0
  79. package/dist/admin/media.js.map +1 -0
  80. package/dist/admin/menu.d.ts +79 -0
  81. package/dist/admin/menu.d.ts.map +1 -0
  82. package/dist/admin/menu.js +143 -0
  83. package/dist/admin/menu.js.map +1 -0
  84. package/dist/admin/messages.d.ts +80 -0
  85. package/dist/admin/messages.d.ts.map +1 -0
  86. package/dist/admin/messages.js +152 -0
  87. package/dist/admin/messages.js.map +1 -0
  88. package/dist/admin/navigation.d.ts +155 -0
  89. package/dist/admin/navigation.d.ts.map +1 -0
  90. package/dist/admin/navigation.js +292 -0
  91. package/dist/admin/navigation.js.map +1 -0
  92. package/dist/admin/preview.d.ts.map +1 -1
  93. package/dist/admin/preview.js +31 -7
  94. package/dist/admin/preview.js.map +1 -1
  95. package/dist/admin/recovery.d.ts +92 -0
  96. package/dist/admin/recovery.d.ts.map +1 -0
  97. package/dist/admin/recovery.js +229 -0
  98. package/dist/admin/recovery.js.map +1 -0
  99. package/dist/admin/routes.d.ts +0 -20
  100. package/dist/admin/routes.d.ts.map +1 -1
  101. package/dist/admin/routes.js +164 -105
  102. package/dist/admin/routes.js.map +1 -1
  103. package/dist/admin/session.d.ts +17 -3
  104. package/dist/admin/session.d.ts.map +1 -1
  105. package/dist/admin/session.js +19 -5
  106. package/dist/admin/session.js.map +1 -1
  107. package/dist/admin/settings-discussion.d.ts +37 -0
  108. package/dist/admin/settings-discussion.d.ts.map +1 -0
  109. package/dist/admin/settings-discussion.js +117 -0
  110. package/dist/admin/settings-discussion.js.map +1 -0
  111. package/dist/admin/settings-email.d.ts +54 -0
  112. package/dist/admin/settings-email.d.ts.map +1 -0
  113. package/dist/admin/settings-email.js +189 -0
  114. package/dist/admin/settings-email.js.map +1 -0
  115. package/dist/admin/settings-federation.d.ts +48 -0
  116. package/dist/admin/settings-federation.d.ts.map +1 -0
  117. package/dist/admin/settings-federation.js +123 -0
  118. package/dist/admin/settings-federation.js.map +1 -0
  119. package/dist/admin/settings-general.d.ts +12 -0
  120. package/dist/admin/settings-general.d.ts.map +1 -0
  121. package/dist/admin/settings-general.js +48 -0
  122. package/dist/admin/settings-general.js.map +1 -0
  123. package/dist/admin/settings-page.d.ts +119 -0
  124. package/dist/admin/settings-page.d.ts.map +1 -0
  125. package/dist/admin/settings-page.js +132 -0
  126. package/dist/admin/settings-page.js.map +1 -0
  127. package/dist/admin/settings-pages.d.ts +30 -0
  128. package/dist/admin/settings-pages.d.ts.map +1 -0
  129. package/dist/admin/settings-pages.js +43 -0
  130. package/dist/admin/settings-pages.js.map +1 -0
  131. package/dist/admin/settings-permalinks.d.ts +16 -0
  132. package/dist/admin/settings-permalinks.d.ts.map +1 -0
  133. package/dist/admin/settings-permalinks.js +32 -0
  134. package/dist/admin/settings-permalinks.js.map +1 -0
  135. package/dist/admin/settings-reading.d.ts +34 -0
  136. package/dist/admin/settings-reading.d.ts.map +1 -0
  137. package/dist/admin/settings-reading.js +49 -0
  138. package/dist/admin/settings-reading.js.map +1 -0
  139. package/dist/admin/settings.d.ts +339 -96
  140. package/dist/admin/settings.d.ts.map +1 -1
  141. package/dist/admin/settings.js +600 -308
  142. package/dist/admin/settings.js.map +1 -1
  143. package/dist/admin/store.d.ts +606 -152
  144. package/dist/admin/store.d.ts.map +1 -1
  145. package/dist/admin/store.js +863 -268
  146. package/dist/admin/store.js.map +1 -1
  147. package/dist/admin/taxonomy.d.ts +114 -0
  148. package/dist/admin/taxonomy.d.ts.map +1 -0
  149. package/dist/admin/taxonomy.js +289 -0
  150. package/dist/admin/taxonomy.js.map +1 -0
  151. package/dist/admin/templates.d.ts +46 -14
  152. package/dist/admin/templates.d.ts.map +1 -1
  153. package/dist/admin/templates.js +46 -14
  154. package/dist/admin/templates.js.map +1 -1
  155. package/dist/admin/throttle.d.ts +84 -0
  156. package/dist/admin/throttle.d.ts.map +1 -0
  157. package/dist/admin/throttle.js +143 -0
  158. package/dist/admin/throttle.js.map +1 -0
  159. package/dist/admin/tools.d.ts +111 -0
  160. package/dist/admin/tools.d.ts.map +1 -0
  161. package/dist/admin/tools.js +182 -0
  162. package/dist/admin/tools.js.map +1 -0
  163. package/dist/admin/uploads.d.ts +28 -2
  164. package/dist/admin/uploads.d.ts.map +1 -1
  165. package/dist/admin/uploads.js +43 -4
  166. package/dist/admin/uploads.js.map +1 -1
  167. package/dist/admin/users.d.ts +100 -13
  168. package/dist/admin/users.d.ts.map +1 -1
  169. package/dist/admin/users.js +412 -39
  170. package/dist/admin/users.js.map +1 -1
  171. package/dist/cache.d.ts +141 -0
  172. package/dist/cache.d.ts.map +1 -0
  173. package/dist/cache.js +207 -0
  174. package/dist/cache.js.map +1 -0
  175. package/dist/cli.d.ts +14 -2
  176. package/dist/cli.d.ts.map +1 -1
  177. package/dist/cli.js +429 -21
  178. package/dist/cli.js.map +1 -1
  179. package/dist/comments/akismet.d.ts +130 -0
  180. package/dist/comments/akismet.d.ts.map +1 -0
  181. package/dist/comments/akismet.js +274 -0
  182. package/dist/comments/akismet.js.map +1 -0
  183. package/dist/comments/conversation.d.ts +29 -0
  184. package/dist/comments/conversation.d.ts.map +1 -0
  185. package/dist/comments/conversation.js +60 -0
  186. package/dist/comments/conversation.js.map +1 -0
  187. package/dist/comments/form.d.ts +123 -0
  188. package/dist/comments/form.d.ts.map +1 -0
  189. package/dist/comments/form.js +79 -0
  190. package/dist/comments/form.js.map +1 -0
  191. package/dist/comments/index.d.ts +16 -0
  192. package/dist/comments/index.d.ts.map +1 -0
  193. package/dist/comments/index.js +10 -0
  194. package/dist/comments/index.js.map +1 -0
  195. package/dist/comments/markdown.d.ts +8 -0
  196. package/dist/comments/markdown.d.ts.map +1 -0
  197. package/dist/comments/markdown.js +85 -0
  198. package/dist/comments/markdown.js.map +1 -0
  199. package/dist/comments/moderate.d.ts +71 -0
  200. package/dist/comments/moderate.d.ts.map +1 -0
  201. package/dist/comments/moderate.js +88 -0
  202. package/dist/comments/moderate.js.map +1 -0
  203. package/dist/comments/policy.d.ts +57 -0
  204. package/dist/comments/policy.d.ts.map +1 -0
  205. package/dist/comments/policy.js +80 -0
  206. package/dist/comments/policy.js.map +1 -0
  207. package/dist/comments/records.d.ts +266 -0
  208. package/dist/comments/records.d.ts.map +1 -0
  209. package/dist/comments/records.js +487 -0
  210. package/dist/comments/records.js.map +1 -0
  211. package/dist/comments/routes.d.ts +60 -0
  212. package/dist/comments/routes.d.ts.map +1 -0
  213. package/dist/comments/routes.js +212 -0
  214. package/dist/comments/routes.js.map +1 -0
  215. package/dist/comments/submission.d.ts +297 -0
  216. package/dist/comments/submission.d.ts.map +1 -0
  217. package/dist/comments/submission.js +280 -0
  218. package/dist/comments/submission.js.map +1 -0
  219. package/dist/comments/viewer.d.ts +21 -0
  220. package/dist/comments/viewer.d.ts.map +1 -0
  221. package/dist/comments/viewer.js +45 -0
  222. package/dist/comments/viewer.js.map +1 -0
  223. package/dist/config.d.ts +225 -5
  224. package/dist/config.d.ts.map +1 -1
  225. package/dist/config.js +121 -6
  226. package/dist/config.js.map +1 -1
  227. package/dist/contact/delivery.d.ts +60 -0
  228. package/dist/contact/delivery.d.ts.map +1 -0
  229. package/dist/contact/delivery.js +87 -0
  230. package/dist/contact/delivery.js.map +1 -0
  231. package/dist/contact/form.d.ts +130 -0
  232. package/dist/contact/form.d.ts.map +1 -0
  233. package/dist/contact/form.js +163 -0
  234. package/dist/contact/form.js.map +1 -0
  235. package/dist/contact/index.d.ts +17 -0
  236. package/dist/contact/index.d.ts.map +1 -0
  237. package/dist/contact/index.js +13 -0
  238. package/dist/contact/index.js.map +1 -0
  239. package/dist/contact/records.d.ts +124 -0
  240. package/dist/contact/records.d.ts.map +1 -0
  241. package/dist/contact/records.js +218 -0
  242. package/dist/contact/records.js.map +1 -0
  243. package/dist/contact/routes.d.ts +17 -0
  244. package/dist/contact/routes.d.ts.map +1 -0
  245. package/dist/contact/routes.js +127 -0
  246. package/dist/contact/routes.js.map +1 -0
  247. package/dist/contact/submission.d.ts +103 -0
  248. package/dist/contact/submission.d.ts.map +1 -0
  249. package/dist/contact/submission.js +166 -0
  250. package/dist/contact/submission.js.map +1 -0
  251. package/dist/content/document.d.ts +7 -2
  252. package/dist/content/document.d.ts.map +1 -1
  253. package/dist/content/document.js +1 -0
  254. package/dist/content/document.js.map +1 -1
  255. package/dist/content/index.d.ts +7 -2
  256. package/dist/content/index.d.ts.map +1 -1
  257. package/dist/content/index.js +4 -1
  258. package/dist/content/index.js.map +1 -1
  259. package/dist/content/markdown.d.ts.map +1 -1
  260. package/dist/content/markdown.js +24 -2
  261. package/dist/content/markdown.js.map +1 -1
  262. package/dist/content/parser.d.ts.map +1 -1
  263. package/dist/content/parser.js +11 -4
  264. package/dist/content/parser.js.map +1 -1
  265. package/dist/content/save.d.ts +5 -0
  266. package/dist/content/save.d.ts.map +1 -1
  267. package/dist/content/save.js +44 -1
  268. package/dist/content/save.js.map +1 -1
  269. package/dist/content/schedule.d.ts +125 -0
  270. package/dist/content/schedule.d.ts.map +1 -0
  271. package/dist/content/schedule.js +207 -0
  272. package/dist/content/schedule.js.map +1 -0
  273. package/dist/content/search.d.ts +64 -0
  274. package/dist/content/search.d.ts.map +1 -0
  275. package/dist/content/search.js +136 -0
  276. package/dist/content/search.js.map +1 -0
  277. package/dist/content/store.d.ts +220 -7
  278. package/dist/content/store.d.ts.map +1 -1
  279. package/dist/content/store.js +392 -67
  280. package/dist/content/store.js.map +1 -1
  281. package/dist/content/sync.d.ts +7 -1
  282. package/dist/content/sync.d.ts.map +1 -1
  283. package/dist/content/sync.js +18 -10
  284. package/dist/content/sync.js.map +1 -1
  285. package/dist/content/time.d.ts +56 -0
  286. package/dist/content/time.d.ts.map +1 -0
  287. package/dist/content/time.js +141 -0
  288. package/dist/content/time.js.map +1 -0
  289. package/dist/content/writer.d.ts.map +1 -1
  290. package/dist/content/writer.js +3 -0
  291. package/dist/content/writer.js.map +1 -1
  292. package/dist/env.d.ts +70 -5
  293. package/dist/env.d.ts.map +1 -1
  294. package/dist/federation/actor.d.ts +95 -41
  295. package/dist/federation/actor.d.ts.map +1 -1
  296. package/dist/federation/actor.js +180 -53
  297. package/dist/federation/actor.js.map +1 -1
  298. package/dist/federation/article.d.ts +44 -17
  299. package/dist/federation/article.d.ts.map +1 -1
  300. package/dist/federation/article.js +98 -46
  301. package/dist/federation/article.js.map +1 -1
  302. package/dist/federation/delivery.d.ts +63 -15
  303. package/dist/federation/delivery.d.ts.map +1 -1
  304. package/dist/federation/delivery.js +154 -80
  305. package/dist/federation/delivery.js.map +1 -1
  306. package/dist/federation/federation.d.ts +22 -17
  307. package/dist/federation/federation.d.ts.map +1 -1
  308. package/dist/federation/federation.js +98 -77
  309. package/dist/federation/federation.js.map +1 -1
  310. package/dist/federation/followers.d.ts +3 -2
  311. package/dist/federation/followers.d.ts.map +1 -1
  312. package/dist/federation/followers.js +6 -5
  313. package/dist/federation/followers.js.map +1 -1
  314. package/dist/federation/import-wordpress.d.ts +128 -0
  315. package/dist/federation/import-wordpress.d.ts.map +1 -0
  316. package/dist/federation/import-wordpress.js +426 -0
  317. package/dist/federation/import-wordpress.js.map +1 -0
  318. package/dist/federation/inbox.d.ts +36 -13
  319. package/dist/federation/inbox.d.ts.map +1 -1
  320. package/dist/federation/inbox.js +120 -49
  321. package/dist/federation/inbox.js.map +1 -1
  322. package/dist/federation/index.d.ts +22 -10
  323. package/dist/federation/index.d.ts.map +1 -1
  324. package/dist/federation/index.js +13 -8
  325. package/dist/federation/index.js.map +1 -1
  326. package/dist/federation/keys.d.ts +98 -20
  327. package/dist/federation/keys.d.ts.map +1 -1
  328. package/dist/federation/keys.js +269 -39
  329. package/dist/federation/keys.js.map +1 -1
  330. package/dist/federation/mount.d.ts +46 -10
  331. package/dist/federation/mount.d.ts.map +1 -1
  332. package/dist/federation/mount.js +300 -28
  333. package/dist/federation/mount.js.map +1 -1
  334. package/dist/federation/paths.d.ts +23 -35
  335. package/dist/federation/paths.d.ts.map +1 -1
  336. package/dist/federation/paths.js +26 -39
  337. package/dist/federation/paths.js.map +1 -1
  338. package/dist/federation/records.d.ts +223 -0
  339. package/dist/federation/records.d.ts.map +1 -0
  340. package/dist/federation/records.js +506 -0
  341. package/dist/federation/records.js.map +1 -0
  342. package/dist/federation/relays.d.ts +114 -0
  343. package/dist/federation/relays.d.ts.map +1 -0
  344. package/dist/federation/relays.js +290 -0
  345. package/dist/federation/relays.js.map +1 -0
  346. package/dist/federation/replies.d.ts +80 -0
  347. package/dist/federation/replies.d.ts.map +1 -0
  348. package/dist/federation/replies.js +166 -0
  349. package/dist/federation/replies.js.map +1 -0
  350. package/dist/federation/wordpress.d.ts +131 -0
  351. package/dist/federation/wordpress.d.ts.map +1 -0
  352. package/dist/federation/wordpress.js +341 -0
  353. package/dist/federation/wordpress.js.map +1 -0
  354. package/dist/files/atomic.d.ts +103 -0
  355. package/dist/files/atomic.d.ts.map +1 -0
  356. package/dist/files/atomic.js +150 -0
  357. package/dist/files/atomic.js.map +1 -0
  358. package/dist/files/index.d.ts +3 -0
  359. package/dist/files/index.d.ts.map +1 -0
  360. package/dist/files/index.js +2 -0
  361. package/dist/files/index.js.map +1 -0
  362. package/dist/forms/protection.d.ts +85 -0
  363. package/dist/forms/protection.d.ts.map +1 -0
  364. package/dist/forms/protection.js +116 -0
  365. package/dist/forms/protection.js.map +1 -0
  366. package/dist/images/icons.d.ts +59 -0
  367. package/dist/images/icons.d.ts.map +1 -0
  368. package/dist/images/icons.js +131 -0
  369. package/dist/images/icons.js.map +1 -0
  370. package/dist/images/index.d.ts +7 -0
  371. package/dist/images/index.d.ts.map +1 -0
  372. package/dist/images/index.js +4 -0
  373. package/dist/images/index.js.map +1 -0
  374. package/dist/images/markup.d.ts +47 -0
  375. package/dist/images/markup.d.ts.map +1 -0
  376. package/dist/images/markup.js +175 -0
  377. package/dist/images/markup.js.map +1 -0
  378. package/dist/images/paths.d.ts +34 -0
  379. package/dist/images/paths.d.ts.map +1 -0
  380. package/dist/images/paths.js +41 -0
  381. package/dist/images/paths.js.map +1 -0
  382. package/dist/images/variants.d.ts +104 -0
  383. package/dist/images/variants.d.ts.map +1 -0
  384. package/dist/images/variants.js +320 -0
  385. package/dist/images/variants.js.map +1 -0
  386. package/dist/index.d.ts +123 -19
  387. package/dist/index.d.ts.map +1 -1
  388. package/dist/index.js +344 -25
  389. package/dist/index.js.map +1 -1
  390. package/dist/init.d.ts +18 -0
  391. package/dist/init.d.ts.map +1 -1
  392. package/dist/init.js +34 -3
  393. package/dist/init.js.map +1 -1
  394. package/dist/mail/brevo.d.ts +35 -0
  395. package/dist/mail/brevo.d.ts.map +1 -0
  396. package/dist/mail/brevo.js +85 -0
  397. package/dist/mail/brevo.js.map +1 -0
  398. package/dist/mail/credentials.d.ts +62 -0
  399. package/dist/mail/credentials.d.ts.map +1 -0
  400. package/dist/mail/credentials.js +86 -0
  401. package/dist/mail/credentials.js.map +1 -0
  402. package/dist/mail/index.d.ts +15 -0
  403. package/dist/mail/index.d.ts.map +1 -0
  404. package/dist/mail/index.js +8 -0
  405. package/dist/mail/index.js.map +1 -0
  406. package/dist/mail/memory.d.ts +35 -0
  407. package/dist/mail/memory.d.ts.map +1 -0
  408. package/dist/mail/memory.js +29 -0
  409. package/dist/mail/memory.js.map +1 -0
  410. package/dist/mail/provider.d.ts +80 -0
  411. package/dist/mail/provider.d.ts.map +1 -0
  412. package/dist/mail/provider.js +23 -0
  413. package/dist/mail/provider.js.map +1 -0
  414. package/dist/mail/service.d.ts +156 -0
  415. package/dist/mail/service.d.ts.map +1 -0
  416. package/dist/mail/service.js +265 -0
  417. package/dist/mail/service.js.map +1 -0
  418. package/dist/mail/smtp.d.ts +39 -0
  419. package/dist/mail/smtp.d.ts.map +1 -0
  420. package/dist/mail/smtp.js +58 -0
  421. package/dist/mail/smtp.js.map +1 -0
  422. package/dist/mail/templates.d.ts +91 -0
  423. package/dist/mail/templates.d.ts.map +1 -0
  424. package/dist/mail/templates.js +69 -0
  425. package/dist/mail/templates.js.map +1 -0
  426. package/dist/notifications/comments.d.ts +66 -0
  427. package/dist/notifications/comments.d.ts.map +1 -0
  428. package/dist/notifications/comments.js +151 -0
  429. package/dist/notifications/comments.js.map +1 -0
  430. package/dist/notifications/digest.d.ts +135 -0
  431. package/dist/notifications/digest.d.ts.map +1 -0
  432. package/dist/notifications/digest.js +287 -0
  433. package/dist/notifications/digest.js.map +1 -0
  434. package/dist/notifications/index.d.ts +13 -0
  435. package/dist/notifications/index.d.ts.map +1 -0
  436. package/dist/notifications/index.js +8 -0
  437. package/dist/notifications/index.js.map +1 -0
  438. package/dist/notifications/links.d.ts +60 -0
  439. package/dist/notifications/links.d.ts.map +1 -0
  440. package/dist/notifications/links.js +63 -0
  441. package/dist/notifications/links.js.map +1 -0
  442. package/dist/notifications/optouts.d.ts +48 -0
  443. package/dist/notifications/optouts.d.ts.map +1 -0
  444. package/dist/notifications/optouts.js +93 -0
  445. package/dist/notifications/optouts.js.map +1 -0
  446. package/dist/notifications/preferences.d.ts +158 -0
  447. package/dist/notifications/preferences.d.ts.map +1 -0
  448. package/dist/notifications/preferences.js +195 -0
  449. package/dist/notifications/preferences.js.map +1 -0
  450. package/dist/notifications/routes.d.ts +28 -0
  451. package/dist/notifications/routes.d.ts.map +1 -0
  452. package/dist/notifications/routes.js +184 -0
  453. package/dist/notifications/routes.js.map +1 -0
  454. package/dist/notifications/tokens.d.ts +60 -0
  455. package/dist/notifications/tokens.d.ts.map +1 -0
  456. package/dist/notifications/tokens.js +125 -0
  457. package/dist/notifications/tokens.js.map +1 -0
  458. package/dist/notify.d.ts +84 -0
  459. package/dist/notify.d.ts.map +1 -0
  460. package/dist/notify.js +149 -0
  461. package/dist/notify.js.map +1 -0
  462. package/dist/web/archive.d.ts +49 -0
  463. package/dist/web/archive.d.ts.map +1 -0
  464. package/dist/web/archive.js +59 -0
  465. package/dist/web/archive.js.map +1 -0
  466. package/dist/web/assets.d.ts +6 -5
  467. package/dist/web/assets.d.ts.map +1 -1
  468. package/dist/web/assets.js +5 -7
  469. package/dist/web/assets.js.map +1 -1
  470. package/dist/web/authors.d.ts +143 -0
  471. package/dist/web/authors.d.ts.map +1 -0
  472. package/dist/web/authors.js +178 -0
  473. package/dist/web/authors.js.map +1 -0
  474. package/dist/web/comments.d.ts +60 -0
  475. package/dist/web/comments.d.ts.map +1 -0
  476. package/dist/web/comments.js +207 -0
  477. package/dist/web/comments.js.map +1 -0
  478. package/dist/web/context.d.ts +162 -28
  479. package/dist/web/context.d.ts.map +1 -1
  480. package/dist/web/context.js +123 -26
  481. package/dist/web/context.js.map +1 -1
  482. package/dist/web/conversation.d.ts +209 -0
  483. package/dist/web/conversation.d.ts.map +1 -0
  484. package/dist/web/conversation.js +557 -0
  485. package/dist/web/conversation.js.map +1 -0
  486. package/dist/web/documents.d.ts +36 -6
  487. package/dist/web/documents.d.ts.map +1 -1
  488. package/dist/web/documents.js +52 -10
  489. package/dist/web/documents.js.map +1 -1
  490. package/dist/web/feed-atom.d.ts +22 -0
  491. package/dist/web/feed-atom.d.ts.map +1 -0
  492. package/dist/web/feed-atom.js +83 -0
  493. package/dist/web/feed-atom.js.map +1 -0
  494. package/dist/web/feed-item.d.ts +145 -0
  495. package/dist/web/feed-item.d.ts.map +1 -0
  496. package/dist/web/feed-item.js +120 -0
  497. package/dist/web/feed-item.js.map +1 -0
  498. package/dist/web/feed-json.d.ts +79 -0
  499. package/dist/web/feed-json.d.ts.map +1 -0
  500. package/dist/web/feed-json.js +53 -0
  501. package/dist/web/feed-json.js.map +1 -0
  502. package/dist/web/feed-rss.d.ts +26 -0
  503. package/dist/web/feed-rss.d.ts.map +1 -0
  504. package/dist/web/feed-rss.js +203 -0
  505. package/dist/web/feed-rss.js.map +1 -0
  506. package/dist/web/feed-source.d.ts +244 -0
  507. package/dist/web/feed-source.d.ts.map +1 -0
  508. package/dist/web/feed-source.js +201 -0
  509. package/dist/web/feed-source.js.map +1 -0
  510. package/dist/web/feed-xml.d.ts +63 -0
  511. package/dist/web/feed-xml.d.ts.map +1 -0
  512. package/dist/web/feed-xml.js +116 -0
  513. package/dist/web/feed-xml.js.map +1 -0
  514. package/dist/web/feeds.d.ts +31 -96
  515. package/dist/web/feeds.d.ts.map +1 -1
  516. package/dist/web/feeds.js +94 -198
  517. package/dist/web/feeds.js.map +1 -1
  518. package/dist/web/health.d.ts +31 -0
  519. package/dist/web/health.d.ts.map +1 -0
  520. package/dist/web/health.js +44 -0
  521. package/dist/web/health.js.map +1 -0
  522. package/dist/web/index.d.ts +30 -10
  523. package/dist/web/index.d.ts.map +1 -1
  524. package/dist/web/index.js +18 -7
  525. package/dist/web/index.js.map +1 -1
  526. package/dist/web/navigation.d.ts +195 -0
  527. package/dist/web/navigation.d.ts.map +1 -0
  528. package/dist/web/navigation.js +300 -0
  529. package/dist/web/navigation.js.map +1 -0
  530. package/dist/web/negotiate.d.ts +31 -0
  531. package/dist/web/negotiate.d.ts.map +1 -1
  532. package/dist/web/negotiate.js +31 -11
  533. package/dist/web/negotiate.js.map +1 -1
  534. package/dist/web/recent.d.ts +43 -0
  535. package/dist/web/recent.d.ts.map +1 -0
  536. package/dist/web/recent.js +37 -0
  537. package/dist/web/recent.js.map +1 -0
  538. package/dist/web/render.d.ts +203 -9
  539. package/dist/web/render.d.ts.map +1 -1
  540. package/dist/web/render.js +341 -17
  541. package/dist/web/render.js.map +1 -1
  542. package/dist/web/routes.d.ts +25 -8
  543. package/dist/web/routes.d.ts.map +1 -1
  544. package/dist/web/routes.js +854 -93
  545. package/dist/web/routes.js.map +1 -1
  546. package/dist/web/sanitize.d.ts +19 -0
  547. package/dist/web/sanitize.d.ts.map +1 -0
  548. package/dist/web/sanitize.js +272 -0
  549. package/dist/web/sanitize.js.map +1 -0
  550. package/dist/web/search.d.ts +79 -0
  551. package/dist/web/search.d.ts.map +1 -0
  552. package/dist/web/search.js +94 -0
  553. package/dist/web/search.js.map +1 -0
  554. package/dist/web/sitemap.d.ts +120 -0
  555. package/dist/web/sitemap.d.ts.map +1 -0
  556. package/dist/web/sitemap.js +199 -0
  557. package/dist/web/sitemap.js.map +1 -0
  558. package/dist/web/taxonomy.d.ts +152 -0
  559. package/dist/web/taxonomy.d.ts.map +1 -0
  560. package/dist/web/taxonomy.js +237 -0
  561. package/dist/web/taxonomy.js.map +1 -0
  562. package/dist/web/templates.d.ts +54 -16
  563. package/dist/web/templates.d.ts.map +1 -1
  564. package/dist/web/templates.js +109 -28
  565. package/dist/web/templates.js.map +1 -1
  566. package/dist/web/themes.d.ts +226 -0
  567. package/dist/web/themes.d.ts.map +1 -0
  568. package/dist/web/themes.js +314 -0
  569. package/dist/web/themes.js.map +1 -0
  570. package/dist/webmention/discovery.d.ts +51 -0
  571. package/dist/webmention/discovery.d.ts.map +1 -0
  572. package/dist/webmention/discovery.js +176 -0
  573. package/dist/webmention/discovery.js.map +1 -0
  574. package/dist/webmention/html.d.ts +71 -0
  575. package/dist/webmention/html.d.ts.map +1 -0
  576. package/dist/webmention/html.js +285 -0
  577. package/dist/webmention/html.js.map +1 -0
  578. package/dist/webmention/index.d.ts +12 -0
  579. package/dist/webmention/index.d.ts.map +1 -0
  580. package/dist/webmention/index.js +8 -0
  581. package/dist/webmention/index.js.map +1 -0
  582. package/dist/webmention/links.d.ts +18 -0
  583. package/dist/webmention/links.d.ts.map +1 -0
  584. package/dist/webmention/links.js +64 -0
  585. package/dist/webmention/links.js.map +1 -0
  586. package/dist/webmention/microformats.d.ts +93 -0
  587. package/dist/webmention/microformats.d.ts.map +1 -0
  588. package/dist/webmention/microformats.js +376 -0
  589. package/dist/webmention/microformats.js.map +1 -0
  590. package/dist/webmention/receive.d.ts +141 -0
  591. package/dist/webmention/receive.d.ts.map +1 -0
  592. package/dist/webmention/receive.js +264 -0
  593. package/dist/webmention/receive.js.map +1 -0
  594. package/dist/webmention/routes.d.ts +37 -0
  595. package/dist/webmention/routes.d.ts.map +1 -0
  596. package/dist/webmention/routes.js +75 -0
  597. package/dist/webmention/routes.js.map +1 -0
  598. package/dist/webmention/service.d.ts +94 -0
  599. package/dist/webmention/service.d.ts.map +1 -0
  600. package/dist/webmention/service.js +207 -0
  601. package/dist/webmention/service.js.map +1 -0
  602. package/package.json +11 -6
  603. package/templates/site/content/_data/site.json +8 -0
  604. package/templates/site/content/pages/about.md +7 -0
  605. package/templates/site/content/posts/2026-01-01-hello-world.md +5 -3
  606. package/templates/site/geekity.config.ts +11 -1
  607. package/templates/site/tsconfig.json +1 -1
  608. package/themes/default/README.md +1216 -50
  609. package/themes/default/layouts/404.njk +2 -3
  610. package/themes/default/layouts/author.njk +36 -0
  611. package/themes/default/layouts/base.njk +161 -31
  612. package/themes/default/layouts/category.njk +27 -0
  613. package/themes/default/layouts/front-page.njk +56 -0
  614. package/themes/default/layouts/home.njk +17 -1
  615. package/themes/default/layouts/page.njk +57 -5
  616. package/themes/default/layouts/post.njk +78 -19
  617. package/themes/default/layouts/search.njk +57 -0
  618. package/themes/default/layouts/tag.njk +3 -6
  619. package/themes/default/mail/comment-digest.html.njk +37 -0
  620. package/themes/default/mail/comment-digest.subject.njk +3 -0
  621. package/themes/default/mail/comment-digest.txt.njk +40 -0
  622. package/themes/default/mail/comment-pending.html.njk +27 -0
  623. package/themes/default/mail/comment-pending.subject.njk +2 -0
  624. package/themes/default/mail/comment-pending.txt.njk +24 -0
  625. package/themes/default/mail/comment-reply.html.njk +21 -0
  626. package/themes/default/mail/comment-reply.subject.njk +2 -0
  627. package/themes/default/mail/comment-reply.txt.njk +22 -0
  628. package/themes/default/mail/contact-message.html.njk +24 -0
  629. package/themes/default/mail/contact-message.subject.njk +5 -0
  630. package/themes/default/mail/contact-message.txt.njk +20 -0
  631. package/themes/default/mail/password-changed.html.njk +18 -0
  632. package/themes/default/mail/password-changed.subject.njk +2 -0
  633. package/themes/default/mail/password-changed.txt.njk +17 -0
  634. package/themes/default/mail/password-reset.html.njk +22 -0
  635. package/themes/default/mail/password-reset.subject.njk +2 -0
  636. package/themes/default/mail/password-reset.txt.njk +18 -0
  637. package/themes/default/mail/test.html.njk +14 -0
  638. package/themes/default/mail/test.subject.njk +2 -0
  639. package/themes/default/mail/test.txt.njk +12 -0
  640. package/themes/default/partials/archive.njk +27 -0
  641. package/themes/default/partials/bio.njk +71 -0
  642. package/themes/default/partials/comment-form.njk +137 -0
  643. package/themes/default/partials/contact-form.njk +88 -0
  644. package/themes/default/partials/conversation.njk +139 -0
  645. package/themes/default/partials/feeds.njk +26 -0
  646. package/themes/default/partials/jsonld.njk +120 -0
  647. package/themes/default/partials/menu.njk +32 -0
  648. package/themes/default/partials/pagination.njk +12 -6
  649. package/themes/default/partials/post-list.njk +51 -18
  650. package/themes/default/partials/search-form.njk +22 -0
  651. package/themes/default/partials/tags.njk +26 -5
  652. package/themes/default/static/highlight.js +4 -0
  653. package/themes/default/static/style.css +1142 -194
  654. package/themes/default/theme.json +9 -0
  655. package/admin/layouts/federation.njk +0 -145
  656. package/admin/layouts/login.njk +0 -23
  657. package/admin/layouts/settings.njk +0 -116
  658. package/admin/layouts/setup.njk +0 -27
  659. package/admin/layouts/users.njk +0 -91
  660. /package/admin/{layouts/_flash.njk → components/flash.njk} +0 -0
@@ -5,17 +5,38 @@ without copying anything.
5
5
 
6
6
  ```
7
7
  themes/default/
8
+ theme.json the manifest: name, kind and description
8
9
  layouts/
9
10
  base.njk the page every other layout extends
10
11
  home.njk the post archive, paginated
12
+ front-page.njk the page the Reading setting serves at /
11
13
  post.njk one post
12
14
  page.njk one page
13
15
  tag.njk a tag archive, paginated
16
+ category.njk a category archive, paginated
17
+ author.njk one person's archive, paginated
18
+ search.njk the search form and what it found, paginated
14
19
  404.njk nothing at this URL
15
20
  partials/
16
- post-list.njk a list of documents
17
- pagination.njk previous/next pager
18
- tags.njk macros for tag links
21
+ post-list.njk the h-feed a listing is made of
22
+ pagination.njk previous/next pager
23
+ tags.njk macros for tag and category links
24
+ bio.njk who an entry is by, as an h-card
25
+ menu.njk one named menu, as a nav of links
26
+ feeds.njk macros for the feed links in <head>
27
+ conversation.njk the replies, likes and boosts under a post
28
+ comment-form.njk the form under a post that is taking comments
29
+ contact-form.njk the form on a page whose front matter says contact: true
30
+ archive.njk every post by month, on a page that says archive: true
31
+ search-form.njk the search box, on the search page
32
+ mail/
33
+ test.*.njk the Send test email message
34
+ password-reset.*.njk the forgot-password link
35
+ password-changed.*.njk the notice sent once a password has been set
36
+ comment-pending.*.njk the moderation notice
37
+ comment-digest.*.njk the hourly or daily digest of what is waiting
38
+ comment-reply.*.njk the notice a commenter gets about a reply
39
+ contact-message.*.njk a message from a page's contact form
19
40
  static/
20
41
  style.css served at /theme/style.css
21
42
  ```
@@ -23,18 +44,62 @@ themes/default/
23
44
  Plain Nunjucks and plain CSS. There is no build step and no dependency: a site
24
45
  that wants Tailwind or anything else brings its own.
25
46
 
47
+ ## The manifest
48
+
49
+ `theme.json` is what makes a directory a theme:
50
+
51
+ ```json
52
+ {
53
+ "name": "Default",
54
+ "kind": "site",
55
+ "description": "One line about the theme."
56
+ }
57
+ ```
58
+
59
+ `name` is what a person sees, `kind` is `site` — the only kind there is, and
60
+ the field exists so another can be added later without the format changing —
61
+ and `description` is optional. The directory name is the theme's id. A
62
+ directory without a readable manifest, or one naming a kind this CMS does not
63
+ have, is not a theme.
64
+
26
65
  ## Overriding a template
27
66
 
28
- Resolution order for any template is the site's own `themeDir` (`theme/` by
29
- default) first, then this directory, one file at a time. A site that ships only
67
+ A site keeps its themes under one directory, `themes/` by default and
68
+ `themesDir` in the config, with one folder per theme and a `theme.json` in
69
+ each. Which one it wears is one setting, `theme` in `content/_data/site.json`,
70
+ holding the folder's name:
30
71
 
31
72
  ```
32
- theme/layouts/post.njk
73
+ themes/
74
+ midnight/
75
+ theme.json
76
+ layouts/post.njk
77
+ static/style.css
78
+ ```
79
+
80
+ ```json
81
+ { "theme": "midnight" }
33
82
  ```
34
83
 
35
- replaces the post layout and keeps receiving updates to every other template.
36
- `theme/static/style.css` replaces the stylesheet the same way — assets under
37
- `/theme/` resolve in the same order.
84
+ The setting is what **Appearance > Themes** in the admin writes: the screen
85
+ lists this theme and every folder under `themes/`, marks the one in use, and
86
+ Activate on another writes its name — or, on the packaged theme, takes the key
87
+ out again. A change takes effect on the next request, with no restart, and a
88
+ folder whose manifest will not read is listed under "Not themes" with the
89
+ reason rather than quietly left out.
90
+
91
+ Resolution order for any template is then that theme first, this directory
92
+ second, one file at a time. The theme above replaces the post layout and keeps
93
+ receiving updates to every other template, and `static/style.css` replaces the
94
+ stylesheet the same way — assets under `/theme/` resolve in the same order. A
95
+ site that has chosen no theme reads this one and nothing else, and a theme
96
+ sitting in `themes/` that the setting does not name is never on the path at
97
+ all. A `theme` naming a folder that is not there, or is not a theme, falls back
98
+ to this one with a warning in the log rather than a broken site.
99
+
100
+ The admin is not a theme and has no setting. Its templates live in their own
101
+ tree with a loader of their own, off this search path entirely, so no theme can
102
+ shadow the login form or the CSRF field inside it.
38
103
 
39
104
  An override is a normal Nunjucks template, so it can extend or include the
40
105
  packaged ones by name:
@@ -53,6 +118,586 @@ packaged ones by name:
53
118
  `layouts/base.njk` defines the blocks `title`, `head`, `alternates`, `header`,
54
119
  `content`, `footer` and `scripts`, so most sites never have to copy it.
55
120
 
121
+ Two layouts are named for the pages the Reading settings pick:
122
+
123
+ | Template | Rendered for | Shipped here | Falls back to |
124
+ | ------------------------ | ----------------------------- | ------------ | ------------------ |
125
+ | `layouts/front-page.njk` | the page served at `/` | yes | `layouts/page.njk` |
126
+ | `layouts/posts-page.njk` | the listing on the posts page | no | `layouts/home.njk` |
127
+
128
+ `layouts/front-page.njk` is a layout here rather than only an override point:
129
+ see [The front page](#the-front-page) for what it draws. Overriding it is how a
130
+ front page is laid out differently from every other page without overriding the
131
+ layout they all use, and the fallback above is reached only by a theme that
132
+ replaced it with nothing.
133
+
134
+ There is no posts page layout: a site that sets one gets the listing layout
135
+ until it writes one. On the posts page the page's own front matter and rendered
136
+ body are on the context beside the listing, so `{{ content | safe }}` prints its
137
+ words above the posts; `layouts/home.njk` already does.
138
+
139
+ ## The page shell
140
+
141
+ `layouts/base.njk` is the andrewshell.org design's shell (decision-16): the
142
+ skip link, one `.global-wrapper` at the 42rem measure, `.global-header`,
143
+ `<main id="main">` and the footer, in that order.
144
+
145
+ **One home for each kind of link.** The header carries the site menu, on every
146
+ page. [The bio](#the-bio) carries the person whose page or post it is. The
147
+ footer carries the site's own links. The search box is on the search page and
148
+ nowhere else.
149
+
150
+ **The header has one rule, and the menu under it.** On the front page it is the
151
+ site title as `h1.main-heading`, linked home, with `site.tagline` in a
152
+ paragraph under it; on every other page it is `a.header-link-home`, the site
153
+ title small and linked home, and no tagline. The wrapper carries
154
+ `data-is-root-path="true"` at `/` and nothing anywhere else, which is how the
155
+ stylesheet tells the two apart. `menus.primary` is printed inside the
156
+ `.global-header` on both: at the root it is a line of its own under the
157
+ tagline, and everywhere else the stylesheet lays the header out as one line so
158
+ it sits beside the link home. See [Navigation](#navigation).
159
+
160
+ **The footer** prints the copyright with the current year and `site.author`,
161
+ `Published with Geekity`, and then `menus.footer` — the `footer` area this
162
+ theme declares, and the whole of what the footer links. A site that wants its
163
+ feed there types an `RSS | /feed/` line into it, the way the starter site does.
164
+ Nothing in the footer is read off an account: it used to hold one `rel="me"`
165
+ link per entry of `siteAuthor.links`, which was one nominated user's profile
166
+ presented as the site's. An empty or missing footer menu prints no list at all,
167
+ and the copyright line stands on its own. The year is
168
+ `{{ "now" | date("year") }}` — `now` is the one word the `date` filter reads
169
+ rather than parses — so it is the year at the moment the page is rendered, in
170
+ the site's own timezone.
171
+
172
+ Webrings, badges, a licence notice and anything else that is markup rather than
173
+ a link are deliberately not in the package. They go in a site theme's `footer`
174
+ block:
175
+
176
+ ```njk
177
+ {% extends "layouts/base.njk" %}
178
+
179
+ {% block footer %}
180
+ {{ super() }}
181
+ <p class="webring">
182
+ <a href="https://example.ring/previous">&larr;</a>
183
+ An <a href="https://example.ring">example webring</a>
184
+ <a href="https://example.ring/next">&rarr;</a>
185
+ </p>
186
+ {% endblock %}
187
+ ```
188
+
189
+ ### An entry
190
+
191
+ `layouts/post.njk` and `layouts/page.njk` draw the same thing, the source
192
+ design's entry, and it is a microformats2 `h-entry`:
193
+
194
+ ```html
195
+ <article class="blog-post h-entry">
196
+ <header>
197
+ <h1 class="p-name">Hello</h1>
198
+ </header>
199
+ <section class="e-content">
200
+ <p>The rendered body.</p>
201
+ <p class="entry-meta">
202
+ <a class="u-category small" href="https://news.indieweb.org/en"
203
+ >#indienews</a
204
+ >
205
+ <a href="/2026/09/hello/" class="u-url"
206
+ ><time class="small dt-published" datetime="2026-09-02T09:00:00.000Z"
207
+ >Published 2 September 2026</time
208
+ ></a
209
+ >
210
+ <br /><time class="small dt-updated" datetime="2026-09-05T09:00:00.000Z"
211
+ >Updated 5 September 2026</time
212
+ >
213
+ </p>
214
+ </section>
215
+ <p class="post-categories">…categories…</p>
216
+ <p class="post-categories">…tags…</p>
217
+ <hr />
218
+ <footer>…the bio…</footer>
219
+ </article>
220
+ <nav class="blog-post-nav">…the posts either side…</nav>
221
+ ```
222
+
223
+ **The Published line is inside the `e-content`**, which is the one thing about
224
+ this shape worth knowing. It is what the source theme does: the date and the
225
+ permalink are part of the words, so a reader — or a fediverse peer reading the
226
+ `e-content` — takes them with the post. The permalink wraps the `dt-published`
227
+ time as a `u-url`, and a `dt-updated` line follows it only when the update
228
+ happened on a different day in the site's own timezone; a typo fixed an hour
229
+ later is not news. A post tagged `indienews` opens the line with a
230
+ `u-category` link to <https://news.indieweb.org/en>, which is how IndieNews is
231
+ told the post is for it; a page never prints one, and its line is a
232
+ `p.page-meta` rather than a `p.entry-meta`.
233
+
234
+ What it is filed under is printed under the words and inside the article, so
235
+ that each link is a `p-category` of this entry: the categories first, then the
236
+ tags, each as one `p.post-categories` from `partials/tags.njk`.
237
+
238
+ After the entry a post prints `nav.blog-post-nav`, `rel="prev"` and
239
+ `rel="next"` links to `previous` and `next` with an arrow either side, and
240
+ nothing at all at the ends of the archive; then the conversation and the
241
+ comment form. A page prints the contact form when its front matter asked for
242
+ one. A page has no neighbours, no taxonomy and no IndieNews link, because none
243
+ of those are things a page has.
244
+
245
+ ### The bio
246
+
247
+ `partials/bio.njk` is who the page is by: the `p-author h-card` the rule and
248
+ the footer at the end of an entry hold, and the heading of an author archive.
249
+ It replaces `partials/byline.njk`, which is gone — a byline was a name in a
250
+ meta line, and this is the whole credit.
251
+
252
+ ```njk
253
+ {% set bioAuthor = author or siteAuthor %}
254
+ {% include "partials/bio.njk" %}
255
+ ```
256
+
257
+ `bioAuthor` is the one thing to set, and nothing renders when it is absent.
258
+ It is one of the profile objects the context already carries: `author` on a
259
+ post, falling back to `siteAuthor`; `siteAuthor` on a page; the archive's
260
+ person on an author archive.
261
+
262
+ What the bio prints: a round `u-photo` at 50px when they have an avatar,
263
+ "Written by" and their name as a `p-name u-url` linked `rel="author me"` to
264
+ their archive, then `p-job-title` and `p-locality` when the profile says, then
265
+ their `p-note` and their `rel="me"` links as a `ul.hlist.bio-links`. Each of
266
+ those is printed only when the profile says it, so a profile holding a name
267
+ alone prints a name alone. A name this site has no account for is printed
268
+ unlinked, because the file still said somebody wrote this.
269
+
270
+ **The person and only the person.** The site menu used to be printed here as
271
+ well, because the design had no header navigation; it is in the header on
272
+ every page now — see [Navigation](#navigation) — so the bio says nothing about
273
+ the site. The note and the links used to be behind a `bioProfile` switch that
274
+ an author archive set and an entry did not, on the grounds that the page
275
+ footer already carried the site's identity links. It carries nobody's, so the
276
+ switch is gone and the card is the same wherever it appears: at the top of
277
+ somebody's archive, and under each of their posts.
278
+
279
+ `bioLead` is what the line opens with, `Written by` unless a layout sets
280
+ another; `layouts/author.njk` sets `Posts by`, because the card there heads
281
+ somebody's writing rather than crediting one piece of it.
282
+
283
+ **A site that used `partials/byline.njk`** — `{{ byline.line(author) }}` from
284
+ an overridden layout — either includes this partial instead or writes the line
285
+ itself. It was four lines of markup, and the object behind it has not changed:
286
+
287
+ ```njk
288
+ {% set writer = author or { name: site.author } %}
289
+ <span class="p-author h-card">
290
+ {%- if writer.url %}<a class="u-url" href="{{ writer.url | url }}">{{ writer.name }}</a>
291
+ {%- else %}{{ writer.name }}{% endif %}
292
+ </span>
293
+ ```
294
+
295
+ `apps/demo/themes/demo/layouts/post.njk` is that, worked: the demo wants a
296
+ byline under the title rather than a bio in the footer, so it writes one.
297
+
298
+ ### A listing
299
+
300
+ `partials/post-list.njk` is the feed every listing is made of — the home page,
301
+ the posts page, a tag, category or author archive — and it is a microformats2
302
+ `h-feed`:
303
+
304
+ ```html
305
+ <div class="feed h-feed">
306
+ <article class="feed-item h-entry">
307
+ <div class="feed-content">
308
+ <h2 class="feed-title p-name">
309
+ <a href="/2026/09/hello/" class="u-url">Hello</a>
310
+ </h2>
311
+ <div class="feed-excerpt p-summary">
312
+ <p>What the post is about, in about 280 characters...</p>
313
+ </div>
314
+ <p class="feed-more">
315
+ <a href="/hello/" aria-label="Continue reading: Hello">
316
+ Continue reading<span aria-hidden="true"> &rarr;</span>
317
+ </a>
318
+ </p>
319
+ <div class="feed-meta">
320
+ <p>
321
+ <time class="feed-date dt-published" datetime="…"
322
+ >2 September 2026</time
323
+ >
324
+ </p>
325
+ <p class="post-categories">
326
+ <a href="/category/notes/" class="p-category" rel="category">notes</a>
327
+ </p>
328
+ </div>
329
+ </div>
330
+ </article>
331
+ <hr class="feed-separator" />
332
+ <article class="feed-item h-entry">…</article>
333
+ </div>
334
+ ```
335
+
336
+ The excerpt is the entry's `summary` — the very line the feeds publish, so a
337
+ reader and a feed cannot be told two different things — through Nunjucks'
338
+ `truncate(280)`, which cuts at the last space before 280 characters and adds an
339
+ ellipsis. A `description` shorter than that is printed whole. It is plain text
340
+ rather than markup, deliberately: an excerpt with half a code block in it is
341
+ not an excerpt.
342
+
343
+ An entry's tags are not in a feed item. They belong under the entry, where
344
+ there is one post's worth of them rather than twenty.
345
+
346
+ **The heading level belongs to the layout**, not to the list. `feedHeading` is
347
+ the level `post-list.njk` prints its titles at and defaults to 2, which is what
348
+ a listing wants; a page whose feed sits under a heading of its own sets 3
349
+ before including it, so the outline never skips a level:
350
+
351
+ ```njk
352
+ {% set feedHeading = 3 %}
353
+ {% include "partials/post-list.njk" %}
354
+ ```
355
+
356
+ A listing with nothing on it says `No posts found.` in `p.empty`.
357
+
358
+ `partials/pagination.njk` prints two arrows in `nav.pagination` — `← Previous`
359
+ to the page before this one and `Next →` to the page after it, with `rel="prev"`
360
+ and `rel="next"` — and nothing at all on a listing of one page. The arrows
361
+ themselves are `aria-hidden`, because a screen reader that announced them would
362
+ read the decoration and then the word.
363
+
364
+ Each listing layout heads its own page: `layouts/home.njk` with the listing's
365
+ title, `layouts/tag.njk` and `layouts/category.njk` with the term, and
366
+ `layouts/author.njk` with the person as an `h-card`. The category archive puts
367
+ its heading in a `header.category-header` and prints a `div.category-description`
368
+ under it when the context carries a `categoryDescription`; the CMS has no store
369
+ of term descriptions, so nothing writes one today and the header is the heading
370
+ alone.
371
+
372
+ `layouts/404.njk` says `Content not found.` and links home and to the search.
373
+
374
+ ### Search
375
+
376
+ `layouts/search.njk` is the page at `/search/` (TASK-22). It prints
377
+ `partials/search-form.njk`, and once there is a `query` a `p.search-summary`
378
+ saying how many documents matched, a `div.search-results` of
379
+ `article.search-result` entries, and the ordinary pager. It asks not to be
380
+ indexed with `<meta name="robots" content="noindex">` in its `head` block.
381
+
382
+ The context is a listing's with two differences. `query` is the words searched
383
+ for, trimmed, and an empty string on the page before a search. Each entry in
384
+ `posts` carries a `snippet`: a few words of
385
+ HTML around the match, escaped, with every matched word in `<mark>`. Print it
386
+ with `safe`. The results are best match first rather than newest first, and
387
+ only what the public site would serve is ever among them.
388
+
389
+ `partials/search-form.njk` is a `GET` form to `/search/` with the words in `q`,
390
+ so it needs no JavaScript and a search is a URL. This layout is the only thing
391
+ that includes it: the box used to be in the footer of every page as well, and
392
+ search is a menu item now — a `Search | /search/` line like any other. The
393
+ search page takes the path `/search/` ahead of any document permalinked there.
394
+
395
+ ### The front page
396
+
397
+ `layouts/front-page.njk` is what a site gets at `/` once the Reading settings
398
+ name a homepage (`homepage` in `content/_data/site.json`). It is that page read
399
+ somewhere else: the same document, the same context, with `page.url` saying `/`
400
+ rather than the permalink that redirects there.
401
+
402
+ It draws the page's own words and then what the site has been writing:
403
+
404
+ 1. `div.page-body.e-content`, the rendered body. No title — `layouts/base.njk`
405
+ heads the root path with the site title, and a second `h1` under it would be
406
+ one heading too many — and no Published line, because a front page is read
407
+ as the site rather than as a page somebody wrote on a Tuesday.
408
+ 2. `<h2>Recent Posts</h2>` over `partials/post-list.njk` with `feedHeading` set
409
+ to 3, so the entries sit under that heading rather than beside it.
410
+ 3. `p.front-links`, a line of links to where the writing is. The posts page is
411
+ linked by its own title when the site names one, and the search always is
412
+ (TASK-22).
413
+ 4. The bio, under a rule, exactly as an entry ends: whoever the site's author
414
+ setting names, with their note and their own links.
415
+
416
+ Two context keys are the front page's alone. `recentPosts` is the entries to
417
+ list, in the same shape a listing's are: the posts of the current month when
418
+ there are at least five of them, and the five newest otherwise. `postsPage` is
419
+ `{ title, url }` for the page carrying the listing, and is **absent** when the
420
+ site names none — a link to a listing that has no URL is a link to nothing.
421
+
422
+ ### An archive page
423
+
424
+ A page whose front matter says `archive: true` prints every post the site has
425
+ published under its own words, newest month first:
426
+
427
+ ```html
428
+ <section class="archive">
429
+ <h2>September 2026</h2>
430
+ <ol class="list-none">
431
+ <li>
432
+ <a href="/2026/09/hello/"><span>Hello</span></a>
433
+ </li>
434
+ </ol>
435
+ <h2>August 2026</h2>
436
+ …
437
+ </section>
438
+ ```
439
+
440
+ `partials/archive.njk` draws it and `layouts/page.njk` includes it. It is the
441
+ whole archive on one page and nothing paginates it: an archive page is a way of
442
+ finding one piece of writing rather than a listing to read through, which is
443
+ also why there are no excerpts on it. Drafts and posts whose date has not
444
+ arrived are absent, as they are everywhere else.
445
+
446
+ `archiveMonths` is what it loops over, and it is on the context **only** for a
447
+ page whose front matter says `archive: true`, so a layout asks:
448
+
449
+ ```njk
450
+ {% if archiveMonths %}
451
+ {% include "partials/archive.njk" %}
452
+ {% endif %}
453
+ ```
454
+
455
+ Each entry of it is `{ month, posts }`: `month` is the heading, `September
456
+ 2026`, and `posts` is `{ title, url, date }` for each post of it, newest first.
457
+ Which month a post belongs to is the CMS's answer rather than the template's,
458
+ because a date in a file is a UTC instant and the site's `timezone` is the lens
459
+ it is read through — so the grouping and the date printed under an entry are
460
+ one decision rather than two.
461
+
462
+ The key is honoured wherever it is written, exactly as `contact: true` is: a
463
+ theme that wants the list on some other kind of page adds the same three lines
464
+ to that layout.
465
+
466
+ ### The head
467
+
468
+ Beside the title, the canonical link and the feeds, every page carries a
469
+ description, Open Graph and Twitter card tags, the site's icons and one
470
+ JSON-LD graph. All of it is in the `head` block, so an override that only means
471
+ to add a tag calls `{{ super() }}` first.
472
+
473
+ **The description** is the page's own `description` from the front matter, else
474
+ the entry's `summary` — the line the feeds publish — else `site.tagline`. It is
475
+ printed once, as `<meta name="description">`, and the Open Graph and Twitter
476
+ descriptions say the same thing.
477
+
478
+ **The card.** `og:title` is the page's title, or the site's on the front page;
479
+ `og:site_name` is always the site's. `og:type` is `article` on a rendered post
480
+ or page and `website` everywhere else, a listing carrying a page's front matter
481
+ included. `og:url` is the canonical URL. `twitter:card` is `summary`, the small
482
+ square picture beside the words, because the picture is usually a face rather
483
+ than a wide photograph.
484
+
485
+ **The picture** is the `image` in the entry's front matter, else `site.avatar`.
486
+ A site with neither prints no `og:image` and no `twitter:image` rather than an
487
+ empty one.
488
+
489
+ **The icons** come from the site's avatar through the derived images
490
+ (decision-10): `icon` at 32 and 16 pixels and `apple-touch-icon` at 180, each a
491
+ square PNG cropped from the middle of the avatar and encoded the first time a
492
+ browser asks for it. They are on the context as `icons`, a list of
493
+ `{ rel, sizes, href }`, which is empty — and the links are not printed at all —
494
+ when the site has no avatar, when its avatar is a file no icon can be made of,
495
+ or when image optimization is off. There is no web manifest; a site that wants
496
+ one adds it in its own `head` block.
497
+
498
+ **The structured data** is `partials/jsonld.njk`, one `<script
499
+ type="application/ld+json">` holding one `@graph` per page, and it is the only
500
+ structured data the theme emits — there is no Microdata anywhere, by
501
+ decision-16, because the visible markup already carries microformats2 for the
502
+ IndieWeb. The graph holds:
503
+
504
+ - `WebSite`, always, with the site's title, tagline and URL, and a `publisher`
505
+ pointing at the Person, and a `potentialAction` that is a `SearchAction` on
506
+ `/search/?q={search_term_string}`.
507
+ - `Person`, from `siteAuthor`: their name, archive URL, avatar, bio, job title
508
+ and location, and a `sameAs` of their profile links and their actor id, which
509
+ is what asserts that the schema.org Person and the fediverse actor are one
510
+ identity. Identity comes from a user profile, so a site — or a byline —
511
+ naming nobody with an account here prints no Person, and the `author` and
512
+ `publisher` references go with it.
513
+ - `ProfilePage` on an author archive, whose `mainEntity` is that Person.
514
+ - `BlogPosting` on a post and `Article` on a page, with the headline, URL,
515
+ `mainEntityOfPage`, `datePublished`, `dateModified`, description, image,
516
+ `author` and `publisher`.
517
+
518
+ A site that wants a different graph — more types, an `Organization` publisher,
519
+ nothing at all — writes its own `partials/jsonld.njk` and that file replaces
520
+ this one, like any other partial.
521
+
522
+ ### Colours
523
+
524
+ `static/style.css` is the source design: a serif body and sans headings at an
525
+ 18px root on a 1.2 minor-third scale, warm paper with near-black text, a rust
526
+ primary and a blue secondary, one column, links that invert to the primary
527
+ colour on hover, and a rule in the primary colour. Everything is a custom
528
+ property on `:root`, so a site that only wants different colours overrides the
529
+ half-dozen `--color-*` tokens rather than the stylesheet.
530
+
531
+ The source is light only. The theme adds a second scheme under
532
+ `@media (prefers-color-scheme: dark)` that redefines the same colour tokens on
533
+ dark paper, with the rust and the blue lifted until they read on it, and
534
+ `color-scheme: light dark` so a browser paints its own form controls and
535
+ scrollbars to match. There is no toggle: the reader's system setting is the
536
+ setting.
537
+
538
+ Both schemes meet WCAG 2.2 AA — 4.5:1 for body text, 3:1 for large text, rules
539
+ and focus outlines — and that is a test rather than a claim.
540
+ `src/web/theme-colors.test.ts` reads the custom properties out of this
541
+ stylesheet and computes the ratios for every pair the design puts on screen, so
542
+ a colour changed here that breaks one fails the build.
543
+
544
+ | Token | What it colours |
545
+ | ------------------------- | -------------------------------------------------- |
546
+ | `--color-body` | The paper, and the text of an inverted link. |
547
+ | `--color-text` | The ink. |
548
+ | `--color-primary` | Links, the rule, the focus outline, table headers. |
549
+ | `--color-secondary` | Blockquote text and its border. |
550
+ | `--color-base` | A warm sunk surface: rules between entries. |
551
+ | `--color-base-2` | A cooler sunk surface. |
552
+ | `--color-base-3` | The raised surface: the focused skip link, inputs. |
553
+ | `--color-code-background` | Behind `code` and a fenced block. |
554
+ | `--color-code-text` | Code with no highlighting on it. |
555
+ | `--color-error` | A form field that will not do. |
556
+
557
+ ### Code highlighting
558
+
559
+ The CMS renders a fenced block as `<pre tabindex="0"><code
560
+ class="language-x">` and highlights nothing: highlighting is a theme's, and it
561
+ happens on the client (decision-16). The `tabindex` is the core's doing and is
562
+ there on every `<pre>`, because a block wider than the measure scrolls sideways
563
+ and a scroll container nothing can focus cannot be read from a keyboard.
564
+
565
+ This theme highlights with **[highlight.js](https://highlightjs.org/)**,
566
+ self-hosted at `static/highlight.js` and pinned — nothing is fetched from a
567
+ CDN. The `scripts` block of `layouts/base.njk` loads it, deferred, **only on a
568
+ page whose rendered body holds a `language-` class**, so a page with no code on
569
+ it ships no JavaScript at all. Auto-detection is off: a block with no language,
570
+ or one naming a language the bundle does not carry, is left exactly as the
571
+ renderer wrote it — its class, its plain text, no highlighting.
572
+
573
+ The bundle carries the core and eighteen grammars — `bash`, `css`, `diff`,
574
+ `dockerfile`, `go`, `ini`, `javascript`, `json`, `markdown`, `nginx`, `php`,
575
+ `python`, `rust`, `shell`, `sql`, `typescript`, `xml` and `yaml`, with their
576
+ aliases, so `js`, `ts`, `html`, `yml` and `sh` all work. It is a build product
577
+ that is nevertheless committed, so a site gets a working highlighter out of the
578
+ package with no build step. Rebuilding it is one command:
579
+
580
+ ```
581
+ pnpm --filter @geekity/cms build:highlight
582
+ ```
583
+
584
+ Run that after bumping the `highlight.js` devDependency or editing the language
585
+ list in `scripts/build-highlight.js`, and commit what it writes.
586
+ `src/web/highlight-bundle.test.ts` fails when the committed file was built from
587
+ a different version of highlight.js than the one installed.
588
+
589
+ **The palette is Tomorrow**, the one both of the sites this design comes from
590
+ used: Chris Kempson's Tomorrow on light paper and Tomorrow Night on dark, from
591
+ highlight.js's own base16 themes, which is also what decides which `hljs-`
592
+ scope takes which slot. Five of the light colours are not the stock ones — a
593
+ syntax theme is drawn for an editor, where a faint comment is a feature, and on
594
+ this paper the stock greys, orange, yellow, green and teal read between 1.7:1
595
+ and 3.5:1. Each was darkened along its own hue until it made 4.5:1 and no
596
+ further; on the dark scheme only the `base0F` brown needed lifting. Every one
597
+ of them is in the contrast test beside the rest of the palette.
598
+
599
+ | Token | base16 | What it colours |
600
+ | --------------------------------- | -------- | ------------------------------------------ |
601
+ | `--color-code-comment` | `base03` | Comments. |
602
+ | `--color-code-tag` | `base04` | Markup tags. |
603
+ | `--color-code-name` | `base08` | Variables, names, selectors, list bullets. |
604
+ | `--color-code-literal` | `base09` | Numbers, constants, attributes, links. |
605
+ | `--color-code-class` | `base0A` | Class names, bold. |
606
+ | `--color-code-string` | `base0B` | Strings and inline code. |
607
+ | `--color-code-support` | `base0C` | Built-ins, regexps, quotes. |
608
+ | `--color-code-function` | `base0D` | Function names, headings, attributes. |
609
+ | `--color-code-keyword` | `base0E` | Keywords, types, italic. |
610
+ | `--color-code-meta` | `base0F` | Preprocessor and embedded-language lines. |
611
+ | `--color-code-added-background` | — | An added line in a diff. |
612
+ | `--color-code-removed-background` | — | A removed line in a diff. |
613
+
614
+ Delimiters, operators and substitutions are deliberately `--color-code-text`,
615
+ the block's own ink: they are most of the characters on screen and a colour
616
+ there is noise. A `diff` block reads by the line rather than by the token, the
617
+ way the source site's `prism-diff.css` did: `.hljs-addition` and
618
+ `.hljs-deletion` take a tint out to the edges of the block and keep the ink, so
619
+ the `+` and the `-` in the text carry the meaning as well as the colour does.
620
+
621
+ **Swapping it.** Four sizes of change, smallest first:
622
+
623
+ - _Another palette, same highlighter._ Redefine the dozen `--color-code-*`
624
+ tokens. Note that `static/style.css` is an all-or-nothing override: a theme
625
+ that ships its own stylesheet owns these rules too, and a fenced block on it
626
+ is unhighlighted until it writes them.
627
+ - _Other languages._ Edit `LANGUAGES` in `scripts/build-highlight.js`, rebuild,
628
+ and commit — or put your own `static/highlight.js` in your theme, which the
629
+ `/theme/` route resolves before the packaged one.
630
+ - _Another highlighter._ Override the `scripts` block with your own tag. The
631
+ markup is `pre > code.language-x`, which is what every highlighter reads.
632
+ - _None._ Override the `scripts` block with nothing in it.
633
+
634
+ ```njk
635
+ {% extends "layouts/base.njk" %}
636
+
637
+ {% block scripts %}{% endblock %}
638
+ ```
639
+
640
+ ## Mail templates
641
+
642
+ The messages the CMS sends live under `mail/` and resolve the same way, so a
643
+ `mail/test.txt.njk` in the theme the site wears replaces the text of the test
644
+ message and leaves its subject and HTML twin coming from the package. Each message is up to three
645
+ files:
646
+
647
+ | File | What it is |
648
+ | ------------------------- | -------------------------------------------------------------- |
649
+ | `mail/<name>.subject.njk` | The subject. Rendered to a single line. Optional. |
650
+ | `mail/<name>.txt.njk` | The plain text body. **Required.** |
651
+ | `mail/<name>.html.njk` | The HTML twin. Optional; without it the message is plain text. |
652
+
653
+ A message that has no `.txt.njk` anywhere on the search path is an error rather
654
+ than an empty email, so a typo in a template name is reported instead of sent.
655
+
656
+ The package ships seven messages:
657
+
658
+ | Name | When it goes | What `data` carries |
659
+ | ------------------ | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ |
660
+ | `test` | Send test email, on Settings. | Nothing. |
661
+ | `password-reset` | Somebody asked to reset a password. | `username`, `resetUrl`, `expiresAt`, `expiresInHours`. |
662
+ | `password-changed` | A reset link was used. | `username`, `signedOut` (how many sessions ended). |
663
+ | `comment-pending` | A comment or webmention is waiting for approval. | `comment`, `post` (`title`, `url`), `actions` (one `{ action, label, url }` each for approve, spam and delete), `queueUrl`. |
664
+ | `comment-digest` | An hourly or daily digest of what is waiting. | `items` (one `{ comment, post, actions }` each), `total` waiting, `more` beyond the ones listed, the `mode` that asked for it, `queueUrl`. |
665
+ | `comment-reply` | A reply to somebody's comment was approved. | `reply`, `comment` (the one it answers), `post`, `unsubscribeUrl`. |
666
+ | `contact-message` | Somebody filled in a page's contact form. | `message` (`id`, `subject`, `text`, `received`), `from` (`name`, `email`), `page` (`slug`, `permalink`, `title`, `url`), `messagesUrl`. |
667
+
668
+ The `comment` inside each of a digest's `items` is the same shape as
669
+ `comment-pending`'s, and its `actions` are the same three links, minted per
670
+ recipient because each is spent the first time it is used. A digest is built
671
+ from the queue as it stands when it is sent, so an item moderated in the
672
+ meantime is simply not in it, and no digest is sent to somebody with an empty
673
+ queue.
674
+
675
+ `comment` and `reply` above are the same shape: `author`, `website`, `source`
676
+ (`comment` or `webmention`), `kind`, `text`, `html`, `submitted`, and `url`.
677
+ The commenter's email address is deliberately **not** among them — the message
678
+ goes to a moderator, but it also goes through a mail provider's servers, and
679
+ nothing on that screen needs it.
680
+
681
+ The links in `actions` and `unsubscribeUrl` each carry a signed token and work
682
+ without a login. Print them; do not try to build one.
683
+
684
+ `contact-message` is sent with reply-to set to the sender, so answering it in a
685
+ mail reader answers the person who wrote it. `message.text` is plain text as it
686
+ was typed — print it, do not render it as Markdown or as HTML.
687
+
688
+ The subject and the plain text body render with autoescaping **off**, because
689
+ neither is HTML: `&` between two query parameters stays an `&`, and an
690
+ apostrophe in somebody's name stays an apostrophe. The HTML twin escapes as
691
+ every other template does.
692
+
693
+ The context is `site` (that is `content/_data/site.json`), `baseUrl`, and
694
+ whatever the feature that sent it passed as `data`. The `date`, `url` and
695
+ `absoluteUrl` filters are the same ones a page has, so a message can write a
696
+ link with `{{ "/admin/" | absoluteUrl }}`.
697
+
698
+ Writing `mail/welcome.txt.njk` into the theme is enough to add a message this
699
+ package never shipped; nothing has to be registered.
700
+
56
701
  ## Template context
57
702
 
58
703
  The context mirrors what an Eleventy layout receives, so a layout ported from
@@ -60,34 +705,48 @@ an Eleventy build needs few edits. It is part of the package's semver contract.
60
705
 
61
706
  Every template gets:
62
707
 
63
- | Key | What it holds |
64
- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
65
- | `site` | `content/_data/site.json`, if the site has one, over the defaults `title` and `url`. Any key in the file is readable, so `site.tagline`, `site.author` and anything else a site adds are all available. |
708
+ | Key | What it holds |
709
+ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
710
+ | `site` | `content/_data/site.json`, if the site has one, over the defaults `title` and `url`. Any key in the file is readable, so `site.tagline`, `site.author` and anything else a site adds are all available. |
711
+ | `menus` | Every menu the site stores, by name, marked for this page: `menus.primary`, `menus.footer`, and any other name. See [Navigation](#navigation). |
712
+ | `siteAuthor` | Who the page is by, as a profile. **Absent** when nobody matches. See [Bylines and author archives](#bylines-and-author-archives). |
713
+ | `icons` | The site's icons, as `{ rel, sizes, href }`. Empty until the site has an avatar to derive them from. See [The head](#the-head). |
66
714
 
67
715
  A document — one post, one page, or one entry of a listing — adds:
68
716
 
69
- | Key | What it holds |
70
- | ----------------------------------------------------- | --------------------------------------------------------------------------------- |
71
- | `title` | Display title. |
72
- | `date` | Publish date, a JavaScript `Date`. Absent when the document has none. |
73
- | `updated` | Last modified date, a `Date`, when the front matter has one. |
74
- | `tags` | The document's tags, in file order. |
75
- | `content` | The Markdown body rendered to HTML. Print it with `\| safe`. |
76
- | `url` | The document's URL path, the same value as `page.url`. |
77
- | `page.url` | The document's URL path. Always ends in `/`. |
78
- | `page.date` | The same `Date` as `date`. |
79
- | `page.fileSlug` | The permalink's last segment. |
80
- | `page.inputPath` | The source file, relative to the content directory. |
81
- | `type` | `post` or `page`. |
82
- | `permalink`, `slug`, `draft`, `description`, `author` | Straight from the front matter. |
83
- | `activityStreams` | The post's ActivityPub object id, absolute. Only on a rendered published post. |
84
- | everything else | Any front matter key the CMS does not model is on the context under its own name. |
85
-
86
- A listing — the home page or a tag archive — adds:
717
+ | Key | What it holds |
718
+ | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
719
+ | `title` | Display title. |
720
+ | `date` | Publish date, a JavaScript `Date` at the UTC instant the file holds. Absent when the document has none. |
721
+ | `updated` | Last modified date, a `Date`, when the front matter has one. |
722
+ | `tags` | The document's tags, in file order. |
723
+ | `categories` | The document's categories, in file order. |
724
+ | `content` | The Markdown body rendered to HTML. Print it with `\| safe`. |
725
+ | `summary` | Its `description` as plain text, else an excerpt of the body, else empty. The line the feeds publish. |
726
+ | `url` | The document's URL path, the same value as `page.url`. |
727
+ | `page.url` | The document's URL path. Always ends in `/`. |
728
+ | `page.date` | The same `Date` as `date`. |
729
+ | `page.fileSlug` | The permalink's last segment. |
730
+ | `page.inputPath` | The source file, relative to the content directory. |
731
+ | `type` | `post` or `page`. |
732
+ | `permalink`, `slug`, `draft`, `description` | Straight from the front matter. |
733
+ | `author` | Who wrote it, as a profile rather than a string. See [Bylines and author archives](#bylines-and-author-archives). |
734
+ | `activityStreams` | The post's ActivityPub object id, absolute. Only on a rendered published post. |
735
+ | `previous` | The published post before this one by date, as `{ title, url }`. Absent on the oldest post. |
736
+ | `next` | The published post after it. Absent on the newest post, and on a page. |
737
+ | `recentPosts` | The newest posts, as entries, on the front page only: this month's when it holds five, else five. |
738
+ | `postsPage` | The page carrying the listing, as `{ title, url }`, on the front page only. Absent when the site names none. |
739
+ | `archiveMonths` | Every published post as `{ month, posts }`, newest month first. Only on a page that says `archive: true`. |
740
+ | `webmention` | Where a webmention about this page is sent. Only on a rendered document, and only while the site takes them. |
741
+ | `conversation` | The replies, likes and boosts under the post. Only when there are any. See [The conversation](#the-conversation). |
742
+ | everything else | Any front matter key the CMS does not model is on the context under its own name. |
743
+
744
+ A listing — the home page, a tag archive, a category archive or an author
745
+ archive — adds:
87
746
 
88
747
  | Key | What it holds |
89
748
  | ------------------------------------------ | --------------------------------------------------------------------------------- |
90
- | `title` | The site title on the home page, the tag on a tag archive. |
749
+ | `title` | The site title on the home page, the term on a taxonomy archive. |
91
750
  | `posts` | The documents on this page of the listing, newest first, each in the shape above. |
92
751
  | `pagination.items` | The same array, under the name Eleventy gives it. |
93
752
  | `pagination.pageNumber` | Zero-based index of this page, as in Eleventy. |
@@ -95,39 +754,256 @@ A listing — the home page or a tag archive — adds:
95
754
  | `pagination.href.first/last/next/previous` | Pager URLs. `next` and `previous` are `null` at the ends. |
96
755
  | `pagination.pages` | Every page's URL, in order. |
97
756
  | `tag` | The tag, on a tag archive only. |
757
+ | `category` | The category, on a category archive only. |
758
+ | `author` | The person, on an author archive only. Same shape as a post's `author`. |
98
759
 
99
760
  How many posts a listing page holds comes from `postsPerPage` in
100
761
  `content/_data/site.json`, and defaults to 10. How many entries a feed holds
101
- comes from `feedSize` in the same file, and defaults to 20.
762
+ comes from `feedSize` in the same file, and defaults to 20. The `<html lang>`
763
+ comes from `language`, and defaults to `en`. The rssCloud and WebSub server the
764
+ feeds advertise comes from `notifyServer`, and is empty for none; the theme
765
+ writes nothing for it, because a cloud is advertised in the feed rather than on
766
+ the page.
767
+
768
+ ## Bylines and author archives
769
+
770
+ `author` on a document is the person the front matter names, resolved against
771
+ the site's users. The fields come from their profile on **Users** in the admin,
772
+ which is the one place identity is written:
773
+
774
+ | Key | What it holds |
775
+ | ----------------- | -------------------------------------------------------------------------------------------------------------- |
776
+ | `author.name` | What to print: their display name, else their username, else the raw name the file gives. |
777
+ | `author.url` | Their archive, `/author/{username}/`. **Absent** when the name is nobody this site has. |
778
+ | `author.username` | Their login. Absent for the same reason `url` is. |
779
+ | `author.bio` | What they wrote about themselves, when they wrote any. |
780
+ | `author.avatar` | Their picture, as a path or URL. Absent when they have none. |
781
+ | `author.jobTitle` | What they do, when their profile says. |
782
+ | `author.location` | Where they are, as they wrote it. |
783
+ | `author.links` | `{ label, href }` for each link on their profile, in the order they listed them. Absent when they listed none. |
784
+
785
+ `author` is absent altogether when the document names no author, so anything
786
+ printing it is `{% if author %}`. Guard the link with `{% if author.url %}`: a
787
+ file may name somebody who has no account here — a guest post, or a colleague
788
+ whose account has gone — and that name is still printed, it simply links
789
+ nowhere. [The bio](#the-bio) does both, and is what the theme prints instead of
790
+ the `partials/byline.njk` it used to have.
791
+
792
+ `layouts/author.njk` is that person's archive, at `/author/{username}/`, with
793
+ their pages at `/author/{username}/page/2/` and their three feeds under
794
+ `/author/{username}/feed/`. It is headed with their name and then the bio —
795
+ the avatar, the note and the `rel="me"` links, the same card that ends each of
796
+ their posts — and lists their published posts newest first. The source design has no author archive, so
797
+ the heading is the CMS's own; the h-card under it is the one partial the theme
798
+ has. A user with no profile still has one; they are called by their username.
799
+
800
+ `siteAuthor` is the same object on a different question: not who wrote this
801
+ document, but who the page in front of the reader is by. It is what the bio and
802
+ any structured data a theme emits should both read, so that what a reader sees
803
+ and what a machine reads cannot drift apart. The footer does not read it at
804
+ all: the site's links are `menus.footer`, typed rather than borrowed from an
805
+ account. It resolves in this order:
806
+
807
+ - the document's own `author`, on a post or a page that names one;
808
+ - the person whose archive it is, on an author archive;
809
+ - the profile behind the site's `author` setting, everywhere else — the home
810
+ page, a taxonomy archive, a page that names nobody, the 404.
811
+
812
+ It is **absent** when none of those name anybody this site has. The site
813
+ setting is read more strictly than a byline is: a byline prints the name a file
814
+ gives whether or not somebody answers to it, but a site author with no profile
815
+ behind it has no picture, no bio and nowhere to link, so there is nothing to
816
+ print and the key is not there. Write `{% if siteAuthor %}` around the bio.
817
+
818
+ The URL is not only a page. Each user is an ActivityPub actor at that address,
819
+ so it is the page a follower lands on when they click through from the
820
+ fediverse. `author` is therefore a reserved first URL segment, like `tag` and
821
+ `feed`: no document can be permalinked under it and no taxonomy base can take
822
+ it.
823
+
824
+ ## Navigation
825
+
826
+ A site stores its menus by name in `content/_data/site.json`:
827
+
828
+ ```json
829
+ {
830
+ "menus": {
831
+ "primary": [
832
+ { "label": "Home", "url": "/" },
833
+ { "label": "About", "url": "/about/" }
834
+ ],
835
+ "footer": [
836
+ { "label": "Colophon", "url": "/colophon/" },
837
+ { "label": "Mastodon", "url": "https://example.social/@me", "me": true }
838
+ ]
839
+ }
840
+ }
841
+ ```
842
+
843
+ Every template gets them as `menus`, keyed by the same names, already in order
844
+ and already knowing which item is the page being looked at:
845
+
846
+ ```njk
847
+ {% for item in menus.footer %}
848
+ <a href="{{ item.url | url }}">{{ item.label }}</a>
849
+ {% endfor %}
850
+ ```
851
+
852
+ That is the whole interface, so a theme renders a menu this one has never heard
853
+ of by looping over the name a site stored it under.
854
+
855
+ ### The areas a theme declares
856
+
857
+ `theme.json` says where a theme renders a menu, so the Navigation screen can
858
+ offer those places and nowhere else. This theme declares two:
859
+
860
+ ```json
861
+ {
862
+ "name": "Default",
863
+ "kind": "site",
864
+ "areas": [
865
+ { "name": "primary", "label": "Site menu" },
866
+ { "name": "footer", "label": "Footer links" }
867
+ ]
868
+ }
869
+ ```
870
+
871
+ `name` is the key under `menus`, in `site.json` and on the context. `label` is
872
+ what a person choosing an area reads; leave it out and it falls back to the
873
+ name. Declare a third area and a site can fill it in; declare none and the
874
+ theme renders no menu.
875
+
876
+ The declaration is for the screen, not for the render. Every menu the site
877
+ stores is on the context whether or not a theme declared an area for it — a
878
+ menu nothing loops over is rendered nowhere and kept, which is what lets
879
+ somebody write the menu a theme will use before switching to that theme. An
880
+ `areas` that is missing, or that will not read, leaves the theme with none
881
+ rather than failing to load it.
882
+
883
+ ### One item
884
+
885
+ Each item is `{ label, url, current }`, plus `me: true` on a link that should
886
+ carry `rel="me"`. `url` is a site-root path or an absolute URL for somewhere
887
+ else, so put it through the `url` filter and a site served from a subdirectory
888
+ still links correctly. `current` is true for the item whose path is the one
889
+ being rendered, comparing without the trailing slash; an item pointing off the
890
+ site is never current. `me` is for the IndieWeb's identity check: Mastodon
891
+ verifies a link on a profile by looking for a `rel="me"` link back, so a footer
892
+ link to a profile marked `me` is what makes the tick appear.
893
+
894
+ ### Where this theme prints them
895
+
896
+ `partials/menu.njk` holds the markup, as one macro:
897
+
898
+ ```njk
899
+ {% import "partials/menu.njk" as nav %}
900
+ {{ nav.list(menus.footer, "Footer") }}
901
+ ```
902
+
903
+ The second argument is the `aria-label`, which is what tells a reader on a
904
+ screen reader which of a page's menus this one is. Nothing is printed for a
905
+ menu with nothing in it.
906
+
907
+ `menus.primary` is printed once, in `header.global-header`, on every page
908
+ (TASK-105). At the root, where the header is the site title with the tagline
909
+ under it, the menu is a line of its own under the tagline; everywhere else,
910
+ where the header is the small link home, the stylesheet lays the header out as
911
+ one line and the menu sits beside it. Nothing decides between two places any
912
+ more: a reader looks in the same spot whatever they are reading, and a layout
913
+ that overrides the `content` block cannot lose the navigation by not printing
914
+ a bio.
915
+
916
+ `menus.footer` is printed in the `footer` block of `layouts/base.njk`, under
917
+ the copyright line, and is the whole of what the footer links — a feed, a
918
+ colophon, a webring, a profile marked `me`. The footer reads nothing off an
919
+ account: it used to print one `rel="me"` link per entry of `siteAuthor.links`,
920
+ which meant one nominated user's links stood for the site and nobody else's
921
+ appeared at all.
922
+
923
+ `menus.primary` is the whole of the site menu, in the order the Navigation
924
+ screen names it. Both areas are declared in this theme's `theme.json`, which is
925
+ what puts them on `/admin/navigation` with those labels; a theme that renders a
926
+ third menu declares a third area. A page cannot put itself in a menu; a page
927
+ that should be linked — including the one a site serves as its front page,
928
+ typed `Home | /` — is typed into the box there.
929
+
930
+ Because the menus live in `site.json`, an Eleventy build of the same content
931
+ renders the same ones; `docs/eleventy.config.example.js` builds them as
932
+ `collections.menus`.
102
933
 
103
934
  ## Feeds
104
935
 
105
- `layouts/base.njk` fills the `alternates` block with the `rel="alternate"`
106
- links for `/feed.xml` and `/feed.json`, so every page that extends it
107
- advertises both feeds. On a published post it adds a third, pointing at the
108
- post's ActivityPub object:
936
+ `partials/feeds.njk` holds two macros. `feedLinks(root, title)` writes the three
937
+ `rel="alternate"` links for a listing's RSS, Atom and JSON feeds — RSS first,
938
+ because it is what most subscribers hold:
939
+
940
+ ```njk
941
+ {% import "partials/feeds.njk" as feeds %}
942
+ {{ feeds.feedLinks("/", site.title) }}
943
+ ```
944
+
945
+ `root` is any listing URL ending in `/`; the macro appends `feed/`, `feed/atom/`
946
+ and `feed/json/` and puts each through the `url` filter, so the links carry the
947
+ base path of a site served from a subdirectory. The arguments carry everything
948
+ the macro needs, so it can be imported without `with context`.
949
+
950
+ `commentsFeedLink(feed, title)` writes the one link a comments feed has. It
951
+ takes the feed's own URL rather than a root, because a comments feed is RSS 2.0
952
+ and nothing else:
953
+
954
+ ```njk
955
+ {{ feeds.commentsFeedLink("/comments/feed/", site.title + " comments") }}
956
+ ```
957
+
958
+ `layouts/tag.njk`, `layouts/category.njk` and `layouts/author.njk` each call
959
+ `feedLinks` over their own root, so an archive advertises its own three feeds
960
+ as well as the site's — on an author archive that root is `author.url`.
961
+
962
+ `layouts/base.njk` fills the `alternates` block with `feedLinks` over `/` and
963
+ then the site's comments feed, so every page that extends it advertises all
964
+ four. On a published post it adds that post's own comments feed, from
965
+ `commentsFeed` on the context, and a link pointing at the post's ActivityPub
966
+ object:
109
967
 
110
968
  ```html
111
969
  <link
112
970
  rel="alternate"
113
971
  type="application/activity+json"
114
- href="https://example.com/ap/posts/hello"
972
+ href="https://example.com/2026/09/hello/"
115
973
  />
116
974
  ```
117
975
 
118
- That link is what lets a fediverse client find the post from its permalink. It
119
- comes from `activityStreams`, which is only on the context of a rendered
120
- published post, so a layout that overrides the block and does not call
121
- `super()` has to emit it itself.
976
+ A post's object id is its permalink, so that link usually points at the page it
977
+ is on: it says the URL answers ActivityStreams as well as HTML. A post migrated
978
+ from elsewhere keeps the id its file names, and the link points there instead.
979
+ It comes from `activityStreams`, which — like `commentsFeed` — is only on the
980
+ context of a rendered published post, so a layout that overrides the block and
981
+ does not call `super()` has to emit both itself. They are on the context rather
982
+ than in `post.njk` so that a site which overrides that layout, as the demo
983
+ does, keeps them.
122
984
 
123
- `layouts/tag.njk` overrides that block, calls `super()` and adds the tag's own
124
- two feeds:
985
+ After the block, and outside it, the base layout writes the webmention
986
+ endpoint:
987
+
988
+ ```html
989
+ <link rel="webmention" href="/_geekity/webmention" />
990
+ ```
991
+
992
+ It comes from `webmention` on the context, which is on a rendered document only
993
+ when the site is taking webmentions, so a theme asks `{% if webmention %}` and a
994
+ site that has turned them off advertises nothing. The CMS also sends the same
995
+ endpoint as a `Link` header on every representation of a document, so a sender
996
+ that does not parse HTML still finds it.
997
+
998
+ `layouts/tag.njk` and `layouts/category.njk` override that block, call `super()`
999
+ and add the archive's own three feeds:
125
1000
 
126
1001
  ```njk
127
- {% set tagRoot = "/tags/" + (tag | urlencode) + "/" %}
1002
+ {% import "partials/feeds.njk" as feeds %}
1003
+ {% set tagRoot = "/" + (site.tagBase or "tag") + "/" + (tag | urlencode) + "/" %}
128
1004
  {% block alternates %}
129
1005
  {{ super() }}
130
- <link rel="alternate" type="application/atom+xml" href="{{ (tagRoot + "feed.xml") | url }}">
1006
+ {{ feeds.feedLinks(tagRoot, site.title + ": " + tag) }}
131
1007
  {% endblock %}
132
1008
  ```
133
1009
 
@@ -135,13 +1011,303 @@ A layout that does not extend the base one has to emit the links itself. The
135
1011
  feeds are generated by the CMS, not by a template, so nothing here decides what
136
1012
  goes in them.
137
1013
 
1014
+ Category archives get their own three the same way.
1015
+
1016
+ ## The conversation
1017
+
1018
+ `conversation` is what has been said about a post: the fediverse replies, likes
1019
+ and boosts its inbox was sent, the comments people left on the page itself, and
1020
+ the webmentions other pages sent it.
1021
+ It is on the context of a rendered post **only when there is something in it**,
1022
+ so a post nobody has answered renders no empty section and a layout can simply
1023
+ ask:
1024
+
1025
+ ```njk
1026
+ {% if conversation %}
1027
+ {% include "partials/conversation.njk" %}
1028
+ {% endif %}
1029
+ ```
1030
+
1031
+ That is what `layouts/post.njk` does. `partials/conversation.njk` is the whole
1032
+ section — a `div.reactions-section` of the likes, the boosts and the mentions
1033
+ as facepiles grouped by kind, and then the thread as
1034
+ `div#comments.comments-area` — and a site replaces it with a
1035
+ `partials/conversation.njk` of its own in the theme it wears, exactly as it
1036
+ replaces any other template. It defines three macros, `face(item, icon, href)`,
1037
+ `group(items, label, kind, icon, source)` and `comment(reply)`, and a layout
1038
+ that wants to place the pieces itself can import them:
1039
+
1040
+ ```njk
1041
+ {% import "partials/conversation.njk" as thread with context %}
1042
+ {{ thread.group(conversation.likes, "Likes", "p-like", "❤️") }}
1043
+ ```
1044
+
1045
+ ### The shape
1046
+
1047
+ The conversation is deliberately **not** spelled in ActivityPub's vocabulary.
1048
+ Native comments and webmentions land in the same thread, so every
1049
+ entry says where it came from and what it is, and nothing else about it changes
1050
+ with the source. A theme that never looks at `source` renders all of them
1051
+ correctly; one that does can style them apart. The one thing a theme does have
1052
+ to look at is `kind`: a `mention` is in `conversation.mentions` rather than in
1053
+ the thread, because it is not an answer.
1054
+
1055
+ | Key | What it holds |
1056
+ | -------------------------------------------------- | ------------------------------------------------------------------------------------------ |
1057
+ | `replies` | The replies to the post, oldest first, each carrying its own `replies`. See below. |
1058
+ | `likes` | The likes, oldest first, in the same shape. |
1059
+ | `boosts` | The boosts, oldest first, in the same shape. A webmention `repost` is one of them. |
1060
+ | `mentions` | The pages that linked here without answering, oldest first, in the same shape. |
1061
+ | `counts.replies` | How many replies, counted through the whole thread rather than the top of it. |
1062
+ | `counts.likes`, `counts.boosts`, `counts.mentions` | How many of each. |
1063
+ | `counts.total` | All four added up. Zero never reaches a template: there would be no `conversation` at all. |
1064
+
1065
+ Each entry — a reply, a like or a boost — is:
1066
+
1067
+ | Key | What it holds |
1068
+ | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1069
+ | `id` | What it is called: a reply's own note id, which is what an answer to it names. |
1070
+ | `source` | `"activitypub"` for a fediverse reply, `"comment"` for one left on the page, `"webmention"` for another page linking here. |
1071
+ | `kind` | `"reply"`, `"like"`, `"boost"`, `"repost"` or `"mention"`. The last two only ever come from a webmention. |
1072
+ | `author.name` | The best name available: their display name, else their handle, else their id. For a webmention, the source's `h-card` name, else its host. |
1073
+ | `author.handle` | `@user@host`, or `null`. Taken from the follower profile the site holds, else guessed from the actor URL. `null` for a native comment. |
1074
+ | `author.url` | Their profile page, the website a commenter typed, or a webmention author's `u-url`. May be `null`, so guard the link. |
1075
+ | `author.avatar` | Their avatar, or `null`. The site knows one for an actor that follows it and for a webmention whose `h-card` carried a `u-photo`. |
1076
+ | `author.actorId` | Their id, which is what identifies them however they are named. `null` for a native comment. |
1077
+ | `url` | Where it can be read: the remote note's `url` for a fediverse reply, the source page for a webmention, and `{permalink}#comment-{id}` — this page's own anchor — for a native comment. |
1078
+ | `content` | What it says, **already sanitised**, so print it with `\| safe`. Empty for a like or a boost. |
1079
+ | `published` | A `Date`: when it was published, or when it arrived if it did not say. Use the `date` filter. |
1080
+ | `inReplyTo` | What it answers — the post's ActivityPub id, or another reply's — and `null` for a reaction. |
1081
+ | `status` | `"published"`. On the record for the sources that moderate. |
1082
+ | `replies` | The replies to this one, oldest first, nested as deep as the site has seen. |
1083
+
1084
+ Three rules decide what is in the thread, and they are the CMS's rather than a
1085
+ theme's: a reply whose author deleted it is gone, and its own answers move up to
1086
+ whatever it was answering; a like is counted once per actor and disappears when
1087
+ that actor undoes it; and a note answering something this post has nothing to do
1088
+ with is left out.
1089
+
1090
+ `content` is safe to print whatever produced it, and safe for different reasons.
1091
+ A fediverse reply is HTML somebody else's server composed, rebuilt from an
1092
+ allowlist — `a`, `p`, `br`, lists, `blockquote`, `pre`, `code` and the inline
1093
+ emphasis tags, with every link carrying `rel="nofollow noopener noreferrer"`. A
1094
+ webmention's `e-content` goes through the same allowlist. A native comment is
1095
+ Markdown the commenter typed, rendered with raw HTML off, no images embedded,
1096
+ and every link carrying `rel="nofollow ugc"`. Nothing else survives any of the
1097
+ three routes, so a theme may print all of them directly.
1098
+
1099
+ The packaged partial draws each entry as the source design does: an
1100
+ `li.comment.h-entry` carrying `id="comment-{{ reply.id }}"` and a
1101
+ `comment-{{ reply.source }}` class, holding an `article.comment-body` whose
1102
+ `footer.comment-meta` is the author as a `.comment-author.vcard.p-author.h-card`
1103
+ and the permalink as a `.comment-metadata` link around a `time.dt-published`,
1104
+ then the words in `div.comment-content.e-content`. Answers nest in an
1105
+ `ol.children` inside what they answer. A Reply link in a `div.reply` goes on the
1106
+ ones written here — `source == "comment"` — when the post is still open; it
1107
+ carries the comment's id to the form as `?reply_to=`, so threading needs no
1108
+ JavaScript. A fediverse reply is answered on the server that holds it and a
1109
+ webmention on the page that sent it, so neither gets one.
1110
+
1111
+ Above the thread, `group()` draws one `div.reaction-group` per kind that has
1112
+ anything — `p-like`, `p-repost` and `p-mention`, the source theme's classes — as
1113
+ an `h2.reaction-title` of the label and the count beside a `div.facepile` of
1114
+ `a.u-url` faces. A face is a round `u-photo` avatar; a reaction whose author the
1115
+ site knows no picture of is the emoji badge of its kind and a `.reaction-name`
1116
+ instead. Likes and boosts link to the person, and a mention to the page it came
1117
+ from, because the page is the thing worth reading.
1118
+
1119
+ There is no key saying a post has stopped taking comments, and none is needed:
1120
+ **the absence of `commentForm` is what a closed post looks like**, since the CMS
1121
+ puts the form on the context only while the site switch, the closing window and
1122
+ the post's own front matter all agree it is open. The packaged partial prints
1123
+ `p.no-comments` under a thread when there is no form, which is WordPress's
1124
+ "Comments are closed." and the reason a reader cannot find one.
1125
+
1126
+ An Eleventy build of the same content gets the same thing from the same files:
1127
+ `docs/eleventy.config.example.js` adds a `conversation` filter over
1128
+ `federation.inbox`, and puts the post's ActivityPub id on the context as
1129
+ `activityStreams` and its slug as `geekitySlug`, so a layout reads
1130
+
1131
+ ```njk
1132
+ {% set conversation = federation.inbox | conversation(activityStreams, geekitySlug) %}
1133
+ ```
1134
+
1135
+ and then loops over exactly the keys above. The slug is what names the post's
1136
+ comment file under `content/_data/comments/`; the format is documented in
1137
+ backlog doc-6.
1138
+
1139
+ ## The comment form
1140
+
1141
+ `commentForm` is on the context of a rendered post **only when that post is
1142
+ still taking comments**. A site switch, a closing window counted from the
1143
+ post's date, and `comments: true` or `comments: false` in the post's own front
1144
+ matter all agree before it gets here, so a layout asks:
1145
+
1146
+ ```njk
1147
+ {% if commentForm %}
1148
+ {% include "partials/comment-form.njk" %}
1149
+ {% endif %}
1150
+ ```
1151
+
1152
+ That is what `layouts/post.njk` does, after the conversation. A closed post
1153
+ still shows the thread — including every fediverse reply, which arrives whether
1154
+ a post is open or not — and simply has no form, which is what the conversation
1155
+ reads to print "Comments are closed." A site replaces
1156
+ `partials/comment-form.njk` the way it replaces any other template.
1157
+
1158
+ The packaged partial is a `section#respond.comment-respond` with an
1159
+ `h2.comment-reply-title`, one paragraph per field and the button in a
1160
+ `p.form-submit` — the source design's names, so the stylesheet styles every
1161
+ label, field and button of both forms through `.comment-respond` and a site
1162
+ that has CSS for the WordPress theme can bring it.
1163
+
1164
+ | Key | What it holds |
1165
+ | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1166
+ | `action` | Where the form posts. One fixed path; the post travels as a field. |
1167
+ | `fields` | The name each field is submitted under: `post`, `name`, `email`, `url`, `body`, `inReplyTo`, `trap`, `loaded`, `notify`, `csrf`. Use these rather than typing the names. |
1168
+ | `post` | The post's slug, for the hidden field. |
1169
+ | `loaded` | When this form was rendered, in epoch milliseconds, for the hidden field. A submission that comes back too fast is refused. |
1170
+ | `values` | What is in the fields: empty on a fresh form, what was typed on a refused one. |
1171
+ | `problems` | One message per field a person has to put right — `name`, `email`, `url`, `body`. |
1172
+ | `error` | A message about the submission as a whole, when there is one. |
1173
+ | `notifiable` | Whether to offer `fields.notify`, the "email me when somebody replies" box. False on a site that sends no mail, where the box would promise a message nothing could send. |
1174
+ | `nameLength`, `bodyLength` | The `maxlength` for the two fields that have one. |
1175
+
1176
+ `fields.trap` is a honeypot: render it, hide it from sight and from assistive
1177
+ technology, and give it `tabindex="-1"` and `autocomplete="off"`. A submission
1178
+ that filled it is dropped. **Do not** remove it from a replacement partial —
1179
+ it is one of three things standing between the site and a spam queue.
1180
+
1181
+ ### When somebody is signed in
1182
+
1183
+ Two more keys are on `commentForm` when — and only when — the request carries a
1184
+ valid session for this site's admin:
1185
+
1186
+ | Key | What it holds |
1187
+ | ------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
1188
+ | `signedInAs` | Who the comment will be posted as: `name`, which is their display name or their username, and `url`, which is their author archive. |
1189
+ | `csrfToken` | The value for a hidden `fields.csrf`, without which the submission is refused. |
1190
+
1191
+ **A replacement partial has to draw both forms**, because the site draws this
1192
+ one for its own author and the stranger's one for everybody else:
1193
+
1194
+ ```njk
1195
+ {% if commentForm.signedInAs %}
1196
+ <input type="hidden" name="{{ commentForm.fields.csrf }}" value="{{ commentForm.csrfToken }}" />
1197
+ <p class="comment-signed-in">
1198
+ Commenting as <a href="{{ commentForm.signedInAs.url }}">{{ commentForm.signedInAs.name }}</a>.
1199
+ </p>
1200
+ {% else %}
1201
+ {# fields.loaded, fields.trap, and the name, email and website boxes #}
1202
+ {% endif %}
1203
+ ```
1204
+
1205
+ The signed-in branch renders **no** name, email or website box, **no**
1206
+ `fields.trap` and **no** `fields.loaded`: the site already knows all three
1207
+ answers, and a session is better evidence that this is a person than a honeypot
1208
+ or a stopwatch. It renders `fields.csrf` instead, because this is the one form
1209
+ on the public site that acts on somebody's behalf — without the token, a page
1210
+ on another site could make them post under their own name. The comment box, the
1211
+ reply-to field and the notify box are the same in both branches.
1212
+
1213
+ The account's email address is **never** on the context. The comment is stored
1214
+ with it, exactly as a stranger's is, and it is no more printable than theirs.
1215
+
1216
+ A page carrying this form is one reader's page rather than the post, so the CMS
1217
+ answers it `Cache-Control: private, no-store` and with no ETag. A theme does
1218
+ not have to do anything about that, but it is why printing somebody's name into
1219
+ a page is safe here.
1220
+
1221
+ Two more keys travel beside it, both from the URL rather than from the post:
1222
+
1223
+ | Key | What it holds |
1224
+ | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
1225
+ | `commentNotice` | The thank-you after a submission, from the `?comment=` the redirect carried. |
1226
+ | `commentReplyTo`, `commentReplyingTo` | The comment a `?reply_to=` link named and the name on it, once the CMS has checked it is an approved comment on this very post. |
1227
+
1228
+ ## The contact form
1229
+
1230
+ `contactForm` is on the context of a rendered document **only when its front
1231
+ matter says `contact: true`**, so a layout asks:
1232
+
1233
+ ```njk
1234
+ {% if contactForm %}
1235
+ {% include "partials/contact-form.njk" %}
1236
+ {% endif %}
1237
+ ```
1238
+
1239
+ That is what `layouts/page.njk` does, after the page's content. The key is
1240
+ honoured wherever it is written, so a theme that wants the form under a post as
1241
+ well only has to add the same two lines to `layouts/post.njk`; the editor
1242
+ offers the checkbox on pages.
1243
+
1244
+ `archive: true` is the other front matter key of this kind: it puts every
1245
+ published post on the page instead of a form. See
1246
+ [An archive page](#an-archive-page).
1247
+
1248
+ The packaged partial is a `section#contact.comment-respond.contact-form`: it is
1249
+ the same kind of form as the comment one, so it wears the same class and the two
1250
+ share one set of rules rather than keeping two that drift.
1251
+
1252
+ | Key | What it holds |
1253
+ | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
1254
+ | `action` | Where the form posts. One fixed path; the page travels as a field. |
1255
+ | `fields` | The name each field is submitted under: `page`, `name`, `email`, `subject`, `message`, `trap`, `loaded`. Use these rather than typing the names. |
1256
+ | `page` | The page's slug, for the hidden field. |
1257
+ | `loaded` | When this form was rendered, in epoch milliseconds, for the hidden field. A submission that comes back too fast is refused. |
1258
+ | `values` | What is in the fields: empty on a fresh form, what was typed on a refused one. |
1259
+ | `problems` | One message per field a person has to put right — `name`, `email`, `subject`, `message`. |
1260
+ | `error` | A message about the submission as a whole, when there is one. |
1261
+ | `nameLength`, `subjectLength`, `messageLength` | The `maxlength` for the three fields that have one. |
1262
+
1263
+ `fields.trap` is the same honeypot the comment form carries, and the same rules
1264
+ apply: render it, hide it, and **do not** remove it from a replacement partial.
1265
+
1266
+ One more key travels beside it, from the URL rather than from the page:
1267
+
1268
+ | Key | What it holds |
1269
+ | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
1270
+ | `contactNotice` | The thank-you after a message was sent, from the `?contact=` the redirect carried. The shipped partial shows it **instead of** the form. |
1271
+
1272
+ **Where the message goes is never on the context.** The contact address is read
1273
+ when a submission arrives, so it cannot end up in the HTML however this partial
1274
+ is rewritten. Do not try to print it, and do not put a `mailto:` beside the
1275
+ form.
1276
+
1277
+ ## Taxonomy macros
1278
+
1279
+ `partials/tags.njk` holds one macro per taxonomy, and each renders nothing at
1280
+ all for an empty list. Both are one `p.post-categories` of `p-category` links,
1281
+ which is the shape the design prints in a feed item's meta line and under an
1282
+ entry — `categories` marks each link `rel="category"` and `list` marks each
1283
+ `rel="tag"`, so under an entry the two read as one line of what it is filed
1284
+ under, categories first:
1285
+
1286
+ ```njk
1287
+ {% import "partials/tags.njk" as taxonomy with context %}
1288
+ {{ taxonomy.categories(categories) }}
1289
+ {{ taxonomy.list(tags) }}
1290
+ ```
1291
+
1292
+ `list` links to `/{{ site.tagBase }}/{tag}/` and `categories` to
1293
+ `/{{ site.categoryBase }}/{category}/`, which is where the CMS serves each
1294
+ archive. Both bases are settings — `tag` and `category` until the settings
1295
+ screen says otherwise — and they reach a template through `site`, which is why
1296
+ the import above carries `with context`: a macro imported without it cannot see
1297
+ `site` and would fall back to the defaults.
1298
+
138
1299
  ## Filters
139
1300
 
140
- | Filter | What it does |
141
- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
142
- | `date(format)` | Formats a `Date` or a date string. `readable` (the default) gives `2 September 2026`, `html` gives `2026-09-02` for a `<time datetime>`, `year` gives `2026`, `iso` gives the full ISO 8601 instant. Formatting is in UTC. A value that is not a date renders as the empty string. |
143
- | `url` | Prefixes a root-relative path with the base URL's path, so a site served from a subdirectory links correctly. Eleventy's filter of the same name. |
144
- | `absoluteUrl` | The same path as a fully qualified URL against the site's `baseUrl`. |
1301
+ | Filter | What it does |
1302
+ | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1303
+ | `date(format, zone)` | Formats a `Date` or a date string. `readable` (the default) gives `2 September 2026`, `html` gives `2026-09-02` for a `<time datetime>`, `year` gives `2026`, `month` gives `September 2026`, `iso` gives the full ISO 8601 instant. A date in a file is a UTC instant; every format but `iso` is rendered in the site's `timezone` setting, and `iso` stays the instant. Pass `zone` — an IANA name — to override the setting for one call. A value that is not a date renders as the empty string. |
1304
+ | `url` | Prefixes a root-relative path with the base URL's path, so a site served from a subdirectory links correctly. Eleventy's filter of the same name. |
1305
+ | `absoluteUrl` | The same path as a fully qualified URL against the site's `baseUrl`. |
1306
+
1307
+ `date` reads one word rather than parsing it: `{{ "now" | date("year") }}` is
1308
+ the year at the moment the page is rendered, in the site's own timezone. It is
1309
+ what the footer's copyright line is written with, and it is the one date a page
1310
+ has that no file carries.
145
1311
 
146
1312
  Nunjucks' own filters — `default`, `join`, `urlencode`, `safe` and the rest —
147
1313
  are all available. Autoescaping is on, so rendered Markdown is the one thing