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,4 +1,4 @@
1
- import type { Kysely } from "kysely";
1
+ import type { Kysely, Selectable } from "kysely";
2
2
  import { ulid } from "ulidx";
3
3
 
4
4
  import type { Database, TaxonomyTable, ContentTaxonomyTable } from "../types.js";
@@ -10,6 +10,8 @@ export interface Taxonomy {
10
10
  label: string;
11
11
  parentId: string | null;
12
12
  data: Record<string, unknown> | null;
13
+ locale: string;
14
+ translationGroup: string | null;
13
15
  }
14
16
 
15
17
  export interface CreateTaxonomyInput {
@@ -18,6 +20,11 @@ export interface CreateTaxonomyInput {
18
20
  label: string;
19
21
  parentId?: string;
20
22
  data?: Record<string, unknown>;
23
+ /** Omit to let the DB default (current value: 'en') apply. Higher layers
24
+ * resolve the locale from the request context / i18n config. */
25
+ locale?: string;
26
+ /** When set, links the new term into the source term's translation_group. */
27
+ translationOf?: string;
21
28
  }
22
29
 
23
30
  export interface UpdateTaxonomyInput {
@@ -27,16 +34,29 @@ export interface UpdateTaxonomyInput {
27
34
  data?: Record<string, unknown>;
28
35
  }
29
36
 
37
+ export interface FindOptions {
38
+ parentId?: string | null;
39
+ locale?: string;
40
+ }
41
+
30
42
  /**
31
- * Taxonomy repository for categories, tags, and other classification
43
+ * Taxonomy repository for categories, tags, and other classification.
32
44
  *
33
- * Taxonomies are hierarchical (via parentId) and can be attached to content entries.
45
+ * Terms are per-locale. Translations of the same term share a `translation_group`
46
+ * ULID. `content_taxonomies.taxonomy_id` stores the translation_group so a single
47
+ * association spans every locale of a post.
48
+ *
49
+ * The repository does not resolve locale fallbacks on its own — callers supply
50
+ * the locale they want. Runtime helpers and handlers use `getFallbackChain()`
51
+ * from `i18n/config` when they need fallback behaviour.
34
52
  */
35
53
  export class TaxonomyRepository {
36
54
  constructor(private db: Kysely<Database>) {}
37
55
 
38
56
  /**
39
- * Create a new taxonomy term
57
+ * Create a new taxonomy term. When `translationOf` is set the new row joins
58
+ * the source term's translation_group; otherwise a fresh group is minted
59
+ * (matching the migration backfill pattern `translation_group = id`).
40
60
  */
41
61
  async create(input: CreateTaxonomyInput): Promise<Taxonomy> {
42
62
  const id = ulid();
@@ -44,58 +64,68 @@ export class TaxonomyRepository {
44
64
  // Empty-string parentId is coerced to null defensively. Higher layers
45
65
  // also normalize this — see handleTermCreate / handleTermUpdate.
46
66
  const parentId = input.parentId === undefined || input.parentId === "" ? null : input.parentId;
47
- const row: TaxonomyTable = {
48
- id,
49
- name: input.name,
50
- slug: input.slug,
51
- label: input.label,
52
- parent_id: parentId,
53
- data: input.data ? JSON.stringify(input.data) : null,
54
- };
55
67
 
56
- await this.db.insertInto("taxonomies").values(row).execute();
68
+ let translationGroup = id;
69
+ if (input.translationOf) {
70
+ const source = await this.findById(input.translationOf);
71
+ if (source?.translationGroup) translationGroup = source.translationGroup;
72
+ }
73
+
74
+ await this.db
75
+ .insertInto("taxonomies")
76
+ .values({
77
+ id,
78
+ name: input.name,
79
+ slug: input.slug,
80
+ label: input.label,
81
+ parent_id: parentId,
82
+ data: input.data ? JSON.stringify(input.data) : null,
83
+ // When omitted, the DB DEFAULT 'en' is used — keeps behaviour
84
+ // consistent with ContentRepository and lets higher layers
85
+ // supply an explicit locale from request context.
86
+ ...(input.locale !== undefined ? { locale: input.locale } : {}),
87
+ translation_group: translationGroup,
88
+ })
89
+ .execute();
57
90
 
58
91
  const taxonomy = await this.findById(id);
59
- if (!taxonomy) {
60
- throw new Error("Failed to create taxonomy");
61
- }
92
+ if (!taxonomy) throw new Error("Failed to create taxonomy");
62
93
  return taxonomy;
63
94
  }
64
95
 
65
- /**
66
- * Find taxonomy by ID
67
- */
68
96
  async findById(id: string): Promise<Taxonomy | null> {
69
97
  const row = await this.db
70
98
  .selectFrom("taxonomies")
71
99
  .selectAll()
72
100
  .where("id", "=", id)
73
101
  .executeTakeFirst();
74
-
75
102
  return row ? this.rowToTaxonomy(row) : null;
76
103
  }
77
104
 
78
105
  /**
79
- * Find taxonomy by name and slug (unique constraint)
106
+ * Find a term by (name, slug). When `locale` is provided, filter by it.
107
+ * When omitted, returns the lowest-locale-code match (deterministic across
108
+ * calls). Mirrors `ContentRepository.findBySlug`.
80
109
  */
81
- async findBySlug(name: string, slug: string): Promise<Taxonomy | null> {
82
- const row = await this.db
110
+ async findBySlug(name: string, slug: string, locale?: string): Promise<Taxonomy | null> {
111
+ let query = this.db
83
112
  .selectFrom("taxonomies")
84
113
  .selectAll()
85
114
  .where("name", "=", name)
86
- .where("slug", "=", slug)
87
- .executeTakeFirst();
88
-
115
+ .where("slug", "=", slug);
116
+ if (locale !== undefined) query = query.where("locale", "=", locale);
117
+ const row = await query.orderBy("locale", "asc").executeTakeFirst();
89
118
  return row ? this.rowToTaxonomy(row) : null;
90
119
  }
91
120
 
92
121
  /**
93
- * Get all terms for a taxonomy (e.g., all categories)
122
+ * Get all terms for a taxonomy (e.g., all categories).
123
+ *
124
+ * `id asc` is a stable tiebreaker for terms that share a label. Without it
125
+ * the SQL ordering is implementation-defined when labels match, which
126
+ * breaks keyset pagination over `(label, id)`.
94
127
  */
95
- async findByName(name: string, options: { parentId?: string | null } = {}): Promise<Taxonomy[]> {
96
- // `id asc` is a stable tiebreaker for terms that share a label.
97
- // Without it the SQL ordering is implementation-defined when labels
98
- // match, which breaks keyset pagination over `(label, id)`.
128
+ async findByName(name: string, options: FindOptions = {}): Promise<Taxonomy[]> {
99
129
  let query = this.db
100
130
  .selectFrom("taxonomies")
101
131
  .selectAll()
@@ -103,6 +133,8 @@ export class TaxonomyRepository {
103
133
  .orderBy("label", "asc")
104
134
  .orderBy("id", "asc");
105
135
 
136
+ if (options.locale !== undefined) query = query.where("locale", "=", options.locale);
137
+
106
138
  if (options.parentId !== undefined) {
107
139
  if (options.parentId === null) {
108
140
  query = query.where("parent_id", "is", null);
@@ -115,9 +147,6 @@ export class TaxonomyRepository {
115
147
  return rows.map((row) => this.rowToTaxonomy(row));
116
148
  }
117
149
 
118
- /**
119
- * Get children of a taxonomy term
120
- */
121
150
  async findChildren(parentId: string): Promise<Taxonomy[]> {
122
151
  const rows = await this.db
123
152
  .selectFrom("taxonomies")
@@ -126,18 +155,28 @@ export class TaxonomyRepository {
126
155
  .orderBy("label", "asc")
127
156
  .orderBy("id", "asc")
128
157
  .execute();
129
-
130
158
  return rows.map((row) => this.rowToTaxonomy(row));
131
159
  }
132
160
 
133
161
  /**
134
- * Update a taxonomy term
162
+ * Every translation sibling of a term (including itself), identified by
163
+ * their shared `translation_group`.
135
164
  */
165
+ async findTranslations(translationGroup: string): Promise<Taxonomy[]> {
166
+ const rows = await this.db
167
+ .selectFrom("taxonomies")
168
+ .selectAll()
169
+ .where("translation_group", "=", translationGroup)
170
+ .orderBy("locale", "asc")
171
+ .execute();
172
+ return rows.map((row) => this.rowToTaxonomy(row));
173
+ }
174
+
136
175
  async update(id: string, input: UpdateTaxonomyInput): Promise<Taxonomy | null> {
137
176
  const existing = await this.findById(id);
138
177
  if (!existing) return null;
139
178
 
140
- const updates: Partial<TaxonomyTable> = {};
179
+ const updates: Record<string, unknown> = {};
141
180
  if (input.slug !== undefined) updates.slug = input.slug;
142
181
  if (input.label !== undefined) updates.label = input.label;
143
182
  if (input.parentId !== undefined) {
@@ -153,31 +192,42 @@ export class TaxonomyRepository {
153
192
  return this.findById(id);
154
193
  }
155
194
 
156
- /**
157
- * Delete a taxonomy term
158
- */
159
195
  async delete(id: string): Promise<boolean> {
160
- // First remove any content associations
161
- await this.db.deleteFrom("content_taxonomies").where("taxonomy_id", "=", id).execute();
196
+ const term = await this.findById(id);
197
+ if (!term) return false;
198
+
199
+ // When deleting the last translation of a group the pivot rows that
200
+ // reference that translation_group become orphaned — purge them.
201
+ if (term.translationGroup) {
202
+ const siblings = await this.db
203
+ .selectFrom("taxonomies")
204
+ .select("id")
205
+ .where("translation_group", "=", term.translationGroup)
206
+ .where("id", "!=", id)
207
+ .execute();
208
+ if (siblings.length === 0) {
209
+ await this.db
210
+ .deleteFrom("content_taxonomies")
211
+ .where("taxonomy_id", "=", term.translationGroup)
212
+ .execute();
213
+ }
214
+ }
162
215
 
163
216
  const result = await this.db.deleteFrom("taxonomies").where("id", "=", id).executeTakeFirst();
164
-
165
- return (result.numDeletedRows ?? 0) > 0;
217
+ return (result.numDeletedRows ?? 0n) > 0n;
166
218
  }
167
219
 
168
- // --- Content-Taxonomy Junction ---
220
+ // --- Content-Taxonomy Junction (taxonomy_id stores the translation_group) ---
169
221
 
170
- /**
171
- * Attach a taxonomy term to a content entry
172
- */
173
222
  async attachToEntry(collection: string, entryId: string, taxonomyId: string): Promise<void> {
223
+ const group = await this.resolveTranslationGroup(taxonomyId);
224
+ if (!group) return;
225
+
174
226
  const row: ContentTaxonomyTable = {
175
227
  collection,
176
228
  entry_id: entryId,
177
- taxonomy_id: taxonomyId,
229
+ taxonomy_id: group,
178
230
  };
179
-
180
- // Use INSERT OR IGNORE pattern for idempotency
181
231
  await this.db
182
232
  .insertInto("content_taxonomies")
183
233
  .values(row)
@@ -185,58 +235,72 @@ export class TaxonomyRepository {
185
235
  .execute();
186
236
  }
187
237
 
188
- /**
189
- * Detach a taxonomy term from a content entry
190
- */
191
238
  async detachFromEntry(collection: string, entryId: string, taxonomyId: string): Promise<void> {
239
+ const group = await this.resolveTranslationGroup(taxonomyId);
240
+ if (!group) return;
241
+
192
242
  await this.db
193
243
  .deleteFrom("content_taxonomies")
194
244
  .where("collection", "=", collection)
195
245
  .where("entry_id", "=", entryId)
196
- .where("taxonomy_id", "=", taxonomyId)
246
+ .where("taxonomy_id", "=", group)
197
247
  .execute();
198
248
  }
199
249
 
200
250
  /**
201
- * Get all taxonomy terms for a content entry
251
+ * Taxonomy terms assigned to a content entry, resolved into a specific locale.
252
+ * Terms whose translation_group lacks a row in the requested locale are
253
+ * omitted — callers wanting fallback behaviour apply it themselves.
202
254
  */
203
255
  async getTermsForEntry(
204
256
  collection: string,
205
257
  entryId: string,
206
258
  taxonomyName?: string,
259
+ locale?: string,
207
260
  ): Promise<Taxonomy[]> {
208
261
  let query = this.db
209
262
  .selectFrom("content_taxonomies")
210
- .innerJoin("taxonomies", "taxonomies.id", "content_taxonomies.taxonomy_id")
263
+ .innerJoin("taxonomies", "taxonomies.translation_group", "content_taxonomies.taxonomy_id")
211
264
  .selectAll("taxonomies")
212
265
  .where("content_taxonomies.collection", "=", collection)
213
266
  .where("content_taxonomies.entry_id", "=", entryId);
214
267
 
215
- if (taxonomyName) {
216
- query = query.where("taxonomies.name", "=", taxonomyName);
217
- }
268
+ if (taxonomyName) query = query.where("taxonomies.name", "=", taxonomyName);
269
+ if (locale !== undefined) query = query.where("taxonomies.locale", "=", locale);
218
270
 
219
- const rows = await query.execute();
271
+ const rows = await query.orderBy("taxonomies.locale", "asc").execute();
220
272
  return rows.map((row) => this.rowToTaxonomy(row));
221
273
  }
222
274
 
223
275
  /**
224
- * Set all taxonomy terms for a content entry (replaces existing)
225
- * Uses batch operations to avoid N+1 queries.
276
+ * Replace all assignments of a given taxonomy for one content entry.
277
+ * Term ids OR translation_groups are accepted and normalised to groups.
226
278
  */
227
279
  async setTermsForEntry(
228
280
  collection: string,
229
281
  entryId: string,
230
282
  taxonomyName: string,
231
- taxonomyIds: string[],
283
+ termIds: string[],
232
284
  ): Promise<void> {
233
- // Get current terms of this taxonomy type
234
- const current = await this.getTermsForEntry(collection, entryId, taxonomyName);
235
- const currentIds = new Set(current.map((t) => t.id));
236
- const newIds = new Set(taxonomyIds);
285
+ const groups: string[] = [];
286
+ for (const id of termIds) {
287
+ const group = await this.resolveTranslationGroup(id);
288
+ if (group) groups.push(group);
289
+ }
290
+ const newGroups = new Set(groups);
291
+
292
+ const current = await this.db
293
+ .selectFrom("content_taxonomies")
294
+ .innerJoin("taxonomies", "taxonomies.translation_group", "content_taxonomies.taxonomy_id")
295
+ .select(["content_taxonomies.taxonomy_id as group"])
296
+ .distinct()
297
+ .where("content_taxonomies.collection", "=", collection)
298
+ .where("content_taxonomies.entry_id", "=", entryId)
299
+ .where("taxonomies.name", "=", taxonomyName)
300
+ .execute();
301
+ const currentGroups = new Set(current.map((r) => r.group));
237
302
 
238
- // Batch remove terms no longer present
239
- const toRemove = current.filter((t) => !newIds.has(t.id)).map((t) => t.id);
303
+ const toRemove = [...currentGroups].filter((g) => !newGroups.has(g));
240
304
  if (toRemove.length > 0) {
241
305
  await this.db
242
306
  .deleteFrom("content_taxonomies")
@@ -246,8 +310,7 @@ export class TaxonomyRepository {
246
310
  .execute();
247
311
  }
248
312
 
249
- // Batch add new terms
250
- const toAdd = taxonomyIds.filter((id) => !currentIds.has(id));
313
+ const toAdd = [...newGroups].filter((g) => !currentGroups.has(g));
251
314
  if (toAdd.length > 0) {
252
315
  await this.db
253
316
  .insertInto("content_taxonomies")
@@ -263,44 +326,86 @@ export class TaxonomyRepository {
263
326
  }
264
327
  }
265
328
 
266
- /**
267
- * Remove all taxonomy associations for an entry (use when entry is deleted)
268
- */
269
329
  async clearEntryTerms(collection: string, entryId: string): Promise<number> {
270
330
  const result = await this.db
271
331
  .deleteFrom("content_taxonomies")
272
332
  .where("collection", "=", collection)
273
333
  .where("entry_id", "=", entryId)
274
334
  .executeTakeFirst();
275
-
276
335
  return Number(result.numDeletedRows ?? 0);
277
336
  }
278
337
 
279
338
  /**
280
- * Count entries that have a specific taxonomy term
339
+ * Copy every term assignment from one content entry to another. Used when
340
+ * creating a translation of a post so the new translation inherits the
341
+ * source's term assignments. Safe to call when the source has no terms.
342
+ */
343
+ async copyEntryTerms(
344
+ collection: string,
345
+ sourceEntryId: string,
346
+ targetEntryId: string,
347
+ ): Promise<void> {
348
+ const rows = await this.db
349
+ .selectFrom("content_taxonomies")
350
+ .select(["taxonomy_id"])
351
+ .where("collection", "=", collection)
352
+ .where("entry_id", "=", sourceEntryId)
353
+ .execute();
354
+ if (rows.length === 0) return;
355
+
356
+ await this.db
357
+ .insertInto("content_taxonomies")
358
+ .values(
359
+ rows.map((r) => ({
360
+ collection,
361
+ entry_id: targetEntryId,
362
+ taxonomy_id: r.taxonomy_id,
363
+ })),
364
+ )
365
+ .onConflict((oc) => oc.doNothing())
366
+ .execute();
367
+ }
368
+
369
+ /**
370
+ * Count content entries that use any translation of this term. Accepts
371
+ * either a term id or a translation_group — we normalise to the group.
281
372
  */
282
- async countEntriesWithTerm(taxonomyId: string): Promise<number> {
373
+ async countEntriesWithTerm(termIdOrGroup: string): Promise<number> {
374
+ const group = await this.resolveTranslationGroup(termIdOrGroup);
375
+ if (!group) return 0;
376
+
283
377
  const result = await this.db
284
378
  .selectFrom("content_taxonomies")
285
379
  .select((eb) => eb.fn.count("entry_id").as("count"))
286
- .where("taxonomy_id", "=", taxonomyId)
380
+ .where("taxonomy_id", "=", group)
287
381
  .executeTakeFirst();
382
+ return Number(result?.count ?? 0);
383
+ }
288
384
 
289
- return Number(result?.count || 0);
385
+ private async resolveTranslationGroup(idOrGroup: string): Promise<string | null> {
386
+ const row = await this.db
387
+ .selectFrom("taxonomies")
388
+ .select(["translation_group"])
389
+ .where((eb) => eb.or([eb("id", "=", idOrGroup), eb("translation_group", "=", idOrGroup)]))
390
+ .executeTakeFirst();
391
+ return row?.translation_group ?? null;
290
392
  }
291
393
 
292
394
  /**
293
- * Batch count entries for multiple taxonomy term IDs.
395
+ * Batch count entries for multiple taxonomy translation_groups.
294
396
  * Chunks the query at SQL_BATCH_SIZE to stay below D1's bind-parameter limit.
295
- * Returns a Map from term ID to count.
397
+ * Returns a Map from translation_group to count.
398
+ *
399
+ * Pass translation_groups (not term ids) — `content_taxonomies.taxonomy_id`
400
+ * stores the translation_group so a single assignment spans every locale.
296
401
  */
297
- async countEntriesForTerms(termIds: string[]): Promise<Map<string, number>> {
298
- if (termIds.length === 0) return new Map();
402
+ async countEntriesForTerms(translationGroups: string[]): Promise<Map<string, number>> {
403
+ if (translationGroups.length === 0) return new Map();
299
404
 
300
405
  const { chunks, SQL_BATCH_SIZE } = await import("../../utils/chunks.js");
301
406
 
302
407
  const counts = new Map<string, number>();
303
- for (const chunk of chunks(termIds, SQL_BATCH_SIZE)) {
408
+ for (const chunk of chunks(translationGroups, SQL_BATCH_SIZE)) {
304
409
  const rows = await this.db
305
410
  .selectFrom("content_taxonomies")
306
411
  .select(["taxonomy_id", (eb) => eb.fn.count("entry_id").as("count")])
@@ -315,10 +420,7 @@ export class TaxonomyRepository {
315
420
  return counts;
316
421
  }
317
422
 
318
- /**
319
- * Convert database row to Taxonomy object
320
- */
321
- private rowToTaxonomy(row: TaxonomyTable): Taxonomy {
423
+ private rowToTaxonomy(row: Selectable<TaxonomyTable>): Taxonomy {
322
424
  return {
323
425
  id: row.id,
324
426
  name: row.name,
@@ -326,6 +428,8 @@ export class TaxonomyRepository {
326
428
  label: row.label,
327
429
  parentId: row.parent_id,
328
430
  data: row.data ? JSON.parse(row.data) : null,
431
+ locale: row.locale,
432
+ translationGroup: row.translation_group,
329
433
  };
330
434
  }
331
435
  }
@@ -20,12 +20,14 @@ export interface TaxonomyTable {
20
20
  label: string;
21
21
  parent_id: string | null;
22
22
  data: string | null; // JSON
23
+ locale: Generated<string>; // e.g. 'en', 'es', 'fr'
24
+ translation_group: string | null; // shared across translations of the same term
23
25
  }
24
26
 
25
27
  export interface ContentTaxonomyTable {
26
28
  collection: string; // e.g., 'posts'
27
29
  entry_id: string; // ID in the ec_* table
28
- taxonomy_id: string;
30
+ taxonomy_id: string; // stores taxonomies.translation_group (locale-agnostic)
29
31
  }
30
32
 
31
33
  export interface TaxonomyDefTable {
@@ -36,6 +38,8 @@ export interface TaxonomyDefTable {
36
38
  hierarchical: number; // 0 or 1 (SQLite boolean)
37
39
  collections: string | null; // JSON array
38
40
  created_at: Generated<string>;
41
+ locale: Generated<string>;
42
+ translation_group: string | null;
39
43
  }
40
44
 
41
45
  export interface MediaTable {
@@ -292,6 +296,8 @@ export interface MenuTable {
292
296
  label: string;
293
297
  created_at: Generated<string>;
294
298
  updated_at: Generated<string>;
299
+ locale: Generated<string>;
300
+ translation_group: string | null;
295
301
  }
296
302
 
297
303
  export interface MenuItemTable {
@@ -301,13 +307,15 @@ export interface MenuItemTable {
301
307
  sort_order: number;
302
308
  type: string;
303
309
  reference_collection: string | null;
304
- reference_id: string | null;
310
+ reference_id: string | null; // stores translation_group of referenced content/term
305
311
  custom_url: string | null;
306
312
  label: string;
307
313
  title_attr: string | null;
308
314
  target: string | null;
309
315
  css_classes: string | null;
310
316
  created_at: Generated<string>;
317
+ locale: Generated<string>;
318
+ translation_group: string | null;
311
319
  }
312
320
 
313
321
  // Widget Areas
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Shared locale-resolution helpers.
3
+ *
4
+ * Matches the pattern used by `query.ts` for content: an explicit locale wins,
5
+ * otherwise we fall back to the request-context locale, otherwise to
6
+ * `defaultLocale` when i18n is enabled, otherwise to `undefined` (meaning "do
7
+ * not filter by locale" — legacy single-locale behaviour).
8
+ */
9
+
10
+ import { getRequestContext } from "../request-context.js";
11
+ import { getFallbackChain, getI18nConfig, isI18nEnabled } from "./config.js";
12
+
13
+ /**
14
+ * Resolve the locale to use for a query given an optional explicit value.
15
+ * Returns `undefined` when no locale information is available; callers should
16
+ * treat that as "do not filter by locale".
17
+ */
18
+ export function resolveLocale(explicit?: string): string | undefined {
19
+ if (explicit !== undefined) return explicit;
20
+ const ctxLocale = getRequestContext()?.locale;
21
+ if (ctxLocale !== undefined) return ctxLocale;
22
+ const cfg = getI18nConfig();
23
+ if (cfg && isI18nEnabled()) return cfg.defaultLocale;
24
+ return undefined;
25
+ }
26
+
27
+ /**
28
+ * Fallback chain to try when looking up a single item. When i18n is disabled
29
+ * or the locale is unspecified, returns a single-element array (or empty when
30
+ * no locale resolves) so callers can iterate uniformly.
31
+ */
32
+ export function resolveLocaleChain(explicit?: string): string[] {
33
+ const locale = resolveLocale(explicit);
34
+ if (locale === undefined) return [];
35
+ if (!isI18nEnabled()) return [locale];
36
+ return getFallbackChain(locale);
37
+ }
package/src/loader.ts CHANGED
@@ -125,12 +125,59 @@ const DATE_COLUMNS = new Set(["created_at", "updated_at", "published_at", "sched
125
125
  */
126
126
  export const CURSOR_RAW_VALUES: unique symbol = Symbol("emdash:cursorRawValues");
127
127
 
128
+ const LOCAL_MEDIA_FILE_PREFIX = "/_emdash/api/media/file/";
129
+ const URL_SCHEME_PATTERN = /^[a-zA-Z][a-zA-Z\d+\-.]*:/;
130
+
128
131
  /** Safely extract a string value from a record, returning fallback if not a string */
129
132
  function rowStr(row: Record<string, unknown>, key: string, fallback = ""): string {
130
133
  const val = row[key];
131
134
  return typeof val === "string" ? val : fallback;
132
135
  }
133
136
 
137
+ function isRecord(value: unknown): value is Record<string, unknown> {
138
+ return typeof value === "object" && value !== null && !Array.isArray(value);
139
+ }
140
+
141
+ function isBareMediaKey(src: string): boolean {
142
+ return !src.startsWith("/") && !URL_SCHEME_PATTERN.test(src);
143
+ }
144
+
145
+ function normalizeLocalMediaValue(value: unknown): unknown {
146
+ if (Array.isArray(value)) {
147
+ return value.map(normalizeLocalMediaValue);
148
+ }
149
+
150
+ if (!isRecord(value)) {
151
+ return value;
152
+ }
153
+
154
+ const normalized: Record<string, unknown> = {};
155
+ for (const [key, child] of Object.entries(value)) {
156
+ normalized[key] = normalizeLocalMediaValue(child);
157
+ }
158
+
159
+ if (
160
+ normalized.provider === "local" &&
161
+ typeof normalized.src === "string" &&
162
+ normalized.src.length > 0
163
+ ) {
164
+ const src = normalized.src;
165
+ if (src.startsWith(LOCAL_MEDIA_FILE_PREFIX)) {
166
+ const id = src.slice(LOCAL_MEDIA_FILE_PREFIX.length);
167
+ if (!normalized.id && id) {
168
+ normalized.id = id;
169
+ }
170
+ } else if (isBareMediaKey(src)) {
171
+ if (!normalized.id) {
172
+ normalized.id = src;
173
+ }
174
+ normalized.src = `${LOCAL_MEDIA_FILE_PREFIX}${src}`;
175
+ }
176
+ }
177
+
178
+ return normalized;
179
+ }
180
+
134
181
  /**
135
182
  * Map a database row to entry data
136
183
  * Extracts content fields (non-system columns) and parses JSON where needed.
@@ -164,7 +211,7 @@ function mapRowToData(row: Record<string, unknown>): Record<string, unknown> {
164
211
  try {
165
212
  // Only parse if it looks like JSON (starts with { or [)
166
213
  if (value.startsWith("{") || value.startsWith("[")) {
167
- data[key] = JSON.parse(value);
214
+ data[key] = normalizeLocalMediaValue(JSON.parse(value));
168
215
  } else {
169
216
  data[key] = value;
170
217
  }
@@ -194,7 +241,7 @@ function mapRevisionData(data: Record<string, unknown>): Record<string, unknown>
194
241
  const result: Record<string, unknown> = {};
195
242
  for (const [key, value] of Object.entries(data)) {
196
243
  if (key.startsWith("_")) continue; // revision metadata
197
- result[key] = value;
244
+ result[key] = normalizeLocalMediaValue(value);
198
245
  }
199
246
  return result;
200
247
  }