@jant/core 0.6.16 → 0.7.1

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 (592) hide show
  1. package/README.md +11 -11
  2. package/bin/commands/assets/upload.js +192 -15
  3. package/bin/commands/export.js +3 -1
  4. package/bin/commands/import-site.js +749 -220
  5. package/bin/commands/reset-password.js +21 -8
  6. package/bin/commands/setup.js +136 -0
  7. package/bin/commands/site/export.js +71 -34
  8. package/bin/commands/site/pull-media.js +2 -6
  9. package/bin/commands/site/snapshot/export.js +19 -61
  10. package/bin/commands/site/snapshot/import.js +23 -24
  11. package/bin/lib/d1-query.js +92 -18
  12. package/bin/lib/hugo-markdown.js +4 -0
  13. package/bin/lib/node-env.js +40 -4
  14. package/bin/lib/site-pull-media.js +21 -28
  15. package/bin/lib/site-selection.js +94 -29
  16. package/bin/lib/site-snapshot.js +338 -3
  17. package/bin/lib/sql-export.js +68 -5
  18. package/bin/lib/wrangler-cli.js +37 -2
  19. package/bin/lib/wrangler-json.js +35 -0
  20. package/bin/lib/zip-archive.js +187 -0
  21. package/dist/{app-C7_4oab9.js → app-ZcaI1kPN.js} +53190 -13529
  22. package/dist/client/.vite/manifest.json +255 -90
  23. package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
  24. package/dist/client/_assets/chunks/create-editor-B-m7X7S5.js +261 -0
  25. package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
  26. package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
  27. package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
  28. package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
  29. package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
  30. package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
  31. package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
  32. package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
  33. package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
  34. package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
  35. package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
  36. package/dist/client/_assets/chunks/sortable-list-BJyd-LXE.js +2 -0
  37. package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
  38. package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
  39. package/dist/client/_assets/chunks/unsafe-svg-BkscJx69.js +2 -0
  40. package/dist/client/_assets/client-DAhoqPdr.css +2 -0
  41. package/dist/client/_assets/client-auth-CAMfTrKW.js +828 -0
  42. package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
  43. package/dist/client/_assets/client-compose-D6rExKVK.js +2004 -0
  44. package/dist/client/_assets/client-knSEyUJO.js +295 -0
  45. package/dist/client/_assets/client-manage-sVOkqVbH.js +2170 -0
  46. package/dist/client/_assets/client-settings-CGR_nZ78.js +1112 -0
  47. package/dist/github-sync-Gw4orAHk.js +8398 -0
  48. package/dist/index.js +2 -5
  49. package/dist/node.js +11 -9
  50. package/package.json +15 -5
  51. package/src/__tests__/auth-session-freshness.test.ts +57 -0
  52. package/src/__tests__/basecoat-variants.test.ts +121 -0
  53. package/src/__tests__/bin/assets-upload.test.ts +68 -0
  54. package/src/__tests__/dev-scripts.test.ts +203 -0
  55. package/src/__tests__/export-collection-order.test.ts +276 -0
  56. package/src/__tests__/export-feed-ids.test.ts +184 -0
  57. package/src/__tests__/export-feed-order.test.ts +294 -0
  58. package/src/__tests__/export-hugo-build.test.ts +390 -0
  59. package/src/__tests__/export-import-roundtrip.test.ts +94 -0
  60. package/src/__tests__/export-service.test.ts +842 -28
  61. package/src/__tests__/export-smart-collection.test.ts +329 -0
  62. package/src/__tests__/helpers/component-css.ts +91 -0
  63. package/src/__tests__/helpers/db.ts +33 -0
  64. package/src/__tests__/helpers/export-fixtures.ts +3 -0
  65. package/src/__tests__/helpers/hugo-site.ts +146 -0
  66. package/src/__tests__/import-site-command.test.ts +487 -1
  67. package/src/__tests__/mise-config.test.ts +75 -4
  68. package/src/__tests__/node-dev-tasks.test.ts +264 -0
  69. package/src/__tests__/site-export-canonical-import.test.ts +149 -0
  70. package/src/__tests__/snapshot-canonical-replay.test.ts +179 -0
  71. package/src/__tests__/snapshot-settings.test.ts +97 -0
  72. package/src/__tests__/snapshot-tables.test.ts +219 -0
  73. package/src/__tests__/sql-export.test.ts +173 -0
  74. package/src/__tests__/stylesheet-audience.test.ts +159 -0
  75. package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
  76. package/src/__tests__/zip-archive.test.ts +106 -0
  77. package/src/app.tsx +86 -22
  78. package/src/auth.ts +18 -1
  79. package/src/client/__tests__/collection-page-actions.test.ts +51 -2
  80. package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
  81. package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
  82. package/src/client/__tests__/compose-bridge.test.ts +272 -0
  83. package/src/client/__tests__/compose-launch.test.ts +62 -1
  84. package/src/client/__tests__/compose-shortcuts.test.ts +38 -9
  85. package/src/client/__tests__/compose-triggers.test.ts +90 -0
  86. package/src/client/__tests__/copy-field.test.ts +162 -0
  87. package/src/client/__tests__/custom-url-menu.test.ts +127 -6
  88. package/src/client/__tests__/image-processor.test.ts +324 -2
  89. package/src/client/__tests__/lazy-entries.test.ts +92 -0
  90. package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
  91. package/src/client/__tests__/runtime-paths.test.ts +84 -0
  92. package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
  93. package/src/client/__tests__/toast.test.ts +23 -0
  94. package/src/client/__tests__/video-processor-probe.test.ts +175 -0
  95. package/src/client/__tests__/video-processor.test.ts +181 -0
  96. package/src/client/audio-processor.ts +24 -15
  97. package/src/client/audio-waveform.ts +49 -0
  98. package/src/client/collection-dialog-host.ts +72 -0
  99. package/src/client/collection-navigation.ts +51 -16
  100. package/src/client/collection-page-actions.ts +26 -0
  101. package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
  102. package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
  103. package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
  104. package/src/client/components/__tests__/jant-command-palette.test.ts +28 -0
  105. package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
  106. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1250 -53
  107. package/src/client/components/__tests__/jant-compose-editor.test.ts +544 -40
  108. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +0 -1
  109. package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
  110. package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
  111. package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
  112. package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
  113. package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
  114. package/src/client/components/__tests__/jant-settings-general.test.ts +561 -172
  115. package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
  116. package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
  117. package/src/client/components/collection-dialog-types.ts +49 -0
  118. package/src/client/components/collection-manager-types.ts +32 -1
  119. package/src/client/components/collection-types.ts +1 -15
  120. package/src/client/components/compose-types.ts +75 -1
  121. package/src/client/components/jant-collection-dialog.ts +722 -0
  122. package/src/client/components/jant-collection-directory.ts +567 -182
  123. package/src/client/components/jant-collection-form.ts +53 -232
  124. package/src/client/components/jant-command-palette.ts +100 -49
  125. package/src/client/components/jant-compose-dialog.ts +1486 -707
  126. package/src/client/components/jant-compose-editor.ts +673 -336
  127. package/src/client/components/jant-compose-fullscreen.ts +51 -43
  128. package/src/client/components/jant-config-editor.ts +65 -53
  129. package/src/client/components/jant-confirm-dialog.ts +13 -11
  130. package/src/client/components/jant-locale-picker.ts +418 -0
  131. package/src/client/components/jant-media-lightbox.ts +146 -136
  132. package/src/client/components/jant-nav-manager.ts +690 -366
  133. package/src/client/components/jant-post-menu.ts +1086 -282
  134. package/src/client/components/jant-post-picker.ts +324 -0
  135. package/src/client/components/jant-repo-picker-types.ts +6 -1
  136. package/src/client/components/jant-repo-picker.ts +137 -108
  137. package/src/client/components/jant-settings-avatar.ts +31 -27
  138. package/src/client/components/jant-settings-general.ts +622 -543
  139. package/src/client/components/jant-settings-language.ts +1087 -0
  140. package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
  141. package/src/client/components/jant-text-preview.ts +94 -86
  142. package/src/client/components/nav-manager-types.ts +62 -1
  143. package/src/client/components/settings-types.ts +14 -29
  144. package/src/client/components/smart-collection-conditions.ts +285 -0
  145. package/src/client/components/smart-collection-dialog-types.ts +68 -0
  146. package/src/client/compose-bridge.ts +214 -141
  147. package/src/client/compose-launch.ts +76 -2
  148. package/src/client/compose-shortcuts.ts +6 -8
  149. package/src/client/compose-triggers.ts +50 -0
  150. package/src/client/copy-field.ts +108 -0
  151. package/src/client/custom-url-menu.ts +60 -4
  152. package/src/client/icons.ts +14 -0
  153. package/src/client/image-plan.ts +165 -0
  154. package/src/client/image-processor.ts +195 -117
  155. package/src/client/image-worker-client.ts +45 -0
  156. package/src/client/image-worker.ts +135 -0
  157. package/src/client/lazy-entries.ts +95 -0
  158. package/src/client/lazy-slugify.ts +2 -2
  159. package/src/client/media-scroll-hint.ts +43 -2
  160. package/src/client/mediabunny.ts +29 -0
  161. package/src/client/post-picker.ts +41 -0
  162. package/src/client/runtime-paths.ts +74 -1
  163. package/src/client/settings-bridge.ts +4 -10
  164. package/src/client/site-header-nav.js +18 -6
  165. package/src/client/smart-collection-dialog-host.ts +116 -0
  166. package/src/client/smart-collection-page-actions.ts +208 -0
  167. package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
  168. package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
  169. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
  170. package/src/client/tiptap/__tests__/list-editing.test.ts +224 -86
  171. package/src/client/tiptap/__tests__/mark-exit.test.ts +1 -2
  172. package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +26 -0
  173. package/src/client/tiptap/bubble-menu.ts +47 -16
  174. package/src/client/tiptap/extensions.ts +0 -3
  175. package/src/client/tiptap/floating-position.ts +216 -2
  176. package/src/client/tiptap/inline-image-upload.ts +7 -3
  177. package/src/client/tiptap/link-toolbar.ts +22 -9
  178. package/src/client/tiptap/structural-keymap.ts +158 -47
  179. package/src/client/toast.ts +13 -0
  180. package/src/client/types/vite-worker.d.ts +12 -0
  181. package/src/client/upload-with-metadata.ts +3 -13
  182. package/src/client/video-processor.ts +351 -107
  183. package/src/client-auth.ts +14 -31
  184. package/src/client-compose.ts +19 -0
  185. package/src/client-manage.ts +13 -0
  186. package/src/client-settings.ts +14 -0
  187. package/src/client-site.ts +10 -3
  188. package/src/client.ts +4 -0
  189. package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
  190. package/src/db/__tests__/d1-query.test.ts +56 -1
  191. package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
  192. package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
  193. package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
  194. package/src/db/migrations/0032_married_tomas.sql +3 -0
  195. package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
  196. package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
  197. package/src/db/migrations/meta/0032_snapshot.json +2582 -0
  198. package/src/db/migrations/meta/0033_snapshot.json +2816 -0
  199. package/src/db/migrations/meta/0034_snapshot.json +2816 -0
  200. package/src/db/migrations/meta/_journal.json +21 -0
  201. package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
  202. package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
  203. package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
  204. package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
  205. package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
  206. package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
  207. package/src/db/migrations/pg/meta/_journal.json +21 -0
  208. package/src/db/pg/schema.ts +195 -27
  209. package/src/db/post-visibility.ts +117 -0
  210. package/src/db/schema.ts +222 -27
  211. package/src/i18n/__tests__/detect.test.ts +26 -81
  212. package/src/i18n/__tests__/supported-locales.test.ts +83 -0
  213. package/src/i18n/context.tsx +12 -2
  214. package/src/i18n/detect.ts +7 -66
  215. package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
  216. package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
  217. package/src/i18n/locales/public/en.po +490 -158
  218. package/src/i18n/locales/public/en.ts +1 -1
  219. package/src/i18n/locales/public/zh-Hans.po +487 -155
  220. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  221. package/src/i18n/locales/public/zh-Hant.po +487 -155
  222. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  223. package/src/i18n/locales/settings/en.po +555 -73
  224. package/src/i18n/locales/settings/en.ts +1 -1
  225. package/src/i18n/locales/settings/zh-Hans.po +601 -119
  226. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  227. package/src/i18n/locales/settings/zh-Hant.po +614 -132
  228. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  229. package/src/i18n/locales.ts +94 -0
  230. package/src/i18n/middleware.ts +12 -2
  231. package/src/i18n/supported-locales.ts +80 -0
  232. package/src/lib/__tests__/api-settings.test.ts +11 -2
  233. package/src/lib/__tests__/asset-path.test.ts +92 -0
  234. package/src/lib/__tests__/deferred.test.ts +90 -0
  235. package/src/lib/__tests__/discover-ping.test.ts +89 -0
  236. package/src/lib/__tests__/discover.test.ts +410 -0
  237. package/src/lib/__tests__/feed-policy.test.ts +26 -0
  238. package/src/lib/__tests__/feed.test.ts +1526 -15
  239. package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
  240. package/src/lib/__tests__/github-sync-repo-name.test.ts +40 -0
  241. package/src/lib/__tests__/hosted-signin.test.ts +1 -1
  242. package/src/lib/__tests__/http-cache.test.ts +162 -0
  243. package/src/lib/__tests__/image.test.ts +27 -1
  244. package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
  245. package/src/lib/__tests__/jant-docs.test.ts +23 -0
  246. package/src/lib/__tests__/lang-detect.test.ts +231 -0
  247. package/src/lib/__tests__/markdown-to-tiptap.test.ts +105 -0
  248. package/src/lib/__tests__/markdown.test.ts +10 -0
  249. package/src/lib/__tests__/media-helpers.test.ts +81 -0
  250. package/src/lib/__tests__/navigation.test.ts +135 -1
  251. package/src/lib/__tests__/resolve-config.test.ts +131 -3
  252. package/src/lib/__tests__/schemas.test.ts +69 -5
  253. package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
  254. package/src/lib/__tests__/slug.test.ts +70 -0
  255. package/src/lib/__tests__/slugify.test.ts +85 -0
  256. package/src/lib/__tests__/storage.test.ts +32 -0
  257. package/src/lib/__tests__/thread-fold.test.ts +113 -0
  258. package/src/lib/__tests__/timeline.test.ts +87 -0
  259. package/src/lib/__tests__/tiptap-to-markdown.test.ts +172 -4
  260. package/src/lib/__tests__/upload-sideload.test.ts +14 -0
  261. package/src/lib/__tests__/url-fetch.test.ts +1 -2
  262. package/src/lib/__tests__/url.test.ts +78 -72
  263. package/src/lib/__tests__/view-language.test.ts +413 -0
  264. package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
  265. package/src/lib/address.ts +57 -0
  266. package/src/lib/api-posts.ts +12 -0
  267. package/src/lib/asset-path.ts +98 -1
  268. package/src/lib/build-env.ts +16 -0
  269. package/src/lib/collection-groups.ts +5 -1
  270. package/src/lib/collection-paths.ts +3 -10
  271. package/src/lib/constants.ts +28 -3
  272. package/src/lib/copy-field.ts +36 -0
  273. package/src/lib/crypto.ts +40 -0
  274. package/src/lib/csp-builder.ts +2 -1
  275. package/src/lib/deferred.ts +79 -0
  276. package/src/lib/discover-ping.ts +141 -0
  277. package/src/lib/discover.ts +439 -0
  278. package/src/lib/env.ts +134 -0
  279. package/src/lib/errors.ts +33 -0
  280. package/src/lib/feed-policy.ts +236 -2
  281. package/src/lib/feed.ts +926 -73
  282. package/src/lib/filter-dimensions.ts +1349 -0
  283. package/src/lib/github-sync-repo-name.ts +45 -0
  284. package/src/lib/github-sync-site-config.ts +3 -0
  285. package/src/lib/hosted-signin.ts +1 -1
  286. package/src/lib/http-cache.ts +148 -0
  287. package/src/lib/hugo-markdown.ts +62 -0
  288. package/src/lib/icons.ts +9 -2
  289. package/src/lib/ids.ts +3 -0
  290. package/src/lib/image.ts +17 -4
  291. package/src/lib/jant-docs.ts +42 -0
  292. package/src/lib/lang-detect.ts +308 -0
  293. package/src/lib/markdown-manager.ts +392 -2
  294. package/src/lib/media-helpers.ts +13 -1
  295. package/src/lib/navigation.ts +78 -11
  296. package/src/lib/pagination.ts +28 -24
  297. package/src/lib/per-language-surfaces.ts +67 -0
  298. package/src/lib/post-body-html.ts +11 -4
  299. package/src/lib/post-meta.ts +19 -0
  300. package/src/lib/render.tsx +55 -7
  301. package/src/lib/resolve-config.ts +111 -6
  302. package/src/lib/schemas.ts +251 -15
  303. package/src/lib/site-header-fragment.tsx +2 -1
  304. package/src/lib/site-unavailable-page.ts +47 -0
  305. package/src/lib/slug.ts +51 -7
  306. package/src/lib/slugify.ts +113 -0
  307. package/src/lib/startup-config.ts +4 -6
  308. package/src/lib/storage.ts +7 -0
  309. package/src/lib/summary.ts +50 -0
  310. package/src/lib/thread-fold.ts +161 -0
  311. package/src/lib/time.ts +32 -0
  312. package/src/lib/timeline.ts +43 -2
  313. package/src/lib/tiptap-render.ts +1 -2
  314. package/src/lib/tiptap-to-markdown.ts +13 -7
  315. package/src/lib/upload.ts +16 -13
  316. package/src/lib/url.ts +159 -86
  317. package/src/lib/version.ts +10 -8
  318. package/src/lib/view-language.ts +436 -0
  319. package/src/lib/view.ts +53 -25
  320. package/src/lib/viewer-context.ts +62 -0
  321. package/src/middleware/__tests__/auth.test.ts +70 -2
  322. package/src/middleware/__tests__/cache-control.test.ts +71 -3
  323. package/src/middleware/__tests__/onboarding.test.ts +76 -5
  324. package/src/middleware/__tests__/secure-headers.test.ts +1 -1
  325. package/src/middleware/__tests__/session.test.ts +181 -33
  326. package/src/middleware/auth.ts +50 -31
  327. package/src/middleware/cache-control.ts +75 -10
  328. package/src/middleware/config.ts +6 -6
  329. package/src/middleware/error-handler.ts +12 -0
  330. package/src/middleware/onboarding.ts +32 -10
  331. package/src/middleware/secure-headers.ts +1 -1
  332. package/src/middleware/session.ts +96 -8
  333. package/src/node/__tests__/cli-migrate.test.ts +21 -19
  334. package/src/node/__tests__/cli-reset-password.test.ts +81 -8
  335. package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
  336. package/src/node/__tests__/cli-setup.test.ts +163 -0
  337. package/src/node/__tests__/cli-site-selection.test.ts +128 -38
  338. package/src/node/__tests__/cli-site-snapshot.test.ts +67 -0
  339. package/src/node/__tests__/cli-snapshot-meta.test.ts +20 -0
  340. package/src/node/__tests__/runtime.test.ts +75 -0
  341. package/src/node/index.ts +3 -0
  342. package/src/node/request-handler.ts +3 -1
  343. package/src/preset.css +76 -22
  344. package/src/routes/__tests__/feed-validator.test.ts +156 -0
  345. package/src/routes/api/__tests__/collections.test.ts +114 -0
  346. package/src/routes/api/__tests__/discover.test.ts +222 -0
  347. package/src/routes/api/__tests__/nav-items.test.ts +212 -2
  348. package/src/routes/api/__tests__/post-translations.test.ts +829 -0
  349. package/src/routes/api/__tests__/posts.test.ts +24 -0
  350. package/src/routes/api/__tests__/settings.test.ts +24 -3
  351. package/src/routes/api/__tests__/telegram.test.ts +6 -5
  352. package/src/routes/api/__tests__/upload.test.ts +34 -0
  353. package/src/routes/api/collections.ts +49 -6
  354. package/src/routes/api/custom-urls.ts +0 -1
  355. package/src/routes/api/discover.ts +95 -0
  356. package/src/routes/api/export.ts +6 -3
  357. package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
  358. package/src/routes/api/internal/sites.ts +0 -1
  359. package/src/routes/api/nav-items.ts +55 -8
  360. package/src/routes/api/posts.ts +161 -0
  361. package/src/routes/api/public/__tests__/archive.test.ts +111 -0
  362. package/src/routes/api/public/archive.ts +77 -81
  363. package/src/routes/api/public/posts.ts +44 -7
  364. package/src/routes/api/smart-collections.ts +132 -0
  365. package/src/routes/api/telegram.ts +2 -40
  366. package/src/routes/api/upload.ts +10 -3
  367. package/src/routes/api/uploads.ts +1 -2
  368. package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
  369. package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
  370. package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
  371. package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
  372. package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
  373. package/src/routes/auth/__tests__/setup.test.ts +278 -26
  374. package/src/routes/auth/__tests__/signin.test.ts +135 -0
  375. package/src/routes/auth/setup.tsx +934 -186
  376. package/src/routes/auth/signin.tsx +10 -8
  377. package/src/routes/compose.tsx +9 -0
  378. package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
  379. package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
  380. package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
  381. package/src/routes/dash/__tests__/github-sync-app.test.ts +365 -0
  382. package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
  383. package/src/routes/dash/custom-urls.tsx +1 -37
  384. package/src/routes/dash/settings.tsx +714 -111
  385. package/src/routes/discover-announce.ts +96 -0
  386. package/src/routes/feed/__tests__/feed.test.ts +165 -2
  387. package/src/routes/feed/feed.ts +69 -40
  388. package/src/routes/feed/sitemap.ts +95 -11
  389. package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
  390. package/src/routes/pages/__tests__/archive-params.test.ts +370 -6
  391. package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
  392. package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
  393. package/src/routes/pages/__tests__/collections.test.ts +9 -3
  394. package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
  395. package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
  396. package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
  397. package/src/routes/pages/__tests__/post-page-round-trips.test.ts +211 -0
  398. package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
  399. package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
  400. package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
  401. package/src/routes/pages/__tests__/thread-order.test.ts +179 -0
  402. package/src/routes/pages/archive.tsx +487 -379
  403. package/src/routes/pages/collection.tsx +94 -96
  404. package/src/routes/pages/collections.tsx +38 -50
  405. package/src/routes/pages/featured.tsx +43 -18
  406. package/src/routes/pages/home.tsx +25 -6
  407. package/src/routes/pages/language.tsx +113 -0
  408. package/src/routes/pages/latest.tsx +26 -16
  409. package/src/routes/pages/new.tsx +3 -0
  410. package/src/routes/pages/page.tsx +228 -45
  411. package/src/routes/pages/partials.tsx +41 -0
  412. package/src/routes/pages/search.tsx +22 -5
  413. package/src/routes/pages/smart-collection.tsx +340 -0
  414. package/src/routes/pages/subscribe.tsx +182 -0
  415. package/src/runtime/__tests__/index.test.ts +19 -0
  416. package/src/runtime/__tests__/node.test.ts +129 -4
  417. package/src/runtime/__tests__/readiness.test.ts +23 -0
  418. package/src/runtime/cloudflare.ts +21 -2
  419. package/src/runtime/index.ts +27 -1
  420. package/src/runtime/node.ts +67 -1
  421. package/src/runtime/readiness.ts +15 -0
  422. package/src/runtime/site.ts +95 -24
  423. package/src/services/__tests__/auth.test.ts +116 -7
  424. package/src/services/__tests__/bootstrap-setup-instance.test.ts +230 -0
  425. package/src/services/__tests__/collection.test.ts +108 -4
  426. package/src/services/__tests__/custom-url.test.ts +34 -0
  427. package/src/services/__tests__/github-app-installations.test.ts +153 -0
  428. package/src/services/__tests__/github-sync-push.test.ts +46 -0
  429. package/src/services/__tests__/language.test.ts +495 -0
  430. package/src/services/__tests__/media.test.ts +31 -0
  431. package/src/services/__tests__/navigation.test.ts +213 -10
  432. package/src/services/__tests__/path.test.ts +236 -0
  433. package/src/services/__tests__/post-language.test.ts +830 -0
  434. package/src/services/__tests__/post-timeline.test.ts +127 -0
  435. package/src/services/__tests__/post.test.ts +120 -9
  436. package/src/services/__tests__/settings.test.ts +228 -17
  437. package/src/services/__tests__/site-admin.test.ts +5 -5
  438. package/src/services/__tests__/smart-collection.test.ts +651 -0
  439. package/src/services/about-page.ts +22 -1
  440. package/src/services/auth.ts +51 -49
  441. package/src/services/bootstrap.ts +408 -39
  442. package/src/services/collection-directory-position.ts +76 -0
  443. package/src/services/collection.ts +331 -46
  444. package/src/services/custom-url.ts +29 -24
  445. package/src/services/export-theme/assets/client-site.css +1 -1
  446. package/src/services/export-theme/assets/client-site.js +123 -123
  447. package/src/services/export-theme/layouts/_default/alias.html +27 -1
  448. package/src/services/export-theme/layouts/_default/list.html +2 -63
  449. package/src/services/export-theme/layouts/_default/rss.xml +103 -38
  450. package/src/services/export-theme/layouts/collections/list.html +3 -3
  451. package/src/services/export-theme/layouts/featured/list.html +1 -4
  452. package/src/services/export-theme/layouts/index.html +40 -26
  453. package/src/services/export-theme/layouts/partials/collection-members.html +100 -0
  454. package/src/services/export-theme/layouts/partials/collection-threads.html +33 -0
  455. package/src/services/export-theme/layouts/partials/featured-members.html +35 -0
  456. package/src/services/export-theme/layouts/partials/featured-thread.html +1 -1
  457. package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
  458. package/src/services/export-theme/layouts/partials/footer.html +1 -1
  459. package/src/services/export-theme/layouts/partials/head.html +1 -1
  460. package/src/services/export-theme/layouts/partials/header.html +5 -3
  461. package/src/services/export-theme/layouts/partials/jant-data.html +23 -0
  462. package/src/services/export-theme/layouts/partials/latest-members.html +48 -0
  463. package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
  464. package/src/services/export-theme/layouts/partials/pagination.html +14 -13
  465. package/src/services/export-theme/layouts/partials/post-card.html +8 -4
  466. package/src/services/export-theme/layouts/partials/reply.html +2 -1
  467. package/src/services/export-theme/layouts/partials/smart-collection-members.html +129 -0
  468. package/src/services/export-theme/layouts/partials/thread-preview.html +41 -31
  469. package/src/services/export-theme/layouts/post/list.html +1 -1
  470. package/src/services/export-theme/layouts/smart_collection/list.html +24 -0
  471. package/src/services/export-theme/styles/main.css +13 -2
  472. package/src/services/export-theme/theme.toml +1 -1
  473. package/src/services/export.ts +880 -72
  474. package/src/services/github-app-installations.ts +171 -4
  475. package/src/services/github-sync.ts +69 -15
  476. package/src/services/index.ts +34 -3
  477. package/src/services/language.ts +412 -0
  478. package/src/services/mcp.ts +22 -2
  479. package/src/services/media.ts +22 -7
  480. package/src/services/navigation.ts +429 -67
  481. package/src/services/path.ts +254 -6
  482. package/src/services/post.ts +1234 -190
  483. package/src/services/search.ts +39 -2
  484. package/src/services/settings.ts +238 -43
  485. package/src/services/site-admin.ts +27 -14
  486. package/src/services/site.ts +160 -32
  487. package/src/services/smart-collection.ts +636 -0
  488. package/src/services/telegram.ts +20 -22
  489. package/src/services/upload-session.ts +1 -3
  490. package/src/style-author.css +27 -0
  491. package/src/styles/components-author.css +1622 -0
  492. package/src/styles/components.css +24 -1571
  493. package/src/styles/site-media.css +16 -2
  494. package/src/styles/tokens.css +82 -12
  495. package/src/styles/ui-author.css +7602 -0
  496. package/src/styles/ui.css +1090 -7216
  497. package/src/types/app-context.ts +23 -0
  498. package/src/types/bindings.ts +8 -0
  499. package/src/types/config.ts +94 -15
  500. package/src/types/constants.ts +188 -16
  501. package/src/types/entities.ts +87 -1
  502. package/src/types/operations.ts +74 -2
  503. package/src/types/props.ts +157 -31
  504. package/src/types/views.ts +55 -1
  505. package/src/ui/__tests__/font-themes.test.ts +52 -12
  506. package/src/ui/compose/ComposeDialog.tsx +201 -7
  507. package/src/ui/compose/ComposePrompt.tsx +1 -5
  508. package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
  509. package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
  510. package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
  511. package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
  512. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
  513. package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
  514. package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
  515. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  516. package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
  517. package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
  518. package/src/ui/dash/settings/GeneralContent.tsx +277 -113
  519. package/src/ui/dash/settings/LanguageContent.tsx +409 -0
  520. package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
  521. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
  522. package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
  523. package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
  524. package/src/ui/dash/settings/discover-copy.ts +116 -0
  525. package/src/ui/feed/LinkCard.tsx +55 -45
  526. package/src/ui/feed/NoteCard.tsx +9 -4
  527. package/src/ui/feed/PostStatusBadges.tsx +90 -43
  528. package/src/ui/feed/QuoteCard.tsx +55 -30
  529. package/src/ui/feed/ThreadPreview.tsx +6 -2
  530. package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
  531. package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
  532. package/src/ui/feed/thread-preview-state.ts +20 -6
  533. package/src/ui/font-themes.ts +39 -29
  534. package/src/ui/layouts/BaseLayout.tsx +155 -10
  535. package/src/ui/layouts/SiteLayout.tsx +192 -34
  536. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
  537. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
  538. package/src/ui/pages/ArchivePage.tsx +282 -289
  539. package/src/ui/pages/CollectionPage.tsx +86 -76
  540. package/src/ui/pages/CollectionsPage.tsx +5 -0
  541. package/src/ui/pages/ComposePage.tsx +8 -0
  542. package/src/ui/pages/PostPage.tsx +47 -0
  543. package/src/ui/pages/SearchPage.tsx +3 -3
  544. package/src/ui/pages/SmartCollectionPage.tsx +434 -0
  545. package/src/ui/pages/SubscribePage.tsx +236 -0
  546. package/src/ui/pages/__tests__/ArchivePage.test.tsx +65 -9
  547. package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
  548. package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
  549. package/src/ui/shared/CollectionDirectory.tsx +178 -3
  550. package/src/ui/shared/CollectionsManager.tsx +70 -17
  551. package/src/ui/shared/CopyField.tsx +127 -0
  552. package/src/ui/shared/EmptyPostContent.tsx +23 -0
  553. package/src/ui/shared/MediaGallery.tsx +125 -46
  554. package/src/ui/shared/Pagination.tsx +20 -5
  555. package/src/ui/shared/PostFooter.tsx +20 -11
  556. package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
  557. package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
  558. package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
  559. package/src/ui/shared/__tests__/pagination.test.ts +38 -24
  560. package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
  561. package/src/ui/shared/archive-labels.ts +98 -0
  562. package/src/ui/shared/collection-management-labels.ts +94 -16
  563. package/src/ui/shared/navigation-labels.ts +45 -71
  564. package/src/ui/shared/post-article-attributes.ts +2 -0
  565. package/src/ui/shared/smart-collection-labels.ts +343 -0
  566. package/dist/app-C75Zc8dD.js +0 -6
  567. package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
  568. package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
  569. package/dist/client/_assets/chunks/url-CU9pEbk5.js +0 -1
  570. package/dist/client/_assets/client-Bq0vre8Y.js +0 -296
  571. package/dist/client/_assets/client-D3VZyG4L.css +0 -2
  572. package/dist/client/_assets/client-auth-F2rb9WfF.js +0 -5571
  573. package/dist/env-BPYViDJJ.js +0 -281
  574. package/dist/export-Dl5KCiEs.js +0 -5562
  575. package/dist/github-api-BNF35Lkx.js +0 -176
  576. package/dist/github-app-DdiD-bC4.js +0 -275
  577. package/dist/github-sync-Bqg62IvV.js +0 -4
  578. package/dist/github-sync-HdK2EgvJ.js +0 -431
  579. package/dist/url-CbLAtlZe.js +0 -900
  580. package/src/client/__tests__/collection-created-notice.test.ts +0 -36
  581. package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
  582. package/src/client/__tests__/post-form-bridge.test.ts +0 -82
  583. package/src/client/collection-created-notice.ts +0 -66
  584. package/src/client/collection-form-bridge.ts +0 -191
  585. package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
  586. package/src/client/components/jant-post-form.ts +0 -422
  587. package/src/client/components/post-form-template.ts +0 -352
  588. package/src/client/components/post-form-types.ts +0 -96
  589. package/src/client/media-metadata.ts +0 -247
  590. package/src/client/post-form-bridge.ts +0 -193
  591. package/src/client/tiptap/exitable-marks.ts +0 -73
  592. package/src/ui/pages/CollectionEditorPage.tsx +0 -175
