@jant/core 0.6.15 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -11
- package/bin/commands/assets/upload.js +192 -15
- package/bin/commands/import-site.js +67 -4
- package/bin/commands/reset-password.js +21 -8
- package/bin/commands/site/snapshot/export.js +1 -1
- package/bin/commands/site/snapshot/import.js +1 -1
- package/bin/lib/d1-query.js +5 -16
- package/bin/lib/node-env.js +40 -4
- package/bin/lib/site-selection.js +85 -29
- package/bin/lib/wrangler-cli.js +28 -2
- package/bin/lib/wrangler-json.js +35 -0
- package/dist/{app-C-oi_t8W.js → app-K_Aa1MMn.js} +52623 -13193
- package/dist/client/.vite/manifest.json +255 -90
- package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
- package/dist/client/_assets/chunks/create-editor-CD3FhrOB.js +257 -0
- package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
- package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
- package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
- package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
- package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
- package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
- package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
- package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
- package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
- package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
- package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
- package/dist/client/_assets/chunks/sortable-list-CgaL2jCs.js +2 -0
- package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
- package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
- package/dist/client/_assets/chunks/unsafe-svg-0QCkP0vZ.js +2 -0
- package/dist/client/_assets/client-GiYENVw8.css +2 -0
- package/dist/client/_assets/client-VnFxJN7G.js +295 -0
- package/dist/client/_assets/client-auth-k8gJ6QJj.js +828 -0
- package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
- package/dist/client/_assets/client-compose-B_kDtWkW.js +2004 -0
- package/dist/client/_assets/client-manage-M90aSTOk.js +2170 -0
- package/dist/client/_assets/client-settings-DfYs9n1F.js +1112 -0
- package/dist/{export-CsFx6F_M.js → github-sync-BPAvT999.js} +2343 -110
- package/dist/index.js +2 -5
- package/dist/node.js +5 -7
- package/package.json +3 -3
- package/src/__tests__/auth-session-freshness.test.ts +57 -0
- package/src/__tests__/basecoat-variants.test.ts +121 -0
- package/src/__tests__/bin/assets-upload.test.ts +68 -0
- package/src/__tests__/export-hugo-build.test.ts +260 -0
- package/src/__tests__/export-service.test.ts +231 -0
- package/src/__tests__/helpers/component-css.ts +91 -0
- package/src/__tests__/helpers/db.ts +33 -0
- package/src/__tests__/helpers/export-fixtures.ts +3 -0
- package/src/__tests__/mise-config.test.ts +22 -0
- package/src/__tests__/stylesheet-audience.test.ts +159 -0
- package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
- package/src/app.tsx +71 -16
- package/src/auth.ts +18 -1
- package/src/client/__tests__/collection-page-actions.test.ts +51 -2
- package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
- package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
- package/src/client/__tests__/compose-bridge.test.ts +524 -2
- package/src/client/__tests__/compose-launch.test.ts +62 -1
- package/src/client/__tests__/compose-shortcuts.test.ts +57 -8
- package/src/client/__tests__/compose-triggers.test.ts +90 -0
- package/src/client/__tests__/copy-field.test.ts +162 -0
- package/src/client/__tests__/custom-url-menu.test.ts +127 -6
- package/src/client/__tests__/image-processor.test.ts +324 -2
- package/src/client/__tests__/lazy-entries.test.ts +92 -0
- package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
- package/src/client/__tests__/runtime-paths.test.ts +84 -0
- package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
- package/src/client/__tests__/toast.test.ts +88 -1
- package/src/client/__tests__/video-processor-probe.test.ts +175 -0
- package/src/client/__tests__/video-processor.test.ts +181 -0
- package/src/client/audio-processor.ts +24 -15
- package/src/client/audio-waveform.ts +49 -0
- package/src/client/collection-dialog-host.ts +72 -0
- package/src/client/collection-navigation.ts +51 -16
- package/src/client/collection-page-actions.ts +26 -0
- package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
- package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
- package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
- package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
- package/src/client/components/__tests__/jant-command-palette.test.ts +79 -0
- package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +3283 -389
- package/src/client/components/__tests__/jant-compose-editor.test.ts +581 -86
- package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +34 -89
- package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
- package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
- package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
- package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
- package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
- package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
- package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
- package/src/client/components/collection-dialog-types.ts +49 -0
- package/src/client/components/collection-manager-types.ts +32 -1
- package/src/client/components/collection-types.ts +1 -15
- package/src/client/components/compose-format-convert.ts +5 -1
- package/src/client/components/compose-types.ts +121 -3
- package/src/client/components/jant-collection-dialog.ts +722 -0
- package/src/client/components/jant-collection-directory.ts +567 -182
- package/src/client/components/jant-collection-form.ts +53 -232
- package/src/client/components/jant-command-palette.ts +106 -49
- package/src/client/components/jant-compose-dialog.ts +2757 -1194
- package/src/client/components/jant-compose-editor.ts +866 -397
- package/src/client/components/jant-compose-fullscreen.ts +53 -47
- package/src/client/components/jant-config-editor.ts +65 -53
- package/src/client/components/jant-confirm-dialog.ts +13 -11
- package/src/client/components/jant-locale-picker.ts +418 -0
- package/src/client/components/jant-media-lightbox.ts +146 -136
- package/src/client/components/jant-nav-manager.ts +690 -366
- package/src/client/components/jant-post-menu.ts +1142 -268
- package/src/client/components/jant-post-picker.ts +324 -0
- package/src/client/components/jant-repo-picker.ts +138 -106
- package/src/client/components/jant-settings-avatar.ts +31 -27
- package/src/client/components/jant-settings-general.ts +617 -543
- package/src/client/components/jant-settings-language.ts +1087 -0
- package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
- package/src/client/components/jant-text-preview.ts +94 -86
- package/src/client/components/nav-manager-types.ts +62 -1
- package/src/client/components/settings-types.ts +14 -29
- package/src/client/components/smart-collection-conditions.ts +285 -0
- package/src/client/components/smart-collection-dialog-types.ts +68 -0
- package/src/client/compose-bridge.ts +336 -242
- package/src/client/compose-launch.ts +89 -2
- package/src/client/compose-shortcuts.ts +9 -7
- package/src/client/compose-triggers.ts +50 -0
- package/src/client/copy-field.ts +108 -0
- package/src/client/custom-url-menu.ts +60 -4
- package/src/client/icons.ts +14 -0
- package/src/client/image-plan.ts +165 -0
- package/src/client/image-processor.ts +195 -117
- package/src/client/image-worker-client.ts +45 -0
- package/src/client/image-worker.ts +135 -0
- package/src/client/lazy-entries.ts +95 -0
- package/src/client/lazy-slugify.ts +2 -2
- package/src/client/media-scroll-hint.ts +43 -2
- package/src/client/mediabunny.ts +29 -0
- package/src/client/post-picker.ts +41 -0
- package/src/client/post-refresh.ts +135 -0
- package/src/client/runtime-paths.ts +74 -1
- package/src/client/settings-bridge.ts +4 -10
- package/src/client/site-header-nav.js +18 -6
- package/src/client/smart-collection-dialog-host.ts +116 -0
- package/src/client/smart-collection-page-actions.ts +208 -0
- package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
- package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
- package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
- package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
- package/src/client/tiptap/bubble-menu.ts +47 -16
- package/src/client/tiptap/floating-position.ts +216 -2
- package/src/client/tiptap/inline-image-upload.ts +7 -3
- package/src/client/tiptap/link-input-rules.ts +4 -4
- package/src/client/tiptap/link-toolbar.ts +22 -9
- package/src/client/toast.ts +143 -38
- package/src/client/types/vite-worker.d.ts +12 -0
- package/src/client/upload-with-metadata.ts +3 -13
- package/src/client/video-processor.ts +351 -107
- package/src/client-auth.ts +14 -31
- package/src/client-compose.ts +19 -0
- package/src/client-manage.ts +13 -0
- package/src/client-settings.ts +14 -0
- package/src/client-site.ts +10 -3
- package/src/client.ts +4 -0
- package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
- package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
- package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
- package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
- package/src/db/__tests__/thread-activity.test.ts +113 -0
- package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
- package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
- package/src/db/migrations/0031_many_ego.sql +3 -0
- package/src/db/migrations/0032_married_tomas.sql +3 -0
- package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
- package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
- package/src/db/migrations/meta/0031_snapshot.json +2562 -0
- package/src/db/migrations/meta/0032_snapshot.json +2582 -0
- package/src/db/migrations/meta/0033_snapshot.json +2816 -0
- package/src/db/migrations/meta/0034_snapshot.json +2816 -0
- package/src/db/migrations/meta/_journal.json +28 -0
- package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
- package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
- package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
- package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
- package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
- package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
- package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
- package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
- package/src/db/migrations/pg/meta/_journal.json +28 -0
- package/src/db/pg/schema.ts +213 -27
- package/src/db/post-visibility.ts +117 -0
- package/src/db/schema.ts +242 -27
- package/src/db/thread-activity.ts +101 -0
- package/src/i18n/__tests__/detect.test.ts +26 -81
- package/src/i18n/__tests__/supported-locales.test.ts +83 -0
- package/src/i18n/context.tsx +12 -2
- package/src/i18n/detect.ts +7 -66
- package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
- package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
- package/src/i18n/locales/public/en.po +568 -168
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +562 -162
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +562 -162
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/i18n/locales/settings/en.po +551 -69
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +597 -115
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +610 -128
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/i18n/locales.ts +94 -0
- package/src/i18n/middleware.ts +12 -2
- package/src/i18n/supported-locales.ts +80 -0
- package/src/lib/__tests__/api-settings.test.ts +11 -2
- package/src/lib/__tests__/asset-path.test.ts +92 -0
- package/src/lib/__tests__/deferred.test.ts +90 -0
- package/src/lib/__tests__/discover-ping.test.ts +89 -0
- package/src/lib/__tests__/discover.test.ts +410 -0
- package/src/lib/__tests__/feed-policy.test.ts +26 -0
- package/src/lib/__tests__/feed.test.ts +1691 -12
- package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
- package/src/lib/__tests__/hosted-signin.test.ts +1 -1
- package/src/lib/__tests__/http-cache.test.ts +162 -0
- package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
- package/src/lib/__tests__/jant-docs.test.ts +23 -0
- package/src/lib/__tests__/lang-detect.test.ts +231 -0
- package/src/lib/__tests__/markdown.test.ts +16 -0
- package/src/lib/__tests__/media-helpers.test.ts +81 -0
- package/src/lib/__tests__/navigation.test.ts +135 -1
- package/src/lib/__tests__/resolve-config.test.ts +64 -3
- package/src/lib/__tests__/schemas.test.ts +42 -4
- package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
- package/src/lib/__tests__/slug.test.ts +88 -0
- package/src/lib/__tests__/slugify.test.ts +85 -0
- package/src/lib/__tests__/storage.test.ts +32 -0
- package/src/lib/__tests__/thread-fold.test.ts +113 -0
- package/src/lib/__tests__/translit.test.ts +87 -0
- package/src/lib/__tests__/upload-sideload.test.ts +14 -0
- package/src/lib/__tests__/url-fetch.test.ts +1 -2
- package/src/lib/__tests__/url.test.ts +93 -34
- package/src/lib/__tests__/view-language.test.ts +413 -0
- package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
- package/src/lib/address.ts +57 -0
- package/src/lib/api-posts.ts +12 -0
- package/src/lib/asset-path.ts +98 -1
- package/src/lib/build-env.ts +16 -0
- package/src/lib/collection-groups.ts +5 -1
- package/src/lib/collection-paths.ts +3 -10
- package/src/lib/constants.ts +28 -3
- package/src/lib/copy-field.ts +36 -0
- package/src/lib/crypto.ts +40 -0
- package/src/lib/csp-builder.ts +2 -1
- package/src/lib/deferred.ts +79 -0
- package/src/lib/discover-ping.ts +141 -0
- package/src/lib/discover.ts +437 -0
- package/src/lib/env.ts +134 -0
- package/src/lib/errors.ts +33 -0
- package/src/lib/feed-policy.ts +236 -2
- package/src/lib/feed.ts +1021 -66
- package/src/lib/filter-dimensions.ts +1349 -0
- package/src/lib/github-sync-site-config.ts +3 -0
- package/src/lib/hosted-signin.ts +1 -1
- package/src/lib/http-cache.ts +148 -0
- package/src/lib/hugo-markdown.ts +21 -0
- package/src/lib/icons.ts +9 -2
- package/src/lib/ids.ts +3 -0
- package/src/lib/jant-docs.ts +42 -0
- package/src/lib/lang-detect.ts +308 -0
- package/src/lib/link-preview.ts +25 -0
- package/src/lib/markdown-manager.ts +6 -1
- package/src/lib/media-helpers.ts +13 -1
- package/src/lib/navigation.ts +78 -11
- package/src/lib/pagination.ts +28 -24
- package/src/lib/per-language-surfaces.ts +67 -0
- package/src/lib/post-display.ts +42 -9
- package/src/lib/post-meta.ts +19 -0
- package/src/lib/render.tsx +55 -7
- package/src/lib/resolve-config.ts +53 -5
- package/src/lib/schemas.ts +201 -10
- package/src/lib/site-header-fragment.tsx +2 -1
- package/src/lib/site-unavailable-page.ts +47 -0
- package/src/lib/slug.ts +51 -7
- package/src/lib/slugify.ts +113 -0
- package/src/lib/startup-config.ts +4 -6
- package/src/lib/storage.ts +7 -0
- package/src/lib/summary.ts +50 -0
- package/src/lib/thread-fold.ts +161 -0
- package/src/lib/time.ts +32 -0
- package/src/lib/timeline.ts +81 -10
- package/src/lib/tiptap-render.ts +5 -4
- package/src/lib/translit.ts +288 -0
- package/src/lib/upload.ts +16 -13
- package/src/lib/url.ts +188 -24
- package/src/lib/version.ts +10 -8
- package/src/lib/view-language.ts +436 -0
- package/src/lib/view.ts +79 -23
- package/src/lib/viewer-context.ts +62 -0
- package/src/middleware/__tests__/auth.test.ts +70 -2
- package/src/middleware/__tests__/cache-control.test.ts +71 -3
- package/src/middleware/__tests__/onboarding.test.ts +76 -5
- package/src/middleware/__tests__/secure-headers.test.ts +1 -1
- package/src/middleware/__tests__/session.test.ts +181 -33
- package/src/middleware/auth.ts +50 -31
- package/src/middleware/cache-control.ts +75 -10
- package/src/middleware/config.ts +6 -6
- package/src/middleware/error-handler.ts +12 -0
- package/src/middleware/onboarding.ts +32 -10
- package/src/middleware/secure-headers.ts +1 -1
- package/src/middleware/session.ts +96 -8
- package/src/node/__tests__/cli-migrate.test.ts +21 -19
- package/src/node/__tests__/cli-reset-password.test.ts +81 -8
- package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
- package/src/node/__tests__/cli-site-selection.test.ts +128 -38
- package/src/node/__tests__/runtime.test.ts +37 -0
- package/src/node/index.ts +1 -0
- package/src/preset.css +76 -22
- package/src/routes/__tests__/feed-validator.test.ts +156 -0
- package/src/routes/api/__tests__/collections.test.ts +114 -0
- package/src/routes/api/__tests__/discover.test.ts +222 -0
- package/src/routes/api/__tests__/nav-items.test.ts +212 -2
- package/src/routes/api/__tests__/palette.test.ts +44 -0
- package/src/routes/api/__tests__/post-translations.test.ts +829 -0
- package/src/routes/api/__tests__/settings.test.ts +24 -3
- package/src/routes/api/__tests__/telegram.test.ts +6 -5
- package/src/routes/api/collections.ts +49 -6
- package/src/routes/api/custom-urls.ts +0 -1
- package/src/routes/api/discover.ts +95 -0
- package/src/routes/api/export.ts +3 -0
- package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
- package/src/routes/api/nav-items.ts +55 -8
- package/src/routes/api/posts.ts +172 -9
- package/src/routes/api/public/__tests__/archive.test.ts +111 -0
- package/src/routes/api/public/__tests__/posts.test.ts +38 -0
- package/src/routes/api/public/archive.ts +77 -81
- package/src/routes/api/public/posts.ts +30 -6
- package/src/routes/api/smart-collections.ts +132 -0
- package/src/routes/api/telegram.ts +2 -40
- package/src/routes/api/uploads.ts +1 -2
- package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
- package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
- package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
- package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
- package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
- package/src/routes/auth/__tests__/setup.test.ts +278 -26
- package/src/routes/auth/__tests__/signin.test.ts +135 -0
- package/src/routes/auth/setup.tsx +934 -186
- package/src/routes/auth/signin.tsx +10 -8
- package/src/routes/compose.tsx +11 -2
- package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
- package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
- package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
- package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
- package/src/routes/dash/custom-urls.tsx +1 -37
- package/src/routes/dash/settings.tsx +502 -41
- package/src/routes/discover-announce.ts +96 -0
- package/src/routes/feed/__tests__/feed.test.ts +165 -2
- package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
- package/src/routes/feed/feed.ts +69 -40
- package/src/routes/feed/sitemap.ts +95 -11
- package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
- package/src/routes/pages/__tests__/archive-params.test.ts +657 -1
- package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
- package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
- package/src/routes/pages/__tests__/collections.test.ts +9 -3
- package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
- package/src/routes/pages/__tests__/featured.test.ts +6 -2
- package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
- package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
- package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
- package/src/routes/pages/__tests__/preview.test.ts +3 -1
- package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
- package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
- package/src/routes/pages/archive.tsx +507 -357
- package/src/routes/pages/collection.tsx +94 -96
- package/src/routes/pages/collections.tsx +38 -50
- package/src/routes/pages/featured.tsx +32 -13
- package/src/routes/pages/home.tsx +25 -6
- package/src/routes/pages/language.tsx +113 -0
- package/src/routes/pages/latest.tsx +26 -16
- package/src/routes/pages/new.tsx +3 -0
- package/src/routes/pages/page.tsx +183 -19
- package/src/routes/pages/partials.tsx +41 -0
- package/src/routes/pages/search.tsx +18 -3
- package/src/routes/pages/smart-collection.tsx +340 -0
- package/src/routes/pages/subscribe.tsx +182 -0
- package/src/runtime/__tests__/index.test.ts +19 -0
- package/src/runtime/__tests__/node.test.ts +129 -4
- package/src/runtime/cloudflare.ts +21 -2
- package/src/runtime/index.ts +27 -1
- package/src/runtime/node.ts +18 -1
- package/src/runtime/site.ts +95 -24
- package/src/services/__tests__/auth.test.ts +116 -7
- package/src/services/__tests__/collection.test.ts +139 -4
- package/src/services/__tests__/language.test.ts +495 -0
- package/src/services/__tests__/navigation.test.ts +213 -10
- package/src/services/__tests__/path.test.ts +180 -0
- package/src/services/__tests__/post-language.test.ts +830 -0
- package/src/services/__tests__/post-timeline.test.ts +142 -11
- package/src/services/__tests__/post.test.ts +323 -9
- package/src/services/__tests__/settings.test.ts +228 -17
- package/src/services/__tests__/site-admin.test.ts +5 -5
- package/src/services/__tests__/smart-collection.test.ts +651 -0
- package/src/services/about-page.ts +22 -1
- package/src/services/auth.ts +51 -49
- package/src/services/bootstrap.ts +188 -38
- package/src/services/collection-directory-position.ts +76 -0
- package/src/services/collection.ts +330 -47
- package/src/services/custom-url.ts +27 -22
- package/src/services/export-theme/assets/client-site.css +1 -1
- package/src/services/export-theme/assets/client-site.js +123 -123
- package/src/services/export-theme/layouts/_default/rss.xml +77 -1
- package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
- package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
- package/src/services/export-theme/layouts/partials/pagination.html +14 -13
- package/src/services/export-theme/layouts/partials/post-card.html +8 -4
- package/src/services/export-theme/layouts/partials/reply.html +2 -1
- package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
- package/src/services/export-theme/styles/main.css +13 -1
- package/src/services/export.ts +281 -24
- package/src/services/index.ts +34 -3
- package/src/services/language.ts +412 -0
- package/src/services/mcp.ts +3 -1
- package/src/services/media.ts +14 -6
- package/src/services/navigation.ts +429 -67
- package/src/services/path.ts +225 -7
- package/src/services/post.ts +1318 -252
- package/src/services/search.ts +48 -2
- package/src/services/settings.ts +238 -43
- package/src/services/site-admin.ts +24 -7
- package/src/services/site.ts +77 -1
- package/src/services/smart-collection.ts +636 -0
- package/src/services/telegram.ts +20 -22
- package/src/services/upload-session.ts +1 -3
- package/src/style-author.css +27 -0
- package/src/styles/components-author.css +1622 -0
- package/src/styles/components.css +24 -1585
- package/src/styles/site-media.css +20 -6
- package/src/styles/tokens.css +144 -13
- package/src/styles/ui-author.css +7602 -0
- package/src/styles/ui.css +1224 -7276
- package/src/types/app-context.ts +7 -0
- package/src/types/bindings.ts +3 -0
- package/src/types/config.ts +94 -15
- package/src/types/constants.ts +188 -16
- package/src/types/entities.ts +92 -1
- package/src/types/operations.ts +64 -2
- package/src/types/props.ts +168 -30
- package/src/types/views.ts +61 -1
- package/src/ui/__tests__/font-themes.test.ts +52 -12
- package/src/ui/compose/ComposeDialog.tsx +215 -21
- package/src/ui/compose/ComposePrompt.tsx +1 -5
- package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
- package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
- package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
- package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
- package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
- package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
- package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
- package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
- package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
- package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
- package/src/ui/dash/settings/GeneralContent.tsx +275 -113
- package/src/ui/dash/settings/LanguageContent.tsx +409 -0
- package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
- package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
- package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
- package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
- package/src/ui/dash/settings/discover-copy.ts +116 -0
- package/src/ui/feed/LinkCard.tsx +55 -45
- package/src/ui/feed/LinkPreview.tsx +2 -7
- package/src/ui/feed/NoteCard.tsx +9 -4
- package/src/ui/feed/PostStatusBadges.tsx +103 -17
- package/src/ui/feed/QuoteCard.tsx +55 -30
- package/src/ui/feed/ThreadPreview.tsx +6 -2
- package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
- package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
- package/src/ui/feed/thread-preview-state.ts +20 -6
- package/src/ui/font-themes.ts +39 -29
- package/src/ui/layouts/BaseLayout.tsx +155 -10
- package/src/ui/layouts/SiteLayout.tsx +192 -34
- package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
- package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
- package/src/ui/pages/ArchivePage.tsx +401 -301
- package/src/ui/pages/CollectionPage.tsx +86 -76
- package/src/ui/pages/CollectionsPage.tsx +5 -0
- package/src/ui/pages/ComposePage.tsx +8 -0
- package/src/ui/pages/PostPage.tsx +47 -0
- package/src/ui/pages/SearchPage.tsx +3 -3
- package/src/ui/pages/SmartCollectionPage.tsx +434 -0
- package/src/ui/pages/SubscribePage.tsx +236 -0
- package/src/ui/pages/__tests__/ArchivePage.test.tsx +122 -3
- package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
- package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
- package/src/ui/shared/CollectionDirectory.tsx +178 -3
- package/src/ui/shared/CollectionsManager.tsx +70 -17
- package/src/ui/shared/CopyField.tsx +127 -0
- package/src/ui/shared/DraftPreviewBar.tsx +25 -9
- package/src/ui/shared/EmptyPostContent.tsx +23 -0
- package/src/ui/shared/MediaGallery.tsx +125 -46
- package/src/ui/shared/Pagination.tsx +20 -5
- package/src/ui/shared/PostFooter.tsx +55 -23
- package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
- package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
- package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
- package/src/ui/shared/__tests__/pagination.test.ts +38 -24
- package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
- package/src/ui/shared/archive-labels.ts +98 -0
- package/src/ui/shared/collection-management-labels.ts +94 -16
- package/src/ui/shared/custom-icons.ts +5 -0
- package/src/ui/shared/navigation-labels.ts +45 -71
- package/src/ui/shared/post-article-attributes.ts +8 -0
- package/src/ui/shared/smart-collection-labels.ts +343 -0
- package/dist/app-BxCOR3Uc.js +0 -6
- package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
- package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
- package/dist/client/_assets/chunks/url-pLre2DM_.js +0 -1
- package/dist/client/_assets/client-B_eGKN5p.css +0 -2
- package/dist/client/_assets/client-DxY5BFe8.js +0 -296
- package/dist/client/_assets/client-auth-CakPESv9.js +0 -5533
- package/dist/env-OHRKGcMj.js +0 -281
- package/dist/github-api-BgSiE71w.js +0 -176
- package/dist/github-app-BbklkFmU.js +0 -275
- package/dist/github-sync-Cq1pzzOI.js +0 -431
- package/dist/github-sync-ppWXN3jb.js +0 -4
- package/dist/url-BMYO-Zlt.js +0 -395
- package/src/client/__tests__/collection-created-notice.test.ts +0 -36
- package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
- package/src/client/__tests__/post-form-bridge.test.ts +0 -82
- package/src/client/collection-created-notice.ts +0 -66
- package/src/client/collection-form-bridge.ts +0 -191
- package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
- package/src/client/components/jant-post-form.ts +0 -422
- package/src/client/components/post-form-template.ts +0 -352
- package/src/client/components/post-form-types.ts +0 -96
- package/src/client/media-metadata.ts +0 -247
- package/src/client/post-form-bridge.ts +0 -193
- package/src/ui/pages/CollectionEditorPage.tsx +0 -175
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slug generation from free text.
|
|
3
|
+
*
|
|
4
|
+
* Lives apart from `url.ts` on purpose: `limax` drags the whole `pinyin-pro`
|
|
5
|
+
* dictionary (≈390 KB minified) into any bundle that imports it. Keeping the
|
|
6
|
+
* dictionary behind this single module lets the browser load it lazily via
|
|
7
|
+
* `client/lazy-slugify.ts`, while `url.ts` stays cheap enough for the public
|
|
8
|
+
* bundle. The build guard in `vite.shared.ts` fails the client build if
|
|
9
|
+
* `pinyin-pro` ever lands in an entry chunk again.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import limax from "limax";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
HAN_RE,
|
|
16
|
+
HANGUL_RE,
|
|
17
|
+
KANA_RE,
|
|
18
|
+
hangulToLatin,
|
|
19
|
+
kanaToRomaji,
|
|
20
|
+
} from "./translit.js";
|
|
21
|
+
|
|
22
|
+
const HAN_GLOBAL_RE = /\p{Script=Han}/gu;
|
|
23
|
+
const CONTENT_CHAR_RE = /[\p{L}\p{N}]/u;
|
|
24
|
+
const ASCII_ALNUM_RE = /[a-zA-Z0-9]/;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Minimum fraction of a title's letters/digits the transliteration pipeline
|
|
28
|
+
* must handle for the result to be usable. Below this, too much meaning is
|
|
29
|
+
* lost (e.g. mixed kanji+kana Japanese where the semantic core is kanji, or
|
|
30
|
+
* scripts limax drops entirely) and `slugify` returns "" so callers fall back
|
|
31
|
+
* to their random-ID path instead of shipping a garbled slug.
|
|
32
|
+
*/
|
|
33
|
+
const SLUG_COVERAGE_THRESHOLD = 0.75;
|
|
34
|
+
|
|
35
|
+
/** Per-character limax probe cache: does this char transliterate to anything? */
|
|
36
|
+
const charCoverageCache = new Map<string, boolean>();
|
|
37
|
+
|
|
38
|
+
function isCharCovered(char: string): boolean {
|
|
39
|
+
if (ASCII_ALNUM_RE.test(char)) return true;
|
|
40
|
+
let covered = charCoverageCache.get(char);
|
|
41
|
+
if (covered === undefined) {
|
|
42
|
+
covered = limax(char, { tone: false }).length > 0;
|
|
43
|
+
charCoverageCache.set(char, covered);
|
|
44
|
+
}
|
|
45
|
+
return covered;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function slugCoverage(text: string, japanese: boolean): number {
|
|
49
|
+
let content = 0;
|
|
50
|
+
let covered = 0;
|
|
51
|
+
for (const char of text) {
|
|
52
|
+
if (!CONTENT_CHAR_RE.test(char)) continue;
|
|
53
|
+
content++;
|
|
54
|
+
if (KANA_RE.test(char) || HANGUL_RE.test(char)) {
|
|
55
|
+
covered++;
|
|
56
|
+
} else if (japanese && HAN_RE.test(char)) {
|
|
57
|
+
// Kanji in a kana-containing title is dropped, never pinyin-read.
|
|
58
|
+
} else if (isCharCovered(char)) {
|
|
59
|
+
covered++;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return content === 0 ? 0 : covered / content;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Converts text to a URL-friendly slug.
|
|
67
|
+
*
|
|
68
|
+
* Transliterates i18n text to Latin before slugifying: Korean hangul via
|
|
69
|
+
* Revised Romanization, Japanese kana via Hepburn romaji (both in-house, see
|
|
70
|
+
* `translit.ts`), everything else (Han → pinyin, Cyrillic, accents, …) via
|
|
71
|
+
* limax. When the title contains kana it is treated as Japanese and Han
|
|
72
|
+
* characters are dropped rather than misread as Chinese pinyin; Han without
|
|
73
|
+
* kana is treated as Chinese.
|
|
74
|
+
*
|
|
75
|
+
* Returns `""` when transliteration would lose too much of the title (see
|
|
76
|
+
* `SLUG_COVERAGE_THRESHOLD`) — every caller treats an empty base as "no
|
|
77
|
+
* usable title" and falls back to a random ID, which beats publishing a
|
|
78
|
+
* garbled slug.
|
|
79
|
+
*
|
|
80
|
+
* @param text - The text to convert to a slug
|
|
81
|
+
* @returns The slugified string, or `""` when no usable slug can be derived
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* ```ts
|
|
85
|
+
* slugify("Hello World! This is a Test.");
|
|
86
|
+
* // Returns: "hello-world-this-is-a-test"
|
|
87
|
+
*
|
|
88
|
+
* slugify("书评");
|
|
89
|
+
* // Returns: "shu-ping"
|
|
90
|
+
*
|
|
91
|
+
* slugify("안녕하세요 세계");
|
|
92
|
+
* // Returns: "annyeonghaseyo-segye"
|
|
93
|
+
*
|
|
94
|
+
* slugify("ハンバーガー");
|
|
95
|
+
* // Returns: "hanbaga"
|
|
96
|
+
*
|
|
97
|
+
* slugify("日本語のタイトルです");
|
|
98
|
+
* // Returns: "" (kanji carries the meaning and can't be transliterated)
|
|
99
|
+
* ```
|
|
100
|
+
*/
|
|
101
|
+
export function slugify(text: string): string {
|
|
102
|
+
const japanese = KANA_RE.test(text);
|
|
103
|
+
let pre = text;
|
|
104
|
+
if (japanese) {
|
|
105
|
+
pre = kanaToRomaji(pre.replace(HAN_GLOBAL_RE, " "));
|
|
106
|
+
}
|
|
107
|
+
if (HANGUL_RE.test(pre)) {
|
|
108
|
+
pre = hangulToLatin(pre);
|
|
109
|
+
}
|
|
110
|
+
const base = limax(pre, { tone: false }).replace(/_/g, "-");
|
|
111
|
+
if (!base) return "";
|
|
112
|
+
return slugCoverage(text, japanese) >= SLUG_COVERAGE_THRESHOLD ? base : "";
|
|
113
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { escapeHtml } from "./html.js";
|
|
2
|
+
import { getJantDocsUrl } from "./jant-docs.js";
|
|
2
3
|
import {
|
|
3
4
|
getAuthSecret,
|
|
4
5
|
getHostedControlPlaneBaseUrl,
|
|
@@ -94,8 +95,7 @@ function getAuthSecretErrorHtml(kind: AuthSecretIssueKind): string {
|
|
|
94
95
|
return renderConfigurationErrorPage({
|
|
95
96
|
title: titleByKind[kind],
|
|
96
97
|
bodyHtml: `${leadByKind[kind]}${runtimeInstructions}`,
|
|
97
|
-
docsHref:
|
|
98
|
-
"https://github.com/jant-me/jant/blob/main/docs/configuration.md#required",
|
|
98
|
+
docsHref: getJantDocsUrl("configuration#required"),
|
|
99
99
|
});
|
|
100
100
|
}
|
|
101
101
|
|
|
@@ -219,8 +219,7 @@ function getHostBasedConfigurationErrorHtml(
|
|
|
219
219
|
return renderConfigurationErrorPage({
|
|
220
220
|
title: "Hosted configuration is incomplete",
|
|
221
221
|
bodyHtml: `<p>Jant is running with <code>SITE_RESOLUTION_MODE=host-based</code>, so hosted control-plane integration must be configured before the instance can serve requests.</p><ul>${itemsHtml}</ul>`,
|
|
222
|
-
docsHref:
|
|
223
|
-
"https://github.com/jant-me/jant/blob/main/docs/configuration.md#hosted-control-plane-integration-variables",
|
|
222
|
+
docsHref: getJantDocsUrl("configuration"),
|
|
224
223
|
});
|
|
225
224
|
}
|
|
226
225
|
|
|
@@ -243,8 +242,7 @@ export function getRuntimeConfigurationErrorPage(message: string): string {
|
|
|
243
242
|
return renderConfigurationErrorPage({
|
|
244
243
|
title: "Configuration Error",
|
|
245
244
|
bodyHtml: `<p>${escapeHtml(message)}</p><p>Update your environment or instance data, then restart Jant.</p>`,
|
|
246
|
-
docsHref:
|
|
247
|
-
"https://github.com/jant-me/jant/blob/main/docs/configuration.md#site-resolution",
|
|
245
|
+
docsHref: getJantDocsUrl("configuration"),
|
|
248
246
|
});
|
|
249
247
|
}
|
|
250
248
|
|
package/src/lib/storage.ts
CHANGED
|
@@ -432,6 +432,13 @@ export function createS3Driver(config: S3DriverConfig): StorageDriver {
|
|
|
432
432
|
// uploaded object checksums during finalize, so only enable response
|
|
433
433
|
// checksum verification when a request explicitly asks for it.
|
|
434
434
|
responseChecksumValidation: "WHEN_REQUIRED",
|
|
435
|
+
// Presigning runs with an empty body, so the SDK's default
|
|
436
|
+
// "WHEN_SUPPORTED" request checksums bake CRC32-of-nothing
|
|
437
|
+
// (x-amz-checksum-crc32=AAAAAA==) into every presigned PUT URL. The
|
|
438
|
+
// browser then uploads real bytes and the storage provider rejects
|
|
439
|
+
// the mismatch. Only send a request checksum when the caller asked
|
|
440
|
+
// for one (presignPut passes ChecksumSHA256 explicitly).
|
|
441
|
+
requestChecksumCalculation: "WHEN_REQUIRED",
|
|
435
442
|
});
|
|
436
443
|
return {
|
|
437
444
|
send: (cmd: unknown) => client.send(cmd as never),
|
package/src/lib/summary.ts
CHANGED
|
@@ -22,6 +22,21 @@ interface TiptapNode {
|
|
|
22
22
|
attrs?: Record<string, unknown>;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Summary limits for titled, article-style posts — the excerpt is a teaser
|
|
27
|
+
* and the reader continues on the post's own page.
|
|
28
|
+
*/
|
|
29
|
+
export const ARTICLE_SUMMARY_MAX_BLOCKS = 5;
|
|
30
|
+
export const ARTICLE_SUMMARY_MAX_CHARS = 500;
|
|
31
|
+
/**
|
|
32
|
+
* Larger limits for untitled notes — the body itself is the content, so the
|
|
33
|
+
* preview only exists to cap runaway length.
|
|
34
|
+
*/
|
|
35
|
+
export const NOTE_SUMMARY_MAX_BLOCKS = 10;
|
|
36
|
+
export const NOTE_SUMMARY_MAX_CHARS = 1500;
|
|
37
|
+
/** Don't truncate an untitled note just to hide a tail under this many chars. */
|
|
38
|
+
export const NOTE_SUMMARY_MIN_HIDDEN_CHARS = 200;
|
|
39
|
+
|
|
25
40
|
/**
|
|
26
41
|
* Block node types that carry user-visible content for summary extraction.
|
|
27
42
|
* Structural nodes (horizontalRule, moreBreak, image) are excluded.
|
|
@@ -348,3 +363,38 @@ export function extractSummaryHtml(
|
|
|
348
363
|
breakAtIndex: lastSelectedIdx + 1,
|
|
349
364
|
};
|
|
350
365
|
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Extract the summary the site's timeline shows for a post, at the boundary
|
|
369
|
+
* the timeline uses.
|
|
370
|
+
*
|
|
371
|
+
* Wraps {@link extractSummaryHtml} with the limits that decide where a card
|
|
372
|
+
* stops: a titled post is an article whose excerpt is a teaser, an untitled one
|
|
373
|
+
* is a note whose body is the content and only needs a cap. Callers that need
|
|
374
|
+
* that boundary — the feed renderer, the theme exporter — share this rather
|
|
375
|
+
* than repeating five numbers each.
|
|
376
|
+
*
|
|
377
|
+
* @param bodyJson - TipTap JSON body, or null/undefined for a post without one
|
|
378
|
+
* @param hasTitle - Whether the post has a title, which makes it an article
|
|
379
|
+
* @param renderOptions - Renderer options, notably the footnote namespace
|
|
380
|
+
* @returns The truncated HTML and whether content continues, or null when
|
|
381
|
+
* there is no TipTap document to truncate
|
|
382
|
+
* @example
|
|
383
|
+
* extractTimelineSummary(post.body, !!post.title, { namespace: post.id });
|
|
384
|
+
* // { html: "<p>Intro</p>", hasMore: true }
|
|
385
|
+
*/
|
|
386
|
+
export function extractTimelineSummary(
|
|
387
|
+
bodyJson: string | null | undefined,
|
|
388
|
+
hasTitle: boolean,
|
|
389
|
+
renderOptions: TiptapRenderOptions = {},
|
|
390
|
+
): { html: string; hasMore: boolean } | null {
|
|
391
|
+
if (!bodyJson) return null;
|
|
392
|
+
const result = extractSummaryHtml(
|
|
393
|
+
bodyJson,
|
|
394
|
+
hasTitle ? ARTICLE_SUMMARY_MAX_BLOCKS : NOTE_SUMMARY_MAX_BLOCKS,
|
|
395
|
+
hasTitle ? ARTICLE_SUMMARY_MAX_CHARS : NOTE_SUMMARY_MAX_CHARS,
|
|
396
|
+
hasTitle ? 0 : NOTE_SUMMARY_MIN_HIDDEN_CHARS,
|
|
397
|
+
renderOptions,
|
|
398
|
+
);
|
|
399
|
+
return result ? { html: result.html, hasMore: result.hasMore } : null;
|
|
400
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thread Fold
|
|
3
|
+
*
|
|
4
|
+
* How much of a thread the timeline shows, and how much it folds away.
|
|
5
|
+
*
|
|
6
|
+
* A thread appears in two places that cannot select its posts the same way.
|
|
7
|
+
* The site paints a page of timeline items and must not load whole threads to
|
|
8
|
+
* do it, so `getThreadTimelineContext` ranks replies in SQL and hands the
|
|
9
|
+
* buckets down. The feed already holds every reply — `<content>` carries the
|
|
10
|
+
* whole chain — so it slices the array it has. Two access paths, one rule.
|
|
11
|
+
*
|
|
12
|
+
* This module owns what they share: the thresholds, and the arithmetic that
|
|
13
|
+
* turns a selection into a hidden count. Whoever changes the shape of the fold
|
|
14
|
+
* changes it here, and both surfaces move together.
|
|
15
|
+
*
|
|
16
|
+
* ## The fold
|
|
17
|
+
*
|
|
18
|
+
* ```
|
|
19
|
+
* root · reply 1 · reply 2 · [N more posts] · 3rd-newest · 2nd-newest · newest
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* Six posts at most. A thread that fits arrives whole, with no gap at all —
|
|
23
|
+
* the gap appears only once something is actually left out.
|
|
24
|
+
*
|
|
25
|
+
* ## Where the gap points
|
|
26
|
+
*
|
|
27
|
+
* **At the first post it hides.** The link stands for a run of posts, and it
|
|
28
|
+
* opens the earliest of them, so a reader following "3 more posts" lands on
|
|
29
|
+
* the first of those three.
|
|
30
|
+
*
|
|
31
|
+
* It used to open the last *visible* post instead, on the reasoning that the
|
|
32
|
+
* detail page would then start just above the missing stretch, like a
|
|
33
|
+
* bookmark. That was a deliberate choice and it reads well on the site — but
|
|
34
|
+
* it makes the link mean something other than what its label says, and the
|
|
35
|
+
* feed cannot follow it: `<jant:thread>`'s `gap` attribute is documented as
|
|
36
|
+
* the first hidden post, and a consumer joining it against the `<jant:post>`
|
|
37
|
+
* rows would be handed a post that is on screen. One rule beats two, so both
|
|
38
|
+
* surfaces open the first hidden post.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import type { Post, PostView } from "../types.js";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Replies kept from the start of a thread, as context for the newest post.
|
|
45
|
+
*
|
|
46
|
+
* Also fixes where the SQL path's rank cut-off falls, so the two selections
|
|
47
|
+
* stay the same fold.
|
|
48
|
+
*/
|
|
49
|
+
export const THREAD_LEADING_REPLIES = 2;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Replies kept from the end of a thread, the newest one included — it is the
|
|
53
|
+
* hero, and the two before it are its immediate context.
|
|
54
|
+
*/
|
|
55
|
+
export const THREAD_TRAILING_REPLIES = 3;
|
|
56
|
+
|
|
57
|
+
/** Anything with an id, which is all the dedupe needs. */
|
|
58
|
+
type Identified = Pick<Post, "id"> | Pick<PostView, "id">;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* How many replies a fold leaves unshown.
|
|
62
|
+
*
|
|
63
|
+
* Deduplicated, because on a short thread the buckets overlap: with four
|
|
64
|
+
* replies the first is also the third-newest, and counting it twice would
|
|
65
|
+
* report posts hidden that are on screen.
|
|
66
|
+
*
|
|
67
|
+
* @param selection - The replies the fold shows, and the thread's total
|
|
68
|
+
* @returns Replies the fold hides, never negative
|
|
69
|
+
* @example
|
|
70
|
+
* getThreadHiddenCount({ leadingReplies: [r1, r2], trailingReplies: [r6, r7],
|
|
71
|
+
* latestReply: r8, totalReplyCount: 8 }); // 3
|
|
72
|
+
*/
|
|
73
|
+
export function getThreadHiddenCount({
|
|
74
|
+
leadingReplies,
|
|
75
|
+
trailingReplies,
|
|
76
|
+
latestReply,
|
|
77
|
+
totalReplyCount,
|
|
78
|
+
}: {
|
|
79
|
+
leadingReplies: Identified[];
|
|
80
|
+
trailingReplies: Identified[];
|
|
81
|
+
latestReply: Identified;
|
|
82
|
+
totalReplyCount: number;
|
|
83
|
+
}): number {
|
|
84
|
+
const shown = new Set(
|
|
85
|
+
[...leadingReplies, ...trailingReplies, latestReply].map((post) => post.id),
|
|
86
|
+
);
|
|
87
|
+
return Math.max(0, totalReplyCount - shown.size);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** What the fold shows of a thread, and what it hides. */
|
|
91
|
+
export interface ThreadFold<T> {
|
|
92
|
+
/** Earliest replies, kept as context above the gap. */
|
|
93
|
+
leadingReplies: T[];
|
|
94
|
+
/** Replies just before the newest, kept as its immediate context. */
|
|
95
|
+
trailingReplies: T[];
|
|
96
|
+
/** The newest reply — the hero the timeline builds the card around. */
|
|
97
|
+
latestReply: T;
|
|
98
|
+
/** First reply the fold hides, which is where the gap link points. */
|
|
99
|
+
firstHiddenReply: T | null;
|
|
100
|
+
/** How many replies the fold hides. */
|
|
101
|
+
hiddenCount: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Fold a thread whose replies are already in hand.
|
|
106
|
+
*
|
|
107
|
+
* Takes the same slices the SQL path ranks for: the first
|
|
108
|
+
* `THREAD_LEADING_REPLIES`, and the last `THREAD_TRAILING_REPLIES` with the
|
|
109
|
+
* newest as the hero. `getPublishedThreads` orders replies by
|
|
110
|
+
* `(threadId, createdAt, id)` ascending, which is the window function's own
|
|
111
|
+
* `ORDER BY`, so position in this array *is* the rank — no approximation.
|
|
112
|
+
*
|
|
113
|
+
* @param replies - Every published reply, oldest first
|
|
114
|
+
* @returns The fold, or null when the thread has no replies
|
|
115
|
+
* @example
|
|
116
|
+
* foldThreadReplies([r1, r2, r3, r4, r5, r6, r7, r8]);
|
|
117
|
+
* // leading [r1, r2], trailing [r6, r7], latest r8, firstHidden r3, hidden 3
|
|
118
|
+
*/
|
|
119
|
+
export function foldThreadReplies<T extends Identified>(
|
|
120
|
+
replies: T[],
|
|
121
|
+
): ThreadFold<T> | null {
|
|
122
|
+
const latestReply = replies.at(-1);
|
|
123
|
+
if (!latestReply) return null;
|
|
124
|
+
|
|
125
|
+
// A short thread's windows overlap, and the hero is inside both of them on
|
|
126
|
+
// the shortest ones: with a single reply, that reply is the newest *and* the
|
|
127
|
+
// whole leading window. Each list therefore drops whatever an earlier one
|
|
128
|
+
// already shows, hero first — the same order `ThreadPreview` dedupes in when
|
|
129
|
+
// it walks the buckets the SQL path hands it.
|
|
130
|
+
const leadingReplies = replies
|
|
131
|
+
.slice(0, THREAD_LEADING_REPLIES)
|
|
132
|
+
.filter((reply) => reply.id !== latestReply.id);
|
|
133
|
+
|
|
134
|
+
const leadingIds = new Set(leadingReplies.map((reply) => reply.id));
|
|
135
|
+
const trailingReplies = replies
|
|
136
|
+
.slice(-THREAD_TRAILING_REPLIES, -1)
|
|
137
|
+
.filter(
|
|
138
|
+
(reply) => reply.id !== latestReply.id && !leadingIds.has(reply.id),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const shown = new Set([
|
|
142
|
+
...leadingIds,
|
|
143
|
+
...trailingReplies.map((reply) => reply.id),
|
|
144
|
+
latestReply.id,
|
|
145
|
+
]);
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
leadingReplies,
|
|
149
|
+
trailingReplies,
|
|
150
|
+
latestReply,
|
|
151
|
+
// In thread order, so the gap link lands on the earliest post it stands
|
|
152
|
+
// for rather than somewhere in the middle of the run it hides.
|
|
153
|
+
firstHiddenReply: replies.find((reply) => !shown.has(reply.id)) ?? null,
|
|
154
|
+
hiddenCount: getThreadHiddenCount({
|
|
155
|
+
leadingReplies,
|
|
156
|
+
trailingReplies,
|
|
157
|
+
latestReply,
|
|
158
|
+
totalReplyCount: replies.length,
|
|
159
|
+
}),
|
|
160
|
+
};
|
|
161
|
+
}
|
package/src/lib/time.ts
CHANGED
|
@@ -223,3 +223,35 @@ export function formatYearMonth(timestamp: number, timeZone = "UTC"): string {
|
|
|
223
223
|
const month = parts.find((part) => part.type === "month")?.value ?? "01";
|
|
224
224
|
return `${year}-${month}`;
|
|
225
225
|
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Formats a "YYYY-MM" grouping key as a readable month label.
|
|
229
|
+
*
|
|
230
|
+
* The label is always en-US, matching the archive's other month copy — the
|
|
231
|
+
* grid header and the list marker read from this one function so their labels
|
|
232
|
+
* cannot drift apart.
|
|
233
|
+
*
|
|
234
|
+
* @param yearMonth - Year-month string in "YYYY-MM" format
|
|
235
|
+
* @returns Month label such as "February 2024", or null when the key is malformed
|
|
236
|
+
*
|
|
237
|
+
* @example
|
|
238
|
+
* ```ts
|
|
239
|
+
* const label = formatYearMonthLabel("2024-02");
|
|
240
|
+
* // Returns: "February 2024"
|
|
241
|
+
* ```
|
|
242
|
+
*/
|
|
243
|
+
export function formatYearMonthLabel(yearMonth: string): string | null {
|
|
244
|
+
const [year, month] = yearMonth.split("-");
|
|
245
|
+
if (!year || !month) return null;
|
|
246
|
+
|
|
247
|
+
const yearNumber = parseInt(year, 10);
|
|
248
|
+
const monthNumber = parseInt(month, 10);
|
|
249
|
+
if (!Number.isInteger(yearNumber) || !Number.isInteger(monthNumber)) {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return new Date(yearNumber, monthNumber - 1).toLocaleDateString("en-US", {
|
|
254
|
+
year: "numeric",
|
|
255
|
+
month: "long",
|
|
256
|
+
});
|
|
257
|
+
}
|
package/src/lib/timeline.ts
CHANGED
|
@@ -14,10 +14,25 @@ import type {
|
|
|
14
14
|
} from "../types.js";
|
|
15
15
|
import type { AppVariables } from "../types/app-context.js";
|
|
16
16
|
import { buildMediaMap } from "./media-helpers.js";
|
|
17
|
-
import { createMediaContext, toPostView } from "./view.js";
|
|
17
|
+
import { createMediaContext, resolveDraftTailId, toPostView } from "./view.js";
|
|
18
|
+
import { getViewLang } from "./view-language.js";
|
|
19
|
+
import { toPublicPath } from "./url.js";
|
|
18
20
|
|
|
19
21
|
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
20
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Language filter for the view the request is being served in.
|
|
25
|
+
*
|
|
26
|
+
* Read from the context rather than passed in, so a caller cannot forget it
|
|
27
|
+
* and quietly leak another language's posts into a language view.
|
|
28
|
+
*
|
|
29
|
+
* @param c - Hono context
|
|
30
|
+
* @returns Canonical tag to filter on, or undefined on the root view
|
|
31
|
+
*/
|
|
32
|
+
function viewLangFilter(c: Context<Env>): string | undefined {
|
|
33
|
+
return getViewLang(c) ?? undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
21
36
|
/**
|
|
22
37
|
* Result from assembling a timeline page.
|
|
23
38
|
*/
|
|
@@ -51,13 +66,32 @@ async function buildTimelineItems(
|
|
|
51
66
|
// Batch load media, collections, and latest-reply contexts in parallel
|
|
52
67
|
const postIds = posts.map((p) => p.id);
|
|
53
68
|
const mediaCtx = createMediaContext(c.var.appConfig);
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
69
|
+
const threadIds = posts.map((p) => p.threadId);
|
|
70
|
+
const [
|
|
71
|
+
rawMediaMap,
|
|
72
|
+
collectionsMap,
|
|
73
|
+
threadContexts,
|
|
74
|
+
aliasesMap,
|
|
75
|
+
publishedTails,
|
|
76
|
+
draftInclusiveTails,
|
|
77
|
+
] = await Promise.all([
|
|
78
|
+
c.var.services.media.getByPostIds(postIds),
|
|
79
|
+
c.var.services.collections.getCollectionsByPostIds(postIds),
|
|
80
|
+
c.var.services.posts.getThreadTimelineContext(postIds),
|
|
81
|
+
c.var.services.paths.getPostAliases(postIds),
|
|
82
|
+
// The feed never renders drafts — it would turn a reading surface into a
|
|
83
|
+
// workspace. So the author's trailing draft stays hidden here, and the
|
|
84
|
+
// reply affordance carries its ID instead of silently offering a reply
|
|
85
|
+
// the server will refuse.
|
|
86
|
+
c.var.isAuthenticated
|
|
87
|
+
? c.var.services.posts.getThreadTailIds(threadIds)
|
|
88
|
+
: Promise.resolve(new Map<string, string>()),
|
|
89
|
+
c.var.isAuthenticated
|
|
90
|
+
? c.var.services.posts.getThreadTailIds(threadIds, {
|
|
91
|
+
includeDrafts: true,
|
|
92
|
+
})
|
|
93
|
+
: Promise.resolve(new Map<string, string>()),
|
|
94
|
+
]);
|
|
61
95
|
const mediaMap = buildMediaMap(
|
|
62
96
|
rawMediaMap,
|
|
63
97
|
mediaCtx.r2PublicUrl,
|
|
@@ -69,11 +103,15 @@ async function buildTimelineItems(
|
|
|
69
103
|
|
|
70
104
|
// Batch load media for the bounded leading/trailing thread context.
|
|
71
105
|
const contextPostIds = new Set<string>();
|
|
106
|
+
const gapPostIds = new Set<string>();
|
|
72
107
|
for (const ctx of threadContexts.values()) {
|
|
73
108
|
contextPostIds.add(ctx.latestReply.id);
|
|
74
109
|
for (const reply of [...ctx.leadingReplies, ...ctx.trailingReplies]) {
|
|
75
110
|
contextPostIds.add(reply.id);
|
|
76
111
|
}
|
|
112
|
+
// The gap target is never rendered, so it needs no media or collections —
|
|
113
|
+
// only the alias that decides its address.
|
|
114
|
+
if (ctx.firstHiddenReply) gapPostIds.add(ctx.firstHiddenReply.id);
|
|
77
115
|
}
|
|
78
116
|
const [contextMediaMap, contextCollectionsMap, contextAliasesMap] =
|
|
79
117
|
contextPostIds.size > 0
|
|
@@ -93,7 +131,12 @@ async function buildTimelineItems(
|
|
|
93
131
|
c.var.services.collections.getCollectionsByPostIds([
|
|
94
132
|
...contextPostIds,
|
|
95
133
|
]),
|
|
96
|
-
|
|
134
|
+
// Gap targets ride along here and nowhere else: their address is
|
|
135
|
+
// all the gap link needs.
|
|
136
|
+
c.var.services.paths.getPostAliases([
|
|
137
|
+
...contextPostIds,
|
|
138
|
+
...gapPostIds,
|
|
139
|
+
]),
|
|
97
140
|
])
|
|
98
141
|
: [new Map(), new Map(), new Map<string, string[]>()];
|
|
99
142
|
|
|
@@ -113,6 +156,12 @@ async function buildTimelineItems(
|
|
|
113
156
|
firstAlias(post.id),
|
|
114
157
|
);
|
|
115
158
|
|
|
159
|
+
const draftTailId = resolveDraftTailId(
|
|
160
|
+
post.threadId,
|
|
161
|
+
publishedTails,
|
|
162
|
+
draftInclusiveTails,
|
|
163
|
+
);
|
|
164
|
+
|
|
116
165
|
const threadCtx = threadContexts.get(post.id);
|
|
117
166
|
|
|
118
167
|
if (threadCtx) {
|
|
@@ -142,9 +191,10 @@ async function buildTimelineItems(
|
|
|
142
191
|
},
|
|
143
192
|
mediaCtx,
|
|
144
193
|
contextCollectionsMap.get(threadCtx.latestReply.id),
|
|
145
|
-
true, // latestReply is the last post in the thread
|
|
194
|
+
true, // latestReply is the last published post in the thread
|
|
146
195
|
firstContextAlias(threadCtx.latestReply.id),
|
|
147
196
|
);
|
|
197
|
+
if (draftTailId) latestReplyView.draftTailId = draftTailId;
|
|
148
198
|
|
|
149
199
|
return {
|
|
150
200
|
post: postView,
|
|
@@ -152,11 +202,21 @@ async function buildTimelineItems(
|
|
|
152
202
|
leadingReplies: leadingReplyViews,
|
|
153
203
|
trailingReplies: trailingReplyViews,
|
|
154
204
|
latestReply: latestReplyView,
|
|
205
|
+
gapHref: threadCtx.firstHiddenReply
|
|
206
|
+
? toPublicPath(
|
|
207
|
+
`/${firstContextAlias(threadCtx.firstHiddenReply.id) ?? threadCtx.firstHiddenReply.slug}`,
|
|
208
|
+
mediaCtx.sitePathPrefix,
|
|
209
|
+
)
|
|
210
|
+
: undefined,
|
|
155
211
|
totalReplyCount: threadCtx.totalReplyCount,
|
|
156
212
|
},
|
|
157
213
|
};
|
|
158
214
|
}
|
|
159
215
|
|
|
216
|
+
// No published replies, so the root carries the reply affordance — and any
|
|
217
|
+
// trailing draft with it.
|
|
218
|
+
if (draftTailId) postView.draftTailId = draftTailId;
|
|
219
|
+
|
|
160
220
|
return { post: postView };
|
|
161
221
|
});
|
|
162
222
|
}
|
|
@@ -314,18 +374,22 @@ export async function assembleTimeline(
|
|
|
314
374
|
const excludePrivate = !(options?.isAuthenticated ?? false);
|
|
315
375
|
|
|
316
376
|
// Count + list are independent — run in parallel
|
|
377
|
+
const lang = viewLangFilter(c);
|
|
378
|
+
|
|
317
379
|
const [totalCount, posts] = await Promise.all([
|
|
318
380
|
c.var.services.posts.count({
|
|
319
381
|
status: "published",
|
|
320
382
|
excludeReplies: true,
|
|
321
383
|
excludeLatestHidden: true,
|
|
322
384
|
excludePrivate,
|
|
385
|
+
lang,
|
|
323
386
|
}),
|
|
324
387
|
c.var.services.posts.list({
|
|
325
388
|
status: "published",
|
|
326
389
|
excludeReplies: true,
|
|
327
390
|
excludeLatestHidden: true,
|
|
328
391
|
excludePrivate,
|
|
392
|
+
lang,
|
|
329
393
|
limit: pageSize,
|
|
330
394
|
offset,
|
|
331
395
|
}),
|
|
@@ -390,14 +454,18 @@ export async function assembleFeaturedTimeline(
|
|
|
390
454
|
const offset = (page - 1) * pageSize;
|
|
391
455
|
const excludePrivate = !(options?.isAuthenticated ?? false);
|
|
392
456
|
|
|
457
|
+
const lang = viewLangFilter(c);
|
|
458
|
+
|
|
393
459
|
const [totalCount, rootIds] = await Promise.all([
|
|
394
460
|
c.var.services.posts.countFeaturedThreadRoots({
|
|
395
461
|
status: "published",
|
|
396
462
|
excludePrivate,
|
|
463
|
+
lang,
|
|
397
464
|
}),
|
|
398
465
|
c.var.services.posts.listFeaturedThreadRootIds({
|
|
399
466
|
status: "published",
|
|
400
467
|
excludePrivate,
|
|
468
|
+
lang,
|
|
401
469
|
limit: pageSize,
|
|
402
470
|
offset,
|
|
403
471
|
}),
|
|
@@ -452,6 +520,7 @@ export async function assembleCollectionTimeline(
|
|
|
452
520
|
const page = Math.max(1, options.page ?? 1);
|
|
453
521
|
const offset = (page - 1) * pageSize;
|
|
454
522
|
const excludePrivate = !(options.isAuthenticated ?? false);
|
|
523
|
+
const lang = viewLangFilter(c);
|
|
455
524
|
|
|
456
525
|
const [totalCount, rootIds] = await Promise.all([
|
|
457
526
|
c.var.services.posts.countCollectionThreadRootsForCollections(
|
|
@@ -459,6 +528,7 @@ export async function assembleCollectionTimeline(
|
|
|
459
528
|
{
|
|
460
529
|
status: "published",
|
|
461
530
|
excludePrivate,
|
|
531
|
+
lang,
|
|
462
532
|
},
|
|
463
533
|
),
|
|
464
534
|
c.var.services.posts.listCollectionThreadRootIdsForCollections(
|
|
@@ -466,6 +536,7 @@ export async function assembleCollectionTimeline(
|
|
|
466
536
|
{
|
|
467
537
|
status: "published",
|
|
468
538
|
excludePrivate,
|
|
539
|
+
lang,
|
|
469
540
|
sortOrder: options.sortOrder,
|
|
470
541
|
limit: pageSize,
|
|
471
542
|
offset,
|
package/src/lib/tiptap-render.ts
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
import { renderEmbedFromAttrs } from "./embed-render.js";
|
|
15
15
|
import { escapeHtml } from "./html.js";
|
|
16
16
|
import { renderPublishedImageFigure } from "./rich-image.js";
|
|
17
|
-
import {
|
|
17
|
+
import { sanitizeRichTextHref } from "./url.js";
|
|
18
18
|
|
|
19
19
|
interface TiptapMark {
|
|
20
20
|
type: string;
|
|
@@ -48,8 +48,7 @@ export interface TiptapRenderOptions {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
export type TiptapRenderResult =
|
|
51
|
-
|
|
52
|
-
| { ok: false; error: string };
|
|
51
|
+
{ ok: true; html: string } | { ok: false; error: string };
|
|
53
52
|
|
|
54
53
|
interface FootnoteReferencePlan {
|
|
55
54
|
definitionId: string;
|
|
@@ -324,7 +323,9 @@ const MARK_RENDERERS: Record<string, MarkRenderer> = {
|
|
|
324
323
|
strike: (html) => `<s>${html}</s>`,
|
|
325
324
|
code: (html) => `<code>${html}</code>`,
|
|
326
325
|
link: (html, mark) => {
|
|
327
|
-
const href = escapeHtml(
|
|
326
|
+
const href = escapeHtml(
|
|
327
|
+
sanitizeRichTextHref(getStringAttr(mark.attrs, "href")),
|
|
328
|
+
);
|
|
328
329
|
const target = getStringAttr(mark.attrs, "target");
|
|
329
330
|
const targetAttr = target ? ` target="${escapeHtml(target)}"` : "";
|
|
330
331
|
const relAttr = target ? ' rel="noopener noreferrer"' : "";
|