emdash 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (239) 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-Ded_1vng.mjs} +167 -254
  4. package/dist/apply-Ded_1vng.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.d.mts.map +1 -1
  14. package/dist/astro/middleware.mjs +94 -43
  15. package/dist/astro/middleware.mjs.map +1 -1
  16. package/dist/astro/types.d.mts +12 -11
  17. package/dist/astro/types.d.mts.map +1 -1
  18. package/dist/{base64-BRICGH2l.mjs → base64-MBPo9ozB.mjs} +1 -1
  19. package/dist/{base64-BRICGH2l.mjs.map → base64-MBPo9ozB.mjs.map} +1 -1
  20. package/dist/{byline-BSaNL1w7.mjs → byline-gFn1r0vA.mjs} +4 -4
  21. package/dist/{byline-BSaNL1w7.mjs.map → byline-gFn1r0vA.mjs.map} +1 -1
  22. package/dist/{bylines-CvJ3PYz2.mjs → bylines-DTFI8nDM.mjs} +5 -5
  23. package/dist/{bylines-CvJ3PYz2.mjs.map → bylines-DTFI8nDM.mjs.map} +1 -1
  24. package/dist/{cache-C6N_hhN7.mjs → cache-BAJbeoZ8.mjs} +3 -3
  25. package/dist/{cache-C6N_hhN7.mjs.map → cache-BAJbeoZ8.mjs.map} +1 -1
  26. package/dist/{chunks-NBQVDOci.mjs → chunks-BK1oZS-l.mjs} +2 -2
  27. package/dist/{chunks-NBQVDOci.mjs.map → chunks-BK1oZS-l.mjs.map} +1 -1
  28. package/dist/cli/index.mjs +342 -95
  29. package/dist/cli/index.mjs.map +1 -1
  30. package/dist/client/cf-access.d.mts +1 -1
  31. package/dist/client/index.d.mts +1 -1
  32. package/dist/client/index.mjs +1 -1
  33. package/dist/{config-BI0V3ICQ.mjs → config-CVssduLe.mjs} +1 -1
  34. package/dist/{config-BI0V3ICQ.mjs.map → config-CVssduLe.mjs.map} +1 -1
  35. package/dist/{content-8lOYF0pr.mjs → content-CERxPUN0.mjs} +14 -3
  36. package/dist/content-CERxPUN0.mjs.map +1 -0
  37. package/dist/database/instrumentation.d.mts +6 -4
  38. package/dist/database/instrumentation.d.mts.map +1 -1
  39. package/dist/database/instrumentation.mjs +19 -7
  40. package/dist/database/instrumentation.mjs.map +1 -1
  41. package/dist/db/index.d.mts +3 -3
  42. package/dist/db/index.mjs +1 -1
  43. package/dist/db/libsql.d.mts +1 -1
  44. package/dist/db/postgres.d.mts +1 -1
  45. package/dist/db/sqlite.d.mts +1 -1
  46. package/dist/{db-errors-WRezodiz.mjs → db-errors-B7P2pSCn.mjs} +1 -1
  47. package/dist/{db-errors-WRezodiz.mjs.map → db-errors-B7P2pSCn.mjs.map} +1 -1
  48. package/dist/{default-D8ksjWhO.mjs → default-pHuz9WF6.mjs} +1 -1
  49. package/dist/{default-D8ksjWhO.mjs.map → default-pHuz9WF6.mjs.map} +1 -1
  50. package/dist/{error-D_-tqP-I.mjs → error-DqnRMM5z.mjs} +1 -1
  51. package/dist/{error-D_-tqP-I.mjs.map → error-DqnRMM5z.mjs.map} +1 -1
  52. package/dist/{index-BFRaVcD6.d.mts → index-Cg-rC4Gj.d.mts} +110 -87
  53. package/dist/index-Cg-rC4Gj.d.mts.map +1 -0
  54. package/dist/index.d.mts +11 -11
  55. package/dist/index.mjs +29 -28
  56. package/dist/{load-DDqMMvZL.mjs → load-DR1VwFXR.mjs} +2 -2
  57. package/dist/{load-DDqMMvZL.mjs.map → load-DR1VwFXR.mjs.map} +1 -1
  58. package/dist/{loader-CKLbBnhK.mjs → loader-ou_PXAjg.mjs} +31 -6
  59. package/dist/loader-ou_PXAjg.mjs.map +1 -0
  60. package/dist/{manifest-schema-DqWNC3lM.mjs → manifest-schema-CXAbd1vH.mjs} +1 -1
  61. package/dist/{manifest-schema-DqWNC3lM.mjs.map → manifest-schema-CXAbd1vH.mjs.map} +1 -1
  62. package/dist/media/index.d.mts +1 -1
  63. package/dist/media/index.mjs +1 -1
  64. package/dist/media/local-runtime.d.mts +7 -7
  65. package/dist/media/local-runtime.mjs +3 -3
  66. package/dist/{media-BW32b4gi.mjs → media-1fFhub9c.mjs} +22 -10
  67. package/dist/media-1fFhub9c.mjs.map +1 -0
  68. package/dist/{mode-ier8jbBk.mjs → mode-YhqNVef_.mjs} +1 -1
  69. package/dist/{mode-ier8jbBk.mjs.map → mode-YhqNVef_.mjs.map} +1 -1
  70. package/dist/{options-BVp3UsTS.mjs → options-nPxWnrya.mjs} +1 -1
  71. package/dist/{options-BVp3UsTS.mjs.map → options-nPxWnrya.mjs.map} +1 -1
  72. package/dist/page/index.d.mts +2 -2
  73. package/dist/{patterns-CrCYkMBb.mjs → patterns-DsUZ4uxI.mjs} +1 -1
  74. package/dist/{patterns-CrCYkMBb.mjs.map → patterns-DsUZ4uxI.mjs.map} +1 -1
  75. package/dist/{placeholder-BE4o_2dc.d.mts → placeholder-CDPtkelt.d.mts} +1 -1
  76. package/dist/{placeholder-BE4o_2dc.d.mts.map → placeholder-CDPtkelt.d.mts.map} +1 -1
  77. package/dist/{placeholder-CIJejMlK.mjs → placeholder-Ci0RLeCk.mjs} +1 -1
  78. package/dist/{placeholder-CIJejMlK.mjs.map → placeholder-Ci0RLeCk.mjs.map} +1 -1
  79. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  80. package/dist/plugins/adapt-sandbox-entry.mjs +2 -2
  81. package/dist/{public-url-DByxYjUw.mjs → public-url-B1AxbbbQ.mjs} +1 -1
  82. package/dist/{public-url-DByxYjUw.mjs.map → public-url-B1AxbbbQ.mjs.map} +1 -1
  83. package/dist/{query-Cg9ZKRQ0.mjs → query-8c_meo_K.mjs} +13 -13
  84. package/dist/{query-Cg9ZKRQ0.mjs.map → query-8c_meo_K.mjs.map} +1 -1
  85. package/dist/{redirect-BhUBKRc1.mjs → redirect-C5H7VGIX.mjs} +3 -3
  86. package/dist/{redirect-BhUBKRc1.mjs.map → redirect-C5H7VGIX.mjs.map} +1 -1
  87. package/dist/{registry-Dw70ChxB.mjs → registry-Do34mz_P.mjs} +7 -6
  88. package/dist/registry-Do34mz_P.mjs.map +1 -0
  89. package/dist/{request-cache-B-bmkipQ.mjs → request-cache-D4I69LeL.mjs} +6 -2
  90. package/dist/request-cache-D4I69LeL.mjs.map +1 -0
  91. package/dist/request-context.d.mts +27 -1
  92. package/dist/request-context.d.mts.map +1 -1
  93. package/dist/request-context.mjs +16 -3
  94. package/dist/request-context.mjs.map +1 -1
  95. package/dist/{runner-C7ADox5q.mjs → runner-DIcU2UCC.mjs} +465 -148
  96. package/dist/runner-DIcU2UCC.mjs.map +1 -0
  97. package/dist/{runner-Bnoj7vjK.d.mts → runner-Iu3IZSDM.d.mts} +2 -2
  98. package/dist/{runner-Bnoj7vjK.d.mts.map → runner-Iu3IZSDM.d.mts.map} +1 -1
  99. package/dist/runtime.d.mts +6 -6
  100. package/dist/runtime.mjs +3 -3
  101. package/dist/{search-dOGEccMa.mjs → search-DuWhx4NG.mjs} +322 -108
  102. package/dist/search-DuWhx4NG.mjs.map +1 -0
  103. package/dist/{secrets-CW3reAnU.mjs → secrets-CZ8rxLX3.mjs} +3 -3
  104. package/dist/{secrets-CW3reAnU.mjs.map → secrets-CZ8rxLX3.mjs.map} +1 -1
  105. package/dist/seed/index.d.mts +2 -2
  106. package/dist/seed/index.mjs +15 -14
  107. package/dist/seo/index.d.mts +1 -1
  108. package/dist/storage/local.d.mts +1 -1
  109. package/dist/storage/local.mjs +1 -1
  110. package/dist/storage/s3.d.mts +1 -1
  111. package/dist/storage/s3.mjs +1 -1
  112. package/dist/taxonomies-Bw76xAxo.mjs +407 -0
  113. package/dist/taxonomies-Bw76xAxo.mjs.map +1 -0
  114. package/dist/taxonomy-D6NvlKo8.mjs +218 -0
  115. package/dist/taxonomy-D6NvlKo8.mjs.map +1 -0
  116. package/dist/{tokens-D7zMmWi2.mjs → tokens-CyRDPVW2.mjs} +2 -2
  117. package/dist/{tokens-D7zMmWi2.mjs.map → tokens-CyRDPVW2.mjs.map} +1 -1
  118. package/dist/{transaction-Cn2rjY78.mjs → transaction-D44LBXvU.mjs} +1 -1
  119. package/dist/{transaction-Cn2rjY78.mjs.map → transaction-D44LBXvU.mjs.map} +1 -1
  120. package/dist/{transport-DNEfeMaU.d.mts → transport-DX_5rpsq.d.mts} +1 -1
  121. package/dist/{transport-DNEfeMaU.d.mts.map → transport-DX_5rpsq.d.mts.map} +1 -1
  122. package/dist/{transport-BeMCmin1.mjs → transport-xpzIjCIB.mjs} +1 -1
  123. package/dist/{transport-BeMCmin1.mjs.map → transport-xpzIjCIB.mjs.map} +1 -1
  124. package/dist/{types-CIOg5AR8.mjs → types-56BKbld_.mjs} +1 -1
  125. package/dist/types-56BKbld_.mjs.map +1 -0
  126. package/dist/{types-CRxNbK-Z.mjs → types-BIgulNsW.mjs} +2 -2
  127. package/dist/{types-CRxNbK-Z.mjs.map → types-BIgulNsW.mjs.map} +1 -1
  128. package/dist/{types-CrtWgIvl.d.mts → types-BQx6ZXpR.d.mts} +10 -1
  129. package/dist/types-BQx6ZXpR.d.mts.map +1 -0
  130. package/dist/{types-CJsYGpco.d.mts → types-B_CXXnzh.d.mts} +1 -1
  131. package/dist/{types-CJsYGpco.d.mts.map → types-B_CXXnzh.d.mts.map} +1 -1
  132. package/dist/{types-M78DQ1lx.d.mts → types-C-aFbqmA.d.mts} +1 -1
  133. package/dist/{types-M78DQ1lx.d.mts.map → types-C-aFbqmA.d.mts.map} +1 -1
  134. package/dist/types-DiI8NOG_.mjs +16 -0
  135. package/dist/types-DiI8NOG_.mjs.map +1 -0
  136. package/dist/{types-BuBIptGk.d.mts → types-IN5z_S3P.d.mts} +158 -92
  137. package/dist/types-IN5z_S3P.d.mts.map +1 -0
  138. package/dist/{types-BSyXeCFW.d.mts → types-IZSZfEwv.d.mts} +4 -3
  139. package/dist/types-IZSZfEwv.d.mts.map +1 -0
  140. package/dist/{types-CDbKp7ND.mjs → types-K-EkEQCI.mjs} +1 -1
  141. package/dist/{types-CDbKp7ND.mjs.map → types-K-EkEQCI.mjs.map} +1 -1
  142. package/dist/{validate-BfQh_C_y.d.mts → validate-CO3JjFV5.d.mts} +22 -5
  143. package/dist/validate-CO3JjFV5.d.mts.map +1 -0
  144. package/dist/{validate-Baqf0slj.mjs → validate-UK4Ja1uo.mjs} +14 -10
  145. package/dist/validate-UK4Ja1uo.mjs.map +1 -0
  146. package/dist/{validation-BfEI7tNe.mjs → validation-Vc5DQkJa.mjs} +5 -5
  147. package/dist/{validation-BfEI7tNe.mjs.map → validation-Vc5DQkJa.mjs.map} +1 -1
  148. package/dist/version-Bg31I_Ff.mjs +7 -0
  149. package/dist/{version-DoxrVdYf.mjs.map → version-Bg31I_Ff.mjs.map} +1 -1
  150. package/dist/{zod-generator-CC0xNe_K.mjs → zod-generator-CHnJUP2l.mjs} +8 -3
  151. package/dist/zod-generator-CHnJUP2l.mjs.map +1 -0
  152. package/package.json +9 -8
  153. package/src/api/errors.ts +5 -0
  154. package/src/api/handlers/content.ts +20 -0
  155. package/src/api/handlers/dashboard.ts +29 -36
  156. package/src/api/handlers/media-allowlist.ts +40 -0
  157. package/src/api/handlers/media.ts +1 -1
  158. package/src/api/handlers/menus.ts +400 -89
  159. package/src/api/handlers/taxonomies.ts +273 -97
  160. package/src/api/handlers/validate-media-fields.ts +125 -0
  161. package/src/api/schemas/common.ts +7 -0
  162. package/src/api/schemas/media.ts +23 -3
  163. package/src/api/schemas/menus.ts +23 -0
  164. package/src/api/schemas/schema.ts +11 -2
  165. package/src/api/schemas/taxonomies.ts +39 -0
  166. package/src/astro/integration/routes.ts +10 -0
  167. package/src/astro/middleware.ts +46 -11
  168. package/src/astro/routes/api/content/[collection]/[id]/permanent.ts +1 -1
  169. package/src/astro/routes/api/import/wordpress/rewrite-url-helpers.ts +196 -0
  170. package/src/astro/routes/api/import/wordpress/rewrite-urls.ts +9 -177
  171. package/src/astro/routes/api/media/upload-url.ts +10 -4
  172. package/src/astro/routes/api/media.ts +12 -4
  173. package/src/astro/routes/api/menus/[name]/items.ts +16 -6
  174. package/src/astro/routes/api/menus/[name]/reorder.ts +8 -3
  175. package/src/astro/routes/api/menus/[name]/translations.ts +82 -0
  176. package/src/astro/routes/api/menus/[name].ts +19 -10
  177. package/src/astro/routes/api/menus/index.ts +9 -6
  178. package/src/astro/routes/api/taxonomies/[name]/terms/[slug]/translations.ts +89 -0
  179. package/src/astro/routes/api/taxonomies/[name]/terms/[slug].ts +22 -22
  180. package/src/astro/routes/api/taxonomies/[name]/terms/index.ts +11 -14
  181. package/src/astro/routes/api/taxonomies/index.ts +9 -6
  182. package/src/astro/types.ts +5 -1
  183. package/src/auth/rate-limit.ts +3 -3
  184. package/src/cli/commands/bundle-utils.ts +81 -6
  185. package/src/cli/commands/bundle.ts +18 -15
  186. package/src/cli/commands/export-seed.ts +139 -24
  187. package/src/cli/commands/plugin-init.ts +216 -90
  188. package/src/database/instrumentation.ts +22 -8
  189. package/src/database/migrations/016_api_tokens.ts +18 -3
  190. package/src/database/migrations/036_i18n_menus_and_taxonomies.ts +477 -0
  191. package/src/database/migrations/037_credential_algorithm.ts +18 -0
  192. package/src/database/migrations/runner.ts +4 -0
  193. package/src/database/repositories/content.ts +11 -0
  194. package/src/database/repositories/media.ts +40 -10
  195. package/src/database/repositories/taxonomy.ts +193 -89
  196. package/src/database/types.ts +12 -3
  197. package/src/emdash-runtime.ts +16 -3
  198. package/src/fields/file.ts +7 -6
  199. package/src/fields/image.ts +12 -11
  200. package/src/fields/types.ts +3 -0
  201. package/src/i18n/resolve.ts +37 -0
  202. package/src/index.ts +1 -1
  203. package/src/loader.ts +49 -2
  204. package/src/mcp/server.ts +114 -26
  205. package/src/media/mime.ts +75 -0
  206. package/src/menus/index.ts +143 -124
  207. package/src/menus/types.ts +15 -1
  208. package/src/plugins/types.ts +81 -191
  209. package/src/request-cache.ts +6 -2
  210. package/src/request-context.ts +42 -2
  211. package/src/schema/registry.ts +5 -5
  212. package/src/schema/types.ts +3 -2
  213. package/src/schema/zod-generator.ts +12 -2
  214. package/src/seed/apply.ts +157 -54
  215. package/src/seed/types.ts +18 -1
  216. package/src/seed/validate.ts +27 -13
  217. package/src/taxonomies/index.ts +230 -213
  218. package/src/taxonomies/types.ts +10 -0
  219. package/dist/apply-BzltprvY.mjs.map +0 -1
  220. package/dist/content-8lOYF0pr.mjs.map +0 -1
  221. package/dist/index-BFRaVcD6.d.mts.map +0 -1
  222. package/dist/loader-CKLbBnhK.mjs.map +0 -1
  223. package/dist/media-BW32b4gi.mjs.map +0 -1
  224. package/dist/registry-Dw70ChxB.mjs.map +0 -1
  225. package/dist/request-cache-B-bmkipQ.mjs.map +0 -1
  226. package/dist/runner-C7ADox5q.mjs.map +0 -1
  227. package/dist/search-dOGEccMa.mjs.map +0 -1
  228. package/dist/taxonomies-ZlRtD6AG.mjs +0 -315
  229. package/dist/taxonomies-ZlRtD6AG.mjs.map +0 -1
  230. package/dist/types-4fVtCIm0.mjs +0 -68
  231. package/dist/types-4fVtCIm0.mjs.map +0 -1
  232. package/dist/types-BSyXeCFW.d.mts.map +0 -1
  233. package/dist/types-BuBIptGk.d.mts.map +0 -1
  234. package/dist/types-CIOg5AR8.mjs.map +0 -1
  235. package/dist/types-CrtWgIvl.d.mts.map +0 -1
  236. package/dist/validate-Baqf0slj.mjs.map +0 -1
  237. package/dist/validate-BfQh_C_y.d.mts.map +0 -1
  238. package/dist/version-DoxrVdYf.mjs +0 -7
  239. package/dist/zod-generator-CC0xNe_K.mjs.map +0 -1
