@jant/core 0.6.14 → 0.6.15

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 (311) hide show
  1. package/bin/commands/import-site.js +81 -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-BxCOR3Uc.js +6 -0
  12. package/dist/{app-2i7-WQHg.js → app-C-oi_t8W.js} +7408 -6343
  13. package/dist/client/.vite/manifest.json +3 -3
  14. package/dist/client/_assets/client-B_eGKN5p.css +2 -0
  15. package/dist/client/_assets/{client-BvID5ucz.js → client-DxY5BFe8.js} +58 -37
  16. package/dist/client/_assets/client-auth-CakPESv9.js +5533 -0
  17. package/dist/{export-DWn149b7.js → export-CsFx6F_M.js} +1259 -125
  18. package/dist/{github-sync-Ckk0sJxK.js → github-sync-Cq1pzzOI.js} +1 -1
  19. package/dist/{github-sync-C0zyF6XS.js → github-sync-ppWXN3jb.js} +2 -2
  20. package/dist/index.js +3 -3
  21. package/dist/node.js +5 -5
  22. package/package.json +11 -10
  23. package/src/__tests__/bin/posts-rebuild-html.test.ts +137 -0
  24. package/src/__tests__/dev-navigation-defaults.test.ts +22 -0
  25. package/src/__tests__/export-hugo-build.test.ts +63 -4
  26. package/src/__tests__/export-import-roundtrip.test.ts +6 -4
  27. package/src/__tests__/export-service.test.ts +142 -9
  28. package/src/__tests__/helpers/app.ts +3 -0
  29. package/src/__tests__/helpers/export-fixtures.ts +2 -0
  30. package/src/__tests__/import-site-command.test.ts +65 -0
  31. package/src/__tests__/vite-config.test.ts +45 -0
  32. package/src/app.tsx +16 -3
  33. package/src/client/__tests__/collection-created-notice.test.ts +36 -0
  34. package/src/client/__tests__/collection-form-bridge.test.ts +10 -1
  35. package/src/client/__tests__/collection-page-actions.test.ts +71 -2
  36. package/src/client/__tests__/compose-bridge.test.ts +64 -0
  37. package/src/client/__tests__/feed-video-player.test.ts +16 -1
  38. package/src/client/__tests__/footnote-rail.test.ts +472 -0
  39. package/src/client/__tests__/hydrate-partial.test.ts +3 -0
  40. package/src/client/__tests__/toast.test.ts +18 -1
  41. package/src/client/collection-created-notice.ts +66 -0
  42. package/src/client/collection-form-bridge.ts +17 -0
  43. package/src/client/collection-navigation.ts +44 -0
  44. package/src/client/collection-page-actions.ts +57 -1
  45. package/src/client/components/__tests__/jant-collection-directory.test.ts +155 -6
  46. package/src/client/components/__tests__/jant-compose-dialog.test.ts +370 -5
  47. package/src/client/components/__tests__/jant-compose-editor.test.ts +97 -0
  48. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +104 -0
  49. package/src/client/components/__tests__/jant-config-editor.test.ts +405 -0
  50. package/src/client/components/__tests__/jant-media-lightbox.test.ts +121 -0
  51. package/src/client/components/__tests__/jant-nav-manager.test.ts +538 -2
  52. package/src/client/components/__tests__/jant-post-form.test.ts +2 -2
  53. package/src/client/components/__tests__/jant-post-menu.test.ts +47 -5
  54. package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
  55. package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
  56. package/src/client/components/collection-manager-types.ts +9 -3
  57. package/src/client/components/compose-types.ts +8 -0
  58. package/src/client/components/jant-collection-directory.ts +165 -30
  59. package/src/client/components/jant-command-palette.ts +2 -1
  60. package/src/client/components/jant-compose-dialog.ts +85 -21
  61. package/src/client/components/jant-compose-editor.ts +13 -7
  62. package/src/client/components/jant-compose-fullscreen.ts +24 -4
  63. package/src/client/components/jant-config-editor.ts +722 -0
  64. package/src/client/components/jant-media-lightbox.ts +100 -18
  65. package/src/client/components/jant-nav-manager.ts +1283 -54
  66. package/src/client/components/jant-post-form.ts +3 -3
  67. package/src/client/components/jant-post-menu.ts +86 -74
  68. package/src/client/components/nav-manager-types.ts +51 -2
  69. package/src/client/components/post-form-types.ts +1 -1
  70. package/src/client/compose-bridge.ts +4 -1
  71. package/src/client/feed-video-player.ts +62 -2
  72. package/src/client/footnote-rail.ts +478 -0
  73. package/src/client/hydrate-partial.ts +2 -0
  74. package/src/client/media-lightbox-events.ts +63 -0
  75. package/src/client/site-header-fragment.ts +38 -0
  76. package/src/client/tiptap/__tests__/blockquote-editing.test.ts +201 -0
  77. package/src/client/tiptap/__tests__/clear-formatting.test.ts +168 -0
  78. package/src/client/tiptap/__tests__/list-editing.test.ts +447 -0
  79. package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +727 -4
  80. package/src/client/tiptap/__tests__/table-controls.test.ts +263 -0
  81. package/src/client/tiptap/__tests__/table-size-picker.test.ts +179 -0
  82. package/src/client/tiptap/bubble-menu.ts +88 -10
  83. package/src/client/tiptap/continuous-lists.ts +102 -0
  84. package/src/client/tiptap/create-editor.ts +3 -0
  85. package/src/client/tiptap/extensions.ts +14 -5
  86. package/src/client/tiptap/markdown-clipboard.ts +608 -11
  87. package/src/client/tiptap/slash-commands.ts +8 -1
  88. package/src/client/tiptap/structural-keymap.ts +211 -0
  89. package/src/client/tiptap/tab-indent.ts +8 -22
  90. package/src/client/tiptap/table-control-labels.ts +30 -0
  91. package/src/client/tiptap/table-controls.ts +537 -0
  92. package/src/client/tiptap/table-size-picker.ts +254 -0
  93. package/src/client/toast.ts +8 -6
  94. package/src/client-auth.ts +1 -0
  95. package/src/client.ts +1 -0
  96. package/src/db/__tests__/d1-query.test.ts +43 -1
  97. package/src/db/__tests__/migration-runner.test.ts +63 -0
  98. package/src/db/__tests__/migrations.test.ts +469 -0
  99. package/src/db/__tests__/thread-collection-migration.test.ts +364 -0
  100. package/src/db/migrations/0027_old_blue_blade.sql +39 -0
  101. package/src/db/migrations/0028_superb_post.sql +2 -0
  102. package/src/db/migrations/0029_boring_magma.sql +1 -0
  103. package/src/db/migrations/0030_typical_vivisector.sql +50 -0
  104. package/src/db/migrations/meta/0027_snapshot.json +2511 -0
  105. package/src/db/migrations/meta/0028_snapshot.json +2511 -0
  106. package/src/db/migrations/meta/0029_snapshot.json +2519 -0
  107. package/src/db/migrations/meta/0030_snapshot.json +2541 -0
  108. package/src/db/migrations/meta/_journal.json +28 -0
  109. package/src/db/migrations/pg/0025_moaning_epoch.sql +39 -0
  110. package/src/db/migrations/pg/0026_talented_killer_shrike.sql +2 -0
  111. package/src/db/migrations/pg/0027_short_cannonball.sql +1 -0
  112. package/src/db/migrations/pg/0028_perfect_sharon_carter.sql +27 -0
  113. package/src/db/migrations/pg/meta/0025_snapshot.json +3204 -0
  114. package/src/db/migrations/pg/meta/0026_snapshot.json +3204 -0
  115. package/src/db/migrations/pg/meta/0027_snapshot.json +3211 -0
  116. package/src/db/migrations/pg/meta/0028_snapshot.json +3248 -0
  117. package/src/db/migrations/pg/meta/_journal.json +28 -0
  118. package/src/db/pg/schema.ts +28 -20
  119. package/src/db/rehearsal-fixtures/demo-current.json +39 -4
  120. package/src/db/rehearsal-fixtures/demo-current.sql +121 -6
  121. package/src/db/schema.ts +28 -20
  122. package/src/i18n/__tests__/detect.test.ts +8 -2
  123. package/src/i18n/detect.ts +14 -5
  124. package/src/i18n/locales/public/en.po +133 -30
  125. package/src/i18n/locales/public/en.ts +1 -1
  126. package/src/i18n/locales/public/zh-Hans.po +128 -25
  127. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  128. package/src/i18n/locales/public/zh-Hant.po +128 -25
  129. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  130. package/src/i18n/locales/settings/en.po +513 -10
  131. package/src/i18n/locales/settings/en.ts +1 -1
  132. package/src/i18n/locales/settings/zh-Hans.po +515 -12
  133. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  134. package/src/i18n/locales/settings/zh-Hant.po +515 -12
  135. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  136. package/src/lib/__tests__/api-settings.test.ts +299 -0
  137. package/src/lib/__tests__/constants.test.ts +5 -0
  138. package/src/lib/__tests__/feed-policy.test.ts +25 -0
  139. package/src/lib/__tests__/footnotes-upgrade.test.ts +181 -0
  140. package/src/lib/__tests__/markdown-roundtrip-embed.test.ts +61 -0
  141. package/src/lib/__tests__/markdown-to-tiptap.test.ts +32 -4
  142. package/src/lib/__tests__/markdown.test.ts +8 -6
  143. package/src/lib/__tests__/navigation.test.ts +59 -2
  144. package/src/lib/__tests__/post-body-html.test.ts +176 -0
  145. package/src/lib/__tests__/post-display.test.ts +64 -0
  146. package/src/lib/__tests__/resolve-config.test.ts +113 -1
  147. package/src/lib/__tests__/summary.test.ts +82 -0
  148. package/src/lib/__tests__/timeline.test.ts +109 -70
  149. package/src/lib/__tests__/timezones.test.ts +31 -3
  150. package/src/lib/__tests__/tiptap-render.test.ts +268 -9
  151. package/src/lib/__tests__/tiptap-to-markdown.test.ts +6 -0
  152. package/src/lib/__tests__/view.test.ts +53 -0
  153. package/src/lib/__tests__/worker-response-cache.test.ts +18 -1
  154. package/src/lib/api-settings.ts +239 -12
  155. package/src/lib/collection-sort.ts +7 -5
  156. package/src/lib/constants.ts +2 -0
  157. package/src/lib/feed-path.ts +25 -0
  158. package/src/lib/feed-policy.ts +64 -0
  159. package/src/lib/footnotes.ts +264 -0
  160. package/src/lib/github-sync-site-config.ts +2 -0
  161. package/src/lib/hugo-markdown.ts +10 -3
  162. package/src/lib/jant-branding.ts +1 -0
  163. package/src/lib/markdown-manager.ts +188 -5
  164. package/src/lib/markdown.ts +10 -3
  165. package/src/lib/navigation.ts +16 -3
  166. package/src/lib/post-body-html.ts +131 -0
  167. package/src/lib/post-display.ts +7 -1
  168. package/src/lib/render.tsx +9 -2
  169. package/src/lib/resolve-config.ts +100 -16
  170. package/src/lib/schemas.ts +133 -1
  171. package/src/lib/settings-paths.ts +5 -0
  172. package/src/lib/site-skill.ts +78 -0
  173. package/src/lib/summary.ts +16 -5
  174. package/src/lib/timeline.ts +98 -122
  175. package/src/lib/timezones.ts +54 -14
  176. package/src/lib/tiptap-render.ts +355 -81
  177. package/src/lib/view.ts +30 -20
  178. package/src/lib/worker-response-cache.ts +11 -18
  179. package/src/middleware/__tests__/public-content-access.test.ts +91 -0
  180. package/src/middleware/config.ts +2 -2
  181. package/src/middleware/public-content-access.ts +87 -0
  182. package/src/node/__tests__/cli-export.test.ts +14 -0
  183. package/src/node/__tests__/cli-migrate.test.ts +6 -0
  184. package/src/node/__tests__/cli-site-snapshot.test.ts +205 -2
  185. package/src/node/__tests__/cli-snapshot-meta.test.ts +100 -0
  186. package/src/node/index.ts +2 -1
  187. package/src/preset.css +16 -2
  188. package/src/routes/__tests__/compose.test.ts +51 -0
  189. package/src/routes/__tests__/site-skill.test.ts +112 -0
  190. package/src/routes/api/__tests__/collections.test.ts +91 -19
  191. package/src/routes/api/__tests__/mcp.test.ts +65 -1
  192. package/src/routes/api/__tests__/nav-items.test.ts +91 -0
  193. package/src/routes/api/__tests__/public-access.test.ts +81 -0
  194. package/src/routes/api/__tests__/settings.test.ts +217 -0
  195. package/src/routes/api/collections.ts +22 -26
  196. package/src/routes/api/export.ts +2 -0
  197. package/src/routes/api/internal/__tests__/post-body-html.test.ts +163 -0
  198. package/src/routes/api/internal/__tests__/sites.test.ts +35 -2
  199. package/src/routes/api/internal/post-body-html.ts +43 -0
  200. package/src/routes/api/internal/sites.ts +24 -0
  201. package/src/routes/api/nav-items.ts +24 -1
  202. package/src/routes/api/posts.ts +2 -2
  203. package/src/routes/api/public/__tests__/archive.test.ts +35 -0
  204. package/src/routes/api/public/__tests__/posts.test.ts +208 -0
  205. package/src/routes/api/public/archive.ts +4 -0
  206. package/src/routes/api/public/posts.ts +41 -24
  207. package/src/routes/api/search.ts +3 -0
  208. package/src/routes/api/settings.ts +33 -0
  209. package/src/routes/auth/__tests__/setup.test.ts +7 -4
  210. package/src/routes/compose.tsx +2 -1
  211. package/src/routes/dash/settings.tsx +45 -2
  212. package/src/routes/feed/__tests__/feed.test.ts +210 -6
  213. package/src/routes/feed/feed.ts +23 -14
  214. package/src/routes/pages/__tests__/archive-params.test.ts +92 -3
  215. package/src/routes/pages/__tests__/collection-routing.test.ts +63 -5
  216. package/src/routes/pages/__tests__/collections.test.ts +9 -9
  217. package/src/routes/pages/__tests__/preview.test.ts +174 -0
  218. package/src/routes/pages/__tests__/search.test.ts +69 -0
  219. package/src/routes/pages/archive.tsx +27 -7
  220. package/src/routes/pages/collection.tsx +32 -19
  221. package/src/routes/pages/collections.tsx +6 -1
  222. package/src/routes/pages/page.tsx +83 -11
  223. package/src/routes/site-skill.ts +18 -0
  224. package/src/services/__tests__/auth.test.ts +7 -5
  225. package/src/services/__tests__/collection.test.ts +159 -44
  226. package/src/services/__tests__/navigation.test.ts +229 -21
  227. package/src/services/__tests__/post-timeline.test.ts +109 -30
  228. package/src/services/__tests__/post.test.ts +567 -5
  229. package/src/services/__tests__/search.test.ts +48 -1
  230. package/src/services/__tests__/settings.test.ts +8 -0
  231. package/src/services/__tests__/site-admin.test.ts +121 -1
  232. package/src/services/auth.ts +2 -2
  233. package/src/services/bootstrap.ts +3 -2
  234. package/src/services/collection.ts +226 -167
  235. package/src/services/export-theme/assets/client-site.css +1 -1
  236. package/src/services/export-theme/assets/client-site.js +46 -25
  237. package/src/services/export-theme/layouts/_default/list.html +18 -4
  238. package/src/services/export-theme/layouts/_default/rss.xml +8 -12
  239. package/src/services/export-theme/layouts/featured/list.html +5 -4
  240. package/src/services/export-theme/layouts/partials/featured-thread.html +56 -0
  241. package/src/services/export-theme/layouts/partials/footer.html +1 -1
  242. package/src/services/export-theme/layouts/partials/media-gallery.html +1 -0
  243. package/src/services/export-theme/styles/main.css +30 -2
  244. package/src/services/export.ts +91 -80
  245. package/src/services/mcp.ts +37 -27
  246. package/src/services/media.ts +1 -1
  247. package/src/services/navigation.ts +185 -46
  248. package/src/services/post.ts +874 -271
  249. package/src/services/search.ts +8 -2
  250. package/src/services/settings.ts +2 -10
  251. package/src/services/site-admin.ts +63 -43
  252. package/src/style-cjk-jp.css +1 -1
  253. package/src/style-cjk-kr.css +1 -1
  254. package/src/style-cjk-tc.css +1 -1
  255. package/src/style-cjk.css +1 -1
  256. package/src/styles/components.css +540 -0
  257. package/src/styles/site-media.css +111 -19
  258. package/src/styles/tokens.css +14 -9
  259. package/src/styles/ui.css +829 -29
  260. package/src/types/bindings.ts +3 -0
  261. package/src/types/config.ts +243 -12
  262. package/src/types/constants.ts +22 -1
  263. package/src/types/entities.ts +9 -8
  264. package/src/types/operations.ts +18 -10
  265. package/src/types/props.ts +8 -2
  266. package/src/types/raw-assets.d.ts +5 -0
  267. package/src/types/views.ts +16 -4
  268. package/src/ui/__tests__/font-themes.test.ts +42 -30
  269. package/src/ui/compose/ComposeDialog.tsx +92 -2
  270. package/src/ui/dash/appearance/NavigationContent.tsx +332 -4
  271. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +23 -0
  272. package/src/ui/dash/settings/ConfigEditorContent.tsx +578 -0
  273. package/src/ui/dash/settings/GeneralContent.tsx +15 -5
  274. package/src/ui/dash/settings/SettingsRootContent.tsx +21 -1
  275. package/src/ui/dash/settings/__tests__/ConfigEditorContent.test.tsx +98 -0
  276. package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +15 -0
  277. package/src/ui/feed/CuratedThreadPreview.tsx +16 -4
  278. package/src/ui/feed/LinkCard.tsx +2 -12
  279. package/src/ui/feed/NoteCard.tsx +10 -20
  280. package/src/ui/feed/QuoteCard.tsx +2 -12
  281. package/src/ui/feed/ThreadPreview.tsx +26 -34
  282. package/src/ui/feed/TimelineFeed.tsx +2 -2
  283. package/src/ui/feed/__tests__/thread-preview.test.ts +197 -56
  284. package/src/ui/feed/__tests__/timeline-cards.test.ts +178 -3
  285. package/src/ui/feed/thread-preview-state.ts +5 -7
  286. package/src/ui/font-themes.ts +74 -26
  287. package/src/ui/layouts/BaseLayout.tsx +22 -8
  288. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +64 -1
  289. package/src/ui/pages/CollectionPage.tsx +101 -22
  290. package/src/ui/pages/CollectionsPage.tsx +3 -1
  291. package/src/ui/pages/PostPage.tsx +39 -12
  292. package/src/ui/pages/SearchPage.tsx +6 -14
  293. package/src/ui/pages/__tests__/ArchivePage.test.tsx +7 -0
  294. package/src/ui/pages/__tests__/PostPage.test.tsx +88 -0
  295. package/src/ui/shared/CollectionDirectory.tsx +13 -9
  296. package/src/ui/shared/CollectionsManager.tsx +17 -7
  297. package/src/ui/shared/DraftPreviewBar.tsx +33 -0
  298. package/src/ui/shared/HomePageBranding.tsx +2 -2
  299. package/src/ui/shared/MediaGallery.tsx +1 -0
  300. package/src/ui/shared/PostFooter.tsx +3 -1
  301. package/src/ui/shared/__tests__/home-page-branding.test.tsx +1 -1
  302. package/src/ui/shared/__tests__/media-gallery.test.ts +1 -0
  303. package/src/ui/shared/__tests__/navigation-labels.test.ts +2 -2
  304. package/src/ui/shared/__tests__/post-footer.test.ts +15 -0
  305. package/src/ui/shared/collection-management-labels.ts +33 -2
  306. package/src/ui/shared/navigation-labels.ts +2 -4
  307. package/src/ui/shared/post-article-attributes.ts +46 -0
  308. package/dist/app-DZDlmh7C.js +0 -6
  309. package/dist/client/_assets/client-Cevfpm8H.css +0 -2
  310. package/dist/client/_assets/client-auth-v3yvhbgZ.js +0 -4736
  311. package/src/client/tiptap/wrapping-input-rules.ts +0 -102
