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.
- package/README.md +87 -43
- package/dist/adapters-BLMa4JGD.d.mts +106 -0
- package/dist/adapters-BLMa4JGD.d.mts.map +1 -0
- package/dist/apply-Bjfq_b4-.mjs +1293 -0
- package/dist/apply-Bjfq_b4-.mjs.map +1 -0
- package/dist/astro/index.d.mts +51 -0
- package/dist/astro/index.d.mts.map +1 -0
- package/dist/astro/index.mjs +1336 -0
- package/dist/astro/index.mjs.map +1 -0
- package/dist/astro/middleware/auth.d.mts +31 -0
- package/dist/astro/middleware/auth.d.mts.map +1 -0
- package/dist/astro/middleware/auth.mjs +654 -0
- package/dist/astro/middleware/auth.mjs.map +1 -0
- package/dist/astro/middleware/redirect.d.mts +22 -0
- package/dist/astro/middleware/redirect.d.mts.map +1 -0
- package/dist/astro/middleware/redirect.mjs +63 -0
- package/dist/astro/middleware/redirect.mjs.map +1 -0
- package/dist/astro/middleware/request-context.d.mts +18 -0
- package/dist/astro/middleware/request-context.d.mts.map +1 -0
- package/dist/astro/middleware/request-context.mjs +1310 -0
- package/dist/astro/middleware/request-context.mjs.map +1 -0
- package/dist/astro/middleware/setup.d.mts +20 -0
- package/dist/astro/middleware/setup.d.mts.map +1 -0
- package/dist/astro/middleware/setup.mjs +47 -0
- package/dist/astro/middleware/setup.mjs.map +1 -0
- package/dist/astro/middleware.d.mts +13 -0
- package/dist/astro/middleware.d.mts.map +1 -0
- package/dist/astro/middleware.mjs +1613 -0
- package/dist/astro/middleware.mjs.map +1 -0
- package/dist/astro/types.d.mts +250 -0
- package/dist/astro/types.d.mts.map +1 -0
- package/dist/astro/types.mjs +1 -0
- package/dist/base64-MBPo9ozB.mjs +59 -0
- package/dist/base64-MBPo9ozB.mjs.map +1 -0
- package/dist/byline-CL847F26.mjs +213 -0
- package/dist/byline-CL847F26.mjs.map +1 -0
- package/dist/bylines-C2a-2TGt.mjs +136 -0
- package/dist/bylines-C2a-2TGt.mjs.map +1 -0
- package/dist/chunk-ClPoSABd.mjs +21 -0
- package/dist/cli/index.d.mts +1 -0
- package/dist/cli/index.mjs +3909 -0
- package/dist/cli/index.mjs.map +1 -0
- package/dist/client/cf-access.d.mts +60 -0
- package/dist/client/cf-access.d.mts.map +1 -0
- package/dist/client/cf-access.mjs +179 -0
- package/dist/client/cf-access.mjs.map +1 -0
- package/dist/client/index.d.mts +398 -0
- package/dist/client/index.d.mts.map +1 -0
- package/dist/client/index.mjs +346 -0
- package/dist/client/index.mjs.map +1 -0
- package/dist/config-CKE8p9xM.mjs +55 -0
- package/dist/config-CKE8p9xM.mjs.map +1 -0
- package/dist/connection-B4zVnQIa.mjs +40 -0
- package/dist/connection-B4zVnQIa.mjs.map +1 -0
- package/dist/content-D6C2WsZC.mjs +824 -0
- package/dist/content-D6C2WsZC.mjs.map +1 -0
- package/dist/db/index.d.mts +4 -0
- package/dist/db/index.mjs +62 -0
- package/dist/db/index.mjs.map +1 -0
- package/dist/db/libsql.d.mts +11 -0
- package/dist/db/libsql.d.mts.map +1 -0
- package/dist/db/libsql.mjs +17 -0
- package/dist/db/libsql.mjs.map +1 -0
- package/dist/db/postgres.d.mts +11 -0
- package/dist/db/postgres.d.mts.map +1 -0
- package/dist/db/postgres.mjs +30 -0
- package/dist/db/postgres.mjs.map +1 -0
- package/dist/db/sqlite.d.mts +11 -0
- package/dist/db/sqlite.d.mts.map +1 -0
- package/dist/db/sqlite.mjs +16 -0
- package/dist/db/sqlite.mjs.map +1 -0
- package/dist/default-Cyi4aAxu.mjs +81 -0
- package/dist/default-Cyi4aAxu.mjs.map +1 -0
- package/dist/dialect-helpers-B9uSp2GJ.mjs +90 -0
- package/dist/dialect-helpers-B9uSp2GJ.mjs.map +1 -0
- package/dist/error-Cxz0tQeO.mjs +27 -0
- package/dist/error-Cxz0tQeO.mjs.map +1 -0
- package/dist/index-C1xF3OGh.d.mts +4527 -0
- package/dist/index-C1xF3OGh.d.mts.map +1 -0
- package/dist/index.d.mts +16 -0
- package/dist/index.mjs +30 -0
- package/dist/load-yOOlckBj.mjs +28 -0
- package/dist/load-yOOlckBj.mjs.map +1 -0
- package/dist/loader-fz8Q_3EO.mjs +447 -0
- package/dist/loader-fz8Q_3EO.mjs.map +1 -0
- package/dist/manifest-schema-Dcl0R6nM.mjs +184 -0
- package/dist/manifest-schema-Dcl0R6nM.mjs.map +1 -0
- package/dist/media/index.d.mts +26 -0
- package/dist/media/index.d.mts.map +1 -0
- package/dist/media/index.mjs +55 -0
- package/dist/media/index.mjs.map +1 -0
- package/dist/media/local-runtime.d.mts +39 -0
- package/dist/media/local-runtime.d.mts.map +1 -0
- package/dist/media/local-runtime.mjs +133 -0
- package/dist/media/local-runtime.mjs.map +1 -0
- package/dist/media-DqHVh136.mjs +200 -0
- package/dist/media-DqHVh136.mjs.map +1 -0
- package/dist/mode-C2EzN1uE.mjs +23 -0
- package/dist/mode-C2EzN1uE.mjs.map +1 -0
- package/dist/page/index.d.mts +140 -0
- package/dist/page/index.d.mts.map +1 -0
- package/dist/page/index.mjs +416 -0
- package/dist/page/index.mjs.map +1 -0
- package/dist/placeholder-CmGAmqeO.d.mts +276 -0
- package/dist/placeholder-CmGAmqeO.d.mts.map +1 -0
- package/dist/placeholder-SmpOx-_v.mjs +243 -0
- package/dist/placeholder-SmpOx-_v.mjs.map +1 -0
- package/dist/plugin-utils.d.mts +58 -0
- package/dist/plugin-utils.d.mts.map +1 -0
- package/dist/plugin-utils.mjs +78 -0
- package/dist/plugin-utils.mjs.map +1 -0
- package/dist/plugins/adapt-sandbox-entry.d.mts +22 -0
- package/dist/plugins/adapt-sandbox-entry.d.mts.map +1 -0
- package/dist/plugins/adapt-sandbox-entry.mjs +113 -0
- package/dist/plugins/adapt-sandbox-entry.mjs.map +1 -0
- package/dist/query-CS_iSj34.mjs +460 -0
- package/dist/query-CS_iSj34.mjs.map +1 -0
- package/dist/redirect-DIfIni3r.mjs +329 -0
- package/dist/redirect-DIfIni3r.mjs.map +1 -0
- package/dist/registry-D_w5HW4G.mjs +863 -0
- package/dist/registry-D_w5HW4G.mjs.map +1 -0
- package/dist/request-context.d.mts +49 -0
- package/dist/request-context.d.mts.map +1 -0
- package/dist/request-context.mjs +43 -0
- package/dist/request-context.mjs.map +1 -0
- package/dist/runner-C0hCbYnD.mjs +1412 -0
- package/dist/runner-C0hCbYnD.mjs.map +1 -0
- package/dist/runner-EAtf0ZIe.d.mts +27 -0
- package/dist/runner-EAtf0ZIe.d.mts.map +1 -0
- package/dist/runtime.d.mts +26 -0
- package/dist/runtime.d.mts.map +1 -0
- package/dist/runtime.mjs +42 -0
- package/dist/runtime.mjs.map +1 -0
- package/dist/search-DG603UrT.mjs +9211 -0
- package/dist/search-DG603UrT.mjs.map +1 -0
- package/dist/seed/index.d.mts +3 -0
- package/dist/seed/index.mjs +15 -0
- package/dist/seo/index.d.mts +70 -0
- package/dist/seo/index.d.mts.map +1 -0
- package/dist/seo/index.mjs +70 -0
- package/dist/seo/index.mjs.map +1 -0
- package/dist/storage/local.d.mts +39 -0
- package/dist/storage/local.d.mts.map +1 -0
- package/dist/storage/local.mjs +166 -0
- package/dist/storage/local.mjs.map +1 -0
- package/dist/storage/s3.d.mts +32 -0
- package/dist/storage/s3.d.mts.map +1 -0
- package/dist/storage/s3.mjs +175 -0
- package/dist/storage/s3.mjs.map +1 -0
- package/dist/tokens-DpgrkrXK.mjs +171 -0
- package/dist/tokens-DpgrkrXK.mjs.map +1 -0
- package/dist/transport-BFGblqwG.d.mts +42 -0
- package/dist/transport-BFGblqwG.d.mts.map +1 -0
- package/dist/transport-yxiQsi8I.mjs +418 -0
- package/dist/transport-yxiQsi8I.mjs.map +1 -0
- package/dist/types-BRuPJGdV.d.mts +102 -0
- package/dist/types-BRuPJGdV.d.mts.map +1 -0
- package/dist/types-C4-fAxN3.d.mts +182 -0
- package/dist/types-C4-fAxN3.d.mts.map +1 -0
- package/dist/types-CMMN0pNg.mjs +31 -0
- package/dist/types-CMMN0pNg.mjs.map +1 -0
- package/dist/types-CUBbjgmP.mjs +16 -0
- package/dist/types-CUBbjgmP.mjs.map +1 -0
- package/dist/types-DRjfYOEv.d.mts +426 -0
- package/dist/types-DRjfYOEv.d.mts.map +1 -0
- package/dist/types-DY5zk5HN.mjs +73 -0
- package/dist/types-DY5zk5HN.mjs.map +1 -0
- package/dist/types-DaNLHo_T.d.mts +184 -0
- package/dist/types-DaNLHo_T.d.mts.map +1 -0
- package/dist/types-DvhsUmSJ.d.mts +1111 -0
- package/dist/types-DvhsUmSJ.d.mts.map +1 -0
- package/dist/validate-CpBtVMsD.d.mts +378 -0
- package/dist/validate-CpBtVMsD.d.mts.map +1 -0
- package/dist/validate-CqRJb_xU.mjs +97 -0
- package/dist/validate-CqRJb_xU.mjs.map +1 -0
- package/dist/validate-O7PWmlnq.mjs +328 -0
- package/dist/validate-O7PWmlnq.mjs.map +1 -0
- package/locals.d.ts +46 -0
- package/package.json +233 -19
- package/src/api/authorize.ts +63 -0
- package/src/api/csrf.ts +48 -0
- package/src/api/error.ts +99 -0
- package/src/api/errors.ts +445 -0
- package/src/api/escape.ts +9 -0
- package/src/api/handlers/api-tokens.ts +240 -0
- package/src/api/handlers/comments.ts +314 -0
- package/src/api/handlers/content.ts +1315 -0
- package/src/api/handlers/dashboard.ts +205 -0
- package/src/api/handlers/device-flow.ts +684 -0
- package/src/api/handlers/index.ts +163 -0
- package/src/api/handlers/manifest.ts +158 -0
- package/src/api/handlers/marketplace.ts +930 -0
- package/src/api/handlers/media.ts +207 -0
- package/src/api/handlers/menus.ts +493 -0
- package/src/api/handlers/oauth-authorization.ts +429 -0
- package/src/api/handlers/oauth-clients.ts +349 -0
- package/src/api/handlers/oauth-user-lookup.ts +39 -0
- package/src/api/handlers/plugins.ts +254 -0
- package/src/api/handlers/redirects.ts +360 -0
- package/src/api/handlers/revision.ts +145 -0
- package/src/api/handlers/schema.ts +534 -0
- package/src/api/handlers/sections.ts +289 -0
- package/src/api/handlers/seo.ts +115 -0
- package/src/api/handlers/settings.ts +49 -0
- package/src/api/handlers/snapshot.ts +350 -0
- package/src/api/handlers/taxonomies.ts +523 -0
- package/src/api/index.ts +6 -0
- package/src/api/openapi/document.ts +2368 -0
- package/src/api/openapi/index.ts +1 -0
- package/src/api/parse.ts +139 -0
- package/src/api/redirect.ts +14 -0
- package/src/api/rev.ts +67 -0
- package/src/api/schemas/auth.ts +112 -0
- package/src/api/schemas/bylines.ts +85 -0
- package/src/api/schemas/comments.ts +117 -0
- package/src/api/schemas/common.ts +89 -0
- package/src/api/schemas/content.ts +191 -0
- package/src/api/schemas/import.ts +52 -0
- package/src/api/schemas/index.ts +17 -0
- package/src/api/schemas/media.ts +116 -0
- package/src/api/schemas/menus.ts +111 -0
- package/src/api/schemas/redirects.ts +155 -0
- package/src/api/schemas/schema.ts +203 -0
- package/src/api/schemas/search.ts +63 -0
- package/src/api/schemas/sections.ts +67 -0
- package/src/api/schemas/settings.ts +63 -0
- package/src/api/schemas/setup.ts +37 -0
- package/src/api/schemas/taxonomies.ts +113 -0
- package/src/api/schemas/users.ts +96 -0
- package/src/api/schemas/widgets.ts +80 -0
- package/src/api/site-url.ts +25 -0
- package/src/api/types.ts +82 -0
- package/src/astro/index.ts +27 -0
- package/src/astro/integration/index.ts +303 -0
- package/src/astro/integration/routes.ts +834 -0
- package/src/astro/integration/runtime.ts +338 -0
- package/src/astro/integration/virtual-modules.ts +469 -0
- package/src/astro/integration/vite-config.ts +335 -0
- package/src/astro/middleware/auth.ts +743 -0
- package/src/astro/middleware/redirect.ts +89 -0
- package/src/astro/middleware/request-context.ts +129 -0
- package/src/astro/middleware/setup.ts +89 -0
- package/src/astro/middleware.ts +398 -0
- package/src/astro/routes/PluginRegistry.tsx +15 -0
- package/src/astro/routes/admin.astro +81 -0
- package/src/astro/routes/api/admin/allowed-domains/[domain].ts +112 -0
- package/src/astro/routes/api/admin/allowed-domains/index.ts +108 -0
- package/src/astro/routes/api/admin/api-tokens/[id].ts +40 -0
- package/src/astro/routes/api/admin/api-tokens/index.ts +68 -0
- package/src/astro/routes/api/admin/bylines/[id]/index.ts +87 -0
- package/src/astro/routes/api/admin/bylines/index.ts +72 -0
- package/src/astro/routes/api/admin/comments/[id]/status.ts +116 -0
- package/src/astro/routes/api/admin/comments/[id].ts +64 -0
- package/src/astro/routes/api/admin/comments/bulk.ts +42 -0
- package/src/astro/routes/api/admin/comments/counts.ts +30 -0
- package/src/astro/routes/api/admin/comments/index.ts +46 -0
- package/src/astro/routes/api/admin/hooks/exclusive/[hookName].ts +91 -0
- package/src/astro/routes/api/admin/hooks/exclusive/index.ts +51 -0
- package/src/astro/routes/api/admin/oauth-clients/[id].ts +110 -0
- package/src/astro/routes/api/admin/oauth-clients/index.ts +71 -0
- package/src/astro/routes/api/admin/plugins/[id]/disable.ts +39 -0
- package/src/astro/routes/api/admin/plugins/[id]/enable.ts +39 -0
- package/src/astro/routes/api/admin/plugins/[id]/index.ts +38 -0
- package/src/astro/routes/api/admin/plugins/[id]/uninstall.ts +48 -0
- package/src/astro/routes/api/admin/plugins/[id]/update.ts +59 -0
- package/src/astro/routes/api/admin/plugins/index.ts +32 -0
- package/src/astro/routes/api/admin/plugins/marketplace/[id]/icon.ts +61 -0
- package/src/astro/routes/api/admin/plugins/marketplace/[id]/index.ts +33 -0
- package/src/astro/routes/api/admin/plugins/marketplace/[id]/install.ts +62 -0
- package/src/astro/routes/api/admin/plugins/marketplace/index.ts +38 -0
- package/src/astro/routes/api/admin/plugins/updates.ts +28 -0
- package/src/astro/routes/api/admin/themes/marketplace/[id]/index.ts +33 -0
- package/src/astro/routes/api/admin/themes/marketplace/[id]/thumbnail.ts +61 -0
- package/src/astro/routes/api/admin/themes/marketplace/index.ts +45 -0
- package/src/astro/routes/api/admin/users/[id]/disable.ts +69 -0
- package/src/astro/routes/api/admin/users/[id]/enable.ts +48 -0
- package/src/astro/routes/api/admin/users/[id]/index.ts +146 -0
- package/src/astro/routes/api/admin/users/[id]/send-recovery.ts +72 -0
- package/src/astro/routes/api/admin/users/index.ts +66 -0
- package/src/astro/routes/api/auth/dev-bypass.ts +139 -0
- package/src/astro/routes/api/auth/invite/accept.ts +52 -0
- package/src/astro/routes/api/auth/invite/complete.ts +84 -0
- package/src/astro/routes/api/auth/invite/index.ts +99 -0
- package/src/astro/routes/api/auth/logout.ts +40 -0
- package/src/astro/routes/api/auth/magic-link/send.ts +89 -0
- package/src/astro/routes/api/auth/magic-link/verify.ts +71 -0
- package/src/astro/routes/api/auth/me.ts +60 -0
- package/src/astro/routes/api/auth/oauth/[provider]/callback.ts +219 -0
- package/src/astro/routes/api/auth/oauth/[provider].ts +119 -0
- package/src/astro/routes/api/auth/passkey/[id].ts +124 -0
- package/src/astro/routes/api/auth/passkey/index.ts +54 -0
- package/src/astro/routes/api/auth/passkey/options.ts +82 -0
- package/src/astro/routes/api/auth/passkey/register/options.ts +86 -0
- package/src/astro/routes/api/auth/passkey/register/verify.ts +115 -0
- package/src/astro/routes/api/auth/passkey/verify.ts +66 -0
- package/src/astro/routes/api/auth/signup/complete.ts +85 -0
- package/src/astro/routes/api/auth/signup/request.ts +77 -0
- package/src/astro/routes/api/auth/signup/verify.ts +53 -0
- package/src/astro/routes/api/comments/[collection]/[contentId]/index.ts +312 -0
- package/src/astro/routes/api/content/[collection]/[id]/compare.ts +28 -0
- package/src/astro/routes/api/content/[collection]/[id]/discard-draft.ts +54 -0
- package/src/astro/routes/api/content/[collection]/[id]/duplicate.ts +61 -0
- package/src/astro/routes/api/content/[collection]/[id]/permanent.ts +33 -0
- package/src/astro/routes/api/content/[collection]/[id]/preview-url.ts +107 -0
- package/src/astro/routes/api/content/[collection]/[id]/publish.ts +56 -0
- package/src/astro/routes/api/content/[collection]/[id]/restore.ts +54 -0
- package/src/astro/routes/api/content/[collection]/[id]/revisions.ts +31 -0
- package/src/astro/routes/api/content/[collection]/[id]/schedule.ts +101 -0
- package/src/astro/routes/api/content/[collection]/[id]/terms/[taxonomy].ts +140 -0
- package/src/astro/routes/api/content/[collection]/[id]/translations.ts +30 -0
- package/src/astro/routes/api/content/[collection]/[id]/unpublish.ts +56 -0
- package/src/astro/routes/api/content/[collection]/[id].ts +137 -0
- package/src/astro/routes/api/content/[collection]/index.ts +59 -0
- package/src/astro/routes/api/content/[collection]/trash.ts +33 -0
- package/src/astro/routes/api/dashboard.ts +32 -0
- package/src/astro/routes/api/dev/emails.ts +36 -0
- package/src/astro/routes/api/import/probe.ts +47 -0
- package/src/astro/routes/api/import/wordpress/analyze.ts +510 -0
- package/src/astro/routes/api/import/wordpress/execute.ts +283 -0
- package/src/astro/routes/api/import/wordpress/media.ts +338 -0
- package/src/astro/routes/api/import/wordpress/prepare.ts +181 -0
- package/src/astro/routes/api/import/wordpress/rewrite-urls.ts +393 -0
- package/src/astro/routes/api/import/wordpress-plugin/analyze.ts +111 -0
- package/src/astro/routes/api/import/wordpress-plugin/callback.ts +58 -0
- package/src/astro/routes/api/import/wordpress-plugin/execute.ts +347 -0
- package/src/astro/routes/api/manifest.ts +62 -0
- package/src/astro/routes/api/mcp.ts +124 -0
- package/src/astro/routes/api/media/[id]/confirm.ts +93 -0
- package/src/astro/routes/api/media/[id].ts +145 -0
- package/src/astro/routes/api/media/file/[key].ts +79 -0
- package/src/astro/routes/api/media/providers/[providerId]/[itemId].ts +86 -0
- package/src/astro/routes/api/media/providers/[providerId]/index.ts +111 -0
- package/src/astro/routes/api/media/providers/index.ts +30 -0
- package/src/astro/routes/api/media/upload-url.ts +137 -0
- package/src/astro/routes/api/media.ts +190 -0
- package/src/astro/routes/api/menus/[name]/items.ts +87 -0
- package/src/astro/routes/api/menus/[name]/reorder.ts +33 -0
- package/src/astro/routes/api/menus/[name].ts +65 -0
- package/src/astro/routes/api/menus/index.ts +47 -0
- package/src/astro/routes/api/oauth/authorize.ts +412 -0
- package/src/astro/routes/api/oauth/device/authorize.ts +45 -0
- package/src/astro/routes/api/oauth/device/code.ts +51 -0
- package/src/astro/routes/api/oauth/device/token.ts +69 -0
- package/src/astro/routes/api/oauth/token/refresh.ts +38 -0
- package/src/astro/routes/api/oauth/token/revoke.ts +38 -0
- package/src/astro/routes/api/oauth/token.ts +184 -0
- package/src/astro/routes/api/openapi.json.ts +32 -0
- package/src/astro/routes/api/plugins/[pluginId]/[...path].ts +92 -0
- package/src/astro/routes/api/redirects/404s/index.ts +72 -0
- package/src/astro/routes/api/redirects/404s/summary.ts +33 -0
- package/src/astro/routes/api/redirects/[id].ts +84 -0
- package/src/astro/routes/api/redirects/index.ts +52 -0
- package/src/astro/routes/api/revisions/[revisionId]/index.ts +29 -0
- package/src/astro/routes/api/revisions/[revisionId]/restore.ts +58 -0
- package/src/astro/routes/api/schema/collections/[slug]/fields/[fieldSlug].ts +76 -0
- package/src/astro/routes/api/schema/collections/[slug]/fields/index.ts +52 -0
- package/src/astro/routes/api/schema/collections/[slug]/fields/reorder.ts +32 -0
- package/src/astro/routes/api/schema/collections/[slug]/index.ts +80 -0
- package/src/astro/routes/api/schema/collections/index.ts +47 -0
- package/src/astro/routes/api/schema/index.ts +109 -0
- package/src/astro/routes/api/schema/orphans/[slug].ts +36 -0
- package/src/astro/routes/api/schema/orphans/index.ts +26 -0
- package/src/astro/routes/api/search/enable.ts +64 -0
- package/src/astro/routes/api/search/index.ts +55 -0
- package/src/astro/routes/api/search/rebuild.ts +72 -0
- package/src/astro/routes/api/search/stats.ts +35 -0
- package/src/astro/routes/api/search/suggest.ts +53 -0
- package/src/astro/routes/api/sections/[slug].ts +84 -0
- package/src/astro/routes/api/sections/index.ts +52 -0
- package/src/astro/routes/api/settings/email.ts +150 -0
- package/src/astro/routes/api/settings.ts +67 -0
- package/src/astro/routes/api/setup/admin-verify.ts +100 -0
- package/src/astro/routes/api/setup/admin.ts +94 -0
- package/src/astro/routes/api/setup/dev-bypass.ts +199 -0
- package/src/astro/routes/api/setup/dev-reset.ts +40 -0
- package/src/astro/routes/api/setup/index.ts +126 -0
- package/src/astro/routes/api/setup/status.ts +122 -0
- package/src/astro/routes/api/snapshot.ts +75 -0
- package/src/astro/routes/api/taxonomies/[name]/terms/[slug].ts +95 -0
- package/src/astro/routes/api/taxonomies/[name]/terms/index.ts +69 -0
- package/src/astro/routes/api/taxonomies/index.ts +59 -0
- package/src/astro/routes/api/themes/preview.ts +77 -0
- package/src/astro/routes/api/typegen.ts +114 -0
- package/src/astro/routes/api/well-known/auth.ts +68 -0
- package/src/astro/routes/api/well-known/oauth-authorization-server.ts +44 -0
- package/src/astro/routes/api/well-known/oauth-protected-resource.ts +37 -0
- package/src/astro/routes/api/widget-areas/[name]/reorder.ts +68 -0
- package/src/astro/routes/api/widget-areas/[name]/widgets/[id].ts +127 -0
- package/src/astro/routes/api/widget-areas/[name]/widgets.ts +80 -0
- package/src/astro/routes/api/widget-areas/[name].ts +87 -0
- package/src/astro/routes/api/widget-areas/index.ts +99 -0
- package/src/astro/routes/api/widget-components.ts +22 -0
- package/src/astro/routes/robots.txt.ts +77 -0
- package/src/astro/routes/sitemap.xml.ts +97 -0
- package/src/astro/storage/adapters.ts +74 -0
- package/src/astro/storage/index.ts +19 -0
- package/src/astro/storage/types.ts +60 -0
- package/src/astro/types.ts +346 -0
- package/src/auth/api-tokens.ts +25 -0
- package/src/auth/challenge-store.ts +80 -0
- package/src/auth/mode.ts +96 -0
- package/src/auth/oauth-state-store.ts +96 -0
- package/src/auth/passkey-config.ts +27 -0
- package/src/auth/rate-limit.ts +158 -0
- package/src/auth/scopes.ts +33 -0
- package/src/auth/types.ts +104 -0
- package/src/aws-sdk.d.ts +100 -0
- package/src/bylines/index.ts +237 -0
- package/src/cleanup.ts +153 -0
- package/src/cli/client-factory.ts +100 -0
- package/src/cli/commands/auth.ts +46 -0
- package/src/cli/commands/bundle-utils.ts +247 -0
- package/src/cli/commands/bundle.ts +609 -0
- package/src/cli/commands/content.ts +442 -0
- package/src/cli/commands/dev.ts +191 -0
- package/src/cli/commands/doctor.ts +211 -0
- package/src/cli/commands/export-seed.ts +630 -0
- package/src/cli/commands/import/wordpress.ts +1056 -0
- package/src/cli/commands/init.ts +192 -0
- package/src/cli/commands/login.ts +547 -0
- package/src/cli/commands/media.ts +165 -0
- package/src/cli/commands/menu.ts +67 -0
- package/src/cli/commands/plugin-init.ts +291 -0
- package/src/cli/commands/plugin-validate.ts +31 -0
- package/src/cli/commands/plugin.ts +33 -0
- package/src/cli/commands/publish.ts +697 -0
- package/src/cli/commands/schema.ts +233 -0
- package/src/cli/commands/search-cmd.ts +54 -0
- package/src/cli/commands/seed.ts +286 -0
- package/src/cli/commands/taxonomy.ts +128 -0
- package/src/cli/commands/types.ts +68 -0
- package/src/cli/credentials.ts +236 -0
- package/src/cli/index.ts +70 -0
- package/src/cli/output.ts +75 -0
- package/src/cli/wxr/parser.ts +969 -0
- package/src/client/cf-access.ts +193 -0
- package/src/client/index.ts +854 -0
- package/src/client/portable-text.ts +413 -0
- package/src/client/transport.ts +200 -0
- package/src/comments/moderator.ts +46 -0
- package/src/comments/notifications.ts +144 -0
- package/src/comments/query.ts +105 -0
- package/src/comments/service.ts +213 -0
- package/src/components/Break.astro +45 -0
- package/src/components/Button.astro +71 -0
- package/src/components/Buttons.astro +49 -0
- package/src/components/Code.astro +59 -0
- package/src/components/Columns.astro +59 -0
- package/src/components/CommentForm.astro +315 -0
- package/src/components/Comments.astro +232 -0
- package/src/components/Cover.astro +128 -0
- package/src/components/EmDashBodyEnd.astro +32 -0
- package/src/components/EmDashBodyStart.astro +32 -0
- package/src/components/EmDashHead.astro +53 -0
- package/src/components/EmDashImage.astro +178 -0
- package/src/components/EmDashMedia.astro +167 -0
- package/src/components/Embed.astro +128 -0
- package/src/components/File.astro +122 -0
- package/src/components/Gallery.astro +93 -0
- package/src/components/HtmlBlock.astro +33 -0
- package/src/components/Image.astro +178 -0
- package/src/components/InlineEditor.astro +27 -0
- package/src/components/InlinePortableTextEditor.tsx +1905 -0
- package/src/components/LiveSearch.astro +614 -0
- package/src/components/PortableText.astro +51 -0
- package/src/components/Pullquote.astro +51 -0
- package/src/components/Table.astro +108 -0
- package/src/components/WidgetArea.astro +22 -0
- package/src/components/WidgetRenderer.astro +72 -0
- package/src/components/index.ts +116 -0
- package/src/components/marks/Link.astro +31 -0
- package/src/components/marks/StrikeThrough.astro +7 -0
- package/src/components/marks/Subscript.astro +7 -0
- package/src/components/marks/Superscript.astro +7 -0
- package/src/components/marks/Underline.astro +7 -0
- package/src/components/widgets/Archives.astro +65 -0
- package/src/components/widgets/Categories.astro +35 -0
- package/src/components/widgets/RecentPosts.astro +51 -0
- package/src/components/widgets/Search.astro +18 -0
- package/src/components/widgets/Tags.astro +38 -0
- package/src/content/converters/index.ts +9 -0
- package/src/content/converters/portable-text-to-prosemirror.ts +385 -0
- package/src/content/converters/prosemirror-to-portable-text.ts +413 -0
- package/src/content/converters/types.ts +120 -0
- package/src/content/index.ts +5 -0
- package/src/database/connection.ts +67 -0
- package/src/database/dialect-helpers.ts +138 -0
- package/src/database/index.ts +5 -0
- package/src/database/migrations/001_initial.ts +170 -0
- package/src/database/migrations/002_media_status.ts +26 -0
- package/src/database/migrations/003_schema_registry.ts +79 -0
- package/src/database/migrations/004_plugins.ts +62 -0
- package/src/database/migrations/005_menus.ts +67 -0
- package/src/database/migrations/006_taxonomy_defs.ts +51 -0
- package/src/database/migrations/007_widgets.ts +42 -0
- package/src/database/migrations/008_auth.ts +194 -0
- package/src/database/migrations/009_user_disabled.ts +27 -0
- package/src/database/migrations/011_sections.ts +65 -0
- package/src/database/migrations/012_search.ts +25 -0
- package/src/database/migrations/013_scheduled_publishing.ts +51 -0
- package/src/database/migrations/014_draft_revisions.ts +72 -0
- package/src/database/migrations/015_indexes.ts +82 -0
- package/src/database/migrations/016_api_tokens.ts +89 -0
- package/src/database/migrations/017_authorization_codes.ts +45 -0
- package/src/database/migrations/018_seo.ts +56 -0
- package/src/database/migrations/019_i18n.ts +618 -0
- package/src/database/migrations/020_collection_url_pattern.ts +23 -0
- package/src/database/migrations/021_remove_section_categories.ts +43 -0
- package/src/database/migrations/022_marketplace_plugin_state.ts +46 -0
- package/src/database/migrations/023_plugin_metadata.ts +33 -0
- package/src/database/migrations/024_media_placeholders.ts +32 -0
- package/src/database/migrations/025_oauth_clients.ts +28 -0
- package/src/database/migrations/026_cron_tasks.ts +49 -0
- package/src/database/migrations/027_comments.ts +87 -0
- package/src/database/migrations/028_drop_author_url.ts +9 -0
- package/src/database/migrations/029_redirects.ts +67 -0
- package/src/database/migrations/030_widen_scheduled_index.ts +48 -0
- package/src/database/migrations/031_bylines.ts +90 -0
- package/src/database/migrations/032_rate_limits.ts +39 -0
- package/src/database/migrations/runner.ts +170 -0
- package/src/database/repositories/audit.ts +294 -0
- package/src/database/repositories/byline.ts +387 -0
- package/src/database/repositories/comment.ts +458 -0
- package/src/database/repositories/content.ts +1144 -0
- package/src/database/repositories/index.ts +30 -0
- package/src/database/repositories/media.ts +347 -0
- package/src/database/repositories/options.ts +150 -0
- package/src/database/repositories/plugin-storage.ts +373 -0
- package/src/database/repositories/redirect.ts +480 -0
- package/src/database/repositories/revision.ts +200 -0
- package/src/database/repositories/seo.ts +176 -0
- package/src/database/repositories/taxonomy.ts +294 -0
- package/src/database/repositories/types.ts +132 -0
- package/src/database/repositories/user.ts +258 -0
- package/src/database/transaction.ts +54 -0
- package/src/database/types.ts +501 -0
- package/src/database/validate.ts +138 -0
- package/src/db/adapters.ts +125 -0
- package/src/db/index.ts +37 -0
- package/src/db/libsql.ts +23 -0
- package/src/db/postgres.ts +30 -0
- package/src/db/sqlite.ts +27 -0
- package/src/emdash-runtime.ts +2096 -0
- package/src/fields/boolean.ts +34 -0
- package/src/fields/datetime.ts +44 -0
- package/src/fields/file.ts +41 -0
- package/src/fields/image.ts +34 -0
- package/src/fields/index.ts +42 -0
- package/src/fields/integer.ts +50 -0
- package/src/fields/json.ts +37 -0
- package/src/fields/multiselect.ts +48 -0
- package/src/fields/number.ts +52 -0
- package/src/fields/portable-text.ts +33 -0
- package/src/fields/reference.ts +29 -0
- package/src/fields/richtext.ts +31 -0
- package/src/fields/select.ts +46 -0
- package/src/fields/slug.ts +38 -0
- package/src/fields/text.ts +55 -0
- package/src/fields/textarea.ts +52 -0
- package/src/fields/types.ts +64 -0
- package/src/i18n/config.ts +68 -0
- package/src/import/index.ts +90 -0
- package/src/import/menus.ts +436 -0
- package/src/import/registry.ts +111 -0
- package/src/import/sections.ts +103 -0
- package/src/import/settings.ts +281 -0
- package/src/import/sources/wordpress-plugin.ts +641 -0
- package/src/import/sources/wordpress-rest.ts +191 -0
- package/src/import/sources/wxr.ts +330 -0
- package/src/import/ssrf.ts +260 -0
- package/src/import/types.ts +418 -0
- package/src/import/utils.ts +412 -0
- package/src/index.ts +481 -0
- package/src/loader.ts +770 -0
- package/src/mcp/server.ts +1463 -0
- package/src/media/index.ts +32 -0
- package/src/media/local-runtime.ts +213 -0
- package/src/media/local.ts +46 -0
- package/src/media/normalize.ts +190 -0
- package/src/media/placeholder.ts +150 -0
- package/src/media/provider-loader.ts +78 -0
- package/src/media/types.ts +279 -0
- package/src/menus/index.ts +324 -0
- package/src/menus/types.ts +112 -0
- package/src/page/context.ts +93 -0
- package/src/page/fragments.ts +89 -0
- package/src/page/index.ts +58 -0
- package/src/page/jsonld.ts +94 -0
- package/src/page/metadata.ts +185 -0
- package/src/page/seo-contributions.ts +136 -0
- package/src/plugin-utils.ts +80 -0
- package/src/plugins/adapt-sandbox-entry.ts +207 -0
- package/src/plugins/context.ts +833 -0
- package/src/plugins/cron.ts +361 -0
- package/src/plugins/define-plugin.ts +259 -0
- package/src/plugins/email-console.ts +73 -0
- package/src/plugins/email.ts +209 -0
- package/src/plugins/hooks.ts +1273 -0
- package/src/plugins/index.ts +193 -0
- package/src/plugins/manager.ts +595 -0
- package/src/plugins/manifest-schema.ts +230 -0
- package/src/plugins/marketplace.ts +460 -0
- package/src/plugins/request-meta.ts +139 -0
- package/src/plugins/routes.ts +302 -0
- package/src/plugins/sandbox/index.ts +18 -0
- package/src/plugins/sandbox/noop.ts +76 -0
- package/src/plugins/sandbox/types.ts +173 -0
- package/src/plugins/scheduler/node.ts +122 -0
- package/src/plugins/scheduler/piggyback.ts +71 -0
- package/src/plugins/scheduler/types.ts +27 -0
- package/src/plugins/state.ts +208 -0
- package/src/plugins/storage-indexes.ts +326 -0
- package/src/plugins/storage-query.ts +240 -0
- package/src/plugins/types.ts +1284 -0
- package/src/preview/helpers.ts +27 -0
- package/src/preview/index.ts +40 -0
- package/src/preview/tokens.ts +279 -0
- package/src/preview/urls.ts +118 -0
- package/src/query.ts +674 -0
- package/src/redirects/patterns.ts +224 -0
- package/src/request-context.ts +67 -0
- package/src/runtime.ts +21 -0
- package/src/schema/index.ts +29 -0
- package/src/schema/query.ts +44 -0
- package/src/schema/registry.ts +965 -0
- package/src/schema/types.ts +276 -0
- package/src/schema/zod-generator.ts +413 -0
- package/src/search/fts-manager.ts +452 -0
- package/src/search/index.ts +26 -0
- package/src/search/query.ts +396 -0
- package/src/search/text-extraction.ts +162 -0
- package/src/search/types.ts +114 -0
- package/src/sections/index.ts +226 -0
- package/src/sections/types.ts +86 -0
- package/src/seed/apply.ts +1141 -0
- package/src/seed/default.ts +86 -0
- package/src/seed/index.ts +28 -0
- package/src/seed/load.ts +35 -0
- package/src/seed/types.ts +341 -0
- package/src/seed/validate.ts +642 -0
- package/src/seo/index.ts +179 -0
- package/src/settings/index.ts +203 -0
- package/src/settings/types.ts +58 -0
- package/src/storage/index.ts +28 -0
- package/src/storage/local.ts +249 -0
- package/src/storage/s3.ts +263 -0
- package/src/storage/types.ts +204 -0
- package/src/taxonomies/index.ts +309 -0
- package/src/taxonomies/types.ts +61 -0
- package/src/ui.ts +75 -0
- package/src/utils/base64.ts +73 -0
- package/src/utils/hash.ts +36 -0
- package/src/utils/sanitize.ts +20 -0
- package/src/utils/slugify.ts +29 -0
- package/src/utils/url.ts +48 -0
- package/src/virtual-modules.d.ts +111 -0
- package/src/visual-editing/editable.ts +108 -0
- package/src/visual-editing/toolbar.ts +1229 -0
- package/src/widgets/components.ts +105 -0
- package/src/widgets/index.ts +131 -0
- 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
|
+
}
|