@jant/core 0.6.15 → 0.7.0
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/import-site.js +67 -4
- package/bin/commands/reset-password.js +21 -8
- package/bin/commands/site/snapshot/export.js +1 -1
- package/bin/commands/site/snapshot/import.js +1 -1
- package/bin/lib/d1-query.js +5 -16
- package/bin/lib/node-env.js +40 -4
- package/bin/lib/site-selection.js +85 -29
- package/bin/lib/wrangler-cli.js +28 -2
- package/bin/lib/wrangler-json.js +35 -0
- package/dist/{app-C-oi_t8W.js → app-K_Aa1MMn.js} +52623 -13193
- 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-CD3FhrOB.js +257 -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-CgaL2jCs.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-0QCkP0vZ.js +2 -0
- package/dist/client/_assets/client-GiYENVw8.css +2 -0
- package/dist/client/_assets/client-VnFxJN7G.js +295 -0
- package/dist/client/_assets/client-auth-k8gJ6QJj.js +828 -0
- package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
- package/dist/client/_assets/client-compose-B_kDtWkW.js +2004 -0
- package/dist/client/_assets/client-manage-M90aSTOk.js +2170 -0
- package/dist/client/_assets/client-settings-DfYs9n1F.js +1112 -0
- package/dist/{export-CsFx6F_M.js → github-sync-BPAvT999.js} +2343 -110
- package/dist/index.js +2 -5
- package/dist/node.js +5 -7
- package/package.json +3 -3
- 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__/export-hugo-build.test.ts +260 -0
- package/src/__tests__/export-service.test.ts +231 -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__/mise-config.test.ts +22 -0
- package/src/__tests__/stylesheet-audience.test.ts +159 -0
- package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
- package/src/app.tsx +71 -16
- 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 +524 -2
- package/src/client/__tests__/compose-launch.test.ts +62 -1
- package/src/client/__tests__/compose-shortcuts.test.ts +57 -8
- 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 +88 -1
- 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__/compose-format-convert.test.ts +0 -19
- 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 +79 -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 +3283 -389
- package/src/client/components/__tests__/jant-compose-editor.test.ts +581 -86
- package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +34 -89
- 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 +541 -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-format-convert.ts +5 -1
- package/src/client/components/compose-types.ts +121 -3
- 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 +106 -49
- package/src/client/components/jant-compose-dialog.ts +2757 -1194
- package/src/client/components/jant-compose-editor.ts +866 -397
- package/src/client/components/jant-compose-fullscreen.ts +53 -47
- 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 +1142 -268
- package/src/client/components/jant-post-picker.ts +324 -0
- package/src/client/components/jant-repo-picker.ts +138 -106
- package/src/client/components/jant-settings-avatar.ts +31 -27
- package/src/client/components/jant-settings-general.ts +617 -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 +336 -242
- package/src/client/compose-launch.ts +89 -2
- package/src/client/compose-shortcuts.ts +9 -7
- 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/post-refresh.ts +135 -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__/link-input-rules.test.ts +121 -0
- package/src/client/tiptap/bubble-menu.ts +47 -16
- package/src/client/tiptap/floating-position.ts +216 -2
- package/src/client/tiptap/inline-image-upload.ts +7 -3
- package/src/client/tiptap/link-input-rules.ts +4 -4
- package/src/client/tiptap/link-toolbar.ts +22 -9
- package/src/client/toast.ts +143 -38
- 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__/backfill-thread-activity.test.ts +234 -0
- 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/__tests__/thread-activity.test.ts +113 -0
- package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
- package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
- package/src/db/migrations/0031_many_ego.sql +3 -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/0031_snapshot.json +2562 -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 +28 -0
- package/src/db/migrations/pg/0029_rare_hairball.sql +3 -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/0029_snapshot.json +3289 -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 +28 -0
- package/src/db/pg/schema.ts +213 -27
- package/src/db/post-visibility.ts +117 -0
- package/src/db/schema.ts +242 -27
- package/src/db/thread-activity.ts +101 -0
- 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 +568 -168
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +562 -162
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +562 -162
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/i18n/locales/settings/en.po +551 -69
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +597 -115
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +610 -128
- 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 +1691 -12
- package/src/lib/__tests__/filter-dimensions.test.ts +341 -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__/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.test.ts +16 -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 +64 -3
- package/src/lib/__tests__/schemas.test.ts +42 -4
- package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
- package/src/lib/__tests__/slug.test.ts +88 -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__/translit.test.ts +87 -0
- 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 +93 -34
- 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 +437 -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 +1021 -66
- package/src/lib/filter-dimensions.ts +1349 -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 +21 -0
- package/src/lib/icons.ts +9 -2
- package/src/lib/ids.ts +3 -0
- package/src/lib/jant-docs.ts +42 -0
- package/src/lib/lang-detect.ts +308 -0
- package/src/lib/link-preview.ts +25 -0
- package/src/lib/markdown-manager.ts +6 -1
- 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-display.ts +42 -9
- package/src/lib/post-meta.ts +19 -0
- package/src/lib/render.tsx +55 -7
- package/src/lib/resolve-config.ts +53 -5
- package/src/lib/schemas.ts +201 -10
- 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 +81 -10
- package/src/lib/tiptap-render.ts +5 -4
- package/src/lib/translit.ts +288 -0
- package/src/lib/upload.ts +16 -13
- package/src/lib/url.ts +188 -24
- package/src/lib/version.ts +10 -8
- package/src/lib/view-language.ts +436 -0
- package/src/lib/view.ts +79 -23
- 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-site-selection.test.ts +128 -38
- package/src/node/__tests__/runtime.test.ts +37 -0
- package/src/node/index.ts +1 -0
- 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__/palette.test.ts +44 -0
- package/src/routes/api/__tests__/post-translations.test.ts +829 -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/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 +3 -0
- package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
- package/src/routes/api/nav-items.ts +55 -8
- package/src/routes/api/posts.ts +172 -9
- package/src/routes/api/public/__tests__/archive.test.ts +111 -0
- package/src/routes/api/public/__tests__/posts.test.ts +38 -0
- package/src/routes/api/public/archive.ts +77 -81
- package/src/routes/api/public/posts.ts +30 -6
- package/src/routes/api/smart-collections.ts +132 -0
- package/src/routes/api/telegram.ts +2 -40
- 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 +11 -2
- 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__/settings-discover.test.ts +213 -0
- package/src/routes/dash/custom-urls.tsx +1 -37
- package/src/routes/dash/settings.tsx +502 -41
- package/src/routes/discover-announce.ts +96 -0
- package/src/routes/feed/__tests__/feed.test.ts +165 -2
- package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
- 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 +657 -1
- 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__/featured.test.ts +6 -2
- 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-preview-partial.test.ts +96 -0
- package/src/routes/pages/__tests__/preview.test.ts +3 -1
- 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/archive.tsx +507 -357
- package/src/routes/pages/collection.tsx +94 -96
- package/src/routes/pages/collections.tsx +38 -50
- package/src/routes/pages/featured.tsx +32 -13
- 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 +183 -19
- package/src/routes/pages/partials.tsx +41 -0
- package/src/routes/pages/search.tsx +18 -3
- 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/cloudflare.ts +21 -2
- package/src/runtime/index.ts +27 -1
- package/src/runtime/node.ts +18 -1
- package/src/runtime/site.ts +95 -24
- package/src/services/__tests__/auth.test.ts +116 -7
- package/src/services/__tests__/collection.test.ts +139 -4
- package/src/services/__tests__/language.test.ts +495 -0
- package/src/services/__tests__/navigation.test.ts +213 -10
- package/src/services/__tests__/path.test.ts +180 -0
- package/src/services/__tests__/post-language.test.ts +830 -0
- package/src/services/__tests__/post-timeline.test.ts +142 -11
- package/src/services/__tests__/post.test.ts +323 -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 +188 -38
- package/src/services/collection-directory-position.ts +76 -0
- package/src/services/collection.ts +330 -47
- package/src/services/custom-url.ts +27 -22
- 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/rss.xml +77 -1
- package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
- 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/thread-preview.html +40 -30
- package/src/services/export-theme/styles/main.css +13 -1
- package/src/services/export.ts +281 -24
- package/src/services/index.ts +34 -3
- package/src/services/language.ts +412 -0
- package/src/services/mcp.ts +3 -1
- package/src/services/media.ts +14 -6
- package/src/services/navigation.ts +429 -67
- package/src/services/path.ts +225 -7
- package/src/services/post.ts +1318 -252
- package/src/services/search.ts +48 -2
- package/src/services/settings.ts +238 -43
- package/src/services/site-admin.ts +24 -7
- package/src/services/site.ts +77 -1
- 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 -1585
- package/src/styles/site-media.css +20 -6
- package/src/styles/tokens.css +144 -13
- package/src/styles/ui-author.css +7602 -0
- package/src/styles/ui.css +1224 -7276
- package/src/types/app-context.ts +7 -0
- package/src/types/bindings.ts +3 -0
- package/src/types/config.ts +94 -15
- package/src/types/constants.ts +188 -16
- package/src/types/entities.ts +92 -1
- package/src/types/operations.ts +64 -2
- package/src/types/props.ts +168 -30
- package/src/types/views.ts +61 -1
- package/src/ui/__tests__/font-themes.test.ts +52 -12
- package/src/ui/compose/ComposeDialog.tsx +215 -21
- 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 +275 -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/LinkPreview.tsx +2 -7
- package/src/ui/feed/NoteCard.tsx +9 -4
- package/src/ui/feed/PostStatusBadges.tsx +103 -17
- 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 +401 -301
- 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 +122 -3
- 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/DraftPreviewBar.tsx +25 -9
- 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 +55 -23
- 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/custom-icons.ts +5 -0
- package/src/ui/shared/navigation-labels.ts +45 -71
- package/src/ui/shared/post-article-attributes.ts +8 -0
- package/src/ui/shared/smart-collection-labels.ts +343 -0
- package/dist/app-BxCOR3Uc.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-pLre2DM_.js +0 -1
- package/dist/client/_assets/client-B_eGKN5p.css +0 -2
- package/dist/client/_assets/client-DxY5BFe8.js +0 -296
- package/dist/client/_assets/client-auth-CakPESv9.js +0 -5533
- package/dist/env-OHRKGcMj.js +0 -281
- package/dist/github-api-BgSiE71w.js +0 -176
- package/dist/github-app-BbklkFmU.js +0 -275
- package/dist/github-sync-Cq1pzzOI.js +0 -431
- package/dist/github-sync-ppWXN3jb.js +0 -4
- package/dist/url-BMYO-Zlt.js +0 -395
- 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/ui/pages/CollectionEditorPage.tsx +0 -175
|
@@ -13,30 +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,
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
ArchiveLayout,
|
|
24
|
+
ArchiveSort,
|
|
26
25
|
} from "../../types/props.js";
|
|
27
|
-
import {
|
|
26
|
+
import { PUBLIC_ARCHIVE_VISIBILITIES } from "../../types.js";
|
|
28
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";
|
|
29
47
|
import { defaultFeedRenderer } from "../../lib/feed.js";
|
|
30
48
|
import {
|
|
49
|
+
buildFeedDiscoveryFields,
|
|
31
50
|
getFeedEntryUpdatedAt,
|
|
51
|
+
getFeedLimit,
|
|
32
52
|
getRssPublishedBefore,
|
|
33
|
-
|
|
53
|
+
renderFeed,
|
|
34
54
|
} from "../../lib/feed-policy.js";
|
|
35
55
|
import { getNavigationData } from "../../lib/navigation.js";
|
|
36
56
|
import { buildPageTitle } from "../../lib/page-title.js";
|
|
37
57
|
import { renderPublicPage } from "../../lib/render.js";
|
|
38
58
|
import { formatYearMonth } from "../../lib/time.js";
|
|
39
|
-
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";
|
|
40
66
|
import {
|
|
41
67
|
createMediaContext,
|
|
42
68
|
toArchiveGroupsWithMedia,
|
|
@@ -54,115 +80,167 @@ type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
|
54
80
|
// Shared filter parsing
|
|
55
81
|
// =============================================================================
|
|
56
82
|
|
|
57
|
-
/**
|
|
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
|
+
*/
|
|
58
90
|
interface ParsedArchiveParams {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
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;
|
|
99
|
+
sort: ArchiveSort;
|
|
69
100
|
currentPage: number;
|
|
70
101
|
}
|
|
71
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
|
+
|
|
72
131
|
/**
|
|
73
|
-
*
|
|
132
|
+
* Read the archive's query string into a selection plus rendering options.
|
|
74
133
|
*
|
|
75
134
|
* @param c - Hono context
|
|
76
|
-
* @param
|
|
135
|
+
* @param read - Query parameter reader
|
|
136
|
+
* @param collections - Collections to resolve slugs against, when any is named
|
|
77
137
|
* @returns Parsed and validated query parameters
|
|
78
138
|
*/
|
|
79
|
-
function
|
|
139
|
+
function readArchiveParams(
|
|
80
140
|
c: Context<Env>,
|
|
81
|
-
|
|
141
|
+
read: ParamReader,
|
|
142
|
+
collections: Collection[] | null,
|
|
82
143
|
): ParsedArchiveParams {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
): boolean | undefined => {
|
|
105
|
-
if (param === "any") return true;
|
|
106
|
-
if (param === "none") return false;
|
|
107
|
-
if (legacy === "1") return true;
|
|
108
|
-
if (legacy === "0") return false;
|
|
109
|
-
return undefined;
|
|
110
|
-
};
|
|
111
|
-
|
|
112
|
-
const mediaParam = q("media") || undefined;
|
|
113
|
-
const mediaIsPresence = mediaParam === "any" || mediaParam === "none";
|
|
114
|
-
const mediaKinds =
|
|
115
|
-
mediaParam && !mediaIsPresence
|
|
116
|
-
? (mediaParam
|
|
117
|
-
.split(",")
|
|
118
|
-
.filter((m): m is MediaKind =>
|
|
119
|
-
(MEDIA_KINDS as readonly string[]).includes(m),
|
|
120
|
-
) as MediaKind[])
|
|
121
|
-
: undefined;
|
|
122
|
-
const hasMedia = parsePresence(
|
|
123
|
-
mediaIsPresence ? mediaParam : undefined,
|
|
124
|
-
q("hasMedia"),
|
|
125
|
-
);
|
|
126
|
-
|
|
127
|
-
const hasTitle = parsePresence(q("title"), q("hasTitle"));
|
|
128
|
-
const hasReplies = parsePresence(q("replies"), q("hasReplies"));
|
|
129
|
-
|
|
130
|
-
const VALID_VISIBILITIES = ["public", "latest_hidden", "private", "featured"];
|
|
131
|
-
const rawVisibilityParam = q("visibility");
|
|
132
|
-
// "hidden" is the URL spelling of the internal latest_hidden value
|
|
133
|
-
const visibilityParam =
|
|
134
|
-
rawVisibilityParam === "hidden" ? "latest_hidden" : rawVisibilityParam;
|
|
135
|
-
const visibilityAll = visibilityParam === "all";
|
|
136
|
-
const visibility =
|
|
137
|
-
visibilityParam && VALID_VISIBILITIES.includes(visibilityParam)
|
|
138
|
-
? (visibilityParam as ArchiveVisibility)
|
|
139
|
-
: undefined;
|
|
140
|
-
|
|
141
|
-
const viewParam = q("view") as ArchiveView | undefined;
|
|
142
|
-
const view =
|
|
143
|
-
viewParam && (viewParam === "grid" || viewParam === "list")
|
|
144
|
-
? viewParam
|
|
145
|
-
: 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"));
|
|
152
|
+
|
|
153
|
+
// sort selects the time axis for ordering, month grouping, and the year
|
|
154
|
+
// filter alike — they must stay on the same column or the month headers
|
|
155
|
+
// stop agreeing with the order inside them.
|
|
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
|
+
});
|
|
146
165
|
|
|
147
166
|
// Page always comes from the actual request URL (pagination links use ?page=N)
|
|
148
167
|
const pageParam = c.req.query("page");
|
|
149
168
|
const currentPage = Math.max(1, parseInt(pageParam || "1", 10) || 1);
|
|
150
169
|
|
|
151
170
|
return {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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,
|
|
181
|
+
sort,
|
|
162
182
|
currentPage,
|
|
163
183
|
};
|
|
164
184
|
}
|
|
165
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
|
+
|
|
166
244
|
/**
|
|
167
245
|
* Build PostFilters from parsed archive params.
|
|
168
246
|
*
|
|
@@ -174,69 +252,51 @@ function buildArchivePostFilters(
|
|
|
174
252
|
params: ParsedArchiveParams,
|
|
175
253
|
opts: {
|
|
176
254
|
isAuthenticated: boolean;
|
|
177
|
-
|
|
255
|
+
lang?: string;
|
|
256
|
+
/** Overrides the request's own selection, for the unfiltered baseline. */
|
|
257
|
+
selection?: PostFilterSelection;
|
|
178
258
|
},
|
|
179
259
|
): PostFilters {
|
|
180
|
-
const { isAuthenticated,
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
let publishedAfter: number | undefined;
|
|
191
|
-
let publishedBefore: number | undefined;
|
|
192
|
-
if (params.validYear) {
|
|
193
|
-
publishedAfter = Date.UTC(params.validYear, 0, 1) / 1000;
|
|
194
|
-
publishedBefore = Date.UTC(params.validYear + 1, 0, 1) / 1000;
|
|
195
|
-
}
|
|
196
|
-
|
|
260
|
+
const { isAuthenticated, lang } = opts;
|
|
261
|
+
const selection = opts.selection ?? params.selection;
|
|
262
|
+
const sortsByActivity = params.sort === "updated";
|
|
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.
|
|
197
269
|
return {
|
|
198
|
-
|
|
270
|
+
lang,
|
|
199
271
|
status: "published",
|
|
200
272
|
excludeReplies: true,
|
|
201
273
|
excludePrivate: !isAuthenticated,
|
|
202
274
|
excludeLatestHidden: false,
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
hasMedia: params.hasMedia,
|
|
213
|
-
hasTitle: params.hasTitle,
|
|
214
|
-
hasReplies: params.hasReplies,
|
|
215
|
-
sortBy: "published",
|
|
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
|
+
}),
|
|
280
|
+
// "thread_updated", not "activity": the archive is the canonical all-posts
|
|
281
|
+
// view, and the quiet-reply switch only promises not to move a Thread on
|
|
282
|
+
// Latest. Here the honest answer is when the Thread last changed.
|
|
283
|
+
sortBy: sortsByActivity ? "thread_updated" : "published",
|
|
216
284
|
ignorePinnedSort: true,
|
|
217
285
|
};
|
|
218
286
|
}
|
|
219
287
|
|
|
220
288
|
/**
|
|
221
289
|
* Build a query string from parsed archive params (for feed self-URL and
|
|
222
|
-
* archive page feed link). Omits page
|
|
290
|
+
* archive page feed link). Omits layout and page — those shape the rendered
|
|
291
|
+
* page, not the result set — but carries `sort`, so the feed button on an
|
|
292
|
+
* updated-sorted page hands back the matching feed.
|
|
223
293
|
*/
|
|
224
|
-
function buildArchiveFeedQuery(
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
if (params.
|
|
230
|
-
qs.set("media", params.mediaKinds.join(","));
|
|
231
|
-
} else if (params.hasMedia !== undefined) {
|
|
232
|
-
qs.set("media", params.hasMedia ? "any" : "none");
|
|
233
|
-
}
|
|
234
|
-
if (params.hasTitle !== undefined) {
|
|
235
|
-
qs.set("title", params.hasTitle ? "any" : "none");
|
|
236
|
-
}
|
|
237
|
-
if (params.hasReplies !== undefined) {
|
|
238
|
-
qs.set("replies", params.hasReplies ? "any" : "none");
|
|
239
|
-
}
|
|
294
|
+
function buildArchiveFeedQuery(
|
|
295
|
+
params: ParsedArchiveParams,
|
|
296
|
+
ctx: DimensionContext,
|
|
297
|
+
): string {
|
|
298
|
+
const qs = serializePostFilterSelection(params.selection, ctx);
|
|
299
|
+
if (params.sort === "updated") qs.set("sort", "updated");
|
|
240
300
|
const str = qs.toString();
|
|
241
301
|
return str ? `?${str}` : "";
|
|
242
302
|
}
|
|
@@ -244,19 +304,30 @@ function buildArchiveFeedQuery(params: ParsedArchiveParams): string {
|
|
|
244
304
|
export const archiveRoutes = new Hono<Env>();
|
|
245
305
|
|
|
246
306
|
/**
|
|
247
|
-
* Build a canonical redirect
|
|
248
|
-
*
|
|
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.
|
|
249
318
|
*
|
|
250
|
-
* Only
|
|
251
|
-
*
|
|
252
|
-
* Applies to the /archive page only — feeds and the public API accept
|
|
253
|
-
*
|
|
254
|
-
*
|
|
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.
|
|
255
324
|
*
|
|
256
325
|
* @param c - Hono context
|
|
257
|
-
* @returns
|
|
326
|
+
* @returns Redirect target and status code, or null
|
|
258
327
|
*/
|
|
259
|
-
function
|
|
328
|
+
function archiveParamsRedirect(
|
|
329
|
+
c: Context<Env>,
|
|
330
|
+
): { location: string; status: 302 | 308 } | null {
|
|
260
331
|
const url = new URL(c.req.url);
|
|
261
332
|
const params = url.searchParams;
|
|
262
333
|
let changed = false;
|
|
@@ -281,9 +352,48 @@ function legacyArchiveParamsRedirect(c: Context<Env>): string | null {
|
|
|
281
352
|
changed = true;
|
|
282
353
|
}
|
|
283
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
|
+
|
|
284
391
|
if (!changed) return null;
|
|
285
392
|
const qs = params.toString();
|
|
286
|
-
return
|
|
393
|
+
return {
|
|
394
|
+
location: `${url.pathname}${qs ? `?${qs}` : ""}`,
|
|
395
|
+
status: authStripped ? 302 : 308,
|
|
396
|
+
};
|
|
287
397
|
}
|
|
288
398
|
|
|
289
399
|
// =============================================================================
|
|
@@ -303,44 +413,91 @@ export async function renderArchivePage(
|
|
|
303
413
|
): Promise<Response> {
|
|
304
414
|
const { services, appConfig } = c.var;
|
|
305
415
|
const pageSize = appConfig.archivePageSize;
|
|
306
|
-
const params
|
|
307
|
-
|
|
308
|
-
|
|
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
|
+
}
|
|
309
432
|
|
|
310
|
-
|
|
311
|
-
? await services.collections.getBySlug(params.collectionSlug)
|
|
312
|
-
: undefined;
|
|
313
|
-
const collectionId = collection?.id;
|
|
433
|
+
if (params.collectionMissing) return c.notFound();
|
|
314
434
|
|
|
315
435
|
const navData = await getNavigationData(c);
|
|
316
436
|
|
|
317
437
|
const filters = buildArchivePostFilters(params, {
|
|
318
438
|
isAuthenticated: navData.isAuthenticated,
|
|
319
|
-
|
|
439
|
+
lang: getViewLang(c) ?? undefined,
|
|
320
440
|
});
|
|
321
441
|
|
|
322
442
|
// --- Parallel data fetches ------------------------------------------------
|
|
323
443
|
// List view doesn't need month-based grouping, so skip countByYearMonth.
|
|
324
444
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
445
|
+
// No layout in the URL means the site's configured default, not grid: the
|
|
446
|
+
// archive is the widest of Featured / Latest / All and the other two are
|
|
447
|
+
// timelines, so `list` is the default default.
|
|
448
|
+
const layout: ArchiveLayout = params.layout ?? appConfig.archiveDefaultLayout;
|
|
449
|
+
const isListView = layout === "list";
|
|
450
|
+
|
|
451
|
+
// The same view with every selection cleared, so the count can say how much
|
|
452
|
+
// the filter removed. Skipped entirely when nothing is filtered — the two
|
|
453
|
+
// numbers would be equal — which keeps the plain /archive page at its
|
|
454
|
+
// current query count. When it does run it is the cheapest of the three
|
|
455
|
+
// aggregates, since clearing the selections is what drops the collection and
|
|
456
|
+
// media EXISTS subqueries.
|
|
457
|
+
const baselineFilters = hasActiveArchiveFilter(params.selection)
|
|
458
|
+
? buildArchivePostFilters(params, {
|
|
459
|
+
isAuthenticated: navData.isAuthenticated,
|
|
460
|
+
lang: filters.lang,
|
|
461
|
+
selection: {},
|
|
462
|
+
})
|
|
463
|
+
: undefined;
|
|
464
|
+
|
|
465
|
+
const [
|
|
466
|
+
totalCount,
|
|
467
|
+
baselineCount,
|
|
468
|
+
monthlyCounts,
|
|
469
|
+
posts,
|
|
470
|
+
availableYears,
|
|
471
|
+
listedCollections,
|
|
472
|
+
] = await Promise.all([
|
|
473
|
+
services.posts.count(filters),
|
|
474
|
+
baselineFilters
|
|
475
|
+
? services.posts.count(baselineFilters)
|
|
476
|
+
: Promise.resolve(undefined),
|
|
477
|
+
isListView
|
|
478
|
+
? Promise.resolve([] as { yearMonth: string; count: number }[])
|
|
479
|
+
: services.posts.countByYearMonth(filters),
|
|
480
|
+
services.posts.list({
|
|
481
|
+
...filters,
|
|
482
|
+
limit: pageSize,
|
|
483
|
+
offset: (params.currentPage - 1) * pageSize,
|
|
484
|
+
}),
|
|
485
|
+
services.posts.getDistinctYears({
|
|
486
|
+
status: "published",
|
|
487
|
+
excludeReplies: true,
|
|
488
|
+
lang: filters.lang,
|
|
489
|
+
sortBy: filters.sortBy,
|
|
490
|
+
}),
|
|
491
|
+
// Already loaded when the query named a collection, because parsing needed
|
|
492
|
+
// it. Fetched here otherwise, so an unfiltered archive keeps paying for one
|
|
493
|
+
// round of queries rather than two.
|
|
494
|
+
preloadedCollections ?? services.collections.list(),
|
|
495
|
+
]);
|
|
496
|
+
|
|
497
|
+
const allCollections = preloadedCollections ?? listedCollections;
|
|
498
|
+
const dimensionCtx: DimensionContext = {
|
|
499
|
+
collections: buildCollectionVocabulary(allCollections),
|
|
500
|
+
};
|
|
344
501
|
|
|
345
502
|
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
|
346
503
|
|
|
@@ -365,8 +522,12 @@ export async function renderArchivePage(
|
|
|
365
522
|
|
|
366
523
|
const grouped = new Map<string, PostWithMedia[]>();
|
|
367
524
|
for (const post of posts) {
|
|
368
|
-
|
|
369
|
-
const
|
|
525
|
+
// Bucket on the same axis the query sorted by, so groups stay ordered.
|
|
526
|
+
const groupedAt =
|
|
527
|
+
params.sort === "updated"
|
|
528
|
+
? post.threadUpdatedAt
|
|
529
|
+
: (post.publishedAt ?? post.updatedAt);
|
|
530
|
+
const key = formatYearMonth(groupedAt, appConfig.timeZone);
|
|
370
531
|
if (!grouped.has(key)) {
|
|
371
532
|
grouped.set(key, []);
|
|
372
533
|
}
|
|
@@ -421,51 +582,89 @@ export async function renderArchivePage(
|
|
|
421
582
|
|
|
422
583
|
// --- Build active filter state for UI -------------------------------------
|
|
423
584
|
|
|
424
|
-
const effectiveVisibility = navData.isAuthenticated
|
|
425
|
-
? params.visibilityAll
|
|
426
|
-
? undefined
|
|
427
|
-
: (params.visibility ?? undefined)
|
|
428
|
-
: undefined;
|
|
429
|
-
|
|
430
585
|
const archiveFilters: ArchiveFilters = {
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
format: params.format,
|
|
435
|
-
mediaKinds: params.mediaKinds,
|
|
436
|
-
hasMedia: params.hasMedia,
|
|
437
|
-
hasTitle: params.hasTitle,
|
|
438
|
-
hasReplies: params.hasReplies,
|
|
439
|
-
visibility: effectiveVisibility,
|
|
440
|
-
view: params.view,
|
|
586
|
+
selection: params.selection,
|
|
587
|
+
layout: params.layout,
|
|
588
|
+
sort: params.sort === "updated" ? "updated" : undefined,
|
|
441
589
|
};
|
|
442
590
|
|
|
443
|
-
const feedQuery = buildArchiveFeedQuery(params);
|
|
591
|
+
const feedQuery = buildArchiveFeedQuery(params, dimensionCtx);
|
|
592
|
+
// A feed is unauthenticated by construction, so it cannot serve a selection
|
|
593
|
+
// only the author can see. Offering the link anyway would promise a feed
|
|
594
|
+
// that answers 404.
|
|
595
|
+
const feedHref =
|
|
596
|
+
appConfig.rssFeedsEnabled && !archiveQueryRequiresAuth(params)
|
|
597
|
+
? `/archive/feed${feedQuery}`
|
|
598
|
+
: undefined;
|
|
444
599
|
|
|
445
600
|
const availableCollectionsList = allCollections.map((col) => ({
|
|
601
|
+
id: col.id,
|
|
446
602
|
slug: col.slug,
|
|
447
603
|
title: col.title,
|
|
448
604
|
}));
|
|
449
605
|
|
|
606
|
+
// A facet is one URL out of a family with no ceiling: `media` alone is a
|
|
607
|
+
// comma-joined subset of kinds, multiplied by year x collection x format x
|
|
608
|
+
// title x replies x visibility x sort. None of them is a page the site
|
|
609
|
+
// publishes, and the archive sits in no cache, so every one a crawler walks
|
|
610
|
+
// is a Worker invocation and seven D1 queries. `follow`, not `nofollow`:
|
|
611
|
+
// the posts these URLs link to are the real pages.
|
|
612
|
+
const readerAssembled = isReaderAssembledArchive(params, queryOverrides);
|
|
613
|
+
if (readerAssembled) c.header("X-Robots-Tag", "noindex, follow");
|
|
614
|
+
|
|
615
|
+
// Everything left over renders the same posts in the same order as the bare
|
|
616
|
+
// path — `layout` is markup only, `visibility=all` selects nothing, and
|
|
617
|
+
// tracking params select nothing either — so they all consolidate onto it.
|
|
618
|
+
// `page` is the exception and stays: page 2 is different content, not a
|
|
619
|
+
// different rendering of page 1.
|
|
620
|
+
const canonicalQuery =
|
|
621
|
+
params.currentPage > 1 ? `?page=${params.currentPage}` : "";
|
|
622
|
+
const canonicalHref = readerAssembled
|
|
623
|
+
? undefined
|
|
624
|
+
: `${toAbsoluteSiteUrl(c.req.path, appConfig.siteUrl, appConfig.sitePathPrefix)}${canonicalQuery}`;
|
|
625
|
+
|
|
450
626
|
return renderPublicPage(c, {
|
|
451
|
-
|
|
627
|
+
// A tab has no filter bar beside it, so unlike the page heading this has to
|
|
628
|
+
// name the selection itself — otherwise every bookmarked filtered view
|
|
629
|
+
// reads identically in the tab bar.
|
|
630
|
+
title: buildPageTitle(
|
|
631
|
+
getArchiveViewTitle(params.selection, getI18n(c), dimensionCtx),
|
|
632
|
+
params.sort === "updated" ? "Recently updated" : undefined,
|
|
633
|
+
navData.siteName,
|
|
634
|
+
),
|
|
635
|
+
// An hreflang set is made of canonical URLs, so it mirrors the canonical
|
|
636
|
+
// exactly: the same trimmed query where there is one, and nothing at all
|
|
637
|
+
// on a facet, which has no canonical for the set to be built from.
|
|
638
|
+
alternateLanguages: readerAssembled
|
|
639
|
+
? undefined
|
|
640
|
+
: buildSurfaceAlternates(c, { query: canonicalQuery }),
|
|
641
|
+
noindex: readerAssembled ? "follow" : undefined,
|
|
642
|
+
canonicalHref,
|
|
643
|
+
// The filters are part of the feed: a reader subscribing from a filtered
|
|
644
|
+
// archive gets that filtered feed, not the whole archive.
|
|
645
|
+
pageFeed: feedHref
|
|
646
|
+
? {
|
|
647
|
+
href: toViewPath(c, feedHref),
|
|
648
|
+
title: buildArchiveFeedLabel(c, params, dimensionCtx),
|
|
649
|
+
}
|
|
650
|
+
: undefined,
|
|
452
651
|
navData,
|
|
453
652
|
content: (
|
|
454
653
|
<ArchivePage
|
|
455
654
|
groups={groups}
|
|
456
655
|
items={flatItems}
|
|
656
|
+
defaultLayout={appConfig.archiveDefaultLayout}
|
|
457
657
|
totalCount={totalCount}
|
|
658
|
+
baselineCount={baselineCount}
|
|
458
659
|
currentPage={params.currentPage}
|
|
459
660
|
totalPages={totalPages}
|
|
460
661
|
filters={archiveFilters}
|
|
461
662
|
availableYears={availableYears}
|
|
462
663
|
availableCollections={availableCollectionsList}
|
|
463
664
|
isAuthenticated={navData.isAuthenticated}
|
|
464
|
-
|
|
665
|
+
basePath={navData.basePath}
|
|
465
666
|
timeZone={appConfig.timeZone}
|
|
466
|
-
feedHref={
|
|
467
|
-
appConfig.rssFeedsEnabled ? `/archive/feed${feedQuery}` : undefined
|
|
468
|
-
}
|
|
667
|
+
feedHref={feedHref}
|
|
469
668
|
/>
|
|
470
669
|
),
|
|
471
670
|
});
|
|
@@ -475,130 +674,51 @@ export async function renderArchivePage(
|
|
|
475
674
|
// Archive page route
|
|
476
675
|
// =============================================================================
|
|
477
676
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
677
|
+
/**
|
|
678
|
+
* Serve the archive page, first normalizing any legacy query parameters.
|
|
679
|
+
*
|
|
680
|
+
* @param c - Hono context
|
|
681
|
+
* @returns Archive page response, or a 308 to the canonical parameter spelling
|
|
682
|
+
*/
|
|
683
|
+
export function renderArchiveRoute(
|
|
684
|
+
c: Context<Env>,
|
|
685
|
+
): Promise<Response> | Response {
|
|
686
|
+
const redirect = archiveParamsRedirect(c);
|
|
687
|
+
if (redirect) return c.redirect(redirect.location, redirect.status);
|
|
481
688
|
return renderArchivePage(c);
|
|
482
|
-
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
archiveRoutes.get("/", renderArchiveRoute);
|
|
483
692
|
|
|
484
693
|
// =============================================================================
|
|
485
694
|
// Archive feed
|
|
486
695
|
// =============================================================================
|
|
487
696
|
|
|
488
697
|
/**
|
|
489
|
-
* Build a descriptive feed
|
|
698
|
+
* Build a descriptive label for the archive feed from the active filters.
|
|
699
|
+
*
|
|
700
|
+
* Shared by the feed document's own title and the autodiscovery link on the
|
|
701
|
+
* archive page, so a subscriber sees the same name in both places.
|
|
702
|
+
*
|
|
703
|
+
* Unlike the page title this lists every active dimension. A feed name sits in
|
|
704
|
+
* someone's reader for years with no UI to inspect it, so two subscriptions
|
|
705
|
+
* that differ only in a dropped dimension must not arrive under one name.
|
|
490
706
|
*
|
|
491
707
|
* @param c - Hono context
|
|
492
708
|
* @param params - Parsed archive filter params
|
|
493
|
-
* @param
|
|
494
|
-
* @returns Feed
|
|
709
|
+
* @param ctx - Collection vocabulary, for naming a selected collection
|
|
710
|
+
* @returns Feed label, e.g. "Archive: Untitled, 2024"
|
|
711
|
+
* @example
|
|
712
|
+
* buildArchiveFeedLabel(c, params); // "Archive"
|
|
495
713
|
*/
|
|
496
|
-
function
|
|
714
|
+
function buildArchiveFeedLabel(
|
|
497
715
|
c: Context<Env>,
|
|
498
716
|
params: ParsedArchiveParams,
|
|
499
|
-
|
|
717
|
+
ctx: DimensionContext,
|
|
500
718
|
): string {
|
|
501
719
|
const i18n = getI18n(c);
|
|
502
|
-
const siteName = c.var.appConfig.siteName;
|
|
503
|
-
|
|
504
|
-
const parts: string[] = [];
|
|
505
|
-
|
|
506
|
-
if (params.format) {
|
|
507
|
-
const formatLabels: Record<string, string> = {
|
|
508
|
-
note: i18n._(
|
|
509
|
-
msg({
|
|
510
|
-
message: "Notes",
|
|
511
|
-
comment:
|
|
512
|
-
"@context: Archive feed title segment for note format filter",
|
|
513
|
-
}),
|
|
514
|
-
),
|
|
515
|
-
link: i18n._(
|
|
516
|
-
msg({
|
|
517
|
-
message: "Links",
|
|
518
|
-
comment:
|
|
519
|
-
"@context: Archive feed title segment for link format filter",
|
|
520
|
-
}),
|
|
521
|
-
),
|
|
522
|
-
quote: i18n._(
|
|
523
|
-
msg({
|
|
524
|
-
message: "Quotes",
|
|
525
|
-
comment:
|
|
526
|
-
"@context: Archive feed title segment for quote format filter",
|
|
527
|
-
}),
|
|
528
|
-
),
|
|
529
|
-
};
|
|
530
|
-
parts.push(formatLabels[params.format] ?? params.format);
|
|
531
|
-
}
|
|
532
720
|
|
|
533
|
-
|
|
534
|
-
parts.push(collectionTitle);
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
if (params.hasTitle === false) {
|
|
538
|
-
parts.push(
|
|
539
|
-
i18n._(
|
|
540
|
-
msg({
|
|
541
|
-
message: "without title",
|
|
542
|
-
comment: "@context: Archive feed title segment for hasTitle=0 filter",
|
|
543
|
-
}),
|
|
544
|
-
),
|
|
545
|
-
);
|
|
546
|
-
} else if (params.hasTitle === true) {
|
|
547
|
-
parts.push(
|
|
548
|
-
i18n._(
|
|
549
|
-
msg({
|
|
550
|
-
message: "with title",
|
|
551
|
-
comment: "@context: Archive feed title segment for hasTitle=1 filter",
|
|
552
|
-
}),
|
|
553
|
-
),
|
|
554
|
-
);
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
if (params.hasMedia === true) {
|
|
558
|
-
parts.push(
|
|
559
|
-
i18n._(
|
|
560
|
-
msg({
|
|
561
|
-
message: "with media",
|
|
562
|
-
comment: "@context: Archive feed title segment for hasMedia=1 filter",
|
|
563
|
-
}),
|
|
564
|
-
),
|
|
565
|
-
);
|
|
566
|
-
} else if (params.hasMedia === false) {
|
|
567
|
-
parts.push(
|
|
568
|
-
i18n._(
|
|
569
|
-
msg({
|
|
570
|
-
message: "without media",
|
|
571
|
-
comment: "@context: Archive feed title segment for hasMedia=0 filter",
|
|
572
|
-
}),
|
|
573
|
-
),
|
|
574
|
-
);
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
if (params.hasReplies === true) {
|
|
578
|
-
parts.push(
|
|
579
|
-
i18n._(
|
|
580
|
-
msg({
|
|
581
|
-
message: "threads",
|
|
582
|
-
comment:
|
|
583
|
-
"@context: Archive feed title segment for hasReplies=1 filter",
|
|
584
|
-
}),
|
|
585
|
-
),
|
|
586
|
-
);
|
|
587
|
-
} else if (params.hasReplies === false) {
|
|
588
|
-
parts.push(
|
|
589
|
-
i18n._(
|
|
590
|
-
msg({
|
|
591
|
-
message: "single posts",
|
|
592
|
-
comment:
|
|
593
|
-
"@context: Archive feed title segment for hasReplies=0 filter",
|
|
594
|
-
}),
|
|
595
|
-
),
|
|
596
|
-
);
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
if (params.validYear) {
|
|
600
|
-
parts.push(String(params.validYear));
|
|
601
|
-
}
|
|
721
|
+
const parts = describeArchiveFilters(params.selection, i18n, ctx);
|
|
602
722
|
|
|
603
723
|
const archiveLabel = i18n._(
|
|
604
724
|
msg({
|
|
@@ -608,54 +728,67 @@ function buildArchiveFeedTitle(
|
|
|
608
728
|
);
|
|
609
729
|
|
|
610
730
|
if (parts.length === 0) {
|
|
611
|
-
return
|
|
731
|
+
return archiveLabel;
|
|
612
732
|
}
|
|
613
733
|
|
|
614
|
-
return `${
|
|
734
|
+
return `${archiveLabel}: ${parts.join(", ")}`;
|
|
615
735
|
}
|
|
616
736
|
|
|
617
737
|
async function buildArchiveFeedData(
|
|
618
738
|
c: Context<Env>,
|
|
619
739
|
selfPath: string,
|
|
620
|
-
): Promise<FeedData> {
|
|
740
|
+
): Promise<FeedData | null> {
|
|
621
741
|
const { appConfig, services } = c.var;
|
|
622
|
-
const params = parseArchiveParams(c);
|
|
742
|
+
const { params, collections } = await parseArchiveParams(c);
|
|
623
743
|
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
744
|
+
// A collection slug that names nothing: the caller turns this into the same
|
|
745
|
+
// 404 the page gives, rather than handing a subscriber the entire archive
|
|
746
|
+
// under the collection's name.
|
|
747
|
+
if (params.collectionMissing) return null;
|
|
748
|
+
|
|
749
|
+
const dimensionCtx: DimensionContext = {
|
|
750
|
+
collections: collections
|
|
751
|
+
? buildCollectionVocabulary(collections)
|
|
752
|
+
: EMPTY_COLLECTION_VOCABULARY,
|
|
753
|
+
};
|
|
627
754
|
const rssPublishedBefore = getRssPublishedBefore(
|
|
628
755
|
appConfig.rssPublishDelaySeconds,
|
|
629
756
|
);
|
|
630
|
-
const yearPublishedBefore = params.validYear
|
|
631
|
-
? Date.UTC(params.validYear + 1, 0, 1) / 1000
|
|
632
|
-
: undefined;
|
|
633
757
|
|
|
634
758
|
// Feed mirrors the unauthenticated archive page: published + non-private,
|
|
635
759
|
// including Hidden-from-Latest. /archive is the canonical "all posts" view,
|
|
636
760
|
// so its feed must match.
|
|
761
|
+
//
|
|
762
|
+
// Ordered by publication by default, like the page it belongs to. Ordering
|
|
763
|
+
// by activity would make the feed's contents shift under a fixed length — a
|
|
764
|
+
// new reply pulls an old Thread back into the window and pushes something
|
|
765
|
+
// else out — and it buys nothing in return, because readers
|
|
766
|
+
// key entries by id and will not re-surface one that merely moved.
|
|
767
|
+
// /latest/feed is the activity feed; this one is the chronological record.
|
|
768
|
+
//
|
|
769
|
+
// `?sort=updated` opts into the same axis the page uses, for a subscriber
|
|
770
|
+
// who wants that trade-off deliberately.
|
|
771
|
+
const sortsByActivity = params.sort === "updated";
|
|
772
|
+
// The year filter follows the active axis; the RSS delay always stays on
|
|
773
|
+
// publication, so a just-published post is never announced early. When both
|
|
774
|
+
// bound the publication column, the tighter one wins.
|
|
775
|
+
const selectionFilters = toPostFilters(params.selection, {
|
|
776
|
+
yearAxis: sortsByActivity ? "sort" : "published",
|
|
777
|
+
});
|
|
637
778
|
const filters: PostFilters = {
|
|
638
|
-
|
|
779
|
+
lang: getViewLang(c) ?? undefined,
|
|
639
780
|
status: "published",
|
|
640
781
|
excludeReplies: true,
|
|
641
782
|
excludePrivate: true,
|
|
642
783
|
excludeLatestHidden: false,
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
hasMedia: params.hasMedia,
|
|
646
|
-
hasTitle: params.hasTitle,
|
|
647
|
-
hasReplies: params.hasReplies,
|
|
784
|
+
...selectionFilters,
|
|
785
|
+
sortBy: sortsByActivity ? "thread_updated" : "published",
|
|
648
786
|
ignorePinnedSort: true,
|
|
649
|
-
...(params.validYear
|
|
650
|
-
? {
|
|
651
|
-
publishedAfter: Date.UTC(params.validYear, 0, 1) / 1000,
|
|
652
|
-
}
|
|
653
|
-
: {}),
|
|
654
787
|
publishedBefore:
|
|
655
|
-
|
|
788
|
+
selectionFilters.publishedBefore === undefined
|
|
656
789
|
? rssPublishedBefore
|
|
657
|
-
: Math.min(
|
|
658
|
-
limit:
|
|
790
|
+
: Math.min(selectionFilters.publishedBefore, rssPublishedBefore),
|
|
791
|
+
limit: getFeedLimit(c),
|
|
659
792
|
};
|
|
660
793
|
|
|
661
794
|
const posts = await services.posts.list(filters);
|
|
@@ -745,16 +878,20 @@ async function buildArchiveFeedData(
|
|
|
745
878
|
};
|
|
746
879
|
});
|
|
747
880
|
|
|
748
|
-
const feedQuery = buildArchiveFeedQuery(params);
|
|
881
|
+
const feedQuery = buildArchiveFeedQuery(params, dimensionCtx);
|
|
749
882
|
|
|
750
883
|
return {
|
|
884
|
+
...buildFeedDiscoveryFields(c, { query: feedQuery }),
|
|
751
885
|
siteName: appConfig.siteName,
|
|
752
886
|
siteDescription: toPlainText(appConfig.siteDescription),
|
|
753
887
|
siteUrl: appConfig.siteUrl,
|
|
754
|
-
siteLanguage: appConfig.siteLanguage,
|
|
755
|
-
title:
|
|
888
|
+
siteLanguage: getViewLang(c) ?? appConfig.siteLanguage,
|
|
889
|
+
title: buildPageTitle(
|
|
890
|
+
appConfig.siteName,
|
|
891
|
+
buildArchiveFeedLabel(c, params, dimensionCtx),
|
|
892
|
+
),
|
|
756
893
|
selfUrl: toAbsoluteSiteUrl(
|
|
757
|
-
`${selfPath}${feedQuery}`,
|
|
894
|
+
`${viewBasePath(c)}${selfPath}${feedQuery}`,
|
|
758
895
|
appConfig.siteUrl,
|
|
759
896
|
appConfig.sitePathPrefix,
|
|
760
897
|
),
|
|
@@ -762,25 +899,38 @@ async function buildArchiveFeedData(
|
|
|
762
899
|
};
|
|
763
900
|
}
|
|
764
901
|
|
|
765
|
-
|
|
766
|
-
|
|
902
|
+
/**
|
|
903
|
+
* Render the archive Atom feed for the current view language.
|
|
904
|
+
*
|
|
905
|
+
* @param c - Hono context
|
|
906
|
+
* @returns Atom feed response
|
|
907
|
+
*/
|
|
908
|
+
export async function renderArchiveFeed(c: Context<Env>): Promise<Response> {
|
|
909
|
+
// No session reaches a feed reader, so a selection only the author can see
|
|
910
|
+
// has no honest rendering here — not an empty feed, and certainly not the
|
|
911
|
+
// unfiltered one. The page already withholds the link; this is the backstop.
|
|
912
|
+
// Read without resolving anything: the guard reads the visibility param and
|
|
913
|
+
// the session, so it must not depend on a database round trip.
|
|
914
|
+
const guardParams = readArchiveParams(c, archiveParamReader(c), null);
|
|
915
|
+
if (archiveQueryRequiresAuth(guardParams)) return c.notFound();
|
|
916
|
+
|
|
767
917
|
const feedData = await buildArchiveFeedData(c, "/archive/feed");
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
918
|
+
// A collection slug that names nothing: the same 404 the page gives, rather
|
|
919
|
+
// than handing a subscriber the entire archive under the collection's name.
|
|
920
|
+
if (!feedData) return c.notFound();
|
|
921
|
+
|
|
922
|
+
return renderFeed(defaultFeedRenderer(feedData));
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// Atom — /archive/feed
|
|
926
|
+
archiveRoutes.get("/feed", renderArchiveFeed);
|
|
775
927
|
|
|
776
928
|
// Legacy atom.xml redirect
|
|
777
|
-
|
|
778
|
-
const sitePathPrefix = c.var.appConfig.sitePathPrefix;
|
|
929
|
+
export function redirectLegacyArchiveFeed(c: Context<Env>): Response {
|
|
779
930
|
const qs = c.req.url.includes("?")
|
|
780
931
|
? c.req.url.slice(c.req.url.indexOf("?"))
|
|
781
932
|
: "";
|
|
782
|
-
return c.redirect(
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
});
|
|
933
|
+
return c.redirect(`${toViewPath(c, "/archive/feed")}${qs}`, 308);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
archiveRoutes.get("/feed/atom.xml", redirectLegacyArchiveFeed);
|