emdash 0.17.0 → 0.17.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/dist/api/route-utils.mjs +11 -11
- package/dist/api/schemas/index.d.mts +1 -1
- package/dist/{api-Dmz40c2V.mjs → api-B7GATEYo.mjs} +12 -12
- package/dist/{api-Dmz40c2V.mjs.map → api-B7GATEYo.mjs.map} +1 -1
- package/dist/{apply-CgamLmed.mjs → apply-BrVqULFe.mjs} +16 -16
- package/dist/{apply-CgamLmed.mjs.map → apply-BrVqULFe.mjs.map} +1 -1
- package/dist/astro/index.d.mts +2 -2
- package/dist/astro/index.mjs +10 -1
- package/dist/astro/index.mjs.map +1 -1
- package/dist/astro/middleware/auth.d.mts +2 -2
- package/dist/astro/middleware/auth.mjs +2 -2
- package/dist/astro/middleware/redirect.mjs +5 -5
- package/dist/astro/middleware.d.mts.map +1 -1
- package/dist/astro/middleware.mjs +65 -49
- package/dist/astro/middleware.mjs.map +1 -1
- package/dist/astro/routes/api/admin/allowed-domains/_domain_.mjs +3 -3
- package/dist/astro/routes/api/admin/allowed-domains/index.mjs +3 -3
- package/dist/astro/routes/api/admin/api-tokens/_id_.mjs +2 -2
- package/dist/astro/routes/api/admin/api-tokens/index.mjs +3 -3
- package/dist/astro/routes/api/admin/byline-fields/_slug_/usage.mjs +3 -3
- package/dist/astro/routes/api/admin/byline-fields/_slug_.mjs +4 -4
- package/dist/astro/routes/api/admin/byline-fields/index.mjs +4 -4
- package/dist/astro/routes/api/admin/byline-fields/reorder.mjs +4 -4
- package/dist/astro/routes/api/admin/bylines/_id_/index.mjs +9 -9
- package/dist/astro/routes/api/admin/bylines/_id_/translations.mjs +9 -9
- package/dist/astro/routes/api/admin/bylines/index.mjs +9 -9
- package/dist/astro/routes/api/admin/comments/_id_/status.mjs +7 -7
- package/dist/astro/routes/api/admin/comments/_id_.mjs +5 -5
- package/dist/astro/routes/api/admin/comments/bulk.mjs +6 -6
- package/dist/astro/routes/api/admin/comments/counts.mjs +5 -5
- package/dist/astro/routes/api/admin/comments/index.mjs +6 -6
- package/dist/astro/routes/api/admin/hooks/exclusive/_hookName_.mjs +4 -4
- package/dist/astro/routes/api/admin/hooks/exclusive/index.mjs +3 -3
- package/dist/astro/routes/api/admin/oauth-clients/_id_.mjs +3 -3
- package/dist/astro/routes/api/admin/oauth-clients/index.mjs +3 -3
- package/dist/astro/routes/api/admin/plugins/_id_/disable.mjs +26 -26
- package/dist/astro/routes/api/admin/plugins/_id_/enable.mjs +26 -26
- package/dist/astro/routes/api/admin/plugins/_id_/index.mjs +26 -26
- package/dist/astro/routes/api/admin/plugins/_id_/uninstall.mjs +26 -26
- package/dist/astro/routes/api/admin/plugins/_id_/update.mjs +26 -26
- package/dist/astro/routes/api/admin/plugins/index.mjs +26 -26
- package/dist/astro/routes/api/admin/plugins/marketplace/_id_/icon.mjs +3 -3
- package/dist/astro/routes/api/admin/plugins/marketplace/_id_/index.mjs +26 -26
- package/dist/astro/routes/api/admin/plugins/marketplace/_id_/install.mjs +26 -26
- package/dist/astro/routes/api/admin/plugins/marketplace/index.mjs +26 -26
- package/dist/astro/routes/api/admin/plugins/registry/_id_/uninstall.mjs +26 -26
- package/dist/astro/routes/api/admin/plugins/registry/_id_/update.mjs +27 -27
- package/dist/astro/routes/api/admin/plugins/registry/artifact.mjs +26 -26
- package/dist/astro/routes/api/admin/plugins/registry/install.mjs +27 -27
- package/dist/astro/routes/api/admin/plugins/updates.mjs +26 -26
- package/dist/astro/routes/api/admin/themes/marketplace/_id_/index.mjs +26 -26
- package/dist/astro/routes/api/admin/themes/marketplace/_id_/thumbnail.mjs +3 -3
- package/dist/astro/routes/api/admin/themes/marketplace/index.mjs +26 -26
- package/dist/astro/routes/api/admin/users/_id_/disable.mjs +2 -2
- package/dist/astro/routes/api/admin/users/_id_/enable.mjs +2 -2
- package/dist/astro/routes/api/admin/users/_id_/index.mjs +3 -3
- package/dist/astro/routes/api/admin/users/_id_/send-recovery.mjs +2 -2
- package/dist/astro/routes/api/admin/users/index.mjs +3 -3
- package/dist/astro/routes/api/auth/dev-bypass.mjs +4 -4
- package/dist/astro/routes/api/auth/invite/accept.mjs +2 -2
- package/dist/astro/routes/api/auth/invite/complete.mjs +3 -3
- package/dist/astro/routes/api/auth/invite/index.mjs +3 -3
- package/dist/astro/routes/api/auth/invite/register-options.mjs +3 -3
- package/dist/astro/routes/api/auth/logout.mjs +2 -2
- package/dist/astro/routes/api/auth/magic-link/send.mjs +4 -4
- package/dist/astro/routes/api/auth/magic-link/verify.mjs +2 -2
- package/dist/astro/routes/api/auth/me.mjs +4 -4
- package/dist/astro/routes/api/auth/passkey/_id_.mjs +3 -3
- package/dist/astro/routes/api/auth/passkey/index.mjs +2 -2
- package/dist/astro/routes/api/auth/passkey/options.mjs +4 -4
- package/dist/astro/routes/api/auth/passkey/register/options.mjs +3 -3
- package/dist/astro/routes/api/auth/passkey/register/verify.mjs +3 -3
- package/dist/astro/routes/api/auth/passkey/verify.mjs +3 -3
- package/dist/astro/routes/api/auth/signup/complete.mjs +3 -3
- package/dist/astro/routes/api/auth/signup/request.mjs +4 -4
- package/dist/astro/routes/api/auth/signup/verify.mjs +2 -2
- package/dist/astro/routes/api/comments/_collection_/_contentId_/index.mjs +6 -6
- package/dist/astro/routes/api/content/_collection_/_id_/compare.mjs +3 -3
- package/dist/astro/routes/api/content/_collection_/_id_/discard-draft.mjs +3 -3
- package/dist/astro/routes/api/content/_collection_/_id_/duplicate.mjs +3 -3
- package/dist/astro/routes/api/content/_collection_/_id_/permanent.mjs +3 -3
- package/dist/astro/routes/api/content/_collection_/_id_/preview-url.mjs +4 -4
- package/dist/astro/routes/api/content/_collection_/_id_/publish.mjs +4 -4
- package/dist/astro/routes/api/content/_collection_/_id_/restore.mjs +3 -3
- package/dist/astro/routes/api/content/_collection_/_id_/revisions.mjs +3 -3
- package/dist/astro/routes/api/content/_collection_/_id_/schedule.mjs +4 -4
- package/dist/astro/routes/api/content/_collection_/_id_/terms/_taxonomy_.mjs +9 -9
- package/dist/astro/routes/api/content/_collection_/_id_/translations.mjs +3 -3
- package/dist/astro/routes/api/content/_collection_/_id_/unpublish.mjs +3 -3
- package/dist/astro/routes/api/content/_collection_/_id_.mjs +4 -4
- package/dist/astro/routes/api/content/_collection_/index.mjs +4 -4
- package/dist/astro/routes/api/content/_collection_/trash.mjs +4 -4
- package/dist/astro/routes/api/dashboard.mjs +7 -7
- package/dist/astro/routes/api/dev/emails.mjs +2 -2
- package/dist/astro/routes/api/import/probe.mjs +4 -4
- package/dist/astro/routes/api/import/wordpress/analyze.mjs +3 -3
- package/dist/astro/routes/api/import/wordpress/execute.d.mts +2 -2
- package/dist/astro/routes/api/import/wordpress/execute.mjs +8 -8
- package/dist/astro/routes/api/import/wordpress/media.mjs +4 -4
- package/dist/astro/routes/api/import/wordpress/prepare.mjs +6 -6
- package/dist/astro/routes/api/import/wordpress/rewrite-urls.mjs +5 -5
- package/dist/astro/routes/api/import/wordpress-plugin/analyze.mjs +4 -4
- package/dist/astro/routes/api/import/wordpress-plugin/execute.mjs +6 -6
- package/dist/astro/routes/api/manifest.mjs +3 -3
- package/dist/astro/routes/api/mcp.mjs +26 -26
- package/dist/astro/routes/api/media/_id_/confirm.mjs +4 -4
- package/dist/astro/routes/api/media/_id_.mjs +4 -4
- package/dist/astro/routes/api/media/file/_...key_.mjs +2 -2
- package/dist/astro/routes/api/media/providers/_providerId_/_itemId_.mjs +3 -3
- package/dist/astro/routes/api/media/providers/_providerId_/index.mjs +3 -3
- package/dist/astro/routes/api/media/providers/index.mjs +3 -3
- package/dist/astro/routes/api/media/upload-url.mjs +4 -4
- package/dist/astro/routes/api/media.mjs +5 -5
- package/dist/astro/routes/api/menus/_name_/items/_id_.mjs +5 -5
- package/dist/astro/routes/api/menus/_name_/items.mjs +5 -5
- package/dist/astro/routes/api/menus/_name_/reorder.mjs +5 -5
- package/dist/astro/routes/api/menus/_name_/translations.mjs +5 -5
- package/dist/astro/routes/api/menus/_name_.mjs +5 -5
- package/dist/astro/routes/api/menus/index.mjs +5 -5
- package/dist/astro/routes/api/oauth/device/authorize.mjs +3 -3
- package/dist/astro/routes/api/oauth/device/code.mjs +4 -4
- package/dist/astro/routes/api/oauth/device/token.mjs +4 -4
- package/dist/astro/routes/api/oauth/register.mjs +2 -2
- package/dist/astro/routes/api/oauth/token/refresh.mjs +3 -3
- package/dist/astro/routes/api/oauth/token/revoke.mjs +3 -3
- package/dist/astro/routes/api/oauth/token.mjs +2 -2
- package/dist/astro/routes/api/openapi.json.mjs +2 -2
- package/dist/astro/routes/api/plugins/_pluginId_/_...path_.mjs +3 -3
- package/dist/astro/routes/api/redirects/404s/index.mjs +7 -7
- package/dist/astro/routes/api/redirects/404s/summary.mjs +7 -7
- package/dist/astro/routes/api/redirects/_id_.mjs +8 -8
- package/dist/astro/routes/api/redirects/index.mjs +8 -8
- package/dist/astro/routes/api/revisions/_revisionId_/index.mjs +3 -3
- package/dist/astro/routes/api/revisions/_revisionId_/restore.mjs +3 -3
- package/dist/astro/routes/api/schema/collections/_slug_/fields/_fieldSlug_.mjs +26 -26
- package/dist/astro/routes/api/schema/collections/_slug_/fields/index.mjs +26 -26
- package/dist/astro/routes/api/schema/collections/_slug_/fields/reorder.mjs +26 -26
- package/dist/astro/routes/api/schema/collections/_slug_/index.mjs +26 -26
- package/dist/astro/routes/api/schema/collections/index.mjs +26 -26
- package/dist/astro/routes/api/schema/index.mjs +7 -7
- package/dist/astro/routes/api/schema/orphans/_slug_.mjs +26 -26
- package/dist/astro/routes/api/schema/orphans/index.mjs +26 -26
- package/dist/astro/routes/api/search/enable.mjs +8 -8
- package/dist/astro/routes/api/search/index.mjs +7 -7
- package/dist/astro/routes/api/search/rebuild.mjs +8 -8
- package/dist/astro/routes/api/search/stats.mjs +7 -7
- package/dist/astro/routes/api/search/suggest.mjs +7 -7
- package/dist/astro/routes/api/sections/_slug_.mjs +7 -7
- package/dist/astro/routes/api/sections/index.mjs +7 -7
- package/dist/astro/routes/api/settings/email.mjs +4 -4
- package/dist/astro/routes/api/settings.mjs +9 -9
- package/dist/astro/routes/api/setup/admin-verify.mjs +3 -3
- package/dist/astro/routes/api/setup/admin.mjs +3 -3
- package/dist/astro/routes/api/setup/dev-bypass.mjs +16 -16
- package/dist/astro/routes/api/setup/dev-reset.mjs +2 -2
- package/dist/astro/routes/api/setup/index.mjs +17 -17
- package/dist/astro/routes/api/setup/status.mjs +3 -3
- package/dist/astro/routes/api/snapshot.mjs +3 -3
- package/dist/astro/routes/api/taxonomies/_name_/terms/_slug_/translations.mjs +9 -9
- package/dist/astro/routes/api/taxonomies/_name_/terms/_slug_.mjs +9 -9
- package/dist/astro/routes/api/taxonomies/_name_/terms/index.mjs +9 -9
- package/dist/astro/routes/api/taxonomies/index.mjs +9 -9
- package/dist/astro/routes/api/themes/preview.mjs +3 -3
- package/dist/astro/routes/api/typegen.mjs +5 -5
- package/dist/astro/routes/api/widget-areas/_name_/reorder.mjs +4 -4
- package/dist/astro/routes/api/widget-areas/_name_/widgets/_id_.mjs +7 -7
- package/dist/astro/routes/api/widget-areas/_name_/widgets.mjs +7 -7
- package/dist/astro/routes/api/widget-areas/_name_.mjs +6 -6
- package/dist/astro/routes/api/widget-areas/index.mjs +7 -7
- package/dist/astro/routes/api/widget-components.mjs +2 -2
- package/dist/astro/routes/robots.txt.mjs +5 -5
- package/dist/astro/routes/sitemap-_collection_.xml.mjs +5 -5
- package/dist/astro/routes/sitemap.xml.mjs +5 -5
- package/dist/astro/types.d.mts +2 -2
- package/dist/{authorize-_wWM_44T.mjs → authorize-CLTmOUyx.mjs} +2 -2
- package/dist/{authorize-_wWM_44T.mjs.map → authorize-CLTmOUyx.mjs.map} +1 -1
- package/dist/{byline-BrIVWLm-.mjs → byline-CAhk4FrG.mjs} +4 -4
- package/dist/{byline-BrIVWLm-.mjs.map → byline-CAhk4FrG.mjs.map} +1 -1
- package/dist/{byline-fields-BNy7Ng1U.d.mts → byline-fields-CR5hGLMw.d.mts} +28 -28
- package/dist/{byline-fields-BNy7Ng1U.d.mts.map → byline-fields-CR5hGLMw.d.mts.map} +1 -1
- package/dist/{bylines-sqExMElV.mjs → bylines-CbrD7STW.mjs} +3 -3
- package/dist/{bylines-sqExMElV.mjs.map → bylines-CbrD7STW.mjs.map} +1 -1
- package/dist/{bylines-C_POWmGT.mjs → bylines-DCczH3AV.mjs} +4 -4
- package/dist/{bylines-C_POWmGT.mjs.map → bylines-DCczH3AV.mjs.map} +1 -1
- package/dist/{cache-wsDkA8ru.mjs → cache-DIHHyPkt.mjs} +2 -2
- package/dist/{cache-wsDkA8ru.mjs.map → cache-DIHHyPkt.mjs.map} +1 -1
- package/dist/{chunks-BAYkM-CF.mjs → chunks-DnnHlRG3.mjs} +2 -2
- package/dist/{chunks-BAYkM-CF.mjs.map → chunks-DnnHlRG3.mjs.map} +1 -1
- package/dist/cli/index.mjs +125 -23
- package/dist/cli/index.mjs.map +1 -1
- package/dist/{comment-Cd29aktf.mjs → comment-DkAfGX9E.mjs} +2 -2
- package/dist/{comment-Cd29aktf.mjs.map → comment-DkAfGX9E.mjs.map} +1 -1
- package/dist/{comments-B7ufhkxN.mjs → comments-DLFnXs7J.mjs} +3 -3
- package/dist/{comments-B7ufhkxN.mjs.map → comments-DLFnXs7J.mjs.map} +1 -1
- package/dist/{content-BbqKo3Kc.mjs → content-C7aJ7keg.mjs} +3 -3
- package/dist/{content-BbqKo3Kc.mjs.map → content-C7aJ7keg.mjs.map} +1 -1
- package/dist/{context-BsF1rhoI.mjs → context-Ca0HkaIh.mjs} +8 -8
- package/dist/{context-BsF1rhoI.mjs.map → context-Ca0HkaIh.mjs.map} +1 -1
- package/dist/{dashboard-BwIX9r-X.mjs → dashboard-BrfLIsX1.mjs} +4 -4
- package/dist/{dashboard-BwIX9r-X.mjs.map → dashboard-BrfLIsX1.mjs.map} +1 -1
- package/dist/db/index.mjs +2 -2
- package/dist/{dialect-helpers-BKCvISIQ.mjs → dialect-helpers-DRI5pyY3.mjs} +3 -3
- package/dist/dialect-helpers-DRI5pyY3.mjs.map +1 -0
- package/dist/{error-npZWBSb7.mjs → error-Bk9s3Ism.mjs} +2 -2
- package/dist/{error-npZWBSb7.mjs.map → error-Bk9s3Ism.mjs.map} +1 -1
- package/dist/{fts-manager-DmUAk-kQ.mjs → fts-manager-XpDfbIKo.mjs} +3 -3
- package/dist/{fts-manager-DmUAk-kQ.mjs.map → fts-manager-XpDfbIKo.mjs.map} +1 -1
- package/dist/{index-CjKdMZ3U.d.mts → index-C8ciqSMJ.d.mts} +4 -4
- package/dist/{index-CjKdMZ3U.d.mts.map → index-C8ciqSMJ.d.mts.map} +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +35 -35
- package/dist/{load-DsoLq7ex.mjs → load-CF5oETkh.mjs} +2 -2
- package/dist/{load-DsoLq7ex.mjs.map → load-CF5oETkh.mjs.map} +1 -1
- package/dist/{loader-CJ6lWO0d.mjs → loader-BxyvbrZP.mjs} +4 -4
- package/dist/{loader-CJ6lWO0d.mjs.map → loader-BxyvbrZP.mjs.map} +1 -1
- package/dist/media/local-runtime.d.mts +2 -2
- package/dist/media/local-runtime.mjs +5 -5
- package/dist/{media-jk_HzzOl.mjs → media-Cyz5BhSN.mjs} +2 -2
- package/dist/{media-jk_HzzOl.mjs.map → media-Cyz5BhSN.mjs.map} +1 -1
- package/dist/{menus-CyMO6GBx.mjs → menus-CIdZ_Q6U.mjs} +4 -4
- package/dist/{menus-CyMO6GBx.mjs.map → menus-CIdZ_Q6U.mjs.map} +1 -1
- package/dist/{menus-B-5-3aon.mjs → menus-PFp8FDuO.mjs} +2 -2
- package/dist/{menus-B-5-3aon.mjs.map → menus-PFp8FDuO.mjs.map} +1 -1
- package/dist/{parse-4zO5Y2DL.mjs → parse-B-K21lvm.mjs} +2 -2
- package/dist/{parse-4zO5Y2DL.mjs.map → parse-B-K21lvm.mjs.map} +1 -1
- package/dist/plugin-utils.d.mts +2 -2
- package/dist/plugins/adapt-sandbox-entry.d.mts +2 -2
- package/dist/{query-CuvjwhrE.mjs → query-Cc649nDl.mjs} +17 -16
- package/dist/query-Cc649nDl.mjs.map +1 -0
- package/dist/{rate-limit-D6VQqBk_.mjs → rate-limit-BI1OdpQH.mjs} +2 -2
- package/dist/{rate-limit-D6VQqBk_.mjs.map → rate-limit-BI1OdpQH.mjs.map} +1 -1
- package/dist/{redirect-BZUJltlj.mjs → redirect-C-FeA4j9.mjs} +3 -3
- package/dist/{redirect-BZUJltlj.mjs.map → redirect-C-FeA4j9.mjs.map} +1 -1
- package/dist/{redirects-DnYuqsEf.mjs → redirects-C1UgU9E0.mjs} +3 -3
- package/dist/{redirects-DnYuqsEf.mjs.map → redirects-C1UgU9E0.mjs.map} +1 -1
- package/dist/{registry-Dn6gsx3L.mjs → registry-C-T_PWgp.mjs} +5 -5
- package/dist/{registry-Dn6gsx3L.mjs.map → registry-C-T_PWgp.mjs.map} +1 -1
- package/dist/{runner-eAgyIkeg.mjs → runner-BiuUfx-V.mjs} +4 -4
- package/dist/runner-BiuUfx-V.mjs.map +1 -0
- package/dist/runtime.d.mts +2 -2
- package/dist/runtime.mjs +3 -3
- package/dist/{schema--mYZX4D7.mjs → schema-BpCJh2lU.mjs} +4 -4
- package/dist/{schema--mYZX4D7.mjs.map → schema-BpCJh2lU.mjs.map} +1 -1
- package/dist/{search-C6U_NvZI.mjs → search-BrF7k0Ho.mjs} +4 -4
- package/dist/{search-C6U_NvZI.mjs.map → search-BrF7k0Ho.mjs.map} +1 -1
- package/dist/{sections-Ba-rJLKb.mjs → sections-8DEa-dWt.mjs} +3 -3
- package/dist/{sections-Ba-rJLKb.mjs.map → sections-8DEa-dWt.mjs.map} +1 -1
- package/dist/seed/index.mjs +14 -14
- package/dist/seo/index.mjs +1 -0
- package/dist/seo/index.mjs.map +1 -1
- package/dist/{seo-BTzb5ksq.mjs → seo-CKr7pLfA.mjs} +2 -2
- package/dist/{seo-BTzb5ksq.mjs.map → seo-CKr7pLfA.mjs.map} +1 -1
- package/dist/{service-Cn-kIfZn.mjs → service-9P2cdyR_.mjs} +2 -2
- package/dist/{service-Cn-kIfZn.mjs.map → service-9P2cdyR_.mjs.map} +1 -1
- package/dist/{settings-C65OSm41.mjs → settings-DYVzINdn.mjs} +3 -3
- package/dist/{settings-C65OSm41.mjs.map → settings-DYVzINdn.mjs.map} +1 -1
- package/dist/{settings-ChlQbwU0.mjs → settings-Jro4YcUb.mjs} +3 -3
- package/dist/{settings-ChlQbwU0.mjs.map → settings-Jro4YcUb.mjs.map} +1 -1
- package/dist/{taxonomies-D72gTOg_.mjs → taxonomies-C0bVme_m.mjs} +4 -4
- package/dist/{taxonomies-D72gTOg_.mjs.map → taxonomies-C0bVme_m.mjs.map} +1 -1
- package/dist/{taxonomies-CgpzAU6F.mjs → taxonomies-CGD6y79Q.mjs} +5 -5
- package/dist/{taxonomies-CgpzAU6F.mjs.map → taxonomies-CGD6y79Q.mjs.map} +1 -1
- package/dist/{taxonomy-BBK-UAEo.mjs → taxonomy-Db5xwphL.mjs} +3 -3
- package/dist/{taxonomy-BBK-UAEo.mjs.map → taxonomy-Db5xwphL.mjs.map} +1 -1
- package/dist/{types-SF1DwGf2.mjs → types-CfyYQ7eY.mjs} +2 -2
- package/dist/{types-SF1DwGf2.mjs.map → types-CfyYQ7eY.mjs.map} +1 -1
- package/dist/{user-X4rtyO4Y.mjs → user-tLdHUEXV.mjs} +2 -2
- package/dist/{user-X4rtyO4Y.mjs.map → user-tLdHUEXV.mjs.map} +1 -1
- package/dist/{validate-DactmcJG.mjs → validate-DWmnRg6E.mjs} +2 -2
- package/dist/{validate-DactmcJG.mjs.map → validate-DWmnRg6E.mjs.map} +1 -1
- package/dist/{validation-BYA4i85b.mjs → validation-BQ_TP-On.mjs} +6 -6
- package/dist/{validation-BYA4i85b.mjs.map → validation-BQ_TP-On.mjs.map} +1 -1
- package/dist/version-CgcnMvqS.mjs +7 -0
- package/dist/{version-FGcv0ooe.mjs.map → version-CgcnMvqS.mjs.map} +1 -1
- package/dist/{widgets-DG-1jxnz.mjs → widgets-DzlINGI6.mjs} +2 -2
- package/dist/{widgets-DG-1jxnz.mjs.map → widgets-DzlINGI6.mjs.map} +1 -1
- package/dist/{zod-generator-BNAObjSt.mjs → zod-generator-MMm56Prt.mjs} +2 -2
- package/dist/{zod-generator-BNAObjSt.mjs.map → zod-generator-MMm56Prt.mjs.map} +1 -1
- package/package.json +6 -6
- package/src/astro/integration/vite-config.ts +16 -0
- package/src/astro/middleware.ts +34 -8
- package/src/cli/commands/export-seed.ts +174 -12
- package/src/database/dialect-helpers.ts +8 -2
- package/src/database/migrations/019_i18n.ts +2 -2
- package/src/query.ts +7 -7
- package/src/seo/index.ts +10 -1
- package/dist/dialect-helpers-BKCvISIQ.mjs.map +0 -1
- package/dist/query-CuvjwhrE.mjs.map +0 -1
- package/dist/runner-eAgyIkeg.mjs.map +0 -1
- package/dist/version-FGcv0ooe.mjs +0 -7
package/dist/runtime.d.mts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import "./options-tb7DJROi.mjs";
|
|
2
2
|
import "./types-DawhLFwy.mjs";
|
|
3
3
|
import "./types-DMwSpvcw.mjs";
|
|
4
|
-
import "./byline-fields-
|
|
4
|
+
import "./byline-fields-CR5hGLMw.mjs";
|
|
5
5
|
import { f as MediaProvider } from "./placeholder-B9lUUEmj.mjs";
|
|
6
|
-
import { Sn as getDb, _n as EntryData, gn as CollectionFilter, vn as EntryFilter, xn as emdashLoader } from "./index-
|
|
6
|
+
import { Sn as getDb, _n as EntryData, gn as CollectionFilter, vn as EntryFilter, xn as emdashLoader } from "./index-C8ciqSMJ.mjs";
|
|
7
7
|
import "./runner-DM1yR5qd.mjs";
|
|
8
8
|
import "./index-D60_SzHG.mjs";
|
|
9
9
|
import "./types-DWnN7weG.mjs";
|
package/dist/runtime.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import "./dialect-helpers-
|
|
1
|
+
import "./dialect-helpers-DRI5pyY3.mjs";
|
|
2
2
|
import "./base64-CqR-7kqF.mjs";
|
|
3
|
-
import "./types-
|
|
4
|
-
import { n as emdashLoader, r as getDb } from "./loader-
|
|
3
|
+
import "./types-CfyYQ7eY.mjs";
|
|
4
|
+
import { n as emdashLoader, r as getDb } from "./loader-BxyvbrZP.mjs";
|
|
5
5
|
|
|
6
6
|
//#region src/media/provider-loader.ts
|
|
7
7
|
let virtualMediaProviders;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { i as __exportAll } from "./runner-
|
|
1
|
+
import { i as __exportAll } from "./runner-BiuUfx-V.mjs";
|
|
2
2
|
import { r as requestCached } from "./request-cache-BYMs-BGX.mjs";
|
|
3
|
-
import { n as SchemaRegistry } from "./registry-
|
|
4
|
-
import { r as getDb } from "./loader-
|
|
3
|
+
import { n as SchemaRegistry } from "./registry-C-T_PWgp.mjs";
|
|
4
|
+
import { r as getDb } from "./loader-BxyvbrZP.mjs";
|
|
5
5
|
|
|
6
6
|
//#region src/schema/query.ts
|
|
7
7
|
/**
|
|
@@ -38,4 +38,4 @@ var schema_exports = /* @__PURE__ */ __exportAll({ SchemaRegistry: () => SchemaR
|
|
|
38
38
|
|
|
39
39
|
//#endregion
|
|
40
40
|
export { getCollectionInfo as n, schema_exports as t };
|
|
41
|
-
//# sourceMappingURL=schema
|
|
41
|
+
//# sourceMappingURL=schema-BpCJh2lU.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema
|
|
1
|
+
{"version":3,"file":"schema-BpCJh2lU.mjs","names":[],"sources":["../src/schema/query.ts","../src/schema/index.ts"],"sourcesContent":["/**\n * Collection info query for Astro templates.\n *\n * Same pattern as getMenu() / getComments() — uses getDb() for ambient DB access.\n */\n\nimport type { Kysely } from \"kysely\";\n\nimport type { Database } from \"../database/types.js\";\nimport { getDb } from \"../loader.js\";\nimport { requestCached } from \"../request-cache.js\";\nimport { SchemaRegistry } from \"./registry.js\";\nimport type { Collection } from \"./types.js\";\n\n/**\n * Get collection metadata by slug.\n *\n * @example\n * ```ts\n * import { getCollectionInfo } from \"emdash\";\n *\n * const info = await getCollectionInfo(\"posts\");\n * if (info?.commentsEnabled) {\n * // render comment UI\n * }\n * ```\n */\nexport async function getCollectionInfo(slug: string): Promise<Collection | null> {\n\treturn requestCached(`collection-info:${slug}`, async () => {\n\t\tconst db = await getDb();\n\t\treturn getCollectionInfoWithDb(db, slug);\n\t});\n}\n\n/**\n * Get collection metadata with an explicit db handle.\n *\n * @internal Use `getCollectionInfo()` in templates. This variant is for\n * routes that already have a database handle.\n */\nexport async function getCollectionInfoWithDb(\n\tdb: Kysely<Database>,\n\tslug: string,\n): Promise<Collection | null> {\n\tconst registry = new SchemaRegistry(db);\n\treturn registry.getCollection(slug);\n}\n","export { SchemaRegistry, SchemaError } from \"./registry.js\";\nexport type {\n\tFieldType,\n\tColumnType,\n\tCollectionSupport,\n\tCollectionSource,\n\tFieldValidation,\n\tFieldWidgetOptions,\n\tCollection,\n\tField,\n\tCreateCollectionInput,\n\tUpdateCollectionInput,\n\tCreateFieldInput,\n\tUpdateFieldInput,\n\tCollectionWithFields,\n} from \"./types.js\";\nexport { FIELD_TYPE_TO_COLUMN, RESERVED_FIELD_SLUGS, RESERVED_COLLECTION_SLUGS } from \"./types.js\";\n\nexport { getCollectionInfo, getCollectionInfoWithDb } from \"./query.js\";\n\nexport {\n\tgenerateZodSchema,\n\tgenerateFieldSchema,\n\tgetCachedSchema,\n\tinvalidateSchemaCache,\n\tclearSchemaCache,\n\tvalidateContent,\n\tgenerateTypeScript,\n} from \"./zod-generator.js\";\n"],"mappings":";;;;;;;;;;;;;;;;;;;AA2BA,eAAsB,kBAAkB,MAA0C;AACjF,QAAO,cAAc,mBAAmB,QAAQ,YAAY;AAE3D,SAAO,wBADI,MAAM,OAAO,EACW,KAAK;GACvC;;;;;;;;AASH,eAAsB,wBACrB,IACA,MAC6B;AAE7B,QADiB,IAAI,eAAe,GAAG,CACvB,cAAc,KAAK"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { i as __exportAll } from "./runner-
|
|
1
|
+
import { i as __exportAll } from "./runner-BiuUfx-V.mjs";
|
|
2
2
|
import { t as validateIdentifier } from "./validate-VPnKoIzW.mjs";
|
|
3
|
-
import { t as FTSManager } from "./fts-manager-
|
|
4
|
-
import { r as getDb } from "./loader-
|
|
3
|
+
import { t as FTSManager } from "./fts-manager-XpDfbIKo.mjs";
|
|
4
|
+
import { r as getDb } from "./loader-BxyvbrZP.mjs";
|
|
5
5
|
import { sql } from "kysely";
|
|
6
6
|
import { toPlainText } from "@portabletext/toolkit";
|
|
7
7
|
|
|
@@ -373,4 +373,4 @@ var search_exports = /* @__PURE__ */ __exportAll({ searchWithDb: () => searchWit
|
|
|
373
373
|
|
|
374
374
|
//#endregion
|
|
375
375
|
export { getSuggestions as a, searchWithDb as c, getSearchStats as i, extractPlainText as n, search as o, extractSearchableFields as r, searchCollection as s, search_exports as t };
|
|
376
|
-
//# sourceMappingURL=search-
|
|
376
|
+
//# sourceMappingURL=search-BrF7k0Ho.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"search-C6U_NvZI.mjs","names":[],"sources":["../src/search/query.ts","../src/search/text-extraction.ts","../src/search/index.ts"],"sourcesContent":["/**\n * Search Query Functions\n *\n * Programmatic API for searching content using FTS5.\n */\n\nimport type { Kysely } from \"kysely\";\nimport { sql } from \"kysely\";\n\nimport type { Database } from \"../database/types.js\";\nimport { validateIdentifier } from \"../database/validate.js\";\nimport { getDb } from \"../loader.js\";\nimport { FTSManager } from \"./fts-manager.js\";\nimport type {\n\tSearchOptions,\n\tCollectionSearchOptions,\n\tSearchResult,\n\tSearchResponse,\n\tSuggestOptions,\n\tSuggestion,\n\tSearchStats,\n} from \"./types.js\";\n\n/** Pattern to split on whitespace for query term extraction */\nconst WHITESPACE_SPLIT_PATTERN = /\\s+/;\nconst FTS_OPERATORS_PATTERN = /\\b(AND|OR|NOT|NEAR)\\b/i;\nconst DOUBLE_QUOTE_PATTERN = /\"/g;\n\n/**\n * Detect FTS5 query syntax errors. Match specifically on the SQLite FTS5\n * error fingerprints rather than a broad \"fts5\" / \"syntax error\" filter\n * (which would also swallow internal table-corruption errors). The two\n * fingerprints we care about are:\n *\n * - \"fts5: syntax error near …\" — unbalanced quotes, stray operators,\n * other malformed user input\n * - \"unknown special query: …\" — bare special tokens like `^*` that\n * parse but don't resolve to a real FTS5 directive\n */\nfunction isFts5SyntaxError(error: unknown): boolean {\n\tif (!(error instanceof Error)) return false;\n\tconst message = error.message.toLowerCase();\n\treturn message.includes(\"fts5: syntax error\") || message.includes(\"unknown special query\");\n}\n\n/**\n * Search across multiple collections\n *\n * Public API that auto-injects the database.\n *\n * @param query - Search query (FTS5 syntax supported)\n * @param options - Search options\n * @returns Search results with pagination\n *\n * @example\n * ```typescript\n * import { search } from \"emdash\";\n *\n * const results = await search(\"hello world\", {\n * collections: [\"posts\", \"pages\"],\n * limit: 20\n * });\n * ```\n */\nexport async function search(query: string, options: SearchOptions = {}): Promise<SearchResponse> {\n\tconst db = await getDb();\n\treturn searchWithDb(db, query, options);\n}\n\n/**\n * Search across multiple collections (with explicit db)\n *\n * @internal Use `search()` in templates. This variant is for admin routes\n * that already have a database handle.\n *\n * @param db - Kysely database instance\n * @param query - Search query (FTS5 syntax supported)\n * @param options - Search options\n * @returns Search results with pagination\n */\nexport async function searchWithDb(\n\tdb: Kysely<Database>,\n\tquery: string,\n\toptions: SearchOptions = {},\n): Promise<SearchResponse> {\n\tconst ftsManager = new FTSManager(db);\n\tconst limit = options.limit ?? 20;\n\tconst status = options.status ?? \"published\";\n\n\t// Get searchable collections\n\tlet collections = options.collections;\n\tif (!collections || collections.length === 0) {\n\t\tcollections = await getSearchableCollections(db);\n\t}\n\n\tif (collections.length === 0) {\n\t\treturn { items: [] };\n\t}\n\n\t// Search each collection and merge results\n\tconst allResults: SearchResult[] = [];\n\n\tfor (const collection of collections) {\n\t\tconst config = await ftsManager.getSearchConfig(collection);\n\t\tif (!config?.enabled) {\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst collectionResults = await searchSingleCollection(\n\t\t\tdb,\n\t\t\tcollection,\n\t\t\tquery,\n\t\t\t{\n\t\t\t\tstatus,\n\t\t\t\tlocale: options.locale,\n\t\t\t\tlimit: limit * 2, // Get extra for merging\n\t\t\t},\n\t\t\tconfig.weights,\n\t\t);\n\n\t\tallResults.push(...collectionResults);\n\t}\n\n\t// Sort by score descending\n\tallResults.sort((a, b) => b.score - a.score);\n\n\t// Apply limit\n\tconst items = allResults.slice(0, limit);\n\n\treturn { items };\n}\n\n/**\n * Search within a single collection\n *\n * @param db - Kysely database instance\n * @param collection - Collection slug\n * @param query - Search query (FTS5 syntax supported)\n * @param options - Search options\n * @returns Search results with pagination\n *\n * @example\n * ```typescript\n * const results = await searchCollection(db, \"posts\", \"hello world\", {\n * limit: 10\n * });\n * ```\n */\nexport async function searchCollection(\n\tdb: Kysely<Database>,\n\tcollection: string,\n\tquery: string,\n\toptions: CollectionSearchOptions = {},\n): Promise<SearchResponse> {\n\tconst ftsManager = new FTSManager(db);\n\tconst config = await ftsManager.getSearchConfig(collection);\n\n\tif (!config?.enabled) {\n\t\treturn { items: [] };\n\t}\n\n\tconst items = await searchSingleCollection(db, collection, query, options, config.weights);\n\n\treturn { items };\n}\n\n/**\n * Internal function to search a single collection\n */\nasync function searchSingleCollection(\n\tdb: Kysely<Database>,\n\tcollection: string,\n\tquery: string,\n\toptions: CollectionSearchOptions,\n\tweights?: Record<string, number>,\n): Promise<SearchResult[]> {\n\t// Validate before any raw SQL interpolation\n\tvalidateIdentifier(collection, \"collection slug\");\n\n\tconst ftsManager = new FTSManager(db);\n\tconst ftsTable = ftsManager.getFtsTableName(collection);\n\tconst contentTable = ftsManager.getContentTableName(collection);\n\tconst limit = options.limit ?? 20;\n\tconst status = options.status ?? \"published\";\n\tconst locale = options.locale;\n\n\t// Check if FTS table exists\n\tif (!(await ftsManager.ftsTableExists(collection))) {\n\t\treturn [];\n\t}\n\n\t// Escape the query for FTS5\n\tconst escapedQuery = escapeQuery(query);\n\tif (!escapedQuery) {\n\t\treturn [];\n\t}\n\n\t// Get searchable fields for snippet generation\n\tconst searchableFields = await ftsManager.getSearchableFields(collection);\n\n\t// Build weight string for bm25 if weights provided\n\t// Format: bm25(table, weight1, weight2, ...)\n\t// First two weights are for 'id' and 'locale' columns (UNINDEXED, so 0)\n\tlet bm25Args = \"\";\n\tif (weights && searchableFields.length > 0) {\n\t\tconst weightValues = [\"0\", \"0\"]; // id column, locale column\n\t\tfor (const field of searchableFields) {\n\t\t\tweightValues.push(String(weights[field] ?? 1));\n\t\t}\n\t\tbm25Args = weightValues.join(\", \");\n\t}\n\n\t// Build and execute the search query\n\t// Using raw SQL because Kysely doesn't have FTS5 support\n\tconst bm25Expr = bm25Args ? `bm25(\"${ftsTable}\", ${bm25Args})` : `bm25(\"${ftsTable}\")`;\n\n\t// Snippet column index is 2 (after id=0, locale=1, first searchable field=2)\n\tlet results;\n\ttry {\n\t\tresults = await sql<{\n\t\t\tid: string;\n\t\t\tslug: string | null;\n\t\t\tlocale: string;\n\t\t\ttitle: string | null;\n\t\t\tsnippet: string | null;\n\t\t\tscore: number;\n\t\t}>`\n\t\tSELECT \n\t\t\tc.id,\n\t\t\tc.slug,\n\t\t\tc.locale,\n\t\t\tc.title,\n\t\t\tsnippet(\"${sql.raw(ftsTable)}\", 2, '<mark>', '</mark>', '...', 32) as snippet,\n\t\t\t${sql.raw(bm25Expr)} as score\n\t\tFROM \"${sql.raw(ftsTable)}\" f\n\t\tJOIN \"${sql.raw(contentTable)}\" c ON f.id = c.id\n\t\tWHERE \"${sql.raw(ftsTable)}\" MATCH ${escapedQuery}\n\t\tAND c.status = ${status}\n\t\tAND c.deleted_at IS NULL\n\t\t${locale ? sql`AND c.locale = ${locale}` : sql``}\n\t\tORDER BY score\n\t\tLIMIT ${limit}\n\t`.execute(db);\n\t} catch (error) {\n\t\t// FTS5 returns syntax errors for queries with unbalanced quotes,\n\t\t// stray operators, or other malformed input. Treat these as\n\t\t// \"no matches\" so the user gets an empty result rather than an\n\t\t// internals-leaking error. Other errors (table missing, IO) still\n\t\t// propagate. Intentionally not logged: any anonymous client can\n\t\t// trigger this path, and the underlying error message embeds the\n\t\t// raw query, so logging would be both noisy and a log-injection\n\t\t// vector.\n\t\tif (isFts5SyntaxError(error)) {\n\t\t\treturn [];\n\t\t}\n\t\tthrow error;\n\t}\n\n\treturn results.rows.map((row) => ({\n\t\tcollection,\n\t\tid: row.id,\n\t\tslug: row.slug,\n\t\tlocale: row.locale,\n\t\ttitle: row.title ?? undefined,\n\t\t// SQLite's snippet() returns NULL when the targeted column is\n\t\t// NULL for that row — even if the row matched via a different\n\t\t// searchable column. Skip sanitization in that case so we don't\n\t\t// throw on `null.replace`. The SearchResult.snippet field is\n\t\t// already optional, so omitting it is the documented contract.\n\t\tsnippet: row.snippet === null ? undefined : sanitizeSnippet(row.snippet),\n\t\tscore: Math.abs(row.score), // bm25 returns negative scores\n\t}));\n}\n\n// Module-scope regexes so the engine doesn't recompile per call —\n// snippet sanitization runs on every search result.\nconst SNIPPET_AMP_RE = /&/g;\nconst SNIPPET_LT_RE = /</g;\nconst SNIPPET_GT_RE = />/g;\nconst SNIPPET_QUOT_RE = /\"/g;\nconst SNIPPET_APOS_RE = /'/g;\n\n/**\n * Make an FTS5 snippet safe to render with `set:html` / `innerHTML`.\n *\n * SQLite's `snippet()` function splices literal `<mark>` and `</mark>`\n * markers around matched terms but does not escape the surrounding\n * source text. Posts that legitimately contain `<`, `>`, `&`, `\"` or\n * `'` would render as broken markup, and a `<script>` literal in a\n * title (or any other indexed field) would execute when displayed.\n *\n * The fix: HTML-escape the whole string, which turns the markers into\n * `<mark>` / `</mark>`. Then restore those two patterns to\n * their original tag form. The result is \"the indexed text with all\n * HTML metacharacters escaped, plus a small set of literal `<mark>`\n * highlight tags around matched terms\" — which matches the API's\n * documented contract.\n */\nfunction sanitizeSnippet(snippet: string): string {\n\treturn snippet\n\t\t.replace(SNIPPET_AMP_RE, \"&\")\n\t\t.replace(SNIPPET_LT_RE, \"<\")\n\t\t.replace(SNIPPET_GT_RE, \">\")\n\t\t.replace(SNIPPET_QUOT_RE, \""\")\n\t\t.replace(SNIPPET_APOS_RE, \"'\")\n\t\t.replaceAll(\"<mark>\", \"<mark>\")\n\t\t.replaceAll(\"</mark>\", \"</mark>\");\n}\n\n/**\n * Get search suggestions for autocomplete\n *\n * @param db - Kysely database instance\n * @param query - Partial search query\n * @param options - Suggestion options\n * @returns Array of suggestions\n */\nexport async function getSuggestions(\n\tdb: Kysely<Database>,\n\tquery: string,\n\toptions: SuggestOptions = {},\n): Promise<Suggestion[]> {\n\tconst limit = options.limit ?? 5;\n\tconst locale = options.locale;\n\n\t// Get searchable collections\n\tlet collections = options.collections;\n\tif (!collections || collections.length === 0) {\n\t\tcollections = await getSearchableCollections(db);\n\t}\n\n\tif (collections.length === 0) {\n\t\treturn [];\n\t}\n\n\tconst suggestions: Suggestion[] = [];\n\n\tfor (const collection of collections) {\n\t\tconst ftsManager = new FTSManager(db);\n\t\tconst config = await ftsManager.getSearchConfig(collection);\n\t\tif (!config?.enabled) {\n\t\t\tcontinue;\n\t\t}\n\n\t\t// Validate before raw SQL interpolation\n\t\tvalidateIdentifier(collection, \"collection slug\");\n\n\t\tconst ftsTable = ftsManager.getFtsTableName(collection);\n\t\tconst contentTable = ftsManager.getContentTableName(collection);\n\n\t\t// Use prefix search for autocomplete. `escapeQuery` already appends `*`\n\t\t// to each term for prefix matching, so we must not append another one.\n\t\tconst prefixQuery = escapeQuery(query);\n\t\tif (!prefixQuery) {\n\t\t\tcontinue;\n\t\t}\n\n\t\tlet results;\n\t\ttry {\n\t\t\tresults = await sql<{\n\t\t\t\tid: string;\n\t\t\t\ttitle: string;\n\t\t\t}>`\n\t\t\t\tSELECT \n\t\t\t\t\tc.id,\n\t\t\t\t\tc.title\n\t\t\t\tFROM \"${sql.raw(ftsTable)}\" f\n\t\t\t\tJOIN \"${sql.raw(contentTable)}\" c ON f.id = c.id\n\t\t\t\tWHERE \"${sql.raw(ftsTable)}\" MATCH ${prefixQuery}\n\t\t\t\tAND c.status = 'published'\n\t\t\t\tAND c.deleted_at IS NULL\n\t\t\t\tAND c.title IS NOT NULL\n\t\t\t\t${locale ? sql`AND c.locale = ${locale}` : sql``}\n\t\t\t\tORDER BY bm25(\"${sql.raw(ftsTable)}\")\n\t\t\t\tLIMIT ${limit}\n\t\t\t`.execute(db);\n\t\t} catch (error) {\n\t\t\t// Same swallow as searchSingleCollection: malformed prefix\n\t\t\t// queries should yield no suggestions, not surface DB errors.\n\t\t\t// Intentionally not logged (anonymous-triggerable, echoes\n\t\t\t// user input -- see searchSingleCollection for rationale).\n\t\t\tif (isFts5SyntaxError(error)) {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tthrow error;\n\t\t}\n\n\t\tfor (const row of results.rows) {\n\t\t\tsuggestions.push({\n\t\t\t\tcollection,\n\t\t\t\tid: row.id,\n\t\t\t\ttitle: row.title,\n\t\t\t});\n\t\t}\n\t}\n\n\treturn suggestions.slice(0, limit);\n}\n\n/**\n * Get search statistics for all collections\n */\nexport async function getSearchStats(db: Kysely<Database>): Promise<SearchStats> {\n\tconst ftsManager = new FTSManager(db);\n\tconst collections = await getSearchableCollections(db);\n\tconst stats: SearchStats = { collections: {} };\n\n\tfor (const collection of collections) {\n\t\tconst collectionStats = await ftsManager.getIndexStats(collection);\n\t\tif (collectionStats) {\n\t\t\tstats.collections[collection] = collectionStats;\n\t\t}\n\t}\n\n\treturn stats;\n}\n\n/**\n * Get list of collections with search enabled\n */\nasync function getSearchableCollections(db: Kysely<Database>): Promise<string[]> {\n\tconst results = await db\n\t\t.selectFrom(\"_emdash_collections\")\n\t\t.select([\"slug\", \"search_config\"])\n\t\t.execute();\n\n\treturn results\n\t\t.filter((r) => {\n\t\t\tif (!r.search_config) return false;\n\t\t\ttry {\n\t\t\t\tconst config = JSON.parse(r.search_config);\n\t\t\t\treturn config.enabled === true;\n\t\t\t} catch {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t})\n\t\t.map((r) => r.slug);\n}\n\n/**\n * Escape a query string for FTS5\n *\n * Handles special characters and prevents injection.\n */\nfunction escapeQuery(query: string): string {\n\tif (!query || typeof query !== \"string\") {\n\t\treturn \"\";\n\t}\n\n\t// Trim whitespace\n\tquery = query.trim();\n\n\tif (query.length === 0) {\n\t\treturn \"\";\n\t}\n\n\t// If already a quoted phrase, escape only interior quotes and preserve phrase syntax\n\tif (query.startsWith('\"') && query.endsWith('\"') && query.length >= 2) {\n\t\tconst inner = query.slice(1, -1);\n\t\treturn `\"${inner.replace(DOUBLE_QUOTE_PATTERN, '\"\"')}\"`;\n\t}\n\n\t// Escape any existing quotes\n\tconst escaped = query.replace(DOUBLE_QUOTE_PATTERN, '\"\"');\n\n\t// If the query contains FTS5 operators (AND, OR, NOT, NEAR),\n\t// pass through with quotes escaped but operators preserved\n\tif (FTS_OPERATORS_PATTERN.test(query)) {\n\t\treturn escaped;\n\t}\n\n\t// For simple queries, wrap each word to handle special chars\n\tconst terms = escaped.split(WHITESPACE_SPLIT_PATTERN).filter((t) => t.length > 0);\n\tif (terms.length === 0) {\n\t\treturn \"\";\n\t}\n\n\t// Join with implicit AND, add prefix matching (*) to all terms\n\t// This allows \"hel wor\" to match \"hello world\"\n\treturn terms.map((t) => `\"${t}\"*`).join(\" \");\n}\n","/**\n * Text Extraction\n *\n * Extracts plain text from Portable Text blocks for FTS indexing.\n * Uses @portabletext/toolkit as base with extensions for custom block types.\n */\n\nimport { toPlainText } from \"@portabletext/toolkit\";\n\nimport type { PortableTextBlock } from \"../content/converters/types.js\";\n\n/**\n * Validate that a value looks like a Portable Text block array.\n * Each element must have at least a `_type` string property.\n */\nfunction isPortableTextArray(value: unknown[]): value is PortableTextBlock[] {\n\treturn value.every(\n\t\t(item) =>\n\t\t\ttypeof item === \"object\" &&\n\t\t\titem !== null &&\n\t\t\t\"_type\" in item &&\n\t\t\ttypeof item._type === \"string\",\n\t);\n}\n\n/**\n * Extract additional text from custom block types that toPlainText doesn't handle\n */\nfunction extractCustomBlockText(block: PortableTextBlock): string {\n\t// Code blocks - include the code content\n\tif (block._type === \"code\" && \"code\" in block && typeof block.code === \"string\") {\n\t\treturn block.code;\n\t}\n\n\t// Image blocks - include alt text and caption\n\tif (block._type === \"image\") {\n\t\tconst parts: string[] = [];\n\t\tif (\"alt\" in block && typeof block.alt === \"string\" && block.alt) {\n\t\t\tparts.push(block.alt);\n\t\t}\n\t\tif (\"caption\" in block && typeof block.caption === \"string\" && block.caption) {\n\t\t\tparts.push(block.caption);\n\t\t}\n\t\treturn parts.join(\" \");\n\t}\n\n\treturn \"\";\n}\n\n/**\n * Extract plain text from Portable Text blocks\n *\n * Uses @portabletext/toolkit's toPlainText for standard blocks,\n * plus extracts text from custom block types (code, images with alt/caption).\n *\n * @param blocks - Array of Portable Text blocks (or a JSON string)\n * @returns Plain text content\n *\n * @example\n * ```typescript\n * const text = extractPlainText([\n * {\n * _type: \"block\",\n * _key: \"abc\",\n * children: [{ _type: \"span\", _key: \"s1\", text: \"Hello World\" }]\n * }\n * ]);\n * // Returns: \"Hello World\"\n * ```\n */\nexport function extractPlainText(blocks: PortableTextBlock[] | string | null | undefined): string {\n\tif (!blocks) {\n\t\treturn \"\";\n\t}\n\n\t// Handle JSON string input\n\tlet parsedBlocks: PortableTextBlock[];\n\tif (typeof blocks === \"string\") {\n\t\ttry {\n\t\t\tparsedBlocks = JSON.parse(blocks);\n\t\t} catch {\n\t\t\t// If it's not valid JSON, treat as plain text\n\t\t\treturn blocks;\n\t\t}\n\t} else {\n\t\tparsedBlocks = blocks;\n\t}\n\n\tif (!Array.isArray(parsedBlocks)) {\n\t\treturn \"\";\n\t}\n\n\t// Use official toPlainText for standard blocks.\n\t// toPlainText expects `{ _type: string; [key: string]: any }[]` but our blocks use\n\t// `unknown` index sigs. They're structurally compatible at runtime — spread each block\n\t// to satisfy the wider index signature without an unsafe cast.\n\tconst toolkitBlocks = parsedBlocks.map((b) => {\n\t\tconst obj: Record<string, unknown> & { _type: string } = { _type: b._type };\n\t\tfor (const [key, val] of Object.entries(b)) {\n\t\t\tobj[key] = val;\n\t\t}\n\t\treturn obj;\n\t});\n\tconst standardText = toPlainText(toolkitBlocks);\n\n\t// Extract text from custom block types that toPlainText doesn't handle\n\tconst customTexts = parsedBlocks.map(extractCustomBlockText).filter((text) => text.length > 0);\n\n\t// Combine both\n\tconst allTexts = [standardText, ...customTexts].filter((t) => t.length > 0);\n\treturn allTexts.join(\"\\n\");\n}\n\n/**\n * Extract searchable text from a content entry\n *\n * Extracts text from specified fields, handling both plain text and Portable Text.\n *\n * @param entry - Content entry data\n * @param fields - Field names to extract text from\n * @returns Object mapping field names to extracted text\n */\nexport function extractSearchableFields(\n\tentry: Record<string, unknown>,\n\tfields: string[],\n): Record<string, string> {\n\tconst result: Record<string, string> = {};\n\n\tfor (const field of fields) {\n\t\tconst value = entry[field];\n\n\t\tif (value === null || value === undefined) {\n\t\t\tresult[field] = \"\";\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (typeof value === \"string\") {\n\t\t\t// Could be plain text or JSON Portable Text\n\t\t\tif (value.startsWith(\"[\")) {\n\t\t\t\tresult[field] = extractPlainText(value);\n\t\t\t} else {\n\t\t\t\tresult[field] = value;\n\t\t\t}\n\t\t} else if (Array.isArray(value)) {\n\t\t\t// Validate the array looks like Portable Text before treating it as such\n\t\t\tif (isPortableTextArray(value)) {\n\t\t\t\tresult[field] = extractPlainText(value);\n\t\t\t} else {\n\t\t\t\tresult[field] = JSON.stringify(value);\n\t\t\t}\n\t\t} else if (typeof value === \"object\") {\n\t\t\t// Object — serialize to JSON for searchable text\n\t\t\tresult[field] = JSON.stringify(value);\n\t\t} else if (typeof value === \"number\" || typeof value === \"boolean\") {\n\t\t\tresult[field] = `${value}`;\n\t\t} else {\n\t\t\tresult[field] = \"\";\n\t\t}\n\t}\n\n\treturn result;\n}\n","/**\n * Search Module\n *\n * Full-text search for EmDash using SQLite FTS5.\n */\n\n// Types\nexport type {\n\tSearchConfig,\n\tSearchOptions,\n\tCollectionSearchOptions,\n\tSearchResult,\n\tSearchResponse,\n\tSuggestOptions,\n\tSuggestion,\n\tSearchStats,\n} from \"./types.js\";\n\n// FTS Manager\nexport { FTSManager } from \"./fts-manager.js\";\n\n// Query functions (public API uses getDb() internally)\nexport { search, searchWithDb, searchCollection, getSuggestions, getSearchStats } from \"./query.js\";\n\n// Text extraction\nexport { extractPlainText, extractSearchableFields } from \"./text-extraction.js\";\n"],"mappings":";;;;;;;;;AAwBA,MAAM,2BAA2B;AACjC,MAAM,wBAAwB;AAC9B,MAAM,uBAAuB;;;;;;;;;;;;AAa7B,SAAS,kBAAkB,OAAyB;AACnD,KAAI,EAAE,iBAAiB,OAAQ,QAAO;CACtC,MAAM,UAAU,MAAM,QAAQ,aAAa;AAC3C,QAAO,QAAQ,SAAS,qBAAqB,IAAI,QAAQ,SAAS,wBAAwB;;;;;;;;;;;;;;;;;;;;;AAsB3F,eAAsB,OAAO,OAAe,UAAyB,EAAE,EAA2B;AAEjG,QAAO,aADI,MAAM,OAAO,EACA,OAAO,QAAQ;;;;;;;;;;;;;AAcxC,eAAsB,aACrB,IACA,OACA,UAAyB,EAAE,EACD;CAC1B,MAAM,aAAa,IAAI,WAAW,GAAG;CACrC,MAAM,QAAQ,QAAQ,SAAS;CAC/B,MAAM,SAAS,QAAQ,UAAU;CAGjC,IAAI,cAAc,QAAQ;AAC1B,KAAI,CAAC,eAAe,YAAY,WAAW,EAC1C,eAAc,MAAM,yBAAyB,GAAG;AAGjD,KAAI,YAAY,WAAW,EAC1B,QAAO,EAAE,OAAO,EAAE,EAAE;CAIrB,MAAM,aAA6B,EAAE;AAErC,MAAK,MAAM,cAAc,aAAa;EACrC,MAAM,SAAS,MAAM,WAAW,gBAAgB,WAAW;AAC3D,MAAI,CAAC,QAAQ,QACZ;EAGD,MAAM,oBAAoB,MAAM,uBAC/B,IACA,YACA,OACA;GACC;GACA,QAAQ,QAAQ;GAChB,OAAO,QAAQ;GACf,EACD,OAAO,QACP;AAED,aAAW,KAAK,GAAG,kBAAkB;;AAItC,YAAW,MAAM,GAAG,MAAM,EAAE,QAAQ,EAAE,MAAM;AAK5C,QAAO,EAAE,OAFK,WAAW,MAAM,GAAG,MAAM,EAExB;;;;;;;;;;;;;;;;;;AAmBjB,eAAsB,iBACrB,IACA,YACA,OACA,UAAmC,EAAE,EACX;CAE1B,MAAM,SAAS,MADI,IAAI,WAAW,GAAG,CACL,gBAAgB,WAAW;AAE3D,KAAI,CAAC,QAAQ,QACZ,QAAO,EAAE,OAAO,EAAE,EAAE;AAKrB,QAAO,EAAE,OAFK,MAAM,uBAAuB,IAAI,YAAY,OAAO,SAAS,OAAO,QAAQ,EAE1E;;;;;AAMjB,eAAe,uBACd,IACA,YACA,OACA,SACA,SAC0B;AAE1B,oBAAmB,YAAY,kBAAkB;CAEjD,MAAM,aAAa,IAAI,WAAW,GAAG;CACrC,MAAM,WAAW,WAAW,gBAAgB,WAAW;CACvD,MAAM,eAAe,WAAW,oBAAoB,WAAW;CAC/D,MAAM,QAAQ,QAAQ,SAAS;CAC/B,MAAM,SAAS,QAAQ,UAAU;CACjC,MAAM,SAAS,QAAQ;AAGvB,KAAI,CAAE,MAAM,WAAW,eAAe,WAAW,CAChD,QAAO,EAAE;CAIV,MAAM,eAAe,YAAY,MAAM;AACvC,KAAI,CAAC,aACJ,QAAO,EAAE;CAIV,MAAM,mBAAmB,MAAM,WAAW,oBAAoB,WAAW;CAKzE,IAAI,WAAW;AACf,KAAI,WAAW,iBAAiB,SAAS,GAAG;EAC3C,MAAM,eAAe,CAAC,KAAK,IAAI;AAC/B,OAAK,MAAM,SAAS,iBACnB,cAAa,KAAK,OAAO,QAAQ,UAAU,EAAE,CAAC;AAE/C,aAAW,aAAa,KAAK,KAAK;;CAKnC,MAAM,WAAW,WAAW,SAAS,SAAS,KAAK,SAAS,KAAK,SAAS,SAAS;CAGnF,IAAI;AACJ,KAAI;AACH,YAAU,MAAM,GAOd;;;;;;cAMU,IAAI,IAAI,SAAS,CAAC;KAC3B,IAAI,IAAI,SAAS,CAAC;UACb,IAAI,IAAI,SAAS,CAAC;UAClB,IAAI,IAAI,aAAa,CAAC;WACrB,IAAI,IAAI,SAAS,CAAC,UAAU,aAAa;mBACjC,OAAO;;IAEtB,SAAS,GAAG,kBAAkB,WAAW,GAAG,GAAG;;UAEzC,MAAM;GACb,QAAQ,GAAG;UACJ,OAAO;AASf,MAAI,kBAAkB,MAAM,CAC3B,QAAO,EAAE;AAEV,QAAM;;AAGP,QAAO,QAAQ,KAAK,KAAK,SAAS;EACjC;EACA,IAAI,IAAI;EACR,MAAM,IAAI;EACV,QAAQ,IAAI;EACZ,OAAO,IAAI,SAAS;EAMpB,SAAS,IAAI,YAAY,OAAO,SAAY,gBAAgB,IAAI,QAAQ;EACxE,OAAO,KAAK,IAAI,IAAI,MAAM;EAC1B,EAAE;;AAKJ,MAAM,iBAAiB;AACvB,MAAM,gBAAgB;AACtB,MAAM,gBAAgB;AACtB,MAAM,kBAAkB;AACxB,MAAM,kBAAkB;;;;;;;;;;;;;;;;;AAkBxB,SAAS,gBAAgB,SAAyB;AACjD,QAAO,QACL,QAAQ,gBAAgB,QAAQ,CAChC,QAAQ,eAAe,OAAO,CAC9B,QAAQ,eAAe,OAAO,CAC9B,QAAQ,iBAAiB,SAAS,CAClC,QAAQ,iBAAiB,QAAQ,CACjC,WAAW,gBAAgB,SAAS,CACpC,WAAW,iBAAiB,UAAU;;;;;;;;;;AAWzC,eAAsB,eACrB,IACA,OACA,UAA0B,EAAE,EACJ;CACxB,MAAM,QAAQ,QAAQ,SAAS;CAC/B,MAAM,SAAS,QAAQ;CAGvB,IAAI,cAAc,QAAQ;AAC1B,KAAI,CAAC,eAAe,YAAY,WAAW,EAC1C,eAAc,MAAM,yBAAyB,GAAG;AAGjD,KAAI,YAAY,WAAW,EAC1B,QAAO,EAAE;CAGV,MAAM,cAA4B,EAAE;AAEpC,MAAK,MAAM,cAAc,aAAa;EACrC,MAAM,aAAa,IAAI,WAAW,GAAG;AAErC,MAAI,EADW,MAAM,WAAW,gBAAgB,WAAW,GAC9C,QACZ;AAID,qBAAmB,YAAY,kBAAkB;EAEjD,MAAM,WAAW,WAAW,gBAAgB,WAAW;EACvD,MAAM,eAAe,WAAW,oBAAoB,WAAW;EAI/D,MAAM,cAAc,YAAY,MAAM;AACtC,MAAI,CAAC,YACJ;EAGD,IAAI;AACJ,MAAI;AACH,aAAU,MAAM,GAGd;;;;YAIO,IAAI,IAAI,SAAS,CAAC;YAClB,IAAI,IAAI,aAAa,CAAC;aACrB,IAAI,IAAI,SAAS,CAAC,UAAU,YAAY;;;;MAI/C,SAAS,GAAG,kBAAkB,WAAW,GAAG,GAAG;qBAChC,IAAI,IAAI,SAAS,CAAC;YAC3B,MAAM;KACb,QAAQ,GAAG;WACL,OAAO;AAKf,OAAI,kBAAkB,MAAM,CAC3B;AAED,SAAM;;AAGP,OAAK,MAAM,OAAO,QAAQ,KACzB,aAAY,KAAK;GAChB;GACA,IAAI,IAAI;GACR,OAAO,IAAI;GACX,CAAC;;AAIJ,QAAO,YAAY,MAAM,GAAG,MAAM;;;;;AAMnC,eAAsB,eAAe,IAA4C;CAChF,MAAM,aAAa,IAAI,WAAW,GAAG;CACrC,MAAM,cAAc,MAAM,yBAAyB,GAAG;CACtD,MAAM,QAAqB,EAAE,aAAa,EAAE,EAAE;AAE9C,MAAK,MAAM,cAAc,aAAa;EACrC,MAAM,kBAAkB,MAAM,WAAW,cAAc,WAAW;AAClE,MAAI,gBACH,OAAM,YAAY,cAAc;;AAIlC,QAAO;;;;;AAMR,eAAe,yBAAyB,IAAyC;AAMhF,SALgB,MAAM,GACpB,WAAW,sBAAsB,CACjC,OAAO,CAAC,QAAQ,gBAAgB,CAAC,CACjC,SAAS,EAGT,QAAQ,MAAM;AACd,MAAI,CAAC,EAAE,cAAe,QAAO;AAC7B,MAAI;AAEH,UADe,KAAK,MAAM,EAAE,cAAc,CAC5B,YAAY;UACnB;AACP,UAAO;;GAEP,CACD,KAAK,MAAM,EAAE,KAAK;;;;;;;AAQrB,SAAS,YAAY,OAAuB;AAC3C,KAAI,CAAC,SAAS,OAAO,UAAU,SAC9B,QAAO;AAIR,SAAQ,MAAM,MAAM;AAEpB,KAAI,MAAM,WAAW,EACpB,QAAO;AAIR,KAAI,MAAM,WAAW,KAAI,IAAI,MAAM,SAAS,KAAI,IAAI,MAAM,UAAU,EAEnE,QAAO,IADO,MAAM,MAAM,GAAG,GAAG,CACf,QAAQ,sBAAsB,OAAK,CAAC;CAItD,MAAM,UAAU,MAAM,QAAQ,sBAAsB,OAAK;AAIzD,KAAI,sBAAsB,KAAK,MAAM,CACpC,QAAO;CAIR,MAAM,QAAQ,QAAQ,MAAM,yBAAyB,CAAC,QAAQ,MAAM,EAAE,SAAS,EAAE;AACjF,KAAI,MAAM,WAAW,EACpB,QAAO;AAKR,QAAO,MAAM,KAAK,MAAM,IAAI,EAAE,IAAI,CAAC,KAAK,IAAI;;;;;;;;;;;;;;;AChd7C,SAAS,oBAAoB,OAAgD;AAC5E,QAAO,MAAM,OACX,SACA,OAAO,SAAS,YAChB,SAAS,QACT,WAAW,QACX,OAAO,KAAK,UAAU,SACvB;;;;;AAMF,SAAS,uBAAuB,OAAkC;AAEjE,KAAI,MAAM,UAAU,UAAU,UAAU,SAAS,OAAO,MAAM,SAAS,SACtE,QAAO,MAAM;AAId,KAAI,MAAM,UAAU,SAAS;EAC5B,MAAM,QAAkB,EAAE;AAC1B,MAAI,SAAS,SAAS,OAAO,MAAM,QAAQ,YAAY,MAAM,IAC5D,OAAM,KAAK,MAAM,IAAI;AAEtB,MAAI,aAAa,SAAS,OAAO,MAAM,YAAY,YAAY,MAAM,QACpE,OAAM,KAAK,MAAM,QAAQ;AAE1B,SAAO,MAAM,KAAK,IAAI;;AAGvB,QAAO;;;;;;;;;;;;;;;;;;;;;;;AAwBR,SAAgB,iBAAiB,QAAiE;AACjG,KAAI,CAAC,OACJ,QAAO;CAIR,IAAI;AACJ,KAAI,OAAO,WAAW,SACrB,KAAI;AACH,iBAAe,KAAK,MAAM,OAAO;SAC1B;AAEP,SAAO;;KAGR,gBAAe;AAGhB,KAAI,CAAC,MAAM,QAAQ,aAAa,CAC/B,QAAO;AAqBR,QADiB,CANI,YAPC,aAAa,KAAK,MAAM;EAC7C,MAAM,MAAmD,EAAE,OAAO,EAAE,OAAO;AAC3E,OAAK,MAAM,CAAC,KAAK,QAAQ,OAAO,QAAQ,EAAE,CACzC,KAAI,OAAO;AAEZ,SAAO;GACN,CAC6C,EAMf,GAHZ,aAAa,IAAI,uBAAuB,CAAC,QAAQ,SAAS,KAAK,SAAS,EAAE,CAG/C,CAAC,QAAQ,MAAM,EAAE,SAAS,EAAE,CAC3D,KAAK,KAAK;;;;;;;;;;;AAY3B,SAAgB,wBACf,OACA,QACyB;CACzB,MAAM,SAAiC,EAAE;AAEzC,MAAK,MAAM,SAAS,QAAQ;EAC3B,MAAM,QAAQ,MAAM;AAEpB,MAAI,UAAU,QAAQ,UAAU,QAAW;AAC1C,UAAO,SAAS;AAChB;;AAGD,MAAI,OAAO,UAAU,SAEpB,KAAI,MAAM,WAAW,IAAI,CACxB,QAAO,SAAS,iBAAiB,MAAM;MAEvC,QAAO,SAAS;WAEP,MAAM,QAAQ,MAAM,CAE9B,KAAI,oBAAoB,MAAM,CAC7B,QAAO,SAAS,iBAAiB,MAAM;MAEvC,QAAO,SAAS,KAAK,UAAU,MAAM;WAE5B,OAAO,UAAU,SAE3B,QAAO,SAAS,KAAK,UAAU,MAAM;WAC3B,OAAO,UAAU,YAAY,OAAO,UAAU,UACxD,QAAO,SAAS,GAAG;MAEnB,QAAO,SAAS;;AAIlB,QAAO"}
|
|
1
|
+
{"version":3,"file":"search-BrF7k0Ho.mjs","names":[],"sources":["../src/search/query.ts","../src/search/text-extraction.ts","../src/search/index.ts"],"sourcesContent":["/**\n * Search Query Functions\n *\n * Programmatic API for searching content using FTS5.\n */\n\nimport type { Kysely } from \"kysely\";\nimport { sql } from \"kysely\";\n\nimport type { Database } from \"../database/types.js\";\nimport { validateIdentifier } from \"../database/validate.js\";\nimport { getDb } from \"../loader.js\";\nimport { FTSManager } from \"./fts-manager.js\";\nimport type {\n\tSearchOptions,\n\tCollectionSearchOptions,\n\tSearchResult,\n\tSearchResponse,\n\tSuggestOptions,\n\tSuggestion,\n\tSearchStats,\n} from \"./types.js\";\n\n/** Pattern to split on whitespace for query term extraction */\nconst WHITESPACE_SPLIT_PATTERN = /\\s+/;\nconst FTS_OPERATORS_PATTERN = /\\b(AND|OR|NOT|NEAR)\\b/i;\nconst DOUBLE_QUOTE_PATTERN = /\"/g;\n\n/**\n * Detect FTS5 query syntax errors. Match specifically on the SQLite FTS5\n * error fingerprints rather than a broad \"fts5\" / \"syntax error\" filter\n * (which would also swallow internal table-corruption errors). The two\n * fingerprints we care about are:\n *\n * - \"fts5: syntax error near …\" — unbalanced quotes, stray operators,\n * other malformed user input\n * - \"unknown special query: …\" — bare special tokens like `^*` that\n * parse but don't resolve to a real FTS5 directive\n */\nfunction isFts5SyntaxError(error: unknown): boolean {\n\tif (!(error instanceof Error)) return false;\n\tconst message = error.message.toLowerCase();\n\treturn message.includes(\"fts5: syntax error\") || message.includes(\"unknown special query\");\n}\n\n/**\n * Search across multiple collections\n *\n * Public API that auto-injects the database.\n *\n * @param query - Search query (FTS5 syntax supported)\n * @param options - Search options\n * @returns Search results with pagination\n *\n * @example\n * ```typescript\n * import { search } from \"emdash\";\n *\n * const results = await search(\"hello world\", {\n * collections: [\"posts\", \"pages\"],\n * limit: 20\n * });\n * ```\n */\nexport async function search(query: string, options: SearchOptions = {}): Promise<SearchResponse> {\n\tconst db = await getDb();\n\treturn searchWithDb(db, query, options);\n}\n\n/**\n * Search across multiple collections (with explicit db)\n *\n * @internal Use `search()` in templates. This variant is for admin routes\n * that already have a database handle.\n *\n * @param db - Kysely database instance\n * @param query - Search query (FTS5 syntax supported)\n * @param options - Search options\n * @returns Search results with pagination\n */\nexport async function searchWithDb(\n\tdb: Kysely<Database>,\n\tquery: string,\n\toptions: SearchOptions = {},\n): Promise<SearchResponse> {\n\tconst ftsManager = new FTSManager(db);\n\tconst limit = options.limit ?? 20;\n\tconst status = options.status ?? \"published\";\n\n\t// Get searchable collections\n\tlet collections = options.collections;\n\tif (!collections || collections.length === 0) {\n\t\tcollections = await getSearchableCollections(db);\n\t}\n\n\tif (collections.length === 0) {\n\t\treturn { items: [] };\n\t}\n\n\t// Search each collection and merge results\n\tconst allResults: SearchResult[] = [];\n\n\tfor (const collection of collections) {\n\t\tconst config = await ftsManager.getSearchConfig(collection);\n\t\tif (!config?.enabled) {\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst collectionResults = await searchSingleCollection(\n\t\t\tdb,\n\t\t\tcollection,\n\t\t\tquery,\n\t\t\t{\n\t\t\t\tstatus,\n\t\t\t\tlocale: options.locale,\n\t\t\t\tlimit: limit * 2, // Get extra for merging\n\t\t\t},\n\t\t\tconfig.weights,\n\t\t);\n\n\t\tallResults.push(...collectionResults);\n\t}\n\n\t// Sort by score descending\n\tallResults.sort((a, b) => b.score - a.score);\n\n\t// Apply limit\n\tconst items = allResults.slice(0, limit);\n\n\treturn { items };\n}\n\n/**\n * Search within a single collection\n *\n * @param db - Kysely database instance\n * @param collection - Collection slug\n * @param query - Search query (FTS5 syntax supported)\n * @param options - Search options\n * @returns Search results with pagination\n *\n * @example\n * ```typescript\n * const results = await searchCollection(db, \"posts\", \"hello world\", {\n * limit: 10\n * });\n * ```\n */\nexport async function searchCollection(\n\tdb: Kysely<Database>,\n\tcollection: string,\n\tquery: string,\n\toptions: CollectionSearchOptions = {},\n): Promise<SearchResponse> {\n\tconst ftsManager = new FTSManager(db);\n\tconst config = await ftsManager.getSearchConfig(collection);\n\n\tif (!config?.enabled) {\n\t\treturn { items: [] };\n\t}\n\n\tconst items = await searchSingleCollection(db, collection, query, options, config.weights);\n\n\treturn { items };\n}\n\n/**\n * Internal function to search a single collection\n */\nasync function searchSingleCollection(\n\tdb: Kysely<Database>,\n\tcollection: string,\n\tquery: string,\n\toptions: CollectionSearchOptions,\n\tweights?: Record<string, number>,\n): Promise<SearchResult[]> {\n\t// Validate before any raw SQL interpolation\n\tvalidateIdentifier(collection, \"collection slug\");\n\n\tconst ftsManager = new FTSManager(db);\n\tconst ftsTable = ftsManager.getFtsTableName(collection);\n\tconst contentTable = ftsManager.getContentTableName(collection);\n\tconst limit = options.limit ?? 20;\n\tconst status = options.status ?? \"published\";\n\tconst locale = options.locale;\n\n\t// Check if FTS table exists\n\tif (!(await ftsManager.ftsTableExists(collection))) {\n\t\treturn [];\n\t}\n\n\t// Escape the query for FTS5\n\tconst escapedQuery = escapeQuery(query);\n\tif (!escapedQuery) {\n\t\treturn [];\n\t}\n\n\t// Get searchable fields for snippet generation\n\tconst searchableFields = await ftsManager.getSearchableFields(collection);\n\n\t// Build weight string for bm25 if weights provided\n\t// Format: bm25(table, weight1, weight2, ...)\n\t// First two weights are for 'id' and 'locale' columns (UNINDEXED, so 0)\n\tlet bm25Args = \"\";\n\tif (weights && searchableFields.length > 0) {\n\t\tconst weightValues = [\"0\", \"0\"]; // id column, locale column\n\t\tfor (const field of searchableFields) {\n\t\t\tweightValues.push(String(weights[field] ?? 1));\n\t\t}\n\t\tbm25Args = weightValues.join(\", \");\n\t}\n\n\t// Build and execute the search query\n\t// Using raw SQL because Kysely doesn't have FTS5 support\n\tconst bm25Expr = bm25Args ? `bm25(\"${ftsTable}\", ${bm25Args})` : `bm25(\"${ftsTable}\")`;\n\n\t// Snippet column index is 2 (after id=0, locale=1, first searchable field=2)\n\tlet results;\n\ttry {\n\t\tresults = await sql<{\n\t\t\tid: string;\n\t\t\tslug: string | null;\n\t\t\tlocale: string;\n\t\t\ttitle: string | null;\n\t\t\tsnippet: string | null;\n\t\t\tscore: number;\n\t\t}>`\n\t\tSELECT \n\t\t\tc.id,\n\t\t\tc.slug,\n\t\t\tc.locale,\n\t\t\tc.title,\n\t\t\tsnippet(\"${sql.raw(ftsTable)}\", 2, '<mark>', '</mark>', '...', 32) as snippet,\n\t\t\t${sql.raw(bm25Expr)} as score\n\t\tFROM \"${sql.raw(ftsTable)}\" f\n\t\tJOIN \"${sql.raw(contentTable)}\" c ON f.id = c.id\n\t\tWHERE \"${sql.raw(ftsTable)}\" MATCH ${escapedQuery}\n\t\tAND c.status = ${status}\n\t\tAND c.deleted_at IS NULL\n\t\t${locale ? sql`AND c.locale = ${locale}` : sql``}\n\t\tORDER BY score\n\t\tLIMIT ${limit}\n\t`.execute(db);\n\t} catch (error) {\n\t\t// FTS5 returns syntax errors for queries with unbalanced quotes,\n\t\t// stray operators, or other malformed input. Treat these as\n\t\t// \"no matches\" so the user gets an empty result rather than an\n\t\t// internals-leaking error. Other errors (table missing, IO) still\n\t\t// propagate. Intentionally not logged: any anonymous client can\n\t\t// trigger this path, and the underlying error message embeds the\n\t\t// raw query, so logging would be both noisy and a log-injection\n\t\t// vector.\n\t\tif (isFts5SyntaxError(error)) {\n\t\t\treturn [];\n\t\t}\n\t\tthrow error;\n\t}\n\n\treturn results.rows.map((row) => ({\n\t\tcollection,\n\t\tid: row.id,\n\t\tslug: row.slug,\n\t\tlocale: row.locale,\n\t\ttitle: row.title ?? undefined,\n\t\t// SQLite's snippet() returns NULL when the targeted column is\n\t\t// NULL for that row — even if the row matched via a different\n\t\t// searchable column. Skip sanitization in that case so we don't\n\t\t// throw on `null.replace`. The SearchResult.snippet field is\n\t\t// already optional, so omitting it is the documented contract.\n\t\tsnippet: row.snippet === null ? undefined : sanitizeSnippet(row.snippet),\n\t\tscore: Math.abs(row.score), // bm25 returns negative scores\n\t}));\n}\n\n// Module-scope regexes so the engine doesn't recompile per call —\n// snippet sanitization runs on every search result.\nconst SNIPPET_AMP_RE = /&/g;\nconst SNIPPET_LT_RE = /</g;\nconst SNIPPET_GT_RE = />/g;\nconst SNIPPET_QUOT_RE = /\"/g;\nconst SNIPPET_APOS_RE = /'/g;\n\n/**\n * Make an FTS5 snippet safe to render with `set:html` / `innerHTML`.\n *\n * SQLite's `snippet()` function splices literal `<mark>` and `</mark>`\n * markers around matched terms but does not escape the surrounding\n * source text. Posts that legitimately contain `<`, `>`, `&`, `\"` or\n * `'` would render as broken markup, and a `<script>` literal in a\n * title (or any other indexed field) would execute when displayed.\n *\n * The fix: HTML-escape the whole string, which turns the markers into\n * `<mark>` / `</mark>`. Then restore those two patterns to\n * their original tag form. The result is \"the indexed text with all\n * HTML metacharacters escaped, plus a small set of literal `<mark>`\n * highlight tags around matched terms\" — which matches the API's\n * documented contract.\n */\nfunction sanitizeSnippet(snippet: string): string {\n\treturn snippet\n\t\t.replace(SNIPPET_AMP_RE, \"&\")\n\t\t.replace(SNIPPET_LT_RE, \"<\")\n\t\t.replace(SNIPPET_GT_RE, \">\")\n\t\t.replace(SNIPPET_QUOT_RE, \""\")\n\t\t.replace(SNIPPET_APOS_RE, \"'\")\n\t\t.replaceAll(\"<mark>\", \"<mark>\")\n\t\t.replaceAll(\"</mark>\", \"</mark>\");\n}\n\n/**\n * Get search suggestions for autocomplete\n *\n * @param db - Kysely database instance\n * @param query - Partial search query\n * @param options - Suggestion options\n * @returns Array of suggestions\n */\nexport async function getSuggestions(\n\tdb: Kysely<Database>,\n\tquery: string,\n\toptions: SuggestOptions = {},\n): Promise<Suggestion[]> {\n\tconst limit = options.limit ?? 5;\n\tconst locale = options.locale;\n\n\t// Get searchable collections\n\tlet collections = options.collections;\n\tif (!collections || collections.length === 0) {\n\t\tcollections = await getSearchableCollections(db);\n\t}\n\n\tif (collections.length === 0) {\n\t\treturn [];\n\t}\n\n\tconst suggestions: Suggestion[] = [];\n\n\tfor (const collection of collections) {\n\t\tconst ftsManager = new FTSManager(db);\n\t\tconst config = await ftsManager.getSearchConfig(collection);\n\t\tif (!config?.enabled) {\n\t\t\tcontinue;\n\t\t}\n\n\t\t// Validate before raw SQL interpolation\n\t\tvalidateIdentifier(collection, \"collection slug\");\n\n\t\tconst ftsTable = ftsManager.getFtsTableName(collection);\n\t\tconst contentTable = ftsManager.getContentTableName(collection);\n\n\t\t// Use prefix search for autocomplete. `escapeQuery` already appends `*`\n\t\t// to each term for prefix matching, so we must not append another one.\n\t\tconst prefixQuery = escapeQuery(query);\n\t\tif (!prefixQuery) {\n\t\t\tcontinue;\n\t\t}\n\n\t\tlet results;\n\t\ttry {\n\t\t\tresults = await sql<{\n\t\t\t\tid: string;\n\t\t\t\ttitle: string;\n\t\t\t}>`\n\t\t\t\tSELECT \n\t\t\t\t\tc.id,\n\t\t\t\t\tc.title\n\t\t\t\tFROM \"${sql.raw(ftsTable)}\" f\n\t\t\t\tJOIN \"${sql.raw(contentTable)}\" c ON f.id = c.id\n\t\t\t\tWHERE \"${sql.raw(ftsTable)}\" MATCH ${prefixQuery}\n\t\t\t\tAND c.status = 'published'\n\t\t\t\tAND c.deleted_at IS NULL\n\t\t\t\tAND c.title IS NOT NULL\n\t\t\t\t${locale ? sql`AND c.locale = ${locale}` : sql``}\n\t\t\t\tORDER BY bm25(\"${sql.raw(ftsTable)}\")\n\t\t\t\tLIMIT ${limit}\n\t\t\t`.execute(db);\n\t\t} catch (error) {\n\t\t\t// Same swallow as searchSingleCollection: malformed prefix\n\t\t\t// queries should yield no suggestions, not surface DB errors.\n\t\t\t// Intentionally not logged (anonymous-triggerable, echoes\n\t\t\t// user input -- see searchSingleCollection for rationale).\n\t\t\tif (isFts5SyntaxError(error)) {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tthrow error;\n\t\t}\n\n\t\tfor (const row of results.rows) {\n\t\t\tsuggestions.push({\n\t\t\t\tcollection,\n\t\t\t\tid: row.id,\n\t\t\t\ttitle: row.title,\n\t\t\t});\n\t\t}\n\t}\n\n\treturn suggestions.slice(0, limit);\n}\n\n/**\n * Get search statistics for all collections\n */\nexport async function getSearchStats(db: Kysely<Database>): Promise<SearchStats> {\n\tconst ftsManager = new FTSManager(db);\n\tconst collections = await getSearchableCollections(db);\n\tconst stats: SearchStats = { collections: {} };\n\n\tfor (const collection of collections) {\n\t\tconst collectionStats = await ftsManager.getIndexStats(collection);\n\t\tif (collectionStats) {\n\t\t\tstats.collections[collection] = collectionStats;\n\t\t}\n\t}\n\n\treturn stats;\n}\n\n/**\n * Get list of collections with search enabled\n */\nasync function getSearchableCollections(db: Kysely<Database>): Promise<string[]> {\n\tconst results = await db\n\t\t.selectFrom(\"_emdash_collections\")\n\t\t.select([\"slug\", \"search_config\"])\n\t\t.execute();\n\n\treturn results\n\t\t.filter((r) => {\n\t\t\tif (!r.search_config) return false;\n\t\t\ttry {\n\t\t\t\tconst config = JSON.parse(r.search_config);\n\t\t\t\treturn config.enabled === true;\n\t\t\t} catch {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t})\n\t\t.map((r) => r.slug);\n}\n\n/**\n * Escape a query string for FTS5\n *\n * Handles special characters and prevents injection.\n */\nfunction escapeQuery(query: string): string {\n\tif (!query || typeof query !== \"string\") {\n\t\treturn \"\";\n\t}\n\n\t// Trim whitespace\n\tquery = query.trim();\n\n\tif (query.length === 0) {\n\t\treturn \"\";\n\t}\n\n\t// If already a quoted phrase, escape only interior quotes and preserve phrase syntax\n\tif (query.startsWith('\"') && query.endsWith('\"') && query.length >= 2) {\n\t\tconst inner = query.slice(1, -1);\n\t\treturn `\"${inner.replace(DOUBLE_QUOTE_PATTERN, '\"\"')}\"`;\n\t}\n\n\t// Escape any existing quotes\n\tconst escaped = query.replace(DOUBLE_QUOTE_PATTERN, '\"\"');\n\n\t// If the query contains FTS5 operators (AND, OR, NOT, NEAR),\n\t// pass through with quotes escaped but operators preserved\n\tif (FTS_OPERATORS_PATTERN.test(query)) {\n\t\treturn escaped;\n\t}\n\n\t// For simple queries, wrap each word to handle special chars\n\tconst terms = escaped.split(WHITESPACE_SPLIT_PATTERN).filter((t) => t.length > 0);\n\tif (terms.length === 0) {\n\t\treturn \"\";\n\t}\n\n\t// Join with implicit AND, add prefix matching (*) to all terms\n\t// This allows \"hel wor\" to match \"hello world\"\n\treturn terms.map((t) => `\"${t}\"*`).join(\" \");\n}\n","/**\n * Text Extraction\n *\n * Extracts plain text from Portable Text blocks for FTS indexing.\n * Uses @portabletext/toolkit as base with extensions for custom block types.\n */\n\nimport { toPlainText } from \"@portabletext/toolkit\";\n\nimport type { PortableTextBlock } from \"../content/converters/types.js\";\n\n/**\n * Validate that a value looks like a Portable Text block array.\n * Each element must have at least a `_type` string property.\n */\nfunction isPortableTextArray(value: unknown[]): value is PortableTextBlock[] {\n\treturn value.every(\n\t\t(item) =>\n\t\t\ttypeof item === \"object\" &&\n\t\t\titem !== null &&\n\t\t\t\"_type\" in item &&\n\t\t\ttypeof item._type === \"string\",\n\t);\n}\n\n/**\n * Extract additional text from custom block types that toPlainText doesn't handle\n */\nfunction extractCustomBlockText(block: PortableTextBlock): string {\n\t// Code blocks - include the code content\n\tif (block._type === \"code\" && \"code\" in block && typeof block.code === \"string\") {\n\t\treturn block.code;\n\t}\n\n\t// Image blocks - include alt text and caption\n\tif (block._type === \"image\") {\n\t\tconst parts: string[] = [];\n\t\tif (\"alt\" in block && typeof block.alt === \"string\" && block.alt) {\n\t\t\tparts.push(block.alt);\n\t\t}\n\t\tif (\"caption\" in block && typeof block.caption === \"string\" && block.caption) {\n\t\t\tparts.push(block.caption);\n\t\t}\n\t\treturn parts.join(\" \");\n\t}\n\n\treturn \"\";\n}\n\n/**\n * Extract plain text from Portable Text blocks\n *\n * Uses @portabletext/toolkit's toPlainText for standard blocks,\n * plus extracts text from custom block types (code, images with alt/caption).\n *\n * @param blocks - Array of Portable Text blocks (or a JSON string)\n * @returns Plain text content\n *\n * @example\n * ```typescript\n * const text = extractPlainText([\n * {\n * _type: \"block\",\n * _key: \"abc\",\n * children: [{ _type: \"span\", _key: \"s1\", text: \"Hello World\" }]\n * }\n * ]);\n * // Returns: \"Hello World\"\n * ```\n */\nexport function extractPlainText(blocks: PortableTextBlock[] | string | null | undefined): string {\n\tif (!blocks) {\n\t\treturn \"\";\n\t}\n\n\t// Handle JSON string input\n\tlet parsedBlocks: PortableTextBlock[];\n\tif (typeof blocks === \"string\") {\n\t\ttry {\n\t\t\tparsedBlocks = JSON.parse(blocks);\n\t\t} catch {\n\t\t\t// If it's not valid JSON, treat as plain text\n\t\t\treturn blocks;\n\t\t}\n\t} else {\n\t\tparsedBlocks = blocks;\n\t}\n\n\tif (!Array.isArray(parsedBlocks)) {\n\t\treturn \"\";\n\t}\n\n\t// Use official toPlainText for standard blocks.\n\t// toPlainText expects `{ _type: string; [key: string]: any }[]` but our blocks use\n\t// `unknown` index sigs. They're structurally compatible at runtime — spread each block\n\t// to satisfy the wider index signature without an unsafe cast.\n\tconst toolkitBlocks = parsedBlocks.map((b) => {\n\t\tconst obj: Record<string, unknown> & { _type: string } = { _type: b._type };\n\t\tfor (const [key, val] of Object.entries(b)) {\n\t\t\tobj[key] = val;\n\t\t}\n\t\treturn obj;\n\t});\n\tconst standardText = toPlainText(toolkitBlocks);\n\n\t// Extract text from custom block types that toPlainText doesn't handle\n\tconst customTexts = parsedBlocks.map(extractCustomBlockText).filter((text) => text.length > 0);\n\n\t// Combine both\n\tconst allTexts = [standardText, ...customTexts].filter((t) => t.length > 0);\n\treturn allTexts.join(\"\\n\");\n}\n\n/**\n * Extract searchable text from a content entry\n *\n * Extracts text from specified fields, handling both plain text and Portable Text.\n *\n * @param entry - Content entry data\n * @param fields - Field names to extract text from\n * @returns Object mapping field names to extracted text\n */\nexport function extractSearchableFields(\n\tentry: Record<string, unknown>,\n\tfields: string[],\n): Record<string, string> {\n\tconst result: Record<string, string> = {};\n\n\tfor (const field of fields) {\n\t\tconst value = entry[field];\n\n\t\tif (value === null || value === undefined) {\n\t\t\tresult[field] = \"\";\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (typeof value === \"string\") {\n\t\t\t// Could be plain text or JSON Portable Text\n\t\t\tif (value.startsWith(\"[\")) {\n\t\t\t\tresult[field] = extractPlainText(value);\n\t\t\t} else {\n\t\t\t\tresult[field] = value;\n\t\t\t}\n\t\t} else if (Array.isArray(value)) {\n\t\t\t// Validate the array looks like Portable Text before treating it as such\n\t\t\tif (isPortableTextArray(value)) {\n\t\t\t\tresult[field] = extractPlainText(value);\n\t\t\t} else {\n\t\t\t\tresult[field] = JSON.stringify(value);\n\t\t\t}\n\t\t} else if (typeof value === \"object\") {\n\t\t\t// Object — serialize to JSON for searchable text\n\t\t\tresult[field] = JSON.stringify(value);\n\t\t} else if (typeof value === \"number\" || typeof value === \"boolean\") {\n\t\t\tresult[field] = `${value}`;\n\t\t} else {\n\t\t\tresult[field] = \"\";\n\t\t}\n\t}\n\n\treturn result;\n}\n","/**\n * Search Module\n *\n * Full-text search for EmDash using SQLite FTS5.\n */\n\n// Types\nexport type {\n\tSearchConfig,\n\tSearchOptions,\n\tCollectionSearchOptions,\n\tSearchResult,\n\tSearchResponse,\n\tSuggestOptions,\n\tSuggestion,\n\tSearchStats,\n} from \"./types.js\";\n\n// FTS Manager\nexport { FTSManager } from \"./fts-manager.js\";\n\n// Query functions (public API uses getDb() internally)\nexport { search, searchWithDb, searchCollection, getSuggestions, getSearchStats } from \"./query.js\";\n\n// Text extraction\nexport { extractPlainText, extractSearchableFields } from \"./text-extraction.js\";\n"],"mappings":"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"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { i as encodeCursor, n as InvalidCursorError, r as decodeCursor } from "./types-
|
|
2
|
-
import { r as getDb } from "./loader-
|
|
1
|
+
import { i as encodeCursor, n as InvalidCursorError, r as decodeCursor } from "./types-CfyYQ7eY.mjs";
|
|
2
|
+
import { r as getDb } from "./loader-BxyvbrZP.mjs";
|
|
3
3
|
import { ulid } from "ulidx";
|
|
4
4
|
|
|
5
5
|
//#region src/sections/index.ts
|
|
@@ -343,4 +343,4 @@ async function handleSectionDelete(db, slug) {
|
|
|
343
343
|
|
|
344
344
|
//#endregion
|
|
345
345
|
export { handleSectionUpdate as a, handleSectionList as i, handleSectionDelete as n, getSection as o, handleSectionGet as r, getSections as s, handleSectionCreate as t };
|
|
346
|
-
//# sourceMappingURL=sections-
|
|
346
|
+
//# sourceMappingURL=sections-8DEa-dWt.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sections-Ba-rJLKb.mjs","names":[],"sources":["../src/sections/index.ts","../src/api/handlers/sections.ts"],"sourcesContent":["/**\n * Sections runtime functions\n *\n * Sections are reusable content blocks that can be inserted into any Portable Text field.\n */\n\nimport type { Kysely } from \"kysely\";\n\nimport { encodeCursor, decodeCursor, type FindManyResult } from \"../database/repositories/types.js\";\nimport type { Database } from \"../database/types.js\";\nimport { getDb } from \"../loader.js\";\nimport type { Section, SectionRow, GetSectionsOptions } from \"./types.js\";\n\nexport type {\n\tSection,\n\tSectionSource,\n\tSectionRow,\n\tCreateSectionInput,\n\tUpdateSectionInput,\n\tGetSectionsOptions,\n} from \"./types.js\";\n\n/**\n * Get a section by slug\n *\n * @example\n * ```ts\n * import { getSection } from \"emdash\";\n *\n * const section = await getSection(\"hero-centered\");\n * if (section) {\n * console.log(section.content); // Portable Text array\n * }\n * ```\n */\nexport async function getSection(slug: string): Promise<Section | null> {\n\tconst db = await getDb();\n\treturn getSectionWithDb(slug, db);\n}\n\n/**\n * Get a section by slug (with explicit db)\n *\n * @internal Use `getSection()` in templates. This variant is for admin routes\n * that already have a database handle.\n */\nexport async function getSectionWithDb(\n\tslug: string,\n\tdb: Kysely<Database>,\n): Promise<Section | null> {\n\tconst row = await db\n\t\t.selectFrom(\"_emdash_sections\")\n\t\t.selectAll()\n\t\t.$castTo<SectionRow>()\n\t\t.where(\"slug\", \"=\", slug)\n\t\t.executeTakeFirst();\n\n\tif (!row) {\n\t\treturn null;\n\t}\n\n\treturn rowToSection(row, db);\n}\n\n/**\n * Get a section by ID\n *\n * @internal Primarily for admin use\n */\nexport async function getSectionById(id: string, db: Kysely<Database>): Promise<Section | null> {\n\tconst row = await db\n\t\t.selectFrom(\"_emdash_sections\")\n\t\t.selectAll()\n\t\t.$castTo<SectionRow>()\n\t\t.where(\"id\", \"=\", id)\n\t\t.executeTakeFirst();\n\n\tif (!row) {\n\t\treturn null;\n\t}\n\n\treturn rowToSection(row, db);\n}\n\n/**\n * Get all sections with optional filtering\n *\n * @example\n * ```ts\n * import { getSections } from \"emdash\";\n *\n * // Get all theme-provided sections\n * const themeSections = await getSections({ source: \"theme\" });\n *\n * // Search sections\n * const results = await getSections({ search: \"pricing\" });\n * ```\n */\nexport async function getSections(\n\toptions: GetSectionsOptions = {},\n): Promise<FindManyResult<Section>> {\n\tconst db = await getDb();\n\treturn getSectionsWithDb(db, options);\n}\n\n/**\n * Get all sections with optional filtering (with explicit db)\n *\n * @internal Use `getSections()` in templates. This variant is for admin routes\n * that already have a database handle.\n */\nexport async function getSectionsWithDb(\n\tdb: Kysely<Database>,\n\toptions: GetSectionsOptions = {},\n): Promise<FindManyResult<Section>> {\n\tconst limit = Math.min(Math.max(1, options.limit || 50), 100);\n\n\tlet query = db.selectFrom(\"_emdash_sections\").selectAll();\n\n\t// Filter by source\n\tif (options.source) {\n\t\tquery = query.where(\"source\", \"=\", options.source);\n\t}\n\n\t// Search - search title, description, and keywords\n\tif (options.search) {\n\t\tconst searchTerm = `%${options.search.toLowerCase()}%`;\n\t\tquery = query.where((eb) =>\n\t\t\teb.or([\n\t\t\t\teb(\"title\", \"like\", searchTerm),\n\t\t\t\teb(\"description\", \"like\", searchTerm),\n\t\t\t\teb(\"keywords\", \"like\", searchTerm),\n\t\t\t]),\n\t\t);\n\t}\n\n\t// Order by title ASC, id ASC for stable cursor pagination\n\tquery = query.orderBy(\"title\", \"asc\").orderBy(\"id\", \"asc\");\n\n\t// Cursor-based pagination — throws on invalid cursor.\n\tif (options.cursor) {\n\t\tconst decoded = decodeCursor(options.cursor);\n\t\tquery = query.where((eb) =>\n\t\t\teb.or([\n\t\t\t\teb(\"title\", \">\", decoded.orderValue),\n\t\t\t\teb.and([eb(\"title\", \"=\", decoded.orderValue), eb(\"id\", \">\", decoded.id)]),\n\t\t\t]),\n\t\t);\n\t}\n\n\tquery = query.limit(limit + 1);\n\n\tconst rows = await query.$castTo<SectionRow>().execute();\n\tconst hasMore = rows.length > limit;\n\tconst sliced = rows.slice(0, limit);\n\n\t// Convert rows to sections\n\tconst items = await Promise.all(sliced.map((row) => rowToSection(row, db)));\n\tconst result: FindManyResult<Section> = { items };\n\n\tif (hasMore && items.length > 0) {\n\t\tconst last = items.at(-1)!;\n\t\tresult.nextCursor = encodeCursor(last.title, last.id);\n\t}\n\n\treturn result;\n}\n\n/**\n * Convert a section row to the API type\n */\nasync function rowToSection(row: SectionRow, db: Kysely<Database>): Promise<Section> {\n\t// Parse keywords\n\tlet keywords: string[] = [];\n\tif (row.keywords) {\n\t\ttry {\n\t\t\tkeywords = JSON.parse(row.keywords);\n\t\t} catch {\n\t\t\t// Invalid JSON, ignore\n\t\t}\n\t}\n\n\t// Parse content — stored as JSON array of Portable Text blocks\n\tlet content: Section[\"content\"] = [];\n\tif (row.content) {\n\t\ttry {\n\t\t\tconst parsed: unknown = JSON.parse(row.content);\n\t\t\tif (Array.isArray(parsed)) {\n\t\t\t\t// DB stores serialized PortableTextBlock[]; trust the schema\n\t\t\t\tcontent = parsed;\n\t\t\t}\n\t\t} catch {\n\t\t\t// Invalid JSON, ignore\n\t\t}\n\t}\n\n\t// Get preview URL from media (if present)\n\tlet previewUrl: string | undefined;\n\tif (row.preview_media_id) {\n\t\tconst media = await db\n\t\t\t.selectFrom(\"media\")\n\t\t\t.select(\"storage_key\")\n\t\t\t.where(\"id\", \"=\", row.preview_media_id)\n\t\t\t.executeTakeFirst();\n\n\t\tif (media) {\n\t\t\tpreviewUrl = `/_emdash/media/${media.storage_key}`;\n\t\t}\n\t}\n\n\treturn {\n\t\tid: row.id,\n\t\tslug: row.slug,\n\t\ttitle: row.title,\n\t\tdescription: row.description ?? undefined,\n\t\tkeywords,\n\t\tcontent,\n\t\tpreviewUrl,\n\t\tsource: row.source,\n\t\tthemeId: row.theme_id ?? undefined,\n\t\tcreatedAt: row.created_at,\n\t\tupdatedAt: row.updated_at,\n\t};\n}\n","/**\n * Section CRUD handlers\n */\n\nimport type { Kysely } from \"kysely\";\nimport { ulid } from \"ulidx\";\n\nimport { InvalidCursorError } from \"../../database/repositories/types.js\";\nimport type { FindManyResult } from \"../../database/repositories/types.js\";\nimport type { Database } from \"../../database/types.js\";\nimport {\n\tgetSectionById,\n\tgetSectionWithDb,\n\tgetSectionsWithDb,\n\ttype Section,\n\ttype GetSectionsOptions,\n} from \"../../sections/index.js\";\nimport type { ApiResult } from \"../types.js\";\n\nconst SLUG_PATTERN = /^[a-z0-9-]+$/;\n\nexport type SectionListResponse = FindManyResult<Section>;\n\n/**\n * List sections with optional filters\n */\nexport async function handleSectionList(\n\tdb: Kysely<Database>,\n\tparams: GetSectionsOptions,\n): Promise<ApiResult<SectionListResponse>> {\n\ttry {\n\t\tconst result = await getSectionsWithDb(db, {\n\t\t\tsource: params.source,\n\t\t\tsearch: params.search,\n\t\t\tlimit: params.limit,\n\t\t\tcursor: params.cursor,\n\t\t});\n\n\t\treturn { success: true, data: result };\n\t} catch (error) {\n\t\tif (error instanceof InvalidCursorError) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"INVALID_CURSOR\", message: error.message },\n\t\t\t};\n\t\t}\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"SECTION_LIST_ERROR\", message: \"Failed to fetch sections\" },\n\t\t};\n\t}\n}\n\n/**\n * Create a section\n */\nexport async function handleSectionCreate(\n\tdb: Kysely<Database>,\n\tinput: {\n\t\tslug: string;\n\t\ttitle: string;\n\t\tdescription?: string;\n\t\tkeywords?: string[];\n\t\tcontent: unknown[];\n\t\tpreviewMediaId?: string;\n\t\tsource?: string;\n\t\tthemeId?: string;\n\t},\n): Promise<ApiResult<Section>> {\n\ttry {\n\t\t// Validate slug format\n\t\tif (!SLUG_PATTERN.test(input.slug)) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\t\t\tmessage: \"slug must only contain lowercase letters, numbers, and hyphens\",\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\t// Check if slug already exists\n\t\tconst existing = await db\n\t\t\t.selectFrom(\"_emdash_sections\")\n\t\t\t.select(\"id\")\n\t\t\t.where(\"slug\", \"=\", input.slug)\n\t\t\t.executeTakeFirst();\n\n\t\tif (existing) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"CONFLICT\",\n\t\t\t\t\tmessage: `Section with slug \"${input.slug}\" already exists`,\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\tconst id = ulid();\n\t\tconst now = new Date().toISOString();\n\n\t\tawait db\n\t\t\t.insertInto(\"_emdash_sections\")\n\t\t\t.values({\n\t\t\t\tid,\n\t\t\t\tslug: input.slug,\n\t\t\t\ttitle: input.title,\n\t\t\t\tdescription: input.description ?? null,\n\t\t\t\tkeywords: input.keywords ? JSON.stringify(input.keywords) : null,\n\t\t\t\tcontent: JSON.stringify(input.content),\n\t\t\t\tpreview_media_id: input.previewMediaId ?? null,\n\t\t\t\tsource: input.source ?? \"user\",\n\t\t\t\ttheme_id: input.themeId ?? null,\n\t\t\t\tcreated_at: now,\n\t\t\t\tupdated_at: now,\n\t\t\t})\n\t\t\t.execute();\n\n\t\tconst section = await getSectionById(id, db);\n\t\tif (!section) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"SECTION_CREATE_ERROR\", message: \"Failed to fetch created section\" },\n\t\t\t};\n\t\t}\n\n\t\treturn { success: true, data: section };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"SECTION_CREATE_ERROR\", message: \"Failed to create section\" },\n\t\t};\n\t}\n}\n\n/**\n * Get a section by slug\n */\nexport async function handleSectionGet(\n\tdb: Kysely<Database>,\n\tslug: string,\n): Promise<ApiResult<Section>> {\n\ttry {\n\t\tconst section = await getSectionWithDb(slug, db);\n\n\t\tif (!section) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"NOT_FOUND\", message: `Section \"${slug}\" not found` },\n\t\t\t};\n\t\t}\n\n\t\treturn { success: true, data: section };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"SECTION_GET_ERROR\", message: \"Failed to fetch section\" },\n\t\t};\n\t}\n}\n\n/**\n * Update a section by slug\n */\nexport async function handleSectionUpdate(\n\tdb: Kysely<Database>,\n\tslug: string,\n\tinput: {\n\t\tslug?: string;\n\t\ttitle?: string;\n\t\tdescription?: string;\n\t\tkeywords?: string[];\n\t\tcontent?: unknown[];\n\t\tpreviewMediaId?: string | null;\n\t},\n): Promise<ApiResult<Section>> {\n\ttry {\n\t\t// Check if section exists\n\t\tconst existing = await db\n\t\t\t.selectFrom(\"_emdash_sections\")\n\t\t\t.select([\"id\", \"source\"])\n\t\t\t.where(\"slug\", \"=\", slug)\n\t\t\t.executeTakeFirst();\n\n\t\tif (!existing) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"NOT_FOUND\", message: `Section \"${slug}\" not found` },\n\t\t\t};\n\t\t}\n\n\t\t// Validate new slug if changing\n\t\tif (input.slug && input.slug !== slug) {\n\t\t\tif (!SLUG_PATTERN.test(input.slug)) {\n\t\t\t\treturn {\n\t\t\t\t\tsuccess: false,\n\t\t\t\t\terror: {\n\t\t\t\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\t\t\t\tmessage: \"slug must only contain lowercase letters, numbers, and hyphens\",\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t}\n\n\t\t\t// Check if new slug already exists\n\t\t\tconst slugExists = await db\n\t\t\t\t.selectFrom(\"_emdash_sections\")\n\t\t\t\t.select(\"id\")\n\t\t\t\t.where(\"slug\", \"=\", input.slug)\n\t\t\t\t.executeTakeFirst();\n\n\t\t\tif (slugExists) {\n\t\t\t\treturn {\n\t\t\t\t\tsuccess: false,\n\t\t\t\t\terror: {\n\t\t\t\t\t\tcode: \"CONFLICT\",\n\t\t\t\t\t\tmessage: `Section with slug \"${input.slug}\" already exists`,\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\t// Build update object\n\t\tconst updates: Record<string, unknown> = {\n\t\t\tupdated_at: new Date().toISOString(),\n\t\t};\n\n\t\tif (input.slug !== undefined) updates.slug = input.slug;\n\t\tif (input.title !== undefined) updates.title = input.title;\n\t\tif (input.description !== undefined) updates.description = input.description;\n\t\tif (input.keywords !== undefined) updates.keywords = JSON.stringify(input.keywords);\n\t\tif (input.content !== undefined) updates.content = JSON.stringify(input.content);\n\t\tif (input.previewMediaId !== undefined) updates.preview_media_id = input.previewMediaId;\n\n\t\tawait db.updateTable(\"_emdash_sections\").set(updates).where(\"id\", \"=\", existing.id).execute();\n\n\t\tconst section = await getSectionById(existing.id, db);\n\t\tif (!section) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"SECTION_UPDATE_ERROR\", message: \"Failed to fetch updated section\" },\n\t\t\t};\n\t\t}\n\n\t\treturn { success: true, data: section };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"SECTION_UPDATE_ERROR\", message: \"Failed to update section\" },\n\t\t};\n\t}\n}\n\n/**\n * Delete a section by slug\n */\nexport async function handleSectionDelete(\n\tdb: Kysely<Database>,\n\tslug: string,\n): Promise<ApiResult<{ deleted: true }>> {\n\ttry {\n\t\t// Check if section exists and get source\n\t\tconst existing = await db\n\t\t\t.selectFrom(\"_emdash_sections\")\n\t\t\t.select([\"id\", \"source\", \"theme_id\"])\n\t\t\t.where(\"slug\", \"=\", slug)\n\t\t\t.executeTakeFirst();\n\n\t\tif (!existing) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"NOT_FOUND\", message: `Section \"${slug}\" not found` },\n\t\t\t};\n\t\t}\n\n\t\t// Prevent deleting theme sections\n\t\tif (existing.source === \"theme\") {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"FORBIDDEN\",\n\t\t\t\t\tmessage:\n\t\t\t\t\t\t\"Cannot delete theme-provided sections. Edit the section to create a user copy, then delete that.\",\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\tawait db.deleteFrom(\"_emdash_sections\").where(\"id\", \"=\", existing.id).execute();\n\n\t\treturn { success: true, data: { deleted: true } };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"SECTION_DELETE_ERROR\", message: \"Failed to delete section\" },\n\t\t};\n\t}\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"sections-8DEa-dWt.mjs","names":[],"sources":["../src/sections/index.ts","../src/api/handlers/sections.ts"],"sourcesContent":["/**\n * Sections runtime functions\n *\n * Sections are reusable content blocks that can be inserted into any Portable Text field.\n */\n\nimport type { Kysely } from \"kysely\";\n\nimport { encodeCursor, decodeCursor, type FindManyResult } from \"../database/repositories/types.js\";\nimport type { Database } from \"../database/types.js\";\nimport { getDb } from \"../loader.js\";\nimport type { Section, SectionRow, GetSectionsOptions } from \"./types.js\";\n\nexport type {\n\tSection,\n\tSectionSource,\n\tSectionRow,\n\tCreateSectionInput,\n\tUpdateSectionInput,\n\tGetSectionsOptions,\n} from \"./types.js\";\n\n/**\n * Get a section by slug\n *\n * @example\n * ```ts\n * import { getSection } from \"emdash\";\n *\n * const section = await getSection(\"hero-centered\");\n * if (section) {\n * console.log(section.content); // Portable Text array\n * }\n * ```\n */\nexport async function getSection(slug: string): Promise<Section | null> {\n\tconst db = await getDb();\n\treturn getSectionWithDb(slug, db);\n}\n\n/**\n * Get a section by slug (with explicit db)\n *\n * @internal Use `getSection()` in templates. This variant is for admin routes\n * that already have a database handle.\n */\nexport async function getSectionWithDb(\n\tslug: string,\n\tdb: Kysely<Database>,\n): Promise<Section | null> {\n\tconst row = await db\n\t\t.selectFrom(\"_emdash_sections\")\n\t\t.selectAll()\n\t\t.$castTo<SectionRow>()\n\t\t.where(\"slug\", \"=\", slug)\n\t\t.executeTakeFirst();\n\n\tif (!row) {\n\t\treturn null;\n\t}\n\n\treturn rowToSection(row, db);\n}\n\n/**\n * Get a section by ID\n *\n * @internal Primarily for admin use\n */\nexport async function getSectionById(id: string, db: Kysely<Database>): Promise<Section | null> {\n\tconst row = await db\n\t\t.selectFrom(\"_emdash_sections\")\n\t\t.selectAll()\n\t\t.$castTo<SectionRow>()\n\t\t.where(\"id\", \"=\", id)\n\t\t.executeTakeFirst();\n\n\tif (!row) {\n\t\treturn null;\n\t}\n\n\treturn rowToSection(row, db);\n}\n\n/**\n * Get all sections with optional filtering\n *\n * @example\n * ```ts\n * import { getSections } from \"emdash\";\n *\n * // Get all theme-provided sections\n * const themeSections = await getSections({ source: \"theme\" });\n *\n * // Search sections\n * const results = await getSections({ search: \"pricing\" });\n * ```\n */\nexport async function getSections(\n\toptions: GetSectionsOptions = {},\n): Promise<FindManyResult<Section>> {\n\tconst db = await getDb();\n\treturn getSectionsWithDb(db, options);\n}\n\n/**\n * Get all sections with optional filtering (with explicit db)\n *\n * @internal Use `getSections()` in templates. This variant is for admin routes\n * that already have a database handle.\n */\nexport async function getSectionsWithDb(\n\tdb: Kysely<Database>,\n\toptions: GetSectionsOptions = {},\n): Promise<FindManyResult<Section>> {\n\tconst limit = Math.min(Math.max(1, options.limit || 50), 100);\n\n\tlet query = db.selectFrom(\"_emdash_sections\").selectAll();\n\n\t// Filter by source\n\tif (options.source) {\n\t\tquery = query.where(\"source\", \"=\", options.source);\n\t}\n\n\t// Search - search title, description, and keywords\n\tif (options.search) {\n\t\tconst searchTerm = `%${options.search.toLowerCase()}%`;\n\t\tquery = query.where((eb) =>\n\t\t\teb.or([\n\t\t\t\teb(\"title\", \"like\", searchTerm),\n\t\t\t\teb(\"description\", \"like\", searchTerm),\n\t\t\t\teb(\"keywords\", \"like\", searchTerm),\n\t\t\t]),\n\t\t);\n\t}\n\n\t// Order by title ASC, id ASC for stable cursor pagination\n\tquery = query.orderBy(\"title\", \"asc\").orderBy(\"id\", \"asc\");\n\n\t// Cursor-based pagination — throws on invalid cursor.\n\tif (options.cursor) {\n\t\tconst decoded = decodeCursor(options.cursor);\n\t\tquery = query.where((eb) =>\n\t\t\teb.or([\n\t\t\t\teb(\"title\", \">\", decoded.orderValue),\n\t\t\t\teb.and([eb(\"title\", \"=\", decoded.orderValue), eb(\"id\", \">\", decoded.id)]),\n\t\t\t]),\n\t\t);\n\t}\n\n\tquery = query.limit(limit + 1);\n\n\tconst rows = await query.$castTo<SectionRow>().execute();\n\tconst hasMore = rows.length > limit;\n\tconst sliced = rows.slice(0, limit);\n\n\t// Convert rows to sections\n\tconst items = await Promise.all(sliced.map((row) => rowToSection(row, db)));\n\tconst result: FindManyResult<Section> = { items };\n\n\tif (hasMore && items.length > 0) {\n\t\tconst last = items.at(-1)!;\n\t\tresult.nextCursor = encodeCursor(last.title, last.id);\n\t}\n\n\treturn result;\n}\n\n/**\n * Convert a section row to the API type\n */\nasync function rowToSection(row: SectionRow, db: Kysely<Database>): Promise<Section> {\n\t// Parse keywords\n\tlet keywords: string[] = [];\n\tif (row.keywords) {\n\t\ttry {\n\t\t\tkeywords = JSON.parse(row.keywords);\n\t\t} catch {\n\t\t\t// Invalid JSON, ignore\n\t\t}\n\t}\n\n\t// Parse content — stored as JSON array of Portable Text blocks\n\tlet content: Section[\"content\"] = [];\n\tif (row.content) {\n\t\ttry {\n\t\t\tconst parsed: unknown = JSON.parse(row.content);\n\t\t\tif (Array.isArray(parsed)) {\n\t\t\t\t// DB stores serialized PortableTextBlock[]; trust the schema\n\t\t\t\tcontent = parsed;\n\t\t\t}\n\t\t} catch {\n\t\t\t// Invalid JSON, ignore\n\t\t}\n\t}\n\n\t// Get preview URL from media (if present)\n\tlet previewUrl: string | undefined;\n\tif (row.preview_media_id) {\n\t\tconst media = await db\n\t\t\t.selectFrom(\"media\")\n\t\t\t.select(\"storage_key\")\n\t\t\t.where(\"id\", \"=\", row.preview_media_id)\n\t\t\t.executeTakeFirst();\n\n\t\tif (media) {\n\t\t\tpreviewUrl = `/_emdash/media/${media.storage_key}`;\n\t\t}\n\t}\n\n\treturn {\n\t\tid: row.id,\n\t\tslug: row.slug,\n\t\ttitle: row.title,\n\t\tdescription: row.description ?? undefined,\n\t\tkeywords,\n\t\tcontent,\n\t\tpreviewUrl,\n\t\tsource: row.source,\n\t\tthemeId: row.theme_id ?? undefined,\n\t\tcreatedAt: row.created_at,\n\t\tupdatedAt: row.updated_at,\n\t};\n}\n","/**\n * Section CRUD handlers\n */\n\nimport type { Kysely } from \"kysely\";\nimport { ulid } from \"ulidx\";\n\nimport { InvalidCursorError } from \"../../database/repositories/types.js\";\nimport type { FindManyResult } from \"../../database/repositories/types.js\";\nimport type { Database } from \"../../database/types.js\";\nimport {\n\tgetSectionById,\n\tgetSectionWithDb,\n\tgetSectionsWithDb,\n\ttype Section,\n\ttype GetSectionsOptions,\n} from \"../../sections/index.js\";\nimport type { ApiResult } from \"../types.js\";\n\nconst SLUG_PATTERN = /^[a-z0-9-]+$/;\n\nexport type SectionListResponse = FindManyResult<Section>;\n\n/**\n * List sections with optional filters\n */\nexport async function handleSectionList(\n\tdb: Kysely<Database>,\n\tparams: GetSectionsOptions,\n): Promise<ApiResult<SectionListResponse>> {\n\ttry {\n\t\tconst result = await getSectionsWithDb(db, {\n\t\t\tsource: params.source,\n\t\t\tsearch: params.search,\n\t\t\tlimit: params.limit,\n\t\t\tcursor: params.cursor,\n\t\t});\n\n\t\treturn { success: true, data: result };\n\t} catch (error) {\n\t\tif (error instanceof InvalidCursorError) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"INVALID_CURSOR\", message: error.message },\n\t\t\t};\n\t\t}\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"SECTION_LIST_ERROR\", message: \"Failed to fetch sections\" },\n\t\t};\n\t}\n}\n\n/**\n * Create a section\n */\nexport async function handleSectionCreate(\n\tdb: Kysely<Database>,\n\tinput: {\n\t\tslug: string;\n\t\ttitle: string;\n\t\tdescription?: string;\n\t\tkeywords?: string[];\n\t\tcontent: unknown[];\n\t\tpreviewMediaId?: string;\n\t\tsource?: string;\n\t\tthemeId?: string;\n\t},\n): Promise<ApiResult<Section>> {\n\ttry {\n\t\t// Validate slug format\n\t\tif (!SLUG_PATTERN.test(input.slug)) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\t\t\tmessage: \"slug must only contain lowercase letters, numbers, and hyphens\",\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\t// Check if slug already exists\n\t\tconst existing = await db\n\t\t\t.selectFrom(\"_emdash_sections\")\n\t\t\t.select(\"id\")\n\t\t\t.where(\"slug\", \"=\", input.slug)\n\t\t\t.executeTakeFirst();\n\n\t\tif (existing) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"CONFLICT\",\n\t\t\t\t\tmessage: `Section with slug \"${input.slug}\" already exists`,\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\tconst id = ulid();\n\t\tconst now = new Date().toISOString();\n\n\t\tawait db\n\t\t\t.insertInto(\"_emdash_sections\")\n\t\t\t.values({\n\t\t\t\tid,\n\t\t\t\tslug: input.slug,\n\t\t\t\ttitle: input.title,\n\t\t\t\tdescription: input.description ?? null,\n\t\t\t\tkeywords: input.keywords ? JSON.stringify(input.keywords) : null,\n\t\t\t\tcontent: JSON.stringify(input.content),\n\t\t\t\tpreview_media_id: input.previewMediaId ?? null,\n\t\t\t\tsource: input.source ?? \"user\",\n\t\t\t\ttheme_id: input.themeId ?? null,\n\t\t\t\tcreated_at: now,\n\t\t\t\tupdated_at: now,\n\t\t\t})\n\t\t\t.execute();\n\n\t\tconst section = await getSectionById(id, db);\n\t\tif (!section) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"SECTION_CREATE_ERROR\", message: \"Failed to fetch created section\" },\n\t\t\t};\n\t\t}\n\n\t\treturn { success: true, data: section };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"SECTION_CREATE_ERROR\", message: \"Failed to create section\" },\n\t\t};\n\t}\n}\n\n/**\n * Get a section by slug\n */\nexport async function handleSectionGet(\n\tdb: Kysely<Database>,\n\tslug: string,\n): Promise<ApiResult<Section>> {\n\ttry {\n\t\tconst section = await getSectionWithDb(slug, db);\n\n\t\tif (!section) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"NOT_FOUND\", message: `Section \"${slug}\" not found` },\n\t\t\t};\n\t\t}\n\n\t\treturn { success: true, data: section };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"SECTION_GET_ERROR\", message: \"Failed to fetch section\" },\n\t\t};\n\t}\n}\n\n/**\n * Update a section by slug\n */\nexport async function handleSectionUpdate(\n\tdb: Kysely<Database>,\n\tslug: string,\n\tinput: {\n\t\tslug?: string;\n\t\ttitle?: string;\n\t\tdescription?: string;\n\t\tkeywords?: string[];\n\t\tcontent?: unknown[];\n\t\tpreviewMediaId?: string | null;\n\t},\n): Promise<ApiResult<Section>> {\n\ttry {\n\t\t// Check if section exists\n\t\tconst existing = await db\n\t\t\t.selectFrom(\"_emdash_sections\")\n\t\t\t.select([\"id\", \"source\"])\n\t\t\t.where(\"slug\", \"=\", slug)\n\t\t\t.executeTakeFirst();\n\n\t\tif (!existing) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"NOT_FOUND\", message: `Section \"${slug}\" not found` },\n\t\t\t};\n\t\t}\n\n\t\t// Validate new slug if changing\n\t\tif (input.slug && input.slug !== slug) {\n\t\t\tif (!SLUG_PATTERN.test(input.slug)) {\n\t\t\t\treturn {\n\t\t\t\t\tsuccess: false,\n\t\t\t\t\terror: {\n\t\t\t\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\t\t\t\tmessage: \"slug must only contain lowercase letters, numbers, and hyphens\",\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t}\n\n\t\t\t// Check if new slug already exists\n\t\t\tconst slugExists = await db\n\t\t\t\t.selectFrom(\"_emdash_sections\")\n\t\t\t\t.select(\"id\")\n\t\t\t\t.where(\"slug\", \"=\", input.slug)\n\t\t\t\t.executeTakeFirst();\n\n\t\t\tif (slugExists) {\n\t\t\t\treturn {\n\t\t\t\t\tsuccess: false,\n\t\t\t\t\terror: {\n\t\t\t\t\t\tcode: \"CONFLICT\",\n\t\t\t\t\t\tmessage: `Section with slug \"${input.slug}\" already exists`,\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\t// Build update object\n\t\tconst updates: Record<string, unknown> = {\n\t\t\tupdated_at: new Date().toISOString(),\n\t\t};\n\n\t\tif (input.slug !== undefined) updates.slug = input.slug;\n\t\tif (input.title !== undefined) updates.title = input.title;\n\t\tif (input.description !== undefined) updates.description = input.description;\n\t\tif (input.keywords !== undefined) updates.keywords = JSON.stringify(input.keywords);\n\t\tif (input.content !== undefined) updates.content = JSON.stringify(input.content);\n\t\tif (input.previewMediaId !== undefined) updates.preview_media_id = input.previewMediaId;\n\n\t\tawait db.updateTable(\"_emdash_sections\").set(updates).where(\"id\", \"=\", existing.id).execute();\n\n\t\tconst section = await getSectionById(existing.id, db);\n\t\tif (!section) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"SECTION_UPDATE_ERROR\", message: \"Failed to fetch updated section\" },\n\t\t\t};\n\t\t}\n\n\t\treturn { success: true, data: section };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"SECTION_UPDATE_ERROR\", message: \"Failed to update section\" },\n\t\t};\n\t}\n}\n\n/**\n * Delete a section by slug\n */\nexport async function handleSectionDelete(\n\tdb: Kysely<Database>,\n\tslug: string,\n): Promise<ApiResult<{ deleted: true }>> {\n\ttry {\n\t\t// Check if section exists and get source\n\t\tconst existing = await db\n\t\t\t.selectFrom(\"_emdash_sections\")\n\t\t\t.select([\"id\", \"source\", \"theme_id\"])\n\t\t\t.where(\"slug\", \"=\", slug)\n\t\t\t.executeTakeFirst();\n\n\t\tif (!existing) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"NOT_FOUND\", message: `Section \"${slug}\" not found` },\n\t\t\t};\n\t\t}\n\n\t\t// Prevent deleting theme sections\n\t\tif (existing.source === \"theme\") {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"FORBIDDEN\",\n\t\t\t\t\tmessage:\n\t\t\t\t\t\t\"Cannot delete theme-provided sections. Edit the section to create a user copy, then delete that.\",\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\tawait db.deleteFrom(\"_emdash_sections\").where(\"id\", \"=\", existing.id).execute();\n\n\t\treturn { success: true, data: { deleted: true } };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"SECTION_DELETE_ERROR\", message: \"Failed to delete section\" },\n\t\t};\n\t}\n}\n"],"mappings":"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"}
|
package/dist/seed/index.mjs
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
|
-
import "../dialect-helpers-
|
|
2
|
-
import "../content-
|
|
1
|
+
import "../dialect-helpers-DRI5pyY3.mjs";
|
|
2
|
+
import "../content-C7aJ7keg.mjs";
|
|
3
3
|
import "../base64-CqR-7kqF.mjs";
|
|
4
|
-
import "../types-
|
|
5
|
-
import "../media-
|
|
6
|
-
import "../taxonomy-
|
|
4
|
+
import "../types-CfyYQ7eY.mjs";
|
|
5
|
+
import "../media-Cyz5BhSN.mjs";
|
|
6
|
+
import "../taxonomy-Db5xwphL.mjs";
|
|
7
7
|
import "../options-BL4X94qY.mjs";
|
|
8
|
-
import "../redirect-
|
|
8
|
+
import "../redirect-C-FeA4j9.mjs";
|
|
9
9
|
import "../request-cache-BYMs-BGX.mjs";
|
|
10
10
|
import "../byline-registry-CxK5g559.mjs";
|
|
11
|
-
import "../byline-
|
|
12
|
-
import "../fts-manager-
|
|
13
|
-
import "../registry-
|
|
14
|
-
import "../loader-
|
|
15
|
-
import "../settings-
|
|
11
|
+
import "../byline-CAhk4FrG.mjs";
|
|
12
|
+
import "../fts-manager-XpDfbIKo.mjs";
|
|
13
|
+
import "../registry-C-T_PWgp.mjs";
|
|
14
|
+
import "../loader-BxyvbrZP.mjs";
|
|
15
|
+
import "../settings-Jro4YcUb.mjs";
|
|
16
16
|
import "../ssrf-BsVGIE0Z.mjs";
|
|
17
17
|
import "../ssrf-BvgVcfNQ.mjs";
|
|
18
|
-
import { t as validateSeed } from "../validate-
|
|
19
|
-
import { t as applySeed } from "../apply-
|
|
18
|
+
import { t as validateSeed } from "../validate-DWmnRg6E.mjs";
|
|
19
|
+
import { t as applySeed } from "../apply-BrVqULFe.mjs";
|
|
20
20
|
import { t as defaultSeed } from "../default-xLFNSsZ9.mjs";
|
|
21
|
-
import { n as loadUserSeed, t as loadSeed } from "../load-
|
|
21
|
+
import { n as loadUserSeed, t as loadSeed } from "../load-CF5oETkh.mjs";
|
|
22
22
|
|
|
23
23
|
export { applySeed, defaultSeed, loadSeed, loadUserSeed, validateSeed };
|
package/dist/seo/index.mjs
CHANGED
|
@@ -60,6 +60,7 @@ function getContentSeo(content) {
|
|
|
60
60
|
*/
|
|
61
61
|
function buildMediaUrl(imageRef, siteUrl) {
|
|
62
62
|
if (ABSOLUTE_URL_RE.test(imageRef)) return imageRef;
|
|
63
|
+
if (imageRef.startsWith("/")) return siteUrl ? `${siteUrl.replace(TRAILING_SLASH_RE, "")}${imageRef}` : imageRef;
|
|
63
64
|
const mediaPath = `/_emdash/api/media/file/${imageRef}`;
|
|
64
65
|
if (siteUrl) return `${siteUrl.replace(TRAILING_SLASH_RE, "")}${mediaPath}`;
|
|
65
66
|
return mediaPath;
|
package/dist/seo/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/seo/index.ts"],"sourcesContent":["/**\n * SEO Helpers\n *\n * Public API functions for generating SEO meta tags in Astro templates.\n *\n * @example\n * ```astro\n * ---\n * import { getEmDashEntry } from \"emdash\";\n * import { getSeoMeta } from \"emdash/seo\";\n *\n * const post = await getEmDashEntry(\"posts\", Astro.params.slug);\n * const meta = await getSeoMeta(post, {\n * siteTitle: \"My Blog\",\n * siteUrl: Astro.url.origin,\n * });\n * ---\n * <html>\n * <head>\n * <title>{meta.title}</title>\n * <meta name=\"description\" content={meta.description} />\n * <meta property=\"og:title\" content={meta.ogTitle} />\n * <meta property=\"og:description\" content={meta.ogDescription} />\n * {meta.ogImage && <meta property=\"og:image\" content={meta.ogImage} />}\n * <link rel=\"canonical\" href={meta.canonical} />\n * {meta.robots && <meta name=\"robots\" content={meta.robots} />}\n * </head>\n * </html>\n * ```\n */\n\nimport type { ContentSeo } from \"../database/repositories/types.js\";\n\nconst TRAILING_SLASH_RE = /\\/$/;\nconst ABSOLUTE_URL_RE = /^https?:\\/\\//i;\n\n/**\n * Content input for SEO functions.\n * Accepts both ContentEntry<T> (from query functions) and ContentItem (internal).\n */\nexport interface SeoContentInput<T = Record<string, unknown>> {\n\t/** Content data object */\n\tdata: T & {\n\t\ttitle?: unknown;\n\t\texcerpt?: unknown;\n\t\tseo?: ContentSeo;\n\t};\n\t/** SEO metadata (legacy location, prefer data.seo) */\n\tseo?: ContentSeo;\n}\n\n/** Resolved SEO meta tags ready for use in templates */\nexport interface SeoMeta {\n\t/** Full <title> tag content (e.g., \"Post Title | Site Name\") */\n\ttitle: string;\n\t/** Meta description */\n\tdescription: string | null;\n\t/** OG title (same as title by default) */\n\togTitle: string;\n\t/** OG description */\n\togDescription: string | null;\n\t/** OG image URL (absolute) */\n\togImage: string | null;\n\t/** Canonical URL */\n\tcanonical: string | null;\n\t/** Robots directive (e.g., \"noindex, nofollow\") or null if default */\n\trobots: string | null;\n}\n\n/** Options for generating SEO meta from a content item */\nexport interface SeoMetaOptions {\n\t/** Site title for the suffix (e.g., \"My Blog\") */\n\tsiteTitle?: string;\n\t/** Site URL origin for building absolute URLs (e.g., \"https://example.com\") */\n\tsiteUrl?: string;\n\t/** Title separator between page title and site title */\n\ttitleSeparator?: string;\n\t/** Path to this content (e.g., \"/posts/my-post\") for canonical fallback */\n\tpath?: string;\n\t/** Default OG image URL if content has none */\n\tdefaultOgImage?: string;\n}\n\n/**\n * Generate resolved SEO meta tags from a content item.\n *\n * Uses the content item's SEO fields, falling back to content data\n * (title from `data.title`, description from `data.excerpt`).\n *\n * @param content - The content item (from getEmDashEntry, etc.)\n * @param options - Configuration for title construction, canonical URLs, etc.\n * @returns Resolved meta tags ready for template use\n */\nexport function getSeoMeta<T>(content: SeoContentInput<T>, options: SeoMetaOptions = {}): SeoMeta {\n\tconst { siteTitle, siteUrl, path, defaultOgImage } = options;\n\tconst separator = options.titleSeparator || \" | \";\n\t// SEO can be in content.seo (ContentItem) or content.data.seo (ContentEntry)\n\tconst seo = content.seo ??\n\t\tcontent.data.seo ?? {\n\t\t\ttitle: null,\n\t\t\tdescription: null,\n\t\t\timage: null,\n\t\t\tcanonical: null,\n\t\t\tnoIndex: false,\n\t\t};\n\n\t// Title: SEO title > content title > fallback\n\tconst pageTitle =\n\t\tseo.title || (typeof content.data.title === \"string\" ? content.data.title : null) || \"\";\n\n\tconst fullTitle = siteTitle && pageTitle ? `${pageTitle}${separator}${siteTitle}` : pageTitle;\n\n\t// Description: SEO description > excerpt\n\tconst description =\n\t\tseo.description ||\n\t\t(typeof content.data.excerpt === \"string\" ? content.data.excerpt : null) ||\n\t\tnull;\n\n\t// OG image: SEO image > default\n\tconst ogImage = seo.image ? buildMediaUrl(seo.image, siteUrl) : (defaultOgImage ?? null);\n\n\t// Canonical: explicit > path-based > null\n\tlet canonical: string | null = null;\n\tif (seo.canonical) {\n\t\t// Ensure relative canonical paths get a leading slash so we don't\n\t\t// produce \"https://example.composts/x\" when joined with siteUrl\n\t\tif (siteUrl && !seo.canonical.startsWith(\"/\") && !ABSOLUTE_URL_RE.test(seo.canonical)) {\n\t\t\tcanonical = `${siteUrl.replace(TRAILING_SLASH_RE, \"\")}/${seo.canonical}`;\n\t\t} else {\n\t\t\tcanonical = seo.canonical;\n\t\t}\n\t} else if (siteUrl && path) {\n\t\tconst safePath = path.startsWith(\"/\") ? path : `/${path}`;\n\t\tcanonical = `${siteUrl.replace(TRAILING_SLASH_RE, \"\")}${safePath}`;\n\t}\n\n\t// Robots\n\tconst robots = seo.noIndex ? \"noindex, nofollow\" : null;\n\n\treturn {\n\t\ttitle: fullTitle,\n\t\tdescription,\n\t\togTitle: pageTitle || fullTitle,\n\t\togDescription: description,\n\t\togImage,\n\t\tcanonical,\n\t\trobots,\n\t};\n}\n\n/**\n * Extract SEO data from a content item.\n *\n * Convenience accessor for the raw SEO fields without template resolution.\n *\n * @param content - The content item\n * @returns The content's SEO fields\n */\nexport function getContentSeo<T>(content: SeoContentInput<T>): ContentSeo | undefined {\n\treturn content.seo ?? content.data.seo;\n}\n\n/**\n * Build a media URL from a media reference ID.\n * If it's already an absolute URL, return as-is.\n */\nfunction buildMediaUrl(imageRef: string, siteUrl?: string): string {\n\t// If already an absolute URL, return as-is\n\tif (ABSOLUTE_URL_RE.test(imageRef)) {\n\t\treturn imageRef;\n\t}\n\n\t//
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/seo/index.ts"],"sourcesContent":["/**\n * SEO Helpers\n *\n * Public API functions for generating SEO meta tags in Astro templates.\n *\n * @example\n * ```astro\n * ---\n * import { getEmDashEntry } from \"emdash\";\n * import { getSeoMeta } from \"emdash/seo\";\n *\n * const post = await getEmDashEntry(\"posts\", Astro.params.slug);\n * const meta = await getSeoMeta(post, {\n * siteTitle: \"My Blog\",\n * siteUrl: Astro.url.origin,\n * });\n * ---\n * <html>\n * <head>\n * <title>{meta.title}</title>\n * <meta name=\"description\" content={meta.description} />\n * <meta property=\"og:title\" content={meta.ogTitle} />\n * <meta property=\"og:description\" content={meta.ogDescription} />\n * {meta.ogImage && <meta property=\"og:image\" content={meta.ogImage} />}\n * <link rel=\"canonical\" href={meta.canonical} />\n * {meta.robots && <meta name=\"robots\" content={meta.robots} />}\n * </head>\n * </html>\n * ```\n */\n\nimport type { ContentSeo } from \"../database/repositories/types.js\";\n\nconst TRAILING_SLASH_RE = /\\/$/;\nconst ABSOLUTE_URL_RE = /^https?:\\/\\//i;\n\n/**\n * Content input for SEO functions.\n * Accepts both ContentEntry<T> (from query functions) and ContentItem (internal).\n */\nexport interface SeoContentInput<T = Record<string, unknown>> {\n\t/** Content data object */\n\tdata: T & {\n\t\ttitle?: unknown;\n\t\texcerpt?: unknown;\n\t\tseo?: ContentSeo;\n\t};\n\t/** SEO metadata (legacy location, prefer data.seo) */\n\tseo?: ContentSeo;\n}\n\n/** Resolved SEO meta tags ready for use in templates */\nexport interface SeoMeta {\n\t/** Full <title> tag content (e.g., \"Post Title | Site Name\") */\n\ttitle: string;\n\t/** Meta description */\n\tdescription: string | null;\n\t/** OG title (same as title by default) */\n\togTitle: string;\n\t/** OG description */\n\togDescription: string | null;\n\t/** OG image URL (absolute) */\n\togImage: string | null;\n\t/** Canonical URL */\n\tcanonical: string | null;\n\t/** Robots directive (e.g., \"noindex, nofollow\") or null if default */\n\trobots: string | null;\n}\n\n/** Options for generating SEO meta from a content item */\nexport interface SeoMetaOptions {\n\t/** Site title for the suffix (e.g., \"My Blog\") */\n\tsiteTitle?: string;\n\t/** Site URL origin for building absolute URLs (e.g., \"https://example.com\") */\n\tsiteUrl?: string;\n\t/** Title separator between page title and site title */\n\ttitleSeparator?: string;\n\t/** Path to this content (e.g., \"/posts/my-post\") for canonical fallback */\n\tpath?: string;\n\t/** Default OG image URL if content has none */\n\tdefaultOgImage?: string;\n}\n\n/**\n * Generate resolved SEO meta tags from a content item.\n *\n * Uses the content item's SEO fields, falling back to content data\n * (title from `data.title`, description from `data.excerpt`).\n *\n * @param content - The content item (from getEmDashEntry, etc.)\n * @param options - Configuration for title construction, canonical URLs, etc.\n * @returns Resolved meta tags ready for template use\n */\nexport function getSeoMeta<T>(content: SeoContentInput<T>, options: SeoMetaOptions = {}): SeoMeta {\n\tconst { siteTitle, siteUrl, path, defaultOgImage } = options;\n\tconst separator = options.titleSeparator || \" | \";\n\t// SEO can be in content.seo (ContentItem) or content.data.seo (ContentEntry)\n\tconst seo = content.seo ??\n\t\tcontent.data.seo ?? {\n\t\t\ttitle: null,\n\t\t\tdescription: null,\n\t\t\timage: null,\n\t\t\tcanonical: null,\n\t\t\tnoIndex: false,\n\t\t};\n\n\t// Title: SEO title > content title > fallback\n\tconst pageTitle =\n\t\tseo.title || (typeof content.data.title === \"string\" ? content.data.title : null) || \"\";\n\n\tconst fullTitle = siteTitle && pageTitle ? `${pageTitle}${separator}${siteTitle}` : pageTitle;\n\n\t// Description: SEO description > excerpt\n\tconst description =\n\t\tseo.description ||\n\t\t(typeof content.data.excerpt === \"string\" ? content.data.excerpt : null) ||\n\t\tnull;\n\n\t// OG image: SEO image > default\n\tconst ogImage = seo.image ? buildMediaUrl(seo.image, siteUrl) : (defaultOgImage ?? null);\n\n\t// Canonical: explicit > path-based > null\n\tlet canonical: string | null = null;\n\tif (seo.canonical) {\n\t\t// Ensure relative canonical paths get a leading slash so we don't\n\t\t// produce \"https://example.composts/x\" when joined with siteUrl\n\t\tif (siteUrl && !seo.canonical.startsWith(\"/\") && !ABSOLUTE_URL_RE.test(seo.canonical)) {\n\t\t\tcanonical = `${siteUrl.replace(TRAILING_SLASH_RE, \"\")}/${seo.canonical}`;\n\t\t} else {\n\t\t\tcanonical = seo.canonical;\n\t\t}\n\t} else if (siteUrl && path) {\n\t\tconst safePath = path.startsWith(\"/\") ? path : `/${path}`;\n\t\tcanonical = `${siteUrl.replace(TRAILING_SLASH_RE, \"\")}${safePath}`;\n\t}\n\n\t// Robots\n\tconst robots = seo.noIndex ? \"noindex, nofollow\" : null;\n\n\treturn {\n\t\ttitle: fullTitle,\n\t\tdescription,\n\t\togTitle: pageTitle || fullTitle,\n\t\togDescription: description,\n\t\togImage,\n\t\tcanonical,\n\t\trobots,\n\t};\n}\n\n/**\n * Extract SEO data from a content item.\n *\n * Convenience accessor for the raw SEO fields without template resolution.\n *\n * @param content - The content item\n * @returns The content's SEO fields\n */\nexport function getContentSeo<T>(content: SeoContentInput<T>): ContentSeo | undefined {\n\treturn content.seo ?? content.data.seo;\n}\n\n/**\n * Build a media URL from a media reference ID.\n * If it's already an absolute URL, return as-is.\n */\nfunction buildMediaUrl(imageRef: string, siteUrl?: string): string {\n\t// If already an absolute URL, return as-is\n\tif (ABSOLUTE_URL_RE.test(imageRef)) {\n\t\treturn imageRef;\n\t}\n\n\t// Root-relative path — the CMS SEO panel stores seo_image as\n\t// \"/_emdash/api/media/file/01KS....svg\" (already includes the API\n\t// prefix). Without this branch we'd re-prefix and produce\n\t// \"${siteUrl}/_emdash/api/media/file//_emdash/api/media/file/<id>\"\n\t// which 404s and breaks <meta property=\"og:image\">.\n\tif (imageRef.startsWith(\"/\")) {\n\t\treturn siteUrl ? `${siteUrl.replace(TRAILING_SLASH_RE, \"\")}${imageRef}` : imageRef;\n\t}\n\n\t// Bare media_id — build the full media API path\n\tconst mediaPath = `/_emdash/api/media/file/${imageRef}`;\n\tif (siteUrl) {\n\t\treturn `${siteUrl.replace(TRAILING_SLASH_RE, \"\")}${mediaPath}`;\n\t}\n\treturn mediaPath;\n}\n"],"mappings":";AAiCA,MAAM,oBAAoB;AAC1B,MAAM,kBAAkB;;;;;;;;;;;AA2DxB,SAAgB,WAAc,SAA6B,UAA0B,EAAE,EAAW;CACjG,MAAM,EAAE,WAAW,SAAS,MAAM,mBAAmB;CACrD,MAAM,YAAY,QAAQ,kBAAkB;CAE5C,MAAM,MAAM,QAAQ,OACnB,QAAQ,KAAK,OAAO;EACnB,OAAO;EACP,aAAa;EACb,OAAO;EACP,WAAW;EACX,SAAS;EACT;CAGF,MAAM,YACL,IAAI,UAAU,OAAO,QAAQ,KAAK,UAAU,WAAW,QAAQ,KAAK,QAAQ,SAAS;CAEtF,MAAM,YAAY,aAAa,YAAY,GAAG,YAAY,YAAY,cAAc;CAGpF,MAAM,cACL,IAAI,gBACH,OAAO,QAAQ,KAAK,YAAY,WAAW,QAAQ,KAAK,UAAU,SACnE;CAGD,MAAM,UAAU,IAAI,QAAQ,cAAc,IAAI,OAAO,QAAQ,GAAI,kBAAkB;CAGnF,IAAI,YAA2B;AAC/B,KAAI,IAAI,UAGP,KAAI,WAAW,CAAC,IAAI,UAAU,WAAW,IAAI,IAAI,CAAC,gBAAgB,KAAK,IAAI,UAAU,CACpF,aAAY,GAAG,QAAQ,QAAQ,mBAAmB,GAAG,CAAC,GAAG,IAAI;KAE7D,aAAY,IAAI;UAEP,WAAW,MAAM;EAC3B,MAAM,WAAW,KAAK,WAAW,IAAI,GAAG,OAAO,IAAI;AACnD,cAAY,GAAG,QAAQ,QAAQ,mBAAmB,GAAG,GAAG;;CAIzD,MAAM,SAAS,IAAI,UAAU,sBAAsB;AAEnD,QAAO;EACN,OAAO;EACP;EACA,SAAS,aAAa;EACtB,eAAe;EACf;EACA;EACA;EACA;;;;;;;;;;AAWF,SAAgB,cAAiB,SAAqD;AACrF,QAAO,QAAQ,OAAO,QAAQ,KAAK;;;;;;AAOpC,SAAS,cAAc,UAAkB,SAA0B;AAElE,KAAI,gBAAgB,KAAK,SAAS,CACjC,QAAO;AAQR,KAAI,SAAS,WAAW,IAAI,CAC3B,QAAO,UAAU,GAAG,QAAQ,QAAQ,mBAAmB,GAAG,GAAG,aAAa;CAI3E,MAAM,YAAY,2BAA2B;AAC7C,KAAI,QACH,QAAO,GAAG,QAAQ,QAAQ,mBAAmB,GAAG,GAAG;AAEpD,QAAO"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { n as chunks, t as SQL_BATCH_SIZE } from "./chunks-
|
|
1
|
+
import { n as chunks, t as SQL_BATCH_SIZE } from "./chunks-DnnHlRG3.mjs";
|
|
2
2
|
import { sql } from "kysely";
|
|
3
3
|
|
|
4
4
|
//#region src/database/repositories/seo.ts
|
|
@@ -127,4 +127,4 @@ var SeoRepository = class {
|
|
|
127
127
|
|
|
128
128
|
//#endregion
|
|
129
129
|
export { SeoRepository as t };
|
|
130
|
-
//# sourceMappingURL=seo-
|
|
130
|
+
//# sourceMappingURL=seo-CKr7pLfA.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"seo-
|
|
1
|
+
{"version":3,"file":"seo-CKr7pLfA.mjs","names":[],"sources":["../src/database/repositories/seo.ts"],"sourcesContent":["import { sql, type Kysely } from \"kysely\";\n\nimport { chunks, SQL_BATCH_SIZE } from \"../../utils/chunks.js\";\nimport type { Database } from \"../types.js\";\nimport type { ContentSeo, ContentSeoInput } from \"./types.js\";\n\n/** Default SEO values for content without an explicit SEO row */\nconst SEO_DEFAULTS: ContentSeo = {\n\ttitle: null,\n\tdescription: null,\n\timage: null,\n\tcanonical: null,\n\tnoIndex: false,\n};\n\n/**\n * Returns true if the input has at least one explicitly-set SEO field.\n * Used to skip no-op upserts when callers pass `{ seo: {} }`.\n */\nfunction hasAnyField(input: ContentSeoInput): boolean {\n\treturn (\n\t\tinput.title !== undefined ||\n\t\tinput.description !== undefined ||\n\t\tinput.image !== undefined ||\n\t\tinput.canonical !== undefined ||\n\t\tinput.noIndex !== undefined\n\t);\n}\n\n/**\n * Repository for SEO metadata stored in `_emdash_seo`.\n *\n * SEO data lives in a separate table keyed by (collection, content_id).\n * Only collections with `has_seo = 1` should use this — callers are\n * responsible for checking the flag before reading/writing.\n */\nexport class SeoRepository {\n\tconstructor(private db: Kysely<Database>) {}\n\n\t/**\n\t * Check whether a collection has SEO enabled (`has_seo = 1`).\n\t * Returns `false` if the collection does not exist.\n\t */\n\tasync isEnabled(collection: string): Promise<boolean> {\n\t\tconst row = await this.db\n\t\t\t.selectFrom(\"_emdash_collections\")\n\t\t\t.select(\"has_seo\")\n\t\t\t.where(\"slug\", \"=\", collection)\n\t\t\t.executeTakeFirst();\n\t\treturn row?.has_seo === 1;\n\t}\n\n\t/**\n\t * Get SEO data for a content item. Returns null defaults if no row exists.\n\t */\n\tasync get(collection: string, contentId: string): Promise<ContentSeo> {\n\t\tconst row = await this.db\n\t\t\t.selectFrom(\"_emdash_seo\")\n\t\t\t.selectAll()\n\t\t\t.where(\"collection\", \"=\", collection)\n\t\t\t.where(\"content_id\", \"=\", contentId)\n\t\t\t.executeTakeFirst();\n\n\t\tif (!row) {\n\t\t\treturn { ...SEO_DEFAULTS };\n\t\t}\n\n\t\treturn {\n\t\t\ttitle: row.seo_title ?? null,\n\t\t\tdescription: row.seo_description ?? null,\n\t\t\timage: row.seo_image ?? null,\n\t\t\tcanonical: row.seo_canonical ?? null,\n\t\t\tnoIndex: row.seo_no_index === 1,\n\t\t};\n\t}\n\n\t/**\n\t * Get SEO data for multiple content items.\n\t * Returns a Map keyed by content_id. Items without SEO rows get defaults.\n\t *\n\t * Chunks the `content_id IN (…)` clause so the total bound-parameter count\n\t * per statement (ids + the `collection = ?` filter) stays within Cloudflare\n\t * D1's 100-variable limit regardless of how many content items are passed.\n\t */\n\tasync getMany(collection: string, contentIds: string[]): Promise<Map<string, ContentSeo>> {\n\t\tconst result = new Map<string, ContentSeo>();\n\n\t\tif (contentIds.length === 0) return result;\n\n\t\t// Pre-fill with defaults so every input id has an entry even if no row exists.\n\t\tfor (const id of contentIds) {\n\t\t\tresult.set(id, { ...SEO_DEFAULTS });\n\t\t}\n\n\t\tconst uniqueContentIds = [...new Set(contentIds)];\n\t\tfor (const chunk of chunks(uniqueContentIds, SQL_BATCH_SIZE)) {\n\t\t\tconst rows = await this.db\n\t\t\t\t.selectFrom(\"_emdash_seo\")\n\t\t\t\t.selectAll()\n\t\t\t\t.where(\"collection\", \"=\", collection)\n\t\t\t\t.where(\"content_id\", \"in\", chunk)\n\t\t\t\t.execute();\n\n\t\t\tfor (const row of rows) {\n\t\t\t\tresult.set(row.content_id, {\n\t\t\t\t\ttitle: row.seo_title ?? null,\n\t\t\t\t\tdescription: row.seo_description ?? null,\n\t\t\t\t\timage: row.seo_image ?? null,\n\t\t\t\t\tcanonical: row.seo_canonical ?? null,\n\t\t\t\t\tnoIndex: row.seo_no_index === 1,\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\n\t\treturn result;\n\t}\n\n\t/**\n\t * Upsert SEO data for a content item using INSERT ON CONFLICT DO UPDATE\n\t * for atomicity. Skips no-op writes when input has no fields set.\n\t */\n\tasync upsert(collection: string, contentId: string, input: ContentSeoInput): Promise<ContentSeo> {\n\t\t// Skip no-op: empty input (e.g., `{ seo: {} }` from form libs)\n\t\tif (!hasAnyField(input)) {\n\t\t\treturn this.get(collection, contentId);\n\t\t}\n\n\t\tconst now = new Date().toISOString();\n\n\t\t// Use INSERT ON CONFLICT for atomic upsert — avoids TOCTOU race\n\t\t// where two concurrent requests both see \"no row\" and both try INSERT.\n\t\t//\n\t\t// On conflict, we use COALESCE(excluded.col, current.col) so that\n\t\t// only explicitly-provided fields overwrite existing values.\n\t\tawait sql`\n\t\t\tINSERT INTO _emdash_seo (\n\t\t\t\tcollection, content_id,\n\t\t\t\tseo_title, seo_description, seo_image, seo_canonical, seo_no_index,\n\t\t\t\tcreated_at, updated_at\n\t\t\t) VALUES (\n\t\t\t\t${collection}, ${contentId},\n\t\t\t\t${input.title ?? null}, ${input.description ?? null},\n\t\t\t\t${input.image ?? null}, ${input.canonical ?? null},\n\t\t\t\t${input.noIndex ? 1 : 0},\n\t\t\t\t${now}, ${now}\n\t\t\t)\n\t\t\tON CONFLICT (collection, content_id) DO UPDATE SET\n\t\t\t\tseo_title = ${input.title !== undefined ? sql`${input.title}` : sql`_emdash_seo.seo_title`},\n\t\t\t\tseo_description = ${input.description !== undefined ? sql`${input.description}` : sql`_emdash_seo.seo_description`},\n\t\t\t\tseo_image = ${input.image !== undefined ? sql`${input.image}` : sql`_emdash_seo.seo_image`},\n\t\t\t\tseo_canonical = ${input.canonical !== undefined ? sql`${input.canonical}` : sql`_emdash_seo.seo_canonical`},\n\t\t\t\tseo_no_index = ${input.noIndex !== undefined ? sql`${input.noIndex ? 1 : 0}` : sql`_emdash_seo.seo_no_index`},\n\t\t\t\tupdated_at = ${now}\n\t\t`.execute(this.db);\n\n\t\treturn this.get(collection, contentId);\n\t}\n\n\t/**\n\t * Delete SEO data for a content item.\n\t */\n\tasync delete(collection: string, contentId: string): Promise<void> {\n\t\tawait this.db\n\t\t\t.deleteFrom(\"_emdash_seo\")\n\t\t\t.where(\"collection\", \"=\", collection)\n\t\t\t.where(\"content_id\", \"=\", contentId)\n\t\t\t.execute();\n\t}\n\n\t/**\n\t * Copy SEO data from one content item to another.\n\t * Used by duplicate. Clears canonical (it pointed to the original).\n\t */\n\tasync copyForDuplicate(collection: string, sourceId: string, targetId: string): Promise<void> {\n\t\tconst source = await this.get(collection, sourceId);\n\n\t\t// Only write if there's actual SEO data worth copying\n\t\tif (\n\t\t\tsource.title !== null ||\n\t\t\tsource.description !== null ||\n\t\t\tsource.image !== null ||\n\t\t\tsource.noIndex\n\t\t) {\n\t\t\tawait this.upsert(collection, targetId, {\n\t\t\t\ttitle: source.title,\n\t\t\t\tdescription: source.description,\n\t\t\t\timage: source.image,\n\t\t\t\tcanonical: null, // Don't copy canonical — it pointed to the original\n\t\t\t\tnoIndex: source.noIndex,\n\t\t\t});\n\t\t}\n\t}\n}\n"],"mappings":"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"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { t as validateIdentifier } from "./validate-VPnKoIzW.mjs";
|
|
2
|
-
import { t as CommentRepository } from "./comment-
|
|
2
|
+
import { t as CommentRepository } from "./comment-DkAfGX9E.mjs";
|
|
3
3
|
import { t as escapeHtml } from "./escape-bIyGoW5W.mjs";
|
|
4
4
|
|
|
5
5
|
//#region src/comments/notifications.ts
|
|
@@ -192,4 +192,4 @@ function commentToStored(comment) {
|
|
|
192
192
|
|
|
193
193
|
//#endregion
|
|
194
194
|
export { sendCommentNotification as i, moderateComment as n, lookupContentAuthor as r, createComment as t };
|
|
195
|
-
//# sourceMappingURL=service-
|
|
195
|
+
//# sourceMappingURL=service-9P2cdyR_.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"service-Cn-kIfZn.mjs","names":[],"sources":["../src/comments/notifications.ts","../src/comments/service.ts"],"sourcesContent":["/**\n * Comment Notification Emails\n *\n * Sends email notifications to content authors when comments are\n * approved on their content. Used by:\n * - Public comment POST route (comment:afterCreate, if auto-approved)\n * - Admin moderation route (comment:afterModerate, when approving)\n */\n\nimport type { Kysely } from \"kysely\";\n\nimport { escapeHtml } from \"../api/escape.js\";\nimport type { Database } from \"../database/types.js\";\nimport { validateIdentifier } from \"../database/validate.js\";\nimport type { EmailPipeline } from \"../plugins/email.js\";\nimport type { EmailMessage } from \"../plugins/types.js\";\n\nconst NOTIFICATION_SOURCE = \"emdash-comments\";\nconst MAX_EXCERPT_LENGTH = 500;\nconst CRLF_RE = /[\\r\\n]/g;\n\nexport interface CommentNotificationData {\n\tcommentAuthorName: string;\n\tcommentBody: string;\n\tcontentTitle: string;\n\tcollection: string;\n\tadminBaseUrl: string;\n}\n\n/**\n * Build an email notification for a new comment.\n */\nexport function buildCommentNotificationEmail(\n\tto: string,\n\tdata: CommentNotificationData,\n): EmailMessage {\n\tconst title = data.contentTitle || `${data.collection} item`;\n\tconst subject = `New comment on \"${title}\"`.replace(CRLF_RE, \" \");\n\n\tconst excerpt =\n\t\tdata.commentBody.length > MAX_EXCERPT_LENGTH\n\t\t\t? data.commentBody.slice(0, MAX_EXCERPT_LENGTH) + \"...\"\n\t\t\t: data.commentBody;\n\n\tconst adminUrl = `${data.adminBaseUrl}/admin/comments`;\n\n\tconst text = [\n\t\t`${data.commentAuthorName} commented on \"${title}\":`,\n\t\t\"\",\n\t\texcerpt,\n\t\t\"\",\n\t\t`View in admin: ${adminUrl}`,\n\t].join(\"\\n\");\n\n\tconst html = [\n\t\t`<p><strong>${escapeHtml(data.commentAuthorName)}</strong> commented on “${escapeHtml(title)}”:</p>`,\n\t\t`<blockquote style=\"border-left:3px solid #ccc;padding-left:12px;margin:12px 0;color:#555\">${escapeHtml(excerpt)}</blockquote>`,\n\t\t`<p><a href=\"${escapeHtml(adminUrl)}\">View in admin</a></p>`,\n\t].join(\"\\n\");\n\n\treturn { to, subject, text, html };\n}\n\n/**\n * Send a comment notification to the content author if all conditions are met:\n * 1. Comment status is \"approved\"\n * 2. Content author exists and has an email\n * 3. Email provider is configured\n * 4. Commenter is not the content author (no self-notifications)\n *\n * Returns true if the email was sent, false if skipped.\n */\nexport async function sendCommentNotification(params: {\n\temail: EmailPipeline;\n\tcomment: {\n\t\tauthorName: string;\n\t\tauthorEmail: string;\n\t\tbody: string;\n\t\tstatus: string;\n\t\tcollection: string;\n\t};\n\tcontentTitle?: string;\n\tcontentAuthor?: { email: string; name: string | null };\n\tadminBaseUrl: string;\n}): Promise<boolean> {\n\tconst { email, comment, contentAuthor, adminBaseUrl } = params;\n\n\tif (comment.status !== \"approved\") return false;\n\tif (!contentAuthor?.email) return false;\n\tif (!email.isAvailable()) return false;\n\tif (comment.authorEmail.toLowerCase() === contentAuthor.email.toLowerCase()) return false;\n\n\tconst message = buildCommentNotificationEmail(contentAuthor.email, {\n\t\tcommentAuthorName: comment.authorName,\n\t\tcommentBody: comment.body,\n\t\tcontentTitle: params.contentTitle || \"\",\n\t\tcollection: comment.collection,\n\t\tadminBaseUrl,\n\t});\n\n\tawait email.send(message, NOTIFICATION_SOURCE);\n\treturn true;\n}\n\n/**\n * Look up a content item's author from the database.\n *\n * Used by the admin moderation route where content info isn't\n * readily available (only the comment record is at hand).\n */\nexport async function lookupContentAuthor(\n\tdb: Kysely<Database>,\n\tcollection: string,\n\tcontentId: string,\n): Promise<{\n\tslug: string;\n\tauthor?: { id: string; email: string; name: string | null };\n} | null> {\n\tvalidateIdentifier(collection, \"collection\");\n\n\tconst contentRow = await db\n\t\t.selectFrom(`ec_${collection}` as never)\n\t\t.select([\"slug\" as never, \"author_id\" as never])\n\t\t.where(\"id\" as never, \"=\", contentId as never)\n\t\t.executeTakeFirst();\n\n\tif (!contentRow) return null;\n\n\tconst typed = contentRow as { slug: string; author_id: string | null };\n\n\tlet author: { id: string; email: string; name: string | null } | undefined;\n\tif (typed.author_id) {\n\t\tconst userRow = await db\n\t\t\t.selectFrom(\"users\")\n\t\t\t.select([\"id\", \"name\", \"email\", \"email_verified\"])\n\t\t\t.where(\"id\", \"=\", typed.author_id)\n\t\t\t.executeTakeFirst();\n\t\tif (userRow && userRow.email_verified) {\n\t\t\tauthor = { id: userRow.id, email: userRow.email, name: userRow.name };\n\t\t}\n\t}\n\n\treturn { slug: typed.slug, author };\n}\n","/**\n * Comment Service\n *\n * Orchestrates comment creation through the hook pipeline:\n * 1. Run comment:beforeCreate pipeline (transform/reject)\n * 2. Query priorApprovedCount for first-time moderation\n * 3. Invoke comment:moderate exclusive hook (or built-in fallback)\n * 4. Save comment with determined status\n * 5. Fire comment:afterCreate (fire-and-forget)\n *\n * Also handles admin moderation (status changes) with afterModerate hooks.\n */\n\nimport type { Kysely } from \"kysely\";\n\nimport { CommentRepository } from \"../database/repositories/comment.js\";\nimport type { Comment, CommentStatus } from \"../database/repositories/comment.js\";\nimport type { Database } from \"../database/types.js\";\nimport type {\n\tCollectionCommentSettings,\n\tCommentAfterCreateEvent,\n\tCommentAfterModerateEvent,\n\tCommentBeforeCreateEvent,\n\tCommentModerateEvent,\n\tModerationDecision,\n\tStoredComment,\n} from \"../plugins/types.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface CommentCreateInput {\n\tcollection: string;\n\tcontentId: string;\n\tparentId?: string | null;\n\tauthorName: string;\n\tauthorEmail: string;\n\tauthorUserId?: string | null;\n\tbody: string;\n\tipHash?: string | null;\n\tuserAgent?: string | null;\n}\n\nexport interface CommentCreateResult {\n\tcomment: Comment;\n\tdecision: ModerationDecision;\n}\n\n/**\n * Hook runner interface — injected from the runtime so the service\n * doesn't need to know about the hook pipeline internals.\n */\nexport interface CommentHookRunner {\n\t/** Run comment:beforeCreate pipeline. Returns modified event or false. */\n\trunBeforeCreate(event: CommentBeforeCreateEvent): Promise<CommentBeforeCreateEvent | false>;\n\n\t/** Run comment:moderate exclusive hook. Returns moderation decision. */\n\trunModerate(event: CommentModerateEvent): Promise<ModerationDecision>;\n\n\t/** Fire comment:afterCreate (fire-and-forget). */\n\tfireAfterCreate(event: CommentAfterCreateEvent): void;\n\n\t/** Fire comment:afterModerate (fire-and-forget). */\n\tfireAfterModerate(event: CommentAfterModerateEvent): void;\n}\n\n// ---------------------------------------------------------------------------\n// Service\n// ---------------------------------------------------------------------------\n\n/**\n * Create a comment through the full hook pipeline.\n *\n * Returns null if the comment was rejected by a beforeCreate handler.\n */\nexport async function createComment(\n\tdb: Kysely<Database>,\n\tinput: CommentCreateInput,\n\tcollectionSettings: CollectionCommentSettings,\n\thooks: CommentHookRunner,\n\tcontentInfo?: {\n\t\tid: string;\n\t\tcollection: string;\n\t\tslug: string;\n\t\ttitle?: string;\n\t\tauthor?: { id: string; name: string | null; email: string };\n\t},\n): Promise<CommentCreateResult | null> {\n\tconst repo = new CommentRepository(db);\n\n\t// 1. Build the beforeCreate event\n\tconst beforeCreateEvent: CommentBeforeCreateEvent = {\n\t\tcomment: {\n\t\t\tcollection: input.collection,\n\t\t\tcontentId: input.contentId,\n\t\t\tparentId: input.parentId ?? null,\n\t\t\tauthorName: input.authorName,\n\t\t\tauthorEmail: input.authorEmail,\n\t\t\tauthorUserId: input.authorUserId ?? null,\n\t\t\tbody: input.body,\n\t\t\tipHash: input.ipHash ?? null,\n\t\t\tuserAgent: input.userAgent ?? null,\n\t\t},\n\t\tmetadata: {},\n\t};\n\n\t// 2. Run comment:beforeCreate pipeline\n\tconst result = await hooks.runBeforeCreate(beforeCreateEvent);\n\tif (result === false) {\n\t\treturn null; // Rejected\n\t}\n\n\tconst event = result;\n\n\t// 3. Query prior approved count for first-time moderation\n\tconst priorApprovedCount = await repo.countApprovedByEmail(event.comment.authorEmail);\n\n\t// 4. Run comment:moderate exclusive hook\n\tconst moderateEvent: CommentModerateEvent = {\n\t\tcomment: event.comment,\n\t\tmetadata: event.metadata,\n\t\tcollectionSettings,\n\t\tpriorApprovedCount,\n\t};\n\n\tconst decision = await hooks.runModerate(moderateEvent);\n\n\t// 5. Save comment with determined status\n\tconst comment = await repo.create({\n\t\tcollection: event.comment.collection,\n\t\tcontentId: event.comment.contentId,\n\t\tparentId: event.comment.parentId,\n\t\tauthorName: event.comment.authorName,\n\t\tauthorEmail: event.comment.authorEmail,\n\t\tauthorUserId: event.comment.authorUserId,\n\t\tbody: event.comment.body,\n\t\tstatus: decision.status as CommentStatus,\n\t\tipHash: event.comment.ipHash,\n\t\tuserAgent: event.comment.userAgent,\n\t\tmoderationMetadata: Object.keys(event.metadata).length > 0 ? event.metadata : null,\n\t});\n\n\t// 6. Fire comment:afterCreate (fire-and-forget)\n\tif (contentInfo) {\n\t\tconst afterEvent: CommentAfterCreateEvent = {\n\t\t\tcomment: commentToStored(comment),\n\t\t\tmetadata: event.metadata,\n\t\t\tcontent: {\n\t\t\t\tid: contentInfo.id,\n\t\t\t\tcollection: contentInfo.collection,\n\t\t\t\tslug: contentInfo.slug,\n\t\t\t\ttitle: contentInfo.title,\n\t\t\t},\n\t\t\tcontentAuthor: contentInfo.author,\n\t\t};\n\t\thooks.fireAfterCreate(afterEvent);\n\t}\n\n\treturn { comment, decision };\n}\n\n/**\n * Admin moderation — change a comment's status.\n * Fires comment:afterModerate hook.\n */\nexport async function moderateComment(\n\tdb: Kysely<Database>,\n\tid: string,\n\tnewStatus: CommentStatus,\n\tmoderator: { id: string; name: string | null },\n\thooks: CommentHookRunner,\n): Promise<Comment | null> {\n\tconst repo = new CommentRepository(db);\n\tconst existing = await repo.findById(id);\n\tif (!existing) return null;\n\n\tconst previousStatus = existing.status;\n\tconst updated = await repo.updateStatus(id, newStatus);\n\tif (!updated) return null;\n\n\t// Fire comment:afterModerate (fire-and-forget)\n\tconst afterEvent: CommentAfterModerateEvent = {\n\t\tcomment: commentToStored(updated),\n\t\tpreviousStatus,\n\t\tnewStatus,\n\t\tmoderator,\n\t};\n\thooks.fireAfterModerate(afterEvent);\n\n\treturn updated;\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction commentToStored(comment: Comment): StoredComment {\n\treturn {\n\t\tid: comment.id,\n\t\tcollection: comment.collection,\n\t\tcontentId: comment.contentId,\n\t\tparentId: comment.parentId,\n\t\tauthorName: comment.authorName,\n\t\tauthorEmail: comment.authorEmail,\n\t\tauthorUserId: comment.authorUserId,\n\t\tbody: comment.body,\n\t\tstatus: comment.status,\n\t\tmoderationMetadata: comment.moderationMetadata,\n\t\tcreatedAt: comment.createdAt,\n\t\tupdatedAt: comment.updatedAt,\n\t};\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"service-9P2cdyR_.mjs","names":[],"sources":["../src/comments/notifications.ts","../src/comments/service.ts"],"sourcesContent":["/**\n * Comment Notification Emails\n *\n * Sends email notifications to content authors when comments are\n * approved on their content. Used by:\n * - Public comment POST route (comment:afterCreate, if auto-approved)\n * - Admin moderation route (comment:afterModerate, when approving)\n */\n\nimport type { Kysely } from \"kysely\";\n\nimport { escapeHtml } from \"../api/escape.js\";\nimport type { Database } from \"../database/types.js\";\nimport { validateIdentifier } from \"../database/validate.js\";\nimport type { EmailPipeline } from \"../plugins/email.js\";\nimport type { EmailMessage } from \"../plugins/types.js\";\n\nconst NOTIFICATION_SOURCE = \"emdash-comments\";\nconst MAX_EXCERPT_LENGTH = 500;\nconst CRLF_RE = /[\\r\\n]/g;\n\nexport interface CommentNotificationData {\n\tcommentAuthorName: string;\n\tcommentBody: string;\n\tcontentTitle: string;\n\tcollection: string;\n\tadminBaseUrl: string;\n}\n\n/**\n * Build an email notification for a new comment.\n */\nexport function buildCommentNotificationEmail(\n\tto: string,\n\tdata: CommentNotificationData,\n): EmailMessage {\n\tconst title = data.contentTitle || `${data.collection} item`;\n\tconst subject = `New comment on \"${title}\"`.replace(CRLF_RE, \" \");\n\n\tconst excerpt =\n\t\tdata.commentBody.length > MAX_EXCERPT_LENGTH\n\t\t\t? data.commentBody.slice(0, MAX_EXCERPT_LENGTH) + \"...\"\n\t\t\t: data.commentBody;\n\n\tconst adminUrl = `${data.adminBaseUrl}/admin/comments`;\n\n\tconst text = [\n\t\t`${data.commentAuthorName} commented on \"${title}\":`,\n\t\t\"\",\n\t\texcerpt,\n\t\t\"\",\n\t\t`View in admin: ${adminUrl}`,\n\t].join(\"\\n\");\n\n\tconst html = [\n\t\t`<p><strong>${escapeHtml(data.commentAuthorName)}</strong> commented on “${escapeHtml(title)}”:</p>`,\n\t\t`<blockquote style=\"border-left:3px solid #ccc;padding-left:12px;margin:12px 0;color:#555\">${escapeHtml(excerpt)}</blockquote>`,\n\t\t`<p><a href=\"${escapeHtml(adminUrl)}\">View in admin</a></p>`,\n\t].join(\"\\n\");\n\n\treturn { to, subject, text, html };\n}\n\n/**\n * Send a comment notification to the content author if all conditions are met:\n * 1. Comment status is \"approved\"\n * 2. Content author exists and has an email\n * 3. Email provider is configured\n * 4. Commenter is not the content author (no self-notifications)\n *\n * Returns true if the email was sent, false if skipped.\n */\nexport async function sendCommentNotification(params: {\n\temail: EmailPipeline;\n\tcomment: {\n\t\tauthorName: string;\n\t\tauthorEmail: string;\n\t\tbody: string;\n\t\tstatus: string;\n\t\tcollection: string;\n\t};\n\tcontentTitle?: string;\n\tcontentAuthor?: { email: string; name: string | null };\n\tadminBaseUrl: string;\n}): Promise<boolean> {\n\tconst { email, comment, contentAuthor, adminBaseUrl } = params;\n\n\tif (comment.status !== \"approved\") return false;\n\tif (!contentAuthor?.email) return false;\n\tif (!email.isAvailable()) return false;\n\tif (comment.authorEmail.toLowerCase() === contentAuthor.email.toLowerCase()) return false;\n\n\tconst message = buildCommentNotificationEmail(contentAuthor.email, {\n\t\tcommentAuthorName: comment.authorName,\n\t\tcommentBody: comment.body,\n\t\tcontentTitle: params.contentTitle || \"\",\n\t\tcollection: comment.collection,\n\t\tadminBaseUrl,\n\t});\n\n\tawait email.send(message, NOTIFICATION_SOURCE);\n\treturn true;\n}\n\n/**\n * Look up a content item's author from the database.\n *\n * Used by the admin moderation route where content info isn't\n * readily available (only the comment record is at hand).\n */\nexport async function lookupContentAuthor(\n\tdb: Kysely<Database>,\n\tcollection: string,\n\tcontentId: string,\n): Promise<{\n\tslug: string;\n\tauthor?: { id: string; email: string; name: string | null };\n} | null> {\n\tvalidateIdentifier(collection, \"collection\");\n\n\tconst contentRow = await db\n\t\t.selectFrom(`ec_${collection}` as never)\n\t\t.select([\"slug\" as never, \"author_id\" as never])\n\t\t.where(\"id\" as never, \"=\", contentId as never)\n\t\t.executeTakeFirst();\n\n\tif (!contentRow) return null;\n\n\tconst typed = contentRow as { slug: string; author_id: string | null };\n\n\tlet author: { id: string; email: string; name: string | null } | undefined;\n\tif (typed.author_id) {\n\t\tconst userRow = await db\n\t\t\t.selectFrom(\"users\")\n\t\t\t.select([\"id\", \"name\", \"email\", \"email_verified\"])\n\t\t\t.where(\"id\", \"=\", typed.author_id)\n\t\t\t.executeTakeFirst();\n\t\tif (userRow && userRow.email_verified) {\n\t\t\tauthor = { id: userRow.id, email: userRow.email, name: userRow.name };\n\t\t}\n\t}\n\n\treturn { slug: typed.slug, author };\n}\n","/**\n * Comment Service\n *\n * Orchestrates comment creation through the hook pipeline:\n * 1. Run comment:beforeCreate pipeline (transform/reject)\n * 2. Query priorApprovedCount for first-time moderation\n * 3. Invoke comment:moderate exclusive hook (or built-in fallback)\n * 4. Save comment with determined status\n * 5. Fire comment:afterCreate (fire-and-forget)\n *\n * Also handles admin moderation (status changes) with afterModerate hooks.\n */\n\nimport type { Kysely } from \"kysely\";\n\nimport { CommentRepository } from \"../database/repositories/comment.js\";\nimport type { Comment, CommentStatus } from \"../database/repositories/comment.js\";\nimport type { Database } from \"../database/types.js\";\nimport type {\n\tCollectionCommentSettings,\n\tCommentAfterCreateEvent,\n\tCommentAfterModerateEvent,\n\tCommentBeforeCreateEvent,\n\tCommentModerateEvent,\n\tModerationDecision,\n\tStoredComment,\n} from \"../plugins/types.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface CommentCreateInput {\n\tcollection: string;\n\tcontentId: string;\n\tparentId?: string | null;\n\tauthorName: string;\n\tauthorEmail: string;\n\tauthorUserId?: string | null;\n\tbody: string;\n\tipHash?: string | null;\n\tuserAgent?: string | null;\n}\n\nexport interface CommentCreateResult {\n\tcomment: Comment;\n\tdecision: ModerationDecision;\n}\n\n/**\n * Hook runner interface — injected from the runtime so the service\n * doesn't need to know about the hook pipeline internals.\n */\nexport interface CommentHookRunner {\n\t/** Run comment:beforeCreate pipeline. Returns modified event or false. */\n\trunBeforeCreate(event: CommentBeforeCreateEvent): Promise<CommentBeforeCreateEvent | false>;\n\n\t/** Run comment:moderate exclusive hook. Returns moderation decision. */\n\trunModerate(event: CommentModerateEvent): Promise<ModerationDecision>;\n\n\t/** Fire comment:afterCreate (fire-and-forget). */\n\tfireAfterCreate(event: CommentAfterCreateEvent): void;\n\n\t/** Fire comment:afterModerate (fire-and-forget). */\n\tfireAfterModerate(event: CommentAfterModerateEvent): void;\n}\n\n// ---------------------------------------------------------------------------\n// Service\n// ---------------------------------------------------------------------------\n\n/**\n * Create a comment through the full hook pipeline.\n *\n * Returns null if the comment was rejected by a beforeCreate handler.\n */\nexport async function createComment(\n\tdb: Kysely<Database>,\n\tinput: CommentCreateInput,\n\tcollectionSettings: CollectionCommentSettings,\n\thooks: CommentHookRunner,\n\tcontentInfo?: {\n\t\tid: string;\n\t\tcollection: string;\n\t\tslug: string;\n\t\ttitle?: string;\n\t\tauthor?: { id: string; name: string | null; email: string };\n\t},\n): Promise<CommentCreateResult | null> {\n\tconst repo = new CommentRepository(db);\n\n\t// 1. Build the beforeCreate event\n\tconst beforeCreateEvent: CommentBeforeCreateEvent = {\n\t\tcomment: {\n\t\t\tcollection: input.collection,\n\t\t\tcontentId: input.contentId,\n\t\t\tparentId: input.parentId ?? null,\n\t\t\tauthorName: input.authorName,\n\t\t\tauthorEmail: input.authorEmail,\n\t\t\tauthorUserId: input.authorUserId ?? null,\n\t\t\tbody: input.body,\n\t\t\tipHash: input.ipHash ?? null,\n\t\t\tuserAgent: input.userAgent ?? null,\n\t\t},\n\t\tmetadata: {},\n\t};\n\n\t// 2. Run comment:beforeCreate pipeline\n\tconst result = await hooks.runBeforeCreate(beforeCreateEvent);\n\tif (result === false) {\n\t\treturn null; // Rejected\n\t}\n\n\tconst event = result;\n\n\t// 3. Query prior approved count for first-time moderation\n\tconst priorApprovedCount = await repo.countApprovedByEmail(event.comment.authorEmail);\n\n\t// 4. Run comment:moderate exclusive hook\n\tconst moderateEvent: CommentModerateEvent = {\n\t\tcomment: event.comment,\n\t\tmetadata: event.metadata,\n\t\tcollectionSettings,\n\t\tpriorApprovedCount,\n\t};\n\n\tconst decision = await hooks.runModerate(moderateEvent);\n\n\t// 5. Save comment with determined status\n\tconst comment = await repo.create({\n\t\tcollection: event.comment.collection,\n\t\tcontentId: event.comment.contentId,\n\t\tparentId: event.comment.parentId,\n\t\tauthorName: event.comment.authorName,\n\t\tauthorEmail: event.comment.authorEmail,\n\t\tauthorUserId: event.comment.authorUserId,\n\t\tbody: event.comment.body,\n\t\tstatus: decision.status as CommentStatus,\n\t\tipHash: event.comment.ipHash,\n\t\tuserAgent: event.comment.userAgent,\n\t\tmoderationMetadata: Object.keys(event.metadata).length > 0 ? event.metadata : null,\n\t});\n\n\t// 6. Fire comment:afterCreate (fire-and-forget)\n\tif (contentInfo) {\n\t\tconst afterEvent: CommentAfterCreateEvent = {\n\t\t\tcomment: commentToStored(comment),\n\t\t\tmetadata: event.metadata,\n\t\t\tcontent: {\n\t\t\t\tid: contentInfo.id,\n\t\t\t\tcollection: contentInfo.collection,\n\t\t\t\tslug: contentInfo.slug,\n\t\t\t\ttitle: contentInfo.title,\n\t\t\t},\n\t\t\tcontentAuthor: contentInfo.author,\n\t\t};\n\t\thooks.fireAfterCreate(afterEvent);\n\t}\n\n\treturn { comment, decision };\n}\n\n/**\n * Admin moderation — change a comment's status.\n * Fires comment:afterModerate hook.\n */\nexport async function moderateComment(\n\tdb: Kysely<Database>,\n\tid: string,\n\tnewStatus: CommentStatus,\n\tmoderator: { id: string; name: string | null },\n\thooks: CommentHookRunner,\n): Promise<Comment | null> {\n\tconst repo = new CommentRepository(db);\n\tconst existing = await repo.findById(id);\n\tif (!existing) return null;\n\n\tconst previousStatus = existing.status;\n\tconst updated = await repo.updateStatus(id, newStatus);\n\tif (!updated) return null;\n\n\t// Fire comment:afterModerate (fire-and-forget)\n\tconst afterEvent: CommentAfterModerateEvent = {\n\t\tcomment: commentToStored(updated),\n\t\tpreviousStatus,\n\t\tnewStatus,\n\t\tmoderator,\n\t};\n\thooks.fireAfterModerate(afterEvent);\n\n\treturn updated;\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction commentToStored(comment: Comment): StoredComment {\n\treturn {\n\t\tid: comment.id,\n\t\tcollection: comment.collection,\n\t\tcontentId: comment.contentId,\n\t\tparentId: comment.parentId,\n\t\tauthorName: comment.authorName,\n\t\tauthorEmail: comment.authorEmail,\n\t\tauthorUserId: comment.authorUserId,\n\t\tbody: comment.body,\n\t\tstatus: comment.status,\n\t\tmoderationMetadata: comment.moderationMetadata,\n\t\tcreatedAt: comment.createdAt,\n\t\tupdatedAt: comment.updatedAt,\n\t};\n}\n"],"mappings":"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"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { i as __exportAll } from "./runner-
|
|
2
|
-
import { a as getSiteSettingsWithDb, s as setSiteSettings } from "./settings-
|
|
1
|
+
import { i as __exportAll } from "./runner-BiuUfx-V.mjs";
|
|
2
|
+
import { a as getSiteSettingsWithDb, s as setSiteSettings } from "./settings-Jro4YcUb.mjs";
|
|
3
3
|
|
|
4
4
|
//#region src/api/handlers/settings.ts
|
|
5
5
|
var settings_exports = /* @__PURE__ */ __exportAll({
|
|
@@ -48,4 +48,4 @@ async function handleSettingsUpdate(db, storage, input) {
|
|
|
48
48
|
|
|
49
49
|
//#endregion
|
|
50
50
|
export { handleSettingsUpdate as n, settings_exports as r, handleSettingsGet as t };
|
|
51
|
-
//# sourceMappingURL=settings-
|
|
51
|
+
//# sourceMappingURL=settings-DYVzINdn.mjs.map
|