emdash 0.9.0 → 0.10.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 (195) hide show
  1. package/dist/{adapters-DoNJiveC.d.mts → adapters-BktHA7EO.d.mts} +1 -1
  2. package/dist/{adapters-DoNJiveC.d.mts.map → adapters-BktHA7EO.d.mts.map} +1 -1
  3. package/dist/{apply-BzltprvY.mjs → apply-UsrFuO7l.mjs} +156 -254
  4. package/dist/apply-UsrFuO7l.mjs.map +1 -0
  5. package/dist/astro/index.d.mts +6 -6
  6. package/dist/astro/index.mjs +10 -2
  7. package/dist/astro/index.mjs.map +1 -1
  8. package/dist/astro/middleware/auth.d.mts +5 -5
  9. package/dist/astro/middleware/auth.mjs +5 -5
  10. package/dist/astro/middleware/redirect.mjs +5 -5
  11. package/dist/astro/middleware/request-context.mjs +4 -4
  12. package/dist/astro/middleware/setup.mjs +1 -1
  13. package/dist/astro/middleware.mjs +35 -34
  14. package/dist/astro/middleware.mjs.map +1 -1
  15. package/dist/astro/types.d.mts +8 -9
  16. package/dist/astro/types.d.mts.map +1 -1
  17. package/dist/{base64-BRICGH2l.mjs → base64-MBPo9ozB.mjs} +1 -1
  18. package/dist/{base64-BRICGH2l.mjs.map → base64-MBPo9ozB.mjs.map} +1 -1
  19. package/dist/{byline-BSaNL1w7.mjs → byline-C3vnhIpU.mjs} +4 -4
  20. package/dist/{byline-BSaNL1w7.mjs.map → byline-C3vnhIpU.mjs.map} +1 -1
  21. package/dist/{bylines-CvJ3PYz2.mjs → bylines-esI7ioa9.mjs} +5 -5
  22. package/dist/{bylines-CvJ3PYz2.mjs.map → bylines-esI7ioa9.mjs.map} +1 -1
  23. package/dist/{cache-C6N_hhN7.mjs → cache-fTzxgMFJ.mjs} +3 -3
  24. package/dist/{cache-C6N_hhN7.mjs.map → cache-fTzxgMFJ.mjs.map} +1 -1
  25. package/dist/{chunks-NBQVDOci.mjs → chunks-Da2-b-oA.mjs} +2 -2
  26. package/dist/{chunks-NBQVDOci.mjs.map → chunks-Da2-b-oA.mjs.map} +1 -1
  27. package/dist/cli/index.mjs +251 -79
  28. package/dist/cli/index.mjs.map +1 -1
  29. package/dist/client/cf-access.d.mts +1 -1
  30. package/dist/client/index.d.mts +1 -1
  31. package/dist/client/index.mjs +1 -1
  32. package/dist/{config-BI0V3ICQ.mjs → config-CVssduLe.mjs} +1 -1
  33. package/dist/{config-BI0V3ICQ.mjs.map → config-CVssduLe.mjs.map} +1 -1
  34. package/dist/{content-8lOYF0pr.mjs → content-C7G4QXkK.mjs} +14 -3
  35. package/dist/content-C7G4QXkK.mjs.map +1 -0
  36. package/dist/db/index.d.mts +3 -3
  37. package/dist/db/index.mjs +1 -1
  38. package/dist/db/libsql.d.mts +1 -1
  39. package/dist/db/postgres.d.mts +1 -1
  40. package/dist/db/sqlite.d.mts +1 -1
  41. package/dist/{db-errors-WRezodiz.mjs → db-errors-B7P2pSCn.mjs} +1 -1
  42. package/dist/{db-errors-WRezodiz.mjs.map → db-errors-B7P2pSCn.mjs.map} +1 -1
  43. package/dist/{default-D8ksjWhO.mjs → default-pHuz9WF6.mjs} +1 -1
  44. package/dist/{default-D8ksjWhO.mjs.map → default-pHuz9WF6.mjs.map} +1 -1
  45. package/dist/{error-D_-tqP-I.mjs → error-DqnRMM5z.mjs} +1 -1
  46. package/dist/{error-D_-tqP-I.mjs.map → error-DqnRMM5z.mjs.map} +1 -1
  47. package/dist/{index-BFRaVcD6.d.mts → index-DjPMOfO0.d.mts} +82 -67
  48. package/dist/index-DjPMOfO0.d.mts.map +1 -0
  49. package/dist/index.d.mts +10 -10
  50. package/dist/index.mjs +28 -27
  51. package/dist/{load-DDqMMvZL.mjs → load-sXRuM7Us.mjs} +2 -2
  52. package/dist/{load-DDqMMvZL.mjs.map → load-sXRuM7Us.mjs.map} +1 -1
  53. package/dist/{loader-CKLbBnhK.mjs → loader-Bx2_9-5e.mjs} +31 -6
  54. package/dist/loader-Bx2_9-5e.mjs.map +1 -0
  55. package/dist/{manifest-schema-DqWNC3lM.mjs → manifest-schema-CXAbd1vH.mjs} +1 -1
  56. package/dist/{manifest-schema-DqWNC3lM.mjs.map → manifest-schema-CXAbd1vH.mjs.map} +1 -1
  57. package/dist/media/index.d.mts +1 -1
  58. package/dist/media/index.mjs +1 -1
  59. package/dist/media/local-runtime.d.mts +7 -7
  60. package/dist/media/local-runtime.mjs +3 -3
  61. package/dist/{media-BW32b4gi.mjs → media-D8FbNsl0.mjs} +2 -2
  62. package/dist/{media-BW32b4gi.mjs.map → media-D8FbNsl0.mjs.map} +1 -1
  63. package/dist/{mode-ier8jbBk.mjs → mode-YhqNVef_.mjs} +1 -1
  64. package/dist/{mode-ier8jbBk.mjs.map → mode-YhqNVef_.mjs.map} +1 -1
  65. package/dist/{options-BVp3UsTS.mjs → options-nPxWnrya.mjs} +1 -1
  66. package/dist/{options-BVp3UsTS.mjs.map → options-nPxWnrya.mjs.map} +1 -1
  67. package/dist/page/index.d.mts +2 -2
  68. package/dist/{patterns-CrCYkMBb.mjs → patterns-DsUZ4uxI.mjs} +1 -1
  69. package/dist/{patterns-CrCYkMBb.mjs.map → patterns-DsUZ4uxI.mjs.map} +1 -1
  70. package/dist/{placeholder-BE4o_2dc.d.mts → placeholder-CDPtkelt.d.mts} +1 -1
  71. package/dist/{placeholder-BE4o_2dc.d.mts.map → placeholder-CDPtkelt.d.mts.map} +1 -1
  72. package/dist/{placeholder-CIJejMlK.mjs → placeholder-Ci0RLeCk.mjs} +1 -1
  73. package/dist/{placeholder-CIJejMlK.mjs.map → placeholder-Ci0RLeCk.mjs.map} +1 -1
  74. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  75. package/dist/plugins/adapt-sandbox-entry.mjs +2 -2
  76. package/dist/{public-url-DByxYjUw.mjs → public-url-B1AxbbbQ.mjs} +1 -1
  77. package/dist/{public-url-DByxYjUw.mjs.map → public-url-B1AxbbbQ.mjs.map} +1 -1
  78. package/dist/{query-Cg9ZKRQ0.mjs → query-Bo-msrmu.mjs} +13 -13
  79. package/dist/{query-Cg9ZKRQ0.mjs.map → query-Bo-msrmu.mjs.map} +1 -1
  80. package/dist/{redirect-BhUBKRc1.mjs → redirect-C5H7VGIX.mjs} +3 -3
  81. package/dist/{redirect-BhUBKRc1.mjs.map → redirect-C5H7VGIX.mjs.map} +1 -1
  82. package/dist/{registry-Dw70ChxB.mjs → registry-Beb7wxFc.mjs} +5 -5
  83. package/dist/{registry-Dw70ChxB.mjs.map → registry-Beb7wxFc.mjs.map} +1 -1
  84. package/dist/{request-cache-B-bmkipQ.mjs → request-cache-C-tIpYIw.mjs} +1 -1
  85. package/dist/{request-cache-B-bmkipQ.mjs.map → request-cache-C-tIpYIw.mjs.map} +1 -1
  86. package/dist/{runner-Bnoj7vjK.d.mts → runner-Clwe4Mme.d.mts} +2 -2
  87. package/dist/{runner-Bnoj7vjK.d.mts.map → runner-Clwe4Mme.d.mts.map} +1 -1
  88. package/dist/{runner-C7ADox5q.mjs → runner-DMnlIkh4.mjs} +433 -138
  89. package/dist/runner-DMnlIkh4.mjs.map +1 -0
  90. package/dist/runtime.d.mts +6 -6
  91. package/dist/runtime.mjs +3 -3
  92. package/dist/{search-dOGEccMa.mjs → search-DkN-BqsS.mjs} +164 -92
  93. package/dist/search-DkN-BqsS.mjs.map +1 -0
  94. package/dist/{secrets-CW3reAnU.mjs → secrets-CZ8rxLX3.mjs} +3 -3
  95. package/dist/{secrets-CW3reAnU.mjs.map → secrets-CZ8rxLX3.mjs.map} +1 -1
  96. package/dist/seed/index.d.mts +2 -2
  97. package/dist/seed/index.mjs +15 -14
  98. package/dist/seo/index.d.mts +1 -1
  99. package/dist/storage/local.d.mts +1 -1
  100. package/dist/storage/local.mjs +1 -1
  101. package/dist/storage/s3.d.mts +1 -1
  102. package/dist/storage/s3.mjs +1 -1
  103. package/dist/taxonomies-CTtewrSQ.mjs +407 -0
  104. package/dist/taxonomies-CTtewrSQ.mjs.map +1 -0
  105. package/dist/taxonomy-DSxx2K2L.mjs +218 -0
  106. package/dist/taxonomy-DSxx2K2L.mjs.map +1 -0
  107. package/dist/{tokens-D7zMmWi2.mjs → tokens-CyRDPVW2.mjs} +2 -2
  108. package/dist/{tokens-D7zMmWi2.mjs.map → tokens-CyRDPVW2.mjs.map} +1 -1
  109. package/dist/{transaction-Cn2rjY78.mjs → transaction-D44LBXvU.mjs} +1 -1
  110. package/dist/{transaction-Cn2rjY78.mjs.map → transaction-D44LBXvU.mjs.map} +1 -1
  111. package/dist/{transport-DNEfeMaU.d.mts → transport-DX_5rpsq.d.mts} +1 -1
  112. package/dist/{transport-DNEfeMaU.d.mts.map → transport-DX_5rpsq.d.mts.map} +1 -1
  113. package/dist/{transport-BeMCmin1.mjs → transport-xpzIjCIB.mjs} +1 -1
  114. package/dist/{transport-BeMCmin1.mjs.map → transport-xpzIjCIB.mjs.map} +1 -1
  115. package/dist/{types-CRxNbK-Z.mjs → types-BIgulNsW.mjs} +2 -2
  116. package/dist/{types-CRxNbK-Z.mjs.map → types-BIgulNsW.mjs.map} +1 -1
  117. package/dist/{types-CJsYGpco.d.mts → types-B_CXXnzh.d.mts} +1 -1
  118. package/dist/{types-CJsYGpco.d.mts.map → types-B_CXXnzh.d.mts.map} +1 -1
  119. package/dist/{types-M78DQ1lx.d.mts → types-C-aFbqmA.d.mts} +1 -1
  120. package/dist/{types-M78DQ1lx.d.mts.map → types-C-aFbqmA.d.mts.map} +1 -1
  121. package/dist/{types-4fVtCIm0.mjs → types-CoO6mpV3.mjs} +1 -1
  122. package/dist/{types-4fVtCIm0.mjs.map → types-CoO6mpV3.mjs.map} +1 -1
  123. package/dist/{types-BuBIptGk.d.mts → types-D19uBYWn.d.mts} +149 -4
  124. package/dist/types-D19uBYWn.d.mts.map +1 -0
  125. package/dist/{types-BSyXeCFW.d.mts → types-Dl1fgFjn.d.mts} +1 -1
  126. package/dist/{types-BSyXeCFW.d.mts.map → types-Dl1fgFjn.d.mts.map} +1 -1
  127. package/dist/{types-CrtWgIvl.d.mts → types-Dtx1mSMX.d.mts} +9 -1
  128. package/dist/types-Dtx1mSMX.d.mts.map +1 -0
  129. package/dist/{types-CIOg5AR8.mjs → types-Eg829jj9.mjs} +1 -1
  130. package/dist/{types-CIOg5AR8.mjs.map → types-Eg829jj9.mjs.map} +1 -1
  131. package/dist/{types-CDbKp7ND.mjs → types-K-EkEQCI.mjs} +1 -1
  132. package/dist/{types-CDbKp7ND.mjs.map → types-K-EkEQCI.mjs.map} +1 -1
  133. package/dist/{validate-Baqf0slj.mjs → validate-CBIbxM3L.mjs} +14 -10
  134. package/dist/validate-CBIbxM3L.mjs.map +1 -0
  135. package/dist/{validate-BfQh_C_y.d.mts → validate-DHGwADqO.d.mts} +18 -5
  136. package/dist/validate-DHGwADqO.d.mts.map +1 -0
  137. package/dist/{validation-BfEI7tNe.mjs → validation-B1NYiEos.mjs} +5 -5
  138. package/dist/{validation-BfEI7tNe.mjs.map → validation-B1NYiEos.mjs.map} +1 -1
  139. package/dist/version-CMD42IRC.mjs +7 -0
  140. package/dist/{version-DoxrVdYf.mjs.map → version-CMD42IRC.mjs.map} +1 -1
  141. package/dist/{zod-generator-CC0xNe_K.mjs → zod-generator-BNJDQBSZ.mjs} +8 -3
  142. package/dist/zod-generator-BNJDQBSZ.mjs.map +1 -0
  143. package/package.json +6 -6
  144. package/src/api/handlers/content.ts +11 -0
  145. package/src/api/handlers/dashboard.ts +29 -36
  146. package/src/api/handlers/menus.ts +256 -75
  147. package/src/api/handlers/taxonomies.ts +273 -97
  148. package/src/api/schemas/common.ts +7 -0
  149. package/src/api/schemas/menus.ts +23 -0
  150. package/src/api/schemas/taxonomies.ts +39 -0
  151. package/src/astro/integration/routes.ts +10 -0
  152. package/src/astro/routes/api/content/[collection]/[id]/permanent.ts +1 -1
  153. package/src/astro/routes/api/import/wordpress/rewrite-url-helpers.ts +196 -0
  154. package/src/astro/routes/api/import/wordpress/rewrite-urls.ts +9 -177
  155. package/src/astro/routes/api/menus/[name]/items.ts +16 -6
  156. package/src/astro/routes/api/menus/[name]/reorder.ts +8 -3
  157. package/src/astro/routes/api/menus/[name]/translations.ts +82 -0
  158. package/src/astro/routes/api/menus/[name].ts +19 -10
  159. package/src/astro/routes/api/menus/index.ts +9 -6
  160. package/src/astro/routes/api/taxonomies/[name]/terms/[slug]/translations.ts +89 -0
  161. package/src/astro/routes/api/taxonomies/[name]/terms/[slug].ts +22 -22
  162. package/src/astro/routes/api/taxonomies/[name]/terms/index.ts +11 -14
  163. package/src/astro/routes/api/taxonomies/index.ts +9 -6
  164. package/src/cli/commands/export-seed.ts +82 -21
  165. package/src/cli/commands/plugin-init.ts +216 -90
  166. package/src/database/migrations/036_i18n_menus_and_taxonomies.ts +477 -0
  167. package/src/database/migrations/runner.ts +2 -0
  168. package/src/database/repositories/content.ts +11 -0
  169. package/src/database/repositories/taxonomy.ts +193 -89
  170. package/src/database/types.ts +10 -2
  171. package/src/i18n/resolve.ts +37 -0
  172. package/src/loader.ts +49 -2
  173. package/src/mcp/server.ts +77 -18
  174. package/src/menus/index.ts +143 -124
  175. package/src/menus/types.ts +15 -1
  176. package/src/schema/zod-generator.ts +12 -2
  177. package/src/seed/apply.ts +140 -54
  178. package/src/seed/types.ts +14 -1
  179. package/src/seed/validate.ts +27 -13
  180. package/src/taxonomies/index.ts +230 -213
  181. package/src/taxonomies/types.ts +10 -0
  182. package/dist/apply-BzltprvY.mjs.map +0 -1
  183. package/dist/content-8lOYF0pr.mjs.map +0 -1
  184. package/dist/index-BFRaVcD6.d.mts.map +0 -1
  185. package/dist/loader-CKLbBnhK.mjs.map +0 -1
  186. package/dist/runner-C7ADox5q.mjs.map +0 -1
  187. package/dist/search-dOGEccMa.mjs.map +0 -1
  188. package/dist/taxonomies-ZlRtD6AG.mjs +0 -315
  189. package/dist/taxonomies-ZlRtD6AG.mjs.map +0 -1
  190. package/dist/types-BuBIptGk.d.mts.map +0 -1
  191. package/dist/types-CrtWgIvl.d.mts.map +0 -1
  192. package/dist/validate-Baqf0slj.mjs.map +0 -1
  193. package/dist/validate-BfQh_C_y.d.mts.map +0 -1
  194. package/dist/version-DoxrVdYf.mjs +0 -7
  195. package/dist/zod-generator-CC0xNe_K.mjs.map +0 -1