@@ -29,7 +29,7 @@ import {
29
29
  } from "../lib/jant-branding.js";
30
30
  import { getRootActivityAt } from "../db/thread-activity.js";
31
31
  import { tiptapJsonToMarkdown } from "../lib/tiptap-to-markdown.js";
32
- import { extractBodyText } from "../lib/summary.js";
32
+ import { extractBodyText, extractTimelineSummary } from "../lib/summary.js";
33
33
  import { getMediaUrl, getPublicUrlForProvider } from "../lib/image.js";
34
34
  import { render as renderMarkdown } from "../lib/markdown.js";
35
35
  import { formatRelativeAge, toISOString } from "../lib/time.js";
@@ -37,6 +37,7 @@ import {
37
37
  formatFrontMatter,
38
38
  type HugoCollectionRef,
39
39
  type HugoFrontMatter,
40
+ type HugoSmartCollectionSelection,
40
41
  type JantMedia,
41
42
  } from "../lib/hugo-markdown.js";
42
43
  // Shared design tokens — single source of truth for colors, typography,
@@ -69,6 +70,8 @@ import LAYOUT_FEATURED_LIST from "./export-theme/layouts/featured/list.html?raw"
69
70
  import LAYOUT_ARCHIVE_LIST from "./export-theme/layouts/archive/list.html?raw";
