emdash 0.10.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 (139) 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 +9 -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-Cg-rC4Gj.d.mts} +32 -24
  31. package/dist/index-Cg-rC4Gj.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-DiI8NOG_.mjs +16 -0
  74. package/dist/types-DiI8NOG_.mjs.map +1 -0
  75. package/dist/{types-D19uBYWn.d.mts → types-IN5z_S3P.d.mts} +19 -98
  76. package/dist/types-IN5z_S3P.d.mts.map +1 -0
  77. package/dist/{types-Dl1fgFjn.d.mts → types-IZSZfEwv.d.mts} +4 -3
  78. package/dist/types-IZSZfEwv.d.mts.map +1 -0
  79. package/dist/{validate-DHGwADqO.d.mts → validate-CO3JjFV5.d.mts} +7 -3
  80. package/dist/validate-CO3JjFV5.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-Bg31I_Ff.mjs +7 -0
  86. package/dist/{version-CMD42IRC.mjs.map → version-Bg31I_Ff.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/media/upload-url.ts +10 -4
  100. package/src/astro/routes/api/media.ts +12 -4
  101. package/src/astro/types.ts +5 -1
  102. package/src/auth/rate-limit.ts +3 -3
  103. package/src/cli/commands/bundle-utils.ts +81 -6
  104. package/src/cli/commands/bundle.ts +18 -15
  105. package/src/cli/commands/export-seed.ts +57 -3
  106. package/src/database/instrumentation.ts +22 -8
  107. package/src/database/migrations/016_api_tokens.ts +18 -3
  108. package/src/database/migrations/037_credential_algorithm.ts +18 -0
  109. package/src/database/migrations/runner.ts +2 -0
  110. package/src/database/repositories/media.ts +40 -10
  111. package/src/database/types.ts +2 -1
  112. package/src/emdash-runtime.ts +16 -3
  113. package/src/fields/file.ts +7 -6
  114. package/src/fields/image.ts +12 -11
  115. package/src/fields/types.ts +3 -0
  116. package/src/index.ts +1 -1
  117. package/src/mcp/server.ts +37 -8
  118. package/src/media/mime.ts +75 -0
  119. package/src/plugins/types.ts +81 -191
  120. package/src/request-cache.ts +6 -2
  121. package/src/request-context.ts +42 -2
  122. package/src/schema/registry.ts +5 -5
  123. package/src/schema/types.ts +3 -2
  124. package/src/seed/apply.ts +25 -8
  125. package/src/seed/types.ts +4 -0
  126. package/dist/index-DjPMOfO0.d.mts.map +0 -1
  127. package/dist/media-D8FbNsl0.mjs.map +0 -1
  128. package/dist/registry-Beb7wxFc.mjs.map +0 -1
  129. package/dist/request-cache-C-tIpYIw.mjs.map +0 -1
  130. package/dist/runner-DMnlIkh4.mjs.map +0 -1
  131. package/dist/search-DkN-BqsS.mjs.map +0 -1
  132. package/dist/types-CoO6mpV3.mjs +0 -68
  133. package/dist/types-CoO6mpV3.mjs.map +0 -1
  134. package/dist/types-D19uBYWn.d.mts.map +0 -1
  135. package/dist/types-Dl1fgFjn.d.mts.map +0 -1
  136. package/dist/types-Dtx1mSMX.d.mts.map +0 -1
  137. package/dist/types-Eg829jj9.mjs.map +0 -1
  138. package/dist/validate-DHGwADqO.d.mts.map +0 -1
  139. package/dist/version-CMD42IRC.mjs +0 -7
@@ -32,6 +32,7 @@ import type {
32
32
  SeedWidget,
33
33
  SeedContentEntry,
34
34
  } from "../../seed/types.js";
35
+ import { slugify } from "../../utils/slugify.js";
35
36
 
36
37
  const SETTINGS_PREFIX = "site:";
37
38
 
@@ -101,7 +102,7 @@ export const exportSeedCommand = defineCommand({
101
102
  /**
102
103
  * Export database to seed file format
103
104
  */
104
- async function exportSeed(db: Kysely<Database>, withContent?: string): Promise<SeedFile> {
105
+ export async function exportSeed(db: Kysely<Database>, withContent?: string): Promise<SeedFile> {
105
106
  const seed: SeedFile = {
106
107
  $schema: "https://emdashcms.com/seed.schema.json",
107
108
  version: "1",
@@ -317,6 +318,9 @@ async function exportMenus(db: Kysely<Database>): Promise<SeedMenu[]> {
317
318
  const result: SeedMenu[] = [];
318
319
  // translation_group -> seed-local id of the anchor menu in that group.
319
320
  const groupToSeedId = new Map<string, string>();
321
+ // Shared across menus: translated items reference anchor items in sibling menus.
322
+ const itemGroupToSeedId = new Map<string, string>();
323
+ const usedItemSeedIds = new Set<string>();
320
324
 
321
325
  for (const menu of menus) {
322
326
  const seedId =
@@ -329,7 +333,13 @@ async function exportMenus(db: Kysely<Database>): Promise<SeedMenu[]> {
329
333
  .orderBy("sort_order", "asc")
330
334
  .execute();
331
335
 
332
- const seedItems = buildMenuItemTree(items);
336
+ const seedItems = buildMenuItemTree(items, {
337
+ i18nEnabled,
338
+ menuName: menu.name,
339
+ menuLocale: menu.locale ?? null,
340
+ itemGroupToSeedId,
341
+ usedItemSeedIds,
342
+ });
333
343
 
334
344
  const seedMenu: SeedMenu = {
335
345
  id: seedId,
@@ -376,7 +386,17 @@ function buildMenuItemTree(
376
386
  target: string | null;
377
387
  title_attr: string | null;
378
388
  css_classes: string | null;
389
+ locale?: string | null;
390
+ translation_group?: string | null;
379
391
  }>,
392
+ i18nCtx: {
393
+ i18nEnabled: boolean;
394
+ menuName: string;
395
+ menuLocale: string | null;
396
+ // translation_group -> seed-local id of the anchor item in that group.
397
+ itemGroupToSeedId: Map<string, string>;
398
+ usedItemSeedIds: Set<string>;
399
+ },
380
400
  ): SeedMenuItem[] {
381
401
  // Build parent -> children map
382
402
  const childMap = new Map<string | null, typeof items>();
@@ -389,10 +409,28 @@ function buildMenuItemTree(
389
409
  childMap.get(parentId)!.push(item);
390
410
  }
391
411
 
412
+ function makeSeedId(item: (typeof items)[number]): string {
413
+ const base = slugify(item.label || "") || item.id;
414
+ const locale = i18nCtx.i18nEnabled ? (item.locale ?? i18nCtx.menuLocale) : null;
415
+ const candidate = locale
416
+ ? `item:${i18nCtx.menuName}:${base}:${locale}`
417
+ : `item:${i18nCtx.menuName}:${base}`;
418
+ if (!i18nCtx.usedItemSeedIds.has(candidate)) {
419
+ i18nCtx.usedItemSeedIds.add(candidate);
420
+ return candidate;
421
+ }
422
+ // Collision fallback: append DB id to disambiguate duplicate labels.
423
+ const fallback = locale
424
+ ? `item:${i18nCtx.menuName}:${base}:${item.id}:${locale}`
425
+ : `item:${i18nCtx.menuName}:${base}:${item.id}`;
426
+ i18nCtx.usedItemSeedIds.add(fallback);
427
+ return fallback;
428
+ }
429
+
392
430
  // Recursively build tree
393
431
  function buildLevel(parentId: string | null): SeedMenuItem[] {
394
432
  const children = childMap.get(parentId) || [];
395
- return children.map((item) => {
433
+ const result = children.map((item) => {
396
434
  const seedItem: SeedMenuItem = {
397
435
  type: item.type,
398
436
  label: item.label || undefined,
@@ -415,6 +453,18 @@ function buildMenuItemTree(
415
453
  seedItem.cssClasses = item.css_classes;
416
454
  }
417
455
 
456
+ if (i18nCtx.i18nEnabled) {
457
+ const itemLocale = item.locale ?? i18nCtx.menuLocale;
458
+ const seedId = makeSeedId(item);
459
+ seedItem.id = seedId;
460
+ if (itemLocale) seedItem.locale = itemLocale;
461
+ if (item.translation_group) {
462
+ const anchor = i18nCtx.itemGroupToSeedId.get(item.translation_group);
463
+ if (anchor && anchor !== seedId) seedItem.translationOf = anchor;
464
+ else if (!anchor) i18nCtx.itemGroupToSeedId.set(item.translation_group, seedId);
465
+ }
466
+ }
467
+
418
468
  // Add children
419
469
  const itemChildren = buildLevel(item.id);
420
470
  if (itemChildren.length > 0) {
@@ -423,6 +473,10 @@ function buildMenuItemTree(
423
473
 
424
474
  return seedItem;
425
475
  });
476
+
477
+ // Sibling order is preserved (maps to sort_order on import). Cross-menu
478
+ // `translationOf` already resolves because exportMenus sorts anchors first.
479
+ return result;
426
480
  }
427
481
 
428
482
  return buildLevel(null);
@@ -83,16 +83,30 @@ export function isInstrumentationEnabled(): boolean {
83
83
 
84
84
  function kyselyLog(event: LogEvent): void {
85
85
  if (event.level !== "query") return;
86
- const rec = getRequestContext()?.queryRecorder;
87
- if (!rec) return;
88
- recordEvent(rec, event.query.sql, event.query.parameters, event.queryDurationMillis);
86
+ const ctx = getRequestContext();
87
+ if (!ctx) return;
88
+ const dur = event.queryDurationMillis;
89
+ if (ctx.metrics) {
90
+ const m = ctx.metrics;
91
+ m.dbCount += 1;
92
+ m.dbTotalMs += dur;
93
+ const finishedAt = performance.now() - m.start;
94
+ const startedAt = finishedAt - dur;
95
+ if (m.dbFirstOffset === null) m.dbFirstOffset = startedAt;
96
+ m.dbLastOffset = finishedAt;
97
+ }
98
+ if (ctx.queryRecorder) {
99
+ recordEvent(ctx.queryRecorder, event.query.sql, event.query.parameters, dur);
100
+ }
89
101
  }
90
102
 
91
103
  /**
92
- * Returns a Kysely `log` option when instrumentation is enabled, or undefined.
93
- * Pass as `new Kysely({ dialect, log: kyselyLogOption() })` so disabled mode
94
- * has zero overhead Kysely skips query timing entirely when `log` is absent.
104
+ * Returns a Kysely `log` callback. Always returns a function so per-request
105
+ * counters (db.count, db.total, db.first, db.last) and the optional NDJSON
106
+ * recorder both get fed. The cost over the previous "undefined when off"
107
+ * behaviour is one `performance.now()` pair per query inside Kysely, which
108
+ * is in the noise compared to any real query.
95
109
  */
96
- export function kyselyLogOption(): Logger | undefined {
97
- return isInstrumentationEnabled() ? kyselyLog : undefined;
110
+ export function kyselyLogOption(): Logger {
111
+ return kyselyLog;
98
112
  }
@@ -9,11 +9,20 @@ import { currentTimestamp } from "../dialect-helpers.js";
9
9
  * 1. _emdash_api_tokens — Personal Access Tokens (ec_pat_...)
10
10
  * 2. _emdash_oauth_tokens — OAuth access/refresh tokens (ec_oat_/ec_ort_...)
11
11
  * 3. _emdash_device_codes — OAuth Device Flow state (RFC 8628)
12
+ *
13
+ * Every CREATE is guarded with `.ifNotExists()` so the migration is safe to
14
+ * re-run against a partially-applied schema. See #954 for the failure mode:
15
+ * if `up()` crashes mid-way (D1 subrequest limit, isolate cancellation,
16
+ * transient connection error), the migration record never gets inserted
17
+ * into `_emdash_migrations`, and the next request retries `up()` from the
18
+ * top. Without these guards, the retry crashed with `table ... already
19
+ * exists` and blocked every subsequent boot of the Worker.
12
20
  */
13
21
  export async function up(db: Kysely<unknown>): Promise<void> {
14
22
  // ── Personal Access Tokens ───────────────────────────────────────
15
23
  await db.schema
16
24
  .createTable("_emdash_api_tokens")
25
+ .ifNotExists()
17
26
  .addColumn("id", "text", (col) => col.primaryKey())
18
27
  .addColumn("name", "text", (col) => col.notNull())
19
28
  .addColumn("token_hash", "text", (col) => col.notNull().unique())
@@ -30,12 +39,14 @@ export async function up(db: Kysely<unknown>): Promise<void> {
30
39
 
31
40
  await db.schema
32
41
  .createIndex("idx_api_tokens_token_hash")
42
+ .ifNotExists()
33
43
  .on("_emdash_api_tokens")
34
44
  .column("token_hash")
35
45
  .execute();
36
46
 
37
47
  await db.schema
38
48
  .createIndex("idx_api_tokens_user_id")
49
+ .ifNotExists()
39
50
  .on("_emdash_api_tokens")
40
51
  .column("user_id")
41
52
  .execute();
@@ -43,6 +54,7 @@ export async function up(db: Kysely<unknown>): Promise<void> {
43
54
  // ── OAuth Tokens ─────────────────────────────────────────────────
44
55
  await db.schema
45
56
  .createTable("_emdash_oauth_tokens")
57
+ .ifNotExists()
46
58
  .addColumn("token_hash", "text", (col) => col.primaryKey())
47
59
  .addColumn("token_type", "text", (col) => col.notNull()) // 'access' | 'refresh'
48
60
  .addColumn("user_id", "text", (col) => col.notNull())
@@ -58,12 +70,14 @@ export async function up(db: Kysely<unknown>): Promise<void> {
58
70
 
59
71
  await db.schema
60
72
  .createIndex("idx_oauth_tokens_user_id")
73
+ .ifNotExists()
61
74
  .on("_emdash_oauth_tokens")
62
75
  .column("user_id")
63
76
  .execute();
64
77
 
65
78
  await db.schema
66
79
  .createIndex("idx_oauth_tokens_expires")
80
+ .ifNotExists()
67
81
  .on("_emdash_oauth_tokens")
68
82
  .column("expires_at")
69
83
  .execute();
@@ -71,6 +85,7 @@ export async function up(db: Kysely<unknown>): Promise<void> {
71
85
  // ── Device Codes (OAuth Device Flow, RFC 8628) ───────────────────
72
86
  await db.schema
73
87
  .createTable("_emdash_device_codes")
88
+ .ifNotExists()
74
89
  .addColumn("device_code", "text", (col) => col.primaryKey())
75
90
  .addColumn("user_code", "text", (col) => col.notNull().unique())
76
91
  .addColumn("scopes", "text", (col) => col.notNull()) // JSON array
@@ -83,7 +98,7 @@ export async function up(db: Kysely<unknown>): Promise<void> {
83
98
  }
84
99
 
85
100
  export async function down(db: Kysely<unknown>): Promise<void> {
86
- await db.schema.dropTable("_emdash_device_codes").execute();
87
- await db.schema.dropTable("_emdash_oauth_tokens").execute();
88
- await db.schema.dropTable("_emdash_api_tokens").execute();
101
+ await db.schema.dropTable("_emdash_device_codes").ifExists().execute();
102
+ await db.schema.dropTable("_emdash_oauth_tokens").ifExists().execute();
103
+ await db.schema.dropTable("_emdash_api_tokens").ifExists().execute();
89
104
  }
@@ -0,0 +1,18 @@
1
+ import { type Kysely } from "kysely";
2
+
3
+ import { columnExists } from "../dialect-helpers.js";
4
+
5
+ export async function up(db: Kysely<unknown>): Promise<void> {
6
+ if (!(await columnExists(db, "credentials", "algorithm"))) {
7
+ await db.schema
8
+ .alterTable("credentials")
9
+ .addColumn("algorithm", "integer", (col) => col.notNull().defaultTo(-7))
10
+ .execute();
11
+ }
12
+ }
13
+
14
+ export async function down(db: Kysely<unknown>): Promise<void> {
15
+ if (await columnExists(db, "credentials", "algorithm")) {
16
+ await db.schema.alterTable("credentials").dropColumn("algorithm").execute();
17
+ }
18
+ }
@@ -37,6 +37,7 @@ import * as m033 from "./033_optimize_content_indexes.js";
37
37
  import * as m034 from "./034_published_at_index.js";
38
38
  import * as m035 from "./035_bounded_404_log.js";
39
39
  import * as m036 from "./036_i18n_menus_and_taxonomies.js";
40
+ import * as m037 from "./037_credential_algorithm.js";
40
41
 
41
42
  const MIGRATIONS: Readonly<Record<string, Migration>> = Object.freeze({
42
43
  "001_initial": m001,
@@ -74,6 +75,7 @@ const MIGRATIONS: Readonly<Record<string, Migration>> = Object.freeze({
74
75
  "034_published_at_index": m034,
75
76
  "035_bounded_404_log": m035,
76
77
  "036_i18n_menus_and_taxonomies": m036,
78
+ "037_credential_algorithm": m037,
77
79
  });
78
80
 
79
81
  /** Total number of registered migrations. Exported for use in tests. */
@@ -1,4 +1,4 @@
1
- import { sql, type Kysely, type SqlBool } from "kysely";
1
+ import { sql, type ExpressionBuilder, type Kysely, type SqlBool } from "kysely";
2
2
  import { ulid } from "ulidx";
3
3
 
4
4
  import type { Database, MediaRow } from "../types.js";
@@ -10,6 +10,35 @@ function escapeLike(value: string): string {
10
10
  return value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
11
11
  }
12
12
 
13
+ /**
14
+ * Normalize a mimeType filter (string or array) into a clean string[].
15
+ * Entries that are empty strings are dropped.
16
+ */
17
+ function normalizeMimeFilter(input?: string | readonly string[]): string[] {
18
+ if (!input) return [];
19
+ const arr = Array.isArray(input) ? input : [input];
20
+ return arr
21
+ .filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
22
+ .map((entry) =>
23
+ entry.endsWith("/") ? entry.toLowerCase() : entry.split(";")[0]!.trim().toLowerCase(),
24
+ );
25
+ }
26
+
27
+ /**
28
+ * Build a WHERE clause that matches `mime_type` against any of the given
29
+ * filter entries — exact equality for full MIMEs, LIKE prefix for entries
30
+ * ending in "/".
31
+ */
32
+ function mimeMatchExpr(eb: ExpressionBuilder<Database, "media">, filters: string[]) {
33
+ return eb.or(
34
+ filters.map((entry) =>
35
+ entry.endsWith("/")
36
+ ? sql<SqlBool>`mime_type LIKE ${`${escapeLike(entry)}%`} ESCAPE '\\'`
37
+ : eb("mime_type", "=", entry),
38
+ ),
39
+ );
40
+ }
41
+
13
42
  export type MediaStatus = "pending" | "ready" | "failed";
14
43
 
15
44
  export interface MediaItem {
@@ -49,7 +78,8 @@ export interface CreateMediaInput {
49
78
  export interface FindManyMediaOptions {
50
79
  limit?: number;
51
80
  cursor?: string;
52
- mimeType?: string; // Filter by mime type prefix, e.g., "image/"
81
+ /** Filter by MIME type. Pass a string for a single prefix/exact, or an array to match any. Strings ending with "/" are treated as LIKE prefix matches; others are exact equality. */
82
+ mimeType?: string | readonly string[];
53
83
  status?: MediaStatus | "all"; // Filter by status, defaults to "ready"
54
84
  }
55
85
 
@@ -215,9 +245,9 @@ export class MediaRepository {
215
245
  );
216
246
  }
217
247
 
218
- if (options.mimeType) {
219
- const pattern = `${escapeLike(options.mimeType)}%`;
220
- query = query.where(sql<SqlBool>`mime_type LIKE ${pattern} ESCAPE '\\'`);
248
+ const mimeFilters = normalizeMimeFilter(options.mimeType);
249
+ if (mimeFilters.length > 0) {
250
+ query = query.where((eb) => mimeMatchExpr(eb, mimeFilters));
221
251
  }
222
252
 
223
253
  // Default to only showing ready items
@@ -276,12 +306,12 @@ export class MediaRepository {
276
306
  /**
277
307
  * Count media items
278
308
  */
279
- async count(mimeType?: string): Promise<number> {
280
- let query = this.db.selectFrom("media").select((eb) => eb.fn.count("id").as("count"));
309
+ async count(mimeType?: string | readonly string[]): Promise<number> {
310
+ const filters = normalizeMimeFilter(mimeType);
311
+ let query = this.db.selectFrom("media").select((eb) => eb.fn.count<number>("id").as("count"));
281
312
 
282
- if (mimeType) {
283
- const pattern = `${escapeLike(mimeType)}%`;
284
- query = query.where(sql<SqlBool>`mime_type LIKE ${pattern} ESCAPE '\\'`);
313
+ if (filters.length > 0) {
314
+ query = query.where((eb) => mimeMatchExpr(eb, filters));
285
315
  }
286
316
 
287
317
  const result = await query.executeTakeFirst();
@@ -76,7 +76,8 @@ export interface UserTable {
76
76
  export interface CredentialTable {
77
77
  id: string; // Base64url credential ID
78
78
  user_id: string;
79
- public_key: Uint8Array; // COSE public key
79
+ public_key: Uint8Array; // SEC1 or PKIX encoded public key
80
+ algorithm: number;
80
81
  counter: number;
81
82
  device_type: string; // 'singleDevice' | 'multiDevice'
82
83
  backed_up: number; // 0 or 1
@@ -1287,6 +1287,8 @@ export class EmDashRuntime {
1287
1287
  // or arbitrary `Record<string, unknown>` for plugin field widgets that
1288
1288
  // need per-field config (e.g. a checkbox grid receiving its column defs).
1289
1289
  options?: Array<{ value: string; label: string }> | Record<string, unknown>;
1290
+ id?: string;
1291
+ validation?: Record<string, unknown>;
1290
1292
  }
1291
1293
  > = {};
1292
1294
 
@@ -1296,6 +1298,9 @@ export class EmDashRuntime {
1296
1298
  label: field.label,
1297
1299
  required: field.required,
1298
1300
  };
1301
+ // Always include the field's database ID so the admin can forward it
1302
+ // to upload/media-list API calls for MIME allowlist widening.
1303
+ entry.id = field.id;
1299
1304
  if (field.widget) entry.widget = field.widget;
1300
1305
  // Plugin field widgets read their per-field config from `field.options`,
1301
1306
  // which the seed schema types as `Record<string, unknown>`. Pass it
@@ -1312,8 +1317,12 @@ export class EmDashRuntime {
1312
1317
  }));
1313
1318
  }
1314
1319
  // Include full validation for repeater fields (subFields, minItems, maxItems)
1315
- if (field.type === "repeater" && field.validation) {
1316
- (entry as Record<string, unknown>).validation = field.validation;
1320
+ // and for file/image fields (allowedMimeTypes).
1321
+ if (
1322
+ (field.type === "repeater" || field.type === "file" || field.type === "image") &&
1323
+ field.validation
1324
+ ) {
1325
+ entry.validation = { ...field.validation };
1317
1326
  }
1318
1327
  fields[field.slug] = entry;
1319
1328
  }
@@ -1980,7 +1989,11 @@ export class EmDashRuntime {
1980
1989
  // Media Handlers
1981
1990
  // =========================================================================
1982
1991
 
1983
- async handleMediaList(params: { cursor?: string; limit?: number; mimeType?: string }) {
1992
+ async handleMediaList(params: {
1993
+ cursor?: string;
1994
+ limit?: number;
1995
+ mimeType?: string | readonly string[];
1996
+ }) {
1984
1997
  return handleMediaList(this.db, params);
1985
1998
  }
1986
1999
 
@@ -5,13 +5,10 @@ import type { FieldDefinition, FieldUIHints, FileValue } from "./types.js";
5
5
  export interface FileOptions {
6
6
  required?: boolean;
7
7
  maxSize?: number; // In bytes
8
- allowedTypes?: string[]; // MIME types
8
+ allowedTypes?: string[]; // MIME types — exact (image/png) or prefix (image/)
9
9
  helpText?: string;
10
10
  }
11
11
 
12
- /**
13
- * File field - file upload
14
- */
15
12
  export function file(options: FileOptions = {}): FieldDefinition<FileValue> {
16
13
  const fileObjSchema = z.object({
17
14
  id: z.string(),
@@ -21,21 +18,25 @@ export function file(options: FileOptions = {}): FieldDefinition<FileValue> {
21
18
  size: z.number(),
22
19
  });
23
20
 
24
- // Optional vs required
25
21
  const schema: z.ZodTypeAny = options.required ? fileObjSchema : fileObjSchema.optional();
26
22
 
27
23
  const ui: FieldUIHints = {
28
24
  widget: "file",
29
25
  helpText: options.helpText,
30
26
  maxSize: options.maxSize,
31
- allowedTypes: options.allowedTypes,
32
27
  };
33
28
 
29
+ const validation =
30
+ options.allowedTypes && options.allowedTypes.length > 0
31
+ ? { allowedMimeTypes: [...options.allowedTypes] }
32
+ : undefined;
33
+
34
34
  return {
35
35
  type: "file",
36
36
  columnType: "TEXT",
37
37
  schema,
38
38
  options,
39
39
  ui,
40
+ validation,
40
41
  };
41
42
  }
@@ -2,9 +2,6 @@ import { z } from "astro/zod";
2
2
 
3
3
  import type { FieldDefinition, ImageValue } from "./types.js";
4
4
 
5
- /**
6
- * Image field schema
7
- */
8
5
  const imageSchema = z.object({
9
6
  id: z.string(),
10
7
  src: z.string(),
@@ -13,22 +10,26 @@ const imageSchema = z.object({
13
10
  height: z.number().optional(),
14
11
  });
15
12
 
16
- /**
17
- * Image field
18
- * References media items from the media library
19
- */
20
- export function image(options?: {
13
+ export interface ImageOptions {
21
14
  required?: boolean;
22
15
  maxSize?: number; // in bytes
23
- allowedTypes?: string[]; // MIME types
24
- }): FieldDefinition<ImageValue | undefined> {
16
+ allowedTypes?: string[]; // MIME types — exact or prefix
17
+ }
18
+
19
+ export function image(options: ImageOptions = {}): FieldDefinition<ImageValue | undefined> {
20
+ const validation =
21
+ options.allowedTypes && options.allowedTypes.length > 0
22
+ ? { allowedMimeTypes: [...options.allowedTypes] }
23
+ : undefined;
24
+
25
25
  return {
26
26
  type: "image",
27
27
  columnType: "TEXT",
28
- schema: options?.required === false ? imageSchema.optional() : imageSchema,
28
+ schema: options.required === false ? imageSchema.optional() : imageSchema,
29
29
  options,
30
30
  ui: {
31
31
  widget: "image",
32
32
  },
33
+ validation,
33
34
  };
34
35
  }
@@ -1,5 +1,7 @@
1
1
  import type { z } from "astro/zod";
2
2
 
3
+ import type { FieldValidation } from "../schema/types.js";
4
+
3
5
  /**
4
6
  * SQLite column types that map from field types
5
7
  */
@@ -19,6 +21,7 @@ export interface FieldDefinition<_T = unknown> {
19
21
  schema: z.ZodTypeAny;
20
22
  options?: unknown;
21
23
  ui?: FieldUIHints;
24
+ validation?: FieldValidation;
22
25
  }
23
26
 
24
27
  /**
package/src/index.ts CHANGED
@@ -27,7 +27,7 @@ export type {
27
27
  export type { MediaItem, CreateMediaInput } from "./database/repositories/media.js";
28
28
 
29
29
  // Fields
30
- export { portableText, image, reference } from "./fields/index.js";
30
+ export { portableText, image, file, reference } from "./fields/index.js";
31
31
  export { normalizeMediaValue } from "./media/normalize.js";
32
32
  export { generatePlaceholder } from "./media/placeholder.js";
33
33
  export type { PlaceholderData } from "./media/placeholder.js";
package/src/mcp/server.ts CHANGED
@@ -1993,13 +1993,23 @@ export function createMcpServer(): McpServer {
1993
1993
  description:
1994
1994
  "Create a new navigation menu. The `name` is the stable identifier used " +
1995
1995
  "by site templates (e.g. 'main', 'footer'); `label` is the human-readable " +
1996
- "name shown in the admin. Add items afterwards with menu_set_items.",
1996
+ "name shown in the admin. Menus are per-locale, so pass `locale` when " +
1997
+ "the same menu name exists in multiple translations. Add items afterwards " +
1998
+ "with menu_set_items. If `translationOf` is set, `locale` must also be set.",
1999
+ // `locale`-when-`translationOf` is enforced inside handleMenuCreate
2000
+ // so REST/SDK callers get the same guard. The description above
2001
+ // documents the rule; the handler returns VALIDATION_ERROR.
1997
2002
  inputSchema: z.object({
1998
2003
  name: z
1999
2004
  .string()
2000
2005
  .regex(COLLECTION_SLUG_PATTERN)
2001
2006
  .describe("Stable identifier (lowercase letters, numbers, underscores)"),
2002
2007
  label: z.string().describe("Display name for the admin"),
2008
+ locale: z.string().optional().describe("Locale for this menu (e.g. 'fr-fr')"),
2009
+ translationOf: z
2010
+ .string()
2011
+ .optional()
2012
+ .describe("Existing menu id to create this locale variant from"),
2003
2013
  }),
2004
2014
  },
2005
2015
  async (args, extra) => {
@@ -2008,7 +2018,14 @@ export function createMcpServer(): McpServer {
2008
2018
  const ec = getEmDash(extra);
2009
2019
  try {
2010
2020
  const { handleMenuCreate } = await import("../api/handlers/menus.js");
2011
- return unwrap(await handleMenuCreate(ec.db, { name: args.name, label: args.label }));
2021
+ return unwrap(
2022
+ await handleMenuCreate(ec.db, {
2023
+ name: args.name,
2024
+ label: args.label,
2025
+ locale: args.locale,
2026
+ translationOf: args.translationOf,
2027
+ }),
2028
+ );
2012
2029
  } catch (error) {
2013
2030
  return respondHandlerError(error, "MENU_CREATE_ERROR");
2014
2031
  }
@@ -2019,10 +2036,13 @@ export function createMcpServer(): McpServer {
2019
2036
  "menu_update",
2020
2037
  {
2021
2038
  title: "Update Menu",
2022
- description: "Update a menu's label. The `name` (stable identifier) cannot be changed.",
2039
+ description:
2040
+ "Update a menu's label. The `name` (stable identifier) cannot be changed. " +
2041
+ "On multi-locale installs, pass `locale` so the correct translation is updated.",
2023
2042
  inputSchema: z.object({
2024
2043
  name: z.string().describe("Menu name to update"),
2025
2044
  label: z.string().describe("New display label"),
2045
+ locale: z.string().optional().describe("Locale of the menu to update"),
2026
2046
  }),
2027
2047
  },
2028
2048
  async (args, extra) => {
@@ -2031,7 +2051,9 @@ export function createMcpServer(): McpServer {
2031
2051
  const ec = getEmDash(extra);
2032
2052
  try {
2033
2053
  const { handleMenuUpdate } = await import("../api/handlers/menus.js");
2034
- return unwrap(await handleMenuUpdate(ec.db, args.name, { label: args.label }));
2054
+ return unwrap(
2055
+ await handleMenuUpdate(ec.db, args.name, { label: args.label, locale: args.locale }),
2056
+ );
2035
2057
  } catch (error) {
2036
2058
  return respondHandlerError(error, "MENU_UPDATE_ERROR");
2037
2059
  }
@@ -2042,9 +2064,12 @@ export function createMcpServer(): McpServer {
2042
2064
  "menu_delete",
2043
2065
  {
2044
2066
  title: "Delete Menu",
2045
- description: "Delete a menu. Items are also removed. Cannot be undone.",
2067
+ description:
2068
+ "Delete a menu. Items are also removed. Cannot be undone. On multi-locale " +
2069
+ "installs, pass `locale` so only the intended translation is removed.",
2046
2070
  inputSchema: z.object({
2047
2071
  name: z.string().describe("Menu name to delete"),
2072
+ locale: z.string().optional().describe("Locale of the menu to delete"),
2048
2073
  }),
2049
2074
  annotations: { destructiveHint: true },
2050
2075
  },
@@ -2054,7 +2079,7 @@ export function createMcpServer(): McpServer {
2054
2079
  const ec = getEmDash(extra);
2055
2080
  try {
2056
2081
  const { handleMenuDelete } = await import("../api/handlers/menus.js");
2057
- return unwrap(await handleMenuDelete(ec.db, args.name));
2082
+ return unwrap(await handleMenuDelete(ec.db, args.name, { locale: args.locale }));
2058
2083
  } catch (error) {
2059
2084
  return respondHandlerError(error, "MENU_DELETE_ERROR");
2060
2085
  }
@@ -2069,9 +2094,11 @@ export function createMcpServer(): McpServer {
2069
2094
  "Replace the entire item list of a menu in one call. This is atomic: the " +
2070
2095
  "existing items are deleted and the new list is inserted in the order " +
2071
2096
  "provided. Use this rather than per-item add/remove tools so the resulting " +
2072
- "order and parent links are unambiguous.",
2097
+ "order and parent links are unambiguous. On multi-locale installs, pass " +
2098
+ "`locale` so only the intended translation is rewritten.",
2073
2099
  inputSchema: z.object({
2074
2100
  name: z.string().describe("Menu name to update"),
2101
+ locale: z.string().optional().describe("Locale of the menu to rewrite"),
2075
2102
  items: z
2076
2103
  .array(
2077
2104
  z.object({
@@ -2115,7 +2142,9 @@ export function createMcpServer(): McpServer {
2115
2142
  const ec = getEmDash(extra);
2116
2143
  try {
2117
2144
  const { handleMenuSetItems } = await import("../api/handlers/menus.js");
2118
- return unwrap(await handleMenuSetItems(ec.db, args.name, args.items));
2145
+ return unwrap(
2146
+ await handleMenuSetItems(ec.db, args.name, args.items, { locale: args.locale }),
2147
+ );
2119
2148
  } catch (error) {
2120
2149
  return respondHandlerError(error, "MENU_SET_ITEMS_ERROR");
2121
2150
  }