@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.
Files changed (350) hide show
  1. package/bin/commands/import-site.js +88 -13
  2. package/bin/commands/migrate.js +55 -0
  3. package/bin/commands/posts/rebuild-html.js +184 -0
  4. package/bin/commands/site/snapshot/import.js +14 -10
  5. package/bin/lib/d1-query.js +23 -5
  6. package/bin/lib/migration-runner.js +29 -7
  7. package/bin/lib/node-database.js +32 -0
  8. package/bin/lib/site-snapshot.js +347 -46
  9. package/bin/lib/sql-export.js +1 -1
  10. package/bin/lib/thread-collection-migration.js +326 -0
  11. package/dist/app-C75Zc8dD.js +6 -0
  12. package/dist/{app-2i7-WQHg.js → app-C7_4oab9.js} +7774 -6425
  13. package/dist/client/.vite/manifest.json +8 -8
  14. package/dist/client/_assets/chunks/{url-pLre2DM_.js → url-CU9pEbk5.js} +1 -1
  15. package/dist/client/_assets/{client-BvID5ucz.js → client-Bq0vre8Y.js} +59 -38
  16. package/dist/client/_assets/client-D3VZyG4L.css +2 -0
  17. package/dist/client/_assets/client-auth-F2rb9WfF.js +5571 -0
  18. package/dist/{env-OHRKGcMj.js → env-BPYViDJJ.js} +1 -1
  19. package/dist/{export-DWn149b7.js → export-Dl5KCiEs.js} +1348 -128
  20. package/dist/{github-api-BgSiE71w.js → github-api-BNF35Lkx.js} +1 -1
  21. package/dist/{github-app-BbklkFmU.js → github-app-DdiD-bC4.js} +1 -1
  22. package/dist/{github-sync-C0zyF6XS.js → github-sync-Bqg62IvV.js} +3 -3
  23. package/dist/{github-sync-Ckk0sJxK.js → github-sync-HdK2EgvJ.js} +3 -3
  24. package/dist/index.js +5 -5
  25. package/dist/node.js +7 -7
  26. package/dist/{url-BMYO-Zlt.js → url-CbLAtlZe.js} +517 -12
  27. package/package.json +11 -10
  28. package/src/__tests__/bin/posts-rebuild-html.test.ts +137 -0
  29. package/src/__tests__/dev-navigation-defaults.test.ts +22 -0
  30. package/src/__tests__/export-hugo-build.test.ts +63 -4
  31. package/src/__tests__/export-import-roundtrip.test.ts +6 -4
  32. package/src/__tests__/export-service.test.ts +142 -9
  33. package/src/__tests__/helpers/app.ts +3 -0
  34. package/src/__tests__/helpers/export-fixtures.ts +2 -0
  35. package/src/__tests__/import-site-command.test.ts +65 -0
  36. package/src/__tests__/mise-config.test.ts +22 -0
  37. package/src/__tests__/vite-config.test.ts +45 -0
  38. package/src/app.tsx +16 -3
  39. package/src/client/__tests__/collection-created-notice.test.ts +36 -0
  40. package/src/client/__tests__/collection-form-bridge.test.ts +10 -1
  41. package/src/client/__tests__/collection-page-actions.test.ts +71 -2
  42. package/src/client/__tests__/compose-bridge.test.ts +316 -2
  43. package/src/client/__tests__/compose-shortcuts.test.ts +20 -0
  44. package/src/client/__tests__/feed-video-player.test.ts +16 -1
  45. package/src/client/__tests__/footnote-rail.test.ts +472 -0
  46. package/src/client/__tests__/hydrate-partial.test.ts +3 -0
  47. package/src/client/__tests__/toast.test.ts +82 -1
  48. package/src/client/collection-created-notice.ts +66 -0
  49. package/src/client/collection-form-bridge.ts +17 -0
  50. package/src/client/collection-navigation.ts +44 -0
  51. package/src/client/collection-page-actions.ts +57 -1
  52. package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
  53. package/src/client/components/__tests__/jant-collection-directory.test.ts +155 -6
  54. package/src/client/components/__tests__/jant-command-palette.test.ts +51 -0
  55. package/src/client/components/__tests__/jant-compose-dialog.test.ts +2268 -206
  56. package/src/client/components/__tests__/jant-compose-editor.test.ts +147 -59
  57. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +114 -64
  58. package/src/client/components/__tests__/jant-config-editor.test.ts +405 -0
  59. package/src/client/components/__tests__/jant-media-lightbox.test.ts +121 -0
  60. package/src/client/components/__tests__/jant-nav-manager.test.ts +538 -2
  61. package/src/client/components/__tests__/jant-post-form.test.ts +2 -2
  62. package/src/client/components/__tests__/jant-post-menu.test.ts +47 -5
  63. package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
  64. package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
  65. package/src/client/components/collection-manager-types.ts +9 -3
  66. package/src/client/components/compose-format-convert.ts +5 -1
  67. package/src/client/components/compose-types.ts +55 -3
  68. package/src/client/components/jant-collection-directory.ts +165 -30
  69. package/src/client/components/jant-command-palette.ts +14 -7
  70. package/src/client/components/jant-compose-dialog.ts +1542 -694
  71. package/src/client/components/jant-compose-editor.ts +237 -99
  72. package/src/client/components/jant-compose-fullscreen.ts +27 -9
  73. package/src/client/components/jant-config-editor.ts +722 -0
  74. package/src/client/components/jant-media-lightbox.ts +100 -18
  75. package/src/client/components/jant-nav-manager.ts +1283 -54
  76. package/src/client/components/jant-post-form.ts +3 -3
  77. package/src/client/components/jant-post-menu.ts +177 -95
  78. package/src/client/components/nav-manager-types.ts +51 -2
  79. package/src/client/components/post-form-types.ts +1 -1
  80. package/src/client/compose-bridge.ts +147 -123
  81. package/src/client/compose-launch.ts +13 -0
  82. package/src/client/compose-shortcuts.ts +5 -1
  83. package/src/client/feed-video-player.ts +62 -2
  84. package/src/client/footnote-rail.ts +478 -0
  85. package/src/client/hydrate-partial.ts +2 -0
  86. package/src/client/media-lightbox-events.ts +63 -0
  87. package/src/client/post-refresh.ts +135 -0
  88. package/src/client/site-header-fragment.ts +38 -0
  89. package/src/client/tiptap/__tests__/blockquote-editing.test.ts +201 -0
  90. package/src/client/tiptap/__tests__/clear-formatting.test.ts +168 -0
  91. package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
  92. package/src/client/tiptap/__tests__/list-editing.test.ts +447 -0
  93. package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +727 -4
  94. package/src/client/tiptap/__tests__/table-controls.test.ts +263 -0
  95. package/src/client/tiptap/__tests__/table-size-picker.test.ts +179 -0
  96. package/src/client/tiptap/bubble-menu.ts +88 -10
  97. package/src/client/tiptap/continuous-lists.ts +102 -0
  98. package/src/client/tiptap/create-editor.ts +3 -0
  99. package/src/client/tiptap/extensions.ts +14 -5
  100. package/src/client/tiptap/link-input-rules.ts +4 -4
  101. package/src/client/tiptap/markdown-clipboard.ts +608 -11
  102. package/src/client/tiptap/slash-commands.ts +8 -1
  103. package/src/client/tiptap/structural-keymap.ts +211 -0
  104. package/src/client/tiptap/tab-indent.ts +8 -22
  105. package/src/client/tiptap/table-control-labels.ts +30 -0
  106. package/src/client/tiptap/table-controls.ts +537 -0
  107. package/src/client/tiptap/table-size-picker.ts +254 -0
  108. package/src/client/toast.ts +133 -39
  109. package/src/client-auth.ts +1 -0
  110. package/src/client.ts +1 -0
  111. package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
  112. package/src/db/__tests__/d1-query.test.ts +43 -1
  113. package/src/db/__tests__/migration-runner.test.ts +63 -0
  114. package/src/db/__tests__/migrations.test.ts +469 -0
  115. package/src/db/__tests__/thread-activity.test.ts +113 -0
  116. package/src/db/__tests__/thread-collection-migration.test.ts +364 -0
  117. package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
  118. package/src/db/migrations/0027_old_blue_blade.sql +39 -0
  119. package/src/db/migrations/0028_superb_post.sql +2 -0
  120. package/src/db/migrations/0029_boring_magma.sql +1 -0
  121. package/src/db/migrations/0030_typical_vivisector.sql +50 -0
  122. package/src/db/migrations/0031_many_ego.sql +3 -0
  123. package/src/db/migrations/meta/0027_snapshot.json +2511 -0
  124. package/src/db/migrations/meta/0028_snapshot.json +2511 -0
  125. package/src/db/migrations/meta/0029_snapshot.json +2519 -0
  126. package/src/db/migrations/meta/0030_snapshot.json +2541 -0
  127. package/src/db/migrations/meta/0031_snapshot.json +2562 -0
  128. package/src/db/migrations/meta/_journal.json +35 -0
  129. package/src/db/migrations/pg/0025_moaning_epoch.sql +39 -0
  130. package/src/db/migrations/pg/0026_talented_killer_shrike.sql +2 -0
  131. package/src/db/migrations/pg/0027_short_cannonball.sql +1 -0
  132. package/src/db/migrations/pg/0028_perfect_sharon_carter.sql +27 -0
  133. package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
  134. package/src/db/migrations/pg/meta/0025_snapshot.json +3204 -0
  135. package/src/db/migrations/pg/meta/0026_snapshot.json +3204 -0
  136. package/src/db/migrations/pg/meta/0027_snapshot.json +3211 -0
  137. package/src/db/migrations/pg/meta/0028_snapshot.json +3248 -0
  138. package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
  139. package/src/db/migrations/pg/meta/_journal.json +35 -0
  140. package/src/db/pg/schema.ts +46 -20
  141. package/src/db/rehearsal-fixtures/demo-current.json +39 -4
  142. package/src/db/rehearsal-fixtures/demo-current.sql +121 -6
  143. package/src/db/schema.ts +48 -20
  144. package/src/db/thread-activity.ts +101 -0
  145. package/src/i18n/__tests__/detect.test.ts +8 -2
  146. package/src/i18n/detect.ts +14 -5
  147. package/src/i18n/locales/public/en.po +222 -51
  148. package/src/i18n/locales/public/en.ts +1 -1
  149. package/src/i18n/locales/public/zh-Hans.po +216 -45
  150. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  151. package/src/i18n/locales/public/zh-Hant.po +216 -45
  152. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  153. package/src/i18n/locales/settings/en.po +513 -10
  154. package/src/i18n/locales/settings/en.ts +1 -1
  155. package/src/i18n/locales/settings/zh-Hans.po +515 -12
  156. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  157. package/src/i18n/locales/settings/zh-Hant.po +515 -12
  158. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  159. package/src/lib/__tests__/api-settings.test.ts +299 -0
  160. package/src/lib/__tests__/constants.test.ts +5 -0
  161. package/src/lib/__tests__/feed-policy.test.ts +25 -0
  162. package/src/lib/__tests__/feed.test.ts +168 -0
  163. package/src/lib/__tests__/footnotes-upgrade.test.ts +181 -0
  164. package/src/lib/__tests__/markdown-roundtrip-embed.test.ts +61 -0
  165. package/src/lib/__tests__/markdown-to-tiptap.test.ts +32 -4
  166. package/src/lib/__tests__/markdown.test.ts +24 -6
  167. package/src/lib/__tests__/navigation.test.ts +59 -2
  168. package/src/lib/__tests__/post-body-html.test.ts +176 -0
  169. package/src/lib/__tests__/post-display.test.ts +64 -0
  170. package/src/lib/__tests__/resolve-config.test.ts +113 -1
  171. package/src/lib/__tests__/slug.test.ts +18 -0
  172. package/src/lib/__tests__/summary.test.ts +82 -0
  173. package/src/lib/__tests__/timeline.test.ts +109 -70
  174. package/src/lib/__tests__/timezones.test.ts +31 -3
  175. package/src/lib/__tests__/tiptap-render.test.ts +268 -9
  176. package/src/lib/__tests__/tiptap-to-markdown.test.ts +6 -0
  177. package/src/lib/__tests__/translit.test.ts +87 -0
  178. package/src/lib/__tests__/url.test.ts +53 -0
  179. package/src/lib/__tests__/view.test.ts +53 -0
  180. package/src/lib/__tests__/worker-response-cache.test.ts +18 -1
  181. package/src/lib/api-settings.ts +239 -12
  182. package/src/lib/collection-sort.ts +7 -5
  183. package/src/lib/constants.ts +2 -0
  184. package/src/lib/feed-path.ts +25 -0
  185. package/src/lib/feed-policy.ts +64 -0
  186. package/src/lib/feed.ts +113 -11
  187. package/src/lib/footnotes.ts +264 -0
  188. package/src/lib/github-sync-site-config.ts +2 -0
  189. package/src/lib/hugo-markdown.ts +15 -3
  190. package/src/lib/jant-branding.ts +1 -0
  191. package/src/lib/link-preview.ts +25 -0
  192. package/src/lib/markdown-manager.ts +193 -5
  193. package/src/lib/markdown.ts +10 -3
  194. package/src/lib/navigation.ts +16 -3
  195. package/src/lib/post-body-html.ts +131 -0
  196. package/src/lib/post-display.ts +47 -8
  197. package/src/lib/render.tsx +9 -2
  198. package/src/lib/resolve-config.ts +100 -16
  199. package/src/lib/schemas.ts +133 -1
  200. package/src/lib/settings-paths.ts +5 -0
  201. package/src/lib/site-skill.ts +78 -0
  202. package/src/lib/summary.ts +16 -5
  203. package/src/lib/timeline.ts +137 -131
  204. package/src/lib/timezones.ts +54 -14
  205. package/src/lib/tiptap-render.ts +359 -83
  206. package/src/lib/translit.ts +288 -0
  207. package/src/lib/url.ts +123 -12
  208. package/src/lib/view.ts +58 -20
  209. package/src/lib/worker-response-cache.ts +11 -18
  210. package/src/middleware/__tests__/public-content-access.test.ts +91 -0
  211. package/src/middleware/config.ts +2 -2
  212. package/src/middleware/public-content-access.ts +87 -0
  213. package/src/node/__tests__/cli-export.test.ts +14 -0
  214. package/src/node/__tests__/cli-migrate.test.ts +6 -0
  215. package/src/node/__tests__/cli-site-snapshot.test.ts +205 -2
  216. package/src/node/__tests__/cli-snapshot-meta.test.ts +100 -0
  217. package/src/node/index.ts +2 -1
  218. package/src/preset.css +16 -2
  219. package/src/routes/__tests__/compose.test.ts +51 -0
  220. package/src/routes/__tests__/site-skill.test.ts +112 -0
  221. package/src/routes/api/__tests__/collections.test.ts +91 -19
  222. package/src/routes/api/__tests__/mcp.test.ts +65 -1
  223. package/src/routes/api/__tests__/nav-items.test.ts +91 -0
  224. package/src/routes/api/__tests__/palette.test.ts +44 -0
  225. package/src/routes/api/__tests__/public-access.test.ts +81 -0
  226. package/src/routes/api/__tests__/settings.test.ts +217 -0
  227. package/src/routes/api/collections.ts +22 -26
  228. package/src/routes/api/export.ts +2 -0
  229. package/src/routes/api/internal/__tests__/post-body-html.test.ts +163 -0
  230. package/src/routes/api/internal/__tests__/sites.test.ts +35 -2
  231. package/src/routes/api/internal/post-body-html.ts +43 -0
  232. package/src/routes/api/internal/sites.ts +24 -0
  233. package/src/routes/api/nav-items.ts +24 -1
  234. package/src/routes/api/posts.ts +14 -10
  235. package/src/routes/api/public/__tests__/archive.test.ts +35 -0
  236. package/src/routes/api/public/__tests__/posts.test.ts +246 -0
  237. package/src/routes/api/public/archive.ts +4 -0
  238. package/src/routes/api/public/posts.ts +48 -24
  239. package/src/routes/api/search.ts +3 -0
  240. package/src/routes/api/settings.ts +33 -0
  241. package/src/routes/auth/__tests__/setup.test.ts +7 -4
  242. package/src/routes/compose.tsx +8 -3
  243. package/src/routes/dash/settings.tsx +45 -2
  244. package/src/routes/feed/__tests__/feed.test.ts +210 -6
  245. package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
  246. package/src/routes/feed/feed.ts +23 -14
  247. package/src/routes/pages/__tests__/archive-params.test.ts +384 -3
  248. package/src/routes/pages/__tests__/collection-routing.test.ts +63 -5
  249. package/src/routes/pages/__tests__/collections.test.ts +9 -9
  250. package/src/routes/pages/__tests__/featured.test.ts +6 -2
  251. package/src/routes/pages/__tests__/preview.test.ts +176 -0
  252. package/src/routes/pages/__tests__/search.test.ts +69 -0
  253. package/src/routes/pages/archive.tsx +90 -22
  254. package/src/routes/pages/collection.tsx +32 -19
  255. package/src/routes/pages/collections.tsx +6 -1
  256. package/src/routes/pages/page.tsx +86 -11
  257. package/src/routes/site-skill.ts +18 -0
  258. package/src/services/__tests__/auth.test.ts +7 -5
  259. package/src/services/__tests__/collection.test.ts +190 -44
  260. package/src/services/__tests__/navigation.test.ts +229 -21
  261. package/src/services/__tests__/post-timeline.test.ts +249 -39
  262. package/src/services/__tests__/post.test.ts +844 -5
  263. package/src/services/__tests__/search.test.ts +48 -1
  264. package/src/services/__tests__/settings.test.ts +8 -0
  265. package/src/services/__tests__/site-admin.test.ts +121 -1
  266. package/src/services/auth.ts +2 -2
  267. package/src/services/bootstrap.ts +3 -2
  268. package/src/services/collection.ts +224 -167
  269. package/src/services/export-theme/assets/client-site.css +1 -1
  270. package/src/services/export-theme/assets/client-site.js +46 -25
  271. package/src/services/export-theme/layouts/_default/list.html +18 -4
  272. package/src/services/export-theme/layouts/_default/rss.xml +8 -12
  273. package/src/services/export-theme/layouts/featured/list.html +5 -4
  274. package/src/services/export-theme/layouts/partials/featured-thread.html +56 -0
  275. package/src/services/export-theme/layouts/partials/footer.html +1 -1
  276. package/src/services/export-theme/layouts/partials/media-gallery.html +1 -0
  277. package/src/services/export-theme/styles/main.css +30 -2
  278. package/src/services/export.ts +94 -85
  279. package/src/services/mcp.ts +37 -27
  280. package/src/services/media.ts +1 -1
  281. package/src/services/navigation.ts +185 -46
  282. package/src/services/path.ts +4 -1
  283. package/src/services/post.ts +1142 -342
  284. package/src/services/search.ts +17 -2
  285. package/src/services/settings.ts +2 -10
  286. package/src/services/site-admin.ts +63 -43
  287. package/src/style-cjk-jp.css +1 -1
  288. package/src/style-cjk-kr.css +1 -1
  289. package/src/style-cjk-tc.css +1 -1
  290. package/src/style-cjk.css +1 -1
  291. package/src/styles/components.css +540 -14
  292. package/src/styles/site-media.css +111 -19
  293. package/src/styles/tokens.css +76 -10
  294. package/src/styles/ui.css +2227 -1347
  295. package/src/types/bindings.ts +3 -0
  296. package/src/types/config.ts +243 -12
  297. package/src/types/constants.ts +22 -1
  298. package/src/types/entities.ts +14 -8
  299. package/src/types/operations.ts +18 -10
  300. package/src/types/props.ts +20 -2
  301. package/src/types/raw-assets.d.ts +5 -0
  302. package/src/types/views.ts +22 -4
  303. package/src/ui/__tests__/font-themes.test.ts +42 -30
  304. package/src/ui/compose/ComposeDialog.tsx +109 -19
  305. package/src/ui/dash/appearance/NavigationContent.tsx +332 -4
  306. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +23 -0
  307. package/src/ui/dash/settings/ConfigEditorContent.tsx +578 -0
  308. package/src/ui/dash/settings/GeneralContent.tsx +15 -5
  309. package/src/ui/dash/settings/SettingsRootContent.tsx +21 -1
  310. package/src/ui/dash/settings/__tests__/ConfigEditorContent.test.tsx +98 -0
  311. package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +15 -0
  312. package/src/ui/feed/CuratedThreadPreview.tsx +16 -4
  313. package/src/ui/feed/LinkCard.tsx +2 -12
  314. package/src/ui/feed/LinkPreview.tsx +2 -7
  315. package/src/ui/feed/NoteCard.tsx +10 -20
  316. package/src/ui/feed/PostStatusBadges.tsx +42 -3
  317. package/src/ui/feed/QuoteCard.tsx +2 -12
  318. package/src/ui/feed/ThreadPreview.tsx +26 -34
  319. package/src/ui/feed/TimelineFeed.tsx +2 -2
  320. package/src/ui/feed/__tests__/thread-preview.test.ts +197 -56
  321. package/src/ui/feed/__tests__/timeline-cards.test.ts +178 -3
  322. package/src/ui/feed/thread-preview-state.ts +5 -7
  323. package/src/ui/font-themes.ts +74 -26
  324. package/src/ui/layouts/BaseLayout.tsx +22 -8
  325. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +64 -1
  326. package/src/ui/pages/ArchivePage.tsx +131 -24
  327. package/src/ui/pages/CollectionPage.tsx +101 -22
  328. package/src/ui/pages/CollectionsPage.tsx +3 -1
  329. package/src/ui/pages/PostPage.tsx +39 -12
  330. package/src/ui/pages/SearchPage.tsx +6 -14
  331. package/src/ui/pages/__tests__/ArchivePage.test.tsx +70 -0
  332. package/src/ui/pages/__tests__/PostPage.test.tsx +88 -0
  333. package/src/ui/shared/CollectionDirectory.tsx +13 -9
  334. package/src/ui/shared/CollectionsManager.tsx +17 -7
  335. package/src/ui/shared/DraftPreviewBar.tsx +49 -0
  336. package/src/ui/shared/HomePageBranding.tsx +2 -2
  337. package/src/ui/shared/MediaGallery.tsx +1 -0
  338. package/src/ui/shared/PostFooter.tsx +38 -13
  339. package/src/ui/shared/__tests__/home-page-branding.test.tsx +1 -1
  340. package/src/ui/shared/__tests__/media-gallery.test.ts +1 -0
  341. package/src/ui/shared/__tests__/navigation-labels.test.ts +2 -2
  342. package/src/ui/shared/__tests__/post-footer.test.ts +15 -0
  343. package/src/ui/shared/collection-management-labels.ts +33 -2
  344. package/src/ui/shared/custom-icons.ts +5 -0
  345. package/src/ui/shared/navigation-labels.ts +2 -4
  346. package/src/ui/shared/post-article-attributes.ts +52 -0
  347. package/dist/app-DZDlmh7C.js +0 -6
  348. package/dist/client/_assets/client-Cevfpm8H.css +0 -2
  349. package/dist/client/_assets/client-auth-v3yvhbgZ.js +0 -4736
  350. 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