70
71
  import LAYOUT_COLLECTIONS_LIST from "./export-theme/layouts/collections/list.html?raw";
71
72
  import LAYOUT_COLLECTION_SINGLE from "./export-theme/layouts/collection/single.html?raw";
73
+ import LAYOUT_SMART_COLLECTION_LIST from "./export-theme/layouts/smart_collection/list.html?raw";
74
+ import PARTIAL_JANT_DATA from "./export-theme/layouts/partials/jant-data.html?raw";
72
75
  import PARTIAL_HEAD from "./export-theme/layouts/partials/head.html?raw";
73
76
  import PARTIAL_HEADER from "./export-theme/layouts/partials/header.html?raw";
74
77
  import PARTIAL_FOOTER from "./export-theme/layouts/partials/footer.html?raw";
@@ -78,31 +81,98 @@ import PARTIAL_MEDIA_GALLERY from "./export-theme/layouts/partials/media-gallery
78
81
  import PARTIAL_REPLY from "./export-theme/layouts/partials/reply.html?raw";
79
82
  import PARTIAL_THREAD_PREVIEW from "./export-theme/layouts/partials/thread-preview.html?raw";
80
83
  import PARTIAL_FEATURED_THREAD from "./export-theme/layouts/partials/featured-thread.html?raw";
84
+ import PARTIAL_SMART_COLLECTION_MEMBERS from "./export-theme/layouts/partials/smart-collection-members.html?raw";
85
+ import PARTIAL_COLLECTION_THREADS from "./export-theme/layouts/partials/collection-threads.html?raw";
86
+ import PARTIAL_COLLECTION_MEMBERS from "./export-theme/layouts/partials/collection-members.html?raw";
87
+ import PARTIAL_LATEST_MEMBERS from "./export-theme/layouts/partials/latest-members.html?raw";
88
+ import PARTIAL_FEATURED_MEMBERS from "./export-theme/layouts/partials/featured-members.html?raw";
81
89
  import LAYOUT_RSS from "./export-theme/layouts/_default/rss.xml?raw";
82
90
  import PARTIAL_FEED_POST_CONTENT from "./export-theme/layouts/partials/feed-post-content.xml?raw";
83
91
 
92
+ import { suggestSyncRepoName } from "../lib/github-sync-repo-name.js";
93
+ import { getPostPath, toAbsoluteSiteUrl } from "../lib/url.js";
84
94
  import type { StorageDriver } from "../lib/storage.js";
85
95
  import { base64ToUint8Array } from "../lib/favicon.js";
96
+ import { makeZip } from "client-zip";
86
97
  import {
87
98
  SYSTEM_NAV_KEYS,
99
+ isFeedNavKey,
88
100
  type Collection,
89
101
  type Media,
90
102
  type NavItem,
103
+ type PathRecord,
91
104
  type Post,
105
+ type SmartCollection,
92
106
  type SystemNavKey,
93
107
  } from "../types.js";
94
108
 
