@jant/core 0.7.0 → 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 (158) hide show
  1. package/bin/commands/export.js +3 -1
  2. package/bin/commands/import-site.js +689 -216
  3. package/bin/commands/setup.js +136 -0
  4. package/bin/commands/site/export.js +71 -34
  5. package/bin/commands/site/pull-media.js +2 -6
  6. package/bin/commands/site/snapshot/export.js +18 -60
  7. package/bin/commands/site/snapshot/import.js +22 -23
  8. package/bin/lib/d1-query.js +87 -2
  9. package/bin/lib/hugo-markdown.js +4 -0
  10. package/bin/lib/site-pull-media.js +21 -28
  11. package/bin/lib/site-selection.js +10 -1
  12. package/bin/lib/site-snapshot.js +338 -3
  13. package/bin/lib/sql-export.js +68 -5
  14. package/bin/lib/wrangler-cli.js +9 -0
  15. package/bin/lib/zip-archive.js +187 -0
  16. package/dist/{app-K_Aa1MMn.js → app-ZcaI1kPN.js} +732 -217
  17. package/dist/client/.vite/manifest.json +20 -20
  18. package/dist/client/_assets/chunks/{create-editor-CD3FhrOB.js → create-editor-B-m7X7S5.js} +50 -46
  19. package/dist/client/_assets/chunks/{sortable-list-CgaL2jCs.js → sortable-list-BJyd-LXE.js} +1 -1
  20. package/dist/client/_assets/chunks/{unsafe-svg-0QCkP0vZ.js → unsafe-svg-BkscJx69.js} +2 -2
  21. package/dist/client/_assets/{client-GiYENVw8.css → client-DAhoqPdr.css} +1 -1
  22. package/dist/client/_assets/{client-auth-k8gJ6QJj.js → client-auth-CAMfTrKW.js} +1 -1
  23. package/dist/client/_assets/{client-compose-B_kDtWkW.js → client-compose-D6rExKVK.js} +1 -1
  24. package/dist/client/_assets/{client-VnFxJN7G.js → client-knSEyUJO.js} +1 -1
  25. package/dist/client/_assets/{client-manage-M90aSTOk.js → client-manage-sVOkqVbH.js} +1 -1
  26. package/dist/client/_assets/{client-settings-DfYs9n1F.js → client-settings-CGR_nZ78.js} +7 -7
  27. package/dist/{github-sync-BPAvT999.js → github-sync-Gw4orAHk.js} +799 -110
  28. package/dist/index.js +2 -2
  29. package/dist/node.js +9 -5
  30. package/package.json +13 -3
  31. package/src/__tests__/dev-scripts.test.ts +203 -0
  32. package/src/__tests__/export-collection-order.test.ts +276 -0
  33. package/src/__tests__/export-feed-ids.test.ts +184 -0
  34. package/src/__tests__/export-feed-order.test.ts +294 -0
  35. package/src/__tests__/export-hugo-build.test.ts +130 -0
  36. package/src/__tests__/export-import-roundtrip.test.ts +94 -0
  37. package/src/__tests__/export-service.test.ts +611 -28
  38. package/src/__tests__/export-smart-collection.test.ts +329 -0
  39. package/src/__tests__/helpers/hugo-site.ts +146 -0
  40. package/src/__tests__/import-site-command.test.ts +487 -1
  41. package/src/__tests__/mise-config.test.ts +75 -4
  42. package/src/__tests__/node-dev-tasks.test.ts +264 -0
  43. package/src/__tests__/site-export-canonical-import.test.ts +149 -0
  44. package/src/__tests__/snapshot-canonical-replay.test.ts +179 -0
  45. package/src/__tests__/snapshot-settings.test.ts +97 -0
  46. package/src/__tests__/snapshot-tables.test.ts +219 -0
  47. package/src/__tests__/sql-export.test.ts +173 -0
  48. package/src/__tests__/zip-archive.test.ts +106 -0
  49. package/src/app.tsx +15 -6
  50. package/src/client/components/__tests__/jant-settings-avatar.test.ts +1 -1
  51. package/src/client/components/__tests__/jant-settings-general.test.ts +21 -1
  52. package/src/client/components/jant-repo-picker-types.ts +6 -1
  53. package/src/client/components/jant-repo-picker.ts +4 -7
  54. package/src/client/components/jant-settings-general.ts +17 -12
  55. package/src/client/tiptap/__tests__/list-editing.test.ts +224 -86
  56. package/src/client/tiptap/__tests__/mark-exit.test.ts +1 -2
  57. package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +26 -0
  58. package/src/client/tiptap/extensions.ts +0 -3
  59. package/src/client/tiptap/structural-keymap.ts +158 -47
  60. package/src/db/__tests__/d1-query.test.ts +56 -1
  61. package/src/i18n/locales/settings/en.po +8 -8
  62. package/src/i18n/locales/settings/en.ts +1 -1
  63. package/src/i18n/locales/settings/zh-Hans.po +8 -8
  64. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  65. package/src/i18n/locales/settings/zh-Hant.po +8 -8
  66. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  67. package/src/lib/__tests__/github-sync-repo-name.test.ts +40 -0
  68. package/src/lib/__tests__/image.test.ts +27 -1
  69. package/src/lib/__tests__/markdown-to-tiptap.test.ts +105 -0
  70. package/src/lib/__tests__/markdown.test.ts +10 -0
  71. package/src/lib/__tests__/resolve-config.test.ts +67 -0
  72. package/src/lib/__tests__/schemas.test.ts +27 -1
  73. package/src/lib/__tests__/timeline.test.ts +87 -0
  74. package/src/lib/__tests__/tiptap-to-markdown.test.ts +172 -4
  75. package/src/lib/discover.ts +3 -1
  76. package/src/lib/github-sync-repo-name.ts +45 -0
  77. package/src/lib/hugo-markdown.ts +46 -0
  78. package/src/lib/image.ts +17 -4
  79. package/src/lib/markdown-manager.ts +392 -2
  80. package/src/lib/post-body-html.ts +11 -4
  81. package/src/lib/resolve-config.ts +58 -1
  82. package/src/lib/schemas.ts +50 -5
  83. package/src/lib/thread-fold.ts +3 -3
  84. package/src/lib/timeline.ts +1 -1
  85. package/src/lib/tiptap-to-markdown.ts +13 -7
  86. package/src/lib/url.ts +20 -0
  87. package/src/lib/view.ts +2 -2
  88. package/src/node/__tests__/cli-setup.test.ts +163 -0
  89. package/src/node/__tests__/cli-site-snapshot.test.ts +67 -0
  90. package/src/node/__tests__/cli-snapshot-meta.test.ts +20 -0
  91. package/src/node/__tests__/runtime.test.ts +38 -0
  92. package/src/node/index.ts +2 -0
  93. package/src/node/request-handler.ts +3 -1
  94. package/src/routes/api/__tests__/posts.test.ts +24 -0
  95. package/src/routes/api/__tests__/upload.test.ts +34 -0
  96. package/src/routes/api/export.ts +3 -3
  97. package/src/routes/api/internal/sites.ts +0 -1
  98. package/src/routes/api/posts.ts +2 -0
  99. package/src/routes/api/public/posts.ts +21 -1
  100. package/src/routes/api/upload.ts +10 -3
  101. package/src/routes/compose.tsx +4 -0
  102. package/src/routes/dash/__tests__/github-sync-app.test.ts +365 -0
  103. package/src/routes/dash/settings.tsx +212 -70
  104. package/src/routes/pages/__tests__/post-page-round-trips.test.ts +211 -0
  105. package/src/routes/pages/__tests__/thread-order.test.ts +179 -0
  106. package/src/routes/pages/archive.tsx +17 -11
  107. package/src/routes/pages/featured.tsx +11 -5
  108. package/src/routes/pages/page.tsx +53 -31
  109. package/src/routes/pages/search.tsx +4 -2
  110. package/src/runtime/__tests__/readiness.test.ts +23 -0
  111. package/src/runtime/node.ts +49 -0
  112. package/src/runtime/readiness.ts +15 -0
  113. package/src/services/__tests__/bootstrap-setup-instance.test.ts +230 -0
  114. package/src/services/__tests__/custom-url.test.ts +34 -0
  115. package/src/services/__tests__/github-app-installations.test.ts +153 -0
  116. package/src/services/__tests__/github-sync-push.test.ts +46 -0
  117. package/src/services/__tests__/media.test.ts +31 -0
  118. package/src/services/__tests__/path.test.ts +56 -0
  119. package/src/services/__tests__/post-timeline.test.ts +127 -0
  120. package/src/services/__tests__/post.test.ts +74 -0
  121. package/src/services/bootstrap.ts +263 -44
  122. package/src/services/custom-url.ts +2 -2
  123. package/src/services/export-theme/layouts/_default/alias.html +27 -1
  124. package/src/services/export-theme/layouts/_default/list.html +2 -63
  125. package/src/services/export-theme/layouts/_default/rss.xml +27 -38
  126. package/src/services/export-theme/layouts/collections/list.html +3 -3
  127. package/src/services/export-theme/layouts/featured/list.html +1 -4
  128. package/src/services/export-theme/layouts/index.html +40 -26
  129. package/src/services/export-theme/layouts/partials/collection-members.html +100 -0
  130. package/src/services/export-theme/layouts/partials/collection-threads.html +33 -0
  131. package/src/services/export-theme/layouts/partials/featured-members.html +35 -0
  132. package/src/services/export-theme/layouts/partials/featured-thread.html +1 -1
  133. package/src/services/export-theme/layouts/partials/footer.html +1 -1
  134. package/src/services/export-theme/layouts/partials/head.html +1 -1
  135. package/src/services/export-theme/layouts/partials/header.html +5 -3
  136. package/src/services/export-theme/layouts/partials/jant-data.html +23 -0
  137. package/src/services/export-theme/layouts/partials/latest-members.html +48 -0
  138. package/src/services/export-theme/layouts/partials/smart-collection-members.html +129 -0
  139. package/src/services/export-theme/layouts/partials/thread-preview.html +1 -1
  140. package/src/services/export-theme/layouts/post/list.html +1 -1
  141. package/src/services/export-theme/layouts/smart_collection/list.html +24 -0
  142. package/src/services/export-theme/styles/main.css +0 -1
  143. package/src/services/export-theme/theme.toml +1 -1
  144. package/src/services/export.ts +620 -71
  145. package/src/services/github-app-installations.ts +171 -4
  146. package/src/services/github-sync.ts +69 -15
  147. package/src/services/mcp.ts +19 -1
  148. package/src/services/media.ts +8 -1
  149. package/src/services/path.ts +34 -1
  150. package/src/services/post.ts +214 -39
  151. package/src/services/site-admin.ts +3 -7
  152. package/src/services/site.ts +83 -31
  153. package/src/styles/ui.css +7 -1
  154. package/src/types/app-context.ts +16 -0
  155. package/src/types/bindings.ts +5 -0
  156. package/src/types/operations.ts +10 -0
  157. package/src/ui/dash/settings/GeneralContent.tsx +10 -8
  158. package/src/client/tiptap/exitable-marks.ts +0 -73
