emdash 0.8.0 → 0.10.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 (317) hide show
  1. package/dist/{adapters-BKSf3T9R.d.mts → adapters-BktHA7EO.d.mts} +1 -1
  2. package/dist/{adapters-BKSf3T9R.d.mts.map → adapters-BktHA7EO.d.mts.map} +1 -1
  3. package/dist/{apply-x0eMK1lX.mjs → apply-UsrFuO7l.mjs} +207 -355
  4. package/dist/apply-UsrFuO7l.mjs.map +1 -0
  5. package/dist/astro/index.d.mts +6 -6
  6. package/dist/astro/index.d.mts.map +1 -1
  7. package/dist/astro/index.mjs +118 -4
  8. package/dist/astro/index.mjs.map +1 -1
  9. package/dist/astro/middleware/auth.d.mts +6 -7
  10. package/dist/astro/middleware/auth.d.mts.map +1 -1
  11. package/dist/astro/middleware/auth.mjs +14 -57
  12. package/dist/astro/middleware/auth.mjs.map +1 -1
  13. package/dist/astro/middleware/redirect.d.mts.map +1 -1
  14. package/dist/astro/middleware/redirect.mjs +15 -10
  15. package/dist/astro/middleware/redirect.mjs.map +1 -1
  16. package/dist/astro/middleware/request-context.d.mts.map +1 -1
  17. package/dist/astro/middleware/request-context.mjs +8 -5
  18. package/dist/astro/middleware/request-context.mjs.map +1 -1
  19. package/dist/astro/middleware/setup.mjs +1 -1
  20. package/dist/astro/middleware.d.mts.map +1 -1
  21. package/dist/astro/middleware.mjs +70 -121
  22. package/dist/astro/middleware.mjs.map +1 -1
  23. package/dist/astro/types.d.mts +25 -10
  24. package/dist/astro/types.d.mts.map +1 -1
  25. package/dist/{byline-Chbr2GoP.mjs → byline-C3vnhIpU.mjs} +4 -4
  26. package/dist/{byline-Chbr2GoP.mjs.map → byline-C3vnhIpU.mjs.map} +1 -1
  27. package/dist/bylines-esI7ioa9.mjs +113 -0
  28. package/dist/bylines-esI7ioa9.mjs.map +1 -0
  29. package/dist/cache-fTzxgMFJ.mjs +65 -0
  30. package/dist/cache-fTzxgMFJ.mjs.map +1 -0
  31. package/dist/{chunks-HGz06Soa.mjs → chunks-Da2-b-oA.mjs} +8 -2
  32. package/dist/{chunks-HGz06Soa.mjs.map → chunks-Da2-b-oA.mjs.map} +1 -1
  33. package/dist/cli/index.mjs +456 -90
  34. package/dist/cli/index.mjs.map +1 -1
  35. package/dist/client/cf-access.d.mts +1 -1
  36. package/dist/client/index.d.mts +1 -1
  37. package/dist/client/index.mjs +3 -3
  38. package/dist/client/index.mjs.map +1 -1
  39. package/dist/{config-BXwuX8Bx.mjs → config-CVssduLe.mjs} +1 -1
  40. package/dist/{config-BXwuX8Bx.mjs.map → config-CVssduLe.mjs.map} +1 -1
  41. package/dist/{content-BcQPYxdV.mjs → content-C7G4QXkK.mjs} +42 -14
  42. package/dist/content-C7G4QXkK.mjs.map +1 -0
  43. package/dist/db/index.d.mts +3 -3
  44. package/dist/db/index.mjs +2 -2
  45. package/dist/db/libsql.d.mts +1 -1
  46. package/dist/db/libsql.d.mts.map +1 -1
  47. package/dist/db/libsql.mjs +7 -2
  48. package/dist/db/libsql.mjs.map +1 -1
  49. package/dist/db/postgres.d.mts +1 -1
  50. package/dist/db/sqlite.d.mts +1 -1
  51. package/dist/db/sqlite.d.mts.map +1 -1
  52. package/dist/db/sqlite.mjs +8 -3
  53. package/dist/db/sqlite.mjs.map +1 -1
  54. package/dist/{db-errors-l1Qh2RPR.mjs → db-errors-B7P2pSCn.mjs} +1 -1
  55. package/dist/{db-errors-l1Qh2RPR.mjs.map → db-errors-B7P2pSCn.mjs.map} +1 -1
  56. package/dist/{default-DCVqE5ib.mjs → default-pHuz9WF6.mjs} +1 -1
  57. package/dist/{default-DCVqE5ib.mjs.map → default-pHuz9WF6.mjs.map} +1 -1
  58. package/dist/{dialect-helpers-DhTzaUxP.mjs → dialect-helpers-BKCvISIQ.mjs} +19 -2
  59. package/dist/dialect-helpers-BKCvISIQ.mjs.map +1 -0
  60. package/dist/{error-zG5T1UGA.mjs → error-DqnRMM5z.mjs} +1 -1
  61. package/dist/{error-zG5T1UGA.mjs.map → error-DqnRMM5z.mjs.map} +1 -1
  62. package/dist/{index-DIb-CzNx.d.mts → index-DjPMOfO0.d.mts} +162 -87
  63. package/dist/index-DjPMOfO0.d.mts.map +1 -0
  64. package/dist/index.d.mts +11 -11
  65. package/dist/index.mjs +27 -24
  66. package/dist/{load-CyEoextb.mjs → load-sXRuM7Us.mjs} +2 -2
  67. package/dist/{load-CyEoextb.mjs.map → load-sXRuM7Us.mjs.map} +1 -1
  68. package/dist/{loader-CndGj8kM.mjs → loader-Bx2_9-5e.mjs} +53 -8
  69. package/dist/loader-Bx2_9-5e.mjs.map +1 -0
  70. package/dist/{manifest-schema-DH9xhc6t.mjs → manifest-schema-CXAbd1vH.mjs} +33 -3
  71. package/dist/manifest-schema-CXAbd1vH.mjs.map +1 -0
  72. package/dist/media/index.d.mts +1 -1
  73. package/dist/media/index.mjs +1 -1
  74. package/dist/media/local-runtime.d.mts +7 -7
  75. package/dist/{mode-BnAOqItE.mjs → mode-YhqNVef_.mjs} +1 -1
  76. package/dist/{mode-BnAOqItE.mjs.map → mode-YhqNVef_.mjs.map} +1 -1
  77. package/dist/options-nPxWnrya.mjs +117 -0
  78. package/dist/options-nPxWnrya.mjs.map +1 -0
  79. package/dist/page/index.d.mts +2 -2
  80. package/dist/{patterns-CrCYkMBb.mjs → patterns-DsUZ4uxI.mjs} +1 -1
  81. package/dist/{patterns-CrCYkMBb.mjs.map → patterns-DsUZ4uxI.mjs.map} +1 -1
  82. package/dist/{placeholder-D29tWZ7o.d.mts → placeholder-CDPtkelt.d.mts} +1 -1
  83. package/dist/{placeholder-D29tWZ7o.d.mts.map → placeholder-CDPtkelt.d.mts.map} +1 -1
  84. package/dist/{placeholder-C-fk5hYI.mjs → placeholder-Ci0RLeCk.mjs} +1 -1
  85. package/dist/{placeholder-C-fk5hYI.mjs.map → placeholder-Ci0RLeCk.mjs.map} +1 -1
  86. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  87. package/dist/plugins/adapt-sandbox-entry.d.mts.map +1 -1
  88. package/dist/plugins/adapt-sandbox-entry.mjs +6 -5
  89. package/dist/plugins/adapt-sandbox-entry.mjs.map +1 -1
  90. package/dist/public-url-B1AxbbbQ.mjs +51 -0
  91. package/dist/public-url-B1AxbbbQ.mjs.map +1 -0
  92. package/dist/{query-fqEdLFms.mjs → query-Bo-msrmu.mjs} +114 -16
  93. package/dist/query-Bo-msrmu.mjs.map +1 -0
  94. package/dist/{redirect-D_pshWdf.mjs → redirect-C5H7VGIX.mjs} +11 -6
  95. package/dist/redirect-C5H7VGIX.mjs.map +1 -0
  96. package/dist/{registry-C3Mr0ODu.mjs → registry-Beb7wxFc.mjs} +39 -5
  97. package/dist/registry-Beb7wxFc.mjs.map +1 -0
  98. package/dist/{request-cache-Ci7f5pBb.mjs → request-cache-C-tIpYIw.mjs} +1 -1
  99. package/dist/{request-cache-Ci7f5pBb.mjs.map → request-cache-C-tIpYIw.mjs.map} +1 -1
  100. package/dist/runner-Clwe4Mme.d.mts +44 -0
  101. package/dist/runner-Clwe4Mme.d.mts.map +1 -0
  102. package/dist/{runner-tQ7BJ4T7.mjs → runner-DMnlIkh4.mjs} +616 -191
  103. package/dist/runner-DMnlIkh4.mjs.map +1 -0
  104. package/dist/runtime.d.mts +6 -6
  105. package/dist/runtime.mjs +2 -2
  106. package/dist/{search-BoZYFuUk.mjs → search-DkN-BqsS.mjs} +270 -152
  107. package/dist/search-DkN-BqsS.mjs.map +1 -0
  108. package/dist/secrets-CZ8rxLX3.mjs +314 -0
  109. package/dist/secrets-CZ8rxLX3.mjs.map +1 -0
  110. package/dist/seed/index.d.mts +2 -2
  111. package/dist/seed/index.mjs +13 -11
  112. package/dist/seo/index.d.mts +1 -1
  113. package/dist/storage/local.d.mts +1 -1
  114. package/dist/storage/local.mjs +1 -1
  115. package/dist/storage/s3.d.mts +1 -1
  116. package/dist/storage/s3.mjs +1 -1
  117. package/dist/taxonomies-CTtewrSQ.mjs +407 -0
  118. package/dist/taxonomies-CTtewrSQ.mjs.map +1 -0
  119. package/dist/taxonomy-DSxx2K2L.mjs +218 -0
  120. package/dist/taxonomy-DSxx2K2L.mjs.map +1 -0
  121. package/dist/{tokens-D9vnZqYS.mjs → tokens-CyRDPVW2.mjs} +1 -1
  122. package/dist/{tokens-D9vnZqYS.mjs.map → tokens-CyRDPVW2.mjs.map} +1 -1
  123. package/dist/{transaction-Cn2rjY78.mjs → transaction-D44LBXvU.mjs} +1 -1
  124. package/dist/{transaction-Cn2rjY78.mjs.map → transaction-D44LBXvU.mjs.map} +1 -1
  125. package/dist/{transport-CUnEL3Vs.d.mts → transport-DX_5rpsq.d.mts} +1 -1
  126. package/dist/{transport-CUnEL3Vs.d.mts.map → transport-DX_5rpsq.d.mts.map} +1 -1
  127. package/dist/{transport-C9ugt2Nr.mjs → transport-xpzIjCIB.mjs} +6 -5
  128. package/dist/{transport-C9ugt2Nr.mjs.map → transport-xpzIjCIB.mjs.map} +1 -1
  129. package/dist/{types-BrA0xf5I.d.mts → types-B_CXXnzh.d.mts} +1 -1
  130. package/dist/{types-BrA0xf5I.d.mts.map → types-B_CXXnzh.d.mts.map} +1 -1
  131. package/dist/{types-DIMwPFub.d.mts → types-C-aFbqmA.d.mts} +1 -1
  132. package/dist/{types-DIMwPFub.d.mts.map → types-C-aFbqmA.d.mts.map} +1 -1
  133. package/dist/types-CoO6mpV3.mjs +68 -0
  134. package/dist/types-CoO6mpV3.mjs.map +1 -0
  135. package/dist/{types-i36XcA_X.d.mts → types-D19uBYWn.d.mts} +83 -7
  136. package/dist/types-D19uBYWn.d.mts.map +1 -0
  137. package/dist/{types-BmPPSUEx.d.mts → types-Dl1fgFjn.d.mts} +24 -2
  138. package/dist/{types-BmPPSUEx.d.mts.map → types-Dl1fgFjn.d.mts.map} +1 -1
  139. package/dist/{types-CS8FIX7L.d.mts → types-Dtx1mSMX.d.mts} +9 -1
  140. package/dist/types-Dtx1mSMX.d.mts.map +1 -0
  141. package/dist/{types-Bm1dn-q3.mjs → types-Eg829jj9.mjs} +1 -1
  142. package/dist/{types-Bm1dn-q3.mjs.map → types-Eg829jj9.mjs.map} +1 -1
  143. package/dist/{types-CgqmmMJB.mjs → types-K-EkEQCI.mjs} +1 -1
  144. package/dist/{types-CgqmmMJB.mjs.map → types-K-EkEQCI.mjs.map} +1 -1
  145. package/dist/{validate-CxVsLehf.mjs → validate-CBIbxM3L.mjs} +14 -10
  146. package/dist/validate-CBIbxM3L.mjs.map +1 -0
  147. package/dist/{validate-DHxmpFJt.d.mts → validate-DHGwADqO.d.mts} +18 -5
  148. package/dist/validate-DHGwADqO.d.mts.map +1 -0
  149. package/dist/{validation-C-ZpN2GI.mjs → validation-B1NYiEos.mjs} +6 -6
  150. package/dist/{validation-C-ZpN2GI.mjs.map → validation-B1NYiEos.mjs.map} +1 -1
  151. package/dist/version-CMD42IRC.mjs +7 -0
  152. package/dist/{version-Bbq8TCrz.mjs.map → version-CMD42IRC.mjs.map} +1 -1
  153. package/dist/{zod-generator-CpwccCIv.mjs → zod-generator-BNJDQBSZ.mjs} +11 -6
  154. package/dist/{zod-generator-CpwccCIv.mjs.map → zod-generator-BNJDQBSZ.mjs.map} +1 -1
  155. package/locals.d.ts +1 -6
  156. package/package.json +9 -8
  157. package/src/api/handlers/comments.ts +6 -4
  158. package/src/api/handlers/content.ts +40 -1
  159. package/src/api/handlers/dashboard.ts +29 -36
  160. package/src/api/handlers/device-flow.ts +5 -0
  161. package/src/api/handlers/marketplace.ts +11 -4
  162. package/src/api/handlers/menus.ts +256 -75
  163. package/src/api/handlers/oauth-authorization.ts +72 -33
  164. package/src/api/handlers/revision.ts +23 -14
  165. package/src/api/handlers/taxonomies.ts +273 -100
  166. package/src/api/public-url.ts +48 -2
  167. package/src/api/schemas/comments.ts +2 -2
  168. package/src/api/schemas/common.ts +7 -0
  169. package/src/api/schemas/content.ts +17 -0
  170. package/src/api/schemas/menus.ts +23 -0
  171. package/src/api/schemas/sections.ts +3 -3
  172. package/src/api/schemas/taxonomies.ts +39 -0
  173. package/src/api/schemas/users.ts +1 -1
  174. package/src/api/types.ts +5 -1
  175. package/src/astro/integration/index.ts +17 -0
  176. package/src/astro/integration/routes.ts +10 -0
  177. package/src/astro/integration/runtime.ts +30 -0
  178. package/src/astro/integration/virtual-modules.ts +32 -2
  179. package/src/astro/integration/vite-config.ts +6 -1
  180. package/src/astro/middleware/auth.ts +13 -6
  181. package/src/astro/middleware/redirect.ts +29 -16
  182. package/src/astro/middleware/request-context.ts +15 -5
  183. package/src/astro/middleware.ts +23 -9
  184. package/src/astro/routes/api/auth/invite/complete.ts +6 -1
  185. package/src/astro/routes/api/auth/passkey/register/verify.ts +6 -1
  186. package/src/astro/routes/api/auth/passkey/verify.ts +6 -1
  187. package/src/astro/routes/api/auth/signup/complete.ts +6 -1
  188. package/src/astro/routes/api/comments/[collection]/[contentId]/index.ts +2 -2
  189. package/src/astro/routes/api/content/[collection]/[id]/discard-draft.ts +4 -2
  190. package/src/astro/routes/api/content/[collection]/[id]/permanent.ts +1 -1
  191. package/src/astro/routes/api/content/[collection]/[id]/preview-url.ts +34 -12
  192. package/src/astro/routes/api/content/[collection]/[id]/publish.ts +32 -2
  193. package/src/astro/routes/api/content/[collection]/[id]/restore.ts +4 -2
  194. package/src/astro/routes/api/content/[collection]/[id]/revisions.ts +3 -2
  195. package/src/astro/routes/api/content/[collection]/[id]/terms/[taxonomy].ts +8 -4
  196. package/src/astro/routes/api/content/[collection]/[id].ts +12 -0
  197. package/src/astro/routes/api/import/wordpress/execute.ts +3 -1
  198. package/src/astro/routes/api/import/wordpress/prepare.ts +7 -8
  199. package/src/astro/routes/api/import/wordpress/rewrite-url-helpers.ts +196 -0
  200. package/src/astro/routes/api/import/wordpress/rewrite-urls.ts +9 -177
  201. package/src/astro/routes/api/import/wordpress-plugin/execute.ts +3 -1
  202. package/src/astro/routes/api/manifest.ts +62 -45
  203. package/src/astro/routes/api/media/[id]/confirm.ts +10 -1
  204. package/src/astro/routes/api/media/providers/[providerId]/index.ts +12 -3
  205. package/src/astro/routes/api/menus/[name]/items.ts +16 -6
  206. package/src/astro/routes/api/menus/[name]/reorder.ts +8 -3
  207. package/src/astro/routes/api/menus/[name]/translations.ts +82 -0
  208. package/src/astro/routes/api/menus/[name].ts +19 -10
  209. package/src/astro/routes/api/menus/index.ts +9 -6
  210. package/src/astro/routes/api/openapi.json.ts +27 -10
  211. package/src/astro/routes/api/redirects/404s/index.ts +10 -4
  212. package/src/astro/routes/api/redirects/404s/summary.ts +4 -2
  213. package/src/astro/routes/api/redirects/[id].ts +10 -4
  214. package/src/astro/routes/api/redirects/index.ts +7 -3
  215. package/src/astro/routes/api/revisions/[revisionId]/index.ts +1 -1
  216. package/src/astro/routes/api/schema/collections/[slug]/fields/[fieldSlug].ts +0 -2
  217. package/src/astro/routes/api/schema/collections/[slug]/fields/index.ts +0 -1
  218. package/src/astro/routes/api/schema/collections/[slug]/fields/reorder.ts +0 -1
  219. package/src/astro/routes/api/schema/collections/[slug]/index.ts +2 -2
  220. package/src/astro/routes/api/schema/collections/index.ts +1 -1
  221. package/src/astro/routes/api/search/index.ts +10 -2
  222. package/src/astro/routes/api/sections/[slug].ts +10 -4
  223. package/src/astro/routes/api/sections/index.ts +7 -3
  224. package/src/astro/routes/api/setup/admin-verify.ts +6 -1
  225. package/src/astro/routes/api/snapshot.ts +44 -18
  226. package/src/astro/routes/api/taxonomies/[name]/terms/[slug]/translations.ts +89 -0
  227. package/src/astro/routes/api/taxonomies/[name]/terms/[slug].ts +22 -22
  228. package/src/astro/routes/api/taxonomies/[name]/terms/index.ts +11 -14
  229. package/src/astro/routes/api/taxonomies/index.ts +9 -7
  230. package/src/astro/routes/api/themes/preview.ts +11 -5
  231. package/src/astro/types.ts +23 -3
  232. package/src/auth/allowed-origins.ts +168 -0
  233. package/src/auth/passkey-config.ts +35 -13
  234. package/src/bylines/index.ts +37 -88
  235. package/src/cli/commands/auth.ts +28 -6
  236. package/src/cli/commands/bundle-utils.ts +11 -2
  237. package/src/cli/commands/bundle.ts +28 -8
  238. package/src/cli/commands/content.ts +13 -0
  239. package/src/cli/commands/export-seed.ts +82 -21
  240. package/src/cli/commands/login.ts +8 -1
  241. package/src/cli/commands/plugin-init.ts +216 -90
  242. package/src/cli/commands/publish.ts +24 -0
  243. package/src/cli/commands/secrets.ts +183 -0
  244. package/src/cli/credentials.ts +1 -1
  245. package/src/cli/index.ts +5 -1
  246. package/src/client/index.ts +4 -4
  247. package/src/client/transport.ts +17 -7
  248. package/src/components/Break.astro +2 -2
  249. package/src/components/EmDashHead.astro +18 -13
  250. package/src/components/Embed.astro +1 -1
  251. package/src/components/Gallery.astro +1 -1
  252. package/src/components/Image.astro +1 -1
  253. package/src/components/InlinePortableTextEditor.tsx +104 -18
  254. package/src/config/secrets.ts +528 -0
  255. package/src/database/dialect-helpers.ts +50 -0
  256. package/src/database/migrations/034_published_at_index.ts +1 -1
  257. package/src/database/migrations/035_bounded_404_log.ts +56 -39
  258. package/src/database/migrations/036_i18n_menus_and_taxonomies.ts +477 -0
  259. package/src/database/migrations/runner.ts +158 -23
  260. package/src/database/repositories/content.ts +47 -12
  261. package/src/database/repositories/redirect.ts +14 -3
  262. package/src/database/repositories/taxonomy.ts +212 -82
  263. package/src/database/types.ts +10 -2
  264. package/src/db/libsql.ts +1 -3
  265. package/src/db/sqlite.ts +2 -5
  266. package/src/emdash-runtime.ts +84 -159
  267. package/src/i18n/resolve.ts +37 -0
  268. package/src/index.ts +9 -0
  269. package/src/loader.ts +73 -3
  270. package/src/mcp/server.ts +180 -54
  271. package/src/menus/index.ts +143 -124
  272. package/src/menus/types.ts +15 -1
  273. package/src/page/site-identity.ts +58 -0
  274. package/src/plugins/adapt-sandbox-entry.ts +22 -10
  275. package/src/plugins/context.ts +13 -10
  276. package/src/plugins/define-plugin.ts +40 -12
  277. package/src/plugins/hooks.ts +23 -19
  278. package/src/plugins/index.ts +9 -0
  279. package/src/plugins/manifest-schema.ts +37 -2
  280. package/src/plugins/types.ts +151 -11
  281. package/src/preview/urls.ts +23 -3
  282. package/src/query.ts +148 -5
  283. package/src/redirects/cache.ts +38 -18
  284. package/src/schema/registry.ts +56 -0
  285. package/src/schema/zod-generator.ts +39 -7
  286. package/src/seed/apply.ts +142 -54
  287. package/src/seed/types.ts +14 -1
  288. package/src/seed/validate.ts +27 -13
  289. package/src/settings/index.ts +80 -6
  290. package/src/settings/types.ts +23 -1
  291. package/src/taxonomies/index.ts +237 -210
  292. package/src/taxonomies/types.ts +10 -0
  293. package/dist/apply-x0eMK1lX.mjs.map +0 -1
  294. package/dist/bylines-CRNsVG88.mjs +0 -157
  295. package/dist/bylines-CRNsVG88.mjs.map +0 -1
  296. package/dist/cache-BkKBuIvS.mjs +0 -56
  297. package/dist/cache-BkKBuIvS.mjs.map +0 -1
  298. package/dist/chunk-ClPoSABd.mjs +0 -21
  299. package/dist/content-BcQPYxdV.mjs.map +0 -1
  300. package/dist/dialect-helpers-DhTzaUxP.mjs.map +0 -1
  301. package/dist/index-DIb-CzNx.d.mts.map +0 -1
  302. package/dist/loader-CndGj8kM.mjs.map +0 -1
  303. package/dist/manifest-schema-DH9xhc6t.mjs.map +0 -1
  304. package/dist/query-fqEdLFms.mjs.map +0 -1
  305. package/dist/redirect-D_pshWdf.mjs.map +0 -1
  306. package/dist/registry-C3Mr0ODu.mjs.map +0 -1
  307. package/dist/runner-OURCaApa.d.mts +0 -34
  308. package/dist/runner-OURCaApa.d.mts.map +0 -1
  309. package/dist/runner-tQ7BJ4T7.mjs.map +0 -1
  310. package/dist/search-BoZYFuUk.mjs.map +0 -1
  311. package/dist/taxonomies-B4IAshV8.mjs +0 -308
  312. package/dist/taxonomies-B4IAshV8.mjs.map +0 -1
  313. package/dist/types-CS8FIX7L.d.mts.map +0 -1
  314. package/dist/types-i36XcA_X.d.mts.map +0 -1
  315. package/dist/validate-CxVsLehf.mjs.map +0 -1
  316. package/dist/validate-DHxmpFJt.d.mts.map +0 -1
  317. package/dist/version-Bbq8TCrz.mjs +0 -7
@@ -1,4 +1,4 @@
1
- import type { Kysely } from "kysely";
1
+ import type { Kysely, Selectable } from "kysely";
2
2
  import { ulid } from "ulidx";
3
3
 
4
4
  import type { Database, TaxonomyTable, ContentTaxonomyTable } from "../types.js";
@@ -10,6 +10,8 @@ export interface Taxonomy {
10
10
  label: string;
11
11
  parentId: string | null;
12
12
  data: Record<string, unknown> | null;
13
+ locale: string;
14
+ translationGroup: string | null;
13
15
  }
14
16
 
15
17
  export interface CreateTaxonomyInput {
@@ -18,6 +20,11 @@ export interface CreateTaxonomyInput {
18
20
  label: string;
19
21
  parentId?: string;
20
22
  data?: Record<string, unknown>;
23
+ /** Omit to let the DB default (current value: 'en') apply. Higher layers
24
+ * resolve the locale from the request context / i18n config. */
25
+ locale?: string;
26
+ /** When set, links the new term into the source term's translation_group. */
27
+ translationOf?: string;
21
28
  }
22
29
 
23
30
  export interface UpdateTaxonomyInput {
@@ -27,16 +34,29 @@ export interface UpdateTaxonomyInput {
27
34
  data?: Record<string, unknown>;
28
35
  }
29
36
 
37
+ export interface FindOptions {
38
+ parentId?: string | null;
39
+ locale?: string;
40
+ }
41
+
30
42
  /**
31
- * Taxonomy repository for categories, tags, and other classification
43
+ * Taxonomy repository for categories, tags, and other classification.
32
44
  *
33
- * Taxonomies are hierarchical (via parentId) and can be attached to content entries.
45
+ * Terms are per-locale. Translations of the same term share a `translation_group`
46
+ * ULID. `content_taxonomies.taxonomy_id` stores the translation_group so a single
47
+ * association spans every locale of a post.
48
+ *
49
+ * The repository does not resolve locale fallbacks on its own — callers supply
50
+ * the locale they want. Runtime helpers and handlers use `getFallbackChain()`
51
+ * from `i18n/config` when they need fallback behaviour.
34
52
  */
35
53
  export class TaxonomyRepository {
36
54
  constructor(private db: Kysely<Database>) {}
37
55
 
38
56
  /**
39
- * Create a new taxonomy term
57
+ * Create a new taxonomy term. When `translationOf` is set the new row joins
58
+ * the source term's translation_group; otherwise a fresh group is minted
59
+ * (matching the migration backfill pattern `translation_group = id`).
40
60
  */
41
61
  async create(input: CreateTaxonomyInput): Promise<Taxonomy> {
42
62
  const id = ulid();
@@ -44,58 +64,68 @@ export class TaxonomyRepository {
44
64
  // Empty-string parentId is coerced to null defensively. Higher layers
45
65
  // also normalize this — see handleTermCreate / handleTermUpdate.
46
66
  const parentId = input.parentId === undefined || input.parentId === "" ? null : input.parentId;
47
- const row: TaxonomyTable = {
48
- id,
49
- name: input.name,
50
- slug: input.slug,
51
- label: input.label,
52
- parent_id: parentId,
53
- data: input.data ? JSON.stringify(input.data) : null,
54
- };
55
67
 
56
- await this.db.insertInto("taxonomies").values(row).execute();
68
+ let translationGroup = id;
69
+ if (input.translationOf) {
70
+ const source = await this.findById(input.translationOf);
71
+ if (source?.translationGroup) translationGroup = source.translationGroup;
72
+ }
73
+
74
+ await this.db
75
+ .insertInto("taxonomies")
76
+ .values({
77
+ id,
78
+ name: input.name,
79
+ slug: input.slug,
80
+ label: input.label,
81
+ parent_id: parentId,
82
+ data: input.data ? JSON.stringify(input.data) : null,
83
+ // When omitted, the DB DEFAULT 'en' is used — keeps behaviour
84
+ // consistent with ContentRepository and lets higher layers
85
+ // supply an explicit locale from request context.
86
+ ...(input.locale !== undefined ? { locale: input.locale } : {}),
87
+ translation_group: translationGroup,
88
+ })
89
+ .execute();
57
90
 
58
91
  const taxonomy = await this.findById(id);
59
- if (!taxonomy) {
60
- throw new Error("Failed to create taxonomy");
61
- }
92
+ if (!taxonomy) throw new Error("Failed to create taxonomy");
62
93
  return taxonomy;
63
94
  }
64
95
 
65
- /**
66
- * Find taxonomy by ID
67
- */
68
96
  async findById(id: string): Promise<Taxonomy | null> {
69
97
  const row = await this.db
70
98
  .selectFrom("taxonomies")
71
99
  .selectAll()
72
100
  .where("id", "=", id)
73
101
  .executeTakeFirst();
74
-
75
102
  return row ? this.rowToTaxonomy(row) : null;
76
103
  }
77
104
 
78
105
  /**
79
- * Find taxonomy by name and slug (unique constraint)
106
+ * Find a term by (name, slug). When `locale` is provided, filter by it.
107
+ * When omitted, returns the lowest-locale-code match (deterministic across
108
+ * calls). Mirrors `ContentRepository.findBySlug`.
80
109
  */
81
- async findBySlug(name: string, slug: string): Promise<Taxonomy | null> {
82
- const row = await this.db
110
+ async findBySlug(name: string, slug: string, locale?: string): Promise<Taxonomy | null> {
111
+ let query = this.db
83
112
  .selectFrom("taxonomies")
84
113
  .selectAll()
85
114
  .where("name", "=", name)
86
- .where("slug", "=", slug)
87
- .executeTakeFirst();
88
-
115
+ .where("slug", "=", slug);
116
+ if (locale !== undefined) query = query.where("locale", "=", locale);
117
+ const row = await query.orderBy("locale", "asc").executeTakeFirst();
89
118
  return row ? this.rowToTaxonomy(row) : null;
90
119
  }
91
120
 
92
121
  /**
93
- * Get all terms for a taxonomy (e.g., all categories)
122
+ * Get all terms for a taxonomy (e.g., all categories).
123
+ *
124
+ * `id asc` is a stable tiebreaker for terms that share a label. Without it
125
+ * the SQL ordering is implementation-defined when labels match, which
126
+ * breaks keyset pagination over `(label, id)`.
94
127
  */
95
- async findByName(name: string, options: { parentId?: string | null } = {}): Promise<Taxonomy[]> {
96
- // `id asc` is a stable tiebreaker for terms that share a label.
97
- // Without it the SQL ordering is implementation-defined when labels
98
- // match, which breaks keyset pagination over `(label, id)`.
128
+ async findByName(name: string, options: FindOptions = {}): Promise<Taxonomy[]> {
99
129
  let query = this.db
100
130
  .selectFrom("taxonomies")
101
131
  .selectAll()
@@ -103,6 +133,8 @@ export class TaxonomyRepository {
103
133
  .orderBy("label", "asc")
104
134
  .orderBy("id", "asc");
105
135
 
136
+ if (options.locale !== undefined) query = query.where("locale", "=", options.locale);
137
+
106
138
  if (options.parentId !== undefined) {
107
139
  if (options.parentId === null) {
108
140
  query = query.where("parent_id", "is", null);
@@ -115,9 +147,6 @@ export class TaxonomyRepository {
115
147
  return rows.map((row) => this.rowToTaxonomy(row));
116
148
  }
117
149
 
118
- /**
119
- * Get children of a taxonomy term
120
- */
121
150
  async findChildren(parentId: string): Promise<Taxonomy[]> {
122
151
  const rows = await this.db
123
152
  .selectFrom("taxonomies")
@@ -126,18 +155,28 @@ export class TaxonomyRepository {
126
155
  .orderBy("label", "asc")
127
156
  .orderBy("id", "asc")
128
157
  .execute();
129
-
130
158
  return rows.map((row) => this.rowToTaxonomy(row));
131
159
  }
132
160
 
133
161
  /**
134
- * Update a taxonomy term
162
+ * Every translation sibling of a term (including itself), identified by
163
+ * their shared `translation_group`.
135
164
  */
165
+ async findTranslations(translationGroup: string): Promise<Taxonomy[]> {
166
+ const rows = await this.db
167
+ .selectFrom("taxonomies")
168
+ .selectAll()
169
+ .where("translation_group", "=", translationGroup)
170
+ .orderBy("locale", "asc")
171
+ .execute();
172
+ return rows.map((row) => this.rowToTaxonomy(row));
173
+ }
174
+
136
175
  async update(id: string, input: UpdateTaxonomyInput): Promise<Taxonomy | null> {
137
176
  const existing = await this.findById(id);
138
177
  if (!existing) return null;
139
178
 
140
- const updates: Partial<TaxonomyTable> = {};
179
+ const updates: Record<string, unknown> = {};
141
180
  if (input.slug !== undefined) updates.slug = input.slug;
142
181
  if (input.label !== undefined) updates.label = input.label;
143
182
  if (input.parentId !== undefined) {
@@ -153,31 +192,42 @@ export class TaxonomyRepository {
153
192
  return this.findById(id);
154
193
  }
155
194
 
156
- /**
157
- * Delete a taxonomy term
158
- */
159
195
  async delete(id: string): Promise<boolean> {
160
- // First remove any content associations
161
- await this.db.deleteFrom("content_taxonomies").where("taxonomy_id", "=", id).execute();
196
+ const term = await this.findById(id);
197
+ if (!term) return false;
198
+
199
+ // When deleting the last translation of a group the pivot rows that
200
+ // reference that translation_group become orphaned — purge them.
201
+ if (term.translationGroup) {
202
+ const siblings = await this.db
203
+ .selectFrom("taxonomies")
204
+ .select("id")
205
+ .where("translation_group", "=", term.translationGroup)
206
+ .where("id", "!=", id)
207
+ .execute();
208
+ if (siblings.length === 0) {
209
+ await this.db
210
+ .deleteFrom("content_taxonomies")
211
+ .where("taxonomy_id", "=", term.translationGroup)
212
+ .execute();
213
+ }
214
+ }
162
215
 
163
216
  const result = await this.db.deleteFrom("taxonomies").where("id", "=", id).executeTakeFirst();
164
-
165
- return (result.numDeletedRows ?? 0) > 0;
217
+ return (result.numDeletedRows ?? 0n) > 0n;
166
218
  }
167
219
 
168
- // --- Content-Taxonomy Junction ---
220
+ // --- Content-Taxonomy Junction (taxonomy_id stores the translation_group) ---
169
221
 
170
- /**
171
- * Attach a taxonomy term to a content entry
172
- */
173
222
  async attachToEntry(collection: string, entryId: string, taxonomyId: string): Promise<void> {
223
+ const group = await this.resolveTranslationGroup(taxonomyId);
224
+ if (!group) return;
225
+
174
226
  const row: ContentTaxonomyTable = {
175
227
  collection,
176
228
  entry_id: entryId,
177
- taxonomy_id: taxonomyId,
229
+ taxonomy_id: group,
178
230
  };
179
-
180
- // Use INSERT OR IGNORE pattern for idempotency
181
231
  await this.db
182
232
  .insertInto("content_taxonomies")
183
233
  .values(row)
@@ -185,58 +235,72 @@ export class TaxonomyRepository {
185
235
  .execute();
186
236
  }
187
237
 
188
- /**
189
- * Detach a taxonomy term from a content entry
190
- */
191
238
  async detachFromEntry(collection: string, entryId: string, taxonomyId: string): Promise<void> {
239
+ const group = await this.resolveTranslationGroup(taxonomyId);
240
+ if (!group) return;
241
+
192
242
  await this.db
193
243
  .deleteFrom("content_taxonomies")
194
244
  .where("collection", "=", collection)
195
245
  .where("entry_id", "=", entryId)
196
- .where("taxonomy_id", "=", taxonomyId)
246
+ .where("taxonomy_id", "=", group)
197
247
  .execute();
198
248
  }
199
249
 
200
250
  /**
201
- * Get all taxonomy terms for a content entry
251
+ * Taxonomy terms assigned to a content entry, resolved into a specific locale.
252
+ * Terms whose translation_group lacks a row in the requested locale are
253
+ * omitted — callers wanting fallback behaviour apply it themselves.
202
254
  */
203
255
  async getTermsForEntry(
204
256
  collection: string,
205
257
  entryId: string,
206
258
  taxonomyName?: string,
259
+ locale?: string,
207
260
  ): Promise<Taxonomy[]> {
208
261
  let query = this.db
209
262
  .selectFrom("content_taxonomies")
210
- .innerJoin("taxonomies", "taxonomies.id", "content_taxonomies.taxonomy_id")
263
+ .innerJoin("taxonomies", "taxonomies.translation_group", "content_taxonomies.taxonomy_id")
211
264
  .selectAll("taxonomies")
212
265
  .where("content_taxonomies.collection", "=", collection)
213
266
  .where("content_taxonomies.entry_id", "=", entryId);
214
267
 
215
- if (taxonomyName) {
216
- query = query.where("taxonomies.name", "=", taxonomyName);
217
- }
268
+ if (taxonomyName) query = query.where("taxonomies.name", "=", taxonomyName);
269
+ if (locale !== undefined) query = query.where("taxonomies.locale", "=", locale);
218
270
 
219
- const rows = await query.execute();
271
+ const rows = await query.orderBy("taxonomies.locale", "asc").execute();
220
272
  return rows.map((row) => this.rowToTaxonomy(row));
221
273
  }
222
274
 
223
275
  /**
224
- * Set all taxonomy terms for a content entry (replaces existing)
225
- * Uses batch operations to avoid N+1 queries.
276
+ * Replace all assignments of a given taxonomy for one content entry.
277
+ * Term ids OR translation_groups are accepted and normalised to groups.
226
278
  */
227
279
  async setTermsForEntry(
228
280
  collection: string,
229
281
  entryId: string,
230
282
  taxonomyName: string,
231
- taxonomyIds: string[],
283
+ termIds: string[],
232
284
  ): Promise<void> {
233
- // Get current terms of this taxonomy type
234
- const current = await this.getTermsForEntry(collection, entryId, taxonomyName);
235
- const currentIds = new Set(current.map((t) => t.id));
236
- const newIds = new Set(taxonomyIds);
285
+ const groups: string[] = [];
286
+ for (const id of termIds) {
287
+ const group = await this.resolveTranslationGroup(id);
288
+ if (group) groups.push(group);
289
+ }
290
+ const newGroups = new Set(groups);
237
291
 
238
- // Batch remove terms no longer present
239
- const toRemove = current.filter((t) => !newIds.has(t.id)).map((t) => t.id);
292
+ const current = await this.db
293
+ .selectFrom("content_taxonomies")
294
+ .innerJoin("taxonomies", "taxonomies.translation_group", "content_taxonomies.taxonomy_id")
295
+ .select(["content_taxonomies.taxonomy_id as group"])
296
+ .distinct()
297
+ .where("content_taxonomies.collection", "=", collection)
298
+ .where("content_taxonomies.entry_id", "=", entryId)
299
+ .where("taxonomies.name", "=", taxonomyName)
300
+ .execute();
301
+ const currentGroups = new Set(current.map((r) => r.group));
302
+
303
+ const toRemove = [...currentGroups].filter((g) => !newGroups.has(g));
240
304
  if (toRemove.length > 0) {
241
305
  await this.db
242
306
  .deleteFrom("content_taxonomies")
@@ -246,8 +310,7 @@ export class TaxonomyRepository {
246
310
  .execute();
247
311
  }
248
312
 
249
- // Batch add new terms
250
- const toAdd = taxonomyIds.filter((id) => !currentIds.has(id));
313
+ const toAdd = [...newGroups].filter((g) => !currentGroups.has(g));
251
314
  if (toAdd.length > 0) {
252
315
  await this.db
253
316
  .insertInto("content_taxonomies")
@@ -263,36 +326,101 @@ export class TaxonomyRepository {
263
326
  }
264
327
  }
265
328
 
266
- /**
267
- * Remove all taxonomy associations for an entry (use when entry is deleted)
268
- */
269
329
  async clearEntryTerms(collection: string, entryId: string): Promise<number> {
270
330
  const result = await this.db
271
331
  .deleteFrom("content_taxonomies")
272
332
  .where("collection", "=", collection)
273
333
  .where("entry_id", "=", entryId)
274
334
  .executeTakeFirst();
275
-
276
335
  return Number(result.numDeletedRows ?? 0);
277
336
  }
278
337
 
279
338
  /**
280
- * Count entries that have a specific taxonomy term
339
+ * Copy every term assignment from one content entry to another. Used when
340
+ * creating a translation of a post so the new translation inherits the
341
+ * source's term assignments. Safe to call when the source has no terms.
342
+ */
343
+ async copyEntryTerms(
344
+ collection: string,
345
+ sourceEntryId: string,
346
+ targetEntryId: string,
347
+ ): Promise<void> {
348
+ const rows = await this.db
349
+ .selectFrom("content_taxonomies")
350
+ .select(["taxonomy_id"])
351
+ .where("collection", "=", collection)
352
+ .where("entry_id", "=", sourceEntryId)
353
+ .execute();
354
+ if (rows.length === 0) return;
355
+
356
+ await this.db
357
+ .insertInto("content_taxonomies")
358
+ .values(
359
+ rows.map((r) => ({
360
+ collection,
361
+ entry_id: targetEntryId,
362
+ taxonomy_id: r.taxonomy_id,
363
+ })),
364
+ )
365
+ .onConflict((oc) => oc.doNothing())
366
+ .execute();
367
+ }
368
+
369
+ /**
370
+ * Count content entries that use any translation of this term. Accepts
371
+ * either a term id or a translation_group — we normalise to the group.
281
372
  */
282
- async countEntriesWithTerm(taxonomyId: string): Promise<number> {
373
+ async countEntriesWithTerm(termIdOrGroup: string): Promise<number> {
374
+ const group = await this.resolveTranslationGroup(termIdOrGroup);
375
+ if (!group) return 0;
376
+
283
377
  const result = await this.db
284
378
  .selectFrom("content_taxonomies")
285
379
  .select((eb) => eb.fn.count("entry_id").as("count"))
286
- .where("taxonomy_id", "=", taxonomyId)
380
+ .where("taxonomy_id", "=", group)
287
381
  .executeTakeFirst();
382
+ return Number(result?.count ?? 0);
383
+ }
288
384
 
289
- return Number(result?.count || 0);
385
+ private async resolveTranslationGroup(idOrGroup: string): Promise<string | null> {
386
+ const row = await this.db
387
+ .selectFrom("taxonomies")
388
+ .select(["translation_group"])
389
+ .where((eb) => eb.or([eb("id", "=", idOrGroup), eb("translation_group", "=", idOrGroup)]))
390
+ .executeTakeFirst();
391
+ return row?.translation_group ?? null;
290
392
  }
291
393
 
292
394
  /**
293
- * Convert database row to Taxonomy object
395
+ * Batch count entries for multiple taxonomy translation_groups.
396
+ * Chunks the query at SQL_BATCH_SIZE to stay below D1's bind-parameter limit.
397
+ * Returns a Map from translation_group to count.
398
+ *
399
+ * Pass translation_groups (not term ids) — `content_taxonomies.taxonomy_id`
400
+ * stores the translation_group so a single assignment spans every locale.
294
401
  */
295
- private rowToTaxonomy(row: TaxonomyTable): Taxonomy {
402
+ async countEntriesForTerms(translationGroups: string[]): Promise<Map<string, number>> {
403
+ if (translationGroups.length === 0) return new Map();
404
+
405
+ const { chunks, SQL_BATCH_SIZE } = await import("../../utils/chunks.js");
406
+
407
+ const counts = new Map<string, number>();
408
+ for (const chunk of chunks(translationGroups, SQL_BATCH_SIZE)) {
409
+ const rows = await this.db
410
+ .selectFrom("content_taxonomies")
411
+ .select(["taxonomy_id", (eb) => eb.fn.count("entry_id").as("count")])
412
+ .where("taxonomy_id", "in", chunk)
413
+ .groupBy("taxonomy_id")
414
+ .execute();
415
+
416
+ for (const row of rows) {
417
+ counts.set(row.taxonomy_id, Number(row.count || 0));
418
+ }
419
+ }
420
+ return counts;
421
+ }
422
+
423
+ private rowToTaxonomy(row: Selectable<TaxonomyTable>): Taxonomy {
296
424
  return {
297
425
  id: row.id,
298
426
  name: row.name,
@@ -300,6 +428,8 @@ export class TaxonomyRepository {
300
428
  label: row.label,
301
429
  parentId: row.parent_id,
302
430
  data: row.data ? JSON.parse(row.data) : null,
431
+ locale: row.locale,
432
+ translationGroup: row.translation_group,
303
433
  };
304
434
  }
305
435
  }
@@ -20,12 +20,14 @@ export interface TaxonomyTable {
20
20
  label: string;
21
21
  parent_id: string | null;
22
22
  data: string | null; // JSON
23
+ locale: Generated<string>; // e.g. 'en', 'es', 'fr'
24
+ translation_group: string | null; // shared across translations of the same term
23
25
  }
24
26
 
25
27
  export interface ContentTaxonomyTable {
26
28
  collection: string; // e.g., 'posts'
27
29
  entry_id: string; // ID in the ec_* table
28
- taxonomy_id: string;
30
+ taxonomy_id: string; // stores taxonomies.translation_group (locale-agnostic)
29
31
  }
30
32
 
31
33
  export interface TaxonomyDefTable {
@@ -36,6 +38,8 @@ export interface TaxonomyDefTable {
36
38
  hierarchical: number; // 0 or 1 (SQLite boolean)
37
39
  collections: string | null; // JSON array
38
40
  created_at: Generated<string>;
41
+ locale: Generated<string>;
42
+ translation_group: string | null;
39
43
  }
40
44
 
41
45
  export interface MediaTable {
@@ -292,6 +296,8 @@ export interface MenuTable {
292
296
  label: string;
293
297
  created_at: Generated<string>;
294
298
  updated_at: Generated<string>;
299
+ locale: Generated<string>;
300
+ translation_group: string | null;
295
301
  }
296
302
 
297
303
  export interface MenuItemTable {
@@ -301,13 +307,15 @@ export interface MenuItemTable {
301
307
  sort_order: number;
302
308
  type: string;
303
309
  reference_collection: string | null;
304
- reference_id: string | null;
310
+ reference_id: string | null; // stores translation_group of referenced content/term
305
311
  custom_url: string | null;
306
312
  label: string;
307
313
  title_attr: string | null;
308
314
  target: string | null;
309
315
  css_classes: string | null;
310
316
  created_at: Generated<string>;
317
+ locale: Generated<string>;
318
+ translation_group: string | null;
311
319
  }
312
320
 
313
321
  // Widget Areas
package/src/db/libsql.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * Loaded at runtime via virtual module.
6
6
  */
7
7
 
8
+ import { LibsqlDialect } from "@libsql/kysely-libsql";
8
9
  import type { Dialect } from "kysely";
9
10
 
10
11
  import type { LibsqlConfig } from "./adapters.js";
@@ -13,9 +14,6 @@ import type { LibsqlConfig } from "./adapters.js";
13
14
  * Create a libSQL dialect from config
14
15
  */
15
16
  export function createDialect(config: LibsqlConfig): Dialect {
16
- // Dynamic import to avoid loading @libsql/kysely-libsql at config time
17
- const { LibsqlDialect } = require("@libsql/kysely-libsql");
18
-
19
17
  return new LibsqlDialect({
20
18
  url: config.url,
21
19
  authToken: config.authToken,
package/src/db/sqlite.ts CHANGED
@@ -5,7 +5,8 @@
5
5
  * Loaded at runtime via virtual module.
6
6
  */
7
7
 
8
- import type { Dialect } from "kysely";
8
+ import BetterSqlite3 from "better-sqlite3";
9
+ import { type Dialect, SqliteDialect } from "kysely";
9
10
 
10
11
  import type { SqliteConfig } from "./adapters.js";
11
12
 
@@ -13,10 +14,6 @@ import type { SqliteConfig } from "./adapters.js";
13
14
  * Create a SQLite dialect from config
14
15
  */
15
16
  export function createDialect(config: SqliteConfig): Dialect {
16
- // Dynamic import to avoid loading better-sqlite3 at config time
17
- const BetterSqlite3 = require("better-sqlite3");
18
- const { SqliteDialect } = require("kysely");
19
-
20
17
  // Parse URL to get file path
21
18
  const url = config.url;
22
19
  const filePath = url.startsWith("file:") ? url.slice(5) : url;