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,5 +1,7 @@
1
1
  import type { z } from "astro/zod";
2
2
 
3
+ import type { FieldValidation } from "../schema/types.js";
4
+
3
5
  /**
4
6
  * SQLite column types that map from field types
5
7
  */
@@ -19,6 +21,7 @@ export interface FieldDefinition<_T = unknown> {
19
21
  schema: z.ZodTypeAny;
20
22
  options?: unknown;
21
23
  ui?: FieldUIHints;
24
+ validation?: FieldValidation;
22
25
  }
23
26
 
24
27
  /**
@@ -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/index.ts CHANGED
@@ -27,7 +27,7 @@ export type {
27
27
  export type { MediaItem, CreateMediaInput } from "./database/repositories/media.js";
28
28
 
29
29
  // Fields
30
- export { portableText, image, reference } from "./fields/index.js";
30
+ export { portableText, image, file, reference } from "./fields/index.js";
31
31
  export { normalizeMediaValue } from "./media/normalize.js";
32
32
  export { generatePlaceholder } from "./media/placeholder.js";
33
33
  export type { PlaceholderData } from "./media/placeholder.js";
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
  }
package/src/mcp/server.ts CHANGED
@@ -1667,16 +1667,19 @@ export function createMcpServer(): McpServer {
1667
1667
  description:
1668
1668
  "List all taxonomy definitions (e.g. categories, tags). Taxonomies are " +
1669
1669
  "classification systems applied to content. Each has a name, label, and " +
1670
- "can be hierarchical (categories) or flat (tags).",
1671
- inputSchema: z.object({}),
1670
+ "can be hierarchical (categories) or flat (tags). Optionally filter by " +
1671
+ "locale.",
1672
+ inputSchema: z.object({
1673
+ locale: z.string().optional().describe("Filter by locale (omit for all)"),
1674
+ }),
1672
1675
  annotations: { readOnlyHint: true },
1673
1676
  },
1674
- async (_args, extra) => {
1677
+ async (args, extra) => {
1675
1678
  requireScope(extra, "content:read");
1676
1679
  const ec = getEmDash(extra);
1677
1680
  try {
1678
1681
  const { handleTaxonomyList } = await import("../api/handlers/taxonomies.js");
1679
- return unwrap(await handleTaxonomyList(ec.db));
1682
+ return unwrap(await handleTaxonomyList(ec.db, { locale: args.locale }));
1680
1683
  } catch (error) {
1681
1684
  return respondHandlerError(error, "TAXONOMY_LIST_ERROR");
1682
1685
  }
@@ -1695,6 +1698,7 @@ export function createMcpServer(): McpServer {
1695
1698
  taxonomy: z.string().describe("Taxonomy name (e.g. 'categories', 'tags')"),
1696
1699
  limit: z.number().int().min(1).max(100).optional().describe("Max items (default 50)"),
1697
1700
  cursor: z.string().min(1).max(2048).optional().describe("Pagination cursor"),
1701
+ locale: z.string().optional().describe("Filter by locale (omit for all)"),
1698
1702
  }),
1699
1703
  annotations: { readOnlyHint: true },
1700
1704
  },
@@ -1702,9 +1706,8 @@ export function createMcpServer(): McpServer {
1702
1706
  requireScope(extra, "content:read");
1703
1707
  const ec = getEmDash(extra);
1704
1708
  try {
1705
- // Verify taxonomy exists via handler layer
1706
1709
  const { handleTaxonomyList } = await import("../api/handlers/taxonomies.js");
1707
- const listResult = await handleTaxonomyList(ec.db);
1710
+ const listResult = await handleTaxonomyList(ec.db, { locale: args.locale });
1708
1711
  if (!listResult.success) return unwrap(listResult);
1709
1712
 
1710
1713
  const taxonomies = (listResult.data as { taxonomies: Array<{ name: string; id?: string }> })
@@ -1712,13 +1715,12 @@ export function createMcpServer(): McpServer {
1712
1715
  const taxonomy = taxonomies.find((t: { name: string }) => t.name === args.taxonomy);
1713
1716
  if (!taxonomy) return respondError("NOT_FOUND", `Taxonomy '${args.taxonomy}' not found`);
1714
1717
 
1715
- // Paginated term query via repository (avoids N+1 of handleTermList)
1716
1718
  const { TaxonomyRepository } = await import("../database/repositories/taxonomy.js");
1717
1719
  const { decodeCursor, encodeCursor, InvalidCursorError } =
1718
1720
  await import("../database/repositories/types.js");
1719
1721
  const repo = new TaxonomyRepository(ec.db);
1720
1722
  const limit = Math.min(args.limit ?? 50, 100);
1721
- const terms = await repo.findByName(args.taxonomy);
1723
+ const terms = await repo.findByName(args.taxonomy, { locale: args.locale });
1722
1724
 
1723
1725
  // Manual keyset pagination over the sorted-by-label results.
1724
1726
  // Using a base64-encoded `(label, id)` cursor matches the
@@ -1760,6 +1762,8 @@ export function createMcpServer(): McpServer {
1760
1762
  label: t.label,
1761
1763
  parentId: t.parentId,
1762
1764
  description: typeof t.data?.description === "string" ? t.data.description : undefined,
1765
+ locale: t.locale,
1766
+ translationGroup: t.translationGroup,
1763
1767
  })),
1764
1768
  nextCursor,
1765
1769
  });
@@ -1785,6 +1789,11 @@ export function createMcpServer(): McpServer {
1785
1789
  label: z.string().describe("Display name"),
1786
1790
  parentId: z.string().optional().describe("Parent term ID for hierarchical taxonomies"),
1787
1791
  description: z.string().optional().describe("Description of the term"),
1792
+ locale: z.string().optional().describe("Locale for the new term (e.g. 'es')"),
1793
+ translationOf: z
1794
+ .string()
1795
+ .optional()
1796
+ .describe("Term id to join as a translation (same translation_group)"),
1788
1797
  }),
1789
1798
  },
1790
1799
  async (args, extra) => {
@@ -1799,6 +1808,8 @@ export function createMcpServer(): McpServer {
1799
1808
  label: args.label,
1800
1809
  parentId: args.parentId,
1801
1810
  description: args.description,
1811
+ locale: args.locale,
1812
+ translationOf: args.translationOf,
1802
1813
  }),
1803
1814
  );
1804
1815
  } catch (error) {
@@ -1875,6 +1886,29 @@ export function createMcpServer(): McpServer {
1875
1886
  },
1876
1887
  );
1877
1888
 
1889
+ server.registerTool(
1890
+ "taxonomy_term_translations",
1891
+ {
1892
+ title: "List Term Translations",
1893
+ description:
1894
+ "Return every locale variant of a taxonomy term, identified via its shared translation_group.",
1895
+ inputSchema: z.object({
1896
+ id: z.string().describe("Term id (or translation_group)"),
1897
+ }),
1898
+ annotations: { readOnlyHint: true },
1899
+ },
1900
+ async (args, extra) => {
1901
+ requireScope(extra, "content:read");
1902
+ const ec = getEmDash(extra);
1903
+ try {
1904
+ const { handleTermTranslations } = await import("../api/handlers/taxonomies.js");
1905
+ return unwrap(await handleTermTranslations(ec.db, args.id));
1906
+ } catch (error) {
1907
+ return respondHandlerError(error, "TERM_TRANSLATIONS_ERROR");
1908
+ }
1909
+ },
1910
+ );
1911
+
1878
1912
  // =====================================================================
1879
1913
  // Menu tools
1880
1914
  // =====================================================================
@@ -1884,18 +1918,20 @@ export function createMcpServer(): McpServer {
1884
1918
  {
1885
1919
  title: "List Menus",
1886
1920
  description:
1887
- "List all navigation menus defined in the CMS. Menus are named " +
1888
- "navigation structures (e.g. 'main', 'footer') containing ordered " +
1889
- "items with labels, URLs, and optional nesting.",
1890
- inputSchema: z.object({}),
1921
+ "List navigation menus. Menus are per-locale: filter by `locale` to " +
1922
+ "get just one locale's worth, or omit to list every row (one per " +
1923
+ "locale per menu name).",
1924
+ inputSchema: z.object({
1925
+ locale: z.string().optional().describe("Filter by locale (omit for all)"),
1926
+ }),
1891
1927
  annotations: { readOnlyHint: true },
1892
1928
  },
1893
- async (_args, extra) => {
1929
+ async (args, extra) => {
1894
1930
  requireScope(extra, "content:read");
1895
1931
  const ec = getEmDash(extra);
1896
1932
  try {
1897
1933
  const { handleMenuList } = await import("../api/handlers/menus.js");
1898
- return unwrap(await handleMenuList(ec.db));
1934
+ return unwrap(await handleMenuList(ec.db, { locale: args.locale }));
1899
1935
  } catch (error) {
1900
1936
  return respondHandlerError(error, "MENU_LIST_ERROR");
1901
1937
  }
@@ -1907,11 +1943,11 @@ export function createMcpServer(): McpServer {
1907
1943
  {
1908
1944
  title: "Get Menu with Items",
1909
1945
  description:
1910
- "Get a menu by name including all its items in order. Items have a " +
1911
- "label, URL, type (custom/content/collection), and optional parent " +
1912
- "for nesting.",
1946
+ "Get a menu by name, including its items. When multiple locales exist, " +
1947
+ "pass `locale` to pick the right one.",
1913
1948
  inputSchema: z.object({
1914
1949
  name: z.string().describe("Menu name (e.g. 'main', 'footer')"),
1950
+ locale: z.string().optional().describe("Locale to resolve the menu for"),
1915
1951
  }),
1916
1952
  annotations: { readOnlyHint: true },
1917
1953
  },
@@ -1920,13 +1956,36 @@ export function createMcpServer(): McpServer {
1920
1956
  const ec = getEmDash(extra);
1921
1957
  try {
1922
1958
  const { handleMenuGet } = await import("../api/handlers/menus.js");
1923
- return unwrap(await handleMenuGet(ec.db, args.name));
1959
+ return unwrap(await handleMenuGet(ec.db, args.name, { locale: args.locale }));
1924
1960
  } catch (error) {
1925
1961
  return respondHandlerError(error, "MENU_GET_ERROR");
1926
1962
  }
1927
1963
  },
1928
1964
  );
1929
1965
 
1966
+ server.registerTool(
1967
+ "menu_translations",
1968
+ {
1969
+ title: "List Menu Translations",
1970
+ description:
1971
+ "Return every locale variant of a menu, identified via the shared translation_group.",
1972
+ inputSchema: z.object({
1973
+ id: z.string().describe("Menu id (or translation_group)"),
1974
+ }),
1975
+ annotations: { readOnlyHint: true },
1976
+ },
1977
+ async (args, extra) => {
1978
+ requireScope(extra, "content:read");
1979
+ const ec = getEmDash(extra);
1980
+ try {
1981
+ const { handleMenuTranslations } = await import("../api/handlers/menus.js");
1982
+ return unwrap(await handleMenuTranslations(ec.db, args.id));
1983
+ } catch (error) {
1984
+ return respondHandlerError(error, "MENU_TRANSLATIONS_ERROR");
1985
+ }
1986
+ },
1987
+ );
1988
+
1930
1989
  server.registerTool(
1931
1990
  "menu_create",
1932
1991
  {
@@ -1934,13 +1993,23 @@ export function createMcpServer(): McpServer {
1934
1993
  description:
1935
1994
  "Create a new navigation menu. The `name` is the stable identifier used " +
1936
1995
  "by site templates (e.g. 'main', 'footer'); `label` is the human-readable " +
1937
- "name shown in the admin. Add items afterwards with menu_set_items.",
1996
+ "name shown in the admin. Menus are per-locale, so pass `locale` when " +
1997
+ "the same menu name exists in multiple translations. Add items afterwards " +
1998
+ "with menu_set_items. If `translationOf` is set, `locale` must also be set.",
1999
+ // `locale`-when-`translationOf` is enforced inside handleMenuCreate
2000
+ // so REST/SDK callers get the same guard. The description above
2001
+ // documents the rule; the handler returns VALIDATION_ERROR.
1938
2002
  inputSchema: z.object({
1939
2003
  name: z
1940
2004
  .string()
1941
2005
  .regex(COLLECTION_SLUG_PATTERN)
1942
2006
  .describe("Stable identifier (lowercase letters, numbers, underscores)"),
1943
2007
  label: z.string().describe("Display name for the admin"),
2008
+ locale: z.string().optional().describe("Locale for this menu (e.g. 'fr-fr')"),
2009
+ translationOf: z
2010
+ .string()
2011
+ .optional()
2012
+ .describe("Existing menu id to create this locale variant from"),
1944
2013
  }),
1945
2014
  },
1946
2015
  async (args, extra) => {
@@ -1949,7 +2018,14 @@ export function createMcpServer(): McpServer {
1949
2018
  const ec = getEmDash(extra);
1950
2019
  try {
1951
2020
  const { handleMenuCreate } = await import("../api/handlers/menus.js");
1952
- return unwrap(await handleMenuCreate(ec.db, { name: args.name, label: args.label }));
2021
+ return unwrap(
2022
+ await handleMenuCreate(ec.db, {
2023
+ name: args.name,
2024
+ label: args.label,
2025
+ locale: args.locale,
2026
+ translationOf: args.translationOf,
2027
+ }),
2028
+ );
1953
2029
  } catch (error) {
1954
2030
  return respondHandlerError(error, "MENU_CREATE_ERROR");
1955
2031
  }
@@ -1960,10 +2036,13 @@ export function createMcpServer(): McpServer {
1960
2036
  "menu_update",
1961
2037
  {
1962
2038
  title: "Update Menu",
1963
- description: "Update a menu's label. The `name` (stable identifier) cannot be changed.",
2039
+ description:
2040
+ "Update a menu's label. The `name` (stable identifier) cannot be changed. " +
2041
+ "On multi-locale installs, pass `locale` so the correct translation is updated.",
1964
2042
  inputSchema: z.object({
1965
2043
  name: z.string().describe("Menu name to update"),
1966
2044
  label: z.string().describe("New display label"),
2045
+ locale: z.string().optional().describe("Locale of the menu to update"),
1967
2046
  }),
1968
2047
  },
1969
2048
  async (args, extra) => {
@@ -1972,7 +2051,9 @@ export function createMcpServer(): McpServer {
1972
2051
  const ec = getEmDash(extra);
1973
2052
  try {
1974
2053
  const { handleMenuUpdate } = await import("../api/handlers/menus.js");
1975
- return unwrap(await handleMenuUpdate(ec.db, args.name, { label: args.label }));
2054
+ return unwrap(
2055
+ await handleMenuUpdate(ec.db, args.name, { label: args.label, locale: args.locale }),
2056
+ );
1976
2057
  } catch (error) {
1977
2058
  return respondHandlerError(error, "MENU_UPDATE_ERROR");
1978
2059
  }
@@ -1983,9 +2064,12 @@ export function createMcpServer(): McpServer {
1983
2064
  "menu_delete",
1984
2065
  {
1985
2066
  title: "Delete Menu",
1986
- description: "Delete a menu. Items are also removed. Cannot be undone.",
2067
+ description:
2068
+ "Delete a menu. Items are also removed. Cannot be undone. On multi-locale " +
2069
+ "installs, pass `locale` so only the intended translation is removed.",
1987
2070
  inputSchema: z.object({
1988
2071
  name: z.string().describe("Menu name to delete"),
2072
+ locale: z.string().optional().describe("Locale of the menu to delete"),
1989
2073
  }),
1990
2074
  annotations: { destructiveHint: true },
1991
2075
  },
@@ -1995,7 +2079,7 @@ export function createMcpServer(): McpServer {
1995
2079
  const ec = getEmDash(extra);
1996
2080
  try {
1997
2081
  const { handleMenuDelete } = await import("../api/handlers/menus.js");
1998
- return unwrap(await handleMenuDelete(ec.db, args.name));
2082
+ return unwrap(await handleMenuDelete(ec.db, args.name, { locale: args.locale }));
1999
2083
  } catch (error) {
2000
2084
  return respondHandlerError(error, "MENU_DELETE_ERROR");
2001
2085
  }
@@ -2010,9 +2094,11 @@ export function createMcpServer(): McpServer {
2010
2094
  "Replace the entire item list of a menu in one call. This is atomic: the " +
2011
2095
  "existing items are deleted and the new list is inserted in the order " +
2012
2096
  "provided. Use this rather than per-item add/remove tools so the resulting " +
2013
- "order and parent links are unambiguous.",
2097
+ "order and parent links are unambiguous. On multi-locale installs, pass " +
2098
+ "`locale` so only the intended translation is rewritten.",
2014
2099
  inputSchema: z.object({
2015
2100
  name: z.string().describe("Menu name to update"),
2101
+ locale: z.string().optional().describe("Locale of the menu to rewrite"),
2016
2102
  items: z
2017
2103
  .array(
2018
2104
  z.object({
@@ -2056,7 +2142,9 @@ export function createMcpServer(): McpServer {
2056
2142
  const ec = getEmDash(extra);
2057
2143
  try {
2058
2144
  const { handleMenuSetItems } = await import("../api/handlers/menus.js");
2059
- return unwrap(await handleMenuSetItems(ec.db, args.name, args.items));
2145
+ return unwrap(
2146
+ await handleMenuSetItems(ec.db, args.name, args.items, { locale: args.locale }),
2147
+ );
2060
2148
  } catch (error) {
2061
2149
  return respondHandlerError(error, "MENU_SET_ITEMS_ERROR");
2062
2150
  }
@@ -0,0 +1,75 @@
1
+ export function normalizeMime(mime: string): string {
2
+ return mime.split(";")[0]!.trim().toLowerCase();
3
+ }
4
+
5
+ export function matchesMimeAllowlist(mime: string, allowList: readonly string[]): boolean {
6
+ const normalized = normalizeMime(mime);
7
+ for (const entry of allowList) {
8
+ if (!entry || !entry.includes("/")) continue;
9
+ const normalizedEntry = normalizeMime(entry);
10
+ if (normalizedEntry.endsWith("/")) {
11
+ if (normalized.startsWith(normalizedEntry)) return true;
12
+ } else if (normalized === normalizedEntry) {
13
+ return true;
14
+ }
15
+ }
16
+ return false;
17
+ }
18
+
19
+ export const EXTENSION_TO_MIME: Readonly<Record<string, string>> = {
20
+ ".pdf": "application/pdf",
21
+ ".png": "image/png",
22
+ ".jpg": "image/jpeg",
23
+ ".jpeg": "image/jpeg",
24
+ ".gif": "image/gif",
25
+ ".webp": "image/webp",
26
+ ".svg": "image/svg+xml",
27
+ ".mp3": "audio/mpeg",
28
+ ".wav": "audio/wav",
29
+ ".mp4": "video/mp4",
30
+ ".webm": "video/webm",
31
+ ".zip": "application/zip",
32
+ ".tar": "application/x-tar",
33
+ ".gz": "application/gzip",
34
+ ".csv": "text/csv",
35
+ ".doc": "application/msword",
36
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
37
+ ".xls": "application/vnd.ms-excel",
38
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
39
+ ".txt": "text/plain",
40
+ ".rtf": "application/rtf",
41
+ ".vtt": "text/vtt",
42
+ ".srt": "application/x-subrip",
43
+ ".woff": "font/woff",
44
+ ".woff2": "font/woff2",
45
+ };
46
+
47
+ const VALID_MIME_RE = /^[a-z0-9][a-z0-9!#$&^_+\-.]*\/[a-z0-9!#$&^_+\-.]*$/i;
48
+
49
+ export function expandExtensionShorthand(entry: string): string | null {
50
+ const trimmed = entry.trim();
51
+ if (!trimmed) return null;
52
+ if (trimmed.includes("/")) return VALID_MIME_RE.test(trimmed) ? trimmed : null;
53
+ if (trimmed.startsWith(".")) {
54
+ return EXTENSION_TO_MIME[trimmed.toLowerCase()] ?? null;
55
+ }
56
+ return null;
57
+ }
58
+
59
+ /**
60
+ * Extract the `allowedMimeTypes` list from a `_emdash_fields.validation` row
61
+ * (raw JSON string). Returns null when the value is missing, malformed, or the
62
+ * list is empty — callers treat that as "no field-specific constraint".
63
+ */
64
+ export function parseAllowedMimeTypes(rawValidation: string | null | undefined): string[] | null {
65
+ if (!rawValidation) return null;
66
+ try {
67
+ const parsed: unknown = JSON.parse(rawValidation);
68
+ if (typeof parsed !== "object" || parsed === null) return null;
69
+ const list = (parsed as { allowedMimeTypes?: unknown }).allowedMimeTypes;
70
+ if (!Array.isArray(list) || list.length === 0) return null;
71
+ return list.filter((entry): entry is string => typeof entry === "string");
72
+ } catch {
73
+ return null;
74
+ }
75
+ }