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
package/src/mcp/server.ts CHANGED
@@ -1667,16 +1667,19 @@ export function createMcpServer(): McpServer {
1667
1667
  description:
1668
1668
  "List all taxonomy definitions (e.g. categories, tags). Taxonomies are " +
1669
1669
  "classification systems applied to content. Each has a name, label, and " +
1670
- "can be hierarchical (categories) or flat (tags).",
1671
- inputSchema: z.object({}),
1670
+ "can be hierarchical (categories) or flat (tags). Optionally filter by " +
1671
+ "locale.",
1672
+ inputSchema: z.object({
1673
+ locale: z.string().optional().describe("Filter by locale (omit for all)"),
1674
+ }),
1672
1675
  annotations: { readOnlyHint: true },
1673
1676
  },
1674
- async (_args, extra) => {
1677
+ async (args, extra) => {
1675
1678
  requireScope(extra, "content:read");
1676
1679
  const ec = getEmDash(extra);
1677
1680
  try {
1678
1681
  const { handleTaxonomyList } = await import("../api/handlers/taxonomies.js");
1679
- return unwrap(await handleTaxonomyList(ec.db));
1682
+ return unwrap(await handleTaxonomyList(ec.db, { locale: args.locale }));
1680
1683
  } catch (error) {
1681
1684
  return respondHandlerError(error, "TAXONOMY_LIST_ERROR");
1682
1685
  }
@@ -1695,6 +1698,7 @@ export function createMcpServer(): McpServer {
1695
1698
  taxonomy: z.string().describe("Taxonomy name (e.g. 'categories', 'tags')"),
1696
1699
  limit: z.number().int().min(1).max(100).optional().describe("Max items (default 50)"),
1697
1700
  cursor: z.string().min(1).max(2048).optional().describe("Pagination cursor"),
1701
+ locale: z.string().optional().describe("Filter by locale (omit for all)"),
1698
1702
  }),
1699
1703
  annotations: { readOnlyHint: true },
1700
1704
  },
@@ -1702,9 +1706,8 @@ export function createMcpServer(): McpServer {
1702
1706
  requireScope(extra, "content:read");
1703
1707
  const ec = getEmDash(extra);
1704
1708
  try {
1705
- // Verify taxonomy exists via handler layer
1706
1709
  const { handleTaxonomyList } = await import("../api/handlers/taxonomies.js");
1707
- const listResult = await handleTaxonomyList(ec.db);
1710
+ const listResult = await handleTaxonomyList(ec.db, { locale: args.locale });
1708
1711
  if (!listResult.success) return unwrap(listResult);
1709
1712
 
1710
1713
  const taxonomies = (listResult.data as { taxonomies: Array<{ name: string; id?: string }> })
@@ -1712,13 +1715,12 @@ export function createMcpServer(): McpServer {
1712
1715
  const taxonomy = taxonomies.find((t: { name: string }) => t.name === args.taxonomy);
1713
1716
  if (!taxonomy) return respondError("NOT_FOUND", `Taxonomy '${args.taxonomy}' not found`);
1714
1717
 
1715
- // Paginated term query via repository (avoids N+1 of handleTermList)
1716
1718
  const { TaxonomyRepository } = await import("../database/repositories/taxonomy.js");
1717
1719
  const { decodeCursor, encodeCursor, InvalidCursorError } =
1718
1720
  await import("../database/repositories/types.js");
1719
1721
  const repo = new TaxonomyRepository(ec.db);
1720
1722
  const limit = Math.min(args.limit ?? 50, 100);
1721
- const terms = await repo.findByName(args.taxonomy);
1723
+ const terms = await repo.findByName(args.taxonomy, { locale: args.locale });
1722
1724
 
1723
1725
  // Manual keyset pagination over the sorted-by-label results.
1724
1726
  // Using a base64-encoded `(label, id)` cursor matches the
@@ -1760,6 +1762,8 @@ export function createMcpServer(): McpServer {
1760
1762
  label: t.label,
1761
1763
  parentId: t.parentId,
1762
1764
  description: typeof t.data?.description === "string" ? t.data.description : undefined,
1765
+ locale: t.locale,
1766
+ translationGroup: t.translationGroup,
1763
1767
  })),
1764
1768
  nextCursor,
1765
1769
  });
