emdash 0.9.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 (195) hide show
  1. package/dist/{adapters-DoNJiveC.d.mts → adapters-BktHA7EO.d.mts} +1 -1
  2. package/dist/{adapters-DoNJiveC.d.mts.map → adapters-BktHA7EO.d.mts.map} +1 -1
  3. package/dist/{apply-BzltprvY.mjs → apply-UsrFuO7l.mjs} +156 -254
  4. package/dist/apply-UsrFuO7l.mjs.map +1 -0
  5. package/dist/astro/index.d.mts +6 -6
  6. package/dist/astro/index.mjs +10 -2
  7. package/dist/astro/index.mjs.map +1 -1
  8. package/dist/astro/middleware/auth.d.mts +5 -5
  9. package/dist/astro/middleware/auth.mjs +5 -5
  10. package/dist/astro/middleware/redirect.mjs +5 -5
  11. package/dist/astro/middleware/request-context.mjs +4 -4
  12. package/dist/astro/middleware/setup.mjs +1 -1
  13. package/dist/astro/middleware.mjs +35 -34
  14. package/dist/astro/middleware.mjs.map +1 -1
  15. package/dist/astro/types.d.mts +8 -9
  16. package/dist/astro/types.d.mts.map +1 -1
  17. package/dist/{base64-BRICGH2l.mjs → base64-MBPo9ozB.mjs} +1 -1
  18. package/dist/{base64-BRICGH2l.mjs.map → base64-MBPo9ozB.mjs.map} +1 -1
  19. package/dist/{byline-BSaNL1w7.mjs → byline-C3vnhIpU.mjs} +4 -4
  20. package/dist/{byline-BSaNL1w7.mjs.map → byline-C3vnhIpU.mjs.map} +1 -1
  21. package/dist/{bylines-CvJ3PYz2.mjs → bylines-esI7ioa9.mjs} +5 -5
  22. package/dist/{bylines-CvJ3PYz2.mjs.map → bylines-esI7ioa9.mjs.map} +1 -1
  23. package/dist/{cache-C6N_hhN7.mjs → cache-fTzxgMFJ.mjs} +3 -3
  24. package/dist/{cache-C6N_hhN7.mjs.map → cache-fTzxgMFJ.mjs.map} +1 -1
  25. package/dist/{chunks-NBQVDOci.mjs → chunks-Da2-b-oA.mjs} +2 -2
  26. package/dist/{chunks-NBQVDOci.mjs.map → chunks-Da2-b-oA.mjs.map} +1 -1
  27. package/dist/cli/index.mjs +251 -79
  28. package/dist/cli/index.mjs.map +1 -1
  29. package/dist/client/cf-access.d.mts +1 -1
  30. package/dist/client/index.d.mts +1 -1
  31. package/dist/client/index.mjs +1 -1
  32. package/dist/{config-BI0V3ICQ.mjs → config-CVssduLe.mjs} +1 -1
  33. package/dist/{config-BI0V3ICQ.mjs.map → config-CVssduLe.mjs.map} +1 -1
  34. package/dist/{content-8lOYF0pr.mjs → content-C7G4QXkK.mjs} +14 -3
  35. package/dist/content-C7G4QXkK.mjs.map +1 -0
  36. package/dist/db/index.d.mts +3 -3
  37. package/dist/db/index.mjs +1 -1
  38. package/dist/db/libsql.d.mts +1 -1
  39. package/dist/db/postgres.d.mts +1 -1
  40. package/dist/db/sqlite.d.mts +1 -1
  41. package/dist/{db-errors-WRezodiz.mjs → db-errors-B7P2pSCn.mjs} +1 -1
  42. package/dist/{db-errors-WRezodiz.mjs.map → db-errors-B7P2pSCn.mjs.map} +1 -1
  43. package/dist/{default-D8ksjWhO.mjs → default-pHuz9WF6.mjs} +1 -1
  44. package/dist/{default-D8ksjWhO.mjs.map → default-pHuz9WF6.mjs.map} +1 -1
  45. package/dist/{error-D_-tqP-I.mjs → error-DqnRMM5z.mjs} +1 -1
  46. package/dist/{error-D_-tqP-I.mjs.map → error-DqnRMM5z.mjs.map} +1 -1
  47. package/dist/{index-BFRaVcD6.d.mts → index-DjPMOfO0.d.mts} +82 -67
  48. package/dist/index-DjPMOfO0.d.mts.map +1 -0
  49. package/dist/index.d.mts +10 -10
  50. package/dist/index.mjs +28 -27
  51. package/dist/{load-DDqMMvZL.mjs → load-sXRuM7Us.mjs} +2 -2
  52. package/dist/{load-DDqMMvZL.mjs.map → load-sXRuM7Us.mjs.map} +1 -1
  53. package/dist/{loader-CKLbBnhK.mjs → loader-Bx2_9-5e.mjs} +31 -6
  54. package/dist/loader-Bx2_9-5e.mjs.map +1 -0
  55. package/dist/{manifest-schema-DqWNC3lM.mjs → manifest-schema-CXAbd1vH.mjs} +1 -1
  56. package/dist/{manifest-schema-DqWNC3lM.mjs.map → manifest-schema-CXAbd1vH.mjs.map} +1 -1
  57. package/dist/media/index.d.mts +1 -1
  58. package/dist/media/index.mjs +1 -1
  59. package/dist/media/local-runtime.d.mts +7 -7
  60. package/dist/media/local-runtime.mjs +3 -3
  61. package/dist/{media-BW32b4gi.mjs → media-D8FbNsl0.mjs} +2 -2
  62. package/dist/{media-BW32b4gi.mjs.map → media-D8FbNsl0.mjs.map} +1 -1
  63. package/dist/{mode-ier8jbBk.mjs → mode-YhqNVef_.mjs} +1 -1
  64. package/dist/{mode-ier8jbBk.mjs.map → mode-YhqNVef_.mjs.map} +1 -1
  65. package/dist/{options-BVp3UsTS.mjs → options-nPxWnrya.mjs} +1 -1
  66. package/dist/{options-BVp3UsTS.mjs.map → options-nPxWnrya.mjs.map} +1 -1
  67. package/dist/page/index.d.mts +2 -2
  68. package/dist/{patterns-CrCYkMBb.mjs → patterns-DsUZ4uxI.mjs} +1 -1
  69. package/dist/{patterns-CrCYkMBb.mjs.map → patterns-DsUZ4uxI.mjs.map} +1 -1
  70. package/dist/{placeholder-BE4o_2dc.d.mts → placeholder-CDPtkelt.d.mts} +1 -1
  71. package/dist/{placeholder-BE4o_2dc.d.mts.map → placeholder-CDPtkelt.d.mts.map} +1 -1
  72. package/dist/{placeholder-CIJejMlK.mjs → placeholder-Ci0RLeCk.mjs} +1 -1
  73. package/dist/{placeholder-CIJejMlK.mjs.map → placeholder-Ci0RLeCk.mjs.map} +1 -1
  74. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  75. package/dist/plugins/adapt-sandbox-entry.mjs +2 -2
  76. package/dist/{public-url-DByxYjUw.mjs → public-url-B1AxbbbQ.mjs} +1 -1
  77. package/dist/{public-url-DByxYjUw.mjs.map → public-url-B1AxbbbQ.mjs.map} +1 -1
  78. package/dist/{query-Cg9ZKRQ0.mjs → query-Bo-msrmu.mjs} +13 -13
  79. package/dist/{query-Cg9ZKRQ0.mjs.map → query-Bo-msrmu.mjs.map} +1 -1
  80. package/dist/{redirect-BhUBKRc1.mjs → redirect-C5H7VGIX.mjs} +3 -3
  81. package/dist/{redirect-BhUBKRc1.mjs.map → redirect-C5H7VGIX.mjs.map} +1 -1
  82. package/dist/{registry-Dw70ChxB.mjs → registry-Beb7wxFc.mjs} +5 -5
  83. package/dist/{registry-Dw70ChxB.mjs.map → registry-Beb7wxFc.mjs.map} +1 -1
  84. package/dist/{request-cache-B-bmkipQ.mjs → request-cache-C-tIpYIw.mjs} +1 -1
  85. package/dist/{request-cache-B-bmkipQ.mjs.map → request-cache-C-tIpYIw.mjs.map} +1 -1
  86. package/dist/{runner-Bnoj7vjK.d.mts → runner-Clwe4Mme.d.mts} +2 -2
  87. package/dist/{runner-Bnoj7vjK.d.mts.map → runner-Clwe4Mme.d.mts.map} +1 -1
  88. package/dist/{runner-C7ADox5q.mjs → runner-DMnlIkh4.mjs} +433 -138
  89. package/dist/runner-DMnlIkh4.mjs.map +1 -0
  90. package/dist/runtime.d.mts +6 -6
  91. package/dist/runtime.mjs +3 -3
  92. package/dist/{search-dOGEccMa.mjs → search-DkN-BqsS.mjs} +164 -92
  93. package/dist/search-DkN-BqsS.mjs.map +1 -0
  94. package/dist/{secrets-CW3reAnU.mjs → secrets-CZ8rxLX3.mjs} +3 -3
  95. package/dist/{secrets-CW3reAnU.mjs.map → secrets-CZ8rxLX3.mjs.map} +1 -1
  96. package/dist/seed/index.d.mts +2 -2
  97. package/dist/seed/index.mjs +15 -14
  98. package/dist/seo/index.d.mts +1 -1
  99. package/dist/storage/local.d.mts +1 -1
  100. package/dist/storage/local.mjs +1 -1
  101. package/dist/storage/s3.d.mts +1 -1
  102. package/dist/storage/s3.mjs +1 -1
  103. package/dist/taxonomies-CTtewrSQ.mjs +407 -0
  104. package/dist/taxonomies-CTtewrSQ.mjs.map +1 -0
  105. package/dist/taxonomy-DSxx2K2L.mjs +218 -0
  106. package/dist/taxonomy-DSxx2K2L.mjs.map +1 -0
  107. package/dist/{tokens-D7zMmWi2.mjs → tokens-CyRDPVW2.mjs} +2 -2
  108. package/dist/{tokens-D7zMmWi2.mjs.map → tokens-CyRDPVW2.mjs.map} +1 -1
  109. package/dist/{transaction-Cn2rjY78.mjs → transaction-D44LBXvU.mjs} +1 -1
  110. package/dist/{transaction-Cn2rjY78.mjs.map → transaction-D44LBXvU.mjs.map} +1 -1
  111. package/dist/{transport-DNEfeMaU.d.mts → transport-DX_5rpsq.d.mts} +1 -1
  112. package/dist/{transport-DNEfeMaU.d.mts.map → transport-DX_5rpsq.d.mts.map} +1 -1
  113. package/dist/{transport-BeMCmin1.mjs → transport-xpzIjCIB.mjs} +1 -1
  114. package/dist/{transport-BeMCmin1.mjs.map → transport-xpzIjCIB.mjs.map} +1 -1
  115. package/dist/{types-CRxNbK-Z.mjs → types-BIgulNsW.mjs} +2 -2
  116. package/dist/{types-CRxNbK-Z.mjs.map → types-BIgulNsW.mjs.map} +1 -1
  117. package/dist/{types-CJsYGpco.d.mts → types-B_CXXnzh.d.mts} +1 -1
  118. package/dist/{types-CJsYGpco.d.mts.map → types-B_CXXnzh.d.mts.map} +1 -1
  119. package/dist/{types-M78DQ1lx.d.mts → types-C-aFbqmA.d.mts} +1 -1
  120. package/dist/{types-M78DQ1lx.d.mts.map → types-C-aFbqmA.d.mts.map} +1 -1
  121. package/dist/{types-4fVtCIm0.mjs → types-CoO6mpV3.mjs} +1 -1
  122. package/dist/{types-4fVtCIm0.mjs.map → types-CoO6mpV3.mjs.map} +1 -1
  123. package/dist/{types-BuBIptGk.d.mts → types-D19uBYWn.d.mts} +149 -4
  124. package/dist/types-D19uBYWn.d.mts.map +1 -0
  125. package/dist/{types-BSyXeCFW.d.mts → types-Dl1fgFjn.d.mts} +1 -1
  126. package/dist/{types-BSyXeCFW.d.mts.map → types-Dl1fgFjn.d.mts.map} +1 -1
  127. package/dist/{types-CrtWgIvl.d.mts → types-Dtx1mSMX.d.mts} +9 -1
  128. package/dist/types-Dtx1mSMX.d.mts.map +1 -0
  129. package/dist/{types-CIOg5AR8.mjs → types-Eg829jj9.mjs} +1 -1
  130. package/dist/{types-CIOg5AR8.mjs.map → types-Eg829jj9.mjs.map} +1 -1
  131. package/dist/{types-CDbKp7ND.mjs → types-K-EkEQCI.mjs} +1 -1
  132. package/dist/{types-CDbKp7ND.mjs.map → types-K-EkEQCI.mjs.map} +1 -1
  133. package/dist/{validate-Baqf0slj.mjs → validate-CBIbxM3L.mjs} +14 -10
  134. package/dist/validate-CBIbxM3L.mjs.map +1 -0
  135. package/dist/{validate-BfQh_C_y.d.mts → validate-DHGwADqO.d.mts} +18 -5
  136. package/dist/validate-DHGwADqO.d.mts.map +1 -0
  137. package/dist/{validation-BfEI7tNe.mjs → validation-B1NYiEos.mjs} +5 -5
  138. package/dist/{validation-BfEI7tNe.mjs.map → validation-B1NYiEos.mjs.map} +1 -1
  139. package/dist/version-CMD42IRC.mjs +7 -0
  140. package/dist/{version-DoxrVdYf.mjs.map → version-CMD42IRC.mjs.map} +1 -1
  141. package/dist/{zod-generator-CC0xNe_K.mjs → zod-generator-BNJDQBSZ.mjs} +8 -3
  142. package/dist/zod-generator-BNJDQBSZ.mjs.map +1 -0
  143. package/package.json +6 -6
  144. package/src/api/handlers/content.ts +11 -0
  145. package/src/api/handlers/dashboard.ts +29 -36
  146. package/src/api/handlers/menus.ts +256 -75
  147. package/src/api/handlers/taxonomies.ts +273 -97
  148. package/src/api/schemas/common.ts +7 -0
  149. package/src/api/schemas/menus.ts +23 -0
  150. package/src/api/schemas/taxonomies.ts +39 -0
  151. package/src/astro/integration/routes.ts +10 -0
  152. package/src/astro/routes/api/content/[collection]/[id]/permanent.ts +1 -1
  153. package/src/astro/routes/api/import/wordpress/rewrite-url-helpers.ts +196 -0
  154. package/src/astro/routes/api/import/wordpress/rewrite-urls.ts +9 -177
  155. package/src/astro/routes/api/menus/[name]/items.ts +16 -6
  156. package/src/astro/routes/api/menus/[name]/reorder.ts +8 -3
  157. package/src/astro/routes/api/menus/[name]/translations.ts +82 -0
  158. package/src/astro/routes/api/menus/[name].ts +19 -10
  159. package/src/astro/routes/api/menus/index.ts +9 -6
  160. package/src/astro/routes/api/taxonomies/[name]/terms/[slug]/translations.ts +89 -0
  161. package/src/astro/routes/api/taxonomies/[name]/terms/[slug].ts +22 -22
  162. package/src/astro/routes/api/taxonomies/[name]/terms/index.ts +11 -14
  163. package/src/astro/routes/api/taxonomies/index.ts +9 -6
  164. package/src/cli/commands/export-seed.ts +82 -21
  165. package/src/cli/commands/plugin-init.ts +216 -90
  166. package/src/database/migrations/036_i18n_menus_and_taxonomies.ts +477 -0
  167. package/src/database/migrations/runner.ts +2 -0
  168. package/src/database/repositories/content.ts +11 -0
  169. package/src/database/repositories/taxonomy.ts +193 -89
  170. package/src/database/types.ts +10 -2
  171. package/src/i18n/resolve.ts +37 -0
  172. package/src/loader.ts +49 -2
  173. package/src/mcp/server.ts +77 -18
  174. package/src/menus/index.ts +143 -124
  175. package/src/menus/types.ts +15 -1
  176. package/src/schema/zod-generator.ts +12 -2
  177. package/src/seed/apply.ts +140 -54
  178. package/src/seed/types.ts +14 -1
  179. package/src/seed/validate.ts +27 -13
  180. package/src/taxonomies/index.ts +230 -213
  181. package/src/taxonomies/types.ts +10 -0
  182. package/dist/apply-BzltprvY.mjs.map +0 -1
  183. package/dist/content-8lOYF0pr.mjs.map +0 -1
  184. package/dist/index-BFRaVcD6.d.mts.map +0 -1
  185. package/dist/loader-CKLbBnhK.mjs.map +0 -1
  186. package/dist/runner-C7ADox5q.mjs.map +0 -1
  187. package/dist/search-dOGEccMa.mjs.map +0 -1
  188. package/dist/taxonomies-ZlRtD6AG.mjs +0 -315
  189. package/dist/taxonomies-ZlRtD6AG.mjs.map +0 -1
  190. package/dist/types-BuBIptGk.d.mts.map +0 -1
  191. package/dist/types-CrtWgIvl.d.mts.map +0 -1
  192. package/dist/validate-Baqf0slj.mjs.map +0 -1
  193. package/dist/validate-BfQh_C_y.d.mts.map +0 -1
  194. package/dist/version-DoxrVdYf.mjs +0 -7
  195. package/dist/zod-generator-CC0xNe_K.mjs.map +0 -1
