@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.
- package/bin/commands/export.js +3 -1
- package/bin/commands/import-site.js +689 -216
- package/bin/commands/setup.js +136 -0
- package/bin/commands/site/export.js +71 -34
- package/bin/commands/site/pull-media.js +2 -6
- package/bin/commands/site/snapshot/export.js +18 -60
- package/bin/commands/site/snapshot/import.js +22 -23
- package/bin/lib/d1-query.js +87 -2
- package/bin/lib/hugo-markdown.js +4 -0
- package/bin/lib/site-pull-media.js +21 -28
- package/bin/lib/site-selection.js +10 -1
- package/bin/lib/site-snapshot.js +338 -3
- package/bin/lib/sql-export.js +68 -5
- package/bin/lib/wrangler-cli.js +9 -0
- package/bin/lib/zip-archive.js +187 -0
- package/dist/{app-K_Aa1MMn.js → app-ZcaI1kPN.js} +732 -217
- package/dist/client/.vite/manifest.json +20 -20
- package/dist/client/_assets/chunks/{create-editor-CD3FhrOB.js → create-editor-B-m7X7S5.js} +50 -46
- package/dist/client/_assets/chunks/{sortable-list-CgaL2jCs.js → sortable-list-BJyd-LXE.js} +1 -1
- package/dist/client/_assets/chunks/{unsafe-svg-0QCkP0vZ.js → unsafe-svg-BkscJx69.js} +2 -2
- package/dist/client/_assets/{client-GiYENVw8.css → client-DAhoqPdr.css} +1 -1
- package/dist/client/_assets/{client-auth-k8gJ6QJj.js → client-auth-CAMfTrKW.js} +1 -1
- package/dist/client/_assets/{client-compose-B_kDtWkW.js → client-compose-D6rExKVK.js} +1 -1
- package/dist/client/_assets/{client-VnFxJN7G.js → client-knSEyUJO.js} +1 -1
- package/dist/client/_assets/{client-manage-M90aSTOk.js → client-manage-sVOkqVbH.js} +1 -1
- package/dist/client/_assets/{client-settings-DfYs9n1F.js → client-settings-CGR_nZ78.js} +7 -7
- package/dist/{github-sync-BPAvT999.js → github-sync-Gw4orAHk.js} +799 -110
- package/dist/index.js +2 -2
- package/dist/node.js +9 -5
- package/package.json +13 -3
- package/src/__tests__/dev-scripts.test.ts +203 -0
- package/src/__tests__/export-collection-order.test.ts +276 -0
- package/src/__tests__/export-feed-ids.test.ts +184 -0
- package/src/__tests__/export-feed-order.test.ts +294 -0
- package/src/__tests__/export-hugo-build.test.ts +130 -0
- package/src/__tests__/export-import-roundtrip.test.ts +94 -0
- package/src/__tests__/export-service.test.ts +611 -28
- package/src/__tests__/export-smart-collection.test.ts +329 -0
- package/src/__tests__/helpers/hugo-site.ts +146 -0
- package/src/__tests__/import-site-command.test.ts +487 -1
- package/src/__tests__/mise-config.test.ts +75 -4
- package/src/__tests__/node-dev-tasks.test.ts +264 -0
- package/src/__tests__/site-export-canonical-import.test.ts +149 -0
- package/src/__tests__/snapshot-canonical-replay.test.ts +179 -0
- package/src/__tests__/snapshot-settings.test.ts +97 -0
- package/src/__tests__/snapshot-tables.test.ts +219 -0
- package/src/__tests__/sql-export.test.ts +173 -0
- package/src/__tests__/zip-archive.test.ts +106 -0
- package/src/app.tsx +15 -6
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +1 -1
- package/src/client/components/__tests__/jant-settings-general.test.ts +21 -1
- package/src/client/components/jant-repo-picker-types.ts +6 -1
- package/src/client/components/jant-repo-picker.ts +4 -7
- package/src/client/components/jant-settings-general.ts +17 -12
- package/src/client/tiptap/__tests__/list-editing.test.ts +224 -86
- package/src/client/tiptap/__tests__/mark-exit.test.ts +1 -2
- package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +26 -0
- package/src/client/tiptap/extensions.ts +0 -3
- package/src/client/tiptap/structural-keymap.ts +158 -47
- package/src/db/__tests__/d1-query.test.ts +56 -1
- package/src/i18n/locales/settings/en.po +8 -8
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +8 -8
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +8 -8
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/lib/__tests__/github-sync-repo-name.test.ts +40 -0
- package/src/lib/__tests__/image.test.ts +27 -1
- package/src/lib/__tests__/markdown-to-tiptap.test.ts +105 -0
- package/src/lib/__tests__/markdown.test.ts +10 -0
- package/src/lib/__tests__/resolve-config.test.ts +67 -0
- package/src/lib/__tests__/schemas.test.ts +27 -1
- package/src/lib/__tests__/timeline.test.ts +87 -0
- package/src/lib/__tests__/tiptap-to-markdown.test.ts +172 -4
- package/src/lib/discover.ts +3 -1
- package/src/lib/github-sync-repo-name.ts +45 -0
- package/src/lib/hugo-markdown.ts +46 -0
- package/src/lib/image.ts +17 -4
- package/src/lib/markdown-manager.ts +392 -2
- package/src/lib/post-body-html.ts +11 -4
- package/src/lib/resolve-config.ts +58 -1
- package/src/lib/schemas.ts +50 -5
- package/src/lib/thread-fold.ts +3 -3
- package/src/lib/timeline.ts +1 -1
- package/src/lib/tiptap-to-markdown.ts +13 -7
- package/src/lib/url.ts +20 -0
- package/src/lib/view.ts +2 -2
- package/src/node/__tests__/cli-setup.test.ts +163 -0
- package/src/node/__tests__/cli-site-snapshot.test.ts +67 -0
- package/src/node/__tests__/cli-snapshot-meta.test.ts +20 -0
- package/src/node/__tests__/runtime.test.ts +38 -0
- package/src/node/index.ts +2 -0
- package/src/node/request-handler.ts +3 -1
- package/src/routes/api/__tests__/posts.test.ts +24 -0
- package/src/routes/api/__tests__/upload.test.ts +34 -0
- package/src/routes/api/export.ts +3 -3
- package/src/routes/api/internal/sites.ts +0 -1
- package/src/routes/api/posts.ts +2 -0
- package/src/routes/api/public/posts.ts +21 -1
- package/src/routes/api/upload.ts +10 -3
- package/src/routes/compose.tsx +4 -0
- package/src/routes/dash/__tests__/github-sync-app.test.ts +365 -0
- package/src/routes/dash/settings.tsx +212 -70
- package/src/routes/pages/__tests__/post-page-round-trips.test.ts +211 -0
- package/src/routes/pages/__tests__/thread-order.test.ts +179 -0
- package/src/routes/pages/archive.tsx +17 -11
- package/src/routes/pages/featured.tsx +11 -5
- package/src/routes/pages/page.tsx +53 -31
- package/src/routes/pages/search.tsx +4 -2
- package/src/runtime/__tests__/readiness.test.ts +23 -0
- package/src/runtime/node.ts +49 -0
- package/src/runtime/readiness.ts +15 -0
- package/src/services/__tests__/bootstrap-setup-instance.test.ts +230 -0
- package/src/services/__tests__/custom-url.test.ts +34 -0
- package/src/services/__tests__/github-app-installations.test.ts +153 -0
- package/src/services/__tests__/github-sync-push.test.ts +46 -0
- package/src/services/__tests__/media.test.ts +31 -0
- package/src/services/__tests__/path.test.ts +56 -0
- package/src/services/__tests__/post-timeline.test.ts +127 -0
- package/src/services/__tests__/post.test.ts +74 -0
- package/src/services/bootstrap.ts +263 -44
- package/src/services/custom-url.ts +2 -2
- package/src/services/export-theme/layouts/_default/alias.html +27 -1
- package/src/services/export-theme/layouts/_default/list.html +2 -63
- package/src/services/export-theme/layouts/_default/rss.xml +27 -38
- package/src/services/export-theme/layouts/collections/list.html +3 -3
- package/src/services/export-theme/layouts/featured/list.html +1 -4
- package/src/services/export-theme/layouts/index.html +40 -26
- package/src/services/export-theme/layouts/partials/collection-members.html +100 -0
- package/src/services/export-theme/layouts/partials/collection-threads.html +33 -0
- package/src/services/export-theme/layouts/partials/featured-members.html +35 -0
- package/src/services/export-theme/layouts/partials/featured-thread.html +1 -1
- package/src/services/export-theme/layouts/partials/footer.html +1 -1
- package/src/services/export-theme/layouts/partials/head.html +1 -1
- package/src/services/export-theme/layouts/partials/header.html +5 -3
- package/src/services/export-theme/layouts/partials/jant-data.html +23 -0
- package/src/services/export-theme/layouts/partials/latest-members.html +48 -0
- package/src/services/export-theme/layouts/partials/smart-collection-members.html +129 -0
- package/src/services/export-theme/layouts/partials/thread-preview.html +1 -1
- package/src/services/export-theme/layouts/post/list.html +1 -1
- package/src/services/export-theme/layouts/smart_collection/list.html +24 -0
- package/src/services/export-theme/styles/main.css +0 -1
- package/src/services/export-theme/theme.toml +1 -1
- package/src/services/export.ts +620 -71
- package/src/services/github-app-installations.ts +171 -4
- package/src/services/github-sync.ts +69 -15
- package/src/services/mcp.ts +19 -1
- package/src/services/media.ts +8 -1
- package/src/services/path.ts +34 -1
- package/src/services/post.ts +214 -39
- package/src/services/site-admin.ts +3 -7
- package/src/services/site.ts +83 -31
- package/src/styles/ui.css +7 -1
- package/src/types/app-context.ts +16 -0
- package/src/types/bindings.ts +5 -0
- package/src/types/operations.ts +10 -0
- package/src/ui/dash/settings/GeneralContent.tsx +10 -8
- package/src/client/tiptap/exitable-marks.ts +0 -73
package/src/services/export.ts
CHANGED
|
@@ -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
|
|
97
|
-
export interface
|
|
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
|
-
/**
|
|
106
|
-
|
|
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
|
|
324
|
-
//
|
|
325
|
-
// the
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
|
|
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(
|
|
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
|
|
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
|
-
//
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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
|
|
609
|
-
const
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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
|
-
|
|
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
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
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 ?
|
|
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 =
|
|
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 =
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
-
*
|
|
1093
|
-
*
|
|
1094
|
-
*
|
|
1095
|
-
*
|
|
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
|
-
|
|
1337
|
+
function toStoredMediaFile(
|
|
1099
1338
|
storage: StorageDriver | null,
|
|
1100
1339
|
storageKey: string,
|
|
1101
1340
|
bundlePath: string,
|
|
1102
|
-
):
|
|
1103
|
-
|
|
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
|
|
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`
|
|
1639
|
-
//
|
|
1640
|
-
|
|
1641
|
-
//
|
|
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.
|
|
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
|
-
|
|
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
|