emdash 0.9.0 → 0.11.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 (239) 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-Ded_1vng.mjs} +167 -254
  4. package/dist/apply-Ded_1vng.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.d.mts.map +1 -1
  14. package/dist/astro/middleware.mjs +94 -43
  15. package/dist/astro/middleware.mjs.map +1 -1
  16. package/dist/astro/types.d.mts +12 -11
  17. package/dist/astro/types.d.mts.map +1 -1
  18. package/dist/{base64-BRICGH2l.mjs → base64-MBPo9ozB.mjs} +1 -1
  19. package/dist/{base64-BRICGH2l.mjs.map → base64-MBPo9ozB.mjs.map} +1 -1
  20. package/dist/{byline-BSaNL1w7.mjs → byline-gFn1r0vA.mjs} +4 -4
  21. package/dist/{byline-BSaNL1w7.mjs.map → byline-gFn1r0vA.mjs.map} +1 -1
  22. package/dist/{bylines-CvJ3PYz2.mjs → bylines-DTFI8nDM.mjs} +5 -5
  23. package/dist/{bylines-CvJ3PYz2.mjs.map → bylines-DTFI8nDM.mjs.map} +1 -1
  24. package/dist/{cache-C6N_hhN7.mjs → cache-BAJbeoZ8.mjs} +3 -3
  25. package/dist/{cache-C6N_hhN7.mjs.map → cache-BAJbeoZ8.mjs.map} +1 -1
  26. package/dist/{chunks-NBQVDOci.mjs → chunks-BK1oZS-l.mjs} +2 -2
  27. package/dist/{chunks-NBQVDOci.mjs.map → chunks-BK1oZS-l.mjs.map} +1 -1
  28. package/dist/cli/index.mjs +342 -95
  29. package/dist/cli/index.mjs.map +1 -1
  30. package/dist/client/cf-access.d.mts +1 -1
  31. package/dist/client/index.d.mts +1 -1
  32. package/dist/client/index.mjs +1 -1
  33. package/dist/{config-BI0V3ICQ.mjs → config-CVssduLe.mjs} +1 -1
  34. package/dist/{config-BI0V3ICQ.mjs.map → config-CVssduLe.mjs.map} +1 -1
  35. package/dist/{content-8lOYF0pr.mjs → content-CERxPUN0.mjs} +14 -3
  36. package/dist/content-CERxPUN0.mjs.map +1 -0
  37. package/dist/database/instrumentation.d.mts +6 -4
  38. package/dist/database/instrumentation.d.mts.map +1 -1
  39. package/dist/database/instrumentation.mjs +19 -7
  40. package/dist/database/instrumentation.mjs.map +1 -1
  41. package/dist/db/index.d.mts +3 -3
  42. package/dist/db/index.mjs +1 -1
  43. package/dist/db/libsql.d.mts +1 -1
  44. package/dist/db/postgres.d.mts +1 -1
  45. package/dist/db/sqlite.d.mts +1 -1
  46. package/dist/{db-errors-WRezodiz.mjs → db-errors-B7P2pSCn.mjs} +1 -1
  47. package/dist/{db-errors-WRezodiz.mjs.map → db-errors-B7P2pSCn.mjs.map} +1 -1
  48. package/dist/{default-D8ksjWhO.mjs → default-pHuz9WF6.mjs} +1 -1
  49. package/dist/{default-D8ksjWhO.mjs.map → default-pHuz9WF6.mjs.map} +1 -1
  50. package/dist/{error-D_-tqP-I.mjs → error-DqnRMM5z.mjs} +1 -1
  51. package/dist/{error-D_-tqP-I.mjs.map → error-DqnRMM5z.mjs.map} +1 -1
  52. package/dist/{index-BFRaVcD6.d.mts → index-Cg-rC4Gj.d.mts} +110 -87
  53. package/dist/index-Cg-rC4Gj.d.mts.map +1 -0
  54. package/dist/index.d.mts +11 -11
  55. package/dist/index.mjs +29 -28
  56. package/dist/{load-DDqMMvZL.mjs → load-DR1VwFXR.mjs} +2 -2
  57. package/dist/{load-DDqMMvZL.mjs.map → load-DR1VwFXR.mjs.map} +1 -1
  58. package/dist/{loader-CKLbBnhK.mjs → loader-ou_PXAjg.mjs} +31 -6
  59. package/dist/loader-ou_PXAjg.mjs.map +1 -0
  60. package/dist/{manifest-schema-DqWNC3lM.mjs → manifest-schema-CXAbd1vH.mjs} +1 -1
  61. package/dist/{manifest-schema-DqWNC3lM.mjs.map → manifest-schema-CXAbd1vH.mjs.map} +1 -1
  62. package/dist/media/index.d.mts +1 -1
  63. package/dist/media/index.mjs +1 -1
  64. package/dist/media/local-runtime.d.mts +7 -7
  65. package/dist/media/local-runtime.mjs +3 -3
  66. package/dist/{media-BW32b4gi.mjs → media-1fFhub9c.mjs} +22 -10
  67. package/dist/media-1fFhub9c.mjs.map +1 -0
  68. package/dist/{mode-ier8jbBk.mjs → mode-YhqNVef_.mjs} +1 -1
  69. package/dist/{mode-ier8jbBk.mjs.map → mode-YhqNVef_.mjs.map} +1 -1
  70. package/dist/{options-BVp3UsTS.mjs → options-nPxWnrya.mjs} +1 -1
  71. package/dist/{options-BVp3UsTS.mjs.map → options-nPxWnrya.mjs.map} +1 -1
  72. package/dist/page/index.d.mts +2 -2
  73. package/dist/{patterns-CrCYkMBb.mjs → patterns-DsUZ4uxI.mjs} +1 -1
  74. package/dist/{patterns-CrCYkMBb.mjs.map → patterns-DsUZ4uxI.mjs.map} +1 -1
  75. package/dist/{placeholder-BE4o_2dc.d.mts → placeholder-CDPtkelt.d.mts} +1 -1
  76. package/dist/{placeholder-BE4o_2dc.d.mts.map → placeholder-CDPtkelt.d.mts.map} +1 -1
  77. package/dist/{placeholder-CIJejMlK.mjs → placeholder-Ci0RLeCk.mjs} +1 -1
  78. package/dist/{placeholder-CIJejMlK.mjs.map → placeholder-Ci0RLeCk.mjs.map} +1 -1
  79. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  80. package/dist/plugins/adapt-sandbox-entry.mjs +2 -2
  81. package/dist/{public-url-DByxYjUw.mjs → public-url-B1AxbbbQ.mjs} +1 -1
  82. package/dist/{public-url-DByxYjUw.mjs.map → public-url-B1AxbbbQ.mjs.map} +1 -1
  83. package/dist/{query-Cg9ZKRQ0.mjs → query-8c_meo_K.mjs} +13 -13
  84. package/dist/{query-Cg9ZKRQ0.mjs.map → query-8c_meo_K.mjs.map} +1 -1
  85. package/dist/{redirect-BhUBKRc1.mjs → redirect-C5H7VGIX.mjs} +3 -3
  86. package/dist/{redirect-BhUBKRc1.mjs.map → redirect-C5H7VGIX.mjs.map} +1 -1
  87. package/dist/{registry-Dw70ChxB.mjs → registry-Do34mz_P.mjs} +7 -6
  88. package/dist/registry-Do34mz_P.mjs.map +1 -0
  89. package/dist/{request-cache-B-bmkipQ.mjs → request-cache-D4I69LeL.mjs} +6 -2
  90. package/dist/request-cache-D4I69LeL.mjs.map +1 -0
  91. package/dist/request-context.d.mts +27 -1
  92. package/dist/request-context.d.mts.map +1 -1
  93. package/dist/request-context.mjs +16 -3
  94. package/dist/request-context.mjs.map +1 -1
  95. package/dist/{runner-C7ADox5q.mjs → runner-DIcU2UCC.mjs} +465 -148
  96. package/dist/runner-DIcU2UCC.mjs.map +1 -0
  97. package/dist/{runner-Bnoj7vjK.d.mts → runner-Iu3IZSDM.d.mts} +2 -2
  98. package/dist/{runner-Bnoj7vjK.d.mts.map → runner-Iu3IZSDM.d.mts.map} +1 -1
  99. package/dist/runtime.d.mts +6 -6
  100. package/dist/runtime.mjs +3 -3
  101. package/dist/{search-dOGEccMa.mjs → search-DuWhx4NG.mjs} +322 -108
  102. package/dist/search-DuWhx4NG.mjs.map +1 -0
  103. package/dist/{secrets-CW3reAnU.mjs → secrets-CZ8rxLX3.mjs} +3 -3
  104. package/dist/{secrets-CW3reAnU.mjs.map → secrets-CZ8rxLX3.mjs.map} +1 -1
  105. package/dist/seed/index.d.mts +2 -2
  106. package/dist/seed/index.mjs +15 -14
  107. package/dist/seo/index.d.mts +1 -1
  108. package/dist/storage/local.d.mts +1 -1
  109. package/dist/storage/local.mjs +1 -1
  110. package/dist/storage/s3.d.mts +1 -1
  111. package/dist/storage/s3.mjs +1 -1
  112. package/dist/taxonomies-Bw76xAxo.mjs +407 -0
  113. package/dist/taxonomies-Bw76xAxo.mjs.map +1 -0
  114. package/dist/taxonomy-D6NvlKo8.mjs +218 -0
  115. package/dist/taxonomy-D6NvlKo8.mjs.map +1 -0
  116. package/dist/{tokens-D7zMmWi2.mjs → tokens-CyRDPVW2.mjs} +2 -2
  117. package/dist/{tokens-D7zMmWi2.mjs.map → tokens-CyRDPVW2.mjs.map} +1 -1
  118. package/dist/{transaction-Cn2rjY78.mjs → transaction-D44LBXvU.mjs} +1 -1
  119. package/dist/{transaction-Cn2rjY78.mjs.map → transaction-D44LBXvU.mjs.map} +1 -1
  120. package/dist/{transport-DNEfeMaU.d.mts → transport-DX_5rpsq.d.mts} +1 -1
  121. package/dist/{transport-DNEfeMaU.d.mts.map → transport-DX_5rpsq.d.mts.map} +1 -1
  122. package/dist/{transport-BeMCmin1.mjs → transport-xpzIjCIB.mjs} +1 -1
  123. package/dist/{transport-BeMCmin1.mjs.map → transport-xpzIjCIB.mjs.map} +1 -1
  124. package/dist/{types-CIOg5AR8.mjs → types-56BKbld_.mjs} +1 -1
  125. package/dist/types-56BKbld_.mjs.map +1 -0
  126. package/dist/{types-CRxNbK-Z.mjs → types-BIgulNsW.mjs} +2 -2
  127. package/dist/{types-CRxNbK-Z.mjs.map → types-BIgulNsW.mjs.map} +1 -1
  128. package/dist/{types-CrtWgIvl.d.mts → types-BQx6ZXpR.d.mts} +10 -1
  129. package/dist/types-BQx6ZXpR.d.mts.map +1 -0
  130. package/dist/{types-CJsYGpco.d.mts → types-B_CXXnzh.d.mts} +1 -1
  131. package/dist/{types-CJsYGpco.d.mts.map → types-B_CXXnzh.d.mts.map} +1 -1
  132. package/dist/{types-M78DQ1lx.d.mts → types-C-aFbqmA.d.mts} +1 -1
  133. package/dist/{types-M78DQ1lx.d.mts.map → types-C-aFbqmA.d.mts.map} +1 -1
  134. package/dist/types-DiI8NOG_.mjs +16 -0
  135. package/dist/types-DiI8NOG_.mjs.map +1 -0
  136. package/dist/{types-BuBIptGk.d.mts → types-IN5z_S3P.d.mts} +158 -92
  137. package/dist/types-IN5z_S3P.d.mts.map +1 -0
  138. package/dist/{types-BSyXeCFW.d.mts → types-IZSZfEwv.d.mts} +4 -3
  139. package/dist/types-IZSZfEwv.d.mts.map +1 -0
  140. package/dist/{types-CDbKp7ND.mjs → types-K-EkEQCI.mjs} +1 -1
  141. package/dist/{types-CDbKp7ND.mjs.map → types-K-EkEQCI.mjs.map} +1 -1
  142. package/dist/{validate-BfQh_C_y.d.mts → validate-CO3JjFV5.d.mts} +22 -5
  143. package/dist/validate-CO3JjFV5.d.mts.map +1 -0
  144. package/dist/{validate-Baqf0slj.mjs → validate-UK4Ja1uo.mjs} +14 -10
  145. package/dist/validate-UK4Ja1uo.mjs.map +1 -0
  146. package/dist/{validation-BfEI7tNe.mjs → validation-Vc5DQkJa.mjs} +5 -5
  147. package/dist/{validation-BfEI7tNe.mjs.map → validation-Vc5DQkJa.mjs.map} +1 -1
  148. package/dist/version-Bg31I_Ff.mjs +7 -0
  149. package/dist/{version-DoxrVdYf.mjs.map → version-Bg31I_Ff.mjs.map} +1 -1
  150. package/dist/{zod-generator-CC0xNe_K.mjs → zod-generator-CHnJUP2l.mjs} +8 -3
  151. package/dist/zod-generator-CHnJUP2l.mjs.map +1 -0
  152. package/package.json +9 -8
  153. package/src/api/errors.ts +5 -0
  154. package/src/api/handlers/content.ts +20 -0
  155. package/src/api/handlers/dashboard.ts +29 -36
  156. package/src/api/handlers/media-allowlist.ts +40 -0
  157. package/src/api/handlers/media.ts +1 -1
  158. package/src/api/handlers/menus.ts +400 -89
  159. package/src/api/handlers/taxonomies.ts +273 -97
  160. package/src/api/handlers/validate-media-fields.ts +125 -0
  161. package/src/api/schemas/common.ts +7 -0
  162. package/src/api/schemas/media.ts +23 -3
  163. package/src/api/schemas/menus.ts +23 -0
  164. package/src/api/schemas/schema.ts +11 -2
  165. package/src/api/schemas/taxonomies.ts +39 -0
  166. package/src/astro/integration/routes.ts +10 -0
  167. package/src/astro/middleware.ts +46 -11
  168. package/src/astro/routes/api/content/[collection]/[id]/permanent.ts +1 -1
  169. package/src/astro/routes/api/import/wordpress/rewrite-url-helpers.ts +196 -0
  170. package/src/astro/routes/api/import/wordpress/rewrite-urls.ts +9 -177
  171. package/src/astro/routes/api/media/upload-url.ts +10 -4
  172. package/src/astro/routes/api/media.ts +12 -4
  173. package/src/astro/routes/api/menus/[name]/items.ts +16 -6
  174. package/src/astro/routes/api/menus/[name]/reorder.ts +8 -3
  175. package/src/astro/routes/api/menus/[name]/translations.ts +82 -0
  176. package/src/astro/routes/api/menus/[name].ts +19 -10
  177. package/src/astro/routes/api/menus/index.ts +9 -6
  178. package/src/astro/routes/api/taxonomies/[name]/terms/[slug]/translations.ts +89 -0
  179. package/src/astro/routes/api/taxonomies/[name]/terms/[slug].ts +22 -22
  180. package/src/astro/routes/api/taxonomies/[name]/terms/index.ts +11 -14
  181. package/src/astro/routes/api/taxonomies/index.ts +9 -6
  182. package/src/astro/types.ts +5 -1
  183. package/src/auth/rate-limit.ts +3 -3
  184. package/src/cli/commands/bundle-utils.ts +81 -6
  185. package/src/cli/commands/bundle.ts +18 -15
  186. package/src/cli/commands/export-seed.ts +139 -24
  187. package/src/cli/commands/plugin-init.ts +216 -90
  188. package/src/database/instrumentation.ts +22 -8
  189. package/src/database/migrations/016_api_tokens.ts +18 -3
  190. package/src/database/migrations/036_i18n_menus_and_taxonomies.ts +477 -0
  191. package/src/database/migrations/037_credential_algorithm.ts +18 -0
  192. package/src/database/migrations/runner.ts +4 -0
  193. package/src/database/repositories/content.ts +11 -0
  194. package/src/database/repositories/media.ts +40 -10
  195. package/src/database/repositories/taxonomy.ts +193 -89
  196. package/src/database/types.ts +12 -3
  197. package/src/emdash-runtime.ts +16 -3
  198. package/src/fields/file.ts +7 -6
  199. package/src/fields/image.ts +12 -11
  200. package/src/fields/types.ts +3 -0
  201. package/src/i18n/resolve.ts +37 -0
  202. package/src/index.ts +1 -1
  203. package/src/loader.ts +49 -2
  204. package/src/mcp/server.ts +114 -26
  205. package/src/media/mime.ts +75 -0
  206. package/src/menus/index.ts +143 -124
  207. package/src/menus/types.ts +15 -1
  208. package/src/plugins/types.ts +81 -191
  209. package/src/request-cache.ts +6 -2
  210. package/src/request-context.ts +42 -2
  211. package/src/schema/registry.ts +5 -5
  212. package/src/schema/types.ts +3 -2
  213. package/src/schema/zod-generator.ts +12 -2
  214. package/src/seed/apply.ts +157 -54
  215. package/src/seed/types.ts +18 -1
  216. package/src/seed/validate.ts +27 -13
  217. package/src/taxonomies/index.ts +230 -213
  218. package/src/taxonomies/types.ts +10 -0
  219. package/dist/apply-BzltprvY.mjs.map +0 -1
  220. package/dist/content-8lOYF0pr.mjs.map +0 -1
  221. package/dist/index-BFRaVcD6.d.mts.map +0 -1
  222. package/dist/loader-CKLbBnhK.mjs.map +0 -1
  223. package/dist/media-BW32b4gi.mjs.map +0 -1
  224. package/dist/registry-Dw70ChxB.mjs.map +0 -1
  225. package/dist/request-cache-B-bmkipQ.mjs.map +0 -1
  226. package/dist/runner-C7ADox5q.mjs.map +0 -1
  227. package/dist/search-dOGEccMa.mjs.map +0 -1
  228. package/dist/taxonomies-ZlRtD6AG.mjs +0 -315
  229. package/dist/taxonomies-ZlRtD6AG.mjs.map +0 -1
  230. package/dist/types-4fVtCIm0.mjs +0 -68
  231. package/dist/types-4fVtCIm0.mjs.map +0 -1
  232. package/dist/types-BSyXeCFW.d.mts.map +0 -1
  233. package/dist/types-BuBIptGk.d.mts.map +0 -1
  234. package/dist/types-CIOg5AR8.mjs.map +0 -1
  235. package/dist/types-CrtWgIvl.d.mts.map +0 -1
  236. package/dist/validate-Baqf0slj.mjs.map +0 -1
  237. package/dist/validate-BfQh_C_y.d.mts.map +0 -1
  238. package/dist/version-DoxrVdYf.mjs +0 -7
  239. package/dist/zod-generator-CC0xNe_K.mjs.map +0 -1
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,41 @@ 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
+ // Shared across menus: translated items reference anchor items in sibling menus.
516
+ const itemSeedIdMap = new Map<string, { id: string; translationGroup: string }>();
517
+ const fallbackLocale = getI18nConfig()?.defaultLocale ?? "en";
518
+
474
519
  for (const menu of seed.menus) {
475
- // Check if menu exists
476
- const existingMenu = await db
520
+ const locale = menu.locale ?? fallbackLocale;
521
+ let lookup = db
477
522
  .selectFrom("_emdash_menus")
478
523
  .selectAll()
479
524
  .where("name", "=", menu.name)
480
- .executeTakeFirst();
525
+ .where("locale", "=", locale);
526
+ const existingMenu = await lookup.executeTakeFirst();
481
527
 
482
528
  let menuId: string;
529
+ let translationGroup: string;
483
530
 
484
531
  if (existingMenu) {
485
532
  menuId = existingMenu.id;
533
+ translationGroup = existingMenu.translation_group ?? existingMenu.id;
486
534
  // Clear existing items (menus are recreated)
487
535
  await db.deleteFrom("_emdash_menu_items").where("menu_id", "=", menuId).execute();
488
536
  } else {
489
- // Create menu
490
537
  menuId = ulid();
538
+ // Resolve translationOf to the source menu's translation_group.
539
+ translationGroup = menuId;
540
+ if (menu.translationOf) {
541
+ const source = menuSeedIdMap.get(menu.translationOf);
542
+ if (source) translationGroup = source.translationGroup;
543
+ else
544
+ console.warn(
545
+ `menu "${menu.name}" (${locale}): translationOf "${menu.translationOf}" not found yet; minting a fresh group.`,
546
+ );
547
+ }
491
548
  await db
492
549
  .insertInto("_emdash_menus")
493
550
  .values({
@@ -496,19 +553,25 @@ export async function applySeed(
496
553
  label: menu.label,
497
554
  created_at: new Date().toISOString(),
498
555
  updated_at: new Date().toISOString(),
556
+ locale,
557
+ translation_group: translationGroup,
499
558
  })
500
559
  .execute();
501
560
  result.menus.created++;
502
561
  }
503
562
 
563
+ if (menu.id) menuSeedIdMap.set(menu.id, { id: menuId, translationGroup });
564
+
504
565
  // Create menu items
505
566
  const itemCount = await applyMenuItems(
506
567
  db,
507
568
  menuId,
569
+ locale,
508
570
  menu.items,
509
571
  null, // parent_id
510
572
  0, // sort_order
511
573
  seedIdMap,
574
+ itemSeedIdMap,
512
575
  );
513
576
  result.menus.items += itemCount;
514
577
  }
@@ -692,64 +755,75 @@ export async function applySeed(
692
755
  async function applyHierarchicalTerms(
693
756
  termRepo: TaxonomyRepository,
694
757
  taxonomyName: string,
758
+ defLocale: string,
695
759
  terms: SeedTaxonomyTerm[],
760
+ termSeedIdMap: Map<string, string>,
696
761
  result: SeedApplyResult,
697
762
  onConflict: "skip" | "update" | "error" = "skip",
698
763
  ): Promise<void> {
699
- // Map slugs to IDs
764
+ // "locale::slug" -> id, so the same slug can resolve per locale.
700
765
  const slugToId = new Map<string, string>();
701
766
 
702
- // Multiple passes to handle deep nesting
767
+ // Multiple passes handles deep nesting and translationOf forward refs.
703
768
  let remaining = [...terms];
704
- let maxPasses = 10; // Prevent infinite loop
769
+ let maxPasses = 10;
705
770
 
706
771
  while (remaining.length > 0 && maxPasses > 0) {
707
772
  const processedThisPass: string[] = [];
708
773
 
709
774
  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;
775
+ const termLocale = term.locale ?? defLocale;
776
+ const parentReady = !term.parent || slugToId.has(`${termLocale}::${term.parent}`);
777
+ const translationReady = !term.translationOf || termSeedIdMap.has(term.translationOf);
713
778
 
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,
779
+ if (!parentReady || !translationReady) continue;
780
+
781
+ const parentId = term.parent ? slugToId.get(`${termLocale}::${term.parent}`) : undefined;
782
+ const translationOf = term.translationOf ? termSeedIdMap.get(term.translationOf) : undefined;
783
+
784
+ const existing = await termRepo.findBySlug(taxonomyName, term.slug, termLocale);
785
+ if (existing) {
786
+ if (onConflict === "error") {
787
+ throw new Error(
788
+ `Conflict: taxonomy term "${term.slug}" in "${taxonomyName}" (${termLocale}) already exists`,
789
+ );
790
+ }
791
+ if (onConflict === "update") {
792
+ await termRepo.update(existing.id, {
734
793
  label: term.label,
735
794
  parentId,
736
- data: term.description ? { description: term.description } : undefined,
795
+ data: term.description ? { description: term.description } : {},
737
796
  });
738
- slugToId.set(term.slug, created.id);
739
797
  result.taxonomies.terms++;
740
798
  }
741
-
742
- processedThisPass.push(term.slug);
799
+ slugToId.set(`${termLocale}::${term.slug}`, existing.id);
800
+ if (term.id) termSeedIdMap.set(term.id, existing.id);
801
+ } else {
802
+ const created = await termRepo.create({
803
+ name: taxonomyName,
804
+ slug: term.slug,
805
+ label: term.label,
806
+ parentId,
807
+ data: term.description ? { description: term.description } : undefined,
808
+ locale: termLocale,
809
+ translationOf,
810
+ });
811
+ slugToId.set(`${termLocale}::${term.slug}`, created.id);
812
+ if (term.id) termSeedIdMap.set(term.id, created.id);
813
+ result.taxonomies.terms++;
743
814
  }
815
+
816
+ processedThisPass.push(term.slug + "::" + termLocale);
744
817
  }
745
818
 
746
- // Remove processed terms
747
- remaining = remaining.filter((t) => !processedThisPass.includes(t.slug));
819
+ remaining = remaining.filter(
820
+ (t) => !processedThisPass.includes(t.slug + "::" + (t.locale ?? defLocale)),
821
+ );
748
822
  maxPasses--;
749
823
  }
750
824
 
751
825
  if (remaining.length > 0) {
752
- console.warn(`Could not process ${remaining.length} terms due to missing parents`);
826
+ console.warn(`Could not process ${remaining.length} terms due to missing parents/translations`);
753
827
  }
754
828
  }
755
829
 
@@ -847,21 +921,29 @@ async function applyContentTaxonomies(
847
921
  }
848
922
 
849
923
  /**
850
- * Apply menu items recursively
924
+ * Apply menu items recursively.
925
+ *
926
+ * When a `SeedMenuItem` carries `id`/`translationOf`, the import resolves the
927
+ * source item's `translation_group` so cross-locale "same nav entry" links
928
+ * survive export → apply. Items without `translationOf` get a fresh group
929
+ * (= their own id).
851
930
  */
852
931
  async function applyMenuItems(
853
932
  db: Kysely<Database>,
854
933
  menuId: string,
934
+ locale: string,
855
935
  items: SeedMenuItem[],
856
936
  parentId: string | null,
857
937
  startOrder: number,
858
938
  seedIdMap: Map<string, string>,
939
+ itemSeedIdMap: Map<string, { id: string; translationGroup: string }>,
859
940
  ): Promise<number> {
860
941
  let count = 0;
861
942
  let order = startOrder;
862
943
 
863
944
  for (const item of items) {
864
945
  const itemId = ulid();
946
+ const itemLocale = item.locale ?? locale;
865
947
 
866
948
  // Resolve reference if needed
867
949
  let referenceId: string | null = null;
@@ -877,7 +959,16 @@ async function applyMenuItems(
877
959
  // If not in map, the content might not exist yet (will be broken link)
878
960
  }
879
961
 
880
- // Insert menu item
962
+ let translationGroup = itemId;
963
+ if (item.translationOf) {
964
+ const source = itemSeedIdMap.get(item.translationOf);
965
+ if (source) translationGroup = source.translationGroup;
966
+ else
967
+ console.warn(
968
+ `menu item "${item.label ?? item.url ?? item.ref ?? "(unlabeled)"}" (${itemLocale}): translationOf "${item.translationOf}" not found yet; minting a fresh group.`,
969
+ );
970
+ }
971
+
881
972
  await db
882
973
  .insertInto("_emdash_menu_items")
883
974
  .values({
@@ -894,15 +985,27 @@ async function applyMenuItems(
894
985
  target: item.target ?? null,
895
986
  css_classes: item.cssClasses ?? null,
896
987
  created_at: new Date().toISOString(),
988
+ locale: itemLocale,
989
+ translation_group: translationGroup,
897
990
  })
898
991
  .execute();
899
992
 
993
+ if (item.id) itemSeedIdMap.set(item.id, { id: itemId, translationGroup });
994
+
900
995
  count++;
901
996
  order++;
902
997
 
903
- // Process children
904
998
  if (item.children && item.children.length > 0) {
905
- const childCount = await applyMenuItems(db, menuId, item.children, itemId, 0, seedIdMap);
999
+ const childCount = await applyMenuItems(
1000
+ db,
1001
+ menuId,
1002
+ itemLocale,
1003
+ item.children,
1004
+ itemId,
1005
+ 0,
1006
+ seedIdMap,
1007
+ itemSeedIdMap,
1008
+ );
906
1009
  count += childCount;
907
1010
  }
908
1011
  }
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
 
@@ -121,6 +134,8 @@ export interface SeedMenu {
121
134
  * Menu item in seed
122
135
  */
123
136
  export interface SeedMenuItem {
137
+ /** Optional seed-local id, e.g. "item:primary:home:en". */
138
+ id?: string;
124
139
  type: string;
125
140
  label?: string;
126
141
  url?: string; // For custom type
@@ -129,6 +144,8 @@ export interface SeedMenuItem {
129
144
  target?: "_blank" | "_self";
130
145
  titleAttr?: string;
131
146
  cssClasses?: string;
147
+ locale?: string;
148
+ translationOf?: string;
132
149
  children?: SeedMenuItem[];
133
150
  }
134
151
 
@@ -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) {