@geekity/cms 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (660) hide show
  1. package/README.md +2217 -214
  2. package/admin/components/fields.njk +165 -0
  3. package/admin/layouts/base.njk +1 -1
  4. package/admin/layouts/settings-page.njk +26 -0
  5. package/admin/layouts/shell.njk +17 -3
  6. package/admin/pages/account/forgot.njk +47 -0
  7. package/admin/pages/account/login.njk +26 -0
  8. package/admin/pages/account/reset.njk +38 -0
  9. package/admin/pages/account/setup.njk +28 -0
  10. package/admin/pages/appearance/themes.njk +69 -0
  11. package/admin/pages/comments/all.njk +133 -0
  12. package/admin/{layouts/dashboard.njk → pages/dashboard/home.njk} +8 -15
  13. package/admin/{layouts/document-conflict.njk → pages/documents/conflict.njk} +1 -0
  14. package/admin/{layouts/document-editor.njk → pages/documents/editor.njk} +75 -59
  15. package/admin/{layouts/document-list.njk → pages/documents/list.njk} +19 -2
  16. package/admin/pages/documents/taxonomy.njk +95 -0
  17. package/admin/pages/federation/followers.njk +238 -0
  18. package/admin/pages/federation/settings.njk +77 -0
  19. package/admin/pages/media/library.njk +160 -0
  20. package/admin/pages/messages/all.njk +110 -0
  21. package/admin/pages/navigation/menus.njk +129 -0
  22. package/admin/{layouts → pages}/placeholder.njk +5 -1
  23. package/admin/pages/settings/discussion.njk +99 -0
  24. package/admin/pages/settings/email.njk +203 -0
  25. package/admin/pages/settings/general.njk +29 -0
  26. package/admin/pages/settings/permalinks.njk +50 -0
  27. package/admin/pages/settings/reading.njk +64 -0
  28. package/admin/pages/tools/content-index.njk +89 -0
  29. package/admin/pages/users/edit.njk +239 -0
  30. package/admin/pages/users/list.njk +51 -0
  31. package/admin/pages/users/new.njk +41 -0
  32. package/admin/static/admin.css +558 -3
  33. package/admin/static/copy.js +52 -0
  34. package/admin/static/editor.js +16 -16
  35. package/admin/static/slug.js +61 -0
  36. package/dist/access-log.d.ts +44 -0
  37. package/dist/access-log.d.ts.map +1 -0
  38. package/dist/access-log.js +71 -0
  39. package/dist/access-log.js.map +1 -0
  40. package/dist/admin/accounts.d.ts +413 -0
  41. package/dist/admin/accounts.d.ts.map +1 -0
  42. package/dist/admin/accounts.js +680 -0
  43. package/dist/admin/accounts.js.map +1 -0
  44. package/dist/admin/appearance.d.ts +80 -0
  45. package/dist/admin/appearance.d.ts.map +1 -0
  46. package/dist/admin/appearance.js +136 -0
  47. package/dist/admin/appearance.js.map +1 -0
  48. package/dist/admin/comments.d.ts +90 -0
  49. package/dist/admin/comments.d.ts.map +1 -0
  50. package/dist/admin/comments.js +244 -0
  51. package/dist/admin/comments.js.map +1 -0
  52. package/dist/admin/credentials.d.ts +26 -2
  53. package/dist/admin/credentials.d.ts.map +1 -1
  54. package/dist/admin/credentials.js +35 -2
  55. package/dist/admin/credentials.js.map +1 -1
  56. package/dist/admin/documents.d.ts +117 -10
  57. package/dist/admin/documents.d.ts.map +1 -1
  58. package/dist/admin/documents.js +328 -41
  59. package/dist/admin/documents.js.map +1 -1
  60. package/dist/admin/federation.d.ts +161 -50
  61. package/dist/admin/federation.d.ts.map +1 -1
  62. package/dist/admin/federation.js +235 -93
  63. package/dist/admin/federation.js.map +1 -1
  64. package/dist/admin/formatting.d.ts +20 -0
  65. package/dist/admin/formatting.d.ts.map +1 -0
  66. package/dist/admin/formatting.js +72 -0
  67. package/dist/admin/formatting.js.map +1 -0
  68. package/dist/admin/headers.d.ts +65 -0
  69. package/dist/admin/headers.d.ts.map +1 -0
  70. package/dist/admin/headers.js +104 -0
  71. package/dist/admin/headers.js.map +1 -0
  72. package/dist/admin/index.d.ts +38 -12
  73. package/dist/admin/index.d.ts.map +1 -1
  74. package/dist/admin/index.js +25 -7
  75. package/dist/admin/index.js.map +1 -1
  76. package/dist/admin/media.d.ts +168 -0
  77. package/dist/admin/media.d.ts.map +1 -0
  78. package/dist/admin/media.js +295 -0
  79. package/dist/admin/media.js.map +1 -0
  80. package/dist/admin/menu.d.ts +79 -0
  81. package/dist/admin/menu.d.ts.map +1 -0
  82. package/dist/admin/menu.js +143 -0
  83. package/dist/admin/menu.js.map +1 -0
  84. package/dist/admin/messages.d.ts +80 -0
  85. package/dist/admin/messages.d.ts.map +1 -0
  86. package/dist/admin/messages.js +152 -0
  87. package/dist/admin/messages.js.map +1 -0
  88. package/dist/admin/navigation.d.ts +155 -0
  89. package/dist/admin/navigation.d.ts.map +1 -0
  90. package/dist/admin/navigation.js +292 -0
  91. package/dist/admin/navigation.js.map +1 -0
  92. package/dist/admin/preview.d.ts.map +1 -1
  93. package/dist/admin/preview.js +31 -7
  94. package/dist/admin/preview.js.map +1 -1
  95. package/dist/admin/recovery.d.ts +92 -0
  96. package/dist/admin/recovery.d.ts.map +1 -0
  97. package/dist/admin/recovery.js +229 -0
  98. package/dist/admin/recovery.js.map +1 -0
  99. package/dist/admin/routes.d.ts +0 -20
  100. package/dist/admin/routes.d.ts.map +1 -1
  101. package/dist/admin/routes.js +164 -105
  102. package/dist/admin/routes.js.map +1 -1
  103. package/dist/admin/session.d.ts +17 -3
  104. package/dist/admin/session.d.ts.map +1 -1
  105. package/dist/admin/session.js +19 -5
  106. package/dist/admin/session.js.map +1 -1
  107. package/dist/admin/settings-discussion.d.ts +37 -0
  108. package/dist/admin/settings-discussion.d.ts.map +1 -0
  109. package/dist/admin/settings-discussion.js +117 -0
  110. package/dist/admin/settings-discussion.js.map +1 -0
  111. package/dist/admin/settings-email.d.ts +54 -0
  112. package/dist/admin/settings-email.d.ts.map +1 -0
  113. package/dist/admin/settings-email.js +189 -0
  114. package/dist/admin/settings-email.js.map +1 -0
  115. package/dist/admin/settings-federation.d.ts +48 -0
  116. package/dist/admin/settings-federation.d.ts.map +1 -0
  117. package/dist/admin/settings-federation.js +123 -0
  118. package/dist/admin/settings-federation.js.map +1 -0
  119. package/dist/admin/settings-general.d.ts +12 -0
  120. package/dist/admin/settings-general.d.ts.map +1 -0
  121. package/dist/admin/settings-general.js +48 -0
  122. package/dist/admin/settings-general.js.map +1 -0
  123. package/dist/admin/settings-page.d.ts +119 -0
  124. package/dist/admin/settings-page.d.ts.map +1 -0
  125. package/dist/admin/settings-page.js +132 -0
  126. package/dist/admin/settings-page.js.map +1 -0
  127. package/dist/admin/settings-pages.d.ts +30 -0
  128. package/dist/admin/settings-pages.d.ts.map +1 -0
  129. package/dist/admin/settings-pages.js +43 -0
  130. package/dist/admin/settings-pages.js.map +1 -0
  131. package/dist/admin/settings-permalinks.d.ts +16 -0
  132. package/dist/admin/settings-permalinks.d.ts.map +1 -0
  133. package/dist/admin/settings-permalinks.js +32 -0
  134. package/dist/admin/settings-permalinks.js.map +1 -0
  135. package/dist/admin/settings-reading.d.ts +34 -0
  136. package/dist/admin/settings-reading.d.ts.map +1 -0
  137. package/dist/admin/settings-reading.js +49 -0
  138. package/dist/admin/settings-reading.js.map +1 -0
  139. package/dist/admin/settings.d.ts +339 -96
  140. package/dist/admin/settings.d.ts.map +1 -1
  141. package/dist/admin/settings.js +600 -308
  142. package/dist/admin/settings.js.map +1 -1
  143. package/dist/admin/store.d.ts +606 -152
  144. package/dist/admin/store.d.ts.map +1 -1
  145. package/dist/admin/store.js +863 -268
  146. package/dist/admin/store.js.map +1 -1
  147. package/dist/admin/taxonomy.d.ts +114 -0
  148. package/dist/admin/taxonomy.d.ts.map +1 -0
  149. package/dist/admin/taxonomy.js +289 -0
  150. package/dist/admin/taxonomy.js.map +1 -0
  151. package/dist/admin/templates.d.ts +46 -14
  152. package/dist/admin/templates.d.ts.map +1 -1
  153. package/dist/admin/templates.js +46 -14
  154. package/dist/admin/templates.js.map +1 -1
  155. package/dist/admin/throttle.d.ts +84 -0
  156. package/dist/admin/throttle.d.ts.map +1 -0
  157. package/dist/admin/throttle.js +143 -0
  158. package/dist/admin/throttle.js.map +1 -0
  159. package/dist/admin/tools.d.ts +111 -0
  160. package/dist/admin/tools.d.ts.map +1 -0
  161. package/dist/admin/tools.js +182 -0
  162. package/dist/admin/tools.js.map +1 -0
  163. package/dist/admin/uploads.d.ts +28 -2
  164. package/dist/admin/uploads.d.ts.map +1 -1
  165. package/dist/admin/uploads.js +43 -4
  166. package/dist/admin/uploads.js.map +1 -1
  167. package/dist/admin/users.d.ts +100 -13
  168. package/dist/admin/users.d.ts.map +1 -1
  169. package/dist/admin/users.js +412 -39
  170. package/dist/admin/users.js.map +1 -1
  171. package/dist/cache.d.ts +141 -0
  172. package/dist/cache.d.ts.map +1 -0
  173. package/dist/cache.js +207 -0
  174. package/dist/cache.js.map +1 -0
  175. package/dist/cli.d.ts +14 -2
  176. package/dist/cli.d.ts.map +1 -1
  177. package/dist/cli.js +429 -21
  178. package/dist/cli.js.map +1 -1
  179. package/dist/comments/akismet.d.ts +130 -0
  180. package/dist/comments/akismet.d.ts.map +1 -0
  181. package/dist/comments/akismet.js +274 -0
  182. package/dist/comments/akismet.js.map +1 -0
  183. package/dist/comments/conversation.d.ts +29 -0
  184. package/dist/comments/conversation.d.ts.map +1 -0
  185. package/dist/comments/conversation.js +60 -0
  186. package/dist/comments/conversation.js.map +1 -0
  187. package/dist/comments/form.d.ts +123 -0
  188. package/dist/comments/form.d.ts.map +1 -0
  189. package/dist/comments/form.js +79 -0
  190. package/dist/comments/form.js.map +1 -0
  191. package/dist/comments/index.d.ts +16 -0
  192. package/dist/comments/index.d.ts.map +1 -0
  193. package/dist/comments/index.js +10 -0
  194. package/dist/comments/index.js.map +1 -0
  195. package/dist/comments/markdown.d.ts +8 -0
  196. package/dist/comments/markdown.d.ts.map +1 -0
  197. package/dist/comments/markdown.js +85 -0
  198. package/dist/comments/markdown.js.map +1 -0
  199. package/dist/comments/moderate.d.ts +71 -0
  200. package/dist/comments/moderate.d.ts.map +1 -0
  201. package/dist/comments/moderate.js +88 -0
  202. package/dist/comments/moderate.js.map +1 -0
  203. package/dist/comments/policy.d.ts +57 -0
  204. package/dist/comments/policy.d.ts.map +1 -0
  205. package/dist/comments/policy.js +80 -0
  206. package/dist/comments/policy.js.map +1 -0
  207. package/dist/comments/records.d.ts +266 -0
  208. package/dist/comments/records.d.ts.map +1 -0
  209. package/dist/comments/records.js +487 -0
  210. package/dist/comments/records.js.map +1 -0
  211. package/dist/comments/routes.d.ts +60 -0
  212. package/dist/comments/routes.d.ts.map +1 -0
  213. package/dist/comments/routes.js +212 -0
  214. package/dist/comments/routes.js.map +1 -0
  215. package/dist/comments/submission.d.ts +297 -0
  216. package/dist/comments/submission.d.ts.map +1 -0
  217. package/dist/comments/submission.js +280 -0
  218. package/dist/comments/submission.js.map +1 -0
  219. package/dist/comments/viewer.d.ts +21 -0
  220. package/dist/comments/viewer.d.ts.map +1 -0
  221. package/dist/comments/viewer.js +45 -0
  222. package/dist/comments/viewer.js.map +1 -0
  223. package/dist/config.d.ts +225 -5
  224. package/dist/config.d.ts.map +1 -1
  225. package/dist/config.js +121 -6
  226. package/dist/config.js.map +1 -1
  227. package/dist/contact/delivery.d.ts +60 -0
  228. package/dist/contact/delivery.d.ts.map +1 -0
  229. package/dist/contact/delivery.js +87 -0
  230. package/dist/contact/delivery.js.map +1 -0
  231. package/dist/contact/form.d.ts +130 -0
  232. package/dist/contact/form.d.ts.map +1 -0
  233. package/dist/contact/form.js +163 -0
  234. package/dist/contact/form.js.map +1 -0
  235. package/dist/contact/index.d.ts +17 -0
  236. package/dist/contact/index.d.ts.map +1 -0
  237. package/dist/contact/index.js +13 -0
  238. package/dist/contact/index.js.map +1 -0
  239. package/dist/contact/records.d.ts +124 -0
  240. package/dist/contact/records.d.ts.map +1 -0
  241. package/dist/contact/records.js +218 -0
  242. package/dist/contact/records.js.map +1 -0
  243. package/dist/contact/routes.d.ts +17 -0
  244. package/dist/contact/routes.d.ts.map +1 -0
  245. package/dist/contact/routes.js +127 -0
  246. package/dist/contact/routes.js.map +1 -0
  247. package/dist/contact/submission.d.ts +103 -0
  248. package/dist/contact/submission.d.ts.map +1 -0
  249. package/dist/contact/submission.js +166 -0
  250. package/dist/contact/submission.js.map +1 -0
  251. package/dist/content/document.d.ts +7 -2
  252. package/dist/content/document.d.ts.map +1 -1
  253. package/dist/content/document.js +1 -0
  254. package/dist/content/document.js.map +1 -1
  255. package/dist/content/index.d.ts +7 -2
  256. package/dist/content/index.d.ts.map +1 -1
  257. package/dist/content/index.js +4 -1
  258. package/dist/content/index.js.map +1 -1
  259. package/dist/content/markdown.d.ts.map +1 -1
  260. package/dist/content/markdown.js +24 -2
  261. package/dist/content/markdown.js.map +1 -1
  262. package/dist/content/parser.d.ts.map +1 -1
  263. package/dist/content/parser.js +11 -4
  264. package/dist/content/parser.js.map +1 -1
  265. package/dist/content/save.d.ts +5 -0
  266. package/dist/content/save.d.ts.map +1 -1
  267. package/dist/content/save.js +44 -1
  268. package/dist/content/save.js.map +1 -1
  269. package/dist/content/schedule.d.ts +125 -0
  270. package/dist/content/schedule.d.ts.map +1 -0
  271. package/dist/content/schedule.js +207 -0
  272. package/dist/content/schedule.js.map +1 -0
  273. package/dist/content/search.d.ts +64 -0
  274. package/dist/content/search.d.ts.map +1 -0
  275. package/dist/content/search.js +136 -0
  276. package/dist/content/search.js.map +1 -0
  277. package/dist/content/store.d.ts +220 -7
  278. package/dist/content/store.d.ts.map +1 -1
  279. package/dist/content/store.js +392 -67
  280. package/dist/content/store.js.map +1 -1
  281. package/dist/content/sync.d.ts +7 -1
  282. package/dist/content/sync.d.ts.map +1 -1
  283. package/dist/content/sync.js +18 -10
  284. package/dist/content/sync.js.map +1 -1
  285. package/dist/content/time.d.ts +56 -0
  286. package/dist/content/time.d.ts.map +1 -0
  287. package/dist/content/time.js +141 -0
  288. package/dist/content/time.js.map +1 -0
  289. package/dist/content/writer.d.ts.map +1 -1
  290. package/dist/content/writer.js +3 -0
  291. package/dist/content/writer.js.map +1 -1
  292. package/dist/env.d.ts +70 -5
  293. package/dist/env.d.ts.map +1 -1
  294. package/dist/federation/actor.d.ts +95 -41
  295. package/dist/federation/actor.d.ts.map +1 -1
  296. package/dist/federation/actor.js +180 -53
  297. package/dist/federation/actor.js.map +1 -1
  298. package/dist/federation/article.d.ts +44 -17
  299. package/dist/federation/article.d.ts.map +1 -1
  300. package/dist/federation/article.js +98 -46
  301. package/dist/federation/article.js.map +1 -1
  302. package/dist/federation/delivery.d.ts +63 -15
  303. package/dist/federation/delivery.d.ts.map +1 -1
  304. package/dist/federation/delivery.js +154 -80
  305. package/dist/federation/delivery.js.map +1 -1
  306. package/dist/federation/federation.d.ts +22 -17
  307. package/dist/federation/federation.d.ts.map +1 -1
  308. package/dist/federation/federation.js +98 -77
  309. package/dist/federation/federation.js.map +1 -1
  310. package/dist/federation/followers.d.ts +3 -2
  311. package/dist/federation/followers.d.ts.map +1 -1
  312. package/dist/federation/followers.js +6 -5
  313. package/dist/federation/followers.js.map +1 -1
  314. package/dist/federation/import-wordpress.d.ts +128 -0
  315. package/dist/federation/import-wordpress.d.ts.map +1 -0
  316. package/dist/federation/import-wordpress.js +426 -0
  317. package/dist/federation/import-wordpress.js.map +1 -0
  318. package/dist/federation/inbox.d.ts +36 -13
  319. package/dist/federation/inbox.d.ts.map +1 -1
  320. package/dist/federation/inbox.js +120 -49
  321. package/dist/federation/inbox.js.map +1 -1
  322. package/dist/federation/index.d.ts +22 -10
  323. package/dist/federation/index.d.ts.map +1 -1
  324. package/dist/federation/index.js +13 -8
  325. package/dist/federation/index.js.map +1 -1
  326. package/dist/federation/keys.d.ts +98 -20
  327. package/dist/federation/keys.d.ts.map +1 -1
  328. package/dist/federation/keys.js +269 -39
  329. package/dist/federation/keys.js.map +1 -1
  330. package/dist/federation/mount.d.ts +46 -10
  331. package/dist/federation/mount.d.ts.map +1 -1
  332. package/dist/federation/mount.js +300 -28
  333. package/dist/federation/mount.js.map +1 -1
  334. package/dist/federation/paths.d.ts +23 -35
  335. package/dist/federation/paths.d.ts.map +1 -1
  336. package/dist/federation/paths.js +26 -39
  337. package/dist/federation/paths.js.map +1 -1
  338. package/dist/federation/records.d.ts +223 -0
  339. package/dist/federation/records.d.ts.map +1 -0
  340. package/dist/federation/records.js +506 -0
  341. package/dist/federation/records.js.map +1 -0
  342. package/dist/federation/relays.d.ts +114 -0
  343. package/dist/federation/relays.d.ts.map +1 -0
  344. package/dist/federation/relays.js +290 -0
  345. package/dist/federation/relays.js.map +1 -0
  346. package/dist/federation/replies.d.ts +80 -0
  347. package/dist/federation/replies.d.ts.map +1 -0
  348. package/dist/federation/replies.js +166 -0
  349. package/dist/federation/replies.js.map +1 -0
  350. package/dist/federation/wordpress.d.ts +131 -0
  351. package/dist/federation/wordpress.d.ts.map +1 -0
  352. package/dist/federation/wordpress.js +341 -0
  353. package/dist/federation/wordpress.js.map +1 -0
  354. package/dist/files/atomic.d.ts +103 -0
  355. package/dist/files/atomic.d.ts.map +1 -0
  356. package/dist/files/atomic.js +150 -0
  357. package/dist/files/atomic.js.map +1 -0
  358. package/dist/files/index.d.ts +3 -0
  359. package/dist/files/index.d.ts.map +1 -0
  360. package/dist/files/index.js +2 -0
  361. package/dist/files/index.js.map +1 -0
  362. package/dist/forms/protection.d.ts +85 -0
  363. package/dist/forms/protection.d.ts.map +1 -0
  364. package/dist/forms/protection.js +116 -0
  365. package/dist/forms/protection.js.map +1 -0
  366. package/dist/images/icons.d.ts +59 -0
  367. package/dist/images/icons.d.ts.map +1 -0
  368. package/dist/images/icons.js +131 -0
  369. package/dist/images/icons.js.map +1 -0
  370. package/dist/images/index.d.ts +7 -0
  371. package/dist/images/index.d.ts.map +1 -0
  372. package/dist/images/index.js +4 -0
  373. package/dist/images/index.js.map +1 -0
  374. package/dist/images/markup.d.ts +47 -0
  375. package/dist/images/markup.d.ts.map +1 -0
  376. package/dist/images/markup.js +175 -0
  377. package/dist/images/markup.js.map +1 -0
  378. package/dist/images/paths.d.ts +34 -0
  379. package/dist/images/paths.d.ts.map +1 -0
  380. package/dist/images/paths.js +41 -0
  381. package/dist/images/paths.js.map +1 -0
  382. package/dist/images/variants.d.ts +104 -0
  383. package/dist/images/variants.d.ts.map +1 -0
  384. package/dist/images/variants.js +320 -0
  385. package/dist/images/variants.js.map +1 -0
  386. package/dist/index.d.ts +123 -19
  387. package/dist/index.d.ts.map +1 -1
  388. package/dist/index.js +344 -25
  389. package/dist/index.js.map +1 -1
  390. package/dist/init.d.ts +18 -0
  391. package/dist/init.d.ts.map +1 -1
  392. package/dist/init.js +34 -3
  393. package/dist/init.js.map +1 -1
  394. package/dist/mail/brevo.d.ts +35 -0
  395. package/dist/mail/brevo.d.ts.map +1 -0
  396. package/dist/mail/brevo.js +85 -0
  397. package/dist/mail/brevo.js.map +1 -0
  398. package/dist/mail/credentials.d.ts +62 -0
  399. package/dist/mail/credentials.d.ts.map +1 -0
  400. package/dist/mail/credentials.js +86 -0
  401. package/dist/mail/credentials.js.map +1 -0
  402. package/dist/mail/index.d.ts +15 -0
  403. package/dist/mail/index.d.ts.map +1 -0
  404. package/dist/mail/index.js +8 -0
  405. package/dist/mail/index.js.map +1 -0
  406. package/dist/mail/memory.d.ts +35 -0
  407. package/dist/mail/memory.d.ts.map +1 -0
  408. package/dist/mail/memory.js +29 -0
  409. package/dist/mail/memory.js.map +1 -0
  410. package/dist/mail/provider.d.ts +80 -0
  411. package/dist/mail/provider.d.ts.map +1 -0
  412. package/dist/mail/provider.js +23 -0
  413. package/dist/mail/provider.js.map +1 -0
  414. package/dist/mail/service.d.ts +156 -0
  415. package/dist/mail/service.d.ts.map +1 -0
  416. package/dist/mail/service.js +265 -0
  417. package/dist/mail/service.js.map +1 -0
  418. package/dist/mail/smtp.d.ts +39 -0
  419. package/dist/mail/smtp.d.ts.map +1 -0
  420. package/dist/mail/smtp.js +58 -0
  421. package/dist/mail/smtp.js.map +1 -0
  422. package/dist/mail/templates.d.ts +91 -0
  423. package/dist/mail/templates.d.ts.map +1 -0
  424. package/dist/mail/templates.js +69 -0
  425. package/dist/mail/templates.js.map +1 -0
  426. package/dist/notifications/comments.d.ts +66 -0
  427. package/dist/notifications/comments.d.ts.map +1 -0
  428. package/dist/notifications/comments.js +151 -0
  429. package/dist/notifications/comments.js.map +1 -0
  430. package/dist/notifications/digest.d.ts +135 -0
  431. package/dist/notifications/digest.d.ts.map +1 -0
  432. package/dist/notifications/digest.js +287 -0
  433. package/dist/notifications/digest.js.map +1 -0
  434. package/dist/notifications/index.d.ts +13 -0
  435. package/dist/notifications/index.d.ts.map +1 -0
  436. package/dist/notifications/index.js +8 -0
  437. package/dist/notifications/index.js.map +1 -0
  438. package/dist/notifications/links.d.ts +60 -0
  439. package/dist/notifications/links.d.ts.map +1 -0
  440. package/dist/notifications/links.js +63 -0
  441. package/dist/notifications/links.js.map +1 -0
  442. package/dist/notifications/optouts.d.ts +48 -0
  443. package/dist/notifications/optouts.d.ts.map +1 -0
  444. package/dist/notifications/optouts.js +93 -0
  445. package/dist/notifications/optouts.js.map +1 -0
  446. package/dist/notifications/preferences.d.ts +158 -0
  447. package/dist/notifications/preferences.d.ts.map +1 -0
  448. package/dist/notifications/preferences.js +195 -0
  449. package/dist/notifications/preferences.js.map +1 -0
  450. package/dist/notifications/routes.d.ts +28 -0
  451. package/dist/notifications/routes.d.ts.map +1 -0
  452. package/dist/notifications/routes.js +184 -0
  453. package/dist/notifications/routes.js.map +1 -0
  454. package/dist/notifications/tokens.d.ts +60 -0
  455. package/dist/notifications/tokens.d.ts.map +1 -0
  456. package/dist/notifications/tokens.js +125 -0
  457. package/dist/notifications/tokens.js.map +1 -0
  458. package/dist/notify.d.ts +84 -0
  459. package/dist/notify.d.ts.map +1 -0
  460. package/dist/notify.js +149 -0
  461. package/dist/notify.js.map +1 -0
  462. package/dist/web/archive.d.ts +49 -0
  463. package/dist/web/archive.d.ts.map +1 -0
  464. package/dist/web/archive.js +59 -0
  465. package/dist/web/archive.js.map +1 -0
  466. package/dist/web/assets.d.ts +6 -5
  467. package/dist/web/assets.d.ts.map +1 -1
  468. package/dist/web/assets.js +5 -7
  469. package/dist/web/assets.js.map +1 -1
  470. package/dist/web/authors.d.ts +143 -0
  471. package/dist/web/authors.d.ts.map +1 -0
  472. package/dist/web/authors.js +178 -0
  473. package/dist/web/authors.js.map +1 -0
  474. package/dist/web/comments.d.ts +60 -0
  475. package/dist/web/comments.d.ts.map +1 -0
  476. package/dist/web/comments.js +207 -0
  477. package/dist/web/comments.js.map +1 -0
  478. package/dist/web/context.d.ts +162 -28
  479. package/dist/web/context.d.ts.map +1 -1
  480. package/dist/web/context.js +123 -26
  481. package/dist/web/context.js.map +1 -1
  482. package/dist/web/conversation.d.ts +209 -0
  483. package/dist/web/conversation.d.ts.map +1 -0
  484. package/dist/web/conversation.js +557 -0
  485. package/dist/web/conversation.js.map +1 -0
  486. package/dist/web/documents.d.ts +36 -6
  487. package/dist/web/documents.d.ts.map +1 -1
  488. package/dist/web/documents.js +52 -10
  489. package/dist/web/documents.js.map +1 -1
  490. package/dist/web/feed-atom.d.ts +22 -0
  491. package/dist/web/feed-atom.d.ts.map +1 -0
  492. package/dist/web/feed-atom.js +83 -0
  493. package/dist/web/feed-atom.js.map +1 -0
  494. package/dist/web/feed-item.d.ts +145 -0
  495. package/dist/web/feed-item.d.ts.map +1 -0
  496. package/dist/web/feed-item.js +120 -0
  497. package/dist/web/feed-item.js.map +1 -0
  498. package/dist/web/feed-json.d.ts +79 -0
  499. package/dist/web/feed-json.d.ts.map +1 -0
  500. package/dist/web/feed-json.js +53 -0
  501. package/dist/web/feed-json.js.map +1 -0
  502. package/dist/web/feed-rss.d.ts +26 -0
  503. package/dist/web/feed-rss.d.ts.map +1 -0
  504. package/dist/web/feed-rss.js +203 -0
  505. package/dist/web/feed-rss.js.map +1 -0
  506. package/dist/web/feed-source.d.ts +244 -0
  507. package/dist/web/feed-source.d.ts.map +1 -0
  508. package/dist/web/feed-source.js +201 -0
  509. package/dist/web/feed-source.js.map +1 -0
  510. package/dist/web/feed-xml.d.ts +63 -0
  511. package/dist/web/feed-xml.d.ts.map +1 -0
  512. package/dist/web/feed-xml.js +116 -0
  513. package/dist/web/feed-xml.js.map +1 -0
  514. package/dist/web/feeds.d.ts +31 -96
  515. package/dist/web/feeds.d.ts.map +1 -1
  516. package/dist/web/feeds.js +94 -198
  517. package/dist/web/feeds.js.map +1 -1
  518. package/dist/web/health.d.ts +31 -0
  519. package/dist/web/health.d.ts.map +1 -0
  520. package/dist/web/health.js +44 -0
  521. package/dist/web/health.js.map +1 -0
  522. package/dist/web/index.d.ts +30 -10
  523. package/dist/web/index.d.ts.map +1 -1
  524. package/dist/web/index.js +18 -7
  525. package/dist/web/index.js.map +1 -1
  526. package/dist/web/navigation.d.ts +195 -0
  527. package/dist/web/navigation.d.ts.map +1 -0
  528. package/dist/web/navigation.js +300 -0
  529. package/dist/web/navigation.js.map +1 -0
  530. package/dist/web/negotiate.d.ts +31 -0
  531. package/dist/web/negotiate.d.ts.map +1 -1
  532. package/dist/web/negotiate.js +31 -11
  533. package/dist/web/negotiate.js.map +1 -1
  534. package/dist/web/recent.d.ts +43 -0
  535. package/dist/web/recent.d.ts.map +1 -0
  536. package/dist/web/recent.js +37 -0
  537. package/dist/web/recent.js.map +1 -0
  538. package/dist/web/render.d.ts +203 -9
  539. package/dist/web/render.d.ts.map +1 -1
  540. package/dist/web/render.js +341 -17
  541. package/dist/web/render.js.map +1 -1
  542. package/dist/web/routes.d.ts +25 -8
  543. package/dist/web/routes.d.ts.map +1 -1
  544. package/dist/web/routes.js +854 -93
  545. package/dist/web/routes.js.map +1 -1
  546. package/dist/web/sanitize.d.ts +19 -0
  547. package/dist/web/sanitize.d.ts.map +1 -0
  548. package/dist/web/sanitize.js +272 -0
  549. package/dist/web/sanitize.js.map +1 -0
  550. package/dist/web/search.d.ts +79 -0
  551. package/dist/web/search.d.ts.map +1 -0
  552. package/dist/web/search.js +94 -0
  553. package/dist/web/search.js.map +1 -0
  554. package/dist/web/sitemap.d.ts +120 -0
  555. package/dist/web/sitemap.d.ts.map +1 -0
  556. package/dist/web/sitemap.js +199 -0
  557. package/dist/web/sitemap.js.map +1 -0
  558. package/dist/web/taxonomy.d.ts +152 -0
  559. package/dist/web/taxonomy.d.ts.map +1 -0
  560. package/dist/web/taxonomy.js +237 -0
  561. package/dist/web/taxonomy.js.map +1 -0
  562. package/dist/web/templates.d.ts +54 -16
  563. package/dist/web/templates.d.ts.map +1 -1
  564. package/dist/web/templates.js +109 -28
  565. package/dist/web/templates.js.map +1 -1
  566. package/dist/web/themes.d.ts +226 -0
  567. package/dist/web/themes.d.ts.map +1 -0
  568. package/dist/web/themes.js +314 -0
  569. package/dist/web/themes.js.map +1 -0
  570. package/dist/webmention/discovery.d.ts +51 -0
  571. package/dist/webmention/discovery.d.ts.map +1 -0
  572. package/dist/webmention/discovery.js +176 -0
  573. package/dist/webmention/discovery.js.map +1 -0
  574. package/dist/webmention/html.d.ts +71 -0
  575. package/dist/webmention/html.d.ts.map +1 -0
  576. package/dist/webmention/html.js +285 -0
  577. package/dist/webmention/html.js.map +1 -0
  578. package/dist/webmention/index.d.ts +12 -0
  579. package/dist/webmention/index.d.ts.map +1 -0
  580. package/dist/webmention/index.js +8 -0
  581. package/dist/webmention/index.js.map +1 -0
  582. package/dist/webmention/links.d.ts +18 -0
  583. package/dist/webmention/links.d.ts.map +1 -0
  584. package/dist/webmention/links.js +64 -0
  585. package/dist/webmention/links.js.map +1 -0
  586. package/dist/webmention/microformats.d.ts +93 -0
  587. package/dist/webmention/microformats.d.ts.map +1 -0
  588. package/dist/webmention/microformats.js +376 -0
  589. package/dist/webmention/microformats.js.map +1 -0
  590. package/dist/webmention/receive.d.ts +141 -0
  591. package/dist/webmention/receive.d.ts.map +1 -0
  592. package/dist/webmention/receive.js +264 -0
  593. package/dist/webmention/receive.js.map +1 -0
  594. package/dist/webmention/routes.d.ts +37 -0
  595. package/dist/webmention/routes.d.ts.map +1 -0
  596. package/dist/webmention/routes.js +75 -0
  597. package/dist/webmention/routes.js.map +1 -0
  598. package/dist/webmention/service.d.ts +94 -0
  599. package/dist/webmention/service.d.ts.map +1 -0
  600. package/dist/webmention/service.js +207 -0
  601. package/dist/webmention/service.js.map +1 -0
  602. package/package.json +11 -6
  603. package/templates/site/content/_data/site.json +8 -0
  604. package/templates/site/content/pages/about.md +7 -0
  605. package/templates/site/content/posts/2026-01-01-hello-world.md +5 -3
  606. package/templates/site/geekity.config.ts +11 -1
  607. package/templates/site/tsconfig.json +1 -1
  608. package/themes/default/README.md +1216 -50
  609. package/themes/default/layouts/404.njk +2 -3
  610. package/themes/default/layouts/author.njk +36 -0
  611. package/themes/default/layouts/base.njk +161 -31
  612. package/themes/default/layouts/category.njk +27 -0
  613. package/themes/default/layouts/front-page.njk +56 -0
  614. package/themes/default/layouts/home.njk +17 -1
  615. package/themes/default/layouts/page.njk +57 -5
  616. package/themes/default/layouts/post.njk +78 -19
  617. package/themes/default/layouts/search.njk +57 -0
  618. package/themes/default/layouts/tag.njk +3 -6
  619. package/themes/default/mail/comment-digest.html.njk +37 -0
  620. package/themes/default/mail/comment-digest.subject.njk +3 -0
  621. package/themes/default/mail/comment-digest.txt.njk +40 -0
  622. package/themes/default/mail/comment-pending.html.njk +27 -0
  623. package/themes/default/mail/comment-pending.subject.njk +2 -0
  624. package/themes/default/mail/comment-pending.txt.njk +24 -0
  625. package/themes/default/mail/comment-reply.html.njk +21 -0
  626. package/themes/default/mail/comment-reply.subject.njk +2 -0
  627. package/themes/default/mail/comment-reply.txt.njk +22 -0
  628. package/themes/default/mail/contact-message.html.njk +24 -0
  629. package/themes/default/mail/contact-message.subject.njk +5 -0
  630. package/themes/default/mail/contact-message.txt.njk +20 -0
  631. package/themes/default/mail/password-changed.html.njk +18 -0
  632. package/themes/default/mail/password-changed.subject.njk +2 -0
  633. package/themes/default/mail/password-changed.txt.njk +17 -0
  634. package/themes/default/mail/password-reset.html.njk +22 -0
  635. package/themes/default/mail/password-reset.subject.njk +2 -0
  636. package/themes/default/mail/password-reset.txt.njk +18 -0
  637. package/themes/default/mail/test.html.njk +14 -0
  638. package/themes/default/mail/test.subject.njk +2 -0
  639. package/themes/default/mail/test.txt.njk +12 -0
  640. package/themes/default/partials/archive.njk +27 -0
  641. package/themes/default/partials/bio.njk +71 -0
  642. package/themes/default/partials/comment-form.njk +137 -0
  643. package/themes/default/partials/contact-form.njk +88 -0
  644. package/themes/default/partials/conversation.njk +139 -0
  645. package/themes/default/partials/feeds.njk +26 -0
  646. package/themes/default/partials/jsonld.njk +120 -0
  647. package/themes/default/partials/menu.njk +32 -0
  648. package/themes/default/partials/pagination.njk +12 -6
  649. package/themes/default/partials/post-list.njk +51 -18
  650. package/themes/default/partials/search-form.njk +22 -0
  651. package/themes/default/partials/tags.njk +26 -5
  652. package/themes/default/static/highlight.js +4 -0
  653. package/themes/default/static/style.css +1142 -194
  654. package/themes/default/theme.json +9 -0
  655. package/admin/layouts/federation.njk +0 -145
  656. package/admin/layouts/login.njk +0 -23
  657. package/admin/layouts/settings.njk +0 -116
  658. package/admin/layouts/setup.njk +0 -27
  659. package/admin/layouts/users.njk +0 -91
  660. /package/admin/{layouts/_flash.njk → components/flash.njk} +0 -0
