@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
@@ -1,38 +1,41 @@
1
- import { readFileSync } from 'node:fs';
2
- import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
1
+ import { statSync } from 'node:fs';
3
2
  import path from 'node:path';
3
+ import { DEFAULT_COMMENTS_CLOSE_AFTER_DAYS } from '../comments/policy.js';
4
+ import { readFileIfPresentSync, updateFileAtomically, writeFileAtomicallySync, } from '../files/atomic.js';
5
+ import { MAIL_PROVIDERS } from '../mail/provider.js';
4
6
  import { SITE_DATA_FILE } from '../web/context.js';
5
- import { flash } from './flash.js';
7
+ import { DEFAULT_NOTIFY_SERVER } from '../web/feeds.js';
8
+ import { navigationItemsOf } from '../web/navigation.js';
9
+ import { DEFAULT_TAXONOMY_BASES, taxonomyBaseProblems, taxonomyRedirectsOf, } from '../web/taxonomy.js';
10
+ import { readTheme, themeNameProblem } from '../web/themes.js';
6
11
  import { ADMIN_PREFIX } from './session.js';
7
- import { ADMIN_TEMPLATES } from './templates.js';
8
- import { refusedUpload, storeUpload } from './uploads.js';
9
- /** Where the settings screen lives. */
12
+ /**
13
+ * Where the settings live: the General page, and the root every other settings
14
+ * page hangs off ({@link settingsPagePath}).
15
+ */
10
16
  export const SETTINGS_PATH = `${ADMIN_PREFIX}/settings`;
11
- /** Where the avatar's upload form and its Remove button post. */
12
- export const AVATAR_PATH = `${SETTINGS_PATH}/avatar`;
13
- /** The fields those two forms submit. */
14
- export const AVATAR_FIELDS = { file: 'avatar', action: 'action' };
15
- /** The {@link AVATAR_FIELDS.action} that takes the avatar down again. */
16
- export const AVATAR_REMOVE = 'remove';
17
17
  /**
18
- * The ActivityPub actor types doc-4 allows a site to be.
18
+ * What a language tag may look like: BCP 47's shape rather than its registry —
19
+ * a two or three letter primary subtag followed by dash-separated subtags of
20
+ * letters and digits.
19
21
  *
20
- * `Person` is first because it is the default: some clients hide `Service`
21
- * actors from timelines, which is the wrong thing for a personal blog.
22
+ * Checking the shape and not the registry is deliberate. The value ends up in
23
+ * `<html lang>`, an RSS `<language>` and an Atom `xml:lang`, where a
24
+ * well-formed tag a reader has never heard of is harmless and a malformed one
25
+ * is not; refusing a valid tag because this CMS shipped before it was
26
+ * registered would be worse than accepting one nobody uses.
22
27
  */
23
- export const ACTOR_TYPES = [
24
- 'Person',
25
- 'Organization',
26
- 'Service',
27
- 'Group',
28
- 'Application',
29
- ];
28
+ export const LANGUAGE_TAG_PATTERN = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8}){0,4}$/;
30
29
  /**
31
- * What an actor handle may be made of. Tighter than a username: the handle
32
- * becomes the local part of `@handle@host` in WebFinger and in every mention
33
- * somebody types, so it holds no punctuation that would need escaping there.
30
+ * What an email address has to look like before the settings screen will keep
31
+ * it: something, an `@`, and a dotted host.
32
+ *
33
+ * Deliberately loose. The grammar in RFC 5321 allows addresses nobody has ever
34
+ * typed, and the only test that settles whether an address works is sending to
35
+ * it — which is what the Send test email button is for. This catches the
36
+ * typo where a whole field was pasted into the wrong box.
34
37
  */
35
- export const ACTOR_HANDLE_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
38
+ export const EMAIL_PATTERN = /^[^\s@]+@[^\s@.]+(?:\.[^\s@.]+)+$/;
36
39
  /**
37
40
  * What a site is worth before anybody has said otherwise.
38
41
  *
@@ -44,11 +47,28 @@ export const DEFAULT_SITE_SETTINGS = {
44
47
  tagline: '',
45
48
  baseUrl: '',
46
49
  timezone: 'UTC',
50
+ language: 'en',
47
51
  postsPerPage: 10,
52
+ homepage: '',
53
+ postsPage: '',
48
54
  author: '',
49
- actorHandle: 'blog',
50
- actorType: 'Person',
51
- avatar: '',
55
+ theme: '',
56
+ tagBase: DEFAULT_TAXONOMY_BASES.tag,
57
+ categoryBase: DEFAULT_TAXONOMY_BASES.category,
58
+ comments: true,
59
+ commentsCloseAfterDays: DEFAULT_COMMENTS_CLOSE_AFTER_DAYS,
60
+ webmentionsSend: true,
61
+ webmentionsReceive: true,
62
+ notifyServer: DEFAULT_NOTIFY_SERVER,
63
+ mailProvider: 'none',
64
+ mailFromName: '',
65
+ mailFromAddress: '',
66
+ mailReplyTo: '',
67
+ contactEmail: '',
68
+ relays: [],
69
+ wordpressActivityPub: false,
70
+ navigation: [],
71
+ taxonomyRedirects: [],
52
72
  };
53
73
  /** The form field each setting is submitted under. */
