@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
@@ -37,6 +37,7 @@ import {
37
37
  formatFrontMatter,
38
38
  type HugoCollectionRef,
39
39
  type HugoFrontMatter,
40
+ type HugoSmartCollectionSelection,
40
41
  type JantMedia,
41
42
  } from "../lib/hugo-markdown.js";
42
43
  // Shared design tokens — single source of truth for colors, typography,
@@ -69,6 +70,8 @@ import LAYOUT_FEATURED_LIST from "./export-theme/layouts/featured/list.html?raw"
69
70
  import LAYOUT_ARCHIVE_LIST from "./export-theme/layouts/archive/list.html?raw";
70
71
  import LAYOUT_COLLECTIONS_LIST from "./export-theme/layouts/collections/list.html?raw";
71
72
  import LAYOUT_COLLECTION_SINGLE from "./export-theme/layouts/collection/single.html?raw";
73
+ import LAYOUT_SMART_COLLECTION_LIST from "./export-theme/layouts/smart_collection/list.html?raw";
74
+ import PARTIAL_JANT_DATA from "./export-theme/layouts/partials/jant-data.html?raw";
72
75
  import PARTIAL_HEAD from "./export-theme/layouts/partials/head.html?raw";
73
76
  import PARTIAL_HEADER from "./export-theme/layouts/partials/header.html?raw";
74
77
  import PARTIAL_FOOTER from "./export-theme/layouts/partials/footer.html?raw";
@@ -78,32 +81,98 @@ import PARTIAL_MEDIA_GALLERY from "./export-theme/layouts/partials/media-gallery
78
81
  import PARTIAL_REPLY from "./export-theme/layouts/partials/reply.html?raw";
79
82
  import PARTIAL_THREAD_PREVIEW from "./export-theme/layouts/partials/thread-preview.html?raw";
80
83
  import PARTIAL_FEATURED_THREAD from "./export-theme/layouts/partials/featured-thread.html?raw";
84
+ import PARTIAL_SMART_COLLECTION_MEMBERS from "./export-theme/layouts/partials/smart-collection-members.html?raw";
85
+ import PARTIAL_COLLECTION_THREADS from "./export-theme/layouts/partials/collection-threads.html?raw";
86
+ import PARTIAL_COLLECTION_MEMBERS from "./export-theme/layouts/partials/collection-members.html?raw";
87
+ import PARTIAL_LATEST_MEMBERS from "./export-theme/layouts/partials/latest-members.html?raw";
88
+ import PARTIAL_FEATURED_MEMBERS from "./export-theme/layouts/partials/featured-members.html?raw";
81
89
  import LAYOUT_RSS from "./export-theme/layouts/_default/rss.xml?raw";
82
90
  import PARTIAL_FEED_POST_CONTENT from "./export-theme/layouts/partials/feed-post-content.xml?raw";
83
91
 
92
+ import { suggestSyncRepoName } from "../lib/github-sync-repo-name.js";
93
+ import { getPostPath, toAbsoluteSiteUrl } from "../lib/url.js";
84
94
  import type { StorageDriver } from "../lib/storage.js";
85
95
  import { base64ToUint8Array } from "../lib/favicon.js";
96
+ import { makeZip } from "client-zip";
86
97
  import {
87
98
  SYSTEM_NAV_KEYS,
88
99
  isFeedNavKey,
89
100
  type Collection,
90
101
  type Media,
91
102
  type NavItem,
103
+ type PathRecord,
92
104
  type Post,
105
+ type SmartCollection,
93
106
  type SystemNavKey,
94
107
  } from "../types.js";
95
108
 