@@ -1,14 +1,25 @@
1
+ import { listUsers } from '../admin/accounts.js';
2
+ import { readSiteSettings } from '../admin/settings.js';
1
3
  import { serializeDocument } from '../content/writer.js';
2
- import { assetNotModified, assetResponse, findThemeAsset, findUpload, matchesEtag, themeAssetNotModified, themeAssetResponse, themeSearchPath, THEME_ASSET_PREFIX, UPLOAD_ASSET_MAX_AGE, UPLOAD_ASSET_PREFIX, } from './assets.js';
4
+ import { findImageVariant, VARIANT_ASSET_PREFIX } from '../images/variants.js';
5
+ import { assetNotModified, assetResponse, findThemeAsset, findUpload, matchesEtag, themeAssetNotModified, themeAssetResponse, THEME_ASSET_PREFIX, UPLOAD_ASSET_MAX_AGE, UPLOAD_ASSET_PREFIX, } from './assets.js';
6
+ import { COMMENT_NOTICE_PARAM, COMMENT_REPLY_PARAM } from '../comments/form.js';
7
+ import { commentNoticeFor, commentReplyTarget, mountComments } from '../comments/routes.js';
8
+ import { signedInCommenter } from '../comments/viewer.js';
9
+ import { CONTACT_NOTICE_PARAM, contactNoticeFor } from '../contact/form.js';
10
+ import { mountContact } from '../contact/routes.js';
11
+ import { mountWebmentions, WEBMENTION_PATH } from '../webmention/routes.js';
12
+ import { mountNotificationLinks } from '../notifications/routes.js';
13
+ import { authorFeedHref, authorHref, authorNames, parseAuthorPath, profileContext, } from './authors.js';
14
+ import { feedComments, spokenIn } from './conversation.js';
3
15
  import { isPublicDocument, publicDocumentAt } from './documents.js';
