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
@@ -0,0 +1,125 @@
1
+ import type { Kysely } from "kysely";
2
+
3
+ import type { Database } from "../../database/types.js";
4
+ import { matchesMimeAllowlist, parseAllowedMimeTypes } from "../../media/mime.js";
5
+ import { requestCached } from "../../request-cache.js";
6
+ import { chunks, SQL_BATCH_SIZE } from "../../utils/chunks.js";
7
+ import type { ApiResult } from "../types.js";
8
+
9
+ interface FieldRow {
10
+ slug: string;
11
+ type: string;
12
+ allowedMimeTypes: string[];
13
+ }
14
+
15
+ interface MediaRefValue {
16
+ id?: unknown;
17
+ provider?: unknown;
18
+ mimeType?: unknown;
19
+ }
20
+
21
+ function asMediaRef(value: unknown): MediaRefValue | null {
22
+ if (value === null || value === undefined) return null;
23
+ if (typeof value !== "object" || Array.isArray(value)) return null;
24
+ return value as MediaRefValue;
25
+ }
26
+
27
+ function fail(message: string): ApiResult<never> {
28
+ return { success: false, error: { code: "INVALID_MIME_FOR_FIELD", message } };
29
+ }
30
+
31
+ async function loadMediaFieldsForCollection(
32
+ db: Kysely<Database>,
33
+ collectionSlug: string,
34
+ ): Promise<FieldRow[]> {
35
+ const rows = await db
36
+ .selectFrom("_emdash_fields")
37
+ .innerJoin("_emdash_collections", "_emdash_collections.id", "_emdash_fields.collection_id")
38
+ .select(["_emdash_fields.slug", "_emdash_fields.type", "_emdash_fields.validation"])
39
+ .where("_emdash_collections.slug", "=", collectionSlug)
40
+ .where("_emdash_fields.type", "in", ["file", "image"])
41
+ .execute();
42
+
43
+ const out: FieldRow[] = [];
44
+ for (const row of rows) {
45
+ const list = parseAllowedMimeTypes(row.validation);
46
+ if (!list) continue;
47
+ out.push({ slug: row.slug, type: row.type, allowedMimeTypes: list });
48
+ }
49
+ return out;
50
+ }
51
+
52
+ export async function validateMediaFields(
53
+ db: Kysely<Database>,
54
+ collectionSlug: string,
55
+ data: Record<string, unknown>,
56
+ ): Promise<ApiResult<true>> {
57
+ // Cache is keyed on slug only. If a handler creates/modifies a field and
58
+ // then writes content in the same request (e.g. bulk import), the cached
59
+ // list will be stale for that request. This is an edge case in normal use.
60
+ const fields = await requestCached(`mediaFields:${collectionSlug}`, () =>
61
+ loadMediaFieldsForCollection(db, collectionSlug),
62
+ );
63
+ if (fields.length === 0) return { success: true, data: true };
64
+
65
+ // Collect local media ids that need a MIME lookup
66
+ const localIds = new Set<string>();
67
+ for (const field of fields) {
68
+ const ref = asMediaRef(data[field.slug]);
69
+ if (!ref) continue;
70
+ const provider = typeof ref.provider === "string" ? ref.provider : "local";
71
+ if (provider === "local" && typeof ref.id === "string") {
72
+ localIds.add(ref.id);
73
+ }
74
+ }
75
+
76
+ // Batch-load local media MIMEs
77
+ const idList = [...localIds];
78
+ const mimeById = new Map<string, string>();
79
+ if (idList.length > 0) {
80
+ for (const batch of chunks(idList, SQL_BATCH_SIZE)) {
81
+ const rows = await db
82
+ .selectFrom("media")
83
+ .select(["id", "mime_type"])
84
+ .where("id", "in", batch)
85
+ .execute();
86
+ for (const r of rows) mimeById.set(r.id, r.mime_type);
87
+ }
88
+ }
89
+
90
+ for (const field of fields) {
91
+ const value = data[field.slug];
92
+ if (value === null || value === undefined) continue;
93
+ const ref = asMediaRef(value);
94
+ if (!ref) continue;
95
+
96
+ const provider = typeof ref.provider === "string" ? ref.provider : "local";
97
+
98
+ // External providers carry mimeType in the ref; trust it as-is.
99
+ // Local media: look up the stored mimeType by id.
100
+ let mime: string | undefined;
101
+ if (provider === "local") {
102
+ if (typeof ref.id !== "string") {
103
+ return fail(`Field '${field.slug}' references media with an invalid id`);
104
+ }
105
+ mime = mimeById.get(ref.id);
106
+ if (!mime) {
107
+ return fail(`Field '${field.slug}' references media with unknown MIME type`);
108
+ }
109
+ } else {
110
+ if (typeof ref.mimeType !== "string") {
111
+ return fail(`Field '${field.slug}' requires a mimeType declaration for non-local media`);
112
+ }
113
+ // TODO: long-term, consider a server-side HEAD probe or provider-vouched
114
+ // MIMEs for non-local refs; for now the constraint is only as strong as
115
+ // the client that constructed the ref.
116
+ mime = ref.mimeType;
117
+ }
118
+
119
+ if (!matchesMimeAllowlist(mime, field.allowedMimeTypes)) {
120
+ return fail(`Field '${field.slug}' does not accept ${mime}`);
121
+ }
122
+ }
123
+
124
+ return { success: true, data: true };
125
+ }
@@ -59,6 +59,13 @@ export const localeCode = z
59
59
  .regex(/^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i, "Invalid locale code")
