emdash 0.0.0-b → 0.0.1
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 +1333 -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-B-u2F2b6.mjs +1412 -0
- package/dist/runner-B-u2F2b6.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 +687 -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 +353 -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 +328 -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 +120 -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 +117 -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 +105 -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 +62 -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 +72 -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 +699 -0
- package/src/cli/commands/schema.ts +233 -0
- package/src/cli/commands/search-cmd.ts +54 -0
- package/src/cli/commands/seed.ts +288 -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 +136 -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 +42 -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 +253 -0
- package/src/storage/s3.ts +271 -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,1315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content CRUD handlers
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Kysely } from "kysely";
|
|
6
|
+
import { sql } from "kysely";
|
|
7
|
+
|
|
8
|
+
import { BylineRepository } from "../../database/repositories/byline.js";
|
|
9
|
+
import type { ContentBylineInput } from "../../database/repositories/byline.js";
|
|
10
|
+
import { CommentRepository } from "../../database/repositories/comment.js";
|
|
11
|
+
import { ContentRepository } from "../../database/repositories/content.js";
|
|
12
|
+
import { RedirectRepository } from "../../database/repositories/redirect.js";
|
|
13
|
+
import { RevisionRepository } from "../../database/repositories/revision.js";
|
|
14
|
+
import { SeoRepository } from "../../database/repositories/seo.js";
|
|
15
|
+
import {
|
|
16
|
+
EmDashValidationError,
|
|
17
|
+
type ContentItem,
|
|
18
|
+
type ContentSeo,
|
|
19
|
+
type ContentSeoInput,
|
|
20
|
+
} from "../../database/repositories/types.js";
|
|
21
|
+
import { withTransaction } from "../../database/transaction.js";
|
|
22
|
+
import type { Database } from "../../database/types.js";
|
|
23
|
+
import { validateIdentifier } from "../../database/validate.js";
|
|
24
|
+
import { isI18nEnabled } from "../../i18n/config.js";
|
|
25
|
+
import { encodeRev, validateRev } from "../rev.js";
|
|
26
|
+
import type { ApiResult, ContentListResponse, ContentResponse } from "../types.js";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Extract a slug source (title or name) from content data.
|
|
30
|
+
* Returns null if no suitable string field is found.
|
|
31
|
+
*/
|
|
32
|
+
function getSlugSource(data: Record<string, unknown>): string | null {
|
|
33
|
+
if (typeof data.title === "string" && data.title.length > 0) return data.title;
|
|
34
|
+
if (typeof data.name === "string" && data.name.length > 0) return data.name;
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Default SEO values for content without an explicit SEO row */
|
|
39
|
+
const SEO_DEFAULTS: ContentSeo = {
|
|
40
|
+
title: null,
|
|
41
|
+
description: null,
|
|
42
|
+
image: null,
|
|
43
|
+
canonical: null,
|
|
44
|
+
noIndex: false,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Check if a collection has SEO enabled.
|
|
49
|
+
*/
|
|
50
|
+
async function collectionHasSeo(db: Kysely<Database>, collection: string): Promise<boolean> {
|
|
51
|
+
const row = await db
|
|
52
|
+
.selectFrom("_emdash_collections")
|
|
53
|
+
.select("has_seo")
|
|
54
|
+
.where("slug", "=", collection)
|
|
55
|
+
.executeTakeFirst();
|
|
56
|
+
return row?.has_seo === 1;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Hydrate SEO data on a single content item if the collection has SEO enabled.
|
|
61
|
+
*/
|
|
62
|
+
async function hydrateSeo(
|
|
63
|
+
db: Kysely<Database>,
|
|
64
|
+
collection: string,
|
|
65
|
+
item: ContentItem,
|
|
66
|
+
hasSeo: boolean,
|
|
67
|
+
): Promise<void> {
|
|
68
|
+
if (!hasSeo) return;
|
|
69
|
+
const seoRepo = new SeoRepository(db);
|
|
70
|
+
item.seo = await seoRepo.get(collection, item.id);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Hydrate SEO data on multiple content items using a single batch query.
|
|
75
|
+
*/
|
|
76
|
+
async function hydrateSeoMany(
|
|
77
|
+
db: Kysely<Database>,
|
|
78
|
+
collection: string,
|
|
79
|
+
items: ContentItem[],
|
|
80
|
+
hasSeo: boolean,
|
|
81
|
+
): Promise<void> {
|
|
82
|
+
if (!hasSeo || items.length === 0) return;
|
|
83
|
+
const seoRepo = new SeoRepository(db);
|
|
84
|
+
const seoMap = await seoRepo.getMany(
|
|
85
|
+
collection,
|
|
86
|
+
items.map((i) => i.id),
|
|
87
|
+
);
|
|
88
|
+
for (const item of items) {
|
|
89
|
+
item.seo = seoMap.get(item.id) ?? { ...SEO_DEFAULTS };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function hydrateBylines(
|
|
94
|
+
db: Kysely<Database>,
|
|
95
|
+
collection: string,
|
|
96
|
+
item: ContentItem,
|
|
97
|
+
): Promise<void> {
|
|
98
|
+
const bylineRepo = new BylineRepository(db);
|
|
99
|
+
const bylines = await bylineRepo.getContentBylines(collection, item.id);
|
|
100
|
+
|
|
101
|
+
if (bylines.length > 0) {
|
|
102
|
+
item.bylines = bylines.map((c) => ({ ...c, source: "explicit" as const }));
|
|
103
|
+
item.byline = bylines[0]?.byline ?? null;
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Defensive: if primaryBylineId is set but no junction rows exist, it's orphaned
|
|
108
|
+
if (item.primaryBylineId) {
|
|
109
|
+
item.primaryBylineId = null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (item.authorId) {
|
|
113
|
+
const fallback = await bylineRepo.findByUserId(item.authorId);
|
|
114
|
+
if (fallback) {
|
|
115
|
+
item.bylines = [{ byline: fallback, sortOrder: 0, roleLabel: null, source: "inferred" }];
|
|
116
|
+
item.byline = fallback;
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
item.bylines = [];
|
|
122
|
+
item.byline = null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Batch-hydrate bylines for multiple items using two bulk queries instead of N+1.
|
|
127
|
+
*/
|
|
128
|
+
async function hydrateBylinesMany(
|
|
129
|
+
db: Kysely<Database>,
|
|
130
|
+
collection: string,
|
|
131
|
+
items: ContentItem[],
|
|
132
|
+
): Promise<void> {
|
|
133
|
+
if (items.length === 0) return;
|
|
134
|
+
|
|
135
|
+
const bylineRepo = new BylineRepository(db);
|
|
136
|
+
|
|
137
|
+
// 1. Batch fetch all explicit byline credits
|
|
138
|
+
const contentIds = items.map((i) => i.id);
|
|
139
|
+
const bylinesMap = await bylineRepo.getContentBylinesMany(collection, contentIds);
|
|
140
|
+
|
|
141
|
+
// 2. Collect authorIds that need fallback lookup
|
|
142
|
+
const fallbackAuthorIds: string[] = [];
|
|
143
|
+
for (const item of items) {
|
|
144
|
+
if (!bylinesMap.has(item.id) && item.authorId) {
|
|
145
|
+
fallbackAuthorIds.push(item.authorId);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 3. Batch fetch user-linked bylines for fallback
|
|
150
|
+
const uniqueAuthorIds = [...new Set(fallbackAuthorIds)];
|
|
151
|
+
const authorBylineMap = await bylineRepo.findByUserIds(uniqueAuthorIds);
|
|
152
|
+
|
|
153
|
+
// 4. Assign to each item
|
|
154
|
+
for (const item of items) {
|
|
155
|
+
const explicit = bylinesMap.get(item.id);
|
|
156
|
+
if (explicit && explicit.length > 0) {
|
|
157
|
+
item.bylines = explicit.map((c) => ({ ...c, source: "explicit" as const }));
|
|
158
|
+
item.byline = explicit[0]?.byline ?? null;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Defensive: if primaryBylineId is set but no junction rows exist, it's orphaned
|
|
163
|
+
if (item.primaryBylineId) {
|
|
164
|
+
item.primaryBylineId = null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (item.authorId) {
|
|
168
|
+
const fallback = authorBylineMap.get(item.authorId);
|
|
169
|
+
if (fallback) {
|
|
170
|
+
item.bylines = [{ byline: fallback, sortOrder: 0, roleLabel: null, source: "inferred" }];
|
|
171
|
+
item.byline = fallback;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
item.bylines = [];
|
|
177
|
+
item.byline = null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Resolve an identifier (ID or slug) to a real content ID.
|
|
183
|
+
* Returns the ID if found, null if not found.
|
|
184
|
+
* When locale is provided, slug lookups are scoped to that locale.
|
|
185
|
+
*/
|
|
186
|
+
async function resolveId(
|
|
187
|
+
repo: ContentRepository,
|
|
188
|
+
collection: string,
|
|
189
|
+
identifier: string,
|
|
190
|
+
locale?: string,
|
|
191
|
+
): Promise<string | null> {
|
|
192
|
+
const item = await repo.findByIdOrSlug(collection, identifier, locale);
|
|
193
|
+
return item?.id ?? null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Resolve an identifier (ID or slug) to a real content ID,
|
|
198
|
+
* including trashed (soft-deleted) items.
|
|
199
|
+
*/
|
|
200
|
+
async function resolveIdIncludingTrashed(
|
|
201
|
+
repo: ContentRepository,
|
|
202
|
+
collection: string,
|
|
203
|
+
identifier: string,
|
|
204
|
+
locale?: string,
|
|
205
|
+
): Promise<string | null> {
|
|
206
|
+
const item = await repo.findByIdOrSlugIncludingTrashed(collection, identifier, locale);
|
|
207
|
+
return item?.id ?? null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Trashed content item with deletion timestamp
|
|
212
|
+
*/
|
|
213
|
+
export interface TrashedContentItem {
|
|
214
|
+
id: string;
|
|
215
|
+
type: string;
|
|
216
|
+
slug: string | null;
|
|
217
|
+
status: string;
|
|
218
|
+
data: Record<string, unknown>;
|
|
219
|
+
authorId: string | null;
|
|
220
|
+
createdAt: string;
|
|
221
|
+
updatedAt: string;
|
|
222
|
+
publishedAt: string | null;
|
|
223
|
+
deletedAt: string;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Create content list handler
|
|
228
|
+
*/
|
|
229
|
+
export async function handleContentList(
|
|
230
|
+
db: Kysely<Database>,
|
|
231
|
+
collection: string,
|
|
232
|
+
params: {
|
|
233
|
+
cursor?: string;
|
|
234
|
+
limit?: number;
|
|
235
|
+
status?: string;
|
|
236
|
+
orderBy?: string;
|
|
237
|
+
order?: "asc" | "desc";
|
|
238
|
+
locale?: string;
|
|
239
|
+
},
|
|
240
|
+
): Promise<ApiResult<ContentListResponse>> {
|
|
241
|
+
try {
|
|
242
|
+
const repo = new ContentRepository(db);
|
|
243
|
+
const where: { status?: string; locale?: string } = {};
|
|
244
|
+
if (params.status) where.status = params.status;
|
|
245
|
+
if (params.locale) where.locale = params.locale;
|
|
246
|
+
|
|
247
|
+
const result = await repo.findMany(collection, {
|
|
248
|
+
cursor: params.cursor,
|
|
249
|
+
limit: params.limit || 50,
|
|
250
|
+
where: Object.keys(where).length > 0 ? where : undefined,
|
|
251
|
+
orderBy: params.orderBy
|
|
252
|
+
? { field: params.orderBy, direction: params.order || "desc" }
|
|
253
|
+
: undefined,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// Hydrate SEO data if the collection has SEO enabled
|
|
257
|
+
const hasSeo = await collectionHasSeo(db, collection);
|
|
258
|
+
await hydrateSeoMany(db, collection, result.items, hasSeo);
|
|
259
|
+
await hydrateBylinesMany(db, collection, result.items);
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
success: true,
|
|
263
|
+
data: {
|
|
264
|
+
items: result.items,
|
|
265
|
+
nextCursor: result.nextCursor,
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
} catch (error) {
|
|
269
|
+
console.error("Content list error:", error);
|
|
270
|
+
return {
|
|
271
|
+
success: false,
|
|
272
|
+
error: {
|
|
273
|
+
code: "CONTENT_LIST_ERROR",
|
|
274
|
+
message: "Failed to list content",
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Get single content item
|
|
282
|
+
*/
|
|
283
|
+
export async function handleContentGet(
|
|
284
|
+
db: Kysely<Database>,
|
|
285
|
+
collection: string,
|
|
286
|
+
id: string,
|
|
287
|
+
locale?: string,
|
|
288
|
+
): Promise<ApiResult<ContentResponse>> {
|
|
289
|
+
try {
|
|
290
|
+
const repo = new ContentRepository(db);
|
|
291
|
+
const item = await repo.findByIdOrSlug(collection, id, locale);
|
|
292
|
+
|
|
293
|
+
if (!item) {
|
|
294
|
+
return {
|
|
295
|
+
success: false,
|
|
296
|
+
error: {
|
|
297
|
+
code: "NOT_FOUND",
|
|
298
|
+
message: `Content item not found: ${id}`,
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Hydrate SEO data if the collection has SEO enabled
|
|
304
|
+
const hasSeo = await collectionHasSeo(db, collection);
|
|
305
|
+
await hydrateSeo(db, collection, item, hasSeo);
|
|
306
|
+
await hydrateBylines(db, collection, item);
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
success: true,
|
|
310
|
+
data: { item, _rev: encodeRev(item) },
|
|
311
|
+
};
|
|
312
|
+
} catch (error) {
|
|
313
|
+
console.error("Content get error:", error);
|
|
314
|
+
return {
|
|
315
|
+
success: false,
|
|
316
|
+
error: {
|
|
317
|
+
code: "CONTENT_GET_ERROR",
|
|
318
|
+
message: "Failed to get content",
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Get a content item by id, including trashed items.
|
|
326
|
+
* Used by restore endpoint for ownership checks on soft-deleted items.
|
|
327
|
+
*/
|
|
328
|
+
export async function handleContentGetIncludingTrashed(
|
|
329
|
+
db: Kysely<Database>,
|
|
330
|
+
collection: string,
|
|
331
|
+
id: string,
|
|
332
|
+
locale?: string,
|
|
333
|
+
): Promise<ApiResult<ContentResponse>> {
|
|
334
|
+
try {
|
|
335
|
+
const repo = new ContentRepository(db);
|
|
336
|
+
const item = await repo.findByIdOrSlugIncludingTrashed(collection, id, locale);
|
|
337
|
+
|
|
338
|
+
if (!item) {
|
|
339
|
+
return {
|
|
340
|
+
success: false,
|
|
341
|
+
error: {
|
|
342
|
+
code: "NOT_FOUND",
|
|
343
|
+
message: `Content item not found: ${id}`,
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Hydrate SEO data if the collection has SEO enabled
|
|
349
|
+
const hasSeo = await collectionHasSeo(db, collection);
|
|
350
|
+
await hydrateSeo(db, collection, item, hasSeo);
|
|
351
|
+
await hydrateBylines(db, collection, item);
|
|
352
|
+
|
|
353
|
+
return {
|
|
354
|
+
success: true,
|
|
355
|
+
data: { item, _rev: encodeRev(item) },
|
|
356
|
+
};
|
|
357
|
+
} catch (error) {
|
|
358
|
+
console.error("Content get error:", error);
|
|
359
|
+
return {
|
|
360
|
+
success: false,
|
|
361
|
+
error: {
|
|
362
|
+
code: "CONTENT_GET_ERROR",
|
|
363
|
+
message: "Failed to get content",
|
|
364
|
+
},
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Create content item.
|
|
371
|
+
*
|
|
372
|
+
* Content + SEO writes are wrapped in a transaction so either both succeed
|
|
373
|
+
* or neither does. If `body.seo` is provided for a non-SEO collection, the
|
|
374
|
+
* API returns a validation error rather than silently dropping it.
|
|
375
|
+
*/
|
|
376
|
+
export async function handleContentCreate(
|
|
377
|
+
db: Kysely<Database>,
|
|
378
|
+
collection: string,
|
|
379
|
+
body: {
|
|
380
|
+
data: Record<string, unknown>;
|
|
381
|
+
slug?: string;
|
|
382
|
+
status?: string;
|
|
383
|
+
authorId?: string;
|
|
384
|
+
bylines?: ContentBylineInput[];
|
|
385
|
+
locale?: string;
|
|
386
|
+
translationOf?: string;
|
|
387
|
+
seo?: ContentSeoInput;
|
|
388
|
+
},
|
|
389
|
+
): Promise<ApiResult<ContentResponse>> {
|
|
390
|
+
try {
|
|
391
|
+
const hasSeo = await collectionHasSeo(db, collection);
|
|
392
|
+
|
|
393
|
+
// Reject SEO input for non-SEO collections
|
|
394
|
+
if (body.seo && !hasSeo) {
|
|
395
|
+
return {
|
|
396
|
+
success: false,
|
|
397
|
+
error: {
|
|
398
|
+
code: "VALIDATION_ERROR",
|
|
399
|
+
message: `Collection "${collection}" does not have SEO enabled. Remove the seo field or enable SEO on this collection.`,
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Wrap content + SEO writes in a transaction for atomicity
|
|
405
|
+
const item = await withTransaction(db, async (trx) => {
|
|
406
|
+
const repo = new ContentRepository(trx);
|
|
407
|
+
const bylineRepo = new BylineRepository(trx);
|
|
408
|
+
|
|
409
|
+
// Auto-generate slug from title/name if not explicitly provided
|
|
410
|
+
let slug: string | null | undefined = body.slug;
|
|
411
|
+
if (!slug) {
|
|
412
|
+
const slugSource = getSlugSource(body.data);
|
|
413
|
+
if (slugSource) {
|
|
414
|
+
slug = await repo.generateUniqueSlug(collection, slugSource, body.locale);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const created = await repo.create({
|
|
419
|
+
type: collection,
|
|
420
|
+
slug,
|
|
421
|
+
data: body.data,
|
|
422
|
+
status: body.status || "draft",
|
|
423
|
+
authorId: body.authorId,
|
|
424
|
+
locale: body.locale,
|
|
425
|
+
translationOf: body.translationOf,
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
if (body.bylines !== undefined) {
|
|
429
|
+
await bylineRepo.setContentBylines(collection, created.id, body.bylines);
|
|
430
|
+
created.primaryBylineId = body.bylines[0]?.bylineId ?? null;
|
|
431
|
+
}
|
|
432
|
+
await hydrateBylines(trx, collection, created);
|
|
433
|
+
|
|
434
|
+
// Side-write SEO data if provided
|
|
435
|
+
if (body.seo && hasSeo) {
|
|
436
|
+
const seoRepo = new SeoRepository(trx);
|
|
437
|
+
created.seo = await seoRepo.upsert(collection, created.id, body.seo);
|
|
438
|
+
} else if (hasSeo) {
|
|
439
|
+
// Assign defaults in-memory — no DB round-trip needed
|
|
440
|
+
created.seo = { ...SEO_DEFAULTS };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return created;
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
return {
|
|
447
|
+
success: true,
|
|
448
|
+
data: { item, _rev: encodeRev(item) },
|
|
449
|
+
};
|
|
450
|
+
} catch (error) {
|
|
451
|
+
console.error("Content create error:", error);
|
|
452
|
+
return {
|
|
453
|
+
success: false,
|
|
454
|
+
error: {
|
|
455
|
+
code: "CONTENT_CREATE_ERROR",
|
|
456
|
+
message: "Failed to create content",
|
|
457
|
+
},
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Update content item.
|
|
464
|
+
* If `_rev` is provided, validates it against the current version before writing.
|
|
465
|
+
* No `_rev` = blind write (backwards-compatible for admin UI).
|
|
466
|
+
*
|
|
467
|
+
* Content + SEO writes are wrapped in a transaction for atomicity.
|
|
468
|
+
*/
|
|
469
|
+
export async function handleContentUpdate(
|
|
470
|
+
db: Kysely<Database>,
|
|
471
|
+
collection: string,
|
|
472
|
+
id: string,
|
|
473
|
+
body: {
|
|
474
|
+
data?: Record<string, unknown>;
|
|
475
|
+
slug?: string;
|
|
476
|
+
status?: string;
|
|
477
|
+
authorId?: string | null;
|
|
478
|
+
bylines?: ContentBylineInput[];
|
|
479
|
+
_rev?: string;
|
|
480
|
+
seo?: ContentSeoInput;
|
|
481
|
+
},
|
|
482
|
+
): Promise<ApiResult<ContentResponse>> {
|
|
483
|
+
try {
|
|
484
|
+
const hasSeo = await collectionHasSeo(db, collection);
|
|
485
|
+
|
|
486
|
+
// Reject SEO input for non-SEO collections
|
|
487
|
+
if (body.seo && !hasSeo) {
|
|
488
|
+
return {
|
|
489
|
+
success: false,
|
|
490
|
+
error: {
|
|
491
|
+
code: "VALIDATION_ERROR",
|
|
492
|
+
message: `Collection "${collection}" does not have SEO enabled. Remove the seo field or enable SEO on this collection.`,
|
|
493
|
+
},
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const repo = new ContentRepository(db);
|
|
498
|
+
|
|
499
|
+
// Resolve slug → ID if needed
|
|
500
|
+
const resolvedId = (await resolveId(repo, collection, id)) ?? id;
|
|
501
|
+
|
|
502
|
+
// Validate _rev if provided (optimistic concurrency)
|
|
503
|
+
if (body._rev) {
|
|
504
|
+
const existing = await repo.findById(collection, resolvedId);
|
|
505
|
+
if (!existing) {
|
|
506
|
+
return {
|
|
507
|
+
success: false,
|
|
508
|
+
error: { code: "NOT_FOUND", message: `Content item not found: ${id}` },
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const revCheck = validateRev(body._rev, existing);
|
|
513
|
+
if (!revCheck.valid) {
|
|
514
|
+
return {
|
|
515
|
+
success: false,
|
|
516
|
+
error: { code: "CONFLICT", message: revCheck.message },
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Wrap content + SEO writes in a transaction for atomicity
|
|
522
|
+
const item = await withTransaction(db, async (trx) => {
|
|
523
|
+
const trxRepo = new ContentRepository(trx);
|
|
524
|
+
const bylineRepo = new BylineRepository(trx);
|
|
525
|
+
|
|
526
|
+
// Capture old slug before update for auto-redirect
|
|
527
|
+
let oldSlug: string | undefined;
|
|
528
|
+
if (body.slug) {
|
|
529
|
+
const existing = await trxRepo.findById(collection, resolvedId);
|
|
530
|
+
if (existing?.slug && existing.slug !== body.slug) {
|
|
531
|
+
oldSlug = existing.slug;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const updated = await trxRepo.update(collection, resolvedId, {
|
|
536
|
+
data: body.data,
|
|
537
|
+
slug: body.slug,
|
|
538
|
+
status: body.status,
|
|
539
|
+
authorId: body.authorId,
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
if (body.bylines !== undefined) {
|
|
543
|
+
await bylineRepo.setContentBylines(collection, resolvedId, body.bylines);
|
|
544
|
+
updated.primaryBylineId = body.bylines[0]?.bylineId ?? null;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Create auto-redirect when slug changes
|
|
548
|
+
if (oldSlug && body.slug) {
|
|
549
|
+
const collectionRow = await trx
|
|
550
|
+
.selectFrom("_emdash_collections")
|
|
551
|
+
.select("url_pattern")
|
|
552
|
+
.where("slug", "=", collection)
|
|
553
|
+
.executeTakeFirst();
|
|
554
|
+
|
|
555
|
+
const redirectRepo = new RedirectRepository(trx);
|
|
556
|
+
await redirectRepo.createAutoRedirect(
|
|
557
|
+
collection,
|
|
558
|
+
oldSlug,
|
|
559
|
+
body.slug,
|
|
560
|
+
resolvedId,
|
|
561
|
+
collectionRow?.url_pattern ?? null,
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Sync non-translatable fields to sibling locales in the same
|
|
566
|
+
// translation group. Only runs when i18n is enabled, data was updated,
|
|
567
|
+
// and the item belongs to a translation group with siblings.
|
|
568
|
+
if (isI18nEnabled() && body.data && updated.translationGroup) {
|
|
569
|
+
await syncNonTranslatableFields(
|
|
570
|
+
trx,
|
|
571
|
+
collection,
|
|
572
|
+
updated.id,
|
|
573
|
+
updated.translationGroup,
|
|
574
|
+
body.data,
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Side-write SEO data if provided, always hydrate for SEO-enabled collections
|
|
579
|
+
if (body.seo && hasSeo) {
|
|
580
|
+
const seoRepo = new SeoRepository(trx);
|
|
581
|
+
updated.seo = await seoRepo.upsert(collection, resolvedId, body.seo);
|
|
582
|
+
} else if (hasSeo) {
|
|
583
|
+
const seoRepo = new SeoRepository(trx);
|
|
584
|
+
updated.seo = await seoRepo.get(collection, resolvedId);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
await hydrateBylines(trx, collection, updated);
|
|
588
|
+
|
|
589
|
+
return updated;
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
return {
|
|
593
|
+
success: true,
|
|
594
|
+
data: { item, _rev: encodeRev(item) },
|
|
595
|
+
};
|
|
596
|
+
} catch (error) {
|
|
597
|
+
console.error("Content update error:", error);
|
|
598
|
+
return {
|
|
599
|
+
success: false,
|
|
600
|
+
error: {
|
|
601
|
+
code: "CONTENT_UPDATE_ERROR",
|
|
602
|
+
message: "Failed to update content",
|
|
603
|
+
},
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Duplicate content item.
|
|
610
|
+
*
|
|
611
|
+
* Only copies SEO data if the collection has SEO enabled.
|
|
612
|
+
* Always returns consistent `seo` shape for SEO-enabled collections.
|
|
613
|
+
*/
|
|
614
|
+
export async function handleContentDuplicate(
|
|
615
|
+
db: Kysely<Database>,
|
|
616
|
+
collection: string,
|
|
617
|
+
id: string,
|
|
618
|
+
authorId?: string,
|
|
619
|
+
): Promise<ApiResult<{ item: ContentItem }>> {
|
|
620
|
+
try {
|
|
621
|
+
const hasSeo = await collectionHasSeo(db, collection);
|
|
622
|
+
|
|
623
|
+
// Wrap duplicate + SEO copy in a transaction for atomicity
|
|
624
|
+
const duplicate = await withTransaction(db, async (trx) => {
|
|
625
|
+
const repo = new ContentRepository(trx);
|
|
626
|
+
const bylineRepo = new BylineRepository(trx);
|
|
627
|
+
const resolvedId = (await resolveId(repo, collection, id)) ?? id;
|
|
628
|
+
const dup = await repo.duplicate(collection, resolvedId, authorId);
|
|
629
|
+
|
|
630
|
+
const existingBylines = await bylineRepo.getContentBylines(collection, resolvedId);
|
|
631
|
+
if (existingBylines.length > 0) {
|
|
632
|
+
await bylineRepo.setContentBylines(
|
|
633
|
+
collection,
|
|
634
|
+
dup.id,
|
|
635
|
+
existingBylines.map((entry) => ({
|
|
636
|
+
bylineId: entry.byline.id,
|
|
637
|
+
roleLabel: entry.roleLabel,
|
|
638
|
+
})),
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
if (hasSeo) {
|
|
643
|
+
// Copy SEO data from the original (clears canonical)
|
|
644
|
+
const seoRepo = new SeoRepository(trx);
|
|
645
|
+
await seoRepo.copyForDuplicate(collection, resolvedId, dup.id);
|
|
646
|
+
// Always hydrate SEO for consistent response shape
|
|
647
|
+
dup.seo = await seoRepo.get(collection, dup.id);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
await hydrateBylines(trx, collection, dup);
|
|
651
|
+
|
|
652
|
+
return dup;
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
return {
|
|
656
|
+
success: true,
|
|
657
|
+
data: { item: duplicate },
|
|
658
|
+
};
|
|
659
|
+
} catch (err) {
|
|
660
|
+
if (err instanceof EmDashValidationError) {
|
|
661
|
+
return {
|
|
662
|
+
success: false,
|
|
663
|
+
error: {
|
|
664
|
+
code: "NOT_FOUND",
|
|
665
|
+
message: err.message,
|
|
666
|
+
},
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
console.error("Content duplicate error:", err);
|
|
670
|
+
return {
|
|
671
|
+
success: false,
|
|
672
|
+
error: {
|
|
673
|
+
code: "CONTENT_DUPLICATE_ERROR",
|
|
674
|
+
message: "Failed to duplicate content",
|
|
675
|
+
},
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Delete content item (soft delete - moves to trash)
|
|
682
|
+
*/
|
|
683
|
+
export async function handleContentDelete(
|
|
684
|
+
db: Kysely<Database>,
|
|
685
|
+
collection: string,
|
|
686
|
+
id: string,
|
|
687
|
+
): Promise<ApiResult<{ deleted: true }>> {
|
|
688
|
+
try {
|
|
689
|
+
const deleted = await withTransaction(db, async (trx) => {
|
|
690
|
+
const repo = new ContentRepository(trx);
|
|
691
|
+
const resolvedId = (await resolveId(repo, collection, id)) ?? id;
|
|
692
|
+
return repo.delete(collection, resolvedId);
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
if (!deleted) {
|
|
696
|
+
return {
|
|
697
|
+
success: false,
|
|
698
|
+
error: {
|
|
699
|
+
code: "NOT_FOUND",
|
|
700
|
+
message: `Content item not found: ${id}`,
|
|
701
|
+
},
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
return {
|
|
706
|
+
success: true,
|
|
707
|
+
data: { deleted: true },
|
|
708
|
+
};
|
|
709
|
+
} catch (error) {
|
|
710
|
+
console.error("Content delete error:", error);
|
|
711
|
+
return {
|
|
712
|
+
success: false,
|
|
713
|
+
error: {
|
|
714
|
+
code: "CONTENT_DELETE_ERROR",
|
|
715
|
+
message: "Failed to delete content",
|
|
716
|
+
},
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Restore content item from trash
|
|
723
|
+
*/
|
|
724
|
+
export async function handleContentRestore(
|
|
725
|
+
db: Kysely<Database>,
|
|
726
|
+
collection: string,
|
|
727
|
+
id: string,
|
|
728
|
+
): Promise<ApiResult<{ restored: true }>> {
|
|
729
|
+
try {
|
|
730
|
+
const restored = await withTransaction(db, async (trx) => {
|
|
731
|
+
const repo = new ContentRepository(trx);
|
|
732
|
+
const resolvedId = (await resolveIdIncludingTrashed(repo, collection, id)) ?? id;
|
|
733
|
+
return repo.restore(collection, resolvedId);
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
if (!restored) {
|
|
737
|
+
return {
|
|
738
|
+
success: false,
|
|
739
|
+
error: {
|
|
740
|
+
code: "NOT_FOUND",
|
|
741
|
+
message: `Trashed content item not found: ${id}`,
|
|
742
|
+
},
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
return {
|
|
747
|
+
success: true,
|
|
748
|
+
data: { restored: true },
|
|
749
|
+
};
|
|
750
|
+
} catch (error) {
|
|
751
|
+
console.error("Content restore error:", error);
|
|
752
|
+
return {
|
|
753
|
+
success: false,
|
|
754
|
+
error: {
|
|
755
|
+
code: "CONTENT_RESTORE_ERROR",
|
|
756
|
+
message: "Failed to restore content",
|
|
757
|
+
},
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Permanently delete content item (cannot be undone).
|
|
764
|
+
* Also cleans up associated SEO data.
|
|
765
|
+
*/
|
|
766
|
+
export async function handleContentPermanentDelete(
|
|
767
|
+
db: Kysely<Database>,
|
|
768
|
+
collection: string,
|
|
769
|
+
id: string,
|
|
770
|
+
): Promise<ApiResult<{ deleted: true }>> {
|
|
771
|
+
try {
|
|
772
|
+
const repo = new ContentRepository(db);
|
|
773
|
+
const resolvedId = (await resolveIdIncludingTrashed(repo, collection, id)) ?? id;
|
|
774
|
+
|
|
775
|
+
// Wrap content delete + SEO/comment cleanup in a transaction
|
|
776
|
+
const deleted = await withTransaction(db, async (trx) => {
|
|
777
|
+
const trxRepo = new ContentRepository(trx);
|
|
778
|
+
const wasDeleted = await trxRepo.permanentDelete(collection, resolvedId);
|
|
779
|
+
|
|
780
|
+
if (wasDeleted) {
|
|
781
|
+
// Clean up SEO data for permanently deleted content
|
|
782
|
+
const seoRepo = new SeoRepository(trx);
|
|
783
|
+
await seoRepo.delete(collection, resolvedId);
|
|
784
|
+
// Clean up comments for permanently deleted content
|
|
785
|
+
const commentRepo = new CommentRepository(trx);
|
|
786
|
+
await commentRepo.deleteByContent(collection, resolvedId);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
return wasDeleted;
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
if (!deleted) {
|
|
793
|
+
return {
|
|
794
|
+
success: false,
|
|
795
|
+
error: {
|
|
796
|
+
code: "NOT_FOUND",
|
|
797
|
+
message: `Content item not found: ${id}`,
|
|
798
|
+
},
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
return {
|
|
803
|
+
success: true,
|
|
804
|
+
data: { deleted: true },
|
|
805
|
+
};
|
|
806
|
+
} catch (error) {
|
|
807
|
+
console.error("Content permanent delete error:", error);
|
|
808
|
+
return {
|
|
809
|
+
success: false,
|
|
810
|
+
error: {
|
|
811
|
+
code: "CONTENT_DELETE_ERROR",
|
|
812
|
+
message: "Failed to permanently delete content",
|
|
813
|
+
},
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* List trashed content items
|
|
820
|
+
*/
|
|
821
|
+
export async function handleContentListTrashed(
|
|
822
|
+
db: Kysely<Database>,
|
|
823
|
+
collection: string,
|
|
824
|
+
options: { limit?: number; cursor?: string } = {},
|
|
825
|
+
): Promise<ApiResult<{ items: TrashedContentItem[]; nextCursor?: string }>> {
|
|
826
|
+
try {
|
|
827
|
+
const repo = new ContentRepository(db);
|
|
828
|
+
const result = await repo.findTrashed(collection, {
|
|
829
|
+
limit: options.limit,
|
|
830
|
+
cursor: options.cursor,
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
return {
|
|
834
|
+
success: true,
|
|
835
|
+
data: {
|
|
836
|
+
items: result.items.map((item) => ({
|
|
837
|
+
id: item.id,
|
|
838
|
+
type: item.type,
|
|
839
|
+
slug: item.slug,
|
|
840
|
+
status: item.status,
|
|
841
|
+
data: item.data,
|
|
842
|
+
authorId: item.authorId,
|
|
843
|
+
createdAt: item.createdAt,
|
|
844
|
+
updatedAt: item.updatedAt,
|
|
845
|
+
publishedAt: item.publishedAt,
|
|
846
|
+
deletedAt: item.deletedAt,
|
|
847
|
+
})),
|
|
848
|
+
nextCursor: result.nextCursor,
|
|
849
|
+
},
|
|
850
|
+
};
|
|
851
|
+
} catch (error) {
|
|
852
|
+
console.error("Content list trashed error:", error);
|
|
853
|
+
return {
|
|
854
|
+
success: false,
|
|
855
|
+
error: {
|
|
856
|
+
code: "CONTENT_LIST_ERROR",
|
|
857
|
+
message: "Failed to list trashed content",
|
|
858
|
+
},
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* Count trashed content items
|
|
865
|
+
*/
|
|
866
|
+
export async function handleContentCountTrashed(
|
|
867
|
+
db: Kysely<Database>,
|
|
868
|
+
collection: string,
|
|
869
|
+
): Promise<ApiResult<{ count: number }>> {
|
|
870
|
+
try {
|
|
871
|
+
const repo = new ContentRepository(db);
|
|
872
|
+
const count = await repo.countTrashed(collection);
|
|
873
|
+
|
|
874
|
+
return {
|
|
875
|
+
success: true,
|
|
876
|
+
data: { count },
|
|
877
|
+
};
|
|
878
|
+
} catch (error) {
|
|
879
|
+
console.error("Content count trashed error:", error);
|
|
880
|
+
return {
|
|
881
|
+
success: false,
|
|
882
|
+
error: {
|
|
883
|
+
code: "CONTENT_COUNT_ERROR",
|
|
884
|
+
message: "Failed to count trashed content",
|
|
885
|
+
},
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* Schedule content for future publishing
|
|
892
|
+
*/
|
|
893
|
+
export async function handleContentSchedule(
|
|
894
|
+
db: Kysely<Database>,
|
|
895
|
+
collection: string,
|
|
896
|
+
id: string,
|
|
897
|
+
scheduledAt: string,
|
|
898
|
+
): Promise<ApiResult<ContentResponse>> {
|
|
899
|
+
try {
|
|
900
|
+
const item = await withTransaction(db, async (trx) => {
|
|
901
|
+
const repo = new ContentRepository(trx);
|
|
902
|
+
const resolvedId = (await resolveId(repo, collection, id)) ?? id;
|
|
903
|
+
return repo.schedule(collection, resolvedId, scheduledAt);
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
const hasSeo = await collectionHasSeo(db, collection);
|
|
907
|
+
await hydrateSeo(db, collection, item, hasSeo);
|
|
908
|
+
|
|
909
|
+
return {
|
|
910
|
+
success: true,
|
|
911
|
+
data: { item },
|
|
912
|
+
};
|
|
913
|
+
} catch (error) {
|
|
914
|
+
if (error instanceof EmDashValidationError) {
|
|
915
|
+
return {
|
|
916
|
+
success: false,
|
|
917
|
+
error: {
|
|
918
|
+
code: "VALIDATION_ERROR",
|
|
919
|
+
message: error.message,
|
|
920
|
+
},
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
console.error("Content schedule error:", error);
|
|
924
|
+
return {
|
|
925
|
+
success: false,
|
|
926
|
+
error: {
|
|
927
|
+
code: "CONTENT_SCHEDULE_ERROR",
|
|
928
|
+
message: "Failed to schedule content",
|
|
929
|
+
},
|
|
930
|
+
};
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* Unschedule content (revert to draft)
|
|
936
|
+
*/
|
|
937
|
+
export async function handleContentUnschedule(
|
|
938
|
+
db: Kysely<Database>,
|
|
939
|
+
collection: string,
|
|
940
|
+
id: string,
|
|
941
|
+
): Promise<ApiResult<ContentResponse>> {
|
|
942
|
+
try {
|
|
943
|
+
const item = await withTransaction(db, async (trx) => {
|
|
944
|
+
const repo = new ContentRepository(trx);
|
|
945
|
+
const resolvedId = (await resolveId(repo, collection, id)) ?? id;
|
|
946
|
+
return repo.unschedule(collection, resolvedId);
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
const hasSeo = await collectionHasSeo(db, collection);
|
|
950
|
+
await hydrateSeo(db, collection, item, hasSeo);
|
|
951
|
+
|
|
952
|
+
return {
|
|
953
|
+
success: true,
|
|
954
|
+
data: { item },
|
|
955
|
+
};
|
|
956
|
+
} catch (error) {
|
|
957
|
+
console.error("Content unschedule error:", error);
|
|
958
|
+
return {
|
|
959
|
+
success: false,
|
|
960
|
+
error: {
|
|
961
|
+
code: "CONTENT_UNSCHEDULE_ERROR",
|
|
962
|
+
message: "Failed to unschedule content",
|
|
963
|
+
},
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* Publish content immediately.
|
|
970
|
+
*
|
|
971
|
+
* Wrapped in a transaction because publish performs multiple writes
|
|
972
|
+
* (syncDataColumns, slug sync, status/revision update) that must
|
|
973
|
+
* be atomic to prevent FTS shadow table corruption on crash.
|
|
974
|
+
*/
|
|
975
|
+
export async function handleContentPublish(
|
|
976
|
+
db: Kysely<Database>,
|
|
977
|
+
collection: string,
|
|
978
|
+
id: string,
|
|
979
|
+
): Promise<ApiResult<ContentResponse>> {
|
|
980
|
+
try {
|
|
981
|
+
const item = await withTransaction(db, async (trx) => {
|
|
982
|
+
const repo = new ContentRepository(trx);
|
|
983
|
+
const resolvedId = (await resolveId(repo, collection, id)) ?? id;
|
|
984
|
+
return repo.publish(collection, resolvedId);
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
const hasSeo = await collectionHasSeo(db, collection);
|
|
988
|
+
await hydrateSeo(db, collection, item, hasSeo);
|
|
989
|
+
|
|
990
|
+
return {
|
|
991
|
+
success: true,
|
|
992
|
+
data: { item },
|
|
993
|
+
};
|
|
994
|
+
} catch (error) {
|
|
995
|
+
console.error("Content publish error:", error);
|
|
996
|
+
return {
|
|
997
|
+
success: false,
|
|
998
|
+
error: {
|
|
999
|
+
code: "CONTENT_PUBLISH_ERROR",
|
|
1000
|
+
message: "Failed to publish content",
|
|
1001
|
+
},
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* Unpublish content (revert to draft).
|
|
1008
|
+
*
|
|
1009
|
+
* Wrapped in a transaction — unpublish may create a draft revision
|
|
1010
|
+
* from the live version then update the status, which is multi-step.
|
|
1011
|
+
*/
|
|
1012
|
+
export async function handleContentUnpublish(
|
|
1013
|
+
db: Kysely<Database>,
|
|
1014
|
+
collection: string,
|
|
1015
|
+
id: string,
|
|
1016
|
+
): Promise<ApiResult<ContentResponse>> {
|
|
1017
|
+
try {
|
|
1018
|
+
const item = await withTransaction(db, async (trx) => {
|
|
1019
|
+
const repo = new ContentRepository(trx);
|
|
1020
|
+
const resolvedId = (await resolveId(repo, collection, id)) ?? id;
|
|
1021
|
+
return repo.unpublish(collection, resolvedId);
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
const hasSeo = await collectionHasSeo(db, collection);
|
|
1025
|
+
await hydrateSeo(db, collection, item, hasSeo);
|
|
1026
|
+
|
|
1027
|
+
return {
|
|
1028
|
+
success: true,
|
|
1029
|
+
data: { item },
|
|
1030
|
+
};
|
|
1031
|
+
} catch (error) {
|
|
1032
|
+
console.error("Content unpublish error:", error);
|
|
1033
|
+
return {
|
|
1034
|
+
success: false,
|
|
1035
|
+
error: {
|
|
1036
|
+
code: "CONTENT_UNPUBLISH_ERROR",
|
|
1037
|
+
message: "Failed to unpublish content",
|
|
1038
|
+
},
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* Count scheduled content items
|
|
1045
|
+
*/
|
|
1046
|
+
export async function handleContentCountScheduled(
|
|
1047
|
+
db: Kysely<Database>,
|
|
1048
|
+
collection: string,
|
|
1049
|
+
): Promise<ApiResult<{ count: number }>> {
|
|
1050
|
+
try {
|
|
1051
|
+
const repo = new ContentRepository(db);
|
|
1052
|
+
const count = await repo.countScheduled(collection);
|
|
1053
|
+
|
|
1054
|
+
return {
|
|
1055
|
+
success: true,
|
|
1056
|
+
data: { count },
|
|
1057
|
+
};
|
|
1058
|
+
} catch (error) {
|
|
1059
|
+
console.error("Content count scheduled error:", error);
|
|
1060
|
+
return {
|
|
1061
|
+
success: false,
|
|
1062
|
+
error: {
|
|
1063
|
+
code: "CONTENT_COUNT_ERROR",
|
|
1064
|
+
message: "Failed to count scheduled content",
|
|
1065
|
+
},
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* Discard draft changes (revert to live version)
|
|
1072
|
+
*/
|
|
1073
|
+
export async function handleContentDiscardDraft(
|
|
1074
|
+
db: Kysely<Database>,
|
|
1075
|
+
collection: string,
|
|
1076
|
+
id: string,
|
|
1077
|
+
): Promise<ApiResult<ContentResponse>> {
|
|
1078
|
+
try {
|
|
1079
|
+
const item = await withTransaction(db, async (trx) => {
|
|
1080
|
+
const repo = new ContentRepository(trx);
|
|
1081
|
+
const resolvedId = (await resolveId(repo, collection, id)) ?? id;
|
|
1082
|
+
return repo.discardDraft(collection, resolvedId);
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
const hasSeo = await collectionHasSeo(db, collection);
|
|
1086
|
+
await hydrateSeo(db, collection, item, hasSeo);
|
|
1087
|
+
|
|
1088
|
+
return {
|
|
1089
|
+
success: true,
|
|
1090
|
+
data: { item },
|
|
1091
|
+
};
|
|
1092
|
+
} catch (error) {
|
|
1093
|
+
if (error instanceof EmDashValidationError) {
|
|
1094
|
+
return {
|
|
1095
|
+
success: false,
|
|
1096
|
+
error: {
|
|
1097
|
+
code: "NOT_FOUND",
|
|
1098
|
+
message: error.message,
|
|
1099
|
+
},
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
console.error("Content discard draft error:", error);
|
|
1103
|
+
return {
|
|
1104
|
+
success: false,
|
|
1105
|
+
error: {
|
|
1106
|
+
code: "CONTENT_DISCARD_DRAFT_ERROR",
|
|
1107
|
+
message: "Failed to discard draft",
|
|
1108
|
+
},
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* Compare live and draft revisions
|
|
1115
|
+
*/
|
|
1116
|
+
export async function handleContentCompare(
|
|
1117
|
+
db: Kysely<Database>,
|
|
1118
|
+
collection: string,
|
|
1119
|
+
id: string,
|
|
1120
|
+
): Promise<
|
|
1121
|
+
ApiResult<{
|
|
1122
|
+
hasChanges: boolean;
|
|
1123
|
+
live: Record<string, unknown> | null;
|
|
1124
|
+
draft: Record<string, unknown> | null;
|
|
1125
|
+
}>
|
|
1126
|
+
> {
|
|
1127
|
+
try {
|
|
1128
|
+
const repo = new ContentRepository(db);
|
|
1129
|
+
const entry = await repo.findByIdOrSlug(collection, id);
|
|
1130
|
+
|
|
1131
|
+
if (!entry) {
|
|
1132
|
+
return {
|
|
1133
|
+
success: false,
|
|
1134
|
+
error: {
|
|
1135
|
+
code: "NOT_FOUND",
|
|
1136
|
+
message: `Content item not found: ${id}`,
|
|
1137
|
+
},
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
const revisionRepo = new RevisionRepository(db);
|
|
1142
|
+
|
|
1143
|
+
const live = entry.liveRevisionId ? await revisionRepo.findById(entry.liveRevisionId) : null;
|
|
1144
|
+
const draft = entry.draftRevisionId ? await revisionRepo.findById(entry.draftRevisionId) : null;
|
|
1145
|
+
|
|
1146
|
+
return {
|
|
1147
|
+
success: true,
|
|
1148
|
+
data: {
|
|
1149
|
+
hasChanges:
|
|
1150
|
+
entry.draftRevisionId !== null && entry.draftRevisionId !== entry.liveRevisionId,
|
|
1151
|
+
live: live?.data ?? null,
|
|
1152
|
+
draft: draft?.data ?? null,
|
|
1153
|
+
},
|
|
1154
|
+
};
|
|
1155
|
+
} catch (error) {
|
|
1156
|
+
console.error("Content compare error:", error);
|
|
1157
|
+
return {
|
|
1158
|
+
success: false,
|
|
1159
|
+
error: {
|
|
1160
|
+
code: "CONTENT_COMPARE_ERROR",
|
|
1161
|
+
message: "Failed to compare revisions",
|
|
1162
|
+
},
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/**
|
|
1168
|
+
* Get all translations for a content item.
|
|
1169
|
+
* Returns the item's translation group members with locale and status info.
|
|
1170
|
+
*/
|
|
1171
|
+
export async function handleContentTranslations(
|
|
1172
|
+
db: Kysely<Database>,
|
|
1173
|
+
collection: string,
|
|
1174
|
+
id: string,
|
|
1175
|
+
): Promise<
|
|
1176
|
+
ApiResult<{
|
|
1177
|
+
translationGroup: string;
|
|
1178
|
+
translations: Array<{
|
|
1179
|
+
id: string;
|
|
1180
|
+
locale: string | null;
|
|
1181
|
+
slug: string | null;
|
|
1182
|
+
status: string;
|
|
1183
|
+
updatedAt: string;
|
|
1184
|
+
}>;
|
|
1185
|
+
}>
|
|
1186
|
+
> {
|
|
1187
|
+
try {
|
|
1188
|
+
const repo = new ContentRepository(db);
|
|
1189
|
+
const item = await repo.findByIdOrSlug(collection, id);
|
|
1190
|
+
|
|
1191
|
+
if (!item) {
|
|
1192
|
+
return {
|
|
1193
|
+
success: false,
|
|
1194
|
+
error: {
|
|
1195
|
+
code: "NOT_FOUND",
|
|
1196
|
+
message: `Content item not found: ${id}`,
|
|
1197
|
+
},
|
|
1198
|
+
};
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
if (!item.translationGroup) {
|
|
1202
|
+
return {
|
|
1203
|
+
success: true,
|
|
1204
|
+
data: {
|
|
1205
|
+
translationGroup: item.id,
|
|
1206
|
+
translations: [
|
|
1207
|
+
{
|
|
1208
|
+
id: item.id,
|
|
1209
|
+
locale: item.locale,
|
|
1210
|
+
slug: item.slug,
|
|
1211
|
+
status: item.status,
|
|
1212
|
+
updatedAt: item.updatedAt,
|
|
1213
|
+
},
|
|
1214
|
+
],
|
|
1215
|
+
},
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
const translations = await repo.findTranslations(collection, item.translationGroup);
|
|
1220
|
+
|
|
1221
|
+
return {
|
|
1222
|
+
success: true,
|
|
1223
|
+
data: {
|
|
1224
|
+
translationGroup: item.translationGroup,
|
|
1225
|
+
translations: translations.map((t) => ({
|
|
1226
|
+
id: t.id,
|
|
1227
|
+
locale: t.locale,
|
|
1228
|
+
slug: t.slug,
|
|
1229
|
+
status: t.status,
|
|
1230
|
+
updatedAt: t.updatedAt,
|
|
1231
|
+
})),
|
|
1232
|
+
},
|
|
1233
|
+
};
|
|
1234
|
+
} catch (error) {
|
|
1235
|
+
if (error instanceof Error) {
|
|
1236
|
+
console.error("Content translations error:", error);
|
|
1237
|
+
}
|
|
1238
|
+
return {
|
|
1239
|
+
success: false,
|
|
1240
|
+
error: {
|
|
1241
|
+
code: "CONTENT_TRANSLATIONS_ERROR",
|
|
1242
|
+
message: "Failed to get translations",
|
|
1243
|
+
},
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// ---------------------------------------------------------------------------
|
|
1249
|
+
// Non-translatable field sync
|
|
1250
|
+
// ---------------------------------------------------------------------------
|
|
1251
|
+
|
|
1252
|
+
/**
|
|
1253
|
+
* Sync non-translatable fields to sibling locales.
|
|
1254
|
+
*
|
|
1255
|
+
* When a content item is updated and it belongs to a translation group,
|
|
1256
|
+
* any non-translatable fields in the update data are written to all other
|
|
1257
|
+
* rows in the same translation group within the same transaction.
|
|
1258
|
+
*
|
|
1259
|
+
* Non-translatable fields are **copied, not linked** — each row owns its
|
|
1260
|
+
* own data. This keeps queries simple and avoids cross-row joins.
|
|
1261
|
+
*/
|
|
1262
|
+
async function syncNonTranslatableFields(
|
|
1263
|
+
trx: Kysely<Database>,
|
|
1264
|
+
collectionSlug: string,
|
|
1265
|
+
updatedItemId: string,
|
|
1266
|
+
translationGroup: string,
|
|
1267
|
+
data: Record<string, unknown>,
|
|
1268
|
+
): Promise<void> {
|
|
1269
|
+
// Get the collection to find its fields
|
|
1270
|
+
const collection = await trx
|
|
1271
|
+
.selectFrom("_emdash_collections")
|
|
1272
|
+
.select("id")
|
|
1273
|
+
.where("slug", "=", collectionSlug)
|
|
1274
|
+
.executeTakeFirst();
|
|
1275
|
+
|
|
1276
|
+
if (!collection) return;
|
|
1277
|
+
|
|
1278
|
+
// Find non-translatable fields that are present in the update data
|
|
1279
|
+
const fields = await trx
|
|
1280
|
+
.selectFrom("_emdash_fields")
|
|
1281
|
+
.select("slug")
|
|
1282
|
+
.where("collection_id", "=", collection.id)
|
|
1283
|
+
.where("translatable", "=", 0)
|
|
1284
|
+
.execute();
|
|
1285
|
+
|
|
1286
|
+
const nonTranslatableSlugs = fields.map((f) => f.slug);
|
|
1287
|
+
if (nonTranslatableSlugs.length === 0) return;
|
|
1288
|
+
|
|
1289
|
+
// Filter to only the non-translatable fields present in this update
|
|
1290
|
+
const syncData: Record<string, unknown> = {};
|
|
1291
|
+
for (const slug of nonTranslatableSlugs) {
|
|
1292
|
+
if (slug in data) {
|
|
1293
|
+
syncData[slug] = data[slug];
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
if (Object.keys(syncData).length === 0) return;
|
|
1297
|
+
|
|
1298
|
+
// Build the SET clause for sibling rows
|
|
1299
|
+
validateIdentifier(collectionSlug, "collection slug");
|
|
1300
|
+
const tableName = `ec_${collectionSlug}`;
|
|
1301
|
+
|
|
1302
|
+
// Update all sibling rows (same translation_group, different id)
|
|
1303
|
+
const setClauses = Object.entries(syncData).map(([key, value]) => {
|
|
1304
|
+
validateIdentifier(key, "field slug");
|
|
1305
|
+
const serialized = typeof value === "object" && value !== null ? JSON.stringify(value) : value;
|
|
1306
|
+
return sql`${sql.ref(key)} = ${serialized}`;
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1309
|
+
await sql`
|
|
1310
|
+
UPDATE ${sql.ref(tableName)}
|
|
1311
|
+
SET ${sql.join(setClauses, sql`, `)}
|
|
1312
|
+
WHERE translation_group = ${translationGroup}
|
|
1313
|
+
AND id != ${updatedItemId}
|
|
1314
|
+
`.execute(trx);
|
|
1315
|
+
}
|