54
74
  export const SETTINGS_FIELDS = {
@@ -56,74 +76,151 @@ export const SETTINGS_FIELDS = {
56
76
  tagline: 'tagline',
57
77
  baseUrl: 'base_url',
58
78
  timezone: 'timezone',
79
+ language: 'language',
59
80
  postsPerPage: 'posts_per_page',
81
+ homepage: 'homepage',
82
+ postsPage: 'posts_page',
60
83
  author: 'author',
61
- actorHandle: 'actor_handle',
62
- actorType: 'actor_type',
84
+ theme: 'theme',
85
+ tagBase: 'tag_base',
86
+ categoryBase: 'category_base',
87
+ comments: 'comments',
88
+ commentsCloseAfterDays: 'comments_close_after_days',
89
+ webmentionsSend: 'webmentions_send',
90
+ webmentionsReceive: 'webmentions_receive',
91
+ notifyServer: 'notify_server',
92
+ mailProvider: 'mail_provider',
93
+ mailFromName: 'mail_from_name',
94
+ mailFromAddress: 'mail_from_address',
95
+ mailReplyTo: 'mail_reply_to',
96
+ contactEmail: 'contact_email',
97
+ relays: 'relays',
98
+ wordpressActivityPub: 'wordpress_activitypub',
99
+ navigation: 'navigation',
63
100
  };
64
- /** The stored settings, with every default filled in. */
65
- export function readSiteSettings(store) {
66
- const stored = store.allSettings();
67
- const postsPerPage = Number(stored['postsPerPage']);
68
- return {
69
- title: stored['title'] ?? DEFAULT_SITE_SETTINGS.title,
70
- tagline: stored['tagline'] ?? DEFAULT_SITE_SETTINGS.tagline,
71
- baseUrl: stored['baseUrl'] ?? DEFAULT_SITE_SETTINGS.baseUrl,
72
- timezone: stored['timezone'] ?? DEFAULT_SITE_SETTINGS.timezone,
73
- postsPerPage: Number.isInteger(postsPerPage) && postsPerPage > 0
74
- ? postsPerPage
75
- : DEFAULT_SITE_SETTINGS.postsPerPage,
76
- author: stored['author'] ?? DEFAULT_SITE_SETTINGS.author,
77
- actorHandle: stored['actorHandle'] ?? DEFAULT_SITE_SETTINGS.actorHandle,
78
- actorType: ACTOR_TYPES.includes(stored['actorType'] ?? '')
79
- ? stored['actorType']
80
- : DEFAULT_SITE_SETTINGS.actorType,
81
- avatar: stored['avatar'] ?? DEFAULT_SITE_SETTINGS.avatar,
82
- };
83
- }
84
- /** Write a whole settings object back to the store. */
85
- export function writeSiteSettings(store, settings) {
86
- store.setSettings({
87
- title: settings.title,
88
- tagline: settings.tagline,
89
- baseUrl: settings.baseUrl,
90
- timezone: settings.timezone,
91
- postsPerPage: String(settings.postsPerPage),
92
- author: settings.author,
93
- actorHandle: settings.actorHandle,
94
- actorType: settings.actorType,
95
- avatar: settings.avatar,
96
- });
101
+ /**
102
+ * A site's settings, as its `content/_data/site.json` says them right now.
103
+ *
104
+ * Read on every request that needs one rather than cached, which is what a
105
+ * source of truth being a file is worth: a hand edit shows on the public site,
106
+ * on the settings screen and in the actor document without a restart and
107
+ * without anything being told. The file is a few hundred bytes, so the read
108
+ * costs less than the parse of the template that is about to use it.
109
+ *
110
+ * A file that is missing, or one that will not parse, is the defaults rather
111
+ * than an error: a typo in `site.json` should leave a site up and answerable,
112
+ * with the settings screen there to put it right.
113
+ */
114
+ export function readSiteSettings(contentDir) {
115
+ return settingsFromSiteJson(readSiteJsonSync(siteDataPath(contentDir)));
97
116
  }
98
117
  /**
99
- * The settings as the `site` global, for the theme and for the JSON mirror.
118
+ * `content/_data/site.json` as the settings it names, defaults filled in.
100
119
  *
101
- * Empty strings are left out rather than written as empty values, so a key a
102
- * site keeps in `site.json` by hand and leaves blank in the form is overlaid
103
- * rather than blanked.
120
+ * Read tolerantly, key by key, because the file is public, in git and editable
121
+ * by hand: a key of the wrong type, or one a site has never written, falls
122
+ * back to the default rather than taking the site down. The empty string is a
123
+ * value of its own for `tagline`, `author` and `notifyServer`, where empty is
124
+ * a decision — no tagline, no author, notifications off — and not for
125
+ * `title`, `timezone`, `language` or the two archive bases, where it is a hole
126
+ * only a default can fill.
104
127
  */
105
- export function settingsSiteData(settings) {
128
+ export function settingsFromSiteJson(file) {
129
+ const postsPerPage = Number(file['postsPerPage']);
130
+ const closeAfterDays = Number(file['commentsCloseAfterDays']);
106
131
  return {
107
- ...(settings.title === '' ? {} : { title: settings.title }),
108
- ...(settings.tagline === '' ? {} : { tagline: settings.tagline }),
109
- ...(settings.baseUrl === '' ? {} : { url: settings.baseUrl }),
110
- ...(settings.author === '' ? {} : { author: settings.author }),
111
- ...(settings.timezone === '' ? {} : { timezone: settings.timezone }),
112
- ...(settings.avatar === '' ? {} : { avatar: settings.avatar }),
113
- postsPerPage: settings.postsPerPage,
132
+ ...DEFAULT_SITE_SETTINGS,
133
+ ...(typeof file['title'] === 'string' && file['title'] !== '' ? { title: file['title'] } : {}),
134
+ ...(typeof file['tagline'] === 'string' ? { tagline: file['tagline'] } : {}),
135
+ ...(typeof file['author'] === 'string' ? { author: file['author'] } : {}),
136
+ ...(typeof file['timezone'] === 'string' && file['timezone'] !== ''
137
+ ? { timezone: file['timezone'] }
138
+ : {}),
139
+ ...(typeof file['language'] === 'string' && file['language'] !== ''
140
+ ? { language: file['language'] }
141
+ : {}),
142
+ // Absent is the ordinary state of this one: a site on the packaged theme
143
+ // has never written the key, so anything but a string is that site.
144
+ ...(typeof file['theme'] === 'string' ? { theme: file['theme'] } : {}),
145
+ // Absent is the ordinary state of these two: a site showing its latest
146
+ // posts writes neither key, so anything but a string is read as none.
147
+ ...(typeof file['homepage'] === 'string' ? { homepage: file['homepage'] } : {}),
148
+ ...(typeof file['postsPage'] === 'string' ? { postsPage: file['postsPage'] } : {}),
149
+ ...(typeof file['tagBase'] === 'string' && file['tagBase'] !== ''
150
+ ? { tagBase: file['tagBase'] }
151
+ : {}),
152
+ ...(typeof file['categoryBase'] === 'string' && file['categoryBase'] !== ''
153
+ ? { categoryBase: file['categoryBase'] }
154
+ : {}),
155
+ ...(typeof file['comments'] === 'boolean' ? { comments: file['comments'] } : {}),
156
+ ...(Number.isInteger(closeAfterDays) && closeAfterDays >= 0
157
+ ? { commentsCloseAfterDays: closeAfterDays }
158
+ : {}),
159
+ ...(typeof file['webmentionsSend'] === 'boolean'
160
+ ? { webmentionsSend: file['webmentionsSend'] }
161
+ : {}),
162
+ ...(typeof file['webmentionsReceive'] === 'boolean'
163
+ ? { webmentionsReceive: file['webmentionsReceive'] }
164
+ : {}),
165
+ ...(typeof file['notifyServer'] === 'string' ? { notifyServer: file['notifyServer'] } : {}),
166
+ // Only a provider this version ships, for the reason the actor type is
167
+ // read that way: a file naming one it has never heard of is a site that
168
+ // sends no mail rather than a boot that fails.
169
+ ...(typeof file['mailProvider'] === 'string' &&
170
+ MAIL_PROVIDERS.includes(file['mailProvider'])
171
+ ? { mailProvider: file['mailProvider'] }
172
+ : {}),
173
+ ...(typeof file['mailFromName'] === 'string' ? { mailFromName: file['mailFromName'] } : {}),
174
+ ...(typeof file['mailFromAddress'] === 'string'
175
+ ? { mailFromAddress: file['mailFromAddress'] }
176
+ : {}),
177
+ ...(typeof file['mailReplyTo'] === 'string' ? { mailReplyTo: file['mailReplyTo'] } : {}),
178
+ ...(typeof file['contactEmail'] === 'string' ? { contactEmail: file['contactEmail'] } : {}),
179
+ // Through the same normaliser a submitted form goes through, so the file
180
+ // and the screen cannot mean different things by the same line.
181
+ ...(Array.isArray(file['relays'])
182
+ ? {
183
+ relays: relayList(file['relays'].filter((entry) => typeof entry === 'string').join('\n')),
184
+ }
185
+ : {}),
186
+ // Absent is the ordinary state of this one, like `homepage`: a site that
187
+ // has never carried WordPress's paths does not write the key at all.
188
+ ...(typeof file['wordpressActivityPub'] === 'boolean'
189
+ ? { wordpressActivityPub: file['wordpressActivityPub'] }
190
+ : {}),
191
+ ...(Array.isArray(file['navigation'])
192
+ ? { navigation: navigationItemsOf(file['navigation']) }
193
+ : {}),
194
+ // The renames a site has published redirects for: facts about its URLs
195
+ // rather than preferences, which is why a content directory restored on
196
+ // its own keeps answering the archive URLs it used to.
197
+ ...(Array.isArray(file['taxonomyRedirects'])
198
+ ? { taxonomyRedirects: taxonomyRedirectsOf(file['taxonomyRedirects']) }
199
+ : {}),
200
+ ...(Number.isInteger(postsPerPage) && postsPerPage > 0 ? { postsPerPage } : {}),
201
+ ...(typeof file['url'] === 'string' ? { baseUrl: file['url'] } : {}),
114
202
  };
115
203
  }
204
+ /** The two archive bases the settings hold, as the URL builders want them. */
205
+ export function taxonomyBasesFromSettings(settings) {
206
+ return { tag: settings.tagBase, category: settings.categoryBase };
207
+ }
116
208
  /**
117
209
  * `content/_data/site.json` as it should read for these settings.
118
210
  *
119
- * Every key the form manages is written whether or not it has a value, so the
120
- * file's shape is stable and an Eleventy template may reference `site.author`
121
- * without guarding it. Every other key the file already had is kept: a site
122
- * may put anything in there and reach it from its templates, and the settings
123
- * form is not going to be the thing that throws it away.
211
+ * Every key the settings model is written whether or not it has a value, so
212
+ * the file's shape is stable and an Eleventy template may reference
213
+ * `site.author` without guarding it. Every other key the file already had is
214
+ * kept: a site may put anything in there and reach it from its templates, and
215
+ * the settings form is not going to be the thing that throws it away.
216
+ *
217
+ * `homepage` and `postsPage` are the one exception, and they earn it: a site
218
+ * showing its latest posts has no static front page at all rather than an
219
+ * empty one, so the keys are absent rather than empty — which is also how a
220
+ * `site.json` written before this version reads, and what it should mean.
124
221
  */
125
222
  export function siteJsonFor(settings, existing = {}) {
126
- return {
223
+ const file = {
127
224
  ...existing,
128
225
  title: settings.title,
129
226
  tagline: settings.tagline,
@@ -131,69 +228,155 @@ export function siteJsonFor(settings, existing = {}) {
131
228
  author: settings.author,
132
229
  postsPerPage: settings.postsPerPage,
133
230
  timezone: settings.timezone,
134
- avatar: settings.avatar,
231
+ language: settings.language,
232
+ tagBase: settings.tagBase,
233
+ categoryBase: settings.categoryBase,
234
+ comments: settings.comments,
235
+ commentsCloseAfterDays: settings.commentsCloseAfterDays,
236
+ webmentionsSend: settings.webmentionsSend,
237
+ webmentionsReceive: settings.webmentionsReceive,
238
+ notifyServer: settings.notifyServer,
239
+ mailProvider: settings.mailProvider,
240
+ mailFromName: settings.mailFromName,
241
+ mailFromAddress: settings.mailFromAddress,
242
+ mailReplyTo: settings.mailReplyTo,
243
+ contactEmail: settings.contactEmail,
244
+ relays: [...settings.relays],
245
+ navigation: settings.navigation.map((item) => ({ ...item })),
246
+ taxonomyRedirects: settings.taxonomyRedirects.map((entry) => ({ ...entry })),
135
247
  };
248
+ // `theme` is absent for a site on the packaged theme, on the same rule and
249
+ // for the same reason as the two above: running what the package ships is
250
+ // not a choice a site should have to write down, and a `theme` of `""` would
251
+ // be a name no directory has.
252
+ for (const key of ['homepage', 'postsPage', 'theme']) {
253
+ if (settings[key] === '')
254
+ delete file[key];
255
+ else
256
+ file[key] = settings[key];
257
+ }
258
+ // And the WordPress switch, on the same rule and for a sharper reason: it is
259
+ // a temporary accommodation for one migrated site (decision-14), so a site
260
+ // that has never turned it on should have nothing to say about it, and one
261
+ // that has turned it off again should go back to saying nothing.
262
+ if (settings.wordpressActivityPub)
263
+ file['wordpressActivityPub'] = true;
264
+ else
265
+ delete file['wordpressActivityPub'];
266
+ return file;
136
267
  }
137
268
  /** The absolute path of one site's `content/_data/site.json`. */
138
269
  export function siteDataPath(contentDir) {
139
270
  return path.join(contentDir, ...SITE_DATA_FILE.split('/'));
140
271
  }
272
+ /** The bytes of one settings object, as the file holds them. */
273
+ function siteJsonText(settings, existing) {
274
+ return `${JSON.stringify(siteJsonFor(settings, existing), null, 2)}\n`;
275
+ }
141
276
  /**
142
- * Rewrite `content/_data/site.json` from the settings.
277
+ * Rewrite `content/_data/site.json` as these settings, keeping the keys they
278
+ * do not model.
143
279
  *
144
- * Written to a temporary file in the same directory and renamed over the old
145
- * one, so a reader — an Eleventy build, or the CMS's own site data source —
146
- * sees either the whole old file or the whole new one and never a half-written
147
- * one.
280
+ * {@link updateSiteSettings} for a caller whose new settings depend on the old
281
+ * ones, which every screen's is: this is for the callers that have the whole
282
+ * object already, a test's set-up among them.
148
283
  */
149
284
  export async function writeSiteJson(options) {
150
- const file = siteDataPath(options.contentDir);
151
- await mkdir(path.dirname(file), { recursive: true });
152
- let existing = {};
153
- try {
154
- existing = parseSiteJson(await readFile(file, 'utf8'));
155
- }
156
- catch {
157
- // No file yet, or one that will not parse. Either way the settings are
158
- // what the file is about to say.
285
+ await updateSiteSettings({ contentDir: options.contentDir, change: () => options.settings });
286
+ }
287
+ /**
288
+ * Change the settings: re-read the file, apply the change, write it back, with
289
+ * nothing able to write that file in between ({@link updateFileAtomically}).
290
+ *
291
+ * The re-read is what makes the file win the way it wins for a post
292
+ * (decision-9). Two people with the settings screen open both save what they
293
+ * see, and the second save is applied to what the first one actually wrote
294
+ * rather than to what the second one had on screen — so a change to a field
295
+ * the second person did not touch survives. The write is a temporary file and
296
+ * a rename, so a reader — an Eleventy build, the site data source, another
297
+ * process entirely — never sees a half-written file. Keys the settings do not
298
+ * model, `feedSize` and anything a site added, are kept.
299
+ */
300
+ export async function updateSiteSettings(options) {
301
+ let written = DEFAULT_SITE_SETTINGS;
302
+ await updateFileAtomically(siteDataPath(options.contentDir), (current) => {
303
+ const existing = parseSiteJson(current ?? '');
304
+ written = options.change(settingsFromSiteJson(existing));
305
+ return siteJsonText(written, existing);
306
+ });
307
+ return written;
308
+ }
309
+ /**
310
+ * Turn an older site's settings rows into `content/_data/site.json`, once, and
311
+ * drop the table.
312
+ *
313
+ * TASK-14 made SQLite the source and the file its mirror; decision-9 reversed
314
+ * that, and a site upgrading across the two has rows nothing would ever read
315
+ * again. So the first boot of this version writes them out. Which side wins
316
+ * when both have something to say is decided the way it is decided everywhere
317
+ * else: the file wins when it was written after the last save, because that is
318
+ * a hand edit or a restored checkout, and the rows win otherwise. Either way
319
+ * the actor handle and type come from the rows, because `site.json` never
320
+ * carried them and losing them would rename the site's actor.
321
+ *
322
+ * Synchronous because `createCms` is: it runs before the server is listening
323
+ * and before anything else has touched the file.
324
+ */
325
+ export function migrateSettingsToFile(options) {
326
+ const { admin, contentDir } = options;
327
+ const rows = admin.legacySettings();
328
+ if (rows !== undefined && rows.length > 0) {
329
+ const stored = settingsFromRows(rows);
330
+ const file = siteDataPath(contentDir);
331
+ const existing = parseSiteJson(readFileIfPresentSync(file) ?? '');
332
+ const settings = fileWasEditedLast(file, rows) ? settingsFromSiteJson(existing) : stored;
333
+ writeFileAtomicallySync(file, siteJsonText(settings, existing));
159
334
  }
160
- const temporary = `${file}.${process.pid.toString(36)}.tmp`;
161
- await writeFile(temporary, `${JSON.stringify(siteJsonFor(options.settings, existing), null, 2)}\n`, 'utf8');
162
- await rename(temporary, file);
335
+ // Dropped whether or not it had rows: a fresh database gets the table from
336
+ // migration 3, which has shipped and so is never edited, and a site that has
337
+ // been migrated must not be asked again on the next boot.
338
+ admin.dropLegacyTable('settings');
339
+ }
340
+ /** The old key/value rows as settings, defaults filled in. */
341
+ function settingsFromRows(rows) {
342
+ const stored = {};
343
+ for (const row of rows)
344
+ stored[row.key] = row.value;
345
+ // Through the file's own reader, so one description of what a setting is
346
+ // worth serves both: the rows are spelled as the file spells them and read
347
+ // back the same way. `relays`, `navigation` and `taxonomyRedirects` were
348
+ // stored one entry per line, which is not how the file holds them.
349
+ return settingsFromSiteJson({
350
+ ...stored,
351
+ ...(stored['baseUrl'] === undefined ? {} : { url: stored['baseUrl'] }),
352
+ ...(stored['postsPerPage'] === undefined
353
+ ? {}
354
+ : { postsPerPage: Number(stored['postsPerPage']) }),
355
+ relays: relayList(stored['relays'] ?? ''),
356
+ navigation: navigationList(stored['navigation'] ?? ''),
357
+ taxonomyRedirects: redirectList(stored['taxonomyRedirects'] ?? ''),
358
+ });
163
359
  }
164
360
  /**
165
- * Fill an empty settings table from `content/_data/site.json`, so a site that
166
- * existed before this screen did — or one `geekity init` just wrote — keeps
167
- * the values it already had.
361
+ * Whether `site.json` has been written since the newest settings row was.
168
362
  *
169
- * Runs once, on the boot that finds the table empty. After that SQLite is the
170
- * source and the file is the mirror, so a later hand edit of the file no
171
- * longer wins.
363
+ * A missing file has not, so the rows win. A file whose modification time
364
+ * cannot be read is treated the same way: the rows are the only thing that is
365
+ * certainly there.
172
366
  */
173
- export function seedSiteSettings(options) {
174
- const { store, config } = options;
175
- if (store.countSettings() > 0)
176
- return readSiteSettings(store);
177
- const file = readSiteJsonSync(siteDataPath(config.contentDir));
178
- const postsPerPage = Number(file['postsPerPage']);
179
- const seeded = {
180
- ...DEFAULT_SITE_SETTINGS,
181
- ...(typeof file['title'] === 'string' && file['title'] !== '' ? { title: file['title'] } : {}),
182
- ...(typeof file['tagline'] === 'string' ? { tagline: file['tagline'] } : {}),
183
- ...(typeof file['author'] === 'string' ? { author: file['author'] } : {}),
184
- ...(typeof file['timezone'] === 'string' && file['timezone'] !== ''
185
- ? { timezone: file['timezone'] }
186
- : {}),
187
- ...(typeof file['avatar'] === 'string' ? { avatar: file['avatar'] } : {}),
188
- ...(Number.isInteger(postsPerPage) && postsPerPage > 0 ? { postsPerPage } : {}),
189
- // The file's `url` only becomes the setting when the deployment has not
190
- // named one; otherwise the setting records what is actually in effect.
191
- baseUrl: config.baseUrlSource === 'default' && typeof file['url'] === 'string' && file['url'] !== ''
192
- ? file['url']
193
- : config.baseUrl,
194
- };
195
- writeSiteSettings(store, seeded);
196
- return seeded;
367
+ function fileWasEditedLast(file, rows) {
368
+ let modified;
369
+ try {
370
+ modified = statSync(file).mtimeMs;
371
+ }
372
+ catch {
373
+ return false;
374
+ }
375
+ const newest = rows.reduce((latest, row) => {
376
+ const at = Date.parse(row.updatedAt);
377
+ return Number.isNaN(at) ? latest : Math.max(latest, at);
378
+ }, 0);
379
+ return modified > newest;
197
380
  }
198
381
  /**
199
382
  * The base URL actually in effect, and why.
@@ -209,28 +392,151 @@ export function effectiveBaseUrl(config, settings) {
209
392
  return config.baseUrl;
210
393
  return settings.baseUrl === '' ? config.baseUrl : settings.baseUrl;
211
394
  }
212
- /** What is wrong with a submitted settings form, one message per field. */
213
- export function settingsProblems(form) {
395
+ /**
396
+ * What is wrong with each field of a submitted form: one check per field, so a
397
+ * page can run the checks for the fields it carries and no others.
398
+ *
399
+ * A field nothing can be wrong with — a tagline, an author, a checkbox — has a
400
+ * check all the same, because a table with a hole in it is a field somebody
401
+ * added and forgot to think about.
402
+ */
403
+ const FIELD_CHECKS = {
404
+ title: (form) => (form.title.trim() === '' ? 'The site needs a title.' : undefined),
405
+ tagline: () => undefined,
406
+ author: () => undefined,
407
+ baseUrl: (form) => normalizeBaseUrl(form.baseUrl) === undefined
408
+ ? 'The base URL has to be an absolute http:// or https:// URL.'
409
+ : undefined,
410
+ postsPerPage: (form) => {
411
+ const perPage = Number(form.postsPerPage);
412
+ return !Number.isInteger(perPage) || perPage < 1
413
+ ? 'Posts per page has to be a whole number of one or more.'
414
+ : undefined;
415
+ },
416
+ // The two picks are checked as a pair, the way the archive bases are, and
417
+ // for the same reason: two of the three rules are about the pair. Neither
418
+ // check asks whether the slug still names a published page — the select only
419
+ // offers ones that do, and a page drafted after it was picked is a site back
420
+ // on its latest posts rather than a settings screen that will not save.
421
+ homepage: (form) => slugProblem(form.homepage, 'A homepage'),
422
+ postsPage: (form) => {
423
+ if (form.postsPage.trim() === '')
424
+ return undefined;
425
+ if (form.homepage.trim() === '') {
426
+ return 'A posts page needs a homepage: pick the page the front page shows first.';
427
+ }
428
+ if (form.postsPage.trim() === form.homepage.trim()) {
429
+ return 'The homepage and the posts page have to be two different pages.';
430
+ }
431
+ return slugProblem(form.postsPage, 'A posts page');
432
+ },
433
+ timezone: (form) => isValidTimezone(form.timezone)
434
+ ? undefined
435
+ : 'That is not an IANA time zone name, such as Europe/London.',
436
+ language: (form) => LANGUAGE_TAG_PATTERN.test(form.language.trim())
437
+ ? undefined
438
+ : 'That is not a language tag, such as en, en-GB or pt-BR.',
439
+ // Empty is a value here — it is how a site says "the theme the package
440
+ // ships" — so only a name has anything to check. A name that is not a theme
441
+ // is refused rather than saved and warned about later: the screen has a list
442
+ // of the themes that are there, so the only way to type one that is not is a
443
+ // theme deleted between drawing the list and saving it, and the person is in
444
+ // front of the form and can be told.
445
+ theme: (form, context) => {
446
+ const name = form.theme.trim();
447
+ if (name === '')
448
+ return undefined;
449
+ const badName = themeNameProblem(name);
450
+ if (badName !== undefined)
451
+ return badName;
452
+ if (context.themesDir === undefined)
453
+ return undefined;
454
+ const read = readTheme(path.join(context.themesDir, name));
455
+ return read.ok ? undefined : `There is no theme called "${name}": ${read.reason}`;
456
+ },
457
+ comments: () => undefined,
458
+ // The empty string is refused rather than read as zero, which is what
459
+ // `Number('')` would make it: a cleared field is a mistake, and "never close
460
+ // comments" should have to be typed.
461
+ commentsCloseAfterDays: (form) => {
462
+ const typed = form.commentsCloseAfterDays.trim();
463
+ const days = Number(typed);
464
+ return typed === '' || !Number.isInteger(days) || days < 0
465
+ ? 'Comments close after a whole number of days, or 0 for never.'
466
+ : undefined;
467
+ },
468
+ webmentionsSend: () => undefined,
469
+ webmentionsReceive: () => undefined,
470
+ // Empty is a value here — it is how a site turns real-time notification off
471
+ // — so only a non-empty one has to be a URL. http as well as https, because
472
+ // a notify server on a private network or a loopback port is a real one.
473
+ notifyServer: (form) => form.notifyServer.trim() !== '' && normalizeBaseUrl(form.notifyServer) === undefined
474
+ ? 'A notify server is an absolute http:// or https:// URL, or empty for none.'
475
+ : undefined,
476
+ mailProvider: (form) => MAIL_PROVIDERS.includes(form.mailProvider)
477
+ ? undefined
478
+ : `A mail provider is one of ${MAIL_PROVIDERS.join(', ')}.`,
479
+ mailFromName: () => undefined,
480
+ // Empty is a value for both of these — no From address falls back to
481
+ // `no-reply@` at the site's host, and no reply-to means replies go to the
482
+ // From address — so only a non-empty one has to look like an address.
483
+ mailFromAddress: (form) => form.mailFromAddress.trim() !== '' && !EMAIL_PATTERN.test(form.mailFromAddress.trim())
484
+ ? 'A From address is an email address, such as blog@example.com, or empty for the default.'
485
+ : undefined,
486
+ mailReplyTo: (form) => form.mailReplyTo.trim() !== '' && !EMAIL_PATTERN.test(form.mailReplyTo.trim())
487
+ ? 'A reply-to is an email address, such as hello@example.com, or empty to reply to the From address.'
488
+ : undefined,
489
+ // Empty is a value here too — it is how a site says "whichever admin has an
490
+ // address" — so only a non-empty one has to look like one.
491
+ contactEmail: (form) => form.contactEmail.trim() !== '' && !EMAIL_PATTERN.test(form.contactEmail.trim())
492
+ ? 'A contact address is an email address, such as hello@example.com, or empty for the first admin with one.'
493
+ : undefined,
494
+ // A relay list is checked line by line, and the first bad line is what the
495
+ // field says: a textarea has one message, and pointing at the line somebody
496
+ // has to fix is more use than counting how many are wrong.
497
+ relays: (form) => {
498
+ const bad = relayLines(form.relays).find((line) => normalizeRelayInbox(line) === undefined);
499
+ return bad === undefined
500
+ ? undefined
501
+ : `A relay is its inbox as an absolute http:// or https:// URL, one per line. ` +
502
+ `"${bad}" is not one.`;
503
+ },
504
+ // A checkbox is either submitted or not, so there is nothing a person could
505
+ // get wrong about it.
506
+ wordpressActivityPub: () => undefined,
507
+ // A menu is checked line by line like the relays, and for the same reason:
508
+ // one message on a textarea is more use pointing at the line to fix than
509
+ // counting how many are wrong.
510
+ navigation: (form) => {
511
+ const bad = navigationLines(form.navigation).find((line) => navigationItem(line) === undefined);
512
+ return bad === undefined
513
+ ? undefined
514
+ : `A menu item is "Label | URL", one per line, where the URL is a path ` +
515
+ `like /about/ or an absolute http:// or https:// URL. "${bad}" is not one.`;
516
+ },
517
+ // The two archive bases are checked as a pair, because two of the rules —
518
+ // that they differ, and that neither takes a path the site already answers
519
+ // on — are about the pair rather than either one. They are on one page for
520
+ // exactly that reason.
521
+ tagBase: (form) => taxonomyBaseProblems({ tag: form.tagBase, category: form.categoryBase }).tag,
522
+ categoryBase: (form) => taxonomyBaseProblems({ tag: form.tagBase, category: form.categoryBase }).category,
523
+ };
524
+ /** Every field of the settings, in the order the form fields name them. */
525
+ export const SETTINGS_FIELD_NAMES = Object.keys(SETTINGS_FIELDS);
526
+ /**
527
+ * What is wrong with a submitted settings form, one message per field.
528
+ *
529
+ * `fields` is what a page carries: it validates its own fields and says
530
+ * nothing about the rest, so a page cannot refuse a save over a field it does
531
+ * not show and gives no way to fix. `context` is what a check needs beyond the
532
+ * form; see {@link SettingsContext}.
533
+ */
534
+ export function settingsProblems(form, fields = SETTINGS_FIELD_NAMES, context = {}) {
214
535
  const problems = {};
215
- if (form.title.trim() === '')
216
- problems.title = 'The site needs a title.';
217
- const url = normalizeBaseUrl(form.baseUrl);
218
- if (url === undefined) {
219
- problems.baseUrl = 'The base URL has to be an absolute http:// or https:// URL.';
220
- }
221
- const perPage = Number(form.postsPerPage);
222
- if (!Number.isInteger(perPage) || perPage < 1) {
223
- problems.postsPerPage = 'Posts per page has to be a whole number of one or more.';
224
- }
225
- if (!isValidTimezone(form.timezone)) {
226
- problems.timezone = 'That is not an IANA time zone name, such as Europe/London.';
227
- }
228
- if (!ACTOR_HANDLE_PATTERN.test(form.actorHandle)) {
229
- problems.actorHandle =
230
- 'An actor handle is 1 to 64 letters, digits, dashes or underscores, with no @ and no dots.';
231
- }
232
- if (!ACTOR_TYPES.includes(form.actorType)) {
233
- problems.actorType = `An actor type is one of ${ACTOR_TYPES.join(', ')}.`;
536
+ for (const name of fields) {
537
+ const problem = FIELD_CHECKS[name](form, context);
538
+ if (problem !== undefined)
539
+ problems[name] = problem;
234
540
  }
235
541
  return problems;
236
542
  }
@@ -238,21 +544,45 @@ export function settingsProblems(form) {
238
544
  * A validated form as settings. Only call it on a form
239
545
  * {@link settingsProblems} found nothing wrong with.
240
546
  *
241
- * The avatar is carried in rather than read off the form, because it is not on
242
- * it: the image is uploaded and removed through {@link AVATAR_PATH}, and a
243
- * save of the other fields keeps whatever is stored.
547
+ * The recorded archive renames are carried in rather than read off the form,
548
+ * because they are not on it: the taxonomy screens write them, and a save of
549
+ * the other fields keeps whatever is stored.
244
550
  */
245
- export function settingsFromForm(form, avatar = DEFAULT_SITE_SETTINGS.avatar) {
551
+ export function settingsFromForm(form, taxonomyRedirects = DEFAULT_SITE_SETTINGS.taxonomyRedirects) {
246
552
  return {
247
- avatar,
553
+ taxonomyRedirects,
248
554
  title: form.title.trim(),
249
555
  tagline: form.tagline.trim(),
250
556
  baseUrl: normalizeBaseUrl(form.baseUrl) ?? '',
251
557
  timezone: form.timezone.trim(),
558
+ language: form.language.trim(),
252
559
  postsPerPage: Number(form.postsPerPage),
560
+ homepage: form.homepage.trim(),
561
+ // A posts page means nothing without a homepage, and the validator has
562
+ // already refused the pair; this is what makes clearing the homepage clear
563
+ // the listing's own page with it rather than leave it stranded.
564
+ postsPage: form.homepage.trim() === '' ? '' : form.postsPage.trim(),
253
565
  author: form.author.trim(),
254
- actorHandle: form.actorHandle.trim(),
255
- actorType: form.actorType,
566
+ theme: form.theme.trim(),
567
+ tagBase: form.tagBase.trim(),
568
+ categoryBase: form.categoryBase.trim(),
569
+ // A checkbox submits nothing at all when it is clear, which is what the
570
+ // empty string here means.
571
+ comments: form.comments !== '',
572
+ commentsCloseAfterDays: Number(form.commentsCloseAfterDays),
573
+ webmentionsSend: form.webmentionsSend !== '',
574
+ webmentionsReceive: form.webmentionsReceive !== '',
575
+ notifyServer: normalizeBaseUrl(form.notifyServer) ?? '',
576
+ mailProvider: MAIL_PROVIDERS.includes(form.mailProvider)
577
+ ? form.mailProvider
578
+ : 'none',
579
+ mailFromName: form.mailFromName.trim(),
580
+ mailFromAddress: form.mailFromAddress.trim(),
581
+ mailReplyTo: form.mailReplyTo.trim(),
582
+ contactEmail: form.contactEmail.trim(),
583
+ relays: relayList(form.relays),
584
+ wordpressActivityPub: form.wordpressActivityPub !== '',
585
+ navigation: navigationList(form.navigation),
256
586
  };
257
587
  }
258
588
  /** The settings as the form shows them. */
@@ -262,169 +592,110 @@ export function formFromSettings(settings) {
262
592
  tagline: settings.tagline,
263
593
  baseUrl: settings.baseUrl,
264
594
  timezone: settings.timezone,
595
+ language: settings.language,
265
596
  postsPerPage: String(settings.postsPerPage),
597
+ homepage: settings.homepage,
598
+ postsPage: settings.postsPage,
266
599
  author: settings.author,
267
- actorHandle: settings.actorHandle,
268
- actorType: settings.actorType,
600
+ theme: settings.theme,
601
+ tagBase: settings.tagBase,
602
+ categoryBase: settings.categoryBase,
603
+ comments: settings.comments ? '1' : '',
604
+ commentsCloseAfterDays: String(settings.commentsCloseAfterDays),
605
+ webmentionsSend: settings.webmentionsSend ? '1' : '',
606
+ webmentionsReceive: settings.webmentionsReceive ? '1' : '',
607
+ notifyServer: settings.notifyServer,
608
+ mailProvider: settings.mailProvider,
609
+ mailFromName: settings.mailFromName,
610
+ mailFromAddress: settings.mailFromAddress,
611
+ mailReplyTo: settings.mailReplyTo,
612
+ contactEmail: settings.contactEmail,
613
+ relays: settings.relays.join('\n'),
614
+ wordpressActivityPub: settings.wordpressActivityPub ? '1' : '',
615
+ navigation: navigationText(settings.navigation),
269
616
  };
270
617
  }
271
618
  /**
272
- * Register the settings screen.
619
+ * A `taxonomy|from|to` block as the renames it names, which is how the old
620
+ * settings table spelled them ({@link migrateSettingsToFile}).
273
621
  *
274
- * A save writes SQLite and `content/_data/site.json` in that order, so the
275
- * store — which is what the theme reads — is never behind the file. A form the
276
- * validator has anything to say about is a 400 that writes neither.
622
+ * The same tolerance the rest of this file reads its lists with: a line that
623
+ * is not three non-empty parts naming a real taxonomy is dropped rather than
624
+ * failing the read.
277
625
  */
278
- export function mountSettings(app, options) {
279
- const { render } = options;
280
- app.get(SETTINGS_PATH, (c) => render(c, ADMIN_TEMPLATES.settings, screen(c.var.config, readSiteSettings(c.var.admin))));
281
- app.post(SETTINGS_PATH, async (c) => {
282
- const body = await c.req.parseBody();
283
- const stored = readSiteSettings(c.var.admin);
284
- const submitted = {
285
- title: field(body[SETTINGS_FIELDS.title]),
286
- tagline: field(body[SETTINGS_FIELDS.tagline]),
287
- // A field the form rendered read-only is not submitted, so an overridden
288
- // base URL keeps the value it had rather than being cleared by a save.
289
- baseUrl: c.var.config.baseUrlSource === 'default'
290
- ? field(body[SETTINGS_FIELDS.baseUrl])
291
- : stored.baseUrl === ''
292
- ? c.var.config.baseUrl
293
- : stored.baseUrl,
294
- timezone: field(body[SETTINGS_FIELDS.timezone]),
295
- postsPerPage: field(body[SETTINGS_FIELDS.postsPerPage]),
296
- author: field(body[SETTINGS_FIELDS.author]),
297
- actorHandle: field(body[SETTINGS_FIELDS.actorHandle]),
298
- actorType: field(body[SETTINGS_FIELDS.actorType]),
299
- };
300
- const problems = settingsProblems(submitted);
301
- if (Object.keys(problems).length > 0) {
302
- c.status(400);
303
- return render(c, ADMIN_TEMPLATES.settings, {
304
- ...screen(c.var.config, stored),
305
- form: submitted,
306
- problems,
307
- });
308
- }
309
- const settings = settingsFromForm(submitted, stored.avatar);
310
- await store(c, settings);
311
- // The name, the summary and the handle are the actor's profile as much as
312
- // the avatar is, and a follower's copy of it is only as fresh as the last
313
- // thing it was told.
314
- const report = profileChanged(stored, settings)
315
- ? await c.var.delivery.updateActor()
316
- : undefined;
317
- flash(c, 'notice', `Settings saved.${toldFollowers(report)}`);
318
- return c.redirect(SETTINGS_PATH, 303);
319
- });
320
- /**
321
- * The avatar's own endpoint: one multipart form uploads an image, and a
322
- * second, plain one takes it down again.
323
- *
324
- * It is separate from the settings form because a file cannot travel in a
325
- * urlencoded body, and because the two should not share a fate: a rejected
326
- * image must not lose an edit to the title, and a rejected title must not
327
- * lose the avatar. A refusal is a flash and a redirect, so what is stored is
328
- * exactly what it was and the screen says why.
329
- */
330
- app.post(AVATAR_PATH, async (c) => {
331
- const body = await c.req.parseBody();
332
- const stored = readSiteSettings(c.var.admin);
333
- if (field(body[AVATAR_FIELDS.action]) === AVATAR_REMOVE) {
334
- if (stored.avatar === '') {
335
- flash(c, 'notice', 'The site has no avatar.');
336
- return c.redirect(SETTINGS_PATH, 303);
337
- }
338
- await store(c, { ...stored, avatar: '' });
339
- const removal = await c.var.delivery.updateActor();
340
- flash(c, 'notice', `Avatar removed.${toldFollowers(removal)}`);
341
- return c.redirect(SETTINGS_PATH, 303);
342
- }
343
- const outcome = await storeUpload(body[AVATAR_FIELDS.file], c.var.config, {
344
- imagesOnly: true,
626
+ function redirectList(value) {
627
+ const entries = [];
628
+ for (const line of value.split('\n')) {
629
+ const parts = line.split('|');
630
+ if (parts.length !== 3)
631
+ continue;
632
+ entries.push({
633
+ taxonomy: (parts[0] ?? '').trim(),
634
+ from: (parts[1] ?? '').trim(),
635
+ to: (parts[2] ?? '').trim(),
345
636
  });
346
- if (refusedUpload(outcome)) {
347
- flash(c, 'error', `${outcome.error} The avatar is unchanged.`);
348
- return c.redirect(SETTINGS_PATH, 303);
349
- }
350
- await store(c, { ...stored, avatar: outcome.url });
351
- const report = await c.var.delivery.updateActor();
352
- flash(c, 'notice', `Avatar saved.${toldFollowers(report)}`);
353
- return c.redirect(SETTINGS_PATH, 303);
354
- });
355
- /**
356
- * Write settings to SQLite and then to `content/_data/site.json`, in that
357
- * order, so the store — which is what the theme reads — is never behind the
358
- * file.
359
- */
360
- async function store(c, settings) {
361
- writeSiteSettings(c.var.admin, settings);
362
- await writeSiteJson({ contentDir: c.var.config.contentDir, settings });
363
637
  }
638
+ // Through the same reader `site.json` goes through, so the two spellings of
639
+ // the list cannot mean different things.
640
+ return taxonomyRedirectsOf(entries);
364
641
  }
365
642
  /**
366
- * Whether a save moved something the ActivityPub actor carries, which is what
367
- * decides whether the followers are told (doc-4: the profile fields come from
368
- * the settings). The rest — the time zone, the page size — is the site's own
369
- * business and nobody else's.
370
- *
371
- * The base URL is not here either, though the profile is built on it: it is
372
- * settled at boot ({@link effectiveBaseUrl}), so an actor built the moment it
373
- * is saved would carry the old one and say nothing new.
643
+ * A base URL, normalised the way `resolveConfig` normalises the configured
644
+ * one, or `undefined` when it is not an absolute http(s) URL.
374
645
  */
375
- export function profileChanged(before, after) {
376
- return (before.title !== after.title ||
377
- before.tagline !== after.tagline ||
378
- before.actorHandle !== after.actorHandle ||
379
- before.actorType !== after.actorType ||
380
- before.avatar !== after.avatar);
646
+ function normalizeBaseUrl(value) {
647
+ const trimmed = value.trim();
648
+ if (trimmed === '')
649
+ return undefined;
650
+ let parsed;
651
+ try {
652
+ parsed = new URL(trimmed);
653
+ }
654
+ catch {
655
+ return undefined;
656
+ }
657
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
658
+ return undefined;
659
+ const pathname = parsed.pathname.endsWith('/') ? parsed.pathname.slice(0, -1) : parsed.pathname;
660
+ return parsed.origin + pathname;
381
661
  }
382
662
  /**
383
- * The sentence a flash adds about the followers, when there were any: a save
384
- * of the profile is also an announcement, and it should say so rather than
385
- * leave the admin wondering.
663
+ * The non-empty lines of a relay textarea, trimmed.
664
+ *
665
+ * Blank lines are not an error: somebody pasting a list leaves them, and a
666
+ * blank line asks for nothing.
386
667
  */
387
- function toldFollowers(report) {
388
- const total = report?.deliveries.length ?? 0;
389
- if (total === 0)
390
- return '';
391
- return total === 1
392
- ? ' One follower has been told.'
393
- : ` ${String(total)} followers have been told.`;
668
+ function relayLines(value) {
669
+ return value
670
+ .split('\n')
671
+ .map((line) => line.trim())
672
+ .filter((line) => line !== '');
394
673
  }
395
- /** Everything the settings template renders, for a given set of settings. */
396
- function screen(config, settings) {
397
- const overridden = config.baseUrlSource !== 'default';
398
- return {
399
- section: 'settings',
400
- settingsUrl: SETTINGS_PATH,
401
- fields: SETTINGS_FIELDS,
402
- actorTypes: ACTOR_TYPES,
403
- avatar: settings.avatar,
404
- avatarUrl: AVATAR_PATH,
405
- avatarFields: AVATAR_FIELDS,
406
- avatarRemove: AVATAR_REMOVE,
407
- form: formFromSettings(settings),
408
- problems: {},
409
- baseUrlInEffect: effectiveBaseUrl(config, settings),
410
- baseUrlOverridden: overridden,
411
- baseUrlSource: config.baseUrlSource,
412
- baseUrlNote: overridden
413
- ? config.baseUrlSource === 'environment'
414
- ? 'GEEKITY_BASE_URL is set, so it is the base URL in effect and this field is not editable here.'
415
- : 'The config file sets baseUrl, so it is the base URL in effect and this field is not editable here.'
416
- : 'Absolute URLs, the feeds and the session cookie pick this up when the site next starts.',
417
- };
418
- }
419
- /** A form field as a string. A file upload, or a missing field, is the empty one. */
420
- function field(value) {
421
- return typeof value === 'string' ? value : '';
674
+ /**
675
+ * A relay textarea as the list of inboxes it names: normalised, in the order
676
+ * they were given, with repeats dropped.
677
+ *
678
+ * Exported because the same parsing turns a stored setting, a submitted form
679
+ * and a `site.json` array into the one list; a second spelling of it would let
680
+ * the file and the database disagree about what a site subscribes to.
681
+ */
682
+ export function relayList(value) {
683
+ const relays = [];
684
+ for (const line of relayLines(value)) {
685
+ const inbox = normalizeRelayInbox(line);
686
+ if (inbox !== undefined && !relays.includes(inbox))
687
+ relays.push(inbox);
688
+ }
689
+ return relays;
422
690
  }
423
691
  /**
424
- * A base URL, normalised the way `resolveConfig` normalises the configured
425
- * one, or `undefined` when it is not an absolute http(s) URL.
692
+ * A relay inbox URL, or `undefined` when it is not an absolute http(s) one.
693
+ *
694
+ * Unlike {@link normalizeBaseUrl} this keeps the whole URL bar a trailing
695
+ * slash: a relay inbox is a path — `/user/_____relay_____/inbox` — rather than
696
+ * an origin, and some relays hang one off a query string.
426
697
  */
427
- function normalizeBaseUrl(value) {
698
+ export function normalizeRelayInbox(value) {
428
699
  const trimmed = value.trim();
429
700
  if (trimmed === '')
430
701
  return undefined;
@@ -437,8 +708,23 @@ function normalizeBaseUrl(value) {
437
708
  }
438
709
  if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
439
710
  return undefined;
440
- const pathname = parsed.pathname.endsWith('/') ? parsed.pathname.slice(0, -1) : parsed.pathname;
441
- return parsed.origin + pathname;
711
+ const href = parsed.href;
712
+ return href.endsWith('/') && parsed.pathname !== '/' ? href.slice(0, -1) : href;
713
+ }
714
+ /**
715
+ * What a page slug may be made of on this screen: the shape
716
+ * {@link slugify} produces, which is what the editor writes.
717
+ *
718
+ * The setting travels no further than a lookup by slug, so this is about
719
+ * catching a hand-typed value rather than about safety.
720
+ */
721
+ const PAGE_SLUG_PATTERN = /^[A-Za-z0-9._~-]{1,200}$/;
722
+ /** What is wrong with one page pick, or `undefined`. Empty is a value. */
723
+ function slugProblem(value, what) {
724
+ const slug = value.trim();
725
+ return slug === '' || PAGE_SLUG_PATTERN.test(slug)
726
+ ? undefined
727
+ : `${what} is the slug of one of the site's pages, such as about.`;
442
728
  }
443
729
  /** Whether `Intl` knows the zone. An empty name is not a zone. */
444
730
  function isValidTimezone(value) {
@@ -453,18 +739,74 @@ function isValidTimezone(value) {
453
739
  return false;
454
740
  }
455
741
  }
742
+ /** One site.json as an object, empty when it is missing or will not parse. */
456
743
  function readSiteJsonSync(file) {
744
+ return parseSiteJson(readFileIfPresentSync(file) ?? '');
745
+ }
746
+ /** The same, from bytes already in hand. */
747
+ function parseSiteJson(source) {
748
+ let parsed;
457
749
  try {
458
- return parseSiteJson(readFileSync(file, 'utf8'));
750
+ parsed = JSON.parse(source);
459
751
  }
460
752
  catch {
461
753
  return {};
462
754
  }
463
- }
464
- function parseSiteJson(source) {
465
- const parsed = JSON.parse(source);
466
755
  return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
467
756
  ? parsed
468
757
  : {};
469
758
  }
759
+ /**
760
+ * The non-empty lines of the navigation textarea, trimmed.
761
+ *
762
+ * Blank lines are not an error, exactly as they are not in the relay list: a
763
+ * pasted menu leaves them, and a blank line asks for nothing.
764
+ */
765
+ function navigationLines(value) {
766
+ return value
767
+ .split('\n')
768
+ .map((line) => line.trim())
769
+ .filter((line) => line !== '');
770
+ }
771
+ /**
772
+ * One `Label | URL` line as a menu item, or `undefined` when it is not one.
773
+ *
774
+ * The split is at the first bar, so a URL holding one — a query string, say —
775
+ * survives and a label cannot hold one. The URL is either a site-root path or
776
+ * an absolute http(s) URL: a bare `about/` would be resolved against whatever
777
+ * page it was printed on, which is never what a menu means.
778
+ */
779
+ function navigationItem(line) {
780
+ const bar = line.indexOf('|');
781
+ if (bar === -1)
782
+ return undefined;
783
+ const label = line.slice(0, bar).trim();
784
+ const url = line.slice(bar + 1).trim();
785
+ if (label === '' || url === '')
786
+ return undefined;
787
+ if (url.startsWith('/'))
788
+ return { label, url };
789
+ return normalizeRelayInbox(url) === undefined ? undefined : { label, url };
790
+ }
791
+ /**
792
+ * A navigation textarea as the ordered items it names.
793
+ *
794
+ * Exported nowhere: the same parsing turns the stored setting and the
795
+ * submitted form into one list, and `site.json` is read by
796
+ * {@link navigationItemsOf} instead, because the file holds objects rather
797
+ * than lines.
798
+ */
799
+ function navigationList(value) {
800
+ const items = [];
801
+ for (const line of navigationLines(value)) {
802
+ const item = navigationItem(line);
803
+ if (item !== undefined)
804
+ items.push(item);
805
+ }
806
+ return items;
807
+ }
808
+ /** The items as the textarea shows them, and as the settings table holds them. */
809
+ function navigationText(items) {
810
+ return items.map((item) => `${item.label} | ${item.url}`).join('\n');
811
+ }
470
812
  //# sourceMappingURL=settings.js.map