emdash 0.0.0-b → 0.0.2

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 (661) hide show
  1. package/README.md +87 -43
  2. package/dist/adapters-BLMa4JGD.d.mts +106 -0
  3. package/dist/adapters-BLMa4JGD.d.mts.map +1 -0
  4. package/dist/apply-Bjfq_b4-.mjs +1293 -0
  5. package/dist/apply-Bjfq_b4-.mjs.map +1 -0
  6. package/dist/astro/index.d.mts +51 -0
  7. package/dist/astro/index.d.mts.map +1 -0
  8. package/dist/astro/index.mjs +1336 -0
  9. package/dist/astro/index.mjs.map +1 -0
  10. package/dist/astro/middleware/auth.d.mts +31 -0
  11. package/dist/astro/middleware/auth.d.mts.map +1 -0
  12. package/dist/astro/middleware/auth.mjs +654 -0
  13. package/dist/astro/middleware/auth.mjs.map +1 -0
  14. package/dist/astro/middleware/redirect.d.mts +22 -0
  15. package/dist/astro/middleware/redirect.d.mts.map +1 -0
  16. package/dist/astro/middleware/redirect.mjs +63 -0
  17. package/dist/astro/middleware/redirect.mjs.map +1 -0
  18. package/dist/astro/middleware/request-context.d.mts +18 -0
  19. package/dist/astro/middleware/request-context.d.mts.map +1 -0
  20. package/dist/astro/middleware/request-context.mjs +1310 -0
  21. package/dist/astro/middleware/request-context.mjs.map +1 -0
  22. package/dist/astro/middleware/setup.d.mts +20 -0
  23. package/dist/astro/middleware/setup.d.mts.map +1 -0
  24. package/dist/astro/middleware/setup.mjs +47 -0
  25. package/dist/astro/middleware/setup.mjs.map +1 -0
  26. package/dist/astro/middleware.d.mts +13 -0
  27. package/dist/astro/middleware.d.mts.map +1 -0
  28. package/dist/astro/middleware.mjs +1613 -0
  29. package/dist/astro/middleware.mjs.map +1 -0
  30. package/dist/astro/types.d.mts +250 -0
  31. package/dist/astro/types.d.mts.map +1 -0
  32. package/dist/astro/types.mjs +1 -0
  33. package/dist/base64-MBPo9ozB.mjs +59 -0
  34. package/dist/base64-MBPo9ozB.mjs.map +1 -0
  35. package/dist/byline-CL847F26.mjs +213 -0
  36. package/dist/byline-CL847F26.mjs.map +1 -0
  37. package/dist/bylines-C2a-2TGt.mjs +136 -0
  38. package/dist/bylines-C2a-2TGt.mjs.map +1 -0
  39. package/dist/chunk-ClPoSABd.mjs +21 -0
  40. package/dist/cli/index.d.mts +1 -0
  41. package/dist/cli/index.mjs +3909 -0
  42. package/dist/cli/index.mjs.map +1 -0
  43. package/dist/client/cf-access.d.mts +60 -0
  44. package/dist/client/cf-access.d.mts.map +1 -0
  45. package/dist/client/cf-access.mjs +179 -0
  46. package/dist/client/cf-access.mjs.map +1 -0
  47. package/dist/client/index.d.mts +398 -0
  48. package/dist/client/index.d.mts.map +1 -0
  49. package/dist/client/index.mjs +346 -0
  50. package/dist/client/index.mjs.map +1 -0
  51. package/dist/config-CKE8p9xM.mjs +55 -0
  52. package/dist/config-CKE8p9xM.mjs.map +1 -0
  53. package/dist/connection-B4zVnQIa.mjs +40 -0
  54. package/dist/connection-B4zVnQIa.mjs.map +1 -0
  55. package/dist/content-D6C2WsZC.mjs +824 -0
  56. package/dist/content-D6C2WsZC.mjs.map +1 -0
  57. package/dist/db/index.d.mts +4 -0
  58. package/dist/db/index.mjs +62 -0
  59. package/dist/db/index.mjs.map +1 -0
  60. package/dist/db/libsql.d.mts +11 -0
  61. package/dist/db/libsql.d.mts.map +1 -0
  62. package/dist/db/libsql.mjs +17 -0
  63. package/dist/db/libsql.mjs.map +1 -0
  64. package/dist/db/postgres.d.mts +11 -0
  65. package/dist/db/postgres.d.mts.map +1 -0
  66. package/dist/db/postgres.mjs +30 -0
  67. package/dist/db/postgres.mjs.map +1 -0
  68. package/dist/db/sqlite.d.mts +11 -0
  69. package/dist/db/sqlite.d.mts.map +1 -0
  70. package/dist/db/sqlite.mjs +16 -0
  71. package/dist/db/sqlite.mjs.map +1 -0
  72. package/dist/default-Cyi4aAxu.mjs +81 -0
  73. package/dist/default-Cyi4aAxu.mjs.map +1 -0
  74. package/dist/dialect-helpers-B9uSp2GJ.mjs +90 -0
  75. package/dist/dialect-helpers-B9uSp2GJ.mjs.map +1 -0
  76. package/dist/error-Cxz0tQeO.mjs +27 -0
  77. package/dist/error-Cxz0tQeO.mjs.map +1 -0
  78. package/dist/index-C1xF3OGh.d.mts +4527 -0
  79. package/dist/index-C1xF3OGh.d.mts.map +1 -0
  80. package/dist/index.d.mts +16 -0
  81. package/dist/index.mjs +30 -0
  82. package/dist/load-yOOlckBj.mjs +28 -0
  83. package/dist/load-yOOlckBj.mjs.map +1 -0
  84. package/dist/loader-fz8Q_3EO.mjs +447 -0
  85. package/dist/loader-fz8Q_3EO.mjs.map +1 -0
  86. package/dist/manifest-schema-Dcl0R6nM.mjs +184 -0
  87. package/dist/manifest-schema-Dcl0R6nM.mjs.map +1 -0
  88. package/dist/media/index.d.mts +26 -0
  89. package/dist/media/index.d.mts.map +1 -0
  90. package/dist/media/index.mjs +55 -0
  91. package/dist/media/index.mjs.map +1 -0
  92. package/dist/media/local-runtime.d.mts +39 -0
  93. package/dist/media/local-runtime.d.mts.map +1 -0
  94. package/dist/media/local-runtime.mjs +133 -0
  95. package/dist/media/local-runtime.mjs.map +1 -0
  96. package/dist/media-DqHVh136.mjs +200 -0
  97. package/dist/media-DqHVh136.mjs.map +1 -0
  98. package/dist/mode-C2EzN1uE.mjs +23 -0
  99. package/dist/mode-C2EzN1uE.mjs.map +1 -0
  100. package/dist/page/index.d.mts +140 -0
  101. package/dist/page/index.d.mts.map +1 -0
  102. package/dist/page/index.mjs +416 -0
  103. package/dist/page/index.mjs.map +1 -0
  104. package/dist/placeholder-CmGAmqeO.d.mts +276 -0
  105. package/dist/placeholder-CmGAmqeO.d.mts.map +1 -0
  106. package/dist/placeholder-SmpOx-_v.mjs +243 -0
  107. package/dist/placeholder-SmpOx-_v.mjs.map +1 -0
  108. package/dist/plugin-utils.d.mts +58 -0
  109. package/dist/plugin-utils.d.mts.map +1 -0
  110. package/dist/plugin-utils.mjs +78 -0
  111. package/dist/plugin-utils.mjs.map +1 -0
  112. package/dist/plugins/adapt-sandbox-entry.d.mts +22 -0
  113. package/dist/plugins/adapt-sandbox-entry.d.mts.map +1 -0
  114. package/dist/plugins/adapt-sandbox-entry.mjs +113 -0
  115. package/dist/plugins/adapt-sandbox-entry.mjs.map +1 -0
  116. package/dist/query-CS_iSj34.mjs +460 -0
  117. package/dist/query-CS_iSj34.mjs.map +1 -0
  118. package/dist/redirect-DIfIni3r.mjs +329 -0
  119. package/dist/redirect-DIfIni3r.mjs.map +1 -0
  120. package/dist/registry-D_w5HW4G.mjs +863 -0
  121. package/dist/registry-D_w5HW4G.mjs.map +1 -0
  122. package/dist/request-context.d.mts +49 -0
  123. package/dist/request-context.d.mts.map +1 -0
  124. package/dist/request-context.mjs +43 -0
  125. package/dist/request-context.mjs.map +1 -0
  126. package/dist/runner-C0hCbYnD.mjs +1412 -0
  127. package/dist/runner-C0hCbYnD.mjs.map +1 -0
  128. package/dist/runner-EAtf0ZIe.d.mts +27 -0
  129. package/dist/runner-EAtf0ZIe.d.mts.map +1 -0
  130. package/dist/runtime.d.mts +26 -0
  131. package/dist/runtime.d.mts.map +1 -0
  132. package/dist/runtime.mjs +42 -0
  133. package/dist/runtime.mjs.map +1 -0
  134. package/dist/search-DG603UrT.mjs +9211 -0
  135. package/dist/search-DG603UrT.mjs.map +1 -0
  136. package/dist/seed/index.d.mts +3 -0
  137. package/dist/seed/index.mjs +15 -0
  138. package/dist/seo/index.d.mts +70 -0
  139. package/dist/seo/index.d.mts.map +1 -0
  140. package/dist/seo/index.mjs +70 -0
  141. package/dist/seo/index.mjs.map +1 -0
  142. package/dist/storage/local.d.mts +39 -0
  143. package/dist/storage/local.d.mts.map +1 -0
  144. package/dist/storage/local.mjs +166 -0
  145. package/dist/storage/local.mjs.map +1 -0
  146. package/dist/storage/s3.d.mts +32 -0
  147. package/dist/storage/s3.d.mts.map +1 -0
  148. package/dist/storage/s3.mjs +175 -0
  149. package/dist/storage/s3.mjs.map +1 -0
  150. package/dist/tokens-DpgrkrXK.mjs +171 -0
  151. package/dist/tokens-DpgrkrXK.mjs.map +1 -0
  152. package/dist/transport-BFGblqwG.d.mts +42 -0
  153. package/dist/transport-BFGblqwG.d.mts.map +1 -0
  154. package/dist/transport-yxiQsi8I.mjs +418 -0
  155. package/dist/transport-yxiQsi8I.mjs.map +1 -0
  156. package/dist/types-BRuPJGdV.d.mts +102 -0
  157. package/dist/types-BRuPJGdV.d.mts.map +1 -0
  158. package/dist/types-C4-fAxN3.d.mts +182 -0
  159. package/dist/types-C4-fAxN3.d.mts.map +1 -0
  160. package/dist/types-CMMN0pNg.mjs +31 -0
  161. package/dist/types-CMMN0pNg.mjs.map +1 -0
  162. package/dist/types-CUBbjgmP.mjs +16 -0
  163. package/dist/types-CUBbjgmP.mjs.map +1 -0
  164. package/dist/types-DRjfYOEv.d.mts +426 -0
  165. package/dist/types-DRjfYOEv.d.mts.map +1 -0
  166. package/dist/types-DY5zk5HN.mjs +73 -0
  167. package/dist/types-DY5zk5HN.mjs.map +1 -0
  168. package/dist/types-DaNLHo_T.d.mts +184 -0
  169. package/dist/types-DaNLHo_T.d.mts.map +1 -0
  170. package/dist/types-DvhsUmSJ.d.mts +1111 -0
  171. package/dist/types-DvhsUmSJ.d.mts.map +1 -0
  172. package/dist/validate-CpBtVMsD.d.mts +378 -0
  173. package/dist/validate-CpBtVMsD.d.mts.map +1 -0
  174. package/dist/validate-CqRJb_xU.mjs +97 -0
  175. package/dist/validate-CqRJb_xU.mjs.map +1 -0
  176. package/dist/validate-O7PWmlnq.mjs +328 -0
  177. package/dist/validate-O7PWmlnq.mjs.map +1 -0
  178. package/locals.d.ts +46 -0
  179. package/package.json +233 -19
  180. package/src/api/authorize.ts +63 -0
  181. package/src/api/csrf.ts +48 -0
  182. package/src/api/error.ts +99 -0
  183. package/src/api/errors.ts +445 -0
  184. package/src/api/escape.ts +9 -0
  185. package/src/api/handlers/api-tokens.ts +240 -0
  186. package/src/api/handlers/comments.ts +314 -0
  187. package/src/api/handlers/content.ts +1315 -0
  188. package/src/api/handlers/dashboard.ts +205 -0
  189. package/src/api/handlers/device-flow.ts +684 -0
  190. package/src/api/handlers/index.ts +163 -0
  191. package/src/api/handlers/manifest.ts +158 -0
  192. package/src/api/handlers/marketplace.ts +930 -0
  193. package/src/api/handlers/media.ts +207 -0
  194. package/src/api/handlers/menus.ts +493 -0
  195. package/src/api/handlers/oauth-authorization.ts +429 -0
  196. package/src/api/handlers/oauth-clients.ts +349 -0
  197. package/src/api/handlers/oauth-user-lookup.ts +39 -0
  198. package/src/api/handlers/plugins.ts +254 -0
  199. package/src/api/handlers/redirects.ts +360 -0
  200. package/src/api/handlers/revision.ts +145 -0
  201. package/src/api/handlers/schema.ts +534 -0
  202. package/src/api/handlers/sections.ts +289 -0
  203. package/src/api/handlers/seo.ts +115 -0
  204. package/src/api/handlers/settings.ts +49 -0
  205. package/src/api/handlers/snapshot.ts +350 -0
  206. package/src/api/handlers/taxonomies.ts +523 -0
  207. package/src/api/index.ts +6 -0
  208. package/src/api/openapi/document.ts +2368 -0
  209. package/src/api/openapi/index.ts +1 -0
  210. package/src/api/parse.ts +139 -0
  211. package/src/api/redirect.ts +14 -0
  212. package/src/api/rev.ts +67 -0
  213. package/src/api/schemas/auth.ts +112 -0
  214. package/src/api/schemas/bylines.ts +85 -0
  215. package/src/api/schemas/comments.ts +117 -0
  216. package/src/api/schemas/common.ts +89 -0
  217. package/src/api/schemas/content.ts +191 -0
  218. package/src/api/schemas/import.ts +52 -0
  219. package/src/api/schemas/index.ts +17 -0
  220. package/src/api/schemas/media.ts +116 -0
  221. package/src/api/schemas/menus.ts +111 -0
  222. package/src/api/schemas/redirects.ts +155 -0
  223. package/src/api/schemas/schema.ts +203 -0
  224. package/src/api/schemas/search.ts +63 -0
  225. package/src/api/schemas/sections.ts +67 -0
  226. package/src/api/schemas/settings.ts +63 -0
  227. package/src/api/schemas/setup.ts +37 -0
  228. package/src/api/schemas/taxonomies.ts +113 -0
  229. package/src/api/schemas/users.ts +96 -0
  230. package/src/api/schemas/widgets.ts +80 -0
  231. package/src/api/site-url.ts +25 -0
  232. package/src/api/types.ts +82 -0
  233. package/src/astro/index.ts +27 -0
  234. package/src/astro/integration/index.ts +303 -0
  235. package/src/astro/integration/routes.ts +834 -0
  236. package/src/astro/integration/runtime.ts +338 -0
  237. package/src/astro/integration/virtual-modules.ts +469 -0
  238. package/src/astro/integration/vite-config.ts +335 -0
  239. package/src/astro/middleware/auth.ts +743 -0
  240. package/src/astro/middleware/redirect.ts +89 -0
  241. package/src/astro/middleware/request-context.ts +129 -0
  242. package/src/astro/middleware/setup.ts +89 -0
  243. package/src/astro/middleware.ts +398 -0
  244. package/src/astro/routes/PluginRegistry.tsx +15 -0
  245. package/src/astro/routes/admin.astro +81 -0
  246. package/src/astro/routes/api/admin/allowed-domains/[domain].ts +112 -0
  247. package/src/astro/routes/api/admin/allowed-domains/index.ts +108 -0
  248. package/src/astro/routes/api/admin/api-tokens/[id].ts +40 -0
  249. package/src/astro/routes/api/admin/api-tokens/index.ts +68 -0
  250. package/src/astro/routes/api/admin/bylines/[id]/index.ts +87 -0
  251. package/src/astro/routes/api/admin/bylines/index.ts +72 -0
  252. package/src/astro/routes/api/admin/comments/[id]/status.ts +116 -0
  253. package/src/astro/routes/api/admin/comments/[id].ts +64 -0
  254. package/src/astro/routes/api/admin/comments/bulk.ts +42 -0
  255. package/src/astro/routes/api/admin/comments/counts.ts +30 -0
  256. package/src/astro/routes/api/admin/comments/index.ts +46 -0
  257. package/src/astro/routes/api/admin/hooks/exclusive/[hookName].ts +91 -0
  258. package/src/astro/routes/api/admin/hooks/exclusive/index.ts +51 -0
  259. package/src/astro/routes/api/admin/oauth-clients/[id].ts +110 -0
  260. package/src/astro/routes/api/admin/oauth-clients/index.ts +71 -0
  261. package/src/astro/routes/api/admin/plugins/[id]/disable.ts +39 -0
  262. package/src/astro/routes/api/admin/plugins/[id]/enable.ts +39 -0
  263. package/src/astro/routes/api/admin/plugins/[id]/index.ts +38 -0
  264. package/src/astro/routes/api/admin/plugins/[id]/uninstall.ts +48 -0
  265. package/src/astro/routes/api/admin/plugins/[id]/update.ts +59 -0
  266. package/src/astro/routes/api/admin/plugins/index.ts +32 -0
  267. package/src/astro/routes/api/admin/plugins/marketplace/[id]/icon.ts +61 -0
  268. package/src/astro/routes/api/admin/plugins/marketplace/[id]/index.ts +33 -0
  269. package/src/astro/routes/api/admin/plugins/marketplace/[id]/install.ts +62 -0
  270. package/src/astro/routes/api/admin/plugins/marketplace/index.ts +38 -0
  271. package/src/astro/routes/api/admin/plugins/updates.ts +28 -0
  272. package/src/astro/routes/api/admin/themes/marketplace/[id]/index.ts +33 -0
  273. package/src/astro/routes/api/admin/themes/marketplace/[id]/thumbnail.ts +61 -0
  274. package/src/astro/routes/api/admin/themes/marketplace/index.ts +45 -0
  275. package/src/astro/routes/api/admin/users/[id]/disable.ts +69 -0
  276. package/src/astro/routes/api/admin/users/[id]/enable.ts +48 -0
  277. package/src/astro/routes/api/admin/users/[id]/index.ts +146 -0
  278. package/src/astro/routes/api/admin/users/[id]/send-recovery.ts +72 -0
  279. package/src/astro/routes/api/admin/users/index.ts +66 -0
  280. package/src/astro/routes/api/auth/dev-bypass.ts +139 -0
  281. package/src/astro/routes/api/auth/invite/accept.ts +52 -0
  282. package/src/astro/routes/api/auth/invite/complete.ts +84 -0
  283. package/src/astro/routes/api/auth/invite/index.ts +99 -0
  284. package/src/astro/routes/api/auth/logout.ts +40 -0
  285. package/src/astro/routes/api/auth/magic-link/send.ts +89 -0
  286. package/src/astro/routes/api/auth/magic-link/verify.ts +71 -0
  287. package/src/astro/routes/api/auth/me.ts +60 -0
  288. package/src/astro/routes/api/auth/oauth/[provider]/callback.ts +219 -0
  289. package/src/astro/routes/api/auth/oauth/[provider].ts +119 -0
  290. package/src/astro/routes/api/auth/passkey/[id].ts +124 -0
  291. package/src/astro/routes/api/auth/passkey/index.ts +54 -0
  292. package/src/astro/routes/api/auth/passkey/options.ts +82 -0
  293. package/src/astro/routes/api/auth/passkey/register/options.ts +86 -0
  294. package/src/astro/routes/api/auth/passkey/register/verify.ts +115 -0
  295. package/src/astro/routes/api/auth/passkey/verify.ts +66 -0
  296. package/src/astro/routes/api/auth/signup/complete.ts +85 -0
  297. package/src/astro/routes/api/auth/signup/request.ts +77 -0
  298. package/src/astro/routes/api/auth/signup/verify.ts +53 -0
  299. package/src/astro/routes/api/comments/[collection]/[contentId]/index.ts +312 -0
  300. package/src/astro/routes/api/content/[collection]/[id]/compare.ts +28 -0
  301. package/src/astro/routes/api/content/[collection]/[id]/discard-draft.ts +54 -0
  302. package/src/astro/routes/api/content/[collection]/[id]/duplicate.ts +61 -0
  303. package/src/astro/routes/api/content/[collection]/[id]/permanent.ts +33 -0
  304. package/src/astro/routes/api/content/[collection]/[id]/preview-url.ts +107 -0
  305. package/src/astro/routes/api/content/[collection]/[id]/publish.ts +56 -0
  306. package/src/astro/routes/api/content/[collection]/[id]/restore.ts +54 -0
  307. package/src/astro/routes/api/content/[collection]/[id]/revisions.ts +31 -0
  308. package/src/astro/routes/api/content/[collection]/[id]/schedule.ts +101 -0
  309. package/src/astro/routes/api/content/[collection]/[id]/terms/[taxonomy].ts +140 -0
  310. package/src/astro/routes/api/content/[collection]/[id]/translations.ts +30 -0
  311. package/src/astro/routes/api/content/[collection]/[id]/unpublish.ts +56 -0
  312. package/src/astro/routes/api/content/[collection]/[id].ts +137 -0
  313. package/src/astro/routes/api/content/[collection]/index.ts +59 -0
  314. package/src/astro/routes/api/content/[collection]/trash.ts +33 -0
  315. package/src/astro/routes/api/dashboard.ts +32 -0
  316. package/src/astro/routes/api/dev/emails.ts +36 -0
  317. package/src/astro/routes/api/import/probe.ts +47 -0
  318. package/src/astro/routes/api/import/wordpress/analyze.ts +510 -0
  319. package/src/astro/routes/api/import/wordpress/execute.ts +283 -0
  320. package/src/astro/routes/api/import/wordpress/media.ts +338 -0
  321. package/src/astro/routes/api/import/wordpress/prepare.ts +181 -0
  322. package/src/astro/routes/api/import/wordpress/rewrite-urls.ts +393 -0
  323. package/src/astro/routes/api/import/wordpress-plugin/analyze.ts +111 -0
  324. package/src/astro/routes/api/import/wordpress-plugin/callback.ts +58 -0
  325. package/src/astro/routes/api/import/wordpress-plugin/execute.ts +347 -0
  326. package/src/astro/routes/api/manifest.ts +62 -0
  327. package/src/astro/routes/api/mcp.ts +124 -0
  328. package/src/astro/routes/api/media/[id]/confirm.ts +93 -0
  329. package/src/astro/routes/api/media/[id].ts +145 -0
  330. package/src/astro/routes/api/media/file/[key].ts +79 -0
  331. package/src/astro/routes/api/media/providers/[providerId]/[itemId].ts +86 -0
  332. package/src/astro/routes/api/media/providers/[providerId]/index.ts +111 -0
  333. package/src/astro/routes/api/media/providers/index.ts +30 -0
  334. package/src/astro/routes/api/media/upload-url.ts +137 -0
  335. package/src/astro/routes/api/media.ts +190 -0
  336. package/src/astro/routes/api/menus/[name]/items.ts +87 -0
  337. package/src/astro/routes/api/menus/[name]/reorder.ts +33 -0
  338. package/src/astro/routes/api/menus/[name].ts +65 -0
  339. package/src/astro/routes/api/menus/index.ts +47 -0
  340. package/src/astro/routes/api/oauth/authorize.ts +412 -0
  341. package/src/astro/routes/api/oauth/device/authorize.ts +45 -0
  342. package/src/astro/routes/api/oauth/device/code.ts +51 -0
  343. package/src/astro/routes/api/oauth/device/token.ts +69 -0
  344. package/src/astro/routes/api/oauth/token/refresh.ts +38 -0
  345. package/src/astro/routes/api/oauth/token/revoke.ts +38 -0
  346. package/src/astro/routes/api/oauth/token.ts +184 -0
  347. package/src/astro/routes/api/openapi.json.ts +32 -0
  348. package/src/astro/routes/api/plugins/[pluginId]/[...path].ts +92 -0
  349. package/src/astro/routes/api/redirects/404s/index.ts +72 -0
  350. package/src/astro/routes/api/redirects/404s/summary.ts +33 -0
  351. package/src/astro/routes/api/redirects/[id].ts +84 -0
  352. package/src/astro/routes/api/redirects/index.ts +52 -0
  353. package/src/astro/routes/api/revisions/[revisionId]/index.ts +29 -0
  354. package/src/astro/routes/api/revisions/[revisionId]/restore.ts +58 -0
  355. package/src/astro/routes/api/schema/collections/[slug]/fields/[fieldSlug].ts +76 -0
  356. package/src/astro/routes/api/schema/collections/[slug]/fields/index.ts +52 -0
  357. package/src/astro/routes/api/schema/collections/[slug]/fields/reorder.ts +32 -0
  358. package/src/astro/routes/api/schema/collections/[slug]/index.ts +80 -0
  359. package/src/astro/routes/api/schema/collections/index.ts +47 -0
  360. package/src/astro/routes/api/schema/index.ts +109 -0
  361. package/src/astro/routes/api/schema/orphans/[slug].ts +36 -0
  362. package/src/astro/routes/api/schema/orphans/index.ts +26 -0
  363. package/src/astro/routes/api/search/enable.ts +64 -0
  364. package/src/astro/routes/api/search/index.ts +55 -0
  365. package/src/astro/routes/api/search/rebuild.ts +72 -0
  366. package/src/astro/routes/api/search/stats.ts +35 -0
  367. package/src/astro/routes/api/search/suggest.ts +53 -0
  368. package/src/astro/routes/api/sections/[slug].ts +84 -0
  369. package/src/astro/routes/api/sections/index.ts +52 -0
  370. package/src/astro/routes/api/settings/email.ts +150 -0
  371. package/src/astro/routes/api/settings.ts +67 -0
  372. package/src/astro/routes/api/setup/admin-verify.ts +100 -0
  373. package/src/astro/routes/api/setup/admin.ts +94 -0
  374. package/src/astro/routes/api/setup/dev-bypass.ts +199 -0
  375. package/src/astro/routes/api/setup/dev-reset.ts +40 -0
  376. package/src/astro/routes/api/setup/index.ts +126 -0
  377. package/src/astro/routes/api/setup/status.ts +122 -0
  378. package/src/astro/routes/api/snapshot.ts +75 -0
  379. package/src/astro/routes/api/taxonomies/[name]/terms/[slug].ts +95 -0
  380. package/src/astro/routes/api/taxonomies/[name]/terms/index.ts +69 -0
  381. package/src/astro/routes/api/taxonomies/index.ts +59 -0
  382. package/src/astro/routes/api/themes/preview.ts +77 -0
  383. package/src/astro/routes/api/typegen.ts +114 -0
  384. package/src/astro/routes/api/well-known/auth.ts +68 -0
  385. package/src/astro/routes/api/well-known/oauth-authorization-server.ts +44 -0
  386. package/src/astro/routes/api/well-known/oauth-protected-resource.ts +37 -0
  387. package/src/astro/routes/api/widget-areas/[name]/reorder.ts +68 -0
  388. package/src/astro/routes/api/widget-areas/[name]/widgets/[id].ts +127 -0
  389. package/src/astro/routes/api/widget-areas/[name]/widgets.ts +80 -0
  390. package/src/astro/routes/api/widget-areas/[name].ts +87 -0
  391. package/src/astro/routes/api/widget-areas/index.ts +99 -0
  392. package/src/astro/routes/api/widget-components.ts +22 -0
  393. package/src/astro/routes/robots.txt.ts +77 -0
  394. package/src/astro/routes/sitemap.xml.ts +97 -0
  395. package/src/astro/storage/adapters.ts +74 -0
  396. package/src/astro/storage/index.ts +19 -0
  397. package/src/astro/storage/types.ts +60 -0
  398. package/src/astro/types.ts +346 -0
  399. package/src/auth/api-tokens.ts +25 -0
  400. package/src/auth/challenge-store.ts +80 -0
  401. package/src/auth/mode.ts +96 -0
  402. package/src/auth/oauth-state-store.ts +96 -0
  403. package/src/auth/passkey-config.ts +27 -0
  404. package/src/auth/rate-limit.ts +158 -0
  405. package/src/auth/scopes.ts +33 -0
  406. package/src/auth/types.ts +104 -0
  407. package/src/aws-sdk.d.ts +100 -0
  408. package/src/bylines/index.ts +237 -0
  409. package/src/cleanup.ts +153 -0
  410. package/src/cli/client-factory.ts +100 -0
  411. package/src/cli/commands/auth.ts +46 -0
  412. package/src/cli/commands/bundle-utils.ts +247 -0
  413. package/src/cli/commands/bundle.ts +609 -0
  414. package/src/cli/commands/content.ts +442 -0
  415. package/src/cli/commands/dev.ts +191 -0
  416. package/src/cli/commands/doctor.ts +211 -0
  417. package/src/cli/commands/export-seed.ts +630 -0
  418. package/src/cli/commands/import/wordpress.ts +1056 -0
  419. package/src/cli/commands/init.ts +192 -0
  420. package/src/cli/commands/login.ts +547 -0
  421. package/src/cli/commands/media.ts +165 -0
  422. package/src/cli/commands/menu.ts +67 -0
  423. package/src/cli/commands/plugin-init.ts +291 -0
  424. package/src/cli/commands/plugin-validate.ts +31 -0
  425. package/src/cli/commands/plugin.ts +33 -0
  426. package/src/cli/commands/publish.ts +697 -0
  427. package/src/cli/commands/schema.ts +233 -0
  428. package/src/cli/commands/search-cmd.ts +54 -0
  429. package/src/cli/commands/seed.ts +286 -0
  430. package/src/cli/commands/taxonomy.ts +128 -0
  431. package/src/cli/commands/types.ts +68 -0
  432. package/src/cli/credentials.ts +236 -0
  433. package/src/cli/index.ts +70 -0
  434. package/src/cli/output.ts +75 -0
  435. package/src/cli/wxr/parser.ts +969 -0
  436. package/src/client/cf-access.ts +193 -0
  437. package/src/client/index.ts +854 -0
  438. package/src/client/portable-text.ts +413 -0
  439. package/src/client/transport.ts +200 -0
  440. package/src/comments/moderator.ts +46 -0
  441. package/src/comments/notifications.ts +144 -0
  442. package/src/comments/query.ts +105 -0
  443. package/src/comments/service.ts +213 -0
  444. package/src/components/Break.astro +45 -0
  445. package/src/components/Button.astro +71 -0
  446. package/src/components/Buttons.astro +49 -0
  447. package/src/components/Code.astro +59 -0
  448. package/src/components/Columns.astro +59 -0
  449. package/src/components/CommentForm.astro +315 -0
  450. package/src/components/Comments.astro +232 -0
  451. package/src/components/Cover.astro +128 -0
  452. package/src/components/EmDashBodyEnd.astro +32 -0
  453. package/src/components/EmDashBodyStart.astro +32 -0
  454. package/src/components/EmDashHead.astro +53 -0
  455. package/src/components/EmDashImage.astro +178 -0
  456. package/src/components/EmDashMedia.astro +167 -0
  457. package/src/components/Embed.astro +128 -0
  458. package/src/components/File.astro +122 -0
  459. package/src/components/Gallery.astro +93 -0
  460. package/src/components/HtmlBlock.astro +33 -0
  461. package/src/components/Image.astro +178 -0
  462. package/src/components/InlineEditor.astro +27 -0
  463. package/src/components/InlinePortableTextEditor.tsx +1905 -0
  464. package/src/components/LiveSearch.astro +614 -0
  465. package/src/components/PortableText.astro +51 -0
  466. package/src/components/Pullquote.astro +51 -0
  467. package/src/components/Table.astro +108 -0
  468. package/src/components/WidgetArea.astro +22 -0
  469. package/src/components/WidgetRenderer.astro +72 -0
  470. package/src/components/index.ts +116 -0
  471. package/src/components/marks/Link.astro +31 -0
  472. package/src/components/marks/StrikeThrough.astro +7 -0
  473. package/src/components/marks/Subscript.astro +7 -0
  474. package/src/components/marks/Superscript.astro +7 -0
  475. package/src/components/marks/Underline.astro +7 -0
  476. package/src/components/widgets/Archives.astro +65 -0
  477. package/src/components/widgets/Categories.astro +35 -0
  478. package/src/components/widgets/RecentPosts.astro +51 -0
  479. package/src/components/widgets/Search.astro +18 -0
  480. package/src/components/widgets/Tags.astro +38 -0
  481. package/src/content/converters/index.ts +9 -0
  482. package/src/content/converters/portable-text-to-prosemirror.ts +385 -0
  483. package/src/content/converters/prosemirror-to-portable-text.ts +413 -0
  484. package/src/content/converters/types.ts +120 -0
  485. package/src/content/index.ts +5 -0
  486. package/src/database/connection.ts +67 -0
  487. package/src/database/dialect-helpers.ts +138 -0
  488. package/src/database/index.ts +5 -0
  489. package/src/database/migrations/001_initial.ts +170 -0
  490. package/src/database/migrations/002_media_status.ts +26 -0
  491. package/src/database/migrations/003_schema_registry.ts +79 -0
  492. package/src/database/migrations/004_plugins.ts +62 -0
  493. package/src/database/migrations/005_menus.ts +67 -0
  494. package/src/database/migrations/006_taxonomy_defs.ts +51 -0
  495. package/src/database/migrations/007_widgets.ts +42 -0
  496. package/src/database/migrations/008_auth.ts +194 -0
  497. package/src/database/migrations/009_user_disabled.ts +27 -0
  498. package/src/database/migrations/011_sections.ts +65 -0
  499. package/src/database/migrations/012_search.ts +25 -0
  500. package/src/database/migrations/013_scheduled_publishing.ts +51 -0
  501. package/src/database/migrations/014_draft_revisions.ts +72 -0
  502. package/src/database/migrations/015_indexes.ts +82 -0
  503. package/src/database/migrations/016_api_tokens.ts +89 -0
  504. package/src/database/migrations/017_authorization_codes.ts +45 -0
  505. package/src/database/migrations/018_seo.ts +56 -0
  506. package/src/database/migrations/019_i18n.ts +618 -0
  507. package/src/database/migrations/020_collection_url_pattern.ts +23 -0
  508. package/src/database/migrations/021_remove_section_categories.ts +43 -0
  509. package/src/database/migrations/022_marketplace_plugin_state.ts +46 -0
  510. package/src/database/migrations/023_plugin_metadata.ts +33 -0
  511. package/src/database/migrations/024_media_placeholders.ts +32 -0
  512. package/src/database/migrations/025_oauth_clients.ts +28 -0
  513. package/src/database/migrations/026_cron_tasks.ts +49 -0
  514. package/src/database/migrations/027_comments.ts +87 -0
  515. package/src/database/migrations/028_drop_author_url.ts +9 -0
  516. package/src/database/migrations/029_redirects.ts +67 -0
  517. package/src/database/migrations/030_widen_scheduled_index.ts +48 -0
  518. package/src/database/migrations/031_bylines.ts +90 -0
  519. package/src/database/migrations/032_rate_limits.ts +39 -0
  520. package/src/database/migrations/runner.ts +170 -0
  521. package/src/database/repositories/audit.ts +294 -0
  522. package/src/database/repositories/byline.ts +387 -0
  523. package/src/database/repositories/comment.ts +458 -0
  524. package/src/database/repositories/content.ts +1144 -0
  525. package/src/database/repositories/index.ts +30 -0
  526. package/src/database/repositories/media.ts +347 -0
  527. package/src/database/repositories/options.ts +150 -0
  528. package/src/database/repositories/plugin-storage.ts +373 -0
  529. package/src/database/repositories/redirect.ts +480 -0
  530. package/src/database/repositories/revision.ts +200 -0
  531. package/src/database/repositories/seo.ts +176 -0
  532. package/src/database/repositories/taxonomy.ts +294 -0
  533. package/src/database/repositories/types.ts +132 -0
  534. package/src/database/repositories/user.ts +258 -0
  535. package/src/database/transaction.ts +54 -0
  536. package/src/database/types.ts +501 -0
  537. package/src/database/validate.ts +138 -0
  538. package/src/db/adapters.ts +125 -0
  539. package/src/db/index.ts +37 -0
  540. package/src/db/libsql.ts +23 -0
  541. package/src/db/postgres.ts +30 -0
  542. package/src/db/sqlite.ts +27 -0
  543. package/src/emdash-runtime.ts +2096 -0
  544. package/src/fields/boolean.ts +34 -0
  545. package/src/fields/datetime.ts +44 -0
  546. package/src/fields/file.ts +41 -0
  547. package/src/fields/image.ts +34 -0
  548. package/src/fields/index.ts +42 -0
  549. package/src/fields/integer.ts +50 -0
  550. package/src/fields/json.ts +37 -0
  551. package/src/fields/multiselect.ts +48 -0
  552. package/src/fields/number.ts +52 -0
  553. package/src/fields/portable-text.ts +33 -0
  554. package/src/fields/reference.ts +29 -0
  555. package/src/fields/richtext.ts +31 -0
  556. package/src/fields/select.ts +46 -0
  557. package/src/fields/slug.ts +38 -0
  558. package/src/fields/text.ts +55 -0
  559. package/src/fields/textarea.ts +52 -0
  560. package/src/fields/types.ts +64 -0
  561. package/src/i18n/config.ts +68 -0
  562. package/src/import/index.ts +90 -0
  563. package/src/import/menus.ts +436 -0
  564. package/src/import/registry.ts +111 -0
  565. package/src/import/sections.ts +103 -0
  566. package/src/import/settings.ts +281 -0
  567. package/src/import/sources/wordpress-plugin.ts +641 -0
  568. package/src/import/sources/wordpress-rest.ts +191 -0
  569. package/src/import/sources/wxr.ts +330 -0
  570. package/src/import/ssrf.ts +260 -0
  571. package/src/import/types.ts +418 -0
  572. package/src/import/utils.ts +412 -0
  573. package/src/index.ts +481 -0
  574. package/src/loader.ts +770 -0
  575. package/src/mcp/server.ts +1463 -0
  576. package/src/media/index.ts +32 -0
  577. package/src/media/local-runtime.ts +213 -0
  578. package/src/media/local.ts +46 -0
  579. package/src/media/normalize.ts +190 -0
  580. package/src/media/placeholder.ts +150 -0
  581. package/src/media/provider-loader.ts +78 -0
  582. package/src/media/types.ts +279 -0
  583. package/src/menus/index.ts +324 -0
  584. package/src/menus/types.ts +112 -0
  585. package/src/page/context.ts +93 -0
  586. package/src/page/fragments.ts +89 -0
  587. package/src/page/index.ts +58 -0
  588. package/src/page/jsonld.ts +94 -0
  589. package/src/page/metadata.ts +185 -0
  590. package/src/page/seo-contributions.ts +136 -0
  591. package/src/plugin-utils.ts +80 -0
  592. package/src/plugins/adapt-sandbox-entry.ts +207 -0
  593. package/src/plugins/context.ts +833 -0
  594. package/src/plugins/cron.ts +361 -0
  595. package/src/plugins/define-plugin.ts +259 -0
  596. package/src/plugins/email-console.ts +73 -0
  597. package/src/plugins/email.ts +209 -0
  598. package/src/plugins/hooks.ts +1273 -0
  599. package/src/plugins/index.ts +193 -0
  600. package/src/plugins/manager.ts +595 -0
  601. package/src/plugins/manifest-schema.ts +230 -0
  602. package/src/plugins/marketplace.ts +460 -0
  603. package/src/plugins/request-meta.ts +139 -0
  604. package/src/plugins/routes.ts +302 -0
  605. package/src/plugins/sandbox/index.ts +18 -0
  606. package/src/plugins/sandbox/noop.ts +76 -0
  607. package/src/plugins/sandbox/types.ts +173 -0
  608. package/src/plugins/scheduler/node.ts +122 -0
  609. package/src/plugins/scheduler/piggyback.ts +71 -0
  610. package/src/plugins/scheduler/types.ts +27 -0
  611. package/src/plugins/state.ts +208 -0
  612. package/src/plugins/storage-indexes.ts +326 -0
  613. package/src/plugins/storage-query.ts +240 -0
  614. package/src/plugins/types.ts +1284 -0
  615. package/src/preview/helpers.ts +27 -0
  616. package/src/preview/index.ts +40 -0
  617. package/src/preview/tokens.ts +279 -0
  618. package/src/preview/urls.ts +118 -0
  619. package/src/query.ts +674 -0
  620. package/src/redirects/patterns.ts +224 -0
  621. package/src/request-context.ts +67 -0
  622. package/src/runtime.ts +21 -0
  623. package/src/schema/index.ts +29 -0
  624. package/src/schema/query.ts +44 -0
  625. package/src/schema/registry.ts +965 -0
  626. package/src/schema/types.ts +276 -0
  627. package/src/schema/zod-generator.ts +413 -0
  628. package/src/search/fts-manager.ts +452 -0
  629. package/src/search/index.ts +26 -0
  630. package/src/search/query.ts +396 -0
  631. package/src/search/text-extraction.ts +162 -0
  632. package/src/search/types.ts +114 -0
  633. package/src/sections/index.ts +226 -0
  634. package/src/sections/types.ts +86 -0
  635. package/src/seed/apply.ts +1141 -0
  636. package/src/seed/default.ts +86 -0
  637. package/src/seed/index.ts +28 -0
  638. package/src/seed/load.ts +35 -0
  639. package/src/seed/types.ts +341 -0
  640. package/src/seed/validate.ts +642 -0
  641. package/src/seo/index.ts +179 -0
  642. package/src/settings/index.ts +203 -0
  643. package/src/settings/types.ts +58 -0
  644. package/src/storage/index.ts +28 -0
  645. package/src/storage/local.ts +249 -0
  646. package/src/storage/s3.ts +263 -0
  647. package/src/storage/types.ts +204 -0
  648. package/src/taxonomies/index.ts +309 -0
  649. package/src/taxonomies/types.ts +61 -0
  650. package/src/ui.ts +75 -0
  651. package/src/utils/base64.ts +73 -0
  652. package/src/utils/hash.ts +36 -0
  653. package/src/utils/sanitize.ts +20 -0
  654. package/src/utils/slugify.ts +29 -0
  655. package/src/utils/url.ts +48 -0
  656. package/src/virtual-modules.d.ts +111 -0
  657. package/src/visual-editing/editable.ts +108 -0
  658. package/src/visual-editing/toolbar.ts +1229 -0
  659. package/src/widgets/components.ts +105 -0
  660. package/src/widgets/index.ts +131 -0
  661. package/src/widgets/types.ts +81 -0
