@jant/core 0.6.16 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (508) hide show
  1. package/README.md +11 -11
  2. package/bin/commands/assets/upload.js +192 -15
  3. package/bin/commands/import-site.js +60 -4
  4. package/bin/commands/reset-password.js +21 -8
  5. package/bin/commands/site/snapshot/export.js +1 -1
  6. package/bin/commands/site/snapshot/import.js +1 -1
  7. package/bin/lib/d1-query.js +5 -16
  8. package/bin/lib/node-env.js +40 -4
  9. package/bin/lib/site-selection.js +85 -29
  10. package/bin/lib/wrangler-cli.js +28 -2
  11. package/bin/lib/wrangler-json.js +35 -0
  12. package/dist/{app-C7_4oab9.js → app-K_Aa1MMn.js} +52476 -13330
  13. package/dist/client/.vite/manifest.json +255 -90
  14. package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
  15. package/dist/client/_assets/chunks/create-editor-CD3FhrOB.js +257 -0
  16. package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
  17. package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
  18. package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
  19. package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
  20. package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
  21. package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
  22. package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
  23. package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
  24. package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
  25. package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
  26. package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
  27. package/dist/client/_assets/chunks/sortable-list-CgaL2jCs.js +2 -0
  28. package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
  29. package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
  30. package/dist/client/_assets/chunks/unsafe-svg-0QCkP0vZ.js +2 -0
  31. package/dist/client/_assets/client-GiYENVw8.css +2 -0
  32. package/dist/client/_assets/client-VnFxJN7G.js +295 -0
  33. package/dist/client/_assets/client-auth-k8gJ6QJj.js +828 -0
  34. package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
  35. package/dist/client/_assets/client-compose-B_kDtWkW.js +2004 -0
  36. package/dist/client/_assets/client-manage-M90aSTOk.js +2170 -0
  37. package/dist/client/_assets/client-settings-DfYs9n1F.js +1112 -0
  38. package/dist/{export-Dl5KCiEs.js → github-sync-BPAvT999.js} +2254 -107
  39. package/dist/index.js +2 -5
  40. package/dist/node.js +5 -7
  41. package/package.json +3 -3
  42. package/src/__tests__/auth-session-freshness.test.ts +57 -0
  43. package/src/__tests__/basecoat-variants.test.ts +121 -0
  44. package/src/__tests__/bin/assets-upload.test.ts +68 -0
  45. package/src/__tests__/export-hugo-build.test.ts +260 -0
  46. package/src/__tests__/export-service.test.ts +231 -0
  47. package/src/__tests__/helpers/component-css.ts +91 -0
  48. package/src/__tests__/helpers/db.ts +33 -0
  49. package/src/__tests__/helpers/export-fixtures.ts +3 -0
  50. package/src/__tests__/stylesheet-audience.test.ts +159 -0
  51. package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
  52. package/src/app.tsx +71 -16
  53. package/src/auth.ts +18 -1
  54. package/src/client/__tests__/collection-page-actions.test.ts +51 -2
  55. package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
  56. package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
  57. package/src/client/__tests__/compose-bridge.test.ts +272 -0
  58. package/src/client/__tests__/compose-launch.test.ts +62 -1
  59. package/src/client/__tests__/compose-shortcuts.test.ts +38 -9
  60. package/src/client/__tests__/compose-triggers.test.ts +90 -0
  61. package/src/client/__tests__/copy-field.test.ts +162 -0
  62. package/src/client/__tests__/custom-url-menu.test.ts +127 -6
  63. package/src/client/__tests__/image-processor.test.ts +324 -2
  64. package/src/client/__tests__/lazy-entries.test.ts +92 -0
  65. package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
  66. package/src/client/__tests__/runtime-paths.test.ts +84 -0
  67. package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
  68. package/src/client/__tests__/toast.test.ts +23 -0
  69. package/src/client/__tests__/video-processor-probe.test.ts +175 -0
  70. package/src/client/__tests__/video-processor.test.ts +181 -0
  71. package/src/client/audio-processor.ts +24 -15
  72. package/src/client/audio-waveform.ts +49 -0
  73. package/src/client/collection-dialog-host.ts +72 -0
  74. package/src/client/collection-navigation.ts +51 -16
  75. package/src/client/collection-page-actions.ts +26 -0
  76. package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
  77. package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
  78. package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
  79. package/src/client/components/__tests__/jant-command-palette.test.ts +28 -0
  80. package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
  81. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1250 -53
  82. package/src/client/components/__tests__/jant-compose-editor.test.ts +544 -40
  83. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +0 -1
  84. package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
  85. package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
  86. package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
  87. package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
  88. package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
  89. package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
  90. package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
  91. package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
  92. package/src/client/components/collection-dialog-types.ts +49 -0
  93. package/src/client/components/collection-manager-types.ts +32 -1
  94. package/src/client/components/collection-types.ts +1 -15
  95. package/src/client/components/compose-types.ts +75 -1
  96. package/src/client/components/jant-collection-dialog.ts +722 -0
  97. package/src/client/components/jant-collection-directory.ts +567 -182
  98. package/src/client/components/jant-collection-form.ts +53 -232
  99. package/src/client/components/jant-command-palette.ts +100 -49
  100. package/src/client/components/jant-compose-dialog.ts +1486 -707
  101. package/src/client/components/jant-compose-editor.ts +673 -336
  102. package/src/client/components/jant-compose-fullscreen.ts +51 -43
  103. package/src/client/components/jant-config-editor.ts +65 -53
  104. package/src/client/components/jant-confirm-dialog.ts +13 -11
  105. package/src/client/components/jant-locale-picker.ts +418 -0
  106. package/src/client/components/jant-media-lightbox.ts +146 -136
  107. package/src/client/components/jant-nav-manager.ts +690 -366
  108. package/src/client/components/jant-post-menu.ts +1086 -282
  109. package/src/client/components/jant-post-picker.ts +324 -0
  110. package/src/client/components/jant-repo-picker.ts +138 -106
  111. package/src/client/components/jant-settings-avatar.ts +31 -27
  112. package/src/client/components/jant-settings-general.ts +617 -543
  113. package/src/client/components/jant-settings-language.ts +1087 -0
  114. package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
  115. package/src/client/components/jant-text-preview.ts +94 -86
  116. package/src/client/components/nav-manager-types.ts +62 -1
  117. package/src/client/components/settings-types.ts +14 -29
  118. package/src/client/components/smart-collection-conditions.ts +285 -0
  119. package/src/client/components/smart-collection-dialog-types.ts +68 -0
  120. package/src/client/compose-bridge.ts +214 -141
  121. package/src/client/compose-launch.ts +76 -2
  122. package/src/client/compose-shortcuts.ts +6 -8
  123. package/src/client/compose-triggers.ts +50 -0
  124. package/src/client/copy-field.ts +108 -0
  125. package/src/client/custom-url-menu.ts +60 -4
  126. package/src/client/icons.ts +14 -0
  127. package/src/client/image-plan.ts +165 -0
  128. package/src/client/image-processor.ts +195 -117
  129. package/src/client/image-worker-client.ts +45 -0
  130. package/src/client/image-worker.ts +135 -0
  131. package/src/client/lazy-entries.ts +95 -0
  132. package/src/client/lazy-slugify.ts +2 -2
  133. package/src/client/media-scroll-hint.ts +43 -2
  134. package/src/client/mediabunny.ts +29 -0
  135. package/src/client/post-picker.ts +41 -0
  136. package/src/client/runtime-paths.ts +74 -1
  137. package/src/client/settings-bridge.ts +4 -10
  138. package/src/client/site-header-nav.js +18 -6
  139. package/src/client/smart-collection-dialog-host.ts +116 -0
  140. package/src/client/smart-collection-page-actions.ts +208 -0
  141. package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
  142. package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
  143. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
  144. package/src/client/tiptap/bubble-menu.ts +47 -16
  145. package/src/client/tiptap/floating-position.ts +216 -2
  146. package/src/client/tiptap/inline-image-upload.ts +7 -3
  147. package/src/client/tiptap/link-toolbar.ts +22 -9
  148. package/src/client/toast.ts +13 -0
  149. package/src/client/types/vite-worker.d.ts +12 -0
  150. package/src/client/upload-with-metadata.ts +3 -13
  151. package/src/client/video-processor.ts +351 -107
  152. package/src/client-auth.ts +14 -31
  153. package/src/client-compose.ts +19 -0
  154. package/src/client-manage.ts +13 -0
  155. package/src/client-settings.ts +14 -0
  156. package/src/client-site.ts +10 -3
  157. package/src/client.ts +4 -0
  158. package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
  159. package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
  160. package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
  161. package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
  162. package/src/db/migrations/0032_married_tomas.sql +3 -0
  163. package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
  164. package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
  165. package/src/db/migrations/meta/0032_snapshot.json +2582 -0
  166. package/src/db/migrations/meta/0033_snapshot.json +2816 -0
  167. package/src/db/migrations/meta/0034_snapshot.json +2816 -0
  168. package/src/db/migrations/meta/_journal.json +21 -0
  169. package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
  170. package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
  171. package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
  172. package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
  173. package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
  174. package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
  175. package/src/db/migrations/pg/meta/_journal.json +21 -0
  176. package/src/db/pg/schema.ts +195 -27
  177. package/src/db/post-visibility.ts +117 -0
  178. package/src/db/schema.ts +222 -27
  179. package/src/i18n/__tests__/detect.test.ts +26 -81
  180. package/src/i18n/__tests__/supported-locales.test.ts +83 -0
  181. package/src/i18n/context.tsx +12 -2
  182. package/src/i18n/detect.ts +7 -66
  183. package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
  184. package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
  185. package/src/i18n/locales/public/en.po +490 -158
  186. package/src/i18n/locales/public/en.ts +1 -1
  187. package/src/i18n/locales/public/zh-Hans.po +487 -155
  188. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  189. package/src/i18n/locales/public/zh-Hant.po +487 -155
  190. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  191. package/src/i18n/locales/settings/en.po +551 -69
  192. package/src/i18n/locales/settings/en.ts +1 -1
  193. package/src/i18n/locales/settings/zh-Hans.po +597 -115
  194. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  195. package/src/i18n/locales/settings/zh-Hant.po +610 -128
  196. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  197. package/src/i18n/locales.ts +94 -0
  198. package/src/i18n/middleware.ts +12 -2
  199. package/src/i18n/supported-locales.ts +80 -0
  200. package/src/lib/__tests__/api-settings.test.ts +11 -2
  201. package/src/lib/__tests__/asset-path.test.ts +92 -0
  202. package/src/lib/__tests__/deferred.test.ts +90 -0
  203. package/src/lib/__tests__/discover-ping.test.ts +89 -0
  204. package/src/lib/__tests__/discover.test.ts +410 -0
  205. package/src/lib/__tests__/feed-policy.test.ts +26 -0
  206. package/src/lib/__tests__/feed.test.ts +1526 -15
  207. package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
  208. package/src/lib/__tests__/hosted-signin.test.ts +1 -1
  209. package/src/lib/__tests__/http-cache.test.ts +162 -0
  210. package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
  211. package/src/lib/__tests__/jant-docs.test.ts +23 -0
  212. package/src/lib/__tests__/lang-detect.test.ts +231 -0
  213. package/src/lib/__tests__/media-helpers.test.ts +81 -0
  214. package/src/lib/__tests__/navigation.test.ts +135 -1
  215. package/src/lib/__tests__/resolve-config.test.ts +64 -3
  216. package/src/lib/__tests__/schemas.test.ts +42 -4
  217. package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
  218. package/src/lib/__tests__/slug.test.ts +70 -0
  219. package/src/lib/__tests__/slugify.test.ts +85 -0
  220. package/src/lib/__tests__/storage.test.ts +32 -0
  221. package/src/lib/__tests__/thread-fold.test.ts +113 -0
  222. package/src/lib/__tests__/upload-sideload.test.ts +14 -0
  223. package/src/lib/__tests__/url-fetch.test.ts +1 -2
  224. package/src/lib/__tests__/url.test.ts +78 -72
  225. package/src/lib/__tests__/view-language.test.ts +413 -0
  226. package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
  227. package/src/lib/address.ts +57 -0
  228. package/src/lib/api-posts.ts +12 -0
  229. package/src/lib/asset-path.ts +98 -1
  230. package/src/lib/build-env.ts +16 -0
  231. package/src/lib/collection-groups.ts +5 -1
  232. package/src/lib/collection-paths.ts +3 -10
  233. package/src/lib/constants.ts +28 -3
  234. package/src/lib/copy-field.ts +36 -0
  235. package/src/lib/crypto.ts +40 -0
  236. package/src/lib/csp-builder.ts +2 -1
  237. package/src/lib/deferred.ts +79 -0
  238. package/src/lib/discover-ping.ts +141 -0
  239. package/src/lib/discover.ts +437 -0
  240. package/src/lib/env.ts +134 -0
  241. package/src/lib/errors.ts +33 -0
  242. package/src/lib/feed-policy.ts +236 -2
  243. package/src/lib/feed.ts +926 -73
  244. package/src/lib/filter-dimensions.ts +1349 -0
  245. package/src/lib/github-sync-site-config.ts +3 -0
  246. package/src/lib/hosted-signin.ts +1 -1
  247. package/src/lib/http-cache.ts +148 -0
  248. package/src/lib/hugo-markdown.ts +16 -0
  249. package/src/lib/icons.ts +9 -2
  250. package/src/lib/ids.ts +3 -0
  251. package/src/lib/jant-docs.ts +42 -0
  252. package/src/lib/lang-detect.ts +308 -0
  253. package/src/lib/media-helpers.ts +13 -1
  254. package/src/lib/navigation.ts +78 -11
  255. package/src/lib/pagination.ts +28 -24
  256. package/src/lib/per-language-surfaces.ts +67 -0
  257. package/src/lib/post-meta.ts +19 -0
  258. package/src/lib/render.tsx +55 -7
  259. package/src/lib/resolve-config.ts +53 -5
  260. package/src/lib/schemas.ts +201 -10
  261. package/src/lib/site-header-fragment.tsx +2 -1
  262. package/src/lib/site-unavailable-page.ts +47 -0
  263. package/src/lib/slug.ts +51 -7
  264. package/src/lib/slugify.ts +113 -0
  265. package/src/lib/startup-config.ts +4 -6
  266. package/src/lib/storage.ts +7 -0
  267. package/src/lib/summary.ts +50 -0
  268. package/src/lib/thread-fold.ts +161 -0
  269. package/src/lib/time.ts +32 -0
  270. package/src/lib/timeline.ts +42 -1
  271. package/src/lib/tiptap-render.ts +1 -2
  272. package/src/lib/upload.ts +16 -13
  273. package/src/lib/url.ts +139 -86
  274. package/src/lib/version.ts +10 -8
  275. package/src/lib/view-language.ts +436 -0
  276. package/src/lib/view.ts +51 -23
  277. package/src/lib/viewer-context.ts +62 -0
  278. package/src/middleware/__tests__/auth.test.ts +70 -2
  279. package/src/middleware/__tests__/cache-control.test.ts +71 -3
  280. package/src/middleware/__tests__/onboarding.test.ts +76 -5
  281. package/src/middleware/__tests__/secure-headers.test.ts +1 -1
  282. package/src/middleware/__tests__/session.test.ts +181 -33
  283. package/src/middleware/auth.ts +50 -31
  284. package/src/middleware/cache-control.ts +75 -10
  285. package/src/middleware/config.ts +6 -6
  286. package/src/middleware/error-handler.ts +12 -0
  287. package/src/middleware/onboarding.ts +32 -10
  288. package/src/middleware/secure-headers.ts +1 -1
  289. package/src/middleware/session.ts +96 -8
  290. package/src/node/__tests__/cli-migrate.test.ts +21 -19
  291. package/src/node/__tests__/cli-reset-password.test.ts +81 -8
  292. package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
  293. package/src/node/__tests__/cli-site-selection.test.ts +128 -38
  294. package/src/node/__tests__/runtime.test.ts +37 -0
  295. package/src/node/index.ts +1 -0
  296. package/src/preset.css +76 -22
  297. package/src/routes/__tests__/feed-validator.test.ts +156 -0
  298. package/src/routes/api/__tests__/collections.test.ts +114 -0
  299. package/src/routes/api/__tests__/discover.test.ts +222 -0
  300. package/src/routes/api/__tests__/nav-items.test.ts +212 -2
  301. package/src/routes/api/__tests__/post-translations.test.ts +829 -0
  302. package/src/routes/api/__tests__/settings.test.ts +24 -3
  303. package/src/routes/api/__tests__/telegram.test.ts +6 -5
  304. package/src/routes/api/collections.ts +49 -6
  305. package/src/routes/api/custom-urls.ts +0 -1
  306. package/src/routes/api/discover.ts +95 -0
  307. package/src/routes/api/export.ts +3 -0
  308. package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
  309. package/src/routes/api/nav-items.ts +55 -8
  310. package/src/routes/api/posts.ts +159 -0
  311. package/src/routes/api/public/__tests__/archive.test.ts +111 -0
  312. package/src/routes/api/public/archive.ts +77 -81
  313. package/src/routes/api/public/posts.ts +23 -6
  314. package/src/routes/api/smart-collections.ts +132 -0
  315. package/src/routes/api/telegram.ts +2 -40
  316. package/src/routes/api/uploads.ts +1 -2
  317. package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
  318. package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
  319. package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
  320. package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
  321. package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
  322. package/src/routes/auth/__tests__/setup.test.ts +278 -26
  323. package/src/routes/auth/__tests__/signin.test.ts +135 -0
  324. package/src/routes/auth/setup.tsx +934 -186
  325. package/src/routes/auth/signin.tsx +10 -8
  326. package/src/routes/compose.tsx +5 -0
  327. package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
  328. package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
  329. package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
  330. package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
  331. package/src/routes/dash/custom-urls.tsx +1 -37
  332. package/src/routes/dash/settings.tsx +502 -41
  333. package/src/routes/discover-announce.ts +96 -0
  334. package/src/routes/feed/__tests__/feed.test.ts +165 -2
  335. package/src/routes/feed/feed.ts +69 -40
  336. package/src/routes/feed/sitemap.ts +95 -11
  337. package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
  338. package/src/routes/pages/__tests__/archive-params.test.ts +370 -6
  339. package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
  340. package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
  341. package/src/routes/pages/__tests__/collections.test.ts +9 -3
  342. package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
  343. package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
  344. package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
  345. package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
  346. package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
  347. package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
  348. package/src/routes/pages/archive.tsx +471 -369
  349. package/src/routes/pages/collection.tsx +94 -96
  350. package/src/routes/pages/collections.tsx +38 -50
  351. package/src/routes/pages/featured.tsx +32 -13
  352. package/src/routes/pages/home.tsx +25 -6
  353. package/src/routes/pages/language.tsx +113 -0
  354. package/src/routes/pages/latest.tsx +26 -16
  355. package/src/routes/pages/new.tsx +3 -0
  356. package/src/routes/pages/page.tsx +178 -17
  357. package/src/routes/pages/partials.tsx +41 -0
  358. package/src/routes/pages/search.tsx +18 -3
  359. package/src/routes/pages/smart-collection.tsx +340 -0
  360. package/src/routes/pages/subscribe.tsx +182 -0
  361. package/src/runtime/__tests__/index.test.ts +19 -0
  362. package/src/runtime/__tests__/node.test.ts +129 -4
  363. package/src/runtime/cloudflare.ts +21 -2
  364. package/src/runtime/index.ts +27 -1
  365. package/src/runtime/node.ts +18 -1
  366. package/src/runtime/site.ts +95 -24
  367. package/src/services/__tests__/auth.test.ts +116 -7
  368. package/src/services/__tests__/collection.test.ts +108 -4
  369. package/src/services/__tests__/language.test.ts +495 -0
  370. package/src/services/__tests__/navigation.test.ts +213 -10
  371. package/src/services/__tests__/path.test.ts +180 -0
  372. package/src/services/__tests__/post-language.test.ts +830 -0
  373. package/src/services/__tests__/post.test.ts +46 -9
  374. package/src/services/__tests__/settings.test.ts +228 -17
  375. package/src/services/__tests__/site-admin.test.ts +5 -5
  376. package/src/services/__tests__/smart-collection.test.ts +651 -0
  377. package/src/services/about-page.ts +22 -1
  378. package/src/services/auth.ts +51 -49
  379. package/src/services/bootstrap.ts +188 -38
  380. package/src/services/collection-directory-position.ts +76 -0
  381. package/src/services/collection.ts +331 -46
  382. package/src/services/custom-url.ts +27 -22
  383. package/src/services/export-theme/assets/client-site.css +1 -1
  384. package/src/services/export-theme/assets/client-site.js +123 -123
  385. package/src/services/export-theme/layouts/_default/rss.xml +77 -1
  386. package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
  387. package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
  388. package/src/services/export-theme/layouts/partials/pagination.html +14 -13
  389. package/src/services/export-theme/layouts/partials/post-card.html +8 -4
  390. package/src/services/export-theme/layouts/partials/reply.html +2 -1
  391. package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
  392. package/src/services/export-theme/styles/main.css +13 -1
  393. package/src/services/export.ts +278 -19
  394. package/src/services/index.ts +34 -3
  395. package/src/services/language.ts +412 -0
  396. package/src/services/mcp.ts +3 -1
  397. package/src/services/media.ts +14 -6
  398. package/src/services/navigation.ts +429 -67
  399. package/src/services/path.ts +221 -6
  400. package/src/services/post.ts +1021 -152
  401. package/src/services/search.ts +39 -2
  402. package/src/services/settings.ts +238 -43
  403. package/src/services/site-admin.ts +24 -7
  404. package/src/services/site.ts +77 -1
  405. package/src/services/smart-collection.ts +636 -0
  406. package/src/services/telegram.ts +20 -22
  407. package/src/services/upload-session.ts +1 -3
  408. package/src/style-author.css +27 -0
  409. package/src/styles/components-author.css +1622 -0
  410. package/src/styles/components.css +24 -1571
  411. package/src/styles/site-media.css +16 -2
  412. package/src/styles/tokens.css +82 -12
  413. package/src/styles/ui-author.css +7602 -0
  414. package/src/styles/ui.css +1087 -7219
  415. package/src/types/app-context.ts +7 -0
  416. package/src/types/bindings.ts +3 -0
  417. package/src/types/config.ts +94 -15
  418. package/src/types/constants.ts +188 -16
  419. package/src/types/entities.ts +87 -1
  420. package/src/types/operations.ts +64 -2
  421. package/src/types/props.ts +157 -31
  422. package/src/types/views.ts +55 -1
  423. package/src/ui/__tests__/font-themes.test.ts +52 -12
  424. package/src/ui/compose/ComposeDialog.tsx +201 -7
  425. package/src/ui/compose/ComposePrompt.tsx +1 -5
  426. package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
  427. package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
  428. package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
  429. package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
  430. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
  431. package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
  432. package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
  433. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  434. package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
  435. package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
  436. package/src/ui/dash/settings/GeneralContent.tsx +275 -113
  437. package/src/ui/dash/settings/LanguageContent.tsx +409 -0
  438. package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
  439. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
  440. package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
  441. package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
  442. package/src/ui/dash/settings/discover-copy.ts +116 -0
  443. package/src/ui/feed/LinkCard.tsx +55 -45
  444. package/src/ui/feed/NoteCard.tsx +9 -4
  445. package/src/ui/feed/PostStatusBadges.tsx +90 -43
  446. package/src/ui/feed/QuoteCard.tsx +55 -30
  447. package/src/ui/feed/ThreadPreview.tsx +6 -2
  448. package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
  449. package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
  450. package/src/ui/feed/thread-preview-state.ts +20 -6
  451. package/src/ui/font-themes.ts +39 -29
  452. package/src/ui/layouts/BaseLayout.tsx +155 -10
  453. package/src/ui/layouts/SiteLayout.tsx +192 -34
  454. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
  455. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
  456. package/src/ui/pages/ArchivePage.tsx +282 -289
  457. package/src/ui/pages/CollectionPage.tsx +86 -76
  458. package/src/ui/pages/CollectionsPage.tsx +5 -0
  459. package/src/ui/pages/ComposePage.tsx +8 -0
  460. package/src/ui/pages/PostPage.tsx +47 -0
  461. package/src/ui/pages/SearchPage.tsx +3 -3
  462. package/src/ui/pages/SmartCollectionPage.tsx +434 -0
  463. package/src/ui/pages/SubscribePage.tsx +236 -0
  464. package/src/ui/pages/__tests__/ArchivePage.test.tsx +65 -9
  465. package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
  466. package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
  467. package/src/ui/shared/CollectionDirectory.tsx +178 -3
  468. package/src/ui/shared/CollectionsManager.tsx +70 -17
  469. package/src/ui/shared/CopyField.tsx +127 -0
  470. package/src/ui/shared/EmptyPostContent.tsx +23 -0
  471. package/src/ui/shared/MediaGallery.tsx +125 -46
  472. package/src/ui/shared/Pagination.tsx +20 -5
  473. package/src/ui/shared/PostFooter.tsx +20 -11
  474. package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
  475. package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
  476. package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
  477. package/src/ui/shared/__tests__/pagination.test.ts +38 -24
  478. package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
  479. package/src/ui/shared/archive-labels.ts +98 -0
  480. package/src/ui/shared/collection-management-labels.ts +94 -16
  481. package/src/ui/shared/navigation-labels.ts +45 -71
  482. package/src/ui/shared/post-article-attributes.ts +2 -0
  483. package/src/ui/shared/smart-collection-labels.ts +343 -0
  484. package/dist/app-C75Zc8dD.js +0 -6
  485. package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
  486. package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
  487. package/dist/client/_assets/chunks/url-CU9pEbk5.js +0 -1
  488. package/dist/client/_assets/client-Bq0vre8Y.js +0 -296
  489. package/dist/client/_assets/client-D3VZyG4L.css +0 -2
  490. package/dist/client/_assets/client-auth-F2rb9WfF.js +0 -5571
  491. package/dist/env-BPYViDJJ.js +0 -281
  492. package/dist/github-api-BNF35Lkx.js +0 -176
  493. package/dist/github-app-DdiD-bC4.js +0 -275
  494. package/dist/github-sync-Bqg62IvV.js +0 -4
  495. package/dist/github-sync-HdK2EgvJ.js +0 -431
  496. package/dist/url-CbLAtlZe.js +0 -900
  497. package/src/client/__tests__/collection-created-notice.test.ts +0 -36
  498. package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
  499. package/src/client/__tests__/post-form-bridge.test.ts +0 -82
  500. package/src/client/collection-created-notice.ts +0 -66
  501. package/src/client/collection-form-bridge.ts +0 -191
  502. package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
  503. package/src/client/components/jant-post-form.ts +0 -422
  504. package/src/client/components/post-form-template.ts +0 -352
  505. package/src/client/components/post-form-types.ts +0 -96
  506. package/src/client/media-metadata.ts +0 -247
  507. package/src/client/post-form-bridge.ts +0 -193
  508. package/src/ui/pages/CollectionEditorPage.tsx +0 -175