@@ -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}`);
@@ -1346,6 +1598,12 @@ function createRemoteTarget(apiUrl, token) {
1346
1598
  async deleteNavItem(id) {
1347
1599
  return apiCall("DELETE", `/api/nav-items/${id}`, apiUrl, token);
1348
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
+ },
1349
1607
  async removeSiteAvatar() {
1350
1608
  return apiCall("DELETE", "/api/settings/avatar", apiUrl, token);
1351
1609
  },
@@ -1359,11 +1617,7 @@ function createRemoteTarget(apiUrl, token) {
1359
1617
  }
1360
1618
 
1361
1619
  const formData = new FormData();
1362
- formData.append(
1363
- "file",
1364
- new Blob([avatarAsset.bytes], { type: avatarAsset.contentType }),
1365
- avatarAsset.filename,
1366
- );
1620
+ formData.append("file", avatarAsset.blob, avatarAsset.filename);
1367
1621
 
1368
1622
  if (data.faviconUrl || data.faviconFilePath) {
1369
1623
  const faviconAsset = await readImportAsset({
@@ -1373,13 +1627,7 @@ function createRemoteTarget(apiUrl, token) {
1373
1627
  originalName: "favicon.ico",
1374
1628
  });
1375
1629
  if (faviconAsset) {
1376
- formData.append(
1377
- "favicon",
1378
- new Blob([faviconAsset.bytes], {
1379
- type: faviconAsset.contentType,
1380
- }),
1381
- faviconAsset.filename,
1382
- );
1630
+ formData.append("favicon", faviconAsset.blob, faviconAsset.filename);
1383
1631
  }
1384
1632
  }
1385
1633
 
@@ -1391,9 +1639,7 @@ function createRemoteTarget(apiUrl, token) {
1391
1639
  if (appleTouchAsset) {
1392
1640
  formData.append(
1393
1641
  "appleTouch",
1394
- new Blob([appleTouchAsset.bytes], {
1395
- type: appleTouchAsset.contentType,
1396
- }),
1642
+ appleTouchAsset.blob,
1397
1643
  appleTouchAsset.filename,
1398
1644
  );
1399
1645
  }
@@ -1429,6 +1675,25 @@ function createRemoteTarget(apiUrl, token) {
1429
1675
  async createCollection(data) {
1430
1676
  return apiCall("POST", "/api/collections", apiUrl, token, data);
1431
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
+ },
1432
1697
  async createCollectionDirectoryItem(data) {
1433
1698
  return apiCall(
1434
1699
  "POST",
@@ -1495,23 +1760,76 @@ function createRemoteTarget(apiUrl, token) {
1495
1760
  };
1496
1761
  }
1497
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
+
1498
1814
  /**
1499
1815
  * Walk `content/` and classify each `_index.md` / `index.md` bundle by its
1500
1816
  * front-matter `type`. Returns ordered root-post bundles (with child reply
1501
- * bundles attached) and stand-alone collection landing pages.
1817
+ * bundles attached), collection landing pages, and smart collection pages.
1502
1818
  *
1503
1819
  * Algorithm:
1504
1820
  * 1. Recurse into `content/` collecting every directory that has either
1505
1821
  * `_index.md` (branch bundle / section) or `index.md` (leaf bundle).
1506
1822
  * 2. For each `_index.md`, read front matter. `type: "post"` (or a
1507
1823
  * missing `type` with post-shaped keys) → root bundle. `type:
1508
- * "collection"` → collection landing page. Other known section types
1824
+ * "collection"` → collection landing page, `type: "smart_collection"` →
1825
+ * smart collection conditions. Other known section types
1509
1826
  * (`home`, `featured`, `archive`, `collections`) are recorded and
1510
1827
  * skipped for post import.
1511
1828
  * 3. For each root bundle, enumerate immediate child directories; any
1512
1829
  * child dir containing `index.md` becomes a reply leaf bundle.
1513
- * 4. Reply bundles are sorted by `frontMatter.date` ascending (fallback:
1514
- * 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.
1515
1833
  */
1516
1834
  async function walkHugoContent(rootDir) {
1517
1835
  const contentDir = join(rootDir, "content");
@@ -1597,6 +1915,7 @@ async function walkHugoContent(rootDir) {
1597
1915
  // Attach leaf children to their parent root bundles.
1598
1916
  const rootBundles = [];
1599
1917
  const collectionBundles = [];
1918
+ const smartCollectionBundles = [];
1600
1919
 
1601
1920
  for (const record of dirs.values()) {
1602
1921
  if (record.kind === "leaf") {
@@ -1611,6 +1930,10 @@ async function walkHugoContent(rootDir) {
1611
1930
  collectionBundles.push(record);
1612
1931
  continue;
1613
1932
  }
1933
+ if (record.kind === "smart_collection") {
1934
+ smartCollectionBundles.push(record);
1935
+ continue;
1936
+ }
1614
1937
  if (record.kind === "post") {
1615
1938
  rootBundles.push(record);
1616
1939
  continue;
@@ -1618,27 +1941,19 @@ async function walkHugoContent(rootDir) {
1618
1941
  // home / featured / archive / collections / anything else — skip.
1619
1942
  }
1620
1943
 
1621
- // 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.
1622
1948
  for (const root of rootBundles) {
1623
- root.children.sort((a, b) => {
1624
- const aDate =
1625
- typeof a.frontMatter.date === "string"
1626
- ? Date.parse(a.frontMatter.date)
1627
- : NaN;
1628
- const bDate =
1629
- typeof b.frontMatter.date === "string"
1630
- ? Date.parse(b.frontMatter.date)
1631
- : NaN;
1632
- if (Number.isFinite(aDate) && Number.isFinite(bDate) && aDate !== bDate) {
1633
- return aDate - bDate;
1634
- }
1635
- if (Number.isFinite(aDate) && !Number.isFinite(bDate)) return -1;
1636
- if (!Number.isFinite(aDate) && Number.isFinite(bDate)) return 1;
1637
- return basename(a.dir).localeCompare(basename(b.dir));
1638
- });
1949
+ root.children.sort(compareReplyBundles);
1639
1950
  }
1640
1951
 
1641
- 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 };
1642
1957
  }
1643
1958
 
1644
1959
  /**
@@ -1770,6 +2085,45 @@ function shouldImportReplyQuietly(rootFrontMatter, replyFrontMatter) {
1770
2085
  );
1771
2086
  }
1772
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
+
1773
2127
  /**
1774
2128
  * Build the payload for `target.createPost()` from a parsed bundle. Works
1775
2129
  * for both root bundles (`forReply: false`) and reply leaf bundles — the
@@ -1825,6 +2179,16 @@ function buildPostPayloadFromBundle(bundle, options) {
1825
2179
  status === "published" && typeof frontMatter.date === "string"
1826
2180
  ? Math.floor(new Date(frontMatter.date).getTime() / 1000)
1827
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,
1828
2192
  featuredAt:
1829
2193
  typeof frontMatter.featured_at === "string" && frontMatter.featured_at
1830
2194
  ? Math.floor(new Date(frontMatter.featured_at).getTime() / 1000)
@@ -1875,8 +2239,13 @@ export const __test__ = {
1875
2239
  extractAttachmentBlocks,
1876
2240
  buildImportedAttachments,
1877
2241
  uploadMediaList,
2242
+ uploadBundleResources,
1878
2243
  buildSettingsUpdatesFromConfig,
2244
+ splitSettingsUpdatesForImport,
1879
2245
  normalizeImportedNavItems,
2246
+ buildNavItemCreateRequest,
2247
+ buildSmartCollectionCreateRequest,
2248
+ normalizeImportedCustomUrls,
1880
2249
  normalizeImportedCollectionDirectory,
1881
2250
  buildSiteAvatarImport,
1882
2251
  reorderCollectionDirectoryItems,
@@ -1893,6 +2262,7 @@ export const __test__ = {
1893
2262
  resolveThreadCollectionMemberships,
1894
2263
  buildPostPayloadFromBundle,
1895
2264
  shouldImportReplyQuietly,
2265
+ getNextThreadTail,
1896
2266
  };
1897
2267
 
1898
2268
  function printImportUsage() {
@@ -1995,21 +2365,14 @@ export async function run(argv) {
1995
2365
 
1996
2366
  if (inputStat.isFile()) {
1997
2367
  console.log(`Reading ZIP ${inputPath}...`);
1998
- const zipData = await readFile(inputPath);
1999
- const { unzipSync } = await import("fflate");
2000
- const files = unzipSync(new Uint8Array(zipData));
2001
2368
  tempSourceRootDir = await mkdtemp(join(tmpdir(), "jant-site-import-"));
2002
2369
  sourceRootDir = tempSourceRootDir;
2003
- for (const [path, data] of Object.entries(files)) {
2004
- const fullPath = join(sourceRootDir, path);
2005
- await mkdir(dirname(fullPath), { recursive: true });
2006
- await writeFile(fullPath, data);
2007
- }
2370
+ await extractZipFile(inputPath, sourceRootDir);
2008
2371
  } else {
2009
2372
  console.log(`Reading directory ${inputPath}...`);
2010
2373
  }
2011
2374
 
2012
- const { rootBundles, collectionBundles } =
2375
+ const { rootBundles, collectionBundles, smartCollectionBundles } =
2013
2376
  await walkHugoContent(sourceRootDir);
2014
2377
  const siteConfig = await loadSiteConfig(sourceRootDir);
2015
2378
  const customCss = await readImportCustomCss(sourceRootDir);
@@ -2020,11 +2383,17 @@ export async function run(argv) {
2020
2383
  0,
2021
2384
  );
2022
2385
  console.log(
2023
- `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`,
2024
2387
  );
2025
2388
  const importedCollectionDirectory = siteConfig
2026
2389
  ? normalizeImportedCollectionDirectory(siteConfig)
2027
2390
  : { exported: false, items: [] };
2391
+ const importedNav = siteConfig
2392
+ ? normalizeImportedNavItems(siteConfig)
2393
+ : { exported: false, items: [] };
2394
+ const importedCustomUrls = siteConfig
2395
+ ? normalizeImportedCustomUrls(siteConfig)
2396
+ : [];
2028
2397
 
2029
2398
  if (target) {
2030
2399
  const setupError = await getIncompleteSetupError(
@@ -2044,7 +2413,6 @@ export async function run(argv) {
2044
2413
  siteConfig,
2045
2414
  customCss,
2046
2415
  );
2047
- const importedNav = normalizeImportedNavItems(siteConfig);
2048
2416
  const avatarImport = await buildSiteAvatarImport(
2049
2417
  siteConfig,
2050
2418
  sourceRootDir,
@@ -2097,21 +2465,6 @@ export async function run(argv) {
2097
2465
  }
2098
2466
  }
2099
2467
 
2100
- if (importedNav.exported) {
2101
- try {
2102
- const existingNavItems = await target.listNavItems();
2103
- for (const item of existingNavItems) {
2104
- await target.deleteNavItem(item.id);
2105
- }
2106
- for (const item of importedNav.items) {
2107
- await target.createNavItem(item);
2108
- }
2109
- } catch (err) {
2110
- console.error(`Error importing navigation: ${err.message}`);
2111
- process.exit(1);
2112
- }
2113
- }
2114
-
2115
2468
  if (avatarImport) {
2116
2469
  try {
2117
2470
  await target.syncSiteAvatar(
@@ -2132,6 +2485,8 @@ export async function run(argv) {
2132
2485
 
2133
2486
  // 3. Fetch existing collections and create missing ones
2134
2487
  const collectionSlugToId = new Map();
2488
+ // Root posts by slug, for navigation entries that point at a page.
2489
+ const postSlugToId = new Map();
2135
2490
 
2136
2491
  if (!dryRun) {
2137
2492
  try {
@@ -2184,6 +2539,60 @@ export async function run(argv) {
2184
2539
  }
2185
2540
  }
2186
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
+
2187
2596
  if (importedCollectionDirectory.exported) {
2188
2597
  if (dryRun) {
2189
2598
  console.log(
@@ -2195,6 +2604,7 @@ export async function run(argv) {
2195
2604
  target,
2196
2605
  importedCollectionDirectory,
2197
2606
  collectionSlugToId,
2607
+ smartCollectionSlugToId,
2198
2608
  );
2199
2609
  } catch (err) {
2200
2610
  console.error(
@@ -2344,6 +2754,7 @@ export async function run(argv) {
2344
2754
  try {
2345
2755
  post = await target.createPost(postData);
2346
2756
  postsCreated++;
2757
+ if (post?.id && post.slug) postSlugToId.set(post.slug, post.id);
2347
2758
  // Translation groups are rebuilt after every post exists: the group ID
2348
2759
  // in the export is opaque and its members can appear in any order, so
2349
2760
  // there is nothing to link to until the whole run is done.
@@ -2369,10 +2780,14 @@ export async function run(argv) {
2369
2780
  // Create replies before aliases so reply slugs can claim their paths.
2370
2781
  if (!post) continue;
2371
2782
  const replySlugPaths = new Set();
2372
- // Jant threads are linear: each reply must point at the current end of
2373
- // the thread, not at the root. Track the tail as we go so the Nth
2374
- // reply chains after the (N−1)th.
2375
- 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;
2376
2791
  for (const replyBundle of rootBundle.children) {
2377
2792
  const replyFm = replyBundle.frontMatter;
2378
2793
  const replySlug = replyBundle.slug;
@@ -2471,16 +2886,14 @@ export async function run(argv) {
2471
2886
  bodyMarkdown: replyBody,
2472
2887
  attachments: replyAttachments,
2473
2888
  memberships: { entries: [], ids: [] },
2474
- replyToId: threadTailId,
2889
+ replyToId: threadTail.id,
2475
2890
  quietReply: shouldImportReplyQuietly(rootFm, replyFm),
2476
2891
  });
2477
2892
 
2478
2893
  try {
2479
2894
  const createdReply = await target.createPost(replyData);
2480
2895
  repliesCreated++;
2481
- if (createdReply?.id) {
2482
- threadTailId = createdReply.id;
2483
- }
2896
+ threadTail = getNextThreadTail(threadTail, createdReply);
2484
2897
  } catch (err) {
2485
2898
  console.error(` Error creating reply: ${err.message}`);
2486
2899
  process.exit(1);
@@ -2536,6 +2949,63 @@ export async function run(argv) {
2536
2949
  }
2537
2950
  }
2538
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
+
2539
3009
  await target?.close();
2540
3010
 
2541
3011
  // 5. Summary
@@ -2550,6 +3020,9 @@ export async function run(argv) {
2550
3020
  if (aliasesCreated > 0) {
2551
3021
  console.log(` Aliases created: ${aliasesCreated}`);
2552
3022
  }
3023
+ if (customUrlsCreated > 0) {
3024
+ console.log(` Redirects created: ${customUrlsCreated}`);
3025
+ }
2553
3026
  if (dryRun) {
2554
3027
  console.log(" (dry-run mode — no changes were made)");
2555
3028
  }