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
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Menu translation endpoints
3
+ *
4
+ * GET /_emdash/api/menus/:name/translations — list translations for a menu (uses any locale row)
5
+ * POST /_emdash/api/menus/:name/translations — create a new locale translation (body: { locale, label })
6
+ */
7
+
8
+ import type { APIRoute } from "astro";
9
+ import { z } from "zod";
10
+
11
+ import { requirePerm } from "#api/authorize.js";
12
+ import { handleError, requireDb, unwrapResult } from "#api/error.js";
13
+ import { handleMenuCreate, handleMenuGet, handleMenuTranslations } from "#api/handlers/menus.js";
14
+ import { isParseError, parseBody, parseQuery } from "#api/parse.js";
15
+ import { localeFilterQuery } from "#api/schemas.js";
16
+
17
+ export const prerender = false;
18
+
19
+ const createTranslationBody = z
20
+ .object({
21
+ locale: z.string().min(1),
22
+ label: z.string().min(1).optional(),
23
+ })
24
+ .meta({ id: "CreateMenuTranslationBody" });
25
+
26
+ export const GET: APIRoute = async ({ params, request, locals }) => {
27
+ const { emdash, user } = locals;
28
+ const name = params.name!;
29
+
30
+ const dbErr = requireDb(emdash?.db);
31
+ if (dbErr) return dbErr;
32
+
33
+ const denied = requirePerm(user, "menus:read");
34
+ if (denied) return denied;
35
+
36
+ const localeQ = parseQuery(new URL(request.url), localeFilterQuery);
37
+ if (isParseError(localeQ)) return localeQ;
38
+
39
+ try {
40
+ // Look up any menu row matching the name so we can get its translation_group.
41
+ const anchor = await handleMenuGet(emdash.db, name, { locale: localeQ.locale });
42
+ if (!anchor.success) return unwrapResult(anchor);
43
+ const result = await handleMenuTranslations(emdash.db, anchor.data.id);
44
+ return unwrapResult(result);
45
+ } catch (error) {
46
+ return handleError(error, "Failed to fetch menu translations", "MENU_TRANSLATIONS_ERROR");
47
+ }
48
+ };
49
+
50
+ export const POST: APIRoute = async ({ params, request, locals }) => {
51
+ const { emdash, user } = locals;
52
+ const name = params.name!;
53
+
54
+ const dbErr = requireDb(emdash?.db);
55
+ if (dbErr) return dbErr;
56
+
57
+ const denied = requirePerm(user, "menus:manage");
58
+ if (denied) return denied;
59
+
60
+ const localeQ = parseQuery(new URL(request.url), localeFilterQuery);
61
+ if (isParseError(localeQ)) return localeQ;
62
+
63
+ try {
64
+ const body = await parseBody(request, createTranslationBody);
65
+ if (isParseError(body)) return body;
66
+
67
+ // Resolve the source menu (either by explicit locale in query, or the
68
+ // first matching row). Its id becomes the `translationOf` for the new row.
69
+ const source = await handleMenuGet(emdash.db, name, { locale: localeQ.locale });
70
+ if (!source.success) return unwrapResult(source);
71
+
72
+ const result = await handleMenuCreate(emdash.db, {
73
+ name,
74
+ label: body.label ?? source.data.label,
75
+ locale: body.locale,
76
+ translationOf: source.data.id,
77
+ });
78
+ return unwrapResult(result, 201);
79
+ } catch (error) {
80
+ return handleError(error, "Failed to create menu translation", "MENU_TRANSLATION_CREATE_ERROR");
81
+ }
82
+ };
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Single menu endpoint
3
3
  *
4
- * GET /_emdash/api/menus/:name - Get menu with items
5
- * PUT /_emdash/api/menus/:name - Update menu metadata
6
- * DELETE /_emdash/api/menus/:name - Delete menu
4
+ * GET /_emdash/api/menus/:name[?locale=xx]
5
+ * PUT /_emdash/api/menus/:name[?locale=xx]
6
+ * DELETE /_emdash/api/menus/:name[?locale=xx]
7
7
  */
