@jant/core 0.6.16 → 0.7.1
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/export.js +3 -1
- package/bin/commands/import-site.js +749 -220
- package/bin/commands/reset-password.js +21 -8
- package/bin/commands/setup.js +136 -0
- package/bin/commands/site/export.js +71 -34
- package/bin/commands/site/pull-media.js +2 -6
- package/bin/commands/site/snapshot/export.js +19 -61
- package/bin/commands/site/snapshot/import.js +23 -24
- package/bin/lib/d1-query.js +92 -18
- package/bin/lib/hugo-markdown.js +4 -0
- package/bin/lib/node-env.js +40 -4
- package/bin/lib/site-pull-media.js +21 -28
- package/bin/lib/site-selection.js +94 -29
- package/bin/lib/site-snapshot.js +338 -3
- package/bin/lib/sql-export.js +68 -5
- package/bin/lib/wrangler-cli.js +37 -2
- package/bin/lib/wrangler-json.js +35 -0
- package/bin/lib/zip-archive.js +187 -0
- package/dist/{app-C7_4oab9.js → app-ZcaI1kPN.js} +53190 -13529
- 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-B-m7X7S5.js +261 -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-BJyd-LXE.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-BkscJx69.js +2 -0
- package/dist/client/_assets/client-DAhoqPdr.css +2 -0
- package/dist/client/_assets/client-auth-CAMfTrKW.js +828 -0
- package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
- package/dist/client/_assets/client-compose-D6rExKVK.js +2004 -0
- package/dist/client/_assets/client-knSEyUJO.js +295 -0
- package/dist/client/_assets/client-manage-sVOkqVbH.js +2170 -0
- package/dist/client/_assets/client-settings-CGR_nZ78.js +1112 -0
- package/dist/github-sync-Gw4orAHk.js +8398 -0
- package/dist/index.js +2 -5
- package/dist/node.js +11 -9
- package/package.json +15 -5
- 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__/dev-scripts.test.ts +203 -0
- package/src/__tests__/export-collection-order.test.ts +276 -0
- package/src/__tests__/export-feed-ids.test.ts +184 -0
- package/src/__tests__/export-feed-order.test.ts +294 -0
- package/src/__tests__/export-hugo-build.test.ts +390 -0
- package/src/__tests__/export-import-roundtrip.test.ts +94 -0
- package/src/__tests__/export-service.test.ts +842 -28
- package/src/__tests__/export-smart-collection.test.ts +329 -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__/helpers/hugo-site.ts +146 -0
- package/src/__tests__/import-site-command.test.ts +487 -1
- package/src/__tests__/mise-config.test.ts +75 -4
- package/src/__tests__/node-dev-tasks.test.ts +264 -0
- package/src/__tests__/site-export-canonical-import.test.ts +149 -0
- package/src/__tests__/snapshot-canonical-replay.test.ts +179 -0
- package/src/__tests__/snapshot-settings.test.ts +97 -0
- package/src/__tests__/snapshot-tables.test.ts +219 -0
- package/src/__tests__/sql-export.test.ts +173 -0
- package/src/__tests__/stylesheet-audience.test.ts +159 -0
- package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
- package/src/__tests__/zip-archive.test.ts +106 -0
- package/src/app.tsx +86 -22
- 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 +561 -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-types.ts +6 -1
- package/src/client/components/jant-repo-picker.ts +137 -108
- package/src/client/components/jant-settings-avatar.ts +31 -27
- package/src/client/components/jant-settings-general.ts +622 -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/__tests__/list-editing.test.ts +224 -86
- package/src/client/tiptap/__tests__/mark-exit.test.ts +1 -2
- package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +26 -0
- package/src/client/tiptap/bubble-menu.ts +47 -16
- package/src/client/tiptap/extensions.ts +0 -3
- 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/tiptap/structural-keymap.ts +158 -47
- 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__/d1-query.test.ts +56 -1
- 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 +555 -73
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +601 -119
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +614 -132
- 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__/github-sync-repo-name.test.ts +40 -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__/image.test.ts +27 -1
- 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-to-tiptap.test.ts +105 -0
- package/src/lib/__tests__/markdown.test.ts +10 -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 +131 -3
- package/src/lib/__tests__/schemas.test.ts +69 -5
- 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__/timeline.test.ts +87 -0
- package/src/lib/__tests__/tiptap-to-markdown.test.ts +172 -4
- 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 +439 -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-repo-name.ts +45 -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 +62 -0
- package/src/lib/icons.ts +9 -2
- package/src/lib/ids.ts +3 -0
- package/src/lib/image.ts +17 -4
- package/src/lib/jant-docs.ts +42 -0
- package/src/lib/lang-detect.ts +308 -0
- package/src/lib/markdown-manager.ts +392 -2
- 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-body-html.ts +11 -4
- package/src/lib/post-meta.ts +19 -0
- package/src/lib/render.tsx +55 -7
- package/src/lib/resolve-config.ts +111 -6
- package/src/lib/schemas.ts +251 -15
- 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 +43 -2
- package/src/lib/tiptap-render.ts +1 -2
- package/src/lib/tiptap-to-markdown.ts +13 -7
- package/src/lib/upload.ts +16 -13
- package/src/lib/url.ts +159 -86
- package/src/lib/version.ts +10 -8
- package/src/lib/view-language.ts +436 -0
- package/src/lib/view.ts +53 -25
- 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-setup.test.ts +163 -0
- package/src/node/__tests__/cli-site-selection.test.ts +128 -38
- package/src/node/__tests__/cli-site-snapshot.test.ts +67 -0
- package/src/node/__tests__/cli-snapshot-meta.test.ts +20 -0
- package/src/node/__tests__/runtime.test.ts +75 -0
- package/src/node/index.ts +3 -0
- package/src/node/request-handler.ts +3 -1
- 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__/posts.test.ts +24 -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/__tests__/upload.test.ts +34 -0
- 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 +6 -3
- package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
- package/src/routes/api/internal/sites.ts +0 -1
- package/src/routes/api/nav-items.ts +55 -8
- package/src/routes/api/posts.ts +161 -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 +44 -7
- package/src/routes/api/smart-collections.ts +132 -0
- package/src/routes/api/telegram.ts +2 -40
- package/src/routes/api/upload.ts +10 -3
- 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 +9 -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__/github-sync-app.test.ts +365 -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 +714 -111
- 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-page-round-trips.test.ts +211 -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/__tests__/thread-order.test.ts +179 -0
- package/src/routes/pages/archive.tsx +487 -379
- package/src/routes/pages/collection.tsx +94 -96
- package/src/routes/pages/collections.tsx +38 -50
- package/src/routes/pages/featured.tsx +43 -18
- 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 +228 -45
- package/src/routes/pages/partials.tsx +41 -0
- package/src/routes/pages/search.tsx +22 -5
- 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/__tests__/readiness.test.ts +23 -0
- package/src/runtime/cloudflare.ts +21 -2
- package/src/runtime/index.ts +27 -1
- package/src/runtime/node.ts +67 -1
- package/src/runtime/readiness.ts +15 -0
- package/src/runtime/site.ts +95 -24
- package/src/services/__tests__/auth.test.ts +116 -7
- package/src/services/__tests__/bootstrap-setup-instance.test.ts +230 -0
- package/src/services/__tests__/collection.test.ts +108 -4
- package/src/services/__tests__/custom-url.test.ts +34 -0
- package/src/services/__tests__/github-app-installations.test.ts +153 -0
- package/src/services/__tests__/github-sync-push.test.ts +46 -0
- package/src/services/__tests__/language.test.ts +495 -0
- package/src/services/__tests__/media.test.ts +31 -0
- package/src/services/__tests__/navigation.test.ts +213 -10
- package/src/services/__tests__/path.test.ts +236 -0
- package/src/services/__tests__/post-language.test.ts +830 -0
- package/src/services/__tests__/post-timeline.test.ts +127 -0
- package/src/services/__tests__/post.test.ts +120 -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 +408 -39
- package/src/services/collection-directory-position.ts +76 -0
- package/src/services/collection.ts +331 -46
- package/src/services/custom-url.ts +29 -24
- 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/alias.html +27 -1
- package/src/services/export-theme/layouts/_default/list.html +2 -63
- package/src/services/export-theme/layouts/_default/rss.xml +103 -38
- package/src/services/export-theme/layouts/collections/list.html +3 -3
- package/src/services/export-theme/layouts/featured/list.html +1 -4
- package/src/services/export-theme/layouts/index.html +40 -26
- package/src/services/export-theme/layouts/partials/collection-members.html +100 -0
- package/src/services/export-theme/layouts/partials/collection-threads.html +33 -0
- package/src/services/export-theme/layouts/partials/featured-members.html +35 -0
- package/src/services/export-theme/layouts/partials/featured-thread.html +1 -1
- package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
- package/src/services/export-theme/layouts/partials/footer.html +1 -1
- package/src/services/export-theme/layouts/partials/head.html +1 -1
- package/src/services/export-theme/layouts/partials/header.html +5 -3
- package/src/services/export-theme/layouts/partials/jant-data.html +23 -0
- package/src/services/export-theme/layouts/partials/latest-members.html +48 -0
- 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/smart-collection-members.html +129 -0
- package/src/services/export-theme/layouts/partials/thread-preview.html +41 -31
- package/src/services/export-theme/layouts/post/list.html +1 -1
- package/src/services/export-theme/layouts/smart_collection/list.html +24 -0
- package/src/services/export-theme/styles/main.css +13 -2
- package/src/services/export-theme/theme.toml +1 -1
- package/src/services/export.ts +880 -72
- package/src/services/github-app-installations.ts +171 -4
- package/src/services/github-sync.ts +69 -15
- package/src/services/index.ts +34 -3
- package/src/services/language.ts +412 -0
- package/src/services/mcp.ts +22 -2
- package/src/services/media.ts +22 -7
- package/src/services/navigation.ts +429 -67
- package/src/services/path.ts +254 -6
- package/src/services/post.ts +1234 -190
- package/src/services/search.ts +39 -2
- package/src/services/settings.ts +238 -43
- package/src/services/site-admin.ts +27 -14
- package/src/services/site.ts +160 -32
- 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 +1090 -7216
- package/src/types/app-context.ts +23 -0
- package/src/types/bindings.ts +8 -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 +74 -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 +277 -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/export-Dl5KCiEs.js +0 -5562
- 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/client/tiptap/exitable-marks.ts +0 -73
- package/src/ui/pages/CollectionEditorPage.tsx +0 -175
package/src/lib/schemas.ts
CHANGED
|
@@ -10,11 +10,13 @@
|
|
|
10
10
|
|
|
11
11
|
import { z } from "zod";
|
|
12
12
|
import {
|
|
13
|
+
ARCHIVE_LAYOUTS,
|
|
13
14
|
FORMATS,
|
|
14
15
|
STATUSES,
|
|
15
16
|
VISIBILITIES,
|
|
16
17
|
SORT_ORDERS,
|
|
17
18
|
COLLECTION_SORT_ORDERS,
|
|
19
|
+
SMART_COLLECTION_SORT_ORDERS,
|
|
18
20
|
NAV_ITEM_TYPES,
|
|
19
21
|
SYSTEM_NAV_KEY_VALUES,
|
|
20
22
|
MAX_MEDIA_ATTACHMENTS,
|
|
@@ -30,14 +32,20 @@ import {
|
|
|
30
32
|
type ConfigEditorDefinition,
|
|
31
33
|
type ConfigKey,
|
|
32
34
|
} from "../types.js";
|
|
33
|
-
import { isCjkSerifFont } from "../i18n/detect.js";
|
|
34
35
|
import {
|
|
36
|
+
formatLanguageList,
|
|
35
37
|
isLocale,
|
|
36
38
|
isValidContentLanguage,
|
|
37
39
|
normalizeContentLanguage,
|
|
40
|
+
parseLanguageList,
|
|
38
41
|
} from "../i18n/locales.js";
|
|
39
42
|
import { ValidationError } from "./errors.js";
|
|
40
43
|
import { createTypeIdSchema, ID_PREFIX } from "./ids.js";
|
|
44
|
+
import {
|
|
45
|
+
FILTER_DIMENSION_KEYS,
|
|
46
|
+
FILTER_DIMENSIONS,
|
|
47
|
+
type PostFilterSelection,
|
|
48
|
+
} from "./filter-dimensions.js";
|
|
41
49
|
import { normalizeSlug } from "./slug-format.js";
|
|
42
50
|
import { isReservedPath } from "./constants.js";
|
|
43
51
|
import { sanitizeUrl, normalizePath } from "./url.js";
|
|
@@ -69,6 +77,27 @@ export function normalizeEmail(email: string): string {
|
|
|
69
77
|
return email.trim().toLowerCase();
|
|
70
78
|
}
|
|
71
79
|
|
|
80
|
+
/**
|
|
81
|
+
* A display name to open an account under, before the author has given one.
|
|
82
|
+
*
|
|
83
|
+
* The first setup screen asks for credentials only, so better-auth needs a
|
|
84
|
+
* name it can create the row with; the second screen overwrites it with the
|
|
85
|
+
* site's name. This stands in only for the gap between the two, and for the
|
|
86
|
+
* setup that is abandoned in it — which is why it is the address the author
|
|
87
|
+
* just typed rather than a placeholder they would not recognise.
|
|
88
|
+
*
|
|
89
|
+
* @param email - The normalized address the account is being opened under
|
|
90
|
+
* @returns The part before the `@`, or the whole address when there is none
|
|
91
|
+
* @example
|
|
92
|
+
* ```ts
|
|
93
|
+
* deriveAccountName("owner@example.com");
|
|
94
|
+
* // Returns: "owner"
|
|
95
|
+
* ```
|
|
96
|
+
*/
|
|
97
|
+
export function deriveAccountName(email: string): string {
|
|
98
|
+
return email.split("@")[0]?.trim() || email;
|
|
99
|
+
}
|
|
100
|
+
|
|
72
101
|
/** Trim, strip control characters, and collapse to undefined when empty. */
|
|
73
102
|
function sanitizeText(maxLength: number) {
|
|
74
103
|
return z
|
|
@@ -166,13 +195,20 @@ export const NavItemTypeSchema = z.enum(NAV_ITEM_TYPES);
|
|
|
166
195
|
export const SystemNavKeySchema = z.enum(SYSTEM_NAV_KEY_VALUES);
|
|
167
196
|
|
|
168
197
|
/**
|
|
169
|
-
* Redirect type
|
|
170
|
-
*
|
|
198
|
+
* Redirect type, as forms send it ("301") or as the API answers it (301).
|
|
199
|
+
* Parses to the string form; stored as a number.
|
|
171
200
|
*/
|
|
172
|
-
export const RedirectTypeSchema = z.
|
|
201
|
+
export const RedirectTypeSchema = z.union([
|
|
202
|
+
z.enum(["301", "302"]),
|
|
203
|
+
z.literal(301).transform(() => "301" as const),
|
|
204
|
+
z.literal(302).transform(() => "302" as const),
|
|
205
|
+
]);
|
|
173
206
|
|
|
174
207
|
/**
|
|
175
|
-
* Custom URL target type enum schema
|
|
208
|
+
* Custom URL target type enum schema.
|
|
209
|
+
*
|
|
210
|
+
* Every kind a stored path can be, including `archive` — which is read and
|
|
211
|
+
* listed but no longer created. See {@link CreatableCustomUrlTargetTypeSchema}.
|
|
176
212
|
*/
|
|
177
213
|
export const CustomUrlTargetTypeSchema = z.enum([
|
|
178
214
|
"post",
|
|
@@ -181,6 +217,20 @@ export const CustomUrlTargetTypeSchema = z.enum([
|
|
|
181
217
|
"archive",
|
|
182
218
|
]);
|
|
183
219
|
|
|
220
|
+
/**
|
|
221
|
+
* The target kinds a new custom URL may take.
|
|
222
|
+
*
|
|
223
|
+
* `archive` is absent: hand-typing `format=note&title=none` into a text field
|
|
224
|
+
* is the problem smart collections exist to replace, and a form that stopped
|
|
225
|
+
* offering it while the endpoint still accepted it would not have stopped
|
|
226
|
+
* offering it. Existing archive paths keep working indefinitely.
|
|
227
|
+
*/
|
|
228
|
+
export const CreatableCustomUrlTargetTypeSchema = z.enum([
|
|
229
|
+
"post",
|
|
230
|
+
"collection",
|
|
231
|
+
"redirect",
|
|
232
|
+
]);
|
|
233
|
+
|
|
184
234
|
/**
|
|
185
235
|
* Rating schema (1-5 integer)
|
|
186
236
|
*/
|
|
@@ -193,9 +243,24 @@ export const RatingSchema = z.coerce
|
|
|
193
243
|
.or(z.literal("").transform(() => undefined))
|
|
194
244
|
.transform((v) => (v === 0 ? undefined : v));
|
|
195
245
|
|
|
246
|
+
/**
|
|
247
|
+
* Any syntactically valid BCP 47 tag, normalized to canonical case.
|
|
248
|
+
*
|
|
249
|
+
* Deliberately open: Jant ships dashboard catalogs for a handful of locales but
|
|
250
|
+
* content can be written in any language.
|
|
251
|
+
*/
|
|
252
|
+
export const ContentLanguageSchema = z
|
|
253
|
+
.string()
|
|
254
|
+
.trim()
|
|
255
|
+
.refine(isValidContentLanguage, {
|
|
256
|
+
message: "Enter a valid BCP 47 language tag (e.g. en, zh-Hans, ja).",
|
|
257
|
+
})
|
|
258
|
+
.transform(normalizeContentLanguage);
|
|
259
|
+
|
|
196
260
|
const PostIdSchema = createTypeIdSchema(ID_PREFIX.post);
|
|
197
261
|
const MediaIdSchema = createTypeIdSchema(ID_PREFIX.media);
|
|
198
262
|
const CollectionIdSchema = createTypeIdSchema(ID_PREFIX.collection);
|
|
263
|
+
const SmartCollectionIdSchema = createTypeIdSchema(ID_PREFIX.smartCollection);
|
|
199
264
|
const CollectionDirectoryItemIdSchema = createTypeIdSchema(
|
|
200
265
|
ID_PREFIX.collectionDirectoryItem,
|
|
201
266
|
);
|
|
@@ -294,6 +359,12 @@ const PostFieldsSchema = z.object({
|
|
|
294
359
|
.optional(),
|
|
295
360
|
replyToId: PostIdSchema.optional(),
|
|
296
361
|
quietReply: z.boolean().optional(),
|
|
362
|
+
// Content language. The post service normalizes and enforces the
|
|
363
|
+
// thread-uniform rule; this only rejects tags that are not BCP 47 at all.
|
|
364
|
+
language: ContentLanguageSchema.optional().or(
|
|
365
|
+
z.literal("").transform(() => undefined),
|
|
366
|
+
),
|
|
367
|
+
translationOfId: PostIdSchema.optional(),
|
|
297
368
|
publishedAt: z.number().int().positive().optional(),
|
|
298
369
|
mediaIds: z.array(MediaIdSchema).max(MAX_MEDIA_ATTACHMENTS).optional(),
|
|
299
370
|
mediaAlts: z.record(MediaIdSchema, z.string()).optional(),
|
|
@@ -480,15 +551,32 @@ function refineSlugPathExclusivity<T extends { slug?: string; path?: string }>(
|
|
|
480
551
|
});
|
|
481
552
|
}
|
|
482
553
|
|
|
554
|
+
/**
|
|
555
|
+
* Create only: a post's own record timestamps, in Unix seconds. An import or
|
|
556
|
+
* a migration restores them, so "last edited" (feed `<updated>`, sitemap
|
|
557
|
+
* `lastmod`) and Thread order (the root, then replies by creation time, then
|
|
558
|
+
* ID) survive the move.
|
|
559
|
+
* Omitted, both are the time of the request; `updatedAt` alone defaults to
|
|
560
|
+
* `createdAt`.
|
|
561
|
+
*/
|
|
562
|
+
const RestoredTimestampFields = {
|
|
563
|
+
createdAt: z.number().int().positive().optional(),
|
|
564
|
+
updatedAt: z.number().int().positive().optional(),
|
|
565
|
+
};
|
|
566
|
+
|
|
483
567
|
/**
|
|
484
568
|
* API request body schema for creating a post
|
|
485
569
|
*/
|
|
486
570
|
export const CreatePostSchema = refineSlugPathExclusivity(
|
|
487
|
-
refineCreatePostFormatShape(
|
|
571
|
+
refineCreatePostFormatShape(
|
|
572
|
+
refineBodyExclusivity(PostFieldsSchema.extend(RestoredTimestampFields)),
|
|
573
|
+
),
|
|
488
574
|
);
|
|
489
575
|
|
|
490
576
|
export const CreatePostApiSchema = refineSlugPathExclusivity(
|
|
491
|
-
refineCreatePostFormatShape(
|
|
577
|
+
refineCreatePostFormatShape(
|
|
578
|
+
refineBodyExclusivity(ApiPostFieldsSchema.extend(RestoredTimestampFields)),
|
|
579
|
+
),
|
|
492
580
|
);
|
|
493
581
|
|
|
494
582
|
/**
|
|
@@ -566,6 +654,14 @@ export const CreateNavItemSchema = z.discriminatedUnion("type", [
|
|
|
566
654
|
label: sanitizeText(100).pipe(z.string().min(1)).optional(),
|
|
567
655
|
placement: z.enum(["header", "more"]).optional(),
|
|
568
656
|
}),
|
|
657
|
+
// Placed exactly like a collection — the label follows the target's title
|
|
658
|
+
// and the URL follows its address; only which column holds the key differs.
|
|
659
|
+
z.object({
|
|
660
|
+
type: z.literal("smart_collection"),
|
|
661
|
+
smartCollectionId: SmartCollectionIdSchema,
|
|
662
|
+
label: sanitizeText(100).pipe(z.string().min(1)).optional(),
|
|
663
|
+
placement: z.enum(["header", "more"]).optional(),
|
|
664
|
+
}),
|
|
569
665
|
z.object({
|
|
570
666
|
type: z.literal("page"),
|
|
571
667
|
postId: PostIdSchema,
|
|
@@ -620,6 +716,19 @@ export const CreateCollectionDirectoryItemSchema = z.discriminatedUnion(
|
|
|
620
716
|
],
|
|
621
717
|
);
|
|
622
718
|
|
|
719
|
+
/**
|
|
720
|
+
* A row in the collections directory, as the drag surface names it.
|
|
721
|
+
*
|
|
722
|
+
* A placed row is named by its directory row id. A collection or smart
|
|
723
|
+
* collection that has never been placed is named by its own id, because that is
|
|
724
|
+
* all the directory has to render — moving one is what gives it a row.
|
|
725
|
+
*/
|
|
726
|
+
export const CollectionDirectoryRowIdSchema = z.union([
|
|
727
|
+
CollectionDirectoryItemIdSchema,
|
|
728
|
+
CollectionIdSchema,
|
|
729
|
+
SmartCollectionIdSchema,
|
|
730
|
+
]);
|
|
731
|
+
|
|
623
732
|
export const UpdateCollectionDirectoryItemSchema = z.object({
|
|
624
733
|
label: z.union([CollectionDirectoryLabelSchema, z.null()]).optional(),
|
|
625
734
|
url: CollectionDirectoryLinkUrlSchema.optional(),
|
|
@@ -633,6 +742,7 @@ export {
|
|
|
633
742
|
NavItemIdSchema,
|
|
634
743
|
PathIdSchema,
|
|
635
744
|
PostIdSchema,
|
|
745
|
+
SmartCollectionIdSchema,
|
|
636
746
|
};
|
|
637
747
|
|
|
638
748
|
/**
|
|
@@ -677,6 +787,61 @@ export const CreateCollectionSchema = z.object({
|
|
|
677
787
|
*/
|
|
678
788
|
export const UpdateCollectionSchema = CreateCollectionSchema.partial();
|
|
679
789
|
|
|
790
|
+
/**
|
|
791
|
+
* What a stored selection may hold — the validator a create or update request
|
|
792
|
+
* runs its conditions through.
|
|
793
|
+
*
|
|
794
|
+
* Assembled from the dimension registry rather than restated, so a new
|
|
795
|
+
* dimension is accepted by every endpoint the moment it is declared, and a
|
|
796
|
+
* dimension whose stored vocabulary is narrower than its URL one (`visibility`)
|
|
797
|
+
* is narrow everywhere at once.
|
|
798
|
+
*
|
|
799
|
+
* The registry itself carries plain predicates, not schemas, because the smart
|
|
800
|
+
* collection dialog compiles it into the browser bundle. The Zod wrapper lives
|
|
801
|
+
* here, on the server side of that line, where request bodies are validated and
|
|
802
|
+
* a rejected condition still has to say which one it was.
|
|
803
|
+
*/
|
|
804
|
+
export const PostFilterSelectionSchema = z
|
|
805
|
+
.object(
|
|
806
|
+
Object.fromEntries(
|
|
807
|
+
FILTER_DIMENSION_KEYS.map((key) => [
|
|
808
|
+
key,
|
|
809
|
+
z
|
|
810
|
+
.custom((value) => FILTER_DIMENSIONS[key].storage.isStorable(value), {
|
|
811
|
+
message: `Invalid value for the ${key} condition`,
|
|
812
|
+
})
|
|
813
|
+
.optional(),
|
|
814
|
+
]),
|
|
815
|
+
),
|
|
816
|
+
)
|
|
817
|
+
.strict() as unknown as z.ZodType<PostFilterSelection>;
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* API request body schema for creating a smart collection.
|
|
821
|
+
*
|
|
822
|
+
* The conditions come from the dimension registry rather than being restated
|
|
823
|
+
* here, so a new dimension is accepted the moment it is declared and a stored
|
|
824
|
+
* vocabulary narrower than the URL one (`visibility` never being `private`)
|
|
825
|
+
* is narrow at every entry point at once.
|
|
826
|
+
*/
|
|
827
|
+
export const CreateSmartCollectionSchema = z.object({
|
|
828
|
+
slug: CollectionSlugSchema,
|
|
829
|
+
title: CollectionTitleSchema,
|
|
830
|
+
description: CollectionDescriptionValueSchema.nullable().optional(),
|
|
831
|
+
// Omitted means no conditions, which is the honest spelling of "every post".
|
|
832
|
+
selection: PostFilterSelectionSchema.optional(),
|
|
833
|
+
sort: z.enum(SMART_COLLECTION_SORT_ORDERS).optional(),
|
|
834
|
+
layout: z.enum(ARCHIVE_LAYOUTS).nullable().optional(),
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
export const UpdateSmartCollectionSchema =
|
|
838
|
+
CreateSmartCollectionSchema.partial();
|
|
839
|
+
|
|
840
|
+
/** The body of a preview request: the conditions, and nothing else. */
|
|
841
|
+
export const SmartCollectionPreviewSchema = z.object({
|
|
842
|
+
selection: PostFilterSelectionSchema.optional(),
|
|
843
|
+
});
|
|
844
|
+
|
|
680
845
|
export const SiteNameSettingSchema = sanitizeSettingText(MAX_SITE_NAME_LENGTH);
|
|
681
846
|
export const SiteDescriptionSettingSchema = sanitizeSettingText(
|
|
682
847
|
MAX_SITE_DESCRIPTION_LENGTH,
|
|
@@ -808,11 +973,16 @@ export function normalizeEditableSettingValue(
|
|
|
808
973
|
);
|
|
809
974
|
}
|
|
810
975
|
break;
|
|
811
|
-
case "
|
|
812
|
-
if (
|
|
813
|
-
throw new ValidationError("
|
|
976
|
+
case "MULTILINGUAL_ENABLED":
|
|
977
|
+
if (normalized !== "true" && normalized !== "false") {
|
|
978
|
+
throw new ValidationError("Multilingual content is on or off.");
|
|
814
979
|
}
|
|
815
980
|
break;
|
|
981
|
+
case "ADDITIONAL_LANGUAGES":
|
|
982
|
+
// Re-serialized from the parsed form so a hand-written value cannot
|
|
983
|
+
// store blanks, duplicates, or non-canonical casing.
|
|
984
|
+
normalized = formatLanguageList(parseLanguageList(normalized));
|
|
985
|
+
break;
|
|
816
986
|
case "TIME_ZONE":
|
|
817
987
|
if (!isSupportedTimeZone(normalized)) {
|
|
818
988
|
throw new ValidationError("Choose a valid time zone.");
|
|
@@ -837,11 +1007,10 @@ export const CreateCustomUrlSchema = z.object({
|
|
|
837
1007
|
"Path must contain only lowercase alphanumeric characters, hyphens, slashes, and dots",
|
|
838
1008
|
)
|
|
839
1009
|
.transform((p) => (p.startsWith("/") ? p : `/${p}`)),
|
|
840
|
-
targetType:
|
|
1010
|
+
targetType: CreatableCustomUrlTargetTypeSchema,
|
|
841
1011
|
targetId: z.string().optional(),
|
|
842
1012
|
toPath: z.string().optional(),
|
|
843
1013
|
redirectType: RedirectTypeSchema.optional(),
|
|
844
|
-
archiveQuery: z.string().optional(),
|
|
845
1014
|
});
|
|
846
1015
|
|
|
847
1016
|
// =============================================================================
|
|
@@ -849,10 +1018,13 @@ export const CreateCustomUrlSchema = z.object({
|
|
|
849
1018
|
// =============================================================================
|
|
850
1019
|
|
|
851
1020
|
/**
|
|
852
|
-
*
|
|
1021
|
+
* The first of the two screens a self-hosted site is set up through.
|
|
1022
|
+
*
|
|
1023
|
+
* Credentials and nothing else. Everything about the site itself waits for the
|
|
1024
|
+
* second screen, which is the same screen a hosted site shows — so the two
|
|
1025
|
+
* install kinds converge instead of keeping two forms that drift.
|
|
853
1026
|
*/
|
|
854
|
-
export const
|
|
855
|
-
siteName: z.string().min(1, "Site name is required"),
|
|
1027
|
+
export const SetupAccountSchema = z.object({
|
|
856
1028
|
email: z
|
|
857
1029
|
.string()
|
|
858
1030
|
.transform(normalizeEmail)
|
|
@@ -863,6 +1035,70 @@ export const SetupSchema = z.object({
|
|
|
863
1035
|
.max(128),
|
|
864
1036
|
});
|
|
865
1037
|
|
|
1038
|
+
/**
|
|
1039
|
+
* Setup on a site whose shell already exists and is already named.
|
|
1040
|
+
*
|
|
1041
|
+
* A control plane created the site and its owner, so the only thing left to
|
|
1042
|
+
* settle is the language its author writes in — the one fact no header, host,
|
|
1043
|
+
* or billing record can supply.
|
|
1044
|
+
*/
|
|
1045
|
+
export const SetupLanguageSchema = z.object({
|
|
1046
|
+
contentLanguage: ContentLanguageSchema,
|
|
1047
|
+
/**
|
|
1048
|
+
* The Jant Discover answer, from the checkbox on the setup screen.
|
|
1049
|
+
*
|
|
1050
|
+
* Optional, and absence is not `false`: an older client or a scripted setup
|
|
1051
|
+
* sends nothing, and that has to keep meaning "leave the deployment default
|
|
1052
|
+
* in force" rather than "the author said no".
|
|
1053
|
+
*/
|
|
1054
|
+
discover: z.boolean().optional(),
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
/**
|
|
1058
|
+
* The second of the two self-hosted screens: the same questions the hosted
|
|
1059
|
+
* screen asks, plus the name only a self-hosted site is still missing.
|
|
1060
|
+
*
|
|
1061
|
+
* Which of the two applies is not a property of the form but of the site — a
|
|
1062
|
+
* site that already has a name is never asked for one — so the route picks the
|
|
1063
|
+
* schema by reading the stored name rather than trusting a field.
|
|
1064
|
+
*/
|
|
1065
|
+
export const SetupSiteSchema = SetupLanguageSchema.extend({
|
|
1066
|
+
// The message is set twice over on purpose: `.min(1)` catches a field left
|
|
1067
|
+
// blank, and the type-level `error` catches a form that carried no field at
|
|
1068
|
+
// all. Without the second, a scripted setup is told "expected string,
|
|
1069
|
+
// received undefined" — which names nothing the author can act on.
|
|
1070
|
+
siteName: z
|
|
1071
|
+
.string({ error: "Site name is required" })
|
|
1072
|
+
.min(1, "Site name is required"),
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
/** The answers the last setup screen collects from either install kind. */
|
|
1076
|
+
export type SetupLanguageAnswers = z.infer<typeof SetupLanguageSchema>;
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* Both setup screens' answers at once, for `jant setup`: a self-hosted install
|
|
1080
|
+
* set up by its deployment rather than in a browser.
|
|
1081
|
+
*
|
|
1082
|
+
* Only the credentials are required. Everything the second screen asks has
|
|
1083
|
+
* the default an empty answer gets there, and a browser's guesses — its
|
|
1084
|
+
* language, its time zone — have no counterpart here.
|
|
1085
|
+
*/
|
|
1086
|
+
export const InstanceSetupSchema = SetupAccountSchema.extend({
|
|
1087
|
+
siteId: createTypeIdSchema(ID_PREFIX.site).optional(),
|
|
1088
|
+
siteName: z
|
|
1089
|
+
.string()
|
|
1090
|
+
.trim()
|
|
1091
|
+
.min(1, "Site name cannot be empty")
|
|
1092
|
+
.max(MAX_SITE_NAME_LENGTH)
|
|
1093
|
+
.optional(),
|
|
1094
|
+
siteLanguage: ContentLanguageSchema.optional(),
|
|
1095
|
+
timeZone: z
|
|
1096
|
+
.string()
|
|
1097
|
+
.refine(isSupportedTimeZone, "Choose a valid time zone.")
|
|
1098
|
+
.transform(normalizeTimeZone)
|
|
1099
|
+
.optional(),
|
|
1100
|
+
});
|
|
1101
|
+
|
|
866
1102
|
/**
|
|
867
1103
|
* Sign-in form validation schema
|
|
868
1104
|
*/
|
|
@@ -29,7 +29,8 @@ export async function renderSiteHeaderHtml(c: Context<Env>): Promise<string> {
|
|
|
29
29
|
links={navData.links}
|
|
30
30
|
currentPath={navData.currentPath}
|
|
31
31
|
sitePathPrefix={navData.sitePathPrefix}
|
|
32
|
-
|
|
32
|
+
basePath={navData.basePath}
|
|
33
|
+
siteAvatarThumbUrl={navData.siteAvatarThumbUrl}
|
|
33
34
|
showHeaderAvatar={navData.showHeaderAvatar}
|
|
34
35
|
/>
|
|
35
36
|
</I18nProvider>,
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standalone page served when a site resolves but is suspended.
|
|
3
|
+
*
|
|
4
|
+
* Kept deliberately self-contained (no theme, no site services, no i18n
|
|
5
|
+
* catalog): it renders before any site context exists, and the site whose
|
|
6
|
+
* theme we would otherwise use is exactly the one that is unavailable.
|
|
7
|
+
*
|
|
8
|
+
* The copy stays provider-neutral. Hosted deployments pass their brand across
|
|
9
|
+
* the boundary through `HOSTED_CONTROL_PLANE_PROVIDER_NAME` /
|
|
10
|
+
* `HOSTED_CONTROL_PLANE_BASE_URL`, which turn the last line into a real link
|
|
11
|
+
* back to the control plane; a self-hosted instance renders the same page
|
|
12
|
+
* without one.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { getHostedControlPlaneProviderLabel } from "./env.js";
|
|
16
|
+
import { getHostedControlPlaneDashboardUrl } from "./hosted-signin.js";
|
|
17
|
+
import { escapeHtml } from "./html.js";
|
|
18
|
+
|
|
19
|
+
export function renderSiteUnavailablePage(
|
|
20
|
+
env: object | undefined | null,
|
|
21
|
+
): string {
|
|
22
|
+
const providerLabel = getHostedControlPlaneProviderLabel(env);
|
|
23
|
+
const dashboardUrl = getHostedControlPlaneDashboardUrl(env);
|
|
24
|
+
|
|
25
|
+
const ownerLine =
|
|
26
|
+
dashboardUrl && providerLabel
|
|
27
|
+
? `<p>If it's your site, sign in to <a href="${escapeHtml(dashboardUrl)}">${escapeHtml(providerLabel)}</a> to bring it back online.</p>`
|
|
28
|
+
: `<p>If it's your site, sign in to your hosting provider to bring it back online.</p>`;
|
|
29
|
+
|
|
30
|
+
return `<!DOCTYPE html>
|
|
31
|
+
<html lang="en">
|
|
32
|
+
<head>
|
|
33
|
+
<meta charset="utf-8">
|
|
34
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
35
|
+
<meta name="robots" content="noindex">
|
|
36
|
+
<title>This site is offline</title>
|
|
37
|
+
<style>body{font-family:system-ui,sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#fafafa;color:#111;padding:24px}div{max-width:460px;text-align:left}h1{font-size:1.25rem;font-weight:600;margin:0 0 12px}p{color:#4a4a4a;line-height:1.6;margin:12px 0}a{color:#0f766e}@media(prefers-color-scheme:dark){body{background:#111;color:#fafafa}p{color:#a1a1a1}a{color:#2dd4bf}}</style>
|
|
38
|
+
</head>
|
|
39
|
+
<body>
|
|
40
|
+
<div>
|
|
41
|
+
<h1>This site is offline</h1>
|
|
42
|
+
<p>There's nothing to read here right now — the site's hosting has stopped.</p>
|
|
43
|
+
${ownerLine}
|
|
44
|
+
</div>
|
|
45
|
+
</body>
|
|
46
|
+
</html>`;
|
|
47
|
+
}
|
package/src/lib/slug.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Handles three cases: user-provided slug, title-based slug, and random-only slug.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { slugify } from "./
|
|
8
|
+
import { slugify } from "./slugify.js";
|
|
9
9
|
import { generateRandomId } from "./nanoid.js";
|
|
10
10
|
import { isReservedPath } from "./constants.js";
|
|
11
11
|
import { ValidationError, ConflictError } from "./errors.js";
|
|
@@ -19,6 +19,21 @@ export interface SlugOptions {
|
|
|
19
19
|
title?: string;
|
|
20
20
|
/** Length of random IDs */
|
|
21
21
|
idLength: number;
|
|
22
|
+
/**
|
|
23
|
+
* URL form of the post's language (lowercased BCP 47 tag, e.g. `zh-hant`),
|
|
24
|
+
* set only for posts written in a non-primary language. Used as the first
|
|
25
|
+
* fallback when a title-based slug collides, so the transliterations of a
|
|
26
|
+
* translated pair land on `shu-ping` and `shu-ping-en` rather than a random
|
|
27
|
+
* suffix. Omitted for primary-language posts: the bare namespace belongs to
|
|
28
|
+
* them, and a language suffix among same-language posts distinguishes nothing.
|
|
29
|
+
*/
|
|
30
|
+
languageSuffix?: string;
|
|
31
|
+
/**
|
|
32
|
+
* URL prefixes currently served by language views. A slug matching one would
|
|
33
|
+
* be shadowed by that view and never resolve, so it is treated as reserved
|
|
34
|
+
* for as long as the language is configured.
|
|
35
|
+
*/
|
|
36
|
+
reservedPrefixes?: readonly string[];
|
|
22
37
|
/** Callback to check if a slug is available (checks post slugs + path_registry paths) */
|
|
23
38
|
isAvailable: (slug: string) => Promise<boolean>;
|
|
24
39
|
}
|
|
@@ -28,7 +43,7 @@ export interface SlugOptions {
|
|
|
28
43
|
*
|
|
29
44
|
* Resolution order:
|
|
30
45
|
* 1. User-provided slug → validate format, check reserved, check availability
|
|
31
|
-
* 2. Title exists → slugify(title),
|
|
46
|
+
* 2. Title exists → slugify(title), then -{languageSuffix}, then -{randomId}
|
|
32
47
|
* 3. No title → pure random ID
|
|
33
48
|
*
|
|
34
49
|
* @param opts - Slug generation options
|
|
@@ -42,16 +57,33 @@ export interface SlugOptions {
|
|
|
42
57
|
* // Title-based
|
|
43
58
|
* await generatePostSlug({ title: "Hello World", idLength: 5, isAvailable: check });
|
|
44
59
|
*
|
|
60
|
+
* // Title-based in a secondary language, colliding with an existing slug
|
|
61
|
+
* await generatePostSlug({
|
|
62
|
+
* title: "書評",
|
|
63
|
+
* languageSuffix: "en",
|
|
64
|
+
* idLength: 5,
|
|
65
|
+
* isAvailable: check,
|
|
66
|
+
* }); // => "shu-ping-en"
|
|
67
|
+
*
|
|
45
68
|
* // Random
|
|
46
69
|
* await generatePostSlug({ idLength: 5, isAvailable: check });
|
|
47
70
|
* ```
|
|
48
71
|
*/
|
|
49
72
|
export async function generatePostSlug(opts: SlugOptions): Promise<string> {
|
|
50
|
-
const {
|
|
73
|
+
const {
|
|
74
|
+
slug,
|
|
75
|
+
title,
|
|
76
|
+
idLength,
|
|
77
|
+
languageSuffix,
|
|
78
|
+
reservedPrefixes = [],
|
|
79
|
+
isAvailable,
|
|
80
|
+
} = opts;
|
|
81
|
+
const isReserved = (candidate: string) =>
|
|
82
|
+
isReservedPath(candidate, reservedPrefixes);
|
|
51
83
|
|
|
52
84
|
// Case 1: User-provided slug
|
|
53
85
|
if (slug) {
|
|
54
|
-
if (
|
|
86
|
+
if (isReserved(slug)) {
|
|
55
87
|
throw new ValidationError(
|
|
56
88
|
`Slug "${slug}" is reserved and cannot be used`,
|
|
57
89
|
);
|
|
@@ -66,15 +98,27 @@ export async function generatePostSlug(opts: SlugOptions): Promise<string> {
|
|
|
66
98
|
// Case 2: Title-based slug
|
|
67
99
|
if (title) {
|
|
68
100
|
const base = slugify(title);
|
|
69
|
-
if (base && !
|
|
101
|
+
if (base && !isReserved(base)) {
|
|
70
102
|
const available = await isAvailable(base);
|
|
71
103
|
if (available) return base;
|
|
104
|
+
|
|
105
|
+
// A readable language suffix beats a random one, and it is exactly the
|
|
106
|
+
// collision a translated pair produces.
|
|
107
|
+
if (languageSuffix) {
|
|
108
|
+
const languageCandidate = `${base}-${languageSuffix}`;
|
|
109
|
+
if (
|
|
110
|
+
!isReserved(languageCandidate) &&
|
|
111
|
+
(await isAvailable(languageCandidate))
|
|
112
|
+
) {
|
|
113
|
+
return languageCandidate;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
72
116
|
}
|
|
73
117
|
|
|
74
118
|
// Append random suffix on conflict or reserved base
|
|
75
119
|
for (let i = 0; i < MAX_RETRIES; i++) {
|
|
76
120
|
const candidate = `${base || generateRandomId(idLength)}-${generateRandomId(idLength)}`;
|
|
77
|
-
if (!
|
|
121
|
+
if (!isReserved(candidate) && (await isAvailable(candidate))) {
|
|
78
122
|
return candidate;
|
|
79
123
|
}
|
|
80
124
|
}
|
|
@@ -86,7 +130,7 @@ export async function generatePostSlug(opts: SlugOptions): Promise<string> {
|
|
|
86
130
|
// Case 3: Pure random
|
|
87
131
|
for (let i = 0; i < MAX_RETRIES; i++) {
|
|
88
132
|
const candidate = generateRandomId(idLength);
|
|
89
|
-
if (!
|
|
133
|
+
if (!isReserved(candidate) && (await isAvailable(candidate))) {
|
|
90
134
|
return candidate;
|
|
91
135
|
}
|
|
92
136
|
}
|
|
@@ -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
|
+
}
|