emdash 0.8.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 (317) hide show
  1. package/dist/{adapters-BKSf3T9R.d.mts → adapters-BktHA7EO.d.mts} +1 -1
  2. package/dist/{adapters-BKSf3T9R.d.mts.map → adapters-BktHA7EO.d.mts.map} +1 -1
  3. package/dist/{apply-x0eMK1lX.mjs → apply-UsrFuO7l.mjs} +207 -355
  4. package/dist/apply-UsrFuO7l.mjs.map +1 -0
  5. package/dist/astro/index.d.mts +6 -6
  6. package/dist/astro/index.d.mts.map +1 -1
  7. package/dist/astro/index.mjs +118 -4
  8. package/dist/astro/index.mjs.map +1 -1
  9. package/dist/astro/middleware/auth.d.mts +6 -7
  10. package/dist/astro/middleware/auth.d.mts.map +1 -1
  11. package/dist/astro/middleware/auth.mjs +14 -57
  12. package/dist/astro/middleware/auth.mjs.map +1 -1
  13. package/dist/astro/middleware/redirect.d.mts.map +1 -1
  14. package/dist/astro/middleware/redirect.mjs +15 -10
  15. package/dist/astro/middleware/redirect.mjs.map +1 -1
  16. package/dist/astro/middleware/request-context.d.mts.map +1 -1
  17. package/dist/astro/middleware/request-context.mjs +8 -5
  18. package/dist/astro/middleware/request-context.mjs.map +1 -1
  19. package/dist/astro/middleware/setup.mjs +1 -1
  20. package/dist/astro/middleware.d.mts.map +1 -1
  21. package/dist/astro/middleware.mjs +70 -121
  22. package/dist/astro/middleware.mjs.map +1 -1
  23. package/dist/astro/types.d.mts +25 -10
  24. package/dist/astro/types.d.mts.map +1 -1
  25. package/dist/{byline-Chbr2GoP.mjs → byline-C3vnhIpU.mjs} +4 -4
  26. package/dist/{byline-Chbr2GoP.mjs.map → byline-C3vnhIpU.mjs.map} +1 -1
  27. package/dist/bylines-esI7ioa9.mjs +113 -0
  28. package/dist/bylines-esI7ioa9.mjs.map +1 -0
  29. package/dist/cache-fTzxgMFJ.mjs +65 -0
  30. package/dist/cache-fTzxgMFJ.mjs.map +1 -0
  31. package/dist/{chunks-HGz06Soa.mjs → chunks-Da2-b-oA.mjs} +8 -2
  32. package/dist/{chunks-HGz06Soa.mjs.map → chunks-Da2-b-oA.mjs.map} +1 -1
  33. package/dist/cli/index.mjs +456 -90
  34. package/dist/cli/index.mjs.map +1 -1
  35. package/dist/client/cf-access.d.mts +1 -1
  36. package/dist/client/index.d.mts +1 -1
  37. package/dist/client/index.mjs +3 -3
  38. package/dist/client/index.mjs.map +1 -1
  39. package/dist/{config-BXwuX8Bx.mjs → config-CVssduLe.mjs} +1 -1
  40. package/dist/{config-BXwuX8Bx.mjs.map → config-CVssduLe.mjs.map} +1 -1
  41. package/dist/{content-BcQPYxdV.mjs → content-C7G4QXkK.mjs} +42 -14
  42. package/dist/content-C7G4QXkK.mjs.map +1 -0
  43. package/dist/db/index.d.mts +3 -3
  44. package/dist/db/index.mjs +2 -2
  45. package/dist/db/libsql.d.mts +1 -1
  46. package/dist/db/libsql.d.mts.map +1 -1
  47. package/dist/db/libsql.mjs +7 -2
  48. package/dist/db/libsql.mjs.map +1 -1
  49. package/dist/db/postgres.d.mts +1 -1
  50. package/dist/db/sqlite.d.mts +1 -1
  51. package/dist/db/sqlite.d.mts.map +1 -1
  52. package/dist/db/sqlite.mjs +8 -3
  53. package/dist/db/sqlite.mjs.map +1 -1
  54. package/dist/{db-errors-l1Qh2RPR.mjs → db-errors-B7P2pSCn.mjs} +1 -1
  55. package/dist/{db-errors-l1Qh2RPR.mjs.map → db-errors-B7P2pSCn.mjs.map} +1 -1
  56. package/dist/{default-DCVqE5ib.mjs → default-pHuz9WF6.mjs} +1 -1
  57. package/dist/{default-DCVqE5ib.mjs.map → default-pHuz9WF6.mjs.map} +1 -1
  58. package/dist/{dialect-helpers-DhTzaUxP.mjs → dialect-helpers-BKCvISIQ.mjs} +19 -2
  59. package/dist/dialect-helpers-BKCvISIQ.mjs.map +1 -0
  60. package/dist/{error-zG5T1UGA.mjs → error-DqnRMM5z.mjs} +1 -1
  61. package/dist/{error-zG5T1UGA.mjs.map → error-DqnRMM5z.mjs.map} +1 -1
  62. package/dist/{index-DIb-CzNx.d.mts → index-DjPMOfO0.d.mts} +162 -87
  63. package/dist/index-DjPMOfO0.d.mts.map +1 -0
  64. package/dist/index.d.mts +11 -11
  65. package/dist/index.mjs +27 -24
  66. package/dist/{load-CyEoextb.mjs → load-sXRuM7Us.mjs} +2 -2
  67. package/dist/{load-CyEoextb.mjs.map → load-sXRuM7Us.mjs.map} +1 -1
  68. package/dist/{loader-CndGj8kM.mjs → loader-Bx2_9-5e.mjs} +53 -8
  69. package/dist/loader-Bx2_9-5e.mjs.map +1 -0
  70. package/dist/{manifest-schema-DH9xhc6t.mjs → manifest-schema-CXAbd1vH.mjs} +33 -3
  71. package/dist/manifest-schema-CXAbd1vH.mjs.map +1 -0
  72. package/dist/media/index.d.mts +1 -1
  73. package/dist/media/index.mjs +1 -1
  74. package/dist/media/local-runtime.d.mts +7 -7
  75. package/dist/{mode-BnAOqItE.mjs → mode-YhqNVef_.mjs} +1 -1
  76. package/dist/{mode-BnAOqItE.mjs.map → mode-YhqNVef_.mjs.map} +1 -1
  77. package/dist/options-nPxWnrya.mjs +117 -0
  78. package/dist/options-nPxWnrya.mjs.map +1 -0
  79. package/dist/page/index.d.mts +2 -2
  80. package/dist/{patterns-CrCYkMBb.mjs → patterns-DsUZ4uxI.mjs} +1 -1
  81. package/dist/{patterns-CrCYkMBb.mjs.map → patterns-DsUZ4uxI.mjs.map} +1 -1
  82. package/dist/{placeholder-D29tWZ7o.d.mts → placeholder-CDPtkelt.d.mts} +1 -1
  83. package/dist/{placeholder-D29tWZ7o.d.mts.map → placeholder-CDPtkelt.d.mts.map} +1 -1
  84. package/dist/{placeholder-C-fk5hYI.mjs → placeholder-Ci0RLeCk.mjs} +1 -1
  85. package/dist/{placeholder-C-fk5hYI.mjs.map → placeholder-Ci0RLeCk.mjs.map} +1 -1
  86. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  87. package/dist/plugins/adapt-sandbox-entry.d.mts.map +1 -1
  88. package/dist/plugins/adapt-sandbox-entry.mjs +6 -5
  89. package/dist/plugins/adapt-sandbox-entry.mjs.map +1 -1
  90. package/dist/public-url-B1AxbbbQ.mjs +51 -0
  91. package/dist/public-url-B1AxbbbQ.mjs.map +1 -0
  92. package/dist/{query-fqEdLFms.mjs → query-Bo-msrmu.mjs} +114 -16
  93. package/dist/query-Bo-msrmu.mjs.map +1 -0
  94. package/dist/{redirect-D_pshWdf.mjs → redirect-C5H7VGIX.mjs} +11 -6
  95. package/dist/redirect-C5H7VGIX.mjs.map +1 -0
  96. package/dist/{registry-C3Mr0ODu.mjs → registry-Beb7wxFc.mjs} +39 -5
  97. package/dist/registry-Beb7wxFc.mjs.map +1 -0
  98. package/dist/{request-cache-Ci7f5pBb.mjs → request-cache-C-tIpYIw.mjs} +1 -1
  99. package/dist/{request-cache-Ci7f5pBb.mjs.map → request-cache-C-tIpYIw.mjs.map} +1 -1
  100. package/dist/runner-Clwe4Mme.d.mts +44 -0
  101. package/dist/runner-Clwe4Mme.d.mts.map +1 -0
  102. package/dist/{runner-tQ7BJ4T7.mjs → runner-DMnlIkh4.mjs} +616 -191
  103. package/dist/runner-DMnlIkh4.mjs.map +1 -0
  104. package/dist/runtime.d.mts +6 -6
  105. package/dist/runtime.mjs +2 -2
  106. package/dist/{search-BoZYFuUk.mjs → search-DkN-BqsS.mjs} +270 -152
  107. package/dist/search-DkN-BqsS.mjs.map +1 -0
  108. package/dist/secrets-CZ8rxLX3.mjs +314 -0
  109. package/dist/secrets-CZ8rxLX3.mjs.map +1 -0
  110. package/dist/seed/index.d.mts +2 -2
  111. package/dist/seed/index.mjs +13 -11
  112. package/dist/seo/index.d.mts +1 -1
  113. package/dist/storage/local.d.mts +1 -1
  114. package/dist/storage/local.mjs +1 -1
  115. package/dist/storage/s3.d.mts +1 -1
  116. package/dist/storage/s3.mjs +1 -1
  117. package/dist/taxonomies-CTtewrSQ.mjs +407 -0
  118. package/dist/taxonomies-CTtewrSQ.mjs.map +1 -0
  119. package/dist/taxonomy-DSxx2K2L.mjs +218 -0
  120. package/dist/taxonomy-DSxx2K2L.mjs.map +1 -0
  121. package/dist/{tokens-D9vnZqYS.mjs → tokens-CyRDPVW2.mjs} +1 -1
  122. package/dist/{tokens-D9vnZqYS.mjs.map → tokens-CyRDPVW2.mjs.map} +1 -1
  123. package/dist/{transaction-Cn2rjY78.mjs → transaction-D44LBXvU.mjs} +1 -1
  124. package/dist/{transaction-Cn2rjY78.mjs.map → transaction-D44LBXvU.mjs.map} +1 -1
  125. package/dist/{transport-CUnEL3Vs.d.mts → transport-DX_5rpsq.d.mts} +1 -1
  126. package/dist/{transport-CUnEL3Vs.d.mts.map → transport-DX_5rpsq.d.mts.map} +1 -1
  127. package/dist/{transport-C9ugt2Nr.mjs → transport-xpzIjCIB.mjs} +6 -5
  128. package/dist/{transport-C9ugt2Nr.mjs.map → transport-xpzIjCIB.mjs.map} +1 -1
  129. package/dist/{types-BrA0xf5I.d.mts → types-B_CXXnzh.d.mts} +1 -1
  130. package/dist/{types-BrA0xf5I.d.mts.map → types-B_CXXnzh.d.mts.map} +1 -1
  131. package/dist/{types-DIMwPFub.d.mts → types-C-aFbqmA.d.mts} +1 -1
  132. package/dist/{types-DIMwPFub.d.mts.map → types-C-aFbqmA.d.mts.map} +1 -1
  133. package/dist/types-CoO6mpV3.mjs +68 -0
  134. package/dist/types-CoO6mpV3.mjs.map +1 -0
  135. package/dist/{types-i36XcA_X.d.mts → types-D19uBYWn.d.mts} +83 -7
  136. package/dist/types-D19uBYWn.d.mts.map +1 -0
  137. package/dist/{types-BmPPSUEx.d.mts → types-Dl1fgFjn.d.mts} +24 -2
  138. package/dist/{types-BmPPSUEx.d.mts.map → types-Dl1fgFjn.d.mts.map} +1 -1
  139. package/dist/{types-CS8FIX7L.d.mts → types-Dtx1mSMX.d.mts} +9 -1
  140. package/dist/types-Dtx1mSMX.d.mts.map +1 -0
  141. package/dist/{types-Bm1dn-q3.mjs → types-Eg829jj9.mjs} +1 -1
  142. package/dist/{types-Bm1dn-q3.mjs.map → types-Eg829jj9.mjs.map} +1 -1
  143. package/dist/{types-CgqmmMJB.mjs → types-K-EkEQCI.mjs} +1 -1
  144. package/dist/{types-CgqmmMJB.mjs.map → types-K-EkEQCI.mjs.map} +1 -1
  145. package/dist/{validate-CxVsLehf.mjs → validate-CBIbxM3L.mjs} +14 -10
  146. package/dist/validate-CBIbxM3L.mjs.map +1 -0
  147. package/dist/{validate-DHxmpFJt.d.mts → validate-DHGwADqO.d.mts} +18 -5
  148. package/dist/validate-DHGwADqO.d.mts.map +1 -0
  149. package/dist/{validation-C-ZpN2GI.mjs → validation-B1NYiEos.mjs} +6 -6
  150. package/dist/{validation-C-ZpN2GI.mjs.map → validation-B1NYiEos.mjs.map} +1 -1
  151. package/dist/version-CMD42IRC.mjs +7 -0
  152. package/dist/{version-Bbq8TCrz.mjs.map → version-CMD42IRC.mjs.map} +1 -1
  153. package/dist/{zod-generator-CpwccCIv.mjs → zod-generator-BNJDQBSZ.mjs} +11 -6
  154. package/dist/{zod-generator-CpwccCIv.mjs.map → zod-generator-BNJDQBSZ.mjs.map} +1 -1
  155. package/locals.d.ts +1 -6
  156. package/package.json +9 -8
  157. package/src/api/handlers/comments.ts +6 -4
  158. package/src/api/handlers/content.ts +40 -1
  159. package/src/api/handlers/dashboard.ts +29 -36
  160. package/src/api/handlers/device-flow.ts +5 -0
  161. package/src/api/handlers/marketplace.ts +11 -4
  162. package/src/api/handlers/menus.ts +256 -75
  163. package/src/api/handlers/oauth-authorization.ts +72 -33
  164. package/src/api/handlers/revision.ts +23 -14
  165. package/src/api/handlers/taxonomies.ts +273 -100
  166. package/src/api/public-url.ts +48 -2
  167. package/src/api/schemas/comments.ts +2 -2
  168. package/src/api/schemas/common.ts +7 -0
  169. package/src/api/schemas/content.ts +17 -0
  170. package/src/api/schemas/menus.ts +23 -0
  171. package/src/api/schemas/sections.ts +3 -3
  172. package/src/api/schemas/taxonomies.ts +39 -0
  173. package/src/api/schemas/users.ts +1 -1
  174. package/src/api/types.ts +5 -1
  175. package/src/astro/integration/index.ts +17 -0
  176. package/src/astro/integration/routes.ts +10 -0
  177. package/src/astro/integration/runtime.ts +30 -0
  178. package/src/astro/integration/virtual-modules.ts +32 -2
  179. package/src/astro/integration/vite-config.ts +6 -1
  180. package/src/astro/middleware/auth.ts +13 -6
  181. package/src/astro/middleware/redirect.ts +29 -16
  182. package/src/astro/middleware/request-context.ts +15 -5
  183. package/src/astro/middleware.ts +23 -9
  184. package/src/astro/routes/api/auth/invite/complete.ts +6 -1
  185. package/src/astro/routes/api/auth/passkey/register/verify.ts +6 -1
  186. package/src/astro/routes/api/auth/passkey/verify.ts +6 -1
  187. package/src/astro/routes/api/auth/signup/complete.ts +6 -1
  188. package/src/astro/routes/api/comments/[collection]/[contentId]/index.ts +2 -2
  189. package/src/astro/routes/api/content/[collection]/[id]/discard-draft.ts +4 -2
  190. package/src/astro/routes/api/content/[collection]/[id]/permanent.ts +1 -1
  191. package/src/astro/routes/api/content/[collection]/[id]/preview-url.ts +34 -12
  192. package/src/astro/routes/api/content/[collection]/[id]/publish.ts +32 -2
  193. package/src/astro/routes/api/content/[collection]/[id]/restore.ts +4 -2
  194. package/src/astro/routes/api/content/[collection]/[id]/revisions.ts +3 -2
  195. package/src/astro/routes/api/content/[collection]/[id]/terms/[taxonomy].ts +8 -4
  196. package/src/astro/routes/api/content/[collection]/[id].ts +12 -0
  197. package/src/astro/routes/api/import/wordpress/execute.ts +3 -1
  198. package/src/astro/routes/api/import/wordpress/prepare.ts +7 -8
  199. package/src/astro/routes/api/import/wordpress/rewrite-url-helpers.ts +196 -0
  200. package/src/astro/routes/api/import/wordpress/rewrite-urls.ts +9 -177
  201. package/src/astro/routes/api/import/wordpress-plugin/execute.ts +3 -1
  202. package/src/astro/routes/api/manifest.ts +62 -45
  203. package/src/astro/routes/api/media/[id]/confirm.ts +10 -1
  204. package/src/astro/routes/api/media/providers/[providerId]/index.ts +12 -3
  205. package/src/astro/routes/api/menus/[name]/items.ts +16 -6
  206. package/src/astro/routes/api/menus/[name]/reorder.ts +8 -3
  207. package/src/astro/routes/api/menus/[name]/translations.ts +82 -0
  208. package/src/astro/routes/api/menus/[name].ts +19 -10
  209. package/src/astro/routes/api/menus/index.ts +9 -6
  210. package/src/astro/routes/api/openapi.json.ts +27 -10
  211. package/src/astro/routes/api/redirects/404s/index.ts +10 -4
  212. package/src/astro/routes/api/redirects/404s/summary.ts +4 -2
  213. package/src/astro/routes/api/redirects/[id].ts +10 -4
  214. package/src/astro/routes/api/redirects/index.ts +7 -3
  215. package/src/astro/routes/api/revisions/[revisionId]/index.ts +1 -1
  216. package/src/astro/routes/api/schema/collections/[slug]/fields/[fieldSlug].ts +0 -2
  217. package/src/astro/routes/api/schema/collections/[slug]/fields/index.ts +0 -1
  218. package/src/astro/routes/api/schema/collections/[slug]/fields/reorder.ts +0 -1
  219. package/src/astro/routes/api/schema/collections/[slug]/index.ts +2 -2
  220. package/src/astro/routes/api/schema/collections/index.ts +1 -1
  221. package/src/astro/routes/api/search/index.ts +10 -2
  222. package/src/astro/routes/api/sections/[slug].ts +10 -4
  223. package/src/astro/routes/api/sections/index.ts +7 -3
  224. package/src/astro/routes/api/setup/admin-verify.ts +6 -1
  225. package/src/astro/routes/api/snapshot.ts +44 -18
  226. package/src/astro/routes/api/taxonomies/[name]/terms/[slug]/translations.ts +89 -0
  227. package/src/astro/routes/api/taxonomies/[name]/terms/[slug].ts +22 -22
  228. package/src/astro/routes/api/taxonomies/[name]/terms/index.ts +11 -14
  229. package/src/astro/routes/api/taxonomies/index.ts +9 -7
  230. package/src/astro/routes/api/themes/preview.ts +11 -5
  231. package/src/astro/types.ts +23 -3
  232. package/src/auth/allowed-origins.ts +168 -0
  233. package/src/auth/passkey-config.ts +35 -13
  234. package/src/bylines/index.ts +37 -88
  235. package/src/cli/commands/auth.ts +28 -6
  236. package/src/cli/commands/bundle-utils.ts +11 -2
  237. package/src/cli/commands/bundle.ts +28 -8
  238. package/src/cli/commands/content.ts +13 -0
  239. package/src/cli/commands/export-seed.ts +82 -21
  240. package/src/cli/commands/login.ts +8 -1
  241. package/src/cli/commands/plugin-init.ts +216 -90
  242. package/src/cli/commands/publish.ts +24 -0
  243. package/src/cli/commands/secrets.ts +183 -0
  244. package/src/cli/credentials.ts +1 -1
  245. package/src/cli/index.ts +5 -1
  246. package/src/client/index.ts +4 -4
  247. package/src/client/transport.ts +17 -7
  248. package/src/components/Break.astro +2 -2
  249. package/src/components/EmDashHead.astro +18 -13
  250. package/src/components/Embed.astro +1 -1
  251. package/src/components/Gallery.astro +1 -1
  252. package/src/components/Image.astro +1 -1
  253. package/src/components/InlinePortableTextEditor.tsx +104 -18
  254. package/src/config/secrets.ts +528 -0
  255. package/src/database/dialect-helpers.ts +50 -0
  256. package/src/database/migrations/034_published_at_index.ts +1 -1
  257. package/src/database/migrations/035_bounded_404_log.ts +56 -39
  258. package/src/database/migrations/036_i18n_menus_and_taxonomies.ts +477 -0
  259. package/src/database/migrations/runner.ts +158 -23
  260. package/src/database/repositories/content.ts +47 -12
  261. package/src/database/repositories/redirect.ts +14 -3
  262. package/src/database/repositories/taxonomy.ts +212 -82
  263. package/src/database/types.ts +10 -2
  264. package/src/db/libsql.ts +1 -3
  265. package/src/db/sqlite.ts +2 -5
  266. package/src/emdash-runtime.ts +84 -159
  267. package/src/i18n/resolve.ts +37 -0
  268. package/src/index.ts +9 -0
  269. package/src/loader.ts +73 -3
  270. package/src/mcp/server.ts +180 -54
  271. package/src/menus/index.ts +143 -124
  272. package/src/menus/types.ts +15 -1
  273. package/src/page/site-identity.ts +58 -0
  274. package/src/plugins/adapt-sandbox-entry.ts +22 -10
  275. package/src/plugins/context.ts +13 -10
  276. package/src/plugins/define-plugin.ts +40 -12
  277. package/src/plugins/hooks.ts +23 -19
  278. package/src/plugins/index.ts +9 -0
  279. package/src/plugins/manifest-schema.ts +37 -2
  280. package/src/plugins/types.ts +151 -11
  281. package/src/preview/urls.ts +23 -3
  282. package/src/query.ts +148 -5
  283. package/src/redirects/cache.ts +38 -18
  284. package/src/schema/registry.ts +56 -0
  285. package/src/schema/zod-generator.ts +39 -7
  286. package/src/seed/apply.ts +142 -54
  287. package/src/seed/types.ts +14 -1
  288. package/src/seed/validate.ts +27 -13
  289. package/src/settings/index.ts +80 -6
  290. package/src/settings/types.ts +23 -1
  291. package/src/taxonomies/index.ts +237 -210
  292. package/src/taxonomies/types.ts +10 -0
  293. package/dist/apply-x0eMK1lX.mjs.map +0 -1
  294. package/dist/bylines-CRNsVG88.mjs +0 -157
  295. package/dist/bylines-CRNsVG88.mjs.map +0 -1
  296. package/dist/cache-BkKBuIvS.mjs +0 -56
  297. package/dist/cache-BkKBuIvS.mjs.map +0 -1
  298. package/dist/chunk-ClPoSABd.mjs +0 -21
  299. package/dist/content-BcQPYxdV.mjs.map +0 -1
  300. package/dist/dialect-helpers-DhTzaUxP.mjs.map +0 -1
  301. package/dist/index-DIb-CzNx.d.mts.map +0 -1
  302. package/dist/loader-CndGj8kM.mjs.map +0 -1
  303. package/dist/manifest-schema-DH9xhc6t.mjs.map +0 -1
  304. package/dist/query-fqEdLFms.mjs.map +0 -1
  305. package/dist/redirect-D_pshWdf.mjs.map +0 -1
  306. package/dist/registry-C3Mr0ODu.mjs.map +0 -1
  307. package/dist/runner-OURCaApa.d.mts +0 -34
  308. package/dist/runner-OURCaApa.d.mts.map +0 -1
  309. package/dist/runner-tQ7BJ4T7.mjs.map +0 -1
  310. package/dist/search-BoZYFuUk.mjs.map +0 -1
  311. package/dist/taxonomies-B4IAshV8.mjs +0 -308
  312. package/dist/taxonomies-B4IAshV8.mjs.map +0 -1
  313. package/dist/types-CS8FIX7L.d.mts.map +0 -1
  314. package/dist/types-i36XcA_X.d.mts.map +0 -1
  315. package/dist/validate-CxVsLehf.mjs.map +0 -1
  316. package/dist/validate-DHxmpFJt.d.mts.map +0 -1
  317. package/dist/version-Bbq8TCrz.mjs +0 -7