@@ -140,21 +140,22 @@ async function fetchRecentItems(
140
140
 
141
141
  const collectionsWithTitle = new Set(titleFields.map((r) => r.collection_slug));
142
142
 
143
- // Build a UNION ALL query across all content tables.
144
- // Each branch is wrapped in SELECT * FROM (...) so the inner
145
- // ORDER BY + LIMIT is valid SQLite (bare ORDER BY inside UNION
146
- // branches is a syntax error in SQLite).
147
- const subQueries = collections.map((col) => {
148
- validateIdentifier(col.slug);
149
- const table = `ec_${col.slug}`;
150
- const hasTitle = collectionsWithTitle.has(col.slug);
151
-
152
- // Use title column if it exists, otherwise fall back to slug → id.
153
- // All output uses snake_case to avoid SQLite quoting issues on D1.
154
- const titleExpr = hasTitle ? sql`COALESCE(title, slug, id)` : sql`COALESCE(slug, id)`;
155
-
156
- return sql<RecentItemRow>`
157
- SELECT * FROM (
143
+ // Issue one query per collection in parallel, then merge in JS.
144
+ // A single UNION ALL across N collections trips D1's
145
+ // SQLITE_LIMIT_COMPOUND_SELECT cap when N is large enough (#895);
146
+ // per-collection queries side-step that. Each query fetches at most
147
+ // 10 rows, so the merge handles at most N * 10 rows before slicing.
148
+ const perCollection = await Promise.all(
149
+ collections.map(async (col) => {
150
+ validateIdentifier(col.slug);
151
+ const table = `ec_${col.slug}`;
152
+ const hasTitle = collectionsWithTitle.has(col.slug);
153
+
154
+ // Use title column if it exists, otherwise fall back to slug, id.
155
+ // All output uses snake_case to avoid SQLite quoting issues on D1.
156
+ const titleExpr = hasTitle ? sql`COALESCE(title, slug, id)` : sql`COALESCE(slug, id)`;
157
+
158
+ const result = await sql<RecentItemRow>`
158
159
  SELECT
159
160
  id,
160
161
  ${sql.lit(col.slug)} AS collection,
@@ -168,27 +169,19 @@ async function fetchRecentItems(
168
169
  WHERE deleted_at IS NULL
169
170
  ORDER BY updated_at DESC
170
171
  LIMIT 10
171
- )
172
- `;
173
- });
174
-
175
- // Combine with UNION ALL
176
- // eslint-disable-next-line typescript-eslint(no-unnecessary-type-assertion) -- noUncheckedIndexedAccess
177
- let combined = subQueries[0]!;
178
- for (let i = 1; i < subQueries.length; i++) {
179
- // eslint-disable-next-line typescript-eslint(no-unnecessary-type-assertion) -- noUncheckedIndexedAccess
180
- combined = sql<RecentItemRow>`${combined} UNION ALL ${subQueries[i]!}`;
181
- }
182
-
183
- // Final sort + limit across all branches
184
- const result = await sql<RecentItemRow>`
185
- SELECT * FROM (${combined})
186
- ORDER BY updated_at DESC
187
- LIMIT 10
188
- `.execute(db);
189
-
190
- // Map snake_case DB rows → camelCase API shape
191
- return result.rows.map((row) => ({
172
+ `.execute(db);
173
+ return result.rows;
174
+ }),
175
+ );
176
+
177
+ // Merge across collections, sort by updated_at desc, take top 10.
178
+ const merged = perCollection
179
+ .flat()
180
+ .toSorted((a, b) => (a.updated_at < b.updated_at ? 1 : a.updated_at > b.updated_at ? -1 : 0))
181
+ .slice(0, 10);
182
+
183
+ // Map snake_case DB rows to camelCase API shape
184
+ return merged.map((row) => ({
192
185
  id: row.id,
193
186
  collection: row.collection,
194
187
  collectionLabel: row.collection_label,
@@ -1,29 +1,30 @@
1
1
  /**
2
- * Menu CRUD handlers
2
+ * Menu CRUD handlers.
3
3
  *
4
- * Business logic for menu and menu-item endpoints.
5
- * Routes are thin wrappers that parse input, check auth, and call these.
4
+ * Business logic for menu and menu-item endpoints. Routes are thin wrappers
5
+ * that parse input, check auth, and call these.
6
+ *
7
+ * i18n: Menus are per-locale. `(name, locale)` is unique, so the same `name`
8
+ * (e.g. "primary") can exist in several locales within one translation_group.
9
+ * Menu items carry a `locale` + `translation_group` as well, and their
10
+ * `reference_id` points at the referenced content's translation_group (not a
11
+ * specific row id), so a single menu item target survives content translations.
6
12
  */
7
13
 
8
- import type { Kysely } from "kysely";
14
+ import type { Kysely, Selectable } from "kysely";
9
15
  import { ulid } from "ulidx";
10
16
 
11
17
  import { withTransaction } from "../../database/transaction.js";
12
18
  import type { Database, MenuItemTable, MenuTable } from "../../database/types.js";
19
+ import { getI18nConfig } from "../../i18n/config.js";
13
20
  import type { ApiResult } from "../types.js";
14
21
 
15
22
  // ---------------------------------------------------------------------------
16
23
  // Response types
17
24
  // ---------------------------------------------------------------------------
18
25
 
19
- type MenuRow = Omit<MenuTable, "created_at" | "updated_at"> & {
20
- created_at: string;
21
- updated_at: string;
22
- };
23
-
24
- type MenuItemRow = Omit<MenuItemTable, "created_at"> & {
25
- created_at: string;
26
- };
26
+ export type MenuRow = Selectable<MenuTable>;
27
+ export type MenuItemRow = Selectable<MenuItemTable>;
27
28
 
28
29
  export interface MenuListItem extends MenuRow {
29
30
  itemCount: number;
@@ -33,18 +34,34 @@ export interface MenuWithItems extends MenuRow {
33
34
  items: MenuItemRow[];
34
35
  }
35
36
 
37
+ export interface MenuTranslationsResponse {
38
+ translationGroup: string | null;
39
+ translations: Array<{
40
+ id: string;
41
+ name: string;
42
+ locale: string;
43
+ label: string;
44
+ updatedAt: string;
45
+ }>;
46
+ }
47
+
36
48
  // ---------------------------------------------------------------------------
37
49
  // Menu handlers
38
50
  // ---------------------------------------------------------------------------
39
51
 
40
52
  /**
41
- * List all menus with item counts.
53
+ * List menus with item counts. Filter by `locale` when provided; otherwise
54
+ * return every menu row (each locale counts as its own menu for admin listing
55
+ * purposes).
42
56
  */
43
- export async function handleMenuList(db: Kysely<Database>): Promise<ApiResult<MenuListItem[]>> {
57
+ export async function handleMenuList(
58
+ db: Kysely<Database>,
59
+ options: { locale?: string } = {},
60
+ ): Promise<ApiResult<MenuListItem[]>> {
44
61
  try {
45
62
  // Single query: LEFT JOIN + GROUP BY for the per-menu item count.
46
63
  // Avoids the N+1 of one count query per menu.
47
- const rows = await db
64
+ let query = db
48
65
  .selectFrom("_emdash_menus as m")
49
66
  .leftJoin("_emdash_menu_items as i", "i.menu_id", "m.id")
50
67
  .select(({ fn }) => [
@@ -53,11 +70,22 @@ export async function handleMenuList(db: Kysely<Database>): Promise<ApiResult<Me
53
70
  "m.label",
54
71
  "m.created_at",
55
72
  "m.updated_at",
73
+ "m.locale",
74
+ "m.translation_group",
56
75
  fn.count<number>("i.id").as("itemCount"),
57
76
  ])
58
- .groupBy(["m.id", "m.name", "m.label", "m.created_at", "m.updated_at"])
59
- .orderBy("m.name", "asc")
60
- .execute();
77
+ .groupBy([
78
+ "m.id",
79
+ "m.name",
80
+ "m.label",
81
+ "m.created_at",
82
+ "m.updated_at",
83
+ "m.locale",
84
+ "m.translation_group",
85
+ ])
86
+ .orderBy("m.name", "asc");
87
+ if (options.locale !== undefined) query = query.where("m.locale", "=", options.locale);
88
+ const rows = await query.execute();
61
89
 
62
90
  // SQLite returns count as `number`, but some dialects (Postgres)
63
91
  // return `string` from a count() aggregate. Normalize to number.
@@ -67,6 +95,8 @@ export async function handleMenuList(db: Kysely<Database>): Promise<ApiResult<Me
67
95
  label: row.label,
68
96
  created_at: row.created_at,
69
97
  updated_at: row.updated_at,
98
+ locale: row.locale,
99
+ translation_group: row.translation_group,
70
100
  itemCount: typeof row.itemCount === "string" ? Number(row.itemCount) : row.itemCount,
71
101
  }));
72
102
 
@@ -80,42 +110,117 @@ export async function handleMenuList(db: Kysely<Database>): Promise<ApiResult<Me
80
110
  }
81
111
 
82
112
  /**
83
- * Create a new menu.
113
+ * Create a new menu. When `translationOf` is supplied the new menu joins the
114
+ * source menu's translation_group (and gets the source's items cloned).
84
115
  */
85
116
  export async function handleMenuCreate(
86
117
  db: Kysely<Database>,
87
- input: { name: string; label: string },
118
+ input: { name: string; label: string; locale?: string; translationOf?: string },
88
119
  ): Promise<ApiResult<MenuRow>> {
89
120
  try {
121
+ // Resolve translation group + source (if we're creating a translation).
122
+ let translationGroup: string | null = null;
123
+ let sourceMenu: MenuRow | null = null;
124
+ if (input.translationOf) {
125
+ const src = await db
126
+ .selectFrom("_emdash_menus")
127
+ .selectAll()
128
+ .where("id", "=", input.translationOf)
129
+ .executeTakeFirst();
130
+ if (!src) {
131
+ return {
132
+ success: false,
133
+ error: { code: "NOT_FOUND", message: "Source menu for translation not found" },
134
+ };
135
+ }
136
+ sourceMenu = src;
137
+ translationGroup = src.translation_group ?? src.id;
138
+ }
139
+
140
+ // Duplicate guard: same (name, locale). Falls back to the configured
141
+ // defaultLocale to match the column DEFAULT set by migration 036.
142
+ const effectiveLocale = input.locale ?? getI18nConfig()?.defaultLocale ?? "en";
90
143
  const existing = await db
91
144
  .selectFrom("_emdash_menus")
92
145
  .select("id")
93
146
  .where("name", "=", input.name)
147
+ .where("locale", "=", effectiveLocale)
94
148
  .executeTakeFirst();
95
-
96
149
  if (existing) {
97
150
  return {
98
151
  success: false,
99
- error: { code: "CONFLICT", message: `Menu with name "${input.name}" already exists` },
152
+ error: {
153
+ code: "CONFLICT",
154
+ message: `Menu "${input.name}" already exists${
155
+ input.locale ? ` in locale "${input.locale}"` : ""
156
+ }`,
157
+ },
100
158
  };
101
159
  }
102
160
 
103
161
  const id = ulid();
104
- await db
105
- .insertInto("_emdash_menus")
106
- .values({
107
- id,
108
- name: input.name,
109
- label: input.label,
110
- })
111
- .execute();
162
+
163
+ await withTransaction(db, async (trx) => {
164
+ await trx
165
+ .insertInto("_emdash_menus")
166
+ .values({
167
+ id,
168
+ name: input.name,
169
+ label: input.label,
170
+ ...(input.locale !== undefined ? { locale: input.locale } : {}),
171
+ translation_group: translationGroup ?? id,
172
+ })
173
+ .execute();
174
+
175
+ // Clone items from the source menu (same reference_ids — they are
176
+ // translation_groups, which are locale-agnostic). Each clone
177
+ // inherits the source item's translation_group so a nav entry
178
+ // identifies as the same logical item across menu translations.
179
+ if (sourceMenu) {
180
+ const sourceItems = await trx
181
+ .selectFrom("_emdash_menu_items")
182
+ .selectAll()
183
+ .where("menu_id", "=", sourceMenu.id)
184
+ .orderBy("sort_order", "asc")
185
+ .execute();
186
+ if (sourceItems.length > 0) {
187
+ // Build old-id → new-id map so parent pointers land on the clones.
188
+ const idMap = new Map<string, string>();
189
+ for (const item of sourceItems) idMap.set(item.id, ulid());
190
+
191
+ await trx
192
+ .insertInto("_emdash_menu_items")
193
+ .values(
194
+ sourceItems.map((item) => {
195
+ const newId = idMap.get(item.id)!;
196
+ return {
197
+ id: newId,
198
+ menu_id: id,
199
+ parent_id: item.parent_id ? (idMap.get(item.parent_id) ?? null) : null,
200
+ sort_order: item.sort_order,
201
+ type: item.type,
202
+ reference_collection: item.reference_collection,
203
+ reference_id: item.reference_id,
204
+ custom_url: item.custom_url,
205
+ label: item.label,
206
+ title_attr: item.title_attr,
207
+ target: item.target,
208
+ css_classes: item.css_classes,
209
+ ...(input.locale !== undefined ? { locale: input.locale } : {}),
210
+ translation_group: item.translation_group ?? item.id,
211
+ };
212
+ }),
213
+ )
214
+ .execute();
215
+ }
216
+ }
217
+ });
112
218
 
113
219
  const menu = await db
114
220
  .selectFrom("_emdash_menus")
115
221
  .selectAll()
116
222
  .where("id", "=", id)
117
223
  .executeTakeFirstOrThrow();
118
-
119
224
  return { success: true, data: menu };
120
225
  } catch {
121
226
  return {
@@ -126,18 +231,18 @@ export async function handleMenuCreate(
126
231
  }
127
232
 
128
233
  /**
129
- * Get a single menu with all its items.
234
+ * Get a single menu by name. Honours an optional `locale` filter; when two
235
+ * menus share a name across locales, the locale distinguishes them.
130
236
  */
131
237
  export async function handleMenuGet(
132
238
  db: Kysely<Database>,
133
239
  name: string,
240
+ options: { locale?: string } = {},
134
241
  ): Promise<ApiResult<MenuWithItems>> {
135
242
  try {
136
- const menu = await db
137
- .selectFrom("_emdash_menus")
138
- .selectAll()
139
- .where("name", "=", name)
140
- .executeTakeFirst();
243
+ let query = db.selectFrom("_emdash_menus").selectAll().where("name", "=", name);
244
+ if (options.locale !== undefined) query = query.where("locale", "=", options.locale);
245
+ const menu = await query.orderBy("locale", "asc").executeTakeFirst();
141
246
 
142
247
  if (!menu) {
143
248
  return {
@@ -163,19 +268,52 @@ export async function handleMenuGet(
163
268
  }
164
269
 
165
270
  /**
166
- * Update a menu's metadata.
271
+ * Get a menu by id. Useful when the caller already has the id (e.g. after
272
+ * creating a translation and navigating to it).
167
273
  */
168
- export async function handleMenuUpdate(
274
+ export async function handleMenuGetById(
169
275
  db: Kysely<Database>,
170
- name: string,
171
- input: { label?: string },
172
- ): Promise<ApiResult<MenuRow>> {
276
+ id: string,
277
+ ): Promise<ApiResult<MenuWithItems>> {
173
278
  try {
174
279
  const menu = await db
175
280
  .selectFrom("_emdash_menus")
176
- .select("id")
177
- .where("name", "=", name)
281
+ .selectAll()
282
+ .where("id", "=", id)
178
283
  .executeTakeFirst();
284
+ if (!menu) {
285
+ return {
286
+ success: false,
287
+ error: { code: "NOT_FOUND", message: `Menu '${id}' not found` },
288
+ };
289
+ }
290
+ const items = await db
291
+ .selectFrom("_emdash_menu_items")
292
+ .selectAll()
293
+ .where("menu_id", "=", menu.id)
294
+ .orderBy("sort_order", "asc")
295
+ .execute();
296
+ return { success: true, data: { ...menu, items } };
297
+ } catch {
298
+ return {
299
+ success: false,
300
+ error: { code: "MENU_GET_ERROR", message: "Failed to fetch menu" },
301
+ };
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Update a menu's label. The name + locale are immutable.
307
+ */
308
+ export async function handleMenuUpdate(
309
+ db: Kysely<Database>,
310
+ name: string,
311
+ input: { label?: string; locale?: string },
312
+ ): Promise<ApiResult<MenuRow>> {
313
+ try {
314
+ let query = db.selectFrom("_emdash_menus").select("id").where("name", "=", name);
315
+ if (input.locale !== undefined) query = query.where("locale", "=", input.locale);
316
+ const menu = await query.executeTakeFirst();
179
317
 
180
318
  if (!menu) {
181
319
  return {
@@ -197,7 +335,6 @@ export async function handleMenuUpdate(
197
335
  .selectAll()
198
336
  .where("id", "=", menu.id)
199
337
  .executeTakeFirstOrThrow();
200
-
201
338
  return { success: true, data: updated };
202
339
  } catch {
203
340
  return {
@@ -208,18 +345,17 @@ export async function handleMenuUpdate(
208
345
  }
209
346
 
210
347
  /**
211
- * Delete a menu and its items (cascade).
348
+ * Delete a menu (and items, via cascade).
212
349
  */
213
350
  export async function handleMenuDelete(
214
351
  db: Kysely<Database>,
215
352
  name: string,
353
+ options: { locale?: string } = {},
216
354
  ): Promise<ApiResult<{ deleted: true }>> {
217
355
  try {
218
- const menu = await db
219
- .selectFrom("_emdash_menus")
220
- .select("id")
221
- .where("name", "=", name)
222
- .executeTakeFirst();
356
+ let query = db.selectFrom("_emdash_menus").select("id").where("name", "=", name);
357
+ if (options.locale !== undefined) query = query.where("locale", "=", options.locale);
358
+ const menu = await query.executeTakeFirst();
223
359
 
224
360
  if (!menu) {
225
361
  return {
@@ -233,7 +369,6 @@ export async function handleMenuDelete(
233
369
  // idempotent on SQLite/Postgres where the cascade also fires.
234
370
  await db.deleteFrom("_emdash_menu_items").where("menu_id", "=", menu.id).execute();
235
371
  await db.deleteFrom("_emdash_menus").where("id", "=", menu.id).execute();
236
-
237
372
  return { success: true, data: { deleted: true } };
238
373
  } catch {
239
374
  return {
@@ -243,6 +378,53 @@ export async function handleMenuDelete(
243
378
  }
244
379
  }
245
380
 
381
+ /**
382
+ * List every translation of a menu (by id or translation_group).
383
+ */
384
+ export async function handleMenuTranslations(
385
+ db: Kysely<Database>,
386
+ idOrGroup: string,
387
+ ): Promise<ApiResult<MenuTranslationsResponse>> {
388
+ try {
389
+ const anchor = await db
390
+ .selectFrom("_emdash_menus")
391
+ .selectAll()
392
+ .where((eb) => eb.or([eb("id", "=", idOrGroup), eb("translation_group", "=", idOrGroup)]))
393
+ .executeTakeFirst();
394
+ if (!anchor) {
395
+ return {
396
+ success: false,
397
+ error: { code: "NOT_FOUND", message: "Menu not found" },
398
+ };
399
+ }
400
+ const group = anchor.translation_group ?? anchor.id;
401
+ const rows = await db
402
+ .selectFrom("_emdash_menus")
403
+ .selectAll()
404
+ .where("translation_group", "=", group)
405
+ .orderBy("locale", "asc")
406
+ .execute();
407
+ return {
408
+ success: true,
409
+ data: {
410
+ translationGroup: group,
411
+ translations: rows.map((row) => ({
412
+ id: row.id,
413
+ name: row.name,
414
+ locale: row.locale,
415
+ label: row.label,
416
+ updatedAt: row.updated_at,
417
+ })),
418
+ },
419
+ };
420
+ } catch {
421
+ return {
422
+ success: false,
423
+ error: { code: "MENU_TRANSLATIONS_ERROR", message: "Failed to list menu translations" },
424
+ };
425
+ }
426
+ }
427
+
246
428
  // ---------------------------------------------------------------------------
247
429
  // Menu item handlers
248
430
  // ---------------------------------------------------------------------------
@@ -261,19 +443,22 @@ export interface CreateMenuItemInput {
261
443
  }
262
444
 
263
445
  /**
264
- * Add an item to a menu.
446
+ * Add an item to a menu. The item inherits the menu's locale (so listing
447
+ * items by locale stays trivial).
265
448
  */
266
449
  export async function handleMenuItemCreate(
267
450
  db: Kysely<Database>,
268
451
  menuName: string,
269
452
  input: CreateMenuItemInput,
453
+ options: { locale?: string } = {},
270
454
  ): Promise<ApiResult<MenuItemRow>> {
271
455
  try {
272
- const menu = await db
456
+ let menuQuery = db
273
457
  .selectFrom("_emdash_menus")
274
- .select("id")
275
- .where("name", "=", menuName)
276
- .executeTakeFirst();
458
+ .select(["id", "locale"])
459
+ .where("name", "=", menuName);
460
+ if (options.locale !== undefined) menuQuery = menuQuery.where("locale", "=", options.locale);
461
+ const menu = await menuQuery.executeTakeFirst();
277
462
 
278
463
  if (!menu) {
279
464
  return {
@@ -290,7 +475,6 @@ export async function handleMenuItemCreate(
290
475
  .where("menu_id", "=", menu.id)
291
476
  .where("parent_id", "is", input.parentId ?? null)
292
477
  .executeTakeFirst();
293
-
294
478
  // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Kysely fn.max returns unknown; always a number for sort_order column
295
479
  sortOrder = ((maxOrder?.max as number) ?? -1) + 1;
296
480
  }
@@ -311,6 +495,8 @@ export async function handleMenuItemCreate(
311
495
  title_attr: input.titleAttr ?? null,
312
496
  target: input.target ?? null,
313
497
  css_classes: input.cssClasses ?? null,
498
+ locale: menu.locale,
499
+ translation_group: id,
314
500
  })
315
501
  .execute();
316
502
 
@@ -319,7 +505,6 @@ export async function handleMenuItemCreate(
319
505
  .selectAll()
320
506
  .where("id", "=", id)
321
507
  .executeTakeFirstOrThrow();
322
-
323
508
  return { success: true, data: item };
324
509
  } catch {
325
510
  return {
@@ -347,13 +532,12 @@ export async function handleMenuItemUpdate(
347
532
  menuName: string,
348
533
  itemId: string,
349
534
  input: UpdateMenuItemInput,
535
+ options: { locale?: string } = {},
350
536
  ): Promise<ApiResult<MenuItemRow>> {
351
537
  try {
352
- const menu = await db
353
- .selectFrom("_emdash_menus")
354
- .select("id")
355
- .where("name", "=", menuName)
356
- .executeTakeFirst();
538
+ let menuQuery = db.selectFrom("_emdash_menus").select("id").where("name", "=", menuName);
539
+ if (options.locale !== undefined) menuQuery = menuQuery.where("locale", "=", options.locale);
540
+ const menu = await menuQuery.executeTakeFirst();
357
541
 
358
542
  if (!menu) {
359
543
  return {
@@ -394,7 +578,6 @@ export async function handleMenuItemUpdate(
394
578
  .selectAll()
395
579
  .where("id", "=", itemId)
396
580
  .executeTakeFirstOrThrow();
397
-
398
581
  return { success: true, data: updated };
399
582
  } catch {
400
583
  return {
@@ -411,13 +594,12 @@ export async function handleMenuItemDelete(
411
594
  db: Kysely<Database>,
412
595
  menuName: string,
413
596
  itemId: string,
597
+ options: { locale?: string } = {},
414
598
  ): Promise<ApiResult<{ deleted: true }>> {
415
599
  try {
416
- const menu = await db
417
- .selectFrom("_emdash_menus")
418
- .select("id")
419
- .where("name", "=", menuName)
420
- .executeTakeFirst();
600
+ let menuQuery = db.selectFrom("_emdash_menus").select("id").where("name", "=", menuName);
601
+ if (options.locale !== undefined) menuQuery = menuQuery.where("locale", "=", options.locale);
602
+ const menu = await menuQuery.executeTakeFirst();
421
603
 
422
604
  if (!menu) {
423
605
  return {
@@ -589,13 +771,12 @@ export async function handleMenuItemReorder(
589
771
  db: Kysely<Database>,
590
772
  menuName: string,
591
773
  items: ReorderItem[],
774
+ options: { locale?: string } = {},
592
775
  ): Promise<ApiResult<MenuItemRow[]>> {
593
776
  try {
594
- const menu = await db
595
- .selectFrom("_emdash_menus")
596
- .select("id")
597
- .where("name", "=", menuName)
598
- .executeTakeFirst();
777
+ let menuQuery = db.selectFrom("_emdash_menus").select("id").where("name", "=", menuName);
778
+ if (options.locale !== undefined) menuQuery = menuQuery.where("locale", "=", options.locale);
779
+ const menu = await menuQuery.executeTakeFirst();
599
780
 
600
781
  if (!menu) {
601
782
  return {