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
@@ -24,6 +24,8 @@ export interface Menu {
24
24
  name: string;
25
25
  label: string;
26
26
  items: MenuItem[];
27
+ locale: string;
28
+ translationGroup: string | null;
27
29
  }
28
30
 
29
31
  /**
@@ -36,13 +38,15 @@ export interface MenuItemRow {
36
38
  sort_order: number;
37
39
  type: MenuItemType;
38
40
  reference_collection: string | null;
39
- reference_id: string | null;
41
+ reference_id: string | null; // translation_group of referenced content/term
40
42
  custom_url: string | null;
41
43
  label: string;
42
44
  title_attr: string | null;
43
45
  target: string | null;
44
46
  css_classes: string | null;
45
47
  created_at: string;
48
+ locale: string;
49
+ translation_group: string | null;
46
50
  }
47
51
 
48
52
  /**
@@ -54,6 +58,8 @@ export interface MenuRow {
54
58
  label: string;
55
59
  created_at: string;
56
60
  updated_at: string;
61
+ locale: string;
62
+ translation_group: string | null;
57
63
  }
58
64
 
59
65
  /**
@@ -62,6 +68,11 @@ export interface MenuRow {
62
68
  export interface CreateMenuItemInput {
63
69
  type: MenuItemType;
64
70
  label: string;
71
+ /**
72
+ * Identifier of the referenced entity. For `reference_collection` items it is
73
+ * the content's translation_group (locale-agnostic); for `taxonomy` items it
74
+ * is the term's translation_group.
75
+ */
65
76
  referenceCollection?: string;
66
77
  referenceId?: string;
67
78
  customUrl?: string;
@@ -91,6 +102,9 @@ export interface UpdateMenuItemInput {
91
102
  export interface CreateMenuInput {
92
103
  name: string;
93
104
  label: string;
105
+ locale?: string;
106
+ /** When set, links the new menu into an existing translation_group. */
107
+ translationOf?: string;
94
108
  }
95
109
 
96
110
  /**
@@ -131,6 +131,12 @@ function getBaseSchema(type: FieldType, field: Field): ZodTypeAny {
131
131
  alt: z.string().optional(),
132
132
  width: z.number().optional(),
133
133
  height: z.number().optional(),
134
+ /** Provider ID (e.g. "local", "cloudflare-images") */
135
+ provider: z.string().optional(),
136
+ /** Admin-side preview URL for external providers (not persisted by plugins) */
137
+ previewUrl: z.string().optional(),
138
+ /** Provider-specific metadata; for local media this carries storageKey */
139
+ meta: z.record(z.string(), z.unknown()).optional(),
134
140
  });
135
141
 
136
142
  case "file":
@@ -140,6 +146,10 @@ function getBaseSchema(type: FieldType, field: Field): ZodTypeAny {
140
146
  filename: z.string().optional(),
141
147
  mimeType: z.string().optional(),
142
148
  size: z.number().optional(),
149
+ /** Provider ID (e.g. "local", "s3") */
150
+ provider: z.string().optional(),
151
+ /** Provider-specific metadata; for local media this carries storageKey */
152
+ meta: z.record(z.string(), z.unknown()).optional(),
143
153
  });
144
154
 
145
155
  case "reference":