@@ -1,8 +1,13 @@
1
1
  /**
2
- * Redirect pattern cache.
2
+ * Redirect rule cache.
3
3
  *
4
- * Module-level cache for compiled redirect pattern rules. The middleware
5
- * populates this on first request; route handlers invalidate it on writes.
4
+ * Module-level cache for enabled redirect rules. The middleware populates this
5
+ * on first request; route handlers invalidate it on writes.
6
+ *
7
+ * Both exact-match and pattern rules are loaded from one query and cached
8
+ * together: exact rules indexed by source path in a Map, pattern rules
9
+ * pre-compiled into an array. A single warm request issues zero database
10
+ * queries; a cold isolate issues one.
6
11
  *
7
12
  * This module deliberately has NO Astro imports so it can be safely imported
8
13
  * from handlers, seed, CLI, and tests without dragging in `astro:middleware`.
@@ -17,36 +22,51 @@ export interface CachedRedirectRule {
17
22
  compiled: CompiledPattern;
18
23
  }
19
24
 
25
+ export interface CachedRedirects {
26
+ /** Exact-match rules indexed by source path (`source` -> `Redirect`). */
27
+ exact: Map<string, Redirect>;
28
+ /** Pattern rules with their compiled regexes, preserving insertion order. */
29
+ patterns: CachedRedirectRule[];
30
+ }
31
+
20
32
  /**
21
- * Cached pattern rules with compiled regexes.
22
- * null = not yet populated, array = cached.
33
+ * Cached enabled redirects.
34
+ * null = not yet populated, object = cached.
23
35
  */
