@jant/core 0.6.15 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -11
- package/bin/commands/assets/upload.js +192 -15
- package/bin/commands/import-site.js +67 -4
- package/bin/commands/reset-password.js +21 -8
- package/bin/commands/site/snapshot/export.js +1 -1
- package/bin/commands/site/snapshot/import.js +1 -1
- package/bin/lib/d1-query.js +5 -16
- package/bin/lib/node-env.js +40 -4
- package/bin/lib/site-selection.js +85 -29
- package/bin/lib/wrangler-cli.js +28 -2
- package/bin/lib/wrangler-json.js +35 -0
- package/dist/{app-C-oi_t8W.js → app-K_Aa1MMn.js} +52623 -13193
- package/dist/client/.vite/manifest.json +255 -90
- package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
- package/dist/client/_assets/chunks/create-editor-CD3FhrOB.js +257 -0
- package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
- package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
- package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
- package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
- package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
- package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
- package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
- package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
- package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
- package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
- package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
- package/dist/client/_assets/chunks/sortable-list-CgaL2jCs.js +2 -0
- package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
- package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
- package/dist/client/_assets/chunks/unsafe-svg-0QCkP0vZ.js +2 -0
- package/dist/client/_assets/client-GiYENVw8.css +2 -0
- package/dist/client/_assets/client-VnFxJN7G.js +295 -0
- package/dist/client/_assets/client-auth-k8gJ6QJj.js +828 -0
- package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
- package/dist/client/_assets/client-compose-B_kDtWkW.js +2004 -0
- package/dist/client/_assets/client-manage-M90aSTOk.js +2170 -0
- package/dist/client/_assets/client-settings-DfYs9n1F.js +1112 -0
- package/dist/{export-CsFx6F_M.js → github-sync-BPAvT999.js} +2343 -110
- package/dist/index.js +2 -5
- package/dist/node.js +5 -7
- package/package.json +3 -3
- package/src/__tests__/auth-session-freshness.test.ts +57 -0
- package/src/__tests__/basecoat-variants.test.ts +121 -0
- package/src/__tests__/bin/assets-upload.test.ts +68 -0
- package/src/__tests__/export-hugo-build.test.ts +260 -0
- package/src/__tests__/export-service.test.ts +231 -0
- package/src/__tests__/helpers/component-css.ts +91 -0
- package/src/__tests__/helpers/db.ts +33 -0
- package/src/__tests__/helpers/export-fixtures.ts +3 -0
- package/src/__tests__/mise-config.test.ts +22 -0
- package/src/__tests__/stylesheet-audience.test.ts +159 -0
- package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
- package/src/app.tsx +71 -16
- package/src/auth.ts +18 -1
- package/src/client/__tests__/collection-page-actions.test.ts +51 -2
- package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
- package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
- package/src/client/__tests__/compose-bridge.test.ts +524 -2
- package/src/client/__tests__/compose-launch.test.ts +62 -1
- package/src/client/__tests__/compose-shortcuts.test.ts +57 -8
- package/src/client/__tests__/compose-triggers.test.ts +90 -0
- package/src/client/__tests__/copy-field.test.ts +162 -0
- package/src/client/__tests__/custom-url-menu.test.ts +127 -6
- package/src/client/__tests__/image-processor.test.ts +324 -2
- package/src/client/__tests__/lazy-entries.test.ts +92 -0
- package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
- package/src/client/__tests__/runtime-paths.test.ts +84 -0
- package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
- package/src/client/__tests__/toast.test.ts +88 -1
- package/src/client/__tests__/video-processor-probe.test.ts +175 -0
- package/src/client/__tests__/video-processor.test.ts +181 -0
- package/src/client/audio-processor.ts +24 -15
- package/src/client/audio-waveform.ts +49 -0
- package/src/client/collection-dialog-host.ts +72 -0
- package/src/client/collection-navigation.ts +51 -16
- package/src/client/collection-page-actions.ts +26 -0
- package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
- package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
- package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
- package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
- package/src/client/components/__tests__/jant-command-palette.test.ts +79 -0
- package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +3283 -389
- package/src/client/components/__tests__/jant-compose-editor.test.ts +581 -86
- package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +34 -89
- package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
- package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
- package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
- package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
- package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
- package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
- package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
- package/src/client/components/collection-dialog-types.ts +49 -0
- package/src/client/components/collection-manager-types.ts +32 -1
- package/src/client/components/collection-types.ts +1 -15
- package/src/client/components/compose-format-convert.ts +5 -1
- package/src/client/components/compose-types.ts +121 -3
- package/src/client/components/jant-collection-dialog.ts +722 -0
- package/src/client/components/jant-collection-directory.ts +567 -182
- package/src/client/components/jant-collection-form.ts +53 -232
- package/src/client/components/jant-command-palette.ts +106 -49
- package/src/client/components/jant-compose-dialog.ts +2757 -1194
- package/src/client/components/jant-compose-editor.ts +866 -397
- package/src/client/components/jant-compose-fullscreen.ts +53 -47
- package/src/client/components/jant-config-editor.ts +65 -53
- package/src/client/components/jant-confirm-dialog.ts +13 -11
- package/src/client/components/jant-locale-picker.ts +418 -0
- package/src/client/components/jant-media-lightbox.ts +146 -136
- package/src/client/components/jant-nav-manager.ts +690 -366
- package/src/client/components/jant-post-menu.ts +1142 -268
- package/src/client/components/jant-post-picker.ts +324 -0
- package/src/client/components/jant-repo-picker.ts +138 -106
- package/src/client/components/jant-settings-avatar.ts +31 -27
- package/src/client/components/jant-settings-general.ts +617 -543
- package/src/client/components/jant-settings-language.ts +1087 -0
- package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
- package/src/client/components/jant-text-preview.ts +94 -86
- package/src/client/components/nav-manager-types.ts +62 -1
- package/src/client/components/settings-types.ts +14 -29
- package/src/client/components/smart-collection-conditions.ts +285 -0
- package/src/client/components/smart-collection-dialog-types.ts +68 -0
- package/src/client/compose-bridge.ts +336 -242
- package/src/client/compose-launch.ts +89 -2
- package/src/client/compose-shortcuts.ts +9 -7
- package/src/client/compose-triggers.ts +50 -0
- package/src/client/copy-field.ts +108 -0
- package/src/client/custom-url-menu.ts +60 -4
- package/src/client/icons.ts +14 -0
- package/src/client/image-plan.ts +165 -0
- package/src/client/image-processor.ts +195 -117
- package/src/client/image-worker-client.ts +45 -0
- package/src/client/image-worker.ts +135 -0
- package/src/client/lazy-entries.ts +95 -0
- package/src/client/lazy-slugify.ts +2 -2
- package/src/client/media-scroll-hint.ts +43 -2
- package/src/client/mediabunny.ts +29 -0
- package/src/client/post-picker.ts +41 -0
- package/src/client/post-refresh.ts +135 -0
- package/src/client/runtime-paths.ts +74 -1
- package/src/client/settings-bridge.ts +4 -10
- package/src/client/site-header-nav.js +18 -6
- package/src/client/smart-collection-dialog-host.ts +116 -0
- package/src/client/smart-collection-page-actions.ts +208 -0
- package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
- package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
- package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
- package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
- package/src/client/tiptap/bubble-menu.ts +47 -16
- package/src/client/tiptap/floating-position.ts +216 -2
- package/src/client/tiptap/inline-image-upload.ts +7 -3
- package/src/client/tiptap/link-input-rules.ts +4 -4
- package/src/client/tiptap/link-toolbar.ts +22 -9
- package/src/client/toast.ts +143 -38
- package/src/client/types/vite-worker.d.ts +12 -0
- package/src/client/upload-with-metadata.ts +3 -13
- package/src/client/video-processor.ts +351 -107
- package/src/client-auth.ts +14 -31
- package/src/client-compose.ts +19 -0
- package/src/client-manage.ts +13 -0
- package/src/client-settings.ts +14 -0
- package/src/client-site.ts +10 -3
- package/src/client.ts +4 -0
- package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
- package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
- package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
- package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
- package/src/db/__tests__/thread-activity.test.ts +113 -0
- package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
- package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
- package/src/db/migrations/0031_many_ego.sql +3 -0
- package/src/db/migrations/0032_married_tomas.sql +3 -0
- package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
- package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
- package/src/db/migrations/meta/0031_snapshot.json +2562 -0
- package/src/db/migrations/meta/0032_snapshot.json +2582 -0
- package/src/db/migrations/meta/0033_snapshot.json +2816 -0
- package/src/db/migrations/meta/0034_snapshot.json +2816 -0
- package/src/db/migrations/meta/_journal.json +28 -0
- package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
- package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
- package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
- package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
- package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
- package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
- package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
- package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
- package/src/db/migrations/pg/meta/_journal.json +28 -0
- package/src/db/pg/schema.ts +213 -27
- package/src/db/post-visibility.ts +117 -0
- package/src/db/schema.ts +242 -27
- package/src/db/thread-activity.ts +101 -0
- package/src/i18n/__tests__/detect.test.ts +26 -81
- package/src/i18n/__tests__/supported-locales.test.ts +83 -0
- package/src/i18n/context.tsx +12 -2
- package/src/i18n/detect.ts +7 -66
- package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
- package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
- package/src/i18n/locales/public/en.po +568 -168
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +562 -162
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +562 -162
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/i18n/locales/settings/en.po +551 -69
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +597 -115
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +610 -128
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/i18n/locales.ts +94 -0
- package/src/i18n/middleware.ts +12 -2
- package/src/i18n/supported-locales.ts +80 -0
- package/src/lib/__tests__/api-settings.test.ts +11 -2
- package/src/lib/__tests__/asset-path.test.ts +92 -0
- package/src/lib/__tests__/deferred.test.ts +90 -0
- package/src/lib/__tests__/discover-ping.test.ts +89 -0
- package/src/lib/__tests__/discover.test.ts +410 -0
- package/src/lib/__tests__/feed-policy.test.ts +26 -0
- package/src/lib/__tests__/feed.test.ts +1691 -12
- package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
- package/src/lib/__tests__/hosted-signin.test.ts +1 -1
- package/src/lib/__tests__/http-cache.test.ts +162 -0
- package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
- package/src/lib/__tests__/jant-docs.test.ts +23 -0
- package/src/lib/__tests__/lang-detect.test.ts +231 -0
- package/src/lib/__tests__/markdown.test.ts +16 -0
- package/src/lib/__tests__/media-helpers.test.ts +81 -0
- package/src/lib/__tests__/navigation.test.ts +135 -1
- package/src/lib/__tests__/resolve-config.test.ts +64 -3
- package/src/lib/__tests__/schemas.test.ts +42 -4
- package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
- package/src/lib/__tests__/slug.test.ts +88 -0
- package/src/lib/__tests__/slugify.test.ts +85 -0
- package/src/lib/__tests__/storage.test.ts +32 -0
- package/src/lib/__tests__/thread-fold.test.ts +113 -0
- package/src/lib/__tests__/translit.test.ts +87 -0
- package/src/lib/__tests__/upload-sideload.test.ts +14 -0
- package/src/lib/__tests__/url-fetch.test.ts +1 -2
- package/src/lib/__tests__/url.test.ts +93 -34
- package/src/lib/__tests__/view-language.test.ts +413 -0
- package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
- package/src/lib/address.ts +57 -0
- package/src/lib/api-posts.ts +12 -0
- package/src/lib/asset-path.ts +98 -1
- package/src/lib/build-env.ts +16 -0
- package/src/lib/collection-groups.ts +5 -1
- package/src/lib/collection-paths.ts +3 -10
- package/src/lib/constants.ts +28 -3
- package/src/lib/copy-field.ts +36 -0
- package/src/lib/crypto.ts +40 -0
- package/src/lib/csp-builder.ts +2 -1
- package/src/lib/deferred.ts +79 -0
- package/src/lib/discover-ping.ts +141 -0
- package/src/lib/discover.ts +437 -0
- package/src/lib/env.ts +134 -0
- package/src/lib/errors.ts +33 -0
- package/src/lib/feed-policy.ts +236 -2
- package/src/lib/feed.ts +1021 -66
- package/src/lib/filter-dimensions.ts +1349 -0
- package/src/lib/github-sync-site-config.ts +3 -0
- package/src/lib/hosted-signin.ts +1 -1
- package/src/lib/http-cache.ts +148 -0
- package/src/lib/hugo-markdown.ts +21 -0
- package/src/lib/icons.ts +9 -2
- package/src/lib/ids.ts +3 -0
- package/src/lib/jant-docs.ts +42 -0
- package/src/lib/lang-detect.ts +308 -0
- package/src/lib/link-preview.ts +25 -0
- package/src/lib/markdown-manager.ts +6 -1
- package/src/lib/media-helpers.ts +13 -1
- package/src/lib/navigation.ts +78 -11
- package/src/lib/pagination.ts +28 -24
- package/src/lib/per-language-surfaces.ts +67 -0
- package/src/lib/post-display.ts +42 -9
- package/src/lib/post-meta.ts +19 -0
- package/src/lib/render.tsx +55 -7
- package/src/lib/resolve-config.ts +53 -5
- package/src/lib/schemas.ts +201 -10
- package/src/lib/site-header-fragment.tsx +2 -1
- package/src/lib/site-unavailable-page.ts +47 -0
- package/src/lib/slug.ts +51 -7
- package/src/lib/slugify.ts +113 -0
- package/src/lib/startup-config.ts +4 -6
- package/src/lib/storage.ts +7 -0
- package/src/lib/summary.ts +50 -0
- package/src/lib/thread-fold.ts +161 -0
- package/src/lib/time.ts +32 -0
- package/src/lib/timeline.ts +81 -10
- package/src/lib/tiptap-render.ts +5 -4
- package/src/lib/translit.ts +288 -0
- package/src/lib/upload.ts +16 -13
- package/src/lib/url.ts +188 -24
- package/src/lib/version.ts +10 -8
- package/src/lib/view-language.ts +436 -0
- package/src/lib/view.ts +79 -23
- package/src/lib/viewer-context.ts +62 -0
- package/src/middleware/__tests__/auth.test.ts +70 -2
- package/src/middleware/__tests__/cache-control.test.ts +71 -3
- package/src/middleware/__tests__/onboarding.test.ts +76 -5
- package/src/middleware/__tests__/secure-headers.test.ts +1 -1
- package/src/middleware/__tests__/session.test.ts +181 -33
- package/src/middleware/auth.ts +50 -31
- package/src/middleware/cache-control.ts +75 -10
- package/src/middleware/config.ts +6 -6
- package/src/middleware/error-handler.ts +12 -0
- package/src/middleware/onboarding.ts +32 -10
- package/src/middleware/secure-headers.ts +1 -1
- package/src/middleware/session.ts +96 -8
- package/src/node/__tests__/cli-migrate.test.ts +21 -19
- package/src/node/__tests__/cli-reset-password.test.ts +81 -8
- package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
- package/src/node/__tests__/cli-site-selection.test.ts +128 -38
- package/src/node/__tests__/runtime.test.ts +37 -0
- package/src/node/index.ts +1 -0
- package/src/preset.css +76 -22
- package/src/routes/__tests__/feed-validator.test.ts +156 -0
- package/src/routes/api/__tests__/collections.test.ts +114 -0
- package/src/routes/api/__tests__/discover.test.ts +222 -0
- package/src/routes/api/__tests__/nav-items.test.ts +212 -2
- package/src/routes/api/__tests__/palette.test.ts +44 -0
- package/src/routes/api/__tests__/post-translations.test.ts +829 -0
- package/src/routes/api/__tests__/settings.test.ts +24 -3
- package/src/routes/api/__tests__/telegram.test.ts +6 -5
- package/src/routes/api/collections.ts +49 -6
- package/src/routes/api/custom-urls.ts +0 -1
- package/src/routes/api/discover.ts +95 -0
- package/src/routes/api/export.ts +3 -0
- package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
- package/src/routes/api/nav-items.ts +55 -8
- package/src/routes/api/posts.ts +172 -9
- package/src/routes/api/public/__tests__/archive.test.ts +111 -0
- package/src/routes/api/public/__tests__/posts.test.ts +38 -0
- package/src/routes/api/public/archive.ts +77 -81
- package/src/routes/api/public/posts.ts +30 -6
- package/src/routes/api/smart-collections.ts +132 -0
- package/src/routes/api/telegram.ts +2 -40
- package/src/routes/api/uploads.ts +1 -2
- package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
- package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
- package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
- package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
- package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
- package/src/routes/auth/__tests__/setup.test.ts +278 -26
- package/src/routes/auth/__tests__/signin.test.ts +135 -0
- package/src/routes/auth/setup.tsx +934 -186
- package/src/routes/auth/signin.tsx +10 -8
- package/src/routes/compose.tsx +11 -2
- package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
- package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
- package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
- package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
- package/src/routes/dash/custom-urls.tsx +1 -37
- package/src/routes/dash/settings.tsx +502 -41
- package/src/routes/discover-announce.ts +96 -0
- package/src/routes/feed/__tests__/feed.test.ts +165 -2
- package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
- package/src/routes/feed/feed.ts +69 -40
- package/src/routes/feed/sitemap.ts +95 -11
- package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
- package/src/routes/pages/__tests__/archive-params.test.ts +657 -1
- package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
- package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
- package/src/routes/pages/__tests__/collections.test.ts +9 -3
- package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
- package/src/routes/pages/__tests__/featured.test.ts +6 -2
- package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
- package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
- package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
- package/src/routes/pages/__tests__/preview.test.ts +3 -1
- package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
- package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
- package/src/routes/pages/archive.tsx +507 -357
- package/src/routes/pages/collection.tsx +94 -96
- package/src/routes/pages/collections.tsx +38 -50
- package/src/routes/pages/featured.tsx +32 -13
- package/src/routes/pages/home.tsx +25 -6
- package/src/routes/pages/language.tsx +113 -0
- package/src/routes/pages/latest.tsx +26 -16
- package/src/routes/pages/new.tsx +3 -0
- package/src/routes/pages/page.tsx +183 -19
- package/src/routes/pages/partials.tsx +41 -0
- package/src/routes/pages/search.tsx +18 -3
- package/src/routes/pages/smart-collection.tsx +340 -0
- package/src/routes/pages/subscribe.tsx +182 -0
- package/src/runtime/__tests__/index.test.ts +19 -0
- package/src/runtime/__tests__/node.test.ts +129 -4
- package/src/runtime/cloudflare.ts +21 -2
- package/src/runtime/index.ts +27 -1
- package/src/runtime/node.ts +18 -1
- package/src/runtime/site.ts +95 -24
- package/src/services/__tests__/auth.test.ts +116 -7
- package/src/services/__tests__/collection.test.ts +139 -4
- package/src/services/__tests__/language.test.ts +495 -0
- package/src/services/__tests__/navigation.test.ts +213 -10
- package/src/services/__tests__/path.test.ts +180 -0
- package/src/services/__tests__/post-language.test.ts +830 -0
- package/src/services/__tests__/post-timeline.test.ts +142 -11
- package/src/services/__tests__/post.test.ts +323 -9
- package/src/services/__tests__/settings.test.ts +228 -17
- package/src/services/__tests__/site-admin.test.ts +5 -5
- package/src/services/__tests__/smart-collection.test.ts +651 -0
- package/src/services/about-page.ts +22 -1
- package/src/services/auth.ts +51 -49
- package/src/services/bootstrap.ts +188 -38
- package/src/services/collection-directory-position.ts +76 -0
- package/src/services/collection.ts +330 -47
- package/src/services/custom-url.ts +27 -22
- package/src/services/export-theme/assets/client-site.css +1 -1
- package/src/services/export-theme/assets/client-site.js +123 -123
- package/src/services/export-theme/layouts/_default/rss.xml +77 -1
- package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
- package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
- package/src/services/export-theme/layouts/partials/pagination.html +14 -13
- package/src/services/export-theme/layouts/partials/post-card.html +8 -4
- package/src/services/export-theme/layouts/partials/reply.html +2 -1
- package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
- package/src/services/export-theme/styles/main.css +13 -1
- package/src/services/export.ts +281 -24
- package/src/services/index.ts +34 -3
- package/src/services/language.ts +412 -0
- package/src/services/mcp.ts +3 -1
- package/src/services/media.ts +14 -6
- package/src/services/navigation.ts +429 -67
- package/src/services/path.ts +225 -7
- package/src/services/post.ts +1318 -252
- package/src/services/search.ts +48 -2
- package/src/services/settings.ts +238 -43
- package/src/services/site-admin.ts +24 -7
- package/src/services/site.ts +77 -1
- package/src/services/smart-collection.ts +636 -0
- package/src/services/telegram.ts +20 -22
- package/src/services/upload-session.ts +1 -3
- package/src/style-author.css +27 -0
- package/src/styles/components-author.css +1622 -0
- package/src/styles/components.css +24 -1585
- package/src/styles/site-media.css +20 -6
- package/src/styles/tokens.css +144 -13
- package/src/styles/ui-author.css +7602 -0
- package/src/styles/ui.css +1224 -7276
- package/src/types/app-context.ts +7 -0
- package/src/types/bindings.ts +3 -0
- package/src/types/config.ts +94 -15
- package/src/types/constants.ts +188 -16
- package/src/types/entities.ts +92 -1
- package/src/types/operations.ts +64 -2
- package/src/types/props.ts +168 -30
- package/src/types/views.ts +61 -1
- package/src/ui/__tests__/font-themes.test.ts +52 -12
- package/src/ui/compose/ComposeDialog.tsx +215 -21
- package/src/ui/compose/ComposePrompt.tsx +1 -5
- package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
- package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
- package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
- package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
- package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
- package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
- package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
- package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
- package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
- package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
- package/src/ui/dash/settings/GeneralContent.tsx +275 -113
- package/src/ui/dash/settings/LanguageContent.tsx +409 -0
- package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
- package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
- package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
- package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
- package/src/ui/dash/settings/discover-copy.ts +116 -0
- package/src/ui/feed/LinkCard.tsx +55 -45
- package/src/ui/feed/LinkPreview.tsx +2 -7
- package/src/ui/feed/NoteCard.tsx +9 -4
- package/src/ui/feed/PostStatusBadges.tsx +103 -17
- package/src/ui/feed/QuoteCard.tsx +55 -30
- package/src/ui/feed/ThreadPreview.tsx +6 -2
- package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
- package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
- package/src/ui/feed/thread-preview-state.ts +20 -6
- package/src/ui/font-themes.ts +39 -29
- package/src/ui/layouts/BaseLayout.tsx +155 -10
- package/src/ui/layouts/SiteLayout.tsx +192 -34
- package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
- package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
- package/src/ui/pages/ArchivePage.tsx +401 -301
- package/src/ui/pages/CollectionPage.tsx +86 -76
- package/src/ui/pages/CollectionsPage.tsx +5 -0
- package/src/ui/pages/ComposePage.tsx +8 -0
- package/src/ui/pages/PostPage.tsx +47 -0
- package/src/ui/pages/SearchPage.tsx +3 -3
- package/src/ui/pages/SmartCollectionPage.tsx +434 -0
- package/src/ui/pages/SubscribePage.tsx +236 -0
- package/src/ui/pages/__tests__/ArchivePage.test.tsx +122 -3
- package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
- package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
- package/src/ui/shared/CollectionDirectory.tsx +178 -3
- package/src/ui/shared/CollectionsManager.tsx +70 -17
- package/src/ui/shared/CopyField.tsx +127 -0
- package/src/ui/shared/DraftPreviewBar.tsx +25 -9
- package/src/ui/shared/EmptyPostContent.tsx +23 -0
- package/src/ui/shared/MediaGallery.tsx +125 -46
- package/src/ui/shared/Pagination.tsx +20 -5
- package/src/ui/shared/PostFooter.tsx +55 -23
- package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
- package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
- package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
- package/src/ui/shared/__tests__/pagination.test.ts +38 -24
- package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
- package/src/ui/shared/archive-labels.ts +98 -0
- package/src/ui/shared/collection-management-labels.ts +94 -16
- package/src/ui/shared/custom-icons.ts +5 -0
- package/src/ui/shared/navigation-labels.ts +45 -71
- package/src/ui/shared/post-article-attributes.ts +8 -0
- package/src/ui/shared/smart-collection-labels.ts +343 -0
- package/dist/app-BxCOR3Uc.js +0 -6
- package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
- package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
- package/dist/client/_assets/chunks/url-pLre2DM_.js +0 -1
- package/dist/client/_assets/client-B_eGKN5p.css +0 -2
- package/dist/client/_assets/client-DxY5BFe8.js +0 -296
- package/dist/client/_assets/client-auth-CakPESv9.js +0 -5533
- package/dist/env-OHRKGcMj.js +0 -281
- package/dist/github-api-BgSiE71w.js +0 -176
- package/dist/github-app-BbklkFmU.js +0 -275
- package/dist/github-sync-Cq1pzzOI.js +0 -431
- package/dist/github-sync-ppWXN3jb.js +0 -4
- package/dist/url-BMYO-Zlt.js +0 -395
- package/src/client/__tests__/collection-created-notice.test.ts +0 -36
- package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
- package/src/client/__tests__/post-form-bridge.test.ts +0 -82
- package/src/client/collection-created-notice.ts +0 -66
- package/src/client/collection-form-bridge.ts +0 -191
- package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
- package/src/client/components/jant-post-form.ts +0 -422
- package/src/client/components/post-form-template.ts +0 -352
- package/src/client/components/post-form-types.ts +0 -96
- package/src/client/media-metadata.ts +0 -247
- package/src/client/post-form-bridge.ts +0 -193
- package/src/ui/pages/CollectionEditorPage.tsx +0 -175
|
@@ -30,6 +30,12 @@ function setupApp(rssPublishDelaySeconds = 0) {
|
|
|
30
30
|
return { app, services };
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
function setupAuthedApp() {
|
|
34
|
+
const { app, services } = createTestApp({ authenticated: true });
|
|
35
|
+
app.route("/archive", archiveRoutes);
|
|
36
|
+
return { app, services };
|
|
37
|
+
}
|
|
38
|
+
|
|
33
39
|
afterEach(() => {
|
|
34
40
|
vi.restoreAllMocks();
|
|
35
41
|
});
|
|
@@ -60,6 +66,31 @@ describe("archive feed filter params", () => {
|
|
|
60
66
|
expect(xml).not.toContain("Recent Archive Post");
|
|
61
67
|
});
|
|
62
68
|
|
|
69
|
+
it("carries the number of entries ?limit= asks for", async () => {
|
|
70
|
+
const { app, services } = setupApp();
|
|
71
|
+
await services.posts.create({
|
|
72
|
+
format: "note",
|
|
73
|
+
title: "Older Archive Post",
|
|
74
|
+
bodyMarkdown: "Older",
|
|
75
|
+
status: "published",
|
|
76
|
+
publishedAt: 1_000,
|
|
77
|
+
});
|
|
78
|
+
await services.posts.create({
|
|
79
|
+
format: "note",
|
|
80
|
+
title: "Newer Archive Post",
|
|
81
|
+
bodyMarkdown: "Newer",
|
|
82
|
+
status: "published",
|
|
83
|
+
publishedAt: 2_000,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const xml = await fetchFeed(app, "?limit=1");
|
|
87
|
+
|
|
88
|
+
expect(xml).toContain("Newer Archive Post");
|
|
89
|
+
expect(xml).not.toContain("Older Archive Post");
|
|
90
|
+
// Not a filter, so the feed's own address does not carry it.
|
|
91
|
+
expect(xml).not.toContain("limit=1");
|
|
92
|
+
});
|
|
93
|
+
|
|
63
94
|
it("filters by title with the new param and the legacy fallback", async () => {
|
|
64
95
|
const { app, services } = setupApp();
|
|
65
96
|
await services.posts.create({
|
|
@@ -116,10 +147,23 @@ describe("archive feed filter params", () => {
|
|
|
116
147
|
format: "note",
|
|
117
148
|
bodyMarkdown: "text only body",
|
|
118
149
|
});
|
|
150
|
+
await services.media.create({
|
|
151
|
+
filename: "unattached.jpg",
|
|
152
|
+
originalName: "unattached.jpg",
|
|
153
|
+
mimeType: "image/jpeg",
|
|
154
|
+
size: 1024,
|
|
155
|
+
storageKey: "media/unattached.jpg",
|
|
156
|
+
});
|
|
119
157
|
|
|
120
158
|
const fresh = await fetchFeed(app, "?media=none");
|
|
121
159
|
expect(fresh).toContain("text only body");
|
|
122
160
|
|
|
161
|
+
const page = await app.request("/archive?media=none");
|
|
162
|
+
expect(page.status).toBe(200);
|
|
163
|
+
const pageHtml = await page.text();
|
|
164
|
+
expect(pageHtml).toContain('aria-label="1 thread"');
|
|
165
|
+
expect(pageHtml).toContain("text only body");
|
|
166
|
+
|
|
123
167
|
const legacy = await fetchFeed(app, "?hasMedia=0");
|
|
124
168
|
expect(legacy).toContain("text only body");
|
|
125
169
|
|
|
@@ -128,6 +172,134 @@ describe("archive feed filter params", () => {
|
|
|
128
172
|
});
|
|
129
173
|
});
|
|
130
174
|
|
|
175
|
+
describe("archive feed ordering", () => {
|
|
176
|
+
// The feed belongs to the archive page, so it uses the page's default axis.
|
|
177
|
+
// Ordering by activity would let a reply to an old Thread reshuffle the
|
|
178
|
+
// feed window under a fixed rssFeedLimit, and readers key entries by id
|
|
179
|
+
// anyway — a moved entry is not a re-surfaced one. /latest/feed is where
|
|
180
|
+
// activity ordering lives.
|
|
181
|
+
it("orders by publication, not by recent replies", async () => {
|
|
182
|
+
const { app, services } = setupApp();
|
|
183
|
+
const oldThread = await services.posts.create({
|
|
184
|
+
format: "note",
|
|
185
|
+
title: "Old thread with a new reply",
|
|
186
|
+
bodyMarkdown: "root from 2019",
|
|
187
|
+
publishedAt: Date.UTC(2019, 0, 1) / 1000,
|
|
188
|
+
});
|
|
189
|
+
await services.posts.create({
|
|
190
|
+
format: "note",
|
|
191
|
+
bodyMarkdown: "reply from 2026",
|
|
192
|
+
replyToId: oldThread.id,
|
|
193
|
+
publishedAt: Date.UTC(2026, 5, 1) / 1000,
|
|
194
|
+
});
|
|
195
|
+
await services.posts.create({
|
|
196
|
+
format: "note",
|
|
197
|
+
title: "Plain newer post",
|
|
198
|
+
bodyMarkdown: "root from 2025",
|
|
199
|
+
publishedAt: Date.UTC(2025, 0, 1) / 1000,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const xml = await fetchFeed(app, "");
|
|
203
|
+
|
|
204
|
+
expect(xml.indexOf("Plain newer post")).toBeLessThan(
|
|
205
|
+
xml.indexOf("Old thread with a new reply"),
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("honors an explicit sort=updated, quiet replies included", async () => {
|
|
210
|
+
const { app, services } = setupApp();
|
|
211
|
+
const oldThread = await services.posts.create({
|
|
212
|
+
format: "note",
|
|
213
|
+
title: "Old thread with a new reply",
|
|
214
|
+
bodyMarkdown: "root from 2019",
|
|
215
|
+
publishedAt: Date.UTC(2019, 0, 1) / 1000,
|
|
216
|
+
});
|
|
217
|
+
await services.posts.create({
|
|
218
|
+
format: "note",
|
|
219
|
+
bodyMarkdown: "quiet addition",
|
|
220
|
+
replyToId: oldThread.id,
|
|
221
|
+
publishedAt: Date.UTC(2026, 5, 1) / 1000,
|
|
222
|
+
quietReply: true,
|
|
223
|
+
});
|
|
224
|
+
await services.posts.create({
|
|
225
|
+
format: "note",
|
|
226
|
+
title: "Plain newer post",
|
|
227
|
+
bodyMarkdown: "root from 2025",
|
|
228
|
+
publishedAt: Date.UTC(2025, 0, 1) / 1000,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const updated = await fetchFeed(app, "?sort=updated");
|
|
232
|
+
expect(updated.indexOf("Old thread with a new reply")).toBeLessThan(
|
|
233
|
+
updated.indexOf("Plain newer post"),
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
// Same URL param, same axis as the page — the default stays chronological.
|
|
237
|
+
const chronological = await fetchFeed(app, "");
|
|
238
|
+
expect(chronological.indexOf("Plain newer post")).toBeLessThan(
|
|
239
|
+
chronological.indexOf("Old thread with a new reply"),
|
|
240
|
+
);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("scopes a year filter to the axis the feed is sorted on", async () => {
|
|
244
|
+
const { app, services } = setupApp();
|
|
245
|
+
const oldThread = await services.posts.create({
|
|
246
|
+
format: "note",
|
|
247
|
+
title: "Extended in 2026",
|
|
248
|
+
bodyMarkdown: "root from 2019",
|
|
249
|
+
publishedAt: Date.UTC(2019, 0, 1) / 1000,
|
|
250
|
+
});
|
|
251
|
+
await services.posts.create({
|
|
252
|
+
format: "note",
|
|
253
|
+
bodyMarkdown: "addition",
|
|
254
|
+
replyToId: oldThread.id,
|
|
255
|
+
publishedAt: Date.UTC(2026, 5, 1) / 1000,
|
|
256
|
+
});
|
|
257
|
+
await services.posts.create({
|
|
258
|
+
format: "note",
|
|
259
|
+
title: "Published in 2025",
|
|
260
|
+
bodyMarkdown: "root from 2025",
|
|
261
|
+
publishedAt: Date.UTC(2025, 0, 1) / 1000,
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const byActivity = await fetchFeed(app, "?sort=updated&year=2026");
|
|
265
|
+
expect(byActivity).toContain("Extended in 2026");
|
|
266
|
+
expect(byActivity).not.toContain("Published in 2025");
|
|
267
|
+
|
|
268
|
+
const byPublication = await fetchFeed(app, "?year=2025");
|
|
269
|
+
expect(byPublication).toContain("Published in 2025");
|
|
270
|
+
expect(byPublication).not.toContain("Extended in 2026");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("keeps the same order after an old thread gains a reply", async () => {
|
|
274
|
+
const { app, services } = setupApp();
|
|
275
|
+
const first = await services.posts.create({
|
|
276
|
+
format: "note",
|
|
277
|
+
title: "Alpha",
|
|
278
|
+
bodyMarkdown: "alpha root",
|
|
279
|
+
publishedAt: Date.UTC(2020, 0, 1) / 1000,
|
|
280
|
+
});
|
|
281
|
+
await services.posts.create({
|
|
282
|
+
format: "note",
|
|
283
|
+
title: "Beta",
|
|
284
|
+
bodyMarkdown: "beta root",
|
|
285
|
+
publishedAt: Date.UTC(2021, 0, 1) / 1000,
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
const before = await fetchFeed(app, "");
|
|
289
|
+
const orderBefore = before.indexOf("Beta") < before.indexOf("Alpha");
|
|
290
|
+
|
|
291
|
+
await services.posts.create({
|
|
292
|
+
format: "note",
|
|
293
|
+
bodyMarkdown: "late addition",
|
|
294
|
+
replyToId: first.id,
|
|
295
|
+
publishedAt: Date.UTC(2026, 0, 1) / 1000,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
const after = await fetchFeed(app, "");
|
|
299
|
+
expect(after.indexOf("Beta") < after.indexOf("Alpha")).toBe(orderBefore);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
131
303
|
describe("archive page legacy param redirect", () => {
|
|
132
304
|
it("redirects legacy boolean params to their single-word spelling", async () => {
|
|
133
305
|
const { app } = setupApp();
|
|
@@ -157,6 +329,20 @@ describe("archive page legacy param redirect", () => {
|
|
|
157
329
|
expect(res.headers.get("location")).toBe("/archive?visibility=hidden");
|
|
158
330
|
});
|
|
159
331
|
|
|
332
|
+
// `visibility=all` filtered nothing, exactly like an absent parameter — a
|
|
333
|
+
// second spelling of "nothing selected" that only the parser knew about.
|
|
334
|
+
it("drops the visibility=all chip state, which selected nothing", async () => {
|
|
335
|
+
const { app } = setupApp();
|
|
336
|
+
|
|
337
|
+
const res = await app.request("/archive?visibility=all");
|
|
338
|
+
expect(res.status).toBe(308);
|
|
339
|
+
expect(res.headers.get("location")).toBe("/archive");
|
|
340
|
+
|
|
341
|
+
const withOthers = await app.request("/archive?format=note&visibility=all");
|
|
342
|
+
expect(withOthers.status).toBe(308);
|
|
343
|
+
expect(withOthers.headers.get("location")).toBe("/archive?format=note");
|
|
344
|
+
});
|
|
345
|
+
|
|
160
346
|
it("drops a legacy param without overriding an explicit new one", async () => {
|
|
161
347
|
const { app } = setupApp();
|
|
162
348
|
|
|
@@ -164,6 +350,39 @@ describe("archive page legacy param redirect", () => {
|
|
|
164
350
|
expect(res.status).toBe(308);
|
|
165
351
|
expect(res.headers.get("location")).toBe("/archive?title=any");
|
|
166
352
|
});
|
|
353
|
+
|
|
354
|
+
// `view` was renamed to `layout` so `view` can name the saved-selection
|
|
355
|
+
// concept. Shared links and bookmarks predate the rename.
|
|
356
|
+
it("redirects the legacy view param to layout", async () => {
|
|
357
|
+
const { app } = setupApp();
|
|
358
|
+
|
|
359
|
+
const res = await app.request("/archive?view=grid");
|
|
360
|
+
expect(res.status).toBe(308);
|
|
361
|
+
expect(res.headers.get("location")).toBe("/archive?layout=grid");
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("keeps layout when a URL carries both spellings", async () => {
|
|
365
|
+
const { app } = setupApp();
|
|
366
|
+
|
|
367
|
+
const res = await app.request("/archive?layout=list&view=grid");
|
|
368
|
+
expect(res.status).toBe(308);
|
|
369
|
+
expect(res.headers.get("location")).toBe("/archive?layout=list");
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("drops a view value it cannot render", async () => {
|
|
373
|
+
const { app } = setupApp();
|
|
374
|
+
|
|
375
|
+
const res = await app.request("/archive?view=carousel");
|
|
376
|
+
expect(res.status).toBe(308);
|
|
377
|
+
expect(res.headers.get("location")).toBe("/archive");
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("leaves a canonical layout param alone", async () => {
|
|
381
|
+
const { app } = setupApp();
|
|
382
|
+
|
|
383
|
+
const res = await app.request("/archive?layout=grid");
|
|
384
|
+
expect(res.status).toBe(200);
|
|
385
|
+
});
|
|
167
386
|
});
|
|
168
387
|
|
|
169
388
|
describe("archive page ordering", () => {
|
|
@@ -213,7 +432,7 @@ describe("archive page ordering", () => {
|
|
|
213
432
|
publishedAt: Date.UTC(2026, 0, 1) / 1000,
|
|
214
433
|
});
|
|
215
434
|
|
|
216
|
-
const res = await app.request("/archive?
|
|
435
|
+
const res = await app.request("/archive?layout=list");
|
|
217
436
|
expect(res.status).toBe(200);
|
|
218
437
|
const html = await res.text();
|
|
219
438
|
|
|
@@ -222,3 +441,440 @@ describe("archive page ordering", () => {
|
|
|
222
441
|
);
|
|
223
442
|
});
|
|
224
443
|
});
|
|
444
|
+
|
|
445
|
+
describe("archive page sort=updated", () => {
|
|
446
|
+
/**
|
|
447
|
+
* An old thread that recently gained a reply, plus a younger standalone
|
|
448
|
+
* thread. The two axes disagree about their order, which is the whole point
|
|
449
|
+
* of the toggle.
|
|
450
|
+
*/
|
|
451
|
+
async function seedDivergingThreads(services: {
|
|
452
|
+
posts: {
|
|
453
|
+
create: (data: Record<string, unknown>) => Promise<{ id: string }>;
|
|
454
|
+
};
|
|
455
|
+
}) {
|
|
456
|
+
const olderThread = await services.posts.create({
|
|
457
|
+
format: "note",
|
|
458
|
+
title: "Growing thread",
|
|
459
|
+
bodyMarkdown: "root from 2019",
|
|
460
|
+
publishedAt: Date.UTC(2019, 2, 5) / 1000,
|
|
461
|
+
});
|
|
462
|
+
await services.posts.create({
|
|
463
|
+
format: "note",
|
|
464
|
+
bodyMarkdown: "reply from 2026",
|
|
465
|
+
replyToId: olderThread.id,
|
|
466
|
+
publishedAt: Date.UTC(2026, 7, 1) / 1000,
|
|
467
|
+
});
|
|
468
|
+
await services.posts.create({
|
|
469
|
+
format: "note",
|
|
470
|
+
title: "Untouched newer thread",
|
|
471
|
+
bodyMarkdown: "root from 2025",
|
|
472
|
+
publishedAt: Date.UTC(2025, 0, 1) / 1000,
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
it("puts recently extended threads first", async () => {
|
|
477
|
+
const { app, services } = setupApp();
|
|
478
|
+
await seedDivergingThreads(services);
|
|
479
|
+
|
|
480
|
+
const res = await app.request("/archive?sort=updated");
|
|
481
|
+
expect(res.status).toBe(200);
|
|
482
|
+
const html = await res.text();
|
|
483
|
+
|
|
484
|
+
expect(html.indexOf("Growing thread")).toBeLessThan(
|
|
485
|
+
html.indexOf("Untouched newer thread"),
|
|
486
|
+
);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
// Month headers are a grid affordance, so the bucketing axis is asserted
|
|
490
|
+
// through the grid layout rather than the site default.
|
|
491
|
+
it("buckets months on the same axis it sorts by", async () => {
|
|
492
|
+
const { app, services } = setupApp();
|
|
493
|
+
await seedDivergingThreads(services);
|
|
494
|
+
|
|
495
|
+
const byPublished = await (
|
|
496
|
+
await app.request("/archive?layout=grid")
|
|
497
|
+
).text();
|
|
498
|
+
expect(byPublished).toContain("March 2019");
|
|
499
|
+
expect(byPublished).not.toContain("August 2026");
|
|
500
|
+
|
|
501
|
+
const byActivity = await (
|
|
502
|
+
await app.request("/archive?layout=grid&sort=updated")
|
|
503
|
+
).text();
|
|
504
|
+
expect(byActivity).toContain("August 2026");
|
|
505
|
+
expect(byActivity).not.toContain("March 2019");
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it("scopes the year filter to the active axis", async () => {
|
|
509
|
+
const { app, services } = setupApp();
|
|
510
|
+
await seedDivergingThreads(services);
|
|
511
|
+
|
|
512
|
+
const activityYear = await (
|
|
513
|
+
await app.request("/archive?sort=updated&year=2026")
|
|
514
|
+
).text();
|
|
515
|
+
expect(activityYear).toContain("Growing thread");
|
|
516
|
+
expect(activityYear).not.toContain("Untouched newer thread");
|
|
517
|
+
|
|
518
|
+
const publishedYear = await (
|
|
519
|
+
await app.request("/archive?year=2026")
|
|
520
|
+
).text();
|
|
521
|
+
expect(publishedYear).not.toContain("Growing thread");
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it("keeps the default axis unchanged without the param", async () => {
|
|
525
|
+
const { app, services } = setupApp();
|
|
526
|
+
await seedDivergingThreads(services);
|
|
527
|
+
|
|
528
|
+
const html = await (await app.request("/archive")).text();
|
|
529
|
+
expect(html.indexOf("Untouched newer thread")).toBeLessThan(
|
|
530
|
+
html.indexOf("Growing thread"),
|
|
531
|
+
);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it("distinguishes the updated view in the document title", async () => {
|
|
535
|
+
const { app, services } = setupApp();
|
|
536
|
+
await seedDivergingThreads(services);
|
|
537
|
+
|
|
538
|
+
const byPublished = await (await app.request("/archive")).text();
|
|
539
|
+
expect(byPublished).toContain("<title>All posts - ");
|
|
540
|
+
expect(byPublished).not.toContain("Recently updated");
|
|
541
|
+
|
|
542
|
+
const byActivity = await (
|
|
543
|
+
await app.request("/archive?sort=updated")
|
|
544
|
+
).text();
|
|
545
|
+
expect(byActivity).toContain("<title>All posts - Recently updated - ");
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it("says how much a filter removed, and stays quiet when none did", async () => {
|
|
549
|
+
const { app, services } = setupApp();
|
|
550
|
+
await seedDivergingThreads(services);
|
|
551
|
+
|
|
552
|
+
// Two thread roots; asking for threads with replies leaves one of them.
|
|
553
|
+
const filtered = await (await app.request("/archive?replies=any")).text();
|
|
554
|
+
expect(filtered).toContain("of 2");
|
|
555
|
+
|
|
556
|
+
// Nothing filtered, so there is no baseline worth comparing against.
|
|
557
|
+
const unfiltered = await (await app.request("/archive")).text();
|
|
558
|
+
expect(unfiltered).not.toContain("of 2");
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it("names the active filter in the document title", async () => {
|
|
562
|
+
const { app, services } = setupApp();
|
|
563
|
+
await seedDivergingThreads(services);
|
|
564
|
+
|
|
565
|
+
const byFormat = await (await app.request("/archive?format=note")).text();
|
|
566
|
+
expect(byFormat).toContain("<title>Notes - ");
|
|
567
|
+
|
|
568
|
+
// Title presence absorbs the format it refines, as the filter chip does.
|
|
569
|
+
const untitled = await (
|
|
570
|
+
await app.request("/archive?format=note&title=none")
|
|
571
|
+
).text();
|
|
572
|
+
expect(untitled).toContain("<title>Untitled - ");
|
|
573
|
+
|
|
574
|
+
// A tab truncates from the right, so the description stops at two parts.
|
|
575
|
+
const capped = await (
|
|
576
|
+
await app.request("/archive?format=note&year=2019&replies=any")
|
|
577
|
+
).text();
|
|
578
|
+
expect(capped).toContain("<title>Notes, 2019 - ");
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it("ignores an unknown sort value", async () => {
|
|
582
|
+
const { app, services } = setupApp();
|
|
583
|
+
await seedDivergingThreads(services);
|
|
584
|
+
|
|
585
|
+
const html = await (await app.request("/archive?sort=nonsense")).text();
|
|
586
|
+
expect(html.indexOf("Untouched newer thread")).toBeLessThan(
|
|
587
|
+
html.indexOf("Growing thread"),
|
|
588
|
+
);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
// The quiet-reply switch promises "won't move the thread to the top of
|
|
592
|
+
// latest". The archive is not Latest — it is the canonical all-posts view,
|
|
593
|
+
// and it already shows Hidden-from-Latest content. So its updated sort
|
|
594
|
+
// reports when the Thread actually changed.
|
|
595
|
+
it("surfaces quietly extended threads even though Latest does not", async () => {
|
|
596
|
+
const { app, services } = setupApp();
|
|
597
|
+
const quietThread = await services.posts.create({
|
|
598
|
+
format: "note",
|
|
599
|
+
title: "Quietly extended thread",
|
|
600
|
+
bodyMarkdown: "root from 2019",
|
|
601
|
+
publishedAt: Date.UTC(2019, 2, 5) / 1000,
|
|
602
|
+
});
|
|
603
|
+
await services.posts.create({
|
|
604
|
+
format: "note",
|
|
605
|
+
bodyMarkdown: "quiet addendum",
|
|
606
|
+
replyToId: quietThread.id,
|
|
607
|
+
publishedAt: Date.UTC(2026, 7, 1) / 1000,
|
|
608
|
+
quietReply: true,
|
|
609
|
+
});
|
|
610
|
+
await services.posts.create({
|
|
611
|
+
format: "note",
|
|
612
|
+
title: "Untouched newer thread",
|
|
613
|
+
bodyMarkdown: "root from 2025",
|
|
614
|
+
publishedAt: Date.UTC(2025, 0, 1) / 1000,
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
const html = await (
|
|
618
|
+
await app.request("/archive?layout=grid&sort=updated")
|
|
619
|
+
).text();
|
|
620
|
+
expect(html.indexOf("Quietly extended thread")).toBeLessThan(
|
|
621
|
+
html.indexOf("Untouched newer thread"),
|
|
622
|
+
);
|
|
623
|
+
expect(html).toContain("August 2026");
|
|
624
|
+
|
|
625
|
+
// Latest still honors the quiet flag.
|
|
626
|
+
const roots = await services.posts.list({
|
|
627
|
+
status: "published",
|
|
628
|
+
excludeReplies: true,
|
|
629
|
+
excludeLatestHidden: true,
|
|
630
|
+
});
|
|
631
|
+
expect(roots[0]?.title).toBe("Untouched newer thread");
|
|
632
|
+
});
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* `visibility` is the one archive dimension whose values are not all reachable
|
|
637
|
+
* by every reader. The rule: `private` is never silently dropped. On the page
|
|
638
|
+
* the reader's own URL is rewritten so the address bar keeps describing what
|
|
639
|
+
* rendered; a stored archive path 404s instead (see custom-archive-url.test.ts,
|
|
640
|
+
* which has no URL to rewrite).
|
|
641
|
+
*/
|
|
642
|
+
describe("archive visibility without a session", () => {
|
|
643
|
+
async function seedVisibilities(services: {
|
|
644
|
+
posts: {
|
|
645
|
+
create: (input: Record<string, unknown>) => Promise<unknown>;
|
|
646
|
+
};
|
|
647
|
+
}) {
|
|
648
|
+
await services.posts.create({
|
|
649
|
+
format: "note",
|
|
650
|
+
bodyMarkdown: "body on the stream",
|
|
651
|
+
status: "published",
|
|
652
|
+
});
|
|
653
|
+
await services.posts.create({
|
|
654
|
+
format: "note",
|
|
655
|
+
bodyMarkdown: "body off the stream",
|
|
656
|
+
status: "published",
|
|
657
|
+
visibility: "latest_hidden",
|
|
658
|
+
});
|
|
659
|
+
await services.posts.create({
|
|
660
|
+
format: "note",
|
|
661
|
+
bodyMarkdown: "body kept back",
|
|
662
|
+
status: "published",
|
|
663
|
+
visibility: "private",
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
it("filters by Hidden-from-Latest for a signed-out reader", async () => {
|
|
668
|
+
const { app, services } = setupApp();
|
|
669
|
+
await seedVisibilities(services);
|
|
670
|
+
|
|
671
|
+
const res = await app.request("/archive?visibility=hidden");
|
|
672
|
+
expect(res.status).toBe(200);
|
|
673
|
+
|
|
674
|
+
const html = await res.text();
|
|
675
|
+
expect(html).toContain("body off the stream");
|
|
676
|
+
expect(html).not.toContain("body on the stream");
|
|
677
|
+
expect(html).not.toContain("body kept back");
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
it("strips private from a signed-out reader's URL, keeping the rest", async () => {
|
|
681
|
+
const { app } = setupApp();
|
|
682
|
+
|
|
683
|
+
const res = await app.request("/archive?visibility=private&format=note");
|
|
684
|
+
expect(res.status).toBe(302);
|
|
685
|
+
expect(res.headers.get("location")).toBe("/archive?format=note");
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
// 302, not 308: the URL moved for this reader at this moment, and has to
|
|
689
|
+
// keep working once the author signs in.
|
|
690
|
+
it("uses a temporary redirect, and a permanent one for legacy spellings", async () => {
|
|
691
|
+
const { app } = setupApp();
|
|
692
|
+
|
|
693
|
+
const both = await app.request("/archive?visibility=private&hasTitle=1");
|
|
694
|
+
expect(both.status).toBe(302);
|
|
695
|
+
expect(both.headers.get("location")).toBe("/archive?title=any");
|
|
696
|
+
|
|
697
|
+
const legacyOnly = await app.request("/archive?hasTitle=1");
|
|
698
|
+
expect(legacyOnly.status).toBe(308);
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
// The redirect must answer from the param and the session alone. Firing only
|
|
702
|
+
// when private posts exist would tell any visitor whether this site keeps any.
|
|
703
|
+
it("redirects identically when the site holds no private posts", async () => {
|
|
704
|
+
const { app, services } = setupApp();
|
|
705
|
+
await services.posts.create({
|
|
706
|
+
format: "note",
|
|
707
|
+
bodyMarkdown: "the only post here",
|
|
708
|
+
status: "published",
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
const res = await app.request("/archive?visibility=private");
|
|
712
|
+
expect(res.status).toBe(302);
|
|
713
|
+
expect(res.headers.get("location")).toBe("/archive");
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
it("leaves the author's own private filter alone", async () => {
|
|
717
|
+
const { app, services } = setupAuthedApp();
|
|
718
|
+
await seedVisibilities(services);
|
|
719
|
+
|
|
720
|
+
const res = await app.request("/archive?visibility=private");
|
|
721
|
+
expect(res.status).toBe(200);
|
|
722
|
+
|
|
723
|
+
const html = await res.text();
|
|
724
|
+
expect(html).toContain("body kept back");
|
|
725
|
+
expect(html).not.toContain("body on the stream");
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
// A feed carries no session, so it cannot serve this selection. The page
|
|
729
|
+
// withholds the link rather than promising a feed that answers 404.
|
|
730
|
+
it("offers no feed link while the private filter is active", async () => {
|
|
731
|
+
const { app, services } = setupAuthedApp();
|
|
732
|
+
await seedVisibilities(services);
|
|
733
|
+
|
|
734
|
+
const filtered = await app.request("/archive?visibility=private");
|
|
735
|
+
expect(await filtered.text()).not.toContain("/archive/feed");
|
|
736
|
+
|
|
737
|
+
const plain = await app.request("/archive");
|
|
738
|
+
expect(await plain.text()).toContain("/archive/feed");
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it("carries the visibility selection into the feed", async () => {
|
|
742
|
+
const { app, services } = setupApp();
|
|
743
|
+
await seedVisibilities(services);
|
|
744
|
+
|
|
745
|
+
const xml = await fetchFeed(app, "?visibility=hidden");
|
|
746
|
+
expect(xml).toContain("body off the stream");
|
|
747
|
+
expect(xml).not.toContain("body on the stream");
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
it("404s the feed for a selection it cannot serve", async () => {
|
|
751
|
+
const { app, services } = setupApp();
|
|
752
|
+
await seedVisibilities(services);
|
|
753
|
+
|
|
754
|
+
const res = await app.request("/archive/feed?visibility=private");
|
|
755
|
+
expect(res.status).toBe(404);
|
|
756
|
+
});
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
describe("archive collection selection", () => {
|
|
760
|
+
async function seedOneCollectedPost(services: {
|
|
761
|
+
posts: {
|
|
762
|
+
create: (input: Record<string, unknown>) => Promise<{ id: string }>;
|
|
763
|
+
};
|
|
764
|
+
collections: {
|
|
765
|
+
create: (input: Record<string, unknown>) => Promise<{ id: string }>;
|
|
766
|
+
addThread: (collectionId: string, threadId: string) => Promise<unknown>;
|
|
767
|
+
};
|
|
768
|
+
}) {
|
|
769
|
+
const collection = await services.collections.create({
|
|
770
|
+
slug: "recipes",
|
|
771
|
+
title: "Recipes",
|
|
772
|
+
});
|
|
773
|
+
const collected = await services.posts.create({
|
|
774
|
+
format: "note",
|
|
775
|
+
title: "Collected Post",
|
|
776
|
+
bodyMarkdown: "in the collection",
|
|
777
|
+
status: "published",
|
|
778
|
+
});
|
|
779
|
+
await services.collections.addThread(collection.id, collected.id);
|
|
780
|
+
await services.posts.create({
|
|
781
|
+
format: "note",
|
|
782
|
+
title: "Loose Post",
|
|
783
|
+
bodyMarkdown: "in no collection",
|
|
784
|
+
status: "published",
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
it("filters the page by a collection that exists", async () => {
|
|
789
|
+
const { app, services } = setupApp();
|
|
790
|
+
await seedOneCollectedPost(services);
|
|
791
|
+
|
|
792
|
+
const res = await app.request("/archive?collection=recipes");
|
|
793
|
+
expect(res.status).toBe(200);
|
|
794
|
+
const html = await res.text();
|
|
795
|
+
expect(html).toContain("Collected Post");
|
|
796
|
+
expect(html).not.toContain("Loose Post");
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
// Rendering the whole archive here would answer a question nobody asked,
|
|
800
|
+
// under the name the reader typed — the failure `visibility=private` was
|
|
801
|
+
// fixed for. `/collections/{slug}` answers the same question with a 404.
|
|
802
|
+
it("404s the page for a collection that does not exist", async () => {
|
|
803
|
+
const { app, services } = setupApp();
|
|
804
|
+
await seedOneCollectedPost(services);
|
|
805
|
+
|
|
806
|
+
const res = await app.request("/archive?collection=nope");
|
|
807
|
+
expect(res.status).toBe(404);
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
it("404s the feed for a collection that does not exist", async () => {
|
|
811
|
+
const { app, services } = setupApp();
|
|
812
|
+
await seedOneCollectedPost(services);
|
|
813
|
+
|
|
814
|
+
expect((await app.request("/archive/feed?collection=recipes")).status).toBe(
|
|
815
|
+
200,
|
|
816
|
+
);
|
|
817
|
+
expect((await app.request("/archive/feed?collection=nope")).status).toBe(
|
|
818
|
+
404,
|
|
819
|
+
);
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
// The page read a single slug while `/api/public/archive` read a list, so one
|
|
823
|
+
// spelling meant two things depending on which surface you asked. Both now
|
|
824
|
+
// read the same vocabulary.
|
|
825
|
+
it("selects the union of several collections", async () => {
|
|
826
|
+
const { app, services } = setupApp();
|
|
827
|
+
const recipes = await services.collections.create({
|
|
828
|
+
slug: "recipes",
|
|
829
|
+
title: "Recipes",
|
|
830
|
+
});
|
|
831
|
+
const travel = await services.collections.create({
|
|
832
|
+
slug: "travel",
|
|
833
|
+
title: "Travel",
|
|
834
|
+
});
|
|
835
|
+
const inRecipes = await services.posts.create({
|
|
836
|
+
format: "note",
|
|
837
|
+
title: "Recipe Post",
|
|
838
|
+
bodyMarkdown: "in recipes",
|
|
839
|
+
status: "published",
|
|
840
|
+
});
|
|
841
|
+
const inTravel = await services.posts.create({
|
|
842
|
+
format: "note",
|
|
843
|
+
title: "Travel Post",
|
|
844
|
+
bodyMarkdown: "in travel",
|
|
845
|
+
status: "published",
|
|
846
|
+
});
|
|
847
|
+
await services.posts.create({
|
|
848
|
+
format: "note",
|
|
849
|
+
title: "Loose Post",
|
|
850
|
+
bodyMarkdown: "in no collection",
|
|
851
|
+
status: "published",
|
|
852
|
+
});
|
|
853
|
+
await services.collections.addThread(recipes.id, inRecipes.id);
|
|
854
|
+
await services.collections.addThread(travel.id, inTravel.id);
|
|
855
|
+
|
|
856
|
+
const res = await app.request("/archive?collection=recipes,travel");
|
|
857
|
+
expect(res.status).toBe(200);
|
|
858
|
+
const html = await res.text();
|
|
859
|
+
expect(html).toContain("Recipe Post");
|
|
860
|
+
expect(html).toContain("Travel Post");
|
|
861
|
+
expect(html).not.toContain("Loose Post");
|
|
862
|
+
|
|
863
|
+
// Hono decodes a query value as form-urlencoded, so the `+` that separates
|
|
864
|
+
// slugs in `/collections/{a+b}` arrives here as a space. It is read anyway;
|
|
865
|
+
// a comma is what gets written back.
|
|
866
|
+
const plusSpelling = await app.request(
|
|
867
|
+
"/archive?collection=recipes+travel",
|
|
868
|
+
);
|
|
869
|
+
expect(plusSpelling.status).toBe(200);
|
|
870
|
+
expect(await plusSpelling.text()).toContain("Travel Post");
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
it("404s when only one of several collections is missing", async () => {
|
|
874
|
+
const { app, services } = setupApp();
|
|
875
|
+
await seedOneCollectedPost(services);
|
|
876
|
+
|
|
877
|
+
const res = await app.request("/archive?collection=recipes,nope");
|
|
878
|
+
expect(res.status).toBe(404);
|
|
879
|
+
});
|
|
880
|
+
});
|