@jant/core 0.6.14 → 0.6.16
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/bin/commands/import-site.js +88 -13
- package/bin/commands/migrate.js +55 -0
- package/bin/commands/posts/rebuild-html.js +184 -0
- package/bin/commands/site/snapshot/import.js +14 -10
- package/bin/lib/d1-query.js +23 -5
- package/bin/lib/migration-runner.js +29 -7
- package/bin/lib/node-database.js +32 -0
- package/bin/lib/site-snapshot.js +347 -46
- package/bin/lib/sql-export.js +1 -1
- package/bin/lib/thread-collection-migration.js +326 -0
- package/dist/app-C75Zc8dD.js +6 -0
- package/dist/{app-2i7-WQHg.js → app-C7_4oab9.js} +7774 -6425
- package/dist/client/.vite/manifest.json +8 -8
- package/dist/client/_assets/chunks/{url-pLre2DM_.js → url-CU9pEbk5.js} +1 -1
- package/dist/client/_assets/{client-BvID5ucz.js → client-Bq0vre8Y.js} +59 -38
- package/dist/client/_assets/client-D3VZyG4L.css +2 -0
- package/dist/client/_assets/client-auth-F2rb9WfF.js +5571 -0
- package/dist/{env-OHRKGcMj.js → env-BPYViDJJ.js} +1 -1
- package/dist/{export-DWn149b7.js → export-Dl5KCiEs.js} +1348 -128
- package/dist/{github-api-BgSiE71w.js → github-api-BNF35Lkx.js} +1 -1
- package/dist/{github-app-BbklkFmU.js → github-app-DdiD-bC4.js} +1 -1
- package/dist/{github-sync-C0zyF6XS.js → github-sync-Bqg62IvV.js} +3 -3
- package/dist/{github-sync-Ckk0sJxK.js → github-sync-HdK2EgvJ.js} +3 -3
- package/dist/index.js +5 -5
- package/dist/node.js +7 -7
- package/dist/{url-BMYO-Zlt.js → url-CbLAtlZe.js} +517 -12
- package/package.json +11 -10
- package/src/__tests__/bin/posts-rebuild-html.test.ts +137 -0
- package/src/__tests__/dev-navigation-defaults.test.ts +22 -0
- package/src/__tests__/export-hugo-build.test.ts +63 -4
- package/src/__tests__/export-import-roundtrip.test.ts +6 -4
- package/src/__tests__/export-service.test.ts +142 -9
- package/src/__tests__/helpers/app.ts +3 -0
- package/src/__tests__/helpers/export-fixtures.ts +2 -0
- package/src/__tests__/import-site-command.test.ts +65 -0
- package/src/__tests__/mise-config.test.ts +22 -0
- package/src/__tests__/vite-config.test.ts +45 -0
- package/src/app.tsx +16 -3
- package/src/client/__tests__/collection-created-notice.test.ts +36 -0
- package/src/client/__tests__/collection-form-bridge.test.ts +10 -1
- package/src/client/__tests__/collection-page-actions.test.ts +71 -2
- package/src/client/__tests__/compose-bridge.test.ts +316 -2
- package/src/client/__tests__/compose-shortcuts.test.ts +20 -0
- package/src/client/__tests__/feed-video-player.test.ts +16 -1
- package/src/client/__tests__/footnote-rail.test.ts +472 -0
- package/src/client/__tests__/hydrate-partial.test.ts +3 -0
- package/src/client/__tests__/toast.test.ts +82 -1
- package/src/client/collection-created-notice.ts +66 -0
- package/src/client/collection-form-bridge.ts +17 -0
- package/src/client/collection-navigation.ts +44 -0
- package/src/client/collection-page-actions.ts +57 -1
- package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
- package/src/client/components/__tests__/jant-collection-directory.test.ts +155 -6
- package/src/client/components/__tests__/jant-command-palette.test.ts +51 -0
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +2268 -206
- package/src/client/components/__tests__/jant-compose-editor.test.ts +147 -59
- package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +114 -64
- package/src/client/components/__tests__/jant-config-editor.test.ts +405 -0
- package/src/client/components/__tests__/jant-media-lightbox.test.ts +121 -0
- package/src/client/components/__tests__/jant-nav-manager.test.ts +538 -2
- package/src/client/components/__tests__/jant-post-form.test.ts +2 -2
- package/src/client/components/__tests__/jant-post-menu.test.ts +47 -5
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
- package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
- package/src/client/components/collection-manager-types.ts +9 -3
- package/src/client/components/compose-format-convert.ts +5 -1
- package/src/client/components/compose-types.ts +55 -3
- package/src/client/components/jant-collection-directory.ts +165 -30
- package/src/client/components/jant-command-palette.ts +14 -7
- package/src/client/components/jant-compose-dialog.ts +1542 -694
- package/src/client/components/jant-compose-editor.ts +237 -99
- package/src/client/components/jant-compose-fullscreen.ts +27 -9
- package/src/client/components/jant-config-editor.ts +722 -0
- package/src/client/components/jant-media-lightbox.ts +100 -18
- package/src/client/components/jant-nav-manager.ts +1283 -54
- package/src/client/components/jant-post-form.ts +3 -3
- package/src/client/components/jant-post-menu.ts +177 -95
- package/src/client/components/nav-manager-types.ts +51 -2
- package/src/client/components/post-form-types.ts +1 -1
- package/src/client/compose-bridge.ts +147 -123
- package/src/client/compose-launch.ts +13 -0
- package/src/client/compose-shortcuts.ts +5 -1
- package/src/client/feed-video-player.ts +62 -2
- package/src/client/footnote-rail.ts +478 -0
- package/src/client/hydrate-partial.ts +2 -0
- package/src/client/media-lightbox-events.ts +63 -0
- package/src/client/post-refresh.ts +135 -0
- package/src/client/site-header-fragment.ts +38 -0
- package/src/client/tiptap/__tests__/blockquote-editing.test.ts +201 -0
- package/src/client/tiptap/__tests__/clear-formatting.test.ts +168 -0
- package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
- package/src/client/tiptap/__tests__/list-editing.test.ts +447 -0
- package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +727 -4
- package/src/client/tiptap/__tests__/table-controls.test.ts +263 -0
- package/src/client/tiptap/__tests__/table-size-picker.test.ts +179 -0
- package/src/client/tiptap/bubble-menu.ts +88 -10
- package/src/client/tiptap/continuous-lists.ts +102 -0
- package/src/client/tiptap/create-editor.ts +3 -0
- package/src/client/tiptap/extensions.ts +14 -5
- package/src/client/tiptap/link-input-rules.ts +4 -4
- package/src/client/tiptap/markdown-clipboard.ts +608 -11
- package/src/client/tiptap/slash-commands.ts +8 -1
- package/src/client/tiptap/structural-keymap.ts +211 -0
- package/src/client/tiptap/tab-indent.ts +8 -22
- package/src/client/tiptap/table-control-labels.ts +30 -0
- package/src/client/tiptap/table-controls.ts +537 -0
- package/src/client/tiptap/table-size-picker.ts +254 -0
- package/src/client/toast.ts +133 -39
- package/src/client-auth.ts +1 -0
- package/src/client.ts +1 -0
- package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
- package/src/db/__tests__/d1-query.test.ts +43 -1
- package/src/db/__tests__/migration-runner.test.ts +63 -0
- package/src/db/__tests__/migrations.test.ts +469 -0
- package/src/db/__tests__/thread-activity.test.ts +113 -0
- package/src/db/__tests__/thread-collection-migration.test.ts +364 -0
- package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
- package/src/db/migrations/0027_old_blue_blade.sql +39 -0
- package/src/db/migrations/0028_superb_post.sql +2 -0
- package/src/db/migrations/0029_boring_magma.sql +1 -0
- package/src/db/migrations/0030_typical_vivisector.sql +50 -0
- package/src/db/migrations/0031_many_ego.sql +3 -0
- package/src/db/migrations/meta/0027_snapshot.json +2511 -0
- package/src/db/migrations/meta/0028_snapshot.json +2511 -0
- package/src/db/migrations/meta/0029_snapshot.json +2519 -0
- package/src/db/migrations/meta/0030_snapshot.json +2541 -0
- package/src/db/migrations/meta/0031_snapshot.json +2562 -0
- package/src/db/migrations/meta/_journal.json +35 -0
- package/src/db/migrations/pg/0025_moaning_epoch.sql +39 -0
- package/src/db/migrations/pg/0026_talented_killer_shrike.sql +2 -0
- package/src/db/migrations/pg/0027_short_cannonball.sql +1 -0
- package/src/db/migrations/pg/0028_perfect_sharon_carter.sql +27 -0
- package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
- package/src/db/migrations/pg/meta/0025_snapshot.json +3204 -0
- package/src/db/migrations/pg/meta/0026_snapshot.json +3204 -0
- package/src/db/migrations/pg/meta/0027_snapshot.json +3211 -0
- package/src/db/migrations/pg/meta/0028_snapshot.json +3248 -0
- package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
- package/src/db/migrations/pg/meta/_journal.json +35 -0
- package/src/db/pg/schema.ts +46 -20
- package/src/db/rehearsal-fixtures/demo-current.json +39 -4
- package/src/db/rehearsal-fixtures/demo-current.sql +121 -6
- package/src/db/schema.ts +48 -20
- package/src/db/thread-activity.ts +101 -0
- package/src/i18n/__tests__/detect.test.ts +8 -2
- package/src/i18n/detect.ts +14 -5
- package/src/i18n/locales/public/en.po +222 -51
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +216 -45
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +216 -45
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/i18n/locales/settings/en.po +513 -10
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +515 -12
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +515 -12
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/lib/__tests__/api-settings.test.ts +299 -0
- package/src/lib/__tests__/constants.test.ts +5 -0
- package/src/lib/__tests__/feed-policy.test.ts +25 -0
- package/src/lib/__tests__/feed.test.ts +168 -0
- package/src/lib/__tests__/footnotes-upgrade.test.ts +181 -0
- package/src/lib/__tests__/markdown-roundtrip-embed.test.ts +61 -0
- package/src/lib/__tests__/markdown-to-tiptap.test.ts +32 -4
- package/src/lib/__tests__/markdown.test.ts +24 -6
- package/src/lib/__tests__/navigation.test.ts +59 -2
- package/src/lib/__tests__/post-body-html.test.ts +176 -0
- package/src/lib/__tests__/post-display.test.ts +64 -0
- package/src/lib/__tests__/resolve-config.test.ts +113 -1
- package/src/lib/__tests__/slug.test.ts +18 -0
- package/src/lib/__tests__/summary.test.ts +82 -0
- package/src/lib/__tests__/timeline.test.ts +109 -70
- package/src/lib/__tests__/timezones.test.ts +31 -3
- package/src/lib/__tests__/tiptap-render.test.ts +268 -9
- package/src/lib/__tests__/tiptap-to-markdown.test.ts +6 -0
- package/src/lib/__tests__/translit.test.ts +87 -0
- package/src/lib/__tests__/url.test.ts +53 -0
- package/src/lib/__tests__/view.test.ts +53 -0
- package/src/lib/__tests__/worker-response-cache.test.ts +18 -1
- package/src/lib/api-settings.ts +239 -12
- package/src/lib/collection-sort.ts +7 -5
- package/src/lib/constants.ts +2 -0
- package/src/lib/feed-path.ts +25 -0
- package/src/lib/feed-policy.ts +64 -0
- package/src/lib/feed.ts +113 -11
- package/src/lib/footnotes.ts +264 -0
- package/src/lib/github-sync-site-config.ts +2 -0
- package/src/lib/hugo-markdown.ts +15 -3
- package/src/lib/jant-branding.ts +1 -0
- package/src/lib/link-preview.ts +25 -0
- package/src/lib/markdown-manager.ts +193 -5
- package/src/lib/markdown.ts +10 -3
- package/src/lib/navigation.ts +16 -3
- package/src/lib/post-body-html.ts +131 -0
- package/src/lib/post-display.ts +47 -8
- package/src/lib/render.tsx +9 -2
- package/src/lib/resolve-config.ts +100 -16
- package/src/lib/schemas.ts +133 -1
- package/src/lib/settings-paths.ts +5 -0
- package/src/lib/site-skill.ts +78 -0
- package/src/lib/summary.ts +16 -5
- package/src/lib/timeline.ts +137 -131
- package/src/lib/timezones.ts +54 -14
- package/src/lib/tiptap-render.ts +359 -83
- package/src/lib/translit.ts +288 -0
- package/src/lib/url.ts +123 -12
- package/src/lib/view.ts +58 -20
- package/src/lib/worker-response-cache.ts +11 -18
- package/src/middleware/__tests__/public-content-access.test.ts +91 -0
- package/src/middleware/config.ts +2 -2
- package/src/middleware/public-content-access.ts +87 -0
- package/src/node/__tests__/cli-export.test.ts +14 -0
- package/src/node/__tests__/cli-migrate.test.ts +6 -0
- package/src/node/__tests__/cli-site-snapshot.test.ts +205 -2
- package/src/node/__tests__/cli-snapshot-meta.test.ts +100 -0
- package/src/node/index.ts +2 -1
- package/src/preset.css +16 -2
- package/src/routes/__tests__/compose.test.ts +51 -0
- package/src/routes/__tests__/site-skill.test.ts +112 -0
- package/src/routes/api/__tests__/collections.test.ts +91 -19
- package/src/routes/api/__tests__/mcp.test.ts +65 -1
- package/src/routes/api/__tests__/nav-items.test.ts +91 -0
- package/src/routes/api/__tests__/palette.test.ts +44 -0
- package/src/routes/api/__tests__/public-access.test.ts +81 -0
- package/src/routes/api/__tests__/settings.test.ts +217 -0
- package/src/routes/api/collections.ts +22 -26
- package/src/routes/api/export.ts +2 -0
- package/src/routes/api/internal/__tests__/post-body-html.test.ts +163 -0
- package/src/routes/api/internal/__tests__/sites.test.ts +35 -2
- package/src/routes/api/internal/post-body-html.ts +43 -0
- package/src/routes/api/internal/sites.ts +24 -0
- package/src/routes/api/nav-items.ts +24 -1
- package/src/routes/api/posts.ts +14 -10
- package/src/routes/api/public/__tests__/archive.test.ts +35 -0
- package/src/routes/api/public/__tests__/posts.test.ts +246 -0
- package/src/routes/api/public/archive.ts +4 -0
- package/src/routes/api/public/posts.ts +48 -24
- package/src/routes/api/search.ts +3 -0
- package/src/routes/api/settings.ts +33 -0
- package/src/routes/auth/__tests__/setup.test.ts +7 -4
- package/src/routes/compose.tsx +8 -3
- package/src/routes/dash/settings.tsx +45 -2
- package/src/routes/feed/__tests__/feed.test.ts +210 -6
- package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
- package/src/routes/feed/feed.ts +23 -14
- package/src/routes/pages/__tests__/archive-params.test.ts +384 -3
- package/src/routes/pages/__tests__/collection-routing.test.ts +63 -5
- package/src/routes/pages/__tests__/collections.test.ts +9 -9
- package/src/routes/pages/__tests__/featured.test.ts +6 -2
- package/src/routes/pages/__tests__/preview.test.ts +176 -0
- package/src/routes/pages/__tests__/search.test.ts +69 -0
- package/src/routes/pages/archive.tsx +90 -22
- package/src/routes/pages/collection.tsx +32 -19
- package/src/routes/pages/collections.tsx +6 -1
- package/src/routes/pages/page.tsx +86 -11
- package/src/routes/site-skill.ts +18 -0
- package/src/services/__tests__/auth.test.ts +7 -5
- package/src/services/__tests__/collection.test.ts +190 -44
- package/src/services/__tests__/navigation.test.ts +229 -21
- package/src/services/__tests__/post-timeline.test.ts +249 -39
- package/src/services/__tests__/post.test.ts +844 -5
- package/src/services/__tests__/search.test.ts +48 -1
- package/src/services/__tests__/settings.test.ts +8 -0
- package/src/services/__tests__/site-admin.test.ts +121 -1
- package/src/services/auth.ts +2 -2
- package/src/services/bootstrap.ts +3 -2
- package/src/services/collection.ts +224 -167
- package/src/services/export-theme/assets/client-site.css +1 -1
- package/src/services/export-theme/assets/client-site.js +46 -25
- package/src/services/export-theme/layouts/_default/list.html +18 -4
- package/src/services/export-theme/layouts/_default/rss.xml +8 -12
- package/src/services/export-theme/layouts/featured/list.html +5 -4
- package/src/services/export-theme/layouts/partials/featured-thread.html +56 -0
- package/src/services/export-theme/layouts/partials/footer.html +1 -1
- package/src/services/export-theme/layouts/partials/media-gallery.html +1 -0
- package/src/services/export-theme/styles/main.css +30 -2
- package/src/services/export.ts +94 -85
- package/src/services/mcp.ts +37 -27
- package/src/services/media.ts +1 -1
- package/src/services/navigation.ts +185 -46
- package/src/services/path.ts +4 -1
- package/src/services/post.ts +1142 -342
- package/src/services/search.ts +17 -2
- package/src/services/settings.ts +2 -10
- package/src/services/site-admin.ts +63 -43
- package/src/style-cjk-jp.css +1 -1
- package/src/style-cjk-kr.css +1 -1
- package/src/style-cjk-tc.css +1 -1
- package/src/style-cjk.css +1 -1
- package/src/styles/components.css +540 -14
- package/src/styles/site-media.css +111 -19
- package/src/styles/tokens.css +76 -10
- package/src/styles/ui.css +2227 -1347
- package/src/types/bindings.ts +3 -0
- package/src/types/config.ts +243 -12
- package/src/types/constants.ts +22 -1
- package/src/types/entities.ts +14 -8
- package/src/types/operations.ts +18 -10
- package/src/types/props.ts +20 -2
- package/src/types/raw-assets.d.ts +5 -0
- package/src/types/views.ts +22 -4
- package/src/ui/__tests__/font-themes.test.ts +42 -30
- package/src/ui/compose/ComposeDialog.tsx +109 -19
- package/src/ui/dash/appearance/NavigationContent.tsx +332 -4
- package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +23 -0
- package/src/ui/dash/settings/ConfigEditorContent.tsx +578 -0
- package/src/ui/dash/settings/GeneralContent.tsx +15 -5
- package/src/ui/dash/settings/SettingsRootContent.tsx +21 -1
- package/src/ui/dash/settings/__tests__/ConfigEditorContent.test.tsx +98 -0
- package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +15 -0
- package/src/ui/feed/CuratedThreadPreview.tsx +16 -4
- package/src/ui/feed/LinkCard.tsx +2 -12
- package/src/ui/feed/LinkPreview.tsx +2 -7
- package/src/ui/feed/NoteCard.tsx +10 -20
- package/src/ui/feed/PostStatusBadges.tsx +42 -3
- package/src/ui/feed/QuoteCard.tsx +2 -12
- package/src/ui/feed/ThreadPreview.tsx +26 -34
- package/src/ui/feed/TimelineFeed.tsx +2 -2
- package/src/ui/feed/__tests__/thread-preview.test.ts +197 -56
- package/src/ui/feed/__tests__/timeline-cards.test.ts +178 -3
- package/src/ui/feed/thread-preview-state.ts +5 -7
- package/src/ui/font-themes.ts +74 -26
- package/src/ui/layouts/BaseLayout.tsx +22 -8
- package/src/ui/layouts/__tests__/BaseLayout.test.tsx +64 -1
- package/src/ui/pages/ArchivePage.tsx +131 -24
- package/src/ui/pages/CollectionPage.tsx +101 -22
- package/src/ui/pages/CollectionsPage.tsx +3 -1
- package/src/ui/pages/PostPage.tsx +39 -12
- package/src/ui/pages/SearchPage.tsx +6 -14
- package/src/ui/pages/__tests__/ArchivePage.test.tsx +70 -0
- package/src/ui/pages/__tests__/PostPage.test.tsx +88 -0
- package/src/ui/shared/CollectionDirectory.tsx +13 -9
- package/src/ui/shared/CollectionsManager.tsx +17 -7
- package/src/ui/shared/DraftPreviewBar.tsx +49 -0
- package/src/ui/shared/HomePageBranding.tsx +2 -2
- package/src/ui/shared/MediaGallery.tsx +1 -0
- package/src/ui/shared/PostFooter.tsx +38 -13
- package/src/ui/shared/__tests__/home-page-branding.test.tsx +1 -1
- package/src/ui/shared/__tests__/media-gallery.test.ts +1 -0
- package/src/ui/shared/__tests__/navigation-labels.test.ts +2 -2
- package/src/ui/shared/__tests__/post-footer.test.ts +15 -0
- package/src/ui/shared/collection-management-labels.ts +33 -2
- package/src/ui/shared/custom-icons.ts +5 -0
- package/src/ui/shared/navigation-labels.ts +2 -4
- package/src/ui/shared/post-article-attributes.ts +52 -0
- package/dist/app-DZDlmh7C.js +0 -6
- package/dist/client/_assets/client-Cevfpm8H.css +0 -2
- package/dist/client/_assets/client-auth-v3yvhbgZ.js +0 -4736
- package/src/client/tiptap/wrapping-input-rules.ts +0 -102
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dictionary-free transliteration helpers for slug generation.
|
|
3
|
+
*
|
|
4
|
+
* These converters cover the scripts limax handles poorly:
|
|
5
|
+
*
|
|
6
|
+
* - Korean hangul → Revised Romanization (limax strips hangul entirely)
|
|
7
|
+
* - Japanese kana → Hepburn romaji (limax mangles long vowels and digraphs)
|
|
8
|
+
*
|
|
9
|
+
* Both are pure algorithms over Unicode code points — no dictionaries — so
|
|
10
|
+
* they stay small enough for the Workers bundle and the lazy client chunk.
|
|
11
|
+
* Japanese kanji is intentionally out of scope: readings are context-dependent
|
|
12
|
+
* and need a morphological dictionary, which is why `slugify` falls back to a
|
|
13
|
+
* random ID for kanji-heavy Japanese titles instead of guessing.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Matches hiragana, katakana, and the chōonpu (ー) long-vowel mark. */
|
|
17
|
+
export const KANA_RE = /[ぁ-ゖァ-ヺー]/u;
|
|
18
|
+
|
|
19
|
+
/** Matches precomposed hangul syllables and compatibility jamo. */
|
|
20
|
+
export const HANGUL_RE = /[가-힣ㄱ-ㆎ]/u;
|
|
21
|
+
|
|
22
|
+
/** Matches Han (CJK ideograph) characters. */
|
|
23
|
+
export const HAN_RE = /\p{Script=Han}/u;
|
|
24
|
+
|
|
25
|
+
// --- Korean: Revised Romanization ---------------------------------------
|
|
26
|
+
|
|
27
|
+
const HANGUL_SYLLABLE_BASE = 0xac00;
|
|
28
|
+
const HANGUL_SYLLABLE_END = 0xd7a3;
|
|
29
|
+
const VOWEL_COUNT = 21;
|
|
30
|
+
const FINAL_COUNT = 28;
|
|
31
|
+
|
|
32
|
+
// prettier-ignore
|
|
33
|
+
const RR_INITIALS = [
|
|
34
|
+
"g", "kk", "n", "d", "tt", "r", "m", "b", "pp", "s",
|
|
35
|
+
"ss", "", "j", "jj", "ch", "k", "t", "p", "h",
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
// prettier-ignore
|
|
39
|
+
const RR_VOWELS = [
|
|
40
|
+
"a", "ae", "ya", "yae", "eo", "e", "yeo", "ye", "o", "wa",
|
|
41
|
+
"wae", "oe", "yo", "u", "wo", "we", "wi", "yu", "eu", "ui", "i",
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
// Finals use their phonetic (syllable-final) value, e.g. ㅅ → "t".
|
|
45
|
+
// prettier-ignore
|
|
46
|
+
const RR_FINALS = [
|
|
47
|
+
"", "k", "k", "k", "n", "n", "n", "t", "l", "k", "m", "l", "l", "l",
|
|
48
|
+
"p", "l", "m", "p", "p", "t", "t", "ng", "t", "t", "k", "t", "p", "t",
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
// Liaison: when the next syllable starts with the silent initial ㅇ, the
|
|
52
|
+
// final consonant is pronounced as that syllable's initial instead
|
|
53
|
+
// (한국어 → han-gu-geo, not han-guk-eo). Each entry is
|
|
54
|
+
// [what remains as the final, what carries over as the next initial].
|
|
55
|
+
// ㅎ is dropped when carried (좋아 → joa); final ㅇ (ng) never carries.
|
|
56
|
+
// prettier-ignore
|
|
57
|
+
const RR_LIAISON: readonly (readonly [string, string])[] = [
|
|
58
|
+
["", ""], ["", "g"], ["", "kk"], ["k", "s"], ["", "n"], ["n", "j"],
|
|
59
|
+
["n", ""], ["", "d"], ["", "r"], ["l", "g"], ["l", "m"], ["l", "b"],
|
|
60
|
+
["l", "s"], ["l", "t"], ["l", "p"], ["l", ""], ["", "m"], ["", "b"],
|
|
61
|
+
["p", "s"], ["", "s"], ["", "ss"], ["ng", ""], ["", "j"], ["", "ch"],
|
|
62
|
+
["", "k"], ["", "t"], ["", "p"], ["", ""],
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
// Standalone compatibility jamo (ㅋㅋㅋ, ㅠㅠ) — common in informal titles.
|
|
66
|
+
const COMPAT_JAMO: Record<string, string> = {
|
|
67
|
+
ㄱ: "g",
|
|
68
|
+
ㄲ: "kk",
|
|
69
|
+
ㄴ: "n",
|
|
70
|
+
ㄷ: "d",
|
|
71
|
+
ㄸ: "tt",
|
|
72
|
+
ㄹ: "r",
|
|
73
|
+
ㅁ: "m",
|
|
74
|
+
ㅂ: "b",
|
|
75
|
+
ㅃ: "pp",
|
|
76
|
+
ㅅ: "s",
|
|
77
|
+
ㅆ: "ss",
|
|
78
|
+
ㅇ: "ng",
|
|
79
|
+
ㅈ: "j",
|
|
80
|
+
ㅉ: "jj",
|
|
81
|
+
ㅊ: "ch",
|
|
82
|
+
ㅋ: "k",
|
|
83
|
+
ㅌ: "t",
|
|
84
|
+
ㅍ: "p",
|
|
85
|
+
ㅎ: "h",
|
|
86
|
+
ㅏ: "a",
|
|
87
|
+
ㅐ: "ae",
|
|
88
|
+
ㅑ: "ya",
|
|
89
|
+
ㅒ: "yae",
|
|
90
|
+
ㅓ: "eo",
|
|
91
|
+
ㅔ: "e",
|
|
92
|
+
ㅕ: "yeo",
|
|
93
|
+
ㅖ: "ye",
|
|
94
|
+
ㅗ: "o",
|
|
95
|
+
ㅘ: "wa",
|
|
96
|
+
ㅙ: "wae",
|
|
97
|
+
ㅚ: "oe",
|
|
98
|
+
ㅛ: "yo",
|
|
99
|
+
ㅜ: "u",
|
|
100
|
+
ㅝ: "wo",
|
|
101
|
+
ㅞ: "we",
|
|
102
|
+
ㅟ: "wi",
|
|
103
|
+
ㅠ: "yu",
|
|
104
|
+
ㅡ: "eu",
|
|
105
|
+
ㅢ: "ui",
|
|
106
|
+
ㅣ: "i",
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
interface HangulSyllable {
|
|
110
|
+
initial: number;
|
|
111
|
+
vowel: number;
|
|
112
|
+
final: number;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function decomposeHangul(codePoint: number): HangulSyllable | null {
|
|
116
|
+
if (codePoint < HANGUL_SYLLABLE_BASE || codePoint > HANGUL_SYLLABLE_END) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
const index = codePoint - HANGUL_SYLLABLE_BASE;
|
|
120
|
+
return {
|
|
121
|
+
initial: Math.floor(index / (VOWEL_COUNT * FINAL_COUNT)),
|
|
122
|
+
vowel: Math.floor(index / FINAL_COUNT) % VOWEL_COUNT,
|
|
123
|
+
final: index % FINAL_COUNT,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Transliterates Korean hangul to Latin using Revised Romanization.
|
|
129
|
+
*
|
|
130
|
+
* Handles precomposed syllables with basic liaison (a final consonant before
|
|
131
|
+
* a silent-ㅇ syllable carries over as its initial) and standalone
|
|
132
|
+
* compatibility jamo. Assimilation sound rules (e.g. 종로 → "Jongno") are
|
|
133
|
+
* intentionally skipped — slugs need recognizable output, not phonetic
|
|
134
|
+
* perfection. Non-hangul characters pass through unchanged.
|
|
135
|
+
*
|
|
136
|
+
* @param text - Input text, possibly mixing hangul with other characters
|
|
137
|
+
* @returns Text with hangul replaced by its romanization
|
|
138
|
+
*
|
|
139
|
+
* @example
|
|
140
|
+
* ```ts
|
|
141
|
+
* hangulToLatin("안녕하세요"); // "annyeonghaseyo"
|
|
142
|
+
* hangulToLatin("한국어"); // "hangugeo" (liaison)
|
|
143
|
+
* hangulToLatin("개발 일지"); // "gaebal ilji"
|
|
144
|
+
* ```
|
|
145
|
+
*/
|
|
146
|
+
export function hangulToLatin(text: string): string {
|
|
147
|
+
const chars = [...text];
|
|
148
|
+
let out = "";
|
|
149
|
+
let carried: string | null = null;
|
|
150
|
+
|
|
151
|
+
for (const [i, char] of chars.entries()) {
|
|
152
|
+
const syllable = decomposeHangul(char.codePointAt(0) ?? 0);
|
|
153
|
+
if (!syllable) {
|
|
154
|
+
carried = null;
|
|
155
|
+
out += COMPAT_JAMO[char] ?? char;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const initial = carried ?? RR_INITIALS[syllable.initial] ?? "";
|
|
160
|
+
carried = null;
|
|
161
|
+
const next = decomposeHangul(chars[i + 1]?.codePointAt(0) ?? 0);
|
|
162
|
+
let final = RR_FINALS[syllable.final] ?? "";
|
|
163
|
+
if (syllable.final !== 0 && next && RR_INITIALS[next.initial] === "") {
|
|
164
|
+
const [remains, carry] = RR_LIAISON[syllable.final] ?? ["", ""];
|
|
165
|
+
final = remains;
|
|
166
|
+
carried = carry;
|
|
167
|
+
}
|
|
168
|
+
out += initial + (RR_VOWELS[syllable.vowel] ?? "") + final;
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// --- Japanese: Hepburn romaji -------------------------------------------
|
|
174
|
+
|
|
175
|
+
// prettier-ignore
|
|
176
|
+
const KANA_BASE: Record<string, string> = {
|
|
177
|
+
あ: "a", い: "i", う: "u", え: "e", お: "o",
|
|
178
|
+
か: "ka", き: "ki", く: "ku", け: "ke", こ: "ko",
|
|
179
|
+
さ: "sa", し: "shi", す: "su", せ: "se", そ: "so",
|
|
180
|
+
た: "ta", ち: "chi", つ: "tsu", て: "te", と: "to",
|
|
181
|
+
な: "na", に: "ni", ぬ: "nu", ね: "ne", の: "no",
|
|
182
|
+
は: "ha", ひ: "hi", ふ: "fu", へ: "he", ほ: "ho",
|
|
183
|
+
ま: "ma", み: "mi", む: "mu", め: "me", も: "mo",
|
|
184
|
+
や: "ya", ゆ: "yu", よ: "yo",
|
|
185
|
+
ら: "ra", り: "ri", る: "ru", れ: "re", ろ: "ro",
|
|
186
|
+
わ: "wa", ゐ: "i", ゑ: "e", を: "o", ん: "n",
|
|
187
|
+
が: "ga", ぎ: "gi", ぐ: "gu", げ: "ge", ご: "go",
|
|
188
|
+
ざ: "za", じ: "ji", ず: "zu", ぜ: "ze", ぞ: "zo",
|
|
189
|
+
だ: "da", ぢ: "ji", づ: "zu", で: "de", ど: "do",
|
|
190
|
+
ば: "ba", び: "bi", ぶ: "bu", べ: "be", ぼ: "bo",
|
|
191
|
+
ぱ: "pa", ぴ: "pi", ぷ: "pu", ぺ: "pe", ぽ: "po",
|
|
192
|
+
ゔ: "vu",
|
|
193
|
+
ぁ: "a", ぃ: "i", ぅ: "u", ぇ: "e", ぉ: "o",
|
|
194
|
+
ゃ: "ya", ゅ: "yu", ょ: "yo", ゎ: "wa",
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
// Digraphs and extended katakana combos (ファ, ティ, チェ, …). Keys are the
|
|
198
|
+
// hiragana-normalized two-character sequences.
|
|
199
|
+
// prettier-ignore
|
|
200
|
+
const KANA_COMBO: Record<string, string> = {
|
|
201
|
+
きゃ: "kya", きゅ: "kyu", きょ: "kyo",
|
|
202
|
+
ぎゃ: "gya", ぎゅ: "gyu", ぎょ: "gyo",
|
|
203
|
+
しゃ: "sha", しゅ: "shu", しょ: "sho",
|
|
204
|
+
じゃ: "ja", じゅ: "ju", じょ: "jo",
|
|
205
|
+
ちゃ: "cha", ちゅ: "chu", ちょ: "cho",
|
|
206
|
+
ぢゃ: "ja", ぢゅ: "ju", ぢょ: "jo",
|
|
207
|
+
にゃ: "nya", にゅ: "nyu", にょ: "nyo",
|
|
208
|
+
ひゃ: "hya", ひゅ: "hyu", ひょ: "hyo",
|
|
209
|
+
びゃ: "bya", びゅ: "byu", びょ: "byo",
|
|
210
|
+
ぴゃ: "pya", ぴゅ: "pyu", ぴょ: "pyo",
|
|
211
|
+
みゃ: "mya", みゅ: "myu", みょ: "myo",
|
|
212
|
+
りゃ: "rya", りゅ: "ryu", りょ: "ryo",
|
|
213
|
+
ふぁ: "fa", ふぃ: "fi", ふぇ: "fe", ふぉ: "fo",
|
|
214
|
+
てぃ: "ti", でぃ: "di", とぅ: "tu", どぅ: "du",
|
|
215
|
+
うぃ: "wi", うぇ: "we", うぉ: "wo",
|
|
216
|
+
ゔぁ: "va", ゔぃ: "vi", ゔぇ: "ve", ゔぉ: "vo",
|
|
217
|
+
ちぇ: "che", しぇ: "she", じぇ: "je",
|
|
218
|
+
つぁ: "tsa", つぃ: "tsi", つぇ: "tse", つぉ: "tso",
|
|
219
|
+
いぇ: "ye",
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const KATAKANA_START = 0x30a1;
|
|
223
|
+
const KATAKANA_END = 0x30f6;
|
|
224
|
+
const KATAKANA_TO_HIRAGANA_SHIFT = 0x60;
|
|
225
|
+
const SOKUON = "っ";
|
|
226
|
+
const CHOONPU = "ー";
|
|
227
|
+
|
|
228
|
+
function normalizeToHiragana(char: string): string {
|
|
229
|
+
const codePoint = char.codePointAt(0) ?? 0;
|
|
230
|
+
if (codePoint >= KATAKANA_START && codePoint <= KATAKANA_END) {
|
|
231
|
+
return String.fromCodePoint(codePoint - KATAKANA_TO_HIRAGANA_SHIFT);
|
|
232
|
+
}
|
|
233
|
+
return char;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Transliterates Japanese kana to macron-less Hepburn romaji.
|
|
238
|
+
*
|
|
239
|
+
* Handles hiragana and katakana, digraphs (きゃ → "kya"), extended katakana
|
|
240
|
+
* loan combos (ファ → "fa"), sokuon gemination (まっちゃ → "matcha"), and
|
|
241
|
+
* drops the chōonpu long-vowel mark (ハンバーガー → "hanbaga"), matching how
|
|
242
|
+
* macrons are conventionally stripped in URLs. Kanji and other non-kana
|
|
243
|
+
* characters pass through unchanged.
|
|
244
|
+
*
|
|
245
|
+
* @param text - Input text, possibly mixing kana with other characters
|
|
246
|
+
* @returns Text with kana replaced by romaji
|
|
247
|
+
*
|
|
248
|
+
* @example
|
|
249
|
+
* ```ts
|
|
250
|
+
* kanaToRomaji("カタカナタイトル"); // "katakanataitoru"
|
|
251
|
+
* kanaToRomaji("ハンバーガー"); // "hanbaga"
|
|
252
|
+
* kanaToRomaji("まっちゃ"); // "matcha"
|
|
253
|
+
* ```
|
|
254
|
+
*/
|
|
255
|
+
export function kanaToRomaji(text: string): string {
|
|
256
|
+
const chars = [...text].map(normalizeToHiragana);
|
|
257
|
+
let out = "";
|
|
258
|
+
let geminate = false;
|
|
259
|
+
|
|
260
|
+
for (let i = 0; i < chars.length; i++) {
|
|
261
|
+
const char = chars[i] ?? "";
|
|
262
|
+
if (char === SOKUON) {
|
|
263
|
+
geminate = true;
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (char === CHOONPU) {
|
|
267
|
+
geminate = false;
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const combo = KANA_COMBO[char + (chars[i + 1] ?? "")];
|
|
272
|
+
const romaji = combo ?? KANA_BASE[char];
|
|
273
|
+
if (combo) i++;
|
|
274
|
+
if (romaji === undefined) {
|
|
275
|
+
geminate = false;
|
|
276
|
+
out += char;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (geminate) {
|
|
280
|
+
geminate = false;
|
|
281
|
+
if (/^[^aeiou]/.test(romaji)) {
|
|
282
|
+
out += romaji.startsWith("ch") ? "t" : (romaji[0] ?? "");
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
out += romaji;
|
|
286
|
+
}
|
|
287
|
+
return out;
|
|
288
|
+
}
|
package/src/lib/url.ts
CHANGED
|
@@ -4,6 +4,14 @@
|
|
|
4
4
|
|
|
5
5
|
import limax from "limax";
|
|
6
6
|
|
|
7
|
+
import {
|
|
8
|
+
HAN_RE,
|
|
9
|
+
HANGUL_RE,
|
|
10
|
+
KANA_RE,
|
|
11
|
+
hangulToLatin,
|
|
12
|
+
kanaToRomaji,
|
|
13
|
+
} from "./translit.js";
|
|
14
|
+
|
|
7
15
|
function normalizeSitePathname(pathname: string): string {
|
|
8
16
|
if (pathname === "/" || pathname === "") return "";
|
|
9
17
|
const trimmed = pathname.replace(/\/+$/, "");
|
|
@@ -133,14 +141,66 @@ export function toSameSitePath(url: string, siteOrigin: string): string | null {
|
|
|
133
141
|
return `${parsed.pathname}${parsed.search}${parsed.hash}` || "/";
|
|
134
142
|
}
|
|
135
143
|
|
|
144
|
+
const HAN_GLOBAL_RE = /\p{Script=Han}/gu;
|
|
145
|
+
const CONTENT_CHAR_RE = /[\p{L}\p{N}]/u;
|
|
146
|
+
const ASCII_ALNUM_RE = /[a-zA-Z0-9]/;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Minimum fraction of a title's letters/digits the transliteration pipeline
|
|
150
|
+
* must handle for the result to be usable. Below this, too much meaning is
|
|
151
|
+
* lost (e.g. mixed kanji+kana Japanese where the semantic core is kanji, or
|
|
152
|
+
* scripts limax drops entirely) and `slugify` returns "" so callers fall back
|
|
153
|
+
* to their random-ID path instead of shipping a garbled slug.
|
|
154
|
+
*/
|
|
155
|
+
const SLUG_COVERAGE_THRESHOLD = 0.75;
|
|
156
|
+
|
|
157
|
+
/** Per-character limax probe cache: does this char transliterate to anything? */
|
|
158
|
+
const charCoverageCache = new Map<string, boolean>();
|
|
159
|
+
|
|
160
|
+
function isCharCovered(char: string): boolean {
|
|
161
|
+
if (ASCII_ALNUM_RE.test(char)) return true;
|
|
162
|
+
let covered = charCoverageCache.get(char);
|
|
163
|
+
if (covered === undefined) {
|
|
164
|
+
covered = limax(char, { tone: false }).length > 0;
|
|
165
|
+
charCoverageCache.set(char, covered);
|
|
166
|
+
}
|
|
167
|
+
return covered;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function slugCoverage(text: string, japanese: boolean): number {
|
|
171
|
+
let content = 0;
|
|
172
|
+
let covered = 0;
|
|
173
|
+
for (const char of text) {
|
|
174
|
+
if (!CONTENT_CHAR_RE.test(char)) continue;
|
|
175
|
+
content++;
|
|
176
|
+
if (KANA_RE.test(char) || HANGUL_RE.test(char)) {
|
|
177
|
+
covered++;
|
|
178
|
+
} else if (japanese && HAN_RE.test(char)) {
|
|
179
|
+
// Kanji in a kana-containing title is dropped, never pinyin-read.
|
|
180
|
+
} else if (isCharCovered(char)) {
|
|
181
|
+
covered++;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return content === 0 ? 0 : covered / content;
|
|
185
|
+
}
|
|
186
|
+
|
|
136
187
|
/**
|
|
137
188
|
* Converts text to a URL-friendly slug.
|
|
138
189
|
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
190
|
+
* Transliterates i18n text to Latin before slugifying: Korean hangul via
|
|
191
|
+
* Revised Romanization, Japanese kana via Hepburn romaji (both in-house, see
|
|
192
|
+
* `translit.ts`), everything else (Han → pinyin, Cyrillic, accents, …) via
|
|
193
|
+
* limax. When the title contains kana it is treated as Japanese and Han
|
|
194
|
+
* characters are dropped rather than misread as Chinese pinyin; Han without
|
|
195
|
+
* kana is treated as Chinese.
|
|
196
|
+
*
|
|
197
|
+
* Returns `""` when transliteration would lose too much of the title (see
|
|
198
|
+
* `SLUG_COVERAGE_THRESHOLD`) — every caller treats an empty base as "no
|
|
199
|
+
* usable title" and falls back to a random ID, which beats publishing a
|
|
200
|
+
* garbled slug.
|
|
141
201
|
*
|
|
142
202
|
* @param text - The text to convert to a slug
|
|
143
|
-
* @returns The slugified string
|
|
203
|
+
* @returns The slugified string, or `""` when no usable slug can be derived
|
|
144
204
|
*
|
|
145
205
|
* @example
|
|
146
206
|
* ```ts
|
|
@@ -149,10 +209,29 @@ export function toSameSitePath(url: string, siteOrigin: string): string | null {
|
|
|
149
209
|
*
|
|
150
210
|
* slugify("书评");
|
|
151
211
|
* // Returns: "shu-ping"
|
|
212
|
+
*
|
|
213
|
+
* slugify("안녕하세요 세계");
|
|
214
|
+
* // Returns: "annyeonghaseyo-segye"
|
|
215
|
+
*
|
|
216
|
+
* slugify("ハンバーガー");
|
|
217
|
+
* // Returns: "hanbaga"
|
|
218
|
+
*
|
|
219
|
+
* slugify("日本語のタイトルです");
|
|
220
|
+
* // Returns: "" (kanji carries the meaning and can't be transliterated)
|
|
152
221
|
* ```
|
|
153
222
|
*/
|
|
154
223
|
export function slugify(text: string): string {
|
|
155
|
-
|
|
224
|
+
const japanese = KANA_RE.test(text);
|
|
225
|
+
let pre = text;
|
|
226
|
+
if (japanese) {
|
|
227
|
+
pre = kanaToRomaji(pre.replace(HAN_GLOBAL_RE, " "));
|
|
228
|
+
}
|
|
229
|
+
if (HANGUL_RE.test(pre)) {
|
|
230
|
+
pre = hangulToLatin(pre);
|
|
231
|
+
}
|
|
232
|
+
const base = limax(pre, { tone: false }).replace(/_/g, "-");
|
|
233
|
+
if (!base) return "";
|
|
234
|
+
return slugCoverage(text, japanese) >= SLUG_COVERAGE_THRESHOLD ? base : "";
|
|
156
235
|
}
|
|
157
236
|
|
|
158
237
|
/**
|
|
@@ -183,6 +262,25 @@ export function extractDisplayDomain(url: string): string | null {
|
|
|
183
262
|
}
|
|
184
263
|
|
|
185
264
|
const SAFE_URL_PROTOCOLS = new Set(["http:", "https:", "mailto:"]);
|
|
265
|
+
const SAFE_RICH_TEXT_HREF_PROTOCOLS = new Set([
|
|
266
|
+
...SAFE_URL_PROTOCOLS,
|
|
267
|
+
"tel:",
|
|
268
|
+
"sms:",
|
|
269
|
+
]);
|
|
270
|
+
|
|
271
|
+
function sanitizeUrlWithProtocols(
|
|
272
|
+
url: string,
|
|
273
|
+
protocols: ReadonlySet<string>,
|
|
274
|
+
): string {
|
|
275
|
+
try {
|
|
276
|
+
const parsed = new URL(url, "https://placeholder.invalid");
|
|
277
|
+
// Relative URLs resolve against the placeholder and get https: — allow them
|
|
278
|
+
if (protocols.has(parsed.protocol)) return url;
|
|
279
|
+
return "";
|
|
280
|
+
} catch {
|
|
281
|
+
return "";
|
|
282
|
+
}
|
|
283
|
+
}
|
|
186
284
|
|
|
187
285
|
/**
|
|
188
286
|
* Checks whether a string is a safe absolute URL.
|
|
@@ -231,14 +329,27 @@ export function isSafeAbsoluteUrl(url: string): boolean {
|
|
|
231
329
|
* ```
|
|
232
330
|
*/
|
|
233
331
|
export function sanitizeUrl(url: string): string {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
332
|
+
return sanitizeUrlWithProtocols(url, SAFE_URL_PROTOCOLS);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Sanitizes a rich-text link destination.
|
|
337
|
+
*
|
|
338
|
+
* In addition to safe web and email URLs, rich-text links may launch the
|
|
339
|
+
* device's phone or messaging app through `tel:` and `sms:`. Relative links
|
|
340
|
+
* remain valid, while executable and resource-only protocols are rejected.
|
|
341
|
+
*
|
|
342
|
+
* @param url - The link destination to sanitize
|
|
343
|
+
* @returns The original URL if safe for an anchor, or an empty string otherwise
|
|
344
|
+
*
|
|
345
|
+
* @example
|
|
346
|
+
* ```ts
|
|
347
|
+
* sanitizeRichTextHref("sms:+15551234567"); // "sms:+15551234567"
|
|
348
|
+
* sanitizeRichTextHref("javascript:alert(1)"); // ""
|
|
349
|
+
* ```
|
|
350
|
+
*/
|
|
351
|
+
export function sanitizeRichTextHref(url: string): string {
|
|
352
|
+
return sanitizeUrlWithProtocols(url, SAFE_RICH_TEXT_HREF_PROTOCOLS);
|
|
242
353
|
}
|
|
243
354
|
|
|
244
355
|
/**
|
package/src/lib/view.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Theme components receive only View types -- no lib/ imports needed.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import type { JSONContent } from "@tiptap/core";
|
|
8
9
|
import type {
|
|
9
10
|
Post,
|
|
10
11
|
PostWithMedia,
|
|
@@ -35,7 +36,7 @@ import {
|
|
|
35
36
|
import { getCollectionPagePath } from "./collection-paths.js";
|
|
36
37
|
import { getMediaUrl, getImageUrl, getPublicUrlForProvider } from "./image.js";
|
|
37
38
|
import { extractSummaryHtml, extractBodyText } from "./summary.js";
|
|
38
|
-
import {
|
|
39
|
+
import { renderTiptapDocumentAroundBoundary } from "./tiptap-render.js";
|
|
39
40
|
import { highlightText } from "./search-snippet.js";
|
|
40
41
|
import { isFullUrl, toPublicPath, toSameSitePath } from "./url.js";
|
|
41
42
|
|
|
@@ -154,8 +155,9 @@ const NOTE_SUMMARY_MIN_HIDDEN_CHARS = 200;
|
|
|
154
155
|
* The summary HTML is not a byte-prefix of `bodyHtml` — structural nodes
|
|
155
156
|
* (horizontalRule, moreBreak, image) appear in `bodyHtml` but are excluded from
|
|
156
157
|
* the summary, so slicing `bodyHtml` by summary length lands mid-tag and
|
|
157
|
-
* corrupts the markup. Instead we
|
|
158
|
-
*
|
|
158
|
+
* corrupts the markup. Instead we split a full-document render at the exact
|
|
159
|
+
* source boundary. The full render plan keeps repeated-footnote backlinks
|
|
160
|
+
* byte-compatible across the split.
|
|
159
161
|
*
|
|
160
162
|
* @param bodyJson - Tiptap JSON string for the post body
|
|
161
163
|
* @param bodyHtml - Rendered body HTML to splice the marker into
|
|
@@ -169,6 +171,7 @@ function spliceAtSummaryBoundary(
|
|
|
169
171
|
bodyHtml: string,
|
|
170
172
|
breakAtIndex: number,
|
|
171
173
|
markerHtml: string,
|
|
174
|
+
namespace: string,
|
|
172
175
|
): string | null {
|
|
173
176
|
try {
|
|
174
177
|
const doc = JSON.parse(bodyJson) as { type?: string; content?: unknown[] };
|
|
@@ -180,12 +183,13 @@ function spliceAtSummaryBoundary(
|
|
|
180
183
|
) {
|
|
181
184
|
return null;
|
|
182
185
|
}
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
186
|
+
const split = renderTiptapDocumentAroundBoundary(
|
|
187
|
+
doc as JSONContent,
|
|
188
|
+
breakAtIndex,
|
|
189
|
+
{ namespace },
|
|
190
|
+
);
|
|
191
|
+
if (!split || split.beforeHtml + split.afterHtml !== bodyHtml) return null;
|
|
192
|
+
return split.beforeHtml + markerHtml + split.afterHtml;
|
|
189
193
|
} catch {
|
|
190
194
|
// Better an untouched body than corrupted markup.
|
|
191
195
|
return null;
|
|
@@ -243,13 +247,13 @@ function clipPreviewText(
|
|
|
243
247
|
*
|
|
244
248
|
* @param post - Post with media attachments from database
|
|
245
249
|
* @param _ctx - Media context with URL configuration
|
|
246
|
-
* @param
|
|
250
|
+
* @param threadCollections - Optional shared Thread collections projected here
|
|
247
251
|
* @returns Render-ready PostView with pre-computed fields
|
|
248
252
|
*/
|
|
249
253
|
export function toPostView(
|
|
250
254
|
post: PostWithMedia,
|
|
251
255
|
ctx: MediaContext,
|
|
252
|
-
|
|
256
|
+
threadCollections?: Collection[],
|
|
253
257
|
isLastInThread?: boolean,
|
|
254
258
|
aliasPath?: string,
|
|
255
259
|
pinnedInCollection?: boolean,
|
|
@@ -288,6 +292,7 @@ export function toPostView(
|
|
|
288
292
|
isArticle ? ARTICLE_SUMMARY_MAX_BLOCKS : NOTE_SUMMARY_MAX_BLOCKS,
|
|
289
293
|
isArticle ? ARTICLE_SUMMARY_MAX_CHARS : NOTE_SUMMARY_MAX_CHARS,
|
|
290
294
|
isArticle ? 0 : NOTE_SUMMARY_MIN_HIDDEN_CHARS,
|
|
295
|
+
{ namespace: id },
|
|
291
296
|
);
|
|
292
297
|
if (result && isArticle) {
|
|
293
298
|
summaryHtml = result.html;
|
|
@@ -298,6 +303,7 @@ export function toPostView(
|
|
|
298
303
|
post.bodyHtml,
|
|
299
304
|
result.breakAtIndex,
|
|
300
305
|
'<span id="continue"></span>',
|
|
306
|
+
id,
|
|
301
307
|
);
|
|
302
308
|
if (spliced) bodyHtmlWithAnchor = spliced;
|
|
303
309
|
}
|
|
@@ -307,6 +313,7 @@ export function toPostView(
|
|
|
307
313
|
post.bodyHtml,
|
|
308
314
|
result.breakAtIndex,
|
|
309
315
|
"<span data-note-break></span>",
|
|
316
|
+
id,
|
|
310
317
|
);
|
|
311
318
|
if (spliced) {
|
|
312
319
|
bodyHtmlWithAnchor = spliced;
|
|
@@ -316,11 +323,13 @@ export function toPostView(
|
|
|
316
323
|
}
|
|
317
324
|
|
|
318
325
|
// Convert collection tags
|
|
319
|
-
const collections: CollectionTagView[] = (
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
326
|
+
const collections: CollectionTagView[] = (threadCollections ?? []).map(
|
|
327
|
+
(c) => ({
|
|
328
|
+
slug: c.slug,
|
|
329
|
+
title: c.title,
|
|
330
|
+
url: toPublicPath(getCollectionPagePath(c.slug), ctx.sitePathPrefix),
|
|
331
|
+
}),
|
|
332
|
+
);
|
|
324
333
|
|
|
325
334
|
// Convert media attachments
|
|
326
335
|
const media: MediaView[] = post.mediaAttachments.map((m) => ({
|
|
@@ -397,6 +406,34 @@ export function toPostView(
|
|
|
397
406
|
};
|
|
398
407
|
}
|
|
399
408
|
|
|
409
|
+
/**
|
|
410
|
+
* Resolves the unpublished draft sitting at the end of a Thread, if any.
|
|
411
|
+
*
|
|
412
|
+
* Both maps come from the same ordering over the same rows, differing only in
|
|
413
|
+
* whether drafts are eligible — so when they disagree, the draft-inclusive
|
|
414
|
+
* answer is by definition unpublished. No status lookup needed.
|
|
415
|
+
*
|
|
416
|
+
* @param threadId - Thread root ID to look up
|
|
417
|
+
* @param publishedTails - Tails from `getThreadTailIds(ids)`
|
|
418
|
+
* @param draftInclusiveTails - Tails from `getThreadTailIds(ids, { includeDrafts: true })`
|
|
419
|
+
* @returns The trailing draft's Post ID, or undefined when the Thread ends
|
|
420
|
+
* on a published Post
|
|
421
|
+
* @example
|
|
422
|
+
* ```ts
|
|
423
|
+
* const draftTailId = resolveDraftTailId(post.threadId, tails, draftTails);
|
|
424
|
+
* if (draftTailId) view.draftTailId = draftTailId;
|
|
425
|
+
* ```
|
|
426
|
+
*/
|
|
427
|
+
export function resolveDraftTailId(
|
|
428
|
+
threadId: string,
|
|
429
|
+
publishedTails: Map<string, string>,
|
|
430
|
+
draftInclusiveTails: Map<string, string>,
|
|
431
|
+
): string | undefined {
|
|
432
|
+
const withDrafts = draftInclusiveTails.get(threadId);
|
|
433
|
+
if (!withDrafts) return undefined;
|
|
434
|
+
return withDrafts === publishedTails.get(threadId) ? undefined : withDrafts;
|
|
435
|
+
}
|
|
436
|
+
|
|
400
437
|
/**
|
|
401
438
|
* Batch converts PostWithMedia[] to PostView[].
|
|
402
439
|
*
|
|
@@ -429,12 +466,12 @@ export function toPostViewFromPost(
|
|
|
429
466
|
ctx: MediaContext,
|
|
430
467
|
isLastInThread?: boolean,
|
|
431
468
|
aliasPath?: string,
|
|
432
|
-
|
|
469
|
+
threadCollections?: Collection[],
|
|
433
470
|
): PostView {
|
|
434
471
|
return toPostView(
|
|
435
472
|
{ ...post, mediaAttachments: [] },
|
|
436
473
|
ctx,
|
|
437
|
-
|
|
474
|
+
threadCollections,
|
|
438
475
|
isLastInThread,
|
|
439
476
|
aliasPath,
|
|
440
477
|
);
|
|
@@ -534,6 +571,7 @@ export function toNavItemView(
|
|
|
534
571
|
type: item.type as NavItemType,
|
|
535
572
|
systemKey: item.systemKey,
|
|
536
573
|
collectionId: item.collectionId,
|
|
574
|
+
postId: item.postId,
|
|
537
575
|
label,
|
|
538
576
|
url: publicUrl,
|
|
539
577
|
placement: item.placement as NavItemPlacement,
|
|
@@ -587,14 +625,14 @@ export function toSearchResultView(
|
|
|
587
625
|
ctx: MediaContext,
|
|
588
626
|
query?: string,
|
|
589
627
|
aliasPath?: string,
|
|
590
|
-
|
|
628
|
+
threadCollections?: Collection[],
|
|
591
629
|
): SearchResultView {
|
|
592
630
|
const post = toPostViewFromPost(
|
|
593
631
|
result.post,
|
|
594
632
|
ctx,
|
|
595
633
|
undefined,
|
|
596
634
|
aliasPath,
|
|
597
|
-
|
|
635
|
+
threadCollections,
|
|
598
636
|
);
|
|
599
637
|
|
|
600
638
|
let titleHighlighted: string | undefined;
|