8
8
 
9
9
  import type { APIRoute } from "astro";
@@ -11,20 +11,23 @@ import type { APIRoute } from "astro";
11
11
  import { requirePerm } from "#api/authorize.js";
12
12
  import { handleError, unwrapResult } from "#api/error.js";
13
13
  import { handleMenuDelete, handleMenuGet, handleMenuUpdate } from "#api/handlers/menus.js";
14
- import { isParseError, parseBody } from "#api/parse.js";
15
- import { updateMenuBody } from "#api/schemas.js";
14
+ import { isParseError, parseBody, parseQuery } from "#api/parse.js";
15
+ import { localeFilterQuery, updateMenuBody } from "#api/schemas.js";
16
16
 
17
17
  export const prerender = false;
18
18
 
19
- export const GET: APIRoute = async ({ params, locals }) => {
19
+ export const GET: APIRoute = async ({ params, request, locals }) => {
20
20
  const { emdash, user } = locals;
21
21
  const name = params.name!;
22
22
 
23
23
  const denied = requirePerm(user, "menus:read");
24
24
  if (denied) return denied;
25
25
 
26
+ const query = parseQuery(new URL(request.url), localeFilterQuery);
27
+ if (isParseError(query)) return query;
28
+
26
29
  try {
27
- const result = await handleMenuGet(emdash.db, name);
30
+ const result = await handleMenuGet(emdash.db, name, { locale: query.locale });
28
31
  return unwrapResult(result);
29
32
  } catch (error) {
30
33
  return handleError(error, "Failed to fetch menu", "MENU_GET_ERROR");
@@ -38,26 +41,32 @@ export const PUT: APIRoute = async ({ params, request, locals }) => {
38
41
  const denied = requirePerm(user, "menus:manage");
39
42
  if (denied) return denied;
40
43
 
44
+ const query = parseQuery(new URL(request.url), localeFilterQuery);
45
+ if (isParseError(query)) return query;
46
+
41
47
  try {
42
48
  const body = await parseBody(request, updateMenuBody);
43
49
  if (isParseError(body)) return body;
44
50
 
45
- const result = await handleMenuUpdate(emdash.db, name, body);
51
+ const result = await handleMenuUpdate(emdash.db, name, { ...body, locale: query.locale });
46
52
  return unwrapResult(result);
47
53
  } catch (error) {
48
54
  return handleError(error, "Failed to update menu", "MENU_UPDATE_ERROR");
49
55
  }
50
56
  };
51
57
 
52
- export const DELETE: APIRoute = async ({ params, locals }) => {
58
+ export const DELETE: APIRoute = async ({ params, request, locals }) => {
53
59
  const { emdash, user } = locals;
54
60
  const name = params.name!;
55
61
 
56
62
  const denied = requirePerm(user, "menus:manage");
57
63
  if (denied) return denied;
58
64
 
65
+ const query = parseQuery(new URL(request.url), localeFilterQuery);
66
+ if (isParseError(query)) return query;
67
+
59
68
  try {
60
- const result = await handleMenuDelete(emdash.db, name);
69
+ const result = await handleMenuDelete(emdash.db, name, { locale: query.locale });
61
70
  return unwrapResult(result);
62
71
  } catch (error) {
63
72
  return handleError(error, "Failed to delete menu", "MENU_DELETE_ERROR");
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Menus list and create endpoints
3
3
  *
4
- * GET /_emdash/api/menus - List all menus
5
- * POST /_emdash/api/menus - Create menu
4
+ * GET /_emdash/api/menus[?locale=xx] - List menus (optionally filtered by locale)
5
+ * POST /_emdash/api/menus - Create menu (body may include locale & translationOf)
6
6
  */
7
7
 
8
8
  import type { APIRoute } from "astro";
@@ -10,19 +10,22 @@ import type { APIRoute } from "astro";
10
10
  import { requirePerm } from "#api/authorize.js";
11
11
  import { handleError, unwrapResult } from "#api/error.js";
12
12
  import { handleMenuCreate, handleMenuList } from "#api/handlers/menus.js";
13
- import { isParseError, parseBody } from "#api/parse.js";
14
- import { createMenuBody } from "#api/schemas.js";
13
+ import { isParseError, parseBody, parseQuery } from "#api/parse.js";
14
+ import { createMenuBody, localeFilterQuery } from "#api/schemas.js";
15
15
 
16
16
  export const prerender = false;
17
17
 
18
- export const GET: APIRoute = async ({ locals }) => {
18
+ export const GET: APIRoute = async ({ request, locals }) => {
19
19
  const { emdash, user } = locals;
20
20
 
21
21
  const denied = requirePerm(user, "menus:read");
22
22
  if (denied) return denied;
23
23
 
24
+ const query = parseQuery(new URL(request.url), localeFilterQuery);
25
+ if (isParseError(query)) return query;
26
+
24
27
  try {
25
- const result = await handleMenuList(emdash.db);
28
+ const result = await handleMenuList(emdash.db, { locale: query.locale });
26
29
  return unwrapResult(result);
27
30
  } catch (error) {
28
31
  return handleError(error, "Failed to fetch menus", "MENU_LIST_ERROR");
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Term translation endpoints
3
+ *
4
+ * GET /_emdash/api/taxonomies/:name/terms/:slug/translations[?locale=xx]
5
+ * POST /_emdash/api/taxonomies/:name/terms/:slug/translations
6
+ * body: { locale, label?, slug? }
7
+ */
8
+
9
+ import type { APIRoute } from "astro";
10
+ import { z } from "zod";
11
+
12
+ import { requirePerm } from "#api/authorize.js";
13
+ import { apiError, handleError, requireDb, unwrapResult } from "#api/error.js";
14
+ import {
15
+ handleTermCreate,
16
+ handleTermGet,
17
+ handleTermTranslations,
18
+ } from "#api/handlers/taxonomies.js";
19
+ import { isParseError, parseBody, parseQuery } from "#api/parse.js";
20
+ import { localeFilterQuery } from "#api/schemas.js";
21
+
22
+ export const prerender = false;
23
+
24
+ const createTermTranslationBody = z
25
+ .object({
26
+ locale: z.string().min(1),
27
+ label: z.string().min(1).optional(),
28
+ slug: z.string().min(1).optional(),
29
+ })
30
+ .meta({ id: "CreateTermTranslationBody" });
31
+
32
+ export const GET: APIRoute = async ({ params, request, locals }) => {
33
+ const { emdash, user } = locals;
34
+ const { name, slug } = params;
35
+ if (!name || !slug) return apiError("VALIDATION_ERROR", "Taxonomy name and slug required", 400);
36
+
37
+ const dbErr = requireDb(emdash?.db);
38
+ if (dbErr) return dbErr;
39
+
40
+ const denied = requirePerm(user, "taxonomies:read");
41
+ if (denied) return denied;
42
+
43
+ const query = parseQuery(new URL(request.url), localeFilterQuery);
44
+ if (isParseError(query)) return query;
45
+
46
+ try {
47
+ const anchor = await handleTermGet(emdash.db, name, slug, { locale: query.locale });
48
+ if (!anchor.success) return unwrapResult(anchor);
49
+ const result = await handleTermTranslations(emdash.db, anchor.data.term.id);
50
+ return unwrapResult(result);
51
+ } catch (error) {
52
+ return handleError(error, "Failed to list term translations", "TERM_TRANSLATIONS_ERROR");
53
+ }
54
+ };
55
+
56
+ export const POST: APIRoute = async ({ params, request, locals }) => {
57
+ const { emdash, user } = locals;
58
+ const { name, slug } = params;
59
+ if (!name || !slug) return apiError("VALIDATION_ERROR", "Taxonomy name and slug required", 400);
60
+
61
+ const dbErr = requireDb(emdash?.db);
62
+ if (dbErr) return dbErr;
63
+
64
+ const denied = requirePerm(user, "taxonomies:manage");
65
+ if (denied) return denied;
66
+
67
+ const query = parseQuery(new URL(request.url), localeFilterQuery);
68
+ if (isParseError(query)) return query;
69
+
70
+ try {
71
+ const body = await parseBody(request, createTermTranslationBody);
72
+ if (isParseError(body)) return body;
73
+
74
+ const source = await handleTermGet(emdash.db, name, slug, { locale: query.locale });
75
+ if (!source.success) return unwrapResult(source);
76
+
77
+ const result = await handleTermCreate(emdash.db, name, {
78
+ slug: body.slug ?? source.data.term.slug,
79
+ label: body.label ?? source.data.term.label,
80
+ parentId: source.data.term.parentId,
81
+ description: source.data.term.description,
82
+ locale: body.locale,
83
+ translationOf: source.data.term.id,
84
+ });
85
+ return unwrapResult(result, 201);
86
+ } catch (error) {
87
+ return handleError(error, "Failed to create term translation", "TERM_TRANSLATION_CREATE_ERROR");
88
+ }
89
+ };
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Single term endpoint
3
3
  *
4
- * GET /_emdash/api/taxonomies/:name/terms/:slug - Get a single term
5
- * PUT /_emdash/api/taxonomies/:name/terms/:slug - Update a term
6
- * DELETE /_emdash/api/taxonomies/:name/terms/:slug - Delete a term
4
+ * GET /_emdash/api/taxonomies/:name/terms/:slug[?locale=xx]
5
+ * PUT /_emdash/api/taxonomies/:name/terms/:slug[?locale=xx]
6
+ * DELETE /_emdash/api/taxonomies/:name/terms/:slug[?locale=xx]
7
7
  */
8
8
 
9
9
  import type { APIRoute } from "astro";
@@ -11,21 +11,18 @@ import type { APIRoute } from "astro";
11
11
  import { requirePerm } from "#api/authorize.js";
12
12
  import { apiError, handleError, requireDb, unwrapResult } from "#api/error.js";
13
13
  import { handleTermDelete, handleTermGet, handleTermUpdate } from "#api/handlers/taxonomies.js";
14
- import { isParseError, parseBody } from "#api/parse.js";
15
- import { updateTermBody } from "#api/schemas.js";
14
+ import { isParseError, parseBody, parseQuery } from "#api/parse.js";
15
+ import { localeFilterQuery, updateTermBody } from "#api/schemas.js";
16
16
 
17
17
  export const prerender = false;
18
18
 
19
19
  /**
20
20
  * Get a single term
21
21
  */
22
- export const GET: APIRoute = async ({ params, locals }) => {
22
+ export const GET: APIRoute = async ({ params, request, locals }) => {
23
23
  const { emdash, user } = locals;
24
24
  const { name, slug } = params;
25
-
26
- if (!name || !slug) {
27
- return apiError("VALIDATION_ERROR", "Taxonomy name and slug required", 400);
28
- }
25
+ if (!name || !slug) return apiError("VALIDATION_ERROR", "Taxonomy name and slug required", 400);
29
26
 
30
27
  const dbErr = requireDb(emdash?.db);
31
28
  if (dbErr) return dbErr;
@@ -33,8 +30,11 @@ export const GET: APIRoute = async ({ params, locals }) => {
33
30
  const denied = requirePerm(user, "taxonomies:read");
34
31
  if (denied) return denied;
35
32
 
33
+ const query = parseQuery(new URL(request.url), localeFilterQuery);
34
+ if (isParseError(query)) return query;
35
+
36
36
  try {
37
- const result = await handleTermGet(emdash.db, name, slug);
37
+ const result = await handleTermGet(emdash.db, name, slug, { locale: query.locale });
38
38
  return unwrapResult(result);
39
39
  } catch (error) {
40
40
  return handleError(error, "Failed to get term", "TERM_GET_ERROR");
@@ -47,10 +47,7 @@ export const GET: APIRoute = async ({ params, locals }) => {
47
47
  export const PUT: APIRoute = async ({ params, request, locals }) => {
48
48
  const { emdash, user } = locals;
49
49
  const { name, slug } = params;
50
-
51
- if (!name || !slug) {
52
- return apiError("VALIDATION_ERROR", "Taxonomy name and slug required", 400);
53
- }
50
+ if (!name || !slug) return apiError("VALIDATION_ERROR", "Taxonomy name and slug required", 400);
54
51
 
55
52
  const dbErr = requireDb(emdash?.db);
56
53
  if (dbErr) return dbErr;
@@ -58,11 +55,14 @@ export const PUT: APIRoute = async ({ params, request, locals }) => {
58
55
  const denied = requirePerm(user, "taxonomies:manage");
59
56
  if (denied) return denied;
60
57
 
58
+ const query = parseQuery(new URL(request.url), localeFilterQuery);
59
+ if (isParseError(query)) return query;
60
+
61
61
  try {
62
62
  const body = await parseBody(request, updateTermBody);
63
63
  if (isParseError(body)) return body;
64
64
 
65
- const result = await handleTermUpdate(emdash.db, name, slug, body);
65
+ const result = await handleTermUpdate(emdash.db, name, slug, body, { locale: query.locale });
66
66
  return unwrapResult(result);
67
67
  } catch (error) {
68
68
  return handleError(error, "Failed to update term", "TERM_UPDATE_ERROR");
@@ -72,13 +72,10 @@ export const PUT: APIRoute = async ({ params, request, locals }) => {
72
72
  /**
73
73
  * Delete a term
74
74
  */
75
- export const DELETE: APIRoute = async ({ params, locals }) => {
75
+ export const DELETE: APIRoute = async ({ params, request, locals }) => {
76
76
  const { emdash, user } = locals;
77
77
  const { name, slug } = params;
78
-
79
- if (!name || !slug) {
80
- return apiError("VALIDATION_ERROR", "Taxonomy name and slug required", 400);
81
- }
78
+ if (!name || !slug) return apiError("VALIDATION_ERROR", "Taxonomy name and slug required", 400);
82
79
 
83
80
  const dbErr = requireDb(emdash?.db);
84
81
  if (dbErr) return dbErr;
@@ -86,8 +83,11 @@ export const DELETE: APIRoute = async ({ params, locals }) => {
86
83
  const denied = requirePerm(user, "taxonomies:manage");
87
84
  if (denied) return denied;
88
85
 
86
+ const query = parseQuery(new URL(request.url), localeFilterQuery);
87
+ if (isParseError(query)) return query;
88
+
89
89
  try {
90
- const result = await handleTermDelete(emdash.db, name, slug);
90
+ const result = await handleTermDelete(emdash.db, name, slug, { locale: query.locale });
91
91
  return unwrapResult(result);
92
92
  } catch (error) {
93
93
  return handleError(error, "Failed to delete term", "TERM_DELETE_ERROR");
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Taxonomy terms list and create endpoint
3
3
  *
4
- * GET /_emdash/api/taxonomies/:name/terms - List all terms (tree for hierarchical)
5
- * POST /_emdash/api/taxonomies/:name/terms - Create a new term
4
+ * GET /_emdash/api/taxonomies/:name/terms[?locale=xx] - List terms (tree for hierarchical)
5
+ * POST /_emdash/api/taxonomies/:name/terms - Create a new term (body may include locale & translationOf)
6
6
  */
7
7
 
8
8
  import type { APIRoute } from "astro";
@@ -10,21 +10,18 @@ import type { APIRoute } from "astro";
10
10
  import { requirePerm } from "#api/authorize.js";
11
11
  import { apiError, handleError, requireDb, unwrapResult } from "#api/error.js";
12
12
  import { handleTermCreate, handleTermList } from "#api/handlers/taxonomies.js";
13
- import { isParseError, parseBody } from "#api/parse.js";
14
- import { createTermBody } from "#api/schemas.js";
13
+ import { isParseError, parseBody, parseQuery } from "#api/parse.js";
14
+ import { createTermBody, localeFilterQuery } from "#api/schemas.js";
15
15
 
16
16
  export const prerender = false;
17
17
 
18
18
  /**
19
19
  * List all terms for a taxonomy
20
20
  */
21
- export const GET: APIRoute = async ({ params, locals }) => {
21
+ export const GET: APIRoute = async ({ params, request, locals }) => {
22
22
  const { emdash, user } = locals;
23
23
  const { name } = params;
24
-
25
- if (!name) {
26
- return apiError("VALIDATION_ERROR", "Taxonomy name required", 400);
27
- }
24
+ if (!name) return apiError("VALIDATION_ERROR", "Taxonomy name required", 400);
28
25
 
29
26
  const dbErr = requireDb(emdash?.db);
30
27
  if (dbErr) return dbErr;
@@ -32,8 +29,11 @@ export const GET: APIRoute = async ({ params, locals }) => {
32
29
  const denied = requirePerm(user, "taxonomies:read");
33
30
  if (denied) return denied;
34
31
 
32
+ const query = parseQuery(new URL(request.url), localeFilterQuery);
33
+ if (isParseError(query)) return query;
34
+
35
35
  try {
36
- const result = await handleTermList(emdash.db, name);
36
+ const result = await handleTermList(emdash.db, name, { locale: query.locale });
37
37
  return unwrapResult(result);
38
38
  } catch (error) {
39
39
  return handleError(error, "Failed to list terms", "TERM_LIST_ERROR");
@@ -46,10 +46,7 @@ export const GET: APIRoute = async ({ params, locals }) => {
46
46
  export const POST: APIRoute = async ({ params, request, locals }) => {
47
47
  const { emdash, user } = locals;
48
48
  const { name } = params;
49
-
50
- if (!name) {
51
- return apiError("VALIDATION_ERROR", "Taxonomy name required", 400);
52
- }
49
+ if (!name) return apiError("VALIDATION_ERROR", "Taxonomy name required", 400);
53
50
 
54
51
  const dbErr = requireDb(emdash?.db);
55
52
  if (dbErr) return dbErr;
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Taxonomy definitions endpoint
3
3
  *
4
- * GET /_emdash/api/taxonomies - List all taxonomy definitions
5
- * POST /_emdash/api/taxonomies - Create a custom taxonomy definition
4
+ * GET /_emdash/api/taxonomies[?locale=xx] - List taxonomy definitions
5
+ * POST /_emdash/api/taxonomies - Create a custom taxonomy definition
6
6
  */
7
7
 
8
8
  import type { APIRoute } from "astro";
@@ -10,15 +10,15 @@ import type { APIRoute } from "astro";
10
10
  import { requirePerm } from "#api/authorize.js";
11
11
  import { handleError, requireDb, unwrapResult } from "#api/error.js";
12
12
  import { handleTaxonomyCreate, handleTaxonomyList } from "#api/handlers/taxonomies.js";
13
- import { isParseError, parseBody } from "#api/parse.js";
14
- import { createTaxonomyDefBody } from "#api/schemas.js";
13
+ import { isParseError, parseBody, parseQuery } from "#api/parse.js";
14
+ import { createTaxonomyDefBody, localeFilterQuery } from "#api/schemas.js";
15
15
 
16
16
  export const prerender = false;
17
17
 
18
18
  /**
19
19
  * List taxonomy definitions
20
20
  */
21
- export const GET: APIRoute = async ({ locals }) => {
21
+ export const GET: APIRoute = async ({ request, locals }) => {
22
22
  const { emdash, user } = locals;
23
23
 
24
24
  const dbErr = requireDb(emdash?.db);
@@ -27,8 +27,11 @@ export const GET: APIRoute = async ({ locals }) => {
27
27
  const denied = requirePerm(user, "taxonomies:read");
28
28
  if (denied) return denied;
29
29
 
30
+ const query = parseQuery(new URL(request.url), localeFilterQuery);
31
+ if (isParseError(query)) return query;
32
+
30
33
  try {
31
- const result = await handleTaxonomyList(emdash.db);
34
+ const result = await handleTaxonomyList(emdash.db, { locale: query.locale });
32
35
  return unwrapResult(result);
33
36
  } catch (error) {
34
37
  return handleError(error, "Failed to list taxonomies", "TAXONOMY_LIST_ERROR");
@@ -212,41 +212,69 @@ async function exportCollections(db: Kysely<Database>): Promise<SeedCollection[]
212
212
  * Export taxonomy definitions and terms
213
213
  */
214
214
  async function exportTaxonomies(db: Kysely<Database>): Promise<SeedTaxonomy[]> {
215
- // Get taxonomy definitions
216
- const defs = await db.selectFrom("_emdash_taxonomy_defs").selectAll().execute();
215
+ const i18nEnabled = isI18nEnabled();
216
+
217
+ // Mirrors the content export pattern: one entry per (name, locale), stable
218
+ // seed-local id, translations linked via `translationOf` to the anchor's id.
219
+ const defs = await db
220
+ .selectFrom("_emdash_taxonomy_defs")
221
+ .selectAll()
222
+ .orderBy(["name", "locale"])
223
+ .execute();
217
224
 
218
225
  const result: SeedTaxonomy[] = [];
219
226
  const termRepo = new TaxonomyRepository(db);
220
227
 
228
+ // translation_group -> seed-local id of first def we emitted in that group.
229
+ const defGroupToSeedId = new Map<string, string>();
230
+
221
231
  for (const def of defs) {
222
- // Get terms for this taxonomy
223
- const terms = await termRepo.findByName(def.name);
232
+ const defSeedId =
233
+ i18nEnabled && def.locale ? `tax:${def.name}:${def.locale}` : `tax:${def.name}`;
224
234
 
225
- // Build term tree for hierarchical taxonomies
226
- const seedTerms: SeedTaxonomyTerm[] = [];
235
+ // Terms in this def's locale.
236
+ const terms = await termRepo.findByName(def.name, { locale: def.locale });
227
237
 
228
- // First, create a map of id -> slug for parent resolution
238
+ // id -> slug for parent resolution within this locale.
229
239
  const idToSlug = new Map<string, string>();
230
- for (const term of terms) {
231
- idToSlug.set(term.id, term.slug);
232
- }
240
+ for (const term of terms) idToSlug.set(term.id, term.slug);
241
+
242
+ // translation_group -> seed id of the anchor term.
243
+ const termGroupToSeedId = new Map<string, string>();
233
244
 
245
+ const seedTerms: SeedTaxonomyTerm[] = [];
234
246
  for (const term of terms) {
247
+ const termSeedId =
248
+ i18nEnabled && term.locale
249
+ ? `term:${def.name}:${term.slug}:${term.locale}`
250
+ : `term:${def.name}:${term.slug}`;
251
+
235
252
  const seedTerm: SeedTaxonomyTerm = {
253
+ id: termSeedId,
236
254
  slug: term.slug,
237
255
  label: term.label,
238
256
  description: typeof term.data?.description === "string" ? term.data.description : undefined,
239
257
  };
240
258
 
241
- // Resolve parent slug
242
- if (term.parentId) {
243
- seedTerm.parent = idToSlug.get(term.parentId);
259
+ if (term.parentId) seedTerm.parent = idToSlug.get(term.parentId);
260
+
261
+ if (i18nEnabled && term.locale) {
262
+ seedTerm.locale = term.locale;
263
+ if (term.translationGroup) {
264
+ const anchor = termGroupToSeedId.get(term.translationGroup);
265
+ if (anchor) seedTerm.translationOf = anchor;
266
+ else termGroupToSeedId.set(term.translationGroup, termSeedId);
267
+ }
244
268
  }
245
269
 
246
270
  seedTerms.push(seedTerm);
247
271
  }
248
272
 
273
+ // Anchors first so import can resolve `translationOf`.
274
+ seedTerms.sort((a, b) => Number(!!a.translationOf) - Number(!!b.translationOf));
275
+
249
276
  const taxonomy: SeedTaxonomy = {
277
+ id: defSeedId,
250
278
  name: def.name,
251
279
  label: def.label,
252
280
  labelSingular: def.label_singular || undefined,
@@ -254,13 +282,23 @@ async function exportTaxonomies(db: Kysely<Database>): Promise<SeedTaxonomy[]> {
254
282
  collections: def.collections ? JSON.parse(def.collections) : [],
255
283
  };
256
284
 
257
- if (seedTerms.length > 0) {
258
- taxonomy.terms = seedTerms;
285
+ if (i18nEnabled && def.locale) {
286
+ taxonomy.locale = def.locale;
287
+ if (def.translation_group) {
288
+ const anchor = defGroupToSeedId.get(def.translation_group);
289
+ if (anchor) taxonomy.translationOf = anchor;
290
+ else defGroupToSeedId.set(def.translation_group, defSeedId);
291
+ }
259
292
  }
260
293
 
294
+ if (seedTerms.length > 0) taxonomy.terms = seedTerms;
295
+
261
296
  result.push(taxonomy);
262
297
  }
263
298
 
299
+ // Anchors first at def level too.
300
+ result.sort((a, b) => Number(!!a.translationOf) - Number(!!b.translationOf));
301
+
264
302
  return result;
265
303
  }
266
304
 
@@ -268,13 +306,22 @@ async function exportTaxonomies(db: Kysely<Database>): Promise<SeedTaxonomy[]> {
268
306
  * Export menus with their items
269
307
  */
270
308
  async function exportMenus(db: Kysely<Database>): Promise<SeedMenu[]> {
271
- // Get all menus
272
- const menus = await db.selectFrom("_emdash_menus").selectAll().execute();
309
+ const i18nEnabled = isI18nEnabled();
310
+
311
+ const menus = await db
312
+ .selectFrom("_emdash_menus")
313
+ .selectAll()
314
+ .orderBy(["name", "locale"])
315
+ .execute();
273
316
 
274
317
  const result: SeedMenu[] = [];
318
+ // translation_group -> seed-local id of the anchor menu in that group.
319
+ const groupToSeedId = new Map<string, string>();
275
320
 
276
321
  for (const menu of menus) {
277
- // Get menu items
322
+ const seedId =
323
+ i18nEnabled && menu.locale ? `menu:${menu.name}:${menu.locale}` : `menu:${menu.name}`;
324
+
278
325
  const items = await db
279
326
  .selectFrom("_emdash_menu_items")
280
327
  .selectAll()
@@ -282,16 +329,30 @@ async function exportMenus(db: Kysely<Database>): Promise<SeedMenu[]> {
282
329
  .orderBy("sort_order", "asc")
283
330
  .execute();
284
331
 
285
- // Build item tree
286
332
  const seedItems = buildMenuItemTree(items);
287
333
 
288
- result.push({
334
+ const seedMenu: SeedMenu = {
335
+ id: seedId,
289
336
  name: menu.name,
290
337
  label: menu.label,
291
338
  items: seedItems,
292
- });
339
+ };
340
+
341
+ if (i18nEnabled && menu.locale) {
342
+ seedMenu.locale = menu.locale;
343
+ if (menu.translation_group) {
344
+ const anchor = groupToSeedId.get(menu.translation_group);
345
+ if (anchor) seedMenu.translationOf = anchor;
346
+ else groupToSeedId.set(menu.translation_group, seedId);
347
+ }
348
+ }
349
+
350
+ result.push(seedMenu);
293
351
  }
294
352
 
353
+ // Anchors first so import can resolve `translationOf`.
354
+ result.sort((a, b) => Number(!!a.translationOf) - Number(!!b.translationOf));
355
+
295
356
  return result;
296
357
  }
297
358