@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,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smart Collection Page Route
|
|
3
|
+
*
|
|
4
|
+
* A smart collection is always public, so this file carries no page guard, no
|
|
5
|
+
* feed guard, and no "can this reader see it" branch. What it does carry is the
|
|
6
|
+
* ordinary per-post visibility floor, identical to a manual collection page:
|
|
7
|
+
* an author signed in may see more threads than an anonymous reader, on the
|
|
8
|
+
* page and in the count alike.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Context } from "hono";
|
|
12
|
+
import type { Bindings, SmartCollection } from "../../types.js";
|
|
13
|
+
import type { AppVariables } from "../../types/app-context.js";
|
|
14
|
+
import { SmartCollectionPage } from "../../ui/pages/SmartCollectionPage.js";
|
|
15
|
+
import { getNavigationData } from "../../lib/navigation.js";
|
|
16
|
+
import { formatPageLabel, parsePageNumber } from "../../lib/pagination.js";
|
|
17
|
+
import { buildPageTitle } from "../../lib/page-title.js";
|
|
18
|
+
import { renderPublicPage } from "../../lib/render.js";
|
|
19
|
+
import { assembleTimelineItems } from "../../lib/timeline.js";
|
|
20
|
+
import { defaultFeedRenderer } from "../../lib/feed.js";
|
|
21
|
+
import {
|
|
22
|
+
buildFeedDiscoveryFields,
|
|
23
|
+
getFeedEntryUpdatedAt,
|
|
24
|
+
getFeedLimit,
|
|
25
|
+
getRssPublishedBefore,
|
|
26
|
+
renderFeed,
|
|
27
|
+
} from "../../lib/feed-policy.js";
|
|
28
|
+
import { toPlainText as markdownToPlainText } from "../../lib/markdown.js";
|
|
29
|
+
import { buildMediaMap } from "../../lib/media-helpers.js";
|
|
30
|
+
import { createMediaContext, toPostViews } from "../../lib/view.js";
|
|
31
|
+
import { toAbsoluteSiteUrl } from "../../lib/url.js";
|
|
32
|
+
import {
|
|
33
|
+
buildSurfaceAlternates,
|
|
34
|
+
getViewLang,
|
|
35
|
+
toViewPath,
|
|
36
|
+
viewBasePath,
|
|
37
|
+
} from "../../lib/view-language.js";
|
|
38
|
+
import { getCollectionPagePath } from "../../lib/collection-paths.js";
|
|
39
|
+
import { buildCollectionVocabulary } from "../../lib/filter-dimensions.js";
|
|
40
|
+
import {
|
|
41
|
+
buildSmartCollectionArchiveHref,
|
|
42
|
+
describeSmartCollection,
|
|
43
|
+
} from "../../ui/shared/smart-collection-labels.js";
|
|
44
|
+
import { getI18n } from "../../i18n/index.js";
|
|
45
|
+
import { SMART_COLLECTION_SORT_ORDERS } from "../../types.js";
|
|
46
|
+
import type { SmartCollectionSortOrder } from "../../types.js";
|
|
47
|
+
import { supportsCollectionRatingSort } from "../../lib/collection-sort.js";
|
|
48
|
+
|
|
49
|
+
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The collection vocabulary, loaded only when a condition names one.
|
|
53
|
+
*
|
|
54
|
+
* The condition line has to name the collection it filters by, and the archive
|
|
55
|
+
* link has to spell its slug — but most smart collections name none, and the
|
|
56
|
+
* lookup is a round trip.
|
|
57
|
+
*/
|
|
58
|
+
async function loadConditionVocabulary(
|
|
59
|
+
c: Context<Env>,
|
|
60
|
+
smartCollection: SmartCollection,
|
|
61
|
+
) {
|
|
62
|
+
const ids = smartCollection.selection.collection ?? [];
|
|
63
|
+
if (ids.length === 0) return buildCollectionVocabulary([]);
|
|
64
|
+
return buildCollectionVocabulary(await c.var.services.collections.list());
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readSort(
|
|
68
|
+
value: string | undefined,
|
|
69
|
+
fallback: SmartCollectionSortOrder,
|
|
70
|
+
showRatingSort: boolean,
|
|
71
|
+
): SmartCollectionSortOrder {
|
|
72
|
+
// `updated` was a fourth order before `newest` came to mean the same thing.
|
|
73
|
+
// Links to it are still in the wild, and they still resolve to what the
|
|
74
|
+
// reader chose, so the accept-old rule costs one line here.
|
|
75
|
+
const named = value === "updated" ? "newest" : value;
|
|
76
|
+
const requested = (
|
|
77
|
+
SMART_COLLECTION_SORT_ORDERS as readonly string[]
|
|
78
|
+
).includes(named ?? "")
|
|
79
|
+
? (named as SmartCollectionSortOrder)
|
|
80
|
+
: fallback;
|
|
81
|
+
// Silently falls back rather than showing an order that would look arbitrary
|
|
82
|
+
// on a set where almost nothing is rated — the same rule a collection page
|
|
83
|
+
// applies to its own rating sort.
|
|
84
|
+
return requested === "rating_desc" && !showRatingSort ? "newest" : requested;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Render a smart collection page.
|
|
89
|
+
*
|
|
90
|
+
* @param c - Hono context
|
|
91
|
+
* @param slug - The smart collection's address
|
|
92
|
+
* @returns The rendered page, or null when no smart collection lives there
|
|
93
|
+
*/
|
|
94
|
+
export async function renderSmartCollectionPage(
|
|
95
|
+
c: Context<Env>,
|
|
96
|
+
slug: string,
|
|
97
|
+
): Promise<Response | null> {
|
|
98
|
+
const page = parsePageNumber(c.req.query("page"));
|
|
99
|
+
const paginatedPageTitle = formatPageLabel(page);
|
|
100
|
+
|
|
101
|
+
const [smartCollection, navData] = await Promise.all([
|
|
102
|
+
c.var.services.smartCollections.getBySlug(slug),
|
|
103
|
+
getNavigationData(c),
|
|
104
|
+
]);
|
|
105
|
+
if (!smartCollection) return null;
|
|
106
|
+
|
|
107
|
+
const canonicalPagePath = getCollectionPagePath(smartCollection.slug);
|
|
108
|
+
const viewer = {
|
|
109
|
+
isAuthenticated: navData.isAuthenticated,
|
|
110
|
+
lang: getViewLang(c) ?? undefined,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// The rating order is offered only where it would say something. One cheap
|
|
114
|
+
// bounded count answers that, exactly as the collection page does it.
|
|
115
|
+
const ratedCount = await c.var.services.posts.countUpTo(
|
|
116
|
+
{
|
|
117
|
+
...c.var.services.smartCollections.toPostFilters(smartCollection, viewer),
|
|
118
|
+
hasRating: true,
|
|
119
|
+
},
|
|
120
|
+
2,
|
|
121
|
+
);
|
|
122
|
+
const showRatingSort = supportsCollectionRatingSort(ratedCount);
|
|
123
|
+
const defaultSort = readSort(smartCollection.sort, "newest", showRatingSort);
|
|
124
|
+
// The reader's `?sort=` wins over the stored default, the same way it does on
|
|
125
|
+
// a collection page. Condition params in the URL are ignored: membership is
|
|
126
|
+
// edited in the dialog, not by hand in the address bar.
|
|
127
|
+
const currentSort = readSort(
|
|
128
|
+
c.req.query("sort"),
|
|
129
|
+
defaultSort,
|
|
130
|
+
showRatingSort,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const pageSize = c.var.appConfig.pageSize;
|
|
134
|
+
const filters = c.var.services.smartCollections.toPostFilters(
|
|
135
|
+
{ ...smartCollection, sort: currentSort },
|
|
136
|
+
viewer,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const [totalThreadCount, posts, collectionVocabulary] = await Promise.all([
|
|
140
|
+
c.var.services.posts.count(filters),
|
|
141
|
+
c.var.services.posts.list({
|
|
142
|
+
...filters,
|
|
143
|
+
limit: pageSize,
|
|
144
|
+
offset: (page - 1) * pageSize,
|
|
145
|
+
}),
|
|
146
|
+
loadConditionVocabulary(c, smartCollection),
|
|
147
|
+
]);
|
|
148
|
+
|
|
149
|
+
const totalPages = Math.max(1, Math.ceil(totalThreadCount / pageSize));
|
|
150
|
+
const items = await assembleTimelineItems(c, posts);
|
|
151
|
+
|
|
152
|
+
const i18n = getI18n(c);
|
|
153
|
+
const dimensionCtx = { collections: collectionVocabulary };
|
|
154
|
+
const feedHref = c.var.appConfig.rssFeedsEnabled
|
|
155
|
+
? `${canonicalPagePath}/feed`
|
|
156
|
+
: undefined;
|
|
157
|
+
|
|
158
|
+
return renderPublicPage(c, {
|
|
159
|
+
title:
|
|
160
|
+
page > 1
|
|
161
|
+
? buildPageTitle(
|
|
162
|
+
smartCollection.title,
|
|
163
|
+
paginatedPageTitle,
|
|
164
|
+
navData.siteName,
|
|
165
|
+
)
|
|
166
|
+
: buildPageTitle(smartCollection.title, navData.siteName),
|
|
167
|
+
description: smartCollection.description
|
|
168
|
+
? markdownToPlainText(smartCollection.description)
|
|
169
|
+
: undefined,
|
|
170
|
+
// A page the author declared, so it is always indexable and always has a
|
|
171
|
+
// canonical and an hreflang set — unlike an archive URL a reader assembled.
|
|
172
|
+
alternateLanguages: buildSurfaceAlternates(c),
|
|
173
|
+
pageFeed: feedHref
|
|
174
|
+
? { href: toViewPath(c, feedHref), title: smartCollection.title }
|
|
175
|
+
: undefined,
|
|
176
|
+
navData,
|
|
177
|
+
content: (
|
|
178
|
+
<SmartCollectionPage
|
|
179
|
+
smartCollection={smartCollection}
|
|
180
|
+
items={items}
|
|
181
|
+
totalThreadCount={totalThreadCount}
|
|
182
|
+
currentPage={page}
|
|
183
|
+
totalPages={totalPages}
|
|
184
|
+
pagePath={canonicalPagePath}
|
|
185
|
+
baseUrl={
|
|
186
|
+
currentSort === defaultSort
|
|
187
|
+
? toViewPath(c, canonicalPagePath)
|
|
188
|
+
: toViewPath(c, `${canonicalPagePath}?sort=${currentSort}`)
|
|
189
|
+
}
|
|
190
|
+
currentSort={currentSort}
|
|
191
|
+
defaultSort={defaultSort}
|
|
192
|
+
showRatingSort={showRatingSort}
|
|
193
|
+
conditionSummary={describeSmartCollection(
|
|
194
|
+
smartCollection.selection,
|
|
195
|
+
i18n,
|
|
196
|
+
dimensionCtx,
|
|
197
|
+
)}
|
|
198
|
+
conditionHref={buildSmartCollectionArchiveHref(
|
|
199
|
+
smartCollection.selection,
|
|
200
|
+
dimensionCtx,
|
|
201
|
+
)}
|
|
202
|
+
isAuthenticated={navData.isAuthenticated}
|
|
203
|
+
isInNavigation={navData.links.some(
|
|
204
|
+
(item) =>
|
|
205
|
+
item.type === "smart_collection" &&
|
|
206
|
+
item.smartCollectionId === smartCollection.id,
|
|
207
|
+
)}
|
|
208
|
+
sitePathPrefix={navData.sitePathPrefix}
|
|
209
|
+
basePath={navData.basePath}
|
|
210
|
+
feedHref={feedHref}
|
|
211
|
+
/>
|
|
212
|
+
),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Render a smart collection's Atom feed.
|
|
218
|
+
*
|
|
219
|
+
* No auth guard: a feed is anonymous by construction, and a smart collection
|
|
220
|
+
* can never name a set only its author can see.
|
|
221
|
+
*
|
|
222
|
+
* @param c - Hono context
|
|
223
|
+
* @param slug - The smart collection's address
|
|
224
|
+
* @returns The feed response, or null when no smart collection lives there
|
|
225
|
+
*/
|
|
226
|
+
export async function renderSmartCollectionFeed(
|
|
227
|
+
c: Context<Env>,
|
|
228
|
+
slug: string,
|
|
229
|
+
): Promise<Response | null> {
|
|
230
|
+
const smartCollection = await c.var.services.smartCollections.getBySlug(slug);
|
|
231
|
+
if (!smartCollection) return null;
|
|
232
|
+
|
|
233
|
+
const { appConfig, services } = c.var;
|
|
234
|
+
const canonicalFeedPath = `${getCollectionPagePath(
|
|
235
|
+
smartCollection.slug,
|
|
236
|
+
)}/feed`;
|
|
237
|
+
const publishedBefore = getRssPublishedBefore(
|
|
238
|
+
appConfig.rssPublishDelaySeconds,
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
const filters = services.smartCollections.toPostFilters(smartCollection, {
|
|
242
|
+
isAuthenticated: false,
|
|
243
|
+
lang: getViewLang(c) ?? undefined,
|
|
244
|
+
});
|
|
245
|
+
const posts = await services.posts.list({
|
|
246
|
+
...filters,
|
|
247
|
+
// The publication delay never moves: a just-published post is not announced
|
|
248
|
+
// early even when a year condition bounds the same column more tightly.
|
|
249
|
+
publishedBefore:
|
|
250
|
+
filters.publishedBefore === undefined
|
|
251
|
+
? publishedBefore
|
|
252
|
+
: Math.min(filters.publishedBefore, publishedBefore),
|
|
253
|
+
limit: getFeedLimit(c),
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const rootIds = posts.filter((p) => p.threadId === p.id).map((p) => p.id);
|
|
257
|
+
const postIds = posts.map((post) => post.id);
|
|
258
|
+
const [threadMap, rawMediaMap, aliasesMap] = await Promise.all([
|
|
259
|
+
services.posts.getPublishedThreads(rootIds, { publishedBefore }),
|
|
260
|
+
services.media.getByPostIds(postIds),
|
|
261
|
+
services.paths.getPostAliases(postIds),
|
|
262
|
+
]);
|
|
263
|
+
|
|
264
|
+
const replyIds: string[] = [];
|
|
265
|
+
for (const [rootId, thread] of threadMap) {
|
|
266
|
+
for (const reply of thread) {
|
|
267
|
+
if (reply.id !== rootId) replyIds.push(reply.id);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const replyMediaMap =
|
|
271
|
+
replyIds.length > 0
|
|
272
|
+
? await services.media.getByPostIds(replyIds)
|
|
273
|
+
: new Map<string, never[]>();
|
|
274
|
+
|
|
275
|
+
const mediaCtx = createMediaContext(appConfig);
|
|
276
|
+
const allRawMedia = new Map(rawMediaMap);
|
|
277
|
+
for (const [id, media] of replyMediaMap) {
|
|
278
|
+
allRawMedia.set(id, media);
|
|
279
|
+
}
|
|
280
|
+
const mediaMap = buildMediaMap(
|
|
281
|
+
allRawMedia,
|
|
282
|
+
mediaCtx.r2PublicUrl,
|
|
283
|
+
mediaCtx.imageTransformUrl,
|
|
284
|
+
mediaCtx.s3PublicUrl,
|
|
285
|
+
mediaCtx.localPublicUrl,
|
|
286
|
+
mediaCtx.sitePathPrefix,
|
|
287
|
+
);
|
|
288
|
+
const aliasMap = new Map<string, string>();
|
|
289
|
+
for (const [id, aliases] of aliasesMap) {
|
|
290
|
+
if (aliases[0]) aliasMap.set(id, aliases[0]);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const postViews = toPostViews(
|
|
294
|
+
posts.map((post) => ({
|
|
295
|
+
...post,
|
|
296
|
+
mediaAttachments: mediaMap.get(post.id) ?? [],
|
|
297
|
+
})),
|
|
298
|
+
mediaCtx,
|
|
299
|
+
undefined,
|
|
300
|
+
aliasMap,
|
|
301
|
+
).map((postView, index) => {
|
|
302
|
+
const post = posts[index] as (typeof posts)[number];
|
|
303
|
+
const thread = threadMap.get(post.id);
|
|
304
|
+
const replies =
|
|
305
|
+
thread && thread.length > 1
|
|
306
|
+
? toPostViews(
|
|
307
|
+
thread
|
|
308
|
+
.filter((reply) => reply.id !== post.id)
|
|
309
|
+
.map((reply) => ({
|
|
310
|
+
...reply,
|
|
311
|
+
mediaAttachments: mediaMap.get(reply.id) ?? [],
|
|
312
|
+
})),
|
|
313
|
+
mediaCtx,
|
|
314
|
+
)
|
|
315
|
+
: undefined;
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
...postView,
|
|
319
|
+
feedUpdatedAt: getFeedEntryUpdatedAt(post, thread),
|
|
320
|
+
threadReplies: replies,
|
|
321
|
+
};
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
const feedData = {
|
|
325
|
+
...buildFeedDiscoveryFields(c),
|
|
326
|
+
siteName: appConfig.siteName,
|
|
327
|
+
siteDescription: markdownToPlainText(appConfig.siteDescription),
|
|
328
|
+
siteUrl: appConfig.siteUrl,
|
|
329
|
+
siteLanguage: getViewLang(c) ?? appConfig.siteLanguage,
|
|
330
|
+
title: buildPageTitle(appConfig.siteName, smartCollection.title),
|
|
331
|
+
selfUrl: toAbsoluteSiteUrl(
|
|
332
|
+
`${viewBasePath(c)}${canonicalFeedPath}`,
|
|
333
|
+
appConfig.siteUrl,
|
|
334
|
+
appConfig.sitePathPrefix,
|
|
335
|
+
),
|
|
336
|
+
posts: postViews,
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
return renderFeed(defaultFeedRenderer(feedData));
|
|
340
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subscribe Page Route
|
|
3
|
+
*
|
|
4
|
+
* `/subscribe` — how to follow this site, for a reader who does not already
|
|
5
|
+
* know. The nav's feed entry points here rather than at `/feed`, which hands a
|
|
6
|
+
* browser a screen of Atom XML.
|
|
7
|
+
*
|
|
8
|
+
* Three feeds, always, on every site: the main feed, the opposite end of the
|
|
9
|
+
* same list, and the complete record. Nothing here is conditioned on what the
|
|
10
|
+
* site happens to contain — every Jant site's subscribe page looks the same, so
|
|
11
|
+
* the page needs no service calls and no database round-trip at all.
|
|
12
|
+
*
|
|
13
|
+
* Collection feeds are deliberately absent. Wanting one collection and not the
|
|
14
|
+
* rest is a rare intent on a single-author site, and it already has a better
|
|
15
|
+
* home: the feed icon on the collection you are reading. Listing them here
|
|
16
|
+
* would tax every reader's attention for a few readers' benefit. The full route
|
|
17
|
+
* table lives in `docs/feeds.md`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { Hono } from "hono";
|
|
21
|
+
import type { Context } from "hono";
|
|
22
|
+
import { msg } from "@lingui/core/macro";
|
|
23
|
+
import type { Bindings } from "../../types.js";
|
|
24
|
+
import type { AppVariables } from "../../types/app-context.js";
|
|
25
|
+
import { getI18n } from "../../i18n/index.js";
|
|
26
|
+
import { getNavigationData } from "../../lib/navigation.js";
|
|
27
|
+
import { buildPageTitle } from "../../lib/page-title.js";
|
|
28
|
+
import { renderPublicPage } from "../../lib/render.js";
|
|
29
|
+
import { toAbsoluteSiteUrl, toPublicPath } from "../../lib/url.js";
|
|
30
|
+
import {
|
|
31
|
+
buildSurfaceAlternates,
|
|
32
|
+
viewBasePath,
|
|
33
|
+
} from "../../lib/view-language.js";
|
|
34
|
+
import { SubscribePage } from "../../ui/pages/SubscribePage.js";
|
|
35
|
+
import type { SubscribeFeed } from "../../ui/pages/SubscribePage.js";
|
|
36
|
+
|
|
37
|
+
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
38
|
+
|
|
39
|
+
export const subscribeRoutes = new Hono<Env>();
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Render the subscribe page for the current view language.
|
|
43
|
+
*
|
|
44
|
+
* @param c - Hono context
|
|
45
|
+
* @returns The page, or 404 when feeds are switched off site-wide
|
|
46
|
+
*/
|
|
47
|
+
export async function renderSubscribePage(c: Context<Env>): Promise<Response> {
|
|
48
|
+
const { appConfig } = c.var;
|
|
49
|
+
|
|
50
|
+
// With feeds off there is nothing to subscribe to, and the nav entry is
|
|
51
|
+
// filtered out too — so the page 404s rather than listing dead addresses.
|
|
52
|
+
if (!appConfig.rssFeedsEnabled) return c.notFound();
|
|
53
|
+
|
|
54
|
+
const navData = await getNavigationData(c);
|
|
55
|
+
const i18n = getI18n(c);
|
|
56
|
+
|
|
57
|
+
// Readers paste these into a reader, so they have to be whole addresses, and
|
|
58
|
+
// they have to stay inside the language view they were read from.
|
|
59
|
+
const siteUrl =
|
|
60
|
+
appConfig.siteUrl ||
|
|
61
|
+
new URL(toPublicPath("/", appConfig.sitePathPrefix), c.req.url).toString();
|
|
62
|
+
const feedUrl = (path: string): string =>
|
|
63
|
+
toAbsoluteSiteUrl(
|
|
64
|
+
`${viewBasePath(c)}${path}`,
|
|
65
|
+
siteUrl,
|
|
66
|
+
appConfig.sitePathPrefix,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
// The page each feed is the feed of. Relative, unlike the addresses: these
|
|
70
|
+
// are links to follow here and now, not strings to paste elsewhere.
|
|
71
|
+
const pageUrl = (path: string): string =>
|
|
72
|
+
toPublicPath(`${viewBasePath(c)}${path}`, appConfig.sitePathPrefix);
|
|
73
|
+
|
|
74
|
+
const latestLabel = i18n._(
|
|
75
|
+
msg({
|
|
76
|
+
message: "Latest",
|
|
77
|
+
comment: "@context: Subscribe page name for the latest public posts feed",
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
const latestDescription = i18n._(
|
|
81
|
+
msg({
|
|
82
|
+
message: "The same posts as the home page.",
|
|
83
|
+
comment:
|
|
84
|
+
"@context: Subscribe page description of the latest posts feed. The home page and this feed run the same query, so they carry the same posts.",
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
// `/latest` redirects to `/` — the home page is the canonical latest
|
|
88
|
+
// timeline, and its query is the one this feed runs.
|
|
89
|
+
const latestPageUrl = pageUrl("/");
|
|
90
|
+
const featuredLabel = i18n._(
|
|
91
|
+
msg({
|
|
92
|
+
message: "Featured",
|
|
93
|
+
comment: "@context: Subscribe page name for the featured posts feed",
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
const featuredDescription = i18n._(
|
|
97
|
+
msg({
|
|
98
|
+
message: "Only the posts marked as featured.",
|
|
99
|
+
comment:
|
|
100
|
+
"@context: Subscribe page description of the featured posts feed",
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
const featuredPageUrl = pageUrl("/featured");
|
|
104
|
+
|
|
105
|
+
const mainIsLatest = appConfig.mainRssFeed === "latest";
|
|
106
|
+
|
|
107
|
+
const mainFeed: SubscribeFeed = {
|
|
108
|
+
label: i18n._(
|
|
109
|
+
msg({
|
|
110
|
+
message: "Main feed",
|
|
111
|
+
comment: "@context: Subscribe page name for the site's main feed",
|
|
112
|
+
}),
|
|
113
|
+
),
|
|
114
|
+
url: feedUrl("/feed"),
|
|
115
|
+
// Named for what it currently carries. `/feed` is either end of the list
|
|
116
|
+
// depending on the site's setting, and "main feed" alone says neither.
|
|
117
|
+
// The description and the page it links to are the same pair the row
|
|
118
|
+
// below uses for that feed, so the two rows can never disagree.
|
|
119
|
+
description: mainIsLatest ? latestDescription : featuredDescription,
|
|
120
|
+
pageUrl: mainIsLatest ? latestPageUrl : featuredPageUrl,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// The other end of the same list. Offering `/latest/feed` beside a `/feed`
|
|
124
|
+
// that already returns the latest posts would be two names for one feed —
|
|
125
|
+
// exactly the confusion this page exists to end. The stable aliases are an
|
|
126
|
+
// author's concern and stay in General settings and the docs.
|
|
127
|
+
const otherEnd: SubscribeFeed = mainIsLatest
|
|
128
|
+
? {
|
|
129
|
+
label: featuredLabel,
|
|
130
|
+
url: feedUrl("/featured/feed"),
|
|
131
|
+
description: featuredDescription,
|
|
132
|
+
pageUrl: featuredPageUrl,
|
|
133
|
+
}
|
|
134
|
+
: {
|
|
135
|
+
label: latestLabel,
|
|
136
|
+
url: feedUrl("/latest/feed"),
|
|
137
|
+
description: latestDescription,
|
|
138
|
+
pageUrl: latestPageUrl,
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const archiveFeed: SubscribeFeed = {
|
|
142
|
+
label: i18n._(
|
|
143
|
+
msg({
|
|
144
|
+
message: "All",
|
|
145
|
+
comment:
|
|
146
|
+
"@context: Subscribe page name for the full archive feed. Matches the Archive page's own nav label.",
|
|
147
|
+
}),
|
|
148
|
+
),
|
|
149
|
+
url: feedUrl("/archive/feed"),
|
|
150
|
+
// Says what it adds over the others rather than sounding like the default
|
|
151
|
+
// choice, so a reader who wants everything recognizes it and a reader who
|
|
152
|
+
// wants the main line knows to skip it.
|
|
153
|
+
description: i18n._(
|
|
154
|
+
msg({
|
|
155
|
+
message:
|
|
156
|
+
"Every published post, including those kept off the home page.",
|
|
157
|
+
comment:
|
|
158
|
+
"@context: Subscribe page description of the full archive feed. Says what it adds over the other two rather than sounding like the default pick.",
|
|
159
|
+
}),
|
|
160
|
+
),
|
|
161
|
+
pageUrl: pageUrl("/archive"),
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
return renderPublicPage(c, {
|
|
165
|
+
title: buildPageTitle(
|
|
166
|
+
i18n._(
|
|
167
|
+
msg({
|
|
168
|
+
message: "Subscribe",
|
|
169
|
+
comment: "@context: Browser page title for the subscribe page",
|
|
170
|
+
}),
|
|
171
|
+
),
|
|
172
|
+
navData.siteName,
|
|
173
|
+
),
|
|
174
|
+
alternateLanguages: buildSurfaceAlternates(c),
|
|
175
|
+
navData,
|
|
176
|
+
content: (
|
|
177
|
+
<SubscribePage mainFeed={mainFeed} otherFeeds={[otherEnd, archiveFeed]} />
|
|
178
|
+
),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
subscribeRoutes.get("/", renderSubscribePage);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { hasSessionCookie } from "../index.js";
|
|
3
|
+
|
|
4
|
+
describe("hasSessionCookie", () => {
|
|
5
|
+
it.each([
|
|
6
|
+
["__Secure-better-auth.session_token=abc", true],
|
|
7
|
+
["better-auth.session_token=abc", true],
|
|
8
|
+
["theme=dark; __Secure-better-auth.session_token=abc; lang=en", true],
|
|
9
|
+
["theme=dark", false],
|
|
10
|
+
["", false],
|
|
11
|
+
[null, false],
|
|
12
|
+
[undefined, false],
|
|
13
|
+
// A cookie merely *named* after the session must not count — only a value
|
|
14
|
+
// assignment does, so a stray marker cookie can't force primary reads.
|
|
15
|
+
["mysession_token", false],
|
|
16
|
+
])("hasSessionCookie(%s) → %s", (cookieHeader, expected) => {
|
|
17
|
+
expect(hasSessionCookie(cookieHeader)).toBe(expected);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -5,10 +5,15 @@ import {
|
|
|
5
5
|
} from "../../__tests__/helpers/db.js";
|
|
6
6
|
import { sqliteSchemaBundle } from "../../db/schema-bundle.js";
|
|
7
7
|
import { createRequestRuntime } from "../index.js";
|
|
8
|
-
import { createNodeRequestRuntime } from "../node.js";
|
|
8
|
+
import { createNodeCliRuntime, createNodeRequestRuntime } from "../node.js";
|
|
9
|
+
import type { CliSiteSelector } from "../site.js";
|
|
9
10
|
import type { Bindings } from "../../types.js";
|
|
10
11
|
import { siteDomains, sites } from "../../db/schema.js";
|
|
11
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
ConflictError,
|
|
14
|
+
NotFoundError,
|
|
15
|
+
SiteUnavailableError,
|
|
16
|
+
} from "../../lib/errors.js";
|
|
12
17
|
import { TRANSIENT_SINGLE_SITE_ID } from "../../services/site.js";
|
|
13
18
|
|
|
14
19
|
afterEach(() => {
|
|
@@ -217,7 +222,7 @@ describe("createNodeRequestRuntime", () => {
|
|
|
217
222
|
);
|
|
218
223
|
});
|
|
219
224
|
|
|
220
|
-
it("treats suspended host-based sites as not
|
|
225
|
+
it("treats suspended host-based sites as unavailable, not missing", async () => {
|
|
221
226
|
const { db, sqlite } = createTestDatabase();
|
|
222
227
|
const consoleError = vi
|
|
223
228
|
.spyOn(console, "error")
|
|
@@ -250,9 +255,129 @@ describe("createNodeRequestRuntime", () => {
|
|
|
250
255
|
} as Bindings,
|
|
251
256
|
"http://suspended.localtest.me/",
|
|
252
257
|
),
|
|
253
|
-
).rejects.toBeInstanceOf(
|
|
258
|
+
).rejects.toBeInstanceOf(SiteUnavailableError);
|
|
254
259
|
expect(consoleError).toHaveBeenCalledWith(
|
|
255
260
|
"[Jant] Hosted site resolution failed: host=suspended.localtest.me path=/ reason=site-not-active siteId=sit_test00000000000000000000000 siteKey=default siteStatus=suspended",
|
|
256
261
|
);
|
|
257
262
|
});
|
|
258
263
|
});
|
|
264
|
+
|
|
265
|
+
describe("createNodeCliRuntime", () => {
|
|
266
|
+
const OTHER_SITE_ID = "sit_other0000000000000000000000";
|
|
267
|
+
|
|
268
|
+
function nodeDatabaseBindings(
|
|
269
|
+
testDb: ReturnType<typeof createTestDatabase>,
|
|
270
|
+
env: Partial<Bindings> = {},
|
|
271
|
+
): Bindings {
|
|
272
|
+
return {
|
|
273
|
+
NODE_DATABASE: {
|
|
274
|
+
db: testDb.db,
|
|
275
|
+
dialect: "sqlite",
|
|
276
|
+
rawQuery: createSqliteRawQuery(testDb.sqlite),
|
|
277
|
+
schema: sqliteSchemaBundle,
|
|
278
|
+
},
|
|
279
|
+
...env,
|
|
280
|
+
} as Bindings;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** The default test site plus a second tenant on its own host. */
|
|
284
|
+
function createDatabaseWithTwoSites() {
|
|
285
|
+
const testDb = createTestDatabase();
|
|
286
|
+
testDb.sqlite
|
|
287
|
+
.prepare(
|
|
288
|
+
`
|
|
289
|
+
INSERT INTO site (id, key, status, created_at, updated_at)
|
|
290
|
+
VALUES (?, 'other', 'active', 1774200001, 1774200001)
|
|
291
|
+
`,
|
|
292
|
+
)
|
|
293
|
+
.run(OTHER_SITE_ID);
|
|
294
|
+
testDb.sqlite
|
|
295
|
+
.prepare(
|
|
296
|
+
`
|
|
297
|
+
INSERT INTO site_domain (id, site_id, host, path_prefix, kind, redirect_to_primary, created_at, updated_at)
|
|
298
|
+
VALUES ('std_other_1', ?, 'other.localtest.me', NULL, 'primary', 1, 1774200001, 1774200001)
|
|
299
|
+
`,
|
|
300
|
+
)
|
|
301
|
+
.run(OTHER_SITE_ID);
|
|
302
|
+
return testDb;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// A hosted database holds every tenant, so there is no "only site" to fall
|
|
306
|
+
// back to. The single-site lookup would blame SITE_RESOLUTION_MODE, which is
|
|
307
|
+
// already host-based; the CLI has to say which flag picks the site.
|
|
308
|
+
it("requires a site selector in host-based mode with several sites", async () => {
|
|
309
|
+
const testDb = createDatabaseWithTwoSites();
|
|
310
|
+
|
|
311
|
+
await expect(
|
|
312
|
+
createNodeCliRuntime(
|
|
313
|
+
nodeDatabaseBindings(testDb, { SITE_RESOLUTION_MODE: "host-based" }),
|
|
314
|
+
),
|
|
315
|
+
).rejects.toThrow(
|
|
316
|
+
"host-based mode needs a target site. Pass --site <key|id>, --host <host>, or --url <url>.",
|
|
317
|
+
);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it.each<[string, CliSiteSelector]>([
|
|
321
|
+
["key", { kind: "site", idOrKey: "other" }],
|
|
322
|
+
["id", { kind: "site", idOrKey: OTHER_SITE_ID }],
|
|
323
|
+
["host", { kind: "host", host: "other.localtest.me", pathPrefix: null }],
|
|
324
|
+
])(
|
|
325
|
+
"scopes a host-based runtime to the site selected by %s",
|
|
326
|
+
async (_label, selector) => {
|
|
327
|
+
const testDb = createDatabaseWithTwoSites();
|
|
328
|
+
|
|
329
|
+
const runtime = await createNodeCliRuntime(
|
|
330
|
+
nodeDatabaseBindings(testDb, { SITE_RESOLUTION_MODE: "host-based" }),
|
|
331
|
+
selector,
|
|
332
|
+
);
|
|
333
|
+
await runtime.services.settings.set("PASSWORD_RESET_TOKEN", "hash:1");
|
|
334
|
+
|
|
335
|
+
expect(runtime.currentSite.id).toBe(OTHER_SITE_ID);
|
|
336
|
+
expect(runtime.currentSiteDomain?.host).toBe("other.localtest.me");
|
|
337
|
+
expect(
|
|
338
|
+
testDb.sqlite
|
|
339
|
+
.prepare(
|
|
340
|
+
`SELECT site_id FROM site_setting WHERE key = 'PASSWORD_RESET_TOKEN'`,
|
|
341
|
+
)
|
|
342
|
+
.pluck()
|
|
343
|
+
.all(),
|
|
344
|
+
).toEqual([OTHER_SITE_ID]);
|
|
345
|
+
},
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
it("names the selector that matched no site", async () => {
|
|
349
|
+
const testDb = createDatabaseWithTwoSites();
|
|
350
|
+
const env = nodeDatabaseBindings(testDb, {
|
|
351
|
+
SITE_RESOLUTION_MODE: "host-based",
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
await expect(
|
|
355
|
+
createNodeCliRuntime(env, { kind: "site", idOrKey: "missing" }),
|
|
356
|
+
).rejects.toThrow("No site found for --site missing.");
|
|
357
|
+
await expect(
|
|
358
|
+
createNodeCliRuntime(env, {
|
|
359
|
+
kind: "host",
|
|
360
|
+
host: "other.localtest.me",
|
|
361
|
+
pathPrefix: "/blog",
|
|
362
|
+
}),
|
|
363
|
+
).rejects.toThrow(
|
|
364
|
+
'No site found for host "other.localtest.me" and path prefix "/blog".',
|
|
365
|
+
);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("uses the instance's one site in single-site mode", async () => {
|
|
369
|
+
const runtime = await createNodeCliRuntime(
|
|
370
|
+
nodeDatabaseBindings(createTestDatabase()),
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
expect(runtime.currentSite.id).toBe(DEFAULT_TEST_SITE_ID);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// Here the single-site error is the accurate one: the database really does
|
|
377
|
+
// belong to a host-based install.
|
|
378
|
+
it("reports a multi-site database in single-site mode as misconfigured", async () => {
|
|
379
|
+
await expect(
|
|
380
|
+
createNodeCliRuntime(nodeDatabaseBindings(createDatabaseWithTwoSites())),
|
|
381
|
+
).rejects.toThrow("single-site mode found multiple sites in the database:");
|
|
382
|
+
});
|
|
383
|
+
});
|