@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
@@ -1,16 +1,8 @@
1
- import {
2
- mkdir,
3
- mkdtemp,
4
- readFile,
5
- readdir,
6
- rm,
7
- stat,
8
- writeFile,
9
- } from "node:fs/promises";
1
+ import { openAsBlob } from "node:fs";
2
+ import { mkdtemp, readFile, readdir, rm, stat } from "node:fs/promises";
10
3
  import { resolve, join, extname, dirname, basename } from "node:path";
11
4
  import { tmpdir } from "node:os";
12
5
  import { parseArgs } from "node:util";
13
- import { typeidUnboxed } from "typeid-js";
14
6
  import { CLI_API_TOKEN_ENV_VAR, getCliApiToken } from "../lib/cli-api-token.js";
15
7
  import {
16
8
  extractAttachmentBlocks,
@@ -19,6 +11,7 @@ import {
19
11
  rewriteMediaReferences,
20
12
  } from "../lib/site-media-parser.js";
21
13
  import { parseFrontMatter as parseFrontMatterShared } from "../lib/hugo-markdown.js";
14
+ import { extractZipFile } from "../lib/zip-archive.js";
22
15
 
23
16
  /**
24
17
  * Parse front matter from a Markdown file.
@@ -32,22 +25,27 @@ async function parseToml(content) {
32
25
  }
33
26
 
34
27
  /**
35
- * Read custom.css from a Jant Hugo export. Prefers the root
36
- * `static/custom.css` (user override) and falls back to
37
- * `themes/jant/static/custom.css` (the default location Jant writes).
28
+ * Static directories of a Jant Hugo export, in Hugo's lookup order: the root
29
+ * `static/` (user overrides) before `themes/jant/static/`, where Jant writes
30
+ * `custom.css`, `favicon.ico`, and `apple-touch-icon.png`.
31
+ */
32
+ const IMPORT_STATIC_DIRS = [["static"], ["themes", "jant", "static"]];
33
+
34
+ /**
35
+ * Read custom.css from the first static directory of a Jant Hugo export that
36
+ * has one.
38
37
  */
39
38
  async function readImportCustomCss(rootDir) {
40
- const rootCss = await readFile(
41
- join(rootDir, "static", "custom.css"),
42
- "utf-8",
43
- ).catch(() => null);
44
- if (rootCss !== null) {
45
- return rootCss;
39
+ for (const staticDir of IMPORT_STATIC_DIRS) {
40
+ const css = await readFile(
41
+ join(rootDir, ...staticDir, "custom.css"),
42
+ "utf-8",
43
+ ).catch(() => null);
44
+ if (css !== null) {
45
+ return css;
46
+ }
46
47
  }
47
- return readFile(
48
- join(rootDir, "themes", "jant", "static", "custom.css"),
49
- "utf-8",
50
- ).catch(() => "");
48
+ return "";
51
49
  }
52
50
 
53
51
  function resolveImportUrl(url, siteConfig) {
@@ -148,26 +146,40 @@ async function resolveImportLocalAssetPath(rawUrl, siteConfig, sourceRootDir) {
148
146
  return null;
149
147
  }
150
148
 
151
- const fullPath = join(sourceRootDir, "static", pathname);
152
- const fileStat = await stat(fullPath).catch(() => null);
153
- if (!fileStat?.isFile()) {
154
- return null;
149
+ for (const staticDir of IMPORT_STATIC_DIRS) {
150
+ const fullPath = join(sourceRootDir, ...staticDir, pathname);
151
+ const fileStat = await stat(fullPath).catch(() => null);
152
+ if (fileStat?.isFile()) {
153
+ return fullPath;
154
+ }
155
155
  }
156
156
 
157
- return fullPath;
157
+ return null;
158
158
  }
159
159
 
160
+ /**
161
+ * Read one asset as a Blob, from the export's files or its URL.
162
+ *
163
+ * A local file comes back file-backed (`openAsBlob`): an upload streams it
164
+ * from disk. Reading every file into memory first held gigabytes during the
165
+ * import of a real site, since Node does not count Blob memory toward the
166
+ * garbage collector's pressure.
167
+ *
168
+ * @param {{ sourceUrl?: string, sourceFilePath?: string | null, mimeType?: string, originalName?: string }} options
169
+ * @returns {Promise<{ blob: Blob, filename: string, contentType: string } | null>}
170
+ * The asset, or null when a remote source is missing or unreachable
171
+ */
160
172
  async function readImportAsset(options) {
161
173
  const { sourceUrl, sourceFilePath, mimeType, originalName } = options;
162
174
 
163
175
  if (sourceFilePath) {
164
- const bytes = new Uint8Array(await readFile(sourceFilePath));
165
176
  const filename =
166
177
  originalName || basename(sourceFilePath) || getFilenameFromUrl(sourceUrl);
178
+ const contentType = mimeType || guessMimeType(filename);
167
179
  return {
168
- bytes,
180
+ blob: await openAsBlob(sourceFilePath, { type: contentType }),
169
181
  filename,
170
- contentType: mimeType || guessMimeType(filename),
182
+ contentType,
171
183
  };
172
184
  }
173
185
 
@@ -182,23 +194,17 @@ async function readImportAsset(options) {
182
194
 
183
195
  const bytes = new Uint8Array(await response.arrayBuffer());
184
196
  const filename = originalName || getFilenameFromUrl(sourceUrl) || "file";
197
+ const contentType =
198
+ mimeType ||
199
+ response.headers.get("content-type")?.split(";")[0] ||
200
+ guessMimeType(filename);
185
201
  return {
186
- bytes,
202
+ blob: new Blob([bytes], { type: contentType }),
187
203
  filename,
188
- contentType:
189
- mimeType ||
190
- response.headers.get("content-type")?.split(";")[0] ||
191
- guessMimeType(filename),
204
+ contentType,
192
205
  };
193
206
  }
194
207
 
195
- function toArrayBuffer(bytes) {
196
- return bytes.buffer.slice(
197
- bytes.byteOffset,
198
- bytes.byteOffset + bytes.byteLength,
199
- );
200
- }
201
-
202
208
  async function readMediaSpecAsset(media, field = "src") {
203
209
  if (field === "poster") {
204
210
  return readImportAsset({
@@ -305,45 +311,60 @@ async function assertImportSlugAvailable(target, slug, label, kind) {
305
311
  * Download a media file and upload it to the Jant API.
306
312
  * Returns the new URL, or null on failure.
307
313
  */
314
+ /**
315
+ * Upload one media file through the site's API.
316
+ *
317
+ * Throws when the file can't be read or the site refuses it, so a caller
318
+ * decides whether that is fatal (the export's own media) or leaves a link in
319
+ * place (an image in the body). It used to return null for either, and a
320
+ * post was created without the file and without a word.
321
+ *
322
+ * @param {Record<string, unknown>} media - A normalized media spec
323
+ * @param {string} apiUrl - Target site URL
324
+ * @param {string} token - API token
325
+ * @returns {Promise<{ url: string, id: string }>} The uploaded media
326
+ */
308
327
  async function uploadRemoteMedia(media, apiUrl, token) {
309
- try {
310
- const asset = await readMediaSpecAsset(media);
311
- if (!asset) return null;
312
-
313
- const blob = new Blob([asset.bytes], { type: asset.contentType });
314
-
315
- const formData = new FormData();
316
- formData.append("file", blob, asset.filename);
317
- if (media.alt) formData.append("alt", media.alt);
318
- if (media.summary) formData.append("summary", media.summary);
319
- if (media.width) formData.append("width", String(media.width));
320
- if (media.height) formData.append("height", String(media.height));
321
- if (media.blurhash) formData.append("blurhash", media.blurhash);
322
- if (media.waveform) formData.append("waveform", media.waveform);
323
-
324
- if (media.poster) {
325
- const posterAsset = await readMediaSpecAsset(media, "poster");
326
- if (posterAsset) {
327
- formData.append(
328
- "poster",
329
- new Blob([posterAsset.bytes], { type: posterAsset.contentType }),
330
- posterAsset.filename,
331
- );
332
- }
328
+ const asset = await readMediaSpecAsset(media);
329
+ if (!asset) {
330
+ throw new Error(`Couldn't read ${media.src}`);
331
+ }
332
+
333
+ const formData = new FormData();
334
+ formData.append("file", asset.blob, asset.filename);
335
+ if (media.alt) formData.append("alt", media.alt);
336
+ if (media.summary) formData.append("summary", media.summary);
337
+ if (media.width) formData.append("width", String(media.width));
338
+ if (media.height) formData.append("height", String(media.height));
339
+ if (media.blurhash) formData.append("blurhash", media.blurhash);
340
+ if (media.waveform) formData.append("waveform", media.waveform);
341
+ if (media.durationSeconds) {
342
+ formData.append("durationSeconds", String(media.durationSeconds));
343
+ }
344
+
345
+ if (media.poster) {
346
+ const posterAsset = await readMediaSpecAsset(media, "poster");
347
+ if (posterAsset) {
348
+ formData.append("poster", posterAsset.blob, posterAsset.filename);
349
+ } else {
350
+ console.warn(`Warning: couldn't read the poster ${media.poster}`);
333
351
  }
352
+ }
334
353
 
335
- const uploadResponse = await fetch(`${apiUrl}/api/upload`, {
336
- method: "POST",
337
- headers: { Authorization: `Bearer ${token}` },
338
- body: formData,
339
- });
354
+ const uploadResponse = await fetch(`${apiUrl}/api/upload`, {
355
+ method: "POST",
356
+ headers: { Authorization: `Bearer ${token}` },
357
+ body: formData,
358
+ });
340
359
 
341
- if (!uploadResponse.ok) return null;
342
- const data = await uploadResponse.json();
343
- return { url: data.url, id: data.id };
344
- } catch {
345
- return null;
360
+ if (!uploadResponse.ok) {
361
+ const detail = (await uploadResponse.text().catch(() => "")).slice(0, 300);
362
+ throw new Error(
363
+ `The site refused ${media.src}: HTTP ${uploadResponse.status}${detail ? ` ${detail}` : ""}`,
364
+ );
346
365
  }
366
+ const data = await uploadResponse.json();
367
+ return { url: data.url, id: data.id };
347
368
  }
348
369
 
349
370
  function getFilenameFromUrl(fileUrl) {
@@ -394,33 +415,6 @@ function guessMimeType(filename) {
394
415
  }
395
416
  }
396
417
 
397
- function generateImportedStorageKey(originalName) {
398
- const id = typeidUnboxed("med");
399
- const extension = extname(originalName) || "";
400
- const filename = `${id}${extension}`;
401
- return {
402
- id,
403
- filename,
404
- storageKey: `media/${filename}`,
405
- };
406
- }
407
-
408
- function getMediaPublicUrl(storageKey, provider, appConfig) {
409
- const base =
410
- provider === "s3"
411
- ? appConfig.s3PublicUrl
412
- : provider === "local"
413
- ? appConfig.localPublicUrl
414
- : appConfig.r2PublicUrl;
415
-
416
- if (base) {
417
- return `${base.replace(/\/+$/, "")}/${storageKey}`;
418
- }
419
-
420
- const prefix = appConfig.sitePathPrefix || "";
421
- return `${prefix}/${storageKey}`.replace(/\/{2,}/g, "/");
422
- }
423
-
424
418
  function normalizeImportedBodySegment(markdown) {
425
419
  const extracted = extractAttachmentBlocks(markdown);
426
420
  const normalized = normalizeImportedBody(extracted.markdown);
@@ -515,6 +509,10 @@ async function mediaSpecFromJantMedia(entry, sourceRootDir) {
515
509
  waveform: typeof entry.waveform === "string" ? entry.waveform : undefined,
516
510
  summary: typeof entry.summary === "string" ? entry.summary : undefined,
517
511
  chars: typeof entry.chars === "number" ? entry.chars : undefined,
512
+ durationSeconds:
513
+ typeof entry.duration_seconds === "number"
514
+ ? entry.duration_seconds
515
+ : undefined,
518
516
  };
519
517
  }
520
518
 
@@ -624,7 +622,7 @@ async function normalizeTextAttachmentSpec(spec, siteConfig, sourceRootDir) {
624
622
 
625
623
  let markdown;
626
624
  try {
627
- markdown = new TextDecoder("utf-8", { fatal: false }).decode(asset.bytes);
625
+ markdown = await asset.blob.text();
628
626
  } catch {
629
627
  return null;
630
628
  }
@@ -667,7 +665,16 @@ async function buildImportedAttachments(
667
665
  sourceRootDir,
668
666
  );
669
667
  if (!normalized || normalized.src.startsWith("data:")) continue;
670
- const result = await target.uploadMedia(normalized);
668
+ let result;
669
+ try {
670
+ result = await target.uploadMedia(normalized);
671
+ } catch (err) {
672
+ // The post's own attachment: creating the post without it would lose
673
+ // the file quietly.
674
+ throw new Error(`Couldn't upload ${spec.src}: ${err.message}`, {
675
+ cause: err,
676
+ });
677
+ }
671
678
  if (!result) continue;
672
679
  attachments.push({
673
680
  type: "media",
@@ -692,7 +699,14 @@ async function uploadMediaList(mediaSpecs, target, siteConfig, sourceRootDir) {
692
699
  sourceRootDir,
693
700
  );
694
701
  if (!normalized || normalized.src.startsWith("data:")) continue;
695
- const result = await target.uploadMedia(normalized);
702
+ let result;
703
+ try {
704
+ result = await target.uploadMedia(normalized);
705
+ } catch (err) {
706
+ // A body image may be a third party's; the Markdown keeps its URL.
707
+ console.warn(`Warning: kept ${spec.src} as a link. ${err.message}`);
708
+ continue;
709
+ }
696
710
  if (!result) continue;
697
711
  // Key the rewrite map by the *original* URL as it appears in the body
698
712
  // (e.g. `/media/...`). `normalized.src` has been resolved against
@@ -726,7 +740,16 @@ async function uploadBundleResources(resourceSpecs, target) {
726
740
  for (const spec of resourceSpecs) {
727
741
  if (!spec) continue;
728
742
  if (!spec.srcFilePath && !isAbsoluteUrl(spec.src)) continue;
729
- const result = await target.uploadMedia(spec);
743
+ let result;
744
+ try {
745
+ result = await target.uploadMedia(spec);
746
+ } catch (err) {
747
+ // The post's own attachment: creating the post without it would lose
748
+ // the file quietly.
749
+ throw new Error(`Couldn't upload ${spec.src}: ${err.message}`, {
750
+ cause: err,
751
+ });
752
+ }
730
753
  if (!result) continue;
731
754
  urlMap.set(spec.src, result.url);
732
755
  mediaIds.push(result.id);
@@ -888,6 +911,9 @@ async function loadSiteConfig(rootDir) {
888
911
  nav_exported: Array.isArray(jantData.nav),
889
912
  collections_directory: directoryItems,
890
913
  collections_directory_exported: directoryExported,
914
+ custom_urls: Array.isArray(jantData.custom_url)
915
+ ? jantData.custom_url
916
+ : [],
891
917
  },
892
918
  jant_export: {
893
919
  format:
@@ -908,8 +934,10 @@ function buildSettingsUpdatesFromConfig(siteConfig, customCss = "") {
908
934
  SITE_DESCRIPTION: String(siteConfig?.description || ""),
909
935
  SITE_LANGUAGE: String(siteConfig?.default_language || "en"),
910
936
  SITE_FOOTER: String(jant.site_footer_markdown || ""),
911
- SHOW_JANT_BRANDING_ON_HOME: jant.show_jant_branding_on_home ? "true" : "",
912
- NOINDEX: jant.noindex ? "true" : "",
937
+ SHOW_JANT_BRANDING_ON_HOME: jant.show_jant_branding_on_home
938
+ ? "true"
939
+ : "false",
940
+ NOINDEX: jant.noindex ? "true" : "false",
913
941
  PUBLIC_API_ENABLED: jant.public_api_enabled === false ? "false" : "true",
914
942
  RSS_FEEDS_ENABLED: jant.rss_feeds_enabled === false ? "false" : "true",
915
943
  SHOW_HEADER_AVATAR: jant.show_header_avatar ? "true" : "",
@@ -946,6 +974,28 @@ function splitSettingsUpdatesForImport(updates) {
946
974
  return { editable, internal };
947
975
  }
948
976
 
977
+ const NAV_PLACEMENTS = new Set(["header", "more"]);
978
+ const NAV_ITEM_TYPES = new Set([
979
+ "system",
980
+ "link",
981
+ "collection",
982
+ "smart_collection",
983
+ "page",
984
+ ]);
985
+
986
+ /**
987
+ * The slug a nav URL names, for exports older than `collection_slug` and
988
+ * `post_slug`: `/now` or `/now/` → `now`. Anything else names no slug.
989
+ *
990
+ * @param {unknown} url - The exported nav URL
991
+ * @returns {string | null} The slug, or null
992
+ */
993
+ function getSlugFromNavUrl(url) {
994
+ if (typeof url !== "string") return null;
995
+ const match = url.match(/^\/([^/?#]+)\/?$/);
996
+ return match ? decodeURIComponent(match[1]) : null;
997
+ }
998
+
949
999
  function normalizeImportedNavItems(siteConfig) {
950
1000
  const jant = siteConfig?.extra?.jant || {};
951
1001
  const navItems = jant.nav;
@@ -961,23 +1011,217 @@ function normalizeImportedNavItems(siteConfig) {
961
1011
  items: navItems
962
1012
  .map((item) => {
963
1013
  if (!item || typeof item !== "object") return null;
964
- const type = item.type === "system" ? "system" : "link";
965
- if (type === "system" && typeof item.system_key === "string") {
966
- return { type, systemKey: item.system_key };
1014
+ const type = NAV_ITEM_TYPES.has(item.type) ? item.type : "link";
1015
+ const placement = NAV_PLACEMENTS.has(item.placement)
1016
+ ? item.placement
1017
+ : "header";
1018
+ const label = typeof item.label === "string" ? item.label : "";
1019
+ const url = typeof item.url === "string" ? item.url : "";
1020
+ const customLabel =
1021
+ typeof item.custom_label === "string" && item.custom_label.trim()
1022
+ ? item.custom_label
1023
+ : null;
1024
+
1025
+ if (type === "system") {
1026
+ if (typeof item.system_key !== "string" || !item.system_key) {
1027
+ return null;
1028
+ }
1029
+ return { type, systemKey: item.system_key, customLabel, placement };
967
1030
  }
968
- if (
969
- type === "link" &&
970
- typeof item.label === "string" &&
971
- typeof item.url === "string"
972
- ) {
973
- return { type, label: item.label, url: item.url };
1031
+ if (type === "collection") {
1032
+ const slug =
1033
+ typeof item.collection_slug === "string"
1034
+ ? item.collection_slug
1035
+ : getSlugFromNavUrl(url);
1036
+ return { type, slug, customLabel, label, url, placement };
974
1037
  }
975
- return null;
1038
+ if (type === "page") {
1039
+ const slug =
1040
+ typeof item.post_slug === "string"
1041
+ ? item.post_slug
1042
+ : getSlugFromNavUrl(url);
1043
+ return { type, slug, customLabel, label, url, placement };
1044
+ }
1045
+ if (type === "smart_collection") {
1046
+ const slug =
1047
+ typeof item.smart_collection_slug === "string"
1048
+ ? item.smart_collection_slug
1049
+ : getSlugFromNavUrl(url);
1050
+ return { type, slug, customLabel, label, url, placement };
1051
+ }
1052
+ if (!label || !url) return null;
1053
+ return { type: "link", label: customLabel ?? label, url, placement };
976
1054
  })
977
1055
  .filter(Boolean),
978
1056
  };
979
1057
  }
980
1058
 
1059
+ /**
1060
+ * The custom URLs an export lists that name no post or collection: redirects,
1061
+ * and legacy archive URLs the importer reports but can't recreate.
1062
+ *
1063
+ * @param {Record<string, unknown>} siteConfig - From `loadSiteConfig`
1064
+ * @returns {Array<{ path: string, kind: "redirect" | "archive", to?: string, status?: 301 | 302, archiveQuery?: string }>}
1065
+ */
1066
+ function normalizeImportedCustomUrls(siteConfig) {
1067
+ const entries = siteConfig?.extra?.jant?.custom_urls;
1068
+ if (!Array.isArray(entries)) return [];
1069
+
1070
+ return entries
1071
+ .map((entry) => {
1072
+ if (!entry || typeof entry !== "object") return null;
1073
+ const path =
1074
+ typeof entry.path === "string" ? entry.path.replace(/^\/+/, "") : "";
1075
+ if (!path) return null;
1076
+ if (entry.kind === "redirect" && typeof entry.to === "string") {
1077
+ return {
1078
+ path,
1079
+ kind: "redirect",
1080
+ to: entry.to,
1081
+ status: entry.status === 302 ? 302 : 301,
1082
+ };
1083
+ }
1084
+ if (entry.kind === "archive") {
1085
+ return {
1086
+ path,
1087
+ kind: "archive",
1088
+ archiveQuery:
1089
+ typeof entry.archive_query === "string"
1090
+ ? entry.archive_query
1091
+ : undefined,
1092
+ };
1093
+ }
1094
+ return null;
1095
+ })
1096
+ .filter(Boolean);
1097
+ }
1098
+
1099
+ /**
1100
+ * The create request for one imported nav item, once the posts and
1101
+ * collections it may point at exist. A target that didn't come across stays
1102
+ * in the navigation as a plain link to the same address.
1103
+ *
1104
+ * @param {Record<string, unknown>} item - From `normalizeImportedNavItems`
1105
+ * @param {{ collectionSlugToId: Map<string, string>, smartCollectionSlugToId: Map<string, string>, postSlugToId: Map<string, string> }} targets
1106
+ * @returns {{ payload: Record<string, unknown>, customLabel: string | null, warning: string | null }}
1107
+ */
1108
+ function buildNavItemCreateRequest(item, targets) {
1109
+ const { placement, customLabel } = item;
1110
+
1111
+ if (item.type === "system") {
1112
+ return {
1113
+ payload: { type: "system", systemKey: item.systemKey, placement },
1114
+ customLabel,
1115
+ warning: null,
1116
+ };
1117
+ }
1118
+ if (item.type === "link") {
1119
+ return {
1120
+ payload: { type: "link", label: item.label, url: item.url, placement },
1121
+ customLabel: null,
1122
+ warning: null,
1123
+ };
1124
+ }
1125
+
1126
+ const targetId =
1127
+ item.type === "collection"
1128
+ ? targets.collectionSlugToId.get(item.slug)
1129
+ : item.type === "smart_collection"
1130
+ ? targets.smartCollectionSlugToId.get(item.slug)
1131
+ : item.type === "page"
1132
+ ? targets.postSlugToId.get(item.slug)
1133
+ : undefined;
1134
+ if (targetId) {
1135
+ const targetField =
1136
+ item.type === "collection"
1137
+ ? "collectionId"
1138
+ : item.type === "smart_collection"
1139
+ ? "smartCollectionId"
1140
+ : "postId";
1141
+ return {
1142
+ payload: {
1143
+ type: item.type,
1144
+ [targetField]: targetId,
1145
+ ...(customLabel ? { label: customLabel } : {}),
1146
+ placement,
1147
+ },
1148
+ customLabel: null,
1149
+ warning: null,
1150
+ };
1151
+ }
1152
+
1153
+ return {
1154
+ payload: {
1155
+ type: "link",
1156
+ label: customLabel ?? item.label,
1157
+ url: item.url,
1158
+ placement,
1159
+ },
1160
+ customLabel: null,
1161
+ warning: `The ${item.type.replace("_", " ")} behind "${item.label}" wasn't imported; it stays in the navigation as a link to ${item.url}.`,
1162
+ };
1163
+ }
1164
+
1165
+ /**
1166
+ * The create request for one exported smart collection.
1167
+ *
1168
+ * The conditions arrive as the export spells them, with the collection one
1169
+ * naming a slug; it becomes this site's ID. When that collection didn't come
1170
+ * across, the smart collection is skipped rather than created without the
1171
+ * condition, which would widen it to posts it never held.
1172
+ *
1173
+ * @param {{ slug: string, frontMatter: Record<string, unknown> }} bundle - A `type: smart_collection` section
1174
+ * @param {Map<string, string>} collectionSlugToId - Imported collections
1175
+ * @returns {{ payload: Record<string, unknown> | null, warning: string | null }}
1176
+ * @example
1177
+ * buildSmartCollectionCreateRequest(bundle, new Map([["ideas", "col_01…"]]));
1178
+ */
1179
+ function buildSmartCollectionCreateRequest(bundle, collectionSlugToId) {
1180
+ const frontMatter = bundle.frontMatter;
1181
+ const exported = frontMatter.selection;
1182
+ const selection =
1183
+ exported && typeof exported === "object" && !Array.isArray(exported)
1184
+ ? { ...exported }
1185
+ : {};
1186
+
1187
+ if (selection.collection !== undefined) {
1188
+ const collectionId =
1189
+ typeof selection.collection === "string"
1190
+ ? collectionSlugToId.get(selection.collection)
1191
+ : undefined;
1192
+ if (!collectionId) {
1193
+ return {
1194
+ payload: null,
1195
+ warning: `skipped the smart collection /${bundle.slug}: the collection it filters by, "${selection.collection}", wasn't imported.`,
1196
+ };
1197
+ }
1198
+ selection.collection = [collectionId];
1199
+ }
1200
+
1201
+ const title =
1202
+ typeof frontMatter.title === "string" && frontMatter.title.trim()
1203
+ ? frontMatter.title
1204
+ : bundle.slug;
1205
+ return {
1206
+ payload: {
1207
+ slug: bundle.slug,
1208
+ title,
1209
+ ...(typeof frontMatter.summary_text === "string" &&
1210
+ frontMatter.summary_text.trim()
1211
+ ? { description: frontMatter.summary_text }
1212
+ : {}),
1213
+ selection,
1214
+ ...(typeof frontMatter.sort_order === "string"
1215
+ ? { sort: frontMatter.sort_order }
1216
+ : {}),
1217
+ ...(typeof frontMatter.display_layout === "string"
1218
+ ? { layout: frontMatter.display_layout }
1219
+ : {}),
1220
+ },
1221
+ warning: null,
1222
+ };
1223
+ }
1224
+
981
1225
  function normalizeImportedCollectionDirectory(siteConfig) {
982
1226
  const jant = siteConfig?.extra?.jant || {};
983
1227
  const directoryItems = jant.collections_directory;
@@ -995,12 +1239,12 @@ function normalizeImportedCollectionDirectory(siteConfig) {
995
1239
  if (!item || typeof item !== "object") return null;
996
1240
 
997
1241
  if (
998
- item.type === "collection" &&
1242
+ (item.type === "collection" || item.type === "smart_collection") &&
999
1243
  typeof item.slug === "string" &&
1000
1244
  item.slug.trim()
1001
1245
  ) {
1002
1246
  return {
1003
- type: "collection",
1247
+ type: item.type,
1004
1248
  slug: item.slug.trim(),
1005
1249
  };
1006
1250
  }
@@ -1021,6 +1265,10 @@ function normalizeImportedCollectionDirectory(siteConfig) {
1021
1265
  type: "link",
1022
1266
  label: item.label,
1023
1267
  url: item.url,
1268
+ description:
1269
+ typeof item.description === "string" && item.description.trim()
1270
+ ? item.description
1271
+ : null,
1024
1272
  };
1025
1273
  }
1026
1274
 
@@ -1149,6 +1397,7 @@ async function syncImportedCollectionDirectory(
1149
1397
  target,
1150
1398
  importedDirectory,
1151
1399
  collectionSlugToId,
1400
+ smartCollectionSlugToId,
1152
1401
  ) {
1153
1402
  if (!importedDirectory.exported) {
1154
1403
  return { created: 0, deleted: 0, moved: 0 };
@@ -1156,8 +1405,12 @@ async function syncImportedCollectionDirectory(
1156
1405
 
1157
1406
  let deleted = 0;
1158
1407
  const existingItems = await target.listCollectionDirectoryItems();
1408
+ // Collections and smart collections get their row when they are created;
1409
+ // dividers and links exist only in the directory and are rebuilt from it.
1159
1410
  for (const item of existingItems) {
1160
- if (item.type === "collection") continue;
1411
+ if (item.type === "collection" || item.type === "smart_collection") {
1412
+ continue;
1413
+ }
1161
1414
  const removed = await target.deleteCollectionDirectoryItem(item.id);
1162
1415
  if (removed !== false) {
1163
1416
  deleted += 1;
@@ -1165,24 +1418,33 @@ async function syncImportedCollectionDirectory(
1165
1418
  }
1166
1419
 
1167
1420
  let currentItems = await target.listCollectionDirectoryItems();
1168
- const collectionItemIds = new Map(
1169
- currentItems
1170
- .filter((item) => item.type === "collection" && item.collectionId)
1171
- .map((item) => [item.collectionId, item.id]),
1172
- );
1421
+ // Directory rows by what they list: `collection:<id>` or
1422
+ // `smart_collection:<id>`.
1423
+ const rowIdsByTarget = new Map();
1424
+ for (const item of currentItems) {
1425
+ if (item.type === "collection" && item.collectionId) {
1426
+ rowIdsByTarget.set(`collection:${item.collectionId}`, item.id);
1427
+ } else if (item.type === "smart_collection" && item.smartCollectionId) {
1428
+ rowIdsByTarget.set(`smart_collection:${item.smartCollectionId}`, item.id);
1429
+ }
1430
+ }
1173
1431
 
1174
1432
  const desiredIds = [];
1175
- const seenCollectionIds = new Set();
1433
+ const seenTargets = new Set();
1176
1434
  let created = 0;
1177
1435
 
1178
1436
  for (const item of importedDirectory.items) {
1179
- if (item.type === "collection") {
1180
- const collectionId = collectionSlugToId.get(item.slug);
1181
- if (!collectionId || seenCollectionIds.has(collectionId)) {
1437
+ if (item.type === "collection" || item.type === "smart_collection") {
1438
+ const targetId =
1439
+ item.type === "collection"
1440
+ ? collectionSlugToId.get(item.slug)
1441
+ : smartCollectionSlugToId.get(item.slug);
1442
+ const key = `${item.type}:${targetId}`;
1443
+ if (!targetId || seenTargets.has(key)) {
1182
1444
  continue;
1183
1445
  }
1184
- seenCollectionIds.add(collectionId);
1185
- const directoryItemId = collectionItemIds.get(collectionId);
1446
+ seenTargets.add(key);
1447
+ const directoryItemId = rowIdsByTarget.get(key);
1186
1448
  if (directoryItemId) {
1187
1449
  desiredIds.push(directoryItemId);
1188
1450
  }
@@ -1199,6 +1461,7 @@ async function syncImportedCollectionDirectory(
1199
1461
  type: "link",
1200
1462
  label: item.label,
1201
1463
  url: item.url,
1464
+ ...(item.description ? { description: item.description } : {}),
1202
1465
  },
1203
1466
  );
1204
1467
  desiredIds.push(createdItem.id);
@@ -1253,17 +1516,6 @@ async function getIncompleteSetupError(target, targetLabel) {
1253
1516
  return buildIncompleteSetupError(targetLabel);
1254
1517
  }
1255
1518
 
1256
- function createUploadFile(name, type, bytes) {
1257
- return {
1258
- name,
1259
- type,
1260
- size: bytes.byteLength,
1261
- stream() {
1262
- return new Blob([bytes], { type }).stream();
1263
- },
1264
- };
1265
- }
1266
-
1267
1519
  class ApiError extends Error {
1268
1520
  constructor(status, text) {
1269
1521
  super(`HTTP ${status}: ${text}`);
@@ -1290,10 +1542,12 @@ async function apiCall(method, path, apiUrl, token, body) {
1290
1542
  cause === "UNABLE_TO_VERIFY_LEAF_SIGNATURE" ||
1291
1543
  cause?.includes("certificate")
1292
1544
  ) {
1293
- console.error(`\nSSL certificate error connecting to ${apiUrl}`);
1294
- console.error("If using a local/self-signed certificate, run with:");
1295
- console.error(" NODE_TLS_REJECT_UNAUTHORIZED=0 jant import-site ...");
1296
- console.error("Or use: node --use-system-ca bin/jant.js import-site ...");
1545
+ console.error(`\nCertificate error connecting to ${apiUrl}`);
1546
+ console.error(
1547
+ "The certificate is not signed by a CA that Node trusts. For a local",
1548
+ );
1549
+ console.error("or self-signed target, trust this machine's CA store:");
1550
+ console.error(" NODE_OPTIONS=--use-system-ca jant site import ...");
1297
1551
  process.exit(1);
1298
1552
  }
1299
1553
  throw new Error(
@@ -1344,6 +1598,12 @@ function createRemoteTarget(apiUrl, token) {
1344
1598
  async deleteNavItem(id) {
1345
1599
  return apiCall("DELETE", `/api/nav-items/${id}`, apiUrl, token);
1346
1600
  },
1601
+ async updateNavItem(id, data) {
1602
+ return apiCall("PUT", `/api/nav-items/${id}`, apiUrl, token, data);
1603
+ },
1604
+ async createCustomUrl(data) {
1605
+ return apiCall("POST", "/api/custom-urls", apiUrl, token, data);
1606
+ },
1347
1607
  async removeSiteAvatar() {
1348
1608
  return apiCall("DELETE", "/api/settings/avatar", apiUrl, token);
1349
1609
  },
@@ -1357,11 +1617,7 @@ function createRemoteTarget(apiUrl, token) {
1357
1617
  }
1358
1618
 
1359
1619
  const formData = new FormData();
1360
- formData.append(
1361
- "file",
1362
- new Blob([avatarAsset.bytes], { type: avatarAsset.contentType }),
1363
- avatarAsset.filename,
1364
- );
1620
+ formData.append("file", avatarAsset.blob, avatarAsset.filename);
1365
1621
 
1366
1622
  if (data.faviconUrl || data.faviconFilePath) {
1367
1623
  const faviconAsset = await readImportAsset({
@@ -1371,13 +1627,7 @@ function createRemoteTarget(apiUrl, token) {
1371
1627
  originalName: "favicon.ico",
1372
1628
  });
1373
1629
  if (faviconAsset) {
1374
- formData.append(
1375
- "favicon",
1376
- new Blob([faviconAsset.bytes], {
1377
- type: faviconAsset.contentType,
1378
- }),
1379
- faviconAsset.filename,
1380
- );
1630
+ formData.append("favicon", faviconAsset.blob, faviconAsset.filename);
1381
1631
  }
1382
1632
  }
1383
1633
 
@@ -1389,9 +1639,7 @@ function createRemoteTarget(apiUrl, token) {
1389
1639
  if (appleTouchAsset) {
1390
1640
  formData.append(
1391
1641
  "appleTouch",
1392
- new Blob([appleTouchAsset.bytes], {
1393
- type: appleTouchAsset.contentType,
1394
- }),
1642
+ appleTouchAsset.blob,
1395
1643
  appleTouchAsset.filename,
1396
1644
  );
1397
1645
  }
@@ -1427,6 +1675,25 @@ function createRemoteTarget(apiUrl, token) {
1427
1675
  async createCollection(data) {
1428
1676
  return apiCall("POST", "/api/collections", apiUrl, token, data);
1429
1677
  },
1678
+ async listSmartCollections() {
1679
+ const result = await apiCall(
1680
+ "GET",
1681
+ "/api/smart-collections",
1682
+ apiUrl,
1683
+ token,
1684
+ );
1685
+ return result.smartCollections || [];
1686
+ },
1687
+ async createSmartCollection(data) {
1688
+ const result = await apiCall(
1689
+ "POST",
1690
+ "/api/smart-collections",
1691
+ apiUrl,
1692
+ token,
1693
+ data,
1694
+ );
1695
+ return result.smartCollection;
1696
+ },
1430
1697
  async createCollectionDirectoryItem(data) {
1431
1698
  return apiCall(
1432
1699
  "POST",
@@ -1462,6 +1729,15 @@ function createRemoteTarget(apiUrl, token) {
1462
1729
  toRemotePostPayload(data),
1463
1730
  );
1464
1731
  },
1732
+ async linkTranslation(postId, otherPostId) {
1733
+ return apiCall(
1734
+ "POST",
1735
+ `/api/posts/${postId}/translations`,
1736
+ apiUrl,
1737
+ token,
1738
+ { postId: otherPostId },
1739
+ );
1740
+ },
1465
1741
  async createAlias(path, targetSlug) {
1466
1742
  return apiCall("POST", "/api/custom-urls", apiUrl, token, {
1467
1743
  path,
@@ -1484,23 +1760,76 @@ function createRemoteTarget(apiUrl, token) {
1484
1760
  };
1485
1761
  }
1486
1762
 
1763
+ function getBundleTimestamp(bundle) {
1764
+ return parseImportTimestamp(bundle.frontMatter.date);
1765
+ }
1766
+
1767
+ function getBundleSourceId(bundle) {
1768
+ return typeof bundle.frontMatter.id === "string" ? bundle.frontMatter.id : "";
1769
+ }
1770
+
1771
+ /**
1772
+ * Order bundles by `date`, then by the original TypeID (time-sortable, so it
1773
+ * breaks a same-second tie the way the source site did), then by directory.
1774
+ *
1775
+ * @param {{ frontMatter: Record<string, unknown>, dir: string }} a
1776
+ * @param {{ frontMatter: Record<string, unknown>, dir: string }} b
1777
+ * @returns {number} Negative when `a` comes first
1778
+ */
1779
+ function compareBundlesByDateThenId(a, b) {
1780
+ const aDate = getBundleTimestamp(a);
1781
+ const bDate = getBundleTimestamp(b);
1782
+ if (aDate !== null && bDate !== null && aDate !== bDate) return aDate - bDate;
1783
+ if (aDate !== null && bDate === null) return -1;
1784
+ if (aDate === null && bDate !== null) return 1;
1785
+
1786
+ const aId = getBundleSourceId(a);
1787
+ const bId = getBundleSourceId(b);
1788
+ if (aId && bId && aId !== bId) return aId < bId ? -1 : 1;
1789
+
1790
+ return basename(a.dir).localeCompare(basename(b.dir));
1791
+ }
1792
+
1793
+ /**
1794
+ * Order reply bundles by their exported `weight` (position in the Thread),
1795
+ * falling back to {@link compareBundlesByDateThenId} for older exports.
1796
+ *
1797
+ * @param {{ frontMatter: Record<string, unknown>, dir: string }} a
1798
+ * @param {{ frontMatter: Record<string, unknown>, dir: string }} b
1799
+ * @returns {number} Negative when `a` comes first
1800
+ */
1801
+ function compareReplyBundles(a, b) {
1802
+ const aWeight = a.frontMatter.weight;
1803
+ const bWeight = b.frontMatter.weight;
1804
+ if (
1805
+ typeof aWeight === "number" &&
1806
+ typeof bWeight === "number" &&
1807
+ aWeight !== bWeight
1808
+ ) {
1809
+ return aWeight - bWeight;
1810
+ }
1811
+ return compareBundlesByDateThenId(a, b);
1812
+ }
1813
+
1487
1814
  /**
1488
1815
  * Walk `content/` and classify each `_index.md` / `index.md` bundle by its
1489
1816
  * front-matter `type`. Returns ordered root-post bundles (with child reply
1490
- * bundles attached) and stand-alone collection landing pages.
1817
+ * bundles attached), collection landing pages, and smart collection pages.
1491
1818
  *
1492
1819
  * Algorithm:
1493
1820
  * 1. Recurse into `content/` collecting every directory that has either
1494
1821
  * `_index.md` (branch bundle / section) or `index.md` (leaf bundle).
1495
1822
  * 2. For each `_index.md`, read front matter. `type: "post"` (or a
1496
1823
  * missing `type` with post-shaped keys) → root bundle. `type:
1497
- * "collection"` → collection landing page. Other known section types
1824
+ * "collection"` → collection landing page, `type: "smart_collection"` →
1825
+ * smart collection conditions. Other known section types
1498
1826
  * (`home`, `featured`, `archive`, `collections`) are recorded and
1499
1827
  * skipped for post import.
1500
1828
  * 3. For each root bundle, enumerate immediate child directories; any
1501
1829
  * child dir containing `index.md` becomes a reply leaf bundle.
1502
- * 4. Reply bundles are sorted by `frontMatter.date` ascending (fallback:
1503
- * directory name) so thread order is deterministic.
1830
+ * 4. Reply bundles are sorted by `weight` (Thread position), then `date`,
1831
+ * then the original ID; root bundles by `date`, then the original ID.
1832
+ * Creating posts in that order keeps same-second ties as they were.
1504
1833
  */
1505
1834
  async function walkHugoContent(rootDir) {
1506
1835
  const contentDir = join(rootDir, "content");
@@ -1586,6 +1915,7 @@ async function walkHugoContent(rootDir) {
1586
1915
  // Attach leaf children to their parent root bundles.
1587
1916
  const rootBundles = [];
1588
1917
  const collectionBundles = [];
1918
+ const smartCollectionBundles = [];
1589
1919
 
1590
1920
  for (const record of dirs.values()) {
1591
1921
  if (record.kind === "leaf") {
@@ -1600,6 +1930,10 @@ async function walkHugoContent(rootDir) {
1600
1930
  collectionBundles.push(record);
1601
1931
  continue;
1602
1932
  }
1933
+ if (record.kind === "smart_collection") {
1934
+ smartCollectionBundles.push(record);
1935
+ continue;
1936
+ }
1603
1937
  if (record.kind === "post") {
1604
1938
  rootBundles.push(record);
1605
1939
  continue;
@@ -1607,27 +1941,19 @@ async function walkHugoContent(rootDir) {
1607
1941
  // home / featured / archive / collections / anything else — skip.
1608
1942
  }
1609
1943
 
1610
- // Sort replies within each root by `date` asc (fallback: directory name).
1944
+ // Replies in Thread order: the exported `weight`, which follows Jant's own
1945
+ // order (creation time, then ID). Older exports have no weight; `date` then
1946
+ // the original ID come next, since replies written in one second tie on
1947
+ // `date`, and the directory name last.
1611
1948
  for (const root of rootBundles) {
1612
- root.children.sort((a, b) => {
1613
- const aDate =
1614
- typeof a.frontMatter.date === "string"
1615
- ? Date.parse(a.frontMatter.date)
1616
- : NaN;
1617
- const bDate =
1618
- typeof b.frontMatter.date === "string"
1619
- ? Date.parse(b.frontMatter.date)
1620
- : NaN;
1621
- if (Number.isFinite(aDate) && Number.isFinite(bDate) && aDate !== bDate) {
1622
- return aDate - bDate;
1623
- }
1624
- if (Number.isFinite(aDate) && !Number.isFinite(bDate)) return -1;
1625
- if (!Number.isFinite(aDate) && Number.isFinite(bDate)) return 1;
1626
- return basename(a.dir).localeCompare(basename(b.dir));
1627
- });
1949
+ root.children.sort(compareReplyBundles);
1628
1950
  }
1629
1951
 
1630
- return { rootBundles, collectionBundles };
1952
+ // Roots in creation order, oldest first, so the new IDs keep the original
1953
+ // order among posts that share a second: lists break those ties by ID.
1954
+ rootBundles.sort(compareBundlesByDateThenId);
1955
+
1956
+ return { rootBundles, collectionBundles, smartCollectionBundles };
1631
1957
  }
1632
1958
 
1633
1959
  /**
@@ -1759,6 +2085,45 @@ function shouldImportReplyQuietly(rootFrontMatter, replyFrontMatter) {
1759
2085
  );
1760
2086
  }
1761
2087
 
2088
+ /**
2089
+ * The post a Thread's next reply has to answer: the site's rule, the last post
2090
+ * in Thread order. The root comes first whatever its creation time, so the
2091
+ * first reply always ends the Thread; after that the newest reply by creation
2092
+ * time, then by ID, does.
2093
+ *
2094
+ * @param {{ id: string, createdAt: number, replyToId?: string | null }} tail - The current end
2095
+ * @param {{ id: string, createdAt: number, replyToId?: string | null } | null | undefined} created - The post just created
2096
+ * @returns {{ id: string, createdAt: number, replyToId?: string | null }} The new end
2097
+ * @example
2098
+ * getNextThreadTail(
2099
+ * { id: "root", createdAt: 20, replyToId: null },
2100
+ * { id: "r1", createdAt: 10, replyToId: "root" },
2101
+ * );
2102
+ * // r1: a reply older than the root still ends the Thread
2103
+ */
2104
+ function getNextThreadTail(tail, created) {
2105
+ if (!created?.id) return tail;
2106
+ return compareThreadOrder(created, tail) > 0 ? created : tail;
2107
+ }
2108
+
2109
+ /**
2110
+ * Compare two posts of one Thread the way the site orders them (`threadOrder`
2111
+ * in the post service): the root first, then creation time, then ID.
2112
+ *
2113
+ * @param {{ id: string, createdAt: number, replyToId?: string | null }} a
2114
+ * @param {{ id: string, createdAt: number, replyToId?: string | null }} b
2115
+ * @returns {number} Negative when `a` comes first, positive when `b` does
2116
+ * @example
2117
+ * compareThreadOrder({ id: "r1", createdAt: 10, replyToId: "root" },
2118
+ * { id: "root", createdAt: 20, replyToId: null }); // 1
2119
+ */
2120
+ function compareThreadOrder(a, b) {
2121
+ const rank = (post) => (post.replyToId ? 1 : 0);
2122
+ if (rank(a) !== rank(b)) return rank(a) - rank(b);
2123
+ if (a.createdAt !== b.createdAt) return a.createdAt - b.createdAt;
2124
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
2125
+ }
2126
+
1762
2127
  /**
1763
2128
  * Build the payload for `target.createPost()` from a parsed bundle. Works
1764
2129
  * for both root bundles (`forReply: false`) and reply leaf bundles — the
@@ -1814,6 +2179,16 @@ function buildPostPayloadFromBundle(bundle, options) {
1814
2179
  status === "published" && typeof frontMatter.date === "string"
1815
2180
  ? Math.floor(new Date(frontMatter.date).getTime() / 1000)
1816
2181
  : undefined,
2182
+ // `date` holds the publish time of a published post and the creation
2183
+ // time of a draft; `created` and `updated` are written when they differ.
2184
+ createdAt:
2185
+ parseImportTimestamp(frontMatter.created) ??
2186
+ parseImportTimestamp(frontMatter.date) ??
2187
+ undefined,
2188
+ updatedAt:
2189
+ parseImportTimestamp(frontMatter.updated) ??
2190
+ parseImportTimestamp(frontMatter.date) ??
2191
+ undefined,
1817
2192
  featuredAt:
1818
2193
  typeof frontMatter.featured_at === "string" && frontMatter.featured_at
1819
2194
  ? Math.floor(new Date(frontMatter.featured_at).getTime() / 1000)
@@ -1824,6 +2199,12 @@ function buildPostPayloadFromBundle(bundle, options) {
1824
2199
  : undefined,
1825
2200
  rating:
1826
2201
  typeof frontMatter.rating === "number" ? frontMatter.rating : undefined,
2202
+ // Roots only: replies take the root's language from the service, and a
2203
+ // reply bundle has no language of its own in the export.
2204
+ language:
2205
+ !options.replyToId && typeof frontMatter.language === "string"
2206
+ ? frontMatter.language
2207
+ : undefined,
1827
2208
  quietReply: options.quietReply ? true : undefined,
1828
2209
  };
1829
2210
 
@@ -1858,8 +2239,13 @@ export const __test__ = {
1858
2239
  extractAttachmentBlocks,
1859
2240
  buildImportedAttachments,
1860
2241
  uploadMediaList,
2242
+ uploadBundleResources,
1861
2243
  buildSettingsUpdatesFromConfig,
2244
+ splitSettingsUpdatesForImport,
1862
2245
  normalizeImportedNavItems,
2246
+ buildNavItemCreateRequest,
2247
+ buildSmartCollectionCreateRequest,
2248
+ normalizeImportedCustomUrls,
1863
2249
  normalizeImportedCollectionDirectory,
1864
2250
  buildSiteAvatarImport,
1865
2251
  reorderCollectionDirectoryItems,
@@ -1876,6 +2262,7 @@ export const __test__ = {
1876
2262
  resolveThreadCollectionMemberships,
1877
2263
  buildPostPayloadFromBundle,
1878
2264
  shouldImportReplyQuietly,
2265
+ getNextThreadTail,
1879
2266
  };
1880
2267
 
1881
2268
  function printImportUsage() {
@@ -1961,6 +2348,8 @@ export async function run(argv) {
1961
2348
  const apiUrl = url.replace(/\/$/, "");
1962
2349
  const skipRemoteMedia = values["skip-remote-media"];
1963
2350
  const target = dryRun ? null : createRemoteTarget(apiUrl, token);
2351
+ /** Exported translation-group ID → IDs of the posts created for it. */
2352
+ const translationGroups = new Map();
1964
2353
 
1965
2354
  // 1. Read source — directory or ZIP
1966
2355
  const inputPath = resolve(process.cwd(), values.path);
@@ -1976,21 +2365,14 @@ export async function run(argv) {
1976
2365
 
1977
2366
  if (inputStat.isFile()) {
1978
2367
  console.log(`Reading ZIP ${inputPath}...`);
1979
- const zipData = await readFile(inputPath);
1980
- const { unzipSync } = await import("fflate");
1981
- const files = unzipSync(new Uint8Array(zipData));
1982
2368
  tempSourceRootDir = await mkdtemp(join(tmpdir(), "jant-site-import-"));
1983
2369
  sourceRootDir = tempSourceRootDir;
1984
- for (const [path, data] of Object.entries(files)) {
1985
- const fullPath = join(sourceRootDir, path);
1986
- await mkdir(dirname(fullPath), { recursive: true });
1987
- await writeFile(fullPath, data);
1988
- }
2370
+ await extractZipFile(inputPath, sourceRootDir);
1989
2371
  } else {
1990
2372
  console.log(`Reading directory ${inputPath}...`);
1991
2373
  }
1992
2374
 
1993
- const { rootBundles, collectionBundles } =
2375
+ const { rootBundles, collectionBundles, smartCollectionBundles } =
1994
2376
  await walkHugoContent(sourceRootDir);
1995
2377
  const siteConfig = await loadSiteConfig(sourceRootDir);
1996
2378
  const customCss = await readImportCustomCss(sourceRootDir);
@@ -2001,11 +2383,17 @@ export async function run(argv) {
2001
2383
  0,
2002
2384
  );
2003
2385
  console.log(
2004
- `Found ${rootBundles.length} posts (+${replyCount} replies) and ${collectionBundles.length} collections`,
2386
+ `Found ${rootBundles.length} posts (+${replyCount} replies), ${collectionBundles.length} collections, and ${smartCollectionBundles.length} smart collections`,
2005
2387
  );
2006
2388
  const importedCollectionDirectory = siteConfig
2007
2389
  ? normalizeImportedCollectionDirectory(siteConfig)
2008
2390
  : { exported: false, items: [] };
2391
+ const importedNav = siteConfig
2392
+ ? normalizeImportedNavItems(siteConfig)
2393
+ : { exported: false, items: [] };
2394
+ const importedCustomUrls = siteConfig
2395
+ ? normalizeImportedCustomUrls(siteConfig)
2396
+ : [];
2009
2397
 
2010
2398
  if (target) {
2011
2399
  const setupError = await getIncompleteSetupError(
@@ -2025,7 +2413,6 @@ export async function run(argv) {
2025
2413
  siteConfig,
2026
2414
  customCss,
2027
2415
  );
2028
- const importedNav = normalizeImportedNavItems(siteConfig);
2029
2416
  const avatarImport = await buildSiteAvatarImport(
2030
2417
  siteConfig,
2031
2418
  sourceRootDir,
@@ -2078,21 +2465,6 @@ export async function run(argv) {
2078
2465
  }
2079
2466
  }
2080
2467
 
2081
- if (importedNav.exported) {
2082
- try {
2083
- const existingNavItems = await target.listNavItems();
2084
- for (const item of existingNavItems) {
2085
- await target.deleteNavItem(item.id);
2086
- }
2087
- for (const item of importedNav.items) {
2088
- await target.createNavItem(item);
2089
- }
2090
- } catch (err) {
2091
- console.error(`Error importing navigation: ${err.message}`);
2092
- process.exit(1);
2093
- }
2094
- }
2095
-
2096
2468
  if (avatarImport) {
2097
2469
  try {
2098
2470
  await target.syncSiteAvatar(
@@ -2113,6 +2485,8 @@ export async function run(argv) {
2113
2485
 
2114
2486
  // 3. Fetch existing collections and create missing ones
2115
2487
  const collectionSlugToId = new Map();
2488
+ // Root posts by slug, for navigation entries that point at a page.
2489
+ const postSlugToId = new Map();
2116
2490
 
2117
2491
  if (!dryRun) {
2118
2492
  try {
@@ -2165,6 +2539,60 @@ export async function run(argv) {
2165
2539
  }
2166
2540
  }
2167
2541
 
2542
+ // Smart collections after collections, which their conditions may name.
2543
+ // They hold no posts, so nothing about them waits for the posts below.
2544
+ const smartCollectionSlugToId = new Map();
2545
+ if (!dryRun) {
2546
+ try {
2547
+ for (const existing of await target.listSmartCollections()) {
2548
+ smartCollectionSlugToId.set(existing.slug, existing.id);
2549
+ }
2550
+ } catch (err) {
2551
+ console.error(
2552
+ `Error fetching existing smart collections: ${err.message}`,
2553
+ );
2554
+ process.exit(1);
2555
+ }
2556
+ }
2557
+
2558
+ for (const bundle of smartCollectionBundles) {
2559
+ const slug = bundle.slug;
2560
+ if (smartCollectionSlugToId.has(slug)) {
2561
+ console.error(
2562
+ `Import conflict: smart collection slug "${slug}" is already in use. Import into an empty site or remove the existing smart collection first.`,
2563
+ );
2564
+ process.exit(1);
2565
+ }
2566
+
2567
+ const request = buildSmartCollectionCreateRequest(
2568
+ bundle,
2569
+ collectionSlugToId,
2570
+ );
2571
+ if (!request.payload) {
2572
+ console.warn(`Warning: ${request.warning}`);
2573
+ continue;
2574
+ }
2575
+
2576
+ if (dryRun) {
2577
+ console.log(
2578
+ `[dry-run] Would create smart collection: ${request.payload.title}`,
2579
+ );
2580
+ smartCollectionSlugToId.set(slug, `dry-run-${slug}`);
2581
+ continue;
2582
+ }
2583
+
2584
+ try {
2585
+ const created = await target.createSmartCollection(request.payload);
2586
+ smartCollectionSlugToId.set(slug, created.id);
2587
+ console.log(`Created smart collection: ${request.payload.title}`);
2588
+ } catch (err) {
2589
+ console.error(
2590
+ `Error creating smart collection "${slug}": ${err.message}`,
2591
+ );
2592
+ process.exit(1);
2593
+ }
2594
+ }
2595
+
2168
2596
  if (importedCollectionDirectory.exported) {
2169
2597
  if (dryRun) {
2170
2598
  console.log(
@@ -2176,6 +2604,7 @@ export async function run(argv) {
2176
2604
  target,
2177
2605
  importedCollectionDirectory,
2178
2606
  collectionSlugToId,
2607
+ smartCollectionSlugToId,
2179
2608
  );
2180
2609
  } catch (err) {
2181
2610
  console.error(
@@ -2325,6 +2754,19 @@ export async function run(argv) {
2325
2754
  try {
2326
2755
  post = await target.createPost(postData);
2327
2756
  postsCreated++;
2757
+ if (post?.id && post.slug) postSlugToId.set(post.slug, post.id);
2758
+ // Translation groups are rebuilt after every post exists: the group ID
2759
+ // in the export is opaque and its members can appear in any order, so
2760
+ // there is nothing to link to until the whole run is done.
2761
+ const groupKey =
2762
+ typeof rootFm.translation_group === "string"
2763
+ ? rootFm.translation_group.trim()
2764
+ : "";
2765
+ if (groupKey && post?.id) {
2766
+ const members = translationGroups.get(groupKey) ?? [];
2767
+ members.push(post.id);
2768
+ translationGroups.set(groupKey, members);
2769
+ }
2328
2770
  const replyInfo =
2329
2771
  rootBundle.children.length > 0
2330
2772
  ? ` (+${rootBundle.children.length} replies)`
@@ -2338,10 +2780,14 @@ export async function run(argv) {
2338
2780
  // Create replies before aliases so reply slugs can claim their paths.
2339
2781
  if (!post) continue;
2340
2782
  const replySlugPaths = new Set();
2341
- // Jant threads are linear: each reply must point at the current end of
2342
- // the thread, not at the root. Track the tail as we go so the Nth
2343
- // reply chains after the (N−1)th.
2344
- let threadTailId = post.id;
2783
+ // Jant threads are linear: a reply must point at the current end of the
2784
+ // thread, which the site reads as its last post in Thread order — the
2785
+ // root first, then replies by creation time, then ID. Creation times
2786
+ // are restored from the export (or taken from `date` in an older one),
2787
+ // so a reply sent after another can carry the earlier time; the end is
2788
+ // then the one with the later time, not the one sent last. Track it by
2789
+ // the site's rule.
2790
+ let threadTail = post;
2345
2791
  for (const replyBundle of rootBundle.children) {
2346
2792
  const replyFm = replyBundle.frontMatter;
2347
2793
  const replySlug = replyBundle.slug;
@@ -2440,16 +2886,14 @@ export async function run(argv) {
2440
2886
  bodyMarkdown: replyBody,
2441
2887
  attachments: replyAttachments,
2442
2888
  memberships: { entries: [], ids: [] },
2443
- replyToId: threadTailId,
2889
+ replyToId: threadTail.id,
2444
2890
  quietReply: shouldImportReplyQuietly(rootFm, replyFm),
2445
2891
  });
2446
2892
 
2447
2893
  try {
2448
2894
  const createdReply = await target.createPost(replyData);
2449
2895
  repliesCreated++;
2450
- if (createdReply?.id) {
2451
- threadTailId = createdReply.id;
2452
- }
2896
+ threadTail = getNextThreadTail(threadTail, createdReply);
2453
2897
  } catch (err) {
2454
2898
  console.error(` Error creating reply: ${err.message}`);
2455
2899
  process.exit(1);
@@ -2483,6 +2927,85 @@ export async function run(argv) {
2483
2927
  }
2484
2928
  }
2485
2929
 
2930
+ // Rebuild translation groups. Each member is linked to the first one, so a
2931
+ // group of N takes N-1 calls and never asks the server to merge two groups
2932
+ // — which it refuses, on purpose.
2933
+ let translationsLinked = 0;
2934
+ for (const [groupKey, members] of translationGroups) {
2935
+ if (members.length < 2) continue;
2936
+ const [anchor, ...rest] = members;
2937
+ for (const memberId of rest) {
2938
+ try {
2939
+ await target.linkTranslation(anchor, memberId);
2940
+ translationsLinked++;
2941
+ } catch (err) {
2942
+ // A clash here means the export itself held two posts in the same
2943
+ // language for one group. Say so and keep going: the posts are
2944
+ // imported either way, they are just not linked.
2945
+ console.warn(
2946
+ `Warning: could not link a translation in group "${groupKey}": ${err.message}`,
2947
+ );
2948
+ }
2949
+ }
2950
+ }
2951
+
2952
+ // Navigation and custom URLs go last: they point at posts and
2953
+ // collections, which exist by now.
2954
+ let customUrlsCreated = 0;
2955
+ if (!dryRun && target) {
2956
+ if (importedNav.exported) {
2957
+ try {
2958
+ const existingNavItems = await target.listNavItems();
2959
+ for (const item of existingNavItems) {
2960
+ await target.deleteNavItem(item.id);
2961
+ }
2962
+ for (const item of importedNav.items) {
2963
+ const request = buildNavItemCreateRequest(item, {
2964
+ collectionSlugToId,
2965
+ smartCollectionSlugToId,
2966
+ postSlugToId,
2967
+ });
2968
+ if (request.warning) console.warn(`Warning: ${request.warning}`);
2969
+ const created = await target.createNavItem(request.payload);
2970
+ if (request.customLabel && created?.id) {
2971
+ await target.updateNavItem(created.id, {
2972
+ label: request.customLabel,
2973
+ });
2974
+ }
2975
+ }
2976
+ } catch (err) {
2977
+ console.error(`Error importing navigation: ${err.message}`);
2978
+ process.exit(1);
2979
+ }
2980
+ }
2981
+
2982
+ for (const customUrl of importedCustomUrls) {
2983
+ if (customUrl.kind === "archive") {
2984
+ console.warn(
2985
+ `Warning: skipped /${customUrl.path}, an archive URL (${customUrl.archiveQuery ?? "no filter"}). Archive URLs can no longer be created. Create a smart collection at /${customUrl.path} with the same conditions, here or on the source site before exporting.`,
2986
+ );
2987
+ continue;
2988
+ }
2989
+ try {
2990
+ await target.createCustomUrl({
2991
+ path: customUrl.path,
2992
+ targetType: "redirect",
2993
+ toPath: customUrl.to,
2994
+ redirectType: String(customUrl.status),
2995
+ });
2996
+ customUrlsCreated++;
2997
+ } catch (err) {
2998
+ console.warn(
2999
+ `Warning: couldn't recreate the redirect /${customUrl.path} → ${customUrl.to}: ${err.message}`,
3000
+ );
3001
+ }
3002
+ }
3003
+ } else if (dryRun && importedCustomUrls.length > 0) {
3004
+ console.log(
3005
+ `[dry-run] Would recreate ${importedCustomUrls.length} custom URLs`,
3006
+ );
3007
+ }
3008
+
2486
3009
  await target?.close();
2487
3010
 
2488
3011
  // 5. Summary
@@ -2491,9 +3014,15 @@ export async function run(argv) {
2491
3014
  console.log(` Posts created: ${postsCreated}`);
2492
3015
  console.log(` Replies created: ${repliesCreated}`);
2493
3016
  console.log(` Media uploaded: ${mediaUploaded}`);
3017
+ if (translationsLinked > 0) {
3018
+ console.log(` Translations linked: ${translationsLinked}`);
3019
+ }
2494
3020
  if (aliasesCreated > 0) {
2495
3021
  console.log(` Aliases created: ${aliasesCreated}`);
2496
3022
  }
3023
+ if (customUrlsCreated > 0) {
3024
+ console.log(` Redirects created: ${customUrlsCreated}`);
3025
+ }
2497
3026
  if (dryRun) {
2498
3027
  console.log(" (dry-run mode — no changes were made)");
2499
3028
  }