@jant/core 0.6.16 → 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 +60 -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-C7_4oab9.js → app-K_Aa1MMn.js} +52476 -13330
- 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-Dl5KCiEs.js → github-sync-BPAvT999.js} +2254 -107
- 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__/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 +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 +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-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.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 +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/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-toolbar.ts +22 -9
- 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__/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 +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 +1526 -15
- 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__/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 +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__/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 +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 +926 -73
- 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 +16 -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/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-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 +42 -1
- package/src/lib/tiptap-render.ts +1 -2
- package/src/lib/upload.ts +16 -13
- package/src/lib/url.ts +139 -86
- package/src/lib/version.ts +10 -8
- package/src/lib/view-language.ts +436 -0
- package/src/lib/view.ts +51 -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__/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 +159 -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 +23 -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 +5 -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__/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/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-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/archive.tsx +471 -369
- 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 +178 -17
- 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 +108 -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.test.ts +46 -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 +331 -46
- 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 +278 -19
- 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 +221 -6
- package/src/services/post.ts +1021 -152
- package/src/services/search.ts +39 -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 -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 +1087 -7219
- 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 +87 -1
- package/src/types/operations.ts +64 -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 +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/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/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/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:
|
|
269
310
|
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
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.
|
|
318
|
+
*
|
|
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,45 +413,91 @@ 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
|
const navData = await getNavigationData(c);
|
|
336
436
|
|
|
337
437
|
const filters = buildArchivePostFilters(params, {
|
|
338
438
|
isAuthenticated: navData.isAuthenticated,
|
|
339
|
-
|
|
439
|
+
lang: getViewLang(c) ?? undefined,
|
|
340
440
|
});
|
|
341
441
|
|
|
342
442
|
// --- Parallel data fetches ------------------------------------------------
|
|
343
443
|
// List view doesn't need month-based grouping, so skip countByYearMonth.
|
|
344
444
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
})
|
|
363
|
-
|
|
364
|
-
|
|
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
|
+
};
|
|
365
501
|
|
|
366
502
|
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
|
367
503
|
|
|
@@ -446,57 +582,89 @@ export async function renderArchivePage(
|
|
|
446
582
|
|
|
447
583
|
// --- Build active filter state for UI -------------------------------------
|
|
448
584
|
|
|
449
|
-
const effectiveVisibility = navData.isAuthenticated
|
|
450
|
-
? params.visibilityAll
|
|
451
|
-
? undefined
|
|
452
|
-
: (params.visibility ?? undefined)
|
|
453
|
-
: undefined;
|
|
454
|
-
|
|
455
585
|
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,
|
|
586
|
+
selection: params.selection,
|
|
587
|
+
layout: params.layout,
|
|
466
588
|
sort: params.sort === "updated" ? "updated" : undefined,
|
|
467
589
|
};
|
|
468
590
|
|
|
469
|
-
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;
|
|
470
599
|
|
|
471
600
|
const availableCollectionsList = allCollections.map((col) => ({
|
|
601
|
+
id: col.id,
|
|
472
602
|
slug: col.slug,
|
|
473
603
|
title: col.title,
|
|
474
604
|
}));
|
|
475
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
|
+
|
|
476
626
|
return renderPublicPage(c, {
|
|
477
|
-
//
|
|
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.
|
|
478
630
|
title: buildPageTitle(
|
|
479
|
-
|
|
631
|
+
getArchiveViewTitle(params.selection, getI18n(c), dimensionCtx),
|
|
480
632
|
params.sort === "updated" ? "Recently updated" : undefined,
|
|
481
633
|
navData.siteName,
|
|
482
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,
|
|
483
651
|
navData,
|
|
484
652
|
content: (
|
|
485
653
|
<ArchivePage
|
|
486
654
|
groups={groups}
|
|
487
655
|
items={flatItems}
|
|
656
|
+
defaultLayout={appConfig.archiveDefaultLayout}
|
|
488
657
|
totalCount={totalCount}
|
|
658
|
+
baselineCount={baselineCount}
|
|
489
659
|
currentPage={params.currentPage}
|
|
490
660
|
totalPages={totalPages}
|
|
491
661
|
filters={archiveFilters}
|
|
492
662
|
availableYears={availableYears}
|
|
493
663
|
availableCollections={availableCollectionsList}
|
|
494
664
|
isAuthenticated={navData.isAuthenticated}
|
|
495
|
-
|
|
665
|
+
basePath={navData.basePath}
|
|
496
666
|
timeZone={appConfig.timeZone}
|
|
497
|
-
feedHref={
|
|
498
|
-
appConfig.rssFeedsEnabled ? `/archive/feed${feedQuery}` : undefined
|
|
499
|
-
}
|
|
667
|
+
feedHref={feedHref}
|
|
500
668
|
/>
|
|
501
669
|
),
|
|
502
670
|
});
|
|
@@ -506,130 +674,51 @@ export async function renderArchivePage(
|
|
|
506
674
|
// Archive page route
|
|
507
675
|
// =============================================================================
|
|
508
676
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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);
|
|
512
688
|
return renderArchivePage(c);
|
|
513
|
-
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
archiveRoutes.get("/", renderArchiveRoute);
|
|
514
692
|
|
|
515
693
|
// =============================================================================
|
|
516
694
|
// Archive feed
|
|
517
695
|
// =============================================================================
|
|
518
696
|
|
|
519
697
|
/**
|
|
520
|
-
* 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.
|
|
521
706
|
*
|
|
522
707
|
* @param c - Hono context
|
|
523
708
|
* @param params - Parsed archive filter params
|
|
524
|
-
* @param
|
|
525
|
-
* @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"
|
|
526
713
|
*/
|
|
527
|
-
function
|
|
714
|
+
function buildArchiveFeedLabel(
|
|
528
715
|
c: Context<Env>,
|
|
529
716
|
params: ParsedArchiveParams,
|
|
530
|
-
|
|
717
|
+
ctx: DimensionContext,
|
|
531
718
|
): string {
|
|
532
719
|
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
720
|
|
|
564
|
-
|
|
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
|
-
|
|
588
|
-
if (params.hasMedia === true) {
|
|
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
|
-
}
|
|
721
|
+
const parts = describeArchiveFilters(params.selection, i18n, ctx);
|
|
633
722
|
|
|
634
723
|
const archiveLabel = i18n._(
|
|
635
724
|
msg({
|
|
@@ -639,71 +728,67 @@ function buildArchiveFeedTitle(
|
|
|
639
728
|
);
|
|
640
729
|
|
|
641
730
|
if (parts.length === 0) {
|
|
642
|
-
return
|
|
731
|
+
return archiveLabel;
|
|
643
732
|
}
|
|
644
733
|
|
|
645
|
-
return `${
|
|
734
|
+
return `${archiveLabel}: ${parts.join(", ")}`;
|
|
646
735
|
}
|
|
647
736
|
|
|
648
737
|
async function buildArchiveFeedData(
|
|
649
738
|
c: Context<Env>,
|
|
650
739
|
selfPath: string,
|
|
651
|
-
): Promise<FeedData> {
|
|
740
|
+
): Promise<FeedData | null> {
|
|
652
741
|
const { appConfig, services } = c.var;
|
|
653
|
-
const params = parseArchiveParams(c);
|
|
742
|
+
const { params, collections } = await parseArchiveParams(c);
|
|
654
743
|
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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
|
+
};
|
|
658
754
|
const rssPublishedBefore = getRssPublishedBefore(
|
|
659
755
|
appConfig.rssPublishDelaySeconds,
|
|
660
756
|
);
|
|
661
|
-
const yearPublishedBefore = params.validYear
|
|
662
|
-
? Date.UTC(params.validYear + 1, 0, 1) / 1000
|
|
663
|
-
: undefined;
|
|
664
757
|
|
|
665
758
|
// Feed mirrors the unauthenticated archive page: published + non-private,
|
|
666
759
|
// including Hidden-from-Latest. /archive is the canonical "all posts" view,
|
|
667
760
|
// so its feed must match.
|
|
668
761
|
//
|
|
669
762
|
// 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
|
-
//
|
|
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
|
|
673
766
|
// key entries by id and will not re-surface one that merely moved.
|
|
674
767
|
// /latest/feed is the activity feed; this one is the chronological record.
|
|
675
768
|
//
|
|
676
769
|
// `?sort=updated` opts into the same axis the page uses, for a subscriber
|
|
677
770
|
// who wants that trade-off deliberately.
|
|
678
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
|
+
});
|
|
679
778
|
const filters: PostFilters = {
|
|
680
|
-
|
|
779
|
+
lang: getViewLang(c) ?? undefined,
|
|
681
780
|
status: "published",
|
|
682
781
|
excludeReplies: true,
|
|
683
782
|
excludePrivate: true,
|
|
684
783
|
excludeLatestHidden: false,
|
|
685
|
-
|
|
686
|
-
mediaKinds: params.mediaKinds,
|
|
687
|
-
hasMedia: params.hasMedia,
|
|
688
|
-
hasTitle: params.hasTitle,
|
|
689
|
-
hasReplies: params.hasReplies,
|
|
784
|
+
...selectionFilters,
|
|
690
785
|
sortBy: sortsByActivity ? "thread_updated" : "published",
|
|
691
786
|
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
787
|
publishedBefore:
|
|
703
|
-
|
|
788
|
+
selectionFilters.publishedBefore === undefined
|
|
704
789
|
? rssPublishedBefore
|
|
705
|
-
: Math.min(
|
|
706
|
-
limit:
|
|
790
|
+
: Math.min(selectionFilters.publishedBefore, rssPublishedBefore),
|
|
791
|
+
limit: getFeedLimit(c),
|
|
707
792
|
};
|
|
708
793
|
|
|
709
794
|
const posts = await services.posts.list(filters);
|
|
@@ -793,16 +878,20 @@ async function buildArchiveFeedData(
|
|
|
793
878
|
};
|
|
794
879
|
});
|
|
795
880
|
|
|
796
|
-
const feedQuery = buildArchiveFeedQuery(params);
|
|
881
|
+
const feedQuery = buildArchiveFeedQuery(params, dimensionCtx);
|
|
797
882
|
|
|
798
883
|
return {
|
|
884
|
+
...buildFeedDiscoveryFields(c, { query: feedQuery }),
|
|
799
885
|
siteName: appConfig.siteName,
|
|
800
886
|
siteDescription: toPlainText(appConfig.siteDescription),
|
|
801
887
|
siteUrl: appConfig.siteUrl,
|
|
802
|
-
siteLanguage: appConfig.siteLanguage,
|
|
803
|
-
title:
|
|
888
|
+
siteLanguage: getViewLang(c) ?? appConfig.siteLanguage,
|
|
889
|
+
title: buildPageTitle(
|
|
890
|
+
appConfig.siteName,
|
|
891
|
+
buildArchiveFeedLabel(c, params, dimensionCtx),
|
|
892
|
+
),
|
|
804
893
|
selfUrl: toAbsoluteSiteUrl(
|
|
805
|
-
`${selfPath}${feedQuery}`,
|
|
894
|
+
`${viewBasePath(c)}${selfPath}${feedQuery}`,
|
|
806
895
|
appConfig.siteUrl,
|
|
807
896
|
appConfig.sitePathPrefix,
|
|
808
897
|
),
|
|
@@ -810,25 +899,38 @@ async function buildArchiveFeedData(
|
|
|
810
899
|
};
|
|
811
900
|
}
|
|
812
901
|
|
|
813
|
-
|
|
814
|
-
|
|
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
|
+
|
|
815
917
|
const feedData = await buildArchiveFeedData(c, "/archive/feed");
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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);
|
|
823
927
|
|
|
824
928
|
// Legacy atom.xml redirect
|
|
825
|
-
|
|
826
|
-
const sitePathPrefix = c.var.appConfig.sitePathPrefix;
|
|
929
|
+
export function redirectLegacyArchiveFeed(c: Context<Env>): Response {
|
|
827
930
|
const qs = c.req.url.includes("?")
|
|
828
931
|
? c.req.url.slice(c.req.url.indexOf("?"))
|
|
829
932
|
: "";
|
|
830
|
-
return c.redirect(
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
});
|
|
933
|
+
return c.redirect(`${toViewPath(c, "/archive/feed")}${qs}`, 308);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
archiveRoutes.get("/feed/atom.xml", redirectLegacyArchiveFeed);
|