@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
@@ -11,22 +11,39 @@
11
11
  import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
12
12
  import { tmpdir } from "node:os";
13
13
  import { join } from "node:path";
14
- import { afterEach, beforeEach, describe, expect, it } from "vitest";
14
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
15
15
  import { __test__ } from "../../bin/commands/import-site.js";
16
+ import {
17
+ partitionEditableSettingUpdates,
18
+ partitionImportableSettingUpdates,
19
+ } from "../lib/api-settings.js";
20
+ import { normalizeEditableSettingValue } from "../lib/schemas.js";
21
+ import type { ConfigKey } from "../types/config.js";
16
22
 
17
23
  const {
18
24
  walkHugoContent,
19
25
  loadSiteConfig,
20
26
  buildSettingsUpdatesFromConfig,
27
+ splitSettingsUpdatesForImport,
28
+ buildSiteAvatarImport,
21
29
  mediaSpecFromJantMedia,
22
30
  resolveCollectionMemberships,
23
31
  resolveThreadCollectionMemberships,
24
32
  buildPostPayloadFromBundle,
25
33
  getRootAliasPathsForImport,
26
34
  uploadMediaList,
35
+ uploadBundleResources,
36
+ normalizeImportedNavItems,
37
+ buildNavItemCreateRequest,
38
+ buildSmartCollectionCreateRequest,
39
+ normalizeImportedCollectionDirectory,
40
+ syncImportedCollectionDirectory,
41
+ normalizeImportedCustomUrls,
42
+ buildImportedAttachments,
27
43
  normalizeTextAttachmentSpec,
28
44
  isAbsoluteImportUrl,
29
45
  shouldImportReplyQuietly,
46
+ getNextThreadTail,
30
47
  } = __test__;
31
48
 
