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
@@ -8,9 +8,11 @@ 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,
15
+ type CollectionSupport,
14
16
  type ColumnType,
15
17
  type Field,
16
18
  type CreateCollectionInput,
@@ -49,6 +51,34 @@ function isColumnType(value: string): value is ColumnType {
49
51
  return COLUMN_TYPES.has(value);
50
52
  }
51
53
 
54
+ const VALID_COLLECTION_SUPPORTS: ReadonlySet<string> = new Set<CollectionSupport>([
55
+ "drafts",
56
+ "revisions",
57
+ "preview",
58
+ "scheduling",
59
+ "search",
60
+ "seo",
61
+ ]);
62
+
63
+ function isCollectionSupport(value: unknown): value is CollectionSupport {
64
+ return typeof value === "string" && VALID_COLLECTION_SUPPORTS.has(value);
65
+ }
66
+
67
+ /**
68
+ * Parse a collection's `supports` column (stored as a JSON array of
69
+ * CollectionSupport keys). Unknown/invalid entries are filtered out so the
70
+ * runtime value matches the declared `CollectionSupport[]` type.
71
+ *
72
+ * Throws on malformed JSON so corruption surfaces loudly; returns an empty
73
+ * array only for explicitly null/empty values or non-array JSON.
74
+ */
75
+ function parseSupports(raw: string | null | undefined): CollectionSupport[] {
76
+ if (!raw) return [];
77
+ const parsed: unknown = JSON.parse(raw);
78
+ if (!Array.isArray(parsed)) return [];
79
+ return parsed.filter(isCollectionSupport);
80
+ }
81
+
52
82
  /**
53
83
  * Error thrown when a schema operation fails
54
84
  */
@@ -114,6 +144,61 @@ export class SchemaRegistry {
114
144
  return { ...collection, fields };
115
145
  }
116
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
+
117
202
  /**
118
203
  * Create a new collection
119
204
  */
