emdash 0.10.0 → 0.11.1

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 (148) hide show
  1. package/dist/{apply-UsrFuO7l.mjs → apply-Ded_1vng.mjs} +36 -25
  2. package/dist/{apply-UsrFuO7l.mjs.map → apply-Ded_1vng.mjs.map} +1 -1
  3. package/dist/astro/index.d.mts +5 -5
  4. package/dist/astro/index.mjs +1 -1
  5. package/dist/astro/middleware/auth.d.mts +5 -5
  6. package/dist/astro/middleware/redirect.mjs +2 -2
  7. package/dist/astro/middleware.d.mts.map +1 -1
  8. package/dist/astro/middleware.mjs +83 -33
  9. package/dist/astro/middleware.mjs.map +1 -1
  10. package/dist/astro/types.d.mts +10 -7
  11. package/dist/astro/types.d.mts.map +1 -1
  12. package/dist/{byline-C3vnhIpU.mjs → byline-gFn1r0vA.mjs} +2 -2
  13. package/dist/{byline-C3vnhIpU.mjs.map → byline-gFn1r0vA.mjs.map} +1 -1
  14. package/dist/{bylines-esI7ioa9.mjs → bylines-DTFI8nDM.mjs} +4 -4
  15. package/dist/{bylines-esI7ioa9.mjs.map → bylines-DTFI8nDM.mjs.map} +1 -1
  16. package/dist/{cache-fTzxgMFJ.mjs → cache-BAJbeoZ8.mjs} +2 -2
  17. package/dist/{cache-fTzxgMFJ.mjs.map → cache-BAJbeoZ8.mjs.map} +1 -1
  18. package/dist/{chunks-Da2-b-oA.mjs → chunks-BK1oZS-l.mjs} +2 -2
  19. package/dist/{chunks-Da2-b-oA.mjs.map → chunks-BK1oZS-l.mjs.map} +1 -1
  20. package/dist/cli/index.mjs +102 -27
  21. package/dist/cli/index.mjs.map +1 -1
  22. package/dist/{content-C7G4QXkK.mjs → content-CERxPUN0.mjs} +2 -2
  23. package/dist/{content-C7G4QXkK.mjs.map → content-CERxPUN0.mjs.map} +1 -1
  24. package/dist/database/instrumentation.d.mts +6 -4
  25. package/dist/database/instrumentation.d.mts.map +1 -1
  26. package/dist/database/instrumentation.mjs +19 -7
  27. package/dist/database/instrumentation.mjs.map +1 -1
  28. package/dist/db/index.d.mts +2 -2
  29. package/dist/db/index.mjs +1 -1
  30. package/dist/{index-DjPMOfO0.d.mts → index-BogfvE-z.d.mts} +32 -24
  31. package/dist/index-BogfvE-z.d.mts.map +1 -0
  32. package/dist/index.d.mts +7 -7
  33. package/dist/index.mjs +19 -19
  34. package/dist/{load-sXRuM7Us.mjs → load-DR1VwFXR.mjs} +2 -2
  35. package/dist/{load-sXRuM7Us.mjs.map → load-DR1VwFXR.mjs.map} +1 -1
  36. package/dist/{loader-Bx2_9-5e.mjs → loader-ou_PXAjg.mjs} +2 -2
  37. package/dist/{loader-Bx2_9-5e.mjs.map → loader-ou_PXAjg.mjs.map} +1 -1
  38. package/dist/media/local-runtime.d.mts +5 -5
  39. package/dist/media/local-runtime.mjs +1 -1
  40. package/dist/{media-D8FbNsl0.mjs → media-1fFhub9c.mjs} +21 -9
  41. package/dist/media-1fFhub9c.mjs.map +1 -0
  42. package/dist/page/index.d.mts +2 -2
  43. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  44. package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
  45. package/dist/{query-Bo-msrmu.mjs → query-8c_meo_K.mjs} +10 -10
  46. package/dist/{query-Bo-msrmu.mjs.map → query-8c_meo_K.mjs.map} +1 -1
  47. package/dist/{registry-Beb7wxFc.mjs → registry-Do34mz_P.mjs} +6 -5
  48. package/dist/registry-Do34mz_P.mjs.map +1 -0
  49. package/dist/{request-cache-C-tIpYIw.mjs → request-cache-D4I69LeL.mjs} +6 -2
  50. package/dist/request-cache-D4I69LeL.mjs.map +1 -0
  51. package/dist/request-context.d.mts +27 -1
  52. package/dist/request-context.d.mts.map +1 -1
  53. package/dist/request-context.mjs +16 -3
  54. package/dist/request-context.mjs.map +1 -1
  55. package/dist/{runner-DMnlIkh4.mjs → runner-DIcU2UCC.mjs} +174 -152
  56. package/dist/runner-DIcU2UCC.mjs.map +1 -0
  57. package/dist/{runner-Clwe4Mme.d.mts → runner-Iu3IZSDM.d.mts} +2 -2
  58. package/dist/{runner-Clwe4Mme.d.mts.map → runner-Iu3IZSDM.d.mts.map} +1 -1
  59. package/dist/runtime.d.mts +5 -5
  60. package/dist/runtime.mjs +1 -1
  61. package/dist/{search-DkN-BqsS.mjs → search-DuWhx4NG.mjs} +172 -30
  62. package/dist/search-DuWhx4NG.mjs.map +1 -0
  63. package/dist/seed/index.d.mts +2 -2
  64. package/dist/seed/index.mjs +10 -10
  65. package/dist/{taxonomies-CTtewrSQ.mjs → taxonomies-Bw76xAxo.mjs} +6 -6
  66. package/dist/{taxonomies-CTtewrSQ.mjs.map → taxonomies-Bw76xAxo.mjs.map} +1 -1
  67. package/dist/{taxonomy-DSxx2K2L.mjs → taxonomy-D6NvlKo8.mjs} +3 -3
  68. package/dist/{taxonomy-DSxx2K2L.mjs.map → taxonomy-D6NvlKo8.mjs.map} +1 -1
  69. package/dist/{types-Eg829jj9.mjs → types-56BKbld_.mjs} +1 -1
  70. package/dist/types-56BKbld_.mjs.map +1 -0
  71. package/dist/{types-Dtx1mSMX.d.mts → types-BQx6ZXpR.d.mts} +2 -1
  72. package/dist/types-BQx6ZXpR.d.mts.map +1 -0
  73. package/dist/{types-Dl1fgFjn.d.mts → types-BTe41zL6.d.mts} +4 -3
  74. package/dist/types-BTe41zL6.d.mts.map +1 -0
  75. package/dist/types-DiI8NOG_.mjs +16 -0
  76. package/dist/types-DiI8NOG_.mjs.map +1 -0
  77. package/dist/{types-D19uBYWn.d.mts → types-IjUrQMVe.d.mts} +21 -245
  78. package/dist/types-IjUrQMVe.d.mts.map +1 -0
  79. package/dist/{validate-DHGwADqO.d.mts → validate-CcVQQpmH.d.mts} +7 -3
  80. package/dist/validate-CcVQQpmH.d.mts.map +1 -0
  81. package/dist/{validate-CBIbxM3L.mjs → validate-UK4Ja1uo.mjs} +3 -3
  82. package/dist/{validate-CBIbxM3L.mjs.map → validate-UK4Ja1uo.mjs.map} +1 -1
  83. package/dist/{validation-B1NYiEos.mjs → validation-Vc5DQkJa.mjs} +4 -4
  84. package/dist/{validation-B1NYiEos.mjs.map → validation-Vc5DQkJa.mjs.map} +1 -1
  85. package/dist/version-JjSqv90m.mjs +7 -0
  86. package/dist/{version-CMD42IRC.mjs.map → version-JjSqv90m.mjs.map} +1 -1
  87. package/dist/{zod-generator-BNJDQBSZ.mjs → zod-generator-CHnJUP2l.mjs} +1 -1
  88. package/dist/{zod-generator-BNJDQBSZ.mjs.map → zod-generator-CHnJUP2l.mjs.map} +1 -1
  89. package/package.json +9 -8
  90. package/src/api/errors.ts +5 -0
  91. package/src/api/handlers/content.ts +9 -0
  92. package/src/api/handlers/media-allowlist.ts +40 -0
  93. package/src/api/handlers/media.ts +1 -1
  94. package/src/api/handlers/menus.ts +158 -28
  95. package/src/api/handlers/validate-media-fields.ts +125 -0
  96. package/src/api/schemas/media.ts +23 -3
  97. package/src/api/schemas/schema.ts +11 -2
  98. package/src/astro/middleware.ts +46 -11
  99. package/src/astro/routes/api/content/[collection]/[id]/discard-draft.ts +1 -1
  100. package/src/astro/routes/api/content/[collection]/[id]/duplicate.ts +1 -1
  101. package/src/astro/routes/api/content/[collection]/[id]/permanent.ts +1 -1
  102. package/src/astro/routes/api/content/[collection]/[id]/publish.ts +1 -1
  103. package/src/astro/routes/api/content/[collection]/[id]/restore.ts +1 -1
  104. package/src/astro/routes/api/content/[collection]/[id]/schedule.ts +2 -2
  105. package/src/astro/routes/api/content/[collection]/[id]/unpublish.ts +1 -1
  106. package/src/astro/routes/api/content/[collection]/[id].ts +2 -2
  107. package/src/astro/routes/api/content/[collection]/index.ts +1 -1
  108. package/src/astro/routes/api/media/upload-url.ts +10 -4
  109. package/src/astro/routes/api/media.ts +12 -4
  110. package/src/astro/types.ts +5 -1
  111. package/src/auth/rate-limit.ts +3 -3
  112. package/src/cli/commands/bundle-utils.ts +81 -6
  113. package/src/cli/commands/bundle.ts +18 -15
  114. package/src/cli/commands/export-seed.ts +57 -3
  115. package/src/database/instrumentation.ts +22 -8
  116. package/src/database/migrations/016_api_tokens.ts +18 -3
  117. package/src/database/migrations/037_credential_algorithm.ts +18 -0
  118. package/src/database/migrations/runner.ts +2 -0
  119. package/src/database/repositories/media.ts +40 -10
  120. package/src/database/types.ts +2 -1
  121. package/src/emdash-runtime.ts +16 -3
  122. package/src/fields/file.ts +7 -6
  123. package/src/fields/image.ts +12 -11
  124. package/src/fields/types.ts +3 -0
  125. package/src/index.ts +1 -1
  126. package/src/mcp/server.ts +37 -8
  127. package/src/media/mime.ts +75 -0
  128. package/src/plugins/types.ts +81 -191
  129. package/src/request-cache.ts +6 -2
  130. package/src/request-context.ts +42 -2
  131. package/src/schema/registry.ts +5 -5
  132. package/src/schema/types.ts +3 -2
  133. package/src/seed/apply.ts +25 -8
  134. package/src/seed/types.ts +4 -0
  135. package/dist/index-DjPMOfO0.d.mts.map +0 -1
  136. package/dist/media-D8FbNsl0.mjs.map +0 -1
  137. package/dist/registry-Beb7wxFc.mjs.map +0 -1
  138. package/dist/request-cache-C-tIpYIw.mjs.map +0 -1
  139. package/dist/runner-DMnlIkh4.mjs.map +0 -1
  140. package/dist/search-DkN-BqsS.mjs.map +0 -1
  141. package/dist/types-CoO6mpV3.mjs +0 -68
  142. package/dist/types-CoO6mpV3.mjs.map +0 -1
  143. package/dist/types-D19uBYWn.d.mts.map +0 -1
  144. package/dist/types-Dl1fgFjn.d.mts.map +0 -1
  145. package/dist/types-Dtx1mSMX.d.mts.map +0 -1
  146. package/dist/types-Eg829jj9.mjs.map +0 -1
  147. package/dist/validate-DHGwADqO.d.mts.map +0 -1
  148. package/dist/version-CMD42IRC.mjs +0 -7