96
- /** A file entry in the exported Hugo site. */
97
- export interface ExportFile {
109
+ /** A file of the exported Hugo site whose text or bytes the export holds. */
110
+ export interface ExportContentFile {
98
111
  path: string;
99
112
  content: string | Uint8Array;
113
+ /**
114
+ * Scaffolding the destination owns once it exists. A fresh export always
115
+ * carries it — the ZIP is a new tree, and `site export --directory` refuses
116
+ * a non-empty directory — but GitHub Sync writes it only when the
117
+ * repository does not have it yet, so an edit there survives every later
118
+ * push. Deploy config is the case that needs this: the Worker name has to
119
+ * be corrected by hand when it does not match the Worker already serving
120
+ * the domain.
121
+ */
122
+ scaffoldOnce?: boolean;
123
+ }
124
+
125
+ /**
126
+ * A media file of the exported site, named but not read: the archive streams
127
+ * it from storage as it is written. Reading every bundled file up front held a
128
+ * site's media in memory, and on a Docker site with 3 GB of local media the
129
+ * container was killed before it answered.
130
+ */
131
+ export interface ExportStoredFile {
132
+ path: string;
133
+ storageKey: string;
134
+ }
135
+
136
+ /** A file entry in the exported Hugo site. */
137
+ export type ExportFile = ExportContentFile | ExportStoredFile;
138
+
139
+ /**
140
+ * @param file - An export file entry
141
+ * @returns Whether its bytes are still in storage
142
+ * @example
143
+ * if (isStoredExportFile(file)) await readStoredExportFile(file, storage);
144
+ */
145
+ export function isStoredExportFile(file: ExportFile): file is ExportStoredFile {
146
+ return "storageKey" in file;
147
+ }
148
+
149
+ /**
150
+ * Read a stored export file's bytes, for a consumer that needs them whole.
151
+ *
152
+ * @param file - A stored export file
153
+ * @param storage - The site's storage
154
+ * @returns The bytes, or null when the object is gone
155
+ * @example
156
+ * const bytes = await readStoredExportFile(file, storage);
157
+ */
158
+ export async function readStoredExportFile(
159
+ file: ExportStoredFile,
160
+ storage: StorageDriver,
161
+ ): Promise<Uint8Array | null> {
162
+ const object = await storage.get(file.storageKey);
163
+ if (!object?.body) return null;
164
+ return new Uint8Array(await new Response(object.body).arrayBuffer());
100
165
  }
101
166
 
102
167
  export interface ExportService {
103
168
  /** Generate a flat list of files for a complete Hugo site. */
104
169
  generateHugoFiles(): Promise<ExportFile[]>;
105
- /** Generate a ZIP archive of the Hugo site. */
106
- generateHugoSite(): Promise<Uint8Array>;
170
+ /**
171
+ * The Hugo site as a ZIP stream. Stored media is read from storage one file
172
+ * at a time as the stream is consumed, so memory stays flat whatever the
173
+ * size of the site; the archive switches to ZIP64 past 4 GiB.
174
+ */
175
+ generateHugoSite(): Promise<ReadableStream<Uint8Array>>;
107
176
  }
108
177
 
109
178
  export interface SiteConfig {
@@ -151,6 +220,9 @@ export interface SiteConfig {
151
220
  | "url"
152
221
  | "position"
153
222
  | "placement"
223
+ | "collectionId"
224
+ | "smartCollectionId"
225
+ | "postId"
154
226
  >[];
155
227
  /** Items per page for Hugo pagination — kept in sync with the main site's PAGE_SIZE. */
156
228
  pageSize: number;
@@ -174,6 +246,16 @@ type ExportedCollectionDirectoryItem =
174
246
  recentActivityLabel?: string | null;
175
247
  recentActivityIso?: string | null;
176
248
  }
249
+ | {
250
+ type: "smart_collection";
251
+ sequence: string;
252
+ slug: string;
253
+ title: string;
254
+ descriptionHtml?: string | null;
255
+ entryCount?: number;
256
+ recentActivityLabel?: string | null;
257
+ recentActivityIso?: string | null;
258
+ }
177
259
  | {
178
260
  type: "divider";
179
261
  label: string | null;
@@ -183,12 +265,14 @@ type ExportedCollectionDirectoryItem =
183
265
  sequence: string;
184
266
  label: string;
185
267
  url: string;
268
+ /** The link description as the author wrote it, or null if empty. */
269
+ description?: string | null;
186
270
  /** Rendered HTML of the link description, or null if empty. */
187
271
  descriptionHtml?: string | null;
188
272
  };
189
273
 
190
274
  interface ExportCollectionDirectorySourceItem {
191
- type: "collection" | "divider" | "link";
275
+ type: "collection" | "smart_collection" | "divider" | "link";
192
276
  label?: string | null;
193
277
  url?: string | null;
194
278
  description?: string | null;
@@ -200,6 +284,13 @@ interface ExportCollectionDirectorySourceItem {
200
284
  threadCount?: number;
201
285
  recentActivityAt?: number;
202
286
  };
287
+ smartCollection?: {
288
+ slug: string;
289
+ title: string;
290
+ description?: string | null;
291
+ threadCount?: number;
292
+ recentActivityAt?: number;
293
+ };
203
294
  }
204
295
 
205
296
  interface SiteIconAssets {
@@ -262,6 +353,14 @@ export function createExportService(
262
353
  * base64-encodes attachment bytes.
263
354
  */
264
355
  bundleMedia?: boolean;
356
+ /**
357
+ * Name of the GitHub repository this export is destined for, when it has
358
+ * one. It decides the Worker name in `wrangler.jsonc`: Cloudflare names a
359
+ * Worker after the repository it was imported from, and requires the two
360
+ * to match. GitHub Sync passes it; a ZIP export has no repository and
361
+ * falls back to the repository name Sync would suggest for the site.
362
+ */
363
+ repoName?: string | null;
265
364
  } = {},
266
365
  ): ExportService {
267
366
  return {
@@ -297,6 +396,7 @@ export function createExportService(
297
396
  slugMap,
298
397
  aliasMap,
299
398
  collectionSlugMap,
399
+ standalonePaths,
300
400
  ] = await Promise.all([
301
401
  services.collections.getCollectionsByPostIds(rootPostIds),
302
402
  services.collections.getCollectionEntriesByThreadIds(rootPostIds),
@@ -304,6 +404,7 @@ export function createExportService(
304
404
  services.paths.getPostSlugMap(allPostIds),
305
405
  services.paths.getPostAliases(rootPostIds),
306
406
  services.paths.getCollectionSlugMap(allCollections.map((c) => c.id)),
407
+ services.paths.listStandalonePaths(),
307
408
  ]);
308
409
  // Denormalized title lookup so front-matter collection refs can
309
410
  // include a title label without templates having to resolve another
@@ -320,22 +421,48 @@ export function createExportService(
320
421
  allPosts,
321
422
  collectionsByRoot,
322
423
  );
323
- // Smart collections are left out of a static export. Their membership is
324
- // a query, and the exported site has no database to run it against, so
325
- // the honest options are "omit" or "freeze today's matches under a name
326
- // that promises to keep updating". Round-tripping them is out of scope
327
- // (see the smart collections design notes); omitting is what that
328
- // decision means here.
424
+ // Smart collections travel as their conditions (a section page per
425
+ // smart collection, below): a site importing the export recreates them,
426
+ // and the Hugo theme applies the same conditions to the posts it has, so
427
+ // the page keeps up with the repository instead of freezing the matches
428
+ // of the day it was exported.
429
+ const exportedSmartCollections: {
430
+ smartCollection: SmartCollection;
431
+ selection: HugoSmartCollectionSelection;
432
+ }[] = [];
433
+ const smartCollectionSlugMap = new Map<string, string>();
434
+ for (const smartCollection of collectionDirectoryData?.smartCollections ??
435
+ []) {
436
+ const selection = toExportedSelection(
437
+ smartCollection.selection,
438
+ collectionSlugMap,
439
+ );
440
+ if (!selection) {
441
+ // eslint-disable-next-line no-console -- A dropped page must leave a trace
442
+ console.warn(
443
+ `Export: smart collection /${smartCollection.slug} filters by a collection that no longer exists, so it was left out.`,
444
+ );
445
+ continue;
446
+ }
447
+ smartCollectionSlugMap.set(smartCollection.id, smartCollection.slug);
448
+ exportedSmartCollections.push({ smartCollection, selection });
449
+ }
329
450
  const exportableDirectoryItems: ExportCollectionDirectorySourceItem[] =
330
451
  collectionDirectoryData?.items
331
452
  ? collectionDirectoryData.items
332
- .filter((item) => item.type !== "smart_collection")
453
+ .filter(
454
+ (item) =>
455
+ item.type !== "smart_collection" ||
456
+ (item.smartCollection !== undefined &&
457
+ smartCollectionSlugMap.has(item.smartCollection.id)),
458
+ )
333
459
  .map((item) => ({
334
- type: item.type as "collection" | "divider" | "link",
460
+ type: item.type,
335
461
  label: item.label,
336
462
  url: item.url,
337
463
  description: item.description,
338
464
  collection: item.collection,
465
+ smartCollection: item.smartCollection,
339
466
  }))
340
467
  : allCollections.map((collection) => ({
341
468
  type: "collection" as const,
@@ -355,9 +482,14 @@ export function createExportService(
355
482
  list.push(reply);
356
483
  repliesByThread.set(reply.threadId, list);
357
484
  }
358
- // Sort replies by createdAt within each thread
485
+ // Thread order, as `posts.getThread` reads it: after the root, which
486
+ // leads as `_index.md`, creation time, then ID.
359
487
  for (const list of repliesByThread.values()) {
360
- list.sort((a, b) => a.createdAt - b.createdAt);
488
+ list.sort(
489
+ (a, b) =>
490
+ a.createdAt - b.createdAt ||
491
+ (a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
492
+ );
361
493
  }
362
494
 
363
495
  // 3. Build file list
@@ -408,6 +540,19 @@ export function createExportService(
408
540
  });
409
541
  }
410
542
 
543
+ // Smart collection landing pages: the conditions, which the theme's
544
+ // `smart-collection-members` partial applies to the exported posts.
545
+ for (const { smartCollection, selection } of exportedSmartCollections) {
546
+ exportFiles.push({
547
+ path: `content/${smartCollection.slug}/_index.md`,
548
+ content: await buildSmartCollectionSection(
549
+ smartCollection,
550
+ selection,
551
+ siteConfig.rssFeedsEnabled,
552
+ ),
553
+ });
554
+ }
555
+
411
556
  // Section + home scaffolding.
412
557
  exportFiles.push({
413
558
  path: "hugo.toml",
@@ -429,6 +574,7 @@ export function createExportService(
429
574
  const usedSlugs = new Set<string>();
430
575
  for (const s of slugMap.values()) usedSlugs.add(s);
431
576
  for (const s of collectionSlugMap.values()) usedSlugs.add(s);
577
+ for (const s of smartCollectionSlugMap.values()) usedSlugs.add(s);
432
578
  const hasFeaturedSection = !usedSlugs.has("featured");
433
579
  if (hasFeaturedSection) {
434
580
  exportFiles.push({
@@ -440,14 +586,18 @@ export function createExportService(
440
586
  // Feed subscribers are the one audience the export can lose silently:
441
587
  // their reader polls a `/feed` address that this site does not serve.
442
588
  // With feeds off there is nothing to redirect to, and the live site had
443
- // no feeds to have been subscribed to either.
444
- if (siteConfig.rssFeedsEnabled) {
589
+ // no feeds to have been subscribed to either. The author's own
590
+ // redirects go in too; an old feed address is often one of them.
591
+ const redirectSections = [
592
+ siteConfig.rssFeedsEnabled
593
+ ? buildFeedRedirects(siteConfig.mainRssFeed, hasFeaturedSection)
594
+ : null,
595
+ buildCustomUrlRedirects(standalonePaths),
596
+ ].filter((section): section is string => section !== null);
597
+ if (redirectSections.length > 0) {
445
598
  exportFiles.push({
446
599
  path: "static/_redirects",
447
- content: buildFeedRedirects(
448
- siteConfig.mainRssFeed,
449
- hasFeaturedSection,
450
- ),
600
+ content: redirectSections.join("\n"),
451
601
  });
452
602
  }
453
603
 
@@ -460,6 +610,12 @@ export function createExportService(
460
610
  siteConfig,
461
611
  iconAssets,
462
612
  exportedCollectionDirectoryItems,
613
+ {
614
+ postSlugs: slugMap,
615
+ collectionSlugs: collectionSlugMap,
616
+ smartCollectionSlugs: smartCollectionSlugMap,
617
+ standalonePaths,
618
+ },
463
619
  ),
464
620
  });
465
621
 
@@ -508,6 +664,10 @@ export function createExportService(
508
664
  path: "themes/jant/layouts/collection/single.html",
509
665
  content: LAYOUT_COLLECTION_SINGLE,
510
666
  });
667
+ exportFiles.push({
668
+ path: "themes/jant/layouts/partials/jant-data.html",
669
+ content: PARTIAL_JANT_DATA,
670
+ });
511
671
  exportFiles.push({
512
672
  path: "themes/jant/layouts/partials/head.html",
513
673
  content: PARTIAL_HEAD,
@@ -544,6 +704,30 @@ export function createExportService(
544
704
  path: "themes/jant/layouts/partials/featured-thread.html",
545
705
  content: PARTIAL_FEATURED_THREAD,
546
706
  });
707
+ exportFiles.push({
708
+ path: "themes/jant/layouts/smart_collection/list.html",
709
+ content: LAYOUT_SMART_COLLECTION_LIST,
710
+ });
711
+ exportFiles.push({
712
+ path: "themes/jant/layouts/partials/smart-collection-members.html",
713
+ content: PARTIAL_SMART_COLLECTION_MEMBERS,
714
+ });
715
+ exportFiles.push({
716
+ path: "themes/jant/layouts/partials/collection-threads.html",
717
+ content: PARTIAL_COLLECTION_THREADS,
718
+ });
719
+ exportFiles.push({
720
+ path: "themes/jant/layouts/partials/collection-members.html",
721
+ content: PARTIAL_COLLECTION_MEMBERS,
722
+ });
723
+ exportFiles.push({
724
+ path: "themes/jant/layouts/partials/latest-members.html",
725
+ content: PARTIAL_LATEST_MEMBERS,
726
+ });
727
+ exportFiles.push({
728
+ path: "themes/jant/layouts/partials/featured-members.html",
729
+ content: PARTIAL_FEATURED_MEMBERS,
730
+ });
547
731
  exportFiles.push({
548
732
  path: "themes/jant/layouts/_default/rss.xml",
549
733
  content: LAYOUT_RSS,
@@ -599,22 +783,42 @@ export function createExportService(
599
783
  path: ".gitignore",
600
784
  content: buildGitignore(),
601
785
  });
786
+ exportFiles.push({
787
+ path: WRANGLER_CONFIG_PATH,
788
+ content: buildWranglerConfig(siteConfig, deps.repoName),
789
+ scaffoldOnce: true,
790
+ });
602
791
 
603
792
  return exportFiles;
604
793
  },
605
794
 
606
795
  async generateHugoSite() {
607
796
  const exportFiles = await this.generateHugoFiles();
608
- const { zipSync } = await import("fflate");
609
- const encoder = new TextEncoder();
610
- const files: Record<string, Uint8Array> = {};
611
- for (const file of exportFiles) {
612
- files[file.path] =
613
- typeof file.content === "string"
614
- ? encoder.encode(file.content)
615
- : file.content;
797
+ const storage = deps.storage ?? null;
798
+ const lastModified = new Date();
799
+
800
+ async function* entries() {
801
+ for (const file of exportFiles) {
802
+ if (!isStoredExportFile(file)) {
803
+ yield { name: file.path, lastModified, input: file.content };
804
+ continue;
805
+ }
806
+
807
+ const object = storage ? await storage.get(file.storageKey) : null;
808
+ if (!object?.body) {
809
+ // The archive is already streaming; say which file is missing
810
+ // rather than leave it out without a trace.
811
+ // eslint-disable-next-line no-console -- A dropped file must leave a trace
812
+ console.warn(
813
+ `Export: ${file.storageKey} is missing from storage, so ${file.path} is not in the archive.`,
814
+ );
815
+ continue;
816
+ }
817
+ yield { name: file.path, lastModified, input: object.body };
818
+ }
616
819
  }
617
- return zipSync(files);
820
+
821
+ return makeZip(entries());
618
822
  },
619
823
  };
620
824
  }
@@ -910,12 +1114,39 @@ async function buildThreadBundle(
910
1114
  // Root aliases = historical root slugs + every reply slug (so
911
1115
  // /{reply-slug}/ gets a Hugo alias page that redirects/anchors to
912
1116
  // the thread root).
913
- const aliases = [...rootAliases];
914
- for (const reply of threadReplies) {
915
- const replySlug = slugMap.get(reply.id) ?? reply.slug;
916
- aliases.push(`/${replySlug}/`);
1117
+ //
1118
+ // A draft or private root is not built, so its alias pages would have no
1119
+ // target: Hugo still writes them, `.Permalink` resolves to the empty
1120
+ // string, and the result is a page that redirects to itself. The thread
1121
+ // is unpublished, so its historical URLs are unpublished with it.
1122
+ const rootIsUnpublished =
1123
+ root.status === "draft" || root.visibility === "private";
1124
+ const aliases: string[] = [];
1125
+ if (!rootIsUnpublished) {
1126
+ aliases.push(...rootAliases);
1127
+ for (const reply of threadReplies) {
1128
+ const replySlug = slugMap.get(reply.id) ?? reply.slug;
1129
+ aliases.push(`/${replySlug}/`);
1130
+ }
917
1131
  }
918
1132
 
1133
+ // The `<id>` Jant's feeds give this Thread's entry, kept as the string it
1134
+ // was. A reader recognises an entry it has seen by that string alone, and
1135
+ // the Hugo page's own URL differs from it — a trailing slash always, and
1136
+ // the slug where Jant uses a custom path — so an exported feed that used
1137
+ // the page URL would show every entry to every subscriber again after the
1138
+ // move. RFC 4287 §4.2.6 asks for exactly this: an entry's ID must not
1139
+ // change when its feed is migrated or exported.
1140
+ const siteUrl = siteConfig.siteUrl.trim();
1141
+ const feedId =
1142
+ !rootIsUnpublished && siteUrl
1143
+ ? toAbsoluteSiteUrl(
1144
+ getPostPath(rootSlug, rootAliases[0]),
1145
+ siteUrl,
1146
+ siteConfig.sitePathPrefix,
1147
+ )
1148
+ : undefined;
1149
+
919
1150
  // Root front matter.
920
1151
  const rootMedia = mediaByPost.get(root.id) ?? [];
921
1152
  const rootEmissions = rootMedia.map((m) =>
@@ -929,6 +1160,10 @@ async function buildThreadBundle(
929
1160
  root.publishedAt !== null
930
1161
  ? toISOString(root.publishedAt)
931
1162
  : toISOString(root.createdAt),
1163
+ created:
1164
+ root.publishedAt !== null && root.createdAt !== root.publishedAt
1165
+ ? toISOString(root.createdAt)
1166
+ : undefined,
932
1167
  updated:
933
1168
  root.updatedAt && root.updatedAt !== root.publishedAt
934
1169
  ? toISOString(root.updatedAt)
@@ -944,11 +1179,9 @@ async function buildThreadBundle(
944
1179
  : undefined,
945
1180
  slug: rootSlug,
946
1181
  type: "post",
947
- draft:
948
- root.status === "draft" || root.visibility === "private"
949
- ? true
950
- : undefined,
1182
+ draft: rootIsUnpublished ? true : undefined,
951
1183
  aliases: aliases.length > 0 ? aliases : undefined,
1184
+ feed_id: feedId,
952
1185
  format: root.format,
953
1186
  status: root.status,
954
1187
  visibility: root.visibility,
@@ -978,7 +1211,7 @@ async function buildThreadBundle(
978
1211
  media: rootMediaList.length > 0 ? rootMediaList : undefined,
979
1212
  };
980
1213
 
981
- const rootBody = root.body ? tiptapJsonToMarkdown(root.body) : "";
1214
+ const rootBody = root.body ? postBodyToMarkdown(root.body, rootSlug) : "";
982
1215
  files.push({
983
1216
  path: `content/${rootSlug}/_index.md`,
984
1217
  content: `${await formatFrontMatter(rootFrontMatter)}\n${rootBody}${rootBody.endsWith("\n") ? "" : "\n"}`,
@@ -994,7 +1227,7 @@ async function buildThreadBundle(
994
1227
  media: rootMedia[i] as Media,
995
1228
  }))) {
996
1229
  if (emission.inlinePath) {
997
- const file = await readMediaResourceFile(
1230
+ const file = toStoredMediaFile(
998
1231
  storage,
999
1232
  media.storageKey,
1000
1233
  emission.inlinePath,
@@ -1002,7 +1235,7 @@ async function buildThreadBundle(
1002
1235
  if (file) files.push(file);
1003
1236
  }
1004
1237
  if (emission.inlinePosterPath && media.posterKey) {
1005
- const posterFile = await readMediaResourceFile(
1238
+ const posterFile = toStoredMediaFile(
1006
1239
  storage,
1007
1240
  media.posterKey,
1008
1241
  emission.inlinePosterPath,
@@ -1011,8 +1244,8 @@ async function buildThreadBundle(
1011
1244
  }
1012
1245
  }
1013
1246
 
1014
- // Replies as nested leaf bundles.
1015
- for (const reply of threadReplies) {
1247
+ // Replies as nested leaf bundles, in Thread order.
1248
+ for (const [replyIndex, reply] of threadReplies.entries()) {
1016
1249
  const replySlug = slugMap.get(reply.id) ?? reply.slug;
1017
1250
  const replyMedia = mediaByPost.get(reply.id) ?? [];
1018
1251
  const replyEmissions = replyMedia.map((m) =>
@@ -1026,12 +1259,17 @@ async function buildThreadBundle(
1026
1259
  reply.publishedAt !== null
1027
1260
  ? toISOString(reply.publishedAt)
1028
1261
  : toISOString(reply.createdAt),
1262
+ created:
1263
+ reply.publishedAt !== null && reply.createdAt !== reply.publishedAt
1264
+ ? toISOString(reply.createdAt)
1265
+ : undefined,
1029
1266
  updated:
1030
1267
  reply.updatedAt && reply.updatedAt !== reply.publishedAt
1031
1268
  ? toISOString(reply.updatedAt)
1032
1269
  : undefined,
1033
1270
  slug: replySlug,
1034
1271
  type: "post",
1272
+ weight: replyIndex + 1,
1035
1273
  draft:
1036
1274
  reply.status === "draft" || reply.visibility === "private"
1037
1275
  ? true
@@ -1056,7 +1294,9 @@ async function buildThreadBundle(
1056
1294
  media: replyMediaList.length > 0 ? replyMediaList : undefined,
1057
1295
  };
1058
1296
 
1059
- const replyBody = reply.body ? tiptapJsonToMarkdown(reply.body) : "";
1297
+ const replyBody = reply.body
1298
+ ? postBodyToMarkdown(reply.body, replySlug)
1299
+ : "";
1060
1300
  files.push({
1061
1301
  path: `content/${rootSlug}/${replySlug}/index.md`,
1062
1302
  content: `${await formatFrontMatter(replyFrontMatter)}\n${replyBody}${replyBody.endsWith("\n") ? "" : "\n"}`,
@@ -1067,7 +1307,7 @@ async function buildThreadBundle(
1067
1307
  media: replyMedia[i] as Media,
1068
1308
  }))) {
1069
1309
  if (emission.inlinePath) {
1070
- const file = await readMediaResourceFile(
1310
+ const file = toStoredMediaFile(
1071
1311
  storage,
1072
1312
  media.storageKey,
1073
1313
  emission.inlinePath,
@@ -1075,7 +1315,7 @@ async function buildThreadBundle(
1075
1315
  if (file) files.push(file);
1076
1316
  }
1077
1317
  if (emission.inlinePosterPath && media.posterKey) {
1078
- const posterFile = await readMediaResourceFile(
1318
+ const posterFile = toStoredMediaFile(
1079
1319
  storage,
1080
1320
  media.posterKey,
1081
1321
  emission.inlinePosterPath,
@@ -1089,25 +1329,17 @@ async function buildThreadBundle(
1089
1329
  }
1090
1330
 
1091
1331
  /**
1092
- * Read a media record's bytes from storage and return an ExportFile so
1093
- * they can be bundled next to the post as a Hugo page resource. Returns
1094
- * null when storage is unavailable or the object cannot be read, in
1095
- * which case the front matter entry still points at the resource name
1096
- * and the CLI's pull-media step (or a later sync) can fill it in.
1332
+ * Name a media object for the archive to bundle as `static/media/…`. Its
1333
+ * bytes are read when the archive is written. Null when the export has no
1334
+ * storage, in which case the front matter entry still points at the file and
1335
+ * the CLI's pull-media step can fill it in.
1097
1336
  */
1098
- async function readMediaResourceFile(
1337
+ function toStoredMediaFile(
1099
1338
  storage: StorageDriver | null,
1100
1339
  storageKey: string,
1101
1340
  bundlePath: string,
1102
- ): Promise<ExportFile | null> {
1103
- if (!storage) return null;
1104
- try {
1105
- const bytes = await readStorageObjectBytes(storage, storageKey);
1106
- if (!bytes) return null;
1107
- return { path: bundlePath, content: bytes };
1108
- } catch {
1109
- return null;
1110
- }
1341
+ ): ExportStoredFile | null {
1342
+ return storage ? { path: bundlePath, storageKey } : null;
1111
1343
  }
1112
1344
 
1113
1345
  // ---------------------------------------------------------------------------
@@ -1169,6 +1401,48 @@ async function buildCollectionSection(
1169
1401
  return `${await formatFrontMatter(frontMatter)}\n`;
1170
1402
  }
1171
1403
 
1404
+ /**
1405
+ * A smart collection's conditions as its section page carries them: the
1406
+ * stored selection, with the collection named by slug rather than ID so an
1407
+ * import into another site can resolve it. Null when the collection it names
1408
+ * is gone.
1409
+ */
1410
+ function toExportedSelection(
1411
+ selection: SmartCollection["selection"],
1412
+ collectionSlugs: ReadonlyMap<string, string>,
1413
+ ): HugoSmartCollectionSelection | null {
1414
+ const { collection, media, ...rest } = selection;
1415
+ const exported: HugoSmartCollectionSelection = { ...rest };
1416
+ if (collection !== undefined) {
1417
+ const slug = collection[0] ? collectionSlugs.get(collection[0]) : undefined;
1418
+ if (!slug) return null;
1419
+ exported.collection = slug;
1420
+ }
1421
+ if (media !== undefined) {
1422
+ exported.media = typeof media === "string" ? media : [...media];
1423
+ }
1424
+ return exported;
1425
+ }
1426
+
1427
+ async function buildSmartCollectionSection(
1428
+ smartCollection: SmartCollection,
1429
+ selection: HugoSmartCollectionSelection,
1430
+ rssFeedsEnabled: boolean,
1431
+ ): Promise<string> {
1432
+ const frontMatter: HugoFrontMatter = {
1433
+ title: smartCollection.title,
1434
+ slug: smartCollection.slug,
1435
+ type: "smart_collection",
1436
+ summary_text: smartCollection.description ?? undefined,
1437
+ sort_order: smartCollection.sort,
1438
+ display_layout: smartCollection.layout ?? undefined,
1439
+ selection,
1440
+ // Opt into Atom output at /{slug}/index.xml.
1441
+ outputs: rssFeedsEnabled ? ["html", "rss"] : ["html"],
1442
+ };
1443
+ return `${await formatFrontMatter(frontMatter)}\n`;
1444
+ }
1445
+
1172
1446
  // ---------------------------------------------------------------------------
1173
1447
  // Summary extraction (kept from the previous exporter)
1174
1448
  // ---------------------------------------------------------------------------
@@ -1333,6 +1607,7 @@ function buildExportedCollectionDirectoryItems(
1333
1607
  sequence: sequenceLabels[index] ?? "",
1334
1608
  label: item.label,
1335
1609
  url: item.url,
1610
+ description: description || null,
1336
1611
  descriptionHtml: description
1337
1612
  ? renderMarkdown(description, {
1338
1613
  namespace: `collection-directory-link-${sequenceLabels[index] ?? index}`,
@@ -1342,6 +1617,31 @@ function buildExportedCollectionDirectoryItems(
1342
1617
  return;
1343
1618
  }
1344
1619
 
1620
+ if (item.type === "smart_collection") {
1621
+ const smartCollection = item.smartCollection;
1622
+ if (!smartCollection?.slug) return;
1623
+ const smartDescription = smartCollection.description?.trim();
1624
+ exportedItems.push({
1625
+ type: "smart_collection",
1626
+ sequence: sequenceLabels[index] ?? "",
1627
+ slug: smartCollection.slug,
1628
+ title: smartCollection.title || smartCollection.slug,
1629
+ descriptionHtml: smartDescription
1630
+ ? renderMarkdown(smartDescription, {
1631
+ namespace: `smart-collection-${smartCollection.slug}`,
1632
+ })
1633
+ : null,
1634
+ entryCount: smartCollection.threadCount,
1635
+ recentActivityLabel: formatCollectionActivityLabel(
1636
+ smartCollection.recentActivityAt,
1637
+ ),
1638
+ recentActivityIso: formatCollectionActivityIso(
1639
+ smartCollection.recentActivityAt,
1640
+ ),
1641
+ });
1642
+ return;
1643
+ }
1644
+
1345
1645
  const collection = item.collection;
1346
1646
  if (!collection?.id) {
1347
1647
  return;
@@ -1503,7 +1803,12 @@ function buildHugoToml(config: SiteConfig): string {
1503
1803
  `languageCode = "${escapeTomlString(language)}"`,
1504
1804
  `defaultContentLanguage = "${escapeTomlString(language)}"`,
1505
1805
  'theme = "jant"',
1506
- `paginate = ${config.pageSize}`,
1806
+ // No site-level `paginate` / `[pagination] pagerSize`: every paginated
1807
+ // template calls `.Paginate` with an explicit page size taken from
1808
+ // `params.page_size` / `params.archive_page_size`, so a site-level value
1809
+ // would never be read. `paginate` was also deprecated in Hugo v0.128.0
1810
+ // and has since been removed, so emitting it fails the build outright on
1811
+ // any version that only knows `[pagination] pagerSize`.
1507
1812
  "enableRobotsTXT = true",
1508
1813
  // Disable Hugo's built-in taxonomies — jant has no tags or categories
1509
1814
  // and the default empty /tags/ and /categories/ pages are noise. This
@@ -1580,10 +1885,42 @@ function buildHugoToml(config: SiteConfig): string {
1580
1885
  return `${parts.join("\n")}\n`;
1581
1886
  }
1582
1887
 
1888
+ /** What `data/jant.toml` needs to name nav targets and standalone URLs. */
1889
+ interface JantDataTargets {
1890
+ postSlugs: ReadonlyMap<string, string>;
1891
+ collectionSlugs: ReadonlyMap<string, string>;
1892
+ smartCollectionSlugs: ReadonlyMap<string, string>;
1893
+ standalonePaths: readonly PathRecord[];
1894
+ }
1895
+
1896
+ /** The key and slug `data/jant.toml` names a nav item's target by. */
1897
+ function resolveNavItemTarget(
1898
+ item: SiteConfig["navItems"][number],
1899
+ targets: JantDataTargets,
1900
+ ): { key: string; slug: string } | null {
1901
+ const slug =
1902
+ item.type === "collection" && item.collectionId
1903
+ ? targets.collectionSlugs.get(item.collectionId)
1904
+ : item.type === "smart_collection" && item.smartCollectionId
1905
+ ? targets.smartCollectionSlugs.get(item.smartCollectionId)
1906
+ : item.type === "page" && item.postId
1907
+ ? targets.postSlugs.get(item.postId)
1908
+ : undefined;
1909
+ if (!slug) return null;
1910
+ const key =
1911
+ item.type === "collection"
1912
+ ? "collection_slug"
1913
+ : item.type === "smart_collection"
1914
+ ? "smart_collection_slug"
1915
+ : "post_slug";
1916
+ return { key, slug };
1917
+ }
1918
+
1583
1919
  function buildJantDataToml(
1584
1920
  config: SiteConfig,
1585
1921
  iconAssets: SiteIconAssets,
1586
1922
  directoryItems: readonly ExportedCollectionDirectoryItem[],
1923
+ targets: JantDataTargets,
1587
1924
  ): string {
1588
1925
  const footerHtml = config.siteFooter
1589
1926
  ? renderMarkdown(config.siteFooter, { namespace: "site-footer" })
@@ -1635,11 +1972,10 @@ function buildJantDataToml(
1635
1972
  }
1636
1973
 
1637
1974
  for (const item of config.navItems) {
1638
- // `settings` is authenticated-only and has no corresponding page in the
1639
- // static Hugo site — drop it at export time so it never shows up in nav.
1640
- if (item.systemKey === "settings") continue;
1641
- // Both feed entries resolve to a feed file, so with feeds off both would
1642
- // export a link to something that was never written.
1975
+ // `settings` has no page in the static site; the theme skips it, and the
1976
+ // entry stays so an import back into Jant keeps it. Both feed entries
1977
+ // resolve to a feed file, so with feeds off both would export a link to
1978
+ // something that was never written.
1643
1979
  if (
1644
1980
  !config.rssFeedsEnabled &&
1645
1981
  item.type === "system" &&
@@ -1656,13 +1992,22 @@ function buildJantDataToml(
1656
1992
  );
1657
1993
  parts.push(`system_key = "${escapeTomlString(item.systemKey ?? "")}"`);
1658
1994
  parts.push(`placement = "${escapeTomlString(item.placement ?? "header")}"`);
1995
+ // `label` above is what the theme shows. The author's own wording, when
1996
+ // there is one, and the target by slug are what an import restores.
1997
+ if (item.label) {
1998
+ parts.push(`custom_label = "${escapeTomlString(item.label)}"`);
1999
+ }
2000
+ const target = resolveNavItemTarget(item, targets);
2001
+ if (target) {
2002
+ parts.push(`${target.key} = "${escapeTomlString(target.slug)}"`);
2003
+ }
1659
2004
  }
1660
2005
 
1661
2006
  for (const item of directoryItems) {
1662
2007
  parts.push("");
1663
2008
  parts.push("[[directory]]");
1664
2009
  parts.push(`type = "${escapeTomlString(item.type)}"`);
1665
- if (item.type === "collection") {
2010
+ if (item.type === "collection" || item.type === "smart_collection") {
1666
2011
  parts.push(`sequence = "${escapeTomlString(item.sequence)}"`);
1667
2012
  parts.push(`slug = "${escapeTomlString(item.slug)}"`);
1668
2013
  parts.push(`title = "${escapeTomlString(item.title)}"`);
@@ -1692,6 +2037,9 @@ function buildJantDataToml(
1692
2037
  parts.push(`sequence = "${escapeTomlString(item.sequence)}"`);
1693
2038
  parts.push(`label = "${escapeTomlString(item.label)}"`);
1694
2039
  parts.push(`url = "${escapeTomlString(item.url)}"`);
2040
+ if (item.description) {
2041
+ parts.push(`description = "${escapeTomlString(item.description)}"`);
2042
+ }
1695
2043
  if (item.descriptionHtml) {
1696
2044
  parts.push(
1697
2045
  `description_html = "${escapeTomlString(item.descriptionHtml)}"`,
@@ -1700,13 +2048,168 @@ function buildJantDataToml(
1700
2048
  }
1701
2049
  }
1702
2050
 
2051
+ // Custom URLs that name no post or collection. Post aliases travel in each
2052
+ // root's `aliases:`; these would otherwise not travel at all.
2053
+ for (const record of targets.standalonePaths) {
2054
+ parts.push("");
2055
+ parts.push("[[custom_url]]");
2056
+ parts.push(`path = "${escapeTomlString(record.path)}"`);
2057
+ parts.push(`kind = "${escapeTomlString(record.kind)}"`);
2058
+ if (record.kind === "redirect" && record.redirectToPath) {
2059
+ parts.push(`to = "/${escapeTomlString(record.redirectToPath)}"`);
2060
+ parts.push(`status = ${record.redirectType ?? 301}`);
2061
+ }
2062
+ if (record.kind === "archive" && record.archiveQuery) {
2063
+ parts.push(`archive_query = "${escapeTomlString(record.archiveQuery)}"`);
2064
+ }
2065
+ }
2066
+
1703
2067
  return `${parts.join("\n")}\n`;
1704
2068
  }
1705
2069
 
2070
+ /**
2071
+ * The author's redirects as `_redirects` rules, or null when there are none.
2072
+ *
2073
+ * @param standalonePaths - Custom URLs that name no post or collection
2074
+ * @returns The `_redirects` section
2075
+ * @example
2076
+ * buildCustomUrlRedirects([redirectFromAtomXml]); // "# Redirects...\n/atom.xml /feed 301\n"
2077
+ */
2078
+ function buildCustomUrlRedirects(
2079
+ standalonePaths: readonly PathRecord[],
2080
+ ): string | null {
2081
+ const rules = standalonePaths
2082
+ .filter((record) => record.kind === "redirect" && record.redirectToPath)
2083
+ .map(
2084
+ (record) =>
2085
+ [
2086
+ `/${record.path}`,
2087
+ `/${record.redirectToPath}`,
2088
+ record.redirectType ?? 301,
2089
+ ] as const,
2090
+ );
2091
+ if (rules.length === 0) return null;
2092
+
2093
+ const width = Math.max(...rules.map(([from]) => from.length));
2094
+ return `# Redirects set up under Settings → Custom URLs on the live site.
2095
+
2096
+ ${rules.map(([from, to, status]) => `${from.padEnd(width)} ${to} ${status}`).join("\n")}
2097
+ `;
2098
+ }
2099
+
1706
2100
  // ---------------------------------------------------------------------------
1707
- // README + .gitignore + _redirects
2101
+ // README + .gitignore + _redirects + wrangler.jsonc
1708
2102
  // ---------------------------------------------------------------------------
1709
2103
 
2104
+ /** Repo-relative path of the Cloudflare Workers deploy config. */
2105
+ export const WRANGLER_CONFIG_PATH = "wrangler.jsonc";
2106
+
2107
+ /**
2108
+ * Normalize a string into a name Cloudflare accepts for a Worker: lowercase
2109
+ * letters, digits, and hyphens, at most 63 characters.
2110
+ */
2111
+ function toWorkerName(raw: string): string {
2112
+ return (
2113
+ raw
2114
+ .toLowerCase()
2115
+ .replace(/[^a-z0-9-]+/g, "-")
2116
+ // 63 characters is Cloudflare's limit; trimming hyphens comes after the
2117
+ // cut so a truncated name cannot end on one.
2118
+ .slice(0, 63)
2119
+ .replace(/^-+|-+$/g, "")
2120
+ );
2121
+ }
2122
+
2123
+ /**
2124
+ * Pick the Worker name for `wrangler.jsonc`.
2125
+ *
2126
+ * Cloudflare requires this name to match the Worker in the dashboard, and its
2127
+ * repository-import flow names a new Worker after the repository — so the
2128
+ * repository name is the one value that lines up without the user editing
2129
+ * anything. An export with no repository behind it (a ZIP, or
2130
+ * `site export --directory`) uses the repository name the GitHub Sync
2131
+ * settings page prefills for this site, so pushing the export to a repository
2132
+ * created with that default still matches.
2133
+ *
2134
+ * @param repoName - The destination repository's name, without the owner.
2135
+ * @param siteUrl - The exported site's URL, used when there is no repository.
2136
+ * @returns A name Cloudflare accepts.
2137
+ * @example
2138
+ * deriveWorkerName("owenyoung-blog", "https://notes.example.com"); // "owenyoung-blog"
2139
+ * deriveWorkerName(null, "https://notes.example.com"); // "notes-jant-sync"
2140
+ */
2141
+ export function deriveWorkerName(
2142
+ repoName: string | null | undefined,
2143
+ siteUrl: string,
2144
+ ): string {
2145
+ return (
2146
+ toWorkerName(repoName ?? "") || toWorkerName(suggestSyncRepoName(siteUrl))
2147
+ );
2148
+ }
2149
+
2150
+ /**
2151
+ * Build the Cloudflare Workers deploy config for the exported site.
2152
+ *
2153
+ * Workers Builds has no "build output directory" field — that one belongs to
2154
+ * Pages — so `public/` can only be declared here. Without this file the
2155
+ * dashboard's default deploy command (`npx wrangler deploy`) fails on a repo
2156
+ * that has no Worker name to deploy under.
2157
+ *
2158
+ * `build.command` is here because nothing else runs Hugo. Workers Builds
2159
+ * detects frameworks from a `package.json`, which a Hugo site does not have,
2160
+ * so importing this repository leaves the build command empty and the deploy
2161
+ * fails on a `public/` that was never generated. Wrangler runs a custom build
2162
+ * before deploying, assets-only Workers included, which makes
2163
+ * `npx wrangler deploy` self-sufficient — on Cloudflare's image, on any CI,
2164
+ * and in a local checkout. Cloudflare's image ships Hugo extended, so the
2165
+ * command needs no install step; anyone who does fill in a dashboard build
2166
+ * command should leave it empty here to keep Hugo from running twice.
2167
+ *
2168
+ * Deliberately absent:
2169
+ * - `main`: a site with only static assets is a valid assets-only Worker.
2170
+ * - `not_found_handling`: the theme emits no `404.html` to point it at.
2171
+ *
2172
+ * @param config - The exported site's configuration.
2173
+ * @param repoName - The destination repository's name, when there is one.
2174
+ * @returns The contents of `wrangler.jsonc`.
2175
+ * @example
2176
+ * buildWranglerConfig(config, "my-blog"); // '{\n // Cloudflare Workers …'
2177
+ */
2178
+ function buildWranglerConfig(
2179
+ config: SiteConfig,
2180
+ repoName: string | null | undefined,
2181
+ ): string {
2182
+ const name = deriveWorkerName(repoName, config.siteUrl);
2183
+ // `compatibility_date` pins the Workers runtime behavior to what shipped on
2184
+ // the day this export was generated, which is what a new Worker wants.
2185
+ const compatibilityDate = new Date().toISOString().slice(0, 10);
2186
+ return `{
2187
+ // Cloudflare Workers deploy config for the built site.
2188
+ //
2189
+ // "name" must match the Worker's name in the Cloudflare dashboard: Workers
2190
+ // Builds fails the build when they differ, and a deploy run by hand under
2191
+ // another name goes to another Worker. A Worker imported from a repository
2192
+ // is named after the repository, so Jant uses the repository name when it
2193
+ // knows it, and otherwise the name GitHub Sync suggests for this site's
2194
+ // repository.
2195
+ //
2196
+ // Jant writes this file once and never overwrites it, so your edits stay.
2197
+ "$schema": "node_modules/wrangler/config-schema.json",
2198
+ "name": ${JSON.stringify(name)},
2199
+ "compatibility_date": ${JSON.stringify(compatibilityDate)},
2200
+ // Wrangler runs this before it uploads, so "npx wrangler deploy" builds the
2201
+ // site first. Leave the build command in the Cloudflare dashboard empty, or
2202
+ // Hugo runs twice.
2203
+ "build": {
2204
+ "command": "hugo --gc --minify"
2205
+ },
2206
+ "assets": {
2207
+ "directory": "./public"
2208
+ }
2209
+ }
2210
+ `;
2211
+ }
2212
+
1710
2213
  /**
1711
2214
  * Build the `static/_redirects` file that keeps existing feed subscribers
1712
2215
  * working after the site moves to this export.
@@ -1822,6 +2325,29 @@ Thumbs.db
1822
2325
  * @example
1823
2326
  * renderMarkdownTable(["Jant", "This export"], [["/feed", "/index.xml"]]);
1824
2327
  */
2328
+ /**
2329
+ * Convert a stored post body to the export's Markdown, naming the post when
2330
+ * it can't be converted. An export that dropped the body would read as a
2331
+ * complete archive and restore as an empty post.
2332
+ *
2333
+ * @param body - Stored TipTap JSON
2334
+ * @param slug - The post's slug, for the error
2335
+ * @returns The body as Markdown
2336
+ * @throws {Error} When the stored body isn't a TipTap document
2337
+ * @example
2338
+ * postBodyToMarkdown('{"type":"doc","content":[]}', "hello"); // ""
2339
+ */
2340
+ function postBodyToMarkdown(body: string, slug: string): string {
2341
+ try {
2342
+ return tiptapJsonToMarkdown(body);
2343
+ } catch (error) {
2344
+ throw new Error(
2345
+ `Couldn't convert the body of /${slug} to Markdown: ${error instanceof Error ? error.message : String(error)}`,
2346
+ { cause: error },
2347
+ );
2348
+ }
2349
+ }
2350
+
1825
2351
  function renderMarkdownTable(
1826
2352
  headers: [string, string],
1827
2353
  rows: [string, string][],
@@ -1869,6 +2395,10 @@ function buildReadme(config: SiteConfig, hasFeaturedSection: boolean): string {
1869
2395
  const feedNote = config.rssFeedsEnabled
1870
2396
  ? "\n- Feed addresses are the exception: they move to `index.xml` and stay reachable only through `static/_redirects`. See [Feeds](#feeds)."
1871
2397
  : "";
2398
+ // Same reason: without feeds there is no `static/_redirects` to explain.
2399
+ const redirectsNote = config.rssFeedsEnabled
2400
+ ? "`static/_redirects` needs no configuration here. Hugo copies it to `public/_redirects` and Workers applies the rules as published.\n\n"
2401
+ : "";
1872
2402
  // Nothing to say about feeds on a site that publishes none, and no
1873
2403
  // `_redirects` file was written for it either.
1874
2404
  const feedRows: [string, string][] = [
@@ -1890,6 +2420,8 @@ A reader who is already subscribed holds one of the old addresses, and a feed re
1890
2420
 
1891
2421
  Hugo's \`aliases:\` cannot cover this. An alias page redirects with a meta refresh and a script, and feed readers fetch XML without running either — only an HTTP redirect reaches them.
1892
2422
 
2423
+ Feed entries keep the IDs Jant gave them, so feed readers don't show old posts again. Each root post stores its ID in \`feed_id\`: the post's address on Jant, which is not its page URL here. Don't change \`feed_id\`, or feed readers show that post again. A post you add here without one uses its page URL.
2424
+
1893
2425
  The **Subscribe** entry in the site navigation points at \`${mainFeed}\`. The exported site has no \`/subscribe\` page; that page belongs to the Jant runtime.
1894
2426
 
1895
2427
  `
@@ -1900,7 +2432,7 @@ This is a static site exported from [Jant](https://github.com/jant-me/jant), rea
1900
2432
 
1901
2433
  ## Install Hugo
1902
2434
 
1903
- This export targets Hugo **extended 0.160.1+**.
2435
+ This export targets Hugo **extended 0.147.7+**.
1904
2436
 
1905
2437
  **macOS (Homebrew):**
1906
2438
 
@@ -1938,10 +2470,27 @@ hugo --minify
1938
2470
 
1939
2471
  The output goes to the \`public/\` directory. Upload it to any static host (Netlify, Vercel, Cloudflare Pages, GitHub Pages, etc.).
1940
2472
 
1941
- ${feedsSection}## Project structure
2473
+ ## Deploy to Cloudflare Workers
2474
+
2475
+ \`wrangler.jsonc\` at the root is the deploy config: it names the Worker, runs \`hugo --gc --minify\`, and points the upload at \`public/\`. Connect this repository to Cloudflare Workers Builds and leave the commands Cloudflare offers as they are:
2476
+
2477
+ | Field | Value |
2478
+ | --------------- | ------------------------------ |
2479
+ | Build command | leave empty |
2480
+ | Deploy command | \`npx wrangler deploy\` |
2481
+ | Version command | \`npx wrangler versions upload\` |
2482
+
2483
+ The build belongs to \`wrangler.jsonc\` rather than to that field: Workers Builds reads a \`package.json\` to detect a framework, a Hugo site has none, and an empty build command deploys a \`public/\` that was never built. Filling the field in as well makes Hugo run twice.
2484
+
2485
+ Check one thing before the first deploy: \`name\` in \`wrangler.jsonc\` has to match the Worker's name in the Cloudflare dashboard. Workers Builds fails the build when they differ, and a deploy run by hand under another name goes to another Worker. A Worker imported from a repository is named after the repository, so an export pushed by GitHub Sync uses the repository name. A downloaded export has no repository and uses the name GitHub Sync suggests when it creates one for this site. If the Worker is named something else, change \`name\` to match — Cloudflare names each build token \`<worker-name> build token\`, so the token list is one place to read it.
2486
+
2487
+ Jant writes \`wrangler.jsonc\` once and never overwrites it, so a corrected name survives later syncs.
2488
+
2489
+ ${redirectsNote}${feedsSection}## Project structure
1942
2490
 
1943
2491
  \`\`\`
1944
2492
  hugo.toml — Site configuration (baseURL, title, theme, params)
2493
+ wrangler.jsonc — Cloudflare Workers deploy config (see Deploy above)
1945
2494
  content/
1946
2495
  _index.md — Home section
1947
2496
  archive/_index.md — Archive section