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