@@ -1,115 +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
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.
48
51
  *
49
52
  * If `getTaxonomyDefs()` has already loaded the full list in this request
50
53
  * (which happens during entry-term hydration on every page that renders a
51
- * collection), find the matching def in memory rather than running a
52
- * second `WHERE name=?` query against `_emdash_taxonomy_defs`.
54
+ * collection), search the matching def in memory rather than running a
55
+ * second query against `_emdash_taxonomy_defs`.
53
56
  */
54
- export async function getTaxonomyDef(name: string): Promise<TaxonomyDef | null> {
55
- const allDefs = peekRequestCache<TaxonomyDef[]>("taxonomy-defs:all");
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);
56
64
  if (allDefs) {
57
- return (await allDefs).find((d) => d.name === name) ?? null;
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;
58
72
  }
59
73
 
60
- return requestCached(`taxonomy-def:${name}`, async () => {
74
+ return requestCached(`taxonomy-def:${name}:${chain.join(",")}`, async () => {
61
75
  const db = await getDb();
62
76
 
63
- const row = await db
64
- .selectFrom("_emdash_taxonomy_defs")
65
- .selectAll()
66
- .where("name", "=", name)
67
- .executeTakeFirst();
68
-
69
- 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
+ }
70
86
 
71
- return {
72
- id: row.id,
73
- name: row.name,
74
- label: row.label,
75
- labelSingular: row.label_singular ?? undefined,
76
- hierarchical: row.hierarchical === 1,
77
- collections: row.collections ? JSON.parse(row.collections) : [],
78
- };
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;
79
97
  });
80
98
  }
