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,16 +1,20 @@
1
1
  /**
2
- * Taxonomy and term CRUD handlers
2
+ * Taxonomy and term CRUD handlers.
3
+ *
4
+ * i18n: terms and defs are per-locale. `(name, slug, locale)` is unique for
5
+ * terms; `(name, locale)` for defs. Translations of the same term/def share a
6
+ * `translation_group`. The content_taxonomies pivot stores translation_groups
7
+ * so assignments span every locale of a post.
3
8
  */
4
9
 
5
- import type { Kysely } from "kysely";
10
+ import type { Kysely, Selectable } from "kysely";
6
11
  import { ulid } from "ulidx";
7
12
 
8
13
  import { TaxonomyRepository } from "../../database/repositories/taxonomy.js";
9
- import type { Database } from "../../database/types.js";
14
+ import type { Database, TaxonomyDefTable } from "../../database/types.js";
10
15
  import { invalidateTermCache } from "../../taxonomies/index.js";
11
16
  import type { ApiResult } from "../types.js";
12
17
 
13
- /** Taxonomy name validation pattern: lowercase alphanumeric + underscores, starts with letter */
14
18
  const NAME_PATTERN = /^[a-z][a-z0-9_]*$/;
15
19
 
16
20
  // ---------------------------------------------------------------------------
@@ -24,6 +28,8 @@ export interface TaxonomyDef {
24
28
  labelSingular?: string;
25
29
  hierarchical: boolean;
26
30
  collections: string[];
31
+ locale: string;
32
+ translationGroup: string | null;
27
33
  }
28
34
 