32
49
  async function writeFileTree(
@@ -152,6 +169,119 @@ describe("Hugo import CLI helpers", () => {
152
169
  expect(siteConfig.extra.jant.collections_directory).toHaveLength(2);
153
170
  });
154
171
 
172
+ it.each([true, false])(
173
+ "sends settings the settings API accepts when every flag is %s",
174
+ async (flag) => {
175
+ // The Config Editor (6c79f0b4) made boolean settings "true" or "false".
176
+ // The importer kept sending "" for a false flag, the settings route
177
+ // answered 400, and the import stopped before it created anything.
178
+ await writeFileTree(tempDir, {
179
+ "data/jant.toml": [
180
+ 'format = "jant-site"',
181
+ "version = 1",
182
+ 'site_name = "Example Site"',
183
+ 'site_language = "en"',
184
+ `show_jant_branding_on_home = ${flag}`,
185
+ `show_header_avatar = ${flag}`,
186
+ `noindex = ${flag}`,
187
+ `public_api_enabled = ${flag}`,
188
+ `rss_feeds_enabled = ${flag}`,
189
+ 'theme_id = "paper"',
190
+ 'default_theme_id = "tufte"',
191
+ 'font_theme_id = "classic"',
192
+ 'theme_mode = "dark"',
193
+ "",
194
+ ].join("\n"),
195
+ });
196
+
197
+ const siteConfig = await loadSiteConfig(tempDir);
198
+ const { editable, internal } = splitSettingsUpdatesForImport(
199
+ buildSettingsUpdatesFromConfig(siteConfig, "body { color: red; }"),
200
+ );
201
+
202
+ // The two routes the importer calls, in the order the routes apply them:
203
+ // which keys each accepts, then the value check `settings.setMany` runs.
204
+ const settingsRoute = partitionEditableSettingUpdates(editable, false);
205
+ const importRoute = partitionImportableSettingUpdates(internal, false);
206
+ expect(settingsRoute.rejectedKeys).toEqual([]);
207
+ expect(importRoute.rejectedKeys).toEqual([]);
208
+
209
+ const accepted = {
210
+ ...settingsRoute.filteredUpdates,
211
+ ...importRoute.filteredUpdates,
212
+ };
213
+ for (const [key, value] of Object.entries(accepted)) {
214
+ expect(
215
+ () => normalizeEditableSettingValue(key as ConfigKey, value),
216
+ `${key}=${JSON.stringify(value)}`,
217
+ ).not.toThrow();
218
+ }
219
+ },
220
+ );
221
+
222
+ it("buildSiteAvatarImport reads exported icons from the theme's static directory", async () => {
223
+ // The export writes favicon.ico and apple-touch-icon.png under
224
+ // themes/jant/static/. Looking only in static/ sent the importer to the
225
+ // source site for them, and an unreachable source dropped the avatar too.
226
+ await writeFileTree(tempDir, {
227
+ "data/jant.toml": [
228
+ 'format = "jant-site"',
229
+ 'site_avatar_mode = "custom"',
230
+ 'favicon_mode = "custom"',
231
+ 'apple_touch_mode = "custom"',
232
+ 'favicon_path = "/favicon.ico"',
233
+ 'apple_touch_icon_path = "/apple-touch-icon.png"',
234
+ 'site_avatar_url = "/media/avatar.png"',
235
+ "",
236
+ ].join("\n"),
237
+ "hugo.toml": 'baseURL = "https://gone.example/"\n',
238
+ "static/media/avatar.png": new Uint8Array([1]),
239
+ "themes/jant/static/favicon.ico": new Uint8Array([2]),
240
+ "themes/jant/static/apple-touch-icon.png": new Uint8Array([3]),
241
+ });
242
+
243
+ const avatarImport = await buildSiteAvatarImport(
244
+ await loadSiteConfig(tempDir),
245
+ tempDir,
246
+ );
247
+
248
+ expect(avatarImport).toMatchObject({
249
+ mode: "set",
250
+ avatarFilePath: join(tempDir, "static/media/avatar.png"),
251
+ faviconFilePath: join(tempDir, "themes/jant/static/favicon.ico"),
252
+ appleTouchFilePath: join(
253
+ tempDir,
254
+ "themes/jant/static/apple-touch-icon.png",
255
+ ),
256
+ });
257
+ });
258
+
259
+ it("buildSiteAvatarImport prefers an icon in the root static directory", async () => {
260
+ // Hugo serves the site's own static/ over the theme's, so a file placed
261
+ // there after export is the one the site shows.
262
+ await writeFileTree(tempDir, {
263
+ "data/jant.toml": [
264
+ 'format = "jant-site"',
265
+ 'site_avatar_mode = "custom"',
266
+ 'favicon_mode = "custom"',
267
+ 'site_avatar_url = "/media/avatar.png"',
268
+ "",
269
+ ].join("\n"),
270
+ "static/media/avatar.png": new Uint8Array([1]),
271
+ "static/favicon.ico": new Uint8Array([4]),
272
+ "themes/jant/static/favicon.ico": new Uint8Array([2]),
273
+ });
274
+
275
+ const avatarImport = await buildSiteAvatarImport(
276
+ await loadSiteConfig(tempDir),
277
+ tempDir,
278
+ );
279
+
280
+ expect(avatarImport).toMatchObject({
281
+ faviconFilePath: join(tempDir, "static/favicon.ico"),
282
+ });
283
+ });
284
+
155
285
  it("mediaSpecFromJantMedia resolves site-relative src under static/", async () => {
156
286
  await mkdir(join(tempDir, "static", "media"), { recursive: true });
157
287
  await writeFile(join(tempDir, "static/media/med-1.webp"), "PHOTO");
@@ -237,6 +367,362 @@ describe("Hugo import CLI helpers", () => {
237
367
  );
238
368
  });
239
369
 
370
+ it("rebuilds navigation with its placement, labels, and targets", () => {
371
+ const siteConfig = {
372
+ extra: {
373
+ jant: {
374
+ nav: [
375
+ {
376
+ type: "system",
377
+ label: "All",
378
+ url: "/archive/",
379
+ system_key: "archive",
380
+ placement: "header",
381
+ custom_label: "All",
382
+ },
383
+ {
384
+ type: "collection",
385
+ label: "Now",
386
+ url: "/now",
387
+ system_key: "",
388
+ placement: "header",
389
+ collection_slug: "now",
390
+ },
391
+ // An export from before `collection_slug`: the URL names it.
392
+ {
393
+ type: "collection",
394
+ label: "Books",
395
+ url: "/books/",
396
+ system_key: "",
397
+ placement: "more",
398
+ },
399
+ {
400
+ type: "page",
401
+ label: "About",
402
+ url: "/about",
403
+ system_key: "",
404
+ placement: "more",
405
+ post_slug: "about",
406
+ },
407
+ {
408
+ type: "link",
409
+ label: "More Quotes",
410
+ url: "https://jant.me/quotes",
411
+ system_key: "",
412
+ placement: "more",
413
+ },
414
+ {
415
+ type: "collection",
416
+ label: "Gone",
417
+ url: "/gone",
418
+ system_key: "",
419
+ placement: "header",
420
+ collection_slug: "gone",
421
+ },
422
+ {
423
+ type: "smart_collection",
424
+ label: "Thoughts",
425
+ url: "/thoughts",
426
+ system_key: "",
427
+ placement: "header",
428
+ smart_collection_slug: "thoughts",
429
+ },
430
+ ],
431
+ },
432
+ },
433
+ };
434
+ const targets = {
435
+ collectionSlugToId: new Map([
436
+ ["now", "col-now"],
437
+ ["books", "col-books"],
438
+ ]),
439
+ smartCollectionSlugToId: new Map([["thoughts", "smc-thoughts"]]),
440
+ postSlugToId: new Map([["about", "pst-about"]]),
441
+ };
442
+
443
+ const requests = normalizeImportedNavItems(siteConfig).items.map((item) =>
444
+ buildNavItemCreateRequest(item, targets),
445
+ );
446
+
447
+ expect(requests.map((request) => request.payload)).toEqual([
448
+ { type: "system", systemKey: "archive", placement: "header" },
449
+ { type: "collection", collectionId: "col-now", placement: "header" },
450
+ { type: "collection", collectionId: "col-books", placement: "more" },
451
+ { type: "page", postId: "pst-about", placement: "more" },
452
+ {
453
+ type: "link",
454
+ label: "More Quotes",
455
+ url: "https://jant.me/quotes",
456
+ placement: "more",
457
+ },
458
+ { type: "link", label: "Gone", url: "/gone", placement: "header" },
459
+ {
460
+ type: "smart_collection",
461
+ smartCollectionId: "smc-thoughts",
462
+ placement: "header",
463
+ },
464
+ ]);
465
+ expect(requests[0]?.customLabel).toBe("All");
466
+ expect(requests[5]?.warning).toContain('"Gone"');
467
+ });
468
+
469
+ it("recreates a smart collection from its exported conditions", () => {
470
+ const bundle = {
471
+ slug: "pictures",
472
+ frontMatter: {
473
+ title: "Pictures",
474
+ type: "smart_collection",
475
+ summary_text: "Photos from the ideas collection.",
476
+ sort_order: "rating_desc",
477
+ display_layout: "grid",
478
+ selection: {
479
+ collection: "ideas",
480
+ media: ["image", "video"],
481
+ year: 2025,
482
+ title: false,
483
+ },
484
+ },
485
+ };
486
+
487
+ expect(
488
+ buildSmartCollectionCreateRequest(
489
+ bundle,
490
+ new Map([["ideas", "col-ideas"]]),
491
+ ),
492
+ ).toEqual({
493
+ payload: {
494
+ slug: "pictures",
495
+ title: "Pictures",
496
+ description: "Photos from the ideas collection.",
497
+ selection: {
498
+ collection: ["col-ideas"],
499
+ media: ["image", "video"],
500
+ year: 2025,
501
+ title: false,
502
+ },
503
+ sort: "rating_desc",
504
+ layout: "grid",
505
+ },
506
+ warning: null,
507
+ });
508
+
509
+ // Without the collection it names, the smart collection would gather
510
+ // every post the other conditions match; it is skipped instead.
511
+ const skipped = buildSmartCollectionCreateRequest(bundle, new Map());
512
+ expect(skipped.payload).toBeNull();
513
+ expect(skipped.warning).toContain('"ideas"');
514
+
515
+ expect(
516
+ buildSmartCollectionCreateRequest(
517
+ { slug: "everything", frontMatter: { type: "smart_collection" } },
518
+ new Map(),
519
+ ).payload,
520
+ ).toEqual({ slug: "everything", title: "everything", selection: {} });
521
+ });
522
+
523
+ it("finds smart collection pages in the content tree", async () => {
524
+ await writeFileTree(tempDir, {
525
+ "content/thoughts/_index.md": [
526
+ "---",
527
+ 'title: "Thoughts"',
528
+ 'slug: "thoughts"',
529
+ 'type: "smart_collection"',
530
+ "selection:",
531
+ ' format: "note"',
532
+ "---",
533
+ "",
534
+ ].join("\n"),
535
+ "content/ideas/_index.md": [
536
+ "---",
537
+ 'title: "Ideas"',
538
+ 'type: "collection"',
539
+ "---",
540
+ "",
541
+ ].join("\n"),
542
+ });
543
+
544
+ const { rootBundles, collectionBundles, smartCollectionBundles } =
545
+ await walkHugoContent(tempDir);
546
+
547
+ expect(rootBundles).toHaveLength(0);
548
+ expect(collectionBundles.map((bundle) => bundle.slug)).toEqual(["ideas"]);
549
+ expect(smartCollectionBundles).toMatchObject([
550
+ { slug: "thoughts", frontMatter: { selection: { format: "note" } } },
551
+ ]);
552
+ });
553
+
554
+ // Creating a collection or smart collection gives it a directory row;
555
+ // dividers and links are rebuilt. The sync keeps the first kind and puts
556
+ // every row in the exported order.
557
+ it("restores the directory order with smart collections in it", async () => {
558
+ let rows = [
559
+ { id: "dir-col", type: "collection", collectionId: "col-ideas" },
560
+ {
561
+ id: "dir-smc",
562
+ type: "smart_collection",
563
+ smartCollectionId: "smc-thoughts",
564
+ },
565
+ { id: "dir-old-divider", type: "divider" },
566
+ ];
567
+ let nextId = 0;
568
+ const target = {
569
+ async listCollectionDirectoryItems() {
570
+ return rows;
571
+ },
572
+ async deleteCollectionDirectoryItem(id: string) {
573
+ rows = rows.filter((row) => row.id !== id);
574
+ },
575
+ async createCollectionDirectoryItem(data: { type: string }) {
576
+ const row = { id: `dir-new-${nextId++}`, type: data.type };
577
+ rows = [...rows, row];
578
+ return row;
579
+ },
580
+ async moveCollectionDirectoryItem(
581
+ id: string,
582
+ afterId: string | null,
583
+ beforeId: string | null,
584
+ ) {
585
+ const moving = rows.find((row) => row.id === id)!;
586
+ const rest = rows.filter((row) => row.id !== id);
587
+ const index =
588
+ afterId !== null
589
+ ? rest.findIndex((row) => row.id === afterId) + 1
590
+ : beforeId !== null
591
+ ? rest.findIndex((row) => row.id === beforeId)
592
+ : 0;
593
+ rest.splice(index, 0, moving);
594
+ rows = rest;
595
+ },
596
+ };
597
+ const directory = normalizeImportedCollectionDirectory({
598
+ extra: {
599
+ jant: {
600
+ collections_directory: [
601
+ { type: "smart_collection", slug: "thoughts" },
602
+ { type: "divider", label: "Topics" },
603
+ { type: "collection", slug: "ideas" },
604
+ ],
605
+ },
606
+ },
607
+ });
608
+
609
+ const result = await syncImportedCollectionDirectory(
610
+ target,
611
+ directory,
612
+ new Map([["ideas", "col-ideas"]]),
613
+ new Map([["thoughts", "smc-thoughts"]]),
614
+ );
615
+
616
+ expect(rows.map((row) => row.id)).toEqual([
617
+ "dir-smc",
618
+ "dir-new-0",
619
+ "dir-col",
620
+ ]);
621
+ expect(result).toMatchObject({ created: 1, deleted: 1 });
622
+ });
623
+
624
+ // The API refuses a reply to anything but the Thread's end, which the site
625
+ // reads as the last post in Thread order: the root first whatever its
626
+ // creation time, then replies by creation time, then ID. A reply can be
627
+ // older than its root (moved into the Thread, or dated by publish time in
628
+ // an older export) and still end the Thread.
629
+ it("follows the Thread's end the way the site reads it", () => {
630
+ const root = { id: "pst_05", createdAt: 1_000, replyToId: null };
631
+ const older = { id: "pst_06", createdAt: 500, replyToId: root.id };
632
+ const oldest = { id: "pst_07", createdAt: 400, replyToId: older.id };
633
+ const tiedLowerId = { id: "pst_04", createdAt: 500, replyToId: older.id };
634
+ const tiedHigherId = { id: "pst_08", createdAt: 500, replyToId: older.id };
635
+ const newer = { id: "pst_09", createdAt: 2_000, replyToId: older.id };
636
+
637
+ expect(getNextThreadTail(root, older)).toBe(older);
638
+ expect(getNextThreadTail(older, oldest)).toBe(older);
639
+ expect(getNextThreadTail(older, tiedLowerId)).toBe(older);
640
+ expect(getNextThreadTail(older, tiedHigherId)).toBe(tiedHigherId);
641
+ expect(getNextThreadTail(older, newer)).toBe(newer);
642
+ expect(getNextThreadTail(root, null)).toBe(root);
643
+ });
644
+
645
+ it("reads the redirects and archive URLs an export lists", () => {
646
+ expect(
647
+ normalizeImportedCustomUrls({
648
+ extra: {
649
+ jant: {
650
+ custom_urls: [
651
+ { path: "atom.xml", kind: "redirect", to: "/feed", status: 301 },
652
+ {
653
+ path: "/inspires",
654
+ kind: "redirect",
655
+ to: "/inspired",
656
+ status: 302,
657
+ },
658
+ { path: "links", kind: "archive", archive_query: "format=link" },
659
+ { path: "", kind: "redirect", to: "/x" },
660
+ ],
661
+ },
662
+ },
663
+ }),
664
+ ).toEqual([
665
+ { path: "atom.xml", kind: "redirect", to: "/feed", status: 301 },
666
+ { path: "inspires", kind: "redirect", to: "/inspired", status: 302 },
667
+ { path: "links", kind: "archive", archiveQuery: "format=link" },
668
+ ]);
669
+ });
670
+
671
+ it("uploadMediaList keeps a body image as a link when its upload fails", async () => {
672
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
673
+ const target = {
674
+ uploadMedia: async () => {
675
+ throw new Error("Couldn't read https://gone.example/a.png");
676
+ },
677
+ };
678
+
679
+ const result = await uploadMediaList(
680
+ [{ src: "https://gone.example/a.png" }],
681
+ target,
682
+ { base_url: "https://origin.example/" },
683
+ tempDir,
684
+ );
685
+
686
+ expect(result.uploaded).toBe(0);
687
+ expect(result.urlMap.size).toBe(0);
688
+ expect(warn).toHaveBeenCalledWith(
689
+ expect.stringContaining("kept https://gone.example/a.png as a link"),
690
+ );
691
+ warn.mockRestore();
692
+ });
693
+
694
+ it("uploadBundleResources stops when a post's own file can't be uploaded", async () => {
695
+ const target = {
696
+ uploadMedia: async () => {
697
+ throw new Error("The site refused /media/a.webp: HTTP 413");
698
+ },
699
+ };
700
+
701
+ await expect(
702
+ uploadBundleResources(
703
+ [{ src: "/media/a.webp", srcFilePath: join(tempDir, "a.webp") }],
704
+ target,
705
+ ),
706
+ ).rejects.toThrow("Couldn't upload /media/a.webp: The site refused");
707
+ });
708
+
709
+ it("buildImportedAttachments stops when an attachment can't be uploaded", async () => {
710
+ const target = {
711
+ uploadMedia: async () => {
712
+ throw new Error("The site refused /media/b.jpg: HTTP 500");
713
+ },
714
+ };
715
+
716
+ await expect(
717
+ buildImportedAttachments(
718
+ [{ src: "https://origin.example/media/b.jpg" }],
719
+ target,
720
+ { base_url: "https://origin.example/" },
721
+ tempDir,
722
+ ),
723
+ ).rejects.toThrow("Couldn't upload https://origin.example/media/b.jpg");
724
+ });
725
+
240
726
  it("isAbsoluteImportUrl distinguishes absolute URLs from relative paths for --skip-remote-media", () => {
241
727
  // Relative paths — always treated as the source site's own files.
242
728
  expect(isAbsoluteImportUrl("/media/foo.png")).toBe(false);
@@ -1,22 +1,93 @@
1
- import { readFileSync } from "node:fs";
1
+ import { readdirSync, readFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import { describe, expect, it } from "vitest";
4
4
  import { parse } from "smol-toml";
5
5
 
6
+ const REPO_ROOT = resolve(import.meta.dirname, "../../../..");
7
+
6
8
  interface MiseConfig {
7
9
  tasks?: Record<
8
10
  string,
9
11
  {
10
12
  env?: Record<string, string>;
13
+ run?: string | string[];
11
14
  }
12
15
  >;
13
16
  }
14
17
 
18
+ function readMiseConfig(): MiseConfig {
19
+ return parse(
20
+ readFileSync(resolve(REPO_ROOT, "mise.toml"), "utf8"),
21
+ ) as MiseConfig;
22
+ }
23
+
24
+ /**
25
+ * Every shell command the repo defines: mise tasks and the scripts of each
26
+ * workspace package.
27
+ *
28
+ * @returns Each command with the name of the task or script that runs it
29
+ */
30
+ function collectCommands(): { name: string; command: string }[] {
31
+ const packageDirs = ["packages", "sites"].flatMap((parent) =>
32
+ readdirSync(resolve(REPO_ROOT, parent), { withFileTypes: true })
33
+ .filter((entry) => entry.isDirectory())
34
+ .map((entry) => `${parent}/${entry.name}`),
35
+ );
36
+
37
+ return [
38
+ ...Object.entries(readMiseConfig().tasks ?? {}).flatMap(([name, task]) =>
39
+ [task.run ?? []].flat().map((command) => ({
40
+ name: `mise task ${name}`,
41
+ command,
42
+ })),
43
+ ),
44
+ ...packageDirs.flatMap((dir) => {
45
+ const pkg = JSON.parse(
46
+ readFileSync(resolve(REPO_ROOT, dir, "package.json"), "utf8"),
47
+ ) as { scripts?: Record<string, string> };
48
+ return Object.entries(pkg.scripts ?? {}).map(([name, command]) => ({
49
+ name: `${dir} script ${name}`,
50
+ command,
51
+ }));
52
+ }),
53
+ ];
54
+ }
55
+
56
+ /** A `jant migrate` call and its arguments, up to the end of that command. */
57
+ const MIGRATE_INVOCATION =
58
+ /(?:\bjant(?:\.js)?|run-jant\.mjs)\s+migrate(?![\w-])([^\n;&|]*)/g;
59
+
60
+ const RUNTIME_FLAG = /(?:^|\s)--(local|remote|node)(?=\s|$)/;
61
+
15
62
  describe("mise tasks", () => {
16
63
  it("enables environment proxy support before the Node dev runtime starts", () => {
17
- const configPath = resolve(import.meta.dirname, "../../../../mise.toml");
18
- const config = parse(readFileSync(configPath, "utf8")) as MiseConfig;
64
+ expect(readMiseConfig().tasks?.["dev-node"]?.env?.NODE_USE_ENV_PROXY).toBe(
65
+ "1",
66
+ );
67
+ });
68
+
69
+ // Without a runtime flag, `jant migrate` loads packages/core/.env.node and
70
+ // targets the Node database whenever that file sets DATABASE_URL or DATA_DIR.
71
+ // `db-wrangler-migrate` once ran that way: in a worktree with a Postgres
72
+ // `.env.node` it migrated Postgres and left local D1 empty.
73
+ it("names the runtime on every scripted jant migrate", () => {
74
+ const invocations = collectCommands().flatMap(({ name, command }) =>
75
+ [...command.matchAll(MIGRATE_INVOCATION)].map(([, args]) => ({
76
+ name,
77
+ runtime: RUNTIME_FLAG.exec(args)?.[1] ?? null,
78
+ })),
79
+ );
19
80
 
20
- expect(config.tasks?.["dev-node"]?.env?.NODE_USE_ENV_PROXY).toBe("1");
81
+ expect(invocations).toContainEqual({
82
+ name: "mise task db-wrangler-migrate",
83
+ runtime: "local",
84
+ });
85
+ expect(invocations.filter(({ runtime }) => runtime === null)).toEqual([]);
86
+ expect(
87
+ invocations.filter(
88
+ ({ name, runtime }) =>
89
+ name.startsWith("mise task db-wrangler-") && runtime !== "local",
90
+ ),
91
+ ).toEqual([]);
21
92
  });
22
93
  });