@@ -176,6 +176,34 @@
176
176
  "when": 1781807044981,
177
177
  "tag": "0024_high_violations",
178
178
  "breakpoints": true
179
+ },
180
+ {
181
+ "idx": 25,
182
+ "version": "7",
183
+ "when": 1784131458842,
184
+ "tag": "0025_moaning_epoch",
185
+ "breakpoints": true
186
+ },
187
+ {
188
+ "idx": 26,
189
+ "version": "7",
190
+ "when": 1784343834845,
191
+ "tag": "0026_talented_killer_shrike",
192
+ "breakpoints": true
193
+ },
194
+ {
195
+ "idx": 27,
196
+ "version": "7",
197
+ "when": 1784436805462,
198
+ "tag": "0027_short_cannonball",
199
+ "breakpoints": true
200
+ },
201
+ {
202
+ "idx": 28,
203
+ "version": "7",
204
+ "when": 1784531392184,
205
+ "tag": "0028_perfect_sharon_carter",
206
+ "breakpoints": true
179
207
  }
180
208
  ]
181
209
  }
@@ -19,6 +19,7 @@ import {
19
19
  customType,
20
20
  } from "drizzle-orm/pg-core";
21
21
  import { sql } from "drizzle-orm";
22
+ import { SYSTEM_NAV_KEY_VALUES } from "../../types/constants.js";
22
23
 