@@ -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");
@@ -43,6 +43,10 @@ export interface ManifestCollection {
43
43
  * (e.g. a checkbox grid receiving its column definitions)
44
44
  */
45
45
  options?: Array<{ value: string; label: string }> | Record<string, unknown>;
46
+ /** The `_emdash_fields` row ID. Used by the admin to forward to upload/media-list API calls. */
47
+ id?: string;
48
+ /** Validation config for the field (e.g. `allowedMimeTypes` for file/image fields, subFields for repeater). */
49
+ validation?: Record<string, unknown>;
46
50
  }
47
51
  >;
48
52
  }
@@ -292,7 +296,7 @@ export interface EmDashHandlers {
292
296
  handleMediaList: (params: {
293
297
  cursor?: string;
294
298
  limit?: number;
295
- mimeType?: string;
299
+ mimeType?: string | readonly string[];
296
300
  }) => Promise<HandlerResponse>;
297
301
 
298
302
  handleMediaGet: (id: string) => Promise<HandlerResponse>;
@@ -63,9 +63,9 @@ export async function checkRateLimit(
63
63
 
64
64
  // Atomic upsert: insert or increment, return current count
65
65
  const result = await sql<{ count: number }>`
66
- INSERT INTO _emdash_rate_limits (key, window, count)
66
+ INSERT INTO _emdash_rate_limits (key, "window", count)
67
67
  VALUES (${key}, ${windowStart}, 1)
68
- ON CONFLICT (key, window)
68
+ ON CONFLICT (key, "window")
69
69
  DO UPDATE SET count = _emdash_rate_limits.count + 1
70
70
  RETURNING count
71
71
  `.execute(db);
@@ -179,7 +179,7 @@ export async function cleanupExpiredRateLimits(
179
179
  const cutoff = new Date(Date.now() - maxAgeSeconds * 1000).toISOString();
180
180
 
181
181
  const result = await sql`
182
- DELETE FROM _emdash_rate_limits WHERE window < ${cutoff}
182
+ DELETE FROM _emdash_rate_limits WHERE "window" < ${cutoff}
183
183
  `.execute(db);
184
184
 
185
185
  return Number(result.numAffectedRows ?? 0);
@@ -22,7 +22,12 @@ import type {
22
22
 
23
23
  // ── Constants ────────────────────────────────────────────────────────────────
24
24
 
25
- export const MAX_BUNDLE_SIZE = 5 * 1024 * 1024;
25
+ // Bundle size caps per RFC 0001 §"Bundle size limits". These are decompressed
26
+ // sizes; the gzipped tarball is typically a fraction of MAX_BUNDLE_SIZE.
27
+ export const MAX_BUNDLE_SIZE = 256 * 1024;
28
+ export const MAX_FILE_SIZE = 128 * 1024;
29
+ export const MAX_FILE_COUNT = 20;
30
+
26
31
  export const MAX_SCREENSHOTS = 5;
27
32
  export const MAX_SCREENSHOT_WIDTH = 1920;
28
33
  export const MAX_SCREENSHOT_HEIGHT = 1080;
@@ -251,23 +256,93 @@ export function findSourceExports(
251
256
  // ── Directory helpers ────────────────────────────────────────────────────────
252
257
 
253
258
  /**
254
- * Recursively calculate the total size of all files in a directory.
259
+ * One file in a bundle: a tarball-relative path and its byte length.
260
+ * Produced by `collectBundleEntries` (from a staging dir) or by the publish
261
+ * flow (from tarball entries); consumed by `validateBundleSize`.
255
262
  */
256
- export async function calculateDirectorySize(dir: string): Promise<number> {
257
- let total = 0;
263
+ export interface BundleFileEntry {
264
+ name: string;
265
+ bytes: number;
266
+ }
267
+
268
+ /**
269
+ * Recursively walk a staging directory and return a flat list of all files
270
+ * with sizes. Names are relative to `dir` so they match what would appear
271
+ * as the tarball entry name.
272
+ */
273
+ export async function collectBundleEntries(dir: string): Promise<BundleFileEntry[]> {
274
+ const entries: BundleFileEntry[] = [];
275
+ await walkBundle(dir, "", entries);
276
+ return entries;
277
+ }
278
+
279
+ async function walkBundle(dir: string, prefix: string, into: BundleFileEntry[]): Promise<void> {
258
280
  const items = await readdir(dir, { withFileTypes: true });
259
281
  for (const item of items) {
260
282
  const fullPath = join(dir, item.name);
283
+ const relPath = prefix ? `${prefix}/${item.name}` : item.name;
261
284
  if (item.isFile()) {
262
285
  const s = await stat(fullPath);
263
- total += s.size;
286
+ into.push({ name: relPath, bytes: s.size });
264
287
  } else if (item.isDirectory()) {
265
- total += await calculateDirectorySize(fullPath);
288
+ await walkBundle(fullPath, relPath, into);
266
289
  }
267
290
  }
291
+ }
292
+
293
+ /**
294
+ * Sum the byte sizes of all entries.
295
+ */
296
+ export function totalBundleBytes(entries: readonly BundleFileEntry[]): number {
297
+ let total = 0;
298
+ for (const e of entries) total += e.bytes;
268
299
  return total;
269
300
  }
270
301
 
302
+ /**
303
+ * Check a bundle against the three size caps from RFC 0001:
304
+ * - total decompressed ≤ MAX_BUNDLE_SIZE
305
+ * - per-file decompressed ≤ MAX_FILE_SIZE
306
+ * - file count ≤ MAX_FILE_COUNT
307
+ *
308
+ * Returns a list of violation messages (empty if the bundle is within all
309
+ * caps). Messages are deterministic per input — the total/count violations
310
+ * come first, then oversized files in alphabetical order — so the same
311
+ * bundle always produces the same error text.
312
+ */
313
+ export function validateBundleSize(entries: readonly BundleFileEntry[]): string[] {
314
+ const violations: string[] = [];
315
+ const total = totalBundleBytes(entries);
316
+ if (total > MAX_BUNDLE_SIZE) {
317
+ violations.push(
318
+ `Bundle size ${formatBytes(total)} exceeds maximum of ${formatBytes(MAX_BUNDLE_SIZE)}.`,
319
+ );
320
+ }
321
+ if (entries.length > MAX_FILE_COUNT) {
322
+ violations.push(
323
+ `Bundle contains ${entries.length} files, exceeds maximum of ${MAX_FILE_COUNT}.`,
324
+ );
325
+ }
326
+ const oversized = entries
327
+ .filter((e) => e.bytes > MAX_FILE_SIZE)
328
+ .toSorted((a, b) => a.name.localeCompare(b.name));
329
+ for (const e of oversized) {
330
+ violations.push(
331
+ `File ${e.name} is ${formatBytes(e.bytes)}, exceeds per-file maximum of ${formatBytes(MAX_FILE_SIZE)}.`,
332
+ );
333
+ }
334
+ return violations;
335
+ }
336
+
337
+ /**
338
+ * Render a byte count as a human-friendly string (e.g. "256.0 KB").
339
+ */
340
+ export function formatBytes(n: number): string {
341
+ if (n < 1024) return `${n} B`;
342
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
343
+ return `${(n / 1024 / 1024).toFixed(2)} MB`;
344
+ }
345
+
271
346
  // ── Tarball creation ─────────────────────────────────────────────────────────
272
347
 
273
348
  /**
@@ -23,20 +23,22 @@ import consola from "consola";
23
23
  import { CAPABILITY_RENAMES, isDeprecatedCapability } from "../../plugins/types.js";
24
24
  import type { ResolvedPlugin } from "../../plugins/types.js";
25
25
  import {
26
- fileExists,
27
- readImageDimensions,
26
+ collectBundleEntries,
27
+ createTarball,
28
28
  extractManifest,
29
- findNodeBuiltinImports,
29
+ fileExists,
30
30
  findBuildOutput,
31
+ findNodeBuiltinImports,
31
32
  findSourceExports,
32
- resolveSourceEntry,
33
- calculateDirectorySize,
34
- createTarball,
35
- MAX_BUNDLE_SIZE,
33
+ formatBytes,
34
+ ICON_SIZE,
36
35
  MAX_SCREENSHOTS,
37
36
  MAX_SCREENSHOT_WIDTH,
38
37
  MAX_SCREENSHOT_HEIGHT,
39
- ICON_SIZE,
38
+ readImageDimensions,
39
+ resolveSourceEntry,
40
+ totalBundleBytes,
41
+ validateBundleSize,
40
42
  } from "./bundle-utils.js";
41
43
 
42
44
  const TS_EXT_RE = /\.(tsx?|[mc]?js)$/;
@@ -596,15 +598,16 @@ export const bundleCommand = defineCommand({
596
598
  }
597
599
  }
598
600
 
599
- // Calculate total bundle size
600
- const totalSize = await calculateDirectorySize(bundleDir);
601
- if (totalSize > MAX_BUNDLE_SIZE) {
602
- const sizeMB = (totalSize / 1024 / 1024).toFixed(2);
603
- consola.error(`Bundle size ${sizeMB}MB exceeds maximum of 5MB`);
601
+ // Bundle size caps (RFC 0001 §"Bundle size limits").
602
+ const bundleEntries = await collectBundleEntries(bundleDir);
603
+ const sizeViolations = validateBundleSize(bundleEntries);
604
+ if (sizeViolations.length > 0) {
605
+ for (const v of sizeViolations) consola.error(v);
604
606
  hasErrors = true;
605
607
  } else {
606
- const sizeKB = (totalSize / 1024).toFixed(1);
607
- consola.info(`Bundle size: ${sizeKB}KB`);
608
+ consola.info(
609
+ `Bundle size: ${formatBytes(totalBundleBytes(bundleEntries))} across ${bundleEntries.length} file${bundleEntries.length === 1 ? "" : "s"}`,
610
+ );
608
611
  }
609
612
 
610
613
  if (hasErrors) {