95
- /** A file entry in the exported Hugo site. */
96
- export interface ExportFile {
109
+ /** A file of the exported Hugo site whose text or bytes the export holds. */
110
+ export interface ExportContentFile {
97
111
  path: string;
98
112
  content: string | Uint8Array;
113
+ /**
114
+ * Scaffolding the destination owns once it exists. A fresh export always
115
+ * carries it — the ZIP is a new tree, and `site export --directory` refuses
116
+ * a non-empty directory — but GitHub Sync writes it only when the
117
+ * repository does not have it yet, so an edit there survives every later
118
+ * push. Deploy config is the case that needs this: the Worker name has to
119
+ * be corrected by hand when it does not match the Worker already serving
120
+ * the domain.
121
+ */
122
+ scaffoldOnce?: boolean;
123
+ }
124
+
125
+ /**
126
+ * A media file of the exported site, named but not read: the archive streams
127
+ * it from storage as it is written. Reading every bundled file up front held a
128
+ * site's media in memory, and on a Docker site with 3 GB of local media the
129
+ * container was killed before it answered.
130
+ */
131
+ export interface ExportStoredFile {
132
+ path: string;
133
+ storageKey: string;
134
+ }
135
+
136
+ /** A file entry in the exported Hugo site. */
137
+ export type ExportFile = ExportContentFile | ExportStoredFile;
138
+
139
+ /**
140
+ * @param file - An export file entry
141
+ * @returns Whether its bytes are still in storage
142
+ * @example
143
+ * if (isStoredExportFile(file)) await readStoredExportFile(file, storage);
144
+ */
145
+ export function isStoredExportFile(file: ExportFile): file is ExportStoredFile {
146
+ return "storageKey" in file;
147
+ }
148
+
149
+ /**
150
+ * Read a stored export file's bytes, for a consumer that needs them whole.
151
+ *
152
+ * @param file - A stored export file
153
+ * @param storage - The site's storage
154
+ * @returns The bytes, or null when the object is gone
155
+ * @example
156
+ * const bytes = await readStoredExportFile(file, storage);
157
+ */
158
+ export async function readStoredExportFile(
159
+ file: ExportStoredFile,
160
+ storage: StorageDriver,
161
+ ): Promise<Uint8Array | null> {
162
+ const object = await storage.get(file.storageKey);
163
+ if (!object?.body) return null;
164
+ return new Uint8Array(await new Response(object.body).arrayBuffer());
99
165
  }
100
166
 
101
167
  export interface ExportService {
102
168
  /** Generate a flat list of files for a complete Hugo site. */
103
169
  generateHugoFiles(): Promise<ExportFile[]>;
104
- /** Generate a ZIP archive of the Hugo site. */
105
- generateHugoSite(): Promise<Uint8Array>;
170
+ /**
171
+ * The Hugo site as a ZIP stream. Stored media is read from storage one file
172
+ * at a time as the stream is consumed, so memory stays flat whatever the
173
+ * size of the site; the archive switches to ZIP64 past 4 GiB.
174
+ */
175
+ generateHugoSite(): Promise<ReadableStream<Uint8Array>>;
106
176
  }
107
177
 
108
178
  export interface SiteConfig {
@@ -110,6 +180,10 @@ export interface SiteConfig {
110
180
  siteUrl: string;
111
181
  siteDescription: string;
112
182
  siteLanguage: string;
183
+ /** Whether the source site serves per-language browsing views. */
184
+ multilingualEnabled: boolean;
185
+ /** Non-primary content languages, in switcher order. */
186
+ additionalLanguages: readonly string[];
113
187
  showJantBrandingOnHome: boolean;
114
188
  /** Whether anonymous JSON reads are enabled on the source Jant site. */
115
189
  publicApiEnabled: boolean;
@@ -117,6 +191,8 @@ export interface SiteConfig {
117
191
  rssFeedsEnabled: boolean;
118
192
  /** "latest" or "featured" — drives the default RSS nav link in the exported site. */
119
193
  mainRssFeed: string;
194
+ /** "list" or "grid" — the layout the exported archive page opens with. */
195
+ archiveDefaultLayout: string;
120
196
  siteFooter: string;
121
197
  showHeaderAvatar: boolean;
122
198
  siteAvatarUrl: string;
@@ -137,7 +213,16 @@ export interface SiteConfig {
137
213
  sitePathPrefix?: string;
138
214
  navItems: Pick<
139
215
  NavItem,
140
- "type" | "systemKey" | "label" | "url" | "position" | "placement"
216
+ | "type"
217
+ | "systemKey"
218
+ | "label"
219
+ | "targetTitle"
220
+ | "url"
221
+ | "position"
222
+ | "placement"
223
+ | "collectionId"
224
+ | "smartCollectionId"
225
+ | "postId"
141
226
  >[];
142
227
  /** Items per page for Hugo pagination — kept in sync with the main site's PAGE_SIZE. */
143
228
  pageSize: number;
@@ -161,6 +246,16 @@ type ExportedCollectionDirectoryItem =
161
246
  recentActivityLabel?: string | null;
162
247
  recentActivityIso?: string | null;
163
248
  }
249
+ | {
250
+ type: "smart_collection";
251
+ sequence: string;
252
+ slug: string;
253
+ title: string;
254
+ descriptionHtml?: string | null;
255
+ entryCount?: number;
256
+ recentActivityLabel?: string | null;
257
+ recentActivityIso?: string | null;
258
+ }
164
259
  | {
165
260
  type: "divider";
166
261
  label: string | null;
@@ -170,12 +265,14 @@ type ExportedCollectionDirectoryItem =
170
265
  sequence: string;
171
266
  label: string;
172
267
  url: string;
268
+ /** The link description as the author wrote it, or null if empty. */
269
+ description?: string | null;
173
270
  /** Rendered HTML of the link description, or null if empty. */
174
271
  descriptionHtml?: string | null;
175
272
  };
176
273
 
177
274
  interface ExportCollectionDirectorySourceItem {
178
- type: "collection" | "divider" | "link";
275
+ type: "collection" | "smart_collection" | "divider" | "link";
179
276
  label?: string | null;
180
277
  url?: string | null;
181
278
  description?: string | null;
@@ -187,6 +284,13 @@ interface ExportCollectionDirectorySourceItem {
187
284
  threadCount?: number;
188
285
  recentActivityAt?: number;
189
286
  };
287
+ smartCollection?: {
288
+ slug: string;
289
+ title: string;
290
+ description?: string | null;
291
+ threadCount?: number;
292
+ recentActivityAt?: number;
293
+ };
190
294
  }
191
295
 
192
296
  interface SiteIconAssets {
@@ -249,13 +353,24 @@ export function createExportService(
249
353
  * base64-encodes attachment bytes.
250
354
  */
251
355
  bundleMedia?: boolean;
356
+ /**
357
+ * Name of the GitHub repository this export is destined for, when it has
358
+ * one. It decides the Worker name in `wrangler.jsonc`: Cloudflare names a
359
+ * Worker after the repository it was imported from, and requires the two
360
+ * to match. GitHub Sync passes it; a ZIP export has no repository and
361
+ * falls back to the repository name Sync would suggest for the site.
362
+ */
363
+ repoName?: string | null;
252
364
  } = {},
253
365
  ): ExportService {
254
366
  return {
255
367
  async generateHugoFiles() {
256
368
  const collectionDirectoryDataPromise =
257
369
  typeof services.collections.listDirectoryData === "function"
258
- ? services.collections.listDirectoryData()
370
+ ? // The export builds a public static site, so it sees what a
371
+ // signed-out reader sees. Language narrowing is not applied: the
372
+ // export walks every language itself.
373
+ services.collections.listDirectoryData({ isAuthenticated: false })
259
374
  : Promise.resolve(null);
260
375
 
261
376
  // 1. Query all data
@@ -281,6 +396,7 @@ export function createExportService(
281
396
  slugMap,
282
397
  aliasMap,
283
398
  collectionSlugMap,
399
+ standalonePaths,
284
400
  ] = await Promise.all([
285
401
  services.collections.getCollectionsByPostIds(rootPostIds),
286
402
  services.collections.getCollectionEntriesByThreadIds(rootPostIds),
@@ -288,6 +404,7 @@ export function createExportService(
288
404
  services.paths.getPostSlugMap(allPostIds),
289
405
  services.paths.getPostAliases(rootPostIds),
290
406
  services.paths.getCollectionSlugMap(allCollections.map((c) => c.id)),
407
+ services.paths.listStandalonePaths(),
291
408
  ]);
292
409
  // Denormalized title lookup so front-matter collection refs can
293
410
  // include a title label without templates having to resolve another
@@ -304,14 +421,56 @@ export function createExportService(
304
421
  allPosts,
305
422
  collectionsByRoot,
306
423
  );
307
- const exportedCollectionDirectoryItems =
308
- buildExportedCollectionDirectoryItems(
309
- collectionDirectoryData?.items ??
310
- allCollections.map((collection) => ({
311
- id: collection.id,
424
+ // Smart collections travel as their conditions (a section page per
425
+ // smart collection, below): a site importing the export recreates them,
426
+ // and the Hugo theme applies the same conditions to the posts it has, so
427
+ // the page keeps up with the repository instead of freezing the matches
428
+ // of the day it was exported.
429
+ const exportedSmartCollections: {
430
+ smartCollection: SmartCollection;
431
+ selection: HugoSmartCollectionSelection;
432
+ }[] = [];
433
+ const smartCollectionSlugMap = new Map<string, string>();
434
+ for (const smartCollection of collectionDirectoryData?.smartCollections ??
435
+ []) {
436
+ const selection = toExportedSelection(
437
+ smartCollection.selection,
438
+ collectionSlugMap,
439
+ );
440
+ if (!selection) {
441
+ // eslint-disable-next-line no-console -- A dropped page must leave a trace
442
+ console.warn(
443
+ `Export: smart collection /${smartCollection.slug} filters by a collection that no longer exists, so it was left out.`,
444
+ );
445
+ continue;
446
+ }
447
+ smartCollectionSlugMap.set(smartCollection.id, smartCollection.slug);
448
+ exportedSmartCollections.push({ smartCollection, selection });
449
+ }
450
+ const exportableDirectoryItems: ExportCollectionDirectorySourceItem[] =
451
+ collectionDirectoryData?.items
452
+ ? collectionDirectoryData.items
453
+ .filter(
454
+ (item) =>
455
+ item.type !== "smart_collection" ||
456
+ (item.smartCollection !== undefined &&
457
+ smartCollectionSlugMap.has(item.smartCollection.id)),
458
+ )
459
+ .map((item) => ({
460
+ type: item.type,
461
+ label: item.label,
462
+ url: item.url,
463
+ description: item.description,
464
+ collection: item.collection,
465
+ smartCollection: item.smartCollection,
466
+ }))
467
+ : allCollections.map((collection) => ({
312
468
  type: "collection" as const,
313
469
  collection,
314
- })),
470
+ }));
471
+ const exportedCollectionDirectoryItems =
472
+ buildExportedCollectionDirectoryItems(
473
+ exportableDirectoryItems,
315
474
  collectionSlugMap,
316
475
  collectionMetrics,
317
476
  );
@@ -323,9 +482,14 @@ export function createExportService(
323
482
  list.push(reply);
324
483
  repliesByThread.set(reply.threadId, list);
325
484
  }
326
- // Sort replies by createdAt within each thread
485
+ // Thread order, as `posts.getThread` reads it: after the root, which
486
+ // leads as `_index.md`, creation time, then ID.
327
487
  for (const list of repliesByThread.values()) {
328
- list.sort((a, b) => a.createdAt - b.createdAt);
488
+ list.sort(
489
+ (a, b) =>
490
+ a.createdAt - b.createdAt ||
491
+ (a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
492
+ );
329
493
  }
330
494
 
331
495
  // 3. Build file list
@@ -376,6 +540,19 @@ export function createExportService(
376
540
  });
377
541
  }
378
542
 
543
+ // Smart collection landing pages: the conditions, which the theme's
544
+ // `smart-collection-members` partial applies to the exported posts.
545
+ for (const { smartCollection, selection } of exportedSmartCollections) {
546
+ exportFiles.push({
547
+ path: `content/${smartCollection.slug}/_index.md`,
548
+ content: await buildSmartCollectionSection(
549
+ smartCollection,
550
+ selection,
551
+ siteConfig.rssFeedsEnabled,
552
+ ),
553
+ });
554
+ }
555
+
379
556
  // Section + home scaffolding.
380
557
  exportFiles.push({
381
558
  path: "hugo.toml",
@@ -397,13 +574,33 @@ export function createExportService(
397
574
  const usedSlugs = new Set<string>();
398
575
  for (const s of slugMap.values()) usedSlugs.add(s);
399
576
  for (const s of collectionSlugMap.values()) usedSlugs.add(s);
400
- if (!usedSlugs.has("featured")) {
577
+ for (const s of smartCollectionSlugMap.values()) usedSlugs.add(s);
578
+ const hasFeaturedSection = !usedSlugs.has("featured");
579
+ if (hasFeaturedSection) {
401
580
  exportFiles.push({
402
581
  path: "content/featured/_index.md",
403
582
  content: await buildFeaturedSection(siteConfig.rssFeedsEnabled),
404
583
  });
405
584
  }
406
585
 
586
+ // Feed subscribers are the one audience the export can lose silently:
587
+ // their reader polls a `/feed` address that this site does not serve.
588
+ // With feeds off there is nothing to redirect to, and the live site had
589
+ // no feeds to have been subscribed to either. The author's own
590
+ // redirects go in too; an old feed address is often one of them.
591
+ const redirectSections = [
592
+ siteConfig.rssFeedsEnabled
593
+ ? buildFeedRedirects(siteConfig.mainRssFeed, hasFeaturedSection)
594
+ : null,
595
+ buildCustomUrlRedirects(standalonePaths),
596
+ ].filter((section): section is string => section !== null);
597
+ if (redirectSections.length > 0) {
598
+ exportFiles.push({
599
+ path: "static/_redirects",
600
+ content: redirectSections.join("\n"),
601
+ });
602
+ }
603
+
407
604
  // Single data file consumed by templates via `hugo.Data.jant`. The
408
605
  // collection directory lives on the same object as `directory` so
409
606
  // everything Jant owns round-trips in one place.
@@ -413,6 +610,12 @@ export function createExportService(
413
610
  siteConfig,
414
611
  iconAssets,
415
612
  exportedCollectionDirectoryItems,
613
+ {
614
+ postSlugs: slugMap,
615
+ collectionSlugs: collectionSlugMap,
616
+ smartCollectionSlugs: smartCollectionSlugMap,
617
+ standalonePaths,
618
+ },
416
619
  ),
417
620
  });
418
621
 
@@ -461,6 +664,10 @@ export function createExportService(
461
664
  path: "themes/jant/layouts/collection/single.html",
462
665
  content: LAYOUT_COLLECTION_SINGLE,
463
666
  });
667
+ exportFiles.push({
668
+ path: "themes/jant/layouts/partials/jant-data.html",
669
+ content: PARTIAL_JANT_DATA,
670
+ });
464
671
  exportFiles.push({
465
672
  path: "themes/jant/layouts/partials/head.html",
466
673
  content: PARTIAL_HEAD,
@@ -497,6 +704,30 @@ export function createExportService(
497
704
  path: "themes/jant/layouts/partials/featured-thread.html",
498
705
  content: PARTIAL_FEATURED_THREAD,
499
706
  });
707
+ exportFiles.push({
708
+ path: "themes/jant/layouts/smart_collection/list.html",
709
+ content: LAYOUT_SMART_COLLECTION_LIST,
710
+ });
711
+ exportFiles.push({
712
+ path: "themes/jant/layouts/partials/smart-collection-members.html",
713
+ content: PARTIAL_SMART_COLLECTION_MEMBERS,
714
+ });
715
+ exportFiles.push({
716
+ path: "themes/jant/layouts/partials/collection-threads.html",
717
+ content: PARTIAL_COLLECTION_THREADS,
718
+ });
719
+ exportFiles.push({
720
+ path: "themes/jant/layouts/partials/collection-members.html",
721
+ content: PARTIAL_COLLECTION_MEMBERS,
722
+ });
723
+ exportFiles.push({
724
+ path: "themes/jant/layouts/partials/latest-members.html",
725
+ content: PARTIAL_LATEST_MEMBERS,
726
+ });
727
+ exportFiles.push({
728
+ path: "themes/jant/layouts/partials/featured-members.html",
729
+ content: PARTIAL_FEATURED_MEMBERS,
730
+ });
500
731
  exportFiles.push({
501
732
  path: "themes/jant/layouts/_default/rss.xml",
502
733
  content: LAYOUT_RSS,
@@ -546,28 +777,48 @@ export function createExportService(
546
777
 
547
778
  exportFiles.push({
548
779
  path: "README.md",
549
- content: buildReadme(siteConfig.siteName),
780
+ content: buildReadme(siteConfig, hasFeaturedSection),
550
781
  });
551
782
  exportFiles.push({
552
783
  path: ".gitignore",
553
784
  content: buildGitignore(),
554
785
  });
786
+ exportFiles.push({
787
+ path: WRANGLER_CONFIG_PATH,
788
+ content: buildWranglerConfig(siteConfig, deps.repoName),
789
+ scaffoldOnce: true,
790
+ });
555
791
 
556
792
  return exportFiles;
557
793
  },
558
794
 
559
795
  async generateHugoSite() {
560
796
  const exportFiles = await this.generateHugoFiles();
561
- const { zipSync } = await import("fflate");
562
- const encoder = new TextEncoder();
563
- const files: Record<string, Uint8Array> = {};
564
- for (const file of exportFiles) {
565
- files[file.path] =
566
- typeof file.content === "string"
567
- ? encoder.encode(file.content)
568
- : file.content;
797
+ const storage = deps.storage ?? null;
798
+ const lastModified = new Date();
799
+
800
+ async function* entries() {
801
+ for (const file of exportFiles) {
802
+ if (!isStoredExportFile(file)) {
803
+ yield { name: file.path, lastModified, input: file.content };
804
+ continue;
805
+ }
806
+
807
+ const object = storage ? await storage.get(file.storageKey) : null;
808
+ if (!object?.body) {
809
+ // The archive is already streaming; say which file is missing
810
+ // rather than leave it out without a trace.
811
+ // eslint-disable-next-line no-console -- A dropped file must leave a trace
812
+ console.warn(
813
+ `Export: ${file.storageKey} is missing from storage, so ${file.path} is not in the archive.`,
814
+ );
815
+ continue;
816
+ }
817
+ yield { name: file.path, lastModified, input: object.body };
818
+ }
569
819
  }
570
- return zipSync(files);
820
+
821
+ return makeZip(entries());
571
822
  },
572
823
  };
573
824
  }
@@ -676,6 +927,25 @@ function buildExportedCollectionEntriesForThread(
676
927
  return resolved;
677
928
  }
678
929
 
930
+ /**
931
+ * Whether the site's timeline cuts this post's text short.
932
+ *
933
+ * Shares the boundary with the served feed rather than approximating it: Hugo
934
+ * has its own summary rule and would cut somewhere else, so the answer is
935
+ * computed here and carried as front matter.
936
+ *
937
+ * @param post - The post being exported
938
+ * @returns true when the timeline shows less than the post's page
939
+ * @example
940
+ * isPostTruncated(longArticle); // true
941
+ */
942
+ function isPostTruncated(post: Post): boolean {
943
+ return (
944
+ extractTimelineSummary(post.body, !!post.title, { namespace: post.id })
945
+ ?.hasMore === true
946
+ );
947
+ }
948
+
679
949
  function collectionEntriesToRefs(
680
950
  entries: readonly ExportedCollectionEntry[],
681
951
  ): HugoCollectionRef[] {
@@ -844,12 +1114,39 @@ async function buildThreadBundle(
844
1114
  // Root aliases = historical root slugs + every reply slug (so
845
1115
  // /{reply-slug}/ gets a Hugo alias page that redirects/anchors to
846
1116
  // the thread root).
847
- const aliases = [...rootAliases];
848
- for (const reply of threadReplies) {
849
- const replySlug = slugMap.get(reply.id) ?? reply.slug;
850
- aliases.push(`/${replySlug}/`);
1117
+ //
1118
+ // A draft or private root is not built, so its alias pages would have no
1119
+ // target: Hugo still writes them, `.Permalink` resolves to the empty
1120
+ // string, and the result is a page that redirects to itself. The thread
1121
+ // is unpublished, so its historical URLs are unpublished with it.
1122
+ const rootIsUnpublished =
1123
+ root.status === "draft" || root.visibility === "private";
1124
+ const aliases: string[] = [];
1125
+ if (!rootIsUnpublished) {
1126
+ aliases.push(...rootAliases);
1127
+ for (const reply of threadReplies) {
1128
+ const replySlug = slugMap.get(reply.id) ?? reply.slug;
1129
+ aliases.push(`/${replySlug}/`);
1130
+ }
851
1131
  }
852
1132
 
1133
+ // The `<id>` Jant's feeds give this Thread's entry, kept as the string it
1134
+ // was. A reader recognises an entry it has seen by that string alone, and
1135
+ // the Hugo page's own URL differs from it — a trailing slash always, and
1136
+ // the slug where Jant uses a custom path — so an exported feed that used
1137
+ // the page URL would show every entry to every subscriber again after the
1138
+ // move. RFC 4287 §4.2.6 asks for exactly this: an entry's ID must not
1139
+ // change when its feed is migrated or exported.
1140
+ const siteUrl = siteConfig.siteUrl.trim();
1141
+ const feedId =
1142
+ !rootIsUnpublished && siteUrl
1143
+ ? toAbsoluteSiteUrl(
1144
+ getPostPath(rootSlug, rootAliases[0]),
1145
+ siteUrl,
1146
+ siteConfig.sitePathPrefix,
1147
+ )
1148
+ : undefined;
1149
+
853
1150
  // Root front matter.
854
1151
  const rootMedia = mediaByPost.get(root.id) ?? [];
855
1152
  const rootEmissions = rootMedia.map((m) =>
@@ -863,6 +1160,10 @@ async function buildThreadBundle(
863
1160
  root.publishedAt !== null
864
1161
  ? toISOString(root.publishedAt)
865
1162
  : toISOString(root.createdAt),
1163
+ created:
1164
+ root.publishedAt !== null && root.createdAt !== root.publishedAt
1165
+ ? toISOString(root.createdAt)
1166
+ : undefined,
866
1167
  updated:
867
1168
  root.updatedAt && root.updatedAt !== root.publishedAt
868
1169
  ? toISOString(root.updatedAt)
@@ -878,15 +1179,19 @@ async function buildThreadBundle(
878
1179
  : undefined,
879
1180
  slug: rootSlug,
880
1181
  type: "post",
881
- draft:
882
- root.status === "draft" || root.visibility === "private"
883
- ? true
884
- : undefined,
1182
+ draft: rootIsUnpublished ? true : undefined,
885
1183
  aliases: aliases.length > 0 ? aliases : undefined,
1184
+ feed_id: feedId,
886
1185
  format: root.format,
887
1186
  status: root.status,
888
1187
  visibility: root.visibility,
1188
+ // Language is Thread-uniform, so only the root emits it; the importer
1189
+ // re-derives replies from their root. `translation_group` is an opaque
1190
+ // shared key — importing the whole site keeps translated posts linked.
1191
+ language: root.language ?? undefined,
1192
+ translation_group: root.translationGroupId ?? undefined,
889
1193
  summary_text: getArchiveSummaryText(root) ?? undefined,
1194
+ truncated: isPostTruncated(root) || undefined,
890
1195
  link_url: root.format === "link" && root.url ? root.url : undefined,
891
1196
  source_name: root.format === "quote" && root.title ? root.title : undefined,
892
1197
  source_url: root.format === "quote" && root.url ? root.url : undefined,
@@ -906,7 +1211,7 @@ async function buildThreadBundle(
906
1211
  media: rootMediaList.length > 0 ? rootMediaList : undefined,
907
1212
  };
908
1213
 
909
- const rootBody = root.body ? tiptapJsonToMarkdown(root.body) : "";
1214
+ const rootBody = root.body ? postBodyToMarkdown(root.body, rootSlug) : "";
910
1215
  files.push({
911
1216
  path: `content/${rootSlug}/_index.md`,
912
1217
  content: `${await formatFrontMatter(rootFrontMatter)}\n${rootBody}${rootBody.endsWith("\n") ? "" : "\n"}`,
@@ -922,7 +1227,7 @@ async function buildThreadBundle(
922
1227
  media: rootMedia[i] as Media,
923
1228
  }))) {
924
1229
  if (emission.inlinePath) {
925
- const file = await readMediaResourceFile(
1230
+ const file = toStoredMediaFile(
926
1231
  storage,
927
1232
  media.storageKey,
928
1233
  emission.inlinePath,
@@ -930,7 +1235,7 @@ async function buildThreadBundle(
930
1235
  if (file) files.push(file);
931
1236
  }
932
1237
  if (emission.inlinePosterPath && media.posterKey) {
933
- const posterFile = await readMediaResourceFile(
1238
+ const posterFile = toStoredMediaFile(
934
1239
  storage,
935
1240
  media.posterKey,
936
1241
  emission.inlinePosterPath,
@@ -939,8 +1244,8 @@ async function buildThreadBundle(
939
1244
  }
940
1245
  }
941
1246
 
942
- // Replies as nested leaf bundles.
943
- for (const reply of threadReplies) {
1247
+ // Replies as nested leaf bundles, in Thread order.
1248
+ for (const [replyIndex, reply] of threadReplies.entries()) {
944
1249
  const replySlug = slugMap.get(reply.id) ?? reply.slug;
945
1250
  const replyMedia = mediaByPost.get(reply.id) ?? [];
946
1251
  const replyEmissions = replyMedia.map((m) =>
@@ -954,12 +1259,17 @@ async function buildThreadBundle(
954
1259
  reply.publishedAt !== null
955
1260
  ? toISOString(reply.publishedAt)
956
1261
  : toISOString(reply.createdAt),
1262
+ created:
1263
+ reply.publishedAt !== null && reply.createdAt !== reply.publishedAt
1264
+ ? toISOString(reply.createdAt)
1265
+ : undefined,
957
1266
  updated:
958
1267
  reply.updatedAt && reply.updatedAt !== reply.publishedAt
959
1268
  ? toISOString(reply.updatedAt)
960
1269
  : undefined,
961
1270
  slug: replySlug,
962
1271
  type: "post",
1272
+ weight: replyIndex + 1,
963
1273
  draft:
964
1274
  reply.status === "draft" || reply.visibility === "private"
965
1275
  ? true
@@ -969,6 +1279,7 @@ async function buildThreadBundle(
969
1279
  status: reply.status,
970
1280
  visibility: reply.visibility,
971
1281
  summary_text: getArchiveSummaryText(reply) ?? undefined,
1282
+ truncated: isPostTruncated(reply) || undefined,
972
1283
  link_url: reply.format === "link" && reply.url ? reply.url : undefined,
973
1284
  source_name:
974
1285
  reply.format === "quote" && reply.title ? reply.title : undefined,
@@ -983,7 +1294,9 @@ async function buildThreadBundle(
983
1294
  media: replyMediaList.length > 0 ? replyMediaList : undefined,
984
1295
  };
985
1296
 
986
- const replyBody = reply.body ? tiptapJsonToMarkdown(reply.body) : "";
1297
+ const replyBody = reply.body
1298
+ ? postBodyToMarkdown(reply.body, replySlug)
1299
+ : "";
987
1300
  files.push({
988
1301
  path: `content/${rootSlug}/${replySlug}/index.md`,
989
1302
  content: `${await formatFrontMatter(replyFrontMatter)}\n${replyBody}${replyBody.endsWith("\n") ? "" : "\n"}`,
@@ -994,7 +1307,7 @@ async function buildThreadBundle(
994
1307
  media: replyMedia[i] as Media,
995
1308
  }))) {
996
1309
  if (emission.inlinePath) {
997
- const file = await readMediaResourceFile(
1310
+ const file = toStoredMediaFile(
998
1311
  storage,
999
1312
  media.storageKey,
1000
1313
  emission.inlinePath,
@@ -1002,7 +1315,7 @@ async function buildThreadBundle(
1002
1315
  if (file) files.push(file);
1003
1316
  }
1004
1317
  if (emission.inlinePosterPath && media.posterKey) {
1005
- const posterFile = await readMediaResourceFile(
1318
+ const posterFile = toStoredMediaFile(
1006
1319
  storage,
1007
1320
  media.posterKey,
1008
1321
  emission.inlinePosterPath,
@@ -1016,25 +1329,17 @@ async function buildThreadBundle(
1016
1329
  }
1017
1330
 
1018
1331
  /**
1019
- * Read a media record's bytes from storage and return an ExportFile so
1020
- * they can be bundled next to the post as a Hugo page resource. Returns
1021
- * null when storage is unavailable or the object cannot be read, in
1022
- * which case the front matter entry still points at the resource name
1023
- * and the CLI's pull-media step (or a later sync) can fill it in.
1332
+ * Name a media object for the archive to bundle as `static/media/…`. Its
1333
+ * bytes are read when the archive is written. Null when the export has no
1334
+ * storage, in which case the front matter entry still points at the file and
1335
+ * the CLI's pull-media step can fill it in.
1024
1336
  */
1025
- async function readMediaResourceFile(
1337
+ function toStoredMediaFile(
1026
1338
  storage: StorageDriver | null,
1027
1339
  storageKey: string,
1028
1340
  bundlePath: string,
1029
- ): Promise<ExportFile | null> {
1030
- if (!storage) return null;
1031
- try {
1032
- const bytes = await readStorageObjectBytes(storage, storageKey);
1033
- if (!bytes) return null;
1034
- return { path: bundlePath, content: bytes };
1035
- } catch {
1036
- return null;
1037
- }
1341
+ ): ExportStoredFile | null {
1342
+ return storage ? { path: bundlePath, storageKey } : null;
1038
1343
  }
1039
1344
 
1040
1345
  // ---------------------------------------------------------------------------
@@ -1096,6 +1401,48 @@ async function buildCollectionSection(
1096
1401
  return `${await formatFrontMatter(frontMatter)}\n`;
1097
1402
  }
1098
1403
 
1404
+ /**
1405
+ * A smart collection's conditions as its section page carries them: the
1406
+ * stored selection, with the collection named by slug rather than ID so an
1407
+ * import into another site can resolve it. Null when the collection it names
1408
+ * is gone.
1409
+ */
1410
+ function toExportedSelection(
1411
+ selection: SmartCollection["selection"],
1412
+ collectionSlugs: ReadonlyMap<string, string>,
1413
+ ): HugoSmartCollectionSelection | null {
1414
+ const { collection, media, ...rest } = selection;
1415
+ const exported: HugoSmartCollectionSelection = { ...rest };
1416
+ if (collection !== undefined) {
1417
+ const slug = collection[0] ? collectionSlugs.get(collection[0]) : undefined;
1418
+ if (!slug) return null;
1419
+ exported.collection = slug;
1420
+ }
1421
+ if (media !== undefined) {
1422
+ exported.media = typeof media === "string" ? media : [...media];
1423
+ }
1424
+ return exported;
1425
+ }
1426
+
1427
+ async function buildSmartCollectionSection(
1428
+ smartCollection: SmartCollection,
1429
+ selection: HugoSmartCollectionSelection,
1430
+ rssFeedsEnabled: boolean,
1431
+ ): Promise<string> {
1432
+ const frontMatter: HugoFrontMatter = {
1433
+ title: smartCollection.title,
1434
+ slug: smartCollection.slug,
1435
+ type: "smart_collection",
1436
+ summary_text: smartCollection.description ?? undefined,
1437
+ sort_order: smartCollection.sort,
1438
+ display_layout: smartCollection.layout ?? undefined,
1439
+ selection,
1440
+ // Opt into Atom output at /{slug}/index.xml.
1441
+ outputs: rssFeedsEnabled ? ["html", "rss"] : ["html"],
1442
+ };
1443
+ return `${await formatFrontMatter(frontMatter)}\n`;
1444
+ }
1445
+
1099
1446
  // ---------------------------------------------------------------------------
1100
1447
  // Summary extraction (kept from the previous exporter)
1101
1448
  // ---------------------------------------------------------------------------
@@ -1260,6 +1607,7 @@ function buildExportedCollectionDirectoryItems(
1260
1607
  sequence: sequenceLabels[index] ?? "",
1261
1608
  label: item.label,
1262
1609
  url: item.url,
1610
+ description: description || null,
1263
1611
  descriptionHtml: description
1264
1612
  ? renderMarkdown(description, {
1265
1613
  namespace: `collection-directory-link-${sequenceLabels[index] ?? index}`,
@@ -1269,6 +1617,31 @@ function buildExportedCollectionDirectoryItems(
1269
1617
  return;
1270
1618
  }
1271
1619
 
1620
+ if (item.type === "smart_collection") {
1621
+ const smartCollection = item.smartCollection;
1622
+ if (!smartCollection?.slug) return;
1623
+ const smartDescription = smartCollection.description?.trim();
1624
+ exportedItems.push({
1625
+ type: "smart_collection",
1626
+ sequence: sequenceLabels[index] ?? "",
1627
+ slug: smartCollection.slug,
1628
+ title: smartCollection.title || smartCollection.slug,
1629
+ descriptionHtml: smartDescription
1630
+ ? renderMarkdown(smartDescription, {
1631
+ namespace: `smart-collection-${smartCollection.slug}`,
1632
+ })
1633
+ : null,
1634
+ entryCount: smartCollection.threadCount,
1635
+ recentActivityLabel: formatCollectionActivityLabel(
1636
+ smartCollection.recentActivityAt,
1637
+ ),
1638
+ recentActivityIso: formatCollectionActivityIso(
1639
+ smartCollection.recentActivityAt,
1640
+ ),
1641
+ });
1642
+ return;
1643
+ }
1644
+
1272
1645
  const collection = item.collection;
1273
1646
  if (!collection?.id) {
1274
1647
  return;
@@ -1356,13 +1729,21 @@ function buildExportedCollectionMetrics(
1356
1729
  // Nav item resolution
1357
1730
  // ---------------------------------------------------------------------------
1358
1731
 
1732
+ /**
1733
+ * Label to bake into the static site.
1734
+ *
1735
+ * Mirrors `getNavItemDisplayLabel`: a stored label is the author's own words
1736
+ * and wins, an empty one means the item follows what it points at. The export
1737
+ * has no i18n runtime, so built-in destinations fall back to their English
1738
+ * default rather than a translated one.
1739
+ */
1359
1740
  function resolveNavItemLabel(item: SiteConfig["navItems"][number]): string {
1360
1741
  if (item.label) return item.label;
1361
1742
  if (item.systemKey) {
1362
1743
  const definition = SYSTEM_NAV_KEYS[item.systemKey as SystemNavKey];
1363
1744
  if (definition) return definition.defaultLabel;
1364
1745
  }
1365
- return item.label;
1746
+ return item.targetTitle?.trim() || item.label;
1366
1747
  }
1367
1748
 
1368
1749
  /**
@@ -1389,7 +1770,10 @@ function resolveNavItemUrl(
1389
1770
  }
1390
1771
  if (item.systemKey === "collections") return "/collections/";
1391
1772
  if (item.systemKey === "archive") return "/archive/";
1392
- if (item.systemKey === "rss") {
1773
+ // `subscribe` addresses a Jant route, and the exported site has no page at
1774
+ // it. Rather than exporting a nav entry that 404s, it resolves to the feed
1775
+ // that page would have recommended — the same file `rss` points at.
1776
+ if (item.systemKey === "rss" || item.systemKey === "subscribe") {
1393
1777
  return mainRssFeed === "featured" ? "/featured/index.xml" : "/index.xml";
1394
1778
  }
1395
1779
  return item.url;
@@ -1419,7 +1803,12 @@ function buildHugoToml(config: SiteConfig): string {
1419
1803
  `languageCode = "${escapeTomlString(language)}"`,
1420
1804
  `defaultContentLanguage = "${escapeTomlString(language)}"`,
1421
1805
  'theme = "jant"',
1422
- `paginate = ${config.pageSize}`,
1806
+ // No site-level `paginate` / `[pagination] pagerSize`: every paginated
1807
+ // template calls `.Paginate` with an explicit page size taken from
1808
+ // `params.page_size` / `params.archive_page_size`, so a site-level value
1809
+ // would never be read. `paginate` was also deprecated in Hugo v0.128.0
1810
+ // and has since been removed, so emitting it fails the build outright on
1811
+ // any version that only knows `[pagination] pagerSize`.
1423
1812
  "enableRobotsTXT = true",
1424
1813
  // Disable Hugo's built-in taxonomies — jant has no tags or categories
1425
1814
  // and the default empty /tags/ and /categories/ pages are noise. This
@@ -1468,6 +1857,7 @@ function buildHugoToml(config: SiteConfig): string {
1468
1857
  "[params]",
1469
1858
  ` description = "${escapeTomlString(config.siteDescription)}"`,
1470
1859
  ` main_rss_feed = "${escapeTomlString(config.mainRssFeed)}"`,
1860
+ ` archive_default_layout = "${escapeTomlString(config.archiveDefaultLayout)}"`,
1471
1861
  ` public_api_enabled = ${config.publicApiEnabled}`,
1472
1862
  ` rss_feeds_enabled = ${config.rssFeedsEnabled}`,
1473
1863
  ` show_jant_branding_on_home = ${config.showJantBrandingOnHome}`,
@@ -1495,10 +1885,42 @@ function buildHugoToml(config: SiteConfig): string {
1495
1885
  return `${parts.join("\n")}\n`;
1496
1886
  }
1497
1887
 
1888
+ /** What `data/jant.toml` needs to name nav targets and standalone URLs. */
1889
+ interface JantDataTargets {
1890
+ postSlugs: ReadonlyMap<string, string>;
1891
+ collectionSlugs: ReadonlyMap<string, string>;
1892
+ smartCollectionSlugs: ReadonlyMap<string, string>;
1893
+ standalonePaths: readonly PathRecord[];
1894
+ }
1895
+
1896
+ /** The key and slug `data/jant.toml` names a nav item's target by. */
1897
+ function resolveNavItemTarget(
1898
+ item: SiteConfig["navItems"][number],
1899
+ targets: JantDataTargets,
1900
+ ): { key: string; slug: string } | null {
1901
+ const slug =
1902
+ item.type === "collection" && item.collectionId
1903
+ ? targets.collectionSlugs.get(item.collectionId)
1904
+ : item.type === "smart_collection" && item.smartCollectionId
1905
+ ? targets.smartCollectionSlugs.get(item.smartCollectionId)
1906
+ : item.type === "page" && item.postId
1907
+ ? targets.postSlugs.get(item.postId)
1908
+ : undefined;
1909
+ if (!slug) return null;
1910
+ const key =
1911
+ item.type === "collection"
1912
+ ? "collection_slug"
1913
+ : item.type === "smart_collection"
1914
+ ? "smart_collection_slug"
1915
+ : "post_slug";
1916
+ return { key, slug };
1917
+ }
1918
+
1498
1919
  function buildJantDataToml(
1499
1920
  config: SiteConfig,
1500
1921
  iconAssets: SiteIconAssets,
1501
1922
  directoryItems: readonly ExportedCollectionDirectoryItem[],
1923
+ targets: JantDataTargets,
1502
1924
  ): string {
1503
1925
  const footerHtml = config.siteFooter
1504
1926
  ? renderMarkdown(config.siteFooter, { namespace: "site-footer" })
@@ -1510,7 +1932,10 @@ function buildJantDataToml(
1510
1932
  `site_name = "${escapeTomlString(config.siteName)}"`,
1511
1933
  `site_description = "${escapeTomlString(config.siteDescription)}"`,
1512
1934
  `site_language = "${escapeTomlString(config.siteLanguage)}"`,
1935
+ `multilingual_enabled = ${config.multilingualEnabled}`,
1936
+ `additional_languages = "${escapeTomlString(config.additionalLanguages.join(","))}"`,
1513
1937
  `main_rss_feed = "${escapeTomlString(config.mainRssFeed)}"`,
1938
+ `archive_default_layout = "${escapeTomlString(config.archiveDefaultLayout)}"`,
1514
1939
  `public_api_enabled = ${config.publicApiEnabled}`,
1515
1940
  `rss_feeds_enabled = ${config.rssFeedsEnabled}`,
1516
1941
  `show_jant_branding_on_home = ${config.showJantBrandingOnHome}`,
@@ -1547,13 +1972,14 @@ function buildJantDataToml(
1547
1972
  }
1548
1973
 
1549
1974
  for (const item of config.navItems) {
1550
- // `settings` is authenticated-only and has no corresponding page in the
1551
- // static Hugo site — drop it at export time so it never shows up in nav.
1552
- if (item.systemKey === "settings") continue;
1975
+ // `settings` has no page in the static site; the theme skips it, and the
1976
+ // entry stays so an import back into Jant keeps it. Both feed entries
1977
+ // resolve to a feed file, so with feeds off both would export a link to
1978
+ // something that was never written.
1553
1979
  if (
1554
1980
  !config.rssFeedsEnabled &&
1555
1981
  item.type === "system" &&
1556
- item.systemKey === "rss"
1982
+ isFeedNavKey(item.systemKey)
1557
1983
  ) {
1558
1984
  continue;
1559
1985
  }
@@ -1566,13 +1992,22 @@ function buildJantDataToml(
1566
1992
  );
1567
1993
  parts.push(`system_key = "${escapeTomlString(item.systemKey ?? "")}"`);
1568
1994
  parts.push(`placement = "${escapeTomlString(item.placement ?? "header")}"`);
1995
+ // `label` above is what the theme shows. The author's own wording, when
1996
+ // there is one, and the target by slug are what an import restores.
1997
+ if (item.label) {
1998
+ parts.push(`custom_label = "${escapeTomlString(item.label)}"`);
1999
+ }
2000
+ const target = resolveNavItemTarget(item, targets);
2001
+ if (target) {
2002
+ parts.push(`${target.key} = "${escapeTomlString(target.slug)}"`);
2003
+ }
1569
2004
  }
1570
2005
 
1571
2006
  for (const item of directoryItems) {
1572
2007
  parts.push("");
1573
2008
  parts.push("[[directory]]");
1574
2009
  parts.push(`type = "${escapeTomlString(item.type)}"`);
1575
- if (item.type === "collection") {
2010
+ if (item.type === "collection" || item.type === "smart_collection") {
1576
2011
  parts.push(`sequence = "${escapeTomlString(item.sequence)}"`);
1577
2012
  parts.push(`slug = "${escapeTomlString(item.slug)}"`);
1578
2013
  parts.push(`title = "${escapeTomlString(item.title)}"`);
@@ -1602,6 +2037,9 @@ function buildJantDataToml(
1602
2037
  parts.push(`sequence = "${escapeTomlString(item.sequence)}"`);
1603
2038
  parts.push(`label = "${escapeTomlString(item.label)}"`);
1604
2039
  parts.push(`url = "${escapeTomlString(item.url)}"`);
2040
+ if (item.description) {
2041
+ parts.push(`description = "${escapeTomlString(item.description)}"`);
2042
+ }
1605
2043
  if (item.descriptionHtml) {
1606
2044
  parts.push(
1607
2045
  `description_html = "${escapeTomlString(item.descriptionHtml)}"`,
@@ -1610,13 +2048,254 @@ function buildJantDataToml(
1610
2048
  }
1611
2049
  }
1612
2050
 
2051
+ // Custom URLs that name no post or collection. Post aliases travel in each
2052
+ // root's `aliases:`; these would otherwise not travel at all.
2053
+ for (const record of targets.standalonePaths) {
2054
+ parts.push("");
2055
+ parts.push("[[custom_url]]");
2056
+ parts.push(`path = "${escapeTomlString(record.path)}"`);
2057
+ parts.push(`kind = "${escapeTomlString(record.kind)}"`);
2058
+ if (record.kind === "redirect" && record.redirectToPath) {
2059
+ parts.push(`to = "/${escapeTomlString(record.redirectToPath)}"`);
2060
+ parts.push(`status = ${record.redirectType ?? 301}`);
2061
+ }
2062
+ if (record.kind === "archive" && record.archiveQuery) {
2063
+ parts.push(`archive_query = "${escapeTomlString(record.archiveQuery)}"`);
2064
+ }
2065
+ }
2066
+
1613
2067
  return `${parts.join("\n")}\n`;
1614
2068
  }
1615
2069
 
2070
+ /**
2071
+ * The author's redirects as `_redirects` rules, or null when there are none.
2072
+ *
2073
+ * @param standalonePaths - Custom URLs that name no post or collection
2074
+ * @returns The `_redirects` section
2075
+ * @example
2076
+ * buildCustomUrlRedirects([redirectFromAtomXml]); // "# Redirects...\n/atom.xml /feed 301\n"
2077
+ */
2078
+ function buildCustomUrlRedirects(
2079
+ standalonePaths: readonly PathRecord[],
2080
+ ): string | null {
2081
+ const rules = standalonePaths
2082
+ .filter((record) => record.kind === "redirect" && record.redirectToPath)
2083
+ .map(
2084
+ (record) =>
2085
+ [
2086
+ `/${record.path}`,
2087
+ `/${record.redirectToPath}`,
2088
+ record.redirectType ?? 301,
2089
+ ] as const,
2090
+ );
2091
+ if (rules.length === 0) return null;
2092
+
2093
+ const width = Math.max(...rules.map(([from]) => from.length));
2094
+ return `# Redirects set up under Settings → Custom URLs on the live site.
2095
+
2096
+ ${rules.map(([from, to, status]) => `${from.padEnd(width)} ${to} ${status}`).join("\n")}
2097
+ `;
2098
+ }
2099
+
1616
2100
  // ---------------------------------------------------------------------------
1617
- // README + .gitignore
2101
+ // README + .gitignore + _redirects + wrangler.jsonc
1618
2102
  // ---------------------------------------------------------------------------
1619
2103
 
2104
+ /** Repo-relative path of the Cloudflare Workers deploy config. */
2105
+ export const WRANGLER_CONFIG_PATH = "wrangler.jsonc";
2106
+
2107
+ /**
2108
+ * Normalize a string into a name Cloudflare accepts for a Worker: lowercase
2109
+ * letters, digits, and hyphens, at most 63 characters.
2110
+ */
2111
+ function toWorkerName(raw: string): string {
2112
+ return (
2113
+ raw
2114
+ .toLowerCase()
2115
+ .replace(/[^a-z0-9-]+/g, "-")
2116
+ // 63 characters is Cloudflare's limit; trimming hyphens comes after the
2117
+ // cut so a truncated name cannot end on one.
2118
+ .slice(0, 63)
2119
+ .replace(/^-+|-+$/g, "")
2120
+ );
2121
+ }
2122
+
2123
+ /**
2124
+ * Pick the Worker name for `wrangler.jsonc`.
2125
+ *
2126
+ * Cloudflare requires this name to match the Worker in the dashboard, and its
2127
+ * repository-import flow names a new Worker after the repository — so the
2128
+ * repository name is the one value that lines up without the user editing
2129
+ * anything. An export with no repository behind it (a ZIP, or
2130
+ * `site export --directory`) uses the repository name the GitHub Sync
2131
+ * settings page prefills for this site, so pushing the export to a repository
2132
+ * created with that default still matches.
2133
+ *
2134
+ * @param repoName - The destination repository's name, without the owner.
2135
+ * @param siteUrl - The exported site's URL, used when there is no repository.
2136
+ * @returns A name Cloudflare accepts.
2137
+ * @example
2138
+ * deriveWorkerName("owenyoung-blog", "https://notes.example.com"); // "owenyoung-blog"
2139
+ * deriveWorkerName(null, "https://notes.example.com"); // "notes-jant-sync"
2140
+ */
2141
+ export function deriveWorkerName(
2142
+ repoName: string | null | undefined,
2143
+ siteUrl: string,
2144
+ ): string {
2145
+ return (
2146
+ toWorkerName(repoName ?? "") || toWorkerName(suggestSyncRepoName(siteUrl))
2147
+ );
2148
+ }
2149
+
2150
+ /**
2151
+ * Build the Cloudflare Workers deploy config for the exported site.
2152
+ *
2153
+ * Workers Builds has no "build output directory" field — that one belongs to
2154
+ * Pages — so `public/` can only be declared here. Without this file the
2155
+ * dashboard's default deploy command (`npx wrangler deploy`) fails on a repo
2156
+ * that has no Worker name to deploy under.
2157
+ *
2158
+ * `build.command` is here because nothing else runs Hugo. Workers Builds
2159
+ * detects frameworks from a `package.json`, which a Hugo site does not have,
2160
+ * so importing this repository leaves the build command empty and the deploy
2161
+ * fails on a `public/` that was never generated. Wrangler runs a custom build
2162
+ * before deploying, assets-only Workers included, which makes
2163
+ * `npx wrangler deploy` self-sufficient — on Cloudflare's image, on any CI,
2164
+ * and in a local checkout. Cloudflare's image ships Hugo extended, so the
2165
+ * command needs no install step; anyone who does fill in a dashboard build
2166
+ * command should leave it empty here to keep Hugo from running twice.
2167
+ *
2168
+ * Deliberately absent:
2169
+ * - `main`: a site with only static assets is a valid assets-only Worker.
2170
+ * - `not_found_handling`: the theme emits no `404.html` to point it at.
2171
+ *
2172
+ * @param config - The exported site's configuration.
2173
+ * @param repoName - The destination repository's name, when there is one.
2174
+ * @returns The contents of `wrangler.jsonc`.
2175
+ * @example
2176
+ * buildWranglerConfig(config, "my-blog"); // '{\n // Cloudflare Workers …'
2177
+ */
2178
+ function buildWranglerConfig(
2179
+ config: SiteConfig,
2180
+ repoName: string | null | undefined,
2181
+ ): string {
2182
+ const name = deriveWorkerName(repoName, config.siteUrl);
2183
+ // `compatibility_date` pins the Workers runtime behavior to what shipped on
2184
+ // the day this export was generated, which is what a new Worker wants.
2185
+ const compatibilityDate = new Date().toISOString().slice(0, 10);
2186
+ return `{
2187
+ // Cloudflare Workers deploy config for the built site.
2188
+ //
2189
+ // "name" must match the Worker's name in the Cloudflare dashboard: Workers
2190
+ // Builds fails the build when they differ, and a deploy run by hand under
2191
+ // another name goes to another Worker. A Worker imported from a repository
2192
+ // is named after the repository, so Jant uses the repository name when it
2193
+ // knows it, and otherwise the name GitHub Sync suggests for this site's
2194
+ // repository.
2195
+ //
2196
+ // Jant writes this file once and never overwrites it, so your edits stay.
2197
+ "$schema": "node_modules/wrangler/config-schema.json",
2198
+ "name": ${JSON.stringify(name)},
2199
+ "compatibility_date": ${JSON.stringify(compatibilityDate)},
2200
+ // Wrangler runs this before it uploads, so "npx wrangler deploy" builds the
2201
+ // site first. Leave the build command in the Cloudflare dashboard empty, or
2202
+ // Hugo runs twice.
2203
+ "build": {
2204
+ "command": "hugo --gc --minify"
2205
+ },
2206
+ "assets": {
2207
+ "directory": "./public"
2208
+ }
2209
+ }
2210
+ `;
2211
+ }
2212
+
2213
+ /**
2214
+ * Build the `static/_redirects` file that keeps existing feed subscribers
2215
+ * working after the site moves to this export.
2216
+ *
2217
+ * Jant serves its feeds under `/feed` (`/feed`, `/latest/feed`,
2218
+ * `/featured/feed`, `/archive/feed`, `/{collection}/feed`); Hugo serves the
2219
+ * same documents as `index.xml` inside each section. The two namespaces do
2220
+ * not overlap, so every subscriber's saved address 404s the moment the
2221
+ * exported site goes up.
2222
+ *
2223
+ * Hugo's `aliases:` mechanism cannot cover this the way it covers post URLs:
2224
+ * an alias page is a meta-refresh plus a script, and feed readers fetch and
2225
+ * parse XML without running either. Only an HTTP redirect reaches them, which
2226
+ * is host configuration rather than Hugo output — so the export emits the one
2227
+ * format two common static hosts read verbatim (Cloudflare Pages and Netlify).
2228
+ * Hosts that do not read `_redirects` ignore the file; the README carries the
2229
+ * rule for them to translate.
2230
+ *
2231
+ * @param mainRssFeed - The site's main feed setting, "latest" or "featured".
2232
+ * @param hasFeaturedSection - Whether the export emitted a featured section.
2233
+ * It is skipped when a post or collection already owns the `featured` slug,
2234
+ * and pointing a redirect at a feed that was never built would turn a live
2235
+ * subscription into a 404 rather than leaving it merely stale.
2236
+ * @returns The `_redirects` file body.
2237
+ * @example
2238
+ * buildFeedRedirects("latest", true); // "# ...\n/feed /index.xml 301\n..."
2239
+ */
2240
+ function buildFeedRedirects(
2241
+ mainRssFeed: string,
2242
+ hasFeaturedSection: boolean,
2243
+ ): string {
2244
+ const LATEST = "/index.xml";
2245
+ const FEATURED = "/featured/index.xml";
2246
+ // `/feed` follows the site's main-feed setting, mirroring `renderMainFeed`.
2247
+ // Without a featured section there is nothing to point it at, so it falls
2248
+ // back to the latest feed: a wider feed than the reader subscribed to, but
2249
+ // a live one.
2250
+ const main =
2251
+ mainRssFeed === "featured" && hasFeaturedSection ? FEATURED : LATEST;
2252
+ const featured = hasFeaturedSection ? FEATURED : LATEST;
2253
+
2254
+ const rules: [string, string][] = [
2255
+ ["/feed", main],
2256
+ ["/latest/feed", LATEST],
2257
+ ["/featured/feed", featured],
2258
+ ["/archive/feed", "/archive/index.xml"],
2259
+ ];
2260
+ const legacy: [string, string][] = [
2261
+ ["/feed/latest", LATEST],
2262
+ ["/feed/all", LATEST],
2263
+ ["/feed/featured", featured],
2264
+ ["/feed/atom.xml", main],
2265
+ ["/feed/latest/atom.xml", LATEST],
2266
+ ["/feed/all/atom.xml", LATEST],
2267
+ ["/feed/featured/atom.xml", featured],
2268
+ ];
2269
+
2270
+ // The format only needs whitespace between the columns; padding to the
2271
+ // widest source path keeps the file readable for whoever has to translate
2272
+ // it into another host's redirect syntax.
2273
+ const width = Math.max(
2274
+ ...[...rules, ...legacy].map(([from]) => from.length),
2275
+ "/:slug/feed".length,
2276
+ );
2277
+ const line = ([from, to]: [string, string]) =>
2278
+ `${from.padEnd(width)} ${to} 301`;
2279
+
2280
+ return `# Feed addresses moved in this export.
2281
+ #
2282
+ # Jant served its feeds under /feed; Hugo serves them as index.xml inside each
2283
+ # section. Cloudflare Pages and Netlify read this file and issue the redirects
2284
+ # below, so readers who subscribed to the old addresses keep receiving posts.
2285
+ # Other hosts ignore this file — see README.md for the same rules to translate
2286
+ # into your host's configuration.
2287
+
2288
+ ${rules.map(line).join("\n")}
2289
+
2290
+ # Aliases the live site answered with a 308. They would die at the same moment.
2291
+ ${legacy.map(line).join("\n")}
2292
+
2293
+ # Collection feeds, by slug. Collections are the only root-level sections that
2294
+ # emit Atom, so any other slug 404s here exactly as it did on the live site.
2295
+ ${line(["/:slug/feed", "/:slug/index.xml"])}
2296
+ `;
2297
+ }
2298
+
1620
2299
  function buildGitignore(): string {
1621
2300
  return `# Hugo build output
1622
2301
  public/
@@ -1634,14 +2313,126 @@ Thumbs.db
1634
2313
  `;
1635
2314
  }
1636
2315
 
1637
- function buildReadme(siteName: string): string {
2316
+ /**
2317
+ * Render a two-column Markdown table with padded cells.
2318
+ *
2319
+ * The values differ per site, so building the rows by hand would leave the
2320
+ * committed README ragged for some exports and aligned for others.
2321
+ *
2322
+ * @param headers - The two column headers.
2323
+ * @param rows - Cell pairs; each cell is wrapped in backticks.
2324
+ * @returns The table as Markdown, without a trailing newline.
2325
+ * @example
2326
+ * renderMarkdownTable(["Jant", "This export"], [["/feed", "/index.xml"]]);
2327
+ */
2328
+ /**
2329
+ * Convert a stored post body to the export's Markdown, naming the post when
2330
+ * it can't be converted. An export that dropped the body would read as a
2331
+ * complete archive and restore as an empty post.
2332
+ *
2333
+ * @param body - Stored TipTap JSON
2334
+ * @param slug - The post's slug, for the error
2335
+ * @returns The body as Markdown
2336
+ * @throws {Error} When the stored body isn't a TipTap document
2337
+ * @example
2338
+ * postBodyToMarkdown('{"type":"doc","content":[]}', "hello"); // ""
2339
+ */
2340
+ function postBodyToMarkdown(body: string, slug: string): string {
2341
+ try {
2342
+ return tiptapJsonToMarkdown(body);
2343
+ } catch (error) {
2344
+ throw new Error(
2345
+ `Couldn't convert the body of /${slug} to Markdown: ${error instanceof Error ? error.message : String(error)}`,
2346
+ { cause: error },
2347
+ );
2348
+ }
2349
+ }
2350
+
2351
+ function renderMarkdownTable(
2352
+ headers: [string, string],
2353
+ rows: [string, string][],
2354
+ ): string {
2355
+ const cells = rows.map(
2356
+ ([from, to]) => [`\`${from}\``, `\`${to}\``] as [string, string],
2357
+ );
2358
+ const widths: [number, number] = [
2359
+ Math.max(headers[0].length, ...cells.map((r) => r[0].length)),
2360
+ Math.max(headers[1].length, ...cells.map((r) => r[1].length)),
2361
+ ];
2362
+ const line = (a: string, b: string) =>
2363
+ `| ${a.padEnd(widths[0])} | ${b.padEnd(widths[1])} |`;
2364
+ return [
2365
+ line(headers[0], headers[1]),
2366
+ `| ${"-".repeat(widths[0])} | ${"-".repeat(widths[1])} |`,
2367
+ ...cells.map((r) => line(r[0], r[1])),
2368
+ ].join("\n");
2369
+ }
2370
+
2371
+ /**
2372
+ * Build the export's README.
2373
+ *
2374
+ * @param config - The exported site's configuration.
2375
+ * @param hasFeaturedSection - Whether a featured section was emitted, which
2376
+ * decides where the feed redirects point.
2377
+ * @returns Markdown for `README.md` at the root of the export.
2378
+ * @example
2379
+ * buildReadme(config, true); // "# My Site — Hugo Export\n..."
2380
+ */
2381
+ function buildReadme(config: SiteConfig, hasFeaturedSection: boolean): string {
2382
+ const siteName = config.siteName;
2383
+ const featuredFeed = hasFeaturedSection
2384
+ ? "/featured/index.xml"
2385
+ : "/index.xml";
2386
+ const mainFeed =
2387
+ config.mainRssFeed === "featured" ? featuredFeed : "/index.xml";
2388
+ // Nothing to say about feeds on a site that publishes none, and no
2389
+ // `_redirects` file was written for it either.
2390
+ const staticListing = config.rssFeedsEnabled
2391
+ ? "\n _redirects — Feed redirects (see Feeds above)"
2392
+ : "";
2393
+ // The Notes list below says post URLs survive the move. Left alone, that
2394
+ // reads as a promise about every URL, and feed addresses are the exception.
2395
+ const feedNote = config.rssFeedsEnabled
2396
+ ? "\n- Feed addresses are the exception: they move to `index.xml` and stay reachable only through `static/_redirects`. See [Feeds](#feeds)."
2397
+ : "";
2398
+ // Same reason: without feeds there is no `static/_redirects` to explain.
2399
+ const redirectsNote = config.rssFeedsEnabled
2400
+ ? "`static/_redirects` needs no configuration here. Hugo copies it to `public/_redirects` and Workers applies the rules as published.\n\n"
2401
+ : "";
2402
+ // Nothing to say about feeds on a site that publishes none, and no
2403
+ // `_redirects` file was written for it either.
2404
+ const feedRows: [string, string][] = [
2405
+ ["/feed", mainFeed],
2406
+ ["/latest/feed", "/index.xml"],
2407
+ ["/featured/feed", featuredFeed],
2408
+ ["/archive/feed", "/archive/index.xml"],
2409
+ ["/{collection}/feed", "/{collection}/index.xml"],
2410
+ ];
2411
+ const feedTable = renderMarkdownTable(["Jant", "This export"], feedRows);
2412
+ const feedsSection = config.rssFeedsEnabled
2413
+ ? `## Feeds
2414
+
2415
+ The feed addresses changed. Jant served them under \`/feed\`; Hugo serves them as \`index.xml\` inside each section:
2416
+
2417
+ ${feedTable}
2418
+
2419
+ A reader who is already subscribed holds one of the old addresses, and a feed reader that gets a 404 stops delivering posts. \`static/_redirects\` maps every old address to its new one with a 301. Cloudflare Pages and Netlify read that file as published; on any other host, translate its rules into that host's redirect configuration before you point the domain here.
2420
+
2421
+ Hugo's \`aliases:\` cannot cover this. An alias page redirects with a meta refresh and a script, and feed readers fetch XML without running either — only an HTTP redirect reaches them.
2422
+
2423
+ Feed entries keep the IDs Jant gave them, so feed readers don't show old posts again. Each root post stores its ID in \`feed_id\`: the post's address on Jant, which is not its page URL here. Don't change \`feed_id\`, or feed readers show that post again. A post you add here without one uses its page URL.
2424
+
2425
+ The **Subscribe** entry in the site navigation points at \`${mainFeed}\`. The exported site has no \`/subscribe\` page; that page belongs to the Jant runtime.
2426
+
2427
+ `
2428
+ : "";
1638
2429
  return `# ${siteName} — Hugo Export
1639
2430
 
1640
2431
  This is a static site exported from [Jant](https://github.com/jant-me/jant), ready to build with [Hugo](https://gohugo.io/).
1641
2432
 
1642
2433
  ## Install Hugo
1643
2434
 
1644
- This export targets Hugo **extended 0.160.1+**.
2435
+ This export targets Hugo **extended 0.147.7+**.
1645
2436
 
1646
2437
  **macOS (Homebrew):**
1647
2438
 
@@ -1679,10 +2470,27 @@ hugo --minify
1679
2470
 
1680
2471
  The output goes to the \`public/\` directory. Upload it to any static host (Netlify, Vercel, Cloudflare Pages, GitHub Pages, etc.).
1681
2472
 
1682
- ## Project structure
2473
+ ## Deploy to Cloudflare Workers
2474
+
2475
+ \`wrangler.jsonc\` at the root is the deploy config: it names the Worker, runs \`hugo --gc --minify\`, and points the upload at \`public/\`. Connect this repository to Cloudflare Workers Builds and leave the commands Cloudflare offers as they are:
2476
+
2477
+ | Field | Value |
2478
+ | --------------- | ------------------------------ |
2479
+ | Build command | leave empty |
2480
+ | Deploy command | \`npx wrangler deploy\` |
2481
+ | Version command | \`npx wrangler versions upload\` |
2482
+
2483
+ The build belongs to \`wrangler.jsonc\` rather than to that field: Workers Builds reads a \`package.json\` to detect a framework, a Hugo site has none, and an empty build command deploys a \`public/\` that was never built. Filling the field in as well makes Hugo run twice.
2484
+
2485
+ Check one thing before the first deploy: \`name\` in \`wrangler.jsonc\` has to match the Worker's name in the Cloudflare dashboard. Workers Builds fails the build when they differ, and a deploy run by hand under another name goes to another Worker. A Worker imported from a repository is named after the repository, so an export pushed by GitHub Sync uses the repository name. A downloaded export has no repository and uses the name GitHub Sync suggests when it creates one for this site. If the Worker is named something else, change \`name\` to match — Cloudflare names each build token \`<worker-name> build token\`, so the token list is one place to read it.
2486
+
2487
+ Jant writes \`wrangler.jsonc\` once and never overwrites it, so a corrected name survives later syncs.
2488
+
2489
+ ${redirectsNote}${feedsSection}## Project structure
1683
2490
 
1684
2491
  \`\`\`
1685
2492
  hugo.toml — Site configuration (baseURL, title, theme, params)
2493
+ wrangler.jsonc — Cloudflare Workers deploy config (see Deploy above)
1686
2494
  content/
1687
2495
  _index.md — Home section
1688
2496
  archive/_index.md — Archive section
@@ -1695,7 +2503,7 @@ content/
1695
2503
  data/
1696
2504
  jant.toml — Nav items, branding, display preferences, ordered collections directory
1697
2505
  themes/jant/ — Bundled Hugo theme (overrideable via layouts/ at the site root)
1698
- static/ — Copy files here to add them to the published site
2506
+ static/ — Copy files here to add them to the published site${staticListing}
1699
2507
  \`\`\`
1700
2508
 
1701
2509
  ## Customizing
@@ -1721,7 +2529,7 @@ Safe to re-run; files already on disk are reused. Anything that fails to downloa
1721
2529
  ## Notes
1722
2530
 
1723
2531
  - Each thread is a Hugo branch bundle. Replies live as nested leaf bundles with \`build.render = "never"\` so they do not produce standalone URLs; they render inside the thread page.
1724
- - \`/{reply-slug}/\` URLs are preserved via \`aliases:\` on the root post, so old links still land on the right thread anchor.
2532
+ - \`/{reply-slug}/\` URLs are preserved via \`aliases:\` on the root post, so old links still land on the right thread anchor.${feedNote}
1725
2533
  - Media is emitted under \`static/media/{id}.ext\` and referenced from a flat \`media:\` array on each post. When a storage provider has a configured public URL (R2/S3/local proxy), the exporter links to the provider URL instead of re-bundling the bytes.
1726
2534
  - Posts with \`draft: true\` in front matter are only built when you pass \`--buildDrafts\` to \`hugo\` / \`hugo serve\`.
1727
2535
  `;