@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,96 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createTestApp } from "../../../__tests__/helpers/app.js";
|
|
3
|
+
import { partialPageRoutes } from "../partials.js";
|
|
4
|
+
|
|
5
|
+
function createPartialTestApp(options: { authenticated?: boolean } = {}) {
|
|
6
|
+
const testApp = createTestApp(options);
|
|
7
|
+
testApp.app.route("/", partialPageRoutes);
|
|
8
|
+
return testApp;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe("/_/post-preview/:postId", () => {
|
|
12
|
+
it("renders the post the way its own page does", async () => {
|
|
13
|
+
// The composer shows this while writing a translation, and the original's
|
|
14
|
+
// structure is part of what gets translated — so it has to arrive as the
|
|
15
|
+
// post, not as a body-only excerpt.
|
|
16
|
+
const { app, services } = createPartialTestApp({ authenticated: true });
|
|
17
|
+
const post = await services.posts.create({
|
|
18
|
+
format: "note",
|
|
19
|
+
title: "Yirgacheffe",
|
|
20
|
+
bodyMarkdown: "## Brewing\n\nNinety-two degrees was too hot.",
|
|
21
|
+
status: "published",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const res = await app.request(`/_/post-preview/${post.id}`);
|
|
25
|
+
|
|
26
|
+
expect(res.status).toBe(200);
|
|
27
|
+
const html = await res.text();
|
|
28
|
+
expect(html).toContain("Yirgacheffe");
|
|
29
|
+
expect(html).toContain("Brewing");
|
|
30
|
+
expect(html).toContain("Ninety-two degrees was too hot.");
|
|
31
|
+
// Detail styling, not the feed card's summary.
|
|
32
|
+
expect(html).toContain("post-detail-title");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("carries a quote's attribution, which a body-only preview would drop", async () => {
|
|
36
|
+
const { app, services } = createPartialTestApp({ authenticated: true });
|
|
37
|
+
// A Quote stores its attribution in `title` / `url`; the API surfaces the
|
|
38
|
+
// same two as `sourceName` / `sourceUrl`.
|
|
39
|
+
const post = await services.posts.create({
|
|
40
|
+
format: "quote",
|
|
41
|
+
quoteText: "Focus is about saying no.",
|
|
42
|
+
title: "Some Author",
|
|
43
|
+
url: "https://example.com/focus",
|
|
44
|
+
status: "published",
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const html = await (await app.request(`/_/post-preview/${post.id}`)).text();
|
|
48
|
+
|
|
49
|
+
expect(html).toContain("Focus is about saying no.");
|
|
50
|
+
expect(html).toContain("Some Author");
|
|
51
|
+
expect(html).toContain("https://example.com/focus");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("offers no control that would act on the post being previewed", async () => {
|
|
55
|
+
// Every action in the composer belongs to the post being written. A menu
|
|
56
|
+
// or reply trigger here would aim at the wrong one.
|
|
57
|
+
const { app, services } = createPartialTestApp({ authenticated: true });
|
|
58
|
+
const post = await services.posts.create({
|
|
59
|
+
format: "note",
|
|
60
|
+
title: "Yirgacheffe",
|
|
61
|
+
bodyMarkdown: "Body",
|
|
62
|
+
status: "published",
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const html = await (await app.request(`/_/post-preview/${post.id}`)).text();
|
|
66
|
+
|
|
67
|
+
expect(html).not.toContain("data-post-menu-trigger");
|
|
68
|
+
expect(html).not.toContain("data-reply-trigger");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("prints a titled post's date once, not in both header and footer", async () => {
|
|
72
|
+
// The card drops the footer's copy for a titled detail post, but only when
|
|
73
|
+
// `hideTimestamp` is left undefined — stating it either way brings it back.
|
|
74
|
+
const { app, services } = createPartialTestApp({ authenticated: true });
|
|
75
|
+
const post = await services.posts.create({
|
|
76
|
+
format: "note",
|
|
77
|
+
title: "Yirgacheffe",
|
|
78
|
+
bodyMarkdown: "Body",
|
|
79
|
+
status: "published",
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const html = await (await app.request(`/_/post-preview/${post.id}`)).text();
|
|
83
|
+
|
|
84
|
+
expect(html.match(/<time/g) ?? []).toHaveLength(1);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("404s on a post that is not there", async () => {
|
|
88
|
+
const { app } = createPartialTestApp({ authenticated: true });
|
|
89
|
+
|
|
90
|
+
const res = await app.request(
|
|
91
|
+
"/_/post-preview/pst_01000000000000000000000000",
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
expect(res.status).toBe(404);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createTestApp } from "../../../__tests__/helpers/app.js";
|
|
3
|
+
import { pageRoutes } from "../page.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The smart collection page, end to end through the path registry.
|
|
7
|
+
*
|
|
8
|
+
* The property these guard is the one the design turns on: a smart collection
|
|
9
|
+
* is always public, and the posts inside it are exactly as visible as they are
|
|
10
|
+
* anywhere else. Those are different sentences, and confusing them is how a
|
|
11
|
+
* page ends up either 404ing for readers or leaking a draft.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
function setup(authenticated = false) {
|
|
15
|
+
const { app, services } = createTestApp({ authenticated });
|
|
16
|
+
app.route("/", pageRoutes);
|
|
17
|
+
return { app, services };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function seedQuotes(services: {
|
|
21
|
+
posts: {
|
|
22
|
+
create: (input: Record<string, unknown>) => Promise<{ id: string }>;
|
|
23
|
+
};
|
|
24
|
+
}) {
|
|
25
|
+
await services.posts.create({
|
|
26
|
+
format: "quote",
|
|
27
|
+
quoteText: "A public quote",
|
|
28
|
+
bodyMarkdown: "public quote body",
|
|
29
|
+
status: "published",
|
|
30
|
+
});
|
|
31
|
+
await services.posts.create({
|
|
32
|
+
format: "quote",
|
|
33
|
+
quoteText: "A private quote",
|
|
34
|
+
bodyMarkdown: "private quote body",
|
|
35
|
+
status: "published",
|
|
36
|
+
visibility: "private",
|
|
37
|
+
});
|
|
38
|
+
await services.posts.create({
|
|
39
|
+
format: "note",
|
|
40
|
+
bodyMarkdown: "a plain note body",
|
|
41
|
+
status: "published",
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("smart collection page", () => {
|
|
46
|
+
it("renders for a signed-out reader, with title, count, and description", async () => {
|
|
47
|
+
const { app, services } = setup();
|
|
48
|
+
await seedQuotes(services);
|
|
49
|
+
await services.smartCollections.create({
|
|
50
|
+
slug: "quotes",
|
|
51
|
+
title: "Quotes",
|
|
52
|
+
description: "Things worth keeping.",
|
|
53
|
+
selection: { format: "quote" },
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const res = await app.request("/quotes");
|
|
57
|
+
expect(res.status).toBe(200);
|
|
58
|
+
const html = await res.text();
|
|
59
|
+
|
|
60
|
+
expect(html).toContain("Quotes");
|
|
61
|
+
expect(html).toContain("Things worth keeping.");
|
|
62
|
+
// A written description takes the slot; the conditions — and the way to
|
|
63
|
+
// the archive — stay reachable through the marker beside the title.
|
|
64
|
+
expect(html).not.toContain(">Automatically collects: Quotes<");
|
|
65
|
+
expect(html).toContain('title="Automatically collects: Quotes"');
|
|
66
|
+
expect(html).toContain(
|
|
67
|
+
'<a href="/archive?format=quote" class="collection-page-smart-icon"',
|
|
68
|
+
);
|
|
69
|
+
expect(html).toContain("public quote body");
|
|
70
|
+
expect(html).not.toContain("a plain note body");
|
|
71
|
+
// The archive has a chip bar; this page does not.
|
|
72
|
+
expect(html).not.toContain("archive-filters-chips");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("describes itself by its conditions until a description is written", async () => {
|
|
76
|
+
const { app, services } = setup();
|
|
77
|
+
await seedQuotes(services);
|
|
78
|
+
await services.smartCollections.create({
|
|
79
|
+
slug: "quotes",
|
|
80
|
+
title: "Quotes",
|
|
81
|
+
selection: { format: "quote" },
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const html = await (await app.request("/quotes")).text();
|
|
85
|
+
expect(html).toContain(">Automatically collects: Quotes<");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("links its conditions to the archive showing the same posts", async () => {
|
|
89
|
+
const { app, services } = setup();
|
|
90
|
+
await seedQuotes(services);
|
|
91
|
+
await services.smartCollections.create({
|
|
92
|
+
slug: "quotes",
|
|
93
|
+
title: "Quotes",
|
|
94
|
+
selection: { format: "quote", media: "none" },
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const html = await (await app.request("/quotes")).text();
|
|
98
|
+
expect(html).toContain("/archive?format=quote&media=none");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("says so when nothing matches, rather than rendering blank", async () => {
|
|
102
|
+
const { app, services } = setup();
|
|
103
|
+
await seedQuotes(services);
|
|
104
|
+
await services.smartCollections.create({
|
|
105
|
+
slug: "links",
|
|
106
|
+
title: "Links",
|
|
107
|
+
selection: { format: "link" },
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const html = await (await app.request("/links")).text();
|
|
111
|
+
expect(html).toContain("Nothing matches these conditions yet.");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("collects every post when no condition narrows it", async () => {
|
|
115
|
+
const { app, services } = setup();
|
|
116
|
+
await seedQuotes(services);
|
|
117
|
+
await services.smartCollections.create({
|
|
118
|
+
slug: "everything",
|
|
119
|
+
title: "Everything",
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const html = await (await app.request("/everything")).text();
|
|
123
|
+
expect(html).toContain("Automatically collects every post.");
|
|
124
|
+
expect(html).toContain("public quote body");
|
|
125
|
+
expect(html).toContain("a plain note body");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// The whole point of forbidding private conditions: there is no smart
|
|
129
|
+
// collection page that answers 404 to a reader.
|
|
130
|
+
it("shows the private post to its author and to nobody else", async () => {
|
|
131
|
+
const reader = setup(false);
|
|
132
|
+
await seedQuotes(reader.services);
|
|
133
|
+
await reader.services.smartCollections.create({
|
|
134
|
+
slug: "quotes",
|
|
135
|
+
title: "Quotes",
|
|
136
|
+
selection: { format: "quote" },
|
|
137
|
+
});
|
|
138
|
+
const readerHtml = await (await reader.app.request("/quotes")).text();
|
|
139
|
+
expect(readerHtml).toContain("public quote body");
|
|
140
|
+
expect(readerHtml).not.toContain("private quote body");
|
|
141
|
+
expect(readerHtml).toContain("1 thread");
|
|
142
|
+
|
|
143
|
+
const authorApp = setup(true);
|
|
144
|
+
await seedQuotes(authorApp.services);
|
|
145
|
+
await authorApp.services.smartCollections.create({
|
|
146
|
+
slug: "quotes",
|
|
147
|
+
title: "Quotes",
|
|
148
|
+
selection: { format: "quote" },
|
|
149
|
+
});
|
|
150
|
+
const authorHtml = await (await authorApp.app.request("/quotes")).text();
|
|
151
|
+
expect(authorHtml).toContain("private quote body");
|
|
152
|
+
expect(authorHtml).toContain("2 threads");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("ignores condition parameters in the URL", async () => {
|
|
156
|
+
const { app, services } = setup();
|
|
157
|
+
await seedQuotes(services);
|
|
158
|
+
await services.smartCollections.create({
|
|
159
|
+
slug: "quotes",
|
|
160
|
+
title: "Quotes",
|
|
161
|
+
selection: { format: "quote" },
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Membership is edited in the dialog. A reader appending `?format=note`
|
|
165
|
+
// must not be able to redefine what the page collects.
|
|
166
|
+
const html = await (await app.request("/quotes?format=note")).text();
|
|
167
|
+
expect(html).toContain("public quote body");
|
|
168
|
+
expect(html).not.toContain("a plain note body");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("honors ?sort= over the stored default", async () => {
|
|
172
|
+
const { app, services } = setup();
|
|
173
|
+
await services.posts.create({
|
|
174
|
+
format: "quote",
|
|
175
|
+
quoteText: "Older",
|
|
176
|
+
bodyMarkdown: "older body",
|
|
177
|
+
status: "published",
|
|
178
|
+
publishedAt: Date.UTC(2024, 0, 1) / 1000,
|
|
179
|
+
});
|
|
180
|
+
await services.posts.create({
|
|
181
|
+
format: "quote",
|
|
182
|
+
quoteText: "Newer",
|
|
183
|
+
bodyMarkdown: "newer body",
|
|
184
|
+
status: "published",
|
|
185
|
+
publishedAt: Date.UTC(2026, 0, 1) / 1000,
|
|
186
|
+
});
|
|
187
|
+
await services.smartCollections.create({
|
|
188
|
+
slug: "quotes",
|
|
189
|
+
title: "Quotes",
|
|
190
|
+
selection: { format: "quote" },
|
|
191
|
+
sort: "newest",
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const stored = await (await app.request("/quotes")).text();
|
|
195
|
+
expect(stored.indexOf("newer body")).toBeLessThan(
|
|
196
|
+
stored.indexOf("older body"),
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const flipped = await (await app.request("/quotes?sort=oldest")).text();
|
|
200
|
+
expect(flipped.indexOf("older body")).toBeLessThan(
|
|
201
|
+
flipped.indexOf("newer body"),
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("orders newest by date, not by rating", async () => {
|
|
206
|
+
const { app, services } = setup();
|
|
207
|
+
await services.posts.create({
|
|
208
|
+
format: "quote",
|
|
209
|
+
quoteText: "Older",
|
|
210
|
+
bodyMarkdown: "older body",
|
|
211
|
+
status: "published",
|
|
212
|
+
publishedAt: Date.UTC(2024, 0, 1) / 1000,
|
|
213
|
+
rating: 5,
|
|
214
|
+
});
|
|
215
|
+
await services.posts.create({
|
|
216
|
+
format: "quote",
|
|
217
|
+
quoteText: "Newer",
|
|
218
|
+
bodyMarkdown: "newer body",
|
|
219
|
+
status: "published",
|
|
220
|
+
publishedAt: Date.UTC(2026, 0, 1) / 1000,
|
|
221
|
+
rating: 1,
|
|
222
|
+
});
|
|
223
|
+
await services.smartCollections.create({
|
|
224
|
+
slug: "quotes",
|
|
225
|
+
title: "Quotes",
|
|
226
|
+
selection: { format: "quote" },
|
|
227
|
+
sort: "newest",
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// The page once handed `newest` to a query that read it as the ascending
|
|
231
|
+
// rating order, which put the worst-rated post on top of a dated page.
|
|
232
|
+
const page = await (await app.request("/quotes")).text();
|
|
233
|
+
expect(page.indexOf("newer body")).toBeLessThan(page.indexOf("older body"));
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("still answers a link to the retired ?sort=updated", async () => {
|
|
237
|
+
const { app, services } = setup();
|
|
238
|
+
await services.posts.create({
|
|
239
|
+
format: "quote",
|
|
240
|
+
quoteText: "Older",
|
|
241
|
+
bodyMarkdown: "older body",
|
|
242
|
+
status: "published",
|
|
243
|
+
publishedAt: Date.UTC(2024, 0, 1) / 1000,
|
|
244
|
+
});
|
|
245
|
+
await services.posts.create({
|
|
246
|
+
format: "quote",
|
|
247
|
+
quoteText: "Newer",
|
|
248
|
+
bodyMarkdown: "newer body",
|
|
249
|
+
status: "published",
|
|
250
|
+
publishedAt: Date.UTC(2026, 0, 1) / 1000,
|
|
251
|
+
});
|
|
252
|
+
await services.smartCollections.create({
|
|
253
|
+
slug: "quotes",
|
|
254
|
+
title: "Quotes",
|
|
255
|
+
selection: { format: "quote" },
|
|
256
|
+
// A stored order the legacy value must not fall back to, or the test
|
|
257
|
+
// would pass on the fallback rather than on the mapping.
|
|
258
|
+
sort: "oldest",
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const legacy = await (await app.request("/quotes?sort=updated")).text();
|
|
262
|
+
expect(legacy.indexOf("newer body")).toBeLessThan(
|
|
263
|
+
legacy.indexOf("older body"),
|
|
264
|
+
);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("404s an address no smart collection holds", async () => {
|
|
268
|
+
const { app } = setup();
|
|
269
|
+
expect((await app.request("/nothing-here")).status).toBe(404);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
describe("smart collection feed", () => {
|
|
274
|
+
it("serves an anonymous feed with no guard to trip over", async () => {
|
|
275
|
+
const { app, services } = setup();
|
|
276
|
+
await seedQuotes(services);
|
|
277
|
+
await services.smartCollections.create({
|
|
278
|
+
slug: "quotes",
|
|
279
|
+
title: "Quotes",
|
|
280
|
+
selection: { format: "quote" },
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const res = await app.request("/quotes/feed");
|
|
284
|
+
expect(res.status).toBe(200);
|
|
285
|
+
const xml = await res.text();
|
|
286
|
+
|
|
287
|
+
expect(xml).toContain("A public quote");
|
|
288
|
+
// A feed carries no session, so the private quote can never appear in one.
|
|
289
|
+
expect(xml).not.toContain("A private quote");
|
|
290
|
+
expect(xml).toContain('rel="self"');
|
|
291
|
+
expect(xml).toContain("/quotes/feed");
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("404s a feed for an address no smart collection holds", async () => {
|
|
295
|
+
const { app } = setup();
|
|
296
|
+
expect((await app.request("/nothing-here/feed")).status).toBe(404);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The subscribe page.
|
|
3
|
+
*
|
|
4
|
+
* What is under test is mostly the page's contract with readers: three feeds on
|
|
5
|
+
* every site, whole addresses that survive being pasted into a reader, and the
|
|
6
|
+
* right pair for whichever end `/feed` currently returns. Getting the pairing
|
|
7
|
+
* wrong would list the same feed twice under two names — the confusion this
|
|
8
|
+
* page exists to end.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from "vitest";
|
|
12
|
+
import { createTestApp } from "../../../__tests__/helpers/app.js";
|
|
13
|
+
import { i18nMiddleware } from "../../../i18n/index.js";
|
|
14
|
+
import { languageRoutes } from "../language.js";
|
|
15
|
+
import { pageRoutes } from "../page.js";
|
|
16
|
+
import { subscribeRoutes } from "../subscribe.js";
|
|
17
|
+
|
|
18
|
+
function createSubscribeTestApp() {
|
|
19
|
+
const testApp = createTestApp();
|
|
20
|
+
const { app } = testApp;
|
|
21
|
+
|
|
22
|
+
app.use("*", async (c, next) => {
|
|
23
|
+
c.set("publicPath", c.req.path);
|
|
24
|
+
c.set("publicRequestUrl", c.req.url);
|
|
25
|
+
await next();
|
|
26
|
+
});
|
|
27
|
+
app.use("*", i18nMiddleware());
|
|
28
|
+
|
|
29
|
+
app.route("/subscribe", subscribeRoutes);
|
|
30
|
+
app.route("/:lang", languageRoutes);
|
|
31
|
+
app.route("/", pageRoutes);
|
|
32
|
+
|
|
33
|
+
return testApp;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Every address the page offers for copying, in document order. */
|
|
37
|
+
function feedAddresses(html: string): string[] {
|
|
38
|
+
return [...html.matchAll(/<input[^>]*>/g)]
|
|
39
|
+
.filter((match) => match[0].includes("data-copy-field-value"))
|
|
40
|
+
.map((match) => /value="([^"]*)"/.exec(match[0])?.[1] ?? "");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The addresses as paths, for asserting which feeds are offered without
|
|
45
|
+
* pinning the test to the host the test app happens to run under.
|
|
46
|
+
*/
|
|
47
|
+
function feedPaths(html: string): string[] {
|
|
48
|
+
return feedAddresses(html).map((address) => new URL(address).pathname);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Where each feed's name links, in document order. */
|
|
52
|
+
function labelLinks(html: string): string[] {
|
|
53
|
+
return [
|
|
54
|
+
...html.matchAll(/<a href="([^"]*)" title="Open the page[^"]*"/g),
|
|
55
|
+
].map((match) => match[1] ?? "");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe("subscribe page", () => {
|
|
59
|
+
it("offers the main feed, the other end of the list, and the archive", async () => {
|
|
60
|
+
const { app, services } = createSubscribeTestApp();
|
|
61
|
+
await services.settings.set("MAIN_RSS_FEED", "latest");
|
|
62
|
+
|
|
63
|
+
const res = await app.request("/subscribe");
|
|
64
|
+
const html = await res.text();
|
|
65
|
+
|
|
66
|
+
expect(res.status).toBe(200);
|
|
67
|
+
expect(feedPaths(html)).toEqual([
|
|
68
|
+
"/feed",
|
|
69
|
+
"/featured/feed",
|
|
70
|
+
"/archive/feed",
|
|
71
|
+
]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// `/feed` already returns the featured posts here, so offering
|
|
75
|
+
// `/featured/feed` beside it would be one feed under two names.
|
|
76
|
+
it("offers the latest feed when the main feed is the featured one", async () => {
|
|
77
|
+
const { app, services } = createSubscribeTestApp();
|
|
78
|
+
await services.settings.set("MAIN_RSS_FEED", "featured");
|
|
79
|
+
|
|
80
|
+
const html = await (await app.request("/subscribe")).text();
|
|
81
|
+
|
|
82
|
+
expect(feedPaths(html)).toEqual(["/feed", "/latest/feed", "/archive/feed"]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("names the main feed by what it currently carries", async () => {
|
|
86
|
+
const { app, services } = createSubscribeTestApp();
|
|
87
|
+
await services.settings.set("MAIN_RSS_FEED", "latest");
|
|
88
|
+
const latest = await (await app.request("/subscribe")).text();
|
|
89
|
+
|
|
90
|
+
await services.settings.set("MAIN_RSS_FEED", "featured");
|
|
91
|
+
const featured = await (await app.request("/subscribe")).text();
|
|
92
|
+
|
|
93
|
+
expect(latest).toContain("The same posts as the home page.");
|
|
94
|
+
expect(featured).toContain("Only the posts marked as featured.");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Each feed is the feed of a page, and the page is the only honest preview
|
|
98
|
+
// of what subscribing gets you — a sentence can only approximate it.
|
|
99
|
+
it("links each feed to the page whose posts it carries", async () => {
|
|
100
|
+
const { app, services } = createSubscribeTestApp();
|
|
101
|
+
await services.settings.set("MAIN_RSS_FEED", "latest");
|
|
102
|
+
|
|
103
|
+
const html = await (await app.request("/subscribe")).text();
|
|
104
|
+
|
|
105
|
+
// `/latest` only redirects to `/`, so the latest feed links straight at
|
|
106
|
+
// the home page rather than sending readers through a 302.
|
|
107
|
+
expect(labelLinks(html)).toEqual(["/", "/featured", "/archive"]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// The main feed is one of the other two under a different name. If its
|
|
111
|
+
// description and its page link were built separately they could drift, and
|
|
112
|
+
// the page would be describing one feed while linking at another.
|
|
113
|
+
it("gives the main feed the same description and page as the feed it is", async () => {
|
|
114
|
+
const { app, services } = createSubscribeTestApp();
|
|
115
|
+
|
|
116
|
+
await services.settings.set("MAIN_RSS_FEED", "featured");
|
|
117
|
+
const featured = await (await app.request("/subscribe")).text();
|
|
118
|
+
// Main feed first, then the opposite end (`latest`), then the archive.
|
|
119
|
+
expect(labelLinks(featured)).toEqual(["/featured", "/", "/archive"]);
|
|
120
|
+
expect([
|
|
121
|
+
...featured.matchAll(/Only the posts marked as featured\./g),
|
|
122
|
+
]).toHaveLength(1);
|
|
123
|
+
|
|
124
|
+
await services.settings.set("MAIN_RSS_FEED", "latest");
|
|
125
|
+
const latest = await (await app.request("/subscribe")).text();
|
|
126
|
+
expect(labelLinks(latest)).toEqual(["/", "/featured", "/archive"]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// A reader who wants more than these two paragraphs needs somewhere to go,
|
|
130
|
+
// and it must not be a page about Jant.
|
|
131
|
+
it("points at an outside introduction, opened safely", async () => {
|
|
132
|
+
const { app } = createSubscribeTestApp();
|
|
133
|
+
|
|
134
|
+
const html = await (await app.request("/subscribe")).text();
|
|
135
|
+
|
|
136
|
+
const link = /<a[^>]*href="https:\/\/aboutfeeds\.com\/"[^>]*>/.exec(
|
|
137
|
+
html,
|
|
138
|
+
)?.[0];
|
|
139
|
+
expect(link).toBeDefined();
|
|
140
|
+
expect(link).toContain('rel="noopener noreferrer"');
|
|
141
|
+
expect(link).toContain('target="_blank"');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// The description has to say what the archive feed adds, not sound like the
|
|
145
|
+
// default choice.
|
|
146
|
+
it("says what the archive feed carries beyond the others", async () => {
|
|
147
|
+
const { app } = createSubscribeTestApp();
|
|
148
|
+
|
|
149
|
+
const html = await (await app.request("/subscribe")).text();
|
|
150
|
+
|
|
151
|
+
expect(html).toContain(
|
|
152
|
+
"Every published post, including those kept off the home page.",
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// The addresses are the whole content of this page. A reader with scripts off
|
|
157
|
+
// must still be able to select and copy them, so the server renders the field
|
|
158
|
+
// and ships the button hidden rather than leaving a dead control.
|
|
159
|
+
it("renders every address without waiting for JavaScript", async () => {
|
|
160
|
+
const { app } = createSubscribeTestApp();
|
|
161
|
+
|
|
162
|
+
const html = await (await app.request("/subscribe")).text();
|
|
163
|
+
|
|
164
|
+
const addresses = feedAddresses(html);
|
|
165
|
+
expect(addresses).toHaveLength(3);
|
|
166
|
+
// Whole addresses: a path pasted into a feed reader resolves nowhere.
|
|
167
|
+
for (const address of addresses) {
|
|
168
|
+
expect(address).toMatch(/^https?:\/\/[^/]+\//);
|
|
169
|
+
}
|
|
170
|
+
expect(html).toContain("data-copy-field-root");
|
|
171
|
+
expect(/<button[^>]*hidden[^>]*data-copy-field=/.test(html)).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// The page is about feeds and used to carry no feed mark at all, while its
|
|
175
|
+
// closing note tells the reader to look for that glyph on other pages. The
|
|
176
|
+
// note only works once the glyph has been shown here.
|
|
177
|
+
it("shows the feed glyph it tells readers to look for", async () => {
|
|
178
|
+
const { app } = createSubscribeTestApp();
|
|
179
|
+
|
|
180
|
+
const html = await (await app.request("/subscribe")).text();
|
|
181
|
+
|
|
182
|
+
// Once beside the page title, once as the specimen the closing note
|
|
183
|
+
// points at.
|
|
184
|
+
expect([...html.matchAll(/lucide-rss/g)]).toHaveLength(2);
|
|
185
|
+
expect(html).toContain("Look for this icon on those pages.");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// A reader who has never used a feed reader is handed three addresses and no
|
|
189
|
+
// idea what to do with them — the same dead end this page exists to end, one
|
|
190
|
+
// step later. No product names: the list would be identical on every Jant
|
|
191
|
+
// site, and it would rot.
|
|
192
|
+
it("explains what a feed reader is without naming one", async () => {
|
|
193
|
+
const { app } = createSubscribeTestApp();
|
|
194
|
+
|
|
195
|
+
const html = await (await app.request("/subscribe")).text();
|
|
196
|
+
|
|
197
|
+
expect(html).toContain("What a feed reader does");
|
|
198
|
+
expect(html).toContain(
|
|
199
|
+
"A feed reader checks these addresses for new posts",
|
|
200
|
+
);
|
|
201
|
+
expect(html).toContain("Subscribing creates no account here.");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("404s when feeds are switched off site-wide", async () => {
|
|
205
|
+
const { app, services } = createSubscribeTestApp();
|
|
206
|
+
await services.settings.set("RSS_FEEDS_ENABLED", "false");
|
|
207
|
+
|
|
208
|
+
const res = await app.request("/subscribe");
|
|
209
|
+
|
|
210
|
+
expect(res.status).toBe(404);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("serves a language view with that language's addresses", async () => {
|
|
214
|
+
const { app, services } = createSubscribeTestApp();
|
|
215
|
+
await services.settings.set("SITE_LANGUAGE", "en");
|
|
216
|
+
await services.settings.set("ADDITIONAL_LANGUAGES", "ja");
|
|
217
|
+
await services.settings.set("MULTILINGUAL_ENABLED", "true");
|
|
218
|
+
await services.settings.set("MAIN_RSS_FEED", "latest");
|
|
219
|
+
|
|
220
|
+
const res = await app.request("/ja/subscribe");
|
|
221
|
+
const html = await res.text();
|
|
222
|
+
|
|
223
|
+
expect(res.status).toBe(200);
|
|
224
|
+
expect(feedPaths(html)).toEqual([
|
|
225
|
+
"/ja/feed",
|
|
226
|
+
"/ja/featured/feed",
|
|
227
|
+
"/ja/archive/feed",
|
|
228
|
+
]);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// A slug may not shadow the page: `subscribe` is in RESERVED_PATHS, so this
|
|
232
|
+
// never becomes a post's address in the first place.
|
|
233
|
+
it("keeps the path for the page rather than a post", async () => {
|
|
234
|
+
const { app, services } = createSubscribeTestApp();
|
|
235
|
+
|
|
236
|
+
await expect(
|
|
237
|
+
services.posts.create({
|
|
238
|
+
format: "note",
|
|
239
|
+
title: "Subscribe",
|
|
240
|
+
slug: "subscribe",
|
|
241
|
+
bodyMarkdown: "Trying to take the path",
|
|
242
|
+
status: "published",
|
|
243
|
+
}),
|
|
244
|
+
).rejects.toThrow();
|
|
245
|
+
|
|
246
|
+
expect((await app.request("/subscribe")).status).toBe(200);
|
|
247
|
+
});
|
|
248
|
+
});
|