29
35
  export interface TaxonomyListResponse {
@@ -37,6 +43,8 @@ export interface TermData {
37
43
  label: string;
38
44
  parentId: string | null;
39
45
  description?: string;
46
+ locale: string;
47
+ translationGroup: string | null;
40
48
  }
41
49
 
42
50
  export interface TermWithCount extends TermData {
@@ -59,6 +67,26 @@ export interface TermGetResponse {
59
67
  };
60
68
  }
61
69
 
70
+ export interface TermTranslationsResponse {
71
+ translationGroup: string | null;
72
+ translations: Array<{
73
+ id: string;
74
+ slug: string;
75
+ label: string;
76
+ locale: string;
77
+ }>;
78
+ }
79
+
80
+ export interface TaxonomyDefTranslationsResponse {
81
+ translationGroup: string | null;
82
+ translations: Array<{
83
+ id: string;
84
+ name: string;
85
+ label: string;
86
+ locale: string;
87
+ }>;
88
+ }
89
+
62
90
  // ---------------------------------------------------------------------------
63
91
  // Helpers
64
92
  // ---------------------------------------------------------------------------
@@ -69,11 +97,7 @@ export interface TermGetResponse {
69
97
  function buildTree(flatTerms: TermWithCount[]): TermWithCount[] {
70
98
  const map = new Map<string, TermWithCount>();
71
99
  const roots: TermWithCount[] = [];
72
-
73
- for (const term of flatTerms) {
74
- map.set(term.id, term);
75
- }
76
-
100
+ for (const term of flatTerms) map.set(term.id, term);
77
101
  for (const term of flatTerms) {
78
102
  if (term.parentId && map.has(term.parentId)) {
79
103
  map.get(term.parentId)!.children.push(term);
@@ -81,38 +105,48 @@ function buildTree(flatTerms: TermWithCount[]): TermWithCount[] {
81
105
  roots.push(term);
82
106
  }
83
107
  }
84
-
85
108
  return roots;
86
109
  }
87
110
 
88
111
  /**
89
- * Look up a taxonomy definition by name, returning a NOT_FOUND error if missing.
112
+ * Look up a taxonomy definition by name (optionally scoped to a locale).
113
+ * Returns the lowest-locale match when no locale is provided.
90
114
  */
91
115
  async function requireTaxonomyDef(
92
116
  db: Kysely<Database>,
93
117
  name: string,
118
+ locale?: string,
94
119
  ): Promise<
95
- | { success: true; def: { hierarchical: number } }
120
+ | { success: true; def: Selectable<TaxonomyDefTable> }
96
121
  | { success: false; error: { code: string; message: string } }
97
122
  > {
98
- const def = await db
99
- .selectFrom("_emdash_taxonomy_defs")
100
- .selectAll()
101
- .where("name", "=", name)
102
- .executeTakeFirst();
103
-
123
+ let query = db.selectFrom("_emdash_taxonomy_defs").selectAll().where("name", "=", name);
124
+ if (locale !== undefined) query = query.where("locale", "=", locale);
125
+ const def = await query.orderBy("locale", "asc").executeTakeFirst();
104
126
  if (!def) {
105
127
  return {
106
128
  success: false,
107
129
  error: { code: "NOT_FOUND", message: `Taxonomy '${name}' not found` },
108
130
  };
109
131
  }
110
-
111
132
  return { success: true, def };
112
133
  }
113
134
 
135
+ function rowToDef(row: Selectable<TaxonomyDefTable>): TaxonomyDef {
136
+ return {
137
+ id: row.id,
138
+ name: row.name,
139
+ label: row.label,
140
+ labelSingular: row.label_singular ?? undefined,
141
+ hierarchical: row.hierarchical === 1,
142
+ collections: row.collections ? JSON.parse(row.collections) : [],
143
+ locale: row.locale,
144
+ translationGroup: row.translation_group,
145
+ };
146
+ }
147
+
114
148
  // ---------------------------------------------------------------------------
115
- // Handlers
149
+ // Taxonomy definition handlers
116
150
  // ---------------------------------------------------------------------------
117
151
 
118
152
  /**
@@ -120,10 +154,13 @@ async function requireTaxonomyDef(
120
154
  */
121
155
  export async function handleTaxonomyList(
122
156
  db: Kysely<Database>,
157
+ options: { locale?: string } = {},
123
158
  ): Promise<ApiResult<TaxonomyListResponse>> {
124
159
  try {
160
+ let query = db.selectFrom("_emdash_taxonomy_defs").selectAll();
161
+ if (options.locale !== undefined) query = query.where("locale", "=", options.locale);
125
162
  const [rows, collectionRows] = await Promise.all([
126
- db.selectFrom("_emdash_taxonomy_defs").selectAll().execute(),
163
+ query.execute(),
127
164
  db.selectFrom("_emdash_collections").select("slug").execute(),
128
165
  ]);
129
166
 
@@ -133,15 +170,8 @@ export async function handleTaxonomyList(
133
170
  const realCollections = new Set(collectionRows.map((r) => r.slug));
134
171
 
135
172
  const taxonomies: TaxonomyDef[] = rows.map((row) => {
136
- const stored: string[] = row.collections ? JSON.parse(row.collections) : [];
137
- return {
138
- id: row.id,
139
- name: row.name,
140
- label: row.label,
141
- labelSingular: row.label_singular ?? undefined,
142
- hierarchical: row.hierarchical === 1,
143
- collections: stored.filter((slug) => realCollections.has(slug)),
144
- };
173
+ const def = rowToDef(row);
174
+ return { ...def, collections: def.collections.filter((slug) => realCollections.has(slug)) };
145
175
  });
146
176
 
147
177
  return { success: true, data: { taxonomies } };
@@ -158,10 +188,17 @@ export async function handleTaxonomyList(
158
188
  */
159
189
  export async function handleTaxonomyCreate(
160
190
  db: Kysely<Database>,
161
- input: { name: string; label: string; hierarchical?: boolean; collections?: string[] },
191
+ input: {
192
+ name: string;
193
+ label: string;
194
+ labelSingular?: string;
195
+ hierarchical?: boolean;
196
+ collections?: string[];
197
+ locale?: string;
198
+ translationOf?: string;
199
+ },
162
200
  ): Promise<ApiResult<{ taxonomy: TaxonomyDef }>> {
163
201
  try {
164
- // Validate name format
165
202
  if (!NAME_PATTERN.test(input.name)) {
166
203
  return {
167
204
  success: false,
@@ -174,15 +211,12 @@ export async function handleTaxonomyCreate(
174
211
  }
175
212
 
176
213
  const collections = [...new Set(input.collections ?? [])];
177
-
178
- // Validate that referenced collections exist
179
214
  if (collections.length > 0) {
180
215
  const existingCollections = await db
181
216
  .selectFrom("_emdash_collections")
182
217
  .select("slug")
183
218
  .where("slug", "in", collections)
184
219
  .execute();
185
-
186
220
  const existingSlugs = new Set(existingCollections.map((c) => c.slug));
187
221
  const invalid = collections.filter((c) => !existingSlugs.has(c));
188
222
  if (invalid.length > 0) {
@@ -196,58 +230,68 @@ export async function handleTaxonomyCreate(
196
230
  }
197
231
  }
198
232
 
199
- // Check for duplicate name
200
- const existing = await db
201
- .selectFrom("_emdash_taxonomy_defs")
202
- .selectAll()
203
- .where("name", "=", input.name)
204
- .executeTakeFirst();
233
+ let translationGroup: string | null = null;
234
+ if (input.translationOf) {
235
+ const source = await db
236
+ .selectFrom("_emdash_taxonomy_defs")
237
+ .selectAll()
238
+ .where("id", "=", input.translationOf)
239
+ .executeTakeFirst();
240
+ if (!source) {
241
+ return {
242
+ success: false,
243
+ error: { code: "NOT_FOUND", message: "Source taxonomy for translation not found" },
244
+ };
245
+ }
246
+ translationGroup = source.translation_group ?? source.id;
247
+ }
205
248
 
206
- if (existing) {
207
- return {
208
- success: false,
209
- error: {
210
- code: "CONFLICT",
211
- message: `Taxonomy '${input.name}' already exists`,
212
- },
213
- };
249
+ // Duplicate guard scoped to locale (so the same name can exist in ES
250
+ // and EN).
251
+ if (input.locale !== undefined) {
252
+ const existing = await db
253
+ .selectFrom("_emdash_taxonomy_defs")
254
+ .select("id")
255
+ .where("name", "=", input.name)
256
+ .where("locale", "=", input.locale)
257
+ .executeTakeFirst();
258
+ if (existing) {
259
+ return {
260
+ success: false,
261
+ error: {
262
+ code: "CONFLICT",
263
+ message: `Taxonomy '${input.name}' already exists in locale '${input.locale}'`,
264
+ },
265
+ };
266
+ }
214
267
  }
215
268
 
216
269
  const id = ulid();
217
-
218
270
  await db
219
271
  .insertInto("_emdash_taxonomy_defs")
220
272
  .values({
221
273
  id,
222
274
  name: input.name,
223
275
  label: input.label,
224
- label_singular: null,
276
+ label_singular: input.labelSingular ?? null,
225
277
  hierarchical: input.hierarchical ? 1 : 0,
226
278
  collections: JSON.stringify(collections),
279
+ ...(input.locale !== undefined ? { locale: input.locale } : {}),
280
+ translation_group: translationGroup ?? id,
227
281
  })
228
282
  .execute();
229
283
 
230
- return {
231
- success: true,
232
- data: {
233
- taxonomy: {
234
- id,
235
- name: input.name,
236
- label: input.label,
237
- hierarchical: input.hierarchical ?? false,
238
- collections,
239
- },
240
- },
241
- };
284
+ const row = await db
285
+ .selectFrom("_emdash_taxonomy_defs")
286
+ .selectAll()
287
+ .where("id", "=", id)
288
+ .executeTakeFirstOrThrow();
289
+ return { success: true, data: { taxonomy: rowToDef(row) } };
242
290
  } catch (error) {
243
- // Handle UNIQUE constraint violation from concurrent duplicate inserts
244
291
  if (error instanceof Error && error.message.includes("UNIQUE constraint failed")) {
245
292
  return {
246
293
  success: false,
247
- error: {
248
- code: "CONFLICT",
249
- message: `Taxonomy '${input.name}' already exists`,
250
- },
294
+ error: { code: "CONFLICT", message: `Taxonomy '${input.name}' already exists` },
251
295
  };
252
296
  }
253
297
  return {
@@ -257,23 +301,81 @@ export async function handleTaxonomyCreate(
257
301
  }
258
302
  }
259
303
 
304
+ /**
305
+ * List every locale translation of a taxonomy def (by id or translation_group).
306
+ */
307
+ export async function handleTaxonomyDefTranslations(
308
+ db: Kysely<Database>,
309
+ idOrGroup: string,
310
+ ): Promise<ApiResult<TaxonomyDefTranslationsResponse>> {
311
+ try {
312
+ const anchor = await db
313
+ .selectFrom("_emdash_taxonomy_defs")
314
+ .selectAll()
315
+ .where((eb) => eb.or([eb("id", "=", idOrGroup), eb("translation_group", "=", idOrGroup)]))
316
+ .executeTakeFirst();
317
+ if (!anchor) {
318
+ return {
319
+ success: false,
320
+ error: { code: "NOT_FOUND", message: "Taxonomy not found" },
321
+ };
322
+ }
323
+ const group = anchor.translation_group ?? anchor.id;
324
+ const rows = await db
325
+ .selectFrom("_emdash_taxonomy_defs")
326
+ .selectAll()
327
+ .where("translation_group", "=", group)
328
+ .orderBy("locale", "asc")
329
+ .execute();
330
+ return {
331
+ success: true,
332
+ data: {
333
+ translationGroup: group,
334
+ translations: rows.map((r) => ({
335
+ id: r.id,
336
+ name: r.name,
337
+ label: r.label,
338
+ locale: r.locale,
339
+ })),
340
+ },
341
+ };
342
+ } catch {
343
+ return {
344
+ success: false,
345
+ error: {
346
+ code: "TAXONOMY_TRANSLATIONS_ERROR",
347
+ message: "Failed to list taxonomy translations",
348
+ },
349
+ };
350
+ }
351
+ }
352
+
353
+ // ---------------------------------------------------------------------------
354
+ // Term handlers
355
+ // ---------------------------------------------------------------------------
356
+
260
357
  /**
261
358
  * List all terms for a taxonomy (returns tree for hierarchical taxonomies)
262
359
  */
263
360
  export async function handleTermList(
264
361
  db: Kysely<Database>,
265
362
  taxonomyName: string,
363
+ options: { locale?: string } = {},
266
364
  ): Promise<ApiResult<TermListResponse>> {
267
365
  try {
366
+ // Definitions are per-locale but terms aren't bound to the def's locale —
367
+ // just ensure the taxonomy exists somewhere.
268
368
  const lookup = await requireTaxonomyDef(db, taxonomyName);
269
369
  if (!lookup.success) return lookup;
270
370
 
271
371
  const repo = new TaxonomyRepository(db);
272
- const terms = await repo.findByName(taxonomyName);
372
+ const terms = await repo.findByName(taxonomyName, { locale: options.locale });
273
373
 
274
- // Batch count entries per term in a single query (replaces N+1 pattern)
275
- const termIds = terms.map((t) => t.id);
276
- const counts = await repo.countEntriesForTerms(termIds);
374
+ // Batch count entries per term in a single query (replaces N+1 pattern).
375
+ // content_taxonomies.taxonomy_id stores the translation_group, so we
376
+ // look up by group and map back to each term's id.
377
+ const groups = terms.map((t) => t.translationGroup ?? t.id);
378
+ const countsByGroup = await repo.countEntriesForTerms(groups);
277
379
 
278
380
  const termData: TermWithCount[] = terms.map((term) => ({
279
381
  id: term.id,
@@ -283,12 +385,13 @@ export async function handleTermList(
283
385
  parentId: term.parentId,
284
386
  description: typeof term.data?.description === "string" ? term.data.description : undefined,
285
387
  children: [],
286
- count: counts.get(term.id) ?? 0,
388
+ count: countsByGroup.get(term.translationGroup ?? term.id) ?? 0,
389
+ locale: term.locale,
390
+ translationGroup: term.translationGroup,
287
391
  }));
288
392
 
289
393
  const isHierarchical = lookup.def.hierarchical === 1;
290
394
  const result = isHierarchical ? buildTree(termData) : termData;
291
-
292
395
  return { success: true, data: { terms: result } };
293
396
  } catch {
294
397
  return {
@@ -382,30 +485,60 @@ async function validateParentTerm(
382
485
  export async function handleTermCreate(
383
486
  db: Kysely<Database>,
384
487
  taxonomyName: string,
385
- input: { slug: string; label: string; parentId?: string | null; description?: string },
488
+ input: {
489
+ slug: string;
490
+ label: string;
491
+ parentId?: string | null;
492
+ description?: string;
493
+ locale?: string;
494
+ translationOf?: string;
495
+ },
386
496
  ): Promise<ApiResult<TermResponse>> {
387
497
  try {
498
+ // Taxonomy definitions are per-locale, but terms can exist in any locale
499
+ // regardless of whether the def has been translated there. Look up the
500
+ // def across all locales — we only care that it *exists*.
388
501
  const lookup = await requireTaxonomyDef(db, taxonomyName);
389
502
  if (!lookup.success) return lookup;
390
503
 
391
504
  const repo = new TaxonomyRepository(db);
392
505
 
393
506
  // Coerce empty-string parentId to undefined (treat as "no parent").
394
- const parentId =
507
+ let parentId =
395
508
  input.parentId === "" || input.parentId === undefined ? undefined : input.parentId;
396
509
 
397
- // Check for slug conflict
398
- const existing = await repo.findBySlug(taxonomyName, input.slug);
510
+ // Conflict check is scoped to locale (per-locale slugs are unique).
511
+ const existing = await repo.findBySlug(taxonomyName, input.slug, input.locale);
399
512
  if (existing) {
400
513
  return {
401
514
  success: false,
402
515
  error: {
403
516
  code: "CONFLICT",
404
- message: `Term with slug '${input.slug}' already exists in taxonomy '${taxonomyName}'`,
517
+ message: input.locale
518
+ ? `Term '${input.slug}' already exists in '${taxonomyName}' (${input.locale})`
519
+ : `Term with slug '${input.slug}' already exists in taxonomy '${taxonomyName}'`,
405
520
  },
406
521
  };
407
522
  }
408
523
 
524
+ // If creating a translation whose parent is the translated sibling of
525
+ // the source's parent, try to resolve the parent in the same locale.
526
+ if (input.translationOf && parentId) {
527
+ const source = await repo.findById(input.translationOf);
528
+ if (source?.parentId === parentId && input.locale) {
529
+ const sourceParent = await repo.findById(parentId);
530
+ if (sourceParent?.translationGroup) {
531
+ const translatedParent = await db
532
+ .selectFrom("taxonomies")
533
+ .select("id")
534
+ .where("translation_group", "=", sourceParent.translationGroup)
535
+ .where("locale", "=", input.locale)
536
+ .executeTakeFirst();
537
+ if (translatedParent) parentId = translatedParent.id;
538
+ }
539
+ }
540
+ }
541
+
409
542
  // Validate parentId: must exist AND belong to the same taxonomy.
410
543
  // (Cycle check is N/A on create — the term doesn't exist yet.)
411
544
  const parentError = await validateParentTerm(repo, taxonomyName, undefined, parentId);
@@ -419,10 +552,10 @@ export async function handleTermCreate(
419
552
  label: input.label,
420
553
  parentId: parentId ?? undefined,
421
554
  data: input.description ? { description: input.description } : undefined,
555
+ locale: input.locale,
556
+ translationOf: input.translationOf,
422
557
  });
423
558
 
424
- // New term means `hasAnyTermAssignments` may flip from false->true next
425
- // time an entry is tagged. Clear the cache so the next read re-probes.
426
559
  invalidateTermCache();
427
560
 
428
561
  return {
@@ -436,6 +569,8 @@ export async function handleTermCreate(
436
569
  parentId: term.parentId,
437
570
  description:
438
571
  typeof term.data?.description === "string" ? term.data.description : undefined,
572
+ locale: term.locale,
573
+ translationGroup: term.translationGroup,
439
574
  },
440
575
  },
441
576
  };
@@ -454,10 +589,11 @@ export async function handleTermGet(
454
589
  db: Kysely<Database>,
455
590
  taxonomyName: string,
456
591
  termSlug: string,
592
+ options: { locale?: string } = {},
457
593
  ): Promise<ApiResult<TermGetResponse>> {
458
594
  try {
459
595
  const repo = new TaxonomyRepository(db);
460
- const term = await repo.findBySlug(taxonomyName, termSlug);
596
+ const term = await repo.findBySlug(taxonomyName, termSlug, options.locale);
461
597
 
462
598
  if (!term) {
463
599
  return {
@@ -484,11 +620,9 @@ export async function handleTermGet(
484
620
  description:
485
621
  typeof term.data?.description === "string" ? term.data.description : undefined,
486
622
  count,
487
- children: children.map((c) => ({
488
- id: c.id,
489
- slug: c.slug,
490
- label: c.label,
491
- })),
623
+ children: children.map((c) => ({ id: c.id, slug: c.slug, label: c.label })),
624
+ locale: term.locale,
625
+ translationGroup: term.translationGroup,
492
626
  },
493
627
  },
494
628
  };
@@ -500,6 +634,50 @@ export async function handleTermGet(
500
634
  }
501
635
  }
502
636
 
637
+ /** List every translation of a term (by id or translation_group). */
638
+ export async function handleTermTranslations(
639
+ db: Kysely<Database>,
640
+ idOrGroup: string,
641
+ ): Promise<ApiResult<TermTranslationsResponse>> {
642
+ try {
643
+ const anchor = await db
644
+ .selectFrom("taxonomies")
645
+ .selectAll()
646
+ .where((eb) => eb.or([eb("id", "=", idOrGroup), eb("translation_group", "=", idOrGroup)]))
647
+ .executeTakeFirst();
648
+ if (!anchor) {
649
+ return {
650
+ success: false,
651
+ error: { code: "NOT_FOUND", message: "Term not found" },
652
+ };
653
+ }
654
+ const group = anchor.translation_group ?? anchor.id;
655
+ const rows = await db
656
+ .selectFrom("taxonomies")
657
+ .selectAll()
658
+ .where("translation_group", "=", group)
659
+ .orderBy("locale", "asc")
660
+ .execute();
661
+ return {
662
+ success: true,
663
+ data: {
664
+ translationGroup: group,
665
+ translations: rows.map((r) => ({
666
+ id: r.id,
667
+ slug: r.slug,
668
+ label: r.label,
669
+ locale: r.locale,
670
+ })),
671
+ },
672
+ };
673
+ } catch {
674
+ return {
675
+ success: false,
676
+ error: { code: "TERM_TRANSLATIONS_ERROR", message: "Failed to list term translations" },
677
+ };
678
+ }
679
+ }
680
+
503
681
  /**
504
682
  * Update a term
505
683
  */
@@ -508,10 +686,11 @@ export async function handleTermUpdate(
508
686
  taxonomyName: string,
509
687
  termSlug: string,
510
688
  input: { slug?: string; label?: string; parentId?: string | null; description?: string },
689
+ options: { locale?: string } = {},
511
690
  ): Promise<ApiResult<TermResponse>> {
512
691
  try {
513
692
  const repo = new TaxonomyRepository(db);
514
- const term = await repo.findBySlug(taxonomyName, termSlug);
693
+ const term = await repo.findBySlug(taxonomyName, termSlug, options.locale);
515
694
 
516
695
  if (!term) {
517
696
  return {
@@ -529,9 +708,9 @@ export async function handleTermUpdate(
529
708
  const newParentId =
530
709
  input.parentId === "" || input.parentId === undefined ? undefined : input.parentId;
531
710
 
532
- // Check if new slug conflicts
711
+ // Check if new slug conflicts (per-locale uniqueness).
533
712
  if (newSlug !== undefined && newSlug !== termSlug) {
534
- const existing = await repo.findBySlug(taxonomyName, newSlug);
713
+ const existing = await repo.findBySlug(taxonomyName, newSlug, options.locale);
535
714
  if (existing && existing.id !== term.id) {
536
715
  return {
537
716
  success: false,
@@ -556,8 +735,6 @@ export async function handleTermUpdate(
556
735
  data: input.description !== undefined ? { description: input.description } : undefined,
557
736
  });
558
737
 
559
- // Term label/slug changes are reflected in hydrated entry.data.terms —
560
- // invalidate so the next read doesn't short-circuit on a stale probe.
561
738
  invalidateTermCache();
562
739
 
563
740
  if (!updated) {
@@ -578,6 +755,8 @@ export async function handleTermUpdate(
578
755
  parentId: updated.parentId,
579
756
  description:
580
757
  typeof updated.data?.description === "string" ? updated.data.description : undefined,
758
+ locale: updated.locale,
759
+ translationGroup: updated.translationGroup,
581
760
  },
582
761
  },
583
762
  };
@@ -596,10 +775,11 @@ export async function handleTermDelete(
596
775
  db: Kysely<Database>,
597
776
  taxonomyName: string,
598
777
  termSlug: string,
778
+ options: { locale?: string } = {},
599
779
  ): Promise<ApiResult<{ deleted: true }>> {
600
780
  try {
601
781
  const repo = new TaxonomyRepository(db);
602
- const term = await repo.findBySlug(taxonomyName, termSlug);
782
+ const term = await repo.findBySlug(taxonomyName, termSlug, options.locale);
603
783
 
604
784
  if (!term) {
605
785
  return {
@@ -611,7 +791,6 @@ export async function handleTermDelete(
611
791
  };
612
792
  }
613
793
 
614
- // Prevent deletion of terms with children
615
794
  const children = await repo.findChildren(term.id);
616
795
  if (children.length > 0) {
617
796
  return {
@@ -631,10 +810,7 @@ export async function handleTermDelete(
631
810
  };
632
811
  }
633
812
 
634
- // Deleting a term cascades to content_taxonomies; invalidate so
635
- // hydration no longer sees the stale assignments.
636
813
  invalidateTermCache();
637
-
638
814
  return { success: true, data: { deleted: true } };
639
815
  } catch {
640
816
  return {
@@ -59,6 +59,13 @@ export const localeCode = z
59
59
  .regex(/^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i, "Invalid locale code")
60
60
  .transform((v) => v.toLowerCase());
61
61
 
62
+ /** Shared `?locale=xx` query shape for endpoints that filter by locale. */
63
+ export const localeFilterQuery = z
64
+ .object({
65
+ locale: z.string().min(1).optional(),
66
+ })
67
+ .meta({ id: "LocaleFilterQuery" });
68
+
62
69
  // ---------------------------------------------------------------------------
63
70
  // OpenAPI: Shared response schemas
64
71
  // ---------------------------------------------------------------------------