@@ -13,14 +13,10 @@ import {
13
13
  type DatabaseSchema,
14
14
  } from "../db/schema-bundle.js";
15
15
  import { createEntityId } from "../lib/ids.js";
16
- import { ValidationError } from "../lib/errors.js";
16
+ import { getCollectionPagePath } from "../lib/collection-paths.js";
17
+ import { NotFoundError, ValidationError } from "../lib/errors.js";
17
18
  import { now } from "../lib/time.js";
18
- import {
19
- normalizePath,
20
- normalizeSitePathPrefix,
21
- stripSitePathPrefix,
22
- toSameSitePath,
23
- } from "../lib/url.js";
19
+ import { normalizePath, toInternalPath } from "../lib/url.js";
24
20
  import {
25
21
  COLLECTION_FRESHNESS_WINDOW_SECONDS,
26
22
  DEFAULT_NAVIGATION_PROFILE,
@@ -50,13 +46,69 @@ export interface ListSuggestedLinksOptions {
50
46
 
51
47
  // Re-export shared constraint detection — see db/dialect.ts
52
48
  import { isUniqueConstraintError } from "../db/dialect.js";
49
+ import { createPathService, type PathService } from "./path.js";
50
+
51
+ export interface ListNavItemsOptions {
52
+ /**
53
+ * Content language whose view is being rendered, when it is not the site's
54
+ * primary one. Page items then resolve to the version of their target
55
+ * written in that language, so a reader browsing `/en` reaches the English
56
+ * About page. Items whose target has no version in that language are left
57
+ * pointing at the original — an asymmetric site is the normal case, and a
58
+ * link to the one About that exists beats no link at all.
59
+ */
60
+ language?: string | null;
61
+ }
62
+
63
+ /**
64
+ * What an address the author pasted can become in navigation.
65
+ *
66
+ * An address that names something on this site should become an item that
67
+ * *follows* it — a page item tracks its post's title and slug, where a link
68
+ * item freezes both — so resolution is what turns a pasted URL into the right
69
+ * kind of item instead of a string that rots.
70
+ */
71
+ export type NavTargetResolution =
72
+ /** A post navigation can point at, with the name it will show. */
73
+ | { status: "page"; page: NavigationPageCandidate }
74
+ | { status: "collection"; collection: NavTargetCollection }
75
+ /** A smart collection, which navigation follows exactly like a collection. */
76
+ | { status: "smart_collection"; smartCollection: NavTargetCollection }
77
+ /** A page on this site that navigation can only hold as a link. */
78
+ | { status: "link_only" }
79
+ /** Nothing to show in a menu until it has a title. */
80
+ | { status: "untitled" }
81
+ | { status: "unpublished" }
82
+ /** Published but private: a menu entry nobody could open. */
83
+ | { status: "private" }
84
+ | { status: "not_found" };
85
+
86
+ export interface NavTargetCollection {
87
+ id: string;
88
+ title: string;
89
+ slug: string;
90
+ }
53
91
 
54
92
  export interface NavItemService {
55
- list(): Promise<NavItem[]>;
93
+ list(options?: ListNavItemsOptions): Promise<NavItem[]>;
56
94
  listPageCandidates(options?: {
57
95
  query?: string;
58
96
  limit?: number;
59
97
  }): Promise<NavigationPageCandidate[]>;
98
+ /**
99
+ * Resolve an address the author pasted into the navigation item it could
100
+ * become.
101
+ *
102
+ * The picker searches titles, which is no help when the author is holding
103
+ * the URL rather than the name. Every "no" is specific, because the page is
104
+ * one they can see in another tab: a draft, a private post, an untitled one.
105
+ *
106
+ * @param path - Internal path, as produced by `toInternalPath()`
107
+ * @returns The target to add, or why it cannot be added
108
+ * @example
109
+ * await navItems.resolveNavTarget("/about"); // { status: "page", page: … }
110
+ */
111
+ resolveNavTarget(path: string): Promise<NavTargetResolution>;
60
112
  getById(id: string): Promise<NavItem | null>;
61
113
  create(data: CreateNavItem): Promise<NavItem>;
62
114
  /**
@@ -82,20 +134,34 @@ export function createNavItemService(
82
134
  db: Database,
83
135
  siteId: string,
84
136
  databaseSchema: DatabaseSchema = sqliteSchemaBundle,
137
+ paths?: PathService,
85
138
  ): NavItemService {
86
- const { navItems, threadCollections, posts, pathRegistry, collections } =
87
- databaseSchema;
88
-
89
- function toNavItem(row: typeof navItems.$inferSelect): NavItem {
139
+ const {
140
+ navItems,
141
+ threadCollections,
142
+ posts,
143
+ pathRegistry,
144
+ collections,
145
+ smartCollections,
146
+ } = databaseSchema;
147
+ const resolvedPaths = paths ?? createPathService(db, siteId, databaseSchema);
148
+
149
+ function toNavItem(
150
+ row: typeof navItems.$inferSelect,
151
+ targetTitle?: string | null,
152
+ ): NavItem {
153
+ const title = targetTitle?.trim();
90
154
  return {
91
155
  id: row.id,
92
156
  siteId: row.siteId,
93
157
  type: row.type as NavItemType,
94
158
  systemKey: (row.systemKey as SystemNavKey | null) ?? undefined,
95
159
  collectionId: row.collectionId ?? undefined,
160
+ smartCollectionId: row.smartCollectionId ?? undefined,
96
161
  postId: row.postId ?? undefined,
97
162
  label: row.label,
98
163
  url: row.url,
164
+ ...(title ? { targetTitle: title } : {}),
99
165
  placement: (row.placement ?? "header") as NavItemPlacement,
100
166
  position: row.position,
101
167
  createdAt: row.createdAt,
@@ -103,6 +169,128 @@ export function createNavItemService(
103
169
  };
104
170
  }
105
171
 
172
+ /**
173
+ * Read nav rows together with the current title of whatever each one points
174
+ * at.
175
+ *
176
+ * The title is joined rather than stored so renaming a page or a collection
177
+ * moves its nav entry with it. Two left joins on primary keys, on a table
178
+ * that holds a handful of rows — the alternative, a denormalized copy, costs
179
+ * a sync branch in every write path that can rename a target, including the
180
+ * ones that bypass the service entirely (import, GitHub sync, backfills).
181
+ */
182
+ function selectNavItemsWithTargets() {
183
+ return db
184
+ .select({
185
+ nav: navItems,
186
+ postTitle: posts.title,
187
+ postTranslationGroupId: posts.translationGroupId,
188
+ collectionTitle: collections.title,
189
+ smartCollectionTitle: smartCollections.title,
190
+ })
191
+ .from(navItems)
192
+ .leftJoin(
193
+ posts,
194
+ and(eq(posts.siteId, siteId), eq(posts.id, navItems.postId)),
195
+ )
196
+ .leftJoin(
197
+ collections,
198
+ and(
199
+ eq(collections.siteId, siteId),
200
+ eq(collections.id, navItems.collectionId),
201
+ ),
202
+ )
203
+ .leftJoin(
204
+ smartCollections,
205
+ and(
206
+ eq(smartCollections.siteId, siteId),
207
+ eq(smartCollections.id, navItems.smartCollectionId),
208
+ ),
209
+ );
210
+ }
211
+
212
+ type NavItemWithTargetRow = Awaited<
213
+ ReturnType<ReturnType<typeof selectNavItemsWithTargets>["where"]>
214
+ >[number];
215
+
216
+ function targetTitleOf(row: NavItemWithTargetRow): string | null {
217
+ if (row.nav.type === "page") return row.postTitle;
218
+ if (row.nav.type === "collection") return row.collectionTitle;
219
+ if (row.nav.type === "smart_collection") return row.smartCollectionTitle;
220
+ return null;
221
+ }
222
+
223
+ /**
224
+ * Re-point page items at the version of their target written in `language`.
225
+ *
226
+ * A translated post is a different post at its own address, so following a
227
+ * nav entry into another language means swapping the destination, not
228
+ * prefixing the URL. Both the address and the title move together: a link
229
+ * that lands on Chinese content should be labelled in Chinese, whichever
230
+ * view the reader came from.
231
+ *
232
+ * Runs only in non-primary language views — the primary view's targets are
233
+ * already the ones stored — and only when a page item's target actually
234
+ * belongs to a translation group.
235
+ */
236
+ async function resolvePageTranslations(
237
+ rows: NavItemWithTargetRow[],
238
+ language: string,
239
+ ): Promise<Map<string, { url: string; title: string }>> {
240
+ const groupIds = [
241
+ ...new Set(
242
+ rows.flatMap((row) =>
243
+ row.nav.type === "page" && row.postTranslationGroupId
244
+ ? [row.postTranslationGroupId]
245
+ : [],
246
+ ),
247
+ ),
248
+ ];
249
+ if (groupIds.length === 0) return new Map();
250
+
251
+ // Same bar the item had to clear to be added: a nav link must never lead
252
+ // to a draft, a private post, or an untitled one.
253
+ const siblings = await db
254
+ .select({
255
+ groupId: posts.translationGroupId,
256
+ title: posts.title,
257
+ path: pathRegistry.path,
258
+ })
259
+ .from(posts)
260
+ .innerJoin(
261
+ pathRegistry,
262
+ and(
263
+ eq(pathRegistry.siteId, siteId),
264
+ eq(pathRegistry.postId, posts.id),
265
+ eq(pathRegistry.kind, "slug"),
266
+ ),
267
+ )
268
+ .where(
269
+ and(
270
+ eq(posts.siteId, siteId),
271
+ inArray(posts.translationGroupId, groupIds),
272
+ eq(posts.language, language),
273
+ eq(posts.status, "published"),
274
+ sql`${posts.visibility} != 'private'`,
275
+ isNull(posts.replyToId),
276
+ sql`trim(${posts.title}) != ''`,
277
+ ),
278
+ );
279
+
280
+ return new Map(
281
+ siblings.flatMap((sibling) =>
282
+ sibling.groupId && sibling.title
283
+ ? [
284
+ [
285
+ sibling.groupId,
286
+ { url: withLeadingSlash(sibling.path), title: sibling.title },
287
+ ] as const,
288
+ ]
289
+ : [],
290
+ ),
291
+ );
292
+ }
293
+
106
294
  async function normalizeCreateData(data: CreateNavItem) {
107
295
  if (data.type === "system") {
108
296
  const config = SYSTEM_NAV_KEYS[data.systemKey];
@@ -114,22 +302,93 @@ export function createNavItemService(
114
302
  type: data.type,
115
303
  systemKey: data.systemKey,
116
304
  collectionId: null,
305
+ smartCollectionId: null,
117
306
  postId: null,
118
307
  label: "",
119
308
  url: config.url,
309
+ targetTitle: null,
120
310
  placement: data.placement ?? config.defaultPlacement,
121
311
  position: data.position,
122
312
  };
123
313
  }
124
314
 
125
315
  if (data.type === "collection") {
316
+ // The slug lives in `path_registry`, the same place the page branch
317
+ // below reads it from — collections have no slug column of their own.
318
+ const rows = await db
319
+ .select({ title: collections.title, slug: pathRegistry.path })
320
+ .from(collections)
321
+ .innerJoin(
322
+ pathRegistry,
323
+ and(
324
+ eq(pathRegistry.siteId, siteId),
325
+ eq(pathRegistry.collectionId, collections.id),
326
+ eq(pathRegistry.kind, "slug"),
327
+ ),
328
+ )
329
+ .where(
330
+ and(
331
+ eq(collections.siteId, siteId),
332
+ eq(collections.id, data.collectionId),
333
+ ),
334
+ )
335
+ .limit(1);
336
+ const collection = rows[0];
337
+ if (!collection) {
338
+ throw new NotFoundError("Collection");
339
+ }
340
+
126
341
  return {
127
342
  type: data.type,
128
343
  systemKey: null,
129
344
  collectionId: data.collectionId,
345
+ smartCollectionId: null,
346
+ postId: null,
347
+ // Stored empty unless the author typed something: the item then shows
348
+ // the collection's current title, and follows it when renamed.
349
+ label: (data.label?.trim() ?? "").slice(0, 100),
350
+ url: getCollectionPagePath(collection.slug),
351
+ targetTitle: collection.title,
352
+ placement: data.placement ?? "header",
353
+ position: data.position,
354
+ };
355
+ }
356
+
357
+ if (data.type === "smart_collection") {
358
+ // Same shape as the collection branch: the slug lives in `path_registry`,
359
+ // and an omitted label means the item follows the title.
360
+ const rows = await db
361
+ .select({ title: smartCollections.title, slug: pathRegistry.path })
362
+ .from(smartCollections)
363
+ .innerJoin(
364
+ pathRegistry,
365
+ and(
366
+ eq(pathRegistry.siteId, siteId),
367
+ eq(pathRegistry.smartCollectionId, smartCollections.id),
368
+ eq(pathRegistry.kind, "slug"),
369
+ ),
370
+ )
371
+ .where(
372
+ and(
373
+ eq(smartCollections.siteId, siteId),
374
+ eq(smartCollections.id, data.smartCollectionId),
375
+ ),
376
+ )
377
+ .limit(1);
378
+ const smartCollection = rows[0];
379
+ if (!smartCollection) {
380
+ throw new NotFoundError("Smart collection");
381
+ }
382
+
383
+ return {
384
+ type: data.type,
385
+ systemKey: null,
386
+ collectionId: null,
387
+ smartCollectionId: data.smartCollectionId,
130
388
  postId: null,
131
- label: data.label,
132
- url: data.url,
389
+ label: (data.label?.trim() ?? "").slice(0, 100),
390
+ url: getCollectionPagePath(smartCollection.slug),
391
+ targetTitle: smartCollection.title,
133
392
  placement: data.placement ?? "header",
134
393
  position: data.position,
135
394
  };
@@ -173,9 +432,13 @@ export function createNavItemService(
173
432
  type: data.type,
174
433
  systemKey: null,
175
434
  collectionId: null,
435
+ smartCollectionId: null,
176
436
  postId: data.postId,
177
- label: (data.label?.trim() || page.title.trim()).slice(0, 100),
437
+ // Stored empty unless the author typed something: the item then shows
438
+ // the page's current title, and follows it when renamed.
439
+ label: (data.label?.trim() ?? "").slice(0, 100),
178
440
  url: withLeadingSlash(page.slug),
441
+ targetTitle: page.title,
179
442
  placement: data.placement ?? "header",
180
443
  position: data.position,
181
444
  };
@@ -185,14 +448,25 @@ export function createNavItemService(
185
448
  type: data.type,
186
449
  systemKey: null,
187
450
  collectionId: null,
451
+ smartCollectionId: null,
188
452
  postId: null,
189
453
  label: data.label,
190
454
  url: data.url,
455
+ targetTitle: null,
191
456
  placement: data.placement ?? "header",
192
457
  position: data.position,
193
458
  };
194
459
  }
195
460
 
461
+ /** Read one nav item with its target's current title. */
462
+ async function readNavItem(id: string): Promise<NavItem | null> {
463
+ const result = await selectNavItemsWithTargets()
464
+ .where(and(eq(navItems.siteId, siteId), eq(navItems.id, id)))
465
+ .limit(1);
466
+ const row = result[0];
467
+ return row ? toNavItem(row.nav, targetTitleOf(row)) : null;
468
+ }
469
+
196
470
  function withLeadingSlash(path: string): string {
197
471
  const normalized = normalizePath(path);
198
472
  return normalized ? `/${normalized}` : "/";
@@ -202,39 +476,11 @@ export function createNavItemService(
202
476
  url: string,
203
477
  options: ListSuggestedLinksOptions = {},
204
478
  ): string | null {
205
- const value = url.trim();
206
- const sitePathPrefix = normalizeSitePathPrefix(
207
- options.sitePathPrefix ?? "",
208
- );
209
- const sameSitePath = toSameSitePath(value, options.siteOrigin ?? "");
210
- if (sameSitePath !== null) {
211
- try {
212
- const pathname = new URL(sameSitePath, "https://jant.invalid").pathname;
213
- const internalPath = stripSitePathPrefix(pathname, sitePathPrefix);
214
- return internalPath ? withLeadingSlash(internalPath) : null;
215
- } catch {
216
- return null;
217
- }
218
- }
219
-
220
- if (
221
- value.startsWith("http://") ||
222
- value.startsWith("https://") ||
223
- value.startsWith("//") ||
224
- value.startsWith("mailto:") ||
225
- value.startsWith("tel:") ||
226
- value.startsWith("#")
227
- ) {
228
- return null;
229
- }
230
-
231
- try {
232
- const pathname = new URL(value, "https://jant.invalid").pathname;
233
- const internalPath = stripSitePathPrefix(pathname, sitePathPrefix);
234
- return internalPath ? withLeadingSlash(internalPath) : null;
235
- } catch {
236
- return null;
237
- }
479
+ const internalPath = toInternalPath(url, {
480
+ siteOrigins: options.siteOrigin ? [options.siteOrigin] : [],
481
+ sitePathPrefix: options.sitePathPrefix ?? "",
482
+ });
483
+ return internalPath ? withLeadingSlash(internalPath) : null;
238
484
  }
239
485
 
240
486
  function normalizeSuggestedLabel(
@@ -299,13 +545,26 @@ export function createNavItemService(
299
545
  }
300
546
 
301
547
  return {
302
- async list() {
303
- const rows = await db
304
- .select()
305
- .from(navItems)
548
+ async list(options = {}) {
549
+ const rows = await selectNavItemsWithTargets()
306
550
  .where(eq(navItems.siteId, siteId))
307
551
  .orderBy(asc(navItems.position));
308
- return rows.map(toNavItem);
552
+
553
+ const translations = options.language
554
+ ? await resolvePageTranslations(rows, options.language)
555
+ : null;
556
+
557
+ return rows.map((row) => {
558
+ const translated =
559
+ translations && row.nav.type === "page" && row.postTranslationGroupId
560
+ ? translations.get(row.postTranslationGroupId)
561
+ : undefined;
562
+ if (!translated) return toNavItem(row.nav, targetTitleOf(row));
563
+ // `postId` keeps naming the item's configured target: it is what the
564
+ // settings UI edits and what "already in navigation" checks against.
565
+ // Only where the reader lands, and what it is called, follow the view.
566
+ return toNavItem({ ...row.nav, url: translated.url }, translated.title);
567
+ });
309
568
  },
310
569
 
311
570
  async listPageCandidates(options = {}) {
@@ -365,13 +624,93 @@ export function createNavItemService(
365
624
  );
366
625
  },
367
626
 
368
- async getById(id) {
369
- const result = await db
370
- .select()
371
- .from(navItems)
372
- .where(and(eq(navItems.siteId, siteId), eq(navItems.id, id)))
627
+ async resolveNavTarget(path) {
628
+ const target = await resolvedPaths.resolveTarget(path);
629
+ if (!target) return { status: "not_found" };
630
+
631
+ if (target.targetType === "collection" && target.collectionId) {
632
+ const rows = await db
633
+ .select({ id: collections.id, title: collections.title })
634
+ .from(collections)
635
+ .where(
636
+ and(
637
+ eq(collections.siteId, siteId),
638
+ eq(collections.id, target.collectionId),
639
+ ),
640
+ )
641
+ .limit(1);
642
+ const row = rows[0];
643
+ if (!row) return { status: "not_found" };
644
+
645
+ const slug = await resolvedPaths.getCollectionSlug(row.id);
646
+ return {
647
+ status: "collection",
648
+ collection: { id: row.id, title: row.title, slug: slug ?? "" },
649
+ };
650
+ }
651
+
652
+ if (
653
+ target.targetType === "smart_collection" &&
654
+ target.smartCollectionId
655
+ ) {
656
+ const rows = await db
657
+ .select({ id: smartCollections.id, title: smartCollections.title })
658
+ .from(smartCollections)
659
+ .where(
660
+ and(
661
+ eq(smartCollections.siteId, siteId),
662
+ eq(smartCollections.id, target.smartCollectionId),
663
+ ),
664
+ )
665
+ .limit(1);
666
+ const row = rows[0];
667
+ if (!row) return { status: "not_found" };
668
+
669
+ const slug = await resolvedPaths.getSmartCollectionSlug(row.id);
670
+ return {
671
+ status: "smart_collection",
672
+ smartCollection: { id: row.id, title: row.title, slug: slug ?? "" },
673
+ };
674
+ }
675
+
676
+ if (target.targetType !== "post" || !target.postId) {
677
+ // A real address — an archive URL, say — that no navigation item can
678
+ // track. It can still be held as a plain link.
679
+ return { status: "link_only" };
680
+ }
681
+
682
+ const rows = await db
683
+ .select({
684
+ id: posts.id,
685
+ title: posts.title,
686
+ status: posts.status,
687
+ visibility: posts.visibility,
688
+ updatedAt: posts.updatedAt,
689
+ })
690
+ .from(posts)
691
+ .where(and(eq(posts.siteId, siteId), eq(posts.id, target.postId)))
373
692
  .limit(1);
374
- return result[0] ? toNavItem(result[0]) : null;
693
+ const row = rows[0];
694
+ if (!row) return { status: "not_found" };
695
+
696
+ if (row.status !== "published") return { status: "unpublished" };
697
+ if (row.visibility === "private") return { status: "private" };
698
+ const title = row.title?.trim() ?? "";
699
+ if (!title) return { status: "untitled" };
700
+
701
+ // The canonical slug rather than the address that was pasted: an alias
702
+ // resolves here too, and the item should show where the page lives.
703
+ const slug = await resolvedPaths.getPostSlug(row.id);
704
+ if (!slug) return { status: "not_found" };
705
+
706
+ return {
707
+ status: "page",
708
+ page: { id: row.id, title, slug, updatedAt: row.updatedAt },
709
+ };
710
+ },
711
+
712
+ async getById(id) {
713
+ return readNavItem(id);
375
714
  },
376
715
 
377
716
  async create(data) {
@@ -413,6 +752,25 @@ export function createNavItemService(
413
752
  }
414
753
  }
415
754
 
755
+ if (normalized.smartCollectionId) {
756
+ const existingSmartCollectionItem = await db
757
+ .select({ id: navItems.id })
758
+ .from(navItems)
759
+ .where(
760
+ and(
761
+ eq(navItems.siteId, siteId),
762
+ eq(navItems.smartCollectionId, normalized.smartCollectionId),
763
+ ),
764
+ )
765
+ .limit(1);
766
+
767
+ if (existingSmartCollectionItem[0]) {
768
+ throw new ValidationError(
769
+ "Smart collection already added to navigation",
770
+ );
771
+ }
772
+ }
773
+
416
774
  if (normalized.postId) {
417
775
  const existingPageItem = await db
418
776
  .select({ id: navItems.id })
@@ -439,6 +797,7 @@ export function createNavItemService(
439
797
  type: normalized.type,
440
798
  systemKey: normalized.systemKey,
441
799
  collectionId: normalized.collectionId,
800
+ smartCollectionId: normalized.smartCollectionId,
442
801
  postId: normalized.postId,
443
802
  label: normalized.label,
444
803
  url: normalized.url,
@@ -450,7 +809,7 @@ export function createNavItemService(
450
809
  .returning();
451
810
 
452
811
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- DB insert with .returning() always returns inserted row
453
- return toNavItem(result[0]!);
812
+ return toNavItem(result[0]!, normalized.targetTitle);
454
813
  }
455
814
 
456
815
  for (let attempt = 0; attempt < POSITION_RETRY_ATTEMPTS; attempt += 1) {
@@ -463,6 +822,7 @@ export function createNavItemService(
463
822
  type: normalized.type,
464
823
  systemKey: normalized.systemKey,
465
824
  collectionId: normalized.collectionId,
825
+ smartCollectionId: normalized.smartCollectionId,
466
826
  postId: normalized.postId,
467
827
  label: normalized.label,
468
828
  url: normalized.url,
@@ -474,7 +834,7 @@ export function createNavItemService(
474
834
  .returning();
475
835
 
476
836
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- DB insert with .returning() always returns inserted row
477
- return toNavItem(result[0]!);
837
+ return toNavItem(result[0]!, normalized.targetTitle);
478
838
  } catch (err) {
479
839
  if (
480
840
  !isUniqueConstraintError(err) ||
@@ -552,11 +912,13 @@ export function createNavItemService(
552
912
  }
553
913
  }
554
914
 
555
- // Non-system items require a non-empty label
915
+ // Clearing the label is how an item goes back to following what it
916
+ // points at, so only free-form links — which have no target to follow —
917
+ // still require one.
556
918
  if (
557
919
  data.label !== undefined &&
558
- !data.label &&
559
- existing[0].type !== "system"
920
+ !data.label.trim() &&
921
+ existing[0].type === "link"
560
922
  ) {
561
923
  throw new ValidationError("Label is required");
562
924
  }
@@ -565,7 +927,7 @@ export function createNavItemService(
565
927
  const result = await db
566
928
  .update(navItems)
567
929
  .set({
568
- ...(data.label !== undefined && { label: data.label }),
930
+ ...(data.label !== undefined && { label: data.label.trim() }),
569
931
  ...(data.url !== undefined && { url: data.url }),
570
932
  ...(data.placement !== undefined && { placement: data.placement }),
571
933
  ...(data.position !== undefined && { position: data.position }),
@@ -574,7 +936,7 @@ export function createNavItemService(
574
936
  .where(and(eq(navItems.siteId, siteId), eq(navItems.id, id)))
575
937
  .returning();
576
938
 
577
- return result[0] ? toNavItem(result[0]) : null;
939
+ return result[0] ? readNavItem(id) : null;
578
940
  },
579
941
 
580
942
  async delete(id) {
@@ -606,7 +968,7 @@ export function createNavItemService(
606
968
  .where(and(eq(navItems.siteId, siteId), eq(navItems.id, id)))
607
969
  .returning();
608
970
 
609
- return result[0] ? toNavItem(result[0]) : null;
971
+ return result[0] ? readNavItem(id) : null;
610
972
  } catch (err) {
611
973
  if (
612
974
  !isUniqueConstraintError(err) ||