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
@@ -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 {
@@ -72,7 +76,8 @@ export interface UserTable {
72
76
  export interface CredentialTable {
73
77
  id: string; // Base64url credential ID
74
78
  user_id: string;
75
- public_key: Uint8Array; // COSE public key
79
+ public_key: Uint8Array; // SEC1 or PKIX encoded public key
80
+ algorithm: number;
76
81
  counter: number;
77
82
  device_type: string; // 'singleDevice' | 'multiDevice'
78
83
  backed_up: number; // 0 or 1
@@ -292,6 +297,8 @@ export interface MenuTable {
292
297
  label: string;
293
298
  created_at: Generated<string>;
294
299
  updated_at: Generated<string>;
300
+ locale: Generated<string>;
301
+ translation_group: string | null;
295
302
  }
296
303
 
297
304
  export interface MenuItemTable {
@@ -301,13 +308,15 @@ export interface MenuItemTable {
301
308
  sort_order: number;
302
309
  type: string;
303
310
  reference_collection: string | null;
304
- reference_id: string | null;
311
+ reference_id: string | null; // stores translation_group of referenced content/term
305
312
  custom_url: string | null;
306
313
  label: string;
307
314
  title_attr: string | null;
308
315
  target: string | null;
309
316
  css_classes: string | null;
310
317
  created_at: Generated<string>;
318
+ locale: Generated<string>;
319
+ translation_group: string | null;
311
320
  }
312
321
 
313
322
  // Widget Areas
@@ -1287,6 +1287,8 @@ export class EmDashRuntime {
1287
1287
  // or arbitrary `Record<string, unknown>` for plugin field widgets that
1288
1288
  // need per-field config (e.g. a checkbox grid receiving its column defs).
1289
1289
  options?: Array<{ value: string; label: string }> | Record<string, unknown>;
1290
+ id?: string;
1291
+ validation?: Record<string, unknown>;
1290
1292
  }
1291
1293
  > = {};
1292
1294
 
@@ -1296,6 +1298,9 @@ export class EmDashRuntime {
1296
1298
  label: field.label,
1297
1299
  required: field.required,
1298
1300
  };
1301
+ // Always include the field's database ID so the admin can forward it
1302
+ // to upload/media-list API calls for MIME allowlist widening.
1303
+ entry.id = field.id;
1299
1304
  if (field.widget) entry.widget = field.widget;
1300
1305
  // Plugin field widgets read their per-field config from `field.options`,
1301
1306
  // which the seed schema types as `Record<string, unknown>`. Pass it
@@ -1312,8 +1317,12 @@ export class EmDashRuntime {
1312
1317
  }));
1313
1318
  }
1314
1319
  // Include full validation for repeater fields (subFields, minItems, maxItems)
1315
- if (field.type === "repeater" && field.validation) {
1316
- (entry as Record<string, unknown>).validation = field.validation;
1320
+ // and for file/image fields (allowedMimeTypes).
1321
+ if (
1322
+ (field.type === "repeater" || field.type === "file" || field.type === "image") &&
1323
+ field.validation
1324
+ ) {
1325
+ entry.validation = { ...field.validation };
1317
1326
  }
1318
1327
  fields[field.slug] = entry;
1319
1328
  }
@@ -1980,7 +1989,11 @@ export class EmDashRuntime {
1980
1989
  // Media Handlers
1981
1990
  // =========================================================================
1982
1991
 
1983
- async handleMediaList(params: { cursor?: string; limit?: number; mimeType?: string }) {
1992
+ async handleMediaList(params: {
1993
+ cursor?: string;
1994
+ limit?: number;
1995
+ mimeType?: string | readonly string[];
1996
+ }) {
1984
1997
  return handleMediaList(this.db, params);
1985
1998
  }
1986
1999
 
@@ -5,13 +5,10 @@ import type { FieldDefinition, FieldUIHints, FileValue } from "./types.js";
5
5
  export interface FileOptions {
6
6
  required?: boolean;
7
7
  maxSize?: number; // In bytes
8
- allowedTypes?: string[]; // MIME types
8
+ allowedTypes?: string[]; // MIME types — exact (image/png) or prefix (image/)
9
9
  helpText?: string;
10
10
  }
11
11
 
12
- /**
13
- * File field - file upload
14
- */
15
12
  export function file(options: FileOptions = {}): FieldDefinition<FileValue> {
16
13
  const fileObjSchema = z.object({
17
14
  id: z.string(),
@@ -21,21 +18,25 @@ export function file(options: FileOptions = {}): FieldDefinition<FileValue> {
21
18
  size: z.number(),
22
19
  });
23
20
 
24
- // Optional vs required
25
21
  const schema: z.ZodTypeAny = options.required ? fileObjSchema : fileObjSchema.optional();
26
22
 
27
23
  const ui: FieldUIHints = {
28
24
  widget: "file",
29
25
  helpText: options.helpText,
30
26
  maxSize: options.maxSize,
31
- allowedTypes: options.allowedTypes,
32
27
  };
33
28
 
29
+ const validation =
30
+ options.allowedTypes && options.allowedTypes.length > 0
31
+ ? { allowedMimeTypes: [...options.allowedTypes] }
32
+ : undefined;
33
+
34
34
  return {
35
35
  type: "file",
36
36
  columnType: "TEXT",
37
37
  schema,
38
38
  options,
39
39
  ui,
40
+ validation,
40
41
  };
41
42
  }
@@ -2,9 +2,6 @@ import { z } from "astro/zod";
2
2
 
3
3
  import type { FieldDefinition, ImageValue } from "./types.js";
4
4
 
5
- /**
6
- * Image field schema
7
- */
8
5
  const imageSchema = z.object({
9
6
  id: z.string(),
10
7
  src: z.string(),
@@ -13,22 +10,26 @@ const imageSchema = z.object({
13
10
  height: z.number().optional(),
14
11
  });
15
12
 
16
- /**
17
- * Image field
18
- * References media items from the media library
19
- */
20
- export function image(options?: {
13
+ export interface ImageOptions {
21
14
  required?: boolean;
22
15
  maxSize?: number; // in bytes
23
- allowedTypes?: string[]; // MIME types
24
- }): FieldDefinition<ImageValue | undefined> {
16
+ allowedTypes?: string[]; // MIME types — exact or prefix
17
+ }
18
+
19
+ export function image(options: ImageOptions = {}): FieldDefinition<ImageValue | undefined> {
20
+ const validation =
21
+ options.allowedTypes && options.allowedTypes.length > 0
22
+ ? { allowedMimeTypes: [...options.allowedTypes] }
23
+ : undefined;
24
+
25
25
  return {
26
26
  type: "image",
27
27
  columnType: "TEXT",
28
- schema: options?.required === false ? imageSchema.optional() : imageSchema,
28
+ schema: options.required === false ? imageSchema.optional() : imageSchema,
29
29
  options,
30
30
  ui: {
31
31
  widget: "image",
32
32
  },
33
+ validation,
33
34
  };
34
35
  }