@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
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
|
-
import { readFileSync } from "node:fs";
|
|
4
|
-
import { resolve } from "node:path";
|
|
5
3
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
4
|
+
import { readComponentCss } from "../../../__tests__/helpers/component-css.js";
|
|
6
5
|
import type { Editor } from "@tiptap/core";
|
|
7
6
|
import { MAX_THREAD_POSTS } from "../../../types.js";
|
|
8
7
|
|
|
@@ -159,6 +158,7 @@ function mockSlugApi(
|
|
|
159
158
|
|
|
160
159
|
const labels: ComposeLabels = {
|
|
161
160
|
cancel: "Cancel",
|
|
161
|
+
close: "Close",
|
|
162
162
|
note: "Note",
|
|
163
163
|
link: "Link",
|
|
164
164
|
quote: "Quote",
|
|
@@ -197,7 +197,11 @@ const labels: ComposeLabels = {
|
|
|
197
197
|
altHint: "Alt text improves accessibility",
|
|
198
198
|
addMore: "Add",
|
|
199
199
|
removeAttachment: "Remove attachment",
|
|
200
|
+
moveAttachmentEarlier: "Move attachment earlier",
|
|
201
|
+
moveAttachmentLater: "Move attachment later",
|
|
200
202
|
uploading: "Uploading...",
|
|
203
|
+
publishing: "Publishing...",
|
|
204
|
+
saving: "Saving...",
|
|
201
205
|
loadingPost: "Loading post...",
|
|
202
206
|
loadPostFailed: "Couldn't load this post. Try again.",
|
|
203
207
|
published: "Published!",
|
|
@@ -208,7 +212,7 @@ const labels: ComposeLabels = {
|
|
|
208
212
|
confirmCloseTitle: "Save to drafts?",
|
|
209
213
|
confirmCloseSubtitle: "Save to drafts to edit and post at a later time.",
|
|
210
214
|
confirmCloseSave: "Save",
|
|
211
|
-
confirmCloseCancel: "
|
|
215
|
+
confirmCloseCancel: "Keep editing",
|
|
212
216
|
confirmCloseDiscard: "Don't save",
|
|
213
217
|
confirmAttachedTitle: "Save text attachment?",
|
|
214
218
|
confirmAttachedSubtitle:
|
|
@@ -227,14 +231,32 @@ const labels: ComposeLabels = {
|
|
|
227
231
|
deleteDraft: "Delete Draft",
|
|
228
232
|
draftDeleted: "Draft deleted.",
|
|
229
233
|
publishFailedDraft: "Couldn't publish. Saved as draft.",
|
|
234
|
+
publishFailed: "Couldn't publish. Try again.",
|
|
235
|
+
publishFailedOffline: "Couldn't reach the server. Try again.",
|
|
230
236
|
uploadFailedDraft: "Some uploads failed. Saved as draft.",
|
|
231
237
|
addCollection: "Add Collection",
|
|
232
238
|
collectionCountLabel: "%name% + %count% more",
|
|
233
239
|
draftRestored: "Draft restored.",
|
|
240
|
+
draftStoreFailed: "This browser can't keep a local copy. Save as a draft.",
|
|
234
241
|
reply: "Reply",
|
|
235
242
|
publishHideFromLatest: "Hide from Latest",
|
|
236
243
|
publishPrivate: "Post as Private",
|
|
237
244
|
publishSettings: "Publish settings",
|
|
245
|
+
languageLabel: "Language",
|
|
246
|
+
languageAuto: "Detect",
|
|
247
|
+
languageAutoHint: "Read from what you write",
|
|
248
|
+
languageAutoDetected: "Read from what you write — looks like {language}",
|
|
249
|
+
languageAutoPending: "Not enough text to tell yet — publishes in {language}",
|
|
250
|
+
languageTriggerLabel: "Language: {language}",
|
|
251
|
+
translationOf: "Translation of “{title}”",
|
|
252
|
+
translationContext: "Translating",
|
|
253
|
+
translationContextInLanguage: "Translating into {language}",
|
|
254
|
+
translationContextOpen: "Open the original in a new tab",
|
|
255
|
+
translationContextOriginal: "The original",
|
|
256
|
+
translationContextHide: "Hide",
|
|
257
|
+
translationContextHideLong: "Hide the original",
|
|
258
|
+
translationContextShow: "Show",
|
|
259
|
+
translationContextShowLong: "Show the original",
|
|
238
260
|
publishVisibilityLabel: "Visibility",
|
|
239
261
|
publishVisibilityPublic: "Public",
|
|
240
262
|
publishVisibilityPublicHint: "Appears in Latest.",
|
|
@@ -272,7 +294,6 @@ const labels: ComposeLabels = {
|
|
|
272
294
|
threadLimitReached: "Threads can include up to 20 posts.",
|
|
273
295
|
showMore: "Show more",
|
|
274
296
|
showLess: "Show less",
|
|
275
|
-
closeCompose: "Close compose",
|
|
276
297
|
editing: "Editing",
|
|
277
298
|
composeDialogLabel: "Compose",
|
|
278
299
|
slashHint: "Type / for commands",
|
|
@@ -302,13 +323,6 @@ const labels: ComposeLabels = {
|
|
|
302
323
|
quickHint: "More options are available after you create it.",
|
|
303
324
|
quickSubmitLabel: "Done",
|
|
304
325
|
createdLabel: "Collection created.",
|
|
305
|
-
descriptionLabel: "Description (optional)",
|
|
306
|
-
descriptionPlaceholder: "What's this collection about?",
|
|
307
|
-
sortOrderLabel: "Sort Order",
|
|
308
|
-
sortNewest: "Newest first",
|
|
309
|
-
sortOldest: "Oldest first",
|
|
310
|
-
sortRatingDesc: "Highest rated",
|
|
311
|
-
submitLabel: "Save",
|
|
312
326
|
cancelLabel: "Cancel",
|
|
313
327
|
},
|
|
314
328
|
};
|
|
@@ -652,6 +666,860 @@ describe("JantComposeDialog", () => {
|
|
|
652
666
|
expect(el.querySelector(".compose-sheet")).not.toBeNull();
|
|
653
667
|
});
|
|
654
668
|
|
|
669
|
+
describe("language", () => {
|
|
670
|
+
function languageTrigger(el: JantComposeDialog): HTMLButtonElement | null {
|
|
671
|
+
return el.querySelector<HTMLButtonElement>(".compose-language-trigger");
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
async function openLanguagePicker(el: JantComposeDialog) {
|
|
675
|
+
requireElement(languageTrigger(el), "expected language pill").click();
|
|
676
|
+
await flushUpdates(el);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function languageRowTitles(el: JantComposeDialog): string[] {
|
|
680
|
+
const group = el.querySelector(
|
|
681
|
+
".compose-language-popover [role='radiogroup']",
|
|
682
|
+
);
|
|
683
|
+
return Array.from(
|
|
684
|
+
group?.querySelectorAll(".compose-sheet-title") ?? [],
|
|
685
|
+
).map((node) => node.textContent?.trim() ?? "");
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
it("shows nothing on a site that publishes one language", async () => {
|
|
689
|
+
// The whole feature is opt-in: an author who never turned it on should
|
|
690
|
+
// not meet a language control.
|
|
691
|
+
const el = await createElement();
|
|
692
|
+
|
|
693
|
+
expect(languageTrigger(el)).toBeNull();
|
|
694
|
+
expect(el.textContent).not.toContain("Detect");
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
it("offers Detect plus each language once the site is multilingual", async () => {
|
|
698
|
+
const el = await createElement();
|
|
699
|
+
el.languages = [
|
|
700
|
+
{ tag: "zh-Hans", label: "简体中文" },
|
|
701
|
+
{ tag: "en", label: "English" },
|
|
702
|
+
];
|
|
703
|
+
await flushUpdates(el);
|
|
704
|
+
await openLanguagePicker(el);
|
|
705
|
+
|
|
706
|
+
expect(languageRowTitles(el)).toEqual(["Detect", "简体中文", "English"]);
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
it("stays a bare globe while the answer is the page you are on", async () => {
|
|
710
|
+
const el = await createElement();
|
|
711
|
+
el.languages = [
|
|
712
|
+
{ tag: "zh-Hans", label: "简体中文" },
|
|
713
|
+
{ tag: "en", label: "English" },
|
|
714
|
+
];
|
|
715
|
+
el.contextLanguage = "en";
|
|
716
|
+
await flushUpdates(el);
|
|
717
|
+
|
|
718
|
+
// Writing English from /en: the globe is the whole message. The answer
|
|
719
|
+
// is still named for anyone who asks for it.
|
|
720
|
+
const trigger = requireElement(
|
|
721
|
+
languageTrigger(el),
|
|
722
|
+
"expected language pill",
|
|
723
|
+
);
|
|
724
|
+
expect(trigger.textContent?.trim()).toBe("");
|
|
725
|
+
expect(trigger.getAttribute("aria-label")).toBe("Language: English");
|
|
726
|
+
expect(trigger.getAttribute("title")).toBe("Language: English");
|
|
727
|
+
expect(trigger.dataset.chosen).toBeUndefined();
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
it("grows the language name once detection moves it", async () => {
|
|
731
|
+
const el = await createElement();
|
|
732
|
+
el.languages = [
|
|
733
|
+
{ tag: "zh-Hans", label: "简体中文" },
|
|
734
|
+
{ tag: "en", label: "English" },
|
|
735
|
+
];
|
|
736
|
+
el.contextLanguage = "en";
|
|
737
|
+
await flushUpdates(el);
|
|
738
|
+
|
|
739
|
+
const editor = requireElement(
|
|
740
|
+
el.querySelector<JantComposeEditor>("jant-compose-editor"),
|
|
741
|
+
"expected compose editor",
|
|
742
|
+
);
|
|
743
|
+
const write = async (text: string) => {
|
|
744
|
+
editor._bodyJson = {
|
|
745
|
+
type: "doc",
|
|
746
|
+
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
|
|
747
|
+
};
|
|
748
|
+
await editor.updateComplete;
|
|
749
|
+
await flushUpdates(el);
|
|
750
|
+
};
|
|
751
|
+
|
|
752
|
+
// A fragment is not evidence: two characters leave the pill alone.
|
|
753
|
+
await write("这会");
|
|
754
|
+
expect(languageTrigger(el)?.textContent?.trim()).toBe("");
|
|
755
|
+
|
|
756
|
+
// A sentence is. The pill says so itself, with nothing opened.
|
|
757
|
+
await write("这会说国学时间");
|
|
758
|
+
expect(languageTrigger(el)?.textContent?.trim()).toBe("简体中文");
|
|
759
|
+
expect(languageTrigger(el)?.getAttribute("aria-label")).toBe(
|
|
760
|
+
"Language: 简体中文",
|
|
761
|
+
);
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
it("marks the pill once the author picks a language themselves", async () => {
|
|
765
|
+
const el = await createElement();
|
|
766
|
+
el.languages = [
|
|
767
|
+
{ tag: "zh-Hans", label: "简体中文" },
|
|
768
|
+
{ tag: "en", label: "English" },
|
|
769
|
+
];
|
|
770
|
+
el.contextLanguage = "en";
|
|
771
|
+
await flushUpdates(el);
|
|
772
|
+
await openLanguagePicker(el);
|
|
773
|
+
|
|
774
|
+
const rows = Array.from(
|
|
775
|
+
el.querySelectorAll<HTMLButtonElement>(
|
|
776
|
+
".compose-language-popover .compose-sheet-row",
|
|
777
|
+
),
|
|
778
|
+
);
|
|
779
|
+
requireElement(rows[1] ?? null, "expected the 简体中文 row").click();
|
|
780
|
+
await flushUpdates(el);
|
|
781
|
+
|
|
782
|
+
// Choosing closes the list behind the choice.
|
|
783
|
+
expect(el.querySelector(".compose-language-popover")).toBeNull();
|
|
784
|
+
const trigger = requireElement(
|
|
785
|
+
languageTrigger(el),
|
|
786
|
+
"expected language pill",
|
|
787
|
+
);
|
|
788
|
+
expect(trigger.textContent?.trim()).toBe("简体中文");
|
|
789
|
+
expect(trigger.dataset.chosen).toBe("true");
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
it("says the text is too short rather than claiming a reading", async () => {
|
|
793
|
+
// Below the detector's threshold there is no reading to report. Naming
|
|
794
|
+
// the page's language here would pass a fallback off as an answer.
|
|
795
|
+
const el = await createElement();
|
|
796
|
+
el.languages = [
|
|
797
|
+
{ tag: "zh-Hans", label: "简体中文" },
|
|
798
|
+
{ tag: "en", label: "English" },
|
|
799
|
+
];
|
|
800
|
+
el.contextLanguage = "zh-Hans";
|
|
801
|
+
await flushUpdates(el);
|
|
802
|
+
await openLanguagePicker(el);
|
|
803
|
+
|
|
804
|
+
const hint = () =>
|
|
805
|
+
el
|
|
806
|
+
.querySelector(".compose-language-popover .compose-sheet-sub")
|
|
807
|
+
?.textContent?.trim();
|
|
808
|
+
expect(hint()).toBe(
|
|
809
|
+
"Not enough text to tell yet — publishes in 简体中文",
|
|
810
|
+
);
|
|
811
|
+
|
|
812
|
+
const editor = requireElement(
|
|
813
|
+
el.querySelector<JantComposeEditor>("jant-compose-editor"),
|
|
814
|
+
"expected compose editor",
|
|
815
|
+
);
|
|
816
|
+
editor._bodyJson = {
|
|
817
|
+
type: "doc",
|
|
818
|
+
content: [
|
|
819
|
+
{
|
|
820
|
+
type: "paragraph",
|
|
821
|
+
content: [{ type: "text", text: "Hello there, in English." }],
|
|
822
|
+
},
|
|
823
|
+
],
|
|
824
|
+
};
|
|
825
|
+
await editor.updateComplete;
|
|
826
|
+
await flushUpdates(el);
|
|
827
|
+
|
|
828
|
+
expect(hint()).toBe("Read from what you write — looks like English");
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
it("keeps the Detect row honest after an explicit choice", async () => {
|
|
832
|
+
// The row offers to read the text, so it has to report what reading it
|
|
833
|
+
// produces — not echo back the language the author picked instead.
|
|
834
|
+
const el = await createElement();
|
|
835
|
+
el.languages = [
|
|
836
|
+
{ tag: "zh-Hans", label: "简体中文" },
|
|
837
|
+
{ tag: "en", label: "English" },
|
|
838
|
+
];
|
|
839
|
+
el.contextLanguage = "en";
|
|
840
|
+
await flushUpdates(el);
|
|
841
|
+
|
|
842
|
+
const editor = requireElement(
|
|
843
|
+
el.querySelector<JantComposeEditor>("jant-compose-editor"),
|
|
844
|
+
"expected compose editor",
|
|
845
|
+
);
|
|
846
|
+
editor._bodyJson = {
|
|
847
|
+
type: "doc",
|
|
848
|
+
content: [
|
|
849
|
+
{
|
|
850
|
+
type: "paragraph",
|
|
851
|
+
content: [{ type: "text", text: "Hello there, in English." }],
|
|
852
|
+
},
|
|
853
|
+
],
|
|
854
|
+
};
|
|
855
|
+
await editor.updateComplete;
|
|
856
|
+
await flushUpdates(el);
|
|
857
|
+
await openLanguagePicker(el);
|
|
858
|
+
|
|
859
|
+
const rows = Array.from(
|
|
860
|
+
el.querySelectorAll<HTMLButtonElement>(
|
|
861
|
+
".compose-language-popover .compose-sheet-row",
|
|
862
|
+
),
|
|
863
|
+
);
|
|
864
|
+
requireElement(rows[1] ?? null, "expected the 简体中文 row").click();
|
|
865
|
+
await flushUpdates(el);
|
|
866
|
+
await openLanguagePicker(el);
|
|
867
|
+
|
|
868
|
+
const hint = el.querySelector(
|
|
869
|
+
".compose-language-popover .compose-sheet-sub",
|
|
870
|
+
);
|
|
871
|
+
expect(hint?.textContent?.trim()).toBe(
|
|
872
|
+
"Read from what you write — looks like English",
|
|
873
|
+
);
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
it("closes the list on Escape and hands focus back to the pill", async () => {
|
|
877
|
+
const el = await createElement();
|
|
878
|
+
el.languages = [
|
|
879
|
+
{ tag: "zh-Hans", label: "简体中文" },
|
|
880
|
+
{ tag: "en", label: "English" },
|
|
881
|
+
];
|
|
882
|
+
await flushUpdates(el);
|
|
883
|
+
await openLanguagePicker(el);
|
|
884
|
+
expect(el.querySelector(".compose-language-popover")).not.toBeNull();
|
|
885
|
+
|
|
886
|
+
el.dispatchEvent(
|
|
887
|
+
new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
|
|
888
|
+
);
|
|
889
|
+
await flushUpdates(el);
|
|
890
|
+
|
|
891
|
+
expect(el.querySelector(".compose-language-popover")).toBeNull();
|
|
892
|
+
expect(document.activeElement).toBe(languageTrigger(el));
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
it("closes the list when another control opens", async () => {
|
|
896
|
+
const el = await createElement();
|
|
897
|
+
el.languages = [
|
|
898
|
+
{ tag: "zh-Hans", label: "简体中文" },
|
|
899
|
+
{ tag: "en", label: "English" },
|
|
900
|
+
];
|
|
901
|
+
await flushUpdates(el);
|
|
902
|
+
await openLanguagePicker(el);
|
|
903
|
+
|
|
904
|
+
await openPublishPanel(el);
|
|
905
|
+
|
|
906
|
+
expect(el.querySelector(".compose-language-popover")).toBeNull();
|
|
907
|
+
expect(el.querySelector(".compose-sheet")).not.toBeNull();
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
it("keeps language out of the options sheet", async () => {
|
|
911
|
+
// One setting, one place. The sheet speaks for the whole submission; the
|
|
912
|
+
// language belongs to what is being written.
|
|
913
|
+
const el = await createElement();
|
|
914
|
+
el.languages = [
|
|
915
|
+
{ tag: "zh-Hans", label: "简体中文" },
|
|
916
|
+
{ tag: "en", label: "English" },
|
|
917
|
+
];
|
|
918
|
+
await flushUpdates(el);
|
|
919
|
+
|
|
920
|
+
await openPublishPanel(el);
|
|
921
|
+
|
|
922
|
+
expect(
|
|
923
|
+
el.querySelector(".compose-sheet")?.textContent ?? "",
|
|
924
|
+
).not.toContain("Detect");
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Open the composer on a translation of `post`. The composer asks for two
|
|
929
|
+
* things: the post's fields, to seed from, and its server-rendered markup,
|
|
930
|
+
* to show.
|
|
931
|
+
*/
|
|
932
|
+
async function openTranslationOf(
|
|
933
|
+
el: JantComposeDialog,
|
|
934
|
+
post: Record<string, unknown>,
|
|
935
|
+
options: { language?: string; previewHtml?: string | null } = {},
|
|
936
|
+
) {
|
|
937
|
+
const { language = "en", previewHtml = null } = options;
|
|
938
|
+
vi.spyOn(globalThis, "fetch").mockImplementation((input: unknown) => {
|
|
939
|
+
if (String(input).includes("/_/post-preview/")) {
|
|
940
|
+
return Promise.resolve({
|
|
941
|
+
ok: previewHtml !== null,
|
|
942
|
+
text: async () => previewHtml ?? "",
|
|
943
|
+
} as Response);
|
|
944
|
+
}
|
|
945
|
+
return Promise.resolve({
|
|
946
|
+
ok: true,
|
|
947
|
+
json: async () => post,
|
|
948
|
+
} as Response);
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
await el.openTranslation((post.id as string) ?? "pst_source", language);
|
|
952
|
+
await flushUpdates(el);
|
|
953
|
+
await flushUpdates(el);
|
|
954
|
+
await flushUpdates(el);
|
|
955
|
+
return el;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
async function multilingualElement() {
|
|
959
|
+
const el = await createElement();
|
|
960
|
+
el.languages = [
|
|
961
|
+
{ tag: "zh-Hans", label: "简体中文" },
|
|
962
|
+
{ tag: "en", label: "English" },
|
|
963
|
+
];
|
|
964
|
+
await flushUpdates(el);
|
|
965
|
+
return el;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
const PREVIEW_HTML =
|
|
969
|
+
'<article class="h-entry post-menu-target" data-post data-post-id="pst_source" data-page="post">' +
|
|
970
|
+
'<h1 class="post-detail-title">咖啡笔记</h1>' +
|
|
971
|
+
'<div class="post-header-meta-row"><a href="/coffee" class="post-header-meta-link">Aug 8, 2026</a></div>' +
|
|
972
|
+
'<div class="e-content prose post-detail-body"><h2>耶加雪菲</h2>' +
|
|
973
|
+
'<p>今天试了一支,<a href="https://example.com/beans">花香</a>很明显。</p></div>' +
|
|
974
|
+
"</article>";
|
|
975
|
+
|
|
976
|
+
it("says what it was opened to translate, before anything is typed", async () => {
|
|
977
|
+
// Opened from a post, so the composer should carry that context above the
|
|
978
|
+
// editor — not two panels down in the publish sheet.
|
|
979
|
+
const el = await multilingualElement();
|
|
980
|
+
await openTranslationOf(
|
|
981
|
+
el,
|
|
982
|
+
{
|
|
983
|
+
id: "pst_source",
|
|
984
|
+
format: "note",
|
|
985
|
+
displayTitle: "咖啡笔记",
|
|
986
|
+
slug: "coffee",
|
|
987
|
+
},
|
|
988
|
+
{ previewHtml: PREVIEW_HTML },
|
|
989
|
+
);
|
|
990
|
+
|
|
991
|
+
const banner = requireElement(
|
|
992
|
+
el.querySelector(".compose-translation-context"),
|
|
993
|
+
"expected the translation context",
|
|
994
|
+
);
|
|
995
|
+
// The language sits on the seam between the two posts, not above both:
|
|
996
|
+
// it reads as "…and below is that, in English".
|
|
997
|
+
expect(
|
|
998
|
+
banner
|
|
999
|
+
.querySelector(".compose-translation-seam-label")
|
|
1000
|
+
?.textContent?.trim(),
|
|
1001
|
+
).toBe("Translating into English");
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
it("shows the original as the site renders it, inside a frame to scroll", async () => {
|
|
1005
|
+
// Server-rendered rather than rebuilt here, so a Quote arrives with its
|
|
1006
|
+
// attribution and a Link with its card. The frame is fixed because an
|
|
1007
|
+
// original handed its full height pushes the editor off the screen
|
|
1008
|
+
// exactly when both need to be visible.
|
|
1009
|
+
const el = await multilingualElement();
|
|
1010
|
+
await openTranslationOf(
|
|
1011
|
+
el,
|
|
1012
|
+
{
|
|
1013
|
+
id: "pst_source",
|
|
1014
|
+
format: "note",
|
|
1015
|
+
displayTitle: "咖啡笔记",
|
|
1016
|
+
slug: "coffee",
|
|
1017
|
+
},
|
|
1018
|
+
{ previewHtml: PREVIEW_HTML },
|
|
1019
|
+
);
|
|
1020
|
+
|
|
1021
|
+
const original = requireElement(
|
|
1022
|
+
el.querySelector(".compose-translation-original"),
|
|
1023
|
+
"expected the original's frame",
|
|
1024
|
+
);
|
|
1025
|
+
// Keyboard users have to be able to scroll it, and a screen reader has to
|
|
1026
|
+
// be able to name it.
|
|
1027
|
+
expect(original.getAttribute("tabindex")).toBe("0");
|
|
1028
|
+
expect(original.getAttribute("role")).toBe("region");
|
|
1029
|
+
expect(original.getAttribute("aria-label")).toBe("The original");
|
|
1030
|
+
// Structure is part of what is being translated, so it survives intact.
|
|
1031
|
+
expect(original.querySelector("h1")?.textContent).toBe("咖啡笔记");
|
|
1032
|
+
expect(original.querySelector("h2")?.textContent).toBe("耶加雪菲");
|
|
1033
|
+
|
|
1034
|
+
// Nothing left of the expand-in-place preview it replaced, and no bolted
|
|
1035
|
+
// on external-link glyph — that made the card read as a Link post.
|
|
1036
|
+
expect(el.querySelector(".compose-translation-toggle")).toBeNull();
|
|
1037
|
+
expect(el.querySelector(".compose-translation-source-icon")).toBeNull();
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
it("folds the original away and back, keeping the seam either way", async () => {
|
|
1041
|
+
// The frame costs vertical room the editor may want back mid-draft. What
|
|
1042
|
+
// it is being translated into stays on screen while it is folded — that
|
|
1043
|
+
// is about the two posts, not about how much of one is showing.
|
|
1044
|
+
const el = await multilingualElement();
|
|
1045
|
+
await openTranslationOf(
|
|
1046
|
+
el,
|
|
1047
|
+
{
|
|
1048
|
+
id: "pst_source",
|
|
1049
|
+
format: "note",
|
|
1050
|
+
displayTitle: "咖啡笔记",
|
|
1051
|
+
slug: "coffee",
|
|
1052
|
+
},
|
|
1053
|
+
{ previewHtml: PREVIEW_HTML },
|
|
1054
|
+
);
|
|
1055
|
+
|
|
1056
|
+
const toggle = () =>
|
|
1057
|
+
requireElement(
|
|
1058
|
+
el.querySelector<HTMLButtonElement>(
|
|
1059
|
+
".compose-translation-seam-toggle",
|
|
1060
|
+
),
|
|
1061
|
+
"expected the fold toggle",
|
|
1062
|
+
);
|
|
1063
|
+
|
|
1064
|
+
expect(el.querySelector(".compose-translation-original")).not.toBeNull();
|
|
1065
|
+
expect(toggle().getAttribute("aria-expanded")).toBe("true");
|
|
1066
|
+
expect(toggle().textContent?.trim()).toBe("Hide");
|
|
1067
|
+
|
|
1068
|
+
toggle().click();
|
|
1069
|
+
await flushUpdates(el);
|
|
1070
|
+
|
|
1071
|
+
expect(el.querySelector(".compose-translation-original")).toBeNull();
|
|
1072
|
+
expect(toggle().getAttribute("aria-expanded")).toBe("false");
|
|
1073
|
+
expect(toggle().textContent?.trim()).toBe("Show");
|
|
1074
|
+
expect(toggle().getAttribute("aria-label")).toBe("Show the original");
|
|
1075
|
+
expect(
|
|
1076
|
+
el
|
|
1077
|
+
.querySelector(".compose-translation-seam-label")
|
|
1078
|
+
?.textContent?.trim(),
|
|
1079
|
+
).toBe("Translating into English");
|
|
1080
|
+
|
|
1081
|
+
toggle().click();
|
|
1082
|
+
await flushUpdates(el);
|
|
1083
|
+
|
|
1084
|
+
// Unfolding re-creates the markup, so the links have to be sent to a new
|
|
1085
|
+
// tab again — the adopt pass runs on every update, not just the first.
|
|
1086
|
+
const link = requireElement(
|
|
1087
|
+
el.querySelector<HTMLAnchorElement>(
|
|
1088
|
+
".compose-translation-preview a[href]",
|
|
1089
|
+
),
|
|
1090
|
+
"expected the original back",
|
|
1091
|
+
);
|
|
1092
|
+
expect(link.getAttribute("target")).toBe("_blank");
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
it("sends every link in the original to a new tab", async () => {
|
|
1096
|
+
// A link followed in place navigates the composer away and takes the
|
|
1097
|
+
// unsaved translation with it.
|
|
1098
|
+
const el = await multilingualElement();
|
|
1099
|
+
await openTranslationOf(
|
|
1100
|
+
el,
|
|
1101
|
+
{
|
|
1102
|
+
id: "pst_source",
|
|
1103
|
+
format: "note",
|
|
1104
|
+
displayTitle: "咖啡笔记",
|
|
1105
|
+
slug: "coffee",
|
|
1106
|
+
},
|
|
1107
|
+
{ previewHtml: PREVIEW_HTML },
|
|
1108
|
+
);
|
|
1109
|
+
|
|
1110
|
+
const links = Array.from(
|
|
1111
|
+
el.querySelectorAll<HTMLAnchorElement>(
|
|
1112
|
+
".compose-translation-preview a[href]",
|
|
1113
|
+
),
|
|
1114
|
+
);
|
|
1115
|
+
expect(links.length).toBe(2);
|
|
1116
|
+
for (const link of links) {
|
|
1117
|
+
expect(link.getAttribute("target")).toBe("_blank");
|
|
1118
|
+
expect(link.getAttribute("rel")).toBe("noopener noreferrer");
|
|
1119
|
+
}
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
it("strips the original's identity so nothing else can act on it", async () => {
|
|
1123
|
+
// `data-post-id` is how the post menu, the keyboard shortcuts and
|
|
1124
|
+
// `refreshArticleView` find a post. A second copy of the original's id in
|
|
1125
|
+
// the DOM lets any of them act on the preview believing it is the card.
|
|
1126
|
+
const el = await multilingualElement();
|
|
1127
|
+
await openTranslationOf(
|
|
1128
|
+
el,
|
|
1129
|
+
{
|
|
1130
|
+
id: "pst_source",
|
|
1131
|
+
format: "note",
|
|
1132
|
+
displayTitle: "咖啡笔记",
|
|
1133
|
+
slug: "coffee",
|
|
1134
|
+
},
|
|
1135
|
+
{ previewHtml: PREVIEW_HTML },
|
|
1136
|
+
);
|
|
1137
|
+
|
|
1138
|
+
const preview = requireElement(
|
|
1139
|
+
el.querySelector(".compose-translation-preview"),
|
|
1140
|
+
"expected the preview",
|
|
1141
|
+
);
|
|
1142
|
+
expect(preview.querySelector("[data-post-id]")).toBeNull();
|
|
1143
|
+
expect(preview.querySelector(".post-menu-target")).toBeNull();
|
|
1144
|
+
expect(
|
|
1145
|
+
document.querySelectorAll('[data-post-id="pst_source"]').length,
|
|
1146
|
+
).toBe(0);
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
it("falls back to a plain link when the rendering cannot be fetched", async () => {
|
|
1150
|
+
const el = await multilingualElement();
|
|
1151
|
+
await openTranslationOf(
|
|
1152
|
+
el,
|
|
1153
|
+
{
|
|
1154
|
+
id: "pst_source",
|
|
1155
|
+
format: "note",
|
|
1156
|
+
displayTitle: "咖啡笔记",
|
|
1157
|
+
slug: "coffee",
|
|
1158
|
+
},
|
|
1159
|
+
{ previewHtml: null },
|
|
1160
|
+
);
|
|
1161
|
+
|
|
1162
|
+
expect(el.querySelector(".compose-translation-original")).toBeNull();
|
|
1163
|
+
const link = requireElement(
|
|
1164
|
+
el.querySelector<HTMLAnchorElement>(".compose-translation-fallback a"),
|
|
1165
|
+
"expected a fallback link to the original",
|
|
1166
|
+
);
|
|
1167
|
+
expect(link.textContent?.trim()).toBe("咖啡笔记");
|
|
1168
|
+
expect(link.getAttribute("href")).toBe("/coffee");
|
|
1169
|
+
expect(link.getAttribute("target")).toBe("_blank");
|
|
1170
|
+
// The seam still names the language: it is about the two posts, not about
|
|
1171
|
+
// how much of one of them could be shown.
|
|
1172
|
+
expect(
|
|
1173
|
+
el
|
|
1174
|
+
.querySelector(".compose-translation-seam-label")
|
|
1175
|
+
?.textContent?.trim(),
|
|
1176
|
+
).toBe("Translating into English");
|
|
1177
|
+
});
|
|
1178
|
+
|
|
1179
|
+
it("starts a translation in the original's shape, down to its meta", async () => {
|
|
1180
|
+
// Everything that describes the post's subject or its place travels:
|
|
1181
|
+
// format, citation, collections, visibility, rating. Only the words are
|
|
1182
|
+
// language-specific, so only the words start empty.
|
|
1183
|
+
const el = await multilingualElement();
|
|
1184
|
+
await openTranslationOf(el, {
|
|
1185
|
+
id: "pst_source",
|
|
1186
|
+
format: "quote",
|
|
1187
|
+
displayTitle: "关于专注",
|
|
1188
|
+
slug: "focus",
|
|
1189
|
+
quoteText: "专注是一种拒绝。",
|
|
1190
|
+
sourceName: "Some Author",
|
|
1191
|
+
sourceUrl: "https://example.com/focus",
|
|
1192
|
+
collectionIds: ["col_reading", "col_notes"],
|
|
1193
|
+
visibility: "latest_hidden",
|
|
1194
|
+
rating: 4,
|
|
1195
|
+
});
|
|
1196
|
+
|
|
1197
|
+
const editor = requireElement(
|
|
1198
|
+
el.querySelector<JantComposeEditor>("jant-compose-editor"),
|
|
1199
|
+
"expected the editor",
|
|
1200
|
+
);
|
|
1201
|
+
expect(editor.format).toBe("quote");
|
|
1202
|
+
const data = editor.getData();
|
|
1203
|
+
expect(data.url).toBe("https://example.com/focus");
|
|
1204
|
+
expect(data.quoteAuthor).toBe("Some Author");
|
|
1205
|
+
expect(data.rating).toBe(4);
|
|
1206
|
+
// The prose is the part the author is here to write.
|
|
1207
|
+
expect(data.quoteText).toBe("");
|
|
1208
|
+
|
|
1209
|
+
const internals = el as unknown as {
|
|
1210
|
+
_collectionIds: string[];
|
|
1211
|
+
_visibility: string;
|
|
1212
|
+
_hasUnsavedChanges: () => boolean;
|
|
1213
|
+
};
|
|
1214
|
+
expect(internals._collectionIds).toEqual(["col_reading", "col_notes"]);
|
|
1215
|
+
expect(internals._visibility).toBe("latest_hidden");
|
|
1216
|
+
|
|
1217
|
+
// None of that counts as something to discard: the author typed nothing.
|
|
1218
|
+
expect(internals._hasUnsavedChanges()).toBe(false);
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
it("closes a seeded translation without offering to save it", async () => {
|
|
1222
|
+
// The citation arrived from the original, not from the author. Being
|
|
1223
|
+
// asked to save a draft of a URL you never typed is worse than the
|
|
1224
|
+
// prompt is worth — but the moment anything is written, it comes back.
|
|
1225
|
+
const el = await multilingualElement();
|
|
1226
|
+
await openTranslationOf(el, {
|
|
1227
|
+
id: "pst_source",
|
|
1228
|
+
format: "quote",
|
|
1229
|
+
displayTitle: "关于专注",
|
|
1230
|
+
slug: "focus",
|
|
1231
|
+
sourceUrl: "https://example.com/focus",
|
|
1232
|
+
});
|
|
1233
|
+
const internals = el as unknown as {
|
|
1234
|
+
_confirmPanelOpen: boolean;
|
|
1235
|
+
_hasContent: () => boolean;
|
|
1236
|
+
};
|
|
1237
|
+
|
|
1238
|
+
el.requestClose();
|
|
1239
|
+
await flushUpdates(el);
|
|
1240
|
+
expect(internals._confirmPanelOpen).toBe(false);
|
|
1241
|
+
|
|
1242
|
+
await openTranslationOf(el, {
|
|
1243
|
+
id: "pst_source",
|
|
1244
|
+
format: "quote",
|
|
1245
|
+
displayTitle: "关于专注",
|
|
1246
|
+
slug: "focus",
|
|
1247
|
+
sourceUrl: "https://example.com/focus",
|
|
1248
|
+
});
|
|
1249
|
+
const editor = requireElement(
|
|
1250
|
+
el.querySelector<JantComposeEditor>("jant-compose-editor"),
|
|
1251
|
+
"expected the editor",
|
|
1252
|
+
);
|
|
1253
|
+
editor._quoteText = "Focus is about saying no.";
|
|
1254
|
+
await flushUpdates(el);
|
|
1255
|
+
|
|
1256
|
+
el.requestClose();
|
|
1257
|
+
await flushUpdates(el);
|
|
1258
|
+
expect(internals._confirmPanelOpen).toBe(true);
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
it("keeps the citation, the original and the language when a translation grows into a thread", async () => {
|
|
1262
|
+
// The thread path used to be a copy of the single-post one: "Add to
|
|
1263
|
+
// thread" dropped the quote's source link on the way into the thread
|
|
1264
|
+
// editor, and the submit never learned about language or translations.
|
|
1265
|
+
const el = await multilingualElement();
|
|
1266
|
+
await openTranslationOf(el, {
|
|
1267
|
+
id: "pst_source",
|
|
1268
|
+
format: "quote",
|
|
1269
|
+
displayTitle: "关于专注",
|
|
1270
|
+
slug: "focus",
|
|
1271
|
+
quoteText: "专注是一种拒绝。",
|
|
1272
|
+
sourceName: "Some Author",
|
|
1273
|
+
sourceUrl: "https://example.com/focus",
|
|
1274
|
+
});
|
|
1275
|
+
(el as unknown as { _language: string | null })._language = "en";
|
|
1276
|
+
const single = requireElement(
|
|
1277
|
+
el.querySelector<JantComposeEditor>("jant-compose-editor"),
|
|
1278
|
+
"expected the editor",
|
|
1279
|
+
);
|
|
1280
|
+
single._quoteText = "Focus is a refusal.";
|
|
1281
|
+
await flushUpdates(el);
|
|
1282
|
+
|
|
1283
|
+
(el as unknown as { _addThreadItem: () => void })._addThreadItem();
|
|
1284
|
+
await flushUpdates(el);
|
|
1285
|
+
const editors = Array.from(
|
|
1286
|
+
el.querySelectorAll<JantComposeEditor>("jant-compose-editor"),
|
|
1287
|
+
);
|
|
1288
|
+
expect(editors).toHaveLength(2);
|
|
1289
|
+
// The next post starts in the format of the one before it.
|
|
1290
|
+
editors[1]!._quoteText = "And its answer.";
|
|
1291
|
+
await flushUpdates(el);
|
|
1292
|
+
|
|
1293
|
+
let detail: ComposeSubmitDetail | null = null;
|
|
1294
|
+
el.addEventListener("jant:compose-submit-deferred", (event) => {
|
|
1295
|
+
detail = (event as CustomEvent<ComposeSubmitDetail>).detail;
|
|
1296
|
+
});
|
|
1297
|
+
requireElement(
|
|
1298
|
+
el.querySelector<HTMLButtonElement>(".compose-publish-main"),
|
|
1299
|
+
"expected publish button",
|
|
1300
|
+
).click();
|
|
1301
|
+
|
|
1302
|
+
const [root, reply] = (detail as ComposeSubmitDetail | null)
|
|
1303
|
+
?.threadPosts ?? [undefined, undefined];
|
|
1304
|
+
expect(root).toMatchObject({
|
|
1305
|
+
format: "quote",
|
|
1306
|
+
quoteText: "Focus is a refusal.",
|
|
1307
|
+
quoteAuthor: "Some Author",
|
|
1308
|
+
url: "https://example.com/focus",
|
|
1309
|
+
translationOfId: "pst_source",
|
|
1310
|
+
language: "en",
|
|
1311
|
+
});
|
|
1312
|
+
// Thread-level facts: only the root states them.
|
|
1313
|
+
expect(reply?.translationOfId).toBeUndefined();
|
|
1314
|
+
expect(reply?.language).toBeUndefined();
|
|
1315
|
+
});
|
|
1316
|
+
|
|
1317
|
+
it("leaves the format alone once the author has started writing", async () => {
|
|
1318
|
+
// The fetch lands after the composer opens. Overwriting a first sentence
|
|
1319
|
+
// to save a format click is a bad trade.
|
|
1320
|
+
const el = await multilingualElement();
|
|
1321
|
+
vi.spyOn(
|
|
1322
|
+
el as unknown as { _hasContent: () => boolean },
|
|
1323
|
+
"_hasContent",
|
|
1324
|
+
).mockReturnValue(true);
|
|
1325
|
+
|
|
1326
|
+
await openTranslationOf(el, {
|
|
1327
|
+
id: "pst_source",
|
|
1328
|
+
format: "quote",
|
|
1329
|
+
displayTitle: "关于专注",
|
|
1330
|
+
slug: "focus",
|
|
1331
|
+
sourceUrl: "https://example.com/focus",
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1334
|
+
const editor = requireElement(
|
|
1335
|
+
el.querySelector<JantComposeEditor>("jant-compose-editor"),
|
|
1336
|
+
"expected the editor",
|
|
1337
|
+
);
|
|
1338
|
+
expect(editor.format).toBe("note");
|
|
1339
|
+
expect(editor.getData().url).toBe("");
|
|
1340
|
+
// The context still shows — it is the one thing that never fights the
|
|
1341
|
+
// author for the page.
|
|
1342
|
+
expect(el.querySelector(".compose-translation-context")).not.toBeNull();
|
|
1343
|
+
});
|
|
1344
|
+
|
|
1345
|
+
it("sends the chosen language, and the page's language on Detect", async () => {
|
|
1346
|
+
const el = await createElement();
|
|
1347
|
+
el.languages = [
|
|
1348
|
+
{ tag: "zh-Hans", label: "简体中文" },
|
|
1349
|
+
{ tag: "en", label: "English" },
|
|
1350
|
+
];
|
|
1351
|
+
el.contextLanguage = "en";
|
|
1352
|
+
await flushUpdates(el);
|
|
1353
|
+
await openLanguagePicker(el);
|
|
1354
|
+
|
|
1355
|
+
const build = (
|
|
1356
|
+
el as unknown as {
|
|
1357
|
+
_buildSubmitDetail: (
|
|
1358
|
+
status: "published" | "draft",
|
|
1359
|
+
) => ComposeSubmitDetail | null;
|
|
1360
|
+
}
|
|
1361
|
+
)._buildSubmitDetail.bind(el);
|
|
1362
|
+
|
|
1363
|
+
// Left on Detect with no text signal: the post belongs to the page it
|
|
1364
|
+
// was written from, so the page's language goes out explicitly.
|
|
1365
|
+
expect(build("published")?.language).toBe("en");
|
|
1366
|
+
|
|
1367
|
+
const rows = Array.from(
|
|
1368
|
+
el.querySelectorAll<HTMLButtonElement>(
|
|
1369
|
+
".compose-language-popover .compose-sheet-row",
|
|
1370
|
+
),
|
|
1371
|
+
);
|
|
1372
|
+
requireElement(rows[1] ?? null, "expected the 简体中文 row").click();
|
|
1373
|
+
await flushUpdates(el);
|
|
1374
|
+
|
|
1375
|
+
expect(build("published")?.language).toBe("zh-Hans");
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
it("keeps a published post's own language when editing it", async () => {
|
|
1379
|
+
// A post that is already filed in Japanese must not be re-read from the
|
|
1380
|
+
// English page it is being edited on — the language is a fact about the
|
|
1381
|
+
// post, not a guess to redo.
|
|
1382
|
+
mockEditPost({
|
|
1383
|
+
format: "note",
|
|
1384
|
+
title: "Hi",
|
|
1385
|
+
body: null,
|
|
1386
|
+
language: "ja",
|
|
1387
|
+
});
|
|
1388
|
+
|
|
1389
|
+
const el = await createElement();
|
|
1390
|
+
el.languages = [
|
|
1391
|
+
{ tag: "en", label: "English" },
|
|
1392
|
+
{ tag: "ja", label: "日本語" },
|
|
1393
|
+
];
|
|
1394
|
+
el.contextLanguage = "en";
|
|
1395
|
+
await flushUpdates(el);
|
|
1396
|
+
await el.openEdit("pst_123");
|
|
1397
|
+
await flushUpdates(el);
|
|
1398
|
+
|
|
1399
|
+
const trigger = requireElement(
|
|
1400
|
+
languageTrigger(el),
|
|
1401
|
+
"expected language pill",
|
|
1402
|
+
);
|
|
1403
|
+
expect(trigger.textContent?.trim()).toBe("日本語");
|
|
1404
|
+
expect(trigger.dataset.chosen).toBe("true");
|
|
1405
|
+
|
|
1406
|
+
const build = (
|
|
1407
|
+
el as unknown as {
|
|
1408
|
+
_buildSubmitDetail: (
|
|
1409
|
+
status: "published" | "draft",
|
|
1410
|
+
) => ComposeSubmitDetail | null;
|
|
1411
|
+
}
|
|
1412
|
+
)._buildSubmitDetail.bind(el);
|
|
1413
|
+
expect(build("published")?.language).toBe("ja");
|
|
1414
|
+
});
|
|
1415
|
+
|
|
1416
|
+
it("falls back to the primary language when the page has none", async () => {
|
|
1417
|
+
const el = await createElement();
|
|
1418
|
+
el.languages = [
|
|
1419
|
+
{ tag: "zh-Hans", label: "简体中文" },
|
|
1420
|
+
{ tag: "en", label: "English" },
|
|
1421
|
+
];
|
|
1422
|
+
await flushUpdates(el);
|
|
1423
|
+
|
|
1424
|
+
const build = (
|
|
1425
|
+
el as unknown as {
|
|
1426
|
+
_buildSubmitDetail: (
|
|
1427
|
+
status: "published" | "draft",
|
|
1428
|
+
) => ComposeSubmitDetail | null;
|
|
1429
|
+
}
|
|
1430
|
+
)._buildSubmitDetail.bind(el);
|
|
1431
|
+
|
|
1432
|
+
expect(build("published")?.language).toBe("zh-Hans");
|
|
1433
|
+
});
|
|
1434
|
+
|
|
1435
|
+
describe("publishing on automatic", () => {
|
|
1436
|
+
/** A composer whose text reads as a different language than the page. */
|
|
1437
|
+
async function createMismatch(overrides?: {
|
|
1438
|
+
contextLanguage?: string;
|
|
1439
|
+
text?: string;
|
|
1440
|
+
}) {
|
|
1441
|
+
const el = await createElement();
|
|
1442
|
+
el.languages = [
|
|
1443
|
+
{ tag: "zh-Hans", label: "简体中文" },
|
|
1444
|
+
{ tag: "ja", label: "日本語" },
|
|
1445
|
+
];
|
|
1446
|
+
el.contextLanguage = overrides?.contextLanguage ?? "zh-Hans";
|
|
1447
|
+
await flushUpdates(el);
|
|
1448
|
+
|
|
1449
|
+
const editor = requireElement(
|
|
1450
|
+
el.querySelector<JantComposeEditor>("jant-compose-editor"),
|
|
1451
|
+
"expected compose editor",
|
|
1452
|
+
);
|
|
1453
|
+
editor._bodyJson = {
|
|
1454
|
+
type: "doc",
|
|
1455
|
+
content: [
|
|
1456
|
+
{
|
|
1457
|
+
type: "paragraph",
|
|
1458
|
+
content: [
|
|
1459
|
+
{ type: "text", text: overrides?.text ?? "これはテストです" },
|
|
1460
|
+
],
|
|
1461
|
+
},
|
|
1462
|
+
],
|
|
1463
|
+
};
|
|
1464
|
+
await editor.updateComplete;
|
|
1465
|
+
await flushUpdates(el);
|
|
1466
|
+
|
|
1467
|
+
const details: ComposeSubmitDetail[] = [];
|
|
1468
|
+
el.addEventListener("jant:compose-submit-deferred", (event) => {
|
|
1469
|
+
details.push((event as CustomEvent<ComposeSubmitDetail>).detail);
|
|
1470
|
+
});
|
|
1471
|
+
return { el, details };
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
function clickPublish(el: JantComposeDialog) {
|
|
1475
|
+
requireElement(
|
|
1476
|
+
el.querySelector<HTMLButtonElement>(".compose-publish-main"),
|
|
1477
|
+
"expected post button",
|
|
1478
|
+
).click();
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
it("publishes what the pill says, without stopping to ask", async () => {
|
|
1482
|
+
// The pill grew the word 日本語 the moment detection moved off the
|
|
1483
|
+
// page's language — the same moment a sheet used to interrupt. There
|
|
1484
|
+
// is nothing left for one to add.
|
|
1485
|
+
const { el, details } = await createMismatch();
|
|
1486
|
+
expect(
|
|
1487
|
+
el.querySelector(".compose-language-trigger")?.textContent?.trim(),
|
|
1488
|
+
).toBe("日本語");
|
|
1489
|
+
|
|
1490
|
+
clickPublish(el);
|
|
1491
|
+
await flushUpdates(el);
|
|
1492
|
+
|
|
1493
|
+
expect(details).toHaveLength(1);
|
|
1494
|
+
expect(details[0]?.language).toBe("ja");
|
|
1495
|
+
});
|
|
1496
|
+
|
|
1497
|
+
it("publishes in the page's language when detection agrees", async () => {
|
|
1498
|
+
const { el, details } = await createMismatch({ contextLanguage: "ja" });
|
|
1499
|
+
|
|
1500
|
+
clickPublish(el);
|
|
1501
|
+
await flushUpdates(el);
|
|
1502
|
+
|
|
1503
|
+
expect(details).toHaveLength(1);
|
|
1504
|
+
expect(details[0]?.language).toBe("ja");
|
|
1505
|
+
});
|
|
1506
|
+
|
|
1507
|
+
it("never second-guesses an explicit choice", async () => {
|
|
1508
|
+
const { el, details } = await createMismatch();
|
|
1509
|
+
// The author picked 简体中文 themselves; the Japanese text is theirs
|
|
1510
|
+
// to own — a quote, perhaps.
|
|
1511
|
+
(el as unknown as { _language: string | null })._language = "zh-Hans";
|
|
1512
|
+
await flushUpdates(el);
|
|
1513
|
+
|
|
1514
|
+
clickPublish(el);
|
|
1515
|
+
await flushUpdates(el);
|
|
1516
|
+
|
|
1517
|
+
expect(details).toHaveLength(1);
|
|
1518
|
+
expect(details[0]?.language).toBe("zh-Hans");
|
|
1519
|
+
});
|
|
1520
|
+
});
|
|
1521
|
+
});
|
|
1522
|
+
|
|
655
1523
|
it("keeps date and permalink on the post, not in the publish panel", async () => {
|
|
656
1524
|
const el = await createElement();
|
|
657
1525
|
|
|
@@ -727,7 +1595,7 @@ describe("JantComposeDialog", () => {
|
|
|
727
1595
|
|
|
728
1596
|
// Inline coordinates only mean anything if the panel is out of the
|
|
729
1597
|
// scroller — `position: absolute` here is the bug, not a style choice.
|
|
730
|
-
const css =
|
|
1598
|
+
const css = readComponentCss();
|
|
731
1599
|
expect(css).toMatch(
|
|
732
1600
|
/\.compose-post-meta-panel\s*\{\s*position:\s*fixed;[\s\S]*max-height:\s*var\(--compose-post-meta-panel-max-height,\s*none\);\s*overflow-y:\s*auto;/,
|
|
733
1601
|
);
|
|
@@ -1645,8 +2513,7 @@ describe("JantComposeDialog", () => {
|
|
|
1645
2513
|
await editor.updateComplete;
|
|
1646
2514
|
|
|
1647
2515
|
let receivedDetail:
|
|
1648
|
-
|
|
1649
|
-
| null = null;
|
|
2516
|
+
(ComposeSubmitDetail & { pendingAttachments: unknown[] }) | null = null;
|
|
1650
2517
|
el.addEventListener("jant:compose-submit-deferred", (event) => {
|
|
1651
2518
|
const customEvent = event as CustomEvent<
|
|
1652
2519
|
ComposeSubmitDetail & { pendingAttachments: unknown[] }
|
|
@@ -3296,8 +4163,7 @@ describe("JantComposeDialog", () => {
|
|
|
3296
4163
|
await el.updateComplete;
|
|
3297
4164
|
|
|
3298
4165
|
let receivedDetail:
|
|
3299
|
-
|
|
3300
|
-
| null = null;
|
|
4166
|
+
(ComposeSubmitDetail & { pendingAttachments: unknown[] }) | null = null;
|
|
3301
4167
|
el.addEventListener("jant:compose-submit-deferred", (event) => {
|
|
3302
4168
|
const customEvent = event as CustomEvent<
|
|
3303
4169
|
ComposeSubmitDetail & { pendingAttachments: unknown[] }
|
|
@@ -3531,6 +4397,236 @@ describe("JantComposeDialog", () => {
|
|
|
3531
4397
|
expect(scroller.scrollTop).toBe(640);
|
|
3532
4398
|
});
|
|
3533
4399
|
|
|
4400
|
+
describe("thread mode keeps what each post holds", () => {
|
|
4401
|
+
type ThreadInternals = {
|
|
4402
|
+
_addThreadItem: () => void;
|
|
4403
|
+
_removeThreadItem: (index: number) => void;
|
|
4404
|
+
_checkSlugAvailability: (slug: string, index?: number) => Promise<void>;
|
|
4405
|
+
_slug: string;
|
|
4406
|
+
_publishedAtInput: string;
|
|
4407
|
+
_publishedAtTimeMinutes: number | null;
|
|
4408
|
+
};
|
|
4409
|
+
|
|
4410
|
+
const internals = (el: JantComposeDialog) =>
|
|
4411
|
+
el as unknown as ThreadInternals;
|
|
4412
|
+
|
|
4413
|
+
const editorsOf = (el: JantComposeDialog) =>
|
|
4414
|
+
Array.from(el.querySelectorAll<JantComposeEditor>("jant-compose-editor"));
|
|
4415
|
+
|
|
4416
|
+
const paragraph = (text: string) => ({
|
|
4417
|
+
type: "doc",
|
|
4418
|
+
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
|
|
4419
|
+
});
|
|
4420
|
+
|
|
4421
|
+
function submit(el: JantComposeDialog): ComposeSubmitDetail {
|
|
4422
|
+
let detail: ComposeSubmitDetail | null = null;
|
|
4423
|
+
el.addEventListener(
|
|
4424
|
+
"jant:compose-submit-deferred",
|
|
4425
|
+
(event) => {
|
|
4426
|
+
detail = (event as CustomEvent<ComposeSubmitDetail>).detail;
|
|
4427
|
+
},
|
|
4428
|
+
{ once: true },
|
|
4429
|
+
);
|
|
4430
|
+
requireElement(
|
|
4431
|
+
el.querySelector<HTMLButtonElement>(".compose-publish-main"),
|
|
4432
|
+
"expected publish button",
|
|
4433
|
+
).click();
|
|
4434
|
+
if (!detail) throw new Error("expected a submit");
|
|
4435
|
+
return detail;
|
|
4436
|
+
}
|
|
4437
|
+
|
|
4438
|
+
it("keeps a quote's source link on the way into a thread and back out", async () => {
|
|
4439
|
+
const el = await createElement();
|
|
4440
|
+
el._format = "quote";
|
|
4441
|
+
await flushUpdates(el);
|
|
4442
|
+
const single = editorsOf(el)[0]!;
|
|
4443
|
+
single._quoteText = "A borrowed sentence.";
|
|
4444
|
+
single._quoteAuthor = "Someone Else";
|
|
4445
|
+
single._url = "https://example.com/source";
|
|
4446
|
+
await flushUpdates(el);
|
|
4447
|
+
|
|
4448
|
+
internals(el)._addThreadItem();
|
|
4449
|
+
await flushUpdates(el);
|
|
4450
|
+
const root = editorsOf(el)[0]!;
|
|
4451
|
+
expect(root.getData()).toMatchObject({
|
|
4452
|
+
url: "https://example.com/source",
|
|
4453
|
+
quoteText: "A borrowed sentence.",
|
|
4454
|
+
quoteAuthor: "Someone Else",
|
|
4455
|
+
});
|
|
4456
|
+
|
|
4457
|
+
internals(el)._removeThreadItem(1);
|
|
4458
|
+
await flushUpdates(el);
|
|
4459
|
+
expect(el._threadItems).toHaveLength(0);
|
|
4460
|
+
expect(editorsOf(el)[0]!.getData().url).toBe(
|
|
4461
|
+
"https://example.com/source",
|
|
4462
|
+
);
|
|
4463
|
+
expect(submit(el).url).toBe("https://example.com/source");
|
|
4464
|
+
});
|
|
4465
|
+
|
|
4466
|
+
it("publishes an attached text that has no media beside it after entering a thread", async () => {
|
|
4467
|
+
// The strip and the submit both walk the attachment order, which was
|
|
4468
|
+
// only carried across when the post also had media.
|
|
4469
|
+
const el = await createElement();
|
|
4470
|
+
const single = editorsOf(el)[0]!;
|
|
4471
|
+
single._bodyJson = paragraph("See the notes below.");
|
|
4472
|
+
single._attachedTexts = [
|
|
4473
|
+
{
|
|
4474
|
+
clientId: "text-1",
|
|
4475
|
+
bodyJson: paragraph("The notes."),
|
|
4476
|
+
bodyHtml: "",
|
|
4477
|
+
summary: "The notes.",
|
|
4478
|
+
},
|
|
4479
|
+
];
|
|
4480
|
+
single._attachmentOrder = ["text-1"];
|
|
4481
|
+
await flushUpdates(el);
|
|
4482
|
+
|
|
4483
|
+
internals(el)._addThreadItem();
|
|
4484
|
+
await flushUpdates(el);
|
|
4485
|
+
const [root, next] = editorsOf(el);
|
|
4486
|
+
expect(root!.querySelectorAll("[data-attachment-list] > *")).toHaveLength(
|
|
4487
|
+
1,
|
|
4488
|
+
);
|
|
4489
|
+
next!._bodyJson = paragraph("More.");
|
|
4490
|
+
await flushUpdates(el);
|
|
4491
|
+
|
|
4492
|
+
const detail = submit(el);
|
|
4493
|
+
expect(detail.threadPosts?.[0]?.attachments).toEqual([
|
|
4494
|
+
expect.objectContaining({ type: "text", clientId: "text-1" }),
|
|
4495
|
+
]);
|
|
4496
|
+
});
|
|
4497
|
+
|
|
4498
|
+
it("moves the next post's own permalink and date up when the first post is removed", async () => {
|
|
4499
|
+
// The root's permalink and date live on the dialog, every other post's
|
|
4500
|
+
// on its thread item. Removing the root used to leave its values on the
|
|
4501
|
+
// post that took its place, and drop that post's own.
|
|
4502
|
+
const el = await createElement();
|
|
4503
|
+
el._threadItems = [
|
|
4504
|
+
{ id: "a", format: "note" },
|
|
4505
|
+
{
|
|
4506
|
+
id: "b",
|
|
4507
|
+
format: "note",
|
|
4508
|
+
slug: "part-two",
|
|
4509
|
+
publishedAtInput: "2025-01-02",
|
|
4510
|
+
publishedAtTimeMinutes: 600,
|
|
4511
|
+
},
|
|
4512
|
+
{ id: "c", format: "note" },
|
|
4513
|
+
];
|
|
4514
|
+
internals(el)._slug = "part-one";
|
|
4515
|
+
internals(el)._publishedAtInput = "2025-01-01";
|
|
4516
|
+
internals(el)._publishedAtTimeMinutes = 480;
|
|
4517
|
+
await flushUpdates(el);
|
|
4518
|
+
editorsOf(el).forEach((editor, i) => {
|
|
4519
|
+
editor._bodyJson = paragraph(`Post ${i + 1}`);
|
|
4520
|
+
});
|
|
4521
|
+
await flushUpdates(el);
|
|
4522
|
+
|
|
4523
|
+
internals(el)._removeThreadItem(0);
|
|
4524
|
+
await flushUpdates(el);
|
|
4525
|
+
|
|
4526
|
+
expect(internals(el)._slug).toBe("part-two");
|
|
4527
|
+
expect(internals(el)._publishedAtInput).toBe("2025-01-02");
|
|
4528
|
+
expect(internals(el)._publishedAtTimeMinutes).toBe(600);
|
|
4529
|
+
const detail = submit(el);
|
|
4530
|
+
expect(detail.threadPosts?.map((post) => post.slug)).toEqual([
|
|
4531
|
+
"part-two",
|
|
4532
|
+
undefined,
|
|
4533
|
+
]);
|
|
4534
|
+
});
|
|
4535
|
+
|
|
4536
|
+
function mockThreadDraft(requests: string[]) {
|
|
4537
|
+
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
|
|
4538
|
+
const url = String(input);
|
|
4539
|
+
requests.push(url);
|
|
4540
|
+
const json = (body: unknown) =>
|
|
4541
|
+
new Response(JSON.stringify(body), {
|
|
4542
|
+
headers: { "Content-Type": "application/json" },
|
|
4543
|
+
});
|
|
4544
|
+
if (url === "/api/posts/pst_draft_root") {
|
|
4545
|
+
return json({
|
|
4546
|
+
id: "pst_draft_root",
|
|
4547
|
+
threadId: "pst_draft_root",
|
|
4548
|
+
format: "note",
|
|
4549
|
+
status: "draft",
|
|
4550
|
+
slug: "draft-thread",
|
|
4551
|
+
title: "Draft thread",
|
|
4552
|
+
body: null,
|
|
4553
|
+
attachments: [],
|
|
4554
|
+
collectionIds: [],
|
|
4555
|
+
});
|
|
4556
|
+
}
|
|
4557
|
+
if (url === "/api/posts?status=draft&limit=50") {
|
|
4558
|
+
return json({
|
|
4559
|
+
posts: [
|
|
4560
|
+
{
|
|
4561
|
+
id: "pst_draft_reply",
|
|
4562
|
+
threadId: "pst_draft_root",
|
|
4563
|
+
replyToId: "pst_draft_root",
|
|
4564
|
+
format: "quote",
|
|
4565
|
+
status: "draft",
|
|
4566
|
+
slug: "part-two",
|
|
4567
|
+
quoteText: "Draft reply",
|
|
4568
|
+
sourceName: "Someone Else",
|
|
4569
|
+
attachments: [],
|
|
4570
|
+
},
|
|
4571
|
+
],
|
|
4572
|
+
});
|
|
4573
|
+
}
|
|
4574
|
+
if (url.startsWith("/api/posts/slug?")) {
|
|
4575
|
+
return json({ available: true });
|
|
4576
|
+
}
|
|
4577
|
+
throw new Error(`Unexpected fetch: ${url}`);
|
|
4578
|
+
});
|
|
4579
|
+
}
|
|
4580
|
+
|
|
4581
|
+
it("keeps each reply's permalink when a saved thread draft is reopened", async () => {
|
|
4582
|
+
// Saving a thread draft recreates it, so a slug not loaded back into
|
|
4583
|
+
// the composer came back as a random id on the next save.
|
|
4584
|
+
const requests: string[] = [];
|
|
4585
|
+
mockThreadDraft(requests);
|
|
4586
|
+
const el = await createElement();
|
|
4587
|
+
await el.openDraft("pst_draft_root");
|
|
4588
|
+
await flushUpdates(el);
|
|
4589
|
+
|
|
4590
|
+
expect(el._threadItems[1]?.slug).toBe("part-two");
|
|
4591
|
+
|
|
4592
|
+
// A reply's slug already belongs to the saved reply, not to the root.
|
|
4593
|
+
await internals(el)._checkSlugAvailability("part-two", 1);
|
|
4594
|
+
await internals(el)._checkSlugAvailability("draft-thread", 0);
|
|
4595
|
+
const checks = requests
|
|
4596
|
+
.filter((url) => url.startsWith("/api/posts/slug?"))
|
|
4597
|
+
.map((url) => new URL(url, "http://x").searchParams.get("postId"));
|
|
4598
|
+
expect(checks).toEqual(["pst_draft_reply", "pst_draft_root"]);
|
|
4599
|
+
|
|
4600
|
+
expect(submit(el).threadPosts?.map((post) => post.slug)).toEqual([
|
|
4601
|
+
"draft-thread",
|
|
4602
|
+
"part-two",
|
|
4603
|
+
]);
|
|
4604
|
+
});
|
|
4605
|
+
|
|
4606
|
+
it("folds a thread post's quote into its body when its format changes on a saved draft", async () => {
|
|
4607
|
+
// Single-post drafts already did this; a thread post kept the quote in
|
|
4608
|
+
// a hidden field, which the next save then dropped.
|
|
4609
|
+
mockThreadDraft([]);
|
|
4610
|
+
const el = await createElement();
|
|
4611
|
+
await el.openDraft("pst_draft_root");
|
|
4612
|
+
await flushUpdates(el);
|
|
4613
|
+
|
|
4614
|
+
const reply = editorsOf(el)[1]!;
|
|
4615
|
+
const noteButton = requireElement(
|
|
4616
|
+
Array.from(
|
|
4617
|
+
reply.querySelectorAll<HTMLButtonElement>(".compose-segmented-item"),
|
|
4618
|
+
).find((button) => button.textContent?.trim() === labels.note) ?? null,
|
|
4619
|
+
"expected the reply's Note button",
|
|
4620
|
+
);
|
|
4621
|
+
noteButton.click();
|
|
4622
|
+
await flushUpdates(el);
|
|
4623
|
+
|
|
4624
|
+
expect(el._threadItems[1]?.format).toBe("note");
|
|
4625
|
+
expect(reply.getData().body).toContain("Draft reply");
|
|
4626
|
+
expect(submit(el).threadPosts?.[1]?.body).toContain("Draft reply");
|
|
4627
|
+
});
|
|
4628
|
+
});
|
|
4629
|
+
|
|
3534
4630
|
it("omits visibility from locked edit submissions", async () => {
|
|
3535
4631
|
const el = await createElement();
|
|
3536
4632
|
const editor = requireElement(
|
|
@@ -4383,7 +5479,7 @@ describe("JantComposeDialog", () => {
|
|
|
4383
5479
|
});
|
|
4384
5480
|
|
|
4385
5481
|
it("scrolls in exactly one place across every compose mode", async () => {
|
|
4386
|
-
const css =
|
|
5482
|
+
const css = readComponentCss();
|
|
4387
5483
|
|
|
4388
5484
|
// `.compose-scroll` owns the scrolling for single-post, reply and thread
|
|
4389
5485
|
// alike. Anything nested inside it that scrolls on its own is the bug this
|
|
@@ -4410,7 +5506,7 @@ describe("JantComposeDialog", () => {
|
|
|
4410
5506
|
});
|
|
4411
5507
|
|
|
4412
5508
|
it("lets attachment previews take the room the pinned toolbar used to need", () => {
|
|
4413
|
-
const css =
|
|
5509
|
+
const css = readComponentCss();
|
|
4414
5510
|
|
|
4415
5511
|
expect(css).toMatch(
|
|
4416
5512
|
/\.compose-attachment-img\s*\{[\s\S]*max-height:\s*min\(420px,\s*46dvh\);/,
|
|
@@ -4418,14 +5514,37 @@ describe("JantComposeDialog", () => {
|
|
|
4418
5514
|
expect(css).toMatch(
|
|
4419
5515
|
/\.compose-attachment:only-child\s+\.compose-attachment-img\s*\{[\s\S]*max-height:\s*min\(460px,\s*52dvh\);/,
|
|
4420
5516
|
);
|
|
5517
|
+
// The strip's row height is one variable every kind of attachment reads,
|
|
5518
|
+
// so the number is asserted where it is declared.
|
|
5519
|
+
expect(css).toMatch(
|
|
5520
|
+
/\.compose-attachments\s*\{[\s\S]*?--compose-strip-height:\s*min\(340px,\s*40dvh\);/,
|
|
5521
|
+
);
|
|
4421
5522
|
expect(css).toMatch(
|
|
4422
|
-
/\.compose-attachment:not\(:only-child\)\s+\.compose-attachment-img\s*\{[\s\S]*height:\s*
|
|
5523
|
+
/\.compose-attachment:not\(:only-child\)\s+\.compose-attachment-img\s*\{[\s\S]*height:\s*var\(--compose-strip-height\);/,
|
|
4423
5524
|
);
|
|
4424
5525
|
// Reply compose no longer shrinks its previews to 96×72 to spare the
|
|
4425
5526
|
// toolbar — one preview size everywhere.
|
|
4426
5527
|
expect(css).not.toMatch(/--compose-reply-attachment-width/);
|
|
4427
5528
|
});
|
|
4428
5529
|
|
|
5530
|
+
it("gives a card in the strip the same height as the pictures beside it", () => {
|
|
5531
|
+
const css = readComponentCss();
|
|
5532
|
+
|
|
5533
|
+
// A card is chrome, not content: it fills the height a picture sets, so a
|
|
5534
|
+
// mixed strip reads as one row and every control row lands on one line.
|
|
5535
|
+
expect(css).toMatch(
|
|
5536
|
+
/\.compose-attachments:has\(\.compose-attachment-img\)\s+\.compose-attachment:not\(:only-child\)\s+:is\(\s*\.compose-attachment-file-card,\s*\.compose-attachment-text-card,\s*\.compose-attachment-preview-fallback\s*\)\s*\{\s*height:\s*var\(--compose-strip-height\);/,
|
|
5537
|
+
);
|
|
5538
|
+
// With no picture to match, cards keep their own compact height rather
|
|
5539
|
+
// than growing into a row of empty boxes.
|
|
5540
|
+
expect(css).toMatch(
|
|
5541
|
+
/\.compose-attachment:not\(:only-child\)\s+\.compose-attachment-file-card\s*\{[\s\S]*?height:\s*200px;/,
|
|
5542
|
+
);
|
|
5543
|
+
expect(css).toMatch(
|
|
5544
|
+
/\.compose-attachment:not\(:only-child\)\s+\.compose-attachment-text-card\s*\{[\s\S]*?height:\s*200px;/,
|
|
5545
|
+
);
|
|
5546
|
+
});
|
|
5547
|
+
|
|
4429
5548
|
it("routes a format change straight off the single-post editor", async () => {
|
|
4430
5549
|
const el = await createElement();
|
|
4431
5550
|
const editor = requireElement(
|
|
@@ -4445,7 +5564,7 @@ describe("JantComposeDialog", () => {
|
|
|
4445
5564
|
});
|
|
4446
5565
|
|
|
4447
5566
|
it("shows the replied-to post at the same scale as the reply", () => {
|
|
4448
|
-
const css =
|
|
5567
|
+
const css = readComponentCss();
|
|
4449
5568
|
|
|
4450
5569
|
// Same ceiling as the composer's own previews — the parent is not a
|
|
4451
5570
|
// thumbnail strip.
|
|
@@ -4470,7 +5589,7 @@ describe("JantComposeDialog", () => {
|
|
|
4470
5589
|
});
|
|
4471
5590
|
|
|
4472
5591
|
it("keeps reply compose tools inside the editor's text column", () => {
|
|
4473
|
-
const css =
|
|
5592
|
+
const css = readComponentCss();
|
|
4474
5593
|
|
|
4475
5594
|
// `flex: 1` on the editor is horizontal — it claims the width the rail dot
|
|
4476
5595
|
// leaves. Height comes from content.
|
|
@@ -4481,7 +5600,7 @@ describe("JantComposeDialog", () => {
|
|
|
4481
5600
|
/\.compose-reply-compose-layout\s+\.compose-thread-post-header\s*\+\s*\.compose-body\s*\{[\s\S]*padding-top:\s*12px;/,
|
|
4482
5601
|
);
|
|
4483
5602
|
expect(css).toMatch(
|
|
4484
|
-
/@media \(max-width:\s*
|
|
5603
|
+
/@media \(max-width:\s*699px\),\s*\(hover:\s*none\) and \(pointer:\s*coarse\)\s*\{[\s\S]*\.compose-thread-layout\s*\{[\s\S]*padding-top:\s*0\.75rem;/,
|
|
4485
5604
|
);
|
|
4486
5605
|
expect(css).toMatch(
|
|
4487
5606
|
/\.compose-dialog,[\s\S]*\.compose-page-shell\s*>\s*jant-compose-dialog\s*\{[\s\S]*--compose-quote-input-size:\s*var\(--type-content-subtitle\);[\s\S]*--compose-quote-input-leading:\s*1\.32;/,
|
|
@@ -4521,7 +5640,7 @@ describe("JantComposeDialog", () => {
|
|
|
4521
5640
|
});
|
|
4522
5641
|
|
|
4523
5642
|
it("opens every compose text surface at a floor stated in lines", () => {
|
|
4524
|
-
const css =
|
|
5643
|
+
const css = readComponentCss();
|
|
4525
5644
|
|
|
4526
5645
|
// Two lines for the editor, four for the quote — as multiples of the
|
|
4527
5646
|
// leading, not pixels, so the floor follows the type instead of going
|
|
@@ -4539,7 +5658,7 @@ describe("JantComposeDialog", () => {
|
|
|
4539
5658
|
});
|
|
4540
5659
|
|
|
4541
5660
|
it("runs the thread rail from the first dot to the last post's, and no further", () => {
|
|
4542
|
-
const css =
|
|
5661
|
+
const css = readComponentCss();
|
|
4543
5662
|
|
|
4544
5663
|
// Drawn per row rather than as one strip down the layout, which is what
|
|
4545
5664
|
// lets both ends be trimmed back to a dot.
|
|
@@ -4566,7 +5685,7 @@ describe("JantComposeDialog", () => {
|
|
|
4566
5685
|
});
|
|
4567
5686
|
|
|
4568
5687
|
it("starts every compose row's ink on one text column", () => {
|
|
4569
|
-
const css =
|
|
5688
|
+
const css = readComponentCss();
|
|
4570
5689
|
|
|
4571
5690
|
// One knob for the column itself, read by every row rather than restated
|
|
4572
5691
|
// as a literal per row.
|
|
@@ -4616,7 +5735,7 @@ describe("JantComposeDialog", () => {
|
|
|
4616
5735
|
});
|
|
4617
5736
|
|
|
4618
5737
|
it("declares one marker vocabulary for the compose and reading rails", () => {
|
|
4619
|
-
const css =
|
|
5738
|
+
const css = readComponentCss();
|
|
4620
5739
|
|
|
4621
5740
|
// Both rails draw the same dot; only the surface behind it differs.
|
|
4622
5741
|
// `[^}]*` rather than `[\s\S]*` throughout: a greedy any-char run walks past
|
|
@@ -4674,7 +5793,7 @@ describe("JantComposeDialog", () => {
|
|
|
4674
5793
|
});
|
|
4675
5794
|
|
|
4676
5795
|
it("punches the dot's gap with the surface colour on both rails", () => {
|
|
4677
|
-
const css =
|
|
5796
|
+
const css = readComponentCss();
|
|
4678
5797
|
|
|
4679
5798
|
// Compose stops at the punch-out; reading layers its ring inside the same
|
|
4680
5799
|
// radius, so the outermost layer — the one that breaks the rail line — is
|
|
@@ -4698,7 +5817,7 @@ describe("JantComposeDialog", () => {
|
|
|
4698
5817
|
});
|
|
4699
5818
|
|
|
4700
5819
|
it("marks the post a view is about with an accent fill, not a bigger dot", () => {
|
|
4701
|
-
const css =
|
|
5820
|
+
const css = readComponentCss();
|
|
4702
5821
|
|
|
4703
5822
|
// Feed hero, curated highlight and the opened detail post share one rule,
|
|
4704
5823
|
// matching compose's `.is-current`. Size is not a second signal.
|
|
@@ -4724,7 +5843,7 @@ describe("JantComposeDialog", () => {
|
|
|
4724
5843
|
});
|
|
4725
5844
|
|
|
4726
5845
|
it("centres every rail child on the line by giving them a shared track", () => {
|
|
4727
|
-
const css =
|
|
5846
|
+
const css = readComponentCss();
|
|
4728
5847
|
|
|
4729
5848
|
// The line's x is derived from the track, never from a dot's own size — a
|
|
4730
5849
|
// hardcoded half-marker is what put the line down the dot's edge before.
|
|
@@ -4749,7 +5868,7 @@ describe("JantComposeDialog", () => {
|
|
|
4749
5868
|
});
|
|
4750
5869
|
|
|
4751
5870
|
it("accents the dot of the post holding the cursor, not every editor row", () => {
|
|
4752
|
-
const css =
|
|
5871
|
+
const css = readComponentCss();
|
|
4753
5872
|
|
|
4754
5873
|
// Which selector carries the highlight — the colour itself is pinned by
|
|
4755
5874
|
// "marks the post a view is about…", so this stays token-agnostic.
|
|
@@ -4794,7 +5913,7 @@ describe("JantComposeDialog", () => {
|
|
|
4794
5913
|
});
|
|
4795
5914
|
|
|
4796
5915
|
it("keeps passive footnote references quiet until the editor selects them", () => {
|
|
4797
|
-
const css =
|
|
5916
|
+
const css = readComponentCss();
|
|
4798
5917
|
|
|
4799
5918
|
expect(css).toMatch(
|
|
4800
5919
|
/\.compose-tiptap-body \.tiptap \.tiptap-footnote-reference\s*\{[^}]*color:\s*var\(--site-footnote-marker\);/,
|
|
@@ -4943,8 +6062,7 @@ describe("JantComposeDialog", () => {
|
|
|
4943
6062
|
await editor.updateComplete;
|
|
4944
6063
|
|
|
4945
6064
|
let receivedDetail:
|
|
4946
|
-
|
|
4947
|
-
| null = null;
|
|
6065
|
+
(ComposeSubmitDetail & { pendingAttachments: unknown[] }) | null = null;
|
|
4948
6066
|
el.addEventListener("jant:compose-submit-deferred", (event) => {
|
|
4949
6067
|
const customEvent = event as CustomEvent<
|
|
4950
6068
|
ComposeSubmitDetail & { pendingAttachments: unknown[] }
|
|
@@ -5106,7 +6224,7 @@ describe("JantComposeDialog", () => {
|
|
|
5106
6224
|
),
|
|
5107
6225
|
"expected close button in the post header",
|
|
5108
6226
|
);
|
|
5109
|
-
expect(closeBtn.getAttribute("aria-label")).toBe(labels.
|
|
6227
|
+
expect(closeBtn.getAttribute("aria-label")).toBe(labels.close);
|
|
5110
6228
|
|
|
5111
6229
|
closeBtn.click();
|
|
5112
6230
|
await flushUpdates(el);
|
|
@@ -5114,7 +6232,7 @@ describe("JantComposeDialog", () => {
|
|
|
5114
6232
|
expect(requestCloseSpy).toHaveBeenCalledTimes(1);
|
|
5115
6233
|
});
|
|
5116
6234
|
|
|
5117
|
-
it("hands the header slot back to per-post remove in a thread, and moves the exit
|
|
6235
|
+
it("hands the header slot back to per-post remove in a thread, and moves the exit to the quick actions row", async () => {
|
|
5118
6236
|
const el = await createElement();
|
|
5119
6237
|
el._threadItems = [
|
|
5120
6238
|
{ id: "thread-1", format: "note" },
|
|
@@ -5128,42 +6246,61 @@ describe("JantComposeDialog", () => {
|
|
|
5128
6246
|
el.querySelectorAll(".compose-thread-post-remove").length,
|
|
5129
6247
|
).toBeGreaterThan(0);
|
|
5130
6248
|
|
|
5131
|
-
|
|
5132
|
-
|
|
5133
|
-
|
|
5134
|
-
|
|
5135
|
-
|
|
5136
|
-
),
|
|
5137
|
-
).find(
|
|
5138
|
-
(row) =>
|
|
5139
|
-
row.querySelector(".compose-sheet-title")?.textContent?.trim() ===
|
|
5140
|
-
labels.closeCompose,
|
|
5141
|
-
) ?? null,
|
|
5142
|
-
"expected a close row in the options panel",
|
|
6249
|
+
const closeBtn = requireElement(
|
|
6250
|
+
el.querySelector<HTMLButtonElement>(
|
|
6251
|
+
".compose-quick-actions-row .compose-quick-actions-close",
|
|
6252
|
+
),
|
|
6253
|
+
"expected a close button in the quick actions row",
|
|
5143
6254
|
);
|
|
6255
|
+
expect(closeBtn.textContent?.trim()).toBe(labels.close);
|
|
5144
6256
|
|
|
5145
6257
|
const requestCloseSpy = vi.spyOn(el, "requestClose");
|
|
5146
|
-
|
|
6258
|
+
closeBtn.click();
|
|
5147
6259
|
await flushUpdates(el);
|
|
5148
6260
|
|
|
5149
|
-
expect(el._showPublishPanel).toBe(false);
|
|
5150
6261
|
expect(requestCloseSpy).toHaveBeenCalledTimes(1);
|
|
5151
6262
|
});
|
|
5152
6263
|
|
|
5153
|
-
it("keeps the exit out of
|
|
6264
|
+
it("keeps the thread exit out of single-post mode, where the × already offers one", async () => {
|
|
5154
6265
|
const el = await createElement();
|
|
5155
|
-
|
|
6266
|
+
expect(el.querySelector(".compose-quick-actions-close")).toBeNull();
|
|
5156
6267
|
|
|
6268
|
+
await openPublishPanel(el);
|
|
5157
6269
|
const titles = Array.from(
|
|
5158
6270
|
el.querySelectorAll<HTMLElement>(
|
|
5159
6271
|
".compose-publish-panel .compose-sheet-title",
|
|
5160
6272
|
),
|
|
5161
6273
|
).map((n) => n.textContent?.trim());
|
|
5162
6274
|
|
|
5163
|
-
|
|
6275
|
+
// The exit is never a row in the options sheet, in either mode.
|
|
6276
|
+
expect(titles).not.toContain(labels.close);
|
|
5164
6277
|
expect(titles).toContain("Drafts");
|
|
5165
6278
|
});
|
|
5166
6279
|
|
|
6280
|
+
it("keeps the exit visible in a thread whose visibility row is locked", async () => {
|
|
6281
|
+
const el = await createElement();
|
|
6282
|
+
el._threadItems = [
|
|
6283
|
+
{ id: "thread-1", format: "note" },
|
|
6284
|
+
{ id: "thread-2", format: "note" },
|
|
6285
|
+
];
|
|
6286
|
+
el._visibilityLocked = true;
|
|
6287
|
+
await flushUpdates(el);
|
|
6288
|
+
|
|
6289
|
+
expect(el.querySelector(".compose-quick-actions-close")).not.toBeNull();
|
|
6290
|
+
});
|
|
6291
|
+
|
|
6292
|
+
it("leaves the exit to the page's own Back link in page mode", async () => {
|
|
6293
|
+
const el = await createElement();
|
|
6294
|
+
el.pageMode = true;
|
|
6295
|
+
el._threadItems = [
|
|
6296
|
+
{ id: "thread-1", format: "note" },
|
|
6297
|
+
{ id: "thread-2", format: "note" },
|
|
6298
|
+
];
|
|
6299
|
+
await flushUpdates(el);
|
|
6300
|
+
|
|
6301
|
+
expect(el.querySelector(".compose-quick-actions-close")).toBeNull();
|
|
6302
|
+
});
|
|
6303
|
+
|
|
5167
6304
|
it("requestClose on empty form closes immediately without confirmation", async () => {
|
|
5168
6305
|
const el = await createElement();
|
|
5169
6306
|
|
|
@@ -6157,6 +7294,66 @@ describe("JantComposeDialog", () => {
|
|
|
6157
7294
|
expect(el.querySelector("[data-compose-link-title-error]")).toBeNull();
|
|
6158
7295
|
});
|
|
6159
7296
|
|
|
7297
|
+
it("explains the missing link title once publish is attempted", async () => {
|
|
7298
|
+
const el = await createElement();
|
|
7299
|
+
el._format = "link";
|
|
7300
|
+
await el.updateComplete;
|
|
7301
|
+
|
|
7302
|
+
const editor = requireElement(
|
|
7303
|
+
el.querySelector<JantComposeEditor>("jant-compose-editor"),
|
|
7304
|
+
"expected compose editor",
|
|
7305
|
+
);
|
|
7306
|
+
editor._url = "https://example.com";
|
|
7307
|
+
await editor.updateComplete;
|
|
7308
|
+
|
|
7309
|
+
el.dispatchEvent(
|
|
7310
|
+
new globalThis.KeyboardEvent("keydown", {
|
|
7311
|
+
key: "Enter",
|
|
7312
|
+
metaKey: true,
|
|
7313
|
+
bubbles: true,
|
|
7314
|
+
}),
|
|
7315
|
+
);
|
|
7316
|
+
await el.updateComplete;
|
|
7317
|
+
await editor.updateComplete;
|
|
7318
|
+
|
|
7319
|
+
const error = requireElement(
|
|
7320
|
+
el.querySelector("[data-compose-link-title-error]"),
|
|
7321
|
+
"expected link title error",
|
|
7322
|
+
);
|
|
7323
|
+
expect(error.textContent?.trim()).toBe(
|
|
7324
|
+
"Add a title before posting this link.",
|
|
7325
|
+
);
|
|
7326
|
+
});
|
|
7327
|
+
|
|
7328
|
+
it("explains the missing link URL once publish is attempted", async () => {
|
|
7329
|
+
const el = await createElement();
|
|
7330
|
+
el._format = "link";
|
|
7331
|
+
await el.updateComplete;
|
|
7332
|
+
|
|
7333
|
+
const editor = requireElement(
|
|
7334
|
+
el.querySelector<JantComposeEditor>("jant-compose-editor"),
|
|
7335
|
+
"expected compose editor",
|
|
7336
|
+
);
|
|
7337
|
+
|
|
7338
|
+
el.dispatchEvent(
|
|
7339
|
+
new globalThis.KeyboardEvent("keydown", {
|
|
7340
|
+
key: "Enter",
|
|
7341
|
+
metaKey: true,
|
|
7342
|
+
bubbles: true,
|
|
7343
|
+
}),
|
|
7344
|
+
);
|
|
7345
|
+
await el.updateComplete;
|
|
7346
|
+
await editor.updateComplete;
|
|
7347
|
+
|
|
7348
|
+
const error = requireElement(
|
|
7349
|
+
el.querySelector("[data-compose-url-status]"),
|
|
7350
|
+
"expected link url error",
|
|
7351
|
+
);
|
|
7352
|
+
expect(error.textContent?.trim()).toBe(
|
|
7353
|
+
"Add a URL before posting this link.",
|
|
7354
|
+
);
|
|
7355
|
+
});
|
|
7356
|
+
|
|
6160
7357
|
it("Cmd/Ctrl+Enter finishes an attached text editor instead of publishing", async () => {
|
|
6161
7358
|
const el = await createElement();
|
|
6162
7359
|
(
|
|
@@ -6370,7 +7567,7 @@ describe("JantComposeDialog", () => {
|
|
|
6370
7567
|
).toBe("Don't save");
|
|
6371
7568
|
expect(
|
|
6372
7569
|
el.querySelector(".compose-confirm-cancel")?.textContent?.trim(),
|
|
6373
|
-
).toBe("
|
|
7570
|
+
).toBe("Keep editing");
|
|
6374
7571
|
});
|
|
6375
7572
|
|
|
6376
7573
|
it("cancel on attached text confirm returns focus to the attached editor", async () => {
|