81
99
 
82
100
  /**
83
- * 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).
84
103
  */
85
- export async function getTaxonomyTerms(taxonomyName: string): Promise<TaxonomyTerm[]> {
86
- 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 () => {
87
110
  const db = await getDb();
88
111
 
89
- // Get taxonomy definition to check if hierarchical
90
- const def = await getTaxonomyDef(taxonomyName);
112
+ const def = await getTaxonomyDef(taxonomyName, options);
91
113
  if (!def) return [];
92
114
 
93
- // Get all terms for this taxonomy
94
- const rows = await db
115
+ let termsQuery = db
95
116
  .selectFrom("taxonomies")
96
117
  .selectAll()
97
118
  .where("name", "=", taxonomyName)
98
- .orderBy("label", "asc")
99
- .execute();
119
+ .orderBy("label", "asc");
120
+ if (locale !== undefined) termsQuery = termsQuery.where("locale", "=", locale);
121
+ const rows = await termsQuery.execute();
100
122
 
101
- // Count entries for each term
123
+ // Counts are keyed by translation_group (what the pivot stores).
102
124
  const countsResult = await db
103
125
  .selectFrom("content_taxonomies")
104
126
  .select(["taxonomy_id"])
105
127
  .select((eb) => eb.fn.count<number>("entry_id").as("count"))
106
128
  .groupBy("taxonomy_id")
107
129
  .execute();
108
-
109
130
  const counts = new Map<string, number>();
110
- for (const row of countsResult) {
111
- counts.set(row.taxonomy_id, row.count);
112
- }
131
+ for (const row of countsResult) counts.set(row.taxonomy_id, row.count);
113
132
 
114
133
  const flatTerms: TaxonomyTermRow[] = rows.map((row) => ({
115
134
  id: row.id,
@@ -118,12 +137,11 @@ export async function getTaxonomyTerms(taxonomyName: string): Promise<TaxonomyTe
118
137
  label: row.label,
119
138
  parent_id: row.parent_id,
120
139
  data: row.data,
140
+ locale: row.locale,
141
+ translation_group: row.translation_group,
121
142
  }));
122
143
 
123
- // If hierarchical, build tree. Otherwise return flat
124
- if (def.hierarchical) {
125
- return buildTree(flatTerms, counts);
126
- }
144
+ if (def.hierarchical) return buildTree(flatTerms, counts);
127
145
 
128
146
  return flatTerms.map((term) => ({
129
147
  id: term.id,
@@ -131,50 +149,71 @@ export async function getTaxonomyTerms(taxonomyName: string): Promise<TaxonomyTe
131
149
  slug: term.slug,
132
150
  label: term.label,
133
151
  children: [],
134
- 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,
135
155
  }));
136
156
  });