60
60
  .transform((v) => v.toLowerCase());
61
61
 
62
+ /** Shared `?locale=xx` query shape for endpoints that filter by locale. */
63
+ export const localeFilterQuery = z
64
+ .object({
65
+ locale: z.string().min(1).optional(),
66
+ })
67
+ .meta({ id: "LocaleFilterQuery" });
68
+
62
69
  // ---------------------------------------------------------------------------
63
70
  // OpenAPI: Shared response schemas
64
71
  // ---------------------------------------------------------------------------
@@ -6,9 +6,21 @@ import { cursorPaginationQuery } from "./common.js";
6
6
  // Media: Input schemas
7
7
  // ---------------------------------------------------------------------------
8
8
 
9
+ /**
10
+ * Accepts a comma-separated string (from URL query params) or an array of
11
+ * strings (from JSON body or programmatic use) and normalises to string[].
12
+ */
13
+ const mimeTypeFilter = z
14
+ .union([z.string(), z.array(z.string())])
15
+ .transform((v) => {
16
+ const arr = Array.isArray(v) ? v : v.split(",");
17
+ return arr.map((s) => s.trim()).filter((s) => s.length > 0);
18
+ })
19
+ .optional();
20
+
9
21
  export const mediaListQuery = cursorPaginationQuery
10
22
  .extend({
11
- mimeType: z.string().optional(),
23
+ mimeType: mimeTypeFilter,
12
24
  })
13
25
  .meta({ id: "MediaListQuery" });
14
26
 
@@ -30,6 +42,10 @@ export function formatFileSize(bytes: number): string {
30
42
  return `${Math.floor(bytes / 1024 / 1024)}MB`;
31
43
  }
32
44
 
