@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
|
@@ -0,0 +1,1349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post filter dimensions — one declaration per dimension, read by every surface.
|
|
3
|
+
*
|
|
4
|
+
* A "dimension" is one axis a reader can narrow posts along: format, year,
|
|
5
|
+
* collection, media, title presence, reply presence, visibility. Each one needs
|
|
6
|
+
* a query-string spelling, a parser, a serializer, a translation into
|
|
7
|
+
* {@link PostFilters}, and a reader-facing name. Written separately, those live
|
|
8
|
+
* in as many files as there are surfaces, and the same vocabulary ends up stored
|
|
9
|
+
* several times over.
|
|
10
|
+
*
|
|
11
|
+
* That is not a theoretical risk here. Before this module the archive kept
|
|
12
|
+
* **four** implementations of one vocabulary: the page's parser, the public
|
|
13
|
+
* API's Zod schema, the chip bar's URL writer, and the feed's self-URL writer.
|
|
14
|
+
* They had already drifted — `?collection=` accepted a list on the API and a
|
|
15
|
+
* single slug on the page.
|
|
16
|
+
*
|
|
17
|
+
* The rule this module encodes: **a dimension is declared once.** Adding an
|
|
18
|
+
* eighth one touches this file and the storage schema, never a surface.
|
|
19
|
+
*
|
|
20
|
+
* Naming is deliberately neutral. The vocabulary belongs to "filtering posts",
|
|
21
|
+
* not to any one page, so both the archive and anything built on top of it can
|
|
22
|
+
* depend on it without either looking like it depends on the other.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { msg } from "@lingui/core/macro";
|
|
26
|
+
import type { MessageDescriptor } from "@lingui/core";
|
|
27
|
+
import type { I18n } from "../i18n/i18n.js";
|
|
28
|
+
// Imported from `types/constants.js` rather than `types/props.js`: the props
|
|
29
|
+
// module reads this one for `PostFilterSelection`, and the vocabulary should not
|
|
30
|
+
// depend on the viewmodel that displays it.
|
|
31
|
+
import type {
|
|
32
|
+
ArchiveVisibility,
|
|
33
|
+
Format,
|
|
34
|
+
MediaKind,
|
|
35
|
+
} from "../types/constants.js";
|
|
36
|
+
import {
|
|
37
|
+
EARLIEST_FILTERABLE_YEAR,
|
|
38
|
+
FORMATS,
|
|
39
|
+
LATEST_FILTERABLE_YEAR,
|
|
40
|
+
MEDIA_KINDS,
|
|
41
|
+
PUBLIC_ARCHIVE_VISIBILITIES,
|
|
42
|
+
} from "../types/constants.js";
|
|
43
|
+
import { ID_PREFIX, isTypeId } from "./ids.js";
|
|
44
|
+
import type { PostFilters } from "../services/post.js";
|
|
45
|
+
|
|
46
|
+
type Translator = Pick<I18n, "_">;
|
|
47
|
+
|
|
48
|
+
// =============================================================================
|
|
49
|
+
// Values
|
|
50
|
+
// =============================================================================
|
|
51
|
+
|
|
52
|
+
/** Every dimension, in the order a reader-facing summary lists them. */
|
|
53
|
+
export const FILTER_DIMENSION_KEYS = [
|
|
54
|
+
"collection",
|
|
55
|
+
"format",
|
|
56
|
+
"title",
|
|
57
|
+
"year",
|
|
58
|
+
"media",
|
|
59
|
+
"replies",
|
|
60
|
+
"visibility",
|
|
61
|
+
] as const;
|
|
62
|
+
|
|
63
|
+
export type FilterDimensionKey = (typeof FILTER_DIMENSION_KEYS)[number];
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* What `media` selects, in the one folded vocabulary the URL, the storage
|
|
67
|
+
* column, and the editing control all share.
|
|
68
|
+
*
|
|
69
|
+
* `any` and `none` ask about presence; a list of kinds asks for those kinds.
|
|
70
|
+
* Splitting this into a presence flag plus a kind list would create states that
|
|
71
|
+
* cannot mean anything — "no media, of kinds image and video" — so it stays one
|
|
72
|
+
* value. Only {@link toPostFilters} fans it out, because `PostFilters` happens
|
|
73
|
+
* to carry two fields.
|
|
74
|
+
*/
|
|
75
|
+
export type MediaSelection = "any" | "none" | readonly MediaKind[];
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Is this a {@link MediaSelection}, in either of its two shapes?
|
|
79
|
+
*
|
|
80
|
+
* A predicate rather than a schema, like every other storage check here: the
|
|
81
|
+
* editing dialog compiles this registry into the browser bundle, and a
|
|
82
|
+
* validation library is not something a page should download in order to read a
|
|
83
|
+
* query string. The one schema an API boundary needs is assembled from these
|
|
84
|
+
* predicates in `lib/schemas.ts`.
|
|
85
|
+
*/
|
|
86
|
+
export function isMediaSelection(value: unknown): value is MediaSelection {
|
|
87
|
+
if (value === "any" || value === "none") return true;
|
|
88
|
+
return (
|
|
89
|
+
Array.isArray(value) &&
|
|
90
|
+
value.length > 0 &&
|
|
91
|
+
value.every((kind) =>
|
|
92
|
+
(MEDIA_KINDS as readonly string[]).includes(kind as string),
|
|
93
|
+
)
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The value type each dimension carries once parsed. */
|
|
98
|
+
export interface FilterDimensionValues {
|
|
99
|
+
/** Collection ids, OR'd together: a post in any one of them matches. */
|
|
100
|
+
collection: readonly string[];
|
|
101
|
+
format: Format;
|
|
102
|
+
/** `true` = has a title, `false` = has none. */
|
|
103
|
+
title: boolean;
|
|
104
|
+
/** Publication year (Gregorian, UTC). */
|
|
105
|
+
year: number;
|
|
106
|
+
media: MediaSelection;
|
|
107
|
+
/** `true` = thread roots with replies, `false` = posts without any. */
|
|
108
|
+
replies: boolean;
|
|
109
|
+
visibility: ArchiveVisibility;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* One reader's selection: every dimension optional, at most one value each.
|
|
114
|
+
*
|
|
115
|
+
* There is no "match any" mode and no repeated dimension. Both would be lies —
|
|
116
|
+
* the conditions are always AND'd, and every dimension but `media` and
|
|
117
|
+
* `collection` is a single column.
|
|
118
|
+
*/
|
|
119
|
+
export type PostFilterSelection = {
|
|
120
|
+
readonly [K in FilterDimensionKey]?: FilterDimensionValues[K];
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// =============================================================================
|
|
124
|
+
// Context
|
|
125
|
+
// =============================================================================
|
|
126
|
+
|
|
127
|
+
/** Collections, in the three lookups the `collection` dimension needs. */
|
|
128
|
+
export interface CollectionVocabulary {
|
|
129
|
+
idBySlug: ReadonlyMap<string, string>;
|
|
130
|
+
slugById: ReadonlyMap<string, string>;
|
|
131
|
+
titleById: ReadonlyMap<string, string>;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Build the collection lookups from whatever the caller already has loaded.
|
|
136
|
+
*
|
|
137
|
+
* @param collections - Every collection on the site
|
|
138
|
+
* @returns Slug/id/title lookups for {@link DimensionContext}
|
|
139
|
+
* @example
|
|
140
|
+
* buildCollectionVocabulary(await services.collections.list());
|
|
141
|
+
*/
|
|
142
|
+
export function buildCollectionVocabulary(
|
|
143
|
+
collections: readonly { id: string; slug: string; title: string }[],
|
|
144
|
+
): CollectionVocabulary {
|
|
145
|
+
const idBySlug = new Map<string, string>();
|
|
146
|
+
const slugById = new Map<string, string>();
|
|
147
|
+
const titleById = new Map<string, string>();
|
|
148
|
+
for (const collection of collections) {
|
|
149
|
+
idBySlug.set(collection.slug, collection.id);
|
|
150
|
+
slugById.set(collection.id, collection.slug);
|
|
151
|
+
titleById.set(collection.id, collection.title);
|
|
152
|
+
}
|
|
153
|
+
return { idBySlug, slugById, titleById };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** An empty vocabulary, for callers with no collection selection to resolve. */
|
|
157
|
+
export const EMPTY_COLLECTION_VOCABULARY: CollectionVocabulary = {
|
|
158
|
+
idBySlug: new Map(),
|
|
159
|
+
slugById: new Map(),
|
|
160
|
+
titleById: new Map(),
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Which time column `year` narrows.
|
|
165
|
+
*
|
|
166
|
+
* - `published` — when the Thread root was published. The historical record,
|
|
167
|
+
* and the only honest axis for a stored selection: membership must not depend
|
|
168
|
+
* on how the page happens to be sorted today.
|
|
169
|
+
* - `sort` — whichever column the query sorts by. The archive uses this so
|
|
170
|
+
* every month bucket shown under `year=N` really belongs to that year.
|
|
171
|
+
*/
|
|
172
|
+
export type YearAxis = "published" | "sort";
|
|
173
|
+
|
|
174
|
+
/** Everything a dimension needs that it cannot hold itself. */
|
|
175
|
+
export interface DimensionContext {
|
|
176
|
+
/** Required whenever a selection names collections. */
|
|
177
|
+
collections?: CollectionVocabulary;
|
|
178
|
+
/** Defaults to `published`. */
|
|
179
|
+
yearAxis?: YearAxis;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// =============================================================================
|
|
183
|
+
// URL parsing
|
|
184
|
+
// =============================================================================
|
|
185
|
+
|
|
186
|
+
/** Reads one query parameter, or `undefined` when the URL omits it. */
|
|
187
|
+
export type ParamReader = (key: string) => string | undefined;
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* What a query string said about one dimension.
|
|
191
|
+
*
|
|
192
|
+
* `cleared` and `absent` are separate outcomes because a URL can say "I looked
|
|
193
|
+
* at this dimension and chose nothing" (`visibility=all`); they filter
|
|
194
|
+
* identically but a strict caller must not treat the word as unknown.
|
|
195
|
+
*/
|
|
196
|
+
export type DimensionParse<V> =
|
|
197
|
+
| { state: "absent" }
|
|
198
|
+
| { state: "cleared" }
|
|
199
|
+
| { state: "value"; value: V }
|
|
200
|
+
| { state: "invalid"; message: string };
|
|
201
|
+
|
|
202
|
+
/** One rejected parameter, in the shape an API error or a refusal can use. */
|
|
203
|
+
export interface FilterParseIssue {
|
|
204
|
+
param: string;
|
|
205
|
+
message: string;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// =============================================================================
|
|
209
|
+
// Reading stored columns
|
|
210
|
+
// =============================================================================
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Read a stored boolean, whichever way the dialect spells one.
|
|
214
|
+
*
|
|
215
|
+
* SQLite keeps booleans as `0`/`1` integers and Postgres as real booleans, and
|
|
216
|
+
* a value that arrives as neither is not a third state — it is a column this
|
|
217
|
+
* dimension cannot read, which reads as "not selected".
|
|
218
|
+
*/
|
|
219
|
+
function readStoredBoolean(raw: unknown): boolean | null {
|
|
220
|
+
if (typeof raw === "boolean") return raw;
|
|
221
|
+
if (raw === 1 || raw === 0) return raw === 1;
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Read a stored {@link MediaSelection} back out of its folded column. */
|
|
226
|
+
function readStoredMediaSelection(raw: unknown): MediaSelection | null {
|
|
227
|
+
if (typeof raw !== "string" || raw === "") return null;
|
|
228
|
+
if (raw === "any" || raw === "none") return raw;
|
|
229
|
+
const kinds = raw
|
|
230
|
+
.split(",")
|
|
231
|
+
.map((part) => part.trim())
|
|
232
|
+
.filter((part): part is MediaKind =>
|
|
233
|
+
(MEDIA_KINDS as readonly string[]).includes(part),
|
|
234
|
+
);
|
|
235
|
+
return kinds.length > 0 ? kinds : null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// =============================================================================
|
|
239
|
+
// Dimension declarations
|
|
240
|
+
// =============================================================================
|
|
241
|
+
|
|
242
|
+
interface DimensionUrl<V> {
|
|
243
|
+
/** The canonical query-string name — the only one ever written. */
|
|
244
|
+
param: string;
|
|
245
|
+
/**
|
|
246
|
+
* Older spellings still read but never written.
|
|
247
|
+
*
|
|
248
|
+
* Real query strings hold these: bookmarks, feed subscriptions, and stored
|
|
249
|
+
* custom archive paths. A strict parser must recognise them too, or it will
|
|
250
|
+
* call a URL it perfectly understands unparseable.
|
|
251
|
+
*/
|
|
252
|
+
legacy?: readonly string[];
|
|
253
|
+
parse(read: ParamReader, ctx: DimensionContext): DimensionParse<V>;
|
|
254
|
+
/** The canonical value string, or `null` when the value cannot be spelled. */
|
|
255
|
+
serialize(value: V, ctx: DimensionContext): string | null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* How a stored selection keeps this dimension.
|
|
260
|
+
*
|
|
261
|
+
* A smart collection is a selection written down, so every dimension needs one
|
|
262
|
+
* column, one validator for what may go in it, and the two conversions between
|
|
263
|
+
* the column and the value. Declaring them here is what keeps a schema change
|
|
264
|
+
* from needing an edit in the service, the API, and the editor as well.
|
|
265
|
+
*/
|
|
266
|
+
interface DimensionStorage<V> {
|
|
267
|
+
/**
|
|
268
|
+
* The Drizzle property this dimension occupies on `smart_collection`.
|
|
269
|
+
*
|
|
270
|
+
* Both dialect schemas name it identically; the SQL column is its snake_case
|
|
271
|
+
* form. `null` in the column always means "not selected".
|
|
272
|
+
*/
|
|
273
|
+
column: string;
|
|
274
|
+
/**
|
|
275
|
+
* May the column hold this value? Narrower than the URL vocabulary can parse:
|
|
276
|
+
* `visibility=private` is a URL a reader may write and a value a smart
|
|
277
|
+
* collection may never store.
|
|
278
|
+
*/
|
|
279
|
+
isStorable(value: unknown): value is V;
|
|
280
|
+
/** The column value for this dimension's value. */
|
|
281
|
+
toColumn(value: V): string | number | boolean;
|
|
282
|
+
/** Read a column back, or `null` when it is unset or unreadable. */
|
|
283
|
+
fromColumn(raw: unknown): V | null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The control an editor renders for this dimension.
|
|
288
|
+
*
|
|
289
|
+
* Two rules make this small enough to stay small.
|
|
290
|
+
*
|
|
291
|
+
* First: there is no "unset" option in any of them. A condition row exists or it
|
|
292
|
+
* does not, and that is already how "unset" is said. Offering it twice would be
|
|
293
|
+
* two switches for one state.
|
|
294
|
+
*
|
|
295
|
+
* Second: **a control produces the same string the URL does.** Whatever the
|
|
296
|
+
* reader picks is handed back through `url.parse` and written with
|
|
297
|
+
* `url.serialize`, so a control never carries a parser of its own. That is what
|
|
298
|
+
* keeps the union to rendering shapes — a hypothetical `rating` dimension is an
|
|
299
|
+
* `enum` over `"1".."5"`, not a new kind — and it is why `media` gets one folded
|
|
300
|
+
* vocabulary rather than a presence flag beside a kind list.
|
|
301
|
+
*/
|
|
302
|
+
export type DimensionControl =
|
|
303
|
+
| {
|
|
304
|
+
kind: "enum";
|
|
305
|
+
options: readonly string[];
|
|
306
|
+
labelOf(value: string): MessageDescriptor;
|
|
307
|
+
}
|
|
308
|
+
| { kind: "year" }
|
|
309
|
+
| { kind: "collection" }
|
|
310
|
+
/** any / none / a multi-select of kinds, over the one folded vocabulary. */
|
|
311
|
+
| { kind: "media" }
|
|
312
|
+
/** Two states. "Unset" is the row not being there. */
|
|
313
|
+
| { kind: "presence"; yes: MessageDescriptor; no: MessageDescriptor };
|
|
314
|
+
|
|
315
|
+
interface Dimension<K extends FilterDimensionKey> {
|
|
316
|
+
/** Stable identity, and the row key in an editing UI. */
|
|
317
|
+
key: K;
|
|
318
|
+
/** The dimension's own name, for a menu of dimensions. */
|
|
319
|
+
label: MessageDescriptor;
|
|
320
|
+
url: DimensionUrl<FilterDimensionValues[K]>;
|
|
321
|
+
storage: DimensionStorage<FilterDimensionValues[K]>;
|
|
322
|
+
control: DimensionControl;
|
|
323
|
+
/** This value's slice of a `PostFilters`. */
|
|
324
|
+
toPostFilter(
|
|
325
|
+
value: FilterDimensionValues[K],
|
|
326
|
+
ctx: DimensionContext,
|
|
327
|
+
): Partial<PostFilters>;
|
|
328
|
+
/**
|
|
329
|
+
* How this value reads to a reader, on its own.
|
|
330
|
+
*
|
|
331
|
+
* Plain and non-absorbing: a surface that wants "Untitled" to stand in for
|
|
332
|
+
* "Notes, without a title" composes that itself. Returns `null` when the
|
|
333
|
+
* value cannot be named — an unresolvable collection id.
|
|
334
|
+
*/
|
|
335
|
+
describe(
|
|
336
|
+
value: FilterDimensionValues[K],
|
|
337
|
+
i18n: Translator,
|
|
338
|
+
ctx: DimensionContext,
|
|
339
|
+
): string | null;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// --- collection --------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Separators accepted between collection slugs.
|
|
346
|
+
*
|
|
347
|
+
* A comma is what gets written. `+` is accepted because `/collections/{a+b}`
|
|
348
|
+
* spells a selection that way in a path — but in a *query string* `+` is
|
|
349
|
+
* form-encoding for a space, so by the time a value reaches here it has already
|
|
350
|
+
* become one. Both are listed: the space is what actually arrives, and the `+`
|
|
351
|
+
* covers a caller that percent-escaped it.
|
|
352
|
+
*/
|
|
353
|
+
const COLLECTION_SLUG_SEPARATORS = /[,+\s]+/;
|
|
354
|
+
|
|
355
|
+
function parseCollectionSlugs(raw: string): string[] {
|
|
356
|
+
const seen = new Set<string>();
|
|
357
|
+
for (const part of raw.split(COLLECTION_SLUG_SEPARATORS)) {
|
|
358
|
+
const slug = part.trim();
|
|
359
|
+
if (slug) seen.add(slug);
|
|
360
|
+
}
|
|
361
|
+
return [...seen];
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* The collection slugs a query string names, before any of them is resolved.
|
|
366
|
+
*
|
|
367
|
+
* Split out so a caller can decide whether it needs to load the collection
|
|
368
|
+
* vocabulary at all. The archive renders without one on every unfiltered
|
|
369
|
+
* request, and paying for a lookup to discover that would cost a round trip on
|
|
370
|
+
* the site's widest reader page.
|
|
371
|
+
*
|
|
372
|
+
* @param read - Query parameter reader
|
|
373
|
+
* @returns Slugs in the order written, deduplicated; empty when none is named
|
|
374
|
+
* @example
|
|
375
|
+
* readCollectionSlugs((k) => (k === "collection" ? "tech,art" : undefined));
|
|
376
|
+
* // ["tech", "art"]
|
|
377
|
+
*/
|
|
378
|
+
export function readCollectionSlugs(read: ParamReader): string[] {
|
|
379
|
+
const raw = read(COLLECTION_DIMENSION.url.param);
|
|
380
|
+
return raw ? parseCollectionSlugs(raw) : [];
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const COLLECTION_DIMENSION: Dimension<"collection"> = {
|
|
384
|
+
key: "collection",
|
|
385
|
+
label: msg({
|
|
386
|
+
message: "Collection",
|
|
387
|
+
comment: "@context: Post filter dimension name - collection membership",
|
|
388
|
+
}),
|
|
389
|
+
url: {
|
|
390
|
+
param: "collection",
|
|
391
|
+
parse(read, ctx) {
|
|
392
|
+
const raw = read("collection");
|
|
393
|
+
if (!raw) return { state: "absent" };
|
|
394
|
+
const slugs = parseCollectionSlugs(raw);
|
|
395
|
+
if (slugs.length === 0) return { state: "absent" };
|
|
396
|
+
|
|
397
|
+
const vocabulary = ctx.collections;
|
|
398
|
+
const ids: string[] = [];
|
|
399
|
+
for (const slug of slugs) {
|
|
400
|
+
const id = vocabulary?.idBySlug.get(slug);
|
|
401
|
+
// A slug that resolves to nothing is never dropped. Dropping it renders
|
|
402
|
+
// the whole archive under a name the reader typed, with the heading and
|
|
403
|
+
// the feed title both pretending the word was never there.
|
|
404
|
+
if (!id) {
|
|
405
|
+
return {
|
|
406
|
+
state: "invalid",
|
|
407
|
+
message: `No collection named "${slug}".`,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
ids.push(id);
|
|
411
|
+
}
|
|
412
|
+
return { state: "value", value: ids };
|
|
413
|
+
},
|
|
414
|
+
serialize(value, ctx) {
|
|
415
|
+
const slugs = value
|
|
416
|
+
.map((id) => ctx.collections?.slugById.get(id))
|
|
417
|
+
.filter((slug): slug is string => Boolean(slug));
|
|
418
|
+
return slugs.length > 0 ? slugs.join(",") : null;
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
storage: {
|
|
422
|
+
column: "collectionId",
|
|
423
|
+
// The archive can OR several collections together; a smart collection
|
|
424
|
+
// deliberately cannot (see the feature notes on OR within a dimension), so
|
|
425
|
+
// the column is a single foreign key and the stored value is a list of one.
|
|
426
|
+
// Keeping the value shape identical either way is what lets one registry
|
|
427
|
+
// serve both.
|
|
428
|
+
isStorable(value): value is readonly string[] {
|
|
429
|
+
return (
|
|
430
|
+
Array.isArray(value) &&
|
|
431
|
+
value.length === 1 &&
|
|
432
|
+
typeof value[0] === "string" &&
|
|
433
|
+
isTypeId(value[0], ID_PREFIX.collection)
|
|
434
|
+
);
|
|
435
|
+
},
|
|
436
|
+
toColumn(value) {
|
|
437
|
+
return value[0] as string;
|
|
438
|
+
},
|
|
439
|
+
fromColumn(raw) {
|
|
440
|
+
return typeof raw === "string" && raw ? [raw] : null;
|
|
441
|
+
},
|
|
442
|
+
},
|
|
443
|
+
control: { kind: "collection" },
|
|
444
|
+
toPostFilter(value) {
|
|
445
|
+
return { collectionIds: [...value] };
|
|
446
|
+
},
|
|
447
|
+
describe(value, _i18n, ctx) {
|
|
448
|
+
const titles = value
|
|
449
|
+
.map((id) => ctx.collections?.titleById.get(id))
|
|
450
|
+
.filter((title): title is string => Boolean(title));
|
|
451
|
+
return titles.length > 0 ? titles.join(", ") : null;
|
|
452
|
+
},
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
// --- format ------------------------------------------------------------------
|
|
456
|
+
|
|
457
|
+
const FORMAT_LABELS_PLURAL: Record<Format, MessageDescriptor> = {
|
|
458
|
+
note: msg({
|
|
459
|
+
message: "Notes",
|
|
460
|
+
comment: "@context: Post format label plural - notes",
|
|
461
|
+
}),
|
|
462
|
+
link: msg({
|
|
463
|
+
message: "Links",
|
|
464
|
+
comment: "@context: Post format label plural - links",
|
|
465
|
+
}),
|
|
466
|
+
quote: msg({
|
|
467
|
+
message: "Quotes",
|
|
468
|
+
comment: "@context: Post format label plural - quotes",
|
|
469
|
+
}),
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
const FORMAT_DIMENSION: Dimension<"format"> = {
|
|
473
|
+
key: "format",
|
|
474
|
+
label: msg({
|
|
475
|
+
message: "Format",
|
|
476
|
+
comment: "@context: Post filter dimension name - note, link, or quote",
|
|
477
|
+
}),
|
|
478
|
+
url: {
|
|
479
|
+
param: "format",
|
|
480
|
+
parse(read) {
|
|
481
|
+
const raw = read("format");
|
|
482
|
+
if (!raw) return { state: "absent" };
|
|
483
|
+
if (!(FORMATS as readonly string[]).includes(raw)) {
|
|
484
|
+
return {
|
|
485
|
+
state: "invalid",
|
|
486
|
+
message: `Invalid format value. Allowed: ${FORMATS.join(", ")}`,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
return { state: "value", value: raw as Format };
|
|
490
|
+
},
|
|
491
|
+
serialize(value) {
|
|
492
|
+
return value;
|
|
493
|
+
},
|
|
494
|
+
},
|
|
495
|
+
storage: {
|
|
496
|
+
column: "format",
|
|
497
|
+
isStorable(value): value is Format {
|
|
498
|
+
return (
|
|
499
|
+
typeof value === "string" &&
|
|
500
|
+
(FORMATS as readonly string[]).includes(value)
|
|
501
|
+
);
|
|
502
|
+
},
|
|
503
|
+
toColumn(value) {
|
|
504
|
+
return value;
|
|
505
|
+
},
|
|
506
|
+
fromColumn(raw) {
|
|
507
|
+
return (FORMATS as readonly string[]).includes(raw as string)
|
|
508
|
+
? (raw as Format)
|
|
509
|
+
: null;
|
|
510
|
+
},
|
|
511
|
+
},
|
|
512
|
+
control: {
|
|
513
|
+
kind: "enum",
|
|
514
|
+
options: FORMATS,
|
|
515
|
+
labelOf: (value) => FORMAT_LABELS_PLURAL[value as Format],
|
|
516
|
+
},
|
|
517
|
+
toPostFilter(value) {
|
|
518
|
+
return { format: value };
|
|
519
|
+
},
|
|
520
|
+
describe(value, i18n) {
|
|
521
|
+
return i18n._(FORMAT_LABELS_PLURAL[value]);
|
|
522
|
+
},
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Plural name of a post format.
|
|
527
|
+
*
|
|
528
|
+
* @param format - Post format key
|
|
529
|
+
* @param i18n - Translator
|
|
530
|
+
* @returns Reader-facing plural, e.g. `Notes`
|
|
531
|
+
* @example
|
|
532
|
+
* getFormatLabelPlural("note", i18n); // "Notes"
|
|
533
|
+
*/
|
|
534
|
+
export function getFormatLabelPlural(format: Format, i18n: Translator): string {
|
|
535
|
+
return i18n._(FORMAT_LABELS_PLURAL[format]);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// --- title -------------------------------------------------------------------
|
|
539
|
+
|
|
540
|
+
const TITLE_PRESENT_LABEL = msg({
|
|
541
|
+
message: "Titled",
|
|
542
|
+
comment: "@context: Archive filter - notes that have a title",
|
|
543
|
+
});
|
|
544
|
+
const TITLE_ABSENT_LABEL = msg({
|
|
545
|
+
message: "Untitled",
|
|
546
|
+
comment: "@context: Archive filter - notes without a title",
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Read a pre-rename `hasX=1|0` presence flag.
|
|
551
|
+
*
|
|
552
|
+
* Stored custom archive paths, bookmarks, and feed subscriptions still carry
|
|
553
|
+
* these, so every parser has to recognise them; nothing writes them any more.
|
|
554
|
+
*/
|
|
555
|
+
function parseLegacyPresence(
|
|
556
|
+
read: ParamReader,
|
|
557
|
+
legacyParam: string,
|
|
558
|
+
): DimensionParse<boolean> {
|
|
559
|
+
const legacy = read(legacyParam);
|
|
560
|
+
if (legacy === undefined) return { state: "absent" };
|
|
561
|
+
if (legacy === "1") return { state: "value", value: true };
|
|
562
|
+
if (legacy === "0") return { state: "value", value: false };
|
|
563
|
+
return {
|
|
564
|
+
state: "invalid",
|
|
565
|
+
message: `Invalid ${legacyParam} value. Allowed: 0, 1`,
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Read a two-state presence parameter, with its pre-rename spelling.
|
|
571
|
+
*
|
|
572
|
+
* `any`/`none` is the current vocabulary; `1`/`0` under a `hasX` name is the
|
|
573
|
+
* legacy one.
|
|
574
|
+
*/
|
|
575
|
+
function parsePresence(
|
|
576
|
+
read: ParamReader,
|
|
577
|
+
param: string,
|
|
578
|
+
legacyParam: string,
|
|
579
|
+
): DimensionParse<boolean> {
|
|
580
|
+
const raw = read(param);
|
|
581
|
+
if (raw !== undefined && raw !== "") {
|
|
582
|
+
if (raw === "any") return { state: "value", value: true };
|
|
583
|
+
if (raw === "none") return { state: "value", value: false };
|
|
584
|
+
return {
|
|
585
|
+
state: "invalid",
|
|
586
|
+
message: `Invalid ${param} value. Allowed: any, none`,
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
return parseLegacyPresence(read, legacyParam);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const TITLE_DIMENSION: Dimension<"title"> = {
|
|
593
|
+
key: "title",
|
|
594
|
+
label: msg({
|
|
595
|
+
message: "Title",
|
|
596
|
+
comment: "@context: Post filter dimension name - whether a post is titled",
|
|
597
|
+
}),
|
|
598
|
+
url: {
|
|
599
|
+
param: "title",
|
|
600
|
+
legacy: ["hasTitle"],
|
|
601
|
+
parse(read) {
|
|
602
|
+
return parsePresence(read, "title", "hasTitle");
|
|
603
|
+
},
|
|
604
|
+
serialize(value) {
|
|
605
|
+
return value ? "any" : "none";
|
|
606
|
+
},
|
|
607
|
+
},
|
|
608
|
+
storage: {
|
|
609
|
+
column: "hasTitle",
|
|
610
|
+
isStorable(value): value is boolean {
|
|
611
|
+
return typeof value === "boolean";
|
|
612
|
+
},
|
|
613
|
+
toColumn(value) {
|
|
614
|
+
return value;
|
|
615
|
+
},
|
|
616
|
+
fromColumn: readStoredBoolean,
|
|
617
|
+
},
|
|
618
|
+
control: {
|
|
619
|
+
kind: "presence",
|
|
620
|
+
yes: TITLE_PRESENT_LABEL,
|
|
621
|
+
no: TITLE_ABSENT_LABEL,
|
|
622
|
+
},
|
|
623
|
+
toPostFilter(value) {
|
|
624
|
+
return { hasTitle: value };
|
|
625
|
+
},
|
|
626
|
+
describe(value, i18n) {
|
|
627
|
+
return i18n._(value ? TITLE_PRESENT_LABEL : TITLE_ABSENT_LABEL);
|
|
628
|
+
},
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
// --- year --------------------------------------------------------------------
|
|
632
|
+
|
|
633
|
+
const YEAR_DIMENSION: Dimension<"year"> = {
|
|
634
|
+
key: "year",
|
|
635
|
+
label: msg({
|
|
636
|
+
message: "Year",
|
|
637
|
+
comment: "@context: Post filter dimension name - year of publication",
|
|
638
|
+
}),
|
|
639
|
+
url: {
|
|
640
|
+
param: "year",
|
|
641
|
+
parse(read) {
|
|
642
|
+
const raw = read("year");
|
|
643
|
+
if (!raw) return { state: "absent" };
|
|
644
|
+
const year = Number.parseInt(raw, 10);
|
|
645
|
+
// Bounded at both ends, and the ceiling is the load-bearing one:
|
|
646
|
+
// `toPostFilter` turns a year into `Date.UTC` bounds, which go `NaN` past
|
|
647
|
+
// year 275760. A NaN bound is a comparison every row fails, so the page
|
|
648
|
+
// would render empty with nothing to say why.
|
|
649
|
+
if (
|
|
650
|
+
!Number.isFinite(year) ||
|
|
651
|
+
year < EARLIEST_FILTERABLE_YEAR ||
|
|
652
|
+
year > LATEST_FILTERABLE_YEAR
|
|
653
|
+
) {
|
|
654
|
+
return {
|
|
655
|
+
state: "invalid",
|
|
656
|
+
message: `Invalid year value. Allowed: ${EARLIEST_FILTERABLE_YEAR} to ${LATEST_FILTERABLE_YEAR}`,
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
return { state: "value", value: year };
|
|
660
|
+
},
|
|
661
|
+
serialize(value) {
|
|
662
|
+
return String(value);
|
|
663
|
+
},
|
|
664
|
+
},
|
|
665
|
+
storage: {
|
|
666
|
+
column: "year",
|
|
667
|
+
isStorable(value): value is number {
|
|
668
|
+
return (
|
|
669
|
+
typeof value === "number" &&
|
|
670
|
+
Number.isInteger(value) &&
|
|
671
|
+
value >= EARLIEST_FILTERABLE_YEAR &&
|
|
672
|
+
value <= LATEST_FILTERABLE_YEAR
|
|
673
|
+
);
|
|
674
|
+
},
|
|
675
|
+
toColumn(value) {
|
|
676
|
+
return value;
|
|
677
|
+
},
|
|
678
|
+
fromColumn(raw) {
|
|
679
|
+
return typeof raw === "number" && Number.isInteger(raw) ? raw : null;
|
|
680
|
+
},
|
|
681
|
+
},
|
|
682
|
+
control: { kind: "year" },
|
|
683
|
+
toPostFilter(value, ctx) {
|
|
684
|
+
const after = Date.UTC(value, 0, 1) / 1000;
|
|
685
|
+
const before = Date.UTC(value + 1, 0, 1) / 1000;
|
|
686
|
+
// `PostFilters` has no year field: a year is a pair of timestamp bounds on
|
|
687
|
+
// whichever column the caller is treating as the timeline.
|
|
688
|
+
return ctx.yearAxis === "sort"
|
|
689
|
+
? { axisAfter: after, axisBefore: before }
|
|
690
|
+
: { publishedAfter: after, publishedBefore: before };
|
|
691
|
+
},
|
|
692
|
+
describe(value) {
|
|
693
|
+
return String(value);
|
|
694
|
+
},
|
|
695
|
+
};
|
|
696
|
+
|
|
697
|
+
// --- media -------------------------------------------------------------------
|
|
698
|
+
|
|
699
|
+
const MEDIA_KIND_LABELS: Record<MediaKind, MessageDescriptor> = {
|
|
700
|
+
image: msg({
|
|
701
|
+
message: "Images",
|
|
702
|
+
comment: "@context: Archive media filter - images",
|
|
703
|
+
}),
|
|
704
|
+
video: msg({
|
|
705
|
+
message: "Video",
|
|
706
|
+
comment: "@context: Archive media filter - video",
|
|
707
|
+
}),
|
|
708
|
+
audio: msg({
|
|
709
|
+
message: "Audio",
|
|
710
|
+
comment: "@context: Archive media filter - audio",
|
|
711
|
+
}),
|
|
712
|
+
text: msg({
|
|
713
|
+
message: "Text attachment",
|
|
714
|
+
comment: "@context: Archive media filter - text file attachments",
|
|
715
|
+
}),
|
|
716
|
+
document: msg({
|
|
717
|
+
message: "Files",
|
|
718
|
+
comment: "@context: Archive media filter - files/documents",
|
|
719
|
+
}),
|
|
720
|
+
};
|
|
721
|
+
|
|
722
|
+
const MEDIA_ANY_LABEL = msg({
|
|
723
|
+
message: "With media",
|
|
724
|
+
comment: "@context: Archive filter - posts carrying any media attachment",
|
|
725
|
+
});
|
|
726
|
+
const MEDIA_NONE_LABEL = msg({
|
|
727
|
+
message: "Without media",
|
|
728
|
+
comment: "@context: Archive filter - posts with no media attachment",
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
const MEDIA_DIMENSION: Dimension<"media"> = {
|
|
732
|
+
key: "media",
|
|
733
|
+
label: msg({
|
|
734
|
+
message: "Media",
|
|
735
|
+
comment: "@context: Post filter dimension name - attached media",
|
|
736
|
+
}),
|
|
737
|
+
url: {
|
|
738
|
+
param: "media",
|
|
739
|
+
legacy: ["hasMedia"],
|
|
740
|
+
parse(read) {
|
|
741
|
+
const raw = read("media");
|
|
742
|
+
if (raw === undefined || raw === "") {
|
|
743
|
+
const legacy = parseLegacyPresence(read, "hasMedia");
|
|
744
|
+
return legacy.state === "value"
|
|
745
|
+
? { state: "value", value: legacy.value ? "any" : "none" }
|
|
746
|
+
: legacy;
|
|
747
|
+
}
|
|
748
|
+
if (raw === "any" || raw === "none") {
|
|
749
|
+
return { state: "value", value: raw };
|
|
750
|
+
}
|
|
751
|
+
const parts = raw
|
|
752
|
+
.split(",")
|
|
753
|
+
.map((part) => part.trim())
|
|
754
|
+
.filter((part) => part.length > 0);
|
|
755
|
+
const kinds = parts.filter((part): part is MediaKind =>
|
|
756
|
+
(MEDIA_KINDS as readonly string[]).includes(part),
|
|
757
|
+
);
|
|
758
|
+
if (parts.length === 0 || kinds.length !== parts.length) {
|
|
759
|
+
return {
|
|
760
|
+
state: "invalid",
|
|
761
|
+
message: `Invalid media value. Allowed: any, none, or kinds: ${MEDIA_KINDS.join(", ")}`,
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
return { state: "value", value: kinds };
|
|
765
|
+
},
|
|
766
|
+
serialize(value) {
|
|
767
|
+
if (typeof value === "string") return value;
|
|
768
|
+
// A kind list that names no kind selects nothing. Parsing never produces
|
|
769
|
+
// one, but a hand-built selection can, and `media=` is not a spelling any
|
|
770
|
+
// parser should have to make sense of.
|
|
771
|
+
return value.length > 0 ? value.join(",") : null;
|
|
772
|
+
},
|
|
773
|
+
},
|
|
774
|
+
storage: {
|
|
775
|
+
column: "media",
|
|
776
|
+
isStorable: isMediaSelection,
|
|
777
|
+
// The column holds the same folded string the URL does, so what is stored
|
|
778
|
+
// and what is shared are one vocabulary rather than two.
|
|
779
|
+
toColumn(value) {
|
|
780
|
+
return typeof value === "string" ? value : value.join(",");
|
|
781
|
+
},
|
|
782
|
+
fromColumn: readStoredMediaSelection,
|
|
783
|
+
},
|
|
784
|
+
control: { kind: "media" },
|
|
785
|
+
toPostFilter(value) {
|
|
786
|
+
// The one dimension whose single value spans two `PostFilters` fields.
|
|
787
|
+
if (value === "any") return { hasMedia: true };
|
|
788
|
+
if (value === "none") return { hasMedia: false };
|
|
789
|
+
return value.length > 0 ? { mediaKinds: [...value] } : {};
|
|
790
|
+
},
|
|
791
|
+
describe(value, i18n) {
|
|
792
|
+
if (value === "any") return i18n._(MEDIA_ANY_LABEL);
|
|
793
|
+
if (value === "none") return i18n._(MEDIA_NONE_LABEL);
|
|
794
|
+
// A single kind names itself; several are better summarised than listed.
|
|
795
|
+
if (value.length === 1) {
|
|
796
|
+
return i18n._(MEDIA_KIND_LABELS[value[0] as MediaKind]);
|
|
797
|
+
}
|
|
798
|
+
return value.length > 0 ? i18n._(MEDIA_ANY_LABEL) : null;
|
|
799
|
+
},
|
|
800
|
+
};
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Name of a media kind.
|
|
804
|
+
*
|
|
805
|
+
* @param kind - Media kind key
|
|
806
|
+
* @param i18n - Translator
|
|
807
|
+
* @returns Reader-facing label, e.g. `Images`
|
|
808
|
+
* @example
|
|
809
|
+
* getMediaKindLabel("image", i18n); // "Images"
|
|
810
|
+
*/
|
|
811
|
+
export function getMediaKindLabel(kind: MediaKind, i18n: Translator): string {
|
|
812
|
+
return i18n._(MEDIA_KIND_LABELS[kind]);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// --- replies -----------------------------------------------------------------
|
|
816
|
+
|
|
817
|
+
const REPLIES_PRESENT_LABEL = msg({
|
|
818
|
+
message: "Threads",
|
|
819
|
+
comment: "@context: Archive thread filter - thread roots with replies",
|
|
820
|
+
});
|
|
821
|
+
const REPLIES_ABSENT_LABEL = msg({
|
|
822
|
+
message: "Single posts",
|
|
823
|
+
comment: "@context: Archive thread filter - posts without replies",
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
const REPLIES_DIMENSION: Dimension<"replies"> = {
|
|
827
|
+
key: "replies",
|
|
828
|
+
label: msg({
|
|
829
|
+
message: "Replies",
|
|
830
|
+
comment:
|
|
831
|
+
"@context: Post filter dimension name - whether a thread has replies",
|
|
832
|
+
}),
|
|
833
|
+
url: {
|
|
834
|
+
param: "replies",
|
|
835
|
+
legacy: ["hasReplies"],
|
|
836
|
+
parse(read) {
|
|
837
|
+
return parsePresence(read, "replies", "hasReplies");
|
|
838
|
+
},
|
|
839
|
+
serialize(value) {
|
|
840
|
+
return value ? "any" : "none";
|
|
841
|
+
},
|
|
842
|
+
},
|
|
843
|
+
storage: {
|
|
844
|
+
column: "hasReplies",
|
|
845
|
+
isStorable(value): value is boolean {
|
|
846
|
+
return typeof value === "boolean";
|
|
847
|
+
},
|
|
848
|
+
toColumn(value) {
|
|
849
|
+
return value;
|
|
850
|
+
},
|
|
851
|
+
fromColumn: readStoredBoolean,
|
|
852
|
+
},
|
|
853
|
+
control: {
|
|
854
|
+
kind: "presence",
|
|
855
|
+
yes: REPLIES_PRESENT_LABEL,
|
|
856
|
+
no: REPLIES_ABSENT_LABEL,
|
|
857
|
+
},
|
|
858
|
+
toPostFilter(value) {
|
|
859
|
+
return { hasReplies: value };
|
|
860
|
+
},
|
|
861
|
+
describe(value, i18n) {
|
|
862
|
+
return i18n._(value ? REPLIES_PRESENT_LABEL : REPLIES_ABSENT_LABEL);
|
|
863
|
+
},
|
|
864
|
+
};
|
|
865
|
+
|
|
866
|
+
// --- visibility --------------------------------------------------------------
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* How each visibility is spelled in a URL.
|
|
870
|
+
*
|
|
871
|
+
* `latest_hidden` is the stored value; `hidden` is the word every surface puts
|
|
872
|
+
* in a query string. Keyed by the shared list rather than restating it, so a
|
|
873
|
+
* fifth visibility fails to compile here until it is given a spelling.
|
|
874
|
+
*/
|
|
875
|
+
const VISIBILITY_URL_SPELLING = {
|
|
876
|
+
public: "public",
|
|
877
|
+
featured: "featured",
|
|
878
|
+
latest_hidden: "hidden",
|
|
879
|
+
private: "private",
|
|
880
|
+
} as const satisfies Record<ArchiveVisibility, string>;
|
|
881
|
+
|
|
882
|
+
/** Every visibility a dimension value may hold, for reading a column back. */
|
|
883
|
+
const ARCHIVE_VISIBILITY_VALUES = Object.keys(
|
|
884
|
+
VISIBILITY_URL_SPELLING,
|
|
885
|
+
) as ArchiveVisibility[];
|
|
886
|
+
|
|
887
|
+
const VISIBILITY_BY_URL_VALUE = new Map<string, ArchiveVisibility>(
|
|
888
|
+
Object.entries(VISIBILITY_URL_SPELLING).map(([stored, url]) => [
|
|
889
|
+
url,
|
|
890
|
+
stored as ArchiveVisibility,
|
|
891
|
+
]),
|
|
892
|
+
);
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Pre-rename spellings of a visibility value.
|
|
896
|
+
*
|
|
897
|
+
* `latest_hidden` is the stored spelling, which stored custom archive paths and
|
|
898
|
+
* old bookmarks still carry. `all` predates the discovery that it means exactly
|
|
899
|
+
* what an absent parameter means — it is read as "chose nothing" and never
|
|
900
|
+
* written again.
|
|
901
|
+
*/
|
|
902
|
+
const LEGACY_VISIBILITY_VALUES = new Map<
|
|
903
|
+
string,
|
|
904
|
+
{ state: "value"; value: ArchiveVisibility } | { state: "cleared" }
|
|
905
|
+
>([
|
|
906
|
+
["latest_hidden", { state: "value", value: "latest_hidden" }],
|
|
907
|
+
["all", { state: "cleared" }],
|
|
908
|
+
]);
|
|
909
|
+
|
|
910
|
+
const VISIBILITY_LABELS: Record<ArchiveVisibility, MessageDescriptor> = {
|
|
911
|
+
public: msg({
|
|
912
|
+
message: "Public",
|
|
913
|
+
comment: "@context: Archive visibility filter - public posts",
|
|
914
|
+
}),
|
|
915
|
+
latest_hidden: msg({
|
|
916
|
+
message: "Hidden from Latest",
|
|
917
|
+
comment: "@context: Archive visibility filter for posts hidden from Latest",
|
|
918
|
+
}),
|
|
919
|
+
private: msg({
|
|
920
|
+
message: "Private",
|
|
921
|
+
comment: "@context: Archive visibility filter - private posts",
|
|
922
|
+
}),
|
|
923
|
+
featured: msg({
|
|
924
|
+
message: "Featured",
|
|
925
|
+
comment: "@context: Archive visibility filter - featured posts",
|
|
926
|
+
}),
|
|
927
|
+
};
|
|
928
|
+
|
|
929
|
+
const VISIBILITY_DIMENSION: Dimension<"visibility"> = {
|
|
930
|
+
key: "visibility",
|
|
931
|
+
label: msg({
|
|
932
|
+
message: "Visibility",
|
|
933
|
+
comment: "@context: Post filter dimension name - who can see a post",
|
|
934
|
+
}),
|
|
935
|
+
url: {
|
|
936
|
+
param: "visibility",
|
|
937
|
+
parse(read) {
|
|
938
|
+
const raw = read("visibility");
|
|
939
|
+
if (!raw) return { state: "absent" };
|
|
940
|
+
const legacy = LEGACY_VISIBILITY_VALUES.get(raw);
|
|
941
|
+
if (legacy) return legacy;
|
|
942
|
+
const stored = VISIBILITY_BY_URL_VALUE.get(raw);
|
|
943
|
+
if (!stored) {
|
|
944
|
+
return {
|
|
945
|
+
state: "invalid",
|
|
946
|
+
message: `Invalid visibility value. Allowed: ${[
|
|
947
|
+
...VISIBILITY_BY_URL_VALUE.keys(),
|
|
948
|
+
].join(", ")}`,
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
return { state: "value", value: stored };
|
|
952
|
+
},
|
|
953
|
+
serialize(value) {
|
|
954
|
+
return VISIBILITY_URL_SPELLING[value];
|
|
955
|
+
},
|
|
956
|
+
},
|
|
957
|
+
storage: {
|
|
958
|
+
column: "visibility",
|
|
959
|
+
// Narrower than the URL vocabulary on purpose: a smart collection is a
|
|
960
|
+
// published page, so it can never name the one set only its author sees.
|
|
961
|
+
// `private` parses here and fails validation, which is exactly what lets
|
|
962
|
+
// the "turn this link into a smart collection" flow refuse it by name.
|
|
963
|
+
isStorable(value): value is ArchiveVisibility {
|
|
964
|
+
return (
|
|
965
|
+
typeof value === "string" &&
|
|
966
|
+
(PUBLIC_ARCHIVE_VISIBILITIES as readonly string[]).includes(value)
|
|
967
|
+
);
|
|
968
|
+
},
|
|
969
|
+
toColumn(value) {
|
|
970
|
+
return value;
|
|
971
|
+
},
|
|
972
|
+
fromColumn(raw) {
|
|
973
|
+
return (ARCHIVE_VISIBILITY_VALUES as readonly string[]).includes(
|
|
974
|
+
raw as string,
|
|
975
|
+
)
|
|
976
|
+
? (raw as ArchiveVisibility)
|
|
977
|
+
: null;
|
|
978
|
+
},
|
|
979
|
+
},
|
|
980
|
+
control: {
|
|
981
|
+
kind: "enum",
|
|
982
|
+
options: PUBLIC_ARCHIVE_VISIBILITIES,
|
|
983
|
+
labelOf: (value) => VISIBILITY_LABELS[value as ArchiveVisibility],
|
|
984
|
+
},
|
|
985
|
+
toPostFilter(value) {
|
|
986
|
+
// `featured` is a virtual visibility — a separate flag rather than a stored
|
|
987
|
+
// value — so it lands in a different field than the other three.
|
|
988
|
+
return value === "featured" ? { featured: true } : { visibility: value };
|
|
989
|
+
},
|
|
990
|
+
describe(value, i18n) {
|
|
991
|
+
return i18n._(VISIBILITY_LABELS[value]);
|
|
992
|
+
},
|
|
993
|
+
};
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* Name of a visibility filter value.
|
|
997
|
+
*
|
|
998
|
+
* @param visibility - Visibility filter value
|
|
999
|
+
* @param i18n - Translator
|
|
1000
|
+
* @returns Reader-facing label, e.g. `Hidden from Latest`
|
|
1001
|
+
* @example
|
|
1002
|
+
* getVisibilityLabel("private", i18n); // "Private"
|
|
1003
|
+
*/
|
|
1004
|
+
export function getVisibilityLabel(
|
|
1005
|
+
visibility: ArchiveVisibility,
|
|
1006
|
+
i18n: Translator,
|
|
1007
|
+
): string {
|
|
1008
|
+
return i18n._(VISIBILITY_LABELS[visibility]);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// =============================================================================
|
|
1012
|
+
// The registry
|
|
1013
|
+
// =============================================================================
|
|
1014
|
+
|
|
1015
|
+
/** Every dimension, keyed. The one place each is declared. */
|
|
1016
|
+
export const FILTER_DIMENSIONS = {
|
|
1017
|
+
collection: COLLECTION_DIMENSION,
|
|
1018
|
+
format: FORMAT_DIMENSION,
|
|
1019
|
+
title: TITLE_DIMENSION,
|
|
1020
|
+
year: YEAR_DIMENSION,
|
|
1021
|
+
media: MEDIA_DIMENSION,
|
|
1022
|
+
replies: REPLIES_DIMENSION,
|
|
1023
|
+
visibility: VISIBILITY_DIMENSION,
|
|
1024
|
+
} as const satisfies { [K in FilterDimensionKey]: Dimension<K> };
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* Every query parameter any dimension reads, current and legacy spellings both.
|
|
1028
|
+
*
|
|
1029
|
+
* A strict parser uses this to tell "a parameter I do not know" from "a
|
|
1030
|
+
* parameter I know and chose not to act on".
|
|
1031
|
+
*/
|
|
1032
|
+
export const FILTER_DIMENSION_PARAMS: readonly string[] =
|
|
1033
|
+
FILTER_DIMENSION_KEYS.flatMap((key) => [
|
|
1034
|
+
FILTER_DIMENSIONS[key].url.param,
|
|
1035
|
+
...(FILTER_DIMENSIONS[key].url.legacy ?? []),
|
|
1036
|
+
]);
|
|
1037
|
+
|
|
1038
|
+
// =============================================================================
|
|
1039
|
+
// Storing a whole selection
|
|
1040
|
+
// =============================================================================
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* The column values a selection writes, every dimension named.
|
|
1044
|
+
*
|
|
1045
|
+
* Dimensions with no value are written as `null`, not omitted: removing a
|
|
1046
|
+
* condition has to clear its column, and an object that simply left it out
|
|
1047
|
+
* would leave the old condition in place.
|
|
1048
|
+
*
|
|
1049
|
+
* @param selection - The selection to store
|
|
1050
|
+
* @returns One entry per dimension, keyed by its storage column
|
|
1051
|
+
* @example
|
|
1052
|
+
* selectionToColumns({ format: "note" });
|
|
1053
|
+
* // { collectionId: null, format: "note", hasTitle: null, ... }
|
|
1054
|
+
*/
|
|
1055
|
+
export function selectionToColumns(
|
|
1056
|
+
selection: PostFilterSelection,
|
|
1057
|
+
): Record<string, string | number | boolean | null> {
|
|
1058
|
+
const columns: Record<string, string | number | boolean | null> = {};
|
|
1059
|
+
for (const key of FILTER_DIMENSION_KEYS) {
|
|
1060
|
+
const { storage } = FILTER_DIMENSIONS[key];
|
|
1061
|
+
const value = selection[key];
|
|
1062
|
+
columns[storage.column] =
|
|
1063
|
+
value === undefined ? null : storeDimension(key, value);
|
|
1064
|
+
}
|
|
1065
|
+
return columns;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
function storeDimension<K extends FilterDimensionKey>(
|
|
1069
|
+
key: K,
|
|
1070
|
+
value: FilterDimensionValues[K],
|
|
1071
|
+
): string | number | boolean {
|
|
1072
|
+
const toColumn = FILTER_DIMENSIONS[key].storage.toColumn as unknown as (
|
|
1073
|
+
value: FilterDimensionValues[K],
|
|
1074
|
+
) => string | number | boolean;
|
|
1075
|
+
return toColumn(value);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* Read a stored row back into a selection.
|
|
1080
|
+
*
|
|
1081
|
+
* A column this dimension cannot read is treated as unset. That is the honest
|
|
1082
|
+
* reading: the alternative is a smart collection that refuses to render because
|
|
1083
|
+
* one condition is malformed, which turns a bad column into a broken page.
|
|
1084
|
+
*
|
|
1085
|
+
* @param row - A `smart_collection` row, in either dialect's shape
|
|
1086
|
+
* @returns The selection the row stores
|
|
1087
|
+
* @example
|
|
1088
|
+
* selectionFromRow({ format: "note", hasTitle: 0 });
|
|
1089
|
+
* // { format: "note", title: false }
|
|
1090
|
+
*/
|
|
1091
|
+
export function selectionFromRow(
|
|
1092
|
+
row: Record<string, unknown>,
|
|
1093
|
+
): PostFilterSelection {
|
|
1094
|
+
const selection: Record<string, unknown> = {};
|
|
1095
|
+
for (const key of FILTER_DIMENSION_KEYS) {
|
|
1096
|
+
const { storage } = FILTER_DIMENSIONS[key];
|
|
1097
|
+
const raw = row[storage.column];
|
|
1098
|
+
if (raw === null || raw === undefined) continue;
|
|
1099
|
+
const value = storage.fromColumn(raw);
|
|
1100
|
+
if (value !== null) selection[key] = value;
|
|
1101
|
+
}
|
|
1102
|
+
return selection as PostFilterSelection;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// =============================================================================
|
|
1106
|
+
// Parsing a whole selection
|
|
1107
|
+
// =============================================================================
|
|
1108
|
+
|
|
1109
|
+
/** A lenient parse: what was selected, and what could not be read. */
|
|
1110
|
+
export interface FilterSelectionParse {
|
|
1111
|
+
selection: PostFilterSelection;
|
|
1112
|
+
/**
|
|
1113
|
+
* Dimensions whose value was unreadable, keyed by canonical parameter name.
|
|
1114
|
+
* They are absent from `selection`.
|
|
1115
|
+
*/
|
|
1116
|
+
issues: FilterParseIssue[];
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/**
|
|
1120
|
+
* Read every dimension out of a query string, dropping what cannot be read.
|
|
1121
|
+
*
|
|
1122
|
+
* The lenient half of the pair. A page still has to render when a URL carries a
|
|
1123
|
+
* value it does not recognise, so an unreadable dimension is simply not
|
|
1124
|
+
* selected — but it is *reported*, because some surfaces owe the reader an
|
|
1125
|
+
* answer rather than a quietly wider one. Callers that must refuse outright use
|
|
1126
|
+
* {@link parsePostFilterSelectionStrict}: one vocabulary, two policies, never
|
|
1127
|
+
* two vocabularies.
|
|
1128
|
+
*
|
|
1129
|
+
* @param read - Query parameter reader
|
|
1130
|
+
* @param ctx - Collection vocabulary and year axis
|
|
1131
|
+
* @returns The selection, plus any dimension that could not be read
|
|
1132
|
+
* @example
|
|
1133
|
+
* parsePostFilterSelection((k) => (k === "format" ? "quote" : undefined), {});
|
|
1134
|
+
* // { selection: { format: "quote" }, issues: [] }
|
|
1135
|
+
*/
|
|
1136
|
+
export function parsePostFilterSelection(
|
|
1137
|
+
read: ParamReader,
|
|
1138
|
+
ctx: DimensionContext,
|
|
1139
|
+
): FilterSelectionParse {
|
|
1140
|
+
const selection: Record<string, unknown> = {};
|
|
1141
|
+
const issues: FilterParseIssue[] = [];
|
|
1142
|
+
for (const key of FILTER_DIMENSION_KEYS) {
|
|
1143
|
+
const dimension = FILTER_DIMENSIONS[key];
|
|
1144
|
+
const parsed = dimension.url.parse(read, ctx);
|
|
1145
|
+
if (parsed.state === "value") selection[key] = parsed.value;
|
|
1146
|
+
else if (parsed.state === "invalid") {
|
|
1147
|
+
issues.push({ param: dimension.url.param, message: parsed.message });
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
return { selection: selection as PostFilterSelection, issues };
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/** What a strict parse produced, or why it refused. */
|
|
1154
|
+
export type StrictFilterParse =
|
|
1155
|
+
| { ok: true; selection: PostFilterSelection }
|
|
1156
|
+
| { ok: false; issues: FilterParseIssue[] };
|
|
1157
|
+
|
|
1158
|
+
/**
|
|
1159
|
+
* Read every dimension out of a query string, refusing anything unreadable.
|
|
1160
|
+
*
|
|
1161
|
+
* For decisions, not rendering: whether a URL can become a persistent object,
|
|
1162
|
+
* or whether an API caller's words can be answered. A renderer that cannot read
|
|
1163
|
+
* a value drops it and still shows a page; a decision that drops it silently
|
|
1164
|
+
* answers a different question than the one asked.
|
|
1165
|
+
*
|
|
1166
|
+
* @param read - Query parameter reader
|
|
1167
|
+
* @param present - Every parameter name actually in the query string
|
|
1168
|
+
* @param ctx - Collection vocabulary and year axis
|
|
1169
|
+
* @param opts.allow - Parameter names that belong to the caller, not to a
|
|
1170
|
+
* dimension (`limit`, `cursor`, `sort`, …). Anything else present is unknown.
|
|
1171
|
+
* @returns The selection, or the issues that stopped it
|
|
1172
|
+
* @example
|
|
1173
|
+
* parsePostFilterSelectionStrict(read, ["format"], ctx, { allow: [] });
|
|
1174
|
+
* // { ok: true, selection: { format: "quote" } }
|
|
1175
|
+
*/
|
|
1176
|
+
export function parsePostFilterSelectionStrict(
|
|
1177
|
+
read: ParamReader,
|
|
1178
|
+
present: readonly string[],
|
|
1179
|
+
ctx: DimensionContext,
|
|
1180
|
+
opts: { allow?: readonly string[] } = {},
|
|
1181
|
+
): StrictFilterParse {
|
|
1182
|
+
const known = new Set([...FILTER_DIMENSION_PARAMS, ...(opts.allow ?? [])]);
|
|
1183
|
+
const unknown: FilterParseIssue[] = [];
|
|
1184
|
+
|
|
1185
|
+
for (const param of present) {
|
|
1186
|
+
if (!known.has(param)) {
|
|
1187
|
+
unknown.push({ param, message: `Unknown parameter "${param}".` });
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
const { selection, issues } = parsePostFilterSelection(read, ctx);
|
|
1192
|
+
const all = [...unknown, ...issues];
|
|
1193
|
+
if (all.length > 0) return { ok: false, issues: all };
|
|
1194
|
+
return { ok: true, selection };
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// =============================================================================
|
|
1198
|
+
// Serializing a whole selection
|
|
1199
|
+
// =============================================================================
|
|
1200
|
+
|
|
1201
|
+
/**
|
|
1202
|
+
* Write a selection back into query parameters, in the canonical spelling.
|
|
1203
|
+
*
|
|
1204
|
+
* Legacy spellings are read but never written, so a URL this produces round
|
|
1205
|
+
* trips through {@link parsePostFilterSelectionStrict} unchanged.
|
|
1206
|
+
*
|
|
1207
|
+
* @param selection - The selection to spell
|
|
1208
|
+
* @param ctx - Collection vocabulary
|
|
1209
|
+
* @param into - Params to write into; a fresh set when omitted
|
|
1210
|
+
* @returns The params, for chaining
|
|
1211
|
+
* @example
|
|
1212
|
+
* serializePostFilterSelection({ format: "quote" }, {}).toString();
|
|
1213
|
+
* // "format=quote"
|
|
1214
|
+
*/
|
|
1215
|
+
export function serializePostFilterSelection(
|
|
1216
|
+
selection: PostFilterSelection,
|
|
1217
|
+
ctx: DimensionContext,
|
|
1218
|
+
into: URLSearchParams = new URLSearchParams(),
|
|
1219
|
+
): URLSearchParams {
|
|
1220
|
+
for (const key of FILTER_DIMENSION_KEYS) {
|
|
1221
|
+
const value = selection[key];
|
|
1222
|
+
if (value === undefined) continue;
|
|
1223
|
+
const dimension = FILTER_DIMENSIONS[key];
|
|
1224
|
+
// Each branch narrows `value` to its own dimension's type; the registry's
|
|
1225
|
+
// per-key typing cannot survive the loop, so the call is made per key.
|
|
1226
|
+
const spelled = spellDimension(key, value, ctx);
|
|
1227
|
+
if (spelled !== null) into.set(dimension.url.param, spelled);
|
|
1228
|
+
}
|
|
1229
|
+
return into;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/**
|
|
1233
|
+
* Why the three helpers below each carry one cast.
|
|
1234
|
+
*
|
|
1235
|
+
* The registry is precisely typed per key — `satisfies { [K in Key]: Dimension<K> }`
|
|
1236
|
+
* checks every declaration against its own value type. What a `for` loop over
|
|
1237
|
+
* the keys cannot carry is the *pairing*: at the call site TypeScript sees a
|
|
1238
|
+
* union of dimensions and a union of values, and has no way to know that the
|
|
1239
|
+
* one drawn from `FILTER_DIMENSIONS[key]` belongs with the one drawn from
|
|
1240
|
+
* `selection[key]`. They do, by construction — both are indexed by the same
|
|
1241
|
+
* `key`. One cast per operation is the cost of iterating; the alternative is a
|
|
1242
|
+
* seven-arm switch in each of three functions, which is exactly the repetition
|
|
1243
|
+
* this module exists to remove.
|
|
1244
|
+
*/
|
|
1245
|
+
function spellDimension<K extends FilterDimensionKey>(
|
|
1246
|
+
key: K,
|
|
1247
|
+
value: FilterDimensionValues[K],
|
|
1248
|
+
ctx: DimensionContext,
|
|
1249
|
+
): string | null {
|
|
1250
|
+
const serialize = FILTER_DIMENSIONS[key].url.serialize as unknown as (
|
|
1251
|
+
value: FilterDimensionValues[K],
|
|
1252
|
+
ctx: DimensionContext,
|
|
1253
|
+
) => string | null;
|
|
1254
|
+
return serialize(value, ctx);
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
// =============================================================================
|
|
1258
|
+
// Translating a selection
|
|
1259
|
+
// =============================================================================
|
|
1260
|
+
|
|
1261
|
+
/**
|
|
1262
|
+
* Turn a selection into the `PostFilters` fields it implies.
|
|
1263
|
+
*
|
|
1264
|
+
* Nothing else: the caller still owns status, reply exclusion, the reader's
|
|
1265
|
+
* visibility floor, sorting, and paging. This is the selection's contribution
|
|
1266
|
+
* and no more, so the same selection means the same thing on a page, in a feed,
|
|
1267
|
+
* and inside an aggregate count.
|
|
1268
|
+
*
|
|
1269
|
+
* @param selection - Dimensions the reader chose
|
|
1270
|
+
* @param ctx - Collection vocabulary and year axis
|
|
1271
|
+
* @returns The matching `PostFilters` slice
|
|
1272
|
+
* @example
|
|
1273
|
+
* toPostFilters({ format: "quote", media: "any" }, {});
|
|
1274
|
+
* // { format: "quote", hasMedia: true }
|
|
1275
|
+
*/
|
|
1276
|
+
export function toPostFilters(
|
|
1277
|
+
selection: PostFilterSelection,
|
|
1278
|
+
ctx: DimensionContext,
|
|
1279
|
+
): Partial<PostFilters> {
|
|
1280
|
+
let filters: Partial<PostFilters> = {};
|
|
1281
|
+
for (const key of FILTER_DIMENSION_KEYS) {
|
|
1282
|
+
const value = selection[key];
|
|
1283
|
+
if (value === undefined) continue;
|
|
1284
|
+
filters = { ...filters, ...filterFor(key, value, ctx) };
|
|
1285
|
+
}
|
|
1286
|
+
return filters;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
function filterFor<K extends FilterDimensionKey>(
|
|
1290
|
+
key: K,
|
|
1291
|
+
value: FilterDimensionValues[K],
|
|
1292
|
+
ctx: DimensionContext,
|
|
1293
|
+
): Partial<PostFilters> {
|
|
1294
|
+
const toFilter = FILTER_DIMENSIONS[key].toPostFilter as unknown as (
|
|
1295
|
+
value: FilterDimensionValues[K],
|
|
1296
|
+
ctx: DimensionContext,
|
|
1297
|
+
) => Partial<PostFilters>;
|
|
1298
|
+
return toFilter(value, ctx);
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/** Whether a selection narrows anything at all. */
|
|
1302
|
+
export function hasFilterSelection(selection: PostFilterSelection): boolean {
|
|
1303
|
+
return FILTER_DIMENSION_KEYS.some((key) => selection[key] !== undefined);
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
/**
|
|
1307
|
+
* How each selected dimension reads, one string per dimension.
|
|
1308
|
+
*
|
|
1309
|
+
* Plain readings in registry order, with no cross-dimension absorption — a
|
|
1310
|
+
* surface that wants "Untitled" to stand in for "Notes, without a title"
|
|
1311
|
+
* composes that itself, because whether it can afford to depends on how much
|
|
1312
|
+
* room it has.
|
|
1313
|
+
*
|
|
1314
|
+
* @param selection - Dimensions the reader chose
|
|
1315
|
+
* @param i18n - Translator
|
|
1316
|
+
* @param ctx - Collection vocabulary
|
|
1317
|
+
* @returns One label per selected dimension, unnameable ones omitted
|
|
1318
|
+
* @example
|
|
1319
|
+
* describeFilterSelection({ format: "quote", year: 2024 }, i18n, {});
|
|
1320
|
+
* // ["Quotes", "2024"]
|
|
1321
|
+
*/
|
|
1322
|
+
export function describeFilterSelection(
|
|
1323
|
+
selection: PostFilterSelection,
|
|
1324
|
+
i18n: Translator,
|
|
1325
|
+
ctx: DimensionContext,
|
|
1326
|
+
): string[] {
|
|
1327
|
+
const parts: string[] = [];
|
|
1328
|
+
for (const key of FILTER_DIMENSION_KEYS) {
|
|
1329
|
+
const value = selection[key];
|
|
1330
|
+
if (value === undefined) continue;
|
|
1331
|
+
const part = describeDimension(key, value, i18n, ctx);
|
|
1332
|
+
if (part) parts.push(part);
|
|
1333
|
+
}
|
|
1334
|
+
return parts;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
function describeDimension<K extends FilterDimensionKey>(
|
|
1338
|
+
key: K,
|
|
1339
|
+
value: FilterDimensionValues[K],
|
|
1340
|
+
i18n: Translator,
|
|
1341
|
+
ctx: DimensionContext,
|
|
1342
|
+
): string | null {
|
|
1343
|
+
const describe = FILTER_DIMENSIONS[key].describe as unknown as (
|
|
1344
|
+
value: FilterDimensionValues[K],
|
|
1345
|
+
i18n: Translator,
|
|
1346
|
+
ctx: DimensionContext,
|
|
1347
|
+
) => string | null;
|
|
1348
|
+
return describe(value, i18n, ctx);
|
|
1349
|
+
}
|