137
157
  }
138
158
 
139
159
  /**
140
- * 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).
141
163
  */
142
- 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> {
143
169
  const db = await getDb();
170
+ const chain = resolveLocaleChain(options.locale);
144
171
 
145
- const row = await db
146
- .selectFrom("taxonomies")
147
- .selectAll()
148
- .where("name", "=", taxonomyName)
149
- .where("slug", "=", slug)
150
- .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
+ }
151
189
 
152
190
  if (!row) return null;
153
191
 
154
- // Get entry count
155
192
  const countResult = await db
156
193
  .selectFrom("content_taxonomies")
157
194
  .select((eb) => eb.fn.count<number>("entry_id").as("count"))
158
- .where("taxonomy_id", "=", row.id)
195
+ .where("taxonomy_id", "=", row.translation_group ?? row.id)
159
196
  .executeTakeFirst();
160
-
161
197
  const count = countResult?.count ?? 0;
162
198
 
163
- // Get children if hierarchical
164
- const childRows = await db
199
+ let childrenQuery = db
165
200
  .selectFrom("taxonomies")
166
201
  .selectAll()
167
202
  .where("parent_id", "=", row.id)
168
- .orderBy("label", "asc")
169
- .execute();
203
+ .orderBy("label", "asc");
204
+ const termLocale = row.locale;
205
+ if (termLocale) childrenQuery = childrenQuery.where("locale", "=", termLocale);
206
+ const childRows = await childrenQuery.execute();
170
207
 