45
+ // Matches a full MIME type (type/subtype) with an optional semicolon-delimited
46
+ // parameter section. Forbids CR/LF to prevent header injection.
47
+ const CONTENT_TYPE_RE = /^[a-z0-9][a-z0-9!#$&^_+\-.]*\/[a-z0-9!#$&^_+\-.]+(\s*;[^\r\n]*)?$/i;
48
+
33
49
  export function mediaUploadUrlBody(maxSize: number) {
34
50
  if (!Number.isFinite(maxSize) || maxSize <= 0) {
35
51
  throw new Error(`EmDash: maxUploadSize must be a positive finite number, got ${maxSize}`);
@@ -37,13 +53,17 @@ export function mediaUploadUrlBody(maxSize: number) {
37
53
  return z
38
54
  .object({
39
55
  filename: z.string().min(1, "filename is required"),
40
- contentType: z.string().min(1, "contentType is required"),
56
+ contentType: z
57
+ .string()
58
+ .min(1, "contentType is required")
59
+ .regex(CONTENT_TYPE_RE, "Invalid content type"),
41
60
  size: z
42
61
  .number()
43
62
  .int()
44
63
  .positive()
45
64
  .max(maxSize, `File size must not exceed ${formatFileSize(maxSize)}`),
46
65
  contentHash: z.string().optional(),
66
+ fieldId: z.string().optional(),
47
67
  })
48
68
  .meta({ id: "MediaUploadUrlBody" });
49
69
  }
@@ -59,7 +79,7 @@ export const mediaConfirmBody = z
59
79
  export const mediaProviderListQuery = cursorPaginationQuery
60
80
  .extend({
61
81
  query: z.string().optional(),
62
- mimeType: z.string().optional(),
82
+ mimeType: mimeTypeFilter,
63
83
  })
64
84
  .meta({ id: "MediaProviderListQuery" });
65
85
 
@@ -20,6 +20,10 @@ export const createMenuBody = z
20
20
  .object({
21
21
  name: z.string().min(1),
22
22
  label: z.string().min(1),
23
+ locale: z.string().min(1).optional(),
24
+ /** When set, clones the items from the source menu. The new menu joins
25
+ * the source's translation_group. */
26
+ translationOf: z.string().min(1).optional(),
23
27
  })
24
28
  .meta({ id: "CreateMenuBody" });
25
29
 
@@ -87,6 +91,8 @@ export const menuSchema = z
87
91
  label: z.string(),
88
92
  created_at: z.string(),
89
93
  updated_at: z.string(),
94
+ locale: z.string(),
95
+ translation_group: z.string().nullable(),
90
96
  })
91
97
  .meta({ id: "Menu" });
92
98
 
@@ -105,9 +111,26 @@ export const menuItemSchema = z
105
111
  target: z.string().nullable(),
106
112
  css_classes: z.string().nullable(),
107
113
  created_at: z.string(),
114
+ locale: z.string(),
115
+ translation_group: z.string().nullable(),
108
116
  })
109
117
  .meta({ id: "MenuItem" });
110
118
 
119
+ export const menuTranslationsSchema = z
120
+ .object({
121
+ translationGroup: z.string().nullable(),
122
+ translations: z.array(
123
+ z.object({
124
+ id: z.string(),
125
+ name: z.string(),
126
+ label: z.string(),
127
+ locale: z.string(),
128
+ updatedAt: z.string(),
129
+ }),
130
+ ),
131
+ })
132
+ .meta({ id: "MenuTranslations" });
133
+
111
134
  export const menuListItemSchema = menuSchema