4
- import { feedResponse, feedSize, FEED_FILES } from './feeds.js';
5
- import { DOCUMENT_REPRESENTATIONS, documentJson, LISTING_REPRESENTATIONS, lastModifiedOf, latestModified, notAcceptableResponse, representationEtag, representationResponse, selectRepresentation, splitRepresentationExtension, } from './negotiate.js';
16
+ import { commentsFeedPath, commentsFeedResponse, feedPathUnder, feedResponse, feedSize, splitFeedPath, COMMENTS_ROOT, COMMENTS_TITLE_PREFIX, FEED_FORMATS, FEED_SEGMENTS, } from './feeds.js';
17
+ import { DOCUMENT_REPRESENTATIONS, documentJson, LISTING_REPRESENTATIONS, lastModifiedOf, latestModified, MEDIA_TYPES, notAcceptableResponse, representationHref, representationEtag, representationResponse, selectRepresentation, splitRepresentationExtension, } from './negotiate.js';
6
18
  import { offsetForPage, paginate } from './pagination.js';
7
19
  import { TEMPLATES } from './render.js';
8
- /** Where a paginated listing's later pages live, under any listing root. */
9
- export const PAGE_SEGMENT = 'page';
10
- /** Root of the tag archives. */
11
- export const TAG_SEGMENT = 'tags';
20
+ import { robotsResponse, sitemapResponse, ROBOTS_PATH, SITEMAP_CHILD_ROUTE, SITEMAP_PATH, } from './sitemap.js';
21
+ import { searchHref, searchJson, searchPageIndex, searchQuery, SEARCH_PAGE_PARAM, SEARCH_PATH, SEARCH_QUERY_PARAM, } from './search.js';
22
+ import { PAGE_SEGMENT, redirectedTerm, taxonomyForSegment, termHref } from './taxonomy.js';
12
23
  /**
13
24
  * Register the public site on a Hono app.
14
25
  *
@@ -19,84 +30,365 @@ export const TAG_SEGMENT = 'tags';
19
30
  */