@@ -132,11 +217,18 @@ export class SchemaRegistry {
132
217
 
133
218
  const id = ulid();
134
219
 
220
+ // Default `supports` to drafts + revisions when the caller didn't
221
+ // specify it. Explicit empty array (`[]`) is preserved as an opt-out
222
+ // — only `undefined` triggers the default. This is the canonical
223
+ // default for new collections; the MCP and admin UI layers used to
224
+ // duplicate this default but now defer to the registry.
225
+ const supports = input.supports ?? ["drafts", "revisions"];
226
+
135
227
  // Insert collection record and create content table in a transaction
136
228
  // so a failure in table creation doesn't leave an orphaned row.
137
229
  // Uses withTransaction for D1 compatibility (no transaction support).
138
230
  // Derive hasSeo from supports array if not explicitly set
139
- const hasSeo = input.hasSeo ?? input.supports?.includes("seo") ?? false;
231
+ const hasSeo = input.hasSeo ?? supports.includes("seo") ?? false;
140
232
 
141
233
  await withTransaction(this.db, async (trx) => {
142
234
  await trx
@@ -148,7 +240,7 @@ export class SchemaRegistry {
148
240
  label_singular: input.labelSingular ?? null,
149
241
  description: input.description ?? null,
150
242
  icon: input.icon ?? null,
151
- supports: input.supports ? JSON.stringify(input.supports) : null,
243
+ supports: JSON.stringify(supports),
152
244
  source: input.source ?? "manual",
153
245
  has_seo: hasSeo ? 1 : 0,
154
246
  comments_enabled: input.commentsEnabled ? 1 : 0,
@@ -243,7 +335,7 @@ export class SchemaRegistry {
243
335
  // Sync FTS state when the supports array changes (e.g. search toggled on/off)
244
336
  if (input.supports !== undefined) {
245
337
  const hadSearch = existing.supports.includes("search");
246
- const hasSearch = (JSON.parse(row.supports ?? "[]") as string[]).includes("search");
338
+ const hasSearch = parseSupports(row.supports).includes("search");
247
339
  if (hadSearch !== hasSearch) {
248
340
  await this.syncSearchState(slug, trx);
249
341
  }
@@ -525,7 +617,7 @@ export class SchemaRegistry {
525
617
  .executeTakeFirst();
526
618
  if (!row) return;
527
619
 
528
- const wantsSearch = (JSON.parse(row.supports ?? "[]") as string[]).includes("search");
620
+ const wantsSearch = parseSupports(row.supports).includes("search");
529
621
  const searchableFields = await ftsManager.getSearchableFields(collectionSlug);
530
622
  const config = await ftsManager.getSearchConfig(collectionSlug);
531
623
  const ftsActive = config?.enabled === true;
@@ -881,7 +973,7 @@ export class SchemaRegistry {
881
973
  labelSingular: row.label_singular ?? undefined,
882
974
  description: row.description ?? undefined,
883
975
  icon: row.icon ?? undefined,
884
- supports: row.supports ? JSON.parse(row.supports) : [],
976
+ supports: parseSupports(row.supports),
885
977
  source: row.source && isCollectionSource(row.source) ? row.source : undefined,
886
978
  hasSeo: row.has_seo === 1,
887
979
  urlPattern: row.url_pattern ?? undefined,
@@ -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
  );
@@ -418,8 +418,6 @@ export class FTSManager {
418
418
  console.warn(
419
419
  `FTS index for "${collectionSlug}" has ${ftsRows} rows but content table has ${contentRows}. Rebuilding.`,
420
420
  );
421
- const fields = await this.getSearchableFields(collectionSlug);
422
- const config = await this.getSearchConfig(collectionSlug);
423
421
  if (fields.length > 0) {
424
422
  await this.rebuildIndex(collectionSlug, fields, config?.weights);
425
423
  }
@@ -26,6 +26,23 @@ const WHITESPACE_SPLIT_PATTERN = /\s+/;
26
26
  const FTS_OPERATORS_PATTERN = /\b(AND|OR|NOT|NEAR)\b/i;
27
27
  const DOUBLE_QUOTE_PATTERN = /"/g;
28
28
 
29
+ /**
30
+ * Detect FTS5 query syntax errors. Match specifically on the SQLite FTS5
31
+ * error fingerprints rather than a broad "fts5" / "syntax error" filter
32
+ * (which would also swallow internal table-corruption errors). The two
33
+ * fingerprints we care about are:
34
+ *
35
+ * - "fts5: syntax error near …" — unbalanced quotes, stray operators,
36
+ * other malformed user input
37
+ * - "unknown special query: …" — bare special tokens like `^*` that
38
+ * parse but don't resolve to a real FTS5 directive
39
+ */
40
+ function isFts5SyntaxError(error: unknown): boolean {
41
+ if (!(error instanceof Error)) return false;
42
+ const message = error.message.toLowerCase();
43
+ return message.includes("fts5: syntax error") || message.includes("unknown special query");
44
+ }
45
+
29
46
  /**
30
47
  * Search across multiple collections
31
48
  *
@@ -198,14 +215,16 @@ async function searchSingleCollection(
198
215
  const bm25Expr = bm25Args ? `bm25("${ftsTable}", ${bm25Args})` : `bm25("${ftsTable}")`;
199
216
 
200
217
  // Snippet column index is 2 (after id=0, locale=1, first searchable field=2)
201
- const results = await sql<{
202
- id: string;
203
- slug: string | null;
204
- locale: string;
205
- title: string | null;
206
- snippet: string;
207
- score: number;
208
- }>`
218
+ let results;
219
+ try {
220
+ results = await sql<{
221
+ id: string;
222
+ slug: string | null;
223
+ locale: string;
224
+ title: string | null;
225
+ snippet: string | null;
226
+ score: number;
227
+ }>`
209
228
  SELECT
210
229
  c.id,
211
230
  c.slug,
@@ -222,6 +241,20 @@ async function searchSingleCollection(
222
241
  ORDER BY score
223
242
  LIMIT ${limit}
224
243
  `.execute(db);
244
+ } catch (error) {
245
+ // FTS5 returns syntax errors for queries with unbalanced quotes,
246
+ // stray operators, or other malformed input. Treat these as
247
+ // "no matches" so the user gets an empty result rather than an
248
+ // internals-leaking error. Other errors (table missing, IO) still
249
+ // propagate. Intentionally not logged: any anonymous client can
250
+ // trigger this path, and the underlying error message embeds the
251
+ // raw query, so logging would be both noisy and a log-injection
252
+ // vector.
253
+ if (isFts5SyntaxError(error)) {
254
+ return [];
255
+ }
256
+ throw error;
257
+ }
225
258
 
226
259
  return results.rows.map((row) => ({
227
260
  collection,
@@ -229,11 +262,51 @@ async function searchSingleCollection(
229
262
  slug: row.slug,
230
263
  locale: row.locale,
231
264
  title: row.title ?? undefined,
232
- snippet: row.snippet,
265
+ // SQLite's snippet() returns NULL when the targeted column is
266
+ // NULL for that row — even if the row matched via a different
267
+ // searchable column. Skip sanitization in that case so we don't
268
+ // throw on `null.replace`. The SearchResult.snippet field is
269
+ // already optional, so omitting it is the documented contract.
270
+ snippet: row.snippet === null ? undefined : sanitizeSnippet(row.snippet),
233
271
  score: Math.abs(row.score), // bm25 returns negative scores
234
272
  }));
235
273
  }
236
274
 
275
+ // Module-scope regexes so the engine doesn't recompile per call —
276
+ // snippet sanitization runs on every search result.
277
+ const SNIPPET_AMP_RE = /&/g;
278
+ const SNIPPET_LT_RE = /</g;
279
+ const SNIPPET_GT_RE = />/g;
280
+ const SNIPPET_QUOT_RE = /"/g;
281
+ const SNIPPET_APOS_RE = /'/g;
282
+
283
+ /**
284
+ * Make an FTS5 snippet safe to render with `set:html` / `innerHTML`.
285
+ *
286
+ * SQLite's `snippet()` function splices literal `<mark>` and `</mark>`
287
+ * markers around matched terms but does not escape the surrounding
288
+ * source text. Posts that legitimately contain `<`, `>`, `&`, `"` or
289
+ * `'` would render as broken markup, and a `<script>` literal in a
290
+ * title (or any other indexed field) would execute when displayed.
291
+ *
292
+ * The fix: HTML-escape the whole string, which turns the markers into
293
+ * `&lt;mark&gt;` / `&lt;/mark&gt;`. Then restore those two patterns to
294
+ * their original tag form. The result is "the indexed text with all
295
+ * HTML metacharacters escaped, plus a small set of literal `<mark>`
296
+ * highlight tags around matched terms" — which matches the API's
297
+ * documented contract.
298
+ */
299
+ function sanitizeSnippet(snippet: string): string {
300
+ return snippet
301
+ .replace(SNIPPET_AMP_RE, "&amp;")
302
+ .replace(SNIPPET_LT_RE, "&lt;")
303
+ .replace(SNIPPET_GT_RE, "&gt;")
304
+ .replace(SNIPPET_QUOT_RE, "&quot;")
305
+ .replace(SNIPPET_APOS_RE, "&#39;")
306
+ .replaceAll("&lt;mark&gt;", "<mark>")
307
+ .replaceAll("&lt;/mark&gt;", "</mark>");
308
+ }
309
+
237
310
  /**
238
311
  * Get search suggestions for autocomplete
239
312
  *
@@ -282,23 +355,35 @@ export async function getSuggestions(
282
355
  continue;
283
356
  }
284
357
 
285
- const results = await sql<{
286
- id: string;
287
- title: string;
288
- }>`
289
- SELECT
290
- c.id,
291
- c.title
292
- FROM "${sql.raw(ftsTable)}" f
293
- JOIN "${sql.raw(contentTable)}" c ON f.id = c.id
294
- WHERE "${sql.raw(ftsTable)}" MATCH ${prefixQuery}
295
- AND c.status = 'published'
296
- AND c.deleted_at IS NULL
297
- AND c.title IS NOT NULL
298
- ${locale ? sql`AND c.locale = ${locale}` : sql``}
299
- ORDER BY bm25("${sql.raw(ftsTable)}")
300
- LIMIT ${limit}
301
- `.execute(db);
358
+ let results;
359
+ try {
360
+ results = await sql<{
361
+ id: string;
362
+ title: string;
363
+ }>`
364
+ SELECT
365
+ c.id,
366
+ c.title
367
+ FROM "${sql.raw(ftsTable)}" f
368
+ JOIN "${sql.raw(contentTable)}" c ON f.id = c.id
369
+ WHERE "${sql.raw(ftsTable)}" MATCH ${prefixQuery}
370
+ AND c.status = 'published'
371
+ AND c.deleted_at IS NULL
372
+ AND c.title IS NOT NULL
373
+ ${locale ? sql`AND c.locale = ${locale}` : sql``}
374
+ ORDER BY bm25("${sql.raw(ftsTable)}")
375
+ LIMIT ${limit}
376
+ `.execute(db);
377
+ } catch (error) {
378
+ // Same swallow as searchSingleCollection: malformed prefix
379
+ // queries should yield no suggestions, not surface DB errors.
380
+ // Intentionally not logged (anonymous-triggerable, echoes
381
+ // user input -- see searchSingleCollection for rationale).
382
+ if (isFts5SyntaxError(error)) {
383
+ continue;
384
+ }
385
+ throw error;
386
+ }
302
387
 
303
388
  for (const row of results.rows) {
304
389
  suggestions.push({
@@ -58,7 +58,14 @@ export interface SearchResult {
58
58
  locale: string;
59
59
  /** Entry title (if available) */
60
60
  title?: string;
61
- /** Highlighted snippet showing match context */
61
+ /**
62
+ * Highlighted snippet showing match context.
63
+ *
64
+ * Sanitized server-side to be safe for `set:html` / `innerHTML`:
65
+ * all HTML metacharacters in the source text are escaped, and
66
+ * matched terms are wrapped in literal `<mark>...</mark>` tags
67
+ * (the only HTML the snippet is allowed to contain).
68
+ */
62
69
  snippet?: string;
63
70
  /** Relevance score (higher = more relevant) */
64
71
  score: number;
@@ -137,17 +137,15 @@ export async function getSectionsWithDb(
137
137
  // Order by title ASC, id ASC for stable cursor pagination
138
138
  query = query.orderBy("title", "asc").orderBy("id", "asc");
139
139
 
140
- // Cursor-based pagination
140
+ // Cursor-based pagination — throws on invalid cursor.
141
141
  if (options.cursor) {
142
142
  const decoded = decodeCursor(options.cursor);
143
- if (decoded) {
144
- query = query.where((eb) =>
145
- eb.or([
146
- eb("title", ">", decoded.orderValue),
147
- eb.and([eb("title", "=", decoded.orderValue), eb("id", ">", decoded.id)]),
148
- ]),
149
- );
150
- }
143
+ query = query.where((eb) =>
144
+ eb.or([
145
+ eb("title", ">", decoded.orderValue),
146
+ eb.and([eb("title", "=", decoded.orderValue), eb("id", ">", decoded.id)]),
147
+ ]),
148
+ );
151
149
  }
152
150
 
153
151
  query = query.limit(limit + 1);
package/src/seed/apply.ts CHANGED
@@ -927,6 +927,8 @@ async function applyWidget(
927
927
  sort_order: sortOrder,
928
928
  type: widget.type,
929
929
  title: widget.title ?? null,
930
+ // `widget.content` is Portable Text for content-type widgets;
931
+ // for other widget kinds it's null.
930
932
  content: widget.content ? JSON.stringify(widget.content) : null,
931
933
  menu_name: widget.menuName ?? null,
932
934
  component_id: widget.componentId ?? null,
@@ -18,6 +18,53 @@ import type { SiteSettings, SiteSettingKey, MediaReference } from "./types.js";
18
18
  /** Prefix for site settings in the options table */
19
19
  const SETTINGS_PREFIX = "site:";
20
20
 
21
+ /**
22
+ * Worker-isolate cache for the resolved `site:*` settings.
23
+ *
24
+ * Site settings (title, logo, SEO defaults) change rarely but are read on
25
+ * every public request. Caching across the isolate's lifetime drops the
26
+ * `options WHERE name LIKE 'site:%'` prefix scan from once-per-request to
27
+ * once-per-isolate. Cross-isolate staleness is bounded by isolate lifetime
28
+ * (workerd typically recycles within minutes); acceptable for chrome.
29
+ *
30
+ * Stored on globalThis with a Symbol.for key so Vite SSR chunk duplication
31
+ * doesn't produce two independent caches (same pattern as request-context.ts).
32
+ *
33
+ * Invalidation: every `site:*` write bumps `version`. Reads compare the
34
+ * cached promise's version against the current version and refetch on
35
+ * mismatch. Caching the promise (not the resolved value) lets concurrent
36
+ * cold-isolate readers share the in-flight query.
37
+ */
38
+ interface SiteSettingsHolder {
39
+ version: number;
40
+ cached: Promise<Partial<SiteSettings>> | null;
41
+ cachedVersion: number;
42
+ }
43
+
44
+ const SITE_SETTINGS_CACHE_KEY = Symbol.for("emdash:site-settings");
45
+ const g = globalThis as Record<symbol, unknown>;
46
+ const holder: SiteSettingsHolder =
47
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- globalThis singleton pattern (see request-context.ts)
48
+ (g[SITE_SETTINGS_CACHE_KEY] as SiteSettingsHolder | undefined) ??
49
+ (() => {
50
+ const h: SiteSettingsHolder = { version: 0, cached: null, cachedVersion: -1 };
51
+ g[SITE_SETTINGS_CACHE_KEY] = h;
52
+ return h;
53
+ })();
54
+
55
+ /**
56
+ * Bump the isolate-wide site-settings cache version, forcing the next
57
+ * `getSiteSettings()` to re-query the database.
58
+ *
59
+ * Called from every `site:*` write path. Other isolates still serve their
60
+ * own cached copy until they expire — staleness bounded by isolate lifetime.
61
+ */
62
+ export function invalidateSiteSettingsCache(): void {
63
+ holder.version++;
64
+ holder.cached = null;
65
+ holder.cachedVersion = -1;
66
+ }
67
+
21
68
  /**
22
69
  * Type guard for MediaReference values
23
70
  */
@@ -26,13 +73,18 @@ function isMediaReference(value: unknown): value is MediaReference {
26
73
  }
27
74
 
28
75
  /**
29
- * Resolve a media reference to include the full URL
76
+ * Resolve a media reference to include the full URL plus content metadata.
77
+ *
78
+ * Pulls `mimeType` and intrinsic dimensions from the media row so callers
79
+ * can emit correct head tags (e.g. `<link rel="icon" type="image/svg+xml">`,
80
+ * which Chromium requires when the URL has no `.svg` extension) without
81
+ * a second round-trip to the media table.
30
82
  */
31
83
  async function resolveMediaReference(
32
84
  mediaRef: MediaReference | undefined,
33
85
  db: Kysely<Database>,
34
86
  _storage: Storage | null,
35
- ): Promise<(MediaReference & { url?: string }) | undefined> {
87
+ ): Promise<MediaReference | undefined> {
36
88
  if (!mediaRef?.mediaId) {
37
89
  return mediaRef;
38
90
  }
@@ -46,6 +98,9 @@ async function resolveMediaReference(
46
98
  return {
47
99
  ...mediaRef,
48
100
  url: `/_emdash/api/media/file/${media.storageKey}`,
101
+ contentType: media.mimeType,
102
+ ...(media.width !== null ? { width: media.width } : {}),
103
+ ...(media.height !== null ? { height: media.height } : {}),
49
104
  };
50
105
  }
51
106
  } catch {
@@ -142,9 +197,24 @@ export async function getSiteSettingWithDb<K extends SiteSettingKey>(
142
197
  * ```
143
198
  */
144
199
  export function getSiteSettings(): Promise<Partial<SiteSettings>> {
145
- return requestCached("siteSettings", async () => {
146
- const db = await getDb();
147
- return getSiteSettingsWithDb(db);
200
+ return requestCached("siteSettings", () => {
201
+ const versionAtCall = holder.version;
202
+ if (holder.cached && holder.cachedVersion === versionAtCall) {
203
+ return holder.cached;
204
+ }
205
+ const fetchPromise = (async () => {
206
+ const db = await getDb();
207
+ return getSiteSettingsWithDb(db);
208
+ })().catch((error) => {
209
+ if (holder.cached === fetchPromise) {
210
+ holder.cached = null;
211
+ holder.cachedVersion = -1;
212
+ }
213
+ throw error;
214
+ });
215
+ holder.cached = fetchPromise;
216
+ holder.cachedVersion = versionAtCall;
217
+ return fetchPromise;
148
218
  });
149
219
  }
150
220
 
@@ -218,7 +288,11 @@ export async function setSiteSettings(
218
288
  }
219
289
  }
220
290
 
221
- await options.setMany(updates);
291
+ try {
292
+ await options.setMany(updates);
293
+ } finally {
294
+ invalidateSiteSettingsCache();
295
+ }
222
296
  }
223
297
 
224
298
  /**
@@ -4,10 +4,32 @@
4
4
  * Global configuration for the site (title, logo, social links, etc.)
5
5
  */
6
6
 
7
- /** Media reference for logo/favicon */
7
+ /**
8
+ * Media reference for logo/favicon.
9
+ *
10
+ * Stored shape is just `{ mediaId, alt? }`. The remaining fields are
11
+ * populated by `resolveMediaReference` on read so templates can emit
12
+ * correct head tags without a second round-trip to the media table.
13
+ *
14
+ * The Zod schemas at the REST/MCP boundary (`mediaReference`) define
15
+ * only `mediaId` and `alt` and rely on default strip-mode parsing to
16
+ * discard the resolved fields if a client posts them back. If you
17
+ * ever switch those schemas to `passthrough`, you must also strip the
18
+ * resolved fields explicitly in `setSiteSettings`, or stored options
19
+ * will accumulate stale `url` / `contentType` / `width` / `height`
20
+ * snapshots.
21
+ */
8
22
  export interface MediaReference {
9
23
  mediaId: string;
10
24
  alt?: string;
25
+ /** Resolved URL. Populated by `resolveMediaReference`; absent on raw stored values. */
26
+ url?: string;
27
+ /** Stored MIME type (e.g. `image/svg+xml`). Populated alongside `url`. */
28
+ contentType?: string;
29
+ /** Pixel width if known. Populated alongside `url`. */
30
+ width?: number;
31
+ /** Pixel height if known. Populated alongside `url`. */
32
+ height?: number;
11
33
  }
12
34
 
13
35
  /** Site-level SEO settings */
package/src/storage/s3.ts CHANGED
@@ -7,6 +7,7 @@
7
7
 
8
8
  import {
9
9
  S3Client,
10
+ type S3ClientConfig,
10
11
  PutObjectCommand,
11
12
  GetObjectCommand,
12
13
  DeleteObjectCommand,
@@ -131,9 +132,14 @@ export class S3Storage implements Storage {
131
132
  this.publicUrl = config.publicUrl;
132
133
  this.endpoint = config.endpoint;
133
134
 
134
- this.client = new S3Client({
135
+ // S3ClientConfig types `credentials` as required, but the SDK accepts
136
+ // omitted credentials at runtime (falls back to the provider chain).
137
+ /* eslint-disable typescript-eslint(no-unsafe-type-assertion) -- upstream @aws-sdk/client-s3 overstates required fields */
138
+ const clientConfig = {
135
139
  endpoint: config.endpoint,
136
140
  region: config.region || "auto",
141
+ // Required for R2 and some S3-compatible services
142
+ forcePathStyle: true,
137
143
  ...(config.accessKeyId && config.secretAccessKey
138
144
  ? {
139
145
  credentials: {
@@ -142,9 +148,9 @@ export class S3Storage implements Storage {
142
148
  },
143
149
  }
144
150
  : {}),
145
- // Required for R2 and some S3-compatible services
146
- forcePathStyle: true,
147
- } as ConstructorParameters<typeof S3Client>[0]);
151
+ } as S3ClientConfig;
152
+ /* eslint-enable typescript-eslint(no-unsafe-type-assertion) */
153
+ this.client = new S3Client(clientConfig);
148
154
  }
149
155
 
150
156
  async upload(options: {
@@ -317,8 +323,8 @@ export class S3Storage implements Storage {
317
323
  if (this.publicUrl) {
318
324
  return `${this.publicUrl.replace(TRAILING_SLASH_PATTERN, "")}/${key}`;
319
325
  }
320
- // Default to endpoint + bucket + key
321
- return `${this.endpoint.replace(TRAILING_SLASH_PATTERN, "")}/${this.bucket}/${key}`;
326
+ // No public URL configured; defer to the /_emdash/api/media/file route.
327
+ return `/_emdash/api/media/file/${key}`;
322
328
  }
323
329
  }
324
330