- * Transforms text into a lowercase, hyphen-separated slug using limax for
140
- * i18n-aware transliteration (CJK → Pinyin, Japanese → Romaji, accented → ASCII).
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
- return limax(text, { tone: false }).replace(/_/g, "-");
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
- try {
235
- const parsed = new URL(url, "https://placeholder.invalid");
236
- // Relative URLs resolve against the placeholder and get https: — allow them
237
- if (SAFE_URL_PROTOCOLS.has(parsed.protocol)) return url;
238
- return "";
239
- } catch {
240
- return "";
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 { renderTiptapDocument } from "./tiptap-render.js";
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 render the pre-boundary doc slice and splice
158
- * the marker at that exact block boundary.
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 beforeHtml = renderTiptapDocument({
184
- type: "doc",
185
- content: doc.content.slice(0, breakAtIndex) as never[],
186
- });
187
- if (!bodyHtml.startsWith(beforeHtml)) return null;
188
- return beforeHtml + markerHtml + bodyHtml.slice(beforeHtml.length);
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 postCollections - Optional collections this post belongs to
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
- postCollections?: Collection[],
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[] = (postCollections ?? []).map((c) => ({
320
- slug: c.slug,
321
- title: c.title,
322
- url: toPublicPath(getCollectionPagePath(c.slug), ctx.sitePathPrefix),
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
- postCollections?: Collection[],
469
+ threadCollections?: Collection[],
433
470
  ): PostView {
434
471
  return toPostView(
435
472
  { ...post, mediaAttachments: [] },
436
473
  ctx,
437
- postCollections,
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
- postCollections?: Collection[],
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
- postCollections,
635
+ threadCollections,
598
636
  );
599
637
 
600
638
  let titleHighlighted: string | undefined;