@@ -1 +1 @@
1
- {"version":3,"file":"version-CMD42IRC.mjs","names":[],"sources":["../src/version.ts"],"sourcesContent":["/**\n * Build-time version constants, replaced by tsdown/Vite `define`.\n * Falls back to \"dev\" when running uncompiled (tests, dev).\n */\n\ndeclare const __EMDASH_VERSION__: string;\ndeclare const __EMDASH_COMMIT__: string;\n\nexport const VERSION: string =\n\ttypeof __EMDASH_VERSION__ !== \"undefined\" ? __EMDASH_VERSION__ : \"dev\";\n\nexport const COMMIT: string = typeof __EMDASH_COMMIT__ !== \"undefined\" ? __EMDASH_COMMIT__ : \"dev\";\n"],"mappings":";AAQA,MAAa;AAGb,MAAa"}
1
+ {"version":3,"file":"version-JjSqv90m.mjs","names":[],"sources":["../src/version.ts"],"sourcesContent":["/**\n * Build-time version constants, replaced by tsdown/Vite `define`.\n * Falls back to \"dev\" when running uncompiled (tests, dev).\n */\n\ndeclare const __EMDASH_VERSION__: string;\ndeclare const __EMDASH_COMMIT__: string;\n\nexport const VERSION: string =\n\ttypeof __EMDASH_VERSION__ !== \"undefined\" ? __EMDASH_VERSION__ : \"dev\";\n\nexport const COMMIT: string = typeof __EMDASH_COMMIT__ !== \"undefined\" ? __EMDASH_COMMIT__ : \"dev\";\n"],"mappings":";AAQA,MAAa;AAGb,MAAa"}
@@ -134,4 +134,4 @@ function applyValidation(schema, field) {
134
134
 
135
135
  //#endregion
136
136
  export { computeContentHash as n, hashString as r, generateZodSchema as t };
137
- //# sourceMappingURL=zod-generator-BNJDQBSZ.mjs.map
137
+ //# sourceMappingURL=zod-generator-CHnJUP2l.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"zod-generator-BNJDQBSZ.mjs","names":[],"sources":["../src/utils/hash.ts","../src/schema/zod-generator.ts"],"sourcesContent":["/**\n * SHA-256 hash of a string, truncated to 16 hex chars (64 bits).\n * For cache invalidation / ETags — not for security.\n */\nexport async function hashString(content: string): Promise<string> {\n\tconst buf = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(content));\n\treturn Array.from(new Uint8Array(buf).slice(0, 8), (b) => b.toString(16).padStart(2, \"0\")).join(\n\t\t\"\",\n\t);\n}\n\n/**\n * Compute content hash using Web Crypto API\n *\n * Uses SHA-1 which is the fastest option in SubtleCrypto.\n * SHA-1 is cryptographically weak but fine for content deduplication\n * where we only need to detect identical files, not resist attacks.\n *\n * Returns hex string prefixed with \"sha1:\" for future-proofing\n */\nexport async function computeContentHash(content: Uint8Array | ArrayBuffer): Promise<string> {\n\t// SubtleCrypto.digest() requires BufferSource (ArrayBuffer | ArrayBufferView<ArrayBuffer>).\n\t// Uint8Array.buffer is ArrayBufferLike which may include SharedArrayBuffer in the type system,\n\t// so we ensure we have a plain ArrayBuffer.\n\tlet buf: ArrayBuffer;\n\tif (content instanceof ArrayBuffer) {\n\t\tbuf = content;\n\t} else {\n\t\tbuf = new ArrayBuffer(content.byteLength);\n\t\tnew Uint8Array(buf).set(content);\n\t}\n\tconst hashBuffer = await crypto.subtle.digest(\"SHA-1\", buf);\n\tconst hashArray = new Uint8Array(hashBuffer);\n\tconst hashHex = Array.from(hashArray, (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n\treturn `sha1:${hashHex}`;\n}\n","import { z, type ZodTypeAny } from \"zod\";\n\nimport { hashString } from \"../utils/hash.js\";\nimport type { Field, FieldType, CollectionWithFields } from \"./types.js\";\n\n/** Pattern to split on underscores, hyphens, and spaces for PascalCase conversion */\nconst PASCAL_CASE_SPLIT_PATTERN = /[_\\-\\s]+/;\n\n/**\n * Generate a Zod schema from a collection's field definitions\n *\n * This allows runtime validation of content based on dynamically\n * defined schemas stored in D1.\n */\nexport function generateZodSchema(\n\tcollection: CollectionWithFields,\n): z.ZodObject<Record<string, ZodTypeAny>> {\n\tconst shape: Record<string, ZodTypeAny> = {};\n\n\tfor (const field of collection.fields) {\n\t\tshape[field.slug] = generateFieldSchema(field);\n\t}\n\n\treturn z.object(shape);\n}\n\n/**\n * Generate Zod schema for a single field\n */\nexport function generateFieldSchema(field: Field): ZodTypeAny {\n\tlet schema = getBaseSchema(field.type, field);\n\n\t// Apply validation rules\n\tif (field.validation) {\n\t\tschema = applyValidation(schema, field);\n\t}\n\n\t// Apply required/optional. Non-required fields use `.nullish()` rather\n\t// than `.optional()` because the underlying SQLite columns are nullable\n\t// (see `SchemaRegistry.addFieldColumn` -- non-required fields are added\n\t// without `NOT NULL`). The admin re-sends what it loaded from the\n\t// server on autosave, so any field that's actually `null` in the DB\n\t// must round-trip cleanly through the validator. `.optional()` only\n\t// accepts `undefined`; `.nullish()` accepts both `undefined` and\n\t// `null`. (#867 — autosave failures on seeded entries.)\n\tif (!field.required) {\n\t\tschema = schema.nullish();\n\t}\n\n\t// Apply default value\n\tif (field.defaultValue !== undefined) {\n\t\tschema = schema.default(field.defaultValue);\n\t}\n\n\treturn schema;\n}\n\n/**\n * Get base Zod schema for a field type\n */\nfunction getBaseSchema(type: FieldType, field: Field): ZodTypeAny {\n\tswitch (type) {\n\t\tcase \"url\":\n\t\t\treturn z.string().url();\n\n\t\tcase \"string\":\n\t\tcase \"text\":\n\t\tcase \"slug\":\n\t\t\treturn z.string();\n\n\t\tcase \"number\":\n\t\t\treturn z.number();\n\n\t\tcase \"integer\":\n\t\t\treturn z.number().int();\n\n\t\tcase \"boolean\":\n\t\t\t// Boolean fields map to `INTEGER` columns (`FIELD_TYPE_TO_COLUMN`\n\t\t\t// in `schema/types.ts`) and `serializeValue` in\n\t\t\t// `database/repositories/content.ts` writes booleans as 0/1.\n\t\t\t// `deserializeValue` never converts them back, so reads return\n\t\t\t// numbers. Coerce the stored 0/1 shape here so a GET → POST\n\t\t\t// round-trip on a boolean field passes validation. Other inputs\n\t\t\t// (strings, other numbers) fall through to `z.boolean()` and\n\t\t\t// produce its standard rejection.\n\t\t\treturn z.preprocess((v) => (v === 0 || v === 1 ? Boolean(v) : v), z.boolean());\n\n\t\tcase \"datetime\":\n\t\t\treturn z.string().datetime().or(z.string().date());\n\n\t\tcase \"select\": {\n\t\t\tconst options = field.validation?.options;\n\t\t\tif (options && options.length > 0) {\n\t\t\t\tconst [first, ...rest] = options;\n\t\t\t\treturn z.enum([first, ...rest]);\n\t\t\t}\n\t\t\treturn z.string();\n\t\t}\n\n\t\tcase \"multiSelect\": {\n\t\t\tconst multiOptions = field.validation?.options;\n\t\t\tif (multiOptions && multiOptions.length > 0) {\n\t\t\t\tconst [first, ...rest] = multiOptions;\n\t\t\t\treturn z.array(z.enum([first, ...rest]));\n\t\t\t}\n\t\t\treturn z.array(z.string());\n\t\t}\n\n\t\tcase \"portableText\":\n\t\t\t// Portable Text is an array of blocks. We require `_type` because\n\t\t\t// renderers dispatch on it, but `_key` is intentionally optional:\n\t\t\t// it's a UI-layer concern that the editor regenerates on every\n\t\t\t// change (see `PortableTextEditor`), and the rest of this schema\n\t\t\t// uses `.passthrough()` for everything below the top level. Making\n\t\t\t// `_key` strictly required here was an accidentally tight invariant\n\t\t\t// that rejected any seed/import data not authored against the\n\t\t\t// editor (#867 — autosave failures on seeded template content).\n\t\t\treturn z.array(\n\t\t\t\tz\n\t\t\t\t\t.object({\n\t\t\t\t\t\t_type: z.string(),\n\t\t\t\t\t\t_key: z.string().optional(),\n\t\t\t\t\t})\n\t\t\t\t\t.passthrough(),\n\t\t\t);\n\n\t\tcase \"image\":\n\t\t\treturn z.object({\n\t\t\t\tid: z.string(),\n\t\t\t\tsrc: z.string().optional(),\n\t\t\t\talt: z.string().optional(),\n\t\t\t\twidth: z.number().optional(),\n\t\t\t\theight: z.number().optional(),\n\t\t\t\t/** Provider ID (e.g. \"local\", \"cloudflare-images\") */\n\t\t\t\tprovider: z.string().optional(),\n\t\t\t\t/** Admin-side preview URL for external providers (not persisted by plugins) */\n\t\t\t\tpreviewUrl: z.string().optional(),\n\t\t\t\t/** Provider-specific metadata; for local media this carries storageKey */\n\t\t\t\tmeta: z.record(z.string(), z.unknown()).optional(),\n\t\t\t});\n\n\t\tcase \"file\":\n\t\t\treturn z.object({\n\t\t\t\tid: z.string(),\n\t\t\t\tsrc: z.string().optional(),\n\t\t\t\tfilename: z.string().optional(),\n\t\t\t\tmimeType: z.string().optional(),\n\t\t\t\tsize: z.number().optional(),\n\t\t\t\t/** Provider ID (e.g. \"local\", \"s3\") */\n\t\t\t\tprovider: z.string().optional(),\n\t\t\t\t/** Provider-specific metadata; for local media this carries storageKey */\n\t\t\t\tmeta: z.record(z.string(), z.unknown()).optional(),\n\t\t\t});\n\n\t\tcase \"reference\":\n\t\t\treturn z.string(); // Reference ID\n\n\t\tcase \"json\":\n\t\t\treturn z.unknown();\n\n\t\tdefault:\n\t\t\treturn z.unknown();\n\t}\n}\n\n/**\n * Apply validation rules to a schema\n */\nfunction applyValidation(schema: ZodTypeAny, field: Field): ZodTypeAny {\n\tconst validation = field.validation;\n\tif (!validation) return schema;\n\n\t// String validations\n\tif (schema instanceof z.ZodString) {\n\t\tlet strSchema = schema;\n\t\tif (validation.minLength !== undefined) {\n\t\t\tstrSchema = strSchema.min(validation.minLength);\n\t\t}\n\t\tif (validation.maxLength !== undefined) {\n\t\t\tstrSchema = strSchema.max(validation.maxLength);\n\t\t}\n\t\tif (validation.pattern) {\n\t\t\tstrSchema = strSchema.regex(new RegExp(validation.pattern));\n\t\t}\n\t\treturn strSchema;\n\t}\n\n\t// Number validations\n\tif (schema instanceof z.ZodNumber) {\n\t\tlet numSchema = schema;\n\t\tif (validation.min !== undefined) {\n\t\t\tnumSchema = numSchema.min(validation.min);\n\t\t}\n\t\tif (validation.max !== undefined) {\n\t\t\tnumSchema = numSchema.max(validation.max);\n\t\t}\n\t\treturn numSchema;\n\t}\n\n\treturn schema;\n}\n\n/**\n * Schema cache to avoid regenerating schemas on every request\n */\nconst schemaCache = new Map<string, { schema: z.ZodObject<any>; version: string }>();\n\n/**\n * Get or generate a cached schema for a collection\n */\nexport function getCachedSchema(\n\tcollection: CollectionWithFields,\n\tversion?: string,\n): z.ZodObject<any> {\n\tconst cacheKey = collection.slug;\n\tconst cached = schemaCache.get(cacheKey);\n\n\t// If version matches, return cached schema\n\tif (cached && (!version || cached.version === version)) {\n\t\treturn cached.schema;\n\t}\n\n\t// Generate new schema\n\tconst schema = generateZodSchema(collection);\n\n\t// Cache it\n\tschemaCache.set(cacheKey, {\n\t\tschema,\n\t\tversion: version || collection.updatedAt,\n\t});\n\n\treturn schema;\n}\n\n/**\n * Invalidate cached schema for a collection\n */\nexport function invalidateSchemaCache(slug: string): void {\n\tschemaCache.delete(slug);\n}\n\n/**\n * Clear all cached schemas\n */\nexport function clearSchemaCache(): void {\n\tschemaCache.clear();\n}\n\n/**\n * Validate data against a collection's schema\n */\nexport function validateContent(\n\tcollection: CollectionWithFields,\n\tdata: unknown,\n): { success: true; data: unknown } | { success: false; errors: z.ZodError } {\n\tconst schema = getCachedSchema(collection);\n\n\tconst result = schema.safeParse(data);\n\n\tif (result.success) {\n\t\treturn { success: true, data: result.data };\n\t}\n\n\treturn { success: false, errors: result.error };\n}\n\n/**\n * Generate TypeScript interface from field definitions\n * Used by CLI `emdash types` to generate types\n */\nexport function generateTypeScript(collection: CollectionWithFields): string {\n\tconst interfaceName = getInterfaceName(collection);\n\tconst lines: string[] = [];\n\n\tlines.push(`export interface ${interfaceName} {`);\n\tlines.push(` id: string;`);\n\tlines.push(` slug: string | null;`);\n\tlines.push(` status: string;`);\n\n\tfor (const field of collection.fields) {\n\t\tconst tsType = fieldTypeToTypeScript(field);\n\t\tconst optional = field.required ? \"\" : \"?\";\n\t\tlines.push(` ${field.slug}${optional}: ${tsType};`);\n\t}\n\n\tlines.push(` createdAt: Date;`);\n\tlines.push(` updatedAt: Date;`);\n\tlines.push(` publishedAt: Date | null;`);\n\t// Bylines are eagerly loaded by getEmDashCollection/getEmDashEntry\n\tlines.push(` bylines?: ContentBylineCredit[];`);\n\tlines.push(`}`);\n\n\treturn lines.join(\"\\n\");\n}\n\n/**\n * Generate a complete types file with module augmentation\n * This produces emdash-env.d.ts content that provides typed query functions\n */\nexport function generateTypesFile(collections: CollectionWithFields[]): string {\n\tconst lines: string[] = [];\n\n\t// Header\n\tlines.push(`// Generated by EmDash on dev server start`);\n\tlines.push(`// Do not edit manually`);\n\tlines.push(``);\n\tlines.push(`/// <reference types=\"emdash/locals\" />`);\n\tlines.push(``);\n\n\t// Check if we need PortableTextBlock import\n\tconst needsPortableText = collections.some((c) =>\n\t\tc.fields.some((f) => f.type === \"portableText\"),\n\t);\n\n\t// Build imports - ContentBylineCredit is always needed for bylines\n\tconst imports = [\"ContentBylineCredit\"];\n\tif (needsPortableText) {\n\t\timports.push(\"PortableTextBlock\");\n\t}\n\tlines.push(`import type { ${imports.join(\", \")} } from \"emdash\";`);\n\tlines.push(``);\n\n\t// Generate individual interfaces\n\tfor (const collection of collections) {\n\t\tlines.push(generateTypeScript(collection));\n\t\tlines.push(``);\n\t}\n\n\t// Generate the Collections interface for module augmentation\n\tlines.push(`declare module \"emdash\" {`);\n\tlines.push(` interface EmDashCollections {`);\n\tfor (const collection of collections) {\n\t\tconst interfaceName = getInterfaceName(collection);\n\t\tlines.push(` ${collection.slug}: ${interfaceName};`);\n\t}\n\tlines.push(` }`);\n\tlines.push(`}`);\n\n\treturn lines.join(\"\\n\");\n}\n\n/**\n * Generate schema hash for cache invalidation\n */\nexport async function generateSchemaHash(collections: CollectionWithFields[]): Promise<string> {\n\tconst str = JSON.stringify(\n\t\tcollections.map((c) => ({\n\t\t\tslug: c.slug,\n\t\t\tfields: c.fields.map((f) => ({\n\t\t\t\tslug: f.slug,\n\t\t\t\ttype: f.type,\n\t\t\t\trequired: f.required,\n\t\t\t\tvalidation: f.validation,\n\t\t\t})),\n\t\t})),\n\t);\n\treturn hashString(str);\n}\n\n/**\n * Map field type to TypeScript type\n */\nfunction fieldTypeToTypeScript(field: Field): string {\n\tswitch (field.type) {\n\t\tcase \"string\":\n\t\tcase \"text\":\n\t\tcase \"slug\":\n\t\tcase \"url\":\n\t\tcase \"datetime\":\n\t\t\treturn \"string\";\n\n\t\tcase \"number\":\n\t\tcase \"integer\":\n\t\t\treturn \"number\";\n\n\t\tcase \"boolean\":\n\t\t\treturn \"boolean\";\n\n\t\tcase \"select\":\n\t\t\tconst options = field.validation?.options;\n\t\t\tif (options && options.length > 0) {\n\t\t\t\treturn options.map((o) => `\"${o}\"`).join(\" | \");\n\t\t\t}\n\t\t\treturn \"string\";\n\n\t\tcase \"multiSelect\":\n\t\t\tconst multiOptions = field.validation?.options;\n\t\t\tif (multiOptions && multiOptions.length > 0) {\n\t\t\t\treturn `(${multiOptions.map((o) => `\"${o}\"`).join(\" | \")})[]`;\n\t\t\t}\n\t\t\treturn \"string[]\";\n\n\t\tcase \"portableText\":\n\t\t\treturn \"PortableTextBlock[]\";\n\n\t\tcase \"image\":\n\t\t\treturn \"{ id: string; src?: string; alt?: string; width?: number; height?: number; provider?: string; previewUrl?: string; meta?: Record<string, unknown> }\";\n\n\t\tcase \"file\":\n\t\t\treturn \"{ id: string; src?: string; filename?: string; mimeType?: string; size?: number; provider?: string; meta?: Record<string, unknown> }\";\n\n\t\tcase \"reference\":\n\t\t\t// Could be enhanced to include the referenced collection type\n\t\t\treturn \"string\";\n\n\t\tcase \"json\":\n\t\t\treturn \"unknown\";\n\n\t\tdefault:\n\t\t\treturn \"unknown\";\n\t}\n}\n\n/**\n * Convert string to PascalCase (handles slugs, spaces, etc.)\n */\nfunction pascalCase(str: string): string {\n\treturn str\n\t\t.split(PASCAL_CASE_SPLIT_PATTERN)\n\t\t.filter(Boolean)\n\t\t.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())\n\t\t.join(\"\");\n}\n\n/**\n * Simple singularization - handles common cases\n */\nfunction singularize(str: string): string {\n\tif (str.endsWith(\"ies\")) {\n\t\treturn str.slice(0, -3) + \"y\";\n\t}\n\tif (\n\t\tstr.endsWith(\"es\") &&\n\t\t(str.endsWith(\"sses\") || str.endsWith(\"xes\") || str.endsWith(\"ches\") || str.endsWith(\"shes\"))\n\t) {\n\t\treturn str.slice(0, -2);\n\t}\n\tif (str.endsWith(\"s\") && !str.endsWith(\"ss\")) {\n\t\treturn str.slice(0, -1);\n\t}\n\treturn str;\n}\n\n/**\n * Get the interface name for a collection\n */\nfunction getInterfaceName(collection: CollectionWithFields): string {\n\treturn pascalCase(collection.labelSingular || singularize(collection.slug));\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"zod-generator-CHnJUP2l.mjs","names":[],"sources":["../src/utils/hash.ts","../src/schema/zod-generator.ts"],"sourcesContent":["/**\n * SHA-256 hash of a string, truncated to 16 hex chars (64 bits).\n * For cache invalidation / ETags — not for security.\n */\nexport async function hashString(content: string): Promise<string> {\n\tconst buf = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(content));\n\treturn Array.from(new Uint8Array(buf).slice(0, 8), (b) => b.toString(16).padStart(2, \"0\")).join(\n\t\t\"\",\n\t);\n}\n\n/**\n * Compute content hash using Web Crypto API\n *\n * Uses SHA-1 which is the fastest option in SubtleCrypto.\n * SHA-1 is cryptographically weak but fine for content deduplication\n * where we only need to detect identical files, not resist attacks.\n *\n * Returns hex string prefixed with \"sha1:\" for future-proofing\n */\nexport async function computeContentHash(content: Uint8Array | ArrayBuffer): Promise<string> {\n\t// SubtleCrypto.digest() requires BufferSource (ArrayBuffer | ArrayBufferView<ArrayBuffer>).\n\t// Uint8Array.buffer is ArrayBufferLike which may include SharedArrayBuffer in the type system,\n\t// so we ensure we have a plain ArrayBuffer.\n\tlet buf: ArrayBuffer;\n\tif (content instanceof ArrayBuffer) {\n\t\tbuf = content;\n\t} else {\n\t\tbuf = new ArrayBuffer(content.byteLength);\n\t\tnew Uint8Array(buf).set(content);\n\t}\n\tconst hashBuffer = await crypto.subtle.digest(\"SHA-1\", buf);\n\tconst hashArray = new Uint8Array(hashBuffer);\n\tconst hashHex = Array.from(hashArray, (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n\treturn `sha1:${hashHex}`;\n}\n","import { z, type ZodTypeAny } from \"zod\";\n\nimport { hashString } from \"../utils/hash.js\";\nimport type { Field, FieldType, CollectionWithFields } from \"./types.js\";\n\n/** Pattern to split on underscores, hyphens, and spaces for PascalCase conversion */\nconst PASCAL_CASE_SPLIT_PATTERN = /[_\\-\\s]+/;\n\n/**\n * Generate a Zod schema from a collection's field definitions\n *\n * This allows runtime validation of content based on dynamically\n * defined schemas stored in D1.\n */\nexport function generateZodSchema(\n\tcollection: CollectionWithFields,\n): z.ZodObject<Record<string, ZodTypeAny>> {\n\tconst shape: Record<string, ZodTypeAny> = {};\n\n\tfor (const field of collection.fields) {\n\t\tshape[field.slug] = generateFieldSchema(field);\n\t}\n\n\treturn z.object(shape);\n}\n\n/**\n * Generate Zod schema for a single field\n */\nexport function generateFieldSchema(field: Field): ZodTypeAny {\n\tlet schema = getBaseSchema(field.type, field);\n\n\t// Apply validation rules\n\tif (field.validation) {\n\t\tschema = applyValidation(schema, field);\n\t}\n\n\t// Apply required/optional. Non-required fields use `.nullish()` rather\n\t// than `.optional()` because the underlying SQLite columns are nullable\n\t// (see `SchemaRegistry.addFieldColumn` -- non-required fields are added\n\t// without `NOT NULL`). The admin re-sends what it loaded from the\n\t// server on autosave, so any field that's actually `null` in the DB\n\t// must round-trip cleanly through the validator. `.optional()` only\n\t// accepts `undefined`; `.nullish()` accepts both `undefined` and\n\t// `null`. (#867 — autosave failures on seeded entries.)\n\tif (!field.required) {\n\t\tschema = schema.nullish();\n\t}\n\n\t// Apply default value\n\tif (field.defaultValue !== undefined) {\n\t\tschema = schema.default(field.defaultValue);\n\t}\n\n\treturn schema;\n}\n\n/**\n * Get base Zod schema for a field type\n */\nfunction getBaseSchema(type: FieldType, field: Field): ZodTypeAny {\n\tswitch (type) {\n\t\tcase \"url\":\n\t\t\treturn z.string().url();\n\n\t\tcase \"string\":\n\t\tcase \"text\":\n\t\tcase \"slug\":\n\t\t\treturn z.string();\n\n\t\tcase \"number\":\n\t\t\treturn z.number();\n\n\t\tcase \"integer\":\n\t\t\treturn z.number().int();\n\n\t\tcase \"boolean\":\n\t\t\t// Boolean fields map to `INTEGER` columns (`FIELD_TYPE_TO_COLUMN`\n\t\t\t// in `schema/types.ts`) and `serializeValue` in\n\t\t\t// `database/repositories/content.ts` writes booleans as 0/1.\n\t\t\t// `deserializeValue` never converts them back, so reads return\n\t\t\t// numbers. Coerce the stored 0/1 shape here so a GET → POST\n\t\t\t// round-trip on a boolean field passes validation. Other inputs\n\t\t\t// (strings, other numbers) fall through to `z.boolean()` and\n\t\t\t// produce its standard rejection.\n\t\t\treturn z.preprocess((v) => (v === 0 || v === 1 ? Boolean(v) : v), z.boolean());\n\n\t\tcase \"datetime\":\n\t\t\treturn z.string().datetime().or(z.string().date());\n\n\t\tcase \"select\": {\n\t\t\tconst options = field.validation?.options;\n\t\t\tif (options && options.length > 0) {\n\t\t\t\tconst [first, ...rest] = options;\n\t\t\t\treturn z.enum([first, ...rest]);\n\t\t\t}\n\t\t\treturn z.string();\n\t\t}\n\n\t\tcase \"multiSelect\": {\n\t\t\tconst multiOptions = field.validation?.options;\n\t\t\tif (multiOptions && multiOptions.length > 0) {\n\t\t\t\tconst [first, ...rest] = multiOptions;\n\t\t\t\treturn z.array(z.enum([first, ...rest]));\n\t\t\t}\n\t\t\treturn z.array(z.string());\n\t\t}\n\n\t\tcase \"portableText\":\n\t\t\t// Portable Text is an array of blocks. We require `_type` because\n\t\t\t// renderers dispatch on it, but `_key` is intentionally optional:\n\t\t\t// it's a UI-layer concern that the editor regenerates on every\n\t\t\t// change (see `PortableTextEditor`), and the rest of this schema\n\t\t\t// uses `.passthrough()` for everything below the top level. Making\n\t\t\t// `_key` strictly required here was an accidentally tight invariant\n\t\t\t// that rejected any seed/import data not authored against the\n\t\t\t// editor (#867 — autosave failures on seeded template content).\n\t\t\treturn z.array(\n\t\t\t\tz\n\t\t\t\t\t.object({\n\t\t\t\t\t\t_type: z.string(),\n\t\t\t\t\t\t_key: z.string().optional(),\n\t\t\t\t\t})\n\t\t\t\t\t.passthrough(),\n\t\t\t);\n\n\t\tcase \"image\":\n\t\t\treturn z.object({\n\t\t\t\tid: z.string(),\n\t\t\t\tsrc: z.string().optional(),\n\t\t\t\talt: z.string().optional(),\n\t\t\t\twidth: z.number().optional(),\n\t\t\t\theight: z.number().optional(),\n\t\t\t\t/** Provider ID (e.g. \"local\", \"cloudflare-images\") */\n\t\t\t\tprovider: z.string().optional(),\n\t\t\t\t/** Admin-side preview URL for external providers (not persisted by plugins) */\n\t\t\t\tpreviewUrl: z.string().optional(),\n\t\t\t\t/** Provider-specific metadata; for local media this carries storageKey */\n\t\t\t\tmeta: z.record(z.string(), z.unknown()).optional(),\n\t\t\t});\n\n\t\tcase \"file\":\n\t\t\treturn z.object({\n\t\t\t\tid: z.string(),\n\t\t\t\tsrc: z.string().optional(),\n\t\t\t\tfilename: z.string().optional(),\n\t\t\t\tmimeType: z.string().optional(),\n\t\t\t\tsize: z.number().optional(),\n\t\t\t\t/** Provider ID (e.g. \"local\", \"s3\") */\n\t\t\t\tprovider: z.string().optional(),\n\t\t\t\t/** Provider-specific metadata; for local media this carries storageKey */\n\t\t\t\tmeta: z.record(z.string(), z.unknown()).optional(),\n\t\t\t});\n\n\t\tcase \"reference\":\n\t\t\treturn z.string(); // Reference ID\n\n\t\tcase \"json\":\n\t\t\treturn z.unknown();\n\n\t\tdefault:\n\t\t\treturn z.unknown();\n\t}\n}\n\n/**\n * Apply validation rules to a schema\n */\nfunction applyValidation(schema: ZodTypeAny, field: Field): ZodTypeAny {\n\tconst validation = field.validation;\n\tif (!validation) return schema;\n\n\t// String validations\n\tif (schema instanceof z.ZodString) {\n\t\tlet strSchema = schema;\n\t\tif (validation.minLength !== undefined) {\n\t\t\tstrSchema = strSchema.min(validation.minLength);\n\t\t}\n\t\tif (validation.maxLength !== undefined) {\n\t\t\tstrSchema = strSchema.max(validation.maxLength);\n\t\t}\n\t\tif (validation.pattern) {\n\t\t\tstrSchema = strSchema.regex(new RegExp(validation.pattern));\n\t\t}\n\t\treturn strSchema;\n\t}\n\n\t// Number validations\n\tif (schema instanceof z.ZodNumber) {\n\t\tlet numSchema = schema;\n\t\tif (validation.min !== undefined) {\n\t\t\tnumSchema = numSchema.min(validation.min);\n\t\t}\n\t\tif (validation.max !== undefined) {\n\t\t\tnumSchema = numSchema.max(validation.max);\n\t\t}\n\t\treturn numSchema;\n\t}\n\n\treturn schema;\n}\n\n/**\n * Schema cache to avoid regenerating schemas on every request\n */\nconst schemaCache = new Map<string, { schema: z.ZodObject<any>; version: string }>();\n\n/**\n * Get or generate a cached schema for a collection\n */\nexport function getCachedSchema(\n\tcollection: CollectionWithFields,\n\tversion?: string,\n): z.ZodObject<any> {\n\tconst cacheKey = collection.slug;\n\tconst cached = schemaCache.get(cacheKey);\n\n\t// If version matches, return cached schema\n\tif (cached && (!version || cached.version === version)) {\n\t\treturn cached.schema;\n\t}\n\n\t// Generate new schema\n\tconst schema = generateZodSchema(collection);\n\n\t// Cache it\n\tschemaCache.set(cacheKey, {\n\t\tschema,\n\t\tversion: version || collection.updatedAt,\n\t});\n\n\treturn schema;\n}\n\n/**\n * Invalidate cached schema for a collection\n */\nexport function invalidateSchemaCache(slug: string): void {\n\tschemaCache.delete(slug);\n}\n\n/**\n * Clear all cached schemas\n */\nexport function clearSchemaCache(): void {\n\tschemaCache.clear();\n}\n\n/**\n * Validate data against a collection's schema\n */\nexport function validateContent(\n\tcollection: CollectionWithFields,\n\tdata: unknown,\n): { success: true; data: unknown } | { success: false; errors: z.ZodError } {\n\tconst schema = getCachedSchema(collection);\n\n\tconst result = schema.safeParse(data);\n\n\tif (result.success) {\n\t\treturn { success: true, data: result.data };\n\t}\n\n\treturn { success: false, errors: result.error };\n}\n\n/**\n * Generate TypeScript interface from field definitions\n * Used by CLI `emdash types` to generate types\n */\nexport function generateTypeScript(collection: CollectionWithFields): string {\n\tconst interfaceName = getInterfaceName(collection);\n\tconst lines: string[] = [];\n\n\tlines.push(`export interface ${interfaceName} {`);\n\tlines.push(` id: string;`);\n\tlines.push(` slug: string | null;`);\n\tlines.push(` status: string;`);\n\n\tfor (const field of collection.fields) {\n\t\tconst tsType = fieldTypeToTypeScript(field);\n\t\tconst optional = field.required ? \"\" : \"?\";\n\t\tlines.push(` ${field.slug}${optional}: ${tsType};`);\n\t}\n\n\tlines.push(` createdAt: Date;`);\n\tlines.push(` updatedAt: Date;`);\n\tlines.push(` publishedAt: Date | null;`);\n\t// Bylines are eagerly loaded by getEmDashCollection/getEmDashEntry\n\tlines.push(` bylines?: ContentBylineCredit[];`);\n\tlines.push(`}`);\n\n\treturn lines.join(\"\\n\");\n}\n\n/**\n * Generate a complete types file with module augmentation\n * This produces emdash-env.d.ts content that provides typed query functions\n */\nexport function generateTypesFile(collections: CollectionWithFields[]): string {\n\tconst lines: string[] = [];\n\n\t// Header\n\tlines.push(`// Generated by EmDash on dev server start`);\n\tlines.push(`// Do not edit manually`);\n\tlines.push(``);\n\tlines.push(`/// <reference types=\"emdash/locals\" />`);\n\tlines.push(``);\n\n\t// Check if we need PortableTextBlock import\n\tconst needsPortableText = collections.some((c) =>\n\t\tc.fields.some((f) => f.type === \"portableText\"),\n\t);\n\n\t// Build imports - ContentBylineCredit is always needed for bylines\n\tconst imports = [\"ContentBylineCredit\"];\n\tif (needsPortableText) {\n\t\timports.push(\"PortableTextBlock\");\n\t}\n\tlines.push(`import type { ${imports.join(\", \")} } from \"emdash\";`);\n\tlines.push(``);\n\n\t// Generate individual interfaces\n\tfor (const collection of collections) {\n\t\tlines.push(generateTypeScript(collection));\n\t\tlines.push(``);\n\t}\n\n\t// Generate the Collections interface for module augmentation\n\tlines.push(`declare module \"emdash\" {`);\n\tlines.push(` interface EmDashCollections {`);\n\tfor (const collection of collections) {\n\t\tconst interfaceName = getInterfaceName(collection);\n\t\tlines.push(` ${collection.slug}: ${interfaceName};`);\n\t}\n\tlines.push(` }`);\n\tlines.push(`}`);\n\n\treturn lines.join(\"\\n\");\n}\n\n/**\n * Generate schema hash for cache invalidation\n */\nexport async function generateSchemaHash(collections: CollectionWithFields[]): Promise<string> {\n\tconst str = JSON.stringify(\n\t\tcollections.map((c) => ({\n\t\t\tslug: c.slug,\n\t\t\tfields: c.fields.map((f) => ({\n\t\t\t\tslug: f.slug,\n\t\t\t\ttype: f.type,\n\t\t\t\trequired: f.required,\n\t\t\t\tvalidation: f.validation,\n\t\t\t})),\n\t\t})),\n\t);\n\treturn hashString(str);\n}\n\n/**\n * Map field type to TypeScript type\n */\nfunction fieldTypeToTypeScript(field: Field): string {\n\tswitch (field.type) {\n\t\tcase \"string\":\n\t\tcase \"text\":\n\t\tcase \"slug\":\n\t\tcase \"url\":\n\t\tcase \"datetime\":\n\t\t\treturn \"string\";\n\n\t\tcase \"number\":\n\t\tcase \"integer\":\n\t\t\treturn \"number\";\n\n\t\tcase \"boolean\":\n\t\t\treturn \"boolean\";\n\n\t\tcase \"select\":\n\t\t\tconst options = field.validation?.options;\n\t\t\tif (options && options.length > 0) {\n\t\t\t\treturn options.map((o) => `\"${o}\"`).join(\" | \");\n\t\t\t}\n\t\t\treturn \"string\";\n\n\t\tcase \"multiSelect\":\n\t\t\tconst multiOptions = field.validation?.options;\n\t\t\tif (multiOptions && multiOptions.length > 0) {\n\t\t\t\treturn `(${multiOptions.map((o) => `\"${o}\"`).join(\" | \")})[]`;\n\t\t\t}\n\t\t\treturn \"string[]\";\n\n\t\tcase \"portableText\":\n\t\t\treturn \"PortableTextBlock[]\";\n\n\t\tcase \"image\":\n\t\t\treturn \"{ id: string; src?: string; alt?: string; width?: number; height?: number; provider?: string; previewUrl?: string; meta?: Record<string, unknown> }\";\n\n\t\tcase \"file\":\n\t\t\treturn \"{ id: string; src?: string; filename?: string; mimeType?: string; size?: number; provider?: string; meta?: Record<string, unknown> }\";\n\n\t\tcase \"reference\":\n\t\t\t// Could be enhanced to include the referenced collection type\n\t\t\treturn \"string\";\n\n\t\tcase \"json\":\n\t\t\treturn \"unknown\";\n\n\t\tdefault:\n\t\t\treturn \"unknown\";\n\t}\n}\n\n/**\n * Convert string to PascalCase (handles slugs, spaces, etc.)\n */\nfunction pascalCase(str: string): string {\n\treturn str\n\t\t.split(PASCAL_CASE_SPLIT_PATTERN)\n\t\t.filter(Boolean)\n\t\t.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())\n\t\t.join(\"\");\n}\n\n/**\n * Simple singularization - handles common cases\n */\nfunction singularize(str: string): string {\n\tif (str.endsWith(\"ies\")) {\n\t\treturn str.slice(0, -3) + \"y\";\n\t}\n\tif (\n\t\tstr.endsWith(\"es\") &&\n\t\t(str.endsWith(\"sses\") || str.endsWith(\"xes\") || str.endsWith(\"ches\") || str.endsWith(\"shes\"))\n\t) {\n\t\treturn str.slice(0, -2);\n\t}\n\tif (str.endsWith(\"s\") && !str.endsWith(\"ss\")) {\n\t\treturn str.slice(0, -1);\n\t}\n\treturn str;\n}\n\n/**\n * Get the interface name for a collection\n */\nfunction getInterfaceName(collection: CollectionWithFields): string {\n\treturn pascalCase(collection.labelSingular || singularize(collection.slug));\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "emdash",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
4
  "description": "Astro-native CMS with WordPress migration support",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",
@@ -194,9 +194,10 @@
194
194
  "ulidx": "^2.4.1",
195
195
  "upng-js": "^2.1.0",
196
196
  "zod": "^4.3.5",
197
- "@emdash-cms/admin": "0.10.0",
198
- "@emdash-cms/auth": "0.10.0",
199
- "@emdash-cms/gutenberg-to-portable-text": "0.10.0"
197
+ "@emdash-cms/admin": "0.11.1",
198
+ "@emdash-cms/auth": "0.11.1",
199
+ "@emdash-cms/gutenberg-to-portable-text": "0.11.1",
200
+ "@emdash-cms/plugin-types": "0.0.1"
200
201
  },
