@jant/core 0.6.16 → 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/README.md +11 -11
- package/bin/commands/assets/upload.js +192 -15
- package/bin/commands/export.js +3 -1
- package/bin/commands/import-site.js +749 -220
- package/bin/commands/reset-password.js +21 -8
- 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 +19 -61
- package/bin/commands/site/snapshot/import.js +23 -24
- package/bin/lib/d1-query.js +92 -18
- package/bin/lib/hugo-markdown.js +4 -0
- package/bin/lib/node-env.js +40 -4
- package/bin/lib/site-pull-media.js +21 -28
- package/bin/lib/site-selection.js +94 -29
- package/bin/lib/site-snapshot.js +338 -3
- package/bin/lib/sql-export.js +68 -5
- package/bin/lib/wrangler-cli.js +37 -2
- package/bin/lib/wrangler-json.js +35 -0
- package/bin/lib/zip-archive.js +187 -0
- package/dist/{app-C7_4oab9.js → app-ZcaI1kPN.js} +53190 -13529
- package/dist/client/.vite/manifest.json +255 -90
- package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
- package/dist/client/_assets/chunks/create-editor-B-m7X7S5.js +261 -0
- package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
- package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
- package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
- package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
- package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
- package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
- package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
- package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
- package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
- package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
- package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
- package/dist/client/_assets/chunks/sortable-list-BJyd-LXE.js +2 -0
- package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
- package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
- package/dist/client/_assets/chunks/unsafe-svg-BkscJx69.js +2 -0
- package/dist/client/_assets/client-DAhoqPdr.css +2 -0
- package/dist/client/_assets/client-auth-CAMfTrKW.js +828 -0
- package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
- package/dist/client/_assets/client-compose-D6rExKVK.js +2004 -0
- package/dist/client/_assets/client-knSEyUJO.js +295 -0
- package/dist/client/_assets/client-manage-sVOkqVbH.js +2170 -0
- package/dist/client/_assets/client-settings-CGR_nZ78.js +1112 -0
- package/dist/github-sync-Gw4orAHk.js +8398 -0
- package/dist/index.js +2 -5
- package/dist/node.js +11 -9
- package/package.json +15 -5
- package/src/__tests__/auth-session-freshness.test.ts +57 -0
- package/src/__tests__/basecoat-variants.test.ts +121 -0
- package/src/__tests__/bin/assets-upload.test.ts +68 -0
- 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 +390 -0
- package/src/__tests__/export-import-roundtrip.test.ts +94 -0
- package/src/__tests__/export-service.test.ts +842 -28
- package/src/__tests__/export-smart-collection.test.ts +329 -0
- package/src/__tests__/helpers/component-css.ts +91 -0
- package/src/__tests__/helpers/db.ts +33 -0
- package/src/__tests__/helpers/export-fixtures.ts +3 -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__/stylesheet-audience.test.ts +159 -0
- package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
- package/src/__tests__/zip-archive.test.ts +106 -0
- package/src/app.tsx +86 -22
- package/src/auth.ts +18 -1
- package/src/client/__tests__/collection-page-actions.test.ts +51 -2
- package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
- package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
- package/src/client/__tests__/compose-bridge.test.ts +272 -0
- package/src/client/__tests__/compose-launch.test.ts +62 -1
- package/src/client/__tests__/compose-shortcuts.test.ts +38 -9
- package/src/client/__tests__/compose-triggers.test.ts +90 -0
- package/src/client/__tests__/copy-field.test.ts +162 -0
- package/src/client/__tests__/custom-url-menu.test.ts +127 -6
- package/src/client/__tests__/image-processor.test.ts +324 -2
- package/src/client/__tests__/lazy-entries.test.ts +92 -0
- package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
- package/src/client/__tests__/runtime-paths.test.ts +84 -0
- package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
- package/src/client/__tests__/toast.test.ts +23 -0
- package/src/client/__tests__/video-processor-probe.test.ts +175 -0
- package/src/client/__tests__/video-processor.test.ts +181 -0
- package/src/client/audio-processor.ts +24 -15
- package/src/client/audio-waveform.ts +49 -0
- package/src/client/collection-dialog-host.ts +72 -0
- package/src/client/collection-navigation.ts +51 -16
- package/src/client/collection-page-actions.ts +26 -0
- package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
- package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
- package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
- package/src/client/components/__tests__/jant-command-palette.test.ts +28 -0
- package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +1250 -53
- package/src/client/components/__tests__/jant-compose-editor.test.ts +544 -40
- package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +0 -1
- package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
- package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
- package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
- package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
- package/src/client/components/__tests__/jant-settings-general.test.ts +561 -172
- package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
- package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
- package/src/client/components/collection-dialog-types.ts +49 -0
- package/src/client/components/collection-manager-types.ts +32 -1
- package/src/client/components/collection-types.ts +1 -15
- package/src/client/components/compose-types.ts +75 -1
- package/src/client/components/jant-collection-dialog.ts +722 -0
- package/src/client/components/jant-collection-directory.ts +567 -182
- package/src/client/components/jant-collection-form.ts +53 -232
- package/src/client/components/jant-command-palette.ts +100 -49
- package/src/client/components/jant-compose-dialog.ts +1486 -707
- package/src/client/components/jant-compose-editor.ts +673 -336
- package/src/client/components/jant-compose-fullscreen.ts +51 -43
- package/src/client/components/jant-config-editor.ts +65 -53
- package/src/client/components/jant-confirm-dialog.ts +13 -11
- package/src/client/components/jant-locale-picker.ts +418 -0
- package/src/client/components/jant-media-lightbox.ts +146 -136
- package/src/client/components/jant-nav-manager.ts +690 -366
- package/src/client/components/jant-post-menu.ts +1086 -282
- package/src/client/components/jant-post-picker.ts +324 -0
- package/src/client/components/jant-repo-picker-types.ts +6 -1
- package/src/client/components/jant-repo-picker.ts +137 -108
- package/src/client/components/jant-settings-avatar.ts +31 -27
- package/src/client/components/jant-settings-general.ts +622 -543
- package/src/client/components/jant-settings-language.ts +1087 -0
- package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
- package/src/client/components/jant-text-preview.ts +94 -86
- package/src/client/components/nav-manager-types.ts +62 -1
- package/src/client/components/settings-types.ts +14 -29
- package/src/client/components/smart-collection-conditions.ts +285 -0
- package/src/client/components/smart-collection-dialog-types.ts +68 -0
- package/src/client/compose-bridge.ts +214 -141
- package/src/client/compose-launch.ts +76 -2
- package/src/client/compose-shortcuts.ts +6 -8
- package/src/client/compose-triggers.ts +50 -0
- package/src/client/copy-field.ts +108 -0
- package/src/client/custom-url-menu.ts +60 -4
- package/src/client/icons.ts +14 -0
- package/src/client/image-plan.ts +165 -0
- package/src/client/image-processor.ts +195 -117
- package/src/client/image-worker-client.ts +45 -0
- package/src/client/image-worker.ts +135 -0
- package/src/client/lazy-entries.ts +95 -0
- package/src/client/lazy-slugify.ts +2 -2
- package/src/client/media-scroll-hint.ts +43 -2
- package/src/client/mediabunny.ts +29 -0
- package/src/client/post-picker.ts +41 -0
- package/src/client/runtime-paths.ts +74 -1
- package/src/client/settings-bridge.ts +4 -10
- package/src/client/site-header-nav.js +18 -6
- package/src/client/smart-collection-dialog-host.ts +116 -0
- package/src/client/smart-collection-page-actions.ts +208 -0
- package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
- package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
- package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
- 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/bubble-menu.ts +47 -16
- package/src/client/tiptap/extensions.ts +0 -3
- package/src/client/tiptap/floating-position.ts +216 -2
- package/src/client/tiptap/inline-image-upload.ts +7 -3
- package/src/client/tiptap/link-toolbar.ts +22 -9
- package/src/client/tiptap/structural-keymap.ts +158 -47
- package/src/client/toast.ts +13 -0
- package/src/client/types/vite-worker.d.ts +12 -0
- package/src/client/upload-with-metadata.ts +3 -13
- package/src/client/video-processor.ts +351 -107
- package/src/client-auth.ts +14 -31
- package/src/client-compose.ts +19 -0
- package/src/client-manage.ts +13 -0
- package/src/client-settings.ts +14 -0
- package/src/client-site.ts +10 -3
- package/src/client.ts +4 -0
- package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
- package/src/db/__tests__/d1-query.test.ts +56 -1
- package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
- package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
- package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
- package/src/db/migrations/0032_married_tomas.sql +3 -0
- package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
- package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
- package/src/db/migrations/meta/0032_snapshot.json +2582 -0
- package/src/db/migrations/meta/0033_snapshot.json +2816 -0
- package/src/db/migrations/meta/0034_snapshot.json +2816 -0
- package/src/db/migrations/meta/_journal.json +21 -0
- package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
- package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
- package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
- package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
- package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
- package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
- package/src/db/migrations/pg/meta/_journal.json +21 -0
- package/src/db/pg/schema.ts +195 -27
- package/src/db/post-visibility.ts +117 -0
- package/src/db/schema.ts +222 -27
- package/src/i18n/__tests__/detect.test.ts +26 -81
- package/src/i18n/__tests__/supported-locales.test.ts +83 -0
- package/src/i18n/context.tsx +12 -2
- package/src/i18n/detect.ts +7 -66
- package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
- package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
- package/src/i18n/locales/public/en.po +490 -158
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +487 -155
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +487 -155
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/i18n/locales/settings/en.po +555 -73
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +601 -119
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +614 -132
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/i18n/locales.ts +94 -0
- package/src/i18n/middleware.ts +12 -2
- package/src/i18n/supported-locales.ts +80 -0
- package/src/lib/__tests__/api-settings.test.ts +11 -2
- package/src/lib/__tests__/asset-path.test.ts +92 -0
- package/src/lib/__tests__/deferred.test.ts +90 -0
- package/src/lib/__tests__/discover-ping.test.ts +89 -0
- package/src/lib/__tests__/discover.test.ts +410 -0
- package/src/lib/__tests__/feed-policy.test.ts +26 -0
- package/src/lib/__tests__/feed.test.ts +1526 -15
- package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
- package/src/lib/__tests__/github-sync-repo-name.test.ts +40 -0
- package/src/lib/__tests__/hosted-signin.test.ts +1 -1
- package/src/lib/__tests__/http-cache.test.ts +162 -0
- package/src/lib/__tests__/image.test.ts +27 -1
- package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
- package/src/lib/__tests__/jant-docs.test.ts +23 -0
- package/src/lib/__tests__/lang-detect.test.ts +231 -0
- package/src/lib/__tests__/markdown-to-tiptap.test.ts +105 -0
- package/src/lib/__tests__/markdown.test.ts +10 -0
- package/src/lib/__tests__/media-helpers.test.ts +81 -0
- package/src/lib/__tests__/navigation.test.ts +135 -1
- package/src/lib/__tests__/resolve-config.test.ts +131 -3
- package/src/lib/__tests__/schemas.test.ts +69 -5
- package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
- package/src/lib/__tests__/slug.test.ts +70 -0
- package/src/lib/__tests__/slugify.test.ts +85 -0
- package/src/lib/__tests__/storage.test.ts +32 -0
- package/src/lib/__tests__/thread-fold.test.ts +113 -0
- package/src/lib/__tests__/timeline.test.ts +87 -0
- package/src/lib/__tests__/tiptap-to-markdown.test.ts +172 -4
- package/src/lib/__tests__/upload-sideload.test.ts +14 -0
- package/src/lib/__tests__/url-fetch.test.ts +1 -2
- package/src/lib/__tests__/url.test.ts +78 -72
- package/src/lib/__tests__/view-language.test.ts +413 -0
- package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
- package/src/lib/address.ts +57 -0
- package/src/lib/api-posts.ts +12 -0
- package/src/lib/asset-path.ts +98 -1
- package/src/lib/build-env.ts +16 -0
- package/src/lib/collection-groups.ts +5 -1
- package/src/lib/collection-paths.ts +3 -10
- package/src/lib/constants.ts +28 -3
- package/src/lib/copy-field.ts +36 -0
- package/src/lib/crypto.ts +40 -0
- package/src/lib/csp-builder.ts +2 -1
- package/src/lib/deferred.ts +79 -0
- package/src/lib/discover-ping.ts +141 -0
- package/src/lib/discover.ts +439 -0
- package/src/lib/env.ts +134 -0
- package/src/lib/errors.ts +33 -0
- package/src/lib/feed-policy.ts +236 -2
- package/src/lib/feed.ts +926 -73
- package/src/lib/filter-dimensions.ts +1349 -0
- package/src/lib/github-sync-repo-name.ts +45 -0
- package/src/lib/github-sync-site-config.ts +3 -0
- package/src/lib/hosted-signin.ts +1 -1
- package/src/lib/http-cache.ts +148 -0
- package/src/lib/hugo-markdown.ts +62 -0
- package/src/lib/icons.ts +9 -2
- package/src/lib/ids.ts +3 -0
- package/src/lib/image.ts +17 -4
- package/src/lib/jant-docs.ts +42 -0
- package/src/lib/lang-detect.ts +308 -0
- package/src/lib/markdown-manager.ts +392 -2
- package/src/lib/media-helpers.ts +13 -1
- package/src/lib/navigation.ts +78 -11
- package/src/lib/pagination.ts +28 -24
- package/src/lib/per-language-surfaces.ts +67 -0
- package/src/lib/post-body-html.ts +11 -4
- package/src/lib/post-meta.ts +19 -0
- package/src/lib/render.tsx +55 -7
- package/src/lib/resolve-config.ts +111 -6
- package/src/lib/schemas.ts +251 -15
- package/src/lib/site-header-fragment.tsx +2 -1
- package/src/lib/site-unavailable-page.ts +47 -0
- package/src/lib/slug.ts +51 -7
- package/src/lib/slugify.ts +113 -0
- package/src/lib/startup-config.ts +4 -6
- package/src/lib/storage.ts +7 -0
- package/src/lib/summary.ts +50 -0
- package/src/lib/thread-fold.ts +161 -0
- package/src/lib/time.ts +32 -0
- package/src/lib/timeline.ts +43 -2
- package/src/lib/tiptap-render.ts +1 -2
- package/src/lib/tiptap-to-markdown.ts +13 -7
- package/src/lib/upload.ts +16 -13
- package/src/lib/url.ts +159 -86
- package/src/lib/version.ts +10 -8
- package/src/lib/view-language.ts +436 -0
- package/src/lib/view.ts +53 -25
- package/src/lib/viewer-context.ts +62 -0
- package/src/middleware/__tests__/auth.test.ts +70 -2
- package/src/middleware/__tests__/cache-control.test.ts +71 -3
- package/src/middleware/__tests__/onboarding.test.ts +76 -5
- package/src/middleware/__tests__/secure-headers.test.ts +1 -1
- package/src/middleware/__tests__/session.test.ts +181 -33
- package/src/middleware/auth.ts +50 -31
- package/src/middleware/cache-control.ts +75 -10
- package/src/middleware/config.ts +6 -6
- package/src/middleware/error-handler.ts +12 -0
- package/src/middleware/onboarding.ts +32 -10
- package/src/middleware/secure-headers.ts +1 -1
- package/src/middleware/session.ts +96 -8
- package/src/node/__tests__/cli-migrate.test.ts +21 -19
- package/src/node/__tests__/cli-reset-password.test.ts +81 -8
- package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
- package/src/node/__tests__/cli-setup.test.ts +163 -0
- package/src/node/__tests__/cli-site-selection.test.ts +128 -38
- 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 +75 -0
- package/src/node/index.ts +3 -0
- package/src/node/request-handler.ts +3 -1
- package/src/preset.css +76 -22
- package/src/routes/__tests__/feed-validator.test.ts +156 -0
- package/src/routes/api/__tests__/collections.test.ts +114 -0
- package/src/routes/api/__tests__/discover.test.ts +222 -0
- package/src/routes/api/__tests__/nav-items.test.ts +212 -2
- package/src/routes/api/__tests__/post-translations.test.ts +829 -0
- package/src/routes/api/__tests__/posts.test.ts +24 -0
- package/src/routes/api/__tests__/settings.test.ts +24 -3
- package/src/routes/api/__tests__/telegram.test.ts +6 -5
- package/src/routes/api/__tests__/upload.test.ts +34 -0
- package/src/routes/api/collections.ts +49 -6
- package/src/routes/api/custom-urls.ts +0 -1
- package/src/routes/api/discover.ts +95 -0
- package/src/routes/api/export.ts +6 -3
- package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
- package/src/routes/api/internal/sites.ts +0 -1
- package/src/routes/api/nav-items.ts +55 -8
- package/src/routes/api/posts.ts +161 -0
- package/src/routes/api/public/__tests__/archive.test.ts +111 -0
- package/src/routes/api/public/archive.ts +77 -81
- package/src/routes/api/public/posts.ts +44 -7
- package/src/routes/api/smart-collections.ts +132 -0
- package/src/routes/api/telegram.ts +2 -40
- package/src/routes/api/upload.ts +10 -3
- package/src/routes/api/uploads.ts +1 -2
- package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
- package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
- package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
- package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
- package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
- package/src/routes/auth/__tests__/setup.test.ts +278 -26
- package/src/routes/auth/__tests__/signin.test.ts +135 -0
- package/src/routes/auth/setup.tsx +934 -186
- package/src/routes/auth/signin.tsx +10 -8
- package/src/routes/compose.tsx +9 -0
- package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
- package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
- package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
- package/src/routes/dash/__tests__/github-sync-app.test.ts +365 -0
- package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
- package/src/routes/dash/custom-urls.tsx +1 -37
- package/src/routes/dash/settings.tsx +714 -111
- package/src/routes/discover-announce.ts +96 -0
- package/src/routes/feed/__tests__/feed.test.ts +165 -2
- package/src/routes/feed/feed.ts +69 -40
- package/src/routes/feed/sitemap.ts +95 -11
- package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
- package/src/routes/pages/__tests__/archive-params.test.ts +370 -6
- package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
- package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
- package/src/routes/pages/__tests__/collections.test.ts +9 -3
- package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
- package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
- package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
- package/src/routes/pages/__tests__/post-page-round-trips.test.ts +211 -0
- package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
- package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
- package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
- package/src/routes/pages/__tests__/thread-order.test.ts +179 -0
- package/src/routes/pages/archive.tsx +487 -379
- package/src/routes/pages/collection.tsx +94 -96
- package/src/routes/pages/collections.tsx +38 -50
- package/src/routes/pages/featured.tsx +43 -18
- package/src/routes/pages/home.tsx +25 -6
- package/src/routes/pages/language.tsx +113 -0
- package/src/routes/pages/latest.tsx +26 -16
- package/src/routes/pages/new.tsx +3 -0
- package/src/routes/pages/page.tsx +228 -45
- package/src/routes/pages/partials.tsx +41 -0
- package/src/routes/pages/search.tsx +22 -5
- package/src/routes/pages/smart-collection.tsx +340 -0
- package/src/routes/pages/subscribe.tsx +182 -0
- package/src/runtime/__tests__/index.test.ts +19 -0
- package/src/runtime/__tests__/node.test.ts +129 -4
- package/src/runtime/__tests__/readiness.test.ts +23 -0
- package/src/runtime/cloudflare.ts +21 -2
- package/src/runtime/index.ts +27 -1
- package/src/runtime/node.ts +67 -1
- package/src/runtime/readiness.ts +15 -0
- package/src/runtime/site.ts +95 -24
- package/src/services/__tests__/auth.test.ts +116 -7
- package/src/services/__tests__/bootstrap-setup-instance.test.ts +230 -0
- package/src/services/__tests__/collection.test.ts +108 -4
- 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__/language.test.ts +495 -0
- package/src/services/__tests__/media.test.ts +31 -0
- package/src/services/__tests__/navigation.test.ts +213 -10
- package/src/services/__tests__/path.test.ts +236 -0
- package/src/services/__tests__/post-language.test.ts +830 -0
- package/src/services/__tests__/post-timeline.test.ts +127 -0
- package/src/services/__tests__/post.test.ts +120 -9
- package/src/services/__tests__/settings.test.ts +228 -17
- package/src/services/__tests__/site-admin.test.ts +5 -5
- package/src/services/__tests__/smart-collection.test.ts +651 -0
- package/src/services/about-page.ts +22 -1
- package/src/services/auth.ts +51 -49
- package/src/services/bootstrap.ts +408 -39
- package/src/services/collection-directory-position.ts +76 -0
- package/src/services/collection.ts +331 -46
- package/src/services/custom-url.ts +29 -24
- package/src/services/export-theme/assets/client-site.css +1 -1
- package/src/services/export-theme/assets/client-site.js +123 -123
- 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 +103 -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/feed-post-content.xml +105 -2
- 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/media-gallery.html +35 -21
- package/src/services/export-theme/layouts/partials/pagination.html +14 -13
- package/src/services/export-theme/layouts/partials/post-card.html +8 -4
- package/src/services/export-theme/layouts/partials/reply.html +2 -1
- package/src/services/export-theme/layouts/partials/smart-collection-members.html +129 -0
- package/src/services/export-theme/layouts/partials/thread-preview.html +41 -31
- 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 +13 -2
- package/src/services/export-theme/theme.toml +1 -1
- package/src/services/export.ts +880 -72
- package/src/services/github-app-installations.ts +171 -4
- package/src/services/github-sync.ts +69 -15
- package/src/services/index.ts +34 -3
- package/src/services/language.ts +412 -0
- package/src/services/mcp.ts +22 -2
- package/src/services/media.ts +22 -7
- package/src/services/navigation.ts +429 -67
- package/src/services/path.ts +254 -6
- package/src/services/post.ts +1234 -190
- package/src/services/search.ts +39 -2
- package/src/services/settings.ts +238 -43
- package/src/services/site-admin.ts +27 -14
- package/src/services/site.ts +160 -32
- package/src/services/smart-collection.ts +636 -0
- package/src/services/telegram.ts +20 -22
- package/src/services/upload-session.ts +1 -3
- package/src/style-author.css +27 -0
- package/src/styles/components-author.css +1622 -0
- package/src/styles/components.css +24 -1571
- package/src/styles/site-media.css +16 -2
- package/src/styles/tokens.css +82 -12
- package/src/styles/ui-author.css +7602 -0
- package/src/styles/ui.css +1090 -7216
- package/src/types/app-context.ts +23 -0
- package/src/types/bindings.ts +8 -0
- package/src/types/config.ts +94 -15
- package/src/types/constants.ts +188 -16
- package/src/types/entities.ts +87 -1
- package/src/types/operations.ts +74 -2
- package/src/types/props.ts +157 -31
- package/src/types/views.ts +55 -1
- package/src/ui/__tests__/font-themes.test.ts +52 -12
- package/src/ui/compose/ComposeDialog.tsx +201 -7
- package/src/ui/compose/ComposePrompt.tsx +1 -5
- package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
- package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
- package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
- package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
- package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
- package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
- package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
- package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
- package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
- package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
- package/src/ui/dash/settings/GeneralContent.tsx +277 -113
- package/src/ui/dash/settings/LanguageContent.tsx +409 -0
- package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
- package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
- package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
- package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
- package/src/ui/dash/settings/discover-copy.ts +116 -0
- package/src/ui/feed/LinkCard.tsx +55 -45
- package/src/ui/feed/NoteCard.tsx +9 -4
- package/src/ui/feed/PostStatusBadges.tsx +90 -43
- package/src/ui/feed/QuoteCard.tsx +55 -30
- package/src/ui/feed/ThreadPreview.tsx +6 -2
- package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
- package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
- package/src/ui/feed/thread-preview-state.ts +20 -6
- package/src/ui/font-themes.ts +39 -29
- package/src/ui/layouts/BaseLayout.tsx +155 -10
- package/src/ui/layouts/SiteLayout.tsx +192 -34
- package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
- package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
- package/src/ui/pages/ArchivePage.tsx +282 -289
- package/src/ui/pages/CollectionPage.tsx +86 -76
- package/src/ui/pages/CollectionsPage.tsx +5 -0
- package/src/ui/pages/ComposePage.tsx +8 -0
- package/src/ui/pages/PostPage.tsx +47 -0
- package/src/ui/pages/SearchPage.tsx +3 -3
- package/src/ui/pages/SmartCollectionPage.tsx +434 -0
- package/src/ui/pages/SubscribePage.tsx +236 -0
- package/src/ui/pages/__tests__/ArchivePage.test.tsx +65 -9
- package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
- package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
- package/src/ui/shared/CollectionDirectory.tsx +178 -3
- package/src/ui/shared/CollectionsManager.tsx +70 -17
- package/src/ui/shared/CopyField.tsx +127 -0
- package/src/ui/shared/EmptyPostContent.tsx +23 -0
- package/src/ui/shared/MediaGallery.tsx +125 -46
- package/src/ui/shared/Pagination.tsx +20 -5
- package/src/ui/shared/PostFooter.tsx +20 -11
- package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
- package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
- package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
- package/src/ui/shared/__tests__/pagination.test.ts +38 -24
- package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
- package/src/ui/shared/archive-labels.ts +98 -0
- package/src/ui/shared/collection-management-labels.ts +94 -16
- package/src/ui/shared/navigation-labels.ts +45 -71
- package/src/ui/shared/post-article-attributes.ts +2 -0
- package/src/ui/shared/smart-collection-labels.ts +343 -0
- package/dist/app-C75Zc8dD.js +0 -6
- package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
- package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
- package/dist/client/_assets/chunks/url-CU9pEbk5.js +0 -1
- package/dist/client/_assets/client-Bq0vre8Y.js +0 -296
- package/dist/client/_assets/client-D3VZyG4L.css +0 -2
- package/dist/client/_assets/client-auth-F2rb9WfF.js +0 -5571
- package/dist/env-BPYViDJJ.js +0 -281
- package/dist/export-Dl5KCiEs.js +0 -5562
- package/dist/github-api-BNF35Lkx.js +0 -176
- package/dist/github-app-DdiD-bC4.js +0 -275
- package/dist/github-sync-Bqg62IvV.js +0 -4
- package/dist/github-sync-HdK2EgvJ.js +0 -431
- package/dist/url-CbLAtlZe.js +0 -900
- package/src/client/__tests__/collection-created-notice.test.ts +0 -36
- package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
- package/src/client/__tests__/post-form-bridge.test.ts +0 -82
- package/src/client/collection-created-notice.ts +0 -66
- package/src/client/collection-form-bridge.ts +0 -191
- package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
- package/src/client/components/jant-post-form.ts +0 -422
- package/src/client/components/post-form-template.ts +0 -352
- package/src/client/components/post-form-types.ts +0 -96
- package/src/client/media-metadata.ts +0 -247
- package/src/client/post-form-bridge.ts +0 -193
- package/src/client/tiptap/exitable-marks.ts +0 -73
- package/src/ui/pages/CollectionEditorPage.tsx +0 -175
package/src/services/export.ts
CHANGED
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
} from "../lib/jant-branding.js";
|
|
30
30
|
import { getRootActivityAt } from "../db/thread-activity.js";
|
|
31
31
|
import { tiptapJsonToMarkdown } from "../lib/tiptap-to-markdown.js";
|
|
32
|
-
import { extractBodyText } from "../lib/summary.js";
|
|
32
|
+
import { extractBodyText, extractTimelineSummary } from "../lib/summary.js";
|
|
33
33
|
import { getMediaUrl, getPublicUrlForProvider } from "../lib/image.js";
|
|
34
34
|
import { render as renderMarkdown } from "../lib/markdown.js";
|
|
35
35
|
import { formatRelativeAge, toISOString } from "../lib/time.js";
|
|
@@ -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,31 +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,
|
|
99
|
+
isFeedNavKey,
|
|
88
100
|
type Collection,
|
|
89
101
|
type Media,
|
|
90
102
|
type NavItem,
|
|
103
|
+
type PathRecord,
|
|
91
104
|
type Post,
|
|
105
|
+
type SmartCollection,
|
|
92
106
|
type SystemNavKey,
|
|
93
107
|
} from "../types.js";
|
|
94
108
|
|
|
95
|
-
/** A file
|
|
96
|
-
export interface
|
|
109
|
+
/** A file of the exported Hugo site whose text or bytes the export holds. */
|
|
110
|
+
export interface ExportContentFile {
|
|
97
111
|
path: string;
|
|
98
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());
|
|
99
165
|
}
|
|
100
166
|
|
|
101
167
|
export interface ExportService {
|
|
102
168
|
/** Generate a flat list of files for a complete Hugo site. */
|
|
103
169
|
generateHugoFiles(): Promise<ExportFile[]>;
|
|
104
|
-
/**
|
|
105
|
-
|
|
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>>;
|
|
106
176
|
}
|
|
107
177
|
|
|
108
178
|
export interface SiteConfig {
|
|
@@ -110,6 +180,10 @@ export interface SiteConfig {
|
|
|
110
180
|
siteUrl: string;
|
|
111
181
|
siteDescription: string;
|
|
112
182
|
siteLanguage: string;
|
|
183
|
+
/** Whether the source site serves per-language browsing views. */
|
|
184
|
+
multilingualEnabled: boolean;
|
|
185
|
+
/** Non-primary content languages, in switcher order. */
|
|
186
|
+
additionalLanguages: readonly string[];
|
|
113
187
|
showJantBrandingOnHome: boolean;
|
|
114
188
|
/** Whether anonymous JSON reads are enabled on the source Jant site. */
|
|
115
189
|
publicApiEnabled: boolean;
|
|
@@ -117,6 +191,8 @@ export interface SiteConfig {
|
|
|
117
191
|
rssFeedsEnabled: boolean;
|
|
118
192
|
/** "latest" or "featured" — drives the default RSS nav link in the exported site. */
|
|
119
193
|
mainRssFeed: string;
|
|
194
|
+
/** "list" or "grid" — the layout the exported archive page opens with. */
|
|
195
|
+
archiveDefaultLayout: string;
|
|
120
196
|
siteFooter: string;
|
|
121
197
|
showHeaderAvatar: boolean;
|
|
122
198
|
siteAvatarUrl: string;
|
|
@@ -137,7 +213,16 @@ export interface SiteConfig {
|
|
|
137
213
|
sitePathPrefix?: string;
|
|
138
214
|
navItems: Pick<
|
|
139
215
|
NavItem,
|
|
140
|
-
|
|
216
|
+
| "type"
|
|
217
|
+
| "systemKey"
|
|
218
|
+
| "label"
|
|
219
|
+
| "targetTitle"
|
|
220
|
+
| "url"
|
|
221
|
+
| "position"
|
|
222
|
+
| "placement"
|
|
223
|
+
| "collectionId"
|
|
224
|
+
| "smartCollectionId"
|
|
225
|
+
| "postId"
|
|
141
226
|
>[];
|
|
142
227
|
/** Items per page for Hugo pagination — kept in sync with the main site's PAGE_SIZE. */
|
|
143
228
|
pageSize: number;
|
|
@@ -161,6 +246,16 @@ type ExportedCollectionDirectoryItem =
|
|
|
161
246
|
recentActivityLabel?: string | null;
|
|
162
247
|
recentActivityIso?: string | null;
|
|
163
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
|
+
}
|
|
164
259
|
| {
|
|
165
260
|
type: "divider";
|
|
166
261
|
label: string | null;
|
|
@@ -170,12 +265,14 @@ type ExportedCollectionDirectoryItem =
|
|
|
170
265
|
sequence: string;
|
|
171
266
|
label: string;
|
|
172
267
|
url: string;
|
|
268
|
+
/** The link description as the author wrote it, or null if empty. */
|
|
269
|
+
description?: string | null;
|
|
173
270
|
/** Rendered HTML of the link description, or null if empty. */
|
|
174
271
|
descriptionHtml?: string | null;
|
|
175
272
|
};
|
|
176
273
|
|
|
177
274
|
interface ExportCollectionDirectorySourceItem {
|
|
178
|
-
type: "collection" | "divider" | "link";
|
|
275
|
+
type: "collection" | "smart_collection" | "divider" | "link";
|
|
179
276
|
label?: string | null;
|
|
180
277
|
url?: string | null;
|
|
181
278
|
description?: string | null;
|
|
@@ -187,6 +284,13 @@ interface ExportCollectionDirectorySourceItem {
|
|
|
187
284
|
threadCount?: number;
|
|
188
285
|
recentActivityAt?: number;
|
|
189
286
|
};
|
|
287
|
+
smartCollection?: {
|
|
288
|
+
slug: string;
|
|
289
|
+
title: string;
|
|
290
|
+
description?: string | null;
|
|
291
|
+
threadCount?: number;
|
|
292
|
+
recentActivityAt?: number;
|
|
293
|
+
};
|
|
190
294
|
}
|
|
191
295
|
|
|
192
296
|
interface SiteIconAssets {
|
|
@@ -249,13 +353,24 @@ export function createExportService(
|
|
|
249
353
|
* base64-encodes attachment bytes.
|
|
250
354
|
*/
|
|
251
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;
|
|
252
364
|
} = {},
|
|
253
365
|
): ExportService {
|
|
254
366
|
return {
|
|
255
367
|
async generateHugoFiles() {
|
|
256
368
|
const collectionDirectoryDataPromise =
|
|
257
369
|
typeof services.collections.listDirectoryData === "function"
|
|
258
|
-
?
|
|
370
|
+
? // The export builds a public static site, so it sees what a
|
|
371
|
+
// signed-out reader sees. Language narrowing is not applied: the
|
|
372
|
+
// export walks every language itself.
|
|
373
|
+
services.collections.listDirectoryData({ isAuthenticated: false })
|
|
259
374
|
: Promise.resolve(null);
|
|
260
375
|
|
|
261
376
|
// 1. Query all data
|
|
@@ -281,6 +396,7 @@ export function createExportService(
|
|
|
281
396
|
slugMap,
|
|
282
397
|
aliasMap,
|
|
283
398
|
collectionSlugMap,
|
|
399
|
+
standalonePaths,
|
|
284
400
|
] = await Promise.all([
|
|
285
401
|
services.collections.getCollectionsByPostIds(rootPostIds),
|
|
286
402
|
services.collections.getCollectionEntriesByThreadIds(rootPostIds),
|
|
@@ -288,6 +404,7 @@ export function createExportService(
|
|
|
288
404
|
services.paths.getPostSlugMap(allPostIds),
|
|
289
405
|
services.paths.getPostAliases(rootPostIds),
|
|
290
406
|
services.paths.getCollectionSlugMap(allCollections.map((c) => c.id)),
|
|
407
|
+
services.paths.listStandalonePaths(),
|
|
291
408
|
]);
|
|
292
409
|
// Denormalized title lookup so front-matter collection refs can
|
|
293
410
|
// include a title label without templates having to resolve another
|
|
@@ -304,14 +421,56 @@ export function createExportService(
|
|
|
304
421
|
allPosts,
|
|
305
422
|
collectionsByRoot,
|
|
306
423
|
);
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
+
}
|
|
450
|
+
const exportableDirectoryItems: ExportCollectionDirectorySourceItem[] =
|
|
451
|
+
collectionDirectoryData?.items
|
|
452
|
+
? collectionDirectoryData.items
|
|
453
|
+
.filter(
|
|
454
|
+
(item) =>
|
|
455
|
+
item.type !== "smart_collection" ||
|
|
456
|
+
(item.smartCollection !== undefined &&
|
|
457
|
+
smartCollectionSlugMap.has(item.smartCollection.id)),
|
|
458
|
+
)
|
|
459
|
+
.map((item) => ({
|
|
460
|
+
type: item.type,
|
|
461
|
+
label: item.label,
|
|
462
|
+
url: item.url,
|
|
463
|
+
description: item.description,
|
|
464
|
+
collection: item.collection,
|
|
465
|
+
smartCollection: item.smartCollection,
|
|
466
|
+
}))
|
|
467
|
+
: allCollections.map((collection) => ({
|
|
312
468
|
type: "collection" as const,
|
|
313
469
|
collection,
|
|
314
|
-
}))
|
|
470
|
+
}));
|
|
471
|
+
const exportedCollectionDirectoryItems =
|
|
472
|
+
buildExportedCollectionDirectoryItems(
|
|
473
|
+
exportableDirectoryItems,
|
|
315
474
|
collectionSlugMap,
|
|
316
475
|
collectionMetrics,
|
|
317
476
|
);
|
|
@@ -323,9 +482,14 @@ export function createExportService(
|
|
|
323
482
|
list.push(reply);
|
|
324
483
|
repliesByThread.set(reply.threadId, list);
|
|
325
484
|
}
|
|
326
|
-
//
|
|
485
|
+
// Thread order, as `posts.getThread` reads it: after the root, which
|
|
486
|
+
// leads as `_index.md`, creation time, then ID.
|
|
327
487
|
for (const list of repliesByThread.values()) {
|
|
328
|
-
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
|
+
);
|
|
329
493
|
}
|
|
330
494
|
|
|
331
495
|
// 3. Build file list
|
|
@@ -376,6 +540,19 @@ export function createExportService(
|
|
|
376
540
|
});
|
|
377
541
|
}
|
|
378
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
|
+
|
|
379
556
|
// Section + home scaffolding.
|
|
380
557
|
exportFiles.push({
|
|
381
558
|
path: "hugo.toml",
|
|
@@ -397,13 +574,33 @@ export function createExportService(
|
|
|
397
574
|
const usedSlugs = new Set<string>();
|
|
398
575
|
for (const s of slugMap.values()) usedSlugs.add(s);
|
|
399
576
|
for (const s of collectionSlugMap.values()) usedSlugs.add(s);
|
|
400
|
-
|
|
577
|
+
for (const s of smartCollectionSlugMap.values()) usedSlugs.add(s);
|
|
578
|
+
const hasFeaturedSection = !usedSlugs.has("featured");
|
|
579
|
+
if (hasFeaturedSection) {
|
|
401
580
|
exportFiles.push({
|
|
402
581
|
path: "content/featured/_index.md",
|
|
403
582
|
content: await buildFeaturedSection(siteConfig.rssFeedsEnabled),
|
|
404
583
|
});
|
|
405
584
|
}
|
|
406
585
|
|
|
586
|
+
// Feed subscribers are the one audience the export can lose silently:
|
|
587
|
+
// their reader polls a `/feed` address that this site does not serve.
|
|
588
|
+
// With feeds off there is nothing to redirect to, and the live site had
|
|
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) {
|
|
598
|
+
exportFiles.push({
|
|
599
|
+
path: "static/_redirects",
|
|
600
|
+
content: redirectSections.join("\n"),
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
|
|
407
604
|
// Single data file consumed by templates via `hugo.Data.jant`. The
|
|
408
605
|
// collection directory lives on the same object as `directory` so
|
|
409
606
|
// everything Jant owns round-trips in one place.
|
|
@@ -413,6 +610,12 @@ export function createExportService(
|
|
|
413
610
|
siteConfig,
|
|
414
611
|
iconAssets,
|
|
415
612
|
exportedCollectionDirectoryItems,
|
|
613
|
+
{
|
|
614
|
+
postSlugs: slugMap,
|
|
615
|
+
collectionSlugs: collectionSlugMap,
|
|
616
|
+
smartCollectionSlugs: smartCollectionSlugMap,
|
|
617
|
+
standalonePaths,
|
|
618
|
+
},
|
|
416
619
|
),
|
|
417
620
|
});
|
|
418
621
|
|
|
@@ -461,6 +664,10 @@ export function createExportService(
|
|
|
461
664
|
path: "themes/jant/layouts/collection/single.html",
|
|
462
665
|
content: LAYOUT_COLLECTION_SINGLE,
|
|
463
666
|
});
|
|
667
|
+
exportFiles.push({
|
|
668
|
+
path: "themes/jant/layouts/partials/jant-data.html",
|
|
669
|
+
content: PARTIAL_JANT_DATA,
|
|
670
|
+
});
|
|
464
671
|
exportFiles.push({
|
|
465
672
|
path: "themes/jant/layouts/partials/head.html",
|
|
466
673
|
content: PARTIAL_HEAD,
|
|
@@ -497,6 +704,30 @@ export function createExportService(
|
|
|
497
704
|
path: "themes/jant/layouts/partials/featured-thread.html",
|
|
498
705
|
content: PARTIAL_FEATURED_THREAD,
|
|
499
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
|
+
});
|
|
500
731
|
exportFiles.push({
|
|
501
732
|
path: "themes/jant/layouts/_default/rss.xml",
|
|
502
733
|
content: LAYOUT_RSS,
|
|
@@ -546,28 +777,48 @@ export function createExportService(
|
|
|
546
777
|
|
|
547
778
|
exportFiles.push({
|
|
548
779
|
path: "README.md",
|
|
549
|
-
content: buildReadme(siteConfig
|
|
780
|
+
content: buildReadme(siteConfig, hasFeaturedSection),
|
|
550
781
|
});
|
|
551
782
|
exportFiles.push({
|
|
552
783
|
path: ".gitignore",
|
|
553
784
|
content: buildGitignore(),
|
|
554
785
|
});
|
|
786
|
+
exportFiles.push({
|
|
787
|
+
path: WRANGLER_CONFIG_PATH,
|
|
788
|
+
content: buildWranglerConfig(siteConfig, deps.repoName),
|
|
789
|
+
scaffoldOnce: true,
|
|
790
|
+
});
|
|
555
791
|
|
|
556
792
|
return exportFiles;
|
|
557
793
|
},
|
|
558
794
|
|
|
559
795
|
async generateHugoSite() {
|
|
560
796
|
const exportFiles = await this.generateHugoFiles();
|
|
561
|
-
const
|
|
562
|
-
const
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
+
}
|
|
569
819
|
}
|
|
570
|
-
|
|
820
|
+
|
|
821
|
+
return makeZip(entries());
|
|
571
822
|
},
|
|
572
823
|
};
|
|
573
824
|
}
|
|
@@ -676,6 +927,25 @@ function buildExportedCollectionEntriesForThread(
|
|
|
676
927
|
return resolved;
|
|
677
928
|
}
|
|
678
929
|
|
|
930
|
+
/**
|
|
931
|
+
* Whether the site's timeline cuts this post's text short.
|
|
932
|
+
*
|
|
933
|
+
* Shares the boundary with the served feed rather than approximating it: Hugo
|
|
934
|
+
* has its own summary rule and would cut somewhere else, so the answer is
|
|
935
|
+
* computed here and carried as front matter.
|
|
936
|
+
*
|
|
937
|
+
* @param post - The post being exported
|
|
938
|
+
* @returns true when the timeline shows less than the post's page
|
|
939
|
+
* @example
|
|
940
|
+
* isPostTruncated(longArticle); // true
|
|
941
|
+
*/
|
|
942
|
+
function isPostTruncated(post: Post): boolean {
|
|
943
|
+
return (
|
|
944
|
+
extractTimelineSummary(post.body, !!post.title, { namespace: post.id })
|
|
945
|
+
?.hasMore === true
|
|
946
|
+
);
|
|
947
|
+
}
|
|
948
|
+
|
|
679
949
|
function collectionEntriesToRefs(
|
|
680
950
|
entries: readonly ExportedCollectionEntry[],
|
|
681
951
|
): HugoCollectionRef[] {
|
|
@@ -844,12 +1114,39 @@ async function buildThreadBundle(
|
|
|
844
1114
|
// Root aliases = historical root slugs + every reply slug (so
|
|
845
1115
|
// /{reply-slug}/ gets a Hugo alias page that redirects/anchors to
|
|
846
1116
|
// the thread root).
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
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
|
+
}
|
|
851
1131
|
}
|
|
852
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
|
+
|
|
853
1150
|
// Root front matter.
|
|
854
1151
|
const rootMedia = mediaByPost.get(root.id) ?? [];
|
|
855
1152
|
const rootEmissions = rootMedia.map((m) =>
|
|
@@ -863,6 +1160,10 @@ async function buildThreadBundle(
|
|
|
863
1160
|
root.publishedAt !== null
|
|
864
1161
|
? toISOString(root.publishedAt)
|
|
865
1162
|
: toISOString(root.createdAt),
|
|
1163
|
+
created:
|
|
1164
|
+
root.publishedAt !== null && root.createdAt !== root.publishedAt
|
|
1165
|
+
? toISOString(root.createdAt)
|
|
1166
|
+
: undefined,
|
|
866
1167
|
updated:
|
|
867
1168
|
root.updatedAt && root.updatedAt !== root.publishedAt
|
|
868
1169
|
? toISOString(root.updatedAt)
|
|
@@ -878,15 +1179,19 @@ async function buildThreadBundle(
|
|
|
878
1179
|
: undefined,
|
|
879
1180
|
slug: rootSlug,
|
|
880
1181
|
type: "post",
|
|
881
|
-
draft:
|
|
882
|
-
root.status === "draft" || root.visibility === "private"
|
|
883
|
-
? true
|
|
884
|
-
: undefined,
|
|
1182
|
+
draft: rootIsUnpublished ? true : undefined,
|
|
885
1183
|
aliases: aliases.length > 0 ? aliases : undefined,
|
|
1184
|
+
feed_id: feedId,
|
|
886
1185
|
format: root.format,
|
|
887
1186
|
status: root.status,
|
|
888
1187
|
visibility: root.visibility,
|
|
1188
|
+
// Language is Thread-uniform, so only the root emits it; the importer
|
|
1189
|
+
// re-derives replies from their root. `translation_group` is an opaque
|
|
1190
|
+
// shared key — importing the whole site keeps translated posts linked.
|
|
1191
|
+
language: root.language ?? undefined,
|
|
1192
|
+
translation_group: root.translationGroupId ?? undefined,
|
|
889
1193
|
summary_text: getArchiveSummaryText(root) ?? undefined,
|
|
1194
|
+
truncated: isPostTruncated(root) || undefined,
|
|
890
1195
|
link_url: root.format === "link" && root.url ? root.url : undefined,
|
|
891
1196
|
source_name: root.format === "quote" && root.title ? root.title : undefined,
|
|
892
1197
|
source_url: root.format === "quote" && root.url ? root.url : undefined,
|
|
@@ -906,7 +1211,7 @@ async function buildThreadBundle(
|
|
|
906
1211
|
media: rootMediaList.length > 0 ? rootMediaList : undefined,
|
|
907
1212
|
};
|
|
908
1213
|
|
|
909
|
-
const rootBody = root.body ?
|
|
1214
|
+
const rootBody = root.body ? postBodyToMarkdown(root.body, rootSlug) : "";
|
|
910
1215
|
files.push({
|
|
911
1216
|
path: `content/${rootSlug}/_index.md`,
|
|
912
1217
|
content: `${await formatFrontMatter(rootFrontMatter)}\n${rootBody}${rootBody.endsWith("\n") ? "" : "\n"}`,
|
|
@@ -922,7 +1227,7 @@ async function buildThreadBundle(
|
|
|
922
1227
|
media: rootMedia[i] as Media,
|
|
923
1228
|
}))) {
|
|
924
1229
|
if (emission.inlinePath) {
|
|
925
|
-
const file =
|
|
1230
|
+
const file = toStoredMediaFile(
|
|
926
1231
|
storage,
|
|
927
1232
|
media.storageKey,
|
|
928
1233
|
emission.inlinePath,
|
|
@@ -930,7 +1235,7 @@ async function buildThreadBundle(
|
|
|
930
1235
|
if (file) files.push(file);
|
|
931
1236
|
}
|
|
932
1237
|
if (emission.inlinePosterPath && media.posterKey) {
|
|
933
|
-
const posterFile =
|
|
1238
|
+
const posterFile = toStoredMediaFile(
|
|
934
1239
|
storage,
|
|
935
1240
|
media.posterKey,
|
|
936
1241
|
emission.inlinePosterPath,
|
|
@@ -939,8 +1244,8 @@ async function buildThreadBundle(
|
|
|
939
1244
|
}
|
|
940
1245
|
}
|
|
941
1246
|
|
|
942
|
-
// Replies as nested leaf bundles.
|
|
943
|
-
for (const reply of threadReplies) {
|
|
1247
|
+
// Replies as nested leaf bundles, in Thread order.
|
|
1248
|
+
for (const [replyIndex, reply] of threadReplies.entries()) {
|
|
944
1249
|
const replySlug = slugMap.get(reply.id) ?? reply.slug;
|
|
945
1250
|
const replyMedia = mediaByPost.get(reply.id) ?? [];
|
|
946
1251
|
const replyEmissions = replyMedia.map((m) =>
|
|
@@ -954,12 +1259,17 @@ async function buildThreadBundle(
|
|
|
954
1259
|
reply.publishedAt !== null
|
|
955
1260
|
? toISOString(reply.publishedAt)
|
|
956
1261
|
: toISOString(reply.createdAt),
|
|
1262
|
+
created:
|
|
1263
|
+
reply.publishedAt !== null && reply.createdAt !== reply.publishedAt
|
|
1264
|
+
? toISOString(reply.createdAt)
|
|
1265
|
+
: undefined,
|
|
957
1266
|
updated:
|
|
958
1267
|
reply.updatedAt && reply.updatedAt !== reply.publishedAt
|
|
959
1268
|
? toISOString(reply.updatedAt)
|
|
960
1269
|
: undefined,
|
|
961
1270
|
slug: replySlug,
|
|
962
1271
|
type: "post",
|
|
1272
|
+
weight: replyIndex + 1,
|
|
963
1273
|
draft:
|
|
964
1274
|
reply.status === "draft" || reply.visibility === "private"
|
|
965
1275
|
? true
|
|
@@ -969,6 +1279,7 @@ async function buildThreadBundle(
|
|
|
969
1279
|
status: reply.status,
|
|
970
1280
|
visibility: reply.visibility,
|
|
971
1281
|
summary_text: getArchiveSummaryText(reply) ?? undefined,
|
|
1282
|
+
truncated: isPostTruncated(reply) || undefined,
|
|
972
1283
|
link_url: reply.format === "link" && reply.url ? reply.url : undefined,
|
|
973
1284
|
source_name:
|
|
974
1285
|
reply.format === "quote" && reply.title ? reply.title : undefined,
|
|
@@ -983,7 +1294,9 @@ async function buildThreadBundle(
|
|
|
983
1294
|
media: replyMediaList.length > 0 ? replyMediaList : undefined,
|
|
984
1295
|
};
|
|
985
1296
|
|
|
986
|
-
const replyBody = reply.body
|
|
1297
|
+
const replyBody = reply.body
|
|
1298
|
+
? postBodyToMarkdown(reply.body, replySlug)
|
|
1299
|
+
: "";
|
|
987
1300
|
files.push({
|
|
988
1301
|
path: `content/${rootSlug}/${replySlug}/index.md`,
|
|
989
1302
|
content: `${await formatFrontMatter(replyFrontMatter)}\n${replyBody}${replyBody.endsWith("\n") ? "" : "\n"}`,
|
|
@@ -994,7 +1307,7 @@ async function buildThreadBundle(
|
|
|
994
1307
|
media: replyMedia[i] as Media,
|
|
995
1308
|
}))) {
|
|
996
1309
|
if (emission.inlinePath) {
|
|
997
|
-
const file =
|
|
1310
|
+
const file = toStoredMediaFile(
|
|
998
1311
|
storage,
|
|
999
1312
|
media.storageKey,
|
|
1000
1313
|
emission.inlinePath,
|
|
@@ -1002,7 +1315,7 @@ async function buildThreadBundle(
|
|
|
1002
1315
|
if (file) files.push(file);
|
|
1003
1316
|
}
|
|
1004
1317
|
if (emission.inlinePosterPath && media.posterKey) {
|
|
1005
|
-
const posterFile =
|
|
1318
|
+
const posterFile = toStoredMediaFile(
|
|
1006
1319
|
storage,
|
|
1007
1320
|
media.posterKey,
|
|
1008
1321
|
emission.inlinePosterPath,
|
|
@@ -1016,25 +1329,17 @@ async function buildThreadBundle(
|
|
|
1016
1329
|
}
|
|
1017
1330
|
|
|
1018
1331
|
/**
|
|
1019
|
-
*
|
|
1020
|
-
*
|
|
1021
|
-
*
|
|
1022
|
-
*
|
|
1023
|
-
* 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.
|
|
1024
1336
|
*/
|
|
1025
|
-
|
|
1337
|
+
function toStoredMediaFile(
|
|
1026
1338
|
storage: StorageDriver | null,
|
|
1027
1339
|
storageKey: string,
|
|
1028
1340
|
bundlePath: string,
|
|
1029
|
-
):
|
|
1030
|
-
|
|
1031
|
-
try {
|
|
1032
|
-
const bytes = await readStorageObjectBytes(storage, storageKey);
|
|
1033
|
-
if (!bytes) return null;
|
|
1034
|
-
return { path: bundlePath, content: bytes };
|
|
1035
|
-
} catch {
|
|
1036
|
-
return null;
|
|
1037
|
-
}
|
|
1341
|
+
): ExportStoredFile | null {
|
|
1342
|
+
return storage ? { path: bundlePath, storageKey } : null;
|
|
1038
1343
|
}
|
|
1039
1344
|
|
|
1040
1345
|
// ---------------------------------------------------------------------------
|
|
@@ -1096,6 +1401,48 @@ async function buildCollectionSection(
|
|
|
1096
1401
|
return `${await formatFrontMatter(frontMatter)}\n`;
|
|
1097
1402
|
}
|
|
1098
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
|
+
|
|
1099
1446
|
// ---------------------------------------------------------------------------
|
|
1100
1447
|
// Summary extraction (kept from the previous exporter)
|
|
1101
1448
|
// ---------------------------------------------------------------------------
|
|
@@ -1260,6 +1607,7 @@ function buildExportedCollectionDirectoryItems(
|
|
|
1260
1607
|
sequence: sequenceLabels[index] ?? "",
|
|
1261
1608
|
label: item.label,
|
|
1262
1609
|
url: item.url,
|
|
1610
|
+
description: description || null,
|
|
1263
1611
|
descriptionHtml: description
|
|
1264
1612
|
? renderMarkdown(description, {
|
|
1265
1613
|
namespace: `collection-directory-link-${sequenceLabels[index] ?? index}`,
|
|
@@ -1269,6 +1617,31 @@ function buildExportedCollectionDirectoryItems(
|
|
|
1269
1617
|
return;
|
|
1270
1618
|
}
|
|
1271
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
|
+
|
|
1272
1645
|
const collection = item.collection;
|
|
1273
1646
|
if (!collection?.id) {
|
|
1274
1647
|
return;
|
|
@@ -1356,13 +1729,21 @@ function buildExportedCollectionMetrics(
|
|
|
1356
1729
|
// Nav item resolution
|
|
1357
1730
|
// ---------------------------------------------------------------------------
|
|
1358
1731
|
|
|
1732
|
+
/**
|
|
1733
|
+
* Label to bake into the static site.
|
|
1734
|
+
*
|
|
1735
|
+
* Mirrors `getNavItemDisplayLabel`: a stored label is the author's own words
|
|
1736
|
+
* and wins, an empty one means the item follows what it points at. The export
|
|
1737
|
+
* has no i18n runtime, so built-in destinations fall back to their English
|
|
1738
|
+
* default rather than a translated one.
|
|
1739
|
+
*/
|
|
1359
1740
|
function resolveNavItemLabel(item: SiteConfig["navItems"][number]): string {
|
|
1360
1741
|
if (item.label) return item.label;
|
|
1361
1742
|
if (item.systemKey) {
|
|
1362
1743
|
const definition = SYSTEM_NAV_KEYS[item.systemKey as SystemNavKey];
|
|
1363
1744
|
if (definition) return definition.defaultLabel;
|
|
1364
1745
|
}
|
|
1365
|
-
return item.label;
|
|
1746
|
+
return item.targetTitle?.trim() || item.label;
|
|
1366
1747
|
}
|
|
1367
1748
|
|
|
1368
1749
|
/**
|
|
@@ -1389,7 +1770,10 @@ function resolveNavItemUrl(
|
|
|
1389
1770
|
}
|
|
1390
1771
|
if (item.systemKey === "collections") return "/collections/";
|
|
1391
1772
|
if (item.systemKey === "archive") return "/archive/";
|
|
1392
|
-
|
|
1773
|
+
// `subscribe` addresses a Jant route, and the exported site has no page at
|
|
1774
|
+
// it. Rather than exporting a nav entry that 404s, it resolves to the feed
|
|
1775
|
+
// that page would have recommended — the same file `rss` points at.
|
|
1776
|
+
if (item.systemKey === "rss" || item.systemKey === "subscribe") {
|
|
1393
1777
|
return mainRssFeed === "featured" ? "/featured/index.xml" : "/index.xml";
|
|
1394
1778
|
}
|
|
1395
1779
|
return item.url;
|
|
@@ -1419,7 +1803,12 @@ function buildHugoToml(config: SiteConfig): string {
|
|
|
1419
1803
|
`languageCode = "${escapeTomlString(language)}"`,
|
|
1420
1804
|
`defaultContentLanguage = "${escapeTomlString(language)}"`,
|
|
1421
1805
|
'theme = "jant"',
|
|
1422
|
-
`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`.
|
|
1423
1812
|
"enableRobotsTXT = true",
|
|
1424
1813
|
// Disable Hugo's built-in taxonomies — jant has no tags or categories
|
|
1425
1814
|
// and the default empty /tags/ and /categories/ pages are noise. This
|
|
@@ -1468,6 +1857,7 @@ function buildHugoToml(config: SiteConfig): string {
|
|
|
1468
1857
|
"[params]",
|
|
1469
1858
|
` description = "${escapeTomlString(config.siteDescription)}"`,
|
|
1470
1859
|
` main_rss_feed = "${escapeTomlString(config.mainRssFeed)}"`,
|
|
1860
|
+
` archive_default_layout = "${escapeTomlString(config.archiveDefaultLayout)}"`,
|
|
1471
1861
|
` public_api_enabled = ${config.publicApiEnabled}`,
|
|
1472
1862
|
` rss_feeds_enabled = ${config.rssFeedsEnabled}`,
|
|
1473
1863
|
` show_jant_branding_on_home = ${config.showJantBrandingOnHome}`,
|
|
@@ -1495,10 +1885,42 @@ function buildHugoToml(config: SiteConfig): string {
|
|
|
1495
1885
|
return `${parts.join("\n")}\n`;
|
|
1496
1886
|
}
|
|
1497
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
|
+
|
|
1498
1919
|
function buildJantDataToml(
|
|
1499
1920
|
config: SiteConfig,
|
|
1500
1921
|
iconAssets: SiteIconAssets,
|
|
1501
1922
|
directoryItems: readonly ExportedCollectionDirectoryItem[],
|
|
1923
|
+
targets: JantDataTargets,
|
|
1502
1924
|
): string {
|
|
1503
1925
|
const footerHtml = config.siteFooter
|
|
1504
1926
|
? renderMarkdown(config.siteFooter, { namespace: "site-footer" })
|
|
@@ -1510,7 +1932,10 @@ function buildJantDataToml(
|
|
|
1510
1932
|
`site_name = "${escapeTomlString(config.siteName)}"`,
|
|
1511
1933
|
`site_description = "${escapeTomlString(config.siteDescription)}"`,
|
|
1512
1934
|
`site_language = "${escapeTomlString(config.siteLanguage)}"`,
|
|
1935
|
+
`multilingual_enabled = ${config.multilingualEnabled}`,
|
|
1936
|
+
`additional_languages = "${escapeTomlString(config.additionalLanguages.join(","))}"`,
|
|
1513
1937
|
`main_rss_feed = "${escapeTomlString(config.mainRssFeed)}"`,
|
|
1938
|
+
`archive_default_layout = "${escapeTomlString(config.archiveDefaultLayout)}"`,
|
|
1514
1939
|
`public_api_enabled = ${config.publicApiEnabled}`,
|
|
1515
1940
|
`rss_feeds_enabled = ${config.rssFeedsEnabled}`,
|
|
1516
1941
|
`show_jant_branding_on_home = ${config.showJantBrandingOnHome}`,
|
|
@@ -1547,13 +1972,14 @@ function buildJantDataToml(
|
|
|
1547
1972
|
}
|
|
1548
1973
|
|
|
1549
1974
|
for (const item of config.navItems) {
|
|
1550
|
-
// `settings`
|
|
1551
|
-
//
|
|
1552
|
-
|
|
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.
|
|
1553
1979
|
if (
|
|
1554
1980
|
!config.rssFeedsEnabled &&
|
|
1555
1981
|
item.type === "system" &&
|
|
1556
|
-
item.systemKey
|
|
1982
|
+
isFeedNavKey(item.systemKey)
|
|
1557
1983
|
) {
|
|
1558
1984
|
continue;
|
|
1559
1985
|
}
|
|
@@ -1566,13 +1992,22 @@ function buildJantDataToml(
|
|
|
1566
1992
|
);
|
|
1567
1993
|
parts.push(`system_key = "${escapeTomlString(item.systemKey ?? "")}"`);
|
|
1568
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
|
+
}
|
|
1569
2004
|
}
|
|
1570
2005
|
|
|
1571
2006
|
for (const item of directoryItems) {
|
|
1572
2007
|
parts.push("");
|
|
1573
2008
|
parts.push("[[directory]]");
|
|
1574
2009
|
parts.push(`type = "${escapeTomlString(item.type)}"`);
|
|
1575
|
-
if (item.type === "collection") {
|
|
2010
|
+
if (item.type === "collection" || item.type === "smart_collection") {
|
|
1576
2011
|
parts.push(`sequence = "${escapeTomlString(item.sequence)}"`);
|
|
1577
2012
|
parts.push(`slug = "${escapeTomlString(item.slug)}"`);
|
|
1578
2013
|
parts.push(`title = "${escapeTomlString(item.title)}"`);
|
|
@@ -1602,6 +2037,9 @@ function buildJantDataToml(
|
|
|
1602
2037
|
parts.push(`sequence = "${escapeTomlString(item.sequence)}"`);
|
|
1603
2038
|
parts.push(`label = "${escapeTomlString(item.label)}"`);
|
|
1604
2039
|
parts.push(`url = "${escapeTomlString(item.url)}"`);
|
|
2040
|
+
if (item.description) {
|
|
2041
|
+
parts.push(`description = "${escapeTomlString(item.description)}"`);
|
|
2042
|
+
}
|
|
1605
2043
|
if (item.descriptionHtml) {
|
|
1606
2044
|
parts.push(
|
|
1607
2045
|
`description_html = "${escapeTomlString(item.descriptionHtml)}"`,
|
|
@@ -1610,13 +2048,254 @@ function buildJantDataToml(
|
|
|
1610
2048
|
}
|
|
1611
2049
|
}
|
|
1612
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
|
+
|
|
1613
2067
|
return `${parts.join("\n")}\n`;
|
|
1614
2068
|
}
|
|
1615
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
|
+
|
|
1616
2100
|
// ---------------------------------------------------------------------------
|
|
1617
|
-
// README + .gitignore
|
|
2101
|
+
// README + .gitignore + _redirects + wrangler.jsonc
|
|
1618
2102
|
// ---------------------------------------------------------------------------
|
|
1619
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
|
+
|
|
2213
|
+
/**
|
|
2214
|
+
* Build the `static/_redirects` file that keeps existing feed subscribers
|
|
2215
|
+
* working after the site moves to this export.
|
|
2216
|
+
*
|
|
2217
|
+
* Jant serves its feeds under `/feed` (`/feed`, `/latest/feed`,
|
|
2218
|
+
* `/featured/feed`, `/archive/feed`, `/{collection}/feed`); Hugo serves the
|
|
2219
|
+
* same documents as `index.xml` inside each section. The two namespaces do
|
|
2220
|
+
* not overlap, so every subscriber's saved address 404s the moment the
|
|
2221
|
+
* exported site goes up.
|
|
2222
|
+
*
|
|
2223
|
+
* Hugo's `aliases:` mechanism cannot cover this the way it covers post URLs:
|
|
2224
|
+
* an alias page is a meta-refresh plus a script, and feed readers fetch and
|
|
2225
|
+
* parse XML without running either. Only an HTTP redirect reaches them, which
|
|
2226
|
+
* is host configuration rather than Hugo output — so the export emits the one
|
|
2227
|
+
* format two common static hosts read verbatim (Cloudflare Pages and Netlify).
|
|
2228
|
+
* Hosts that do not read `_redirects` ignore the file; the README carries the
|
|
2229
|
+
* rule for them to translate.
|
|
2230
|
+
*
|
|
2231
|
+
* @param mainRssFeed - The site's main feed setting, "latest" or "featured".
|
|
2232
|
+
* @param hasFeaturedSection - Whether the export emitted a featured section.
|
|
2233
|
+
* It is skipped when a post or collection already owns the `featured` slug,
|
|
2234
|
+
* and pointing a redirect at a feed that was never built would turn a live
|
|
2235
|
+
* subscription into a 404 rather than leaving it merely stale.
|
|
2236
|
+
* @returns The `_redirects` file body.
|
|
2237
|
+
* @example
|
|
2238
|
+
* buildFeedRedirects("latest", true); // "# ...\n/feed /index.xml 301\n..."
|
|
2239
|
+
*/
|
|
2240
|
+
function buildFeedRedirects(
|
|
2241
|
+
mainRssFeed: string,
|
|
2242
|
+
hasFeaturedSection: boolean,
|
|
2243
|
+
): string {
|
|
2244
|
+
const LATEST = "/index.xml";
|
|
2245
|
+
const FEATURED = "/featured/index.xml";
|
|
2246
|
+
// `/feed` follows the site's main-feed setting, mirroring `renderMainFeed`.
|
|
2247
|
+
// Without a featured section there is nothing to point it at, so it falls
|
|
2248
|
+
// back to the latest feed: a wider feed than the reader subscribed to, but
|
|
2249
|
+
// a live one.
|
|
2250
|
+
const main =
|
|
2251
|
+
mainRssFeed === "featured" && hasFeaturedSection ? FEATURED : LATEST;
|
|
2252
|
+
const featured = hasFeaturedSection ? FEATURED : LATEST;
|
|
2253
|
+
|
|
2254
|
+
const rules: [string, string][] = [
|
|
2255
|
+
["/feed", main],
|
|
2256
|
+
["/latest/feed", LATEST],
|
|
2257
|
+
["/featured/feed", featured],
|
|
2258
|
+
["/archive/feed", "/archive/index.xml"],
|
|
2259
|
+
];
|
|
2260
|
+
const legacy: [string, string][] = [
|
|
2261
|
+
["/feed/latest", LATEST],
|
|
2262
|
+
["/feed/all", LATEST],
|
|
2263
|
+
["/feed/featured", featured],
|
|
2264
|
+
["/feed/atom.xml", main],
|
|
2265
|
+
["/feed/latest/atom.xml", LATEST],
|
|
2266
|
+
["/feed/all/atom.xml", LATEST],
|
|
2267
|
+
["/feed/featured/atom.xml", featured],
|
|
2268
|
+
];
|
|
2269
|
+
|
|
2270
|
+
// The format only needs whitespace between the columns; padding to the
|
|
2271
|
+
// widest source path keeps the file readable for whoever has to translate
|
|
2272
|
+
// it into another host's redirect syntax.
|
|
2273
|
+
const width = Math.max(
|
|
2274
|
+
...[...rules, ...legacy].map(([from]) => from.length),
|
|
2275
|
+
"/:slug/feed".length,
|
|
2276
|
+
);
|
|
2277
|
+
const line = ([from, to]: [string, string]) =>
|
|
2278
|
+
`${from.padEnd(width)} ${to} 301`;
|
|
2279
|
+
|
|
2280
|
+
return `# Feed addresses moved in this export.
|
|
2281
|
+
#
|
|
2282
|
+
# Jant served its feeds under /feed; Hugo serves them as index.xml inside each
|
|
2283
|
+
# section. Cloudflare Pages and Netlify read this file and issue the redirects
|
|
2284
|
+
# below, so readers who subscribed to the old addresses keep receiving posts.
|
|
2285
|
+
# Other hosts ignore this file — see README.md for the same rules to translate
|
|
2286
|
+
# into your host's configuration.
|
|
2287
|
+
|
|
2288
|
+
${rules.map(line).join("\n")}
|
|
2289
|
+
|
|
2290
|
+
# Aliases the live site answered with a 308. They would die at the same moment.
|
|
2291
|
+
${legacy.map(line).join("\n")}
|
|
2292
|
+
|
|
2293
|
+
# Collection feeds, by slug. Collections are the only root-level sections that
|
|
2294
|
+
# emit Atom, so any other slug 404s here exactly as it did on the live site.
|
|
2295
|
+
${line(["/:slug/feed", "/:slug/index.xml"])}
|
|
2296
|
+
`;
|
|
2297
|
+
}
|
|
2298
|
+
|
|
1620
2299
|
function buildGitignore(): string {
|
|
1621
2300
|
return `# Hugo build output
|
|
1622
2301
|
public/
|
|
@@ -1634,14 +2313,126 @@ Thumbs.db
|
|
|
1634
2313
|
`;
|
|
1635
2314
|
}
|
|
1636
2315
|
|
|
1637
|
-
|
|
2316
|
+
/**
|
|
2317
|
+
* Render a two-column Markdown table with padded cells.
|
|
2318
|
+
*
|
|
2319
|
+
* The values differ per site, so building the rows by hand would leave the
|
|
2320
|
+
* committed README ragged for some exports and aligned for others.
|
|
2321
|
+
*
|
|
2322
|
+
* @param headers - The two column headers.
|
|
2323
|
+
* @param rows - Cell pairs; each cell is wrapped in backticks.
|
|
2324
|
+
* @returns The table as Markdown, without a trailing newline.
|
|
2325
|
+
* @example
|
|
2326
|
+
* renderMarkdownTable(["Jant", "This export"], [["/feed", "/index.xml"]]);
|
|
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
|
+
|
|
2351
|
+
function renderMarkdownTable(
|
|
2352
|
+
headers: [string, string],
|
|
2353
|
+
rows: [string, string][],
|
|
2354
|
+
): string {
|
|
2355
|
+
const cells = rows.map(
|
|
2356
|
+
([from, to]) => [`\`${from}\``, `\`${to}\``] as [string, string],
|
|
2357
|
+
);
|
|
2358
|
+
const widths: [number, number] = [
|
|
2359
|
+
Math.max(headers[0].length, ...cells.map((r) => r[0].length)),
|
|
2360
|
+
Math.max(headers[1].length, ...cells.map((r) => r[1].length)),
|
|
2361
|
+
];
|
|
2362
|
+
const line = (a: string, b: string) =>
|
|
2363
|
+
`| ${a.padEnd(widths[0])} | ${b.padEnd(widths[1])} |`;
|
|
2364
|
+
return [
|
|
2365
|
+
line(headers[0], headers[1]),
|
|
2366
|
+
`| ${"-".repeat(widths[0])} | ${"-".repeat(widths[1])} |`,
|
|
2367
|
+
...cells.map((r) => line(r[0], r[1])),
|
|
2368
|
+
].join("\n");
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
/**
|
|
2372
|
+
* Build the export's README.
|
|
2373
|
+
*
|
|
2374
|
+
* @param config - The exported site's configuration.
|
|
2375
|
+
* @param hasFeaturedSection - Whether a featured section was emitted, which
|
|
2376
|
+
* decides where the feed redirects point.
|
|
2377
|
+
* @returns Markdown for `README.md` at the root of the export.
|
|
2378
|
+
* @example
|
|
2379
|
+
* buildReadme(config, true); // "# My Site — Hugo Export\n..."
|
|
2380
|
+
*/
|
|
2381
|
+
function buildReadme(config: SiteConfig, hasFeaturedSection: boolean): string {
|
|
2382
|
+
const siteName = config.siteName;
|
|
2383
|
+
const featuredFeed = hasFeaturedSection
|
|
2384
|
+
? "/featured/index.xml"
|
|
2385
|
+
: "/index.xml";
|
|
2386
|
+
const mainFeed =
|
|
2387
|
+
config.mainRssFeed === "featured" ? featuredFeed : "/index.xml";
|
|
2388
|
+
// Nothing to say about feeds on a site that publishes none, and no
|
|
2389
|
+
// `_redirects` file was written for it either.
|
|
2390
|
+
const staticListing = config.rssFeedsEnabled
|
|
2391
|
+
? "\n _redirects — Feed redirects (see Feeds above)"
|
|
2392
|
+
: "";
|
|
2393
|
+
// The Notes list below says post URLs survive the move. Left alone, that
|
|
2394
|
+
// reads as a promise about every URL, and feed addresses are the exception.
|
|
2395
|
+
const feedNote = config.rssFeedsEnabled
|
|
2396
|
+
? "\n- Feed addresses are the exception: they move to `index.xml` and stay reachable only through `static/_redirects`. See [Feeds](#feeds)."
|
|
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
|
+
: "";
|
|
2402
|
+
// Nothing to say about feeds on a site that publishes none, and no
|
|
2403
|
+
// `_redirects` file was written for it either.
|
|
2404
|
+
const feedRows: [string, string][] = [
|
|
2405
|
+
["/feed", mainFeed],
|
|
2406
|
+
["/latest/feed", "/index.xml"],
|
|
2407
|
+
["/featured/feed", featuredFeed],
|
|
2408
|
+
["/archive/feed", "/archive/index.xml"],
|
|
2409
|
+
["/{collection}/feed", "/{collection}/index.xml"],
|
|
2410
|
+
];
|
|
2411
|
+
const feedTable = renderMarkdownTable(["Jant", "This export"], feedRows);
|
|
2412
|
+
const feedsSection = config.rssFeedsEnabled
|
|
2413
|
+
? `## Feeds
|
|
2414
|
+
|
|
2415
|
+
The feed addresses changed. Jant served them under \`/feed\`; Hugo serves them as \`index.xml\` inside each section:
|
|
2416
|
+
|
|
2417
|
+
${feedTable}
|
|
2418
|
+
|
|
2419
|
+
A reader who is already subscribed holds one of the old addresses, and a feed reader that gets a 404 stops delivering posts. \`static/_redirects\` maps every old address to its new one with a 301. Cloudflare Pages and Netlify read that file as published; on any other host, translate its rules into that host's redirect configuration before you point the domain here.
|
|
2420
|
+
|
|
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.
|
|
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
|
+
|
|
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.
|
|
2426
|
+
|
|
2427
|
+
`
|
|
2428
|
+
: "";
|
|
1638
2429
|
return `# ${siteName} — Hugo Export
|
|
1639
2430
|
|
|
1640
2431
|
This is a static site exported from [Jant](https://github.com/jant-me/jant), ready to build with [Hugo](https://gohugo.io/).
|
|
1641
2432
|
|
|
1642
2433
|
## Install Hugo
|
|
1643
2434
|
|
|
1644
|
-
This export targets Hugo **extended 0.
|
|
2435
|
+
This export targets Hugo **extended 0.147.7+**.
|
|
1645
2436
|
|
|
1646
2437
|
**macOS (Homebrew):**
|
|
1647
2438
|
|
|
@@ -1679,10 +2470,27 @@ hugo --minify
|
|
|
1679
2470
|
|
|
1680
2471
|
The output goes to the \`public/\` directory. Upload it to any static host (Netlify, Vercel, Cloudflare Pages, GitHub Pages, etc.).
|
|
1681
2472
|
|
|
1682
|
-
##
|
|
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
|
|
1683
2490
|
|
|
1684
2491
|
\`\`\`
|
|
1685
2492
|
hugo.toml — Site configuration (baseURL, title, theme, params)
|
|
2493
|
+
wrangler.jsonc — Cloudflare Workers deploy config (see Deploy above)
|
|
1686
2494
|
content/
|
|
1687
2495
|
_index.md — Home section
|
|
1688
2496
|
archive/_index.md — Archive section
|
|
@@ -1695,7 +2503,7 @@ content/
|
|
|
1695
2503
|
data/
|
|
1696
2504
|
jant.toml — Nav items, branding, display preferences, ordered collections directory
|
|
1697
2505
|
themes/jant/ — Bundled Hugo theme (overrideable via layouts/ at the site root)
|
|
1698
|
-
static/ — Copy files here to add them to the published site
|
|
2506
|
+
static/ — Copy files here to add them to the published site${staticListing}
|
|
1699
2507
|
\`\`\`
|
|
1700
2508
|
|
|
1701
2509
|
## Customizing
|
|
@@ -1721,7 +2529,7 @@ Safe to re-run; files already on disk are reused. Anything that fails to downloa
|
|
|
1721
2529
|
## Notes
|
|
1722
2530
|
|
|
1723
2531
|
- Each thread is a Hugo branch bundle. Replies live as nested leaf bundles with \`build.render = "never"\` so they do not produce standalone URLs; they render inside the thread page.
|
|
1724
|
-
- \`/{reply-slug}/\` URLs are preserved via \`aliases:\` on the root post, so old links still land on the right thread anchor
|
|
2532
|
+
- \`/{reply-slug}/\` URLs are preserved via \`aliases:\` on the root post, so old links still land on the right thread anchor.${feedNote}
|
|
1725
2533
|
- Media is emitted under \`static/media/{id}.ext\` and referenced from a flat \`media:\` array on each post. When a storage provider has a configured public URL (R2/S3/local proxy), the exporter links to the provider URL instead of re-bundling the bytes.
|
|
1726
2534
|
- Posts with \`draft: true\` in front matter are only built when you pass \`--buildDrafts\` to \`hugo\` / \`hugo serve\`.
|
|
1727
2535
|
`;
|