@@ -0,0 +1,1144 @@
1
+ import { sql, type Kysely } from "kysely";
2
+ import { ulid } from "ulidx";
3
+
4
+ import { slugify } from "../../utils/slugify.js";
5
+ import type { Database } from "../types.js";
6
+ import { RevisionRepository } from "./revision.js";
7
+ import type {
8
+ CreateContentInput,
9
+ UpdateContentInput,
10
+ FindManyOptions,
11
+ FindManyResult,
12
+ ContentItem,
13
+ } from "./types.js";
14
+ import { EmDashValidationError, encodeCursor, decodeCursor } from "./types.js";
15
+
16
+ // Regex pattern for ULID validation
17
+ const ULID_PATTERN = /^[0-9A-Z]{26}$/;
18
+
19
+ /**
20
+ * System columns that exist in every ec_* table
21
+ */
22
+ const SYSTEM_COLUMNS = new Set([
23
+ "id",
24
+ "slug",
25
+ "status",
26
+ "author_id",
27
+ "primary_byline_id",
28
+ "created_at",
29
+ "updated_at",
30
+ "published_at",
31
+ "scheduled_at",
32
+ "deleted_at",
33
+ "version",
34
+ "live_revision_id",
35
+ "draft_revision_id",
36
+ "locale",
37
+ "translation_group",
38
+ ]);
39
+
40
+ /**
41
+ * Get the table name for a collection type
42
+ */
43
+ function getTableName(type: string): string {
44
+ return `ec_${type}`;
45
+ }
46
+
47
+ /**
48
+ * Serialize a value for database storage
49
+ * Objects/arrays are JSON-stringified
50
+ * Booleans are converted to 0/1 for SQLite
51
+ */
52
+ function serializeValue(value: unknown): unknown {
53
+ if (value === null || value === undefined) {
54
+ return null;
55
+ }
56
+ if (typeof value === "boolean") {
57
+ return value ? 1 : 0;
58
+ }
59
+ if (typeof value === "object") {
60
+ return JSON.stringify(value);
61
+ }
62
+ return value;
63
+ }
64
+
65
+ /**
66
+ * Deserialize a value from database storage
67
+ * Attempts to parse JSON strings that look like objects/arrays
68
+ */
69
+ function deserializeValue(value: unknown): unknown {
70
+ if (typeof value === "string") {
71
+ // Try to parse if it looks like JSON
72
+ if (value.startsWith("{") || value.startsWith("[")) {
73
+ try {
74
+ return JSON.parse(value);
75
+ } catch {
76
+ return value;
77
+ }
78
+ }
79
+ }
80
+ return value;
81
+ }
82
+
83
+ /** Pattern for escaping special regex characters */
84
+ const REGEX_ESCAPE_PATTERN = /[.*+?^${}()|[\]\\]/g;
85
+
86
+ /**
87
+ * Escape special regex characters in a string for use in `new RegExp()`
88
+ */
89
+ function escapeRegExp(s: string): string {
90
+ return s.replace(REGEX_ESCAPE_PATTERN, "\\$&");
91
+ }
92
+
93
+ /**
94
+ * Repository for content CRUD operations
95
+ *
96
+ * Content is stored in per-collection tables (ec_posts, ec_pages, etc.)
97
+ * Each field becomes a real column in the table.
98
+ */
99
+ export class ContentRepository {
100
+ constructor(private db: Kysely<Database>) {}
101
+
102
+ /**
103
+ * Create a new content item
104
+ */
105
+ async create(input: CreateContentInput): Promise<ContentItem> {
106
+ const id = ulid();
107
+ const now = new Date().toISOString();
108
+
109
+ const {
110
+ type,
111
+ slug,
112
+ data,
113
+ status = "draft",
114
+ authorId,
115
+ primaryBylineId,
116
+ locale,
117
+ translationOf,
118
+ publishedAt,
119
+ } = input;
120
+
121
+ // Validate required fields
122
+ if (!type) {
123
+ throw new EmDashValidationError("Content type is required");
124
+ }
125
+
126
+ const tableName = getTableName(type);
127
+
128
+ // Resolve translation_group: if translationOf is set, look up the source item's group
129
+ let translationGroup: string = id; // default: self-reference
130
+ if (translationOf) {
131
+ const source = await this.findById(type, translationOf);
132
+ if (!source) {
133
+ throw new EmDashValidationError("Translation source content not found");
134
+ }
135
+ translationGroup = source.translationGroup || source.id;
136
+ }
137
+
138
+ // Build column names and values
139
+ const columns: string[] = [
140
+ "id",
141
+ "slug",
142
+ "status",
143
+ "author_id",
144
+ "primary_byline_id",
145
+ "created_at",
146
+ "updated_at",
147
+ "published_at",
148
+ "version",
149
+ "locale",
150
+ "translation_group",
151
+ ];
152
+ const values: unknown[] = [
153
+ id,
154
+ slug || null,
155
+ status,
156
+ authorId || null,
157
+ primaryBylineId ?? null,
158
+ now,
159
+ now,
160
+ publishedAt || null,
161
+ 1,
162
+ locale || "en",
163
+ translationGroup,
164
+ ];
165
+
166
+ // Add data fields as columns (skip system columns to prevent injection via data)
167
+ if (data && typeof data === "object") {
168
+ for (const [key, value] of Object.entries(data)) {
169
+ if (!SYSTEM_COLUMNS.has(key)) {
170
+ columns.push(key);
171
+ values.push(serializeValue(value));
172
+ }
173
+ }
174
+ }
175
+
176
+ // Build dynamic INSERT using raw SQL
177
+ const columnRefs = columns.map((c) => sql.ref(c));
178
+ const valuePlaceholders = values.map((v) => (v === null ? sql`NULL` : sql`${v}`));
179
+
180
+ await sql`
181
+ INSERT INTO ${sql.ref(tableName)} (${sql.join(columnRefs, sql`, `)})
182
+ VALUES (${sql.join(valuePlaceholders, sql`, `)})
183
+ `.execute(this.db);
184
+
185
+ // Fetch and return the created item
186
+ const item = await this.findById(type, id);
187
+ if (!item) {
188
+ throw new Error("Failed to create content");
189
+ }
190
+ return item;
191
+ }
192
+
193
+ /**
194
+ * Generate a unique slug for a content item within a collection.
195
+ *
196
+ * Checks the collection table for existing slugs that match `baseSlug`
197
+ * (optionally scoped to a locale) and appends a numeric suffix (`-1`,
198
+ * `-2`, etc.) on collision to guarantee uniqueness.
199
+ *
200
+ * Returns `null` if `baseSlug` is empty after slugification.
201
+ */
202
+ async generateUniqueSlug(type: string, text: string, locale?: string): Promise<string | null> {
203
+ const baseSlug = slugify(text);
204
+ if (!baseSlug) return null;
205
+
206
+ const tableName = getTableName(type);
207
+
208
+ // Check if the base slug is available
209
+ const existing = locale
210
+ ? await sql<{ slug: string }>`
211
+ SELECT slug FROM ${sql.ref(tableName)}
212
+ WHERE slug = ${baseSlug}
213
+ AND locale = ${locale}
214
+ LIMIT 1
215
+ `.execute(this.db)
216
+ : await sql<{ slug: string }>`
217
+ SELECT slug FROM ${sql.ref(tableName)}
218
+ WHERE slug = ${baseSlug}
219
+ LIMIT 1
220
+ `.execute(this.db);
221
+
222
+ if (existing.rows.length === 0) {
223
+ return baseSlug;
224
+ }
225
+
226
+ // Find all slugs matching the pattern `baseSlug` or `baseSlug-N`
227
+ const pattern = `${baseSlug}-%`;
228
+ const candidates = locale
229
+ ? await sql<{ slug: string }>`
230
+ SELECT slug FROM ${sql.ref(tableName)}
231
+ WHERE (slug = ${baseSlug} OR slug LIKE ${pattern})
232
+ AND locale = ${locale}
233
+ `.execute(this.db)
234
+ : await sql<{ slug: string }>`
235
+ SELECT slug FROM ${sql.ref(tableName)}
236
+ WHERE slug = ${baseSlug} OR slug LIKE ${pattern}
237
+ `.execute(this.db);
238
+
239
+ // Find the highest numeric suffix in use
240
+ let maxSuffix = 0;
241
+ const suffixPattern = new RegExp(`^${escapeRegExp(baseSlug)}-(\\d+)$`);
242
+ for (const row of candidates.rows) {
243
+ const match = suffixPattern.exec(row.slug);
244
+ if (match) {
245
+ const n = parseInt(match[1], 10);
246
+ if (n > maxSuffix) maxSuffix = n;
247
+ }
248
+ }
249
+
250
+ return `${baseSlug}-${maxSuffix + 1}`;
251
+ }
252
+
253
+ /**
254
+ * Duplicate a content item
255
+ * Creates a new draft copy with "(Copy)" appended to the title.
256
+ * A slug is auto-generated from the new title by the handler layer.
257
+ */
258
+ async duplicate(type: string, id: string, authorId?: string): Promise<ContentItem> {
259
+ // Fetch the original item
260
+ const original = await this.findById(type, id);
261
+ if (!original) {
262
+ throw new EmDashValidationError("Content item not found");
263
+ }
264
+
265
+ // Prepare the new data
266
+ const newData = { ...original.data };
267
+
268
+ // Append "(Copy)" to title if present
269
+ if (typeof newData.title === "string") {
270
+ newData.title = `${newData.title} (Copy)`;
271
+ } else if (typeof newData.name === "string") {
272
+ newData.name = `${newData.name} (Copy)`;
273
+ }
274
+
275
+ // Auto-generate a unique slug from the new title/name
276
+ const slugSource =
277
+ typeof newData.title === "string"
278
+ ? newData.title
279
+ : typeof newData.name === "string"
280
+ ? newData.name
281
+ : null;
282
+
283
+ const slug = slugSource
284
+ ? await this.generateUniqueSlug(type, slugSource, original.locale ?? undefined)
285
+ : null;
286
+
287
+ // Create the duplicate as a draft — use override authorId if provided (caller owns the copy)
288
+ return this.create({
289
+ type,
290
+ slug,
291
+ data: newData,
292
+ status: "draft",
293
+ authorId: authorId || original.authorId || undefined,
294
+ });
295
+ }
296
+
297
+ /**
298
+ * Find content by ID
299
+ */
300
+ async findById(type: string, id: string): Promise<ContentItem | null> {
301
+ const tableName = getTableName(type);
302
+
303
+ const result = await sql<Record<string, unknown>>`
304
+ SELECT * FROM ${sql.ref(tableName)}
305
+ WHERE id = ${id}
306
+ AND deleted_at IS NULL
307
+ `.execute(this.db);
308
+
309
+ const row = result.rows[0];
310
+ if (!row) {
311
+ return null;
312
+ }
313
+
314
+ return this.mapRow(type, row);
315
+ }
316
+
317
+ /**
318
+ * Find content by id, including trashed (soft-deleted) items.
319
+ * Used by restore endpoint for ownership checks.
320
+ */
321
+ async findByIdIncludingTrashed(type: string, id: string): Promise<ContentItem | null> {
322
+ const tableName = getTableName(type);
323
+
324
+ const result = await sql<Record<string, unknown>>`
325
+ SELECT * FROM ${sql.ref(tableName)}
326
+ WHERE id = ${id}
327
+ `.execute(this.db);
328
+
329
+ const row = result.rows[0];
330
+ if (!row) {
331
+ return null;
332
+ }
333
+
334
+ return this.mapRow(type, row);
335
+ }
336
+
337
+ /**
338
+ * Find content by ID or slug. Tries ID first if it looks like a ULID,
339
+ * otherwise tries slug. Falls back to the other if the first lookup misses.
340
+ */
341
+ async findByIdOrSlug(
342
+ type: string,
343
+ identifier: string,
344
+ locale?: string,
345
+ ): Promise<ContentItem | null> {
346
+ return this._findByIdOrSlug(type, identifier, false, locale);
347
+ }
348
+
349
+ /**
350
+ * Find content by ID or slug, including trashed (soft-deleted) items.
351
+ * Used by restore/permanent-delete endpoints.
352
+ */
353
+ async findByIdOrSlugIncludingTrashed(
354
+ type: string,
355
+ identifier: string,
356
+ locale?: string,
357
+ ): Promise<ContentItem | null> {
358
+ return this._findByIdOrSlug(type, identifier, true, locale);
359
+ }
360
+
361
+ private async _findByIdOrSlug(
362
+ type: string,
363
+ identifier: string,
364
+ includeTrashed: boolean,
365
+ locale?: string,
366
+ ): Promise<ContentItem | null> {
367
+ // ULIDs are 26 uppercase alphanumeric chars
368
+ const looksLikeUlid = ULID_PATTERN.test(identifier);
369
+
370
+ const findById = includeTrashed
371
+ ? (t: string, id: string) => this.findByIdIncludingTrashed(t, id)
372
+ : (t: string, id: string) => this.findById(t, id);
373
+ const findBySlug = includeTrashed
374
+ ? (t: string, s: string) => this.findBySlugIncludingTrashed(t, s, locale)
375
+ : (t: string, s: string) => this.findBySlug(t, s, locale);
376
+
377
+ if (looksLikeUlid) {
378
+ // Try ID first, fall back to slug
379
+ const byId = await findById(type, identifier);
380
+ if (byId) return byId;
381
+ return findBySlug(type, identifier);
382
+ }
383
+ // Try slug first, fall back to ID
384
+ const bySlug = await findBySlug(type, identifier);
385
+ if (bySlug) return bySlug;
386
+ return findById(type, identifier);
387
+ }
388
+
389
+ /**
390
+ * Find content by slug
391
+ */
392
+ async findBySlug(type: string, slug: string, locale?: string): Promise<ContentItem | null> {
393
+ const tableName = getTableName(type);
394
+
395
+ const result = locale
396
+ ? await sql<Record<string, unknown>>`
397
+ SELECT * FROM ${sql.ref(tableName)}
398
+ WHERE slug = ${slug}
399
+ AND locale = ${locale}
400
+ AND deleted_at IS NULL
401
+ `.execute(this.db)
402
+ : await sql<Record<string, unknown>>`
403
+ SELECT * FROM ${sql.ref(tableName)}
404
+ WHERE slug = ${slug}
405
+ AND deleted_at IS NULL
406
+ ORDER BY locale ASC
407
+ LIMIT 1
408
+ `.execute(this.db);
409
+
410
+ const row = result.rows[0];
411
+ if (!row) {
412
+ return null;
413
+ }
414
+
415
+ return this.mapRow(type, row);
416
+ }
417
+
418
+ /**
419
+ * Find content by slug, including trashed (soft-deleted) items.
420
+ * Used by restore/permanent-delete endpoints.
421
+ */
422
+ async findBySlugIncludingTrashed(
423
+ type: string,
424
+ slug: string,
425
+ locale?: string,
426
+ ): Promise<ContentItem | null> {
427
+ const tableName = getTableName(type);
428
+
429
+ const result = locale
430
+ ? await sql<Record<string, unknown>>`
431
+ SELECT * FROM ${sql.ref(tableName)}
432
+ WHERE slug = ${slug}
433
+ AND locale = ${locale}
434
+ `.execute(this.db)
435
+ : await sql<Record<string, unknown>>`
436
+ SELECT * FROM ${sql.ref(tableName)}
437
+ WHERE slug = ${slug}
438
+ ORDER BY locale ASC
439
+ LIMIT 1
440
+ `.execute(this.db);
441
+
442
+ const row = result.rows[0];
443
+ if (!row) {
444
+ return null;
445
+ }
446
+
447
+ return this.mapRow(type, row);
448
+ }
449
+
450
+ /**
451
+ * Find many content items with filtering and pagination
452
+ */
453
+ async findMany(
454
+ type: string,
455
+ options: FindManyOptions = {},
456
+ ): Promise<FindManyResult<ContentItem>> {
457
+ const tableName = getTableName(type);
458
+ const limit = Math.min(options.limit || 50, 100);
459
+
460
+ // Determine ordering
461
+ const orderField = options.orderBy?.field || "createdAt";
462
+ const orderDirection = options.orderBy?.direction || "desc";
463
+ const dbField = this.mapOrderField(orderField);
464
+
465
+ // Validate order direction to prevent injection
466
+ const safeOrderDirection = orderDirection.toLowerCase() === "asc" ? "ASC" : "DESC";
467
+
468
+ // Build query with parameterized values (no string interpolation)
469
+ // Note: Dynamic content tables have deleted_at column, cast needed for Kysely
470
+ let query = this.db
471
+ .selectFrom(tableName as keyof Database)
472
+ .selectAll()
473
+ .where("deleted_at" as never, "is", null);
474
+
475
+ // Apply filters with parameterized queries
476
+ if (options.where?.status) {
477
+ query = query.where("status", "=", options.where.status);
478
+ }
479
+
480
+ if (options.where?.authorId) {
481
+ query = query.where("author_id", "=", options.where.authorId);
482
+ }
483
+
484
+ if (options.where?.locale) {
485
+ query = query.where("locale" as any, "=", options.where.locale);
486
+ }
487
+
488
+ // Handle cursor pagination
489
+ if (options.cursor) {
490
+ const decoded = decodeCursor(options.cursor);
491
+ if (decoded) {
492
+ const { orderValue, id: cursorId } = decoded;
493
+
494
+ if (safeOrderDirection === "DESC") {
495
+ query = query.where((eb) =>
496
+ eb.or([
497
+ eb(dbField as any, "<", orderValue),
498
+ eb.and([eb(dbField as any, "=", orderValue), eb("id", "<", cursorId)]),
499
+ ]),
500
+ );
501
+ } else {
502
+ query = query.where((eb) =>
503
+ eb.or([
504
+ eb(dbField as any, ">", orderValue),
505
+ eb.and([eb(dbField as any, "=", orderValue), eb("id", ">", cursorId)]),
506
+ ]),
507
+ );
508
+ }
509
+ }
510
+ }
511
+
512
+ // Apply ordering and limit
513
+ query = query
514
+ .orderBy(dbField as any, safeOrderDirection === "ASC" ? "asc" : "desc")
515
+ .orderBy("id", safeOrderDirection === "ASC" ? "asc" : "desc")
516
+ .limit(limit + 1);
517
+
518
+ const rows = await query.execute();
519
+ const hasMore = rows.length > limit;
520
+ const items = rows.slice(0, limit);
521
+
522
+ const mappedResult: FindManyResult<ContentItem> = {
523
+ items: items.map((row) => this.mapRow(type, row as Record<string, unknown>)),
524
+ };
525
+
526
+ if (hasMore && items.length > 0) {
527
+ const lastRow = items.at(-1) as Record<string, unknown>;
528
+ const lastOrderValue = lastRow[dbField];
529
+ const orderStr =
530
+ typeof lastOrderValue === "string" || typeof lastOrderValue === "number"
531
+ ? String(lastOrderValue)
532
+ : "";
533
+ mappedResult.nextCursor = encodeCursor(orderStr, String(lastRow.id));
534
+ }
535
+
536
+ return mappedResult;
537
+ }
538
+
539
+ /**
540
+ * Update content
541
+ */
542
+ async update(type: string, id: string, input: UpdateContentInput): Promise<ContentItem> {
543
+ const tableName = getTableName(type);
544
+ const now = new Date().toISOString();
545
+
546
+ // Build update object with parameterized values
547
+ const updates: Record<string, unknown> = {
548
+ updated_at: now,
549
+ version: sql`version + 1`,
550
+ };
551
+
552
+ if (input.status !== undefined) {
553
+ updates.status = input.status;
554
+ }
555
+
556
+ if (input.slug !== undefined) {
557
+ updates.slug = input.slug;
558
+ }
559
+
560
+ if (input.publishedAt !== undefined) {
561
+ updates.published_at = input.publishedAt;
562
+ }
563
+
564
+ if (input.scheduledAt !== undefined) {
565
+ updates.scheduled_at = input.scheduledAt;
566
+ }
567
+
568
+ if (input.authorId !== undefined) {
569
+ updates.author_id = input.authorId;
570
+ }
571
+
572
+ if (input.primaryBylineId !== undefined) {
573
+ updates.primary_byline_id = input.primaryBylineId;
574
+ }
575
+
576
+ // Update data fields (skip system columns to prevent injection via data)
577
+ if (input.data !== undefined && typeof input.data === "object") {
578
+ for (const [key, value] of Object.entries(input.data)) {
579
+ if (!SYSTEM_COLUMNS.has(key)) {
580
+ updates[key] = serializeValue(value);
581
+ }
582
+ }
583
+ }
584
+
585
+ await this.db
586
+ .updateTable(tableName as keyof Database)
587
+ .set(updates)
588
+ .where("id", "=", id)
589
+ .where("deleted_at" as never, "is", null)
590
+ .execute();
591
+
592
+ const updated = await this.findById(type, id);
593
+ if (!updated) {
594
+ throw new Error("Content not found");
595
+ }
596
+
597
+ return updated;
598
+ }
599
+
600
+ /**
601
+ * Delete content (soft delete - moves to trash)
602
+ */
603
+ async delete(type: string, id: string): Promise<boolean> {
604
+ const tableName = getTableName(type);
605
+ const now = new Date().toISOString();
606
+
607
+ const result = await sql`
608
+ UPDATE ${sql.ref(tableName)}
609
+ SET deleted_at = ${now}
610
+ WHERE id = ${id}
611
+ AND deleted_at IS NULL
612
+ `.execute(this.db);
613
+
614
+ return (result.numAffectedRows ?? 0n) > 0n;
615
+ }
616
+
617
+ /**
618
+ * Restore content from trash
619
+ */
620
+ async restore(type: string, id: string): Promise<boolean> {
621
+ const tableName = getTableName(type);
622
+
623
+ const result = await sql`
624
+ UPDATE ${sql.ref(tableName)}
625
+ SET deleted_at = NULL
626
+ WHERE id = ${id}
627
+ AND deleted_at IS NOT NULL
628
+ `.execute(this.db);
629
+
630
+ return (result.numAffectedRows ?? 0n) > 0n;
631
+ }
632
+
633
+ /**
634
+ * Permanently delete content (cannot be undone)
635
+ */
636
+ async permanentDelete(type: string, id: string): Promise<boolean> {
637
+ const tableName = getTableName(type);
638
+
639
+ const result = await sql`
640
+ DELETE FROM ${sql.ref(tableName)}
641
+ WHERE id = ${id}
642
+ `.execute(this.db);
643
+
644
+ return (result.numAffectedRows ?? 0n) > 0n;
645
+ }
646
+
647
+ /**
648
+ * Find trashed content items
649
+ */
650
+ async findTrashed(
651
+ type: string,
652
+ options: Omit<FindManyOptions, "where"> = {},
653
+ ): Promise<FindManyResult<ContentItem & { deletedAt: string }>> {
654
+ const tableName = getTableName(type);
655
+ const limit = Math.min(options.limit || 50, 100);
656
+
657
+ // Determine ordering - default to most recently deleted
658
+ const orderField = options.orderBy?.field || "deletedAt";
659
+ const orderDirection = options.orderBy?.direction || "desc";
660
+ const dbField = this.mapOrderField(orderField);
661
+
662
+ const safeOrderDirection = orderDirection.toLowerCase() === "asc" ? "ASC" : "DESC";
663
+
664
+ let query = this.db
665
+ .selectFrom(tableName as keyof Database)
666
+ .selectAll()
667
+ .where("deleted_at" as never, "is not", null);
668
+
669
+ // Handle cursor pagination
670
+ if (options.cursor) {
671
+ const decoded = decodeCursor(options.cursor);
672
+ if (decoded) {
673
+ const { orderValue, id: cursorId } = decoded;
674
+
675
+ if (safeOrderDirection === "DESC") {
676
+ query = query.where((eb) =>
677
+ eb.or([
678
+ eb(dbField as any, "<", orderValue),
679
+ eb.and([eb(dbField as any, "=", orderValue), eb("id", "<", cursorId)]),
680
+ ]),
681
+ );
682
+ } else {
683
+ query = query.where((eb) =>
684
+ eb.or([
685
+ eb(dbField as any, ">", orderValue),
686
+ eb.and([eb(dbField as any, "=", orderValue), eb("id", ">", cursorId)]),
687
+ ]),
688
+ );
689
+ }
690
+ }
691
+ }
692
+
693
+ query = query
694
+ .orderBy(dbField as any, safeOrderDirection === "ASC" ? "asc" : "desc")
695
+ .orderBy("id", safeOrderDirection === "ASC" ? "asc" : "desc")
696
+ .limit(limit + 1);
697
+
698
+ const rows = await query.execute();
699
+ const hasMore = rows.length > limit;
700
+ const items = rows.slice(0, limit);
701
+
702
+ const mappedResult: FindManyResult<ContentItem & { deletedAt: string }> = {
703
+ items: items.map((row) => {
704
+ const record = row as Record<string, unknown>;
705
+ return {
706
+ ...this.mapRow(type, record),
707
+ deletedAt: typeof record.deleted_at === "string" ? record.deleted_at : "",
708
+ };
709
+ }),
710
+ };
711
+
712
+ if (hasMore && items.length > 0) {
713
+ const lastRow = items.at(-1) as Record<string, unknown>;
714
+ const lastOrderValue = lastRow[dbField];
715
+ const orderStr =
716
+ typeof lastOrderValue === "string" || typeof lastOrderValue === "number"
717
+ ? String(lastOrderValue)
718
+ : "";
719
+ mappedResult.nextCursor = encodeCursor(orderStr, String(lastRow.id));
720
+ }
721
+
722
+ return mappedResult;
723
+ }
724
+
725
+ /**
726
+ * Count trashed content items
727
+ */
728
+ async countTrashed(type: string): Promise<number> {
729
+ const tableName = getTableName(type);
730
+
731
+ const result = await this.db
732
+ .selectFrom(tableName as keyof Database)
733
+ .select((eb) => eb.fn.count("id").as("count"))
734
+ .where("deleted_at" as never, "is not", null)
735
+ .executeTakeFirst();
736
+
737
+ return Number(result?.count || 0);
738
+ }
739
+
740
+ /**
741
+ * Count content items
742
+ */
743
+ async count(
744
+ type: string,
745
+ where?: { status?: string; authorId?: string; locale?: string },
746
+ ): Promise<number> {
747
+ const tableName = getTableName(type);
748
+
749
+ let query = this.db
750
+ .selectFrom(tableName as keyof Database)
751
+ .select((eb) => eb.fn.count("id").as("count"))
752
+ .where("deleted_at" as never, "is", null);
753
+
754
+ if (where?.status) {
755
+ query = query.where("status", "=", where.status);
756
+ }
757
+
758
+ if (where?.authorId) {
759
+ query = query.where("author_id", "=", where.authorId);
760
+ }
761
+
762
+ if (where?.locale) {
763
+ query = query.where("locale" as any, "=", where.locale);
764
+ }
765
+
766
+ const result = await query.executeTakeFirst();
767
+ return Number(result?.count || 0);
768
+ }
769
+
770
+ /**
771
+ * Schedule content for future publishing
772
+ *
773
+ * Sets status to 'scheduled' and stores the scheduled publish time.
774
+ * The content will be auto-published when the scheduled time is reached.
775
+ */
776
+ async schedule(type: string, id: string, scheduledAt: string): Promise<ContentItem> {
777
+ const tableName = getTableName(type);
778
+ const now = new Date().toISOString();
779
+
780
+ // Validate scheduledAt is in the future
781
+ const scheduledDate = new Date(scheduledAt);
782
+ if (isNaN(scheduledDate.getTime())) {
783
+ throw new EmDashValidationError("Invalid scheduled date");
784
+ }
785
+ if (scheduledDate <= new Date()) {
786
+ throw new EmDashValidationError("Scheduled date must be in the future");
787
+ }
788
+
789
+ const existing = await this.findById(type, id);
790
+ if (!existing) {
791
+ throw new EmDashValidationError("Content item not found");
792
+ }
793
+
794
+ // Published posts keep their status — the schedule applies to the
795
+ // pending draft, not the currently-live revision. Unpublished posts
796
+ // transition to 'scheduled' so they aren't visible before the time.
797
+ const newStatus = existing.status === "published" ? "published" : "scheduled";
798
+
799
+ await sql`
800
+ UPDATE ${sql.ref(tableName)}
801
+ SET status = ${newStatus},
802
+ scheduled_at = ${scheduledAt},
803
+ updated_at = ${now}
804
+ WHERE id = ${id}
805
+ AND deleted_at IS NULL
806
+ `.execute(this.db);
807
+
808
+ const updated = await this.findById(type, id);
809
+ if (!updated) {
810
+ throw new Error("Content not found");
811
+ }
812
+
813
+ return updated;
814
+ }
815
+
816
+ /**
817
+ * Unschedule content
818
+ *
819
+ * Clears the scheduled time. Published posts stay published;
820
+ * draft/scheduled posts revert to 'draft'.
821
+ */
822
+ async unschedule(type: string, id: string): Promise<ContentItem> {
823
+ const tableName = getTableName(type);
824
+ const now = new Date().toISOString();
825
+
826
+ const existing = await this.findById(type, id);
827
+ if (!existing) {
828
+ throw new EmDashValidationError("Content item not found");
829
+ }
830
+
831
+ // Published posts keep their status — just clear the pending schedule.
832
+ // Draft/scheduled posts revert to 'draft'.
833
+ const newStatus = existing.status === "published" ? "published" : "draft";
834
+
835
+ await sql`
836
+ UPDATE ${sql.ref(tableName)}
837
+ SET status = ${newStatus},
838
+ scheduled_at = NULL,
839
+ updated_at = ${now}
840
+ WHERE id = ${id}
841
+ AND scheduled_at IS NOT NULL
842
+ AND deleted_at IS NULL
843
+ `.execute(this.db);
844
+
845
+ const updated = await this.findById(type, id);
846
+ if (!updated) {
847
+ throw new Error("Content not found");
848
+ }
849
+
850
+ return updated;
851
+ }
852
+
853
+ /**
854
+ * Find content that is ready to be published
855
+ *
856
+ * Returns all content where scheduled_at <= now, regardless of status.
857
+ * This covers both draft-scheduled posts (status='scheduled') and
858
+ * published posts with scheduled draft changes (status='published').
859
+ */
860
+ async findReadyToPublish(type: string): Promise<ContentItem[]> {
861
+ const tableName = getTableName(type);
862
+ const now = new Date().toISOString();
863
+
864
+ const result = await sql<Record<string, unknown>>`
865
+ SELECT * FROM ${sql.ref(tableName)}
866
+ WHERE scheduled_at IS NOT NULL
867
+ AND scheduled_at <= ${now}
868
+ AND deleted_at IS NULL
869
+ ORDER BY scheduled_at ASC
870
+ `.execute(this.db);
871
+
872
+ return result.rows.map((row) => this.mapRow(type, row));
873
+ }
874
+
875
+ /**
876
+ * Find all translations in a translation group
877
+ */
878
+ async findTranslations(type: string, translationGroup: string): Promise<ContentItem[]> {
879
+ const tableName = getTableName(type);
880
+
881
+ const result = await sql<Record<string, unknown>>`
882
+ SELECT * FROM ${sql.ref(tableName)}
883
+ WHERE translation_group = ${translationGroup}
884
+ AND deleted_at IS NULL
885
+ ORDER BY locale ASC
886
+ `.execute(this.db);
887
+
888
+ return result.rows.map((row) => this.mapRow(type, row));
889
+ }
890
+
891
+ /**
892
+ * Publish the current draft
893
+ *
894
+ * Promotes draft_revision_id to live_revision_id and clears draft pointer.
895
+ * Syncs the draft revision's data into the content table columns so the
896
+ * content table always reflects the published version.
897
+ * If no draft revision exists, creates one from current data and publishes it.
898
+ */
899
+ async publish(type: string, id: string): Promise<ContentItem> {
900
+ const tableName = getTableName(type);
901
+ const now = new Date().toISOString();
902
+
903
+ const existing = await this.findById(type, id);
904
+ if (!existing) {
905
+ throw new EmDashValidationError("Content item not found");
906
+ }
907
+
908
+ const revisionRepo = new RevisionRepository(this.db);
909
+ let revisionToPublish = existing.draftRevisionId || existing.liveRevisionId;
910
+
911
+ if (!revisionToPublish) {
912
+ // No revision exists - create one from current data
913
+ const revision = await revisionRepo.create({
914
+ collection: type,
915
+ entryId: id,
916
+ data: existing.data,
917
+ });
918
+ revisionToPublish = revision.id;
919
+ }
920
+
921
+ // Sync the revision's data into the content table columns
922
+ // so the content table always holds the published version
923
+ const revision = await revisionRepo.findById(revisionToPublish);
924
+ if (revision) {
925
+ await this.syncDataColumns(type, id, revision.data);
926
+
927
+ // Sync slug from revision if stored there
928
+ if (typeof revision.data._slug === "string") {
929
+ await sql`
930
+ UPDATE ${sql.ref(tableName)}
931
+ SET slug = ${revision.data._slug}
932
+ WHERE id = ${id}
933
+ `.execute(this.db);
934
+ }
935
+ }
936
+
937
+ await sql`
938
+ UPDATE ${sql.ref(tableName)}
939
+ SET live_revision_id = ${revisionToPublish},
940
+ draft_revision_id = NULL,
941
+ status = 'published',
942
+ scheduled_at = NULL,
943
+ published_at = COALESCE(published_at, ${now}),
944
+ updated_at = ${now}
945
+ WHERE id = ${id}
946
+ AND deleted_at IS NULL
947
+ `.execute(this.db);
948
+
949
+ const updated = await this.findById(type, id);
950
+ if (!updated) {
951
+ throw new Error("Content not found");
952
+ }
953
+
954
+ return updated;
955
+ }
956
+
957
+ /**
958
+ * Unpublish content
959
+ *
960
+ * Removes live pointer but preserves draft. If no draft exists,
961
+ * creates one from the live version so the content isn't lost.
962
+ */
963
+ async unpublish(type: string, id: string): Promise<ContentItem> {
964
+ const tableName = getTableName(type);
965
+ const now = new Date().toISOString();
966
+
967
+ const existing = await this.findById(type, id);
968
+ if (!existing) {
969
+ throw new EmDashValidationError("Content item not found");
970
+ }
971
+
972
+ // If no draft exists, create one from the live version
973
+ if (!existing.draftRevisionId && existing.liveRevisionId) {
974
+ const revisionRepo = new RevisionRepository(this.db);
975
+ const liveRevision = await revisionRepo.findById(existing.liveRevisionId);
976
+ if (liveRevision) {
977
+ const draft = await revisionRepo.create({
978
+ collection: type,
979
+ entryId: id,
980
+ data: liveRevision.data,
981
+ });
982
+
983
+ await sql`
984
+ UPDATE ${sql.ref(tableName)}
985
+ SET draft_revision_id = ${draft.id}
986
+ WHERE id = ${id}
987
+ `.execute(this.db);
988
+ }
989
+ }
990
+
991
+ await sql`
992
+ UPDATE ${sql.ref(tableName)}
993
+ SET live_revision_id = NULL,
994
+ status = 'draft',
995
+ updated_at = ${now}
996
+ WHERE id = ${id}
997
+ AND deleted_at IS NULL
998
+ `.execute(this.db);
999
+
1000
+ const updated = await this.findById(type, id);
1001
+ if (!updated) {
1002
+ throw new Error("Content not found");
1003
+ }
1004
+
1005
+ return updated;
1006
+ }
1007
+
1008
+ /**
1009
+ * Discard pending draft changes
1010
+ *
1011
+ * Clears draft_revision_id. The content table columns already hold the
1012
+ * published version, so no data sync is needed.
1013
+ */
1014
+ async discardDraft(type: string, id: string): Promise<ContentItem> {
1015
+ const tableName = getTableName(type);
1016
+ const now = new Date().toISOString();
1017
+
1018
+ const existing = await this.findById(type, id);
1019
+ if (!existing) {
1020
+ throw new EmDashValidationError("Content item not found");
1021
+ }
1022
+
1023
+ if (!existing.draftRevisionId) {
1024
+ // No draft to discard
1025
+ return existing;
1026
+ }
1027
+
1028
+ await sql`
1029
+ UPDATE ${sql.ref(tableName)}
1030
+ SET draft_revision_id = NULL,
1031
+ updated_at = ${now}
1032
+ WHERE id = ${id}
1033
+ AND deleted_at IS NULL
1034
+ `.execute(this.db);
1035
+
1036
+ const updated = await this.findById(type, id);
1037
+ if (!updated) {
1038
+ throw new Error("Content not found");
1039
+ }
1040
+
1041
+ return updated;
1042
+ }
1043
+
1044
+ /**
1045
+ * Sync data columns in the content table from a data object.
1046
+ * Used to promote revision data into the content table on publish.
1047
+ * Keys starting with _ are revision metadata (e.g. _slug) and are skipped.
1048
+ */
1049
+ private async syncDataColumns(
1050
+ type: string,
1051
+ id: string,
1052
+ data: Record<string, unknown>,
1053
+ ): Promise<void> {
1054
+ const tableName = getTableName(type);
1055
+ const updates: Record<string, unknown> = {};
1056
+
1057
+ for (const [key, value] of Object.entries(data)) {
1058
+ if (SYSTEM_COLUMNS.has(key)) continue;
1059
+ if (key.startsWith("_")) continue; // revision metadata
1060
+ updates[key] = serializeValue(value);
1061
+ }
1062
+
1063
+ if (Object.keys(updates).length === 0) return;
1064
+
1065
+ await this.db
1066
+ .updateTable(tableName as keyof Database)
1067
+ .set(updates)
1068
+ .where("id", "=", id)
1069
+ .execute();
1070
+ }
1071
+
1072
+ /**
1073
+ * Count content items with a pending schedule.
1074
+ * Includes both draft-scheduled (status='scheduled') and published
1075
+ * posts with scheduled draft changes (status='published', scheduled_at set).
1076
+ */
1077
+ async countScheduled(type: string): Promise<number> {
1078
+ const tableName = getTableName(type);
1079
+
1080
+ const result = await sql<{ count: number }>`
1081
+ SELECT COUNT(id) as count FROM ${sql.ref(tableName)}
1082
+ WHERE scheduled_at IS NOT NULL
1083
+ AND deleted_at IS NULL
1084
+ `.execute(this.db);
1085
+
1086
+ return Number(result.rows[0]?.count || 0);
1087
+ }
1088
+
1089
+ /**
1090
+ * Map database row to ContentItem
1091
+ * Extracts system columns and puts content fields in data
1092
+ * Excludes null values from data to match input semantics
1093
+ */
1094
+ private mapRow(type: string, row: Record<string, unknown>): ContentItem {
1095
+ const data: Record<string, unknown> = {};
1096
+
1097
+ for (const [key, value] of Object.entries(row)) {
1098
+ if (!SYSTEM_COLUMNS.has(key) && value !== null) {
1099
+ data[key] = deserializeValue(value);
1100
+ }
1101
+ }
1102
+
1103
+ return {
1104
+ id: row.id as string,
1105
+ type,
1106
+ slug: row.slug as string | null,
1107
+ status: row.status as string,
1108
+ data,
1109
+ authorId: row.author_id as string | null,
1110
+ primaryBylineId: (row.primary_byline_id as string | null) ?? null,
1111
+ createdAt: row.created_at as string,
1112
+ updatedAt: row.updated_at as string,
1113
+ publishedAt: row.published_at as string | null,
1114
+ scheduledAt: row.scheduled_at as string | null,
1115
+ liveRevisionId: (row.live_revision_id as string | null) ?? null,
1116
+ draftRevisionId: (row.draft_revision_id as string | null) ?? null,
1117
+ version: typeof row.version === "number" ? row.version : 1,
1118
+ locale: (row.locale as string) ?? null,
1119
+ translationGroup: (row.translation_group as string) ?? null,
1120
+ };
1121
+ }
1122
+
1123
+ /**
1124
+ * Map order field names to database columns.
1125
+ * Only allows known fields to prevent column enumeration via crafted orderBy values.
1126
+ */
1127
+ private mapOrderField(field: string): string {
1128
+ const mapping: Record<string, string> = {
1129
+ createdAt: "created_at",
1130
+ updatedAt: "updated_at",
1131
+ publishedAt: "published_at",
1132
+ scheduledAt: "scheduled_at",
1133
+ deletedAt: "deleted_at",
1134
+ title: "title",
1135
+ slug: "slug",
1136
+ };
1137
+
1138
+ const mapped = mapping[field];
1139
+ if (!mapped) {
1140
+ throw new EmDashValidationError(`Invalid order field: ${field}`);
1141
+ }
1142
+ return mapped;
1143
+ }
1144
+ }