112
135
  .extend({
113
136
  itemCount: z.number().int(),
@@ -49,6 +49,15 @@ const fieldValidation = z
49
49
  subFields: z.array(repeaterSubFieldSchema).min(1).optional(),
50
50
  minItems: z.number().int().min(0).optional(),
51
51
  maxItems: z.number().int().min(1).optional(),
52
+ allowedMimeTypes: z
53
+ .array(
54
+ z
55
+ .string()
56
+ .regex(/^[a-z0-9][a-z0-9!#$&^_+\-.]*\/[a-z0-9!#$&^_+\-.]*$/i, "Invalid MIME type"),
57
+ )
58
+ .min(1, "allowedMimeTypes must not be empty — omit the field to allow all types")
59
+ .max(64, "allowedMimeTypes may contain at most 64 entries")
60
+ .optional(),
52
61
  })
53
62
  .optional();
54
63
 
@@ -92,7 +101,7 @@ export const createFieldBody = z
92
101
  required: z.boolean().optional(),
93
102
  unique: z.boolean().optional(),
94
103
  defaultValue: z.unknown().optional(),
95
- validation: fieldValidation,
104
+ validation: fieldValidation.nullable(),
96
105
  widget: z.string().optional(),
97
106
  options: fieldWidgetOptions,
98
107
  sortOrder: z.number().int().min(0).optional(),
@@ -107,7 +116,7 @@ export const updateFieldBody = z
107
116
  required: z.boolean().optional(),
108
117
  unique: z.boolean().optional(),
109
118
  defaultValue: z.unknown().optional(),
110
- validation: fieldValidation,
119
+ validation: fieldValidation.nullable(),
111
120
  widget: z.string().optional(),
112
121
  options: fieldWidgetOptions,
113
122
  sortOrder: z.number().int().min(0).optional(),
@@ -15,6 +15,7 @@ export const createTaxonomyDefBody = z
15
15
  .max(63)
16
16
  .regex(/^[a-z][a-z0-9_]*$/, "Name must be lowercase alphanumeric with underscores"),
17
17
  label: z.string().min(1).max(200),
18
+ labelSingular: z.string().min(1).max(200).optional(),
18
19
  hierarchical: z.boolean().optional().default(false),
19
20
  collections: z
20
21
  .array(
@@ -23,6 +24,8 @@ export const createTaxonomyDefBody = z
23
24
  .max(100)
24
25
  .optional()
25
26
  .default([]),
27
+ locale: z.string().min(1).optional(),
28
+ translationOf: z.string().min(1).optional(),
26
29
  })
27
30
  .meta({ id: "CreateTaxonomyDefBody" });
28
31
 
@@ -36,6 +39,8 @@ export const createTermBody = z
36
39
  label: z.string().min(1),
37
40
  parentId: z.string().nullish(),
38
41
  description: z.string().optional(),
42
+ locale: z.string().min(1).optional(),
43
+ translationOf: z.string().min(1).optional(),
39
44
  })
40
45
  .meta({ id: "CreateTermBody" });
41
46
 
@@ -60,9 +65,25 @@ export const taxonomyDefSchema = z
60
65
  labelSingular: z.string().optional(),
61
66
  hierarchical: z.boolean(),
62
67
  collections: z.array(z.string()),
68
+ locale: z.string(),
69
+ translationGroup: z.string().nullable(),
63
70
  })
64
71
  .meta({ id: "TaxonomyDef" });
65
72
 
73
+ export const taxonomyDefTranslationsSchema = z
74
+ .object({
75
+ translationGroup: z.string().nullable(),
76
+ translations: z.array(
77
+ z.object({
78
+ id: z.string(),
79
+ name: z.string(),
80
+ label: z.string(),
81
+ locale: z.string(),
82
+ }),
83
+ ),
84
+ })
85
+ .meta({ id: "TaxonomyDefTranslations" });
86
+
66
87
  export const taxonomyListResponseSchema = z
67
88
  .object({ taxonomies: z.array(taxonomyDefSchema) })
68
89
  .meta({ id: "TaxonomyListResponse" });
@@ -75,9 +96,25 @@ export const termSchema = z
75
96
  label: z.string(),
76
97
  parentId: z.string().nullable(),
77
98
  description: z.string().optional(),
99
+ locale: z.string(),
100
+ translationGroup: z.string().nullable(),
78
101
  })
79
102
  .meta({ id: "Term" });
80
103
 
104
+ export const termTranslationsSchema = z
105
+ .object({
106
+ translationGroup: z.string().nullable(),
107
+ translations: z.array(
108
+ z.object({
109
+ id: z.string(),
110
+ slug: z.string(),
111
+ label: z.string(),
112
+ locale: z.string(),
113
+ }),
114
+ ),
115
+ })
116
+ .meta({ id: "TermTranslations" });
117
+
81
118
  export const termWithCountSchema: z.ZodType = z
82
119
  .object({
83
120
  id: z.string(),
@@ -88,6 +125,8 @@ export const termWithCountSchema: z.ZodType = z
88
125
  description: z.string().optional(),
89
126
  count: z.number().int(),
90
127
  children: z.array(z.lazy(() => termWithCountSchema)),
128
+ locale: z.string(),
129
+ translationGroup: z.string().nullable(),
91
130
  })