201
202
  "optionalDependencies": {
202
203
  "@libsql/kysely-libsql": "^0.4.0",
@@ -204,7 +205,7 @@
204
205
  },
205
206
  "peerDependencies": {
206
207
  "@astrojs/react": ">=5.0.0-beta.0",
207
- "@emdash-cms/auth-atproto": ">=0.2.2",
208
+ "@emdash-cms/auth-atproto": ">=0.2.4",
208
209
  "astro": ">=6.0.0-beta.0",
209
210
  "react": ">=18.0.0",
210
211
  "react-dom": ">=18.0.0"
@@ -222,14 +223,14 @@
222
223
  "@types/pg": "^8.16.0",
223
224
  "@types/sanitize-html": "^2.16.0",
224
225
  "@types/sax": "^1.2.7",
225
- "@vitest/ui": "^4.0.17",
226
+ "@vitest/ui": "^4.1.5",
226
227
  "publint": "0.3.17",
227
228
  "tsdown": "0.20.3",
228
229
  "typescript": "^5.9.3",
229
230
  "vite": "^6.0.0",
230
- "vitest": "^4.0.18",
231
+ "vitest": "^4.1.5",
231
232
  "zod-openapi": "^5.4.6",
232
- "@emdash-cms/blocks": "0.10.0"
233
+ "@emdash-cms/blocks": "0.11.1"
233
234
  },