@@ -1785,6 +1789,11 @@ export function createMcpServer(): McpServer {
1785
1789
  label: z.string().describe("Display name"),
1786
1790
  parentId: z.string().optional().describe("Parent term ID for hierarchical taxonomies"),
1787
1791
  description: z.string().optional().describe("Description of the term"),
1792
+ locale: z.string().optional().describe("Locale for the new term (e.g. 'es')"),
1793
+ translationOf: z
1794
+ .string()
1795
+ .optional()
1796
+ .describe("Term id to join as a translation (same translation_group)"),
1788
1797
  }),
1789
1798
  },
1790
1799
  async (args, extra) => {
@@ -1799,6 +1808,8 @@ export function createMcpServer(): McpServer {
1799
1808
  label: args.label,
1800
1809
  parentId: args.parentId,
1801
1810
  description: args.description,
1811
+ locale: args.locale,
1812
+ translationOf: args.translationOf,
1802
1813
  }),
1803
1814
  );
1804
1815
  } catch (error) {
@@ -1875,6 +1886,29 @@ export function createMcpServer(): McpServer {
1875
1886
  },
1876
1887
  );
1877
1888
 
1889
+ server.registerTool(
1890
+ "taxonomy_term_translations",
1891
+ {
1892
+ title: "List Term Translations",
1893
+ description:
1894
+ "Return every locale variant of a taxonomy term, identified via its shared translation_group.",
1895
+ inputSchema: z.object({
1896
+ id: z.string().describe("Term id (or translation_group)"),
1897
+ }),
1898
+ annotations: { readOnlyHint: true },
1899
+ },
1900
+ async (args, extra) => {
1901
+ requireScope(extra, "content:read");
1902
+ const ec = getEmDash(extra);
1903
+ try {
1904
+ const { handleTermTranslations } = await import("../api/handlers/taxonomies.js");
1905
+ return unwrap(await handleTermTranslations(ec.db, args.id));
1906
+ } catch (error) {
1907
+ return respondHandlerError(error, "TERM_TRANSLATIONS_ERROR");
1908
+ }
1909
+ },
1910
+ );
1911
+
1878
1912
  // =====================================================================
1879
1913
  // Menu tools
1880
1914
  // =====================================================================
