emdash 0.7.0 → 0.9.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 (354) hide show
  1. package/dist/{adapters-Di31kZ28.d.mts → adapters-DoNJiveC.d.mts} +1 -1
  2. package/dist/{adapters-Di31kZ28.d.mts.map → adapters-DoNJiveC.d.mts.map} +1 -1
  3. package/dist/{apply-5uslYdUu.mjs → apply-BzltprvY.mjs} +90 -139
  4. package/dist/apply-BzltprvY.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 +194 -17
  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 +34 -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 +17 -12
  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 +9 -6
  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 +301 -165
  22. package/dist/astro/middleware.mjs.map +1 -1
  23. package/dist/astro/types.d.mts +34 -10
  24. package/dist/astro/types.d.mts.map +1 -1
  25. package/dist/{base64-MBPo9ozB.mjs → base64-BRICGH2l.mjs} +1 -1
  26. package/dist/{base64-MBPo9ozB.mjs.map → base64-BRICGH2l.mjs.map} +1 -1
  27. package/dist/{byline-C4OVd8b3.mjs → byline-BSaNL1w7.mjs} +5 -5
  28. package/dist/byline-BSaNL1w7.mjs.map +1 -0
  29. package/dist/bylines-CvJ3PYz2.mjs +113 -0
  30. package/dist/bylines-CvJ3PYz2.mjs.map +1 -0
  31. package/dist/cache-C6N_hhN7.mjs +65 -0
  32. package/dist/cache-C6N_hhN7.mjs.map +1 -0
  33. package/dist/{chunks-HGz06Soa.mjs → chunks-NBQVDOci.mjs} +8 -2
  34. package/dist/{chunks-HGz06Soa.mjs.map → chunks-NBQVDOci.mjs.map} +1 -1
  35. package/dist/cli/index.mjs +229 -31
  36. package/dist/cli/index.mjs.map +1 -1
  37. package/dist/client/cf-access.d.mts +1 -1
  38. package/dist/client/index.d.mts +1 -1
  39. package/dist/client/index.mjs +3 -3
  40. package/dist/client/index.mjs.map +1 -1
  41. package/dist/{config-BXwuX8Bx.mjs → config-BI0V3ICQ.mjs} +1 -1
  42. package/dist/{config-BXwuX8Bx.mjs.map → config-BI0V3ICQ.mjs.map} +1 -1
  43. package/dist/{content-D7J5y73J.mjs → content-8lOYF0pr.mjs} +43 -28
  44. package/dist/content-8lOYF0pr.mjs.map +1 -0
  45. package/dist/db/index.d.mts +3 -3
  46. package/dist/db/index.mjs +2 -2
  47. package/dist/db/libsql.d.mts +1 -1
  48. package/dist/db/libsql.d.mts.map +1 -1
  49. package/dist/db/libsql.mjs +7 -2
  50. package/dist/db/libsql.mjs.map +1 -1
  51. package/dist/db/postgres.d.mts +1 -1
  52. package/dist/db/sqlite.d.mts +1 -1
  53. package/dist/db/sqlite.d.mts.map +1 -1
  54. package/dist/db/sqlite.mjs +8 -3
  55. package/dist/db/sqlite.mjs.map +1 -1
  56. package/dist/{db-errors-D0UT85nC.mjs → db-errors-WRezodiz.mjs} +1 -1
  57. package/dist/{db-errors-D0UT85nC.mjs.map → db-errors-WRezodiz.mjs.map} +1 -1
  58. package/dist/{default-CME5YdZ3.mjs → default-D8ksjWhO.mjs} +1 -1
  59. package/dist/{default-CME5YdZ3.mjs.map → default-D8ksjWhO.mjs.map} +1 -1
  60. package/dist/{dialect-helpers-DhTzaUxP.mjs → dialect-helpers-BKCvISIQ.mjs} +19 -2
  61. package/dist/dialect-helpers-BKCvISIQ.mjs.map +1 -0
  62. package/dist/{error-CiYn9yDu.mjs → error-D_-tqP-I.mjs} +1 -1
  63. package/dist/error-D_-tqP-I.mjs.map +1 -0
  64. package/dist/{index-De6_Xv3v.d.mts → index-BFRaVcD6.d.mts} +243 -40
  65. package/dist/index-BFRaVcD6.d.mts.map +1 -0
  66. package/dist/index.d.mts +11 -11
  67. package/dist/index.mjs +29 -25
  68. package/dist/{load-CBcmDIot.mjs → load-DDqMMvZL.mjs} +2 -2
  69. package/dist/{load-CBcmDIot.mjs.map → load-DDqMMvZL.mjs.map} +1 -1
  70. package/dist/{loader-DeiBJEMe.mjs → loader-CKLbBnhK.mjs} +32 -10
  71. package/dist/loader-CKLbBnhK.mjs.map +1 -0
  72. package/dist/{manifest-schema-V30qsMft.mjs → manifest-schema-DqWNC3lM.mjs} +45 -3
  73. package/dist/manifest-schema-DqWNC3lM.mjs.map +1 -0
  74. package/dist/media/index.d.mts +1 -1
  75. package/dist/media/index.mjs +1 -1
  76. package/dist/media/local-runtime.d.mts +7 -7
  77. package/dist/media/local-runtime.mjs +3 -3
  78. package/dist/{media-DqHVh136.mjs → media-BW32b4gi.mjs} +4 -7
  79. package/dist/media-BW32b4gi.mjs.map +1 -0
  80. package/dist/{mode-CpNnGkPz.mjs → mode-ier8jbBk.mjs} +1 -1
  81. package/dist/mode-ier8jbBk.mjs.map +1 -0
  82. package/dist/options-BVp3UsTS.mjs +117 -0
  83. package/dist/options-BVp3UsTS.mjs.map +1 -0
  84. package/dist/page/index.d.mts +2 -2
  85. package/dist/{placeholder-tzpqGWII.d.mts → placeholder-BE4o_2dc.d.mts} +1 -1
  86. package/dist/{placeholder-tzpqGWII.d.mts.map → placeholder-BE4o_2dc.d.mts.map} +1 -1
  87. package/dist/{placeholder-C-fk5hYI.mjs → placeholder-CIJejMlK.mjs} +1 -1
  88. package/dist/placeholder-CIJejMlK.mjs.map +1 -0
  89. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  90. package/dist/plugins/adapt-sandbox-entry.d.mts.map +1 -1
  91. package/dist/plugins/adapt-sandbox-entry.mjs +6 -5
  92. package/dist/plugins/adapt-sandbox-entry.mjs.map +1 -1
  93. package/dist/public-url-DByxYjUw.mjs +51 -0
  94. package/dist/public-url-DByxYjUw.mjs.map +1 -0
  95. package/dist/{query-g4Ug-9j9.mjs → query-Cg9ZKRQ0.mjs} +114 -16
  96. package/dist/query-Cg9ZKRQ0.mjs.map +1 -0
  97. package/dist/{redirect-CN0Rt9Ob.mjs → redirect-BhUBKRc1.mjs} +13 -8
  98. package/dist/redirect-BhUBKRc1.mjs.map +1 -0
  99. package/dist/{registry-Ci3WxVAr.mjs → registry-Dw70ChxB.mjs} +69 -11
  100. package/dist/registry-Dw70ChxB.mjs.map +1 -0
  101. package/dist/{request-cache-DiR961CV.mjs → request-cache-B-bmkipQ.mjs} +1 -1
  102. package/dist/request-cache-B-bmkipQ.mjs.map +1 -0
  103. package/dist/runner-Bnoj7vjK.d.mts +44 -0
  104. package/dist/runner-Bnoj7vjK.d.mts.map +1 -0
  105. package/dist/{runner-tQ7BJ4T7.mjs → runner-C7ADox5q.mjs} +185 -55
  106. package/dist/{runner-tQ7BJ4T7.mjs.map → runner-C7ADox5q.mjs.map} +1 -1
  107. package/dist/runtime.d.mts +6 -6
  108. package/dist/runtime.mjs +4 -4
  109. package/dist/{search-B0effn3j.mjs → search-dOGEccMa.mjs} +341 -152
  110. package/dist/search-dOGEccMa.mjs.map +1 -0
  111. package/dist/secrets-CW3reAnU.mjs +314 -0
  112. package/dist/secrets-CW3reAnU.mjs.map +1 -0
  113. package/dist/seed/index.d.mts +2 -2
  114. package/dist/seed/index.mjs +15 -14
  115. package/dist/seo/index.d.mts +1 -1
  116. package/dist/storage/local.d.mts +1 -1
  117. package/dist/storage/local.mjs +1 -1
  118. package/dist/storage/s3.d.mts +1 -1
  119. package/dist/storage/s3.d.mts.map +1 -1
  120. package/dist/storage/s3.mjs +4 -4
  121. package/dist/storage/s3.mjs.map +1 -1
  122. package/dist/{taxonomies-K2z0Uhnj.mjs → taxonomies-ZlRtD6AG.mjs} +14 -7
  123. package/dist/taxonomies-ZlRtD6AG.mjs.map +1 -0
  124. package/dist/{tokens-BFPFx3CA.mjs → tokens-D7zMmWi2.mjs} +2 -2
  125. package/dist/{tokens-BFPFx3CA.mjs.map → tokens-D7zMmWi2.mjs.map} +1 -1
  126. package/dist/{transport-BykRfpyy.mjs → transport-BeMCmin1.mjs} +6 -5
  127. package/dist/{transport-BykRfpyy.mjs.map → transport-BeMCmin1.mjs.map} +1 -1
  128. package/dist/{transport-H4Iwx7tC.d.mts → transport-DNEfeMaU.d.mts} +1 -1
  129. package/dist/{transport-H4Iwx7tC.d.mts.map → transport-DNEfeMaU.d.mts.map} +1 -1
  130. package/dist/types-4fVtCIm0.mjs +68 -0
  131. package/dist/types-4fVtCIm0.mjs.map +1 -0
  132. package/dist/{types-CnZYHyLW.d.mts → types-BSyXeCFW.d.mts} +24 -2
  133. package/dist/{types-CnZYHyLW.d.mts.map → types-BSyXeCFW.d.mts.map} +1 -1
  134. package/dist/{types-DgrIP0tF.d.mts → types-BuBIptGk.d.mts} +80 -106
  135. package/dist/types-BuBIptGk.d.mts.map +1 -0
  136. package/dist/{types-BH2L167P.mjs → types-CDbKp7ND.mjs} +1 -1
  137. package/dist/{types-BH2L167P.mjs.map → types-CDbKp7ND.mjs.map} +1 -1
  138. package/dist/{types-DDS4MxsT.mjs → types-CIOg5AR8.mjs} +1 -1
  139. package/dist/{types-DDS4MxsT.mjs.map → types-CIOg5AR8.mjs.map} +1 -1
  140. package/dist/{types-6CUZRrZP.d.mts → types-CJsYGpco.d.mts} +24 -2
  141. package/dist/{types-6CUZRrZP.d.mts.map → types-CJsYGpco.d.mts.map} +1 -1
  142. package/dist/types-CRxNbK-Z.mjs +68 -0
  143. package/dist/types-CRxNbK-Z.mjs.map +1 -0
  144. package/dist/{types-C2v0c34j.d.mts → types-CrtWgIvl.d.mts} +1 -1
  145. package/dist/{types-C2v0c34j.d.mts.map → types-CrtWgIvl.d.mts.map} +1 -1
  146. package/dist/{types-CFWjXmus.d.mts → types-M78DQ1lx.d.mts} +1 -1
  147. package/dist/{types-CFWjXmus.d.mts.map → types-M78DQ1lx.d.mts.map} +1 -1
  148. package/dist/{validate-CqsNItbt.mjs → validate-Baqf0slj.mjs} +3 -3
  149. package/dist/{validate-CqsNItbt.mjs.map → validate-Baqf0slj.mjs.map} +1 -1
  150. package/dist/{validate-kM8Pjuf7.d.mts → validate-BfQh_C_y.d.mts} +4 -4
  151. package/dist/{validate-kM8Pjuf7.d.mts.map → validate-BfQh_C_y.d.mts.map} +1 -1
  152. package/dist/validation-BfEI7tNe.mjs +144 -0
  153. package/dist/validation-BfEI7tNe.mjs.map +1 -0
  154. package/dist/version-DoxrVdYf.mjs +7 -0
  155. package/dist/{version-BnTKdfam.mjs.map → version-DoxrVdYf.mjs.map} +1 -1
  156. package/dist/zod-generator-CC0xNe_K.mjs +132 -0
  157. package/dist/zod-generator-CC0xNe_K.mjs.map +1 -0
  158. package/locals.d.ts +1 -6
  159. package/package.json +21 -7
  160. package/src/api/auth-storage.ts +37 -0
  161. package/src/api/error.ts +6 -0
  162. package/src/api/errors.ts +8 -0
  163. package/src/api/handlers/comments.ts +19 -4
  164. package/src/api/handlers/content.ts +151 -4
  165. package/src/api/handlers/device-flow.ts +5 -0
  166. package/src/api/handlers/index.ts +2 -0
  167. package/src/api/handlers/marketplace.ts +11 -4
  168. package/src/api/handlers/media.ts +8 -1
  169. package/src/api/handlers/menus.ts +160 -21
  170. package/src/api/handlers/oauth-authorization.ts +72 -33
  171. package/src/api/handlers/redirects.ts +16 -3
  172. package/src/api/handlers/revision.ts +23 -14
  173. package/src/api/handlers/sections.ts +8 -1
  174. package/src/api/handlers/taxonomies.ts +131 -22
  175. package/src/api/handlers/validation.ts +212 -0
  176. package/src/api/openapi/document.ts +4 -1
  177. package/src/api/public-url.ts +54 -5
  178. package/src/api/route-utils.ts +14 -0
  179. package/src/api/schemas/comments.ts +2 -2
  180. package/src/api/schemas/common.ts +1 -1
  181. package/src/api/schemas/content.ts +17 -0
  182. package/src/api/schemas/sections.ts +3 -3
  183. package/src/api/schemas/setup.ts +8 -0
  184. package/src/api/schemas/users.ts +1 -1
  185. package/src/api/schemas/widgets.ts +12 -10
  186. package/src/api/setup-complete.ts +40 -0
  187. package/src/api/types.ts +5 -1
  188. package/src/astro/integration/index.ts +30 -2
  189. package/src/astro/integration/routes.ts +28 -0
  190. package/src/astro/integration/runtime.ts +49 -1
  191. package/src/astro/integration/virtual-modules.ts +73 -2
  192. package/src/astro/integration/vite-config.ts +49 -13
  193. package/src/astro/middleware/auth.ts +34 -6
  194. package/src/astro/middleware/redirect.ts +29 -16
  195. package/src/astro/middleware/request-context.ts +15 -5
  196. package/src/astro/middleware.ts +41 -10
  197. package/src/astro/routes/PluginRegistry.tsx +10 -1
  198. package/src/astro/routes/api/auth/invite/complete.ts +6 -1
  199. package/src/astro/routes/api/auth/mode.ts +57 -0
  200. package/src/astro/routes/api/auth/oauth/[provider]/callback.ts +23 -3
  201. package/src/astro/routes/api/auth/oauth/[provider].ts +10 -4
  202. package/src/astro/routes/api/auth/passkey/register/verify.ts +6 -1
  203. package/src/astro/routes/api/auth/passkey/verify.ts +6 -1
  204. package/src/astro/routes/api/auth/signup/complete.ts +6 -1
  205. package/src/astro/routes/api/comments/[collection]/[contentId]/index.ts +2 -2
  206. package/src/astro/routes/api/content/[collection]/[id]/discard-draft.ts +4 -2
  207. package/src/astro/routes/api/content/[collection]/[id]/preview-url.ts +34 -12
  208. package/src/astro/routes/api/content/[collection]/[id]/publish.ts +32 -2
  209. package/src/astro/routes/api/content/[collection]/[id]/restore.ts +4 -2
  210. package/src/astro/routes/api/content/[collection]/[id]/revisions.ts +3 -2
  211. package/src/astro/routes/api/content/[collection]/[id]/terms/[taxonomy].ts +8 -4
  212. package/src/astro/routes/api/content/[collection]/[id]/translations.ts +1 -1
  213. package/src/astro/routes/api/content/[collection]/[id].ts +12 -0
  214. package/src/astro/routes/api/content/[collection]/index.ts +1 -9
  215. package/src/astro/routes/api/import/wordpress/execute.ts +3 -1
  216. package/src/astro/routes/api/import/wordpress/media.ts +2 -7
  217. package/src/astro/routes/api/import/wordpress/prepare.ts +9 -0
  218. package/src/astro/routes/api/import/wordpress-plugin/execute.ts +3 -1
  219. package/src/astro/routes/api/manifest.ts +62 -45
  220. package/src/astro/routes/api/media/[id]/confirm.ts +10 -1
  221. package/src/astro/routes/api/media/providers/[providerId]/index.ts +12 -3
  222. package/src/astro/routes/api/openapi.json.ts +27 -10
  223. package/src/astro/routes/api/redirects/404s/index.ts +10 -4
  224. package/src/astro/routes/api/redirects/404s/summary.ts +4 -2
  225. package/src/astro/routes/api/redirects/[id].ts +10 -4
  226. package/src/astro/routes/api/redirects/index.ts +7 -3
  227. package/src/astro/routes/api/revisions/[revisionId]/index.ts +1 -1
  228. package/src/astro/routes/api/schema/collections/[slug]/fields/[fieldSlug].ts +0 -2
  229. package/src/astro/routes/api/schema/collections/[slug]/fields/index.ts +0 -1
  230. package/src/astro/routes/api/schema/collections/[slug]/fields/reorder.ts +0 -1
  231. package/src/astro/routes/api/schema/collections/[slug]/index.ts +2 -2
  232. package/src/astro/routes/api/schema/collections/index.ts +1 -1
  233. package/src/astro/routes/api/search/index.ts +10 -2
  234. package/src/astro/routes/api/sections/[slug].ts +10 -4
  235. package/src/astro/routes/api/sections/index.ts +7 -3
  236. package/src/astro/routes/api/settings/email.ts +4 -9
  237. package/src/astro/routes/api/setup/admin-verify.ts +6 -1
  238. package/src/astro/routes/api/setup/admin.ts +8 -2
  239. package/src/astro/routes/api/setup/index.ts +2 -2
  240. package/src/astro/routes/api/setup/status.ts +3 -1
  241. package/src/astro/routes/api/snapshot.ts +44 -18
  242. package/src/astro/routes/api/taxonomies/index.ts +0 -1
  243. package/src/astro/routes/api/themes/preview.ts +11 -5
  244. package/src/astro/routes/api/widget-areas/[name]/widgets/[id].ts +4 -1
  245. package/src/astro/routes/api/widget-areas/[name]/widgets.ts +4 -1
  246. package/src/astro/routes/api/widget-areas/[name].ts +4 -1
  247. package/src/astro/routes/api/widget-areas/index.ts +4 -1
  248. package/src/astro/types.ts +32 -3
  249. package/src/auth/allowed-origins.ts +168 -0
  250. package/src/auth/mode.ts +15 -3
  251. package/src/auth/passkey-config.ts +35 -13
  252. package/src/auth/providers/github-admin.tsx +29 -0
  253. package/src/auth/providers/github.ts +31 -0
  254. package/src/auth/providers/google-admin.tsx +44 -0
  255. package/src/auth/providers/google.ts +31 -0
  256. package/src/auth/types.ts +114 -4
  257. package/src/bylines/index.ts +37 -88
  258. package/src/cli/commands/auth.ts +28 -6
  259. package/src/cli/commands/bundle-utils.ts +11 -2
  260. package/src/cli/commands/bundle.ts +31 -9
  261. package/src/cli/commands/content.ts +13 -0
  262. package/src/cli/commands/login.ts +8 -1
  263. package/src/cli/commands/publish.ts +24 -0
  264. package/src/cli/commands/secrets.ts +183 -0
  265. package/src/cli/credentials.ts +1 -1
  266. package/src/cli/index.ts +5 -1
  267. package/src/client/index.ts +4 -4
  268. package/src/client/transport.ts +17 -7
  269. package/src/components/Break.astro +2 -2
  270. package/src/components/EmDashHead.astro +18 -13
  271. package/src/components/EmDashImage.astro +7 -6
  272. package/src/components/Embed.astro +1 -1
  273. package/src/components/Gallery.astro +6 -4
  274. package/src/components/Image.astro +9 -4
  275. package/src/components/InlinePortableTextEditor.tsx +106 -19
  276. package/src/components/LiveSearch.astro +5 -14
  277. package/src/config/secrets.ts +528 -0
  278. package/src/database/dialect-helpers.ts +50 -0
  279. package/src/database/migrations/034_published_at_index.ts +1 -1
  280. package/src/database/migrations/035_bounded_404_log.ts +56 -39
  281. package/src/database/migrations/runner.ts +156 -23
  282. package/src/database/repositories/audit.ts +6 -8
  283. package/src/database/repositories/byline.ts +6 -8
  284. package/src/database/repositories/comment.ts +12 -16
  285. package/src/database/repositories/content.ts +76 -52
  286. package/src/database/repositories/index.ts +1 -1
  287. package/src/database/repositories/media.ts +10 -13
  288. package/src/database/repositories/plugin-storage.ts +4 -6
  289. package/src/database/repositories/redirect.ts +26 -19
  290. package/src/database/repositories/taxonomy.ts +40 -3
  291. package/src/database/repositories/types.ts +57 -8
  292. package/src/database/repositories/user.ts +6 -8
  293. package/src/db/libsql.ts +1 -3
  294. package/src/db/sqlite.ts +2 -5
  295. package/src/emdash-runtime.ts +388 -247
  296. package/src/index.ts +14 -1
  297. package/src/loader.ts +30 -6
  298. package/src/mcp/server.ts +781 -141
  299. package/src/media/normalize.ts +1 -1
  300. package/src/media/url.ts +78 -0
  301. package/src/page/site-identity.ts +58 -0
  302. package/src/plugins/adapt-sandbox-entry.ts +22 -10
  303. package/src/plugins/context.ts +13 -10
  304. package/src/plugins/define-plugin.ts +40 -12
  305. package/src/plugins/email-console.ts +10 -3
  306. package/src/plugins/hooks.ts +34 -19
  307. package/src/plugins/index.ts +9 -0
  308. package/src/plugins/manifest-schema.ts +49 -2
  309. package/src/plugins/types.ts +174 -13
  310. package/src/preview/urls.ts +23 -3
  311. package/src/query.ts +149 -6
  312. package/src/redirects/cache.ts +38 -18
  313. package/src/request-cache.ts +3 -0
  314. package/src/schema/registry.ts +97 -5
  315. package/src/schema/zod-generator.ts +27 -5
  316. package/src/search/fts-manager.ts +0 -2
  317. package/src/search/query.ts +111 -26
  318. package/src/search/types.ts +8 -1
  319. package/src/sections/index.ts +7 -9
  320. package/src/seed/apply.ts +2 -0
  321. package/src/settings/index.ts +80 -6
  322. package/src/settings/types.ts +23 -1
  323. package/src/storage/s3.ts +12 -6
  324. package/src/taxonomies/index.ts +11 -1
  325. package/src/virtual-modules.d.ts +21 -1
  326. package/src/widgets/index.ts +1 -1
  327. package/dist/apply-5uslYdUu.mjs.map +0 -1
  328. package/dist/byline-C4OVd8b3.mjs.map +0 -1
  329. package/dist/bylines-hPTW79hw.mjs +0 -157
  330. package/dist/bylines-hPTW79hw.mjs.map +0 -1
  331. package/dist/cache-BkKBuIvS.mjs +0 -56
  332. package/dist/cache-BkKBuIvS.mjs.map +0 -1
  333. package/dist/chunk-ClPoSABd.mjs +0 -21
  334. package/dist/content-D7J5y73J.mjs.map +0 -1
  335. package/dist/dialect-helpers-DhTzaUxP.mjs.map +0 -1
  336. package/dist/error-CiYn9yDu.mjs.map +0 -1
  337. package/dist/index-De6_Xv3v.d.mts.map +0 -1
  338. package/dist/loader-DeiBJEMe.mjs.map +0 -1
  339. package/dist/manifest-schema-V30qsMft.mjs.map +0 -1
  340. package/dist/media-DqHVh136.mjs.map +0 -1
  341. package/dist/mode-CpNnGkPz.mjs.map +0 -1
  342. package/dist/placeholder-C-fk5hYI.mjs.map +0 -1
  343. package/dist/query-g4Ug-9j9.mjs.map +0 -1
  344. package/dist/redirect-CN0Rt9Ob.mjs.map +0 -1
  345. package/dist/registry-Ci3WxVAr.mjs.map +0 -1
  346. package/dist/request-cache-DiR961CV.mjs.map +0 -1
  347. package/dist/runner-BR2xKwhn.d.mts +0 -34
  348. package/dist/runner-BR2xKwhn.d.mts.map +0 -1
  349. package/dist/search-B0effn3j.mjs.map +0 -1
  350. package/dist/taxonomies-K2z0Uhnj.mjs.map +0 -1
  351. package/dist/types-CMMN0pNg.mjs +0 -31
  352. package/dist/types-CMMN0pNg.mjs.map +0 -1
  353. package/dist/types-DgrIP0tF.d.mts.map +0 -1
  354. package/dist/version-BnTKdfam.mjs +0 -7