20
31
  export function mountPublicSite(app) {
21
32
  app.get(`${THEME_ASSET_PREFIX}*`, themeAsset);
33
+ // Derived images go on first: their prefix is inside the uploads one, so the
34
+ // general route would otherwise swallow them and answer 404 for a file that
35
+ // is not under `content/uploads/` at all.
36
+ app.get(`${VARIANT_ASSET_PREFIX}*`, imageVariant);
22
37
  app.get(`${UPLOAD_ASSET_PREFIX}*`, upload);
23
- app.get('/', (c) => listing(c, { tag: undefined, pageNumber: 0 }));
38
+ // Where the comment form under a post posts to (TASK-50). A POST at a fixed
39
+ // path of the CMS's own, so no permalink can ever shadow it and the route
40
+ // table does not grow with the site.
41
+ mountComments(app);
42
+ // And where a contact form's message is sent (TASK-56): the same prefix
43
+ // again, so the page a form is on can be permalinked anywhere.
44
+ mountContact(app);
45
+ // And where a webmention is sent (TASK-51), for the same reason and under
46
+ // the same prefix.
47
+ mountWebmentions(app);
48
+ // And where the one-click links in a notification land (TASK-55): the same
49
+ // prefix again, and no session behind either of them.
50
+ mountNotificationLinks(app);
51
+ app.get('/', (c) => {
52
+ // The site's latest posts, or the page the Reading setting names — the
53
+ // whole of WordPress's "Your homepage displays", decided per request off
54
+ // `site.json` so a save on the settings screen moves the front page on the
55
+ // very next one.
56
+ const home = frontPages(c).home;
57
+ if (home === undefined)
58
+ return listing(c, { term: undefined, pageNumber: 0 });
59
+ return negotiateDocument(c, home, selectFromAccept(c, DOCUMENT_REPRESENTATIONS), '/');
60
+ });
24
61
  app.get(`/${PAGE_SEGMENT}/:page{[0-9]+}/`, (c) => {
25
62
  const requested = Number(c.req.param('page'));
26
63
  // Page one is the home page; it does not get a second URL.
27
64
  if (requested <= 1)
28
65
  return c.redirect('/', 301);
29
- return listing(c, { tag: undefined, pageNumber: requested - 1 });
30
- });
31
- // Feeds are routes rather than representations, so a reader's careless
32
- // `Accept` header cannot land it on the HTML (doc-3). They are registered
33
- // before the tag archive so the shape of the URL, not the negotiator,
34
- // decides what comes back.
35
- app.get(`/${FEED_FILES.atom}`, (c) => feed(c, 'atom', undefined));
36
- app.get(`/${FEED_FILES.json}`, (c) => feed(c, 'json', undefined));
37
- app.get(`/${TAG_SEGMENT}/:tag/${FEED_FILES.atom}`, (c) => feed(c, 'atom', c.req.param('tag')));
38
- app.get(`/${TAG_SEGMENT}/:tag/${FEED_FILES.json}`, (c) => feed(c, 'json', c.req.param('tag')));
39
- app.get(`/${TAG_SEGMENT}/:tag/`, (c) => listing(c, { tag: c.req.param('tag'), pageNumber: 0 }));
40
- app.get(`/${TAG_SEGMENT}/:tag/${PAGE_SEGMENT}/:page{[0-9]+}/`, (c) => {
41
- const tag = c.req.param('tag');
42
- const requested = Number(c.req.param('page'));
43
- if (requested <= 1)
44
- return c.redirect(tagHref(tag, 0), 301);
45
- return listing(c, { tag, pageNumber: requested - 1 });
66
+ const pages = frontPages(c);
67
+ // With a posts page, the listing's pages live under it and these are the
68
+ // URLs they used to have; with a static homepage and no posts page, the
69
+ // listing has no page of its own and neither have its pages.
70
+ if (pages.posts !== undefined) {
71
+ return c.redirect(listingPageHref(pages.posts.permalink, requested - 1), 301);
72
+ }
73
+ if (pages.home !== undefined)
74
+ return notFound(c);
75
+ return listing(c, { term: undefined, pageNumber: requested - 1 });
46
76
  });
47
- app.notFound(resolveDocument);
77
+ // The site's own feeds are routes rather than representations, so a reader's
78
+ // careless `Accept` header cannot land it on the HTML (doc-3), and so a
79
+ // document permalinked at `/feed/` cannot take the subscribers' URL. Their
80
+ // paths are fixed — only the taxonomy feeds hang off a configurable base —
81
+ // so the route table can hold them.
82
+ for (const format of FEED_FORMATS) {
83
+ app.get(feedPathUnder('/', format), (c) => feed(c, format, {}));
84
+ }
85
+ // The site-wide comments feed, for the same reason and at WordPress's URL.
86
+ app.get(commentsFeedHref(undefined), (c) => comments(c, undefined));
87
+ // The sitemap, its children and the robots file: fixed paths at the root of
88
+ // the site, which is the only place a crawler looks, and routes for the same
89
+ // reason the feeds are — no document permalinked there can take the URL a
90
+ // search engine polls.
91
+ app.get(SITEMAP_PATH, (c) => sitemap(c, undefined));
92
+ app.get(SITEMAP_CHILD_ROUTE, (c) => sitemap(c, Number(c.req.param('page'))));
93
+ app.get(ROBOTS_PATH, (c) => robotsResponse(c.var.config.baseUrl, conditionalHeaders(c)));
94
+ // The site's search (TASK-22): a route at a fixed path for the reason the
95
+ // feeds are, so the form in every theme's footer submits somewhere no
96
+ // permalink can take. It negotiates HTML and JSON like a listing, and
97
+ // `/search/index.json` is the same escape hatch a listing has. The path
98
+ // without its slash is where a hand-typed URL lands, and keeps its query on
99
+ // the way to the real one.
100
+ app.get(SEARCH_PATH, (c) => search(c, selectFromAccept(c, LISTING_REPRESENTATIONS)));
101
+ app.get(representationHref(SEARCH_PATH, 'json'), (c) => search(c, 'json'));
102
+ app.get(SEARCH_PATH.slice(0, -1), (c) => c.redirect(`${SEARCH_PATH}${new URL(c.req.url).search}`, 301));
103
+ // The taxonomy archives are deliberately not routes. A route table is fixed
104
+ // when the app is built and the bases are a setting, so an archive is
105
+ // resolved per request in the not-found handler, from the base the site
106
+ // holds at that moment (TASK-36).
107
+ app.notFound(resolveRequest);
48
108
  }
49
109
  /**
50
- * The last stop for a request: the document at this URL, the same document in
51
- * the representation a `.md` or `.json` suffix asked for, the canonical URL it
52
- * should have asked for, or the theme's 404.
110
+ * The last stop for a request: a taxonomy archive or one of its feeds, the
111
+ * document at this URL, the same document in the representation a `.md` or
112
+ * `.json` suffix asked for, the canonical URL it should have asked for, or the
113
+ * theme's 404.
53
114
  *
54
- * The suffix is stripped and the *same* {@link publicDocumentAt} lookup runs
55
- * again, so no representation can resolve to a document the others cannot see.
115
+ * The archives come first, so an archive URL means the archive however a
116
+ * document is permalinked; they are here rather than in the route table
117
+ * because their bases are a setting, and a route table is fixed when the app
118
+ * is built. The suffix is stripped and the *same* {@link publicDocumentAt}
119
+ * lookup runs again, so no representation can resolve to a document the others
120
+ * cannot see.
56
121
  */