24
- let cachedPatternRules: CachedRedirectRule[] | null = null;
36
+ let cachedRedirects: CachedRedirects | null = null;
25
37
 
26
38
  /**
27
- * Invalidate the cached redirect pattern rules.
39
+ * Invalidate the cached redirects (both exact and pattern).
28
40
  * Call when redirects are created, updated, or deleted.
29
41
  */
30
42
  export function invalidateRedirectCache(): void {
31
- cachedPatternRules = null;
43
+ cachedRedirects = null;
32
44
  }
33
45
 
34
46
  /**
35
- * Get the cached compiled pattern rules, or null if the cache is cold.
47
+ * Get the cached redirects, or null if the cache is cold.
36
48
  */
37
- export function getCachedPatternRules(): CachedRedirectRule[] | null {
38
- return cachedPatternRules;
49
+ export function getCachedRedirects(): CachedRedirects | null {
50
+ return cachedRedirects;
39
51
  }
40
52
 
41
53
  /**
42
- * Populate the pattern rules cache from a list of enabled pattern redirects.
54
+ * Populate the cache from a list of enabled redirects (both exact and
55
+ * pattern). The caller is responsible for passing only enabled rows — the
56
+ * cache stores them as-is.
43
57
  */
44
- export function setCachedPatternRules(redirects: Redirect[]): CachedRedirectRule[] {
45
- cachedPatternRules = redirects.map((r) => ({
46
- redirect: r,
47
- compiled: compilePattern(r.source),
48
- }));
49
- return cachedPatternRules;
58
+ export function setCachedRedirects(redirects: Redirect[]): CachedRedirects {
59
+ const exact = new Map<string, Redirect>();
60
+ const patterns: CachedRedirectRule[] = [];
61
+ for (const r of redirects) {
62
+ if (r.isPattern) {
63
+ patterns.push({ redirect: r, compiled: compilePattern(r.source) });
64
+ } else {
65
+ exact.set(r.source, r);
66
+ }
67
+ }
68
+ cachedRedirects = { exact, patterns };
69
+ return cachedRedirects;
50
70
  }
51
71
 
52
72
  /**
@@ -8,6 +8,7 @@ import { withTransaction } from "../database/transaction.js";
8
8
  import type { CollectionTable, Database, FieldTable } from "../database/types.js";
9
9
  import { validateIdentifier } from "../database/validate.js";
10
10
  import { FTSManager } from "../search/fts-manager.js";
11
+ import { chunks, SQL_BATCH_SIZE } from "../utils/chunks.js";
11
12
  import {
12
13
  type Collection,
13
14
  type CollectionSource,
@@ -143,6 +144,61 @@ export class SchemaRegistry {
143
144
  return { ...collection, fields };
144
145
  }
145
146
 
147
+ /**
148
+ * List every collection together with its fields in O(1) query shapes
149
+ * — one for collections, then one batched query for the fields of every
150
+ * returned collection — instead of the N+1 pattern of `listCollections`
151
+ * + per-collection `listFields`. The fields query is chunked at
152
+ * `SQL_BATCH_SIZE` to stay under D1's bound-parameter limit, so on
153
+ * sites with more than `SQL_BATCH_SIZE` collections the field fetch
154
+ * becomes `ceil(collectionCount / SQL_BATCH_SIZE)` queries — still
155
+ * a constant factor, not N+1. Typical sites have well under
156
+ * `SQL_BATCH_SIZE` collections, so this is two queries in practice.
157
+ *
158
+ * Used by the manifest build, which previously paid N+1 round-trips on
159
+ * every admin request. Each round-trip costs ~80–150ms against the D1
160
+ * primary on a busy link, so a 10-collection site spent ~1 s rebuilding
161
+ * a manifest that is now built fresh per admin request (no cache).
162
+ */
163
+ async listCollectionsWithFields(): Promise<CollectionWithFields[]> {
164
+ const collectionRows = await this.db
165
+ .selectFrom("_emdash_collections")
166
+ .selectAll()
167
+ .orderBy("slug", "asc")
168
+ .execute();
169
+
170
+ if (collectionRows.length === 0) return [];
171
+
172
+ const fieldsByCollection = new Map<string, Field[]>();
173
+ // Chunk to stay under D1's bound-parameter limit. Typical sites have
174
+ // well under SQL_BATCH_SIZE collections, so this is a single query
175
+ // in practice; on larger sites it becomes a small constant number
176
+ // of queries, never N+1.
177
+ for (const idChunk of chunks(
178
+ collectionRows.map((c) => c.id),
179
+ SQL_BATCH_SIZE,
180
+ )) {
181
+ const fieldRows = await this.db
182
+ .selectFrom("_emdash_fields")
183
+ .where("collection_id", "in", idChunk)
184
+ .selectAll()
185
+ .orderBy("collection_id", "asc")
186
+ .orderBy("sort_order", "asc")
187
+ .orderBy("created_at", "asc")
188
+ .execute();
189
+ for (const row of fieldRows) {
190
+ const list = fieldsByCollection.get(row.collection_id) ?? [];
191
+ list.push(this.mapFieldRow(row));
192
+ fieldsByCollection.set(row.collection_id, list);
193
+ }
194
+ }
195
+
196
+ return collectionRows.map((c) => ({
197
+ ...this.mapCollectionRow(c),
198
+ fields: fieldsByCollection.get(c.id) ?? [],
199
+ }));
200
+ }
201
+
146
202
  /**
147
203
  * Create a new collection
148
204
  */
@@ -35,9 +35,16 @@ export function generateFieldSchema(field: Field): ZodTypeAny {
35
35
  schema = applyValidation(schema, field);
36
36
  }
37
37
 
38
- // Apply required/optional
38
+ // Apply required/optional. Non-required fields use `.nullish()` rather
39
+ // than `.optional()` because the underlying SQLite columns are nullable
40
+ // (see `SchemaRegistry.addFieldColumn` -- non-required fields are added
41
+ // without `NOT NULL`). The admin re-sends what it loaded from the
42
+ // server on autosave, so any field that's actually `null` in the DB
43
+ // must round-trip cleanly through the validator. `.optional()` only
44
+ // accepts `undefined`; `.nullish()` accepts both `undefined` and
45
+ // `null`. (#867 — autosave failures on seeded entries.)
39
46
  if (!field.required) {
40
- schema = schema.optional();
47
+ schema = schema.nullish();
41
48
  }
42
49
 
43
50
  // Apply default value
@@ -68,7 +75,15 @@ function getBaseSchema(type: FieldType, field: Field): ZodTypeAny {
68
75
  return z.number().int();
69
76
 
70
77
  case "boolean":
71
- return z.boolean();
78
+ // Boolean fields map to `INTEGER` columns (`FIELD_TYPE_TO_COLUMN`
79
+ // in `schema/types.ts`) and `serializeValue` in
80
+ // `database/repositories/content.ts` writes booleans as 0/1.
81
+ // `deserializeValue` never converts them back, so reads return
82
+ // numbers. Coerce the stored 0/1 shape here so a GET → POST
83
+ // round-trip on a boolean field passes validation. Other inputs
84
+ // (strings, other numbers) fall through to `z.boolean()` and
85
+ // produce its standard rejection.
86
+ return z.preprocess((v) => (v === 0 || v === 1 ? Boolean(v) : v), z.boolean());
72
87
 
73
88
  case "datetime":
74
89
  return z.string().datetime().or(z.string().date());
@@ -92,12 +107,19 @@ function getBaseSchema(type: FieldType, field: Field): ZodTypeAny {
92
107
  }
93
108
 
94
109
  case "portableText":
95
- // Portable Text is an array of blocks
110
+ // Portable Text is an array of blocks. We require `_type` because
111
+ // renderers dispatch on it, but `_key` is intentionally optional:
112
+ // it's a UI-layer concern that the editor regenerates on every
113
+ // change (see `PortableTextEditor`), and the rest of this schema
114
+ // uses `.passthrough()` for everything below the top level. Making
115
+ // `_key` strictly required here was an accidentally tight invariant
116
+ // that rejected any seed/import data not authored against the
117
+ // editor (#867 — autosave failures on seeded template content).
96
118
  return z.array(
97
119
  z
98
120
  .object({
99
121
  _type: z.string(),
100
- _key: z.string(),
122
+ _key: z.string().optional(),
101
123
  })
102
124
  .passthrough(),
103
125
  );
@@ -109,6 +131,12 @@ function getBaseSchema(type: FieldType, field: Field): ZodTypeAny {
109
131
  alt: z.string().optional(),
110
132
  width: z.number().optional(),
111
133
  height: z.number().optional(),
134
+ /** Provider ID (e.g. "local", "cloudflare-images") */
135
+ provider: z.string().optional(),
136
+ /** Admin-side preview URL for external providers (not persisted by plugins) */
137
+ previewUrl: z.string().optional(),
138
+ /** Provider-specific metadata; for local media this carries storageKey */
139
+ meta: z.record(z.string(), z.unknown()).optional(),
112
140
  });
113
141
 
114
142
  case "file":
@@ -118,6 +146,10 @@ function getBaseSchema(type: FieldType, field: Field): ZodTypeAny {
118
146
  filename: z.string().optional(),
119
147
  mimeType: z.string().optional(),
120
148
  size: z.number().optional(),
149
+ /** Provider ID (e.g. "local", "s3") */
150
+ provider: z.string().optional(),
151
+ /** Provider-specific metadata; for local media this carries storageKey */
152
+ meta: z.record(z.string(), z.unknown()).optional(),
121
153
  });
122
154
 
123
155
  case "reference":
@@ -362,10 +394,10 @@ function fieldTypeToTypeScript(field: Field): string {
362
394
  return "PortableTextBlock[]";
363
395
 
364
396
  case "image":
365
- return "{ id: string; src?: string; alt?: string; width?: number; height?: number }";
397
+ return "{ id: string; src?: string; alt?: string; width?: number; height?: number; provider?: string; previewUrl?: string; meta?: Record<string, unknown> }";
366
398
 
367
399
  case "file":
368
- return "{ id: string; src?: string; filename?: string; mimeType?: string; size?: number }";
400
+ return "{ id: string; src?: string; filename?: string; mimeType?: string; size?: number; provider?: string; meta?: Record<string, unknown> }";
369
401
 
370
402
  case "reference":
371
403
  // Could be enhanced to include the referenced collection type
package/src/seed/apply.ts CHANGED
@@ -19,6 +19,7 @@ import { TaxonomyRepository } from "../database/repositories/taxonomy.js";
19
19
  import { withTransaction } from "../database/transaction.js";
20
20
  import type { Database } from "../database/types.js";
21
21
  import type { MediaValue } from "../fields/types.js";
22
+ import { getI18nConfig } from "../i18n/config.js";
22
23
  import { ssrfSafeFetch, validateExternalUrl } from "../import/ssrf.js";
23
24
  import { SchemaRegistry } from "../schema/registry.js";
24
25
  import { FTSManager } from "../search/fts-manager.js";
@@ -219,17 +220,30 @@ export async function applySeed(
219
220
 
220
221
  // 4-5. Taxonomies
221
222
  if (seed.taxonomies) {
223
+ // seed-local id -> resolved info, used to wire `translationOf` refs.
224
+ const defSeedIdMap = new Map<string, { id: string; translationGroup: string }>();
225
+ const termSeedIdMap = new Map<string, string>();
226
+ const fallbackLocale = getI18nConfig()?.defaultLocale ?? "en";
227
+
222
228
  for (const taxonomy of seed.taxonomies) {
223
- // Check if taxonomy definition exists
229
+ const defLocale = taxonomy.locale ?? fallbackLocale;
230
+
231
+ // (name, locale) is the UNIQUE key after migration 036.
224
232
  const existingDef = await db
225
233
  .selectFrom("_emdash_taxonomy_defs")
226
234
  .selectAll()
227
235
  .where("name", "=", taxonomy.name)
236
+ .where("locale", "=", defLocale)
228
237
  .executeTakeFirst();
229
238
 
239
+ let defId: string;
240
+ let defTranslationGroup: string;
241
+
230
242
  if (existingDef) {
243
+ defId = existingDef.id;
244
+ defTranslationGroup = existingDef.translation_group ?? existingDef.id;
231
245
  if (onConflict === "error") {
232
- throw new Error(`Conflict: taxonomy "${taxonomy.name}" already exists`);
246
+ throw new Error(`Conflict: taxonomy "${taxonomy.name}" (${defLocale}) already exists`);
233
247
  }
234
248
  if (onConflict === "update") {
235
249
  await db
@@ -242,40 +256,59 @@ export async function applySeed(
242
256
  })
243
257
  .where("id", "=", existingDef.id)
244
258
  .execute();
245
- // Taxonomy defs don't track an "updated" counter -- just the definition is updated
246
259
  }
247
- // skip: do nothing for the definition
248
260
  } else {
249
- // Create taxonomy definition
261
+ defId = ulid();
262
+ defTranslationGroup = defId;
263
+ if (taxonomy.translationOf) {
264
+ const source = defSeedIdMap.get(taxonomy.translationOf);
265
+ if (source) defTranslationGroup = source.translationGroup;
266
+ else
267
+ console.warn(
268
+ `taxonomy "${taxonomy.name}" (${defLocale}): translationOf "${taxonomy.translationOf}" not found yet; minting a fresh group.`,
269
+ );
270
+ }
250
271
  await db
251
272
  .insertInto("_emdash_taxonomy_defs")
252
273
  .values({
253
- id: ulid(),
274
+ id: defId,
254
275
  name: taxonomy.name,
255
276
  label: taxonomy.label,
256
277
  label_singular: taxonomy.labelSingular ?? null,
257
278
  hierarchical: taxonomy.hierarchical ? 1 : 0,
258
279
  collections: JSON.stringify(taxonomy.collections),
280
+ locale: defLocale,
281
+ translation_group: defTranslationGroup,
259
282
  })
260
283
  .execute();
261
284
  result.taxonomies.created++;
262
285
  }
263
286
 
287
+ if (taxonomy.id)
288
+ defSeedIdMap.set(taxonomy.id, { id: defId, translationGroup: defTranslationGroup });
289
+
264
290
  // Create terms (if provided)
265
291
  if (taxonomy.terms && taxonomy.terms.length > 0) {
266
292
  const termRepo = new TaxonomyRepository(db);
267
293
 
268
- // For hierarchical taxonomies, we need to create parents before children
269
294
  if (taxonomy.hierarchical) {
270
- await applyHierarchicalTerms(termRepo, taxonomy.name, taxonomy.terms, result, onConflict);
295
+ await applyHierarchicalTerms(
296
+ termRepo,
297
+ taxonomy.name,
298
+ defLocale,
299
+ taxonomy.terms,
300
+ termSeedIdMap,
301
+ result,
302
+ onConflict,
303
+ );
271
304
  } else {
272
- // Flat taxonomy - create all terms
273
305
  for (const term of taxonomy.terms) {
274
- const existing = await termRepo.findBySlug(taxonomy.name, term.slug);
306
+ const termLocale = term.locale ?? defLocale;
307
+ const existing = await termRepo.findBySlug(taxonomy.name, term.slug, termLocale);
275
308
  if (existing) {
276
309
  if (onConflict === "error") {
277
310
  throw new Error(
278
- `Conflict: taxonomy term "${term.slug}" in "${taxonomy.name}" already exists`,
311
+ `Conflict: taxonomy term "${term.slug}" in "${taxonomy.name}" (${termLocale}) already exists`,
279
312
  );
280
313
  }
281
314
  if (onConflict === "update") {
@@ -285,14 +318,20 @@ export async function applySeed(
285
318
  });
286
319
  result.taxonomies.terms++;
287
320
  }
288
- // skip: do nothing
321
+ if (term.id) termSeedIdMap.set(term.id, existing.id);
289
322
  } else {
290
- await termRepo.create({
323
+ const translationOf = term.translationOf
324
+ ? termSeedIdMap.get(term.translationOf)
325
+ : undefined;
326
+ const created = await termRepo.create({
291
327
  name: taxonomy.name,
292
328
  slug: term.slug,
293
329
  label: term.label,
294
330
  data: term.description ? { description: term.description } : undefined,
331
+ locale: termLocale,
332
+ translationOf,
295
333
  });
334
+ if (term.id) termSeedIdMap.set(term.id, created.id);
296
335
  result.taxonomies.terms++;
297
336
  }
298
337
  }
@@ -471,23 +510,39 @@ export async function applySeed(
471
510
 
472
511
  // 8. Menus and Menu Items (after content so refs can resolve)
473
512
  if (seed.menus) {
513
+ // seed-local id -> resolved info, used to wire `translationOf` refs.
514
+ const menuSeedIdMap = new Map<string, { id: string; translationGroup: string }>();
515
+ const fallbackLocale = getI18nConfig()?.defaultLocale ?? "en";
516
+
474
517
  for (const menu of seed.menus) {
475
- // Check if menu exists
476
- const existingMenu = await db
518
+ const locale = menu.locale ?? fallbackLocale;
519
+ let lookup = db
477
520
  .selectFrom("_emdash_menus")
478
521
  .selectAll()
479
522
  .where("name", "=", menu.name)
480
- .executeTakeFirst();
523
+ .where("locale", "=", locale);
524
+ const existingMenu = await lookup.executeTakeFirst();
481
525
 
482
526
  let menuId: string;
527
+ let translationGroup: string;
483
528
 
484
529
  if (existingMenu) {
485
530
  menuId = existingMenu.id;
531
+ translationGroup = existingMenu.translation_group ?? existingMenu.id;
486
532
  // Clear existing items (menus are recreated)
487
533
  await db.deleteFrom("_emdash_menu_items").where("menu_id", "=", menuId).execute();
488
534
  } else {
489
- // Create menu
490
535
  menuId = ulid();
536
+ // Resolve translationOf to the source menu's translation_group.
537
+ translationGroup = menuId;
538
+ if (menu.translationOf) {
539
+ const source = menuSeedIdMap.get(menu.translationOf);
540
+ if (source) translationGroup = source.translationGroup;
541
+ else
542
+ console.warn(
543
+ `menu "${menu.name}" (${locale}): translationOf "${menu.translationOf}" not found yet; minting a fresh group.`,
544
+ );
545
+ }
491
546
  await db
492
547
  .insertInto("_emdash_menus")
493
548
  .values({
@@ -496,15 +551,20 @@ export async function applySeed(
496
551
  label: menu.label,
497
552
  created_at: new Date().toISOString(),
498
553
  updated_at: new Date().toISOString(),
554
+ locale,
555
+ translation_group: translationGroup,
499
556
  })
500
557
  .execute();
501
558
  result.menus.created++;
502
559
  }
503
560
 
561
+ if (menu.id) menuSeedIdMap.set(menu.id, { id: menuId, translationGroup });
562
+
504
563
  // Create menu items
505
564
  const itemCount = await applyMenuItems(
506
565
  db,
507
566
  menuId,
567
+ locale,
508
568
  menu.items,
509
569
  null, // parent_id
510
570
  0, // sort_order
@@ -692,64 +752,75 @@ export async function applySeed(
692
752
  async function applyHierarchicalTerms(
693
753
  termRepo: TaxonomyRepository,
694
754
  taxonomyName: string,
755
+ defLocale: string,
695
756
  terms: SeedTaxonomyTerm[],
757
+ termSeedIdMap: Map<string, string>,
696
758
  result: SeedApplyResult,
697
759
  onConflict: "skip" | "update" | "error" = "skip",
698
760
  ): Promise<void> {
699
- // Map slugs to IDs
761
+ // "locale::slug" -> id, so the same slug can resolve per locale.
700
762
  const slugToId = new Map<string, string>();
701
763
 
702
- // Multiple passes to handle deep nesting
764
+ // Multiple passes handles deep nesting and translationOf forward refs.
703
765
  let remaining = [...terms];
704
- let maxPasses = 10; // Prevent infinite loop
766
+ let maxPasses = 10;
705
767
 
706
768
  while (remaining.length > 0 && maxPasses > 0) {
707
769
  const processedThisPass: string[] = [];
708
770
 
709
771
  for (const term of remaining) {
710
- // Check if parent exists (or no parent)
711
- if (!term.parent || slugToId.has(term.parent)) {
712
- const parentId = term.parent ? slugToId.get(term.parent) : undefined;
772
+ const termLocale = term.locale ?? defLocale;
773
+ const parentReady = !term.parent || slugToId.has(`${termLocale}::${term.parent}`);
774
+ const translationReady = !term.translationOf || termSeedIdMap.has(term.translationOf);
713
775
 
714
- const existing = await termRepo.findBySlug(taxonomyName, term.slug);
715
- if (existing) {
716
- if (onConflict === "error") {
717
- throw new Error(
718
- `Conflict: taxonomy term "${term.slug}" in "${taxonomyName}" already exists`,
719
- );
720
- }
721
- if (onConflict === "update") {
722
- await termRepo.update(existing.id, {
723
- label: term.label,
724
- parentId,
725
- data: term.description ? { description: term.description } : {},
726
- });
727
- result.taxonomies.terms++;
728
- }
729
- slugToId.set(term.slug, existing.id);
730
- } else {
731
- const created = await termRepo.create({
732
- name: taxonomyName,
733
- slug: term.slug,
776
+ if (!parentReady || !translationReady) continue;
777
+
778
+ const parentId = term.parent ? slugToId.get(`${termLocale}::${term.parent}`) : undefined;
779
+ const translationOf = term.translationOf ? termSeedIdMap.get(term.translationOf) : undefined;
780
+
781
+ const existing = await termRepo.findBySlug(taxonomyName, term.slug, termLocale);
782
+ if (existing) {
783
+ if (onConflict === "error") {
784
+ throw new Error(
785
+ `Conflict: taxonomy term "${term.slug}" in "${taxonomyName}" (${termLocale}) already exists`,
786
+ );
787
+ }
788
+ if (onConflict === "update") {
789
+ await termRepo.update(existing.id, {
734
790
  label: term.label,
735
791
  parentId,
736
- data: term.description ? { description: term.description } : undefined,
792
+ data: term.description ? { description: term.description } : {},
737
793
  });
738
- slugToId.set(term.slug, created.id);
739
794
  result.taxonomies.terms++;
740
795
  }
741
-
742
- processedThisPass.push(term.slug);
796
+ slugToId.set(`${termLocale}::${term.slug}`, existing.id);
797
+ if (term.id) termSeedIdMap.set(term.id, existing.id);
798
+ } else {
799
+ const created = await termRepo.create({
800
+ name: taxonomyName,
801
+ slug: term.slug,
802
+ label: term.label,
803
+ parentId,
804
+ data: term.description ? { description: term.description } : undefined,
805
+ locale: termLocale,
806
+ translationOf,
807
+ });
808
+ slugToId.set(`${termLocale}::${term.slug}`, created.id);
809
+ if (term.id) termSeedIdMap.set(term.id, created.id);
810
+ result.taxonomies.terms++;
743
811
  }
812
+
813
+ processedThisPass.push(term.slug + "::" + termLocale);
744
814
  }
745
815
 
746
- // Remove processed terms
747
- remaining = remaining.filter((t) => !processedThisPass.includes(t.slug));
816
+ remaining = remaining.filter(
817
+ (t) => !processedThisPass.includes(t.slug + "::" + (t.locale ?? defLocale)),
818
+ );
748
819
  maxPasses--;
749
820
  }
750
821
 
751
822
  if (remaining.length > 0) {
752
- console.warn(`Could not process ${remaining.length} terms due to missing parents`);
823
+ console.warn(`Could not process ${remaining.length} terms due to missing parents/translations`);
753
824
  }
754
825
  }
755
826
 
@@ -847,11 +918,18 @@ async function applyContentTaxonomies(
847
918
  }
848
919
 
849
920
  /**
850
- * Apply menu items recursively
921
+ * Apply menu items recursively.
922
+ *
923
+ * Each item gets a fresh `translation_group` (= its own id). The seed format's
924
+ * `SeedMenuItem` has no `id`/`translationOf` fields, so we can't express the
925
+ * cross-locale "same nav entry" link here — items diverge across locales on
926
+ * re-apply. Runtime navigation still resolves correctly because `reference_id`
927
+ * already holds the content's translation_group.
851
928
  */
852
929
  async function applyMenuItems(
853
930
  db: Kysely<Database>,
854
931
  menuId: string,
932
+ locale: string,
855
933
  items: SeedMenuItem[],
856
934
  parentId: string | null,
857
935
  startOrder: number,
@@ -877,7 +955,6 @@ async function applyMenuItems(
877
955
  // If not in map, the content might not exist yet (will be broken link)
878
956
  }
879
957
 
880
- // Insert menu item
881
958
  await db
882
959
  .insertInto("_emdash_menu_items")
883
960
  .values({
@@ -894,15 +971,24 @@ async function applyMenuItems(
894
971
  target: item.target ?? null,
895
972
  css_classes: item.cssClasses ?? null,
896
973
  created_at: new Date().toISOString(),
974
+ locale,
975
+ translation_group: itemId,
897
976
  })
898
977
  .execute();
899
978
 
900
979
  count++;
901
980
  order++;
902
981
 
903
- // Process children
904
982
  if (item.children && item.children.length > 0) {
905
- const childCount = await applyMenuItems(db, menuId, item.children, itemId, 0, seedIdMap);
983
+ const childCount = await applyMenuItems(
984
+ db,
985
+ menuId,
986
+ locale,
987
+ item.children,
988
+ itemId,
989
+ 0,
990
+ seedIdMap,
991
+ );
906
992
  count += childCount;
907
993
  }
908
994
  }
@@ -927,6 +1013,8 @@ async function applyWidget(
927
1013
  sort_order: sortOrder,
928
1014
  type: widget.type,
929
1015
  title: widget.title ?? null,
1016
+ // `widget.content` is Portable Text for content-type widgets;
1017
+ // for other widget kinds it's null.
930
1018
  content: widget.content ? JSON.stringify(widget.content) : null,
931
1019
  menu_name: widget.menuName ?? null,
932
1020
  component_id: widget.componentId ?? null,
package/src/seed/types.ts CHANGED
@@ -87,14 +87,19 @@ export interface SeedField {
87
87
  }
88
88
 
89
89
  /**
90
- * Taxonomy definition in seed
90
+ * Taxonomy definition in seed. For multi-locale exports each locale variant
91
+ * is its own entry, linked via `translationOf` (referencing another entry's `id`).
91
92
  */
92
93
  export interface SeedTaxonomy {
94
+ /** Optional seed-local id, e.g. "tax:category:en". Target of `translationOf`. */
95
+ id?: string;
93
96
  name: string;
94
97
  label: string;
95
98
  labelSingular?: string;
96
99
  hierarchical: boolean;
97
100
  collections: string[];
101
+ locale?: string;
102
+ translationOf?: string;
98
103
  terms?: SeedTaxonomyTerm[];
99
104
  }
100
105
 
@@ -102,18 +107,26 @@ export interface SeedTaxonomy {
102
107
  * Taxonomy term in seed
103
108
  */
104
109
  export interface SeedTaxonomyTerm {
110
+ /** Optional seed-local id, e.g. "term:category:news:en". */
111
+ id?: string;
105
112
  slug: string;
106
113
  label: string;
107
114
  description?: string;
108
115
  parent?: string; // Slug of parent term (for hierarchical taxonomies)
116
+ locale?: string;
117
+ translationOf?: string;
109
118
  }
110
119
 
111
120
  /**
112
121
  * Menu definition in seed
113
122
  */
114
123
  export interface SeedMenu {
124
+ /** Optional seed-local id, e.g. "menu:primary:en". */
125
+ id?: string;
115
126
  name: string;
116
127
  label: string;
128
+ locale?: string;
129
+ translationOf?: string;
117
130
  items: SeedMenuItem[];
118
131
  }
119
132