@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,651 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
createTestDatabase,
|
|
4
|
+
DEFAULT_TEST_SITE_ID,
|
|
5
|
+
} from "../../__tests__/helpers/db.js";
|
|
6
|
+
import { createCollectionService } from "../collection.js";
|
|
7
|
+
import { createPathService } from "../path.js";
|
|
8
|
+
import { createPostService } from "../post.js";
|
|
9
|
+
import { createSmartCollectionService } from "../smart-collection.js";
|
|
10
|
+
import type { Database } from "../../db/index.js";
|
|
11
|
+
import type { SmartCollectionSortOrder } from "../../types.js";
|
|
12
|
+
|
|
13
|
+
describe("SmartCollectionService", () => {
|
|
14
|
+
let db: Database;
|
|
15
|
+
let sqlite: ReturnType<typeof createTestDatabase>["sqlite"];
|
|
16
|
+
let paths: ReturnType<typeof createPathService>;
|
|
17
|
+
let posts: ReturnType<typeof createPostService>;
|
|
18
|
+
let collections: ReturnType<typeof createCollectionService>;
|
|
19
|
+
let smartCollections: ReturnType<typeof createSmartCollectionService>;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Count the statements that scan `post`, while `run` executes.
|
|
23
|
+
*
|
|
24
|
+
* The directory's promise is one round trip however many smart collections a
|
|
25
|
+
* site has — on Workers that is the cost that actually matters, and it is the
|
|
26
|
+
* kind of property that regresses into a `Promise.all` without any test
|
|
27
|
+
* noticing.
|
|
28
|
+
*/
|
|
29
|
+
async function countPostQueries<T>(
|
|
30
|
+
run: () => Promise<T>,
|
|
31
|
+
): Promise<[T, number]> {
|
|
32
|
+
const original = sqlite.prepare.bind(sqlite);
|
|
33
|
+
let scans = 0;
|
|
34
|
+
const spy = vi.spyOn(sqlite, "prepare").mockImplementation(((
|
|
35
|
+
source: string,
|
|
36
|
+
) => {
|
|
37
|
+
if (/from\s+"?post"?/i.test(source)) scans += 1;
|
|
38
|
+
return original(source);
|
|
39
|
+
}) as typeof sqlite.prepare);
|
|
40
|
+
try {
|
|
41
|
+
const value = await run();
|
|
42
|
+
return [value, scans];
|
|
43
|
+
} finally {
|
|
44
|
+
spy.mockRestore();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
const testDb = createTestDatabase();
|
|
50
|
+
db = testDb.db as unknown as Database;
|
|
51
|
+
sqlite = testDb.sqlite;
|
|
52
|
+
paths = createPathService(db, DEFAULT_TEST_SITE_ID);
|
|
53
|
+
posts = createPostService(db, { slugIdLength: 5 }, DEFAULT_TEST_SITE_ID);
|
|
54
|
+
smartCollections = createSmartCollectionService(
|
|
55
|
+
db,
|
|
56
|
+
DEFAULT_TEST_SITE_ID,
|
|
57
|
+
paths,
|
|
58
|
+
posts,
|
|
59
|
+
);
|
|
60
|
+
collections = createCollectionService(
|
|
61
|
+
db,
|
|
62
|
+
DEFAULT_TEST_SITE_ID,
|
|
63
|
+
paths,
|
|
64
|
+
undefined,
|
|
65
|
+
undefined,
|
|
66
|
+
smartCollections,
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const anonymous = { isAuthenticated: false };
|
|
71
|
+
const author = { isAuthenticated: true };
|
|
72
|
+
|
|
73
|
+
describe("create", () => {
|
|
74
|
+
it("stores conditions and reads them back in the shared vocabulary", async () => {
|
|
75
|
+
const created = await smartCollections.create({
|
|
76
|
+
slug: "quotes",
|
|
77
|
+
title: "Quotes",
|
|
78
|
+
description: "Things worth keeping.",
|
|
79
|
+
selection: { format: "quote", media: "any", title: false },
|
|
80
|
+
sort: "oldest",
|
|
81
|
+
layout: "grid",
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(created.slug).toBe("quotes");
|
|
85
|
+
expect(created.selection).toEqual({
|
|
86
|
+
format: "quote",
|
|
87
|
+
media: "any",
|
|
88
|
+
title: false,
|
|
89
|
+
});
|
|
90
|
+
expect(created.sort).toBe("oldest");
|
|
91
|
+
expect(created.layout).toBe("grid");
|
|
92
|
+
|
|
93
|
+
const reread = await smartCollections.getBySlug("quotes");
|
|
94
|
+
expect(reread?.selection).toEqual(created.selection);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("accepts no conditions at all, which means every post", async () => {
|
|
98
|
+
const created = await smartCollections.create({
|
|
99
|
+
slug: "everything",
|
|
100
|
+
title: "Everything",
|
|
101
|
+
});
|
|
102
|
+
expect(created.selection).toEqual({});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("shares the root address space with posts and collections", async () => {
|
|
106
|
+
await collections.create({ slug: "books", title: "Books" });
|
|
107
|
+
|
|
108
|
+
await expect(
|
|
109
|
+
smartCollections.create({ slug: "books", title: "Quotes" }),
|
|
110
|
+
).rejects.toThrow(/already in use/);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("leaves no path behind when the row cannot be written", async () => {
|
|
114
|
+
// A condition naming a collection that does not exist is refused before
|
|
115
|
+
// anything is written, so the address stays free.
|
|
116
|
+
await expect(
|
|
117
|
+
smartCollections.create({
|
|
118
|
+
slug: "quotes",
|
|
119
|
+
title: "Quotes",
|
|
120
|
+
selection: { collection: ["col_01m0f291t3fzvte3vj2g8d611z"] },
|
|
121
|
+
}),
|
|
122
|
+
).rejects.toThrow();
|
|
123
|
+
|
|
124
|
+
expect(await paths.isPathAvailable("quotes")).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("refuses a private condition, which a published page can never name", async () => {
|
|
128
|
+
await expect(
|
|
129
|
+
smartCollections.create({
|
|
130
|
+
slug: "secrets",
|
|
131
|
+
title: "Secrets",
|
|
132
|
+
// @ts-expect-error — the type rules this out; the check is that the
|
|
133
|
+
// runtime does too, for anything reaching the service untyped.
|
|
134
|
+
selection: { visibility: "private" },
|
|
135
|
+
}),
|
|
136
|
+
).rejects.toThrow();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("directory placement", () => {
|
|
141
|
+
it("takes a place in the directory, after what is already there", async () => {
|
|
142
|
+
const books = await collections.create({ slug: "books", title: "Books" });
|
|
143
|
+
const quotes = await smartCollections.create({
|
|
144
|
+
slug: "quotes",
|
|
145
|
+
title: "Quotes",
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const items = await collections.listDirectoryItems();
|
|
149
|
+
expect(
|
|
150
|
+
items.map((item) => [
|
|
151
|
+
item.type,
|
|
152
|
+
item.collectionId ?? item.smartCollectionId,
|
|
153
|
+
]),
|
|
154
|
+
).toEqual([
|
|
155
|
+
["collection", books.id],
|
|
156
|
+
["smart_collection", quotes.id],
|
|
157
|
+
]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("leaves no directory row behind when the address is taken", async () => {
|
|
161
|
+
await collections.create({ slug: "books", title: "Books" });
|
|
162
|
+
|
|
163
|
+
await expect(
|
|
164
|
+
smartCollections.create({ slug: "books", title: "Quotes" }),
|
|
165
|
+
).rejects.toThrow(/already in use/);
|
|
166
|
+
|
|
167
|
+
const items = await collections.listDirectoryItems();
|
|
168
|
+
expect(items.filter((item) => item.type === "smart_collection")).toEqual(
|
|
169
|
+
[],
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("drags by its own id, which is how the directory names it", async () => {
|
|
174
|
+
const first = await collections.create({ slug: "books", title: "Books" });
|
|
175
|
+
const last = await collections.create({ slug: "notes", title: "Notes" });
|
|
176
|
+
const quotes = await smartCollections.create({
|
|
177
|
+
slug: "quotes",
|
|
178
|
+
title: "Quotes",
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const placed = await collections.listDirectoryItems();
|
|
182
|
+
const [firstRow, lastRow] = placed;
|
|
183
|
+
|
|
184
|
+
// The drag surface sends the smart collection's own id, not its row's.
|
|
185
|
+
const moved = await collections.moveDirectoryItem(
|
|
186
|
+
quotes.id,
|
|
187
|
+
firstRow?.id ?? null,
|
|
188
|
+
lastRow?.id ?? null,
|
|
189
|
+
);
|
|
190
|
+
expect(moved?.smartCollectionId).toBe(quotes.id);
|
|
191
|
+
|
|
192
|
+
const reordered = await collections.listDirectoryItems();
|
|
193
|
+
expect(
|
|
194
|
+
reordered.map((item) => item.collectionId ?? item.smartCollectionId),
|
|
195
|
+
).toEqual([first.id, quotes.id, last.id]);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("places a smart collection that never had a row, then moves it", async () => {
|
|
199
|
+
const first = await collections.create({ slug: "books", title: "Books" });
|
|
200
|
+
const last = await collections.create({ slug: "notes", title: "Notes" });
|
|
201
|
+
const quotes = await smartCollections.create({
|
|
202
|
+
slug: "quotes",
|
|
203
|
+
title: "Quotes",
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// A smart collection from before placement existed: the directory shows
|
|
207
|
+
// it appended at the end, with no row of its own.
|
|
208
|
+
sqlite
|
|
209
|
+
.prepare(
|
|
210
|
+
`DELETE FROM collection_directory_item WHERE smart_collection_id = ?`,
|
|
211
|
+
)
|
|
212
|
+
.run(quotes.id);
|
|
213
|
+
const unplaced = await collections.listDirectoryItems();
|
|
214
|
+
expect(unplaced).toHaveLength(2);
|
|
215
|
+
|
|
216
|
+
const [firstRow, lastRow] = unplaced;
|
|
217
|
+
await collections.moveDirectoryItem(
|
|
218
|
+
quotes.id,
|
|
219
|
+
firstRow?.id ?? null,
|
|
220
|
+
lastRow?.id ?? null,
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const reordered = await collections.listDirectoryItems();
|
|
224
|
+
expect(
|
|
225
|
+
reordered.map((item) => item.collectionId ?? item.smartCollectionId),
|
|
226
|
+
).toEqual([first.id, quotes.id, last.id]);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("names an unplaced neighbour, without reshuffling what was on screen", async () => {
|
|
230
|
+
const books = await collections.create({ slug: "books", title: "Books" });
|
|
231
|
+
const quotes = await smartCollections.create({
|
|
232
|
+
slug: "quotes",
|
|
233
|
+
title: "Quotes",
|
|
234
|
+
});
|
|
235
|
+
sqlite
|
|
236
|
+
.prepare(
|
|
237
|
+
`DELETE FROM collection_directory_item WHERE smart_collection_id = ?`,
|
|
238
|
+
)
|
|
239
|
+
.run(quotes.id);
|
|
240
|
+
|
|
241
|
+
const divider = await collections.createDirectoryItem({
|
|
242
|
+
type: "divider",
|
|
243
|
+
label: "Reading",
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// Dropping the divider between a placed collection and an unplaced smart
|
|
247
|
+
// collection: the smart collection has to become a row for the position
|
|
248
|
+
// to be computable, and it must land where the author already saw it.
|
|
249
|
+
await collections.moveDirectoryItem(divider.id, books.id, quotes.id);
|
|
250
|
+
|
|
251
|
+
const reordered = await collections.listDirectoryItems();
|
|
252
|
+
expect(
|
|
253
|
+
reordered.map(
|
|
254
|
+
(item) => item.collectionId ?? item.smartCollectionId ?? item.id,
|
|
255
|
+
),
|
|
256
|
+
).toEqual([books.id, divider.id, quotes.id]);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe("update", () => {
|
|
261
|
+
it("clears a condition that was removed, rather than leaving it behind", async () => {
|
|
262
|
+
const created = await smartCollections.create({
|
|
263
|
+
slug: "quotes",
|
|
264
|
+
title: "Quotes",
|
|
265
|
+
selection: { format: "quote", year: 2024 },
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
const updated = await smartCollections.update(created.id, {
|
|
269
|
+
selection: { format: "quote" },
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
expect(updated?.selection).toEqual({ format: "quote" });
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("moves the address, and the navigation link with it", async () => {
|
|
276
|
+
const created = await smartCollections.create({
|
|
277
|
+
slug: "quotes",
|
|
278
|
+
title: "Quotes",
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
await smartCollections.update(created.id, { slug: "citations" });
|
|
282
|
+
|
|
283
|
+
expect(await smartCollections.getBySlug("quotes")).toBeNull();
|
|
284
|
+
const moved = await smartCollections.getBySlug("citations");
|
|
285
|
+
expect(moved?.id).toBe(created.id);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
describe("directory entries", () => {
|
|
290
|
+
async function seed() {
|
|
291
|
+
await posts.create({
|
|
292
|
+
format: "quote",
|
|
293
|
+
quoteText: "A public quote",
|
|
294
|
+
bodyMarkdown: "public quote body",
|
|
295
|
+
status: "published",
|
|
296
|
+
});
|
|
297
|
+
await posts.create({
|
|
298
|
+
format: "quote",
|
|
299
|
+
quoteText: "A private quote",
|
|
300
|
+
bodyMarkdown: "private quote body",
|
|
301
|
+
status: "published",
|
|
302
|
+
visibility: "private",
|
|
303
|
+
});
|
|
304
|
+
await posts.create({
|
|
305
|
+
format: "note",
|
|
306
|
+
bodyMarkdown: "a note",
|
|
307
|
+
status: "published",
|
|
308
|
+
});
|
|
309
|
+
await posts.create({
|
|
310
|
+
format: "quote",
|
|
311
|
+
quoteText: "A draft quote",
|
|
312
|
+
bodyMarkdown: "draft quote body",
|
|
313
|
+
status: "draft",
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
it("counts what each reader can actually see", async () => {
|
|
318
|
+
await seed();
|
|
319
|
+
await smartCollections.create({
|
|
320
|
+
slug: "quotes",
|
|
321
|
+
title: "Quotes",
|
|
322
|
+
selection: { format: "quote" },
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
const [asReader] = await smartCollections.listDirectoryEntries(anonymous);
|
|
326
|
+
const [asAuthor] = await smartCollections.listDirectoryEntries(author);
|
|
327
|
+
|
|
328
|
+
// Drafts never count. The private quote counts only for the author —
|
|
329
|
+
// the same rule a manual collection's directory entry follows.
|
|
330
|
+
expect(asReader?.threadCount).toBe(1);
|
|
331
|
+
expect(asAuthor?.threadCount).toBe(2);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("counts every smart collection in one query", async () => {
|
|
335
|
+
await seed();
|
|
336
|
+
await smartCollections.create({
|
|
337
|
+
slug: "quotes",
|
|
338
|
+
title: "Quotes",
|
|
339
|
+
selection: { format: "quote" },
|
|
340
|
+
});
|
|
341
|
+
await smartCollections.create({
|
|
342
|
+
slug: "notes",
|
|
343
|
+
title: "Notes",
|
|
344
|
+
selection: { format: "note" },
|
|
345
|
+
});
|
|
346
|
+
await smartCollections.create({
|
|
347
|
+
slug: "everything",
|
|
348
|
+
title: "Everything",
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
const [entries, postScans] = await countPostQueries(() =>
|
|
352
|
+
smartCollections.listDirectoryEntries(anonymous),
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
expect(entries.map((entry) => [entry.slug, entry.threadCount])).toEqual([
|
|
356
|
+
["quotes", 1],
|
|
357
|
+
["notes", 1],
|
|
358
|
+
["everything", 2],
|
|
359
|
+
]);
|
|
360
|
+
// Three smart collections, one scan of `post`. Not three.
|
|
361
|
+
expect(postScans).toBe(1);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("returns nothing to count when there is nothing to count", async () => {
|
|
365
|
+
expect(await smartCollections.listDirectoryEntries(anonymous)).toEqual(
|
|
366
|
+
[],
|
|
367
|
+
);
|
|
368
|
+
expect(await posts.aggregateMany([], {})).toEqual([]);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// The directory prints a count and a date on every row, and the two kinds
|
|
372
|
+
// of row sit one above the other. A date that meant something different on
|
|
373
|
+
// a smart collection would be worse than no date at all.
|
|
374
|
+
it("dates each smart collection by the newest thread it matches", async () => {
|
|
375
|
+
vi.useFakeTimers();
|
|
376
|
+
try {
|
|
377
|
+
vi.setSystemTime(new Date("2024-01-01T00:00:00Z"));
|
|
378
|
+
await posts.create({
|
|
379
|
+
format: "quote",
|
|
380
|
+
quoteText: "An old quote",
|
|
381
|
+
status: "published",
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
vi.setSystemTime(new Date("2024-01-02T00:00:00Z"));
|
|
385
|
+
await posts.create({
|
|
386
|
+
format: "note",
|
|
387
|
+
bodyMarkdown: "a note",
|
|
388
|
+
status: "published",
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
vi.setSystemTime(new Date("2024-01-03T00:00:00Z"));
|
|
392
|
+
await posts.create({
|
|
393
|
+
format: "quote",
|
|
394
|
+
quoteText: "A new quote",
|
|
395
|
+
status: "published",
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
await smartCollections.create({
|
|
399
|
+
slug: "quotes",
|
|
400
|
+
title: "Quotes",
|
|
401
|
+
selection: { format: "quote" },
|
|
402
|
+
});
|
|
403
|
+
await smartCollections.create({
|
|
404
|
+
slug: "notes",
|
|
405
|
+
title: "Notes",
|
|
406
|
+
selection: { format: "note" },
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
const entries = await smartCollections.listDirectoryEntries(anonymous);
|
|
410
|
+
|
|
411
|
+
// Per filter, not one date for the whole page.
|
|
412
|
+
expect(
|
|
413
|
+
entries.map((entry) => [entry.slug, entry.recentActivityAt]),
|
|
414
|
+
).toEqual([
|
|
415
|
+
["quotes", 1704240000],
|
|
416
|
+
["notes", 1704153600],
|
|
417
|
+
]);
|
|
418
|
+
} finally {
|
|
419
|
+
vi.useRealTimers();
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("dates it by what the viewer can see, as it counts by it", async () => {
|
|
424
|
+
vi.useFakeTimers();
|
|
425
|
+
try {
|
|
426
|
+
vi.setSystemTime(new Date("2024-01-01T00:00:00Z"));
|
|
427
|
+
await posts.create({
|
|
428
|
+
format: "quote",
|
|
429
|
+
quoteText: "A public quote",
|
|
430
|
+
status: "published",
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
vi.setSystemTime(new Date("2024-01-02T00:00:00Z"));
|
|
434
|
+
await posts.create({
|
|
435
|
+
format: "quote",
|
|
436
|
+
quoteText: "A private quote",
|
|
437
|
+
status: "published",
|
|
438
|
+
visibility: "private",
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
await smartCollections.create({
|
|
442
|
+
slug: "quotes",
|
|
443
|
+
title: "Quotes",
|
|
444
|
+
selection: { format: "quote" },
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
const [asReader] =
|
|
448
|
+
await smartCollections.listDirectoryEntries(anonymous);
|
|
449
|
+
const [asAuthor] = await smartCollections.listDirectoryEntries(author);
|
|
450
|
+
|
|
451
|
+
// A reader is never told that something they cannot read just moved.
|
|
452
|
+
expect(asReader?.recentActivityAt).toBe(1704067200);
|
|
453
|
+
expect(asAuthor?.recentActivityAt).toBe(1704153600);
|
|
454
|
+
} finally {
|
|
455
|
+
vi.useRealTimers();
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// Same definition as everywhere else: a Thread's activity is when it
|
|
460
|
+
// gained a post, so fixing a typo does not drag a collection to the top.
|
|
461
|
+
it("counts a reply as activity and a later edit as none", async () => {
|
|
462
|
+
vi.useFakeTimers();
|
|
463
|
+
try {
|
|
464
|
+
vi.setSystemTime(new Date("2024-01-01T00:00:00Z"));
|
|
465
|
+
const root = await posts.create({
|
|
466
|
+
format: "note",
|
|
467
|
+
bodyMarkdown: "root",
|
|
468
|
+
status: "published",
|
|
469
|
+
});
|
|
470
|
+
await smartCollections.create({
|
|
471
|
+
slug: "notes",
|
|
472
|
+
title: "Notes",
|
|
473
|
+
selection: { format: "note" },
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
vi.setSystemTime(new Date("2024-01-01T00:01:00Z"));
|
|
477
|
+
await posts.update(root.id, { bodyMarkdown: "edited" });
|
|
478
|
+
|
|
479
|
+
const [afterEdit] =
|
|
480
|
+
await smartCollections.listDirectoryEntries(anonymous);
|
|
481
|
+
expect(afterEdit?.recentActivityAt).toBe(1704067200);
|
|
482
|
+
|
|
483
|
+
vi.setSystemTime(new Date("2024-01-01T00:02:00Z"));
|
|
484
|
+
await posts.create({
|
|
485
|
+
format: "note",
|
|
486
|
+
bodyMarkdown: "reply",
|
|
487
|
+
replyToId: root.id,
|
|
488
|
+
status: "published",
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
const [afterReply] =
|
|
492
|
+
await smartCollections.listDirectoryEntries(anonymous);
|
|
493
|
+
expect(afterReply?.recentActivityAt).toBe(1704067320);
|
|
494
|
+
} finally {
|
|
495
|
+
vi.useRealTimers();
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it("dates an empty smart collection by itself", async () => {
|
|
500
|
+
const created = await smartCollections.create({
|
|
501
|
+
slug: "quotes",
|
|
502
|
+
title: "Quotes",
|
|
503
|
+
selection: { format: "quote" },
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
const [entry] = await smartCollections.listDirectoryEntries(anonymous);
|
|
507
|
+
|
|
508
|
+
// Nothing matched, so there is no thread to date it by — the same
|
|
509
|
+
// fallback an empty manual collection takes.
|
|
510
|
+
expect(entry?.threadCount).toBe(0);
|
|
511
|
+
expect(entry?.recentActivityAt).toBe(created.updatedAt);
|
|
512
|
+
});
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
describe("referential integrity", () => {
|
|
516
|
+
it("refuses to delete a collection a smart collection filters by, by name", async () => {
|
|
517
|
+
const books = await collections.create({ slug: "books", title: "Books" });
|
|
518
|
+
await smartCollections.create({
|
|
519
|
+
slug: "quotes",
|
|
520
|
+
title: "Quotes",
|
|
521
|
+
selection: { collection: [books.id] },
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
await expect(collections.delete(books.id)).rejects.toThrow(/Quotes/);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it("names every smart collection in the way, not just a count", async () => {
|
|
528
|
+
const books = await collections.create({ slug: "books", title: "Books" });
|
|
529
|
+
await smartCollections.create({
|
|
530
|
+
slug: "quotes",
|
|
531
|
+
title: "Quotes",
|
|
532
|
+
selection: { collection: [books.id] },
|
|
533
|
+
});
|
|
534
|
+
await smartCollections.create({
|
|
535
|
+
slug: "book-notes",
|
|
536
|
+
title: "Book Notes",
|
|
537
|
+
selection: { collection: [books.id] },
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
await expect(collections.delete(books.id)).rejects.toThrow(
|
|
541
|
+
/Quotes.*Book Notes/,
|
|
542
|
+
);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it("deletes a collection nothing depends on", async () => {
|
|
546
|
+
const books = await collections.create({ slug: "books", title: "Books" });
|
|
547
|
+
expect(await collections.delete(books.id)).toBe(true);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it("gathers nothing, not everything, when a condition is left dangling", async () => {
|
|
551
|
+
// `smart_collection.collection_id` carries no foreign key: no ON DELETE
|
|
552
|
+
// action was the right one, and RESTRICT would only turn every bulk
|
|
553
|
+
// delete into an ordering rule that fails anonymously. What makes that
|
|
554
|
+
// safe is this — the refusal above is the normal path, and the state it
|
|
555
|
+
// guards against degrades to an empty page rather than to the whole
|
|
556
|
+
// archive under someone's curated name.
|
|
557
|
+
const books = await collections.create({ slug: "books", title: "Books" });
|
|
558
|
+
const created = await smartCollections.create({
|
|
559
|
+
slug: "book-notes",
|
|
560
|
+
title: "Book Notes",
|
|
561
|
+
selection: { collection: [books.id] },
|
|
562
|
+
});
|
|
563
|
+
await posts.create({
|
|
564
|
+
format: "note",
|
|
565
|
+
bodyMarkdown: "a note in no collection",
|
|
566
|
+
status: "published",
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
// Straight past the service, the way an import or a hand-run statement
|
|
570
|
+
// would reach it.
|
|
571
|
+
sqlite.prepare('DELETE FROM "collection" WHERE "id" = ?').run(books.id);
|
|
572
|
+
|
|
573
|
+
const reread = await smartCollections.getById(created.id);
|
|
574
|
+
expect(reread?.selection).toEqual({ collection: [books.id] });
|
|
575
|
+
expect(
|
|
576
|
+
await posts.count(smartCollections.toPostFilters(reread!, author)),
|
|
577
|
+
).toBe(0);
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it("releases the address when the smart collection is deleted", async () => {
|
|
581
|
+
const created = await smartCollections.create({
|
|
582
|
+
slug: "quotes",
|
|
583
|
+
title: "Quotes",
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
expect(await smartCollections.delete(created.id)).toBe(true);
|
|
587
|
+
expect(await smartCollections.getBySlug("quotes")).toBeNull();
|
|
588
|
+
});
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
describe("toPostFilters", () => {
|
|
592
|
+
it("pins the year condition to publication, whatever the chosen order", async () => {
|
|
593
|
+
const created = await smartCollections.create({
|
|
594
|
+
slug: "twenty-four",
|
|
595
|
+
title: "2024",
|
|
596
|
+
selection: { year: 2024 },
|
|
597
|
+
sort: "newest",
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
const filters = smartCollections.toPostFilters(created, anonymous);
|
|
601
|
+
|
|
602
|
+
// Membership is not allowed to depend on presentation: ordering by
|
|
603
|
+
// activity must not move a post into or out of a year.
|
|
604
|
+
expect(filters.publishedAfter).toBe(Date.UTC(2024, 0, 1) / 1000);
|
|
605
|
+
expect(filters.publishedBefore).toBe(Date.UTC(2025, 0, 1) / 1000);
|
|
606
|
+
expect(filters.axisAfter).toBeUndefined();
|
|
607
|
+
expect(filters.sortBy).toBe("activity");
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it("orders on the same axes a manual collection does", async () => {
|
|
611
|
+
const axisFor = async (sort: SmartCollectionSortOrder, slug: string) => {
|
|
612
|
+
const created = await smartCollections.create({
|
|
613
|
+
slug,
|
|
614
|
+
title: slug,
|
|
615
|
+
sort,
|
|
616
|
+
});
|
|
617
|
+
const filters = smartCollections.toPostFilters(created, anonymous);
|
|
618
|
+
return { sortBy: filters.sortBy, sortOrder: filters.sortOrder };
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
// `newest` and the rating tie-break follow the Thread; only `oldest`
|
|
622
|
+
// reads publication, because a reply must not make a Thread older.
|
|
623
|
+
expect(await axisFor("newest", "recent")).toEqual({
|
|
624
|
+
sortBy: "activity",
|
|
625
|
+
sortOrder: "newest",
|
|
626
|
+
});
|
|
627
|
+
expect(await axisFor("rating_desc", "best")).toEqual({
|
|
628
|
+
sortBy: "activity",
|
|
629
|
+
sortOrder: "rating_desc",
|
|
630
|
+
});
|
|
631
|
+
expect(await axisFor("oldest", "first")).toEqual({
|
|
632
|
+
sortBy: "published",
|
|
633
|
+
sortOrder: "oldest",
|
|
634
|
+
});
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
it("holds the private floor for a signed-out reader", async () => {
|
|
638
|
+
const created = await smartCollections.create({
|
|
639
|
+
slug: "quotes",
|
|
640
|
+
title: "Quotes",
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
expect(
|
|
644
|
+
smartCollections.toPostFilters(created, anonymous).excludePrivate,
|
|
645
|
+
).toBe(true);
|
|
646
|
+
expect(
|
|
647
|
+
smartCollections.toPostFilters(created, author).excludePrivate,
|
|
648
|
+
).toBe(false);
|
|
649
|
+
});
|
|
650
|
+
});
|
|
651
|
+
});
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { ValidationError } from "../lib/errors.js";
|
|
10
10
|
import type { Visibility } from "../types.js";
|
|
11
11
|
import type { CollectionService } from "./collection.js";
|
|
12
|
+
import type { SmartCollectionService } from "./smart-collection.js";
|
|
12
13
|
import type { PathService } from "./path.js";
|
|
13
14
|
import type { PostService } from "./post.js";
|
|
14
15
|
|
|
@@ -34,7 +35,8 @@ export type AboutPageStatus =
|
|
|
34
35
|
state: "conflict";
|
|
35
36
|
path: typeof ABOUT_PAGE_PATH;
|
|
36
37
|
conflict: {
|
|
37
|
-
targetType:
|
|
38
|
+
targetType:
|
|
39
|
+
"collection" | "smart_collection" | "redirect" | "archive" | "post";
|
|
38
40
|
id: string | null;
|
|
39
41
|
title: string | null;
|
|
40
42
|
};
|
|
@@ -49,6 +51,7 @@ export function createAboutPageService(deps: {
|
|
|
49
51
|
paths: PathService;
|
|
50
52
|
posts: PostService;
|
|
51
53
|
collections: CollectionService;
|
|
54
|
+
smartCollections: SmartCollectionService;
|
|
52
55
|
}): AboutPageService {
|
|
53
56
|
async function readStatus(): Promise<AboutPageStatus> {
|
|
54
57
|
const resolved = await deps.paths.resolve(ABOUT_PAGE_SLUG);
|
|
@@ -99,6 +102,24 @@ export function createAboutPageService(deps: {
|
|
|
99
102
|
};
|
|
100
103
|
}
|
|
101
104
|
|
|
105
|
+
if (
|
|
106
|
+
resolved.targetType === "smart_collection" &&
|
|
107
|
+
resolved.smartCollectionId
|
|
108
|
+
) {
|
|
109
|
+
const smartCollection = await deps.smartCollections.getById(
|
|
110
|
+
resolved.smartCollectionId,
|
|
111
|
+
);
|
|
112
|
+
return {
|
|
113
|
+
...base,
|
|
114
|
+
state: "conflict",
|
|
115
|
+
conflict: {
|
|
116
|
+
targetType: "smart_collection",
|
|
117
|
+
id: resolved.smartCollectionId,
|
|
118
|
+
title: smartCollection?.title ?? null,
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
102
123
|
return {
|
|
103
124
|
...base,
|
|
104
125
|
state: "conflict",
|