@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
|
@@ -13,31 +13,56 @@ import { Hono } from "hono";
|
|
|
13
13
|
import type { Context } from "hono";
|
|
14
14
|
import type {
|
|
15
15
|
Bindings,
|
|
16
|
+
Collection,
|
|
16
17
|
FeedData,
|
|
17
|
-
Format,
|
|
18
|
-
MediaKind,
|
|
19
18
|
PostWithMedia,
|
|
20
19
|
} from "../../types.js";
|
|
21
20
|
import type { AppVariables } from "../../types/app-context.js";
|
|
22
21
|
import type {
|
|
23
22
|
ArchiveFilters,
|
|
23
|
+
ArchiveLayout,
|
|
24
24
|
ArchiveSort,
|
|
25
|
-
ArchiveView,
|
|
26
|
-
ArchiveVisibility,
|
|
27
25
|
} from "../../types/props.js";
|
|
28
|
-
import {
|
|
26
|
+
import { PUBLIC_ARCHIVE_VISIBILITIES } from "../../types.js";
|
|
29
27
|
import { ArchivePage } from "../../ui/pages/ArchivePage.js";
|
|
28
|
+
import {
|
|
29
|
+
describeArchiveFilters,
|
|
30
|
+
getArchiveViewTitle,
|
|
31
|
+
hasActiveArchiveFilter,
|
|
32
|
+
} from "../../ui/shared/archive-labels.js";
|
|
33
|
+
import type {
|
|
34
|
+
DimensionContext,
|
|
35
|
+
ParamReader,
|
|
36
|
+
PostFilterSelection,
|
|
37
|
+
} from "../../lib/filter-dimensions.js";
|
|
38
|
+
import {
|
|
39
|
+
buildCollectionVocabulary,
|
|
40
|
+
EMPTY_COLLECTION_VOCABULARY,
|
|
41
|
+
hasFilterSelection,
|
|
42
|
+
parsePostFilterSelection,
|
|
43
|
+
readCollectionSlugs,
|
|
44
|
+
serializePostFilterSelection,
|
|
45
|
+
toPostFilters,
|
|
46
|
+
} from "../../lib/filter-dimensions.js";
|
|
30
47
|
import { defaultFeedRenderer } from "../../lib/feed.js";
|
|
31
48
|
import {
|
|
49
|
+
buildFeedDiscoveryFields,
|
|
32
50
|
getFeedEntryUpdatedAt,
|
|
51
|
+
getFeedLimit,
|
|
33
52
|
getRssPublishedBefore,
|
|
34
|
-
|
|
53
|
+
renderFeed,
|
|
35
54
|
} from "../../lib/feed-policy.js";
|
|
36
55
|
import { getNavigationData } from "../../lib/navigation.js";
|
|
37
56
|
import { buildPageTitle } from "../../lib/page-title.js";
|
|
38
57
|
import { renderPublicPage } from "../../lib/render.js";
|
|
39
58
|
import { formatYearMonth } from "../../lib/time.js";
|
|
40
|
-
import { toAbsoluteSiteUrl
|
|
59
|
+
import { toAbsoluteSiteUrl } from "../../lib/url.js";
|
|
60
|
+
import {
|
|
61
|
+
buildSurfaceAlternates,
|
|
62
|
+
getViewLang,
|
|
63
|
+
toViewPath,
|
|
64
|
+
viewBasePath,
|
|
65
|
+
} from "../../lib/view-language.js";
|
|
41
66
|
import {
|
|
42
67
|
createMediaContext,
|
|
43
68
|
toArchiveGroupsWithMedia,
|
|
@@ -55,122 +80,167 @@ type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
|
55
80
|
// Shared filter parsing
|
|
56
81
|
// =============================================================================
|
|
57
82
|
|
|
58
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* One archive request, after the query string has been read.
|
|
85
|
+
*
|
|
86
|
+
* The filter dimensions live in `selection`, spoken in the shared vocabulary
|
|
87
|
+
* (`lib/filter-dimensions.ts`); everything beside it shapes the rendering
|
|
88
|
+
* rather than the result set.
|
|
89
|
+
*/
|
|
59
90
|
interface ParsedArchiveParams {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
visibilityAll: boolean;
|
|
69
|
-
view?: ArchiveView;
|
|
91
|
+
selection: PostFilterSelection;
|
|
92
|
+
/**
|
|
93
|
+
* Set when `?collection=` named something this site does not have. The whole
|
|
94
|
+
* request is answered with a 404 rather than rendered — see
|
|
95
|
+
* {@link readArchiveParams}.
|
|
96
|
+
*/
|
|
97
|
+
collectionMissing: boolean;
|
|
98
|
+
layout?: ArchiveLayout;
|
|
70
99
|
sort: ArchiveSort;
|
|
71
100
|
currentPage: number;
|
|
72
101
|
}
|
|
73
102
|
|
|
103
|
+
/** Reads this request's query string, or the stored one a custom path carries. */
|
|
104
|
+
function archiveParamReader(
|
|
105
|
+
c: Context<Env>,
|
|
106
|
+
queryOverrides?: Record<string, string>,
|
|
107
|
+
): ParamReader {
|
|
108
|
+
return (key) => (queryOverrides ? queryOverrides[key] : c.req.query(key));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Load the collection vocabulary, but only when this request needs one.
|
|
113
|
+
*
|
|
114
|
+
* Resolving `?collection=` costs a round trip, and the archive renders without
|
|
115
|
+
* one on every request that names no collection — which is most of them, on the
|
|
116
|
+
* site's widest reader page. So the decision is made from the query string
|
|
117
|
+
* alone, before anything is parsed.
|
|
118
|
+
*
|
|
119
|
+
* @param c - Hono context
|
|
120
|
+
* @param read - Query parameter reader
|
|
121
|
+
* @returns Every collection on the site, or null when none was named
|
|
122
|
+
*/
|
|
123
|
+
async function loadArchiveCollections(
|
|
124
|
+
c: Context<Env>,
|
|
125
|
+
read: ParamReader,
|
|
126
|
+
): Promise<Collection[] | null> {
|
|
127
|
+
if (readCollectionSlugs(read).length === 0) return null;
|
|
128
|
+
return c.var.services.collections.list();
|
|
129
|
+
}
|
|
130
|
+
|
|
74
131
|
/**
|
|
75
|
-
*
|
|
132
|
+
* Read the archive's query string into a selection plus rendering options.
|
|
76
133
|
*
|
|
77
134
|
* @param c - Hono context
|
|
78
|
-
* @param
|
|
135
|
+
* @param read - Query parameter reader
|
|
136
|
+
* @param collections - Collections to resolve slugs against, when any is named
|
|
79
137
|
* @returns Parsed and validated query parameters
|
|
80
138
|
*/
|
|
81
|
-
function
|
|
139
|
+
function readArchiveParams(
|
|
82
140
|
c: Context<Env>,
|
|
83
|
-
|
|
141
|
+
read: ParamReader,
|
|
142
|
+
collections: Collection[] | null,
|
|
84
143
|
): ParsedArchiveParams {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
const year = yearParam ? parseInt(yearParam, 10) : undefined;
|
|
94
|
-
const validYear = year && !isNaN(year) && year > 1970 ? year : undefined;
|
|
95
|
-
|
|
96
|
-
const collectionSlug = q("collection") || undefined;
|
|
97
|
-
|
|
98
|
-
// Presence filters use single-word params with any/none values
|
|
99
|
-
// (media=any|none|<kinds>, title=any|none, replies=any|none). The legacy
|
|
100
|
-
// hasMedia/hasTitle/hasReplies=1/0 params are still accepted so old
|
|
101
|
-
// bookmarks, feed subscriptions, and stored custom archive URLs keep
|
|
102
|
-
// working; new URLs are always generated in the new style.
|
|
103
|
-
const parsePresence = (
|
|
104
|
-
param: string | undefined,
|
|
105
|
-
legacy: string | undefined,
|
|
106
|
-
): boolean | undefined => {
|
|
107
|
-
if (param === "any") return true;
|
|
108
|
-
if (param === "none") return false;
|
|
109
|
-
if (legacy === "1") return true;
|
|
110
|
-
if (legacy === "0") return false;
|
|
111
|
-
return undefined;
|
|
112
|
-
};
|
|
113
|
-
|
|
114
|
-
const mediaParam = q("media") || undefined;
|
|
115
|
-
const mediaIsPresence = mediaParam === "any" || mediaParam === "none";
|
|
116
|
-
const mediaKinds =
|
|
117
|
-
mediaParam && !mediaIsPresence
|
|
118
|
-
? (mediaParam
|
|
119
|
-
.split(",")
|
|
120
|
-
.filter((m): m is MediaKind =>
|
|
121
|
-
(MEDIA_KINDS as readonly string[]).includes(m),
|
|
122
|
-
) as MediaKind[])
|
|
123
|
-
: undefined;
|
|
124
|
-
const hasMedia = parsePresence(
|
|
125
|
-
mediaIsPresence ? mediaParam : undefined,
|
|
126
|
-
q("hasMedia"),
|
|
127
|
-
);
|
|
128
|
-
|
|
129
|
-
const hasTitle = parsePresence(q("title"), q("hasTitle"));
|
|
130
|
-
const hasReplies = parsePresence(q("replies"), q("hasReplies"));
|
|
131
|
-
|
|
132
|
-
const VALID_VISIBILITIES = ["public", "latest_hidden", "private", "featured"];
|
|
133
|
-
const rawVisibilityParam = q("visibility");
|
|
134
|
-
// "hidden" is the URL spelling of the internal latest_hidden value
|
|
135
|
-
const visibilityParam =
|
|
136
|
-
rawVisibilityParam === "hidden" ? "latest_hidden" : rawVisibilityParam;
|
|
137
|
-
const visibilityAll = visibilityParam === "all";
|
|
138
|
-
const visibility =
|
|
139
|
-
visibilityParam && VALID_VISIBILITIES.includes(visibilityParam)
|
|
140
|
-
? (visibilityParam as ArchiveVisibility)
|
|
141
|
-
: undefined;
|
|
142
|
-
|
|
143
|
-
const viewParam = q("view") as ArchiveView | undefined;
|
|
144
|
-
const view =
|
|
145
|
-
viewParam && (viewParam === "grid" || viewParam === "list")
|
|
146
|
-
? viewParam
|
|
147
|
-
: undefined;
|
|
144
|
+
// `layout` is the current spelling; `view` is the pre-rename one, kept
|
|
145
|
+
// readable indefinitely so old bookmarks and stored custom archive URLs keep
|
|
146
|
+
// working. Custom archive URLs bypass the canonical redirect below, so this
|
|
147
|
+
// parser — not the redirect — is what actually keeps them alive. `layout`
|
|
148
|
+
// wins when a URL somehow carries both.
|
|
149
|
+
const readLayout = (value: string | undefined): ArchiveLayout | undefined =>
|
|
150
|
+
value === "grid" || value === "list" ? value : undefined;
|
|
151
|
+
const layout = readLayout(read("layout")) ?? readLayout(read("view"));
|
|
148
152
|
|
|
149
153
|
// sort selects the time axis for ordering, month grouping, and the year
|
|
150
154
|
// filter alike — they must stay on the same column or the month headers
|
|
151
155
|
// stop agreeing with the order inside them.
|
|
152
|
-
const sort: ArchiveSort =
|
|
156
|
+
const sort: ArchiveSort =
|
|
157
|
+
read("sort") === "updated" ? "updated" : "published";
|
|
158
|
+
|
|
159
|
+
const { selection, issues } = parsePostFilterSelection(read, {
|
|
160
|
+
collections: collections
|
|
161
|
+
? buildCollectionVocabulary(collections)
|
|
162
|
+
: EMPTY_COLLECTION_VOCABULARY,
|
|
163
|
+
yearAxis: sort === "updated" ? "sort" : "published",
|
|
164
|
+
});
|
|
153
165
|
|
|
154
166
|
// Page always comes from the actual request URL (pagination links use ?page=N)
|
|
155
167
|
const pageParam = c.req.query("page");
|
|
156
168
|
const currentPage = Math.max(1, parseInt(pageParam || "1", 10) || 1);
|
|
157
169
|
|
|
158
170
|
return {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
171
|
+
selection,
|
|
172
|
+
// A slug that resolves to nothing must never fall through as "no collection
|
|
173
|
+
// filter": that renders the whole archive under a name the reader chose,
|
|
174
|
+
// with the heading, the chip bar, and the feed title all pretending the word
|
|
175
|
+
// was never typed. The same rule `visibility=private` follows — a selection
|
|
176
|
+
// that cannot be honored is answered, not erased. `/collections/{slug}`
|
|
177
|
+
// already answers this exact question with a 404, and so does every caller
|
|
178
|
+
// here.
|
|
179
|
+
collectionMissing: issues.some((issue) => issue.param === "collection"),
|
|
180
|
+
layout,
|
|
169
181
|
sort,
|
|
170
182
|
currentPage,
|
|
171
183
|
};
|
|
172
184
|
}
|
|
173
185
|
|
|
186
|
+
/** Read the query string and resolve anything in it that needs the database. */
|
|
187
|
+
async function parseArchiveParams(
|
|
188
|
+
c: Context<Env>,
|
|
189
|
+
queryOverrides?: Record<string, string>,
|
|
190
|
+
): Promise<{ params: ParsedArchiveParams; collections: Collection[] | null }> {
|
|
191
|
+
const read = archiveParamReader(c, queryOverrides);
|
|
192
|
+
const collections = await loadArchiveCollections(c, read);
|
|
193
|
+
return { params: readArchiveParams(c, read, collections), collections };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Whether an archive query names a result set only a signed-in reader can see.
|
|
198
|
+
*
|
|
199
|
+
* Answered from the query alone, never from what the database happens to hold.
|
|
200
|
+
* A check that fired only when private posts existed would answer "does this
|
|
201
|
+
* site keep anything private?" to whoever asked.
|
|
202
|
+
*
|
|
203
|
+
* @param params - Parsed query params
|
|
204
|
+
* @returns `true` when rendering this query requires an authenticated reader
|
|
205
|
+
* @example
|
|
206
|
+
* archiveQueryRequiresAuth({ selection: { visibility: "private" }, ... }); // true
|
|
207
|
+
*/
|
|
208
|
+
function archiveQueryRequiresAuth(params: ParsedArchiveParams): boolean {
|
|
209
|
+
const visibility = params.selection.visibility;
|
|
210
|
+
if (visibility === undefined) return false;
|
|
211
|
+
return !(PUBLIC_ARCHIVE_VISIBILITIES as readonly string[]).includes(
|
|
212
|
+
visibility,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Whether this URL is a selection a reader assembled rather than a page the
|
|
218
|
+
* site publishes.
|
|
219
|
+
*
|
|
220
|
+
* Deliberately not `hasActiveArchiveFilter`, which answers a different
|
|
221
|
+
* question: whether to run the baseline count and render `42 of 1,240`. `sort`
|
|
222
|
+
* does not change that count, so folding it in there would buy a wasted query
|
|
223
|
+
* and the label `1,240 of 1,240`. But `sort` does change which posts land on
|
|
224
|
+
* the first page, and together with `year` it changes the set outright — the
|
|
225
|
+
* year filter follows the sort axis. Two questions, two predicates.
|
|
226
|
+
*
|
|
227
|
+
* A stored query is never reader-assembled: the author declared that path, so
|
|
228
|
+
* it is a page like any other. Same `queryOverrides` seam the auth guard uses.
|
|
229
|
+
*
|
|
230
|
+
* @param params - Parsed query params
|
|
231
|
+
* @param queryOverrides - Stored query from the path registry, when there is one
|
|
232
|
+
* @returns `true` when the URL is one facet of a combinatorial family
|
|
233
|
+
* @example
|
|
234
|
+
* isReaderAssembledArchive({ selection: { format: "note" }, ... }, undefined); // true
|
|
235
|
+
*/
|
|
236
|
+
function isReaderAssembledArchive(
|
|
237
|
+
params: ParsedArchiveParams,
|
|
238
|
+
queryOverrides: Record<string, string> | undefined,
|
|
239
|
+
): boolean {
|
|
240
|
+
if (queryOverrides) return false;
|
|
241
|
+
return hasFilterSelection(params.selection) || params.sort === "updated";
|
|
242
|
+
}
|
|
243
|
+
|
|
174
244
|
/**
|
|
175
245
|
* Build PostFilters from parsed archive params.
|
|
176
246
|
*
|
|
@@ -182,50 +252,31 @@ function buildArchivePostFilters(
|
|
|
182
252
|
params: ParsedArchiveParams,
|
|
183
253
|
opts: {
|
|
184
254
|
isAuthenticated: boolean;
|
|
185
|
-
|
|
255
|
+
lang?: string;
|
|
256
|
+
/** Overrides the request's own selection, for the unfiltered baseline. */
|
|
257
|
+
selection?: PostFilterSelection;
|
|
186
258
|
},
|
|
187
259
|
): PostFilters {
|
|
188
|
-
const { isAuthenticated,
|
|
189
|
-
|
|
190
|
-
// Map visibility: feed routes force public; page respects auth
|
|
191
|
-
// Authenticated users default to showing all visibilities
|
|
192
|
-
const effectiveVisibility = isAuthenticated
|
|
193
|
-
? params.visibilityAll
|
|
194
|
-
? undefined
|
|
195
|
-
: (params.visibility ?? undefined)
|
|
196
|
-
: undefined;
|
|
197
|
-
|
|
198
|
-
// The year filter follows the active axis, so every month bucket shown
|
|
199
|
-
// under `year=N` really belongs to that year.
|
|
200
|
-
const yearRange = params.validYear
|
|
201
|
-
? {
|
|
202
|
-
after: Date.UTC(params.validYear, 0, 1) / 1000,
|
|
203
|
-
before: Date.UTC(params.validYear + 1, 0, 1) / 1000,
|
|
204
|
-
}
|
|
205
|
-
: undefined;
|
|
260
|
+
const { isAuthenticated, lang } = opts;
|
|
261
|
+
const selection = opts.selection ?? params.selection;
|
|
206
262
|
const sortsByActivity = params.sort === "updated";
|
|
207
263
|
|
|
264
|
+
// The visibility clause is applied as selected, with no auth-dependent
|
|
265
|
+
// degrade: a query naming `private` has already been redirected or 404'd by
|
|
266
|
+
// the time it reaches here (see `archiveQueryRequiresAuth`). Silently
|
|
267
|
+
// dropping the clause instead would render a different set under the same
|
|
268
|
+
// name. `excludePrivate` below stays the unconditional floor regardless.
|
|
208
269
|
return {
|
|
209
|
-
|
|
270
|
+
lang,
|
|
210
271
|
status: "published",
|
|
211
272
|
excludeReplies: true,
|
|
212
273
|
excludePrivate: !isAuthenticated,
|
|
213
274
|
excludeLatestHidden: false,
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
collectionId,
|
|
220
|
-
...(yearRange
|
|
221
|
-
? sortsByActivity
|
|
222
|
-
? { axisAfter: yearRange.after, axisBefore: yearRange.before }
|
|
223
|
-
: { publishedAfter: yearRange.after, publishedBefore: yearRange.before }
|
|
224
|
-
: {}),
|
|
225
|
-
mediaKinds: params.mediaKinds,
|
|
226
|
-
hasMedia: params.hasMedia,
|
|
227
|
-
hasTitle: params.hasTitle,
|
|
228
|
-
hasReplies: params.hasReplies,
|
|
275
|
+
// The year filter follows the active axis, so every month bucket shown
|
|
276
|
+
// under `year=N` really belongs to that year.
|
|
277
|
+
...toPostFilters(selection, {
|
|
278
|
+
yearAxis: sortsByActivity ? "sort" : "published",
|
|
279
|
+
}),
|
|
229
280
|
// "thread_updated", not "activity": the archive is the canonical all-posts
|
|
230
281
|
// view, and the quiet-reply switch only promises not to move a Thread on
|
|
231
282
|
// Latest. Here the honest answer is when the Thread last changed.
|
|
@@ -236,26 +287,15 @@ function buildArchivePostFilters(
|
|
|
236
287
|
|
|
237
288
|
/**
|
|
238
289
|
* Build a query string from parsed archive params (for feed self-URL and
|
|
239
|
-
* archive page feed link). Omits
|
|
290
|
+
* archive page feed link). Omits layout and page — those shape the rendered
|
|
240
291
|
* page, not the result set — but carries `sort`, so the feed button on an
|
|
241
292
|
* updated-sorted page hands back the matching feed.
|
|
242
293
|
*/
|
|
243
|
-
function buildArchiveFeedQuery(
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
if (params.mediaKinds && params.mediaKinds.length > 0) {
|
|
249
|
-
qs.set("media", params.mediaKinds.join(","));
|
|
250
|
-
} else if (params.hasMedia !== undefined) {
|
|
251
|
-
qs.set("media", params.hasMedia ? "any" : "none");
|
|
252
|
-
}
|
|
253
|
-
if (params.hasTitle !== undefined) {
|
|
254
|
-
qs.set("title", params.hasTitle ? "any" : "none");
|
|
255
|
-
}
|
|
256
|
-
if (params.hasReplies !== undefined) {
|
|
257
|
-
qs.set("replies", params.hasReplies ? "any" : "none");
|
|
258
|
-
}
|
|
294
|
+
function buildArchiveFeedQuery(
|
|
295
|
+
params: ParsedArchiveParams,
|
|
296
|
+
ctx: DimensionContext,
|
|
297
|
+
): string {
|
|
298
|
+
const qs = serializePostFilterSelection(params.selection, ctx);
|
|
259
299
|
if (params.sort === "updated") qs.set("sort", "updated");
|
|
260
300
|
const str = qs.toString();
|
|
261
301
|
return str ? `?${str}` : "";
|
|
@@ -264,19 +304,30 @@ function buildArchiveFeedQuery(params: ParsedArchiveParams): string {
|
|
|
264
304
|
export const archiveRoutes = new Hono<Env>();
|
|
265
305
|
|
|
266
306
|
/**
|
|
267
|
-
* Build a canonical redirect
|
|
268
|
-
*
|
|
307
|
+
* Build a canonical redirect for the archive page when the URL needs one.
|
|
308
|
+
*
|
|
309
|
+
* Two kinds of rewrite happen here, and they do not deserve the same status:
|
|
310
|
+
*
|
|
311
|
+
* - **Legacy spellings** (`hasMedia`/`hasTitle`/`hasReplies=1/0`,
|
|
312
|
+
* `visibility=latest_hidden`, `visibility=all`, `view=`) are unconditional —
|
|
313
|
+
* the old spelling is gone for everyone, so 308.
|
|
314
|
+
* - **`visibility=private` for a signed-out reader** is not. That URL moved
|
|
315
|
+
* for *this* reader, *now*, and has to keep working once the author signs
|
|
316
|
+
* in, so it is a 302. When both apply, the weaker claim wins and everything
|
|
317
|
+
* is still resolved in a single hop.
|
|
269
318
|
*
|
|
270
|
-
* Only
|
|
271
|
-
*
|
|
272
|
-
* Applies to the /archive page only — feeds and the public API accept
|
|
273
|
-
*
|
|
274
|
-
*
|
|
319
|
+
* Only these params are touched; everything else, including unknown params, is
|
|
320
|
+
* preserved. Returns null when the URL is already canonical for this reader.
|
|
321
|
+
* Applies to the /archive page only — feeds and the public API accept legacy
|
|
322
|
+
* spellings silently, and custom archive URLs (path_registry query overrides)
|
|
323
|
+
* never reach this path.
|
|
275
324
|
*
|
|
276
325
|
* @param c - Hono context
|
|
277
|
-
* @returns
|
|
326
|
+
* @returns Redirect target and status code, or null
|
|
278
327
|
*/
|
|
279
|
-
function
|
|
328
|
+
function archiveParamsRedirect(
|
|
329
|
+
c: Context<Env>,
|
|
330
|
+
): { location: string; status: 302 | 308 } | null {
|
|
280
331
|
const url = new URL(c.req.url);
|
|
281
332
|
const params = url.searchParams;
|
|
282
333
|
let changed = false;
|
|
@@ -301,9 +352,48 @@ function legacyArchiveParamsRedirect(c: Context<Env>): string | null {
|
|
|
301
352
|
changed = true;
|
|
302
353
|
}
|
|
303
354
|
|
|
355
|
+
// `visibility=all` was a second spelling of "no visibility selected" — it
|
|
356
|
+
// filtered nothing, exactly like an absent parameter, and only the parser
|
|
357
|
+
// knew. Dropped rather than kept alive, on the same reasoning as `view=`.
|
|
358
|
+
if (params.get("visibility") === "all") {
|
|
359
|
+
params.delete("visibility");
|
|
360
|
+
changed = true;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// A signed-out reader cannot select `private`, and the one answer that is
|
|
364
|
+
// never right is to drop the clause and render a different set under the
|
|
365
|
+
// same URL. Strip it here so the address bar and the page agree on what the
|
|
366
|
+
// reader is looking at. The decision reads the param and the session only,
|
|
367
|
+
// so it is identical whether or not this site holds a single private post.
|
|
368
|
+
let authStripped = false;
|
|
369
|
+
if (params.get("visibility") === "private" && !c.var.isAuthenticated) {
|
|
370
|
+
params.delete("visibility");
|
|
371
|
+
authStripped = true;
|
|
372
|
+
changed = true;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// `view` became `layout` so `view` can name the saved-selection concept.
|
|
376
|
+
// Only a value this page actually renders is carried across; anything else
|
|
377
|
+
// is dropped, which lands the reader on the site default rather than on a
|
|
378
|
+
// URL that keeps a meaningless param alive.
|
|
379
|
+
const legacyLayout = params.get("view");
|
|
380
|
+
if (legacyLayout !== null) {
|
|
381
|
+
if (
|
|
382
|
+
!params.has("layout") &&
|
|
383
|
+
(legacyLayout === "grid" || legacyLayout === "list")
|
|
384
|
+
) {
|
|
385
|
+
params.set("layout", legacyLayout);
|
|
386
|
+
}
|
|
387
|
+
params.delete("view");
|
|
388
|
+
changed = true;
|
|
389
|
+
}
|
|
390
|
+
|
|
304
391
|
if (!changed) return null;
|
|
305
392
|
const qs = params.toString();
|
|
306
|
-
return
|
|
393
|
+
return {
|
|
394
|
+
location: `${url.pathname}${qs ? `?${qs}` : ""}`,
|
|
395
|
+
status: authStripped ? 302 : 308,
|
|
396
|
+
};
|
|
307
397
|
}
|
|
308
398
|
|
|
309
399
|
// =============================================================================
|
|
@@ -323,56 +413,101 @@ export async function renderArchivePage(
|
|
|
323
413
|
): Promise<Response> {
|
|
324
414
|
const { services, appConfig } = c.var;
|
|
325
415
|
const pageSize = appConfig.archivePageSize;
|
|
326
|
-
const params
|
|
327
|
-
|
|
328
|
-
|
|
416
|
+
const { params, collections: preloadedCollections } =
|
|
417
|
+
await parseArchiveParams(c, queryOverrides);
|
|
418
|
+
|
|
419
|
+
// An owner-stored query is the path's own definition, so there is nothing to
|
|
420
|
+
// redirect to the way the /archive route redirects a reader's own params:
|
|
421
|
+
// the path names a set this reader cannot see, and the honest answer is that
|
|
422
|
+
// it does not exist for them. The presence of `queryOverrides` is exactly
|
|
423
|
+
// what separates the two cases, so every future caller passing a stored
|
|
424
|
+
// query inherits this guard.
|
|
425
|
+
if (
|
|
426
|
+
queryOverrides &&
|
|
427
|
+
archiveQueryRequiresAuth(params) &&
|
|
428
|
+
!c.var.isAuthenticated
|
|
429
|
+
) {
|
|
430
|
+
return c.notFound();
|
|
431
|
+
}
|
|
329
432
|
|
|
330
|
-
|
|
331
|
-
? await services.collections.getBySlug(params.collectionSlug)
|
|
332
|
-
: undefined;
|
|
333
|
-
const collectionId = collection?.id;
|
|
433
|
+
if (params.collectionMissing) return c.notFound();
|
|
334
434
|
|
|
335
|
-
|
|
435
|
+
// The chrome is independent of every query below, so it goes out with them
|
|
436
|
+
// rather than ahead of them. Its `isAuthenticated` is `c.var.isAuthenticated`
|
|
437
|
+
// verbatim, which is what the filters below read.
|
|
438
|
+
const navDataPromise = getNavigationData(c);
|
|
439
|
+
const isAuthenticated = c.var.isAuthenticated;
|
|
336
440
|
|
|
337
441
|
const filters = buildArchivePostFilters(params, {
|
|
338
|
-
isAuthenticated
|
|
339
|
-
|
|
442
|
+
isAuthenticated,
|
|
443
|
+
lang: getViewLang(c) ?? undefined,
|
|
340
444
|
});
|
|
341
445
|
|
|
342
446
|
// --- Parallel data fetches ------------------------------------------------
|
|
343
447
|
// List view doesn't need month-based grouping, so skip countByYearMonth.
|
|
344
448
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
})
|
|
363
|
-
|
|
364
|
-
|
|
449
|
+
// No layout in the URL means the site's configured default, not grid: the
|
|
450
|
+
// archive is the widest of Featured / Latest / All and the other two are
|
|
451
|
+
// timelines, so `list` is the default default.
|
|
452
|
+
const layout: ArchiveLayout = params.layout ?? appConfig.archiveDefaultLayout;
|
|
453
|
+
const isListView = layout === "list";
|
|
454
|
+
|
|
455
|
+
// The same view with every selection cleared, so the count can say how much
|
|
456
|
+
// the filter removed. Skipped entirely when nothing is filtered — the two
|
|
457
|
+
// numbers would be equal — which keeps the plain /archive page at its
|
|
458
|
+
// current query count. When it does run it is the cheapest of the three
|
|
459
|
+
// aggregates, since clearing the selections is what drops the collection and
|
|
460
|
+
// media EXISTS subqueries.
|
|
461
|
+
const baselineFilters = hasActiveArchiveFilter(params.selection)
|
|
462
|
+
? buildArchivePostFilters(params, {
|
|
463
|
+
isAuthenticated,
|
|
464
|
+
lang: filters.lang,
|
|
465
|
+
selection: {},
|
|
466
|
+
})
|
|
467
|
+
: undefined;
|
|
468
|
+
|
|
469
|
+
const [
|
|
470
|
+
navData,
|
|
471
|
+
totalCount,
|
|
472
|
+
baselineCount,
|
|
473
|
+
monthlyCounts,
|
|
474
|
+
posts,
|
|
475
|
+
availableYears,
|
|
476
|
+
listedCollections,
|
|
477
|
+
] = await Promise.all([
|
|
478
|
+
navDataPromise,
|
|
479
|
+
services.posts.count(filters),
|
|
480
|
+
baselineFilters
|
|
481
|
+
? services.posts.count(baselineFilters)
|
|
482
|
+
: Promise.resolve(undefined),
|
|
483
|
+
isListView
|
|
484
|
+
? Promise.resolve([] as { yearMonth: string; count: number }[])
|
|
485
|
+
: services.posts.countByYearMonth(filters),
|
|
486
|
+
services.posts.list({
|
|
487
|
+
...filters,
|
|
488
|
+
limit: pageSize,
|
|
489
|
+
offset: (params.currentPage - 1) * pageSize,
|
|
490
|
+
}),
|
|
491
|
+
services.posts.getDistinctYears({
|
|
492
|
+
status: "published",
|
|
493
|
+
excludeReplies: true,
|
|
494
|
+
lang: filters.lang,
|
|
495
|
+
sortBy: filters.sortBy,
|
|
496
|
+
}),
|
|
497
|
+
// Already loaded when the query named a collection, because parsing needed
|
|
498
|
+
// it. Fetched here otherwise, so an unfiltered archive keeps paying for one
|
|
499
|
+
// round of queries rather than two.
|
|
500
|
+
preloadedCollections ?? services.collections.list(),
|
|
501
|
+
]);
|
|
502
|
+
|
|
503
|
+
const allCollections = preloadedCollections ?? listedCollections;
|
|
504
|
+
const dimensionCtx: DimensionContext = {
|
|
505
|
+
collections: buildCollectionVocabulary(allCollections),
|
|
506
|
+
};
|
|
365
507
|
|
|
366
508
|
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
|
367
509
|
|
|
368
510
|
const mediaCtx = createMediaContext(appConfig);
|
|
369
|
-
const allPostIds = posts.map((p) => p.id);
|
|
370
|
-
const archiveAliasesMap =
|
|
371
|
-
await c.var.services.paths.getPostAliases(allPostIds);
|
|
372
|
-
const archiveAliasMap = new Map<string, string>();
|
|
373
|
-
for (const [id, aliases] of archiveAliasesMap) {
|
|
374
|
-
if (aliases[0]) archiveAliasMap.set(id, aliases[0]);
|
|
375
|
-
}
|
|
376
511
|
|
|
377
512
|
// --- List view: flat timeline items (no month grouping) ------------------
|
|
378
513
|
|
|
@@ -407,10 +542,17 @@ export async function renderArchivePage(
|
|
|
407
542
|
);
|
|
408
543
|
|
|
409
544
|
const postIds = posts.map((p) => p.id);
|
|
410
|
-
|
|
545
|
+
// Only the grid renders permalinks from this map. The list view goes
|
|
546
|
+
// through `assembleTimelineItems`, which loads the same aliases itself.
|
|
547
|
+
const [rawMediaMap, replyCounts, archiveAliasesMap] = await Promise.all([
|
|
411
548
|
services.media.getByPostIds(postIds),
|
|
412
549
|
services.posts.getReplyCounts(postIds),
|
|
550
|
+
services.paths.getPostAliases(postIds),
|
|
413
551
|
]);
|
|
552
|
+
const archiveAliasMap = new Map<string, string>();
|
|
553
|
+
for (const [id, aliases] of archiveAliasesMap) {
|
|
554
|
+
if (aliases[0]) archiveAliasMap.set(id, aliases[0]);
|
|
555
|
+
}
|
|
414
556
|
const mediaMap = buildMediaMap(
|
|
415
557
|
rawMediaMap,
|
|
416
558
|
mediaCtx.r2PublicUrl,
|
|
@@ -446,57 +588,89 @@ export async function renderArchivePage(
|
|
|
446
588
|
|
|
447
589
|
// --- Build active filter state for UI -------------------------------------
|
|
448
590
|
|
|
449
|
-
const effectiveVisibility = navData.isAuthenticated
|
|
450
|
-
? params.visibilityAll
|
|
451
|
-
? undefined
|
|
452
|
-
: (params.visibility ?? undefined)
|
|
453
|
-
: undefined;
|
|
454
|
-
|
|
455
591
|
const archiveFilters: ArchiveFilters = {
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
collectionTitle: collection?.title,
|
|
459
|
-
format: params.format,
|
|
460
|
-
mediaKinds: params.mediaKinds,
|
|
461
|
-
hasMedia: params.hasMedia,
|
|
462
|
-
hasTitle: params.hasTitle,
|
|
463
|
-
hasReplies: params.hasReplies,
|
|
464
|
-
visibility: effectiveVisibility,
|
|
465
|
-
view: params.view,
|
|
592
|
+
selection: params.selection,
|
|
593
|
+
layout: params.layout,
|
|
466
594
|
sort: params.sort === "updated" ? "updated" : undefined,
|
|
467
595
|
};
|
|
468
596
|
|
|
469
|
-
const feedQuery = buildArchiveFeedQuery(params);
|
|
597
|
+
const feedQuery = buildArchiveFeedQuery(params, dimensionCtx);
|
|
598
|
+
// A feed is unauthenticated by construction, so it cannot serve a selection
|
|
599
|
+
// only the author can see. Offering the link anyway would promise a feed
|
|
600
|
+
// that answers 404.
|
|
601
|
+
const feedHref =
|
|
602
|
+
appConfig.rssFeedsEnabled && !archiveQueryRequiresAuth(params)
|
|
603
|
+
? `/archive/feed${feedQuery}`
|
|
604
|
+
: undefined;
|
|
470
605
|
|
|
471
606
|
const availableCollectionsList = allCollections.map((col) => ({
|
|
607
|
+
id: col.id,
|
|
472
608
|
slug: col.slug,
|
|
473
609
|
title: col.title,
|
|
474
610
|
}));
|
|
475
611
|
|
|
612
|
+
// A facet is one URL out of a family with no ceiling: `media` alone is a
|
|
613
|
+
// comma-joined subset of kinds, multiplied by year x collection x format x
|
|
614
|
+
// title x replies x visibility x sort. None of them is a page the site
|
|
615
|
+
// publishes, and the archive sits in no cache, so every one a crawler walks
|
|
616
|
+
// is a Worker invocation and seven D1 queries. `follow`, not `nofollow`:
|
|
617
|
+
// the posts these URLs link to are the real pages.
|
|
618
|
+
const readerAssembled = isReaderAssembledArchive(params, queryOverrides);
|
|
619
|
+
if (readerAssembled) c.header("X-Robots-Tag", "noindex, follow");
|
|
620
|
+
|
|
621
|
+
// Everything left over renders the same posts in the same order as the bare
|
|
622
|
+
// path — `layout` is markup only, `visibility=all` selects nothing, and
|
|
623
|
+
// tracking params select nothing either — so they all consolidate onto it.
|
|
624
|
+
// `page` is the exception and stays: page 2 is different content, not a
|
|
625
|
+
// different rendering of page 1.
|
|
626
|
+
const canonicalQuery =
|
|
627
|
+
params.currentPage > 1 ? `?page=${params.currentPage}` : "";
|
|
628
|
+
const canonicalHref = readerAssembled
|
|
629
|
+
? undefined
|
|
630
|
+
: `${toAbsoluteSiteUrl(c.req.path, appConfig.siteUrl, appConfig.sitePathPrefix)}${canonicalQuery}`;
|
|
631
|
+
|
|
476
632
|
return renderPublicPage(c, {
|
|
477
|
-
//
|
|
633
|
+
// A tab has no filter bar beside it, so unlike the page heading this has to
|
|
634
|
+
// name the selection itself — otherwise every bookmarked filtered view
|
|
635
|
+
// reads identically in the tab bar.
|
|
478
636
|
title: buildPageTitle(
|
|
479
|
-
|
|
637
|
+
getArchiveViewTitle(params.selection, getI18n(c), dimensionCtx),
|
|
480
638
|
params.sort === "updated" ? "Recently updated" : undefined,
|
|
481
639
|
navData.siteName,
|
|
482
640
|
),
|
|
641
|
+
// An hreflang set is made of canonical URLs, so it mirrors the canonical
|
|
642
|
+
// exactly: the same trimmed query where there is one, and nothing at all
|
|
643
|
+
// on a facet, which has no canonical for the set to be built from.
|
|
644
|
+
alternateLanguages: readerAssembled
|
|
645
|
+
? undefined
|
|
646
|
+
: buildSurfaceAlternates(c, { query: canonicalQuery }),
|
|
647
|
+
noindex: readerAssembled ? "follow" : undefined,
|
|
648
|
+
canonicalHref,
|
|
649
|
+
// The filters are part of the feed: a reader subscribing from a filtered
|
|
650
|
+
// archive gets that filtered feed, not the whole archive.
|
|
651
|
+
pageFeed: feedHref
|
|
652
|
+
? {
|
|
653
|
+
href: toViewPath(c, feedHref),
|
|
654
|
+
title: buildArchiveFeedLabel(c, params, dimensionCtx),
|
|
655
|
+
}
|
|
656
|
+
: undefined,
|
|
483
657
|
navData,
|
|
484
658
|
content: (
|
|
485
659
|
<ArchivePage
|
|
486
660
|
groups={groups}
|
|
487
661
|
items={flatItems}
|
|
662
|
+
defaultLayout={appConfig.archiveDefaultLayout}
|
|
488
663
|
totalCount={totalCount}
|
|
664
|
+
baselineCount={baselineCount}
|
|
489
665
|
currentPage={params.currentPage}
|
|
490
666
|
totalPages={totalPages}
|
|
491
667
|
filters={archiveFilters}
|
|
492
668
|
availableYears={availableYears}
|
|
493
669
|
availableCollections={availableCollectionsList}
|
|
494
670
|
isAuthenticated={navData.isAuthenticated}
|
|
495
|
-
|
|
671
|
+
basePath={navData.basePath}
|
|
496
672
|
timeZone={appConfig.timeZone}
|
|
497
|
-
feedHref={
|
|
498
|
-
appConfig.rssFeedsEnabled ? `/archive/feed${feedQuery}` : undefined
|
|
499
|
-
}
|
|
673
|
+
feedHref={feedHref}
|
|
500
674
|
/>
|
|
501
675
|
),
|
|
502
676
|
});
|
|
@@ -506,130 +680,51 @@ export async function renderArchivePage(
|
|
|
506
680
|
// Archive page route
|
|
507
681
|
// =============================================================================
|
|
508
682
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
683
|
+
/**
|
|
684
|
+
* Serve the archive page, first normalizing any legacy query parameters.
|
|
685
|
+
*
|
|
686
|
+
* @param c - Hono context
|
|
687
|
+
* @returns Archive page response, or a 308 to the canonical parameter spelling
|
|
688
|
+
*/
|
|
689
|
+
export function renderArchiveRoute(
|
|
690
|
+
c: Context<Env>,
|
|
691
|
+
): Promise<Response> | Response {
|
|
692
|
+
const redirect = archiveParamsRedirect(c);
|
|
693
|
+
if (redirect) return c.redirect(redirect.location, redirect.status);
|
|
512
694
|
return renderArchivePage(c);
|
|
513
|
-
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
archiveRoutes.get("/", renderArchiveRoute);
|
|
514
698
|
|
|
515
699
|
// =============================================================================
|
|
516
700
|
// Archive feed
|
|
517
701
|
// =============================================================================
|
|
518
702
|
|
|
519
703
|
/**
|
|
520
|
-
* Build a descriptive feed
|
|
704
|
+
* Build a descriptive label for the archive feed from the active filters.
|
|
705
|
+
*
|
|
706
|
+
* Shared by the feed document's own title and the autodiscovery link on the
|
|
707
|
+
* archive page, so a subscriber sees the same name in both places.
|
|
708
|
+
*
|
|
709
|
+
* Unlike the page title this lists every active dimension. A feed name sits in
|
|
710
|
+
* someone's reader for years with no UI to inspect it, so two subscriptions
|
|
711
|
+
* that differ only in a dropped dimension must not arrive under one name.
|
|
521
712
|
*
|
|
522
713
|
* @param c - Hono context
|
|
523
714
|
* @param params - Parsed archive filter params
|
|
524
|
-
* @param
|
|
525
|
-
* @returns Feed
|
|
715
|
+
* @param ctx - Collection vocabulary, for naming a selected collection
|
|
716
|
+
* @returns Feed label, e.g. "Archive: Untitled, 2024"
|
|
717
|
+
* @example
|
|
718
|
+
* buildArchiveFeedLabel(c, params); // "Archive"
|
|
526
719
|
*/
|
|
527
|
-
function
|
|
720
|
+
function buildArchiveFeedLabel(
|
|
528
721
|
c: Context<Env>,
|
|
529
722
|
params: ParsedArchiveParams,
|
|
530
|
-
|
|
723
|
+
ctx: DimensionContext,
|
|
531
724
|
): string {
|
|
532
725
|
const i18n = getI18n(c);
|
|
533
|
-
const siteName = c.var.appConfig.siteName;
|
|
534
|
-
|
|
535
|
-
const parts: string[] = [];
|
|
536
|
-
|
|
537
|
-
if (params.format) {
|
|
538
|
-
const formatLabels: Record<string, string> = {
|
|
539
|
-
note: i18n._(
|
|
540
|
-
msg({
|
|
541
|
-
message: "Notes",
|
|
542
|
-
comment:
|
|
543
|
-
"@context: Archive feed title segment for note format filter",
|
|
544
|
-
}),
|
|
545
|
-
),
|
|
546
|
-
link: i18n._(
|
|
547
|
-
msg({
|
|
548
|
-
message: "Links",
|
|
549
|
-
comment:
|
|
550
|
-
"@context: Archive feed title segment for link format filter",
|
|
551
|
-
}),
|
|
552
|
-
),
|
|
553
|
-
quote: i18n._(
|
|
554
|
-
msg({
|
|
555
|
-
message: "Quotes",
|
|
556
|
-
comment:
|
|
557
|
-
"@context: Archive feed title segment for quote format filter",
|
|
558
|
-
}),
|
|
559
|
-
),
|
|
560
|
-
};
|
|
561
|
-
parts.push(formatLabels[params.format] ?? params.format);
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
if (collectionTitle) {
|
|
565
|
-
parts.push(collectionTitle);
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
if (params.hasTitle === false) {
|
|
569
|
-
parts.push(
|
|
570
|
-
i18n._(
|
|
571
|
-
msg({
|
|
572
|
-
message: "without title",
|
|
573
|
-
comment: "@context: Archive feed title segment for hasTitle=0 filter",
|
|
574
|
-
}),
|
|
575
|
-
),
|
|
576
|
-
);
|
|
577
|
-
} else if (params.hasTitle === true) {
|
|
578
|
-
parts.push(
|
|
579
|
-
i18n._(
|
|
580
|
-
msg({
|
|
581
|
-
message: "with title",
|
|
582
|
-
comment: "@context: Archive feed title segment for hasTitle=1 filter",
|
|
583
|
-
}),
|
|
584
|
-
),
|
|
585
|
-
);
|
|
586
|
-
}
|
|
587
726
|
|
|
588
|
-
|
|
589
|
-
parts.push(
|
|
590
|
-
i18n._(
|
|
591
|
-
msg({
|
|
592
|
-
message: "with media",
|
|
593
|
-
comment: "@context: Archive feed title segment for hasMedia=1 filter",
|
|
594
|
-
}),
|
|
595
|
-
),
|
|
596
|
-
);
|
|
597
|
-
} else if (params.hasMedia === false) {
|
|
598
|
-
parts.push(
|
|
599
|
-
i18n._(
|
|
600
|
-
msg({
|
|
601
|
-
message: "without media",
|
|
602
|
-
comment: "@context: Archive feed title segment for hasMedia=0 filter",
|
|
603
|
-
}),
|
|
604
|
-
),
|
|
605
|
-
);
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
if (params.hasReplies === true) {
|
|
609
|
-
parts.push(
|
|
610
|
-
i18n._(
|
|
611
|
-
msg({
|
|
612
|
-
message: "threads",
|
|
613
|
-
comment:
|
|
614
|
-
"@context: Archive feed title segment for hasReplies=1 filter",
|
|
615
|
-
}),
|
|
616
|
-
),
|
|
617
|
-
);
|
|
618
|
-
} else if (params.hasReplies === false) {
|
|
619
|
-
parts.push(
|
|
620
|
-
i18n._(
|
|
621
|
-
msg({
|
|
622
|
-
message: "single posts",
|
|
623
|
-
comment:
|
|
624
|
-
"@context: Archive feed title segment for hasReplies=0 filter",
|
|
625
|
-
}),
|
|
626
|
-
),
|
|
627
|
-
);
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
if (params.validYear) {
|
|
631
|
-
parts.push(String(params.validYear));
|
|
632
|
-
}
|
|
727
|
+
const parts = describeArchiveFilters(params.selection, i18n, ctx);
|
|
633
728
|
|
|
634
729
|
const archiveLabel = i18n._(
|
|
635
730
|
msg({
|
|
@@ -639,71 +734,67 @@ function buildArchiveFeedTitle(
|
|
|
639
734
|
);
|
|
640
735
|
|
|
641
736
|
if (parts.length === 0) {
|
|
642
|
-
return
|
|
737
|
+
return archiveLabel;
|
|
643
738
|
}
|
|
644
739
|
|
|
645
|
-
return `${
|
|
740
|
+
return `${archiveLabel}: ${parts.join(", ")}`;
|
|
646
741
|
}
|
|
647
742
|
|
|
648
743
|
async function buildArchiveFeedData(
|
|
649
744
|
c: Context<Env>,
|
|
650
745
|
selfPath: string,
|
|
651
|
-
): Promise<FeedData> {
|
|
746
|
+
): Promise<FeedData | null> {
|
|
652
747
|
const { appConfig, services } = c.var;
|
|
653
|
-
const params = parseArchiveParams(c);
|
|
748
|
+
const { params, collections } = await parseArchiveParams(c);
|
|
654
749
|
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
750
|
+
// A collection slug that names nothing: the caller turns this into the same
|
|
751
|
+
// 404 the page gives, rather than handing a subscriber the entire archive
|
|
752
|
+
// under the collection's name.
|
|
753
|
+
if (params.collectionMissing) return null;
|
|
754
|
+
|
|
755
|
+
const dimensionCtx: DimensionContext = {
|
|
756
|
+
collections: collections
|
|
757
|
+
? buildCollectionVocabulary(collections)
|
|
758
|
+
: EMPTY_COLLECTION_VOCABULARY,
|
|
759
|
+
};
|
|
658
760
|
const rssPublishedBefore = getRssPublishedBefore(
|
|
659
761
|
appConfig.rssPublishDelaySeconds,
|
|
660
762
|
);
|
|
661
|
-
const yearPublishedBefore = params.validYear
|
|
662
|
-
? Date.UTC(params.validYear + 1, 0, 1) / 1000
|
|
663
|
-
: undefined;
|
|
664
763
|
|
|
665
764
|
// Feed mirrors the unauthenticated archive page: published + non-private,
|
|
666
765
|
// including Hidden-from-Latest. /archive is the canonical "all posts" view,
|
|
667
766
|
// so its feed must match.
|
|
668
767
|
//
|
|
669
768
|
// Ordered by publication by default, like the page it belongs to. Ordering
|
|
670
|
-
// by activity would make the feed's contents shift under a fixed
|
|
671
|
-
//
|
|
672
|
-
//
|
|
769
|
+
// by activity would make the feed's contents shift under a fixed length — a
|
|
770
|
+
// new reply pulls an old Thread back into the window and pushes something
|
|
771
|
+
// else out — and it buys nothing in return, because readers
|
|
673
772
|
// key entries by id and will not re-surface one that merely moved.
|
|
674
773
|
// /latest/feed is the activity feed; this one is the chronological record.
|
|
675
774
|
//
|
|
676
775
|
// `?sort=updated` opts into the same axis the page uses, for a subscriber
|
|
677
776
|
// who wants that trade-off deliberately.
|
|
678
777
|
const sortsByActivity = params.sort === "updated";
|
|
778
|
+
// The year filter follows the active axis; the RSS delay always stays on
|
|
779
|
+
// publication, so a just-published post is never announced early. When both
|
|
780
|
+
// bound the publication column, the tighter one wins.
|
|
781
|
+
const selectionFilters = toPostFilters(params.selection, {
|
|
782
|
+
yearAxis: sortsByActivity ? "sort" : "published",
|
|
783
|
+
});
|
|
679
784
|
const filters: PostFilters = {
|
|
680
|
-
|
|
785
|
+
lang: getViewLang(c) ?? undefined,
|
|
681
786
|
status: "published",
|
|
682
787
|
excludeReplies: true,
|
|
683
788
|
excludePrivate: true,
|
|
684
789
|
excludeLatestHidden: false,
|
|
685
|
-
|
|
686
|
-
mediaKinds: params.mediaKinds,
|
|
687
|
-
hasMedia: params.hasMedia,
|
|
688
|
-
hasTitle: params.hasTitle,
|
|
689
|
-
hasReplies: params.hasReplies,
|
|
790
|
+
...selectionFilters,
|
|
690
791
|
sortBy: sortsByActivity ? "thread_updated" : "published",
|
|
691
792
|
ignorePinnedSort: true,
|
|
692
|
-
// The year filter follows the active axis; the RSS delay always stays on
|
|
693
|
-
// publication, so a just-published post is never announced early.
|
|
694
|
-
...(params.validYear
|
|
695
|
-
? sortsByActivity
|
|
696
|
-
? {
|
|
697
|
-
axisAfter: Date.UTC(params.validYear, 0, 1) / 1000,
|
|
698
|
-
axisBefore: yearPublishedBefore,
|
|
699
|
-
}
|
|
700
|
-
: { publishedAfter: Date.UTC(params.validYear, 0, 1) / 1000 }
|
|
701
|
-
: {}),
|
|
702
793
|
publishedBefore:
|
|
703
|
-
|
|
794
|
+
selectionFilters.publishedBefore === undefined
|
|
704
795
|
? rssPublishedBefore
|
|
705
|
-
: Math.min(
|
|
706
|
-
limit:
|
|
796
|
+
: Math.min(selectionFilters.publishedBefore, rssPublishedBefore),
|
|
797
|
+
limit: getFeedLimit(c),
|
|
707
798
|
};
|
|
708
799
|
|
|
709
800
|
const posts = await services.posts.list(filters);
|
|
@@ -793,16 +884,20 @@ async function buildArchiveFeedData(
|
|
|
793
884
|
};
|
|
794
885
|
});
|
|
795
886
|
|
|
796
|
-
const feedQuery = buildArchiveFeedQuery(params);
|
|
887
|
+
const feedQuery = buildArchiveFeedQuery(params, dimensionCtx);
|
|
797
888
|
|
|
798
889
|
return {
|
|
890
|
+
...buildFeedDiscoveryFields(c, { query: feedQuery }),
|
|
799
891
|
siteName: appConfig.siteName,
|
|
800
892
|
siteDescription: toPlainText(appConfig.siteDescription),
|
|
801
893
|
siteUrl: appConfig.siteUrl,
|
|
802
|
-
siteLanguage: appConfig.siteLanguage,
|
|
803
|
-
title:
|
|
894
|
+
siteLanguage: getViewLang(c) ?? appConfig.siteLanguage,
|
|
895
|
+
title: buildPageTitle(
|
|
896
|
+
appConfig.siteName,
|
|
897
|
+
buildArchiveFeedLabel(c, params, dimensionCtx),
|
|
898
|
+
),
|
|
804
899
|
selfUrl: toAbsoluteSiteUrl(
|
|
805
|
-
`${selfPath}${feedQuery}`,
|
|
900
|
+
`${viewBasePath(c)}${selfPath}${feedQuery}`,
|
|
806
901
|
appConfig.siteUrl,
|
|
807
902
|
appConfig.sitePathPrefix,
|
|
808
903
|
),
|
|
@@ -810,25 +905,38 @@ async function buildArchiveFeedData(
|
|
|
810
905
|
};
|
|
811
906
|
}
|
|
812
907
|
|
|
813
|
-
|
|
814
|
-
|
|
908
|
+
/**
|
|
909
|
+
* Render the archive Atom feed for the current view language.
|
|
910
|
+
*
|
|
911
|
+
* @param c - Hono context
|
|
912
|
+
* @returns Atom feed response
|
|
913
|
+
*/
|
|
914
|
+
export async function renderArchiveFeed(c: Context<Env>): Promise<Response> {
|
|
915
|
+
// No session reaches a feed reader, so a selection only the author can see
|
|
916
|
+
// has no honest rendering here — not an empty feed, and certainly not the
|
|
917
|
+
// unfiltered one. The page already withholds the link; this is the backstop.
|
|
918
|
+
// Read without resolving anything: the guard reads the visibility param and
|
|
919
|
+
// the session, so it must not depend on a database round trip.
|
|
920
|
+
const guardParams = readArchiveParams(c, archiveParamReader(c), null);
|
|
921
|
+
if (archiveQueryRequiresAuth(guardParams)) return c.notFound();
|
|
922
|
+
|
|
815
923
|
const feedData = await buildArchiveFeedData(c, "/archive/feed");
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
924
|
+
// A collection slug that names nothing: the same 404 the page gives, rather
|
|
925
|
+
// than handing a subscriber the entire archive under the collection's name.
|
|
926
|
+
if (!feedData) return c.notFound();
|
|
927
|
+
|
|
928
|
+
return renderFeed(defaultFeedRenderer(feedData));
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// Atom — /archive/feed
|
|
932
|
+
archiveRoutes.get("/feed", renderArchiveFeed);
|
|
823
933
|
|
|
824
934
|
// Legacy atom.xml redirect
|
|
825
|
-
|
|
826
|
-
const sitePathPrefix = c.var.appConfig.sitePathPrefix;
|
|
935
|
+
export function redirectLegacyArchiveFeed(c: Context<Env>): Response {
|
|
827
936
|
const qs = c.req.url.includes("?")
|
|
828
937
|
? c.req.url.slice(c.req.url.indexOf("?"))
|
|
829
938
|
: "";
|
|
830
|
-
return c.redirect(
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
});
|
|
939
|
+
return c.redirect(`${toViewPath(c, "/archive/feed")}${qs}`, 308);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
archiveRoutes.get("/feed/atom.xml", redirectLegacyArchiveFeed);
|