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
@@ -36,6 +36,7 @@ import * as m032 from "./032_rate_limits.js";
36
36
  import * as m033 from "./033_optimize_content_indexes.js";
37
37
  import * as m034 from "./034_published_at_index.js";
38
38
  import * as m035 from "./035_bounded_404_log.js";
39
+ import * as m036 from "./036_i18n_menus_and_taxonomies.js";
39
40
 
40
41
  const MIGRATIONS: Readonly<Record<string, Migration>> = Object.freeze({
41
42
  "001_initial": m001,
@@ -72,6 +73,7 @@ const MIGRATIONS: Readonly<Record<string, Migration>> = Object.freeze({
72
73
  "033_optimize_content_indexes": m033,
73
74
  "034_published_at_index": m034,
74
75
  "035_bounded_404_log": m035,
76
+ "036_i18n_menus_and_taxonomies": m036,
75
77
  });
76
78
 
77
79
  /** Total number of registered migrations. Exported for use in tests. */
@@ -123,29 +125,156 @@ export async function getMigrationStatus(db: Kysely<Database>): Promise<Migratio
123
125
  return { applied, pending };
124
126
  }
125
127
 
128
+ /** Pattern for escaping special regex characters. Matches the shared helper in `database/repositories/content.ts`. */
129
+ const REGEX_ESCAPE_PATTERN = /[.*+?^${}()|[\]\\]/g;
130
+
131
+ /** Escape special regex characters so a string can be embedded literally in `new RegExp()`. */
132
+ function escapeRegExp(value: string): string {
133
+ return value.replace(REGEX_ESCAPE_PATTERN, "\\$&");
134
+ }
135
+
126
136
  /**
127
- * Run all pending migrations.
137
+ * Pattern used to detect the concurrent-migration race. The Kysely
138
+ * `SqliteAdapter.acquireMigrationLock` is a no-op (inherited by `kysely-d1`
139
+ * and our `EmDashD1Dialect`), so two isolates running migrations against the
140
+ * same database can both attempt `INSERT INTO _emdash_migrations` for the
141
+ * same migration name. The losing insert fails with a UNIQUE constraint
142
+ * error, which is benign: the other isolate is applying the same schema.
128
143
  *
129
- * Includes a fast-path: if the migration table already exists and contains
130
- * exactly MIGRATION_COUNT rows, all migrations have been applied and we can
131
- * skip the Kysely Migrator entirely. This avoids the expensive
132
- * `pragma_table_info` introspection that Kysely runs for every table in the
133
- * database (twice!) just to check if the migration tables exist.
134
- * On D1 with ~57 tables, that's ~116 queries saved per init.
144
+ * We match on the table name (not the full error text) because different
145
+ * SQLite drivers phrase the message differently
146
+ * (`UNIQUE constraint failed: _emdash_migrations.name` for better-sqlite3,
147
+ * `D1_ERROR: UNIQUE constraint failed: _emdash_migrations.name: SQLITE_CONSTRAINT`
148
+ * for D1, etc.). The pattern is built from `MIGRATION_TABLE` so a rename
149
+ * cannot silently disable race detection.
135
150
  */
136
- export async function runMigrations(db: Kysely<Database>): Promise<{ applied: string[] }> {
137
- // Fast path: check if all migrations are already applied.
138
- // A single cheap query vs the Migrator's full schema introspection.
151
+ const MIGRATION_RACE_PATTERN = new RegExp(
152
+ `UNIQUE constraint failed: ${escapeRegExp(MIGRATION_TABLE)}\\.name`,
153
+ "i",
154
+ );
155
+
156
+ /** How long to wait for a concurrent migrator to finish before giving up. */
157
+ const MIGRATION_RACE_WAIT_MS = 10_000;
158
+ /** Polling interval while waiting for a concurrent migrator. */
159
+ const MIGRATION_RACE_POLL_MS = 100;
160
+
161
+ /**
162
+ * Pattern used to detect "table does not exist" errors across the dialects
163
+ * EmDash supports. The phrasing differs by driver:
164
+ *
165
+ * - better-sqlite3: `no such table: _emdash_migrations`
166
+ * - D1: `D1_ERROR: no such table: _emdash_migrations: SQLITE_ERROR`
167
+ * - PostgreSQL: `relation "_emdash_migrations" does not exist`
168
+ * (also occasionally `table "_emdash_migrations" does not exist`)
169
+ *
170
+ * We deliberately match on the migration table name (rather than using the
171
+ * generic `isMissingTableError` helper) so an unexpected missing-table error
172
+ * naming a different table — implausible today since
173
+ * `getAppliedMigrationCount` only references `MIGRATION_TABLE`, but cheap
174
+ * insurance against future edits — is not silently swallowed. The pattern is
175
+ * built from `MIGRATION_TABLE` so a rename cannot drift.
176
+ */
177
+ const MIGRATION_TABLE_MISSING_PATTERN = new RegExp(
178
+ `(?:no such table:\\s*${escapeRegExp(MIGRATION_TABLE)}\\b` +
179
+ `|(?:relation|table)\\s+"?${escapeRegExp(MIGRATION_TABLE)}"?\\s+does(?:n't| not) exist\\b)`,
180
+ "i",
181
+ );
182
+
183
+ /**
184
+ * Read the count of applied migrations.
185
+ *
186
+ * Returns `null` only when the migration table does not exist yet (which is
187
+ * the normal state on a fresh database before the first migration runs).
188
+ * Any other error is rethrown so callers — particularly
189
+ * `waitForConcurrentMigrator` — don't silently mask connection failures,
190
+ * permission errors, or other unexpected driver problems behind a 10s wait
191
+ * and a bogus "we're done" verdict.
192
+ */
193
+ async function getAppliedMigrationCount(db: Kysely<Database>): Promise<number | null> {
139
194
  try {
140
195
  const result = await sql<{ count: number }>`
141
196
  SELECT COUNT(*) as count FROM ${sql.ref(MIGRATION_TABLE)}
142
197
  `.execute(db);
143
- if (result.rows[0]?.count === MIGRATION_COUNT) {
144
- return { applied: [] };
198
+ return Number(result.rows[0]?.count ?? 0);
199
+ } catch (error) {
200
+ if (MIGRATION_TABLE_MISSING_PATTERN.test(deepErrorMessage(error))) {
201
+ return null;
202
+ }
203
+ throw error;
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Wait for a concurrent migrator to finish applying all migrations.
209
+ *
210
+ * Resolves to `true` once the migration table contains at least
211
+ * `MIGRATION_COUNT` rows (i.e. every migration this build knows about has
212
+ * been recorded), `false` if the deadline elapses first. We use `>=` rather
213
+ * than `===` so that an old isolate observing a database that has already
214
+ * been migrated by a newer build still treats the wait as settled instead
215
+ * of timing out.
216
+ */
217
+ async function waitForConcurrentMigrator(db: Kysely<Database>): Promise<boolean> {
218
+ const deadline = Date.now() + MIGRATION_RACE_WAIT_MS;
219
+ while (Date.now() < deadline) {
220
+ const count = await getAppliedMigrationCount(db);
221
+ if (count !== null && count >= MIGRATION_COUNT) {
222
+ return true;
223
+ }
224
+ await new Promise((resolve) => setTimeout(resolve, MIGRATION_RACE_POLL_MS));
225
+ }
226
+ const finalCount = await getAppliedMigrationCount(db);
227
+ return finalCount !== null && finalCount >= MIGRATION_COUNT;
228
+ }
229
+
230
+ /** Extract the deepest error message available from a thrown value. */
231
+ function deepErrorMessage(error: unknown): string {
232
+ if (error instanceof Error) {
233
+ const own = error.message ?? "";
234
+ if (error.cause) {
235
+ const causeMsg = deepErrorMessage(error.cause);
236
+ return own ? `${own}: ${causeMsg}` : causeMsg;
145
237
  }
238
+ return own;
239
+ }
240
+ if (typeof error === "string") return error;
241
+ try {
242
+ return JSON.stringify(error);
146
243
  } catch {
147
- // Table doesn't exist yet (first run). Fall through to the Migrator
148
- // which will create it.
244
+ return String(error);
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Run all pending migrations.
250
+ *
251
+ * Includes a fast-path: if the migration table already exists and contains
252
+ * at least MIGRATION_COUNT rows, all migrations this build knows about have
253
+ * been applied and we can skip the Kysely Migrator entirely. This avoids
254
+ * the expensive `pragma_table_info` introspection that Kysely runs for
255
+ * every table in the database (twice!) just to check if the migration
256
+ * tables exist. On D1 with ~57 tables, that's ~116 queries saved per init.
257
+ *
258
+ * Concurrent-migration safety: the Kysely Migrator's `acquireMigrationLock`
259
+ * is a no-op for SQLite (and therefore D1), so two callers running this
260
+ * concurrently against the same database will both try to apply pending
261
+ * migrations. SQLite serializes the writes, but the loser still surfaces a
262
+ * `UNIQUE constraint failed: _emdash_migrations.name` error. We treat that
263
+ * specific error as benign: another caller is already applying the same
264
+ * schema. We wait for the concurrent migrator to finish, then return
265
+ * success. This matches the user-observable expectation that running
266
+ * migrations twice in a row is a no-op.
267
+ */
268
+ export async function runMigrations(db: Kysely<Database>): Promise<{ applied: string[] }> {
269
+ // Fast path: check if all migrations are already applied.
270
+ // A single cheap query vs the Migrator's full schema introspection.
271
+ // We use `>=` rather than `===` so a database with extra rows from a
272
+ // newer build (e.g. mid-deploy old isolate, or downgrade) still skips
273
+ // the migrator instead of falling through to the race-recovery path
274
+ // unnecessarily.
275
+ const initialCount = await getAppliedMigrationCount(db);
276
+ if (initialCount !== null && initialCount >= MIGRATION_COUNT) {
277
+ return { applied: [] };
149
278
  }
150
279
 
151
280
  const migrator = new Migrator({
@@ -160,17 +289,23 @@ export async function runMigrations(db: Kysely<Database>): Promise<{ applied: st
160
289
  const applied = results?.filter((r) => r.status === "Success").map((r) => r.migrationName) ?? [];
161
290
 
162
291
  if (error) {
163
- // Kysely sometimes wraps errors with an empty message. Check cause and
164
- // failed migration results for the real error.
165
- let msg = error instanceof Error ? error.message : JSON.stringify(error);
166
- if (!msg && error instanceof Error && error.cause) {
167
- msg = error.cause instanceof Error ? error.cause.message : JSON.stringify(error.cause);
168
- }
292
+ // Walk error.cause to get the underlying driver message Kysely
293
+ // often wraps with an empty top-level message.
294
+ const msg = deepErrorMessage(error);
169
295
  const failedMigration = results?.find((r) => r.status === "Error");
170
- if (failedMigration) {
171
- msg = `${msg || "unknown error"} (migration: ${failedMigration.migrationName})`;
296
+
297
+ // Concurrent-migration race: another caller is applying (or just
298
+ // applied) the same migration. Wait for it to finish, then verify
299
+ // the schema is fully migrated and treat as success.
300
+ if (MIGRATION_RACE_PATTERN.test(msg)) {
301
+ const settled = await waitForConcurrentMigrator(db);
302
+ if (settled) {
303
+ return { applied };
304
+ }
172
305
  }
173
- throw new Error(`Migration failed: ${msg}`);
306
+
307
+ const failedSuffix = failedMigration ? ` (migration: ${failedMigration.migrationName})` : "";
308
+ throw new Error(`Migration failed: ${msg || "unknown error"}${failedSuffix}`);
174
309
  }
175
310
 
176
311
  return { applied };
@@ -637,12 +637,23 @@ export class ContentRepository {
637
637
  /**
638
638
  * Permanently delete content (cannot be undone)
639
639
  */
640
+ /**
641
+ * Permanently delete a soft-deleted content row.
642
+ *
643
+ * Returns `true` only when a soft-deleted (trashed) row was removed.
644
+ * Returns `false` when no row exists OR when the row exists but is live —
645
+ * the caller is responsible for distinguishing these cases (typically via
646
+ * a follow-up `findByIdOrSlugIncludingTrashed` to surface NOT_FOUND vs
647
+ * NOT_TRASHED). The `AND deleted_at IS NOT NULL` clause is the safety net
648
+ * that prevents permanent delete from bypassing the trash workflow.
649
+ */
640
650
  async permanentDelete(type: string, id: string): Promise<boolean> {
641
651
  const tableName = getTableName(type);
642
652
 
643
653
  const result = await sql`
644
654
  DELETE FROM ${sql.ref(tableName)}
645
655
  WHERE id = ${id}
656
+ AND deleted_at IS NOT NULL
646
657
  `.execute(this.db);
647
658
 
648
659
  return (result.numAffectedRows ?? 0n) > 0n;
@@ -917,8 +928,14 @@ export class ContentRepository {
917
928
  * Syncs the draft revision's data into the content table columns so the
918
929
  * content table always reflects the published version.
919
930
  * If no draft revision exists, creates one from current data and publishes it.
931
+ *
932
+ * `publishedAt` (optional) overrides the publication timestamp. If omitted,
933
+ * the existing `published_at` is preserved (idempotent re-publish keeps the
934
+ * original date) and falls back to the current time on first publish. Pass
935
+ * an explicit value to backdate a publish (e.g. when migrating content from
936
+ * another CMS).
920
937
  */
921
- async publish(type: string, id: string): Promise<ContentItem> {
938
+ async publish(type: string, id: string, publishedAt?: string): Promise<ContentItem> {
922
939
  const tableName = getTableName(type);
923
940
  const now = new Date().toISOString();
924
941
 
@@ -956,17 +973,35 @@ export class ContentRepository {
956
973
  }
957
974
  }
958
975
 
959
- await sql`
960
- UPDATE ${sql.ref(tableName)}
961
- SET live_revision_id = ${revisionToPublish},
962
- draft_revision_id = NULL,
963
- status = 'published',
964
- scheduled_at = NULL,
965
- published_at = COALESCE(published_at, ${now}),
966
- updated_at = ${now}
967
- WHERE id = ${id}
968
- AND deleted_at IS NULL
969
- `.execute(this.db);
976
+ if (publishedAt !== undefined) {
977
+ // Caller supplied an explicit timestamp, so we overwrite published_at
978
+ // directly (used to backdate a publish, e.g. for content migrations).
979
+ await sql`
980
+ UPDATE ${sql.ref(tableName)}
981
+ SET live_revision_id = ${revisionToPublish},
982
+ draft_revision_id = NULL,
983
+ status = 'published',
984
+ scheduled_at = NULL,
985
+ published_at = ${publishedAt},
986
+ updated_at = ${now}
987
+ WHERE id = ${id}
988
+ AND deleted_at IS NULL
989
+ `.execute(this.db);
990
+ } else {
991
+ // No timestamp supplied — preserve existing published_at on
992
+ // idempotent re-publish, fall back to `now` on first publish.
993
+ await sql`
994
+ UPDATE ${sql.ref(tableName)}
995
+ SET live_revision_id = ${revisionToPublish},
996
+ draft_revision_id = NULL,
997
+ status = 'published',
998
+ scheduled_at = NULL,
999
+ published_at = COALESCE(published_at, ${now}),
1000
+ updated_at = ${now}
1001
+ WHERE id = ${id}
1002
+ AND deleted_at IS NULL
1003
+ `.execute(this.db);
1004
+ }
970
1005
 
971
1006
  const updated = await this.findById(type, id);
972
1007
  if (!updated) {
@@ -28,6 +28,9 @@ export const REFERRER_MAX_LENGTH = 512;
28
28
  /** Max stored length for the `User-Agent` header — truncated on insert. */
29
29
  export const USER_AGENT_MAX_LENGTH = 256;
30
30
 
31
+ /** Pattern to escape LIKE wildcards: %, _, and backslash */
32
+ const LIKE_ESCAPE_RE = /[\\%_]/g;
33
+
31
34
  /**
32
35
  * Truncate a header-derived string to `max` chars, preserving `null`/`undefined`
33
36
  * as `null`. Empty strings stay empty (the caller decides whether to coerce).
@@ -162,9 +165,15 @@ export class RedirectRepository {
162
165
  .limit(limit + 1);
163
166
 
164
167
  if (opts.search) {
165
- const term = `%${opts.search}%`;
168
+ // Escape LIKE wildcards in the search term to prevent injection.
169
+ // Must include ESCAPE clause for SQLite to recognize backslash as escape char.
170
+ const escaped = opts.search.replace(LIKE_ESCAPE_RE, (c) => `\\${c}`);
171
+ const term = `%${escaped}%`;
166
172
  query = query.where((eb) =>
167
- eb.or([eb("source", "like", term), eb("destination", "like", term)]),
173
+ eb.or([
174
+ sql<boolean>`source LIKE ${term} ESCAPE '\\'`,
175
+ sql<boolean>`destination LIKE ${term} ESCAPE '\\'`,
176
+ ]),
168
177
  );
169
178
  }
170
179
 
@@ -502,7 +511,9 @@ export class RedirectRepository {
502
511
  .limit(limit + 1);
503
512
 
504
513
  if (opts.search) {
505
- query = query.where("path", "like", `%${opts.search}%`);
514
+ const escaped = opts.search.replace(LIKE_ESCAPE_RE, (c) => `\\${c}`);
515
+ const term = `%${escaped}%`;
516
+ query = query.where(sql<boolean>`path LIKE ${term} ESCAPE '\\'`);
506
517
  }
507
518
 
508
519
  if (opts.cursor) {