@@ -39,13 +39,26 @@ import type {
39
39
  PageMetadataContribution,
40
40
  PageFragmentContribution,
41
41
  } from "./plugins/types.js";
42
- import { invalidateUrlPatternCache } from "./query.js";
43
42
  import type { FieldType } from "./schema/types.js";
44
43
  import { hashString } from "./utils/hash.js";
45
44
  import { COMMIT, VERSION } from "./version.js";
46
45
 
47
46
  const LEADING_SLASH_PATTERN = /^\//;
48
47
 
48
+ /**
49
+ * Parse a JSON column expected to contain an array of strings.
50
+ *
51
+ * Throws on malformed JSON rather than returning []; callers are responsible
52
+ * for deciding how to handle/log the error. Empty string / null inputs return
53
+ * [] (they represent "no value"). Non-string array entries are filtered out.
54
+ */
55
+ function parseStringArray(raw: string | null | undefined): string[] {
56
+ if (!raw) return [];
57
+ const parsed: unknown = JSON.parse(raw);
58
+ if (!Array.isArray(parsed)) return [];
59
+ return parsed.filter((v): v is string => typeof v === "string");
60
+ }
61
+
49
62
  /** Combined result from a single-pass page contribution collection */
50
63
  interface PageContributions {
51
64
  metadata: PageMetadataContribution[];
@@ -97,6 +110,7 @@ import {
97
110
  DEFAULT_COMMENT_MODERATOR_PLUGIN_ID,
98
111
  defaultCommentModerate,
99
112
  } from "./comments/moderator.js";
113
+ import { validateEncryptionKeyAtStartup } from "./config/secrets.js";
100
114
  import { OptionsRepository } from "./database/repositories/options.js";
101
115
  import {
102
116
  handleContentList,
@@ -147,6 +161,7 @@ import { NodeCronScheduler } from "./plugins/scheduler/node.js";
147
161
  import { PiggybackScheduler } from "./plugins/scheduler/piggyback.js";
148
162
  import type { CronScheduler } from "./plugins/scheduler/types.js";
149
163
  import { PluginStateRepository } from "./plugins/state.js";
164
+ import { requestCached } from "./request-cache.js";
150
165
  import { getRequestContext } from "./request-context.js";
151
166
  import { FTSManager } from "./search/fts-manager.js";
152
167
 
@@ -237,6 +252,44 @@ export interface RuntimeDependencies {
237
252
  createSandboxRunner: ((opts: { db: Kysely<Database> }) => SandboxRunner) | null;
238
253
  }
239
254
 
255
+ /**
256
+ * Constructor parameters for `EmDashRuntime`.
257
+ *
258
+ * Production code should use `EmDashRuntime.create()` which discovers and
259
+ * loads all parts (database, plugins, hooks, cron, etc.) and then calls the
260
+ * constructor. Direct construction is supported for callers that already
261
+ * have all the dependencies in hand — for example, integration tests that
262
+ * supply a pre-migrated database and an empty plugin set.
263
+ *
264
+ * Every field corresponds 1:1 to internal state set on the runtime — none of
265
+ * these are derived. If you don't have a value for one, see what `create()`
266
+ * passes for that field as the canonical default.
267
+ */
268
+ export interface EmDashRuntimeParts {
269
+ db: Kysely<Database>;
270
+ storage: Storage | null;
271
+ configuredPlugins: ResolvedPlugin[];
272
+ sandboxedPlugins: Map<string, SandboxedPlugin>;
273
+ sandboxedPluginEntries: SandboxedPluginEntry[];
274
+ hooks: HookPipeline;
275
+ enabledPlugins: Set<string>;
276
+ pluginStates: Map<string, string>;
277
+ config: EmDashConfig;
278
+ mediaProviders: Map<string, MediaProvider>;
279
+ mediaProviderEntries: MediaProviderEntry[];
280
+ cronExecutor: CronExecutor | null;
281
+ cronScheduler: CronScheduler | null;
282
+ emailPipeline: EmailPipeline | null;
283
+ allPipelinePlugins: ResolvedPlugin[];
284
+ pipelineFactoryOptions: {
285
+ db: Kysely<Database>;
286
+ storage?: Storage;
287
+ siteInfo?: { siteName?: string; siteUrl?: string; locale?: string };
288
+ };
289
+ runtimeDeps: RuntimeDependencies;
290
+ pipelineRef: { current: HookPipeline };
291
+ }
292
+
240
293
  /**
241
294
  * Convert a ContentItem to Record<string, unknown> for hook consumption.
242
295
  * Hooks receive the full item as a flat record.
@@ -290,10 +343,6 @@ export class EmDashRuntime {
290
343
  private enabledPlugins: Set<string>;
291
344
  private pluginStates: Map<string, string>;
292
345
 
293
- private _cachedManifest: EmDashManifest | null = null;
294
- private _manifestPromise: Promise<EmDashManifest> | null = null;
295
- private readonly _manifestCacheKey: string;
296
-
297
346
  /**
298
347
  * Set to true after FTS indexes have been verified for this worker
299
348
  * lifetime so we don't re-scan on every admin request. See
@@ -337,51 +386,26 @@ export class EmDashRuntime {
337
386
  return this._db;
338
387
  }
339
388
 
340
- private constructor(
341
- db: Kysely<Database>,
342
- storage: Storage | null,
343
- configuredPlugins: ResolvedPlugin[],
344
- sandboxedPlugins: Map<string, SandboxedPlugin>,
345
- sandboxedPluginEntries: SandboxedPluginEntry[],
346
- hooks: HookPipeline,
347
- enabledPlugins: Set<string>,
348
- pluginStates: Map<string, string>,
349
- config: EmDashConfig,
350
- mediaProviders: Map<string, MediaProvider>,
351
- mediaProviderEntries: MediaProviderEntry[],
352
- cronExecutor: CronExecutor | null,
353
- cronScheduler: CronScheduler | null,
354
- emailPipeline: EmailPipeline | null,
355
- allPipelinePlugins: ResolvedPlugin[],
356
- pipelineFactoryOptions: {
357
- db: Kysely<Database>;
358
- storage?: Storage;
359
- siteInfo?: { siteName?: string; siteUrl?: string; locale?: string };
360
- },
361
- runtimeDeps: RuntimeDependencies,
362
- pipelineRef: { current: HookPipeline },
363
- manifestCacheKey: string,
364
- ) {
365
- this._db = db;
366
- this.storage = storage;
367
- this.configuredPlugins = configuredPlugins;
368
- this.sandboxedPlugins = sandboxedPlugins;
369
- this.sandboxedPluginEntries = sandboxedPluginEntries;
370
- this.schemaRegistry = new SchemaRegistry(db);
371
- this._hooks = hooks;
372
- this.enabledPlugins = enabledPlugins;
373
- this.pluginStates = pluginStates;
374
- this.config = config;
375
- this.mediaProviders = mediaProviders;
376
- this.mediaProviderEntries = mediaProviderEntries;
377
- this.cronExecutor = cronExecutor;
378
- this.cronScheduler = cronScheduler;
379
- this.email = emailPipeline;
380
- this.allPipelinePlugins = allPipelinePlugins;
381
- this.pipelineFactoryOptions = pipelineFactoryOptions;
382
- this.runtimeDeps = runtimeDeps;
383
- this.pipelineRef = pipelineRef;
384
- this._manifestCacheKey = manifestCacheKey;
389
+ constructor(parts: EmDashRuntimeParts) {
390
+ this._db = parts.db;
391
+ this.storage = parts.storage;
392
+ this.configuredPlugins = parts.configuredPlugins;
393
+ this.sandboxedPlugins = parts.sandboxedPlugins;
394
+ this.sandboxedPluginEntries = parts.sandboxedPluginEntries;
395
+ this.schemaRegistry = new SchemaRegistry(parts.db);
396
+ this._hooks = parts.hooks;
397
+ this.enabledPlugins = parts.enabledPlugins;
398
+ this.pluginStates = parts.pluginStates;
399
+ this.config = parts.config;
400
+ this.mediaProviders = parts.mediaProviders;
401
+ this.mediaProviderEntries = parts.mediaProviderEntries;
402
+ this.cronExecutor = parts.cronExecutor;
403
+ this.cronScheduler = parts.cronScheduler;
404
+ this.email = parts.emailPipeline;
405
+ this.allPipelinePlugins = parts.allPipelinePlugins;
406
+ this.pipelineFactoryOptions = parts.pipelineFactoryOptions;
407
+ this.runtimeDeps = parts.runtimeDeps;
408
+ this.pipelineRef = parts.pipelineRef;
385
409
  }
386
410
 
387
411
  /**
@@ -431,7 +455,6 @@ export class EmDashRuntime {
431
455
  this.enabledPlugins.delete(pluginId);
432
456
  await this.rebuildHookPipeline();
433
457
  }
434
- this.invalidateManifest();
435
458
  }
436
459
 
437
460
  /**
@@ -605,6 +628,13 @@ export class EmDashRuntime {
605
628
  // Initialize database (connects, runs migrations if needed)
606
629
  const db = await phase("rt.db", "DB init + migrations", () => EmDashRuntime.getDatabase(deps));
607
630
 
631
+ // Validate EMDASH_ENCRYPTION_KEY once here so a malformed value
632
+ // surfaces in startup logs instead of as request-time 500s. The key
633
+ // itself is not yet consumed (a follow-up PR adds plugin-secret
634
+ // encryption); validating early just guards against silent
635
+ // misconfiguration.
636
+ await phase("rt.secrets", "Validate encryption key", () => validateEncryptionKeyAtStartup());
637
+
608
638
  // FTS verify/repair is deferred off the cold-start hot path.
609
639
  // See EmDashRuntime.ensureSearchHealthy().
610
640
 
@@ -668,7 +698,7 @@ export class EmDashRuntime {
668
698
  const devConsolePlugin = definePlugin({
669
699
  id: DEV_CONSOLE_EMAIL_PLUGIN_ID,
670
700
  version: "0.0.0",
671
- capabilities: ["email:provide"],
701
+ capabilities: ["hooks.email-transport:register"],
672
702
  hooks: {
673
703
  "email:deliver": {
674
704
  exclusive: true,
@@ -691,7 +721,7 @@ export class EmDashRuntime {
691
721
  const defaultModeratorPlugin = definePlugin({
692
722
  id: DEFAULT_COMMENT_MODERATOR_PLUGIN_ID,
693
723
  version: "0.0.0",
694
- capabilities: ["read:users"],
724
+ capabilities: ["users:read"],
695
725
  hooks: {
696
726
  "comment:moderate": {
697
727
  exclusive: true,
@@ -842,32 +872,16 @@ export class EmDashRuntime {
842
872
  }
843
873
  });
844
874
 
845
- // SHA of emdash commit + user config that affects the manifest.
846
- // COMMIT captures emdash code changes; plugin IDs/versions and i18n
847
- // capture user astro.config changes (e.g. upgrading a plugin package).
848
- // DB-driven changes (collections, fields, plugin toggle) go through
849
- // invalidateManifest(). Sorted for stability across nondeterministic
850
- // plugin ordering.
851
- const manifestCacheKey = await hashString(
852
- [
853
- COMMIT,
854
- ...deps.plugins.map((p) => `${p.id}@${p.version ?? ""}`).toSorted(),
855
- ...deps.sandboxedPluginEntries.map((e) => `${e.id}@${e.version}`).toSorted(),
856
- virtualConfig?.i18n?.defaultLocale ?? "",
857
- (virtualConfig?.i18n?.locales ?? []).toSorted().join(","),
858
- ].join("|"),
859
- );
860
-
861
- return new EmDashRuntime(
875
+ return new EmDashRuntime({
862
876
  db,
863
877
  storage,
864
- deps.plugins,
878
+ configuredPlugins: deps.plugins,
865
879
  sandboxedPlugins,
866
- deps.sandboxedPluginEntries,
867
- pipeline,
880
+ sandboxedPluginEntries: deps.sandboxedPluginEntries,
881
+ hooks: pipeline,
868
882
  enabledPlugins,
869
883
  pluginStates,
870
- deps.config,
884
+ config: deps.config,
871
885
  mediaProviders,
872
886
  mediaProviderEntries,
873
887
  cronExecutor,
@@ -875,10 +889,9 @@ export class EmDashRuntime {
875
889
  emailPipeline,
876
890
  allPipelinePlugins,
877
891
  pipelineFactoryOptions,
878
- deps,
892
+ runtimeDeps: deps,
879
893
  pipelineRef,
880
- manifestCacheKey,
881
- );
894
+ });
882
895
  }
883
896
 
884
897
  /**
@@ -954,18 +967,15 @@ export class EmDashRuntime {
954
967
  const dialect = deps.createDialect(dbConfig.config);
955
968
  const db = new Kysely<Database>({ dialect, log: kyselyLogOption() });
956
969
 
957
- const { applied } = await runMigrations(db);
970
+ await runMigrations(db);
958
971
 
959
- // If migrations were applied, the schema changed — clear the
960
- // DB-persisted manifest cache so getManifest() rebuilds it.
961
- if (applied.length > 0) {
962
- try {
963
- const options = new OptionsRepository(db);
964
- await options.delete("emdash:manifest_cache");
965
- } catch {
966
- // Non-fatal
967
- }
968
- }
972
+ // Note: legacy installs may carry a stray `emdash:manifest_cache`
973
+ // row in the options table from versions that persisted a JSON
974
+ // manifest. The runtime no longer reads or writes it. We do not
975
+ // proactively delete it: the row is a few hundred bytes of dead
976
+ // weight and is never on the read path, whereas a one-shot
977
+ // cleanup-flag check costs an extra `options.get()` on every
978
+ // isolate cold boot forever. Cheaper to leave it.
969
979
 
970
980
  // Auto-seed schema if no collections exist and setup hasn't run.
971
981
  // This covers first-load on sites that skip the setup wizard.
@@ -1227,80 +1237,35 @@ export class EmDashRuntime {
1227
1237
  // =========================================================================
1228
1238
 
1229
1239
  /**
1230
- * Get the manifest, using an in-memory cache with a DB-persisted
1231
- * fallback for cold starts. Avoids N+1 schema registry queries
1232
- * on every request.
1240
+ * Build the admin manifest from the live database.
1241
+ *
1242
+ * Used by the admin UI (sidebar collections, content editor field
1243
+ * dispatch, manifest endpoint) and by WordPress import — it's never
1244
+ * read on a public request, so this isn't on any anonymous hot path.
1233
1245
  *
1234
- * Cache is invalidated by invalidateManifest(), called from schema
1235
- * API routes, MCP server, plugin toggle, and taxonomy def changes.
1246
+ * No cross-request cache. The previous worker-isolate cache produced
1247
+ * a class of cross-isolate staleness bugs (#776, #873, #876, #877)
1248
+ * because Cloudflare Workers keeps multiple warm isolates per region
1249
+ * and there's no fan-out primitive to invalidate them in step. The
1250
+ * cache existed to amortize an N+1 schema query pattern; now that
1251
+ * `listCollectionsWithFields()` does the same work in two queries,
1252
+ * the rebuild is fast enough to pay on every admin request.
1253
+ *
1254
+ * Within a single request, `requestCached` deduplicates concurrent
1255
+ * callers (the manifest endpoint and an admin SSR template, say).
1236
1256
  */
1237
- async getManifest(): Promise<EmDashManifest> {
1238
- // When the DB is overridden by an isolated instance (playground /
1239
- // DO-preview sessions), bypass the module-scoped manifest cache —
1240
- // its schema may diverge from the configured DB. Plain D1 Sessions
1241
- // routing does NOT set `dbIsIsolated`, so the cache still applies.
1242
- if (getRequestContext()?.dbIsIsolated) {
1243
- return this._buildManifest();
1244
- }
1245
-
1246
- if (this._cachedManifest) return this._cachedManifest;
1247
-
1248
- // DB-persisted cache (1 query instead of N+1 rebuild on cold start).
1249
- // Keyed by SHA of commit + config to bust on deploys. DB-driven
1250
- // changes (collections, fields, plugins, taxonomies) go through
1251
- // invalidateManifest().
1252
- try {
1253
- const options = new OptionsRepository(this.db);
1254
- const cached = await options.get<{ key: string; manifest: EmDashManifest }>(
1255
- "emdash:manifest_cache",
1256
- );
1257
- if (cached && cached.key === this._manifestCacheKey && cached.manifest) {
1258
- this._cachedManifest = cached.manifest;
1259
- return cached.manifest;
1260
- }
1261
- } catch {
1262
- // Options table may not exist yet
1263
- }
1264
-
1265
- // Full rebuild, then persist. Track which promise is current so
1266
- // an invalidation during the build can't be overwritten.
1267
- if (!this._manifestPromise) {
1268
- let manifestPromise: Promise<EmDashManifest>;
1269
- const isCurrentLoad = () => this._manifestPromise === manifestPromise;
1270
- manifestPromise = this._loadManifest(isCurrentLoad);
1271
- this._manifestPromise = manifestPromise;
1272
- }
1273
- return this._manifestPromise;
1274
- }
1275
-
1276
- private async _loadManifest(isCurrentLoad: () => boolean): Promise<EmDashManifest> {
1277
- try {
1278
- const manifest = await this._buildManifest();
1279
-
1280
- if (isCurrentLoad()) {
1281
- this._cachedManifest = manifest;
1282
-
1283
- try {
1284
- const options = new OptionsRepository(this.db);
1285
- await options.set("emdash:manifest_cache", {
1286
- key: this._manifestCacheKey,
1287
- manifest,
1288
- });
1289
- } catch {
1290
- // Non-fatal — will just rebuild next time
1291
- }
1292
- }
1293
-
1294
- return manifest;
1295
- } finally {
1296
- if (isCurrentLoad()) {
1297
- this._manifestPromise = null;
1298
- }
1299
- }
1257
+ getManifest(): Promise<EmDashManifest> {
1258
+ return requestCached("emdash:manifest", () => this._buildManifest());
1300
1259
  }
1301
1260
 
1302
1261
  /**
1303
- * Build the manifest from database (N+1 collection queries).
1262
+ * Build the manifest from the database.
1263
+ *
1264
+ * Constant query shapes via `listCollectionsWithFields()` — one query
1265
+ * for collections, one batched query for fields (chunked at
1266
+ * `SQL_BATCH_SIZE` collection IDs to stay under D1's bound-parameter
1267
+ * limit). Typical sites stay well under the chunk threshold, so this
1268
+ * is two queries in practice; never N+1.
1304
1269
  */
1305
1270
  private async _buildManifest(): Promise<EmDashManifest> {
1306
1271
  // Build collections from database.
@@ -1309,9 +1274,8 @@ export class EmDashRuntime {
1309
1274
  const manifestCollections: Record<string, ManifestCollection> = {};
1310
1275
  try {
1311
1276
  const registry = new SchemaRegistry(this.db);
1312
- const dbCollections = await registry.listCollections();
1277
+ const dbCollections = await registry.listCollectionsWithFields();
1313
1278
  for (const collection of dbCollections) {
1314
- const collectionWithFields = await registry.getCollectionWithFields(collection.slug);
1315
1279
  const fields: Record<
1316
1280
  string,
1317
1281
  {
@@ -1326,34 +1290,32 @@ export class EmDashRuntime {
1326
1290
  }
1327
1291
  > = {};
1328
1292
 
1329
- if (collectionWithFields?.fields) {
1330
- for (const field of collectionWithFields.fields) {
1331
- const entry: (typeof fields)[string] = {
1332
- kind: FIELD_TYPE_TO_KIND[field.type] ?? "string",
1333
- label: field.label,
1334
- required: field.required,
1335
- };
1336
- if (field.widget) entry.widget = field.widget;
1337
- // Plugin field widgets read their per-field config from `field.options`,
1338
- // which the seed schema types as `Record<string, unknown>`. Pass it
1339
- // through to the manifest so plugin widgets in the admin SPA receive it.
1340
- if (field.options) {
1341
- entry.options = field.options;
1342
- }
1343
- // Legacy: select/multiSelect enum options live on `field.validation.options`.
1344
- // Wins over `field.options` to preserve existing behavior for enum widgets.
1345
- if (field.validation?.options) {
1346
- entry.options = field.validation.options.map((v) => ({
1347
- value: v,
1348
- label: v.charAt(0).toUpperCase() + v.slice(1),
1349
- }));
1350
- }
1351
- // Include full validation for repeater fields (subFields, minItems, maxItems)
1352
- if (field.type === "repeater" && field.validation) {
1353
- (entry as Record<string, unknown>).validation = field.validation;
1354
- }
1355
- fields[field.slug] = entry;
1293
+ for (const field of collection.fields) {
1294
+ const entry: (typeof fields)[string] = {
1295
+ kind: FIELD_TYPE_TO_KIND[field.type] ?? "string",
1296
+ label: field.label,
1297
+ required: field.required,
1298
+ };
1299
+ if (field.widget) entry.widget = field.widget;
1300
+ // Plugin field widgets read their per-field config from `field.options`,
1301
+ // which the seed schema types as `Record<string, unknown>`. Pass it
1302
+ // through to the manifest so plugin widgets in the admin SPA receive it.
1303
+ if (field.options) {
1304
+ entry.options = field.options;
1305
+ }
1306
+ // Legacy: select/multiSelect enum options live on `field.validation.options`.
1307
+ // Wins over `field.options` to preserve existing behavior for enum widgets.
1308
+ if (field.validation?.options) {
1309
+ entry.options = field.validation.options.map((v) => ({
1310
+ value: v,
1311
+ label: v.charAt(0).toUpperCase() + v.slice(1),
1312
+ }));
1313
+ }
1314
+ // Include full validation for repeater fields (subFields, minItems, maxItems)
1315
+ if (field.type === "repeater" && field.validation) {
1316
+ (entry as Record<string, unknown>).validation = field.validation;
1356
1317
  }
1318
+ fields[field.slug] = entry;
1357
1319
  }
1358
1320
 
1359
1321
  manifestCollections[collection.slug] = {
@@ -1390,6 +1352,7 @@ export class EmDashRuntime {
1390
1352
  description?: string;
1391
1353
  placeholder?: string;
1392
1354
  fields?: Element[];
1355
+ category?: string;
1393
1356
  }>;
1394
1357
  fieldWidgets?: Array<{
1395
1358
  name: string;
@@ -1489,7 +1452,7 @@ export class EmDashRuntime {
1489
1452
  label: row.label,
1490
1453
  labelSingular: row.label_singular ?? undefined,
1491
1454
  hierarchical: row.hierarchical === 1,
1492
- collections: row.collections ? (JSON.parse(row.collections) as string[]).toSorted() : [],
1455
+ collections: parseStringArray(row.collections).toSorted(),
1493
1456
  }));
1494
1457
  } catch (error) {
1495
1458
  console.debug("EmDash: Could not load taxonomy definitions:", error);
@@ -1526,27 +1489,6 @@ export class EmDashRuntime {
1526
1489
  };
1527
1490
  }
1528
1491
 
1529
- /**
1530
- * Invalidate cached data derived from the manifest/schema.
1531
- * Called when collections, fields, plugins, or taxonomy defs change.
1532
- */
1533
- invalidateManifest(): void {
1534
- this._cachedManifest = null;
1535
- this._manifestPromise = null;
1536
- invalidateUrlPatternCache();
1537
- // Delete DB-persisted cache so the next cold start rebuilds.
1538
- // Fire-and-forget: in-memory is already cleared for this worker,
1539
- // DB delete is best-effort for the next cold start.
1540
- try {
1541
- const options = new OptionsRepository(this.db);
1542
- options.delete("emdash:manifest_cache").catch((error) => {
1543
- console.error("Failed to delete persisted manifest cache", error);
1544
- });
1545
- } catch (error) {
1546
- console.error("Failed to initialize manifest cache invalidation", error);
1547
- }
1548
- }
1549
-
1550
1492
  /**
1551
1493
  * Verify and repair FTS indexes on demand. Runs at most once per worker
1552
1494
  * lifetime.
@@ -1615,11 +1557,75 @@ export class EmDashRuntime {
1615
1557
  }
1616
1558
 
1617
1559
  async handleContentGet(collection: string, id: string, locale?: string) {
1618
- return handleContentGet(this.db, collection, id, locale);
1560
+ const result = await handleContentGet(this.db, collection, id, locale);
1561
+ return this.hydrateDraftData(result);
1619
1562
  }
1620
1563
 
1621
1564
  async handleContentGetIncludingTrashed(collection: string, id: string, locale?: string) {
1622
- return handleContentGetIncludingTrashed(this.db, collection, id, locale);
1565
+ const result = await handleContentGetIncludingTrashed(this.db, collection, id, locale);
1566
+ return this.hydrateDraftData(result);
1567
+ }
1568
+
1569
+ /**
1570
+ * If the response item has a `draftRevisionId`, replace `item.data` with
1571
+ * the draft revision's data and expose the original published values as
1572
+ * `liveData`. This makes the content_get / content_update round-trip
1573
+ * intuitive — read returns the latest content the caller has saved
1574
+ * (their pending draft), with the previously-published values still
1575
+ * accessible for compare-style flows.
1576
+ *
1577
+ * No-op when no draft exists or the response is an error.
1578
+ */
1579
+ private async hydrateDraftData<T>(result: T): Promise<T> {
1580
+ if (!result || typeof result !== "object") return result;
1581
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- shape probed below
1582
+ const r = result as {
1583
+ success?: boolean;
1584
+ data?: { item?: Record<string, unknown> };
1585
+ };
1586
+ if (!r.success || !r.data?.item) return result;
1587
+ const item = r.data.item;
1588
+ const draftRevisionId = typeof item.draftRevisionId === "string" ? item.draftRevisionId : null;
1589
+ if (!draftRevisionId) return result;
1590
+ try {
1591
+ const revision = await new RevisionRepository(this.db).findById(draftRevisionId);
1592
+ if (!revision) return result;
1593
+ const liveData =
1594
+ item.data && typeof item.data === "object"
1595
+ ? // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- narrowed to object above
1596
+ (item.data as Record<string, unknown>)
1597
+ : {};
1598
+ // Strip leading-underscore keys (`_slug`, `_rev`, etc.) from the
1599
+ // revision data — those are handler-internal markers and don't
1600
+ // belong in the surfaced `data` field. Match syncDataColumns at
1601
+ // content.ts:~1119.
1602
+ const revisionData: Record<string, unknown> = {};
1603
+ for (const [key, value] of Object.entries(revision.data)) {
1604
+ if (!key.startsWith("_")) revisionData[key] = value;
1605
+ }
1606
+ const mergedData = { ...liveData, ...revisionData };
1607
+ // Return a clone rather than mutating in place. The response
1608
+ // object isn't retained by the runtime today, but a future
1609
+ // request-cache layer would observe stale-after-mutation bugs;
1610
+ // cloning closes that footgun.
1611
+ // `r.data` was narrowed to `{ item?: ... }` at the top of this
1612
+ // method; spread its other keys (e.g. `_rev`) alongside the
1613
+ // hydrated item without going back through `unknown`.
1614
+ return {
1615
+ ...result,
1616
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- shape preserved; result has been narrowed to the {success,data:{item}} envelope
1617
+ data: {
1618
+ ...r.data,
1619
+ item: { ...item, data: mergedData, liveData },
1620
+ },
1621
+ } as T;
1622
+ } catch (error) {
1623
+ // Non-fatal — fall back to the unhydrated response. Log so the
1624
+ // failure isn't completely silent (the response will look stale
1625
+ // to the caller but no error is raised).
1626
+ console.error("[emdash] draft hydration failed:", error);
1627
+ return result;
1628
+ }
1623
1629
  }
1624
1630
 
1625
1631
  async handleContentCreate(
@@ -1647,6 +1653,20 @@ export class EmDashRuntime {
1647
1653
  // Normalize media fields (fill dimensions, storageKey, etc.)
1648
1654
  processedData = await this.normalizeMediaFields(collection, processedData);
1649
1655
 
1656
+ // Validate against the collection schema. Hook output is validated
1657
+ // rather than `body.data` so plugins that mutate field values can't
1658
+ // sneak invalid data past.
1659
+ const { validateContentData } = await import("./api/handlers/validation.js");
1660
+ const validation = await validateContentData(this.db, collection, processedData, {
1661
+ partial: false,
1662
+ });
1663
+ if (!validation.ok) {
1664
+ return {
1665
+ success: false as const,
1666
+ error: validation.error,
1667
+ };
1668
+ }
1669
+
1650
1670
  // Create the content
1651
1671
  const result = await handleContentCreate(this.db, collection, {
1652
1672
  ...body,
@@ -1672,6 +1692,14 @@ export class EmDashRuntime {
1672
1692
  status?: string;
1673
1693
  authorId?: string | null;
1674
1694
  bylines?: Array<{ bylineId: string; roleLabel?: string | null }>;
1695
+ seo?: {
1696
+ title?: string | null;
1697
+ description?: string | null;
1698
+ image?: string | null;
1699
+ canonical?: string | null;
1700
+ noIndex?: boolean;
1701
+ };
1702
+ publishedAt?: string | null;
1675
1703
  /** Skip revision creation (used by autosave) */
1676
1704
  skipRevision?: boolean;
1677
1705
  _rev?: string;
@@ -1720,6 +1748,19 @@ export class EmDashRuntime {
1720
1748
 
1721
1749
  // Normalize media fields (fill dimensions, storageKey, etc.)
1722
1750
  processedData = await this.normalizeMediaFields(collection, processedData);
1751
+
1752
+ // Validate field-level shape BEFORE the draft-revision write so
1753
+ // invalid updates can't silently land in revision history.
1754
+ const { validateContentData } = await import("./api/handlers/validation.js");
1755
+ const validation = await validateContentData(this.db, collection, processedData, {
1756
+ partial: true,
1757
+ });
1758
+ if (!validation.ok) {
1759
+ return {
1760
+ success: false as const,
1761
+ error: validation.error,
1762
+ };
1763
+ }
1723
1764
  }
1724
1765
 
1725
1766
  // Draft-aware revision handling (if collection supports revisions)
@@ -1795,12 +1836,18 @@ export class EmDashRuntime {
1795
1836
  bylines: bodyWithoutRev.bylines,
1796
1837
  });
1797
1838
 
1839
+ // Hydrate draft data BEFORE firing afterSave hooks so the hook sees
1840
+ // the same effective data the response surfaces — for revision-
1841
+ // supporting collections, that's the just-saved draft, not the live
1842
+ // columns.
1843
+ const hydrated = await this.hydrateDraftData(result);
1844
+
1798
1845
  // Run afterSave hooks (fire-and-forget)
1799
- if (result.success && result.data) {
1800
- this.runAfterSaveHooks(contentItemToRecord(result.data.item), collection, false);
1846
+ if (hydrated.success && hydrated.data) {
1847
+ this.runAfterSaveHooks(contentItemToRecord(hydrated.data.item), collection, false);
1801
1848
  }
1802
1849
 
1803
- return result;
1850
+ return hydrated;
1804
1851
  }
1805
1852
 
1806
1853
  async handleContentDelete(collection: string, id: string) {
@@ -1879,8 +1926,12 @@ export class EmDashRuntime {
1879
1926
  // Publishing & Scheduling Handlers
1880
1927
  // =========================================================================
1881
1928
 
1882
- async handleContentPublish(collection: string, id: string) {
1883
- const result = await handleContentPublish(this.db, collection, id);
1929
+ async handleContentPublish(
1930
+ collection: string,
1931
+ id: string,
1932
+ options: { publishedAt?: string } = {},
1933
+ ) {
1934
+ const result = await handleContentPublish(this.db, collection, id, options);
1884
1935
 
1885
1936
  // Run afterPublish hooks (fire-and-forget)
1886
1937
  if (result.success && result.data) {
@@ -1947,6 +1998,7 @@ export class EmDashRuntime {
1947
1998
  contentHash?: string;
1948
1999
  blurhash?: string;
1949
2000
  dominantColor?: string;
2001
+ authorId?: string;
1950
2002
  }) {
1951
2003
  // Run beforeUpload hooks
1952
2004
  let processedInput = input;
@@ -2010,7 +2062,74 @@ export class EmDashRuntime {
2010
2062
  }
2011
2063
 
2012
2064
  async handleRevisionRestore(revisionId: string, callerUserId: string) {
2013
- return handleRevisionRestore(this.db, revisionId, callerUserId);
2065
+ // Discover the parent entry up front so we can branch on whether
2066
+ // the collection uses draft revisions.
2067
+ const revisionRepo = new RevisionRepository(this.db);
2068
+ const revision = await revisionRepo.findById(revisionId);
2069
+ if (!revision) {
2070
+ return {
2071
+ success: false as const,
2072
+ error: {
2073
+ code: "NOT_FOUND",
2074
+ message: `Revision not found: ${revisionId}`,
2075
+ },
2076
+ };
2077
+ }
2078
+
2079
+ const collectionInfo = await this.schemaRegistry.getCollectionWithFields(revision.collection);
2080
+ const usesDraftRevisions = collectionInfo?.supports?.includes("revisions") ?? false;
2081
+
2082
+ // Non-revision collections: keep the legacy behavior of writing the
2083
+ // revision's data straight onto the live row. This preserves
2084
+ // behavior for collections that opt out of the draft model.
2085
+ if (!usesDraftRevisions) {
2086
+ const result = await handleRevisionRestore(this.db, revisionId, callerUserId);
2087
+ return this.hydrateDraftData(result);
2088
+ }
2089
+
2090
+ // Revision-capable collections: restore is "make this revision the
2091
+ // current draft". The live row's data columns are left untouched
2092
+ // (only `draft_revision_id` and `updated_at` change). The caller
2093
+ // must then `content_publish` to promote the restored draft to
2094
+ // live, matching the documented tool contract.
2095
+ try {
2096
+ const newDraft = await revisionRepo.create({
2097
+ collection: revision.collection,
2098
+ entryId: revision.entryId,
2099
+ data: revision.data,
2100
+ authorId: callerUserId,
2101
+ });
2102
+
2103
+ validateIdentifier(revision.collection, "collection");
2104
+ const tableName = `ec_${revision.collection}`;
2105
+ await sql`
2106
+ UPDATE ${sql.ref(tableName)}
2107
+ SET draft_revision_id = ${newDraft.id},
2108
+ updated_at = ${new Date().toISOString()}
2109
+ WHERE id = ${revision.entryId}
2110
+ `.execute(this.db);
2111
+
2112
+ // Fire-and-forget: prune old revisions to prevent unbounded growth
2113
+ void revisionRepo
2114
+ .pruneOldRevisions(revision.collection, revision.entryId, 50)
2115
+ .catch(() => {});
2116
+
2117
+ // Return the freshly-fetched item with the new draft hydrated
2118
+ // onto `data`. Without this the response would echo the live
2119
+ // columns and the next `content_get` would surface different
2120
+ // values (the bug that motivated this rewrite).
2121
+ const refetched = await handleContentGet(this.db, revision.collection, revision.entryId);
2122
+ return this.hydrateDraftData(refetched);
2123
+ } catch (error) {
2124
+ console.error("[emdash] revision restore failed:", error);
2125
+ return {
2126
+ success: false as const,
2127
+ error: {
2128
+ code: "REVISION_RESTORE_ERROR",
2129
+ message: "Failed to restore revision",
2130
+ },
2131
+ };
2132
+ }
2014
2133
  }
2015
2134
 
2016
2135
  // =========================================================================
@@ -2222,22 +2341,34 @@ export class EmDashRuntime {
2222
2341
  collection: string,
2223
2342
  isNew: boolean,
2224
2343
  ): void {
2225
- // Trusted plugins
2226
- if (this.hooks.hasHooks("content:afterSave")) {
2227
- this.hooks
2228
- .runContentAfterSave(content, collection, isNew)
2229
- .catch((err) => console.error("EmDash afterSave hook error:", err));
2230
- }
2231
-
2232
- // Sandboxed plugins
2233
- for (const [pluginKey, plugin] of this.sandboxedPlugins) {
2234
- const [id] = pluginKey.split(":");
2235
- if (!id || !this.isPluginEnabled(id)) continue;
2344
+ after(async () => {
2345
+ // Trusted plugins
2346
+ if (this.hooks.hasHooks("content:afterSave")) {
2347
+ try {
2348
+ await this.hooks.runContentAfterSave(content, collection, isNew);
2349
+ } catch (err) {
2350
+ console.error("EmDash afterSave hook error:", err);
2351
+ }
2352
+ }
2236
2353
 
2237
- plugin
2238
- .invokeHook("content:afterSave", { content, collection, isNew })
2239
- .catch((err) => console.error(`EmDash: Sandboxed plugin ${id} afterSave error:`, err));
2240
- }
2354
+ // Sandboxed plugins
2355
+ const tasks: Promise<void>[] = [];
2356
+ for (const [pluginKey, plugin] of this.sandboxedPlugins) {
2357
+ const [id] = pluginKey.split(":");
2358
+ if (!id || !this.isPluginEnabled(id)) continue;
2359
+
2360
+ tasks.push(
2361
+ (async () => {
2362
+ try {
2363
+ await plugin.invokeHook("content:afterSave", { content, collection, isNew });
2364
+ } catch (err) {
2365
+ console.error(`EmDash: Sandboxed plugin ${id} afterSave error:`, err);
2366
+ }
2367
+ })(),
2368
+ );
2369
+ }
2370
+ await Promise.allSettled(tasks);
2371
+ });
2241
2372
  }
2242
2373
 
2243
2374
  private runAfterDeleteHooks(id: string, collection: string, permanent: boolean): void {
@@ -2262,24 +2393,34 @@ export class EmDashRuntime {
2262
2393
  }
2263
2394
 
2264
2395
  private runAfterPublishHooks(content: Record<string, unknown>, collection: string): void {
2265
- // Trusted plugins
2266
- if (this.hooks.hasHooks("content:afterPublish")) {
2267
- this.hooks
2268
- .runContentAfterPublish(content, collection)
2269
- .catch((err) => console.error("EmDash afterPublish hook error:", err));
2270
- }
2271
-
2272
- // Sandboxed plugins
2273
- for (const [pluginKey, plugin] of this.sandboxedPlugins) {
2274
- const [pluginId] = pluginKey.split(":");
2275
- if (!pluginId || !this.isPluginEnabled(pluginId)) continue;
2396
+ after(async () => {
2397
+ // Trusted plugins
2398
+ if (this.hooks.hasHooks("content:afterPublish")) {
2399
+ try {
2400
+ await this.hooks.runContentAfterPublish(content, collection);
2401
+ } catch (err) {
2402
+ console.error("EmDash afterPublish hook error:", err);
2403
+ }
2404
+ }
2276
2405
 
2277
- plugin
2278
- .invokeHook("content:afterPublish", { content, collection })
2279
- .catch((err) =>
2280
- console.error(`EmDash: Sandboxed plugin ${pluginId} afterPublish error:`, err),
2406
+ // Sandboxed plugins
2407
+ const tasks: Promise<void>[] = [];
2408
+ for (const [pluginKey, plugin] of this.sandboxedPlugins) {
2409
+ const [pluginId] = pluginKey.split(":");
2410
+ if (!pluginId || !this.isPluginEnabled(pluginId)) continue;
2411
+
2412
+ tasks.push(
2413
+ (async () => {
2414
+ try {
2415
+ await plugin.invokeHook("content:afterPublish", { content, collection });
2416
+ } catch (err) {
2417
+ console.error(`EmDash: Sandboxed plugin ${pluginId} afterPublish error:`, err);
2418
+ }
2419
+ })(),
2281
2420
  );
2282
- }
2421
+ }
2422
+ await Promise.allSettled(tasks);
2423
+ });
2283
2424
  }
2284
2425
 
2285
2426
  private runAfterUnpublishHooks(content: Record<string, unknown>, collection: string): void {