@@ -384,10 +394,10 @@ function fieldTypeToTypeScript(field: Field): string {
384
394
  return "PortableTextBlock[]";
385
395
 
386
396
  case "image":
387
- return "{ id: string; src?: string; alt?: string; width?: number; height?: number }";
397
+ return "{ id: string; src?: string; alt?: string; width?: number; height?: number; provider?: string; previewUrl?: string; meta?: Record<string, unknown> }";
388
398
 
389
399
  case "file":
390
- return "{ id: string; src?: string; filename?: string; mimeType?: string; size?: number }";
400
+ return "{ id: string; src?: string; filename?: string; mimeType?: string; size?: number; provider?: string; meta?: Record<string, unknown> }";
391
401
 
392
402
  case "reference":
393
403
  // Could be enhanced to include the referenced collection type
package/src/seed/apply.ts CHANGED
@@ -19,6 +19,7 @@ import { TaxonomyRepository } from "../database/repositories/taxonomy.js";
19
19
  import { withTransaction } from "../database/transaction.js";
20
20
  import type { Database } from "../database/types.js";
21
21
  import type { MediaValue } from "../fields/types.js";
22
+ import { getI18nConfig } from "../i18n/config.js";
22
23
  import { ssrfSafeFetch, validateExternalUrl } from "../import/ssrf.js";
23
24
  import { SchemaRegistry } from "../schema/registry.js";
24
25
  import { FTSManager } from "../search/fts-manager.js";
@@ -219,17 +220,30 @@ export async function applySeed(
219
220
 
220
221
  // 4-5. Taxonomies
221
222
  if (seed.taxonomies) {
223
+ // seed-local id -> resolved info, used to wire `translationOf` refs.
224
+ const defSeedIdMap = new Map<string, { id: string; translationGroup: string }>();
225
+ const termSeedIdMap = new Map<string, string>();
226
+ const fallbackLocale = getI18nConfig()?.defaultLocale ?? "en";
227
+
222
228
  for (const taxonomy of seed.taxonomies) {
223
- // Check if taxonomy definition exists
229
+ const defLocale = taxonomy.locale ?? fallbackLocale;
230
+
231
+ // (name, locale) is the UNIQUE key after migration 036.
224
232
  const existingDef = await db
225
233
  .selectFrom("_emdash_taxonomy_defs")
226
234
  .selectAll()
227
235
  .where("name", "=", taxonomy.name)
236
+ .where("locale", "=", defLocale)
228
237
  .executeTakeFirst();
229
238
 
239
+ let defId: string;
240
+ let defTranslationGroup: string;
241
+
230
242
  if (existingDef) {
243
+ defId = existingDef.id;
244
+ defTranslationGroup = existingDef.translation_group ?? existingDef.id;
231
245
  if (onConflict === "error") {
232
- throw new Error(`Conflict: taxonomy "${taxonomy.name}" already exists`);
246
+ throw new Error(`Conflict: taxonomy "${taxonomy.name}" (${defLocale}) already exists`);
233
247
  }
234
248
  if (onConflict === "update") {
235
249
  await db
@@ -242,40 +256,59 @@ export async function applySeed(
242
256
  })
243
257
  .where("id", "=", existingDef.id)
244
258
  .execute();
245
- // Taxonomy defs don't track an "updated" counter -- just the definition is updated
246
259
  }
247
- // skip: do nothing for the definition
248
260
  } else {
249
- // Create taxonomy definition
261
+ defId = ulid();
262
+ defTranslationGroup = defId;
263
+ if (taxonomy.translationOf) {
264
+ const source = defSeedIdMap.get(taxonomy.translationOf);
265
+ if (source) defTranslationGroup = source.translationGroup;
266
+ else
267
+ console.warn(
268
+ `taxonomy "${taxonomy.name}" (${defLocale}): translationOf "${taxonomy.translationOf}" not found yet; minting a fresh group.`,
269
+ );
270
+ }
250
271
  await db
251
272
  .insertInto("_emdash_taxonomy_defs")
252
273
  .values({
253
- id: ulid(),
274
+ id: defId,
254
275
  name: taxonomy.name,
255
276
  label: taxonomy.label,
256
277
  label_singular: taxonomy.labelSingular ?? null,
257
278
  hierarchical: taxonomy.hierarchical ? 1 : 0,
258
279
  collections: JSON.stringify(taxonomy.collections),
280
+ locale: defLocale,
281
+ translation_group: defTranslationGroup,
259
282
  })
260
283
  .execute();
261
284
  result.taxonomies.created++;
262
285
  }
263
286
 
287
+ if (taxonomy.id)
288
+ defSeedIdMap.set(taxonomy.id, { id: defId, translationGroup: defTranslationGroup });
289
+
264
290
  // Create terms (if provided)
265
291
  if (taxonomy.terms && taxonomy.terms.length > 0) {
266
292
  const termRepo = new TaxonomyRepository(db);
267
293
 
268
- // For hierarchical taxonomies, we need to create parents before children
269
294
  if (taxonomy.hierarchical) {
270
- await applyHierarchicalTerms(termRepo, taxonomy.name, taxonomy.terms, result, onConflict);
295
+ await applyHierarchicalTerms(
296
+ termRepo,
297
+ taxonomy.name,
298
+ defLocale,
299
+ taxonomy.terms,
300
+ termSeedIdMap,
301
+ result,
302
+ onConflict,
303
+ );
271
304
  } else {
272
- // Flat taxonomy - create all terms
273
305
  for (const term of taxonomy.terms) {
274
- const existing = await termRepo.findBySlug(taxonomy.name, term.slug);
306
+ const termLocale = term.locale ?? defLocale;
307
+ const existing = await termRepo.findBySlug(taxonomy.name, term.slug, termLocale);
275
308
  if (existing) {
276
309
  if (onConflict === "error") {
277
310
  throw new Error(
278
- `Conflict: taxonomy term "${term.slug}" in "${taxonomy.name}" already exists`,
311
+ `Conflict: taxonomy term "${term.slug}" in "${taxonomy.name}" (${termLocale}) already exists`,
279
312
  );
280
313
  }
281
314
  if (onConflict === "update") {
@@ -285,14 +318,20 @@ export async function applySeed(
285
318
  });
286
319
  result.taxonomies.terms++;
287
320
  }
288
- // skip: do nothing
321
+ if (term.id) termSeedIdMap.set(term.id, existing.id);
289
322
  } else {
290
- await termRepo.create({
323
+ const translationOf = term.translationOf
324
+ ? termSeedIdMap.get(term.translationOf)
325
+ : undefined;
326
+ const created = await termRepo.create({
291
327
  name: taxonomy.name,
292
328
  slug: term.slug,
293
329
  label: term.label,
294
330
  data: term.description ? { description: term.description } : undefined,
331
+ locale: termLocale,
332
+ translationOf,
295
333
  });
334
+ if (term.id) termSeedIdMap.set(term.id, created.id);
296
335
  result.taxonomies.terms++;
297
336
  }
298
337
  }
@@ -471,23 +510,39 @@ export async function applySeed(
471
510
 
472
511
  // 8. Menus and Menu Items (after content so refs can resolve)
473
512
  if (seed.menus) {
513
+ // seed-local id -> resolved info, used to wire `translationOf` refs.
514
+ const menuSeedIdMap = new Map<string, { id: string; translationGroup: string }>();
515
+ const fallbackLocale = getI18nConfig()?.defaultLocale ?? "en";
516
+
474
517
  for (const menu of seed.menus) {
475
- // Check if menu exists
476
- const existingMenu = await db
518
+ const locale = menu.locale ?? fallbackLocale;
519
+ let lookup = db
477
520
  .selectFrom("_emdash_menus")
478
521
  .selectAll()
479
522
  .where("name", "=", menu.name)
480
- .executeTakeFirst();
523
+ .where("locale", "=", locale);
524
+ const existingMenu = await lookup.executeTakeFirst();
481
525
 
482
526
  let menuId: string;
527
+ let translationGroup: string;
483
528
 
484
529
  if (existingMenu) {
485
530
  menuId = existingMenu.id;
531
+ translationGroup = existingMenu.translation_group ?? existingMenu.id;
486
532
  // Clear existing items (menus are recreated)
487
533
  await db.deleteFrom("_emdash_menu_items").where("menu_id", "=", menuId).execute();
488
534
  } else {
489
- // Create menu
490
535
  menuId = ulid();
536
+ // Resolve translationOf to the source menu's translation_group.
537
+ translationGroup = menuId;
538
+ if (menu.translationOf) {
539
+ const source = menuSeedIdMap.get(menu.translationOf);
540
+ if (source) translationGroup = source.translationGroup;
541
+ else
542
+ console.warn(
543
+ `menu "${menu.name}" (${locale}): translationOf "${menu.translationOf}" not found yet; minting a fresh group.`,
544
+ );
545
+ }
491
546
  await db
492
547
  .insertInto("_emdash_menus")
493
548
  .values({
@@ -496,15 +551,20 @@ export async function applySeed(
496
551
  label: menu.label,
497
552
  created_at: new Date().toISOString(),
498
553
  updated_at: new Date().toISOString(),
554
+ locale,
555
+ translation_group: translationGroup,
499
556
  })
500
557
  .execute();
501
558
  result.menus.created++;
502
559
  }
503
560
 
561
+ if (menu.id) menuSeedIdMap.set(menu.id, { id: menuId, translationGroup });
562
+
504
563
  // Create menu items
505
564
  const itemCount = await applyMenuItems(
506
565
  db,
507
566
  menuId,
567
+ locale,
508
568
  menu.items,
509
569
  null, // parent_id
510
570
  0, // sort_order
@@ -692,64 +752,75 @@ export async function applySeed(
692
752
  async function applyHierarchicalTerms(
693
753
  termRepo: TaxonomyRepository,
694
754
  taxonomyName: string,
755
+ defLocale: string,
695
756
  terms: SeedTaxonomyTerm[],
757
+ termSeedIdMap: Map<string, string>,
696
758
  result: SeedApplyResult,
697
759
  onConflict: "skip" | "update" | "error" = "skip",
698
760
  ): Promise<void> {
699
- // Map slugs to IDs
761
+ // "locale::slug" -> id, so the same slug can resolve per locale.
700
762
  const slugToId = new Map<string, string>();
701
763
 
702
- // Multiple passes to handle deep nesting
764
+ // Multiple passes handles deep nesting and translationOf forward refs.
703
765
  let remaining = [...terms];
704
- let maxPasses = 10; // Prevent infinite loop
766
+ let maxPasses = 10;
705
767
 
706
768
  while (remaining.length > 0 && maxPasses > 0) {
707
769
  const processedThisPass: string[] = [];
708
770
 
709
771
  for (const term of remaining) {
710
- // Check if parent exists (or no parent)
711
- if (!term.parent || slugToId.has(term.parent)) {
712
- const parentId = term.parent ? slugToId.get(term.parent) : undefined;
772
+ const termLocale = term.locale ?? defLocale;
773
+ const parentReady = !term.parent || slugToId.has(`${termLocale}::${term.parent}`);
774
+ const translationReady = !term.translationOf || termSeedIdMap.has(term.translationOf);
713
775
 
714
- const existing = await termRepo.findBySlug(taxonomyName, term.slug);
715
- if (existing) {
716
- if (onConflict === "error") {
717
- throw new Error(
718
- `Conflict: taxonomy term "${term.slug}" in "${taxonomyName}" already exists`,
719
- );
720
- }
721
- if (onConflict === "update") {
722
- await termRepo.update(existing.id, {
723
- label: term.label,
724
- parentId,
725
- data: term.description ? { description: term.description } : {},
726
- });
727
- result.taxonomies.terms++;
728
- }
729
- slugToId.set(term.slug, existing.id);
730
- } else {
731
- const created = await termRepo.create({
732
- name: taxonomyName,
733
- slug: term.slug,
776
+ if (!parentReady || !translationReady) continue;
777
+
778
+ const parentId = term.parent ? slugToId.get(`${termLocale}::${term.parent}`) : undefined;
779
+ const translationOf = term.translationOf ? termSeedIdMap.get(term.translationOf) : undefined;
780
+
781
+ const existing = await termRepo.findBySlug(taxonomyName, term.slug, termLocale);
782
+ if (existing) {
783
+ if (onConflict === "error") {
784
+ throw new Error(
785
+ `Conflict: taxonomy term "${term.slug}" in "${taxonomyName}" (${termLocale}) already exists`,
786
+ );
787
+ }
788
+ if (onConflict === "update") {
789
+ await termRepo.update(existing.id, {
734
790
  label: term.label,
735
791
  parentId,
736
- data: term.description ? { description: term.description } : undefined,
792
+ data: term.description ? { description: term.description } : {},
737
793
  });
738
- slugToId.set(term.slug, created.id);
739
794
  result.taxonomies.terms++;
740
795
  }
741
-
742
- processedThisPass.push(term.slug);
796
+ slugToId.set(`${termLocale}::${term.slug}`, existing.id);
797
+ if (term.id) termSeedIdMap.set(term.id, existing.id);
798
+ } else {
799
+ const created = await termRepo.create({
800
+ name: taxonomyName,
801
+ slug: term.slug,
802
+ label: term.label,
803
+ parentId,
804
+ data: term.description ? { description: term.description } : undefined,
805
+ locale: termLocale,
806
+ translationOf,
807
+ });
808
+ slugToId.set(`${termLocale}::${term.slug}`, created.id);
809
+ if (term.id) termSeedIdMap.set(term.id, created.id);
810
+ result.taxonomies.terms++;
743
811
  }
812
+
813
+ processedThisPass.push(term.slug + "::" + termLocale);
744
814
  }
745
815
 
746
- // Remove processed terms
747
- remaining = remaining.filter((t) => !processedThisPass.includes(t.slug));
816
+ remaining = remaining.filter(
817
+ (t) => !processedThisPass.includes(t.slug + "::" + (t.locale ?? defLocale)),
818
+ );
748
819
  maxPasses--;
749
820
  }
750
821
 
751
822
  if (remaining.length > 0) {
752
- console.warn(`Could not process ${remaining.length} terms due to missing parents`);
823
+ console.warn(`Could not process ${remaining.length} terms due to missing parents/translations`);
753
824
  }
754
825
  }
755
826
 
@@ -847,11 +918,18 @@ async function applyContentTaxonomies(
847
918
  }
848
919
 
849
920
  /**
850
- * Apply menu items recursively
921
+ * Apply menu items recursively.
922
+ *
923
+ * Each item gets a fresh `translation_group` (= its own id). The seed format's
924
+ * `SeedMenuItem` has no `id`/`translationOf` fields, so we can't express the
925
+ * cross-locale "same nav entry" link here — items diverge across locales on
926
+ * re-apply. Runtime navigation still resolves correctly because `reference_id`
927
+ * already holds the content's translation_group.
851
928
  */
852
929
  async function applyMenuItems(
853
930
  db: Kysely<Database>,
854
931
  menuId: string,
932
+ locale: string,
855
933
  items: SeedMenuItem[],
856
934
  parentId: string | null,
857
935
  startOrder: number,
@@ -877,7 +955,6 @@ async function applyMenuItems(
877
955
  // If not in map, the content might not exist yet (will be broken link)
878
956
  }
879
957
 
880
- // Insert menu item
881
958
  await db
882
959
  .insertInto("_emdash_menu_items")
883
960
  .values({
@@ -894,15 +971,24 @@ async function applyMenuItems(
894
971
  target: item.target ?? null,
895
972
  css_classes: item.cssClasses ?? null,
896
973
  created_at: new Date().toISOString(),
974
+ locale,
975
+ translation_group: itemId,
897
976
  })
898
977
  .execute();
899
978
 
900
979
  count++;
901
980
  order++;
902
981
 
903
- // Process children
904
982
  if (item.children && item.children.length > 0) {
905
- const childCount = await applyMenuItems(db, menuId, item.children, itemId, 0, seedIdMap);
983
+ const childCount = await applyMenuItems(
984
+ db,
985
+ menuId,
986
+ locale,
987
+ item.children,
988
+ itemId,
989
+ 0,
990
+ seedIdMap,
991
+ );
906
992
  count += childCount;
907
993
  }
908
994
  }
package/src/seed/types.ts CHANGED
@@ -87,14 +87,19 @@ export interface SeedField {
87
87
  }
88
88
 
89
89
  /**
90
- * Taxonomy definition in seed
90
+ * Taxonomy definition in seed. For multi-locale exports each locale variant
91
+ * is its own entry, linked via `translationOf` (referencing another entry's `id`).
91
92
  */
92
93
  export interface SeedTaxonomy {
94
+ /** Optional seed-local id, e.g. "tax:category:en". Target of `translationOf`. */
95
+ id?: string;
93
96
  name: string;
94
97
  label: string;
95
98
  labelSingular?: string;
96
99
  hierarchical: boolean;
97
100
  collections: string[];
101
+ locale?: string;
102
+ translationOf?: string;
98
103
  terms?: SeedTaxonomyTerm[];
99
104
  }
100
105
 
@@ -102,18 +107,26 @@ export interface SeedTaxonomy {
102
107
  * Taxonomy term in seed
103
108
  */
104
109
  export interface SeedTaxonomyTerm {
110
+ /** Optional seed-local id, e.g. "term:category:news:en". */
111
+ id?: string;
105
112
  slug: string;
106
113
  label: string;
107
114
  description?: string;
108
115
  parent?: string; // Slug of parent term (for hierarchical taxonomies)
116
+ locale?: string;
117
+ translationOf?: string;
109
118
  }
110
119
 
111
120
  /**
112
121
  * Menu definition in seed
113
122
  */
114
123
  export interface SeedMenu {
124
+ /** Optional seed-local id, e.g. "menu:primary:en". */
125
+ id?: string;
115
126
  name: string;
116
127
  label: string;
128
+ locale?: string;
129
+ translationOf?: string;
117
130
  items: SeedMenuItem[];
118
131
  }
119
132
 
@@ -147,11 +147,16 @@ export function validateSeed(data: unknown): ValidationResult {
147
147
  if (!taxonomy.name) {
148
148
  errors.push(`${prefix}: name is required`);
149
149
  } else {
150
- // Check for duplicate taxonomy names
151
- if (taxonomyNames.has(taxonomy.name)) {
152
- errors.push(`${prefix}.name: duplicate taxonomy name "${taxonomy.name}"`);
150
+ // Uniqueness is per (name, locale).
151
+ const key = `${taxonomy.name}::${taxonomy.locale ?? ""}`;
152
+ if (taxonomyNames.has(key)) {
153
+ errors.push(
154
+ taxonomy.locale
155
+ ? `${prefix}.name: duplicate taxonomy "${taxonomy.name}" in locale "${taxonomy.locale}"`
156
+ : `${prefix}.name: duplicate taxonomy name "${taxonomy.name}"`,
157
+ );
153
158
  }
154
- taxonomyNames.add(taxonomy.name);
159
+ taxonomyNames.add(key);
155
160
  }
156
161
 
157
162
  if (!taxonomy.label) {
@@ -184,13 +189,15 @@ export function validateSeed(data: unknown): ValidationResult {
184
189
  if (!term.slug) {
185
190
  errors.push(`${termPrefix}: slug is required`);
186
191
  } else {
187
- // Check for duplicate term slugs
188
- if (termSlugs.has(term.slug)) {
192
+ // Uniqueness is per (slug, locale) so the same slug can repeat
193
+ // across locale variants of the def.
194
+ const key = `${term.slug}::${term.locale ?? taxonomy.locale ?? ""}`;
195
+ if (termSlugs.has(key)) {
189
196
  errors.push(
190
197
  `${termPrefix}.slug: duplicate term slug "${term.slug}" in taxonomy "${taxonomy.name}"`,
191
198
  );
192
199
  }
193
- termSlugs.add(term.slug);
200
+ termSlugs.add(key);
194
201
  }
195
202
 
196
203
  if (!term.label) {
@@ -207,11 +214,12 @@ export function validateSeed(data: unknown): ValidationResult {
207
214
  }
208
215
  }
209
216
 
210
- // Second pass: validate parent references
217
+ // Second pass: validate parent references (within the same locale).
211
218
  if (taxonomy.hierarchical && taxonomy.terms) {
212
219
  for (let j = 0; j < taxonomy.terms.length; j++) {
213
220
  const term = taxonomy.terms[j];
214
- if (term.parent && !termSlugs.has(term.parent)) {
221
+ const termLocale = term.locale ?? taxonomy.locale ?? "";
222
+ if (term.parent && !termSlugs.has(`${term.parent}::${termLocale}`)) {
215
223
  errors.push(
216
224
  `${prefix}.terms[${j}].parent: parent term "${term.parent}" not found in taxonomy`,
217
225
  );
@@ -243,11 +251,17 @@ export function validateSeed(data: unknown): ValidationResult {
243
251
  if (!menu.name) {
244
252
  errors.push(`${prefix}: name is required`);
245
253
  } else {
246
- // Check for duplicate menu names
247
- if (menuNames.has(menu.name)) {
248
- errors.push(`${prefix}.name: duplicate menu name "${menu.name}"`);
254
+ // Uniqueness is per (name, locale) — siblings of a translation
255
+ // group share name but differ in locale.
256
+ const key = `${menu.name}::${menu.locale ?? ""}`;
257
+ if (menuNames.has(key)) {
258
+ errors.push(
259
+ menu.locale
260
+ ? `${prefix}.name: duplicate menu "${menu.name}" in locale "${menu.locale}"`
261
+ : `${prefix}.name: duplicate menu name "${menu.name}"`,
262
+ );
249
263
  }
250
- menuNames.add(menu.name);
264
+ menuNames.add(key);
251
265
  }
252
266
 
253
267
  if (!menu.label) {