23
24
  const SITE_STATUSES = ["active", "suspended"] as const;
24
25
  const SITE_DOMAIN_KINDS = ["primary", "alias"] as const;
@@ -27,16 +28,8 @@ const FORMATS = ["note", "link", "quote"] as const;
27
28
  const STATUSES = ["draft", "published"] as const;
28
29
  const VISIBILITIES = ["public", "latest_hidden", "private"] as const;
29
30
  const COLLECTION_SORT_ORDERS = ["newest", "oldest", "rating_desc"] as const;
30
- const NAV_ITEM_TYPES = ["link", "system", "collection"] as const;
31
+ const NAV_ITEM_TYPES = ["link", "system", "collection", "page"] as const;
31
32
  const NAV_ITEM_PLACEMENTS = ["header", "more"] as const;
32
- const SYSTEM_NAV_KEYS = [
33
- "latest",
34
- "featured",
35
- "collections",
36
- "archive",
37
- "rss",
38
- "settings",
39
- ] as const;
40
33
  const UPLOAD_SESSION_STATES = [
41
34
  "pending",
42
35
  "uploaded",
@@ -167,6 +160,7 @@ export const posts = pgTable(
167
160
  url: text("url"),
168
161
  body: text("body"),
169
162
  bodyHtml: text("body_html"),
163
+ bodyHtmlVersion: integer("body_html_version").notNull().default(1),
170
164
  bodyText: text("body_text"),
171
165
  quoteText: text("quote_text"),
172
166
  summary: text("summary"),
@@ -242,8 +236,8 @@ export const posts = pgTable(
242
236
  .where(
243
237
  sql`${table.replyToId} IS NOT NULL AND ${table.status} = 'published'`,
244
238
  ),
245
- index("idx_post_site_featured_featured_at")
246
- .on(table.siteId, table.featuredAt, table.threadId, table.id)
239
+ index("idx_post_site_featured_thread_published")
240
+ .on(table.siteId, table.threadId, table.publishedAt, table.id)
247
241
  .where(
248
242
  sql`${table.status} = 'published' AND ${table.featuredAt} IS NOT NULL`,
249
243
  ),
@@ -591,16 +585,16 @@ export const collectionDirectoryItems = pgTable(
591
585
  );
592
586
 
593
587
  // =============================================================================
594
- // Post-Collection Junction Table (M:N)
588
+ // Thread-Collection Junction Table (M:N)
595
589
  // =============================================================================
596
590
 
597
- export const postCollections = pgTable(
598
- "post_collection",
591
+ export const threadCollections = pgTable(
592
+ "thread_collection",
599
593
  {
600
594
  siteId: text("site_id")
601
595
  .notNull()
602
596
  .references(() => sites.id, { onDelete: "cascade" }),
603
- postId: text("post_id")
597
+ threadId: text("thread_id")
604
598
  .notNull()
605
599
  .references(() => posts.id, { onDelete: "cascade" }),
606
600
  collectionId: text("collection_id")
@@ -611,16 +605,16 @@ export const postCollections = pgTable(
611
605
  pinnedAt: integer("pinned_at"),
612
606
  },
613
607
  (table) => [
614
- primaryKey({ columns: [table.siteId, table.postId, table.collectionId] }),
615
- index("idx_post_collection_site_collection_id").on(
608
+ primaryKey({ columns: [table.siteId, table.threadId, table.collectionId] }),
609
+ index("idx_thread_collection_site_collection_id").on(
616
610
  table.siteId,
617
611
  table.collectionId,
618
612
  ),
619
- index("idx_post_collection_site_collection_created_post").on(
613
+ index("idx_thread_collection_site_collection_created_thread").on(
620
614
  table.siteId,
621
615
  table.collectionId,
622
616
  table.createdAt,
623
- table.postId,
617
+ table.threadId,
624
618
  ),
625
619
  ],
626
620
  );
@@ -642,11 +636,14 @@ export const navItems = pgTable(
642
636
  .notNull()
643
637
  .default("link"),
644
638
  systemKey: text("system_key", {
645
- enum: SYSTEM_NAV_KEYS,
639
+ enum: SYSTEM_NAV_KEY_VALUES,
646
640
  }),
647
641
  collectionId: text("collection_id").references(() => collections.id, {
648
642
  onDelete: "cascade",
649
643
  }),
644
+ postId: text("post_id").references(() => posts.id, {
645
+ onDelete: "cascade",
646
+ }),
650
647
  label: text("label").notNull(),
651
648
  url: text("url").notNull(),
652
649
  placement: text("placement", {
@@ -673,14 +670,22 @@ export const navItems = pgTable(
673
670
  ${table.type} = 'link'
674
671
  AND ${table.systemKey} IS NULL
675
672
  AND ${table.collectionId} IS NULL
673
+ AND ${table.postId} IS NULL
676
674
  ) OR (
677
675
  ${table.type} = 'system'
678
676
  AND ${table.systemKey} IS NOT NULL
679
677
  AND ${table.collectionId} IS NULL
678
+ AND ${table.postId} IS NULL
680
679
  ) OR (
681
680
  ${table.type} = 'collection'
682
681
  AND ${table.systemKey} IS NULL
683
682
  AND ${table.collectionId} IS NOT NULL
683
+ AND ${table.postId} IS NULL
684
+ ) OR (
685
+ ${table.type} = 'page'
686
+ AND ${table.systemKey} IS NULL
687
+ AND ${table.collectionId} IS NULL
688
+ AND ${table.postId} IS NOT NULL
684
689
  )`,
685
690
  ),
686
691
  uniqueIndex("uq_nav_item_site_position").on(table.siteId, table.position),
@@ -690,6 +695,9 @@ export const navItems = pgTable(
690
695
  uniqueIndex("uq_nav_item_site_collection_id")
691
696
  .on(table.siteId, table.collectionId)
692
697
  .where(sql`${table.collectionId} IS NOT NULL`),
698
+ uniqueIndex("uq_nav_item_site_post_id")
699
+ .on(table.siteId, table.postId)
700
+ .where(sql`${table.postId} IS NOT NULL`),
693
701
  ],
694
702
  );
695
703
 
@@ -1,25 +1,60 @@
1
1
  {
2
2
  "name": "demo-current",
3
- "baseMigrationTag": "0003_fts_site_aware",
3
+ "baseMigrationTag": "0026_absent_rhodey",
4
4
  "seedPath": "./demo-current.sql",
5
5
  "assertions": [
6
6
  {
7
7
  "name": "published posts exist",
8
8
  "sql": "SELECT COUNT(*) AS count FROM post",
9
9
  "column": "count",
10
- "atLeast": 1
10
+ "equals": 3
11
11
  },
12
12
  {
13
13
  "name": "collections exist",
14
14
  "sql": "SELECT COUNT(*) AS count FROM collection",
15
15
  "column": "count",
16
- "atLeast": 1
16
+ "equals": 2
17
17
  },
18
18
  {
19
19
  "name": "fts index populated",
20
20
  "sql": "SELECT COUNT(*) AS count FROM post_fts",
21
21
  "column": "count",
22
- "atLeast": 1
22
+ "equals": 3
23
+ },
24
+ {
25
+ "name": "thread collection union preserves both collections",
26
+ "sql": "SELECT COUNT(*) AS count FROM thread_collection",
27
+ "column": "count",
28
+ "equals": 2
29
+ },
30
+ {
31
+ "name": "shared collection metadata is aggregated across the thread",
32
+ "sql": "SELECT COUNT(*) AS count FROM thread_collection WHERE site_id = 'sit_01kme7j7j1f4m7s2k8r5c9t0qb' AND thread_id = 'pst_01kme7ja2mf4m7s2k8r5c9t0qf' AND collection_id = 'col_01kme7j8f9f4m7s2k8r5c9t0qc' AND created_at = 1774200040 AND position = 1 AND pinned_at = 1774200500",
33
+ "column": "count",
34
+ "equals": 1
35
+ },
36
+ {
37
+ "name": "child-only collection becomes thread membership",
38
+ "sql": "SELECT COUNT(*) AS count FROM thread_collection WHERE site_id = 'sit_01kme7j7j1f4m7s2k8r5c9t0qb' AND thread_id = 'pst_01kme7ja2mf4m7s2k8r5c9t0qf' AND collection_id = 'col_01kme7j8f9f4m7s2k8r5c9t0qd' AND created_at = 1774200060 AND position = 4 AND pinned_at = 1774200600",
39
+ "column": "count",
40
+ "equals": 1
41
+ },
42
+ {
43
+ "name": "thread collection targets are roots",
44
+ "sql": "SELECT COUNT(*) AS count FROM thread_collection tc INNER JOIN post root ON root.site_id = tc.site_id AND root.id = tc.thread_id WHERE root.reply_to_id IS NOT NULL OR root.thread_id <> root.id",
45
+ "column": "count",
46
+ "equals": 0
47
+ },
48
+ {
49
+ "name": "legacy post collection table is removed",
50
+ "sql": "SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name = 'post_collection'",
51
+ "column": "count",
52
+ "equals": 0
53
+ },
54
+ {
55
+ "name": "foreign keys remain valid",
56
+ "sql": "PRAGMA foreign_key_check",
57
+ "rowCount": 0
23
58
  }
24
59
  ]
25
60
  }
@@ -31,6 +31,24 @@ INSERT INTO "collection" (
31
31
  1774200010
32
32
  );
33
33
 
34
+ INSERT INTO "collection" (
35
+ "id",
36
+ "site_id",
37
+ "title",
38
+ "description",
39
+ "sort_order",
40
+ "created_at",
41
+ "updated_at"
42
+ ) VALUES (
43
+ 'col_01kme7j8f9f4m7s2k8r5c9t0qd',
44
+ 'sit_01kme7j7j1f4m7s2k8r5c9t0qb',
45
+ 'Child-only Notes',
46
+ 'Collection assigned only to a reply before the Thread migration.',
47
+ 'newest',
48
+ 1774200011,
49
+ 1774200011
50
+ );
51
+
34
52
  INSERT INTO "nav_item" (
35
53
  "id",
36
54
  "site_id",
@@ -149,18 +167,115 @@ INSERT INTO "post" (
149
167
  1774200020
150
168
  );
151
169
 
152
- INSERT INTO "post_collection" (
170
+ INSERT INTO "post" (
171
+ "id",
153
172
  "site_id",
154
- "post_id",
155
- "collection_id",
156
- "created_at"
173
+ "format",
174
+ "status",
175
+ "visibility",
176
+ "title",
177
+ "body",
178
+ "body_html",
179
+ "body_text",
180
+ "reply_to_id",
181
+ "thread_id",
182
+ "published_at",
183
+ "last_activity_at",
184
+ "created_at",
185
+ "updated_at"
157
186
  ) VALUES (
187
+ 'pst_01kme7ja2mf4m7s2k8r5c9t0qh',
158
188
  'sit_01kme7j7j1f4m7s2k8r5c9t0qb',
189
+ 'note',
190
+ 'published',
191
+ NULL,
192
+ 'First rehearsal reply',
193
+ '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"First migration rehearsal reply."}]}]}',
194
+ '<p>First migration rehearsal reply.</p>',
195
+ 'First migration rehearsal reply.',
159
196
  'pst_01kme7ja2mf4m7s2k8r5c9t0qf',
160
- 'col_01kme7j8f9f4m7s2k8r5c9t0qc',
161
- 1774200021
197
+ 'pst_01kme7ja2mf4m7s2k8r5c9t0qf',
198
+ 1774200030,
199
+ 1774200030,
200
+ 1774200030,
201
+ 1774200030
162
202
  );
163
203
 
204
+ INSERT INTO "post" (
205
+ "id",
206
+ "site_id",
207
+ "format",
208
+ "status",
209
+ "visibility",
210
+ "title",
211
+ "body",
212
+ "body_html",
213
+ "body_text",
214
+ "reply_to_id",
215
+ "thread_id",
216
+ "published_at",
217
+ "last_activity_at",
218
+ "created_at",
219
+ "updated_at"
220
+ ) VALUES (
221
+ 'pst_01kme7ja2mf4m7s2k8r5c9t0qi',
222
+ 'sit_01kme7j7j1f4m7s2k8r5c9t0qb',
223
+ 'note',
224
+ 'published',
225
+ NULL,
226
+ 'Second rehearsal reply',
227
+ '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Second migration rehearsal reply."}]}]}',
228
+ '<p>Second migration rehearsal reply.</p>',
229
+ 'Second migration rehearsal reply.',
230
+ 'pst_01kme7ja2mf4m7s2k8r5c9t0qh',
231
+ 'pst_01kme7ja2mf4m7s2k8r5c9t0qf',
232
+ 1774200040,
233
+ 1774200040,
234
+ 1774200040,
235
+ 1774200040
236
+ );
237
+
238
+ INSERT INTO "post_collection" (
239
+ "site_id",
240
+ "post_id",
241
+ "collection_id",
242
+ "created_at",
243
+ "position",
244
+ "pinned_at"
245
+ ) VALUES
246
+ (
247
+ 'sit_01kme7j7j1f4m7s2k8r5c9t0qb',
248
+ 'pst_01kme7ja2mf4m7s2k8r5c9t0qf',
249
+ 'col_01kme7j8f9f4m7s2k8r5c9t0qc',
250
+ 1774200021,
251
+ 7,
252
+ 1774200300
253
+ ),
254
+ (
255
+ 'sit_01kme7j7j1f4m7s2k8r5c9t0qb',
256
+ 'pst_01kme7ja2mf4m7s2k8r5c9t0qh',
257
+ 'col_01kme7j8f9f4m7s2k8r5c9t0qc',
258
+ 1774200040,
259
+ 5,
260
+ NULL
261
+ ),
262
+ (
263
+ 'sit_01kme7j7j1f4m7s2k8r5c9t0qb',
264
+ 'pst_01kme7ja2mf4m7s2k8r5c9t0qi',
265
+ 'col_01kme7j8f9f4m7s2k8r5c9t0qc',
266
+ 1774200030,
267
+ 1,
268
+ 1774200500
269
+ ),
270
+ (
271
+ 'sit_01kme7j7j1f4m7s2k8r5c9t0qb',
272
+ 'pst_01kme7ja2mf4m7s2k8r5c9t0qh',
273
+ 'col_01kme7j8f9f4m7s2k8r5c9t0qd',
274
+ 1774200060,
275
+ 4,
276
+ 1774200600
277
+ );
278
+
164
279
  INSERT INTO "path_registry" (
165
280
  "id",
166
281
  "site_id",
package/src/db/schema.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  check,
16
16
  } from "drizzle-orm/sqlite-core";
17
17
  import { sql } from "drizzle-orm";
18
+ import { SYSTEM_NAV_KEY_VALUES } from "../types/constants.js";
18
19
 
19
20
  const SITE_STATUSES = ["active", "suspended"] as const;
20
21
  const SITE_DOMAIN_KINDS = ["primary", "alias"] as const;
@@ -23,16 +24,8 @@ const FORMATS = ["note", "link", "quote"] as const;
23
24
  const STATUSES = ["draft", "published"] as const;
24
25
  const VISIBILITIES = ["public", "latest_hidden", "private"] as const;
25
26
  const COLLECTION_SORT_ORDERS = ["newest", "oldest", "rating_desc"] as const;
26
- const NAV_ITEM_TYPES = ["link", "system", "collection"] as const;
27
+ const NAV_ITEM_TYPES = ["link", "system", "collection", "page"] as const;
27
28
  const NAV_ITEM_PLACEMENTS = ["header", "more"] as const;
28
- const SYSTEM_NAV_KEYS = [
29
- "latest",
30
- "featured",
31
- "collections",
32
- "archive",
33
- "rss",
34
- "settings",
35
- ] as const;
36
29
  const UPLOAD_SESSION_STATES = [
37
30
  "pending",
38
31
  "uploaded",
@@ -161,6 +154,7 @@ export const posts = sqliteTable(
161
154
  url: text("url"),
162
155
  body: text("body"),
163
156
  bodyHtml: text("body_html"),
157
+ bodyHtmlVersion: integer("body_html_version").notNull().default(1),
164
158
  bodyText: text("body_text"),
165
159
  quoteText: text("quote_text"),
166
160
  summary: text("summary"),
@@ -227,8 +221,8 @@ export const posts = sqliteTable(
227
221
  .where(
228
222
  sql`${table.replyToId} IS NOT NULL AND ${table.status} = 'published'`,
229
223
  ),
230
- index("idx_post_site_featured_featured_at")
231
- .on(table.siteId, table.featuredAt, table.threadId, table.id)
224
+ index("idx_post_site_featured_thread_published")
225
+ .on(table.siteId, table.threadId, table.publishedAt, table.id)
232
226
  .where(
233
227
  sql`${table.status} = 'published' AND ${table.featuredAt} IS NOT NULL`,
234
228
  ),
@@ -571,16 +565,16 @@ export const collectionDirectoryItems = sqliteTable(
571
565
  );
572
566
 
573
567
  // =============================================================================
574
- // Post-Collection Junction Table (M:N)
568
+ // Thread-Collection Junction Table (M:N)
575
569
  // =============================================================================
576
570
 
577
- export const postCollections = sqliteTable(
578
- "post_collection",
571
+ export const threadCollections = sqliteTable(
572
+ "thread_collection",
579
573
  {
580
574
  siteId: text("site_id")
581
575
  .notNull()
582
576
  .references(() => sites.id, { onDelete: "cascade" }),
583
- postId: text("post_id")
577
+ threadId: text("thread_id")
584
578
  .notNull()
585
579
  .references(() => posts.id, { onDelete: "cascade" }),
586
580
  collectionId: text("collection_id")
@@ -591,16 +585,16 @@ export const postCollections = sqliteTable(
591
585
  pinnedAt: integer("pinned_at"),
592
586
  },
593
587
  (table) => [
594
- primaryKey({ columns: [table.siteId, table.postId, table.collectionId] }),
595
- index("idx_post_collection_site_collection_id").on(
588
+ primaryKey({ columns: [table.siteId, table.threadId, table.collectionId] }),
589
+ index("idx_thread_collection_site_collection_id").on(
596
590
  table.siteId,
597
591
  table.collectionId,
598
592
  ),
599
- index("idx_post_collection_site_collection_created_post").on(
593
+ index("idx_thread_collection_site_collection_created_thread").on(
600
594
  table.siteId,
601
595
  table.collectionId,
602
596
  table.createdAt,
603
- table.postId,
597
+ table.threadId,
604
598
  ),
605
599
  ],
606
600
  );
@@ -622,11 +616,14 @@ export const navItems = sqliteTable(
622
616
  .notNull()
623
617
  .default("link"),
624
618
  systemKey: text("system_key", {
625
- enum: SYSTEM_NAV_KEYS,
619
+ enum: SYSTEM_NAV_KEY_VALUES,
626
620
  }),
627
621
  collectionId: text("collection_id").references(() => collections.id, {
628
622
  onDelete: "cascade",
629
623
  }),
624
+ postId: text("post_id").references(() => posts.id, {
625
+ onDelete: "cascade",
626
+ }),
630
627
  label: text("label").notNull(),
631
628
  url: text("url").notNull(),
632
629
  placement: text("placement", {
@@ -653,14 +650,22 @@ export const navItems = sqliteTable(
653
650
  ${table.type} = 'link'
654
651
  AND ${table.systemKey} IS NULL
655
652
  AND ${table.collectionId} IS NULL
653
+ AND ${table.postId} IS NULL
656
654
  ) OR (
657
655
  ${table.type} = 'system'
658
656
  AND ${table.systemKey} IS NOT NULL
659
657
  AND ${table.collectionId} IS NULL
658
+ AND ${table.postId} IS NULL
660
659
  ) OR (
661
660
  ${table.type} = 'collection'
662
661
  AND ${table.systemKey} IS NULL
663
662
  AND ${table.collectionId} IS NOT NULL
663
+ AND ${table.postId} IS NULL
664
+ ) OR (
665
+ ${table.type} = 'page'
666
+ AND ${table.systemKey} IS NULL
667
+ AND ${table.collectionId} IS NULL
668
+ AND ${table.postId} IS NOT NULL
664
669
  )`,
665
670
  ),
666
671
  uniqueIndex("uq_nav_item_site_position").on(table.siteId, table.position),
@@ -670,6 +675,9 @@ export const navItems = sqliteTable(
670
675
  uniqueIndex("uq_nav_item_site_collection_id")
671
676
  .on(table.siteId, table.collectionId)
672
677
  .where(sql`${table.collectionId} IS NOT NULL`),
678
+ uniqueIndex("uq_nav_item_site_post_id")
679
+ .on(table.siteId, table.postId)
680
+ .where(sql`${table.postId} IS NOT NULL`),
673
681
  ],
674
682
  );
675
683
 
@@ -18,8 +18,14 @@ describe("detectLocaleFromHeader", () => {
18
18
  expect(detectLocaleFromHeader("fr")).toBe("en");
19
19
  });
20
20
 
21
- it("falls back to en for Chinese (not a supported UI locale)", () => {
22
- expect(detectLocaleFromHeader("zh-CN")).toBe("en");
21
+ it("maps Simplified Chinese browser locales to zh-Hans", () => {
22
+ expect(detectLocaleFromHeader("zh-CN")).toBe("zh-Hans");
23
+ expect(detectLocaleFromHeader("zh-SG")).toBe("zh-Hans");
24
+ });
25
+
26
+ it("maps Traditional Chinese browser locales to zh-Hant", () => {
27
+ expect(detectLocaleFromHeader("zh-TW")).toBe("zh-Hant");
28
+ expect(detectLocaleFromHeader("zh-HK")).toBe("zh-Hant");
23
29
  });
24
30
 
25
31
  it("returns en for undefined", () => {
@@ -43,6 +43,11 @@ function mapTagToLocale(tag: string): Locale | undefined {
43
43
  const normalized = tag.trim().toLowerCase();
44
44
  if (!normalized) return undefined;
45
45
 
46
+ const cjkProfile = getCjkFontFromLanguageTag(normalized);
47
+ if (cjkProfile === "zh-Hans" || cjkProfile === "zh-Hant") {
48
+ return cjkProfile;
49
+ }
50
+
46
51
  const primary = normalized.split("-")[0];
47
52
  if (isLocale(primary)) return primary;
48
53
 
@@ -90,7 +95,7 @@ export function detectLocaleFromHeader(header: string | undefined): Locale {
90
95
  return baseLocale;
91
96
  }
92
97
 
93
- /** Valid CJK serif font locale values */
98
+ /** Valid CJK font profile values */
94
99
  export const CJK_SERIF_FONT_VALUES = [
95
100
  "off",
96
101
  "zh-Hans",
@@ -111,12 +116,16 @@ export function isCjkSerifFont(value: unknown): value is CjkSerifFont {
111
116
  }
112
117
 
113
118
  /**
114
- * Map a BCP 47 language tag to a CJK serif font value.
119
+ * Map a BCP 47 language tag to a CJK font profile.
115
120
  *
116
121
  * @param tag - BCP 47 language tag (e.g. "zh-CN", "ja")
117
- * @returns CJK serif font value, or `undefined` if not a CJK language
122
+ * @returns CJK font profile, or `undefined` if not a CJK language
123
+ * @example
124
+ * getCjkFontFromLanguageTag("zh-TW") // "zh-Hant"
118
125
  */
119
- function mapTagToCjkFont(tag: string): CjkSerifFont | undefined {
126
+ export function getCjkFontFromLanguageTag(
127
+ tag: string,
128
+ ): Exclude<CjkSerifFont, "off"> | undefined {
120
129
  const normalized = tag.trim().toLowerCase();
121
130
  if (!normalized) return undefined;
122
131
 
@@ -177,7 +186,7 @@ export function detectCjkFontFromHeader(
177
186
  entries.sort((a, b) => b.q - a.q);
178
187
 
179
188
  for (const { tag } of entries) {
180
- const font = mapTagToCjkFont(tag);
189
+ const font = getCjkFontFromLanguageTag(tag);
181
190
  if (font) return font;
182
191
  }
183
192