234
235
  "repository": {
235
236
  "type": "git",
package/src/api/errors.ts CHANGED
@@ -218,6 +218,10 @@ export const ErrorCode = {
218
218
  MENU_ITEM_UPDATE_ERROR: "MENU_ITEM_UPDATE_ERROR",
219
219
  MENU_ITEM_DELETE_ERROR: "MENU_ITEM_DELETE_ERROR",
220
220
  MENU_REORDER_ERROR: "MENU_REORDER_ERROR",
221
+ // Returned when a menu name resolves to multiple locale variants and
222
+ // the caller did not pass `locale` to disambiguate. (name, locale) is
223
+ // unique, so this only fires for omitted-locale lookups.
224
+ AMBIGUOUS_LOCALE: "AMBIGUOUS_LOCALE",
221
225
 
222
226
  // Taxonomies
223
227
  TAXONOMY_LIST_ERROR: "TAXONOMY_LIST_ERROR",
@@ -362,6 +366,7 @@ export function mapErrorStatus(code: string | undefined): number {
362
366
  case ErrorCode.SELF_ROLE_CHANGE:
363
367
  case ErrorCode.SSRF_BLOCKED:
364
368
  case ErrorCode.UNKNOWN_ACTION:
369
+ case ErrorCode.AMBIGUOUS_LOCALE:
365
370
  return 400;
366
371
 
367
372
  // 401 Unauthorized
@@ -27,6 +27,7 @@ import { invalidateRedirectCache } from "../../redirects/cache.js";
27
27
  import { isMissingTableError } from "../../utils/db-errors.js";
28
28
  import { encodeRev, validateRev } from "../rev.js";
29
29
  import type { ApiResult, ContentListResponse, ContentResponse } from "../types.js";
30
+ import { validateMediaFields } from "./validate-media-fields.js";
30
31
 
31
32
  /**
32
33
  * Narrow a caught error to one carrying a structured `apiError` discriminant.
@@ -444,6 +445,9 @@ export async function handleContentCreate(
444
445
  };
445
446
  }
446
447
 
448
+ const mimeCheck = await validateMediaFields(db, collection, body.data);
449
+ if (!mimeCheck.success) return mimeCheck;
450
+
447
451
  // Wrap content + SEO writes in a transaction for atomicity
448
452
  const item = await withTransaction(db, async (trx) => {
449
453
  const repo = new ContentRepository(trx);
@@ -591,6 +595,11 @@ export async function handleContentUpdate(
591
595
  };
592
596
  }
593
597
 
598
+ if (body.data) {
599
+ const mimeCheck = await validateMediaFields(db, collection, body.data);
600
+ if (!mimeCheck.success) return mimeCheck;
601
+ }
602
+
594
603
  const repo = new ContentRepository(db);
595
604
 
596
605
  // Resolve slug → ID if needed
@@ -0,0 +1,40 @@
1
+ import type { Kysely } from "kysely";
2
+
3
+ import type { Database } from "../../database/types.js";
4
+ import { parseAllowedMimeTypes } from "../../media/mime.js";
5
+
6
+ /**
7
+ * MIME types allowed for upload by default (when no field-specific list
8
+ * overrides this). Entries ending with "/" are prefix-matched (e.g.
9
+ * "image/" matches "image/jpeg", "image/png", etc.).
10
+ */
11
+ export const GLOBAL_UPLOAD_ALLOWLIST: readonly string[] = [
12
+ "image/",
13
+ "video/",
14
+ "audio/",
15
+ "application/pdf",
16
+ ];
17
+
18
+ /**
19
+ * Resolve the MIME allowlist for a specific field.
20
+ *
21
+ * Returns the field's `allowedMimeTypes` list when the field exists, is of
22
+ * type "file" or "image", and has a non-empty list configured. Returns null
23
+ * in all other cases — callers should fall back to GLOBAL_UPLOAD_ALLOWLIST.
24
+ *
25
+ * Authentication is the caller's responsibility (the upload routes already
26
+ * gate on `media:upload`).
27
+ */
28
+ export async function resolveFieldAllowlist(
29
+ db: Kysely<Database>,
30
+ fieldId: string,
31
+ ): Promise<string[] | null> {
32
+ const row = await db
33
+ .selectFrom("_emdash_fields")
34
+ .select(["type", "validation"])
35
+ .where("id", "=", fieldId)
36
+ .where("type", "in", ["file", "image"])
37
+ .executeTakeFirst();
38
+
39
+ return row ? parseAllowedMimeTypes(row.validation) : null;
40
+ }
@@ -26,7 +26,7 @@ export async function handleMediaList(
26
26
  params: {
27
27
  cursor?: string;
28
28
  limit?: number;
29
- mimeType?: string;
29
+ mimeType?: string | readonly string[];
30
30
  },
31
31
  ): Promise<ApiResult<MediaListResponse>> {
32
32
  try {
@@ -45,6 +45,28 @@ export interface MenuTranslationsResponse {
45
45
  }>;
46
46
  }
47
47
 
48
+ /**
49
+ * Error returned when a menu lookup by `name` matches multiple locale
50
+ * variants and the caller did not pass `locale` to disambiguate. Maps to
51
+ * HTTP 400 via `mapErrorStatus`. The available locales are surfaced in the
52
+ * message so MCP/REST callers can recover by re-issuing with `locale`.
53
+ */
54
+ function ambiguousMenuLocaleError(
55
+ name: string,
56
+ locales: readonly string[],
57
+ ): { success: false; error: { code: "AMBIGUOUS_LOCALE"; message: string } } {
58
+ const sortedLocales = locales.toSorted();
59
+ return {
60
+ success: false,
61
+ error: {
62
+ code: "AMBIGUOUS_LOCALE",
63
+ message: `Menu '${name}' exists in multiple locales (${sortedLocales.join(
64
+ ", ",
65
+ )}); pass 'locale' to disambiguate.`,
66
+ },
67
+ };
68
+ }
69
+
48
70
  // ---------------------------------------------------------------------------
49
71
  // Menu handlers
50
72
  // ---------------------------------------------------------------------------
@@ -118,6 +140,21 @@ export async function handleMenuCreate(
118
140
  input: { name: string; label: string; locale?: string; translationOf?: string },
119
141
  ): Promise<ApiResult<MenuRow>> {
120
142
  try {
143
+ // Translating from a source menu only makes sense when the caller
144
+ // names the target locale: otherwise we'd silently clone into the
145
+ // configured default, which is almost never what's intended (and
146
+ // will collide if the source is already the default-locale menu).
147
+ // Enforced here so REST/SDK callers get the same guard as MCP.
148
+ if (input.translationOf && !input.locale) {
149
+ return {
150
+ success: false,
151
+ error: {
152
+ code: "VALIDATION_ERROR",
153
+ message: "`locale` is required when `translationOf` is provided",
154
+ },
155
+ };
156
+ }
157
+
121
158
  // Resolve translation group + source (if we're creating a translation).
122
159
  let translationGroup: string | null = null;
123
160
  let sourceMenu: MenuRow | null = null;
@@ -311,16 +348,30 @@ export async function handleMenuUpdate(
311
348
  input: { label?: string; locale?: string },
312
349
  ): Promise<ApiResult<MenuRow>> {
313
350
  try {
314
- let query = db.selectFrom("_emdash_menus").select("id").where("name", "=", name);
351
+ // Fetch every row matching the name (filtered by locale if supplied)
352
+ // so we can fail loud when an omitted-locale lookup is ambiguous.
353
+ // (name, locale) is unique, so length > 1 only happens when the
354
+ // caller didn't pass `locale` and the menu exists in >1 translation.
355
+ let query = db.selectFrom("_emdash_menus").select(["id", "locale"]).where("name", "=", name);
315
356
  if (input.locale !== undefined) query = query.where("locale", "=", input.locale);
316
- const menu = await query.executeTakeFirst();
357
+ const matches = await query.execute();
317
358
 
318
- if (!menu) {
359
+ if (matches.length === 0) {
319
360
  return {
320
361
  success: false,
321
- error: { code: "NOT_FOUND", message: `Menu '${name}' not found` },
362
+ error: {
363
+ code: "NOT_FOUND",
364
+ message: `Menu '${name}' not found${input.locale ? ` in locale '${input.locale}'` : ""}`,
365
+ },
322
366
  };
323
367
  }
368
+ if (matches.length > 1) {
369
+ return ambiguousMenuLocaleError(
370
+ name,
371
+ matches.map((m) => m.locale),
372
+ );
373
+ }
374
+ const menu = matches[0]!;
324
375
 
325
376
  if (input.label) {
326
377
  await db
@@ -353,16 +404,29 @@ export async function handleMenuDelete(
353
404
  options: { locale?: string } = {},
354
405
  ): Promise<ApiResult<{ deleted: true }>> {
355
406
  try {
356
- let query = db.selectFrom("_emdash_menus").select("id").where("name", "=", name);
407
+ // See ambiguousMenuLocaleError for why we fetch all matches.
408
+ let query = db.selectFrom("_emdash_menus").select(["id", "locale"]).where("name", "=", name);
357
409
  if (options.locale !== undefined) query = query.where("locale", "=", options.locale);
358
- const menu = await query.executeTakeFirst();
410
+ const matches = await query.execute();
359
411
 
360
- if (!menu) {
412
+ if (matches.length === 0) {
361
413
  return {
362
414
  success: false,
363
- error: { code: "NOT_FOUND", message: `Menu '${name}' not found` },
415
+ error: {
416
+ code: "NOT_FOUND",
417
+ message: `Menu '${name}' not found${
418
+ options.locale ? ` in locale '${options.locale}'` : ""
419
+ }`,
420
+ },
364
421
  };
365
422
  }
423
+ if (matches.length > 1) {
424
+ return ambiguousMenuLocaleError(
425
+ name,
426
+ matches.map((m) => m.locale),
427
+ );
428
+ }
429
+ const menu = matches[0]!;
366
430
 
367
431
  // D1 has FOREIGN KEYS off by default, so the migration's `ON DELETE
368
432
  // CASCADE` won't fire there. Delete items explicitly first — this is
@@ -453,19 +517,28 @@ export async function handleMenuItemCreate(
453
517
  options: { locale?: string } = {},
454
518
  ): Promise<ApiResult<MenuItemRow>> {
455
519
  try {
520
+ // Same fail-loud rule as handleMenuUpdate / Delete / SetItems —
521
+ // see ambiguousMenuLocaleError for the rationale.
456
522
  let menuQuery = db
457
523
  .selectFrom("_emdash_menus")
458
524
  .select(["id", "locale"])
459
525
  .where("name", "=", menuName);
460
526
  if (options.locale !== undefined) menuQuery = menuQuery.where("locale", "=", options.locale);
461
- const menu = await menuQuery.executeTakeFirst();
527
+ const matches = await menuQuery.execute();
462
528
 
463
- if (!menu) {
529
+ if (matches.length === 0) {
464
530
  return {
465
531
  success: false,
466
532
  error: { code: "NOT_FOUND", message: "Menu not found" },
467
533
  };
468
534
  }
535
+ if (matches.length > 1) {
536
+ return ambiguousMenuLocaleError(
537
+ menuName,
538
+ matches.map((m) => m.locale),
539
+ );
540
+ }
541
+ const menu = matches[0]!;
469
542
 
470
543
  let sortOrder = input.sortOrder ?? 0;
471
544
  if (input.sortOrder === undefined) {
@@ -535,16 +608,27 @@ export async function handleMenuItemUpdate(
535
608
  options: { locale?: string } = {},
536
609
  ): Promise<ApiResult<MenuItemRow>> {
537
610
  try {
538
- let menuQuery = db.selectFrom("_emdash_menus").select("id").where("name", "=", menuName);
611
+ // See ambiguousMenuLocaleError for the rationale.
612
+ let menuQuery = db
613
+ .selectFrom("_emdash_menus")
614
+ .select(["id", "locale"])
615
+ .where("name", "=", menuName);
539
616
  if (options.locale !== undefined) menuQuery = menuQuery.where("locale", "=", options.locale);
540
- const menu = await menuQuery.executeTakeFirst();
617
+ const matches = await menuQuery.execute();
541
618
 
542
- if (!menu) {
619
+ if (matches.length === 0) {
543
620
  return {
544
621
  success: false,
545
622
  error: { code: "NOT_FOUND", message: "Menu not found" },
546
623
  };
547
624
  }
625
+ if (matches.length > 1) {
626
+ return ambiguousMenuLocaleError(
627
+ menuName,
628
+ matches.map((m) => m.locale),
629
+ );
630
+ }
631
+ const menu = matches[0]!;
548
632
 
549
633
  const item = await db
550
634
  .selectFrom("_emdash_menu_items")
@@ -597,16 +681,27 @@ export async function handleMenuItemDelete(
597
681
  options: { locale?: string } = {},
598
682
  ): Promise<ApiResult<{ deleted: true }>> {
599
683
  try {
600
- let menuQuery = db.selectFrom("_emdash_menus").select("id").where("name", "=", menuName);
684
+ // See ambiguousMenuLocaleError for the rationale.
685
+ let menuQuery = db
686
+ .selectFrom("_emdash_menus")
687
+ .select(["id", "locale"])
688
+ .where("name", "=", menuName);
601
689
  if (options.locale !== undefined) menuQuery = menuQuery.where("locale", "=", options.locale);
602
- const menu = await menuQuery.executeTakeFirst();
690
+ const matches = await menuQuery.execute();
603
691
 
604
- if (!menu) {
692
+ if (matches.length === 0) {
605
693
  return {
606
694
  success: false,
607
695
  error: { code: "NOT_FOUND", message: "Menu not found" },
608
696
  };
609
697
  }
698
+ if (matches.length > 1) {
699
+ return ambiguousMenuLocaleError(
700
+ menuName,
701
+ matches.map((m) => m.locale),
702
+ );
703
+ }
704
+ const menu = matches[0]!;
610
705
 
611
706
  const result = await db
612
707
  .deleteFrom("_emdash_menu_items")
@@ -668,6 +763,7 @@ export async function handleMenuSetItems(
668
763
  db: Kysely<Database>,
669
764
  menuName: string,
670
765
  items: MenuSetItemsInput[],
766
+ options: { locale?: string } = {},
671
767
  ): Promise<ApiResult<{ name: string; itemCount: number }>> {
672
768
  // Validate parentIndex references — must be strictly earlier so
673
769
  // the array can be inserted in order with parents resolved first.
@@ -690,24 +786,38 @@ export async function handleMenuSetItems(
690
786
  }
691
787
 
692
788
  try {
693
- // Sentinel for "menu not found" thrown from inside the transaction
694
- // so the rollback fires before we return the structured error.
789
+ // Sentinels thrown from inside the transaction so the rollback
790
+ // fires before we return the structured error.
695
791
  const notFoundSentinel = Symbol("menu-not-found");
792
+ // We capture the locale list rather than constructing the error
793
+ // inside the transaction, so the helper stays the single source
794
+ // of truth for AMBIGUOUS_LOCALE message shape.
795
+ let ambiguousLocales: string[] | null = null;
796
+ const ambiguousSentinel = Symbol("menu-ambiguous-locale");
696
797
 
697
798
  try {
698
799
  await withTransaction(db, async (trx) => {
699
800
  // Existence check INSIDE the transaction so a concurrent
700
801
  // menu_delete between lookup and write can't leave orphan
701
- // items on D1 (FKs disabled by default).
702
- const menu = await trx
802
+ // items on D1 (FKs disabled by default). Same fail-loud
803
+ // rule as handleMenuUpdate / handleMenuDelete.
804
+ let menuQuery = trx
703
805
  .selectFrom("_emdash_menus")
704
- .select("id")
705
- .where("name", "=", menuName)
706
- .executeTakeFirst();
806
+ .select(["id", "locale"])
807
+ .where("name", "=", menuName);
808
+ if (options.locale !== undefined) {
809
+ menuQuery = menuQuery.where("locale", "=", options.locale);
810
+ }
811
+ const matches = await menuQuery.execute();
707
812
 
708
- if (!menu) {
813
+ if (matches.length === 0) {
709
814
  throw notFoundSentinel;
710
815
  }
816
+ if (matches.length > 1) {
817
+ ambiguousLocales = matches.map((m) => m.locale);
818
+ throw ambiguousSentinel;
819
+ }
820
+ const menu = matches[0]!;
711
821
 
712
822
  await trx.deleteFrom("_emdash_menu_items").where("menu_id", "=", menu.id).execute();
713
823
 
@@ -733,6 +843,7 @@ export async function handleMenuSetItems(
733
843
  title_attr: item.titleAttr ?? null,
734
844
  target: item.target ?? null,
735
845
  css_classes: item.cssClasses ?? null,
846
+ locale: menu.locale,
736
847
  })
737
848
  .execute();
738
849
  insertedIds.push(id);
@@ -748,9 +859,17 @@ export async function handleMenuSetItems(
748
859
  if (error === notFoundSentinel) {
749
860
  return {
750
861
  success: false,
751
- error: { code: "NOT_FOUND", message: `Menu '${menuName}' not found` },
862
+ error: {
863
+ code: "NOT_FOUND",
864
+ message: `Menu '${menuName}' not found${
865
+ options.locale ? ` in locale '${options.locale}'` : ""
866
+ }`,
867
+ },
752
868
  };
753
869
  }
870
+ if (error === ambiguousSentinel && ambiguousLocales) {
871
+ return ambiguousMenuLocaleError(menuName, ambiguousLocales);
872
+ }
754
873
  throw error;
755
874
  }
756
875
 
@@ -774,16 +893,27 @@ export async function handleMenuItemReorder(
774
893
  options: { locale?: string } = {},
775
894
  ): Promise<ApiResult<MenuItemRow[]>> {
776
895
  try {
777
- let menuQuery = db.selectFrom("_emdash_menus").select("id").where("name", "=", menuName);
896
+ // See ambiguousMenuLocaleError for the rationale.
897
+ let menuQuery = db
898
+ .selectFrom("_emdash_menus")
899
+ .select(["id", "locale"])
900
+ .where("name", "=", menuName);
778
901
  if (options.locale !== undefined) menuQuery = menuQuery.where("locale", "=", options.locale);
779
- const menu = await menuQuery.executeTakeFirst();
902
+ const matches = await menuQuery.execute();
780
903
 
781
- if (!menu) {
904
+ if (matches.length === 0) {
782
905
  return {
783
906
  success: false,
784
907
  error: { code: "NOT_FOUND", message: "Menu not found" },
785
908
  };
786
909
  }
910
+ if (matches.length > 1) {
911
+ return ambiguousMenuLocaleError(
912
+ menuName,
913
+ matches.map((m) => m.locale),
914
+ );
915
+ }
916
+ const menu = matches[0]!;
787
917
 
788
918
  const updatedItems = await withTransaction(db, async (trx) => {
789
919
  for (const item of items) {