@@ -1884,18 +1918,20 @@ export function createMcpServer(): McpServer {
1884
1918
  {
1885
1919
  title: "List Menus",
1886
1920
  description:
1887
- "List all navigation menus defined in the CMS. Menus are named " +
1888
- "navigation structures (e.g. 'main', 'footer') containing ordered " +
1889
- "items with labels, URLs, and optional nesting.",
1890
- inputSchema: z.object({}),
1921
+ "List navigation menus. Menus are per-locale: filter by `locale` to " +
1922
+ "get just one locale's worth, or omit to list every row (one per " +
1923
+ "locale per menu name).",
1924
+ inputSchema: z.object({
1925
+ locale: z.string().optional().describe("Filter by locale (omit for all)"),
1926
+ }),
1891
1927
  annotations: { readOnlyHint: true },
1892
1928
  },
1893
- async (_args, extra) => {
1929
+ async (args, extra) => {
1894
1930
  requireScope(extra, "content:read");
1895
1931
  const ec = getEmDash(extra);
1896
1932
  try {
1897
1933
  const { handleMenuList } = await import("../api/handlers/menus.js");
1898
- return unwrap(await handleMenuList(ec.db));
1934
+ return unwrap(await handleMenuList(ec.db, { locale: args.locale }));
1899
1935
  } catch (error) {
1900
1936
  return respondHandlerError(error, "MENU_LIST_ERROR");
1901
1937
  }
@@ -1907,11 +1943,11 @@ export function createMcpServer(): McpServer {
1907
1943
  {
1908
1944
  title: "Get Menu with Items",
1909
1945
  description:
1910
- "Get a menu by name including all its items in order. Items have a " +
1911
- "label, URL, type (custom/content/collection), and optional parent " +
1912
- "for nesting.",
1946
+ "Get a menu by name, including its items. When multiple locales exist, " +
1947
+ "pass `locale` to pick the right one.",
1913
1948
  inputSchema: z.object({
1914
1949
  name: z.string().describe("Menu name (e.g. 'main', 'footer')"),
1950
+ locale: z.string().optional().describe("Locale to resolve the menu for"),
1915
1951
  }),
1916
1952
  annotations: { readOnlyHint: true },
1917
1953
  },
@@ -1920,13 +1956,36 @@ export function createMcpServer(): McpServer {
1920
1956
  const ec = getEmDash(extra);
1921
1957
  try {
1922
1958
  const { handleMenuGet } = await import("../api/handlers/menus.js");
1923
- return unwrap(await handleMenuGet(ec.db, args.name));
1959
+ return unwrap(await handleMenuGet(ec.db, args.name, { locale: args.locale }));
1924
1960
  } catch (error) {
1925
1961
  return respondHandlerError(error, "MENU_GET_ERROR");
1926
1962
  }
1927
1963
  },
1928
1964
  );
1929
1965
 
1966
+ server.registerTool(
1967
+ "menu_translations",
1968
+ {
1969
+ title: "List Menu Translations",
1970
+ description:
1971
+ "Return every locale variant of a menu, identified via the shared translation_group.",
1972
+ inputSchema: z.object({
1973
+ id: z.string().describe("Menu id (or translation_group)"),
1974
+ }),
1975
+ annotations: { readOnlyHint: true },
1976
+ },
1977
+ async (args, extra) => {
1978
+ requireScope(extra, "content:read");
1979
+ const ec = getEmDash(extra);
1980
+ try {
1981
+ const { handleMenuTranslations } = await import("../api/handlers/menus.js");
1982
+ return unwrap(await handleMenuTranslations(ec.db, args.id));
1983
+ } catch (error) {
1984
+ return respondHandlerError(error, "MENU_TRANSLATIONS_ERROR");
1985
+ }
1986
+ },
1987
+ );
1988
+
1930
1989
  server.registerTool(
1931
1990
  "menu_create",
1932
1991
  {
@@ -1,7 +1,11 @@
1
1
  /**
2
- * Navigation menu runtime functions
2
+ * Navigation menu runtime functions.
3
3
  *
4
- * These are called from templates to query menus and resolve URLs.
4
+ * These are called from templates to query menus and resolve URLs. All queries
5
+ * are locale-aware: when a locale is configured (or passed explicitly) items
6
+ * are filtered to that locale, and menu item references resolve against the
7
+ * referenced content's translation_group so the URL points at the right
8
+ * per-locale row.
5
9
  */
6
10
 
7
11
  import type { Kysely } from "kysely";
@@ -9,50 +13,61 @@ import { sql } from "kysely";
9
13
 
10
14
  import type { Database } from "../database/types.js";
11
15
  import { validateIdentifier } from "../database/validate.js";
16
+ import { resolveLocale, resolveLocaleChain } from "../i18n/resolve.js";
12
17
  import { getDb } from "../loader.js";
13
18
  import { requestCached } from "../request-cache.js";
14
19
  import { sanitizeHref } from "../utils/url.js";
15
20
  import type { Menu, MenuItem, MenuItemRow } from "./types.js";
16
21
 
22
+ export interface MenuQueryOptions {
23
+ /** Override the locale used for the lookup. When omitted, the locale comes
24
+ * from the request context or the configured defaultLocale. */
25
+ locale?: string;
26
+ }
27
+
17
28
  /**
18
- * Get menu by name with resolved URLs
29
+ * Get a menu by name with resolved URLs.
19
30
  *
20
31
  * @example
21
32
  * ```ts
22
- * import { getMenu } from "emdash";
23
- *
24
33
  * const menu = await getMenu("primary");
25
- * if (menu) {
26
- * console.log(menu.items); // Array of MenuItem with resolved URLs
27
- * }
34
+ * const menuEs = await getMenu("primary", { locale: "es" });
28
35
  * ```
29
36
  */
30
- export function getMenu(name: string): Promise<Menu | null> {
31
- return requestCached(`menu:${name}`, async () => {
37
+ export function getMenu(name: string, options: MenuQueryOptions = {}): Promise<Menu | null> {
38
+ const locale = resolveLocale(options.locale);
39
+ return requestCached(`menu:${name}:${locale ?? "*"}`, async () => {
32
40
  const db = await getDb();
33
- return getMenuWithDb(name, db);
41
+ return getMenuWithDb(name, db, { locale });
34
42
  });
35
43
  }
36
44
 
37
45
  /**
38
- * Get menu by name with resolved URLs (with explicit db)
39
- *
40
- * @internal Use `getMenu()` in templates. This variant is for admin routes
41
- * that already have a database handle.
46
+ * Get menu by name with resolved URLs (with explicit db). Internal helper for
47
+ * admin routes that already have a database handle.
42
48
  */
43
- export async function getMenuWithDb(name: string, db: Kysely<Database>): Promise<Menu | null> {
44
- // Get menu
45
- const menuRow = await db
46
- .selectFrom("_emdash_menus")
47
- .selectAll()
48
- .where("name", "=", name)
49
- .executeTakeFirst();
50
-
51
- if (!menuRow) {
52
- return null;
49
+ export async function getMenuWithDb(
50
+ name: string,
51
+ db: Kysely<Database>,
52
+ options: MenuQueryOptions = {},
53
+ ): Promise<Menu | null> {
54
+ const chain = resolveLocaleChain(options.locale);
55
+
56
+ const selectMenu = () => db.selectFrom("_emdash_menus").selectAll().where("name", "=", name);
57
+
58
+ let menuRow: Awaited<ReturnType<ReturnType<typeof selectMenu>["executeTakeFirst"]>>;
59
+ if (chain.length === 0) {
60
+ menuRow = await selectMenu().orderBy("locale", "asc").executeTakeFirst();
61
+ } else {
62
+ menuRow = undefined;
63
+ for (const locale of chain) {
64
+ menuRow = await selectMenu().where("locale", "=", locale).executeTakeFirst();
65
+ if (menuRow) break;
66
+ }
53
67
  }
54
68
 
55
- // Get all menu items
69
+ if (!menuRow) return null;
70
+
56
71
  const itemRows = await db
57
72
  .selectFrom("_emdash_menu_items")
58
73
  .selectAll()
@@ -61,31 +76,27 @@ export async function getMenuWithDb(name: string, db: Kysely<Database>): Promise
61
76
  .orderBy("sort_order", "asc")
62
77
  .execute();
63
78
 
64
- // Resolve URLs and build tree
65
- const items = await buildMenuTree(itemRows, db);
79
+ const items = await buildMenuTree(itemRows, db, menuRow.locale);
66
80
 
67
81
  return {
68
82
  id: menuRow.id,
69
83
  name: menuRow.name,
70
84
  label: menuRow.label,
71
85
  items,
86
+ locale: menuRow.locale,
87
+ translationGroup: menuRow.translation_group,
72
88
  };
73
89
  }
74
90
 
75
91
  /**
76
- * Get all menus (without items - for admin list)
77
- *
78
- * @example
79
- * ```ts
80
- * import { getMenus } from "emdash";
81
- *
82
- * const menus = await getMenus();
83
- * console.log(menus); // [{ id, name, label }]
84
- * ```
92
+ * Get all menus (without items, locale-filtered for admin list / site nav
93
+ * summaries). When no locale is configured, returns menus across all locales.
85
94
  */
86
- export async function getMenus(): Promise<Array<{ id: string; name: string; label: string }>> {
95
+ export async function getMenus(
96
+ options: MenuQueryOptions = {},
97
+ ): Promise<Array<{ id: string; name: string; label: string; locale: string }>> {
87
98
  const db = await getDb();
88
- return getMenusWithDb(db);
99
+ return getMenusWithDb(db, options);
89
100
  }
90
101
 
91
102
  /**
@@ -96,26 +107,30 @@ export async function getMenus(): Promise<Array<{ id: string; name: string; labe
96
107
  */
97
108
  export async function getMenusWithDb(
98
109
  db: Kysely<Database>,
99
- ): Promise<Array<{ id: string; name: string; label: string }>> {
100
- const rows = await db
110
+ options: MenuQueryOptions = {},
111
+ ): Promise<Array<{ id: string; name: string; label: string; locale: string }>> {
112
+ const locale = resolveLocale(options.locale);
113
+ let query = db
101
114
  .selectFrom("_emdash_menus")
102
- .select(["id", "name", "label"])
103
- .orderBy("name", "asc")
104
- .execute();
105
-
106
- return rows;
115
+ .select(["id", "name", "label", "locale"])
116
+ .orderBy("name", "asc");
117
+ if (locale !== undefined) query = query.where("locale", "=", locale);
118
+ return query.execute();
107
119
  }
108
120
 
109
121
  /**
110
- * Build hierarchical menu tree from flat array of items
122
+ * Build a hierarchical menu tree from a flat list of items. Items are
123
+ * resolved against the given `locale` so references land on the right
124
+ * per-locale content rows.
111
125
  */
112
- async function buildMenuTree(items: MenuItemRow[], db: Kysely<Database>): Promise<MenuItem[]> {
113
- // Pre-load URL patterns for all collections referenced in this menu
126
+ async function buildMenuTree(
127
+ items: MenuItemRow[],
128
+ db: Kysely<Database>,
129
+ locale: string,
130
+ ): Promise<MenuItem[]> {
114
131
  const collectionSlugs = new Set<string>();
115
132
  for (const item of items) {
116
- if (item.reference_collection) {
117
- collectionSlugs.add(item.reference_collection);
118
- }
133
+ if (item.reference_collection) collectionSlugs.add(item.reference_collection);
119
134
  if (item.type === "page" || item.type === "post") {
120
135
  collectionSlugs.add(item.reference_collection || `${item.type}s`);
121
136
  }
@@ -128,41 +143,28 @@ async function buildMenuTree(items: MenuItemRow[], db: Kysely<Database>): Promis
128
143
  .select(["slug", "url_pattern"])
129
144
  .where("slug", "in", [...collectionSlugs])
130
145
  .execute();
131
- for (const row of rows) {
132
- urlPatterns.set(row.slug, row.url_pattern);
133
- }
146
+ for (const row of rows) urlPatterns.set(row.slug, row.url_pattern);
134
147
  }
135
148
 
136
- // Resolve all URLs first
137
149
  const resolvedItems = await Promise.all(
138
- items.map((item) => resolveMenuItem(item, db, urlPatterns)),
150
+ items.map((item) => resolveMenuItem(item, db, urlPatterns, locale)),
139
151
  );
152
+ const validItems = resolvedItems.filter((item): item is MenuItem => item !== null);
140
153
 
141
- // Filter out items that couldn't be resolved (e.g., deleted content)
142
- const validItems = resolvedItems.filter((item) => item !== null);
143
-
144
- // Build tree structure
145
154
  const itemMap = new Map<string, MenuItem & { children: MenuItem[] }>();
146
155
  const rootItems: MenuItem[] = [];
147
156
 
148
- // First pass: create all items
149
157
  for (const item of validItems) {
150
158
  itemMap.set(item.id, { ...item, children: [] });
151
159
  }
152
160
 
153
- // Second pass: build parent-child relationships
154
161
  for (const item of items) {
155
162
  const menuItem = itemMap.get(item.id);
156
163
  if (!menuItem) continue;
157
-
158
164
  if (item.parent_id) {
159
165
  const parent = itemMap.get(item.parent_id);
160
- if (parent) {
161
- parent.children.push(menuItem);
162
- } else {
163
- // Parent not found, treat as root
164
- rootItems.push(menuItem);
165
- }
166
+ if (parent) parent.children.push(menuItem);
167
+ else rootItems.push(menuItem);
166
168
  } else {
167
169
  rootItems.push(menuItem);
168
170
  }
@@ -172,14 +174,15 @@ async function buildMenuTree(items: MenuItemRow[], db: Kysely<Database>): Promis
172
174
  }
173
175
 
174
176
  /**
175
- * Resolve a single menu item's URL
176
- *
177
- * Returns null if the referenced content no longer exists (item should be skipped)
177
+ * Resolve a single menu item's URL. `reference_id` is a translation_group
178
+ * (migration 036 remapped all existing references); we join it against
179
+ * the per-locale ec_* row or per-locale taxonomy row.
178
180
  */
179
181
  async function resolveMenuItem(
180
182
  item: MenuItemRow,
181
183
  db: Kysely<Database>,
182
184
  urlPatterns: Map<string, string | null>,
185
+ locale: string,
183
186
  ): Promise<MenuItem | null> {
184
187
  let url: string | null;
185
188
 
@@ -192,24 +195,18 @@ async function resolveMenuItem(
192
195
  case "page":
193
196
  case "post":
194
197
  url = await resolveContentUrl(
195
- // Default to plural collection name (pages/posts) if not specified
196
198
  item.reference_collection || `${item.type}s`,
197
199
  item.reference_id,
198
200
  db,
199
201
  urlPatterns,
202
+ locale,
200
203
  );
201
- // Skip items where content no longer exists
202
- if (url === null) {
203
- return null;
204
- }
204
+ if (url === null) return null;
205
205
  break;
206
206
 
207
207
  case "taxonomy":
208
- url = await resolveTaxonomyUrl(item.reference_id, db);
209
- // Skip items where taxonomy no longer exists
210
- if (url === null) {
211
- return null;
212
- }
208
+ url = await resolveTaxonomyUrl(item.reference_id, db, locale);
209
+ if (url === null) return null;
213
210
  break;
214
211
 
215
212
  case "collection":
@@ -223,16 +220,14 @@ async function resolveMenuItem(
223
220
  item.reference_id,
224
221
  db,
225
222
  urlPatterns,
223
+ locale,
226
224
  );
227
- if (url === null) {
228
- return null;
229
- }
225
+ if (url === null) return null;
230
226
  } else {
231
227
  url = "#";
232
228
  }
233
229
  }
234
230
  } catch (error) {
235
- // If resolution fails, skip this item
236
231
  console.error(`Failed to resolve menu item ${item.id}:`, error);
237
232
  return null;
238
233
  }
@@ -244,7 +239,7 @@ async function resolveMenuItem(
244
239
  target: item.target || undefined,
245
240
  titleAttr: item.title_attr || undefined,
246
241
  cssClasses: item.css_classes || undefined,
247
- children: [], // Will be populated by buildMenuTree
242
+ children: [],
248
243
  };
249
244
  }
250
245
 
@@ -261,72 +256,96 @@ function interpolateUrlPattern(pattern: string, slug: string, id: string): strin
261
256
  }
262
257
 
263
258
  /**
264
- * Resolve URL for a content entry (page/post)
265
- *
266
- * Uses the collection's url_pattern if set, otherwise falls back to /{collection}/{slug}.
267
- * Returns null if content not found (item should be skipped).
259
+ * Resolve the URL for a content reference. `referenceGroup` is the content
260
+ * row's translation_group; we look up the row in the requested locale
261
+ * (falling back to the source if no translation exists so the menu link is
262
+ * still clickable).
268
263
  */
269
264
  async function resolveContentUrl(
270
265
  collection: string,
271
- entryId: string | null,
266
+ referenceGroup: string | null,
272
267
  db: Kysely<Database>,
273
268
  urlPatterns: Map<string, string | null>,
269
+ locale: string,
274
270
  ): Promise<string | null> {
275
- if (!entryId) {
276
- return null;
277
- }
271
+ if (!referenceGroup) return null;
278
272
 
279
273
  try {
280
- // Validate collection name before interpolating into table reference
281
274
  validateIdentifier(collection, "menu item collection");
282
275
 
283
- // Dynamic content tables (ec_*) aren't in the Database type, so use sql
284
- const result = await sql<{ slug: string }>`
285
- SELECT slug FROM ${sql.ref(`ec_${collection}`)} WHERE id = ${entryId} LIMIT 1
276
+ // Try the requested locale first, then any locale (deterministic).
277
+ let result = await sql<{ id: string; slug: string }>`
278
+ SELECT id, slug FROM ${sql.ref(`ec_${collection}`)}
279
+ WHERE translation_group = ${referenceGroup} AND locale = ${locale}
280
+ LIMIT 1
286
281
  `.execute(db);
287
-
288
- const row = result.rows[0];
289
- if (row) {
290
- const pattern = urlPatterns.get(collection);
291
- if (pattern) {
292
- return interpolateUrlPattern(pattern, row.slug, entryId);
293
- }
294
- return `/${collection}/${row.slug}`;
282
+ let row = result.rows[0];
283
+ if (!row) {
284
+ result = await sql<{ id: string; slug: string }>`
285
+ SELECT id, slug FROM ${sql.ref(`ec_${collection}`)}
286
+ WHERE translation_group = ${referenceGroup}
287
+ ORDER BY locale ASC LIMIT 1
288
+ `.execute(db);
289
+ row = result.rows[0];
295
290
  }
291
+ if (!row) {
292
+ // Legacy rows whose reference_id still points at an id directly
293
+ // (defensive — migration 036 normalised these, but a row inserted
294
+ // between migrations could predate the remap).
295
+ const legacy = await sql<{ id: string; slug: string }>`
296
+ SELECT id, slug FROM ${sql.ref(`ec_${collection}`)}
297
+ WHERE id = ${referenceGroup} LIMIT 1
298
+ `.execute(db);
299
+ row = legacy.rows[0];
300
+ }
301
+ if (!row) return null;
296
302
 
297
- // Content not found, skip item
298
- return null;
303
+ const pattern = urlPatterns.get(collection);
304
+ if (pattern) return interpolateUrlPattern(pattern, row.slug, row.id);
305
+ return `/${collection}/${row.slug}`;
299
306
  } catch (error) {
300
- // Table might not exist or query failed
301
- console.error(`Failed to resolve content URL for ${collection}/${entryId}:`, error);
307
+ console.error(`Failed to resolve content URL for ${collection}/${referenceGroup}:`, error);
302
308
  return null;
303
309
  }
304
310
  }
305
311
 
306
312
  /**
307
- * Resolve URL for a taxonomy term
308
- *
309
- * Returns null if taxonomy not found (item should be skipped)
313
+ * Resolve URL for a taxonomy term reference. `referenceGroup` is the term's
314
+ * translation_group; we pick the row in the active locale (or fall back).
310
315
  */
311
316
  async function resolveTaxonomyUrl(
312
- taxonomyId: string | null,
317
+ referenceGroup: string | null,
313
318
  db: Kysely<Database>,
319
+ locale: string,
314
320
  ): Promise<string | null> {
315
- if (!taxonomyId) {
316
- return null;
317
- }
321
+ if (!referenceGroup) return null;
318
322
 
319
- const taxonomy = await db
323
+ let taxonomy = await db
320
324
  .selectFrom("taxonomies")
321
325
  .select(["name", "slug"])
322
- .where("id", "=", taxonomyId)
326
+ .where("translation_group", "=", referenceGroup)
327
+ .where("locale", "=", locale)
323
328
  .executeTakeFirst();
324
329
 
325
330
  if (!taxonomy) {
326
- // Taxonomy not found, skip item
327
- return null;
331
+ taxonomy = await db
332
+ .selectFrom("taxonomies")
333
+ .select(["name", "slug"])
334
+ .where("translation_group", "=", referenceGroup)
335
+ .orderBy("locale", "asc")
336
+ .executeTakeFirst();
328
337
  }
329
338
 
330
- // Use taxonomy name as base (e.g., "categories" or "tags")
339
+ if (!taxonomy) {
340
+ // Legacy: id-based reference that predates the migration remap.
341
+ taxonomy = await db
342
+ .selectFrom("taxonomies")
343
+ .select(["name", "slug"])
344
+ .where("id", "=", referenceGroup)
345
+ .executeTakeFirst();
346
+ }
347
+
348
+ if (!taxonomy) return null;
349
+
331
350
  return `/${taxonomy.name}/${taxonomy.slug}`;
332
351
  }