171
- const children = childRows.map((child) => ({
208
+ const children = childRows.map<TaxonomyTerm>((child) => ({
172
209
  id: child.id,
173
210
  name: child.name,
174
211
  slug: child.slug,
175
212
  label: child.label,
176
213
  parentId: child.parent_id ?? undefined,
177
214
  children: [],
215
+ locale: child.locale,
216
+ translationGroup: child.translation_group,
178
217
  }));
179
218
 
180
219
  return {
@@ -186,89 +225,75 @@ export async function getTerm(taxonomyName: string, slug: string): Promise<Taxon
186
225
  description: row.data ? JSON.parse(row.data).description : undefined,
187
226
  children,
188
227
  count,
228
+ locale: row.locale,
229
+ translationGroup: row.translation_group,
189
230
  };
190
231
  }
191
232
 
192
233
  /**
193
- * 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.
194
236
  */
195
237
  export function getEntryTerms(
196
238
  collection: string,
197
239
  entryId: string,
198
240
  taxonomyName?: string,
241
+ options: TaxonomyQueryOptions = {},
199
242
  ): Promise<TaxonomyTerm[]> {
200
- return requestCached(`terms:${collection}:${entryId}:${taxonomyName ?? "*"}`, async () => {
201
- const db = await getDb();
202
-
203
- let query = db
204
- .selectFrom("content_taxonomies")
205
- .innerJoin("taxonomies", "taxonomies.id", "content_taxonomies.taxonomy_id")
206
- .selectAll("taxonomies")
207
- .where("content_taxonomies.collection", "=", collection)
208
- .where("content_taxonomies.entry_id", "=", entryId);
243
+ const locale = resolveLocale(options.locale);
244
+ return requestCached(
245
+ `terms:${collection}:${entryId}:${taxonomyName ?? "*"}:${locale ?? "*"}`,
246
+ async () => {
247
+ const db = await getDb();
209
248
 
210
- if (taxonomyName) {
211
- query = query.where("taxonomies.name", "=", taxonomyName);
212
- }
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);
213
255
 
214
- const rows = await query.execute();
256
+ if (taxonomyName) query = query.where("taxonomies.name", "=", taxonomyName);
257
+ if (locale !== undefined) query = query.where("taxonomies.locale", "=", locale);
215
258
 
216
- return rows.map((row) => ({
217
- id: row.id,
218
- name: row.name,
219
- slug: row.slug,
220
- label: row.label,
221
- parentId: row.parent_id ?? undefined,
222
- children: [],
223
- }));
224
- });
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
+ );
225
272
  }
226
273
 
227
274
  /**
228
- * Get terms for multiple entries in a single query (batched API)
229
- *
230
- * This is more efficient than calling getEntryTerms for each entry
231
- * when you need terms for a list of entries.
232
- *
233
- * @param collection - The collection type (e.g., "posts")
234
- * @param entryIds - Array of entry IDs
235
- * @param taxonomyName - The taxonomy name (e.g., "categories")
236
- * @returns Map from entry ID to array of terms
275
+ * Terms for multiple entries of one taxonomy, single query.
237
276
  */
238
277
  export async function getTermsForEntries(
239
278
  collection: string,
240
279
  entryIds: string[],
241
280
  taxonomyName: string,
281
+ options: TaxonomyQueryOptions = {},
242
282
  ): Promise<Map<string, TaxonomyTerm[]>> {
243
283
  const result = new Map<string, TaxonomyTerm[]>();
244
-
245
- // Initialize all entry IDs with empty arrays so callers can always
246
- // expect the key to be present.
247
284
  const uniqueIds = [...new Set(entryIds)];
248
- for (const id of uniqueIds) {
249
- result.set(id, []);
250
- }
251
-
252
- if (uniqueIds.length === 0) {
253
- return result;
254
- }
285
+ for (const id of uniqueIds) result.set(id, []);
286
+ if (uniqueIds.length === 0) return result;
255
287
 
256
288
  const db = await getDb();
289
+ const locale = resolveLocale(options.locale);
257
290
 
258
- // Chunk the IN clause so we stay below D1's ~100 bound-parameter limit
259
- // (and equivalent limits on other dialects). Matches getContentBylinesMany.
260
- //
261
- // Sites with no term assignments get back empty rows for one query —
262
- // the previous "has any term assignments" probe spent a round-trip on
263
- // every request to save that single query on empty sites, which is
264
- // backwards. Pre-migration databases (content_taxonomies missing) fall
265
- // through to the `isMissingTableError` catch and return empties.
266
291
  for (const chunk of chunks(uniqueIds, SQL_BATCH_SIZE)) {
267
292
  let rows;
268
293
  try {
269
- rows = await db
294
+ let query = db
270
295
  .selectFrom("content_taxonomies")
271
- .innerJoin("taxonomies", "taxonomies.id", "content_taxonomies.taxonomy_id")
296
+ .innerJoin("taxonomies", "taxonomies.translation_group", "content_taxonomies.taxonomy_id")
272
297
  .select([
273
298
  "content_taxonomies.entry_id",
274
299
  "taxonomies.id",
@@ -276,18 +301,20 @@ export async function getTermsForEntries(
276
301
  "taxonomies.slug",
277
302
  "taxonomies.label",
278
303
  "taxonomies.parent_id",
304
+ "taxonomies.locale",
305
+ "taxonomies.translation_group",
279
306
  ])
280
307
  .where("content_taxonomies.collection", "=", collection)
281
308
  .where("content_taxonomies.entry_id", "in", chunk)
282
- .where("taxonomies.name", "=", taxonomyName)
283
- .execute();
309
+ .where("taxonomies.name", "=", taxonomyName);
310
+ if (locale !== undefined) query = query.where("taxonomies.locale", "=", locale);
311
+ rows = await query.execute();
284
312
  } catch (error) {
285
313
  if (isMissingTableError(error)) return result;
286
314
  throw error;
287
315
  }
288
316
 
289
317
  for (const row of rows) {
290
- const entryId = row.entry_id;
291
318
  const term: TaxonomyTerm = {
292
319
  id: row.id,
293
320
  name: row.name,
@@ -295,12 +322,11 @@ export async function getTermsForEntries(
295
322
  label: row.label,
296
323
  parentId: row.parent_id ?? undefined,
297
324
  children: [],
325
+ locale: row.locale,
326
+ translationGroup: row.translation_group,
298
327
  };
299
-
300
- const terms = result.get(entryId);
301
- if (terms) {
302
- terms.push(term);
303
- }
328
+ const terms = result.get(row.entry_id);
329
+ if (terms) terms.push(term);
304
330
  }
305
331
  }
306
332
 
@@ -308,57 +334,29 @@ export async function getTermsForEntries(
308
334
  }
309
335
 
310
336
  /**
311
- * Batch-fetch terms for multiple entries across ALL taxonomies in a single query.
312
- *
313
- * Returns a Map keyed by entry ID, where each value is a Record keyed by
314
- * taxonomy name with the matching terms as an array. Used by
315
- * getEmDashCollection to eagerly hydrate `entry.data.terms` and avoid
316
- * the N+1 pattern that callers hit when they loop and call getEntryTerms.
317
- *
318
- * Pre-migration databases (content_taxonomies missing) return an empty
319
- * 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`.
320
339
  */
321
340
  export async function getAllTermsForEntries(
322
341
  collection: string,
323
342
  entryIds: string[],
343
+ options: TaxonomyQueryOptions = {},
324
344
  ): Promise<Map<string, Record<string, TaxonomyTerm[]>>> {
325
345
  const result = new Map<string, Record<string, TaxonomyTerm[]>>();
326
-
327
- // Initialize unique entry IDs with empty objects so callers can always
328
- // expect the key to be present. Deduping also reduces wasted bound
329
- // parameters when a caller accidentally passes duplicates.
330
346
  const uniqueIds = [...new Set(entryIds)];
331
- for (const id of uniqueIds) {
332
- result.set(id, {});
333
- }
334
-
335
- if (uniqueIds.length === 0) {
336
- return result;
337
- }
347
+ for (const id of uniqueIds) result.set(id, {});
348
+ if (uniqueIds.length === 0) return result;
338
349
 
339
350
  const db = await getDb();
351
+ const locale = resolveLocale(options.locale);
352
+ const applicableTaxonomyNames = await getCollectionTaxonomyNames(collection, { locale });
340
353
 
341
- // Look up which taxonomies apply to this collection. Used below to
342
- // seed empty arrays for taxonomies the entry has no terms in — so
343
- // callers (including the pre-populated getEntryTerms cache) get a
344
- // deterministic `[]` back rather than a cache miss that triggers a DB
345
- // round-trip just to confirm "no terms".
346
- const applicableTaxonomyNames = await getCollectionTaxonomyNames(collection);
347
-
348
- // Chunk the IN clause to stay below D1's ~100 bound-parameter limit
349
- // (and equivalent limits on other dialects). Matches getContentBylinesMany.
350
- //
351
- // Previously we did a separate "has any assignments" probe to skip the
352
- // join on empty sites. That traded one query per request for a query
353
- // saved only on empty sites — backwards. Now the join runs directly
354
- // (returning zero rows cheaply) and pre-migration databases are caught
355
- // by the `isMissingTableError` branch below.
356
354
  for (const chunk of chunks(uniqueIds, SQL_BATCH_SIZE)) {
357
355
  let rows;
358
356
  try {
359
- rows = await db
357
+ let query = db
360
358
  .selectFrom("content_taxonomies")
361
- .innerJoin("taxonomies", "taxonomies.id", "content_taxonomies.taxonomy_id")
359
+ .innerJoin("taxonomies", "taxonomies.translation_group", "content_taxonomies.taxonomy_id")
362
360
  .select([
363
361
  "content_taxonomies.entry_id",
364
362
  "taxonomies.id",
@@ -366,15 +364,18 @@ export async function getAllTermsForEntries(
366
364
  "taxonomies.slug",
367
365
  "taxonomies.label",
368
366
  "taxonomies.parent_id",
367
+ "taxonomies.locale",
368
+ "taxonomies.translation_group",
369
369
  ])
370
370
  .where("content_taxonomies.collection", "=", collection)
371
371
  .where("content_taxonomies.entry_id", "in", chunk)
372
- .orderBy("taxonomies.label", "asc")
373
- .execute();
372
+ .orderBy("taxonomies.label", "asc");
373
+ if (locale !== undefined) query = query.where("taxonomies.locale", "=", locale);
374
+ rows = await query.execute();
374
375
  } catch (error) {
375
376
  if (isMissingTableError(error)) {
376
377
  for (const id of uniqueIds) {
377
- primeEntryTermsCache(collection, id, {}, applicableTaxonomyNames);
378
+ primeEntryTermsCache(collection, id, {}, applicableTaxonomyNames, locale);
378
379
  }
379
380
  return result;
380
381
  }
@@ -382,7 +383,6 @@ export async function getAllTermsForEntries(
382
383
  }
383
384
 
384
385
  for (const row of rows) {
385
- const entryId = row.entry_id;
386
386
  const term: TaxonomyTerm = {
387
387
  id: row.id,
388
388
  name: row.name,
@@ -390,25 +390,19 @@ export async function getAllTermsForEntries(
390
390
  label: row.label,
391
391
  parentId: row.parent_id ?? undefined,
392
392
  children: [],
393
+ locale: row.locale,
394
+ translationGroup: row.translation_group,
393
395
  };
394
-
395
- const byTaxonomy = result.get(entryId);
396
+ const byTaxonomy = result.get(row.entry_id);
396
397
  if (!byTaxonomy) continue;
397
398
  const existing = byTaxonomy[row.name];
398
- if (existing) {
399
- existing.push(term);
400
- } else {
401
- byTaxonomy[row.name] = [term];
402
- }
399
+ if (existing) existing.push(term);
400
+ else byTaxonomy[row.name] = [term];
403
401
  }
404
402
  }
405
403
 
406
- // Prime the request-scoped cache so legacy callers of getEntryTerms
407
- // (which still work per-entry) hit the in-memory cache instead of
408
- // re-querying. This is what gives us the N+1 win in existing templates
409
- // without requiring them to be rewritten.
410
404
  for (const [entryId, byTaxonomy] of result) {
411
- primeEntryTermsCache(collection, entryId, byTaxonomy, applicableTaxonomyNames);
405
+ primeEntryTermsCache(collection, entryId, byTaxonomy, applicableTaxonomyNames, locale);
412
406
  }
413
407
 
414
408
  return result;
@@ -420,9 +414,12 @@ export async function getAllTermsForEntries(
420
414
  *
421
415
  * Returns an empty list when taxonomies haven't been defined yet.
422
416
  */
423
- async function getCollectionTaxonomyNames(collection: string): Promise<string[]> {
417
+ async function getCollectionTaxonomyNames(
418
+ collection: string,
419
+ options: TaxonomyQueryOptions,
420
+ ): Promise<string[]> {
424
421
  try {
425
- const defs = await getTaxonomyDefs();
422
+ const defs = await getTaxonomyDefs(options);
426
423
  return defs.filter((d) => d.collections.includes(collection)).map((d) => d.name);
427
424
  } catch (error) {
428
425
  if (isMissingTableError(error)) return [];
@@ -447,44 +444,64 @@ function primeEntryTermsCache(
447
444
  entryId: string,
448
445
  byTaxonomy: Record<string, TaxonomyTerm[]>,
449
446
  applicableTaxonomyNames: string[],
447
+ locale: string | undefined,
450
448
  ): void {
451
- // Seed every applicable taxonomy with at least [] so
452
- // getEntryTerms(collection, id, "tag") doesn't miss the cache when an
453
- // entry has no tags.
449
+ const localeKey = locale ?? "*";
454
450
  for (const name of applicableTaxonomyNames) {
455
- setRequestCacheEntry(`terms:${collection}:${entryId}:${name}`, byTaxonomy[name] ?? []);
451
+ setRequestCacheEntry(
452
+ `terms:${collection}:${entryId}:${name}:${localeKey}`,
453
+ byTaxonomy[name] ?? [],
454
+ );
456
455
  }
457
- // Also seed individual names that show up in data but aren't listed
458
- // as applicable (e.g. taxonomy reassigned to a different collection
459
- // since the terms were written).
460
456
  for (const [name, terms] of Object.entries(byTaxonomy)) {
461
- setRequestCacheEntry(`terms:${collection}:${entryId}:${name}`, terms);
457
+ setRequestCacheEntry(`terms:${collection}:${entryId}:${name}:${localeKey}`, terms);
462
458
  }
463
- // Flattened `*` view — all terms across all taxonomies in one array.
464
459
  const allTerms = Object.values(byTaxonomy).flat();
465
- setRequestCacheEntry(`terms:${collection}:${entryId}:*`, allTerms);
460
+ setRequestCacheEntry(`terms:${collection}:${entryId}:*:${localeKey}`, allTerms);
466
461
  }
467
462
 
468
463
  /**
469
- * 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.
470
466
  */
471
467
  export async function getEntriesByTerm(
472
468
  collection: string,
473
469
  taxonomyName: string,
474
470
  termSlug: string,
471
+ options: TaxonomyQueryOptions = {},
475
472
  ): Promise<Array<{ id: string; data: Record<string, unknown> }>> {
476
473
  const { getEmDashCollection } = await import("../query.js");
477
474
 
478
- // Build options as the expected type — getEmDashCollection accepts
479
- // a generic options object with `where` for filtering by taxonomy
480
- const options: Record<string, unknown> = {
475
+ const queryOptions: Record<string, unknown> = {
481
476
  where: { [taxonomyName]: termSlug },
482
477
  };
483
- const { entries } = await getEmDashCollection(collection, options);
484
-
478
+ if (options.locale !== undefined) queryOptions.locale = options.locale;
479
+ const { entries } = await getEmDashCollection(collection, queryOptions);
485
480
  return entries;
486
481
  }
487
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
+
488
505
  /**
489
506
  * Build tree structure from flat terms
490
507
  */
@@ -492,7 +509,6 @@ function buildTree(flatTerms: TaxonomyTermRow[], counts: Map<string, number>): T
492
509
  const map = new Map<string, TaxonomyTerm>();
493
510
  const roots: TaxonomyTerm[] = [];
494
511
 
495
- // First pass: create nodes
496
512
  for (const term of flatTerms) {
497
513
  map.set(term.id, {
498
514
  id: term.id,
@@ -502,11 +518,12 @@ function buildTree(flatTerms: TaxonomyTermRow[], counts: Map<string, number>): T
502
518
  parentId: term.parent_id ?? undefined,
503
519
  description: term.data ? JSON.parse(term.data).description : undefined,
504
520
  children: [],
505
- 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,
506
524
  });
507
525
  }
508
526
 
509
- // Second pass: build tree
510
527
  for (const term of map.values()) {
511
528
  if (term.parentId && map.has(term.parentId)) {
512
529
  map.get(term.parentId)!.children.push(term);