92
131
  .meta({ id: "TermWithCount" });
93
132
 
@@ -313,6 +313,11 @@ export function injectCoreRoutes(injectRoute: InjectRoute): void {
313
313
  entrypoint: resolveRoute("api/taxonomies/[name]/terms/[slug].ts"),
314
314
  });
315
315
 
316
+ injectRoute({
317
+ pattern: "/_emdash/api/taxonomies/[name]/terms/[slug]/translations",
318
+ entrypoint: resolveRoute("api/taxonomies/[name]/terms/[slug]/translations.ts"),
319
+ });
320
+
316
321
  injectRoute({
317
322
  pattern: "/_emdash/api/content/[collection]/[id]/terms/[taxonomy]",
318
323
  entrypoint: resolveRoute("api/content/[collection]/[id]/terms/[taxonomy].ts"),
@@ -555,6 +560,11 @@ export function injectCoreRoutes(injectRoute: InjectRoute): void {
555
560
  entrypoint: resolveRoute("api/menus/[name]/reorder.ts"),
556
561
  });
557
562
 
563
+ injectRoute({
564
+ pattern: "/_emdash/api/menus/[name]/translations",
565
+ entrypoint: resolveRoute("api/menus/[name]/translations.ts"),
566
+ });
567
+
558
568
  // Widget area routes
559
569
  injectRoute({
560
570
  pattern: "/_emdash/api/widget-areas",
@@ -47,7 +47,12 @@ import { createPublicMediaUrlResolver } from "../media/url.js";
47
47
  import type { SandboxRunner } from "../plugins/sandbox/types.js";
48
48
  import type { ResolvedPlugin } from "../plugins/types.js";
49
49
  import { invalidateUrlPatternCache } from "../query.js";
50
- import { getRequestContext, runWithContext } from "../request-context.js";
50
+ import {
51
+ createRequestMetrics,
52
+ getRequestContext,
53
+ type RequestMetrics,
54
+ runWithContext,
55
+ } from "../request-context.js";
51
56
  import type { EmDashConfig } from "./integration/runtime.js";
52
57
  import type { EmDashHandlers } from "./types.js";
53
58
 
@@ -209,6 +214,33 @@ function finalizeResponse(
209
214
  return res;
210
215
  }
211
216
 
217
+ /**
218
+ * Append always-on counters (db.*, cache.*) to the Server-Timing list.
219
+ *
220
+ * dur values for `count`, `hit`, `miss` are integer counts — Server-Timing
221
+ * spec only models milliseconds, but browsers show whatever number is given,
222
+ * which is the convention most projects use for non-time samples.
223
+ */
224
+ function pushMetricsTimings(
225
+ timings: Array<{ name: string; dur: number; desc?: string }>,
226
+ metrics: RequestMetrics,
227
+ ): void {
228
+ if (metrics.dbCount > 0) {
229
+ timings.push({ name: "db.total", dur: metrics.dbTotalMs, desc: "DB total" });
230
+ timings.push({ name: "db.count", dur: metrics.dbCount, desc: "Query count" });
231
+ if (metrics.dbFirstOffset !== null) {
232
+ timings.push({ name: "db.first", dur: metrics.dbFirstOffset, desc: "First query at" });
233
+ }
234
+ if (metrics.dbLastOffset !== null) {
235
+ timings.push({ name: "db.last", dur: metrics.dbLastOffset, desc: "Last query at" });
236
+ }
237
+ }
238
+ if (metrics.cacheHits + metrics.cacheMisses > 0) {
239
+ timings.push({ name: "cache.hit", dur: metrics.cacheHits, desc: "Cache hits" });
240
+ timings.push({ name: "cache.miss", dur: metrics.cacheMisses, desc: "Cache misses" });
241
+ }
242
+ }
243
+
212
244
  /** Public routes that require the runtime (sitemap, robots.txt, etc.) */
213
245
  const PUBLIC_RUNTIME_ROUTES = new Set(["/sitemap.xml", "/robots.txt"]);
214
246
  const SITEMAP_COLLECTION_RE = /^\/sitemap-[a-z][a-z0-9_]*\.xml$/;
@@ -252,6 +284,8 @@ export const onRequest = defineMiddleware(async (context, next) => {
252
284
  ? createRecorder(url.pathname, request.method, request.headers.get("x-perf-phase") ?? "default")
253
285
  : undefined;
254
286
 
287
+ const metrics = createRequestMetrics(performance.now());
288
+
255
289
  const run = async (): Promise<Response> => {
256
290
  // Process /_emdash routes and public routes with an active session
257
291
  // (logged-in editors need the runtime for toolbar/visual editing on public pages)
@@ -355,13 +389,14 @@ export const onRequest = defineMiddleware(async (context, next) => {
355
389
  const response = await next();
356
390
  timings.push({ name: "render", dur: performance.now() - t0, desc: "Page render" });
357
391
  timings.push({ name: "mw", dur: performance.now() - mwStart, desc: "Total middleware" });
392
+ pushMetricsTimings(timings, metrics);
358
393
  return finalizeResponse(response, timings);
359
394
  };
360
395
  if (anonScoped) {
361
396
  const parent = getRequestContext();
362
397
  const ctx = parent
363
398
  ? { ...parent, db: anonScoped.db }
364
- : { editMode: false, db: anonScoped.db };
399
+ : { editMode: false, db: anonScoped.db, metrics };
365
400
  return runWithContext(ctx, async () => {
366
401
  const response = await runAnon();
367
402
  anonScoped.commit();
@@ -516,12 +551,15 @@ export const onRequest = defineMiddleware(async (context, next) => {
516
551
  const response = await next();
517
552
  timings.push({ name: "render", dur: performance.now() - t0, desc: "Page render" });
518
553
  timings.push({ name: "mw", dur: performance.now() - mwStart, desc: "Total middleware" });
554
+ pushMetricsTimings(timings, metrics);
519
555
  return finalizeResponse(response, timings);
520
556
  };
521
557
 
522
558
  if (scoped) {
523
559
  const parent = getRequestContext();
524
- const ctx = parent ? { ...parent, db: scoped.db } : { editMode: false, db: scoped.db };
560
+ const ctx = parent
561
+ ? { ...parent, db: scoped.db }
562
+ : { editMode: false, db: scoped.db, metrics };
525
563
  return runWithContext(ctx, async () => {
526
564
  const response = await renderAndFinalize();
527
565
  scoped.commit();
@@ -542,20 +580,17 @@ export const onRequest = defineMiddleware(async (context, next) => {
542
580
  const parent = getRequestContext();
543
581
  const ctx = parent
544
582
  ? { ...parent, editMode, db: playgroundDb, dbIsIsolated: true }
545
- : { editMode, db: playgroundDb, dbIsIsolated: true };
583
+ : { editMode, db: playgroundDb, dbIsIsolated: true, metrics };
546
584
  return runWithContext(ctx, doInit);
547
585
  }
548
586
  return doInit();
549
587
  };
550
588
 
551
- if (queryRecorder) {
552
- try {
553
- return await runWithContext({ editMode: false, queryRecorder }, run);
554
- } finally {
555
- flushRecorder(queryRecorder);
556
- }
589
+ try {
590
+ return await runWithContext({ editMode: false, queryRecorder, metrics }, run);
591
+ } finally {
592
+ if (queryRecorder) flushRecorder(queryRecorder);
557
593
  }
558
- return run();
559
594
  });
560
595
 
561
596
  export default onRequest;
@@ -16,7 +16,7 @@ export const DELETE: APIRoute = async ({ params, locals, cache }) => {
16
16
  const collection = params.collection!;
17
17
  const id = params.id!;
18
18
 
19
- const denied = requirePerm(user, "import:execute");
19
+ const denied = requirePerm(user, "content:delete_permanent");
20
20
  if (denied) return denied;
21
21
 
22
22
  if (!emdash?.handleContentPermanentDelete) {