57
- function resolveDocument(c) {
58
- const { store } = c.var;
122
+ function resolveRequest(c) {
123
+ const { store, renderer } = c.var;
59
124
  const pathname = requestPath(c);
125
+ const bases = renderer.taxonomyBases();
126
+ const authors = authorsOf(c);
127
+ const pages = frontPages(c);
128
+ // The posts page carries the listing at its own URL, so it is answered
129
+ // before the document lookup that would otherwise render it as the page it
130
+ // also is. Its pagination hangs off it: `{permalink}page/2/`.
131
+ if (pages.posts !== undefined) {
132
+ const request = postsPageRequest(pages.posts, pathname);
133
+ if (request !== undefined) {
134
+ // `{permalink}page/1/` is the listing's own URL spelled twice, and
135
+ // collapses onto it exactly as `/page/1/` collapses onto `/`.
136
+ if (request.pageNumber === 0 && pathname !== pages.posts.permalink) {
137
+ return c.redirect(encodePath(pages.posts.permalink), 301);
138
+ }
139
+ return listing(c, request);
140
+ }
141
+ }
142
+ const feedRequest = parseFeedPath(store, pathname, bases, authors);
143
+ if (feedRequest !== undefined) {
144
+ const { target, format } = feedRequest;
145
+ if (!feedRequest.canonical)
146
+ return c.redirect(feedTargetHref(target, format, bases), 301);
147
+ return target.kind === 'listing'
148
+ ? feed(c, format, target.subject)
149
+ : comments(c, target.document);
150
+ }
151
+ const archive = taxonomyArchive(c, pathname, bases, authors);
152
+ if (archive !== undefined)
153
+ return archive;
154
+ // And one person's, which is the same kind of thing at a path the site
155
+ // reserves: `author` is never a document's first segment, so this can be
156
+ // answered before the permalink lookup without anything being shadowed.
157
+ const byAuthor = authorArchive(c, pathname, bases, authors);
158
+ if (byAuthor !== undefined)
159
+ return byAuthor;
60
160
  const document = publicDocumentAt(store, pathname);
61
161
  if (document !== undefined) {
162
+ // The front page has one URL. The page it is made of keeps its own
163
+ // permalink in the file, so that a homepage put back to being an ordinary
164
+ // page goes back to answering there, and that URL points at `/` while it
165
+ // is the front page.
166
+ if (document.path === pages.home?.path)
167
+ return c.redirect('/', 301);
168
+ // WordPress answered `?feed=rss2` on a permalink with that post's comments
169
+ // feed, which is the only feed a post has here too. The other spellings
170
+ // name a format a comments feed does not come in, so they are not a feed
171
+ // request at all and the page is served.
172
+ if (document.type === 'post' && queryFeedFormat(c) === 'rss') {
173
+ return c.redirect(commentsFeedHref(document), 301);
174
+ }
62
175
  return negotiateDocument(c, document, selectFromAccept(c, DOCUMENT_REPRESENTATIONS));
63
176
  }
64
177
  const extension = splitRepresentationExtension(pathname);
65
178
  if (extension !== undefined) {
66
179
  for (const candidate of extension.paths) {
67
- const found = publicDocumentAt(store, candidate);
68
- if (found !== undefined)
69
- return negotiateDocument(c, found, extension.representation);
180
+ // `/index.md` and `/index.json` are the front page's own
181
+ // representations on a site whose `/` is a page: the document is at `/`
182
+ // however it is asked for.
183
+ const found = candidate === '/' ? pages.home : publicDocumentAt(store, candidate);
184
+ if (found !== undefined) {
185
+ return negotiateDocument(c, found, extension.representation, candidate === '/' ? '/' : undefined);
186
+ }
70
187
  }
71
- // `/index.json`, `/page/2/index.json`, `/tags/x/index.json`: the same
72
- // escape hatch over a listing, for the representations a listing has.
188
+ // `/index.json`, `/page/2/index.json`, `/tag/x/index.json`,
189
+ // `/category/x/index.json`: the same escape hatch over a listing, for the
190
+ // representations a listing has.
73
191
  if (LISTING_REPRESENTATIONS.includes(extension.representation)) {
74
192
  for (const candidate of extension.paths) {
75
- const request = parseListingPath(candidate);
193
+ const request = listingRequestAt(pages, candidate, bases, authors);
76
194
  if (request !== undefined)
77
195
  return listing(c, request, extension.representation);
78
196
  }
79
197
  }
80
198
  }
81
- const canonical = canonicalPath(c, pathname);
199
+ const canonical = canonicalPath(c, pathname, bases, authors);
82
200
  if (canonical !== undefined)
83
201
  return c.redirect(canonical, 301);
84
202
  return notFound(c);
85
203
  }
204
+ /**
205
+ * One taxonomy archive, the redirect that puts it at its canonical URL, or
206
+ * `undefined` when the path is not one.
207
+ *
208
+ * `/{base}/x/page/1/` collapses onto the archive root the way `/page/1/`
209
+ * collapses onto the home page, and an archive nothing carries 404s rather
210
+ * than redirecting first: a URL that leads nowhere should cost one 404, not a
211
+ * redirect and then a 404.
212
+ */
213
+ function taxonomyArchive(c, pathname, bases, authors) {
214
+ if (!pathname.endsWith('/'))
215
+ return undefined;
216
+ const request = parseListingPath(pathname, bases, authors);
217
+ if (request?.term === undefined)
218
+ return undefined;
219
+ // An archive that answers is served; only once nothing carries the term is
220
+ // the record of renames consulted, so a term that comes back into use — or
221
+ // one that was merged into and then recreated — beats what it used to be
222
+ // called. One hop: the list is stored with its chains already collapsed.
223
+ if (countListing(c.var.store, request) === 0) {
224
+ const moved = movedTerm(c, request.term);
225
+ if (moved !== undefined)
226
+ return c.redirect(termHref(moved, request.pageNumber, bases), 301);
227
+ }
228
+ const canonical = termHref(request.term, request.pageNumber, bases);
229
+ if (canonical !== encodePath(pathname)) {
230
+ return countListing(c.var.store, request) === 0 ? notFound(c) : c.redirect(canonical, 301);
231
+ }
232
+ return listing(c, request);
233
+ }
234
+ /**
235
+ * One person's archive, the redirect that puts it at its canonical URL, or
236
+ * `undefined` when the path is not one.
237
+ *
238
+ * Unlike a taxonomy archive it does not depend on there being anything on it.
239
+ * A tag exists only while something carries it; a person exists because they
240
+ * have an account, and after decision-14 this URL is the id their followers
241
+ * file them under — so an archive that 404'd until its owner published would
242
+ * be an account that came into being with a post. What decides the 404 is
243
+ * whether the username is somebody's, which {@link parseListingPath} has
244
+ * already asked.
245
+ *
246
+ * `/author/{username}/page/1/` collapses onto the archive root the way
247
+ * `/page/1/` collapses onto the home page.
248
+ */
249
+ function authorArchive(c, pathname, bases, authors) {
250
+ if (!pathname.endsWith('/'))
251
+ return undefined;
252
+ const request = parseListingPath(pathname, bases, authors);
253
+ if (request?.author === undefined)
254
+ return undefined;
255
+ const canonical = authorHref(request.author.user.username, request.pageNumber);
256
+ if (canonical !== encodePath(pathname))
257
+ return c.redirect(canonical, 301);
258
+ return listing(c, request);
259
+ }
260
+ /**
261
+ * Where a term with nothing left under it went, or `undefined` when the site
262
+ * records no such rename.
263
+ */
264
+ function movedTerm(c, term) {
265
+ const moved = redirectedTerm(c.var.renderer.termRedirects(), term);
266
+ return moved === undefined ? undefined : { taxonomy: term.taxonomy, term: moved };
267
+ }
268
+ /**
269
+ * A path as the feed it names: `/feed/`, `/feed/atom/`, `/{base}/x/feed/json/`,
270
+ * the comments feeds at `/comments/feed/` and `{permalink}feed/`, and the
271
+ * `/feed/rss/` spelling WordPress also answered at any of those roots.
272
+ *
273
+ * The site's own canonical feeds are real routes and never reach here; what
274
+ * does reach here is every taxonomy feed — their bases are a setting, so they
275
+ * cannot be in the route table — every post's comments feed, and every alias.
276
+ */
277
+ function parseFeedPath(store, pathname, bases, authors) {
278
+ const split = splitFeedPath(pathname);
279
+ if (split === undefined)
280
+ return undefined;
281
+ const { format, canonical } = split;
282
+ if (split.root === COMMENTS_ROOT) {
283
+ return format === 'rss'
284
+ ? { target: { kind: 'comments', document: undefined }, format, canonical }
285
+ : undefined;
286
+ }
287
+ const root = parseListingPath(split.root, bases, authors);
288
+ // Only a listing root has a feed, and only its first page: a feed is not
289
+ // paginated, so `/{base}/x/page/2/feed/` names nothing.
290
+ if (root !== undefined) {
291
+ return root.pageNumber === 0
292
+ ? { target: { kind: 'listing', subject: root }, format, canonical }
293
+ : undefined;
294
+ }
295
+ // Otherwise it may be a post's comments feed. Only a published post has one:
296
+ // a page never federates, so nothing in the fediverse can ever have replied
297
+ // to it, and a comments feed for it would be empty for ever.
298
+ if (format !== 'rss')
299
+ return undefined;
300
+ const document = publicDocumentAt(store, split.root);
301
+ if (document?.type !== 'post')
302
+ return undefined;
303
+ return { target: { kind: 'comments', document }, format, canonical };
304
+ }
305
+ /** Where the canonical URL of one feed is. */
306
+ function feedTargetHref(target, format, bases) {
307
+ return target.kind === 'listing'
308
+ ? listingFeedHref(target.subject, format, bases)
309
+ : commentsFeedHref(target.document);
310
+ }
311
+ /**
312
+ * The format `?feed=` asked for, WordPress's pre-permalink spelling, or
313
+ * `undefined` when the request did not ask for a feed at all.
314
+ *
315
+ * `rss2` is what WordPress calls RSS 2.0 and `rss` its RSS 0.92, which this
316
+ * site does not serve and answers with the RSS 2.0 feed rather than a 404.
317
+ */
318
+ function queryFeedFormat(c) {
319
+ const asked = c.req.query('feed');
320
+ if (asked === undefined)
321
+ return undefined;
322
+ if (asked === 'rss2' || asked === 'rss')
323
+ return 'rss';
324
+ return FEED_FORMATS.find((format) => FEED_SEGMENTS[format] === asked);
325
+ }
326
+ /**
327
+ * What the query string puts on a rendered post, if anything.
328
+ *
329
+ * A reader has no session, so the message after a submission travels in the
330
+ * URL and is read back here. An unknown value says nothing at all rather than
331
+ * being printed, so the query cannot be used to put words on somebody's post.
332
+ */
333
+ function commentNotice(c, document) {
334
+ const notice = commentNoticeFor(c.req.query(COMMENT_NOTICE_PARAM));
335
+ return {
336
+ ...(notice === undefined ? {} : { commentNotice: notice }),
337
+ // And who a Reply link says the form is answering, which travels the same
338
+ // way and for the same reason: a reader has no session, and threading
339
+ // should not need a line of JavaScript.
340
+ ...commentReplyTarget({
341
+ admin: c.var.admin,
342
+ document,
343
+ id: c.req.query(COMMENT_REPLY_PARAM),
344
+ }),
345
+ // And the thank-you after a contact form was sent, which travels the same
346
+ // way for the same reason (TASK-56).
347
+ ...contactNotice(c),
348
+ };
349
+ }
350
+ /**
351
+ * The thank-you a contact form's redirect landed on, if the query names one.
352
+ *
353
+ * On the context rather than in the form's own state because the redirect is
354
+ * what makes a refresh harmless: the page is drawn fresh, and the query is the
355
+ * only thing that says a message went.
356
+ */
357
+ function contactNotice(c) {
358
+ const notice = contactNoticeFor(c.req.query(CONTACT_NOTICE_PARAM));
359
+ return notice === undefined ? {} : { contactNotice: notice };
360
+ }
86
361
  /** The representation an `Accept` header asked for, or `undefined` for a 406. */
87
362
  function selectFromAccept(c, available) {
88
363
  return selectRepresentation(c.req.header('accept'), available);
89
364
  }
90
- /** One document in one representation, or the 406 an impossible `Accept` earns. */
91
- function negotiateDocument(c, document, representation) {
365
+ /**
366
+ * One document in one representation, or the 406 an impossible `Accept` earns.
367
+ *
368
+ * `href` is where it is being served, which is its own permalink everywhere
369
+ * but the front page: a page serving as the homepage is read at `/`, and the
370
+ * canonical URL, the alternates and the theme's own links have to say so.
371
+ */
372
+ function negotiateDocument(c, document, representation, href = document.permalink) {
92
373
  if (representation === undefined) {
93
- return notAcceptableResponse(encodePath(document.permalink), DOCUMENT_REPRESENTATIONS);
374
+ return notAcceptableResponse(encodePath(href), DOCUMENT_REPRESENTATIONS);
94
375
  }
376
+ // Who the request's session says is reading, when it says anybody
377
+ // (TASK-103). Only the HTML has a form on it, so only the HTML asks; the
378
+ // Markdown and JSON of a post are the same bytes for everybody.
379
+ const viewer = representation === 'html' ? signedInCommenter(c) : undefined;
95
380
  const body = representation === 'markdown'
96
381
  ? serializeDocument(document)
97
382
  : representation === 'json'
98
383
  ? documentJson(document, { baseUrl: c.var.config.baseUrl })
99
- : c.var.renderer.renderDocument(document);
384
+ : // The thank-you after a comment was posted, which the redirect
385
+ // carried back as a query. It is the only thing about a document's
386
+ // HTML that the URL rather than the file decides.
387
+ // A document served at `/` is the site's front page, and the front
388
+ // page is the one place a theme may lay a page out differently.
389
+ href === '/'
390
+ ? c.var.renderer.renderFrontPage(document, commentNotice(c, document), viewer)
391
+ : c.var.renderer.renderDocument(document, commentNotice(c, document), viewer);
100
392
  // The theme can change without the document changing, and only the document
101
393
  // is hashed. While the watcher is on — a development server, where a template
102
394
  // edit lands mid-process — the HTML gets no validator rather than a stale one.
@@ -104,8 +396,17 @@ function negotiateDocument(c, document, representation) {
104
396
  return representationResponse({
105
397
  body,
106
398
  representation,
107
- href: encodePath(document.permalink),
399
+ href: encodePath(href),
108
400
  available: DOCUMENT_REPRESENTATIONS,
401
+ // Where a webmention about this page is sent. It is a header rather than
402
+ // only a `<link>` because a sender is allowed to find the endpoint without
403
+ // parsing the page, and because the JSON and Markdown representations of a
404
+ // post have no head to put one in (TASK-51).
405
+ links: webmentionLinks(c),
406
+ // A page drawn for one named reader is that reader's page rather than the
407
+ // post, so nothing shared may hold it and it carries no validator. A page
408
+ // drawn for everybody is exactly what it has always been.
409
+ ...(viewer === undefined ? {} : { private: true }),
109
410
  ...(validated
110
411
  ? {
111
412
  etag: representationEtag(representation, document.hash),
@@ -115,6 +416,19 @@ function negotiateDocument(c, document, representation) {
115
416
  conditional: conditionalHeaders(c),
116
417
  });
117
418
  }
419
+ /**
420
+ * The `Link` header advertising this site's webmention endpoint, or none when
421
+ * the site does not take them.
422
+ *
423
+ * Read per request off the settings, so turning webmentions off on the
424
+ * settings screen takes the advertisement off the very next page rather than
425
+ * off the next restart.
426
+ */
427
+ function webmentionLinks(c) {
428
+ if (!readSiteSettings(c.var.config.contentDir).webmentionsReceive)
429
+ return [];
430
+ return [`<${WEBMENTION_PATH}>; rel="webmention"`];
431
+ }
118
432
  /** The conditional request headers, as the negotiator wants them. */
119
433
  function conditionalHeaders(c) {
120
434
  return {
@@ -131,55 +445,231 @@ function conditionalHeaders(c) {
131
445
  * itself rather than the path with a slash bolted on, so `/page/1` lands on
132
446
  * `/` in one hop instead of two.
133
447
  */
134
- function canonicalPath(c, pathname) {
448
+ function canonicalPath(c, pathname, bases, authors) {
135
449
  if (pathname === '' || pathname.endsWith('/'))
136
450
  return undefined;
137
- const target = canonicalTarget(c, `${pathname}/`);
451
+ const target = canonicalTarget(c, `${pathname}/`, bases, authors);
138
452
  if (target === undefined)
139
453
  return undefined;
140
454
  return `${target}${new URL(c.req.url).search}`;
141
455
  }
142
456
  /** Where a path with a trailing slash canonically lives, if anywhere. */
143
- function canonicalTarget(c, pathname) {
457
+ function canonicalTarget(c, pathname, bases, authors) {
144
458
  const { store, renderer } = c.var;
459
+ // A feed first: `/feed`, `/feed/atom`, `/{base}/x/feed`, `/comments/feed`
460
+ // and a post's `{permalink}feed` all lead somewhere real, and `/feed/rss`
461
+ // leads to `/feed/` in the same one hop rather than to a second redirect.
462
+ const feedRequest = parseFeedPath(store, pathname, bases, authors);
463
+ if (feedRequest !== undefined) {
464
+ const { target } = feedRequest;
465
+ if (target.kind === 'listing' &&
466
+ target.subject.term !== undefined &&
467
+ countListing(store, target.subject) === 0) {
468
+ return undefined;
469
+ }
470
+ return feedTargetHref(target, feedRequest.format, bases);
471
+ }
472
+ const pages = frontPages(c);
145
473
  const document = store.getByPermalink(pathname);
146
474
  if (document !== undefined) {
147
- return isPublicDocument(document) ? encodePath(pathname) : undefined;
475
+ if (!isPublicDocument(document))
476
+ return undefined;
477
+ // The homepage's own URL leads to `/` rather than to the redirect that
478
+ // leads to `/`: one hop, the way `/page/1` reaches `/` in one.
479
+ return document.path === pages.home?.path ? '/' : encodePath(pathname);
148
480
  }
149
- const listing = parseListingPath(pathname);
481
+ const listing = listingRequestAt(pages, pathname, bases, authors);
150
482
  if (listing === undefined)
151
483
  return undefined;
152
- // The home listing exists even with nothing on it; a tag archive does not.
153
- const total = listing.tag === undefined ? store.counts().posts : store.countByTag(listing.tag);
154
- if (listing.tag !== undefined && total === 0)
484
+ // The home listing and an author archive both exist with nothing on them; a
485
+ // taxonomy archive does not.
486
+ const total = countListing(store, listing);
487
+ if (listing.term !== undefined && total === 0)
155
488
  return undefined;
156
489
  const totalPages = Math.max(1, Math.ceil(total / renderer.pageSize()));
157
490
  if (listing.pageNumber >= totalPages)
158
491
  return undefined;
159
- return listing.tag === undefined
160
- ? homeHref(listing.pageNumber)
161
- : tagHref(listing.tag, listing.pageNumber);
492
+ return listingHref(listing, listing.pageNumber, bases, listing.document?.permalink ?? '/');
493
+ }
494
+ /** {@link AuthorLookup} over one request's site. */
495
+ function authorsOf(c) {
496
+ let users;
497
+ return (username) => {
498
+ users ??= listUsers(c.var.config.dataDir);
499
+ const user = users.find((candidate) => candidate.username === username);
500
+ return user === undefined ? undefined : { user, names: authorNames(users, user) };
501
+ };
502
+ }
503
+ /**
504
+ * WordPress's Reading choice, resolved for this request.
505
+ *
506
+ * The setting holds slugs, and a slug is only a front page while it names a
507
+ * page the public site would serve: one drafted, trashed or deleted since it
508
+ * was picked resolves to nothing, and a site whose homepage resolves to
509
+ * nothing is a site showing its latest posts again. That is the whole of the
510
+ * fallback — there is no state to repair and nothing to write — and it is why
511
+ * the pick is kept rather than cleared: publishing the page again puts the
512
+ * front page back.
513
+ */
514
+ function frontPages(c) {
515
+ const { homepage, postsPage } = c.var.renderer.frontPageSlugs();
516
+ const home = publicPage(c, homepage);
517
+ return {
518
+ home,
519
+ // Without a homepage there is no posts page: the listing is already at
520
+ // `/`, and a second URL for it is exactly what this pair exists to avoid.
521
+ posts: home === undefined ? undefined : publicPage(c, postsPage),
522
+ };
162
523
  }
163
- /** `/`, `/page/N/`, `/tags/x/` and `/tags/x/page/N/`, as a listing to check. */
164
- function parseListingPath(pathname) {
524
+ /**
525
+ * A path as the page of a listing it names, wherever the listing lives, or
526
+ * `undefined` when it names none.
527
+ *
528
+ * The posts page first, because its permalink is the listing's root on a site
529
+ * that has one; then the ordinary `/`, `/page/N/` and the taxonomy archives.
530
+ * A site whose homepage is a page and which named no posts page has no home
531
+ * listing at all, so nothing under the root is one either.
532
+ */
533
+ function listingRequestAt(pages, pathname, bases, authors) {
534
+ const onPostsPage = pages.posts === undefined ? undefined : postsPageRequest(pages.posts, pathname);
535
+ if (onPostsPage !== undefined)
536
+ return onPostsPage;
537
+ const request = parseListingPath(pathname, bases, authors);
538
+ if (request === undefined)
539
+ return undefined;
540
+ // Only the home listing can be homeless. A tag archive and a person's
541
+ // archive have URLs of their own, whatever the Reading setting says `/` is.
542
+ if (request.term === undefined && request.author === undefined) {
543
+ if (listingRoot(pages) === undefined)
544
+ return undefined;
545
+ }
546
+ return request;
547
+ }
548
+ /**
549
+ * Where the post listing lives, or `undefined` when it has no page at all.
550
+ *
551
+ * A site showing its latest posts has it at `/`; a site with a posts page has
552
+ * it there; a site whose homepage is a page and which named no posts page has
553
+ * nowhere for it, which is WordPress's own answer and the reason its Reading
554
+ * screen offers the second pick. The feeds are unaffected either way.
555
+ */
556
+ function listingRoot(pages) {
557
+ if (pages.posts !== undefined)
558
+ return pages.posts.permalink;
559
+ return pages.home === undefined ? '/' : undefined;
560
+ }
561
+ /** The published page one slug names, or `undefined`. */
562
+ function publicPage(c, slug) {
563
+ if (slug === '')
564
+ return undefined;
565
+ const found = c.var.store.getBySlug(slug);
566
+ if (found?.type !== 'page')
567
+ return undefined;
568
+ return isPublicDocument(found, c.var.store.now()) ? found : undefined;
569
+ }
570
+ /**
571
+ * `{permalink}` or `{permalink}page/N/` as a page of the listing the posts
572
+ * page carries, or `undefined` when the path is neither.
573
+ *
574
+ * `page/1/` is not one: the first page of a listing lives at the listing's own
575
+ * URL, exactly as `/page/1/` collapses onto `/`, and the canonicalisation that
576
+ * says so is the ordinary one.
577
+ */
578
+ function postsPageRequest(document, pathname) {
579
+ const { permalink } = document;
580
+ if (pathname === permalink)
581
+ return { term: undefined, pageNumber: 0, document };
582
+ if (!pathname.startsWith(permalink))
583
+ return undefined;
584
+ const segments = pathname
585
+ .slice(permalink.length)
586
+ .split('/')
587
+ .filter((segment) => segment !== '');
588
+ if (segments.length !== 2 || segments[0] !== PAGE_SEGMENT || !pathname.endsWith('/')) {
589
+ return undefined;
590
+ }
591
+ const page = pageIndex(segments[1]);
592
+ return page === undefined ? undefined : { term: undefined, pageNumber: page, document };
593
+ }
594
+ /**
595
+ * `/`, `/page/N/`, `/author/{username}/` and `/author/{username}/page/N/`, and
596
+ * either taxonomy's `/{base}/x/` and `/{base}/x/page/N/`, as a listing to
597
+ * check.
598
+ */
599
+ function parseListingPath(pathname, bases, authors) {
165
600
  const segments = pathname.split('/').filter((segment) => segment !== '');
166
601
  if (segments.length === 0)
167
- return { tag: undefined, pageNumber: 0 };
602
+ return { term: undefined, pageNumber: 0 };
168
603
  if (segments[0] === PAGE_SEGMENT && segments.length === 2) {
169
604
  const page = pageIndex(segments[1]);
170
- return page === undefined ? undefined : { tag: undefined, pageNumber: page };
605
+ return page === undefined ? undefined : { term: undefined, pageNumber: page };
606
+ }
607
+ // One person's archive, at the path decision-14 reserves. A username nobody
608
+ // has is not a listing at all, which is what turns it into one 404 rather
609
+ // than an empty archive.
610
+ const byAuthor = parseAuthorPath(pathname);
611
+ if (byAuthor !== undefined) {
612
+ const found = authors(byAuthor.username);
613
+ return found === undefined ? undefined : { author: found, pageNumber: byAuthor.pageNumber };
171
614
  }
172
- if (segments[0] !== TAG_SEGMENT || segments[1] === undefined)
615
+ const taxonomy = taxonomyForSegment(segments[0], bases);
616
+ if (taxonomy === undefined || segments[1] === undefined)
173
617
  return undefined;
174
- const tag = segments[1];
618
+ const term = { taxonomy, term: segments[1] };
175
619
  if (segments.length === 2)
176
- return { tag, pageNumber: 0 };
620
+ return { term, pageNumber: 0 };
177
621
  if (segments.length === 4 && segments[2] === PAGE_SEGMENT) {
178
622
  const page = pageIndex(segments[3]);
179
- return page === undefined ? undefined : { tag, pageNumber: page };
623
+ return page === undefined ? undefined : { term, pageNumber: page };
180
624
  }
181
625
  return undefined;
182
626
  }
627
+ /** How many published documents a listing holds. */
628
+ function countListing(store, subject) {
629
+ const { term, author } = subject;
630
+ if (author !== undefined)
631
+ return store.countByAuthor(author.names);
632
+ if (term === undefined)
633
+ return store.counts().posts;
634
+ return term.taxonomy === 'tag' ? store.countByTag(term.term) : store.countByCategory(term.term);
635
+ }
636
+ /** One page of a listing's documents, newest first. */
637
+ function listListing(store, subject, paging) {
638
+ const { term, author } = subject;
639
+ if (author !== undefined)
640
+ return store.listByAuthor(author.names, paging);
641
+ if (term === undefined)
642
+ return store.listPosts(paging);
643
+ return term.taxonomy === 'tag'
644
+ ? store.listByTag(term.term, paging)
645
+ : store.listByCategory(term.term, paging);
646
+ }
647
+ /**
648
+ * The URL of a page of a listing: the home archive's, or a taxonomy's.
649
+ *
650
+ * `root` is where the home listing lives — `/`, or the posts page's permalink
651
+ * on a site that has one — so every link the listing draws, every canonical
652
+ * redirect and the sitemap all spell its pages the same way.
653
+ */
654
+ function listingHref(subject, index, bases, root = '/') {
655
+ const { term, author } = subject;
656
+ if (author !== undefined)
657
+ return authorHref(author.user.username, index);
658
+ return term === undefined ? listingPageHref(root, index) : termHref(term, index, bases);
659
+ }
660
+ /**
661
+ * The URL of one format of one listing's feed, whichever kind of listing it is.
662
+ *
663
+ * The site's and a taxonomy's hang off the listing root; a person's hangs off
664
+ * their archive, which decision-14 keeps at WordPress's `/author/{username}/`
665
+ * rather than under the listing machinery, because that root is also an actor
666
+ * id.
667
+ */
668
+ function listingFeedHref(subject, format, bases) {
669
+ return subject.author === undefined
670
+ ? feedHref(subject.term, format, bases)
671
+ : authorFeedHref(subject.author.user.username, format);
672
+ }
183
673
  /**
184
674
  * A `/page/N/` segment as a zero-based index. `page/1` parses to the first
185
675
  * page, which lives at the listing root, so canonicalisation collapses it.
@@ -193,35 +683,56 @@ function pageIndex(segment) {
193
683
  /** One page of a listing, in whichever representation the request settled on. */
194
684
  function listing(c, request, representation = selectFromAccept(c, LISTING_REPRESENTATIONS)) {
195
685
  const { store, renderer } = c.var;
196
- const { tag } = request;
686
+ const { term, author } = request;
197
687
  const size = renderer.pageSize();
198
- // A tag archive only exists while something carries the tag; the home
199
- // listing exists even with nothing on it.
200
- const total = tag === undefined ? store.counts().posts : store.countByTag(tag);
201
- if (tag !== undefined && total === 0)
688
+ const bases = renderer.taxonomyBases();
689
+ // Where this listing's pages live: the posts page's permalink when the site
690
+ // has one, and the site root otherwise.
691
+ const root = request.document?.permalink ?? '/';
692
+ // WordPress served every feed as a query on the listing before it served one
693
+ // at a path, and the links are still out there. `/?feed=rss2` and
694
+ // `/{base}/x/?feed=atom` land on the feed the listing now has.
695
+ const asked = queryFeedFormat(c);
696
+ if (asked !== undefined)
697
+ return c.redirect(listingFeedHref(request, asked, bases), 301);
698
+ // A taxonomy archive only exists while something carries the term; the home
699
+ // listing and a person's archive exist even with nothing on them.
700
+ const total = countListing(store, request);
701
+ if (term !== undefined && total === 0)
202
702
  return notFound(c);
203
- const href = tag === undefined ? homeHref(request.pageNumber) : tagHref(tag, request.pageNumber);
703
+ const href = listingHref(request, request.pageNumber, bases, root);
204
704
  const pagination = paginate({
205
705
  total,
206
706
  size,
207
707
  pageNumber: request.pageNumber,
208
- hrefForPage: (index) => (tag === undefined ? homeHref(index) : tagHref(tag, index)),
708
+ hrefForPage: (index) => listingHref(request, index, bases, root),
209
709
  });
210
710
  if (request.pageNumber >= pagination.totalPages)
211
711
  return notFound(c);
212
712
  if (representation === undefined)
213
713
  return notAcceptableResponse(href, LISTING_REPRESENTATIONS);
214
714
  const paging = { limit: size, offset: offsetForPage(request.pageNumber, size) };
215
- const documents = tag === undefined ? store.listPosts(paging) : store.listByTag(tag, paging);
715
+ const documents = listListing(store, request, paging);
216
716
  const full = wantsFullDocuments(c);
217
717
  const body = representation === 'json'
218
718
  ? documents.map((document) => documentJson(document, { baseUrl: c.var.config.baseUrl, body: full }))
219
719
  : renderer.renderListing({
220
- title: tag ?? renderer.site().title,
720
+ // An author archive is headed by the person, a taxonomy archive by
721
+ // the term, the posts page by its own title the way any page is, and
722
+ // the home listing by the site's.
723
+ title: term?.term ??
724
+ author?.user.profile?.displayName ??
725
+ author?.user.username ??
726
+ request.document?.title ??
727
+ renderer.site().title,
221
728
  url: href,
222
729
  documents,
223
730
  pagination,
224
- ...(tag === undefined ? {} : { tag, template: TEMPLATES.tag }),
731
+ // And its words go above the posts, which is what a posts page is
732
+ // for: a theme prints `{{ content | safe }}` over the list.
733
+ ...(request.document === undefined ? {} : { document: request.document }),
734
+ ...(term === undefined ? {} : taxonomyContext(term)),
735
+ ...(author === undefined ? {} : authorArchiveContext(author)),
225
736
  });
226
737
  const validated = representation !== 'html' || !c.var.config.watch;
227
738
  return representationResponse({
@@ -238,6 +749,59 @@ function listing(c, request, representation = selectFromAccept(c, LISTING_REPRES
238
749
  conditional: conditionalHeaders(c),
239
750
  });
240
751
  }
752
+ /**
753
+ * One page of search results, in whichever representation the request settled
754
+ * on.
755
+ *
756
+ * What it finds is what the listings would show: the index holds the search to
757
+ * published, untrashed, already-due documents, so a draft cannot be found by
758
+ * its words any more than it can be found by its URL. A search with no query
759
+ * is the page with the form on it and nothing found, rather than an error,
760
+ * because that is where a link to the search from a theme lands. A page past
761
+ * the last one 404s, as it does on a listing.
762
+ */
763
+ function search(c, representation) {
764
+ const { store, renderer, config } = c.var;
765
+ const query = searchQuery(c.req.query(SEARCH_QUERY_PARAM));
766
+ const pageNumber = searchPageIndex(c.req.query(SEARCH_PAGE_PARAM));
767
+ if (pageNumber === undefined)
768
+ return notFound(c);
769
+ const size = renderer.pageSize();
770
+ const pagination = paginate({
771
+ total: store.countSearch(query),
772
+ size,
773
+ pageNumber,
774
+ hrefForPage: (index) => searchHref(query, index),
775
+ });
776
+ if (pageNumber >= pagination.totalPages)
777
+ return notFound(c);
778
+ const href = searchHref(query, pageNumber);
779
+ if (representation === undefined)
780
+ return notAcceptableResponse(href, LISTING_REPRESENTATIONS);
781
+ const hits = store.search(query, { limit: size, offset: offsetForPage(pageNumber, size) });
782
+ const documents = hits.map((hit) => hit.document);
783
+ const full = wantsFullDocuments(c);
784
+ const body = representation === 'json'
785
+ ? searchJson({ query, hits, pagination, baseUrl: config.baseUrl, body: full })
786
+ : renderer.renderSearch({ query, url: href, hits, pagination });
787
+ const validated = representation !== 'html' || !config.watch;
788
+ return representationResponse({
789
+ body,
790
+ representation,
791
+ href,
792
+ // The alternates are spelled here rather than by the negotiator, because
793
+ // they carry the query and a listing's never have one to carry.
794
+ available: [representation],
795
+ links: LISTING_REPRESENTATIONS.filter((other) => other !== representation).map((other) => `<${searchHref(query, pageNumber, other)}>; rel="alternate"; type="${MEDIA_TYPES[other]}"`),
796
+ ...(validated
797
+ ? {
798
+ etag: representationEtag(representation, listingFingerprint(href, pagination, documents, full)),
799
+ lastModified: latestModified(documents),
800
+ }
801
+ : {}),
802
+ conditional: conditionalHeaders(c),
803
+ });
804
+ }
241
805
  /**
242
806
  * Whether the request asked for whole documents rather than summaries.
243
807
  *
@@ -264,31 +828,155 @@ function listingFingerprint(href, pagination, documents, full) {
264
828
  ].join('\n');
265
829
  }
266
830
  /**
267
- * The site's feed, or one tag's, in one format.
831
+ * The site's feed, or one archive's, in one format.
268
832
  *
269
- * A tag with nothing published under it 404s exactly as its archive does: a
833
+ * A term with nothing published under it 404s exactly as its archive does: a
270
834
  * feed reader should be told the URL is wrong rather than handed an empty feed
271
835
  * it will poll forever.
272
836
  */
273
- function feed(c, format, tag) {
837
+ function feed(c, format, subject) {
274
838
  const { store, renderer, config } = c.var;
275
839
  const site = renderer.site();
276
- if (tag !== undefined && store.countByTag(tag) === 0)
277
- return notFound(c);
278
- const documents = tag === undefined
279
- ? store.listPosts({ limit: feedSize(site) })
280
- : store.listByTag(tag, { limit: feedSize(site) });
281
- const href = tag === undefined ? homeHref(0) : tagHref(tag, 0);
840
+ const bases = renderer.taxonomyBases();
841
+ const { term, author } = subject;
842
+ if (term !== undefined && countListing(store, subject) === 0) {
843
+ // A subscriber to a renamed archive's feed follows it to the new one
844
+ // rather than being dropped, exactly as a reader of the archive does.
845
+ const moved = movedTerm(c, term);
846
+ if (moved === undefined)
847
+ return notFound(c);
848
+ return c.redirect(feedHref(moved, format, bases), 301);
849
+ }
850
+ const documents = listListing(store, subject, { limit: feedSize(site) });
851
+ const href = listingHref(subject, 0, bases);
852
+ // What the feed calls itself: the site, and then the term or the person it
853
+ // is about, so a reader subscribed to several of a site's feeds can tell
854
+ // them apart in a list.
855
+ const about = term?.term ?? author?.user.profile?.displayName ?? author?.user.username;
282
856
  const source = {
283
857
  site,
284
858
  documents,
285
- title: tag === undefined ? site.title : `${site.title}: ${tag}`,
859
+ title: about === undefined ? site.title : `${site.title}: ${about}`,
286
860
  href,
287
- feedHref: feedHref(tag, format),
861
+ feedHref: listingFeedHref(subject, format, bases),
288
862
  baseUrl: config.baseUrl,
863
+ // Only RSS carries the comment pointers; the other two formats have no
864
+ // vocabulary for them, and counting for a feed that cannot say the number
865
+ // would be a query per item for nothing.
866
+ ...(format === 'rss' ? { commentCounts: c.var.conversation.counts(documents) } : {}),
289
867
  };
290
868
  return feedResponse({ format, source, conditional: conditionalHeaders(c) });
291
869
  }
870
+ /**
871
+ * A post's comments, or the whole site's, as RSS 2.0.
872
+ *
873
+ * A post with no replies answers an empty feed rather than a 404: it exists,
874
+ * and a reader that subscribed before anybody answered should keep polling.
875
+ * Only a permalink that is no published post 404s, which is the ordinary
876
+ * document lookup rather than anything this feed decides.
877
+ */
878
+ function comments(c, document) {
879
+ const { conversation, renderer, config } = c.var;
880
+ const site = renderer.site();
881
+ const limit = feedSize(site);
882
+ // The same reading the page is drawn from, which is the point: a subscriber
883
+ // to a post's comments and a reader who scrolls to the bottom of it are
884
+ // looking at one conversation. A post's own feed hands the builder entries
885
+ // with no post attached, and so its items name no post: there, every item
886
+ // answers the same one.
887
+ const found = feedComments(document === undefined ? conversation.latest(limit) : spokenIn(conversation.thread(document)), { baseUrl: config.baseUrl, limit });
888
+ const source = {
889
+ site,
890
+ comments: found,
891
+ title: document === undefined
892
+ ? `${site.title}: comments`
893
+ : `${COMMENTS_TITLE_PREFIX}${document.title}`,
894
+ href: document?.permalink ?? '/',
895
+ feedHref: commentsFeedHref(document),
896
+ baseUrl: config.baseUrl,
897
+ };
898
+ return commentsFeedResponse(source, conditionalHeaders(c));
899
+ }
900
+ /**
901
+ * The sitemap, or one of its children once the site is too big for one file.
902
+ *
903
+ * A child that does not exist 404s through the theme like any other missing
904
+ * URL: `/sitemap-1.xml` names nothing at all on a site whose whole sitemap
905
+ * fits, and a crawler asking for one should be told so rather than handed an
906
+ * empty file it would come back to.
907
+ */
908
+ function sitemap(c, page) {
909
+ const response = sitemapResponse({
910
+ urls: sitemapUrls(c),
911
+ baseUrl: c.var.config.baseUrl,
912
+ page,
913
+ conditional: conditionalHeaders(c),
914
+ });
915
+ return response ?? notFound(c);
916
+ }
917
+ /**
918
+ * Every URL the public site publishes, in the order the sitemap lists them:
919
+ * the home archive's pages, then every post and page, then every tag archive
920
+ * and every category archive with their own pages.
921
+ *
922
+ * Everything here is spelled by the same functions the site's own links go
923
+ * through — {@link homeHref}, the permalinks, {@link termHref} — and drawn
924
+ * from the same queries the listings use, so a sitemap can never advertise a
925
+ * URL the site does not serve. Nothing hidden reaches it either: the index's
926
+ * public queries already exclude drafts, the trash and posts whose date has
927
+ * not arrived, and {@link isPublicDocument} is asked again with the store's
928
+ * own clock so the two answers cannot drift apart.
929
+ */
930
+ function sitemapUrls(c) {
931
+ const { store, renderer } = c.var;
932
+ const now = store.now();
933
+ const size = renderer.pageSize();
934
+ const bases = renderer.taxonomyBases();
935
+ const pages = frontPages(c);
936
+ const urls = [];
937
+ /** The pages of one listing, each dated by the newest document on it. */
938
+ const listingPages = (documents, hrefForPage) => {
939
+ const totalPages = Math.max(1, Math.ceil(documents.length / size));
940
+ for (let index = 0; index < totalPages; index += 1) {
941
+ const onPage = documents.slice(index * size, (index + 1) * size);
942
+ urls.push({ loc: hrefForPage(index), lastmod: latestModified(onPage) });
943
+ }
944
+ };
945
+ const posts = store.listPosts().filter((document) => isPublicDocument(document, now));
946
+ // The listing's pages, wherever it lives: under `/`, under the posts page,
947
+ // or nowhere at all on a site whose homepage is a page and which named no
948
+ // posts page. Its first page is that root, which is why `/` and the posts
949
+ // page's own URL are listed here rather than with the documents.
950
+ const root = listingRoot(pages);
951
+ if (root !== undefined)
952
+ listingPages(posts, (index) => listingPageHref(root, index));
953
+ // And `/` itself when it is a page rather than the listing: the front page
954
+ // is published at one URL, and it is this one.
955
+ if (pages.home !== undefined)
956
+ urls.push({ loc: '/', lastmod: lastModifiedOf(pages.home) });
957
+ const documents = store
958
+ .listAll({ type: 'page', draft: false, trashed: false, scheduled: false })
959
+ .filter((document) => isPublicDocument(document, now));
960
+ for (const document of [...posts, ...documents]) {
961
+ // The homepage answers at `/` and redirects from its own permalink, and
962
+ // the posts page is the listing's first page, which is already listed:
963
+ // neither is advertised twice.
964
+ if (document.path === pages.home?.path || document.path === pages.posts?.path)
965
+ continue;
966
+ urls.push({ loc: document.permalink, lastmod: lastModifiedOf(document) });
967
+ }
968
+ // A taxonomy archive exists only while something public carries the term,
969
+ // which is exactly what these two queries report.
970
+ for (const { tag } of store.listTags()) {
971
+ const term = { taxonomy: 'tag', term: tag };
972
+ listingPages(store.listByTag(tag), (index) => termHref(term, index, bases));
973
+ }
974
+ for (const { category } of store.listCategories()) {
975
+ const term = { taxonomy: 'category', term: category };
976
+ listingPages(store.listByCategory(category), (index) => termHref(term, index, bases));
977
+ }
978
+ return urls;
979
+ }
292
980
  /**
293
981
  * A file from the theme's `static/` directory, the site's copy first.
294
982
  *
@@ -297,7 +985,7 @@ function feed(c, format, tag) {
297
985
  */
298
986
  function themeAsset(c) {
299
987
  const relative = requestPath(c).slice(THEME_ASSET_PREFIX.length);
300
- const asset = findThemeAsset(relative, themeSearchPath(c.var.config.themeDir));
988
+ const asset = findThemeAsset(relative, c.var.renderer.themeDirs());
301
989
  if (asset === undefined)
302
990
  return notFound(c);
303
991
  if (matchesEtag(c.req.header('if-none-match'), asset.etag)) {
@@ -305,6 +993,38 @@ function themeAsset(c) {
305
993
  }
306
994
  return themeAssetResponse(asset);
307
995
  }
996
+ /**
997
+ * One derived copy of an upload, generated on the spot if it is not there.
998
+ *
999
+ * The directory these live in is disposable (decision-9): deleting it costs
1000
+ * the next reader of each picture one encode and costs the site nothing else,
1001
+ * which is what this handler is for. A width or a format the site does not
1002
+ * offer is a 404 and encodes nothing, so the URL space cannot be used to make
1003
+ * the server work.
1004
+ *
1005
+ * The cache lifetime is the uploads' own, and it is honest for the same
1006
+ * reason: a derived URL names one width of one file, and the file it was
1007
+ * derived from is never overwritten.
1008
+ */
1009
+ async function imageVariant(c) {
1010
+ const relative = requestPath(c).slice(VARIANT_ASSET_PREFIX.length);
1011
+ const asset = await findImageVariant(c.var.config, decodeVariantPath(relative));
1012
+ if (asset === undefined)
1013
+ return notFound(c);
1014
+ const options = { maxAge: UPLOAD_ASSET_MAX_AGE };
1015
+ return matchesEtag(c.req.header('if-none-match'), asset.etag)
1016
+ ? assetNotModified(asset, options)
1017
+ : assetResponse(asset, options);
1018
+ }
1019
+ /** A request path as a path on disk: percent-encoding off, segment by segment. */
1020
+ function decodeVariantPath(relative) {
1021
+ try {
1022
+ return relative.split('/').map(decodeURIComponent).join('/');
1023
+ }
1024
+ catch {
1025
+ return relative;
1026
+ }
1027
+ }
308
1028
  /**
309
1029
  * A file from `content/uploads/`, at the URL Eleventy's passthrough copy puts
310
1030
  * it at.
@@ -327,16 +1047,57 @@ function upload(c) {
327
1047
  }
328
1048
  /** The URL of a page of the home listing, by zero-based index. */
329
1049
  export function homeHref(index) {
330
- return index === 0 ? '/' : `/${PAGE_SEGMENT}/${String(index + 1)}/`;
1050
+ return listingPageHref('/', index);
331
1051
  }
332
- /** The URL of a page of a tag archive, by zero-based index. */
333
- export function tagHref(tag, index) {
334
- const root = `/${TAG_SEGMENT}/${encodeURIComponent(tag)}/`;
1052
+ /**
1053
+ * The URL of a page of the listing that hangs off `root`, by zero-based index.
1054
+ *
1055
+ * The first page is the root itself: `/` or the posts page's own permalink,
1056
+ * which is what makes a posts page one URL rather than two.
1057
+ */
1058
+ export function listingPageHref(root, index) {
335
1059
  return index === 0 ? root : `${root}${PAGE_SEGMENT}/${String(index + 1)}/`;
336
1060
  }
337
- /** The URL of a feed: the whole archive's, or one tag's. */
338
- export function feedHref(tag, format) {
339
- return tag === undefined ? `/${FEED_FILES[format]}` : `${tagHref(tag, 0)}${FEED_FILES[format]}`;
1061
+ /**
1062
+ * What the theme is told about a taxonomy archive: the term under the name of
1063
+ * its taxonomy, and the layout that taxonomy uses.
1064
+ */
1065
+ function taxonomyContext(term) {
1066
+ return term.taxonomy === 'tag'
1067
+ ? { tag: term.term, template: TEMPLATES.tag }
1068
+ : { category: term.term, template: TEMPLATES.category };
1069
+ }
1070
+ /**
1071
+ * What the theme is told about an author archive: the person's profile under
1072
+ * `author`, the same shape a post's byline gets, and the layout an archive of
1073
+ * theirs uses.
1074
+ *
1075
+ * One shape for both places is the point. A theme that knows how to print a
1076
+ * byline already knows how to print the heading of the archive that byline
1077
+ * links to, and neither has to ask whether the site has users.
1078
+ */
1079
+ function authorArchiveContext(author) {
1080
+ return { author: profileContext(author.user), template: TEMPLATES.author };
1081
+ }
1082
+ /**
1083
+ * The URL of a feed: the whole site's, or one taxonomy archive's.
1084
+ *
1085
+ * WordPress's layout, so a subscriber of a migrated site keeps polling the URL
1086
+ * they already hold: `/feed/` is RSS 2.0, `/feed/atom/` and `/feed/json/` are
1087
+ * its siblings, and an archive's feeds hang off the archive.
1088
+ */
1089
+ export function feedHref(term, format, bases) {
1090
+ return feedPathUnder(listingHref({ term }, 0, bases), format);
1091
+ }
1092
+ /**
1093
+ * Where the site's comments feed lives, and where one post's does.
1094
+ *
1095
+ * WordPress's URLs, so a site migrated from it keeps both: the whole site's
1096
+ * comments at `/comments/feed/`, and a post's under its own permalink. There
1097
+ * is one format, RSS 2.0, because that is what a comments feed is read in.
1098
+ */
1099
+ export function commentsFeedHref(document) {
1100
+ return commentsFeedPath(document === undefined ? undefined : encodePath(document.permalink));
340
1101
  }
341
1102
  /** The theme's 404 page. */
342
1103
  export function notFound(c) {