@jant/core 0.6.16 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -11
- package/bin/commands/assets/upload.js +192 -15
- package/bin/commands/import-site.js +60 -4
- package/bin/commands/reset-password.js +21 -8
- package/bin/commands/site/snapshot/export.js +1 -1
- package/bin/commands/site/snapshot/import.js +1 -1
- package/bin/lib/d1-query.js +5 -16
- package/bin/lib/node-env.js +40 -4
- package/bin/lib/site-selection.js +85 -29
- package/bin/lib/wrangler-cli.js +28 -2
- package/bin/lib/wrangler-json.js +35 -0
- package/dist/{app-C7_4oab9.js → app-K_Aa1MMn.js} +52476 -13330
- package/dist/client/.vite/manifest.json +255 -90
- package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
- package/dist/client/_assets/chunks/create-editor-CD3FhrOB.js +257 -0
- package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
- package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
- package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
- package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
- package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
- package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
- package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
- package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
- package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
- package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
- package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
- package/dist/client/_assets/chunks/sortable-list-CgaL2jCs.js +2 -0
- package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
- package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
- package/dist/client/_assets/chunks/unsafe-svg-0QCkP0vZ.js +2 -0
- package/dist/client/_assets/client-GiYENVw8.css +2 -0
- package/dist/client/_assets/client-VnFxJN7G.js +295 -0
- package/dist/client/_assets/client-auth-k8gJ6QJj.js +828 -0
- package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
- package/dist/client/_assets/client-compose-B_kDtWkW.js +2004 -0
- package/dist/client/_assets/client-manage-M90aSTOk.js +2170 -0
- package/dist/client/_assets/client-settings-DfYs9n1F.js +1112 -0
- package/dist/{export-Dl5KCiEs.js → github-sync-BPAvT999.js} +2254 -107
- package/dist/index.js +2 -5
- package/dist/node.js +5 -7
- package/package.json +3 -3
- package/src/__tests__/auth-session-freshness.test.ts +57 -0
- package/src/__tests__/basecoat-variants.test.ts +121 -0
- package/src/__tests__/bin/assets-upload.test.ts +68 -0
- package/src/__tests__/export-hugo-build.test.ts +260 -0
- package/src/__tests__/export-service.test.ts +231 -0
- package/src/__tests__/helpers/component-css.ts +91 -0
- package/src/__tests__/helpers/db.ts +33 -0
- package/src/__tests__/helpers/export-fixtures.ts +3 -0
- package/src/__tests__/stylesheet-audience.test.ts +159 -0
- package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
- package/src/app.tsx +71 -16
- package/src/auth.ts +18 -1
- package/src/client/__tests__/collection-page-actions.test.ts +51 -2
- package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
- package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
- package/src/client/__tests__/compose-bridge.test.ts +272 -0
- package/src/client/__tests__/compose-launch.test.ts +62 -1
- package/src/client/__tests__/compose-shortcuts.test.ts +38 -9
- package/src/client/__tests__/compose-triggers.test.ts +90 -0
- package/src/client/__tests__/copy-field.test.ts +162 -0
- package/src/client/__tests__/custom-url-menu.test.ts +127 -6
- package/src/client/__tests__/image-processor.test.ts +324 -2
- package/src/client/__tests__/lazy-entries.test.ts +92 -0
- package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
- package/src/client/__tests__/runtime-paths.test.ts +84 -0
- package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
- package/src/client/__tests__/toast.test.ts +23 -0
- package/src/client/__tests__/video-processor-probe.test.ts +175 -0
- package/src/client/__tests__/video-processor.test.ts +181 -0
- package/src/client/audio-processor.ts +24 -15
- package/src/client/audio-waveform.ts +49 -0
- package/src/client/collection-dialog-host.ts +72 -0
- package/src/client/collection-navigation.ts +51 -16
- package/src/client/collection-page-actions.ts +26 -0
- package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
- package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
- package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
- package/src/client/components/__tests__/jant-command-palette.test.ts +28 -0
- package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +1250 -53
- package/src/client/components/__tests__/jant-compose-editor.test.ts +544 -40
- package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +0 -1
- package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
- package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
- package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
- package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
- package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
- package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
- package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
- package/src/client/components/collection-dialog-types.ts +49 -0
- package/src/client/components/collection-manager-types.ts +32 -1
- package/src/client/components/collection-types.ts +1 -15
- package/src/client/components/compose-types.ts +75 -1
- package/src/client/components/jant-collection-dialog.ts +722 -0
- package/src/client/components/jant-collection-directory.ts +567 -182
- package/src/client/components/jant-collection-form.ts +53 -232
- package/src/client/components/jant-command-palette.ts +100 -49
- package/src/client/components/jant-compose-dialog.ts +1486 -707
- package/src/client/components/jant-compose-editor.ts +673 -336
- package/src/client/components/jant-compose-fullscreen.ts +51 -43
- package/src/client/components/jant-config-editor.ts +65 -53
- package/src/client/components/jant-confirm-dialog.ts +13 -11
- package/src/client/components/jant-locale-picker.ts +418 -0
- package/src/client/components/jant-media-lightbox.ts +146 -136
- package/src/client/components/jant-nav-manager.ts +690 -366
- package/src/client/components/jant-post-menu.ts +1086 -282
- package/src/client/components/jant-post-picker.ts +324 -0
- package/src/client/components/jant-repo-picker.ts +138 -106
- package/src/client/components/jant-settings-avatar.ts +31 -27
- package/src/client/components/jant-settings-general.ts +617 -543
- package/src/client/components/jant-settings-language.ts +1087 -0
- package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
- package/src/client/components/jant-text-preview.ts +94 -86
- package/src/client/components/nav-manager-types.ts +62 -1
- package/src/client/components/settings-types.ts +14 -29
- package/src/client/components/smart-collection-conditions.ts +285 -0
- package/src/client/components/smart-collection-dialog-types.ts +68 -0
- package/src/client/compose-bridge.ts +214 -141
- package/src/client/compose-launch.ts +76 -2
- package/src/client/compose-shortcuts.ts +6 -8
- package/src/client/compose-triggers.ts +50 -0
- package/src/client/copy-field.ts +108 -0
- package/src/client/custom-url-menu.ts +60 -4
- package/src/client/icons.ts +14 -0
- package/src/client/image-plan.ts +165 -0
- package/src/client/image-processor.ts +195 -117
- package/src/client/image-worker-client.ts +45 -0
- package/src/client/image-worker.ts +135 -0
- package/src/client/lazy-entries.ts +95 -0
- package/src/client/lazy-slugify.ts +2 -2
- package/src/client/media-scroll-hint.ts +43 -2
- package/src/client/mediabunny.ts +29 -0
- package/src/client/post-picker.ts +41 -0
- package/src/client/runtime-paths.ts +74 -1
- package/src/client/settings-bridge.ts +4 -10
- package/src/client/site-header-nav.js +18 -6
- package/src/client/smart-collection-dialog-host.ts +116 -0
- package/src/client/smart-collection-page-actions.ts +208 -0
- package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
- package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
- package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
- package/src/client/tiptap/bubble-menu.ts +47 -16
- package/src/client/tiptap/floating-position.ts +216 -2
- package/src/client/tiptap/inline-image-upload.ts +7 -3
- package/src/client/tiptap/link-toolbar.ts +22 -9
- package/src/client/toast.ts +13 -0
- package/src/client/types/vite-worker.d.ts +12 -0
- package/src/client/upload-with-metadata.ts +3 -13
- package/src/client/video-processor.ts +351 -107
- package/src/client-auth.ts +14 -31
- package/src/client-compose.ts +19 -0
- package/src/client-manage.ts +13 -0
- package/src/client-settings.ts +14 -0
- package/src/client-site.ts +10 -3
- package/src/client.ts +4 -0
- package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
- package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
- package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
- package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
- package/src/db/migrations/0032_married_tomas.sql +3 -0
- package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
- package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
- package/src/db/migrations/meta/0032_snapshot.json +2582 -0
- package/src/db/migrations/meta/0033_snapshot.json +2816 -0
- package/src/db/migrations/meta/0034_snapshot.json +2816 -0
- package/src/db/migrations/meta/_journal.json +21 -0
- package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
- package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
- package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
- package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
- package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
- package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
- package/src/db/migrations/pg/meta/_journal.json +21 -0
- package/src/db/pg/schema.ts +195 -27
- package/src/db/post-visibility.ts +117 -0
- package/src/db/schema.ts +222 -27
- package/src/i18n/__tests__/detect.test.ts +26 -81
- package/src/i18n/__tests__/supported-locales.test.ts +83 -0
- package/src/i18n/context.tsx +12 -2
- package/src/i18n/detect.ts +7 -66
- package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
- package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
- package/src/i18n/locales/public/en.po +490 -158
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +487 -155
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +487 -155
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/i18n/locales/settings/en.po +551 -69
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +597 -115
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +610 -128
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/i18n/locales.ts +94 -0
- package/src/i18n/middleware.ts +12 -2
- package/src/i18n/supported-locales.ts +80 -0
- package/src/lib/__tests__/api-settings.test.ts +11 -2
- package/src/lib/__tests__/asset-path.test.ts +92 -0
- package/src/lib/__tests__/deferred.test.ts +90 -0
- package/src/lib/__tests__/discover-ping.test.ts +89 -0
- package/src/lib/__tests__/discover.test.ts +410 -0
- package/src/lib/__tests__/feed-policy.test.ts +26 -0
- package/src/lib/__tests__/feed.test.ts +1526 -15
- package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
- package/src/lib/__tests__/hosted-signin.test.ts +1 -1
- package/src/lib/__tests__/http-cache.test.ts +162 -0
- package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
- package/src/lib/__tests__/jant-docs.test.ts +23 -0
- package/src/lib/__tests__/lang-detect.test.ts +231 -0
- package/src/lib/__tests__/media-helpers.test.ts +81 -0
- package/src/lib/__tests__/navigation.test.ts +135 -1
- package/src/lib/__tests__/resolve-config.test.ts +64 -3
- package/src/lib/__tests__/schemas.test.ts +42 -4
- package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
- package/src/lib/__tests__/slug.test.ts +70 -0
- package/src/lib/__tests__/slugify.test.ts +85 -0
- package/src/lib/__tests__/storage.test.ts +32 -0
- package/src/lib/__tests__/thread-fold.test.ts +113 -0
- package/src/lib/__tests__/upload-sideload.test.ts +14 -0
- package/src/lib/__tests__/url-fetch.test.ts +1 -2
- package/src/lib/__tests__/url.test.ts +78 -72
- package/src/lib/__tests__/view-language.test.ts +413 -0
- package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
- package/src/lib/address.ts +57 -0
- package/src/lib/api-posts.ts +12 -0
- package/src/lib/asset-path.ts +98 -1
- package/src/lib/build-env.ts +16 -0
- package/src/lib/collection-groups.ts +5 -1
- package/src/lib/collection-paths.ts +3 -10
- package/src/lib/constants.ts +28 -3
- package/src/lib/copy-field.ts +36 -0
- package/src/lib/crypto.ts +40 -0
- package/src/lib/csp-builder.ts +2 -1
- package/src/lib/deferred.ts +79 -0
- package/src/lib/discover-ping.ts +141 -0
- package/src/lib/discover.ts +437 -0
- package/src/lib/env.ts +134 -0
- package/src/lib/errors.ts +33 -0
- package/src/lib/feed-policy.ts +236 -2
- package/src/lib/feed.ts +926 -73
- package/src/lib/filter-dimensions.ts +1349 -0
- package/src/lib/github-sync-site-config.ts +3 -0
- package/src/lib/hosted-signin.ts +1 -1
- package/src/lib/http-cache.ts +148 -0
- package/src/lib/hugo-markdown.ts +16 -0
- package/src/lib/icons.ts +9 -2
- package/src/lib/ids.ts +3 -0
- package/src/lib/jant-docs.ts +42 -0
- package/src/lib/lang-detect.ts +308 -0
- package/src/lib/media-helpers.ts +13 -1
- package/src/lib/navigation.ts +78 -11
- package/src/lib/pagination.ts +28 -24
- package/src/lib/per-language-surfaces.ts +67 -0
- package/src/lib/post-meta.ts +19 -0
- package/src/lib/render.tsx +55 -7
- package/src/lib/resolve-config.ts +53 -5
- package/src/lib/schemas.ts +201 -10
- package/src/lib/site-header-fragment.tsx +2 -1
- package/src/lib/site-unavailable-page.ts +47 -0
- package/src/lib/slug.ts +51 -7
- package/src/lib/slugify.ts +113 -0
- package/src/lib/startup-config.ts +4 -6
- package/src/lib/storage.ts +7 -0
- package/src/lib/summary.ts +50 -0
- package/src/lib/thread-fold.ts +161 -0
- package/src/lib/time.ts +32 -0
- package/src/lib/timeline.ts +42 -1
- package/src/lib/tiptap-render.ts +1 -2
- package/src/lib/upload.ts +16 -13
- package/src/lib/url.ts +139 -86
- package/src/lib/version.ts +10 -8
- package/src/lib/view-language.ts +436 -0
- package/src/lib/view.ts +51 -23
- package/src/lib/viewer-context.ts +62 -0
- package/src/middleware/__tests__/auth.test.ts +70 -2
- package/src/middleware/__tests__/cache-control.test.ts +71 -3
- package/src/middleware/__tests__/onboarding.test.ts +76 -5
- package/src/middleware/__tests__/secure-headers.test.ts +1 -1
- package/src/middleware/__tests__/session.test.ts +181 -33
- package/src/middleware/auth.ts +50 -31
- package/src/middleware/cache-control.ts +75 -10
- package/src/middleware/config.ts +6 -6
- package/src/middleware/error-handler.ts +12 -0
- package/src/middleware/onboarding.ts +32 -10
- package/src/middleware/secure-headers.ts +1 -1
- package/src/middleware/session.ts +96 -8
- package/src/node/__tests__/cli-migrate.test.ts +21 -19
- package/src/node/__tests__/cli-reset-password.test.ts +81 -8
- package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
- package/src/node/__tests__/cli-site-selection.test.ts +128 -38
- package/src/node/__tests__/runtime.test.ts +37 -0
- package/src/node/index.ts +1 -0
- package/src/preset.css +76 -22
- package/src/routes/__tests__/feed-validator.test.ts +156 -0
- package/src/routes/api/__tests__/collections.test.ts +114 -0
- package/src/routes/api/__tests__/discover.test.ts +222 -0
- package/src/routes/api/__tests__/nav-items.test.ts +212 -2
- package/src/routes/api/__tests__/post-translations.test.ts +829 -0
- package/src/routes/api/__tests__/settings.test.ts +24 -3
- package/src/routes/api/__tests__/telegram.test.ts +6 -5
- package/src/routes/api/collections.ts +49 -6
- package/src/routes/api/custom-urls.ts +0 -1
- package/src/routes/api/discover.ts +95 -0
- package/src/routes/api/export.ts +3 -0
- package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
- package/src/routes/api/nav-items.ts +55 -8
- package/src/routes/api/posts.ts +159 -0
- package/src/routes/api/public/__tests__/archive.test.ts +111 -0
- package/src/routes/api/public/archive.ts +77 -81
- package/src/routes/api/public/posts.ts +23 -6
- package/src/routes/api/smart-collections.ts +132 -0
- package/src/routes/api/telegram.ts +2 -40
- package/src/routes/api/uploads.ts +1 -2
- package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
- package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
- package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
- package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
- package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
- package/src/routes/auth/__tests__/setup.test.ts +278 -26
- package/src/routes/auth/__tests__/signin.test.ts +135 -0
- package/src/routes/auth/setup.tsx +934 -186
- package/src/routes/auth/signin.tsx +10 -8
- package/src/routes/compose.tsx +5 -0
- package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
- package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
- package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
- package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
- package/src/routes/dash/custom-urls.tsx +1 -37
- package/src/routes/dash/settings.tsx +502 -41
- package/src/routes/discover-announce.ts +96 -0
- package/src/routes/feed/__tests__/feed.test.ts +165 -2
- package/src/routes/feed/feed.ts +69 -40
- package/src/routes/feed/sitemap.ts +95 -11
- package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
- package/src/routes/pages/__tests__/archive-params.test.ts +370 -6
- package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
- package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
- package/src/routes/pages/__tests__/collections.test.ts +9 -3
- package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
- package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
- package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
- package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
- package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
- package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
- package/src/routes/pages/archive.tsx +471 -369
- package/src/routes/pages/collection.tsx +94 -96
- package/src/routes/pages/collections.tsx +38 -50
- package/src/routes/pages/featured.tsx +32 -13
- package/src/routes/pages/home.tsx +25 -6
- package/src/routes/pages/language.tsx +113 -0
- package/src/routes/pages/latest.tsx +26 -16
- package/src/routes/pages/new.tsx +3 -0
- package/src/routes/pages/page.tsx +178 -17
- package/src/routes/pages/partials.tsx +41 -0
- package/src/routes/pages/search.tsx +18 -3
- package/src/routes/pages/smart-collection.tsx +340 -0
- package/src/routes/pages/subscribe.tsx +182 -0
- package/src/runtime/__tests__/index.test.ts +19 -0
- package/src/runtime/__tests__/node.test.ts +129 -4
- package/src/runtime/cloudflare.ts +21 -2
- package/src/runtime/index.ts +27 -1
- package/src/runtime/node.ts +18 -1
- package/src/runtime/site.ts +95 -24
- package/src/services/__tests__/auth.test.ts +116 -7
- package/src/services/__tests__/collection.test.ts +108 -4
- package/src/services/__tests__/language.test.ts +495 -0
- package/src/services/__tests__/navigation.test.ts +213 -10
- package/src/services/__tests__/path.test.ts +180 -0
- package/src/services/__tests__/post-language.test.ts +830 -0
- package/src/services/__tests__/post.test.ts +46 -9
- package/src/services/__tests__/settings.test.ts +228 -17
- package/src/services/__tests__/site-admin.test.ts +5 -5
- package/src/services/__tests__/smart-collection.test.ts +651 -0
- package/src/services/about-page.ts +22 -1
- package/src/services/auth.ts +51 -49
- package/src/services/bootstrap.ts +188 -38
- package/src/services/collection-directory-position.ts +76 -0
- package/src/services/collection.ts +331 -46
- package/src/services/custom-url.ts +27 -22
- package/src/services/export-theme/assets/client-site.css +1 -1
- package/src/services/export-theme/assets/client-site.js +123 -123
- package/src/services/export-theme/layouts/_default/rss.xml +77 -1
- package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
- package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
- package/src/services/export-theme/layouts/partials/pagination.html +14 -13
- package/src/services/export-theme/layouts/partials/post-card.html +8 -4
- package/src/services/export-theme/layouts/partials/reply.html +2 -1
- package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
- package/src/services/export-theme/styles/main.css +13 -1
- package/src/services/export.ts +278 -19
- package/src/services/index.ts +34 -3
- package/src/services/language.ts +412 -0
- package/src/services/mcp.ts +3 -1
- package/src/services/media.ts +14 -6
- package/src/services/navigation.ts +429 -67
- package/src/services/path.ts +221 -6
- package/src/services/post.ts +1021 -152
- package/src/services/search.ts +39 -2
- package/src/services/settings.ts +238 -43
- package/src/services/site-admin.ts +24 -7
- package/src/services/site.ts +77 -1
- package/src/services/smart-collection.ts +636 -0
- package/src/services/telegram.ts +20 -22
- package/src/services/upload-session.ts +1 -3
- package/src/style-author.css +27 -0
- package/src/styles/components-author.css +1622 -0
- package/src/styles/components.css +24 -1571
- package/src/styles/site-media.css +16 -2
- package/src/styles/tokens.css +82 -12
- package/src/styles/ui-author.css +7602 -0
- package/src/styles/ui.css +1087 -7219
- package/src/types/app-context.ts +7 -0
- package/src/types/bindings.ts +3 -0
- package/src/types/config.ts +94 -15
- package/src/types/constants.ts +188 -16
- package/src/types/entities.ts +87 -1
- package/src/types/operations.ts +64 -2
- package/src/types/props.ts +157 -31
- package/src/types/views.ts +55 -1
- package/src/ui/__tests__/font-themes.test.ts +52 -12
- package/src/ui/compose/ComposeDialog.tsx +201 -7
- package/src/ui/compose/ComposePrompt.tsx +1 -5
- package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
- package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
- package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
- package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
- package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
- package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
- package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
- package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
- package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
- package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
- package/src/ui/dash/settings/GeneralContent.tsx +275 -113
- package/src/ui/dash/settings/LanguageContent.tsx +409 -0
- package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
- package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
- package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
- package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
- package/src/ui/dash/settings/discover-copy.ts +116 -0
- package/src/ui/feed/LinkCard.tsx +55 -45
- package/src/ui/feed/NoteCard.tsx +9 -4
- package/src/ui/feed/PostStatusBadges.tsx +90 -43
- package/src/ui/feed/QuoteCard.tsx +55 -30
- package/src/ui/feed/ThreadPreview.tsx +6 -2
- package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
- package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
- package/src/ui/feed/thread-preview-state.ts +20 -6
- package/src/ui/font-themes.ts +39 -29
- package/src/ui/layouts/BaseLayout.tsx +155 -10
- package/src/ui/layouts/SiteLayout.tsx +192 -34
- package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
- package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
- package/src/ui/pages/ArchivePage.tsx +282 -289
- package/src/ui/pages/CollectionPage.tsx +86 -76
- package/src/ui/pages/CollectionsPage.tsx +5 -0
- package/src/ui/pages/ComposePage.tsx +8 -0
- package/src/ui/pages/PostPage.tsx +47 -0
- package/src/ui/pages/SearchPage.tsx +3 -3
- package/src/ui/pages/SmartCollectionPage.tsx +434 -0
- package/src/ui/pages/SubscribePage.tsx +236 -0
- package/src/ui/pages/__tests__/ArchivePage.test.tsx +65 -9
- package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
- package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
- package/src/ui/shared/CollectionDirectory.tsx +178 -3
- package/src/ui/shared/CollectionsManager.tsx +70 -17
- package/src/ui/shared/CopyField.tsx +127 -0
- package/src/ui/shared/EmptyPostContent.tsx +23 -0
- package/src/ui/shared/MediaGallery.tsx +125 -46
- package/src/ui/shared/Pagination.tsx +20 -5
- package/src/ui/shared/PostFooter.tsx +20 -11
- package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
- package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
- package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
- package/src/ui/shared/__tests__/pagination.test.ts +38 -24
- package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
- package/src/ui/shared/archive-labels.ts +98 -0
- package/src/ui/shared/collection-management-labels.ts +94 -16
- package/src/ui/shared/navigation-labels.ts +45 -71
- package/src/ui/shared/post-article-attributes.ts +2 -0
- package/src/ui/shared/smart-collection-labels.ts +343 -0
- package/dist/app-C75Zc8dD.js +0 -6
- package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
- package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
- package/dist/client/_assets/chunks/url-CU9pEbk5.js +0 -1
- package/dist/client/_assets/client-Bq0vre8Y.js +0 -296
- package/dist/client/_assets/client-D3VZyG4L.css +0 -2
- package/dist/client/_assets/client-auth-F2rb9WfF.js +0 -5571
- package/dist/env-BPYViDJJ.js +0 -281
- package/dist/github-api-BNF35Lkx.js +0 -176
- package/dist/github-app-DdiD-bC4.js +0 -275
- package/dist/github-sync-Bqg62IvV.js +0 -4
- package/dist/github-sync-HdK2EgvJ.js +0 -431
- package/dist/url-CbLAtlZe.js +0 -900
- package/src/client/__tests__/collection-created-notice.test.ts +0 -36
- package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
- package/src/client/__tests__/post-form-bridge.test.ts +0 -82
- package/src/client/collection-created-notice.ts +0 -66
- package/src/client/collection-form-bridge.ts +0 -191
- package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
- package/src/client/components/jant-post-form.ts +0 -422
- package/src/client/components/post-form-template.ts +0 -352
- package/src/client/components/post-form-types.ts +0 -96
- package/src/client/media-metadata.ts +0 -247
- package/src/client/post-form-bridge.ts +0 -193
- package/src/ui/pages/CollectionEditorPage.tsx +0 -175
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
vi.mock("../toast.js", () => ({
|
|
6
|
+
showToast: vi.fn(),
|
|
7
|
+
showToastWithAction: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
vi.mock("../confirm.js", () => ({
|
|
11
|
+
showConfirmDialog: vi.fn(),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock("../smart-collection-dialog-host.js", () => ({
|
|
15
|
+
openSmartCollectionDialog: vi.fn(),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
function createMarkup() {
|
|
19
|
+
document.body.innerHTML = `
|
|
20
|
+
<div id="toast-container"></div>
|
|
21
|
+
<div
|
|
22
|
+
data-smart-collection-page-actions
|
|
23
|
+
data-smart-collection-id="smc_1"
|
|
24
|
+
data-collection-page-labels='{"addToNavigation":"Add to Navigation","addingToNavigation":"Adding…","addedToNavigation":"Collection added to navigation.","editNavigation":"Edit Navigation","addToNavigationFailed":"Couldn\\u0027t add this collection to navigation. Try again.","confirmDelete":"Delete this smart collection?","deleteCollection":"Delete","cancel":"Cancel","saveFailed":"Couldn\\u0027t save. Try again in a moment.","deleted":"Smart collection deleted."}'
|
|
25
|
+
data-collection-in-navigation="false"
|
|
26
|
+
data-collection-page-redirect-url="/collections"
|
|
27
|
+
>
|
|
28
|
+
<button
|
|
29
|
+
type="button"
|
|
30
|
+
data-collection-page-action="toggle-menu"
|
|
31
|
+
aria-expanded="false"
|
|
32
|
+
>
|
|
33
|
+
More actions
|
|
34
|
+
</button>
|
|
35
|
+
<div data-collection-page-menu hidden>
|
|
36
|
+
<button type="button" role="menuitem" data-collection-page-action="edit">
|
|
37
|
+
Edit
|
|
38
|
+
</button>
|
|
39
|
+
<button
|
|
40
|
+
type="button"
|
|
41
|
+
role="menuitem"
|
|
42
|
+
data-collection-page-action="add-to-navigation"
|
|
43
|
+
>
|
|
44
|
+
<span class="collections-page-menu-item-label">Add to Navigation</span>
|
|
45
|
+
</button>
|
|
46
|
+
<a
|
|
47
|
+
href="/settings/navigation"
|
|
48
|
+
role="menuitem"
|
|
49
|
+
data-collection-page-edit-navigation
|
|
50
|
+
hidden
|
|
51
|
+
>
|
|
52
|
+
Edit Navigation
|
|
53
|
+
</a>
|
|
54
|
+
<button
|
|
55
|
+
type="button"
|
|
56
|
+
role="menuitem"
|
|
57
|
+
data-collection-page-action="delete"
|
|
58
|
+
>
|
|
59
|
+
Delete
|
|
60
|
+
</button>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function flushAsyncWork() {
|
|
67
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
68
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe("smart collection page actions", () => {
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
vi.resetModules();
|
|
74
|
+
vi.clearAllMocks();
|
|
75
|
+
window.location.href = "http://localhost/reading";
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// The same request a collection page sends, down to the placement and the
|
|
79
|
+
// header-refresh hint — the two kinds are one thing to the reader, so the
|
|
80
|
+
// action cannot quietly put them in different parts of the nav.
|
|
81
|
+
it("adds the smart collection to navigation and reveals the settings action", async () => {
|
|
82
|
+
createMarkup();
|
|
83
|
+
const fetchMock = vi.fn().mockResolvedValue(
|
|
84
|
+
new Response(JSON.stringify({ id: "nav-1" }), {
|
|
85
|
+
status: 201,
|
|
86
|
+
headers: { "Content-Type": "application/json" },
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
90
|
+
|
|
91
|
+
const { showToastWithAction } = await import("../toast.js");
|
|
92
|
+
await import("../smart-collection-page-actions.js");
|
|
93
|
+
|
|
94
|
+
const addButton = document.querySelector<HTMLButtonElement>(
|
|
95
|
+
"[data-collection-page-action='add-to-navigation']",
|
|
96
|
+
);
|
|
97
|
+
const editNavigationLink = document.querySelector<HTMLAnchorElement>(
|
|
98
|
+
"[data-collection-page-edit-navigation]",
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
addButton?.click();
|
|
102
|
+
await flushAsyncWork();
|
|
103
|
+
|
|
104
|
+
expect(fetchMock).toHaveBeenCalledWith("/api/nav-items", {
|
|
105
|
+
method: "POST",
|
|
106
|
+
headers: {
|
|
107
|
+
"Content-Type": "application/json",
|
|
108
|
+
Accept: "application/json",
|
|
109
|
+
"X-Jant-Site-Header": "include",
|
|
110
|
+
},
|
|
111
|
+
body: JSON.stringify({
|
|
112
|
+
type: "smart_collection",
|
|
113
|
+
smartCollectionId: "smc_1",
|
|
114
|
+
placement: "header",
|
|
115
|
+
}),
|
|
116
|
+
});
|
|
117
|
+
expect(addButton?.hidden).toBe(true);
|
|
118
|
+
expect(editNavigationLink?.hidden).toBe(false);
|
|
119
|
+
expect(
|
|
120
|
+
document.querySelector<HTMLElement>(
|
|
121
|
+
"[data-smart-collection-page-actions]",
|
|
122
|
+
)?.dataset.collectionInNavigation,
|
|
123
|
+
).toBe("true");
|
|
124
|
+
expect(showToastWithAction).toHaveBeenCalledWith(
|
|
125
|
+
"Collection added to navigation.",
|
|
126
|
+
{
|
|
127
|
+
label: "Edit Navigation",
|
|
128
|
+
href: "/settings/navigation",
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("keeps the action available when the request fails", async () => {
|
|
134
|
+
createMarkup();
|
|
135
|
+
const fetchMock = vi
|
|
136
|
+
.fn()
|
|
137
|
+
.mockResolvedValue(new Response(null, { status: 500 }));
|
|
138
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
139
|
+
|
|
140
|
+
const { showToast } = await import("../toast.js");
|
|
141
|
+
await import("../smart-collection-page-actions.js");
|
|
142
|
+
|
|
143
|
+
const addButton = document.querySelector<HTMLButtonElement>(
|
|
144
|
+
"[data-collection-page-action='add-to-navigation']",
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
addButton?.click();
|
|
148
|
+
await flushAsyncWork();
|
|
149
|
+
|
|
150
|
+
expect(showToast).toHaveBeenCalledWith(
|
|
151
|
+
"Couldn't add this collection to navigation. Try again.",
|
|
152
|
+
"error",
|
|
153
|
+
);
|
|
154
|
+
expect(addButton?.hidden).toBe(false);
|
|
155
|
+
expect(addButton?.disabled).toBe(false);
|
|
156
|
+
expect(addButton?.textContent).toContain("Add to Navigation");
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
QUEUED_TOAST_STORAGE_KEY,
|
|
6
6
|
consumeQueuedToast,
|
|
7
7
|
queueToastForNextPage,
|
|
8
|
+
replaceWithAutoClose,
|
|
9
|
+
showPersistentToast,
|
|
8
10
|
showToast,
|
|
9
11
|
showToastWithAction,
|
|
10
12
|
} from "../toast.js";
|
|
@@ -110,6 +112,27 @@ describe("toast", () => {
|
|
|
110
112
|
expect(container?.parentElement).toBe(document.body);
|
|
111
113
|
});
|
|
112
114
|
|
|
115
|
+
it("follows a dialog that opened after the progress toast did", async () => {
|
|
116
|
+
showPersistentToast("compose-deferred", "Publishing...");
|
|
117
|
+
|
|
118
|
+
// A failed publish reopens the composer, which makes everything outside
|
|
119
|
+
// it inert — the result toast has to move in with it.
|
|
120
|
+
const dialog = document.createElement("dialog");
|
|
121
|
+
document.body.appendChild(dialog);
|
|
122
|
+
dialog.showModal();
|
|
123
|
+
|
|
124
|
+
replaceWithAutoClose("compose-deferred", "Couldn't publish.", "error");
|
|
125
|
+
|
|
126
|
+
const container = document.getElementById("toast-container");
|
|
127
|
+
expect(container?.parentElement).toBe(dialog);
|
|
128
|
+
expect(container?.querySelector(".toast span")?.textContent).toBe(
|
|
129
|
+
"Couldn't publish.",
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
dialog.close();
|
|
133
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
134
|
+
});
|
|
135
|
+
|
|
113
136
|
it("keeps action toasts available longer than passive notifications", () => {
|
|
114
137
|
vi.useFakeTimers();
|
|
115
138
|
showToastWithAction("Collection created.", {
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The source probe feeds four independent things: the codec that decides
|
|
5
|
+
* whether a re-encode is needed, the bitrate that sizes it, the duration, and
|
|
6
|
+
* the poster/blurhash pair. They used to share one try/catch, so a single
|
|
7
|
+
* failing step returned nothing at all — a mediabunny version without
|
|
8
|
+
* `getCodec()` cost every upload its thumbnail while the video itself went
|
|
9
|
+
* through fine. Each step is now degraded on its own.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
13
|
+
|
|
14
|
+
const trackStub = {
|
|
15
|
+
displayWidth: 1280,
|
|
16
|
+
displayHeight: 720,
|
|
17
|
+
rotation: 0,
|
|
18
|
+
getCodec: vi.fn(async () => "avc"),
|
|
19
|
+
computePacketStats: vi.fn(async () => ({ averageBitrate: 2_000_000 })),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const conversionOptions: { video?: Record<string, unknown> }[] = [];
|
|
23
|
+
|
|
24
|
+
const canvasSinkStub = {
|
|
25
|
+
getCanvas: vi.fn(async () => ({ canvas: makeCanvas(1280, 720) })),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
vi.mock("mediabunny", () => {
|
|
29
|
+
class Input {
|
|
30
|
+
getPrimaryVideoTrack = async () => trackStub;
|
|
31
|
+
computeDuration = async () => 20;
|
|
32
|
+
dispose = () => {};
|
|
33
|
+
}
|
|
34
|
+
class CanvasSink {
|
|
35
|
+
getCanvas = () => canvasSinkStub.getCanvas();
|
|
36
|
+
}
|
|
37
|
+
class BufferTarget {
|
|
38
|
+
buffer = new ArrayBuffer(64);
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
Input,
|
|
42
|
+
CanvasSink,
|
|
43
|
+
BufferTarget,
|
|
44
|
+
BlobSource: class {},
|
|
45
|
+
Output: class {},
|
|
46
|
+
Mp4OutputFormat: class {},
|
|
47
|
+
Quality: class {
|
|
48
|
+
constructor(readonly options: unknown) {}
|
|
49
|
+
},
|
|
50
|
+
ALL_FORMATS: [],
|
|
51
|
+
Conversion: {
|
|
52
|
+
init: async (options: { video?: Record<string, unknown> }) => {
|
|
53
|
+
conversionOptions.push(options);
|
|
54
|
+
return { onProgress: undefined, execute: async () => {} };
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
vi.mock("../../lib/mp4-track-flags.js", () => ({
|
|
61
|
+
zeroTrackAlternateGroups: () => {},
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
/** A canvas whose 2D context and WebP encoding both succeed. */
|
|
65
|
+
function makeCanvas(width: number, height: number): HTMLCanvasElement {
|
|
66
|
+
return {
|
|
67
|
+
width,
|
|
68
|
+
height,
|
|
69
|
+
getContext: () => ({
|
|
70
|
+
drawImage: () => {},
|
|
71
|
+
getImageData: (_x: number, _y: number, w: number, h: number) => ({
|
|
72
|
+
data: new Uint8ClampedArray(w * h * 4).fill(120),
|
|
73
|
+
}),
|
|
74
|
+
}),
|
|
75
|
+
toBlob: (cb: (blob: Blob | null) => void) =>
|
|
76
|
+
cb(new Blob(["poster"], { type: "image/webp" })),
|
|
77
|
+
} as unknown as HTMLCanvasElement;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const { VideoProcessor } = await import("../video-processor.js");
|
|
81
|
+
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
conversionOptions.length = 0;
|
|
84
|
+
trackStub.getCodec.mockImplementation(async () => "avc");
|
|
85
|
+
trackStub.computePacketStats.mockImplementation(async () => ({
|
|
86
|
+
averageBitrate: 2_000_000,
|
|
87
|
+
}));
|
|
88
|
+
canvasSinkStub.getCanvas.mockImplementation(async () => ({
|
|
89
|
+
canvas: makeCanvas(1280, 720),
|
|
90
|
+
}));
|
|
91
|
+
|
|
92
|
+
vi.spyOn(document, "createElement").mockImplementation(((tag: string) => {
|
|
93
|
+
if (tag === "canvas") return makeCanvas(1280, 720);
|
|
94
|
+
// `probeVideoDimensions` loads the transcoded output in a <video> element.
|
|
95
|
+
const video = {
|
|
96
|
+
preload: "",
|
|
97
|
+
onloadedmetadata: null as null | (() => void),
|
|
98
|
+
onerror: null,
|
|
99
|
+
videoWidth: 1280,
|
|
100
|
+
videoHeight: 720,
|
|
101
|
+
set src(_value: string) {
|
|
102
|
+
queueMicrotask(() => this.onloadedmetadata?.());
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
return video as unknown as HTMLElement;
|
|
106
|
+
}) as typeof document.createElement);
|
|
107
|
+
|
|
108
|
+
vi.stubGlobal("URL", {
|
|
109
|
+
createObjectURL: () => "blob:test",
|
|
110
|
+
revokeObjectURL: () => {},
|
|
111
|
+
});
|
|
112
|
+
vi.stubGlobal("VideoEncoder", class {});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
function sourceFile() {
|
|
116
|
+
return new File(["source"], "clip.mov", { type: "video/quicktime" });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
describe("VideoProcessor.processToFile source probing", () => {
|
|
120
|
+
it("still captures a poster when the codec cannot be read", async () => {
|
|
121
|
+
// The exact shape of a stale install: the installed mediabunny has no
|
|
122
|
+
// `getCodec()`, so the call throws before the frame is ever captured.
|
|
123
|
+
trackStub.getCodec.mockImplementation(() => {
|
|
124
|
+
throw new TypeError("videoTrack.getCodec is not a function");
|
|
125
|
+
});
|
|
126
|
+
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
127
|
+
|
|
128
|
+
const result = await VideoProcessor.processToFile(sourceFile());
|
|
129
|
+
|
|
130
|
+
expect(result.poster).toBeInstanceOf(Blob);
|
|
131
|
+
expect(result.blurhash).toBeTruthy();
|
|
132
|
+
expect(result.durationSeconds).toBe(20);
|
|
133
|
+
// An unreadable codec cannot be vouched for, so the file is re-encoded.
|
|
134
|
+
expect(conversionOptions[0]?.video).toHaveProperty("quality");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("still captures a poster when the bitrate cannot be computed", async () => {
|
|
138
|
+
trackStub.computePacketStats.mockRejectedValue(
|
|
139
|
+
new Error("no sample table"),
|
|
140
|
+
);
|
|
141
|
+
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
142
|
+
|
|
143
|
+
const result = await VideoProcessor.processToFile(sourceFile());
|
|
144
|
+
|
|
145
|
+
expect(result.poster).toBeInstanceOf(Blob);
|
|
146
|
+
expect(result.blurhash).toBeTruthy();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("still reports the duration when the frame cannot be decoded", async () => {
|
|
150
|
+
canvasSinkStub.getCanvas.mockRejectedValue(
|
|
151
|
+
new Error("unsupported codec configuration"),
|
|
152
|
+
);
|
|
153
|
+
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
154
|
+
|
|
155
|
+
const result = await VideoProcessor.processToFile(sourceFile());
|
|
156
|
+
|
|
157
|
+
expect(result.poster).toBeUndefined();
|
|
158
|
+
expect(result.blurhash).toBeUndefined();
|
|
159
|
+
// The half that does not need a decoder survives.
|
|
160
|
+
expect(result.durationSeconds).toBe(20);
|
|
161
|
+
expect(conversionOptions[0]?.video).not.toHaveProperty("quality");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("names the failing step on the console rather than failing silently", async () => {
|
|
165
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
166
|
+
trackStub.getCodec.mockRejectedValue(new Error("boom"));
|
|
167
|
+
|
|
168
|
+
await VideoProcessor.processToFile(sourceFile());
|
|
169
|
+
|
|
170
|
+
expect(warn).toHaveBeenCalledWith(
|
|
171
|
+
expect.stringContaining("read codec"),
|
|
172
|
+
expect.any(Error),
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { planVideoProcessing } from "../video-processor.js";
|
|
3
|
+
|
|
4
|
+
const OPTS = { maxLongEdge: 1920, maxShortEdge: 1080 };
|
|
5
|
+
|
|
6
|
+
const mbps = (n: number) => n * 1_000_000;
|
|
7
|
+
|
|
8
|
+
describe("planVideoProcessing", () => {
|
|
9
|
+
describe("copying", () => {
|
|
10
|
+
it("copies an H.264 video that already fits", () => {
|
|
11
|
+
expect(
|
|
12
|
+
planVideoProcessing(
|
|
13
|
+
{
|
|
14
|
+
width: 1280,
|
|
15
|
+
height: 720,
|
|
16
|
+
videoCodec: "avc",
|
|
17
|
+
videoBitrate: mbps(5),
|
|
18
|
+
},
|
|
19
|
+
OPTS,
|
|
20
|
+
),
|
|
21
|
+
).toEqual({
|
|
22
|
+
needsResize: false,
|
|
23
|
+
width: 1280,
|
|
24
|
+
height: 720,
|
|
25
|
+
videoNeedsReencode: false,
|
|
26
|
+
videoBitrate: undefined,
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("copies a video sitting exactly on the caps", () => {
|
|
31
|
+
const plan = planVideoProcessing(
|
|
32
|
+
{ width: 1920, height: 1080, videoCodec: "avc" },
|
|
33
|
+
OPTS,
|
|
34
|
+
);
|
|
35
|
+
expect(plan.needsResize).toBe(false);
|
|
36
|
+
expect(plan.videoNeedsReencode).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("re-encodes an HEVC video even when it fits", () => {
|
|
40
|
+
const plan = planVideoProcessing(
|
|
41
|
+
{ width: 1280, height: 720, videoCodec: "hevc", videoBitrate: mbps(5) },
|
|
42
|
+
OPTS,
|
|
43
|
+
);
|
|
44
|
+
expect(plan.needsResize).toBe(false);
|
|
45
|
+
expect(plan.videoNeedsReencode).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("re-encodes an unknown video codec", () => {
|
|
49
|
+
const plan = planVideoProcessing(
|
|
50
|
+
{ width: 1280, height: 720, videoCodec: null },
|
|
51
|
+
OPTS,
|
|
52
|
+
);
|
|
53
|
+
expect(plan.videoNeedsReencode).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("resizing", () => {
|
|
58
|
+
it("scales 4K down to the long-edge cap", () => {
|
|
59
|
+
const plan = planVideoProcessing(
|
|
60
|
+
{ width: 3840, height: 2160, videoCodec: "avc" },
|
|
61
|
+
OPTS,
|
|
62
|
+
);
|
|
63
|
+
expect(plan.needsResize).toBe(true);
|
|
64
|
+
expect(plan.width).toBe(1920);
|
|
65
|
+
expect(plan.height).toBe(1080);
|
|
66
|
+
expect(plan.videoNeedsReencode).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("caps portrait video by its short edge, not its long edge", () => {
|
|
70
|
+
const plan = planVideoProcessing(
|
|
71
|
+
{ width: 2160, height: 3840, videoCodec: "avc" },
|
|
72
|
+
OPTS,
|
|
73
|
+
);
|
|
74
|
+
expect(plan.width).toBe(1080);
|
|
75
|
+
expect(plan.height).toBe(1920);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("caps an ultra-wide video by its long edge", () => {
|
|
79
|
+
const plan = planVideoProcessing(
|
|
80
|
+
{ width: 3840, height: 1080, videoCodec: "avc" },
|
|
81
|
+
OPTS,
|
|
82
|
+
);
|
|
83
|
+
expect(plan.width).toBe(1920);
|
|
84
|
+
expect(plan.height).toBe(540);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("rounds scaled dimensions up to even numbers for H.264", () => {
|
|
88
|
+
const plan = planVideoProcessing(
|
|
89
|
+
{ width: 3840, height: 2158, videoCodec: "avc" },
|
|
90
|
+
OPTS,
|
|
91
|
+
);
|
|
92
|
+
expect(plan.width % 2).toBe(0);
|
|
93
|
+
expect(plan.height % 2).toBe(0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("falls back to the caps when dimensions are unknown", () => {
|
|
97
|
+
const plan = planVideoProcessing({ videoCodec: "avc" }, OPTS);
|
|
98
|
+
expect(plan.needsResize).toBe(false);
|
|
99
|
+
expect(plan.width).toBe(1920);
|
|
100
|
+
expect(plan.height).toBe(1080);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("bitrate", () => {
|
|
105
|
+
it("targets roughly 8 Mbps for a 1080p re-encode", () => {
|
|
106
|
+
const plan = planVideoProcessing(
|
|
107
|
+
{
|
|
108
|
+
width: 1920,
|
|
109
|
+
height: 1080,
|
|
110
|
+
videoCodec: "hevc",
|
|
111
|
+
videoBitrate: mbps(50),
|
|
112
|
+
},
|
|
113
|
+
OPTS,
|
|
114
|
+
);
|
|
115
|
+
expect(plan.videoBitrate).toBeGreaterThan(mbps(7));
|
|
116
|
+
expect(plan.videoBitrate).toBeLessThan(mbps(9));
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("never exceeds what the source content carries", () => {
|
|
120
|
+
// A 960×448 HEVC clip at 0.72 Mbps: re-encoding at the size-appropriate
|
|
121
|
+
// 1.8 Mbps would spend most of those bits on the source's own artifacts.
|
|
122
|
+
const plan = planVideoProcessing(
|
|
123
|
+
{
|
|
124
|
+
width: 960,
|
|
125
|
+
height: 448,
|
|
126
|
+
videoCodec: "hevc",
|
|
127
|
+
videoBitrate: 717_000,
|
|
128
|
+
},
|
|
129
|
+
OPTS,
|
|
130
|
+
);
|
|
131
|
+
// 0.72 Mbps HEVC ≈ 1.2 Mbps of H.264, well under the ~1.8 Mbps target.
|
|
132
|
+
expect(plan.videoBitrate).toBeGreaterThan(mbps(1));
|
|
133
|
+
expect(plan.videoBitrate).toBeLessThan(mbps(1.4));
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("allows H.264 more bits than the HEVC source it replaces", () => {
|
|
137
|
+
const source = { width: 1280, height: 720, videoBitrate: mbps(1) };
|
|
138
|
+
const fromHevc = planVideoProcessing(
|
|
139
|
+
{ ...source, videoCodec: "hevc" },
|
|
140
|
+
OPTS,
|
|
141
|
+
);
|
|
142
|
+
const fromAv1 = planVideoProcessing(
|
|
143
|
+
{ ...source, videoCodec: "av1" },
|
|
144
|
+
OPTS,
|
|
145
|
+
);
|
|
146
|
+
expect(fromHevc.videoBitrate).toBeGreaterThan(mbps(1));
|
|
147
|
+
expect(fromAv1.videoBitrate).toBeGreaterThan(fromHevc.videoBitrate!);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("scales the source cap down along with a downscale", () => {
|
|
151
|
+
// 4K at 40 Mbps → 1080p. The source cap must account for the quarter
|
|
152
|
+
// pixel count, not wave 40 Mbps of 4K bitrate through unchanged.
|
|
153
|
+
const plan = planVideoProcessing(
|
|
154
|
+
{
|
|
155
|
+
width: 3840,
|
|
156
|
+
height: 2160,
|
|
157
|
+
videoCodec: "hevc",
|
|
158
|
+
videoBitrate: mbps(40),
|
|
159
|
+
},
|
|
160
|
+
OPTS,
|
|
161
|
+
);
|
|
162
|
+
expect(plan.videoBitrate).toBeLessThan(mbps(9));
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("falls back to the size target when the source bitrate is unknown", () => {
|
|
166
|
+
const plan = planVideoProcessing(
|
|
167
|
+
{ width: 1920, height: 1080, videoCodec: "hevc" },
|
|
168
|
+
OPTS,
|
|
169
|
+
);
|
|
170
|
+
expect(plan.videoBitrate).toBeGreaterThan(mbps(7));
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("leaves the bitrate unset when the track is copied", () => {
|
|
174
|
+
const plan = planVideoProcessing(
|
|
175
|
+
{ width: 1280, height: 720, videoCodec: "avc", videoBitrate: mbps(3) },
|
|
176
|
+
OPTS,
|
|
177
|
+
);
|
|
178
|
+
expect(plan.videoBitrate).toBeUndefined();
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -1,23 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Client-side Audio Processor
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Remuxes audio files into an M4A container (MP4 audio-only) using
|
|
5
|
+
* mediabunny, transcoding to AAC only when the source is not already AAC.
|
|
6
|
+
* A source that needs nothing has its encoded packets copied across, so it
|
|
7
|
+
* arrives without a generation of re-encoding loss. Mirrors the
|
|
8
|
+
* video-processor pattern but discards any video track and skips
|
|
9
|
+
* poster/blurhash extraction.
|
|
7
10
|
*
|
|
8
11
|
* Requires WebCodecs API support — check `isSupported()` before use.
|
|
9
12
|
*/
|
|
10
13
|
|
|
11
|
-
import {
|
|
12
|
-
Input,
|
|
13
|
-
Output,
|
|
14
|
-
Mp4OutputFormat,
|
|
15
|
-
BufferTarget,
|
|
16
|
-
BlobSource,
|
|
17
|
-
Conversion,
|
|
18
|
-
QUALITY_HIGH,
|
|
19
|
-
ALL_FORMATS,
|
|
20
|
-
} from "mediabunny";
|
|
14
|
+
import { loadMediabunny } from "./mediabunny.js";
|
|
21
15
|
|
|
22
16
|
export interface AudioProcessResult {
|
|
23
17
|
file: File;
|
|
@@ -33,7 +27,8 @@ function isSupported(): boolean {
|
|
|
33
27
|
}
|
|
34
28
|
|
|
35
29
|
/**
|
|
36
|
-
* Process an audio file:
|
|
30
|
+
* Process an audio file: remux into an M4A (MP4) container, transcoding to
|
|
31
|
+
* AAC only when the source is not already AAC.
|
|
37
32
|
*
|
|
38
33
|
* @param file - Source audio file
|
|
39
34
|
* @param onProgress - Optional callback receiving progress from 0 to 1
|
|
@@ -43,6 +38,15 @@ async function processToFile(
|
|
|
43
38
|
file: File,
|
|
44
39
|
onProgress?: (progress: number) => void,
|
|
45
40
|
): Promise<AudioProcessResult> {
|
|
41
|
+
const {
|
|
42
|
+
Input,
|
|
43
|
+
Output,
|
|
44
|
+
Mp4OutputFormat,
|
|
45
|
+
BufferTarget,
|
|
46
|
+
BlobSource,
|
|
47
|
+
Conversion,
|
|
48
|
+
ALL_FORMATS,
|
|
49
|
+
} = await loadMediabunny();
|
|
46
50
|
const input = new Input({
|
|
47
51
|
source: new BlobSource(file),
|
|
48
52
|
formats: ALL_FORMATS,
|
|
@@ -54,11 +58,16 @@ async function processToFile(
|
|
|
54
58
|
});
|
|
55
59
|
|
|
56
60
|
try {
|
|
61
|
+
// Setting a quality on the track is what disables mediabunny's copy fast
|
|
62
|
+
// path, and a needless decode/encode round trip costs a generation of
|
|
63
|
+
// quality. Leaving it unset lets an AAC source be copied outright;
|
|
64
|
+
// anything else falls back to mediabunny's default, which for AAC lands
|
|
65
|
+
// on 192 kbps — the highest rate it will pick at any quality level.
|
|
57
66
|
const conversion = await Conversion.init({
|
|
58
67
|
input,
|
|
59
68
|
output,
|
|
60
69
|
video: { discard: true },
|
|
61
|
-
audio: { codec: "aac"
|
|
70
|
+
audio: { codec: "aac" },
|
|
62
71
|
});
|
|
63
72
|
|
|
64
73
|
if (onProgress) {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side Audio Waveform Extraction
|
|
3
|
+
*
|
|
4
|
+
* Decodes an audio file with the Web Audio API and samples the peaks the
|
|
5
|
+
* player draws. Image dimensions and blurhash come from `ImageProcessor`;
|
|
6
|
+
* video dimensions, poster, and blurhash come from `VideoProcessor`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Extract waveform peak amplitudes from an audio file.
|
|
11
|
+
* Decodes via Web Audio API and returns a JSON string of ~100 normalized peak values (0–1).
|
|
12
|
+
*
|
|
13
|
+
* @param file - Audio file to extract peaks from
|
|
14
|
+
* @returns JSON string of peak values, e.g. "[0.2,0.8,0.5,...]"
|
|
15
|
+
*/
|
|
16
|
+
export async function extractAudioWaveform(file: File): Promise<string> {
|
|
17
|
+
const buffer = await file.arrayBuffer();
|
|
18
|
+
const audioCtx = new AudioContext();
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const decoded = await audioCtx.decodeAudioData(buffer);
|
|
22
|
+
const raw = decoded.getChannelData(0);
|
|
23
|
+
const count = 100;
|
|
24
|
+
const step = Math.max(1, Math.floor(raw.length / count));
|
|
25
|
+
const peaks: number[] = new Array(count);
|
|
26
|
+
|
|
27
|
+
for (let i = 0; i < count; i++) {
|
|
28
|
+
let max = 0;
|
|
29
|
+
const start = i * step;
|
|
30
|
+
const end = Math.min(start + step, raw.length);
|
|
31
|
+
for (let j = start; j < end; j++) {
|
|
32
|
+
const v = Math.abs(raw[j]);
|
|
33
|
+
if (v > max) max = v;
|
|
34
|
+
}
|
|
35
|
+
peaks[i] = max;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let maxPeak = 0;
|
|
39
|
+
for (const p of peaks) if (p > maxPeak) maxPeak = p;
|
|
40
|
+
if (maxPeak > 0) {
|
|
41
|
+
for (let i = 0; i < count; i++)
|
|
42
|
+
peaks[i] = Math.round((peaks[i] / maxPeak) * 100) / 100;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return JSON.stringify(peaks);
|
|
46
|
+
} finally {
|
|
47
|
+
await audioCtx.close();
|
|
48
|
+
}
|
|
49
|
+
}
|