@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
@@ -8,11 +8,24 @@
8
8
  * Jant theme, and the `data/*.toml` files consumed by templates.
9
9
  */
10
10
 
11
- import { describe, expect, it } from "vitest";
11
+ import { describe, expect, it, vi } from "vitest";
12
12
  import { unzipSync } from "fflate";
13
- import { createExportService } from "../services/export.js";
13
+ import {
14
+ createExportService,
15
+ deriveWorkerName,
16
+ isStoredExportFile,
17
+ WRANGLER_CONFIG_PATH,
18
+ type ExportFile,
19
+ } from "../services/export.js";
20
+ import { suggestSyncRepoName } from "../lib/github-sync-repo-name.js";
14
21
  import { parseFrontMatter } from "../lib/hugo-markdown.js";
15
- import type { Collection, Media, Post } from "../types.js";
22
+ import type {
23
+ Collection,
24
+ Media,
25
+ PathRecord,
26
+ Post,
27
+ SmartCollectionDirectoryEntry,
28
+ } from "../types.js";
16
29
  import {
17
30
  makeCollection,
18
31
  makeMedia,
@@ -40,6 +53,8 @@ interface FixtureOptions {
40
53
  aliasMap?: Map<string, string[]>;
41
54
  collectionSlugMap?: Map<string, string>;
42
55
  directoryItems?: unknown[];
56
+ smartCollections?: SmartCollectionDirectoryEntry[];
57
+ standalonePaths?: PathRecord[];
43
58
  }
44
59
 
45
60
  function buildServices(opts: FixtureOptions): ServicesArg {
@@ -53,6 +68,8 @@ function buildServices(opts: FixtureOptions): ServicesArg {
53
68
  aliasMap = new Map(),
54
69
  collectionSlugMap = new Map(collections.map((c) => [c.id, c.slug])),
55
70
  directoryItems,
71
+ smartCollections = [],
72
+ standalonePaths = [],
56
73
  } = opts;
57
74
 
58
75
  return {
@@ -61,6 +78,7 @@ function buildServices(opts: FixtureOptions): ServicesArg {
61
78
  },
62
79
  paths: {
63
80
  getPostSlugMap: async () => slugMap,
81
+ listStandalonePaths: async () => standalonePaths,
64
82
  getPostAliases: async () => aliasMap,
65
83
  getCollectionSlugMap: async () => collectionSlugMap,
66
84
  },
@@ -68,6 +86,7 @@ function buildServices(opts: FixtureOptions): ServicesArg {
68
86
  list: async () => collections,
69
87
  listDirectoryData: async () => ({
70
88
  collections: [],
89
+ smartCollections,
71
90
  items:
72
91
  directoryItems ??
73
92
  collections.map((collection) => ({
@@ -90,15 +109,96 @@ function buildServices(opts: FixtureOptions): ServicesArg {
90
109
  } as unknown as ServicesArg;
91
110
  }
92
111
 
93
- function filesToMap(
94
- list: { path: string; content: string | Uint8Array }[],
95
- ): Map<string, string | Uint8Array> {
112
+ function filesToMap(list: ExportFile[]): Map<string, string | Uint8Array> {
96
113
  const map = new Map<string, string | Uint8Array>();
97
- for (const f of list) map.set(f.path, f.content);
114
+ for (const f of list) {
115
+ if (!isStoredExportFile(f)) map.set(f.path, f.content);
116
+ }
98
117
  return map;
99
118
  }
100
119
 
120
+ /** Read the streamed archive the way a download receives it. */
121
+ async function readArchive(
122
+ stream: ReadableStream<Uint8Array>,
123
+ ): Promise<Record<string, Uint8Array>> {
124
+ return unzipSync(new Uint8Array(await new Response(stream).arrayBuffer()));
125
+ }
126
+
101
127
  describe("createExportService (Hugo)", () => {
128
+ // Hugo has its own summary rule and would cut somewhere else, so whether the
129
+ // timeline truncates a post is decided here and carried as front matter.
130
+ // Written only when true — a consumer reads its presence.
131
+ it("marks a truncated post in front matter and leaves a short one unmarked", async () => {
132
+ const para = (text: string) => ({
133
+ type: "paragraph",
134
+ content: [{ type: "text", text }],
135
+ });
136
+ const long = makePost({
137
+ id: "post-long",
138
+ slug: "long-read",
139
+ title: "A long read",
140
+ threadId: "post-long",
141
+ body: JSON.stringify({
142
+ type: "doc",
143
+ content: [
144
+ para("Alpha ".repeat(90).trim()),
145
+ para("Omega ".repeat(90).trim()),
146
+ ],
147
+ }),
148
+ });
149
+ const short = makePost({
150
+ id: "post-short",
151
+ slug: "short-note",
152
+ title: null,
153
+ threadId: "post-short",
154
+ body: JSON.stringify({ type: "doc", content: [para("Short enough.")] }),
155
+ });
156
+
157
+ const files = await createExportService(
158
+ {
159
+ posts: { list: async () => [long, short] },
160
+ paths: {
161
+ getPostSlugMap: async () =>
162
+ new Map([
163
+ ["post-long", "long-read"],
164
+ ["post-short", "short-note"],
165
+ ]),
166
+ listStandalonePaths: async () => [],
167
+ getPostAliases: async () => new Map(),
168
+ getCollectionSlugMap: async () => new Map(),
169
+ },
170
+ collections: {
171
+ list: async () => [],
172
+ listDirectoryData: async () => ({
173
+ collections: [],
174
+ items: [],
175
+ directoryItems: [],
176
+ }),
177
+ getCollectionsByPostIds: async () => new Map(),
178
+ getCollectionEntriesByThreadIds: async () => new Map(),
179
+ },
180
+ media: { getByPostIds: async () => new Map() },
181
+ } as unknown as ServicesArg,
182
+ makeSiteConfig(),
183
+ ).generateHugoFiles();
184
+
185
+ const read = async (path: string) => {
186
+ const file = files.find((f) => f.path === path);
187
+ expect(file, `missing ${path}`).toBeDefined();
188
+ const { frontMatter } = await parseFrontMatter(
189
+ typeof file!.content === "string"
190
+ ? file!.content
191
+ : new TextDecoder().decode(file!.content),
192
+ );
193
+ return frontMatter;
194
+ };
195
+
196
+ expect((await read("content/long-read/_index.md")).truncated).toBe(true);
197
+ expect(
198
+ (await read("content/short-note/_index.md")).truncated,
199
+ ).toBeUndefined();
200
+ });
201
+
102
202
  it("emits a branch bundle per root post with YAML front matter in stable key order", async () => {
103
203
  const root = makePost({
104
204
  id: "post-root",
@@ -251,6 +351,105 @@ describe("createExportService (Hugo)", () => {
251
351
  );
252
352
  });
253
353
 
354
+ it("emits no aliases for a root that is not published", async () => {
355
+ // Hugo writes an alias page for every `aliases:` entry whether or not the
356
+ // page it points at was built. On an unbuilt target `.Permalink` is
357
+ // empty, `<meta http-equiv=refresh content="0; url=">` means "reload this
358
+ // page" to a browser, and the old URL becomes an infinite refresh loop.
359
+ const draftRoot = makePost({
360
+ id: "d",
361
+ slug: "secret-plan",
362
+ threadId: "d",
363
+ status: "draft",
364
+ });
365
+ const reply = makePost({
366
+ id: "d-rep",
367
+ slug: "secret-reply",
368
+ replyToId: "d",
369
+ threadId: "d",
370
+ createdAt: 1773018000,
371
+ publishedAt: 1773018000,
372
+ });
373
+
374
+ const service = createExportService(
375
+ buildServices({
376
+ posts: [draftRoot, reply],
377
+ aliasMap: new Map([["d", ["/blog/old-plan/"]]]),
378
+ }),
379
+ makeSiteConfig(),
380
+ );
381
+ const files = filesToMap(await service.generateHugoFiles());
382
+ const { frontMatter } = await parseFrontMatter(
383
+ files.get("content/secret-plan/_index.md") as string,
384
+ );
385
+
386
+ expect(frontMatter.draft).toBe(true);
387
+ expect(frontMatter.aliases).toBeUndefined();
388
+ // The historical slugs still round-trip through import; it is only the
389
+ // Hugo-facing redirect list that an unpublished thread does not get.
390
+ expect(frontMatter.root_aliases).toEqual(["/blog/old-plan/"]);
391
+ });
392
+
393
+ it("emits no aliases for a private root either", async () => {
394
+ const privateRoot = makePost({
395
+ id: "p",
396
+ slug: "for-me",
397
+ threadId: "p",
398
+ visibility: "private",
399
+ });
400
+
401
+ const service = createExportService(
402
+ buildServices({
403
+ posts: [privateRoot],
404
+ aliasMap: new Map([["p", ["/blog/for-me/"]]]),
405
+ }),
406
+ makeSiteConfig(),
407
+ );
408
+ const files = filesToMap(await service.generateHugoFiles());
409
+ const { frontMatter } = await parseFrontMatter(
410
+ files.get("content/for-me/_index.md") as string,
411
+ );
412
+
413
+ expect(frontMatter.draft).toBe(true);
414
+ expect(frontMatter.aliases).toBeUndefined();
415
+ });
416
+
417
+ it("writes the entry ID Jant's feeds give each published root", async () => {
418
+ // `<id>` is the permalink Jant served: the oldest custom path in place of
419
+ // the slug, under the site path prefix, no trailing slash. The theme's
420
+ // feed writes it as it is, so subscribers don't see the post again.
421
+ const plain = makePost({ id: "a", slug: "xta29", threadId: "a" });
422
+ const custom = makePost({ id: "b", slug: "links-4", threadId: "b" });
423
+ const draft = makePost({
424
+ id: "c",
425
+ slug: "unfinished",
426
+ threadId: "c",
427
+ status: "draft",
428
+ });
429
+
430
+ const service = createExportService(
431
+ buildServices({
432
+ posts: [plain, custom, draft],
433
+ aliasMap: new Map([["b", ["/blog/links/4", "/links/four"]]]),
434
+ }),
435
+ makeSiteConfig({
436
+ siteUrl: "https://example.com/journal",
437
+ sitePathPrefix: "/journal",
438
+ }),
439
+ );
440
+ const files = filesToMap(await service.generateHugoFiles());
441
+ const feedId = async (slug: string) =>
442
+ (await parseFrontMatter(files.get(`content/${slug}/_index.md`) as string))
443
+ .frontMatter.feed_id;
444
+
445
+ expect(await feedId("xta29")).toBe("https://example.com/journal/xta29");
446
+ expect(await feedId("links-4")).toBe(
447
+ "https://example.com/journal/blog/links/4",
448
+ );
449
+ // Not in any feed, so there is no ID to keep.
450
+ expect(await feedId("unfinished")).toBeUndefined();
451
+ });
452
+
254
453
  it("merges historical root aliases + reply slugs onto the root", async () => {
255
454
  const root = makePost({ id: "r", slug: "new-slug", threadId: "r" });
256
455
  const reply = makePost({
@@ -341,6 +540,23 @@ describe("createExportService (Hugo)", () => {
341
540
  expect(toml).not.toContain("home_default_view");
342
541
  });
343
542
 
543
+ it("omits the site-level paginate key the theme never reads", async () => {
544
+ const service = createExportService(
545
+ buildServices({ posts: [] }),
546
+ makeSiteConfig({ pageSize: 25 }),
547
+ );
548
+ const toml = filesToMap(await service.generateHugoFiles()).get(
549
+ "hugo.toml",
550
+ ) as string;
551
+
552
+ // Every paginated template calls `.Paginate` with `params.page_size`, so
553
+ // a site-level pager size would never be read — and `paginate` was
554
+ // removed from Hugo, which fails the build outright rather than warning.
555
+ expect(toml).not.toMatch(/^paginate\b/m);
556
+ expect(toml).not.toContain("pagerSize");
557
+ expect(toml).toContain("page_size = 25");
558
+ });
559
+
344
560
  it("configures hugo.toml for Atom RSS output with per-section opt-in", async () => {
345
561
  // The root feed lives at /index.xml, featured/archive/collections feeds
346
562
  // are opted in via per-section `outputs = ["html", "rss"]` front matter.
@@ -451,6 +667,254 @@ describe("createExportService (Hugo)", () => {
451
667
  }
452
668
  });
453
669
 
670
+ // An import back into Jant restores navigation from this file, so it names
671
+ // what the theme doesn't need: the author's own label, the target by slug,
672
+ // and the settings entry the static site skips.
673
+ it("writes what an import needs to rebuild navigation", async () => {
674
+ const collection = makeCollection({
675
+ id: "col-now",
676
+ slug: "now",
677
+ title: "Now",
678
+ });
679
+ const about = makePost({ id: "pst-about", slug: "about", title: "About" });
680
+ const service = createExportService(
681
+ buildServices({ posts: [about], collections: [collection] }),
682
+ makeSiteConfig({
683
+ navItems: [
684
+ {
685
+ type: "system",
686
+ systemKey: "archive",
687
+ label: "All",
688
+ url: "/archive",
689
+ position: 0,
690
+ placement: "header",
691
+ },
692
+ {
693
+ type: "collection",
694
+ collectionId: "col-now",
695
+ label: "",
696
+ targetTitle: "Now",
697
+ url: "/now",
698
+ position: 1,
699
+ placement: "header",
700
+ },
701
+ {
702
+ type: "page",
703
+ postId: "pst-about",
704
+ label: "",
705
+ targetTitle: "About",
706
+ url: "/about",
707
+ position: 2,
708
+ placement: "more",
709
+ },
710
+ {
711
+ type: "system",
712
+ systemKey: "settings",
713
+ label: "",
714
+ url: "/settings",
715
+ position: 3,
716
+ placement: "more",
717
+ },
718
+ ],
719
+ }),
720
+ );
721
+ const data = (await service.generateHugoFiles()).find(
722
+ (file) => file.path === "data/jant.toml",
723
+ )?.content as string;
724
+
725
+ expect(data).toContain(
726
+ 'system_key = "archive"\nplacement = "header"\ncustom_label = "All"',
727
+ );
728
+ expect(data).toContain('collection_slug = "now"');
729
+ expect(data).toContain('placement = "more"\npost_slug = "about"');
730
+ expect(data).toContain('system_key = "settings"');
731
+ });
732
+
733
+ // The conditions travel, not the matches of the day: an import recreates the
734
+ // smart collection, and the theme evaluates the conditions against the posts
735
+ // in the repository. The collection a condition names goes by slug, which is
736
+ // what another site can resolve.
737
+ it("exports each smart collection's conditions as a section page", async () => {
738
+ const ideas = makeCollection({ id: "col-ideas", slug: "ideas" });
739
+ const smartCollection = (
740
+ over: Partial<SmartCollectionDirectoryEntry>,
741
+ ): SmartCollectionDirectoryEntry => ({
742
+ id: "smc-thoughts",
743
+ siteId: "site",
744
+ slug: "thoughts",
745
+ title: "Thoughts",
746
+ description: "Short notes.",
747
+ selection: {},
748
+ sort: "newest",
749
+ layout: null,
750
+ createdAt: 1,
751
+ updatedAt: 1,
752
+ threadCount: 4,
753
+ recentActivityAt: 1773020000,
754
+ ...over,
755
+ });
756
+ const thoughts = smartCollection({
757
+ selection: { format: "note", title: false },
758
+ });
759
+ const pictures = smartCollection({
760
+ id: "smc-pictures",
761
+ slug: "pictures",
762
+ title: "Pictures",
763
+ description: null,
764
+ selection: {
765
+ collection: ["col-ideas"],
766
+ media: ["image", "video"],
767
+ year: 2025,
768
+ replies: true,
769
+ visibility: "featured",
770
+ },
771
+ sort: "rating_desc",
772
+ layout: "grid",
773
+ });
774
+ const orphaned = smartCollection({
775
+ id: "smc-orphaned",
776
+ slug: "orphaned",
777
+ selection: { collection: ["col-gone"] },
778
+ });
779
+
780
+ const files = filesToMap(
781
+ await createExportService(
782
+ buildServices({
783
+ posts: [],
784
+ collections: [ideas],
785
+ smartCollections: [thoughts, pictures, orphaned],
786
+ directoryItems: [
787
+ {
788
+ id: "dir-1",
789
+ type: "smart_collection",
790
+ smartCollection: thoughts,
791
+ },
792
+ {
793
+ id: "dir-2",
794
+ type: "smart_collection",
795
+ smartCollection: orphaned,
796
+ },
797
+ ],
798
+ }),
799
+ makeSiteConfig({
800
+ navItems: [
801
+ {
802
+ type: "smart_collection",
803
+ smartCollectionId: "smc-pictures",
804
+ label: "",
805
+ targetTitle: "Pictures",
806
+ url: "/pictures",
807
+ position: 0,
808
+ placement: "header",
809
+ },
810
+ ],
811
+ }),
812
+ ).generateHugoFiles(),
813
+ );
814
+
815
+ const { frontMatter: thoughtsPage } = await parseFrontMatter(
816
+ files.get("content/thoughts/_index.md") as string,
817
+ );
818
+ expect(thoughtsPage).toEqual({
819
+ title: "Thoughts",
820
+ slug: "thoughts",
821
+ type: "smart_collection",
822
+ summary_text: "Short notes.",
823
+ sort_order: "newest",
824
+ selection: { format: "note", title: false },
825
+ outputs: ["html", "rss"],
826
+ });
827
+ const { frontMatter: picturesPage } = await parseFrontMatter(
828
+ files.get("content/pictures/_index.md") as string,
829
+ );
830
+ expect(picturesPage.selection).toEqual({
831
+ collection: "ideas",
832
+ media: ["image", "video"],
833
+ year: 2025,
834
+ replies: true,
835
+ visibility: "featured",
836
+ });
837
+ expect(picturesPage.sort_order).toBe("rating_desc");
838
+ expect(picturesPage.display_layout).toBe("grid");
839
+ expect(files.has("content/orphaned/_index.md")).toBe(false);
840
+
841
+ const { parse } = await import("smol-toml");
842
+ const data = parse(files.get("data/jant.toml") as string) as {
843
+ directory: Record<string, unknown>[];
844
+ nav: Record<string, unknown>[];
845
+ };
846
+ expect(data.directory).toEqual([
847
+ expect.objectContaining({
848
+ type: "smart_collection",
849
+ slug: "thoughts",
850
+ title: "Thoughts",
851
+ entry_count: 4,
852
+ }),
853
+ ]);
854
+ expect(data.nav[0]).toMatchObject({
855
+ type: "smart_collection",
856
+ smart_collection_slug: "pictures",
857
+ });
858
+ });
859
+
860
+ it("carries redirects and archive URLs that name no post", async () => {
861
+ const record = (over: Partial<PathRecord>): PathRecord => ({
862
+ id: "pth-1",
863
+ siteId: "site",
864
+ path: "",
865
+ kind: "redirect",
866
+ postId: null,
867
+ collectionId: null,
868
+ smartCollectionId: null,
869
+ redirectToPath: null,
870
+ redirectType: null,
871
+ archiveQuery: null,
872
+ createdAt: 1,
873
+ updatedAt: 1,
874
+ ...over,
875
+ });
876
+ const files = filesToMap(
877
+ await createExportService(
878
+ buildServices({
879
+ posts: [],
880
+ standalonePaths: [
881
+ record({
882
+ path: "atom.xml",
883
+ redirectToPath: "feed",
884
+ redirectType: 301,
885
+ }),
886
+ record({
887
+ path: "inspires",
888
+ redirectToPath: "inspired",
889
+ redirectType: 302,
890
+ }),
891
+ record({
892
+ path: "links",
893
+ kind: "archive",
894
+ archiveQuery: "format=link",
895
+ }),
896
+ ],
897
+ }),
898
+ makeSiteConfig(),
899
+ ).generateHugoFiles(),
900
+ );
901
+ const data = files.get("data/jant.toml") as string;
902
+ const redirects = files.get("static/_redirects") as string;
903
+
904
+ expect(data).toContain(
905
+ '[[custom_url]]\npath = "atom.xml"\nkind = "redirect"\nto = "/feed"\nstatus = 301',
906
+ );
907
+ expect(data).toContain(
908
+ 'path = "inspires"\nkind = "redirect"\nto = "/inspired"\nstatus = 302',
909
+ );
910
+ expect(data).toContain(
911
+ 'path = "links"\nkind = "archive"\narchive_query = "format=link"',
912
+ );
913
+ expect(redirects).toMatch(/^\/atom\.xml\s+\/feed\s+301$/m);
914
+ expect(redirects).toMatch(/^\/inspires\s+\/inspired\s+302$/m);
915
+ expect(redirects).not.toContain("/links");
916
+ });
917
+
454
918
  it("resolves the nav RSS link to /featured/index.xml when mainRssFeed=featured", async () => {
455
919
  const service = createExportService(
456
920
  buildServices({ posts: [] }),
@@ -496,6 +960,164 @@ describe("createExportService (Hugo)", () => {
496
960
  expect(data).toContain('url = "/index.xml"');
497
961
  });
498
962
 
963
+ it("resolves the nav Subscribe link to the feed the page would recommend", async () => {
964
+ const service = createExportService(
965
+ buildServices({ posts: [] }),
966
+ makeSiteConfig({
967
+ mainRssFeed: "featured",
968
+ navItems: [
969
+ {
970
+ type: "system",
971
+ systemKey: "subscribe",
972
+ label: "",
973
+ url: "/subscribe",
974
+ position: 0,
975
+ placement: "more",
976
+ },
977
+ ],
978
+ }),
979
+ );
980
+ const data = filesToMap(await service.generateHugoFiles()).get(
981
+ "data/jant.toml",
982
+ ) as string;
983
+
984
+ // There is no /subscribe page in the exported site to link at.
985
+ expect(data).not.toContain('url = "/subscribe"');
986
+ expect(data).toContain('url = "/featured/index.xml"');
987
+ expect(data).toContain('label = "Subscribe"');
988
+ });
989
+
990
+ it("redirects every runtime feed address to its exported counterpart", async () => {
991
+ const service = createExportService(
992
+ buildServices({ posts: [] }),
993
+ makeSiteConfig({ mainRssFeed: "latest" }),
994
+ );
995
+ const redirects = filesToMap(await service.generateHugoFiles()).get(
996
+ "static/_redirects",
997
+ ) as string;
998
+
999
+ // Feed readers hold the runtime addresses; nothing in the Hugo tree
1000
+ // answers them without these.
1001
+ const rules = redirects
1002
+ .split("\n")
1003
+ .filter((l) => l.startsWith("/"))
1004
+ .map((l) => l.split(/\s+/));
1005
+ expect(new Map(rules.map((r) => [r[0], r[1]]))).toEqual(
1006
+ new Map([
1007
+ ["/feed", "/index.xml"],
1008
+ ["/latest/feed", "/index.xml"],
1009
+ ["/featured/feed", "/featured/index.xml"],
1010
+ ["/archive/feed", "/archive/index.xml"],
1011
+ // The 308 aliases the live site still answers.
1012
+ ["/feed/latest", "/index.xml"],
1013
+ ["/feed/all", "/index.xml"],
1014
+ ["/feed/featured", "/featured/index.xml"],
1015
+ ["/feed/atom.xml", "/index.xml"],
1016
+ ["/feed/latest/atom.xml", "/index.xml"],
1017
+ ["/feed/all/atom.xml", "/index.xml"],
1018
+ ["/feed/featured/atom.xml", "/featured/index.xml"],
1019
+ ["/:slug/feed", "/:slug/index.xml"],
1020
+ ]),
1021
+ );
1022
+ // A feed reader follows a 301 and rewrites its stored address; a 302
1023
+ // would leave it polling the dead one forever.
1024
+ expect(rules.every((r) => r[2] === "301")).toBe(true);
1025
+ // First match wins in both hosts that read this file, so the collection
1026
+ // catch-all must not shadow the fixed sections.
1027
+ expect(rules.at(-1)?.[0]).toBe("/:slug/feed");
1028
+ });
1029
+
1030
+ it("points /feed at the featured feed when that is the site's main feed", async () => {
1031
+ const service = createExportService(
1032
+ buildServices({ posts: [] }),
1033
+ makeSiteConfig({ mainRssFeed: "featured" }),
1034
+ );
1035
+ const redirects = filesToMap(await service.generateHugoFiles()).get(
1036
+ "static/_redirects",
1037
+ ) as string;
1038
+
1039
+ expect(redirects).toMatch(/^\/feed\s+\/featured\/index\.xml\s+301$/m);
1040
+ expect(redirects).toMatch(
1041
+ /^\/feed\/atom\.xml\s+\/featured\/index\.xml\s+301$/m,
1042
+ );
1043
+ });
1044
+
1045
+ it("falls back to the latest feed when no featured section was emitted", async () => {
1046
+ // A post owning the `featured` slug takes the section's place, so
1047
+ // /featured/index.xml is never built. Redirecting there would turn a live
1048
+ // subscription into a 404; the latest feed is wider but real.
1049
+ const service = createExportService(
1050
+ buildServices({ posts: [makePost({ id: "post-1", slug: "featured" })] }),
1051
+ makeSiteConfig({ mainRssFeed: "featured" }),
1052
+ );
1053
+ const files = filesToMap(await service.generateHugoFiles());
1054
+ const redirects = files.get("static/_redirects") as string;
1055
+
1056
+ // The post took the path the featured section would have used, so the
1057
+ // section — and /featured/index.xml with it — was never built.
1058
+ expect(files.get("content/featured/_index.md") as string).not.toContain(
1059
+ "type: featured",
1060
+ );
1061
+ expect(redirects).not.toContain("/featured/index.xml");
1062
+ expect(redirects).toMatch(/^\/feed\s+\/index\.xml\s+301$/m);
1063
+ expect(redirects).toMatch(/^\/featured\/feed\s+\/index\.xml\s+301$/m);
1064
+ });
1065
+
1066
+ it("writes no feed redirects when the site publishes no feeds", async () => {
1067
+ const service = createExportService(
1068
+ buildServices({ posts: [] }),
1069
+ makeSiteConfig({ rssFeedsEnabled: false }),
1070
+ );
1071
+ const files = filesToMap(await service.generateHugoFiles());
1072
+
1073
+ expect(files.has("static/_redirects")).toBe(false);
1074
+ expect(files.get("README.md") as string).not.toContain("## Feeds");
1075
+ });
1076
+
1077
+ it("tells the README reader that feed addresses are the exception to URL continuity", async () => {
1078
+ const service = createExportService(
1079
+ buildServices({ posts: [] }),
1080
+ makeSiteConfig({ mainRssFeed: "latest" }),
1081
+ );
1082
+ const readme = filesToMap(await service.generateHugoFiles()).get(
1083
+ "README.md",
1084
+ ) as string;
1085
+
1086
+ // The Notes list advertises that post URLs survive the move; without this
1087
+ // it reads as a promise covering every URL.
1088
+ expect(readme).toContain("## Feeds");
1089
+ expect(readme).toContain("`/latest/feed`");
1090
+ expect(readme).toContain("static/_redirects");
1091
+ expect(readme).toContain("Feed addresses are the exception");
1092
+ // No /subscribe page exists in the export, and the README says where the
1093
+ // nav entry lands instead.
1094
+ expect(readme).toContain("has no `/subscribe` page");
1095
+ });
1096
+
1097
+ it("hides system Subscribe navigation when feeds are off", async () => {
1098
+ const service = createExportService(
1099
+ buildServices({ posts: [] }),
1100
+ makeSiteConfig({
1101
+ rssFeedsEnabled: false,
1102
+ navItems: [
1103
+ {
1104
+ type: "system",
1105
+ systemKey: "subscribe",
1106
+ label: "System subscribe",
1107
+ url: "/subscribe",
1108
+ position: 0,
1109
+ placement: "more",
1110
+ },
1111
+ ],
1112
+ }),
1113
+ );
1114
+ const data = filesToMap(await service.generateHugoFiles()).get(
1115
+ "data/jant.toml",
1116
+ ) as string;
1117
+
1118
+ expect(data).not.toContain('label = "System subscribe"');
1119
+ });
1120
+
499
1121
  it("emits last_activity_at on root post front matter so feed <updated> tracks thread bumps", async () => {
500
1122
  const post = makePost({
501
1123
  id: "post-1",
@@ -609,6 +1231,12 @@ describe("createExportService (Hugo)", () => {
609
1231
  "themes/jant/layouts/partials/reply.html",
610
1232
  "themes/jant/layouts/partials/featured-thread.html",
611
1233
  "themes/jant/layouts/partials/feed-post-content.xml",
1234
+ "themes/jant/layouts/partials/collection-threads.html",
1235
+ "themes/jant/layouts/partials/collection-members.html",
1236
+ "themes/jant/layouts/partials/latest-members.html",
1237
+ "themes/jant/layouts/partials/featured-members.html",
1238
+ "themes/jant/layouts/partials/smart-collection-members.html",
1239
+ "themes/jant/layouts/smart_collection/list.html",
612
1240
  ];
613
1241
  for (const path of expectedLayouts) {
614
1242
  expect(files.has(path), `missing ${path}`).toBe(true);
@@ -619,10 +1247,10 @@ describe("createExportService (Hugo)", () => {
619
1247
  expect(files.has("themes/jant/static/theme.css")).toBe(true);
620
1248
  expect(files.has("themes/jant/static/custom.css")).toBe(true);
621
1249
 
622
- const collectionList = files.get(
623
- "themes/jant/layouts/_default/list.html",
1250
+ const collectionThreads = files.get(
1251
+ "themes/jant/layouts/partials/collection-threads.html",
624
1252
  ) as string;
625
- expect(collectionList).toContain(
1253
+ expect(collectionThreads).toContain(
626
1254
  'class="thread thread-full{{ if $hasReplies }} thread-has-replies{{ end }}"',
627
1255
  );
628
1256
  });
@@ -663,9 +1291,9 @@ describe("createExportService (Hugo)", () => {
663
1291
  buildServices({ posts: [makePost()] }),
664
1292
  makeSiteConfig(),
665
1293
  );
666
- const zip = await service.generateHugoSite();
667
- expect(zip).toBeInstanceOf(Uint8Array);
668
- expect(zip.byteLength).toBeGreaterThan(0);
1294
+ const zip = new Uint8Array(
1295
+ await new Response(await service.generateHugoSite()).arrayBuffer(),
1296
+ );
669
1297
  // ZIP magic: PK\x03\x04
670
1298
  expect(zip[0]).toBe(0x50);
671
1299
  expect(zip[1]).toBe(0x4b);
@@ -810,18 +1438,60 @@ describe("createExportService (Hugo)", () => {
810
1438
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
811
1439
  { storage: storage as any },
812
1440
  );
813
- const files = filesToMap(await service.generateHugoFiles());
814
- expect(files.has("static/media/med-root.webp")).toBe(true);
815
- expect(files.has("static/media/med-reply.png")).toBe(true);
816
- expect(files.get("static/media/med-root.webp")).toEqual(
1441
+ // The file list names the objects; the archive reads them as it goes.
1442
+ const stored = (await service.generateHugoFiles()).filter(
1443
+ isStoredExportFile,
1444
+ );
1445
+ expect(stored).toEqual([
1446
+ { path: "static/media/med-root.webp", storageKey: "media/med-root.webp" },
1447
+ { path: "static/media/med-reply.png", storageKey: "media/med-reply.png" },
1448
+ ]);
1449
+ const archive = await readArchive(await service.generateHugoSite());
1450
+ expect(archive["static/media/med-root.webp"]).toEqual(
817
1451
  new Uint8Array([1, 2, 3]),
818
1452
  );
819
- expect(files.get("static/media/med-reply.png")).toEqual(
1453
+ expect(archive["static/media/med-reply.png"]).toEqual(
820
1454
  new Uint8Array([9, 9, 9, 9]),
821
1455
  );
822
1456
  // Sanity: the old per-bundle paths are gone.
823
- expect(files.has("content/with-media/med-root.webp")).toBe(false);
824
- expect(files.has("content/with-media/reply-one/med-reply.png")).toBe(false);
1457
+ expect(archive["content/with-media/med-root.webp"]).toBeUndefined();
1458
+ expect(
1459
+ archive["content/with-media/reply-one/med-reply.png"],
1460
+ ).toBeUndefined();
1461
+ });
1462
+
1463
+ it("leaves a missing object out of the archive and says so", async () => {
1464
+ const root = makePost({ id: "post-root", slug: "with-media" });
1465
+ const service = createExportService(
1466
+ buildServices({
1467
+ posts: [root],
1468
+ mediaByPost: new Map([
1469
+ [
1470
+ "post-root",
1471
+ [
1472
+ makeMedia({
1473
+ id: "med-gone",
1474
+ filename: "gone.webp",
1475
+ storageKey: "media/med-gone.webp",
1476
+ }),
1477
+ ],
1478
+ ],
1479
+ ]),
1480
+ }),
1481
+ makeSiteConfig(),
1482
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1483
+ { storage: { get: async () => null } as any },
1484
+ );
1485
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
1486
+
1487
+ const archive = await readArchive(await service.generateHugoSite());
1488
+
1489
+ expect(archive["static/media/med-gone.webp"]).toBeUndefined();
1490
+ expect(archive["hugo.toml"]).toBeDefined();
1491
+ expect(warn).toHaveBeenCalledWith(
1492
+ expect.stringContaining("media/med-gone.webp is missing from storage"),
1493
+ );
1494
+ warn.mockRestore();
825
1495
  });
826
1496
 
827
1497
  it("emits poster bytes and poster field for video media with posterKey", async () => {
@@ -861,12 +1531,11 @@ describe("createExportService (Hugo)", () => {
861
1531
  { storage: storage as any },
862
1532
  );
863
1533
  const files = filesToMap(await service.generateHugoFiles());
864
- expect(files.has("static/media/med-video.mp4")).toBe(true);
865
- expect(files.has("static/media/med-video-poster.webp")).toBe(true);
866
- expect(files.get("static/media/med-video.mp4")).toEqual(
1534
+ const archive = await readArchive(await service.generateHugoSite());
1535
+ expect(archive["static/media/med-video.mp4"]).toEqual(
867
1536
  new Uint8Array([10, 20, 30]),
868
1537
  );
869
- expect(files.get("static/media/med-video-poster.webp")).toEqual(
1538
+ expect(archive["static/media/med-video-poster.webp"]).toEqual(
870
1539
  new Uint8Array([40, 50, 60, 70]),
871
1540
  );
872
1541
 
@@ -1049,14 +1718,159 @@ describe("createExportService (Hugo)", () => {
1049
1718
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1050
1719
  { storage: storage as any, bundleMedia: false },
1051
1720
  );
1052
- const files = filesToMap(await service.generateHugoFiles());
1721
+ const exportFiles = await service.generateHugoFiles();
1722
+ const files = filesToMap(exportFiles);
1053
1723
  // No resolvable URL — bundling is the only way to avoid a broken link.
1054
- expect(files.get("static/media/med-1.webp")).toEqual(
1055
- new Uint8Array([1, 2, 3]),
1056
- );
1724
+ expect(exportFiles.filter(isStoredExportFile)).toEqual([
1725
+ { path: "static/media/med-1.webp", storageKey: "media/med-1.webp" },
1726
+ ]);
1057
1727
  const { frontMatter } = await parseFrontMatter(
1058
1728
  files.get("content/with-media/_index.md") as string,
1059
1729
  );
1060
1730
  expect(frontMatter.media![0].src).toBe("/media/med-1.webp");
1061
1731
  });
1062
1732
  });
1733
+
1734
+ describe("bundled theme", () => {
1735
+ it("reads site data only through the version-agnostic partial", async () => {
1736
+ const service = createExportService(
1737
+ buildServices({ posts: [] }),
1738
+ makeSiteConfig(),
1739
+ );
1740
+ const layouts = (await service.generateHugoFiles()).filter(
1741
+ (f) =>
1742
+ f.path.startsWith("themes/jant/layouts/") &&
1743
+ typeof f.content === "string",
1744
+ );
1745
+
1746
+ expect(layouts.length).toBeGreaterThan(0);
1747
+ // Hugo has no accessor for `data/` that works on every version: it added
1748
+ // `hugo.Data` in v0.160.0 and deprecated `site.Data` in v0.156.0, so a
1749
+ // template naming either one directly is a build failure on one side of
1750
+ // that line. `partials/jant-data.html` branches on the running version;
1751
+ // everything else goes through it.
1752
+ const offenders = layouts
1753
+ .filter(
1754
+ (f) =>
1755
+ f.path !== "themes/jant/layouts/partials/jant-data.html" &&
1756
+ /\b(?:hugo|site)\.Data\b/.test(f.content as string),
1757
+ )
1758
+ .map((f) => f.path);
1759
+
1760
+ expect(offenders).toEqual([]);
1761
+ });
1762
+ });
1763
+
1764
+ describe("deriveWorkerName", () => {
1765
+ it("uses the repository name when the export has one", () => {
1766
+ // Cloudflare names a Worker imported from a repository after the
1767
+ // repository, and fails the build when `wrangler.jsonc` disagrees — so
1768
+ // the repo name is the default that needs no editing.
1769
+ expect(
1770
+ deriveWorkerName("owenyoung-blog", "https://notes.example.com"),
1771
+ ).toBe("owenyoung-blog");
1772
+ });
1773
+
1774
+ it.each([
1775
+ ["My_Blog", "my-blog"],
1776
+ ["blog.owenyoung.com", "blog-owenyoung-com"],
1777
+ ["--site--", "site"],
1778
+ ])("normalizes repository name %s into %s", (repoName, expected) => {
1779
+ expect(deriveWorkerName(repoName, "https://example.com")).toBe(expected);
1780
+ });
1781
+
1782
+ it("falls back to the repository name GitHub Sync suggests", () => {
1783
+ // A downloaded export pushed to a repository created with the settings
1784
+ // page's prefilled name then matches the Worker Cloudflare creates.
1785
+ const siteUrl = "https://notes.example.com";
1786
+
1787
+ expect(deriveWorkerName(null, siteUrl)).toBe("notes-jant-sync");
1788
+ expect(deriveWorkerName(undefined, siteUrl)).toBe(
1789
+ suggestSyncRepoName(siteUrl),
1790
+ );
1791
+ expect(deriveWorkerName("___", siteUrl)).toBe("notes-jant-sync");
1792
+ });
1793
+
1794
+ it("skips a leading www in the fallback, as the settings page does", () => {
1795
+ expect(deriveWorkerName(null, "https://www.owenyoung.com")).toBe(
1796
+ "owenyoung-jant-sync",
1797
+ );
1798
+ });
1799
+
1800
+ it("turns the suggestion's underscores into hyphens Cloudflare accepts", () => {
1801
+ expect(deriveWorkerName(null, "https://my_notes.example.com")).toBe(
1802
+ "my-notes-jant-sync",
1803
+ );
1804
+ });
1805
+
1806
+ it("still yields a name when the site URL has no host", () => {
1807
+ expect(deriveWorkerName(null, "")).toBe("jant-site-sync");
1808
+ expect(deriveWorkerName("", "not a url")).toBe("jant-site-sync");
1809
+ });
1810
+
1811
+ it("keeps the name inside Cloudflare's 63-character limit", () => {
1812
+ const name = deriveWorkerName(`${"a".repeat(62)}.long-repo`, "");
1813
+
1814
+ expect(name).toHaveLength(62);
1815
+ expect(name).toMatch(/^[a-z0-9-]+$/);
1816
+ // Cut at 63, the name would end on the hyphen that replaced the dot.
1817
+ expect(name.endsWith("-")).toBe(false);
1818
+ });
1819
+ });
1820
+
1821
+ describe("wrangler.jsonc", () => {
1822
+ it("declares the Worker name, a compatibility date, the Hugo build, and public/ as the asset directory", async () => {
1823
+ const service = createExportService(
1824
+ buildServices({ posts: [] }),
1825
+ makeSiteConfig({ siteUrl: "https://www.owenyoung.com" }),
1826
+ { repoName: "owenyoung-blog" },
1827
+ );
1828
+ const files = await service.generateHugoFiles();
1829
+ const config = files.find((f) => f.path === WRANGLER_CONFIG_PATH);
1830
+
1831
+ expect(config).toBeDefined();
1832
+ const text = config!.content as string;
1833
+ expect(text).toContain('"name": "owenyoung-blog"');
1834
+ expect(text).toMatch(/"compatibility_date": "\d{4}-\d{2}-\d{2}"/);
1835
+ expect(text).toContain('"directory": "./public"');
1836
+ // Nothing else runs Hugo: Workers Builds detects a framework from a
1837
+ // `package.json`, a Hugo site has none, so the build command it offers
1838
+ // on connect is empty and the deploy uploads a `public/` that was never
1839
+ // built. Wrangler runs this before uploading.
1840
+ expect(text).toContain('"command": "hugo --gc --minify"');
1841
+ // An assets-only Worker needs no script.
1842
+ expect(text).not.toContain('"main"');
1843
+ // The theme emits no 404.html to point it at.
1844
+ expect(text).not.toContain("not_found_handling");
1845
+ });
1846
+
1847
+ it("names the Worker after the suggested repository when there is no repository", async () => {
1848
+ const service = createExportService(
1849
+ buildServices({ posts: [] }),
1850
+ makeSiteConfig({ siteUrl: "https://notes.example.com" }),
1851
+ );
1852
+ const text = (await service.generateHugoFiles()).find(
1853
+ (f) => f.path === WRANGLER_CONFIG_PATH,
1854
+ )!.content as string;
1855
+
1856
+ expect(text).toContain('"name": "notes-jant-sync"');
1857
+ });
1858
+
1859
+ it("is scaffolding the repository owns after the first write", async () => {
1860
+ const service = createExportService(
1861
+ buildServices({ posts: [] }),
1862
+ makeSiteConfig(),
1863
+ );
1864
+ const files = await service.generateHugoFiles();
1865
+
1866
+ // The Worker name is a guess at the site's host; correcting it by hand is
1867
+ // expected, and a later sync must not revert that.
1868
+ expect(
1869
+ files.find((f) => f.path === WRANGLER_CONFIG_PATH)?.scaffoldOnce,
1870
+ ).toBe(true);
1871
+ // Nothing else claims the flag — every other file is Jant's to rewrite.
1872
+ expect(files.filter((f) => f.scaffoldOnce).map((f) => f.path)).toEqual([
1873
+ WRANGLER_CONFIG_PATH,
1874
+ ]);
1875
+ });
1876
+ });