@geekity/cms 0.3.0 → 0.4.0

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