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,105 +1,134 @@
1
1
  /**
2
- * Runtime API for taxonomies
2
+ * Runtime API for taxonomies.
3
3
  *
4
- * Provides functions to query taxonomy definitions and terms.
4
+ * All helpers are locale-aware. When a locale is not passed explicitly we fall
5
+ * back to the request context or the configured `defaultLocale` (see
6
+ * `i18n/resolve.ts`).
7
+ *
8
+ * Because `content_taxonomies.taxonomy_id` stores the translation_group (not a
9
+ * specific term id), the joins here are `taxonomies.translation_group =
10
+ * content_taxonomies.taxonomy_id` + filter by `taxonomies.locale`, which picks
11
+ * the right per-locale term.
5
12
  */
6
13
 
14
+ import { resolveLocale, resolveLocaleChain } from "../i18n/resolve.js";
7
15
  import { getDb } from "../loader.js";
8
- import { requestCached, setRequestCacheEntry } from "../request-cache.js";
16
+ import { peekRequestCache, requestCached, setRequestCacheEntry } from "../request-cache.js";
9
17
  import { chunks, SQL_BATCH_SIZE } from "../utils/chunks.js";
10
18
  import { isMissingTableError } from "../utils/db-errors.js";
11
19
  import type { TaxonomyDef, TaxonomyTerm, TaxonomyTermRow } from "./types.js";
12
20
 
21
+ export interface TaxonomyQueryOptions {
22
+ locale?: string;
23
+ }
24
+
13
25
  /**
14
26
  * No-op — kept for API compatibility.
15
- *
16
- * Used to invalidate a worker-lifetime "has any term assignments?" probe.
17
- * That probe added a query on every cold isolate to save one query on
18
- * sites with zero term assignments (i.e. the wrong tradeoff), so we
19
- * dropped it. The batch term join below returns an empty map for empty
20
- * sites at the same cost as the probe, without the pre-check.
21
27
  */
22
28
  export function invalidateTermCache(): void {
23
29
  // Intentionally empty.
24
30
  }
25
31
 
26
32
  /**
27
- * Get all taxonomy definitions
33
+ * Get every taxonomy definition. Definitions are per-locale (one row per
34
+ * locale inside the same translation_group) — by default we resolve to the
35
+ * active locale.
28
36
  */
29
- export async function getTaxonomyDefs(): Promise<TaxonomyDef[]> {
30
- return requestCached("taxonomy-defs:all", async () => {
37
+ export async function getTaxonomyDefs(options: TaxonomyQueryOptions = {}): Promise<TaxonomyDef[]> {
38
+ const locale = resolveLocale(options.locale);
39
+ return requestCached(`taxonomy-defs:${locale ?? "*"}`, async () => {
31
40
  const db = await getDb();
32
-
33
- const rows = await db.selectFrom("_emdash_taxonomy_defs").selectAll().execute();
34
-
35
- return rows.map((row) => ({
36
- id: row.id,
37
- name: row.name,
38
- label: row.label,
39
- labelSingular: row.label_singular ?? undefined,
40
- hierarchical: row.hierarchical === 1,
41
- collections: row.collections ? JSON.parse(row.collections) : [],
42
- }));
41
+ let query = db.selectFrom("_emdash_taxonomy_defs").selectAll();
42
+ if (locale !== undefined) query = query.where("locale", "=", locale);
43
+ const rows = await query.execute();
44
+ return rows.map(rowToTaxonomyDef);
43
45
  });
44
46
  }
45
47
 
46
48
  /**
47
- * Get a single taxonomy definition by name
49
+ * Get a single taxonomy definition by name. Uses the fallback chain so even
50
+ * if there is no translation for the active locale we still return something.
51
+ *
52
+ * If `getTaxonomyDefs()` has already loaded the full list in this request
53
+ * (which happens during entry-term hydration on every page that renders a
54
+ * collection), search the matching def in memory rather than running a
55
+ * second query against `_emdash_taxonomy_defs`.
48
56
  */
49
- export async function getTaxonomyDef(name: string): Promise<TaxonomyDef | null> {
50
- return requestCached(`taxonomy-def:${name}`, async () => {
51
- const db = await getDb();
57
+ export async function getTaxonomyDef(
58
+ name: string,
59
+ options: TaxonomyQueryOptions = {},
60
+ ): Promise<TaxonomyDef | null> {
61
+ const chain = resolveLocaleChain(options.locale);
62
+ const peekKey = `taxonomy-defs:${resolveLocale(options.locale) ?? "*"}`;
63
+ const allDefs = peekRequestCache<TaxonomyDef[]>(peekKey);
64
+ if (allDefs) {
65
+ const defs = await allDefs;
66
+ if (chain.length === 0) return defs.find((d) => d.name === name) ?? null;
67
+ for (const locale of chain) {
68
+ const found = defs.find((d) => d.name === name && d.locale === locale);
69
+ if (found) return found;
70
+ }
71
+ return null;
72
+ }
52
73
 
53
- const row = await db
54
- .selectFrom("_emdash_taxonomy_defs")
55
- .selectAll()
56
- .where("name", "=", name)
57
- .executeTakeFirst();
74
+ return requestCached(`taxonomy-def:${name}:${chain.join(",")}`, async () => {
75
+ const db = await getDb();
58
76
 
59
- if (!row) return null;
77
+ if (chain.length === 0) {
78
+ const row = await db
79
+ .selectFrom("_emdash_taxonomy_defs")
80
+ .selectAll()
81
+ .where("name", "=", name)
82
+ .orderBy("locale", "asc")
83
+ .executeTakeFirst();
84
+ return row ? rowToTaxonomyDef(row) : null;
85
+ }
60
86
 
61
- return {
62
- id: row.id,
63
- name: row.name,
64
- label: row.label,
65
- labelSingular: row.label_singular ?? undefined,
66
- hierarchical: row.hierarchical === 1,
67
- collections: row.collections ? JSON.parse(row.collections) : [],
68
- };
87
+ for (const locale of chain) {
88
+ const row = await db
89
+ .selectFrom("_emdash_taxonomy_defs")
90
+ .selectAll()
91
+ .where("name", "=", name)
92
+ .where("locale", "=", locale)
93
+ .executeTakeFirst();
94
+ if (row) return rowToTaxonomyDef(row);
95
+ }
96
+ return null;
69
97
  });
70
98
  }
71
99
 
72
100
  /**
73
- * Get all terms for a taxonomy (as tree for hierarchical, flat for tags)
101
+ * All terms of a taxonomy in a specific locale (flat for non-hierarchical,
102
+ * tree for hierarchical).
74
103
  */
75
- export async function getTaxonomyTerms(taxonomyName: string): Promise<TaxonomyTerm[]> {
76
- return requestCached(`taxonomy-terms:${taxonomyName}`, async () => {
104
+ export async function getTaxonomyTerms(
105
+ taxonomyName: string,
106
+ options: TaxonomyQueryOptions = {},
107
+ ): Promise<TaxonomyTerm[]> {
108
+ const locale = resolveLocale(options.locale);
109
+ return requestCached(`taxonomy-terms:${taxonomyName}:${locale ?? "*"}`, async () => {
77
110
  const db = await getDb();
78
111
 
79
- // Get taxonomy definition to check if hierarchical
80
- const def = await getTaxonomyDef(taxonomyName);
112
+ const def = await getTaxonomyDef(taxonomyName, options);
81
113
  if (!def) return [];
82
114
 
83
- // Get all terms for this taxonomy
84
- const rows = await db
115
+ let termsQuery = db
85
116
  .selectFrom("taxonomies")
86
117
  .selectAll()
87
118
  .where("name", "=", taxonomyName)
88
- .orderBy("label", "asc")
89
- .execute();
119
+ .orderBy("label", "asc");
120
+ if (locale !== undefined) termsQuery = termsQuery.where("locale", "=", locale);
121
+ const rows = await termsQuery.execute();
90
122
 
91
- // Count entries for each term
123
+ // Counts are keyed by translation_group (what the pivot stores).
92
124
  const countsResult = await db
93
125
  .selectFrom("content_taxonomies")
94
126
  .select(["taxonomy_id"])
95
127
  .select((eb) => eb.fn.count<number>("entry_id").as("count"))
96
128
  .groupBy("taxonomy_id")
97
129
  .execute();
98
-
99
130
  const counts = new Map<string, number>();
100
- for (const row of countsResult) {
101
- counts.set(row.taxonomy_id, row.count);
102
- }
131
+ for (const row of countsResult) counts.set(row.taxonomy_id, row.count);
103
132
 
104
133
  const flatTerms: TaxonomyTermRow[] = rows.map((row) => ({
105
134
  id: row.id,
@@ -108,12 +137,11 @@ export async function getTaxonomyTerms(taxonomyName: string): Promise<TaxonomyTe
108
137
  label: row.label,
109
138
  parent_id: row.parent_id,
110
139
  data: row.data,
140
+ locale: row.locale,
141
+ translation_group: row.translation_group,
111
142
  }));
112
143
 
113
- // If hierarchical, build tree. Otherwise return flat
114
- if (def.hierarchical) {
115
- return buildTree(flatTerms, counts);
116
- }
144
+ if (def.hierarchical) return buildTree(flatTerms, counts);
117
145
 
118
146
  return flatTerms.map((term) => ({
119
147
  id: term.id,
@@ -121,50 +149,71 @@ export async function getTaxonomyTerms(taxonomyName: string): Promise<TaxonomyTe
121
149
  slug: term.slug,
122
150
  label: term.label,
123
151
  children: [],
124
- count: counts.get(term.id) ?? 0,
152
+ count: counts.get(term.translation_group ?? term.id) ?? 0,
153
+ locale: term.locale,
154
+ translationGroup: term.translation_group,
125
155
  }));
126
156
  });
127
157
  }
128
158
 
129
159
  /**
130
- * Get a single term by taxonomy and slug
160
+ * Get a single term by (taxonomy, slug). Honours the fallback chain — if the
161
+ * slug exists in a fallback locale, we return that row (useful for deep-linking
162
+ * to a term page when the translation is missing).
131
163
  */
132
- export async function getTerm(taxonomyName: string, slug: string): Promise<TaxonomyTerm | null> {
164
+ export async function getTerm(
165
+ taxonomyName: string,
166
+ slug: string,
167
+ options: TaxonomyQueryOptions = {},
168
+ ): Promise<TaxonomyTerm | null> {
133
169
  const db = await getDb();
170
+ const chain = resolveLocaleChain(options.locale);
134
171
 
135
- const row = await db
136
- .selectFrom("taxonomies")
137
- .selectAll()
138
- .where("name", "=", taxonomyName)
139
- .where("slug", "=", slug)
140
- .executeTakeFirst();
172
+ let row: Awaited<ReturnType<ReturnType<typeof selectTerm>["executeTakeFirst"]>>;
173
+ const selectTerm = () =>
174
+ db
175
+ .selectFrom("taxonomies")
176
+ .selectAll()
177
+ .where("name", "=", taxonomyName)
178
+ .where("slug", "=", slug);
179
+
180
+ if (chain.length === 0) {
181
+ row = await selectTerm().orderBy("locale", "asc").executeTakeFirst();
182
+ } else {
183
+ row = undefined;
184
+ for (const locale of chain) {
185
+ row = await selectTerm().where("locale", "=", locale).executeTakeFirst();
186
+ if (row) break;
187
+ }
188
+ }
141
189
 
142
190
  if (!row) return null;
143
191
 
144
- // Get entry count
145
192
  const countResult = await db
146
193
  .selectFrom("content_taxonomies")
147
194
  .select((eb) => eb.fn.count<number>("entry_id").as("count"))
148
- .where("taxonomy_id", "=", row.id)
195
+ .where("taxonomy_id", "=", row.translation_group ?? row.id)
149
196
  .executeTakeFirst();
150
-
151
197
  const count = countResult?.count ?? 0;
152
198
 
153
- // Get children if hierarchical
154
- const childRows = await db
199
+ let childrenQuery = db
155
200
  .selectFrom("taxonomies")
156
201
  .selectAll()
157
202
  .where("parent_id", "=", row.id)
158
- .orderBy("label", "asc")
159
- .execute();
203
+ .orderBy("label", "asc");
204
+ const termLocale = row.locale;
205
+ if (termLocale) childrenQuery = childrenQuery.where("locale", "=", termLocale);
206
+ const childRows = await childrenQuery.execute();
160
207
 
161
- const children = childRows.map((child) => ({
208
+ const children = childRows.map<TaxonomyTerm>((child) => ({
162
209
  id: child.id,
163
210
  name: child.name,
164
211
  slug: child.slug,
165
212
  label: child.label,
166
213
  parentId: child.parent_id ?? undefined,
167
214
  children: [],
215
+ locale: child.locale,
216
+ translationGroup: child.translation_group,
168
217
  }));
169
218
 
170
219
  return {
@@ -176,89 +225,75 @@ export async function getTerm(taxonomyName: string, slug: string): Promise<Taxon
176
225
  description: row.data ? JSON.parse(row.data).description : undefined,
177
226
  children,
178
227
  count,
228
+ locale: row.locale,
229
+ translationGroup: row.translation_group,
179
230
  };
180
231
  }
181
232
 
182
233
  /**
183
- * Get terms assigned to an entry
234
+ * Terms assigned to a content entry, resolved into the active locale. Terms
235
+ * whose translation_group lacks a row in the requested locale are omitted.
184
236
  */
185
237
  export function getEntryTerms(
186
238
  collection: string,
187
239
  entryId: string,
188
240
  taxonomyName?: string,
241
+ options: TaxonomyQueryOptions = {},
189
242
  ): Promise<TaxonomyTerm[]> {
190
- return requestCached(`terms:${collection}:${entryId}:${taxonomyName ?? "*"}`, async () => {
191
- const db = await getDb();
243
+ const locale = resolveLocale(options.locale);
244
+ return requestCached(
245
+ `terms:${collection}:${entryId}:${taxonomyName ?? "*"}:${locale ?? "*"}`,
246
+ async () => {
247
+ const db = await getDb();
192
248
 
193
- let query = db
194
- .selectFrom("content_taxonomies")
195
- .innerJoin("taxonomies", "taxonomies.id", "content_taxonomies.taxonomy_id")
196
- .selectAll("taxonomies")
197
- .where("content_taxonomies.collection", "=", collection)
198
- .where("content_taxonomies.entry_id", "=", entryId);
199
-
200
- if (taxonomyName) {
201
- query = query.where("taxonomies.name", "=", taxonomyName);
202
- }
249
+ let query = db
250
+ .selectFrom("content_taxonomies")
251
+ .innerJoin("taxonomies", "taxonomies.translation_group", "content_taxonomies.taxonomy_id")
252
+ .selectAll("taxonomies")
253
+ .where("content_taxonomies.collection", "=", collection)
254
+ .where("content_taxonomies.entry_id", "=", entryId);
203
255
 
204
- const rows = await query.execute();
256
+ if (taxonomyName) query = query.where("taxonomies.name", "=", taxonomyName);
257
+ if (locale !== undefined) query = query.where("taxonomies.locale", "=", locale);
205
258
 
206
- return rows.map((row) => ({
207
- id: row.id,
208
- name: row.name,
209
- slug: row.slug,
210
- label: row.label,
211
- parentId: row.parent_id ?? undefined,
212
- children: [],
213
- }));
214
- });
259
+ const rows = await query.execute();
260
+ return rows.map<TaxonomyTerm>((row) => ({
261
+ id: row.id,
262
+ name: row.name,
263
+ slug: row.slug,
264
+ label: row.label,
265
+ parentId: row.parent_id ?? undefined,
266
+ children: [],
267
+ locale: row.locale,
268
+ translationGroup: row.translation_group,
269
+ }));
270
+ },
271
+ );
215
272
  }
216
273
 
217
274
  /**
218
- * Get terms for multiple entries in a single query (batched API)
219
- *
220
- * This is more efficient than calling getEntryTerms for each entry
221
- * when you need terms for a list of entries.
222
- *
223
- * @param collection - The collection type (e.g., "posts")
224
- * @param entryIds - Array of entry IDs
225
- * @param taxonomyName - The taxonomy name (e.g., "categories")
226
- * @returns Map from entry ID to array of terms
275
+ * Terms for multiple entries of one taxonomy, single query.
227
276
  */
228
277
  export async function getTermsForEntries(
229
278
  collection: string,
230
279
  entryIds: string[],
231
280
  taxonomyName: string,
281
+ options: TaxonomyQueryOptions = {},
232
282
  ): Promise<Map<string, TaxonomyTerm[]>> {
233
283
  const result = new Map<string, TaxonomyTerm[]>();
234
-
235
- // Initialize all entry IDs with empty arrays so callers can always
236
- // expect the key to be present.
237
284
  const uniqueIds = [...new Set(entryIds)];
238
- for (const id of uniqueIds) {
239
- result.set(id, []);
240
- }
241
-
242
- if (uniqueIds.length === 0) {
243
- return result;
244
- }
285
+ for (const id of uniqueIds) result.set(id, []);
286
+ if (uniqueIds.length === 0) return result;
245
287
 
246
288
  const db = await getDb();
289
+ const locale = resolveLocale(options.locale);
247
290
 
248
- // Chunk the IN clause so we stay below D1's ~100 bound-parameter limit
249
- // (and equivalent limits on other dialects). Matches getContentBylinesMany.
250
- //
251
- // Sites with no term assignments get back empty rows for one query —
252
- // the previous "has any term assignments" probe spent a round-trip on
253
- // every request to save that single query on empty sites, which is
254
- // backwards. Pre-migration databases (content_taxonomies missing) fall
255
- // through to the `isMissingTableError` catch and return empties.
256
291
  for (const chunk of chunks(uniqueIds, SQL_BATCH_SIZE)) {
257
292
  let rows;
258
293
  try {
259
- rows = await db
294
+ let query = db
260
295
  .selectFrom("content_taxonomies")
261
- .innerJoin("taxonomies", "taxonomies.id", "content_taxonomies.taxonomy_id")
296
+ .innerJoin("taxonomies", "taxonomies.translation_group", "content_taxonomies.taxonomy_id")
262
297
  .select([
263
298
  "content_taxonomies.entry_id",
264
299
  "taxonomies.id",
@@ -266,18 +301,20 @@ export async function getTermsForEntries(
266
301
  "taxonomies.slug",
267
302
  "taxonomies.label",
268
303
  "taxonomies.parent_id",
304
+ "taxonomies.locale",
305
+ "taxonomies.translation_group",
269
306
  ])
270
307
  .where("content_taxonomies.collection", "=", collection)
271
308
  .where("content_taxonomies.entry_id", "in", chunk)
272
- .where("taxonomies.name", "=", taxonomyName)
273
- .execute();
309
+ .where("taxonomies.name", "=", taxonomyName);
310
+ if (locale !== undefined) query = query.where("taxonomies.locale", "=", locale);
311
+ rows = await query.execute();
274
312
  } catch (error) {
275
313
  if (isMissingTableError(error)) return result;
276
314
  throw error;
277
315
  }
278
316
 
279
317
  for (const row of rows) {
280
- const entryId = row.entry_id;
281
318
  const term: TaxonomyTerm = {
282
319
  id: row.id,
283
320
  name: row.name,
@@ -285,12 +322,11 @@ export async function getTermsForEntries(
285
322
  label: row.label,
286
323
  parentId: row.parent_id ?? undefined,
287
324
  children: [],
325
+ locale: row.locale,
326
+ translationGroup: row.translation_group,
288
327
  };
289
-
290
- const terms = result.get(entryId);
291
- if (terms) {
292
- terms.push(term);
293
- }
328
+ const terms = result.get(row.entry_id);
329
+ if (terms) terms.push(term);
294
330
  }
295
331
  }
296
332
 
@@ -298,57 +334,29 @@ export async function getTermsForEntries(
298
334
  }
299
335
 
300
336
  /**
301
- * Batch-fetch terms for multiple entries across ALL taxonomies in a single query.
302
- *
303
- * Returns a Map keyed by entry ID, where each value is a Record keyed by
304
- * taxonomy name with the matching terms as an array. Used by
305
- * getEmDashCollection to eagerly hydrate `entry.data.terms` and avoid
306
- * the N+1 pattern that callers hit when they loop and call getEntryTerms.
307
- *
308
- * Pre-migration databases (content_taxonomies missing) return an empty
309
- * Map — the join falls through to the `isMissingTableError` branch.
337
+ * Batch-fetch terms for multiple entries across ALL taxonomies in one query.
338
+ * Primes the request-cache for subsequent per-entry calls to `getEntryTerms`.
310
339
  */
311
340
  export async function getAllTermsForEntries(
312
341
  collection: string,
313
342
  entryIds: string[],
343
+ options: TaxonomyQueryOptions = {},
314
344
  ): Promise<Map<string, Record<string, TaxonomyTerm[]>>> {
315
345
  const result = new Map<string, Record<string, TaxonomyTerm[]>>();
316
-
317
- // Initialize unique entry IDs with empty objects so callers can always
318
- // expect the key to be present. Deduping also reduces wasted bound
319
- // parameters when a caller accidentally passes duplicates.
320
346
  const uniqueIds = [...new Set(entryIds)];
321
- for (const id of uniqueIds) {
322
- result.set(id, {});
323
- }
324
-
325
- if (uniqueIds.length === 0) {
326
- return result;
327
- }
347
+ for (const id of uniqueIds) result.set(id, {});
348
+ if (uniqueIds.length === 0) return result;
328
349
 
329
350
  const db = await getDb();
351
+ const locale = resolveLocale(options.locale);
352
+ const applicableTaxonomyNames = await getCollectionTaxonomyNames(collection, { locale });
330
353
 
331
- // Look up which taxonomies apply to this collection. Used below to
332
- // seed empty arrays for taxonomies the entry has no terms in — so
333
- // callers (including the pre-populated getEntryTerms cache) get a
334
- // deterministic `[]` back rather than a cache miss that triggers a DB
335
- // round-trip just to confirm "no terms".
336
- const applicableTaxonomyNames = await getCollectionTaxonomyNames(collection);
337
-
338
- // Chunk the IN clause to stay below D1's ~100 bound-parameter limit
339
- // (and equivalent limits on other dialects). Matches getContentBylinesMany.
340
- //
341
- // Previously we did a separate "has any assignments" probe to skip the
342
- // join on empty sites. That traded one query per request for a query
343
- // saved only on empty sites — backwards. Now the join runs directly
344
- // (returning zero rows cheaply) and pre-migration databases are caught
345
- // by the `isMissingTableError` branch below.
346
354
  for (const chunk of chunks(uniqueIds, SQL_BATCH_SIZE)) {
347
355
  let rows;
348
356
  try {
349
- rows = await db
357
+ let query = db
350
358
  .selectFrom("content_taxonomies")
351
- .innerJoin("taxonomies", "taxonomies.id", "content_taxonomies.taxonomy_id")
359
+ .innerJoin("taxonomies", "taxonomies.translation_group", "content_taxonomies.taxonomy_id")
352
360
  .select([
353
361
  "content_taxonomies.entry_id",
354
362
  "taxonomies.id",
@@ -356,15 +364,18 @@ export async function getAllTermsForEntries(
356
364
  "taxonomies.slug",
357
365
  "taxonomies.label",
358
366
  "taxonomies.parent_id",
367
+ "taxonomies.locale",
368
+ "taxonomies.translation_group",
359
369
  ])
360
370
  .where("content_taxonomies.collection", "=", collection)
361
371
  .where("content_taxonomies.entry_id", "in", chunk)
362
- .orderBy("taxonomies.label", "asc")
363
- .execute();
372
+ .orderBy("taxonomies.label", "asc");
373
+ if (locale !== undefined) query = query.where("taxonomies.locale", "=", locale);
374
+ rows = await query.execute();
364
375
  } catch (error) {
365
376
  if (isMissingTableError(error)) {
366
377
  for (const id of uniqueIds) {
367
- primeEntryTermsCache(collection, id, {}, applicableTaxonomyNames);
378
+ primeEntryTermsCache(collection, id, {}, applicableTaxonomyNames, locale);
368
379
  }
369
380
  return result;
370
381
  }
@@ -372,7 +383,6 @@ export async function getAllTermsForEntries(
372
383
  }
373
384
 
374
385
  for (const row of rows) {
375
- const entryId = row.entry_id;
376
386
  const term: TaxonomyTerm = {
377
387
  id: row.id,
378
388
  name: row.name,
@@ -380,25 +390,19 @@ export async function getAllTermsForEntries(
380
390
  label: row.label,
381
391
  parentId: row.parent_id ?? undefined,
382
392
  children: [],
393
+ locale: row.locale,
394
+ translationGroup: row.translation_group,
383
395
  };
384
-
385
- const byTaxonomy = result.get(entryId);
396
+ const byTaxonomy = result.get(row.entry_id);
386
397
  if (!byTaxonomy) continue;
387
398
  const existing = byTaxonomy[row.name];
388
- if (existing) {
389
- existing.push(term);
390
- } else {
391
- byTaxonomy[row.name] = [term];
392
- }
399
+ if (existing) existing.push(term);
400
+ else byTaxonomy[row.name] = [term];
393
401
  }
394
402
  }
395
403
 
396
- // Prime the request-scoped cache so legacy callers of getEntryTerms
397
- // (which still work per-entry) hit the in-memory cache instead of
398
- // re-querying. This is what gives us the N+1 win in existing templates
399
- // without requiring them to be rewritten.
400
404
  for (const [entryId, byTaxonomy] of result) {
401
- primeEntryTermsCache(collection, entryId, byTaxonomy, applicableTaxonomyNames);
405
+ primeEntryTermsCache(collection, entryId, byTaxonomy, applicableTaxonomyNames, locale);
402
406
  }
403
407
 
404
408
  return result;
@@ -410,9 +414,12 @@ export async function getAllTermsForEntries(
410
414
  *
411
415
  * Returns an empty list when taxonomies haven't been defined yet.
412
416
  */
413
- async function getCollectionTaxonomyNames(collection: string): Promise<string[]> {
417
+ async function getCollectionTaxonomyNames(
418
+ collection: string,
419
+ options: TaxonomyQueryOptions,
420
+ ): Promise<string[]> {
414
421
  try {
415
- const defs = await getTaxonomyDefs();
422
+ const defs = await getTaxonomyDefs(options);
416
423
  return defs.filter((d) => d.collections.includes(collection)).map((d) => d.name);
417
424
  } catch (error) {
418
425
  if (isMissingTableError(error)) return [];
@@ -437,44 +444,64 @@ function primeEntryTermsCache(
437
444
  entryId: string,
438
445
  byTaxonomy: Record<string, TaxonomyTerm[]>,
439
446
  applicableTaxonomyNames: string[],
447
+ locale: string | undefined,
440
448
  ): void {
441
- // Seed every applicable taxonomy with at least [] so
442
- // getEntryTerms(collection, id, "tag") doesn't miss the cache when an
443
- // entry has no tags.
449
+ const localeKey = locale ?? "*";
444
450
  for (const name of applicableTaxonomyNames) {
445
- setRequestCacheEntry(`terms:${collection}:${entryId}:${name}`, byTaxonomy[name] ?? []);
451
+ setRequestCacheEntry(
452
+ `terms:${collection}:${entryId}:${name}:${localeKey}`,
453
+ byTaxonomy[name] ?? [],
454
+ );
446
455
  }
447
- // Also seed individual names that show up in data but aren't listed
448
- // as applicable (e.g. taxonomy reassigned to a different collection
449
- // since the terms were written).
450
456
  for (const [name, terms] of Object.entries(byTaxonomy)) {
451
- setRequestCacheEntry(`terms:${collection}:${entryId}:${name}`, terms);
457
+ setRequestCacheEntry(`terms:${collection}:${entryId}:${name}:${localeKey}`, terms);
452
458
  }
453
- // Flattened `*` view — all terms across all taxonomies in one array.
454
459
  const allTerms = Object.values(byTaxonomy).flat();
455
- setRequestCacheEntry(`terms:${collection}:${entryId}:*`, allTerms);
460
+ setRequestCacheEntry(`terms:${collection}:${entryId}:*:${localeKey}`, allTerms);
456
461
  }
457
462
 
458
463
  /**
459
- * Get entries by term (wraps getEmDashCollection)
464
+ * Get entries by term. Both the lookup (term slug in the active locale) and
465
+ * the content query respect the active locale.
460
466
  */
461
467
  export async function getEntriesByTerm(
462
468
  collection: string,
463
469
  taxonomyName: string,
464
470
  termSlug: string,
471
+ options: TaxonomyQueryOptions = {},
465
472
  ): Promise<Array<{ id: string; data: Record<string, unknown> }>> {
466
473
  const { getEmDashCollection } = await import("../query.js");
467
474
 
468
- // Build options as the expected type — getEmDashCollection accepts
469
- // a generic options object with `where` for filtering by taxonomy
470
- const options: Record<string, unknown> = {
475
+ const queryOptions: Record<string, unknown> = {
471
476
  where: { [taxonomyName]: termSlug },
472
477
  };
473
- const { entries } = await getEmDashCollection(collection, options);
474
-
478
+ if (options.locale !== undefined) queryOptions.locale = options.locale;
479
+ const { entries } = await getEmDashCollection(collection, queryOptions);
475
480
  return entries;
476
481
  }
477
482
 
483
+ function rowToTaxonomyDef(row: {
484
+ id: string;
485
+ name: string;
486
+ label: string;
487
+ label_singular: string | null;
488
+ hierarchical: number;
489
+ collections: string | null;
490
+ locale: string;
491
+ translation_group: string | null;
492
+ }): TaxonomyDef {
493
+ return {
494
+ id: row.id,
495
+ name: row.name,
496
+ label: row.label,
497
+ labelSingular: row.label_singular ?? undefined,
498
+ hierarchical: row.hierarchical === 1,
499
+ collections: row.collections ? JSON.parse(row.collections) : [],
500
+ locale: row.locale,
501
+ translationGroup: row.translation_group,
502
+ };
503
+ }
504
+
478
505
  /**
479
506
  * Build tree structure from flat terms
480
507
  */
@@ -482,7 +509,6 @@ function buildTree(flatTerms: TaxonomyTermRow[], counts: Map<string, number>): T
482
509
  const map = new Map<string, TaxonomyTerm>();
483
510
  const roots: TaxonomyTerm[] = [];
484
511
 
485
- // First pass: create nodes
486
512
  for (const term of flatTerms) {
487
513
  map.set(term.id, {
488
514
  id: term.id,
@@ -492,11 +518,12 @@ function buildTree(flatTerms: TaxonomyTermRow[], counts: Map<string, number>): T
492
518
  parentId: term.parent_id ?? undefined,
493
519
  description: term.data ? JSON.parse(term.data).description : undefined,
494
520
  children: [],
495
- count: counts.get(term.id) ?? 0,
521
+ count: counts.get(term.translation_group ?? term.id) ?? 0,
522
+ locale: term.locale,
523
+ translationGroup: term.translation_group,
496
524
  });
497
525
  }
498
526
 
499
- // Second pass: build tree
500
527
  for (const term of map.values()) {
501
528
  if (term.parentId && map.has(term.parentId)) {
502
529
  map.get(term.parentId)!.children.push(term);