@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
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import type Database from "better-sqlite3";
|
|
3
|
+
import {
|
|
4
|
+
createTestDatabase,
|
|
5
|
+
DEFAULT_TEST_SITE_ID,
|
|
6
|
+
} from "../../__tests__/helpers/db.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The three tables a smart collection can be placed in, checked by inserting
|
|
10
|
+
* real rows.
|
|
11
|
+
*
|
|
12
|
+
* Reading a CHECK constraint and believing it is not the same as running it.
|
|
13
|
+
* The last time these constraints were written by hand, a test shaped exactly
|
|
14
|
+
* like this one found three bugs in a single pass: a branch that forgot to
|
|
15
|
+
* require its own foreign key, a branch that allowed two of them at once, and a
|
|
16
|
+
* unique index whose partial predicate never matched.
|
|
17
|
+
*
|
|
18
|
+
* Each case states the row it inserts and what the schema promises about it, so
|
|
19
|
+
* a failure names the rule rather than the constraint.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const SITE = DEFAULT_TEST_SITE_ID;
|
|
23
|
+
const NOW = 1_700_000_000;
|
|
24
|
+
|
|
25
|
+
function ids(prefix: string, n: number): string[] {
|
|
26
|
+
return Array.from(
|
|
27
|
+
{ length: n },
|
|
28
|
+
(_, i) => `${prefix}_0000000000000000000000000${i}`,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("smart collection placement constraints", () => {
|
|
33
|
+
let sqlite: Database.Database;
|
|
34
|
+
let smartA: string;
|
|
35
|
+
let smartB: string;
|
|
36
|
+
let collectionA: string;
|
|
37
|
+
let postA: string;
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
({ sqlite } = createTestDatabase());
|
|
41
|
+
[smartA, smartB] = ids("smc", 2) as [string, string];
|
|
42
|
+
[collectionA] = ids("col", 1) as [string];
|
|
43
|
+
[postA] = ids("pst", 1) as [string];
|
|
44
|
+
|
|
45
|
+
sqlite
|
|
46
|
+
.prepare(
|
|
47
|
+
`INSERT INTO collection (id, site_id, title, sort_order, created_at, updated_at)
|
|
48
|
+
VALUES (?, ?, 'Books', 'newest', ?, ?)`,
|
|
49
|
+
)
|
|
50
|
+
.run(collectionA, SITE, NOW, NOW);
|
|
51
|
+
|
|
52
|
+
for (const id of [smartA, smartB]) {
|
|
53
|
+
sqlite
|
|
54
|
+
.prepare(
|
|
55
|
+
`INSERT INTO smart_collection (id, site_id, title, sort, created_at, updated_at)
|
|
56
|
+
VALUES (?, ?, 'Quotes', 'newest', ?, ?)`,
|
|
57
|
+
)
|
|
58
|
+
.run(id, SITE, NOW, NOW);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
sqlite
|
|
62
|
+
.prepare(
|
|
63
|
+
`INSERT INTO post (
|
|
64
|
+
id, site_id, thread_id, format, status, visibility,
|
|
65
|
+
body_html, body_text, created_at, updated_at
|
|
66
|
+
) VALUES (?, ?, ?, 'note', 'published', 'public', '', '', ?, ?)`,
|
|
67
|
+
)
|
|
68
|
+
.run(postA, SITE, postA, NOW, NOW);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
function insertPath(values: Record<string, unknown>) {
|
|
72
|
+
const row = {
|
|
73
|
+
id: `pth_${Math.random().toString(36).slice(2, 12)}`,
|
|
74
|
+
site_id: SITE,
|
|
75
|
+
path: `p-${Math.random().toString(36).slice(2, 8)}`,
|
|
76
|
+
kind: "slug",
|
|
77
|
+
post_id: null,
|
|
78
|
+
collection_id: null,
|
|
79
|
+
smart_collection_id: null,
|
|
80
|
+
redirect_to_path: null,
|
|
81
|
+
redirect_type: null,
|
|
82
|
+
archive_query: null,
|
|
83
|
+
created_at: NOW,
|
|
84
|
+
updated_at: NOW,
|
|
85
|
+
...values,
|
|
86
|
+
};
|
|
87
|
+
const keys = Object.keys(row);
|
|
88
|
+
sqlite
|
|
89
|
+
.prepare(
|
|
90
|
+
`INSERT INTO path_registry (${keys.join(", ")})
|
|
91
|
+
VALUES (${keys.map(() => "?").join(", ")})`,
|
|
92
|
+
)
|
|
93
|
+
.run(...keys.map((key) => row[key]));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function insertDirectoryItem(values: Record<string, unknown>) {
|
|
97
|
+
const row = {
|
|
98
|
+
id: `cdi_${Math.random().toString(36).slice(2, 12)}`,
|
|
99
|
+
site_id: SITE,
|
|
100
|
+
type: "smart_collection",
|
|
101
|
+
collection_id: null,
|
|
102
|
+
smart_collection_id: null,
|
|
103
|
+
label: null,
|
|
104
|
+
url: null,
|
|
105
|
+
description: null,
|
|
106
|
+
position: `a${Math.random().toString(36).slice(2, 8)}`,
|
|
107
|
+
created_at: NOW,
|
|
108
|
+
updated_at: NOW,
|
|
109
|
+
...values,
|
|
110
|
+
};
|
|
111
|
+
const keys = Object.keys(row);
|
|
112
|
+
sqlite
|
|
113
|
+
.prepare(
|
|
114
|
+
`INSERT INTO collection_directory_item (${keys.join(", ")})
|
|
115
|
+
VALUES (${keys.map(() => "?").join(", ")})`,
|
|
116
|
+
)
|
|
117
|
+
.run(...keys.map((key) => row[key]));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function insertNavItem(values: Record<string, unknown>) {
|
|
121
|
+
const row = {
|
|
122
|
+
id: `nav_${Math.random().toString(36).slice(2, 12)}`,
|
|
123
|
+
site_id: SITE,
|
|
124
|
+
type: "smart_collection",
|
|
125
|
+
system_key: null,
|
|
126
|
+
collection_id: null,
|
|
127
|
+
smart_collection_id: null,
|
|
128
|
+
post_id: null,
|
|
129
|
+
label: "",
|
|
130
|
+
url: "/quotes",
|
|
131
|
+
placement: "header",
|
|
132
|
+
position: `a${Math.random().toString(36).slice(2, 8)}`,
|
|
133
|
+
created_at: NOW,
|
|
134
|
+
updated_at: NOW,
|
|
135
|
+
...values,
|
|
136
|
+
};
|
|
137
|
+
const keys = Object.keys(row);
|
|
138
|
+
sqlite
|
|
139
|
+
.prepare(
|
|
140
|
+
`INSERT INTO nav_item (${keys.join(", ")})
|
|
141
|
+
VALUES (${keys.map(() => "?").join(", ")})`,
|
|
142
|
+
)
|
|
143
|
+
.run(...keys.map((key) => row[key]));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
describe("path_registry", () => {
|
|
147
|
+
it("accepts a slug pointing at a smart collection", () => {
|
|
148
|
+
expect(() =>
|
|
149
|
+
insertPath({ smart_collection_id: smartA, path: "quotes" }),
|
|
150
|
+
).not.toThrow();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("accepts an alias pointing at a smart collection", () => {
|
|
154
|
+
expect(() =>
|
|
155
|
+
insertPath({
|
|
156
|
+
kind: "alias",
|
|
157
|
+
smart_collection_id: smartA,
|
|
158
|
+
path: "old-quotes",
|
|
159
|
+
}),
|
|
160
|
+
).not.toThrow();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("refuses a slug that points at two things at once", () => {
|
|
164
|
+
expect(() =>
|
|
165
|
+
insertPath({ smart_collection_id: smartA, collection_id: collectionA }),
|
|
166
|
+
).toThrow();
|
|
167
|
+
expect(() =>
|
|
168
|
+
insertPath({ smart_collection_id: smartA, post_id: postA }),
|
|
169
|
+
).toThrow();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("refuses a redirect or archive row that names a smart collection", () => {
|
|
173
|
+
expect(() =>
|
|
174
|
+
insertPath({
|
|
175
|
+
kind: "redirect",
|
|
176
|
+
smart_collection_id: smartA,
|
|
177
|
+
redirect_to_path: "quotes",
|
|
178
|
+
redirect_type: 301,
|
|
179
|
+
}),
|
|
180
|
+
).toThrow();
|
|
181
|
+
expect(() =>
|
|
182
|
+
insertPath({
|
|
183
|
+
kind: "archive",
|
|
184
|
+
smart_collection_id: smartA,
|
|
185
|
+
archive_query: "format=quote",
|
|
186
|
+
}),
|
|
187
|
+
).toThrow();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("allows only one slug per smart collection", () => {
|
|
191
|
+
insertPath({ smart_collection_id: smartA, path: "quotes" });
|
|
192
|
+
expect(() =>
|
|
193
|
+
insertPath({ smart_collection_id: smartA, path: "quotes-2" }),
|
|
194
|
+
).toThrow();
|
|
195
|
+
|
|
196
|
+
// An alias is not a second slug, so it is still allowed.
|
|
197
|
+
expect(() =>
|
|
198
|
+
insertPath({
|
|
199
|
+
kind: "alias",
|
|
200
|
+
smart_collection_id: smartA,
|
|
201
|
+
path: "old-quotes",
|
|
202
|
+
}),
|
|
203
|
+
).not.toThrow();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("keeps smart collections in the same address space as everything else", () => {
|
|
207
|
+
insertPath({ smart_collection_id: smartA, path: "quotes" });
|
|
208
|
+
expect(() =>
|
|
209
|
+
insertPath({ collection_id: collectionA, path: "quotes" }),
|
|
210
|
+
).toThrow();
|
|
211
|
+
expect(() => insertPath({ post_id: postA, path: "quotes" })).toThrow();
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("collection_directory_item", () => {
|
|
216
|
+
it("accepts a smart collection row", () => {
|
|
217
|
+
expect(() =>
|
|
218
|
+
insertDirectoryItem({ smart_collection_id: smartA }),
|
|
219
|
+
).not.toThrow();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("refuses a smart collection row with no target", () => {
|
|
223
|
+
expect(() => insertDirectoryItem({})).toThrow();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("refuses a smart collection row carrying a label or url", () => {
|
|
227
|
+
expect(() =>
|
|
228
|
+
insertDirectoryItem({ smart_collection_id: smartA, label: "Quotes" }),
|
|
229
|
+
).toThrow();
|
|
230
|
+
expect(() =>
|
|
231
|
+
insertDirectoryItem({ smart_collection_id: smartA, url: "/quotes" }),
|
|
232
|
+
).toThrow();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("refuses every other row type carrying a smart collection", () => {
|
|
236
|
+
expect(() =>
|
|
237
|
+
insertDirectoryItem({
|
|
238
|
+
type: "collection",
|
|
239
|
+
collection_id: collectionA,
|
|
240
|
+
smart_collection_id: smartA,
|
|
241
|
+
}),
|
|
242
|
+
).toThrow();
|
|
243
|
+
expect(() =>
|
|
244
|
+
insertDirectoryItem({ type: "divider", smart_collection_id: smartA }),
|
|
245
|
+
).toThrow();
|
|
246
|
+
expect(() =>
|
|
247
|
+
insertDirectoryItem({
|
|
248
|
+
type: "link",
|
|
249
|
+
label: "Elsewhere",
|
|
250
|
+
url: "https://example.com",
|
|
251
|
+
smart_collection_id: smartA,
|
|
252
|
+
}),
|
|
253
|
+
).toThrow();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("places a smart collection at most once", () => {
|
|
257
|
+
insertDirectoryItem({ smart_collection_id: smartA });
|
|
258
|
+
expect(() =>
|
|
259
|
+
insertDirectoryItem({ smart_collection_id: smartA }),
|
|
260
|
+
).toThrow();
|
|
261
|
+
expect(() =>
|
|
262
|
+
insertDirectoryItem({ smart_collection_id: smartB }),
|
|
263
|
+
).not.toThrow();
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
describe("nav_item", () => {
|
|
268
|
+
it("accepts a smart collection nav item", () => {
|
|
269
|
+
expect(() =>
|
|
270
|
+
insertNavItem({ smart_collection_id: smartA }),
|
|
271
|
+
).not.toThrow();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("refuses a smart collection nav item with no target", () => {
|
|
275
|
+
expect(() => insertNavItem({})).toThrow();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("refuses every other nav type carrying a smart collection", () => {
|
|
279
|
+
expect(() =>
|
|
280
|
+
insertNavItem({
|
|
281
|
+
type: "link",
|
|
282
|
+
smart_collection_id: smartA,
|
|
283
|
+
label: "Elsewhere",
|
|
284
|
+
}),
|
|
285
|
+
).toThrow();
|
|
286
|
+
expect(() =>
|
|
287
|
+
insertNavItem({
|
|
288
|
+
type: "system",
|
|
289
|
+
system_key: "latest",
|
|
290
|
+
smart_collection_id: smartA,
|
|
291
|
+
}),
|
|
292
|
+
).toThrow();
|
|
293
|
+
expect(() =>
|
|
294
|
+
insertNavItem({
|
|
295
|
+
type: "collection",
|
|
296
|
+
collection_id: collectionA,
|
|
297
|
+
smart_collection_id: smartA,
|
|
298
|
+
}),
|
|
299
|
+
).toThrow();
|
|
300
|
+
expect(() =>
|
|
301
|
+
insertNavItem({
|
|
302
|
+
type: "page",
|
|
303
|
+
post_id: postA,
|
|
304
|
+
smart_collection_id: smartA,
|
|
305
|
+
}),
|
|
306
|
+
).toThrow();
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("adds a smart collection to navigation at most once", () => {
|
|
310
|
+
insertNavItem({ smart_collection_id: smartA });
|
|
311
|
+
expect(() => insertNavItem({ smart_collection_id: smartA })).toThrow();
|
|
312
|
+
expect(() =>
|
|
313
|
+
insertNavItem({ smart_collection_id: smartB }),
|
|
314
|
+
).not.toThrow();
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
describe("smart_collection", () => {
|
|
319
|
+
it("refuses a private visibility condition", () => {
|
|
320
|
+
// The one value a published page can never name. Enforced in the column,
|
|
321
|
+
// not only in Zod, so no import path or backfill can slip one in.
|
|
322
|
+
expect(() =>
|
|
323
|
+
sqlite
|
|
324
|
+
.prepare(
|
|
325
|
+
`INSERT INTO smart_collection (id, site_id, title, visibility, sort, created_at, updated_at)
|
|
326
|
+
VALUES (?, ?, 'Private', 'private', 'newest', ?, ?)`,
|
|
327
|
+
)
|
|
328
|
+
.run(`smc_private0000000000000000000`, SITE, NOW, NOW),
|
|
329
|
+
).toThrow();
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("refuses a sort or layout it cannot render", () => {
|
|
333
|
+
expect(() =>
|
|
334
|
+
sqlite
|
|
335
|
+
.prepare(
|
|
336
|
+
`INSERT INTO smart_collection (id, site_id, title, sort, created_at, updated_at)
|
|
337
|
+
VALUES (?, ?, 'Bad sort', 'rating_asc', ?, ?)`,
|
|
338
|
+
)
|
|
339
|
+
.run(`smc_badsort00000000000000000000`, SITE, NOW, NOW),
|
|
340
|
+
).toThrow();
|
|
341
|
+
expect(() =>
|
|
342
|
+
sqlite
|
|
343
|
+
.prepare(
|
|
344
|
+
`INSERT INTO smart_collection (id, site_id, title, sort, layout, created_at, updated_at)
|
|
345
|
+
VALUES (?, ?, 'Bad layout', 'newest', 'carousel', ?, ?)`,
|
|
346
|
+
)
|
|
347
|
+
.run(`smc_badlayout00000000000000000`, SITE, NOW, NOW),
|
|
348
|
+
).toThrow();
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("leaves a deleted collection's id in place rather than widening", () => {
|
|
352
|
+
sqlite
|
|
353
|
+
.prepare(`UPDATE smart_collection SET collection_id = ? WHERE id = ?`)
|
|
354
|
+
.run(collectionA, smartA);
|
|
355
|
+
|
|
356
|
+
// No foreign key on this column, alone in the schema. RESTRICT would fail
|
|
357
|
+
// anonymously and make every bulk delete an ordering rule; SET NULL would
|
|
358
|
+
// turn a curated page into the whole archive. The refusal an author sees
|
|
359
|
+
// is `smartCollections.assertCollectionUnused()`, and what is left if
|
|
360
|
+
// something reaches around it is a condition naming a collection that no
|
|
361
|
+
// longer exists — which matches nothing.
|
|
362
|
+
expect(() =>
|
|
363
|
+
sqlite.prepare(`DELETE FROM collection WHERE id = ?`).run(collectionA),
|
|
364
|
+
).not.toThrow();
|
|
365
|
+
|
|
366
|
+
const row = sqlite
|
|
367
|
+
.prepare(`SELECT collection_id FROM smart_collection WHERE id = ?`)
|
|
368
|
+
.get(smartA) as { collection_id: string | null };
|
|
369
|
+
expect(row.collection_id).toBe(collectionA);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("refuses a year no timestamp could carry", () => {
|
|
373
|
+
// `Date.UTC` returns NaN past year 275760, and a NaN bound is a
|
|
374
|
+
// comparison every row fails with nothing to report.
|
|
375
|
+
for (const year of [1970, 10000]) {
|
|
376
|
+
expect(() =>
|
|
377
|
+
sqlite
|
|
378
|
+
.prepare(
|
|
379
|
+
`INSERT INTO smart_collection (id, site_id, title, year, sort, created_at, updated_at)
|
|
380
|
+
VALUES (?, ?, 'Bad year', ?, 'newest', ?, ?)`,
|
|
381
|
+
)
|
|
382
|
+
.run(
|
|
383
|
+
`smc_year${year}000000000000000000`.slice(0, 30),
|
|
384
|
+
SITE,
|
|
385
|
+
year,
|
|
386
|
+
NOW,
|
|
387
|
+
NOW,
|
|
388
|
+
),
|
|
389
|
+
).toThrow();
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
expect(() =>
|
|
393
|
+
sqlite
|
|
394
|
+
.prepare(
|
|
395
|
+
`INSERT INTO smart_collection (id, site_id, title, year, sort, created_at, updated_at)
|
|
396
|
+
VALUES (?, ?, 'Edge years', 9999, 'newest', ?, ?)`,
|
|
397
|
+
)
|
|
398
|
+
.run(`smc_year9999000000000000000000`.slice(0, 30), SITE, NOW, NOW),
|
|
399
|
+
).not.toThrow();
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { and, eq, sql } from "drizzle-orm";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
createTestDatabase,
|
|
5
|
+
DEFAULT_TEST_SITE_ID,
|
|
6
|
+
} from "../../__tests__/helpers/db.js";
|
|
7
|
+
import { posts, threadCollections } from "../schema.js";
|
|
8
|
+
import {
|
|
9
|
+
buildRootActivityExpr,
|
|
10
|
+
getRootActivityAt,
|
|
11
|
+
rootActivityColumns,
|
|
12
|
+
} from "../thread-activity.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The SQL assertions here look pedantic, but they guard a failure mode that
|
|
16
|
+
* produces no error and no obviously wrong output: Drizzle omits the table
|
|
17
|
+
* qualifier on interpolated columns in a single-table projection, so a
|
|
18
|
+
* correlated subquery silently binds to its own alias instead of the outer
|
|
19
|
+
* row. That is why the subquery flavor takes raw fragments.
|
|
20
|
+
*/
|
|
21
|
+
describe("thread activity definition", () => {
|
|
22
|
+
const normalize = (value: string) => value.replace(/\s+/g, " ");
|
|
23
|
+
|
|
24
|
+
it("prefers last activity, then publication, then the row's own write", () => {
|
|
25
|
+
expect(
|
|
26
|
+
getRootActivityAt({
|
|
27
|
+
lastActivityAt: 300,
|
|
28
|
+
publishedAt: 200,
|
|
29
|
+
updatedAt: 100,
|
|
30
|
+
}),
|
|
31
|
+
).toBe(300);
|
|
32
|
+
expect(
|
|
33
|
+
getRootActivityAt({
|
|
34
|
+
lastActivityAt: null,
|
|
35
|
+
publishedAt: 200,
|
|
36
|
+
updatedAt: 100,
|
|
37
|
+
}),
|
|
38
|
+
).toBe(200);
|
|
39
|
+
expect(
|
|
40
|
+
getRootActivityAt({
|
|
41
|
+
lastActivityAt: null,
|
|
42
|
+
publishedAt: null,
|
|
43
|
+
updatedAt: 100,
|
|
44
|
+
}),
|
|
45
|
+
).toBe(100);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("never falls back to a plain edit when real activity exists", () => {
|
|
49
|
+
// An edited root keeps its activity timestamp — the whole point.
|
|
50
|
+
expect(
|
|
51
|
+
getRootActivityAt({
|
|
52
|
+
lastActivityAt: 1000,
|
|
53
|
+
publishedAt: 1000,
|
|
54
|
+
updatedAt: 9999,
|
|
55
|
+
}),
|
|
56
|
+
).toBe(1000);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("qualifies the subquery flavor against the root alias", () => {
|
|
60
|
+
const { db } = createTestDatabase();
|
|
61
|
+
const expr = sql<number>`(
|
|
62
|
+
SELECT ${buildRootActivityExpr(rootActivityColumns("root"))}
|
|
63
|
+
FROM post AS root
|
|
64
|
+
WHERE root.site_id = ${DEFAULT_TEST_SITE_ID}
|
|
65
|
+
AND root.id = ${posts.threadId}
|
|
66
|
+
)`.as("activity_at");
|
|
67
|
+
|
|
68
|
+
const generated = normalize(
|
|
69
|
+
(db as never as { select: (fields: unknown) => never })
|
|
70
|
+
.select({ threadId: posts.threadId, activityAt: expr })
|
|
71
|
+
// The join is what makes Drizzle qualify `posts.threadId` below.
|
|
72
|
+
.from(posts)
|
|
73
|
+
.innerJoin(
|
|
74
|
+
threadCollections,
|
|
75
|
+
and(
|
|
76
|
+
eq(threadCollections.siteId, posts.siteId),
|
|
77
|
+
eq(threadCollections.threadId, posts.threadId),
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
.groupBy(posts.threadId)
|
|
81
|
+
.toSQL().sql,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// Inner columns resolve against the subquery's own alias...
|
|
85
|
+
expect(generated).toContain(
|
|
86
|
+
"COALESCE( root.last_activity_at, root.published_at, root.updated_at )",
|
|
87
|
+
);
|
|
88
|
+
// ...and the correlation still points at the outer row, not at `root`.
|
|
89
|
+
expect(generated).toContain('root.id = "post"."thread_id"');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("uses the root's own columns when rows are already roots", () => {
|
|
93
|
+
const { db } = createTestDatabase();
|
|
94
|
+
const generated = normalize(
|
|
95
|
+
(db as never as { select: (fields: unknown) => never })
|
|
96
|
+
.select({
|
|
97
|
+
id: posts.id,
|
|
98
|
+
activityAt: buildRootActivityExpr({
|
|
99
|
+
lastActivityAt: posts.lastActivityAt,
|
|
100
|
+
publishedAt: posts.publishedAt,
|
|
101
|
+
updatedAt: posts.updatedAt,
|
|
102
|
+
}),
|
|
103
|
+
})
|
|
104
|
+
.from(posts)
|
|
105
|
+
.innerJoin(threadCollections, eq(threadCollections.threadId, posts.id))
|
|
106
|
+
.toSQL().sql,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
expect(generated).toContain(
|
|
110
|
+
'COALESCE( "post"."last_activity_at", "post"."published_at", "post"."updated_at" )',
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
-- Split "last announced" from "last changed" for Threads.
|
|
2
|
+
--
|
|
3
|
+
-- Until now a quiet reply was implemented by *skipping* the recalculation of
|
|
4
|
+
-- post.last_activity_at, and the quietness itself was never stored. That made
|
|
5
|
+
-- the flag non-durable: the next recalculation (deleting any post in the
|
|
6
|
+
-- Thread, changing a published_at, republishing) recomputed
|
|
7
|
+
-- MAX(published_at) over the whole Thread, absorbed the quiet reply, and
|
|
8
|
+
-- bumped the Thread on Latest anyway.
|
|
9
|
+
--
|
|
10
|
+
-- post.quiet_reply now persists the author's choice, so both timestamps are
|
|
11
|
+
-- pure functions of the rows:
|
|
12
|
+
-- last_activity_at = MAX(published_at) excluding quiet replies (Latest)
|
|
13
|
+
-- thread_updated_at = MAX(published_at) including quiet replies (archive)
|
|
14
|
+
--
|
|
15
|
+
-- Step 1 recovers the quiet decisions that are still visible in the data.
|
|
16
|
+
-- A published reply can only sit later than its root's recorded
|
|
17
|
+
-- last_activity_at if the recalculation was skipped for it — that is the
|
|
18
|
+
-- signature of a quiet reply. Decisions that were already absorbed by a later
|
|
19
|
+
-- recalculation are unrecoverable and stay false, which matches the ordering
|
|
20
|
+
-- those sites are already seeing.
|
|
21
|
+
--
|
|
22
|
+
-- Steps 2 and 3 then recompute both timestamps from scratch.
|
|
23
|
+
--
|
|
24
|
+
-- Idempotent: after step 3 a quiet reply is still later than the recomputed
|
|
25
|
+
-- last_activity_at (it is excluded from that MAX), so a rerun re-selects
|
|
26
|
+
-- exactly the same rows, and every non-quiet published reply is covered by
|
|
27
|
+
-- the MAX and can never be selected.
|
|
28
|
+
|
|
29
|
+
-- Step 1: recover still-detectable quiet replies.
|
|
30
|
+
UPDATE "post"
|
|
31
|
+
SET "quiet_reply" = TRUE
|
|
32
|
+
WHERE "post"."reply_to_id" IS NOT NULL
|
|
33
|
+
AND "post"."published_at" IS NOT NULL
|
|
34
|
+
AND EXISTS (
|
|
35
|
+
SELECT 1
|
|
36
|
+
FROM "post" AS root
|
|
37
|
+
WHERE root."site_id" = "post"."site_id"
|
|
38
|
+
AND root."id" = "post"."thread_id"
|
|
39
|
+
AND root."last_activity_at" IS NOT NULL
|
|
40
|
+
AND "post"."published_at" > root."last_activity_at"
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
-- Step 2: thread_updated_at — newest published post in the Thread, quiet included.
|
|
44
|
+
UPDATE "post"
|
|
45
|
+
SET "thread_updated_at" = (
|
|
46
|
+
SELECT MAX(member."published_at")
|
|
47
|
+
FROM "post" AS member
|
|
48
|
+
WHERE member."site_id" = "post"."site_id"
|
|
49
|
+
AND member."thread_id" = "post"."id"
|
|
50
|
+
AND member."status" = 'published'
|
|
51
|
+
)
|
|
52
|
+
WHERE "post"."reply_to_id" IS NULL;
|
|
53
|
+
|
|
54
|
+
-- Step 3: last_activity_at — newest announced post in the Thread.
|
|
55
|
+
-- Threads with nothing published (drafts) keep whatever they had.
|
|
56
|
+
UPDATE "post"
|
|
57
|
+
SET "last_activity_at" = COALESCE(
|
|
58
|
+
(
|
|
59
|
+
SELECT MAX(member."published_at")
|
|
60
|
+
FROM "post" AS member
|
|
61
|
+
WHERE member."site_id" = "post"."site_id"
|
|
62
|
+
AND member."thread_id" = "post"."id"
|
|
63
|
+
AND member."status" = 'published'
|
|
64
|
+
AND member."quiet_reply" = FALSE
|
|
65
|
+
),
|
|
66
|
+
"post"."last_activity_at"
|
|
67
|
+
)
|
|
68
|
+
WHERE "post"."reply_to_id" IS NULL;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
-- Clear page and collection nav labels that merely mirror their target's title.
|
|
2
|
+
-- Previously these items stored a snapshot of the post or collection title taken
|
|
3
|
+
-- when the item was added, so renaming the target left the nav entry behind. Now
|
|
4
|
+
-- an empty label means "follow the target" and any non-empty value is a label the
|
|
5
|
+
-- author typed, which wins in every language view.
|
|
6
|
+
--
|
|
7
|
+
-- Rows whose stored label differs from the current title were customized and are
|
|
8
|
+
-- left alone. Idempotent: rerunning matches nothing once the labels are empty.
|
|
9
|
+
UPDATE "nav_item"
|
|
10
|
+
SET "label" = ''
|
|
11
|
+
WHERE "type" = 'page'
|
|
12
|
+
AND "post_id" IS NOT NULL
|
|
13
|
+
AND "label" <> ''
|
|
14
|
+
AND "label" = (
|
|
15
|
+
SELECT trim("post"."title")
|
|
16
|
+
FROM "post"
|
|
17
|
+
WHERE "post"."id" = "nav_item"."post_id"
|
|
18
|
+
AND "post"."site_id" = "nav_item"."site_id"
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
UPDATE "nav_item"
|
|
22
|
+
SET "label" = ''
|
|
23
|
+
WHERE "type" = 'collection'
|
|
24
|
+
AND "collection_id" IS NOT NULL
|
|
25
|
+
AND "label" <> ''
|
|
26
|
+
AND "label" = (
|
|
27
|
+
SELECT trim("collection"."title")
|
|
28
|
+
FROM "collection"
|
|
29
|
+
WHERE "collection"."id" = "nav_item"."collection_id"
|
|
30
|
+
AND "collection"."site_id" = "nav_item"."site_id"
|
|
31
|
+
);
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
ALTER TABLE `post` ADD `quiet_reply` integer DEFAULT false NOT NULL;--> statement-breakpoint
|
|
2
|
+
ALTER TABLE `post` ADD `thread_updated_at` integer;--> statement-breakpoint
|
|
3
|
+
CREATE INDEX `idx_post_site_root_thread_updated` ON `post` (`site_id`,`thread_updated_at`,`id`) WHERE "post"."reply_to_id" IS NULL AND "post"."status" = 'published';
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
ALTER TABLE `post` ADD `language` text;--> statement-breakpoint
|
|
2
|
+
ALTER TABLE `post` ADD `translation_group_id` text;--> statement-breakpoint
|
|
3
|
+
CREATE UNIQUE INDEX `uq_post_site_translation_group_language` ON `post` (`site_id`,`translation_group_id`,`language`) WHERE "post"."translation_group_id" IS NOT NULL;
|