emdash 0.20.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{adapters-BzIHV3sw.d.mts → adapters-BxSmgtbF.d.mts} +1 -1
- package/dist/{adapters-BzIHV3sw.d.mts.map → adapters-BxSmgtbF.d.mts.map} +1 -1
- package/dist/{allowed-origins-B1u7Qnvg.mjs → allowed-origins-BqC8cul8.mjs} +2 -2
- package/dist/{allowed-origins-B1u7Qnvg.mjs.map → allowed-origins-BqC8cul8.mjs.map} +1 -1
- package/dist/api/route-utils.d.mts +3 -3
- package/dist/api/route-utils.mjs +13 -12
- package/dist/api/route-utils.mjs.map +1 -1
- package/dist/api/schemas/index.d.mts +1 -1
- package/dist/api/schemas/index.mjs +3 -2
- package/dist/{api-DStv36ik.mjs → api-DxjIV2o8.mjs} +13 -13
- package/dist/{api-DStv36ik.mjs.map → api-DxjIV2o8.mjs.map} +1 -1
- package/dist/{api-tokens-DPfhPu5V.mjs → api-tokens-BFFkB0jB.mjs} +2 -2
- package/dist/{api-tokens-DPfhPu5V.mjs.map → api-tokens-BFFkB0jB.mjs.map} +1 -1
- package/dist/{apply-Dr7snAMT.mjs → apply-CLjxheyb.mjs} +12 -12
- package/dist/{apply-Dr7snAMT.mjs.map → apply-CLjxheyb.mjs.map} +1 -1
- package/dist/astro/index.d.mts +10 -10
- package/dist/astro/index.d.mts.map +1 -1
- package/dist/astro/index.mjs +50 -15
- package/dist/astro/index.mjs.map +1 -1
- package/dist/astro/middleware/auth.d.mts +9 -9
- package/dist/astro/middleware/auth.mjs +5 -5
- package/dist/astro/middleware/redirect.d.mts.map +1 -1
- package/dist/astro/middleware/redirect.mjs +11 -2
- package/dist/astro/middleware/redirect.mjs.map +1 -1
- package/dist/astro/middleware/request-context.mjs +3 -2
- package/dist/astro/middleware/request-context.mjs.map +1 -1
- package/dist/astro/middleware/setup.mjs +1 -1
- package/dist/astro/middleware.d.mts +1 -1
- package/dist/astro/middleware.mjs +63 -60
- package/dist/astro/middleware.mjs.map +1 -1
- package/dist/astro/routes/api/admin/allowed-domains/_domain_.mjs +5 -4
- package/dist/astro/routes/api/admin/allowed-domains/_domain_.mjs.map +1 -1
- package/dist/astro/routes/api/admin/allowed-domains/index.mjs +5 -4
- package/dist/astro/routes/api/admin/allowed-domains/index.mjs.map +1 -1
- package/dist/astro/routes/api/admin/api-tokens/_id_.mjs +3 -3
- package/dist/astro/routes/api/admin/api-tokens/index.mjs +4 -4
- package/dist/astro/routes/api/admin/byline-fields/_slug_/usage.mjs +4 -4
- package/dist/astro/routes/api/admin/byline-fields/_slug_.mjs +8 -7
- package/dist/astro/routes/api/admin/byline-fields/_slug_.mjs.map +1 -1
- package/dist/astro/routes/api/admin/byline-fields/index.mjs +8 -7
- package/dist/astro/routes/api/admin/byline-fields/index.mjs.map +1 -1
- package/dist/astro/routes/api/admin/byline-fields/reorder.mjs +8 -7
- package/dist/astro/routes/api/admin/byline-fields/reorder.mjs.map +1 -1
- package/dist/astro/routes/api/admin/bylines/_id_/index.mjs +14 -12
- package/dist/astro/routes/api/admin/bylines/_id_/index.mjs.map +1 -1
- package/dist/astro/routes/api/admin/bylines/_id_/translations.mjs +14 -12
- package/dist/astro/routes/api/admin/bylines/_id_/translations.mjs.map +1 -1
- package/dist/astro/routes/api/admin/bylines/index.mjs +14 -12
- package/dist/astro/routes/api/admin/bylines/index.mjs.map +1 -1
- package/dist/astro/routes/api/admin/comments/_id_/status.mjs +9 -8
- package/dist/astro/routes/api/admin/comments/_id_/status.mjs.map +1 -1
- package/dist/astro/routes/api/admin/comments/_id_.mjs +3 -3
- package/dist/astro/routes/api/admin/comments/bulk.mjs +7 -6
- package/dist/astro/routes/api/admin/comments/bulk.mjs.map +1 -1
- package/dist/astro/routes/api/admin/comments/counts.mjs +3 -3
- package/dist/astro/routes/api/admin/comments/index.mjs +7 -6
- package/dist/astro/routes/api/admin/comments/index.mjs.map +1 -1
- package/dist/astro/routes/api/admin/hooks/exclusive/_hookName_.mjs +3 -3
- package/dist/astro/routes/api/admin/hooks/exclusive/index.mjs +2 -2
- 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 +29 -27
- package/dist/astro/routes/api/admin/plugins/_id_/disable.mjs.map +1 -1
- package/dist/astro/routes/api/admin/plugins/_id_/enable.mjs +29 -27
- package/dist/astro/routes/api/admin/plugins/_id_/enable.mjs.map +1 -1
- package/dist/astro/routes/api/admin/plugins/_id_/index.mjs +28 -26
- package/dist/astro/routes/api/admin/plugins/_id_/index.mjs.map +1 -1
- package/dist/astro/routes/api/admin/plugins/_id_/uninstall.mjs +28 -26
- package/dist/astro/routes/api/admin/plugins/_id_/uninstall.mjs.map +1 -1
- package/dist/astro/routes/api/admin/plugins/_id_/update.mjs +28 -26
- package/dist/astro/routes/api/admin/plugins/_id_/update.mjs.map +1 -1
- package/dist/astro/routes/api/admin/plugins/index.mjs +28 -26
- package/dist/astro/routes/api/admin/plugins/index.mjs.map +1 -1
- package/dist/astro/routes/api/admin/plugins/marketplace/_id_/icon.mjs +2 -2
- package/dist/astro/routes/api/admin/plugins/marketplace/_id_/index.mjs +28 -26
- package/dist/astro/routes/api/admin/plugins/marketplace/_id_/index.mjs.map +1 -1
- package/dist/astro/routes/api/admin/plugins/marketplace/_id_/install.mjs +28 -26
- package/dist/astro/routes/api/admin/plugins/marketplace/_id_/install.mjs.map +1 -1
- package/dist/astro/routes/api/admin/plugins/marketplace/index.mjs +28 -26
- package/dist/astro/routes/api/admin/plugins/marketplace/index.mjs.map +1 -1
- package/dist/astro/routes/api/admin/plugins/registry/_id_/uninstall.mjs +28 -26
- package/dist/astro/routes/api/admin/plugins/registry/_id_/uninstall.mjs.map +1 -1
- package/dist/astro/routes/api/admin/plugins/registry/_id_/update.mjs +29 -27
- package/dist/astro/routes/api/admin/plugins/registry/_id_/update.mjs.map +1 -1
- package/dist/astro/routes/api/admin/plugins/registry/artifact.mjs +28 -26
- package/dist/astro/routes/api/admin/plugins/registry/artifact.mjs.map +1 -1
- package/dist/astro/routes/api/admin/plugins/registry/install.mjs +29 -27
- package/dist/astro/routes/api/admin/plugins/registry/install.mjs.map +1 -1
- package/dist/astro/routes/api/admin/plugins/updates.mjs +28 -26
- package/dist/astro/routes/api/admin/plugins/updates.mjs.map +1 -1
- package/dist/astro/routes/api/admin/themes/marketplace/_id_/index.mjs +28 -26
- package/dist/astro/routes/api/admin/themes/marketplace/_id_/index.mjs.map +1 -1
- package/dist/astro/routes/api/admin/themes/marketplace/_id_/thumbnail.mjs +2 -2
- package/dist/astro/routes/api/admin/themes/marketplace/index.mjs +28 -26
- package/dist/astro/routes/api/admin/themes/marketplace/index.mjs.map +1 -1
- package/dist/astro/routes/api/admin/users/_id_/disable.mjs +1 -1
- package/dist/astro/routes/api/admin/users/_id_/enable.mjs +1 -1
- package/dist/astro/routes/api/admin/users/_id_/index.mjs +5 -4
- package/dist/astro/routes/api/admin/users/_id_/index.mjs.map +1 -1
- package/dist/astro/routes/api/admin/users/_id_/send-recovery.mjs +2 -2
- package/dist/astro/routes/api/admin/users/index.mjs +5 -4
- package/dist/astro/routes/api/admin/users/index.mjs.map +1 -1
- package/dist/astro/routes/api/auth/dev-bypass.mjs +3 -3
- package/dist/astro/routes/api/auth/invite/accept.mjs +1 -1
- package/dist/astro/routes/api/auth/invite/complete.mjs +9 -8
- package/dist/astro/routes/api/auth/invite/complete.mjs.map +1 -1
- package/dist/astro/routes/api/auth/invite/index.mjs +6 -5
- package/dist/astro/routes/api/auth/invite/index.mjs.map +1 -1
- package/dist/astro/routes/api/auth/invite/register-options.mjs +8 -7
- package/dist/astro/routes/api/auth/invite/register-options.mjs.map +1 -1
- package/dist/astro/routes/api/auth/logout.mjs +2 -2
- package/dist/astro/routes/api/auth/magic-link/send.mjs +8 -7
- package/dist/astro/routes/api/auth/magic-link/send.mjs.map +1 -1
- package/dist/astro/routes/api/auth/magic-link/verify.mjs +2 -2
- package/dist/astro/routes/api/auth/me.mjs +5 -4
- package/dist/astro/routes/api/auth/me.mjs.map +1 -1
- package/dist/astro/routes/api/auth/mode.mjs +1 -1
- package/dist/astro/routes/api/auth/oauth/_provider_/callback.mjs +3 -3
- package/dist/astro/routes/api/auth/oauth/_provider_.mjs +2 -2
- package/dist/astro/routes/api/auth/passkey/_id_.mjs +5 -4
- package/dist/astro/routes/api/auth/passkey/_id_.mjs.map +1 -1
- package/dist/astro/routes/api/auth/passkey/index.mjs +1 -1
- package/dist/astro/routes/api/auth/passkey/options.mjs +10 -9
- package/dist/astro/routes/api/auth/passkey/options.mjs.map +1 -1
- package/dist/astro/routes/api/auth/passkey/register/options.mjs +8 -7
- package/dist/astro/routes/api/auth/passkey/register/options.mjs.map +1 -1
- package/dist/astro/routes/api/auth/passkey/register/verify.mjs +9 -8
- package/dist/astro/routes/api/auth/passkey/register/verify.mjs.map +1 -1
- package/dist/astro/routes/api/auth/passkey/verify.mjs +9 -8
- package/dist/astro/routes/api/auth/passkey/verify.mjs.map +1 -1
- package/dist/astro/routes/api/auth/signup/complete.mjs +9 -8
- package/dist/astro/routes/api/auth/signup/complete.mjs.map +1 -1
- package/dist/astro/routes/api/auth/signup/request.mjs +8 -7
- package/dist/astro/routes/api/auth/signup/request.mjs.map +1 -1
- package/dist/astro/routes/api/auth/signup/verify.mjs +1 -1
- package/dist/astro/routes/api/comments/_collection_/_contentId_/index.mjs +11 -9
- package/dist/astro/routes/api/comments/_collection_/_contentId_/index.mjs.map +1 -1
- package/dist/astro/routes/api/content/_collection_/_id_/compare.mjs +2 -2
- package/dist/astro/routes/api/content/_collection_/_id_/discard-draft.mjs +2 -2
- package/dist/astro/routes/api/content/_collection_/_id_/duplicate.mjs +2 -2
- package/dist/astro/routes/api/content/_collection_/_id_/permanent.mjs +2 -2
- package/dist/astro/routes/api/content/_collection_/_id_/preview-url.mjs +10 -8
- package/dist/astro/routes/api/content/_collection_/_id_/preview-url.mjs.map +1 -1
- package/dist/astro/routes/api/content/_collection_/_id_/publish.mjs +6 -5
- package/dist/astro/routes/api/content/_collection_/_id_/publish.mjs.map +1 -1
- package/dist/astro/routes/api/content/_collection_/_id_/restore.mjs +2 -2
- package/dist/astro/routes/api/content/_collection_/_id_/revisions.mjs +2 -2
- package/dist/astro/routes/api/content/_collection_/_id_/schedule.mjs +6 -5
- package/dist/astro/routes/api/content/_collection_/_id_/schedule.mjs.map +1 -1
- package/dist/astro/routes/api/content/_collection_/_id_/terms/_taxonomy_.mjs +10 -9
- package/dist/astro/routes/api/content/_collection_/_id_/terms/_taxonomy_.mjs.map +1 -1
- package/dist/astro/routes/api/content/_collection_/_id_/translations.mjs +2 -2
- package/dist/astro/routes/api/content/_collection_/_id_/unpublish.mjs +2 -2
- package/dist/astro/routes/api/content/_collection_/_id_.mjs +6 -5
- package/dist/astro/routes/api/content/_collection_/_id_.mjs.map +1 -1
- package/dist/astro/routes/api/content/_collection_/authors.mjs +2 -2
- package/dist/astro/routes/api/content/_collection_/index.mjs +6 -5
- package/dist/astro/routes/api/content/_collection_/index.mjs.map +1 -1
- package/dist/astro/routes/api/content/_collection_/trash.mjs +6 -5
- package/dist/astro/routes/api/content/_collection_/trash.mjs.map +1 -1
- package/dist/astro/routes/api/dashboard.mjs +3 -3
- package/dist/astro/routes/api/dev/emails.mjs +2 -2
- package/dist/astro/routes/api/import/probe.d.mts +3 -3
- package/dist/astro/routes/api/import/probe.mjs +10 -9
- package/dist/astro/routes/api/import/probe.mjs.map +1 -1
- package/dist/astro/routes/api/import/wordpress/analyze.mjs +3 -3
- package/dist/astro/routes/api/import/wordpress/execute.d.mts +9 -9
- package/dist/astro/routes/api/import/wordpress/execute.mjs +10 -9
- package/dist/astro/routes/api/import/wordpress/execute.mjs.map +1 -1
- package/dist/astro/routes/api/import/wordpress/media.mjs +8 -7
- package/dist/astro/routes/api/import/wordpress/media.mjs.map +1 -1
- package/dist/astro/routes/api/import/wordpress/prepare.mjs +9 -8
- package/dist/astro/routes/api/import/wordpress/prepare.mjs.map +1 -1
- package/dist/astro/routes/api/import/wordpress/rewrite-urls.mjs +8 -7
- package/dist/astro/routes/api/import/wordpress/rewrite-urls.mjs.map +1 -1
- package/dist/astro/routes/api/import/wordpress-plugin/analyze.d.mts +1 -1
- package/dist/astro/routes/api/import/wordpress-plugin/analyze.mjs +10 -9
- package/dist/astro/routes/api/import/wordpress-plugin/analyze.mjs.map +1 -1
- package/dist/astro/routes/api/import/wordpress-plugin/execute.d.mts +1 -1
- package/dist/astro/routes/api/import/wordpress-plugin/execute.mjs +14 -12
- package/dist/astro/routes/api/import/wordpress-plugin/execute.mjs.map +1 -1
- package/dist/astro/routes/api/manifest.mjs +3 -3
- package/dist/astro/routes/api/mcp.mjs +20 -19
- package/dist/astro/routes/api/mcp.mjs.map +1 -1
- package/dist/astro/routes/api/media/_id_/confirm.mjs +6 -5
- package/dist/astro/routes/api/media/_id_/confirm.mjs.map +1 -1
- package/dist/astro/routes/api/media/_id_.mjs +6 -5
- package/dist/astro/routes/api/media/_id_.mjs.map +1 -1
- package/dist/astro/routes/api/media/file/_...key_.mjs +1 -1
- package/dist/astro/routes/api/media/providers/_providerId_/_itemId_.mjs +2 -2
- package/dist/astro/routes/api/media/providers/_providerId_/index.mjs +2 -2
- package/dist/astro/routes/api/media/providers/index.mjs +2 -2
- package/dist/astro/routes/api/media/upload-url.mjs +8 -7
- package/dist/astro/routes/api/media/upload-url.mjs.map +1 -1
- package/dist/astro/routes/api/media.mjs +10 -9
- package/dist/astro/routes/api/media.mjs.map +1 -1
- package/dist/astro/routes/api/menus/_name_/items/_id_.mjs +6 -5
- package/dist/astro/routes/api/menus/_name_/items/_id_.mjs.map +1 -1
- package/dist/astro/routes/api/menus/_name_/items.mjs +6 -5
- package/dist/astro/routes/api/menus/_name_/items.mjs.map +1 -1
- package/dist/astro/routes/api/menus/_name_/reorder.mjs +6 -5
- package/dist/astro/routes/api/menus/_name_/reorder.mjs.map +1 -1
- package/dist/astro/routes/api/menus/_name_/translations.mjs +6 -5
- package/dist/astro/routes/api/menus/_name_/translations.mjs.map +1 -1
- package/dist/astro/routes/api/menus/_name_.mjs +6 -5
- package/dist/astro/routes/api/menus/_name_.mjs.map +1 -1
- package/dist/astro/routes/api/menus/index.mjs +6 -5
- package/dist/astro/routes/api/menus/index.mjs.map +1 -1
- package/dist/astro/routes/api/oauth/authorize.mjs +6 -6
- package/dist/astro/routes/api/oauth/device/authorize.mjs +5 -5
- package/dist/astro/routes/api/oauth/device/code.mjs +8 -8
- package/dist/astro/routes/api/oauth/device/token.mjs +7 -7
- package/dist/astro/routes/api/oauth/register.mjs +2 -2
- package/dist/astro/routes/api/oauth/token/refresh.mjs +5 -5
- package/dist/astro/routes/api/oauth/token/revoke.mjs +5 -5
- package/dist/astro/routes/api/oauth/token.mjs +5 -5
- package/dist/astro/routes/api/openapi.json.mjs +3 -2
- package/dist/astro/routes/api/openapi.json.mjs.map +1 -1
- package/dist/astro/routes/api/plugins/_pluginId_/_...path_.mjs +3 -3
- package/dist/astro/routes/api/redirects/404s/index.mjs +7 -6
- package/dist/astro/routes/api/redirects/404s/index.mjs.map +1 -1
- package/dist/astro/routes/api/redirects/404s/summary.mjs +7 -6
- package/dist/astro/routes/api/redirects/404s/summary.mjs.map +1 -1
- package/dist/astro/routes/api/redirects/_id_.mjs +8 -7
- package/dist/astro/routes/api/redirects/_id_.mjs.map +1 -1
- package/dist/astro/routes/api/redirects/index.mjs +8 -7
- package/dist/astro/routes/api/redirects/index.mjs.map +1 -1
- package/dist/astro/routes/api/revisions/_revisionId_/index.mjs +2 -2
- package/dist/astro/routes/api/revisions/_revisionId_/restore.mjs +2 -2
- package/dist/astro/routes/api/schema/collections/_slug_/fields/_fieldSlug_.mjs +28 -26
- package/dist/astro/routes/api/schema/collections/_slug_/fields/_fieldSlug_.mjs.map +1 -1
- package/dist/astro/routes/api/schema/collections/_slug_/fields/index.mjs +28 -26
- package/dist/astro/routes/api/schema/collections/_slug_/fields/index.mjs.map +1 -1
- package/dist/astro/routes/api/schema/collections/_slug_/fields/reorder.mjs +28 -26
- package/dist/astro/routes/api/schema/collections/_slug_/fields/reorder.mjs.map +1 -1
- package/dist/astro/routes/api/schema/collections/_slug_/index.mjs +28 -26
- package/dist/astro/routes/api/schema/collections/_slug_/index.mjs.map +1 -1
- package/dist/astro/routes/api/schema/collections/index.mjs +28 -26
- package/dist/astro/routes/api/schema/collections/index.mjs.map +1 -1
- package/dist/astro/routes/api/schema/index.mjs +5 -5
- package/dist/astro/routes/api/schema/orphans/_slug_.mjs +28 -26
- package/dist/astro/routes/api/schema/orphans/_slug_.mjs.map +1 -1
- package/dist/astro/routes/api/schema/orphans/index.mjs +28 -26
- package/dist/astro/routes/api/schema/orphans/index.mjs.map +1 -1
- package/dist/astro/routes/api/search/enable.mjs +9 -8
- package/dist/astro/routes/api/search/enable.mjs.map +1 -1
- package/dist/astro/routes/api/search/index.mjs +8 -7
- package/dist/astro/routes/api/search/index.mjs.map +1 -1
- package/dist/astro/routes/api/search/rebuild.mjs +9 -8
- package/dist/astro/routes/api/search/rebuild.mjs.map +1 -1
- package/dist/astro/routes/api/search/stats.mjs +5 -5
- package/dist/astro/routes/api/search/suggest.mjs +8 -7
- package/dist/astro/routes/api/search/suggest.mjs.map +1 -1
- package/dist/astro/routes/api/sections/_slug_.mjs +8 -7
- package/dist/astro/routes/api/sections/_slug_.mjs.map +1 -1
- package/dist/astro/routes/api/sections/index.mjs +8 -7
- package/dist/astro/routes/api/sections/index.mjs.map +1 -1
- package/dist/astro/routes/api/settings/email.mjs +3 -3
- package/dist/astro/routes/api/settings.mjs +11 -9
- package/dist/astro/routes/api/settings.mjs.map +1 -1
- package/dist/astro/routes/api/setup/admin-verify.mjs +10 -9
- package/dist/astro/routes/api/setup/admin-verify.mjs.map +1 -1
- package/dist/astro/routes/api/setup/admin.mjs +9 -8
- package/dist/astro/routes/api/setup/admin.mjs.map +1 -1
- package/dist/astro/routes/api/setup/dev-bypass.mjs +19 -18
- package/dist/astro/routes/api/setup/dev-bypass.mjs.map +1 -1
- package/dist/astro/routes/api/setup/dev-reset.mjs +1 -1
- package/dist/astro/routes/api/setup/index.mjs +20 -18
- package/dist/astro/routes/api/setup/index.mjs.map +1 -1
- package/dist/astro/routes/api/setup/status.mjs +3 -3
- package/dist/astro/routes/api/snapshot.mjs +5 -4
- package/dist/astro/routes/api/snapshot.mjs.map +1 -1
- package/dist/astro/routes/api/taxonomies/_name_/terms/_slug_/translations.mjs +11 -10
- package/dist/astro/routes/api/taxonomies/_name_/terms/_slug_/translations.mjs.map +1 -1
- package/dist/astro/routes/api/taxonomies/_name_/terms/_slug_.mjs +11 -10
- package/dist/astro/routes/api/taxonomies/_name_/terms/_slug_.mjs.map +1 -1
- package/dist/astro/routes/api/taxonomies/_name_/terms/index.mjs +11 -10
- package/dist/astro/routes/api/taxonomies/_name_/terms/index.mjs.map +1 -1
- package/dist/astro/routes/api/taxonomies/index.mjs +11 -10
- package/dist/astro/routes/api/taxonomies/index.mjs.map +1 -1
- package/dist/astro/routes/api/themes/preview.mjs +5 -4
- package/dist/astro/routes/api/themes/preview.mjs.map +1 -1
- package/dist/astro/routes/api/typegen.mjs +4 -4
- package/dist/astro/routes/api/well-known/auth.mjs +1 -1
- package/dist/astro/routes/api/well-known/oauth-authorization-server.mjs +2 -2
- package/dist/astro/routes/api/well-known/oauth-protected-resource.mjs +2 -2
- package/dist/astro/routes/api/widget-areas/_name_/reorder.mjs +6 -5
- package/dist/astro/routes/api/widget-areas/_name_/reorder.mjs.map +1 -1
- package/dist/astro/routes/api/widget-areas/_name_/widgets/_id_.mjs +9 -8
- package/dist/astro/routes/api/widget-areas/_name_/widgets/_id_.mjs.map +1 -1
- package/dist/astro/routes/api/widget-areas/_name_/widgets.mjs +9 -8
- package/dist/astro/routes/api/widget-areas/_name_/widgets.mjs.map +1 -1
- package/dist/astro/routes/api/widget-areas/_name_.mjs +5 -5
- package/dist/astro/routes/api/widget-areas/index.mjs +9 -8
- package/dist/astro/routes/api/widget-areas/index.mjs.map +1 -1
- package/dist/astro/routes/api/widget-components.mjs +2 -2
- package/dist/astro/routes/robots.txt.mjs +5 -4
- package/dist/astro/routes/robots.txt.mjs.map +1 -1
- package/dist/astro/routes/sitemap-_collection_.xml.mjs +8 -7
- package/dist/astro/routes/sitemap-_collection_.xml.mjs.map +1 -1
- package/dist/astro/routes/sitemap.xml.mjs +6 -5
- package/dist/astro/routes/sitemap.xml.mjs.map +1 -1
- package/dist/astro/types.d.mts +12 -12
- package/dist/auth/providers/github.d.mts +1 -1
- package/dist/auth/providers/google.d.mts +1 -1
- package/dist/{authorize-DsMSVSaY.mjs → authorize-D5gfBVU5.mjs} +2 -2
- package/dist/{authorize-DsMSVSaY.mjs.map → authorize-D5gfBVU5.mjs.map} +1 -1
- package/dist/{byline-DUx48sJp.mjs → byline-V_Qp1Ziw.mjs} +27 -14
- package/dist/byline-V_Qp1Ziw.mjs.map +1 -0
- package/dist/{byline-fields-8TMtkBnH.mjs → byline-fields-B0NO1yUB.mjs} +3 -3
- package/dist/{byline-fields-8TMtkBnH.mjs.map → byline-fields-B0NO1yUB.mjs.map} +1 -1
- package/dist/{byline-fields-DbibsvTl.d.mts → byline-fields-CQJRIQkn.d.mts} +32 -32
- package/dist/{byline-fields-DbibsvTl.d.mts.map → byline-fields-CQJRIQkn.d.mts.map} +1 -1
- package/dist/{byline-fields--WxSNS79.mjs → byline-fields-nBVqK_Ff.mjs} +2 -2
- package/dist/{byline-fields--WxSNS79.mjs.map → byline-fields-nBVqK_Ff.mjs.map} +1 -1
- package/dist/{byline-registry-CWP7I71B.mjs → byline-registry-DedidtqC.mjs} +2 -2
- package/dist/{byline-registry-CWP7I71B.mjs.map → byline-registry-DedidtqC.mjs.map} +1 -1
- package/dist/{bylines-BdxWCnPL.mjs → bylines-B2NWnIwS.mjs} +2 -2
- package/dist/{bylines-BdxWCnPL.mjs.map → bylines-B2NWnIwS.mjs.map} +1 -1
- package/dist/{bylines-s8c2DXbH.mjs → bylines-DfGDnred.mjs} +7 -7
- package/dist/{bylines-s8c2DXbH.mjs.map → bylines-DfGDnred.mjs.map} +1 -1
- package/dist/{cache-B_HzASVT.mjs → cache-DTTHWD8n.mjs} +1 -1
- package/dist/{cache-B_HzASVT.mjs.map → cache-DTTHWD8n.mjs.map} +1 -1
- package/dist/{challenge-store-DXX3rfdI.mjs → challenge-store-woE0bbCf.mjs} +1 -1
- package/dist/{challenge-store-DXX3rfdI.mjs.map → challenge-store-woE0bbCf.mjs.map} +1 -1
- package/dist/cli/index.mjs +19 -18
- package/dist/cli/index.mjs.map +1 -1
- package/dist/client/cf-access.d.mts +1 -1
- package/dist/client/index.d.mts +1 -1
- package/dist/client/index.mjs +1 -1
- package/dist/{comments-Vkivawyl.mjs → comments-D2hNuxNa.mjs} +1 -1
- package/dist/{comments-Vkivawyl.mjs.map → comments-D2hNuxNa.mjs.map} +1 -1
- package/dist/{components-CK0cuUoH.mjs → components-DYKp2gmo.mjs} +1 -1
- package/dist/{components-CK0cuUoH.mjs.map → components-DYKp2gmo.mjs.map} +1 -1
- package/dist/{context-Y7BRkWes.mjs → context-Cm4pt1Ws.mjs} +5 -5
- package/dist/{context-Y7BRkWes.mjs.map → context-Cm4pt1Ws.mjs.map} +1 -1
- package/dist/{cron-BJ2ClIlj.mjs → cron-DdEVrQ2Y.mjs} +1 -1
- package/dist/{cron-BJ2ClIlj.mjs.map → cron-DdEVrQ2Y.mjs.map} +1 -1
- package/dist/{dashboard-2JgAMWxK.mjs → dashboard-C-UYpps0.mjs} +1 -1
- package/dist/{dashboard-2JgAMWxK.mjs.map → dashboard-C-UYpps0.mjs.map} +1 -1
- package/dist/db/index.d.mts +3 -3
- package/dist/db/libsql.d.mts +1 -1
- package/dist/db/postgres.d.mts +1 -1
- package/dist/db/sqlite.d.mts +1 -1
- package/dist/{db-errors-CtzxKBxe.mjs → db-errors-BluWkwGI.mjs} +1 -1
- package/dist/{db-errors-CtzxKBxe.mjs.map → db-errors-BluWkwGI.mjs.map} +1 -1
- package/dist/{default-IlBaTFxM.mjs → default-NHGuJzQ3.mjs} +1 -1
- package/dist/{default-IlBaTFxM.mjs.map → default-NHGuJzQ3.mjs.map} +1 -1
- package/dist/{device-flow-R23SIbQ2.mjs → device-flow-BQApWgnW.mjs} +4 -4
- package/dist/{device-flow-R23SIbQ2.mjs.map → device-flow-BQApWgnW.mjs.map} +1 -1
- package/dist/{email-console-DHT2Fbpj.mjs → email-console-BbU3RbWv.mjs} +1 -1
- package/dist/{email-console-DHT2Fbpj.mjs.map → email-console-BbU3RbWv.mjs.map} +1 -1
- package/dist/{error-RwM4dD35.mjs → error-CNn_w7jf.mjs} +1 -1
- package/dist/{error-RwM4dD35.mjs.map → error-CNn_w7jf.mjs.map} +1 -1
- package/dist/{escape-Ds07EEyu.mjs → escape-DPgcxcpL.mjs} +1 -1
- package/dist/{escape-Ds07EEyu.mjs.map → escape-DPgcxcpL.mjs.map} +1 -1
- package/dist/{fts-manager-1RgHmopc.mjs → fts-manager-Cx5z8jdA.mjs} +1 -1
- package/dist/{fts-manager-1RgHmopc.mjs.map → fts-manager-Cx5z8jdA.mjs.map} +1 -1
- package/dist/{hash-9w3pd3-m.mjs → hash-DlvIFn0b.mjs} +1 -1
- package/dist/{hash-9w3pd3-m.mjs.map → hash-DlvIFn0b.mjs.map} +1 -1
- package/dist/{import-Dh8bWmyq.mjs → import-KyxT1Mbs.mjs} +3 -3
- package/dist/{import-Dh8bWmyq.mjs.map → import-KyxT1Mbs.mjs.map} +1 -1
- package/dist/{index-B1keaX5Y.d.mts → index-D2VAiumu.d.mts} +15 -15
- package/dist/{index-B1keaX5Y.d.mts.map → index-D2VAiumu.d.mts.map} +1 -1
- package/dist/{index-DR56od45.d.mts → index-uT2yR66F.d.mts} +3 -3
- package/dist/{index-DR56od45.d.mts.map → index-uT2yR66F.d.mts.map} +1 -1
- package/dist/index.d.mts +16 -16
- package/dist/index.mjs +48 -46
- package/dist/init-lock-DlBHjf9-.mjs +83 -0
- package/dist/init-lock-DlBHjf9-.mjs.map +1 -0
- package/dist/{load-BBetCvLC.mjs → load-Dq91b_DK.mjs} +1 -1
- package/dist/{load-BBetCvLC.mjs.map → load-Dq91b_DK.mjs.map} +1 -1
- package/dist/{loader-ZN1ll-d-.mjs → loader-BqWjcH3h.mjs} +2 -2
- package/dist/{loader-ZN1ll-d-.mjs.map → loader-BqWjcH3h.mjs.map} +1 -1
- package/dist/{manifest-schema-BtwbL_vj.mjs → manifest-schema-DFPeqMAn.mjs} +1 -1
- package/dist/{manifest-schema-BtwbL_vj.mjs.map → manifest-schema-DFPeqMAn.mjs.map} +1 -1
- package/dist/media/index.d.mts +1 -1
- package/dist/media/index.mjs +2 -2
- package/dist/media/local-runtime.d.mts +11 -11
- package/dist/media/local-runtime.mjs +4 -3
- package/dist/media/local-runtime.mjs.map +1 -1
- package/dist/{media-allowlist-Dknq-OFY.mjs → media-allowlist-_A0SuDn4.mjs} +2 -2
- package/dist/{media-allowlist-Dknq-OFY.mjs.map → media-allowlist-_A0SuDn4.mjs.map} +1 -1
- package/dist/{media-url-VClf8glU.mjs → media-url-CqLd69IO.mjs} +1 -1
- package/dist/{media-url-VClf8glU.mjs.map → media-url-CqLd69IO.mjs.map} +1 -1
- package/dist/{menus-DrQLusqj.mjs → menus-Ryk9L7fT.mjs} +9 -9
- package/dist/{menus-DrQLusqj.mjs.map → menus-Ryk9L7fT.mjs.map} +1 -1
- package/dist/{mime-CCEzze7W.mjs → mime-YbtlEtvS.mjs} +1 -1
- package/dist/{mime-CCEzze7W.mjs.map → mime-YbtlEtvS.mjs.map} +1 -1
- package/dist/{mode-CO2vQHfq.mjs → mode-CGXzIbD8.mjs} +1 -1
- package/dist/{mode-CO2vQHfq.mjs.map → mode-CGXzIbD8.mjs.map} +1 -1
- package/dist/{normalize-CK5o04zr.mjs → normalize-DKsg36ty.mjs} +1 -1
- package/dist/{normalize-CK5o04zr.mjs.map → normalize-DKsg36ty.mjs.map} +1 -1
- package/dist/{oauth-authorization-Bw4NdF_S.mjs → oauth-authorization-C2kVyjXI.mjs} +4 -4
- package/dist/{oauth-authorization-Bw4NdF_S.mjs.map → oauth-authorization-C2kVyjXI.mjs.map} +1 -1
- package/dist/{oauth-clients-BGGFp57s.mjs → oauth-clients-BC873NCV.mjs} +1 -1
- package/dist/{oauth-clients-BGGFp57s.mjs.map → oauth-clients-BC873NCV.mjs.map} +1 -1
- package/dist/{oauth-state-store-97x0xtN2.mjs → oauth-state-store-Cd--TUaq.mjs} +1 -1
- package/dist/{oauth-state-store-97x0xtN2.mjs.map → oauth-state-store-Cd--TUaq.mjs.map} +1 -1
- package/dist/{oauth-user-lookup-B_vnZHKO.mjs → oauth-user-lookup-e4wOvDud.mjs} +1 -1
- package/dist/{oauth-user-lookup-B_vnZHKO.mjs.map → oauth-user-lookup-e4wOvDud.mjs.map} +1 -1
- package/dist/{options-DyYIYpPd.d.mts → options-9kLgkE8m.d.mts} +3 -3
- package/dist/{options-DyYIYpPd.d.mts.map → options-9kLgkE8m.d.mts.map} +1 -1
- package/dist/page/index.d.mts +2 -2
- package/dist/{parse-CrGndy1A.mjs → parse-DzSrk1t8.mjs} +2 -2
- package/dist/{parse-CrGndy1A.mjs.map → parse-DzSrk1t8.mjs.map} +1 -1
- package/dist/{passkey-config-C3QgnQnU.mjs → passkey-config-BpjbE_Uv.mjs} +1 -1
- package/dist/{passkey-config-C3QgnQnU.mjs.map → passkey-config-BpjbE_Uv.mjs.map} +1 -1
- package/dist/{placeholder-BZxr8W1j.mjs → placeholder-2xumZh4g.mjs} +1 -1
- package/dist/{placeholder-BZxr8W1j.mjs.map → placeholder-2xumZh4g.mjs.map} +1 -1
- package/dist/{placeholder-CVBv5z8k.d.mts → placeholder-BevVKfay.d.mts} +1 -1
- package/dist/{placeholder-CVBv5z8k.d.mts.map → placeholder-BevVKfay.d.mts.map} +1 -1
- package/dist/plugin-types.d.mts +1 -1
- package/dist/plugin-utils.d.mts +9 -9
- package/dist/plugins/adapt-sandbox-entry.d.mts +9 -9
- package/dist/plugins/adapt-sandbox-entry.mjs +2 -2
- package/dist/{preview-BfuRkVKW.mjs → preview-Dqv2hwXr.mjs} +2 -2
- package/dist/{preview-BfuRkVKW.mjs.map → preview-Dqv2hwXr.mjs.map} +1 -1
- package/dist/{public-url-BFVC2OTJ.mjs → public-url-D_zARuvZ.mjs} +1 -1
- package/dist/{public-url-BFVC2OTJ.mjs.map → public-url-D_zARuvZ.mjs.map} +1 -1
- package/dist/{query-CbUcI4Xk.mjs → query-Crm038Mc.mjs} +9 -9
- package/dist/{query-CbUcI4Xk.mjs.map → query-Crm038Mc.mjs.map} +1 -1
- package/dist/{rate-limit-C7hjdkS5.mjs → rate-limit-hRTBqmw1.mjs} +2 -2
- package/dist/{rate-limit-C7hjdkS5.mjs.map → rate-limit-hRTBqmw1.mjs.map} +1 -1
- package/dist/{redirect-B_q19j4v.mjs → redirect-C-OOkyku.mjs} +1 -1
- package/dist/{redirect-B_q19j4v.mjs.map → redirect-C-OOkyku.mjs.map} +1 -1
- package/dist/{redirects-CCbCqCCd.mjs → redirects-6Zg2SoYo.mjs} +8 -9
- package/dist/{redirects-CCbCqCCd.mjs.map → redirects-6Zg2SoYo.mjs.map} +1 -1
- package/dist/{redirects-DxVoR7PI.mjs → redirects-CP3TnTLO.mjs} +20 -14
- package/dist/redirects-CP3TnTLO.mjs.map +1 -0
- package/dist/{registry-brYh-rAT.mjs → registry-diMzD1Wf.mjs} +3 -3
- package/dist/{registry-brYh-rAT.mjs.map → registry-diMzD1Wf.mjs.map} +1 -1
- package/dist/{request-cache-D32LpnmI.mjs → request-cache-UwmBAiUK.mjs} +1 -1
- package/dist/{request-cache-D32LpnmI.mjs.map → request-cache-UwmBAiUK.mjs.map} +1 -1
- package/dist/{request-meta-7ByVLxB-.mjs → request-meta-DPechd0W.mjs} +2 -2
- package/dist/{request-meta-7ByVLxB-.mjs.map → request-meta-DPechd0W.mjs.map} +1 -1
- package/dist/{resolve-BqYMVG0D.mjs → resolve-B3NUUtVY.mjs} +1 -1
- package/dist/{resolve-BqYMVG0D.mjs.map → resolve-B3NUUtVY.mjs.map} +1 -1
- package/dist/{runner-DTdhuI9i.d.mts → runner-C8vcbvCe.d.mts} +2 -2
- package/dist/{runner-DTdhuI9i.d.mts.map → runner-C8vcbvCe.d.mts.map} +1 -1
- package/dist/runtime.d.mts +10 -10
- package/dist/runtime.mjs +1 -1
- package/dist/{schema-C1E70ug_.mjs → schema-BDOkd3OU.mjs} +4 -4
- package/dist/{schema-C1E70ug_.mjs.map → schema-BDOkd3OU.mjs.map} +1 -1
- package/dist/{search-B3SGZw91.mjs → search-Bs_J_EW-.mjs} +3 -3
- package/dist/{search-B3SGZw91.mjs.map → search-Bs_J_EW-.mjs.map} +1 -1
- package/dist/{secrets-ChPTmy9x.mjs → secrets-C8xmE6mR.mjs} +21 -11
- package/dist/secrets-C8xmE6mR.mjs.map +1 -0
- package/dist/{sections-D_lVzwRZ.mjs → sections-P0zuBlyz.mjs} +2 -2
- package/dist/{sections-D_lVzwRZ.mjs.map → sections-P0zuBlyz.mjs.map} +1 -1
- package/dist/seed/index.d.mts +2 -2
- package/dist/seed/index.mjs +14 -13
- package/dist/seo/index.d.mts +1 -1
- package/dist/seo/index.mjs +1 -1
- package/dist/{seo-D_LPkOtu.mjs → seo-CLhm-Fmb.mjs} +1 -1
- package/dist/{seo-D_LPkOtu.mjs.map → seo-CLhm-Fmb.mjs.map} +1 -1
- package/dist/{seo-B5e6y9Wk.mjs → seo-DpNgGQjF.mjs} +1 -1
- package/dist/{seo-B5e6y9Wk.mjs.map → seo-DpNgGQjF.mjs.map} +1 -1
- package/dist/{service-ChDcsTBs.mjs → service-CDQQnT8W.mjs} +2 -2
- package/dist/{service-ChDcsTBs.mjs.map → service-CDQQnT8W.mjs.map} +1 -1
- package/dist/{settings-DfxiWY_s.mjs → settings-BjBsmVAo.mjs} +10 -184
- package/dist/settings-BjBsmVAo.mjs.map +1 -0
- package/dist/{settings-Cv47v9u8.mjs → settings-sO0Fif4p.mjs} +2 -2
- package/dist/{settings-Cv47v9u8.mjs.map → settings-sO0Fif4p.mjs.map} +1 -1
- package/dist/{setup-complete-yvPE4OsP.mjs → setup-complete-CMMr-oZU.mjs} +1 -1
- package/dist/{setup-complete-yvPE4OsP.mjs.map → setup-complete-CMMr-oZU.mjs.map} +1 -1
- package/dist/{setup-nonce-C9aFzb94.mjs → setup-nonce-169xl4fV.mjs} +1 -1
- package/dist/{setup-nonce-C9aFzb94.mjs.map → setup-nonce-169xl4fV.mjs.map} +1 -1
- package/dist/single-flight-cache-C0UV1Npg.mjs +104 -0
- package/dist/single-flight-cache-C0UV1Npg.mjs.map +1 -0
- package/dist/{site-url-CnHlmAs9.mjs → site-url-vtsuOvSD.mjs} +1 -1
- package/dist/{site-url-CnHlmAs9.mjs.map → site-url-vtsuOvSD.mjs.map} +1 -1
- package/dist/{ssrf-BsVGIE0Z.mjs → ssrf-XO05Voq6.mjs} +1 -1
- package/dist/{ssrf-BsVGIE0Z.mjs.map → ssrf-XO05Voq6.mjs.map} +1 -1
- package/dist/status-2gZklYuj.mjs +30 -0
- package/dist/status-2gZklYuj.mjs.map +1 -0
- package/dist/storage/local.d.mts +1 -1
- package/dist/storage/local.mjs +2 -2
- package/dist/storage/s3.d.mts +1 -1
- package/dist/storage/s3.mjs +1 -1
- package/dist/{taxonomies-BdAmbOwx.mjs → taxonomies-BBxYA38v.mjs} +6 -6
- package/dist/{taxonomies-BdAmbOwx.mjs.map → taxonomies-BBxYA38v.mjs.map} +1 -1
- package/dist/{taxonomies-BILwiyGk.mjs → taxonomies-DuESHWKI.mjs} +2 -2
- package/dist/{taxonomies-BILwiyGk.mjs.map → taxonomies-DuESHWKI.mjs.map} +1 -1
- package/dist/{tokens-Bx2afeT-.mjs → tokens-DMkVjxrx.mjs} +1 -1
- package/dist/{tokens-Bx2afeT-.mjs.map → tokens-DMkVjxrx.mjs.map} +1 -1
- package/dist/{transport-CmpLD7W3.mjs → transport-1cIrOb1Y.mjs} +1 -1
- package/dist/{transport-CmpLD7W3.mjs.map → transport-1cIrOb1Y.mjs.map} +1 -1
- package/dist/{transport-B7PPP2CC.d.mts → transport-jdvsZEIt.d.mts} +1 -1
- package/dist/{transport-B7PPP2CC.d.mts.map → transport-jdvsZEIt.d.mts.map} +1 -1
- package/dist/{trusted-proxy-B4AfnoAp.mjs → trusted-proxy-CHp41Fjj.mjs} +1 -1
- package/dist/{trusted-proxy-B4AfnoAp.mjs.map → trusted-proxy-CHp41Fjj.mjs.map} +1 -1
- package/dist/{types-BFgrqwSk.d.mts → types-BFgYtuKd.d.mts} +1 -1
- package/dist/{types-BFgrqwSk.d.mts.map → types-BFgYtuKd.d.mts.map} +1 -1
- package/dist/{types-DZk_y-MU.mjs → types-BIduXPJk.mjs} +1 -1
- package/dist/{types-DZk_y-MU.mjs.map → types-BIduXPJk.mjs.map} +1 -1
- package/dist/{types-DTniiNto.d.mts → types-BTnnBYVX.d.mts} +2 -2
- package/dist/{types-DTniiNto.d.mts.map → types-BTnnBYVX.d.mts.map} +1 -1
- package/dist/{types-BUUVn1zr.d.mts → types-Bzfk2yC8.d.mts} +1 -1
- package/dist/{types-BUUVn1zr.d.mts.map → types-Bzfk2yC8.d.mts.map} +1 -1
- package/dist/{types-BH8-30hc.d.mts → types-CkEuk-Zr.d.mts} +1 -1
- package/dist/{types-BH8-30hc.d.mts.map → types-CkEuk-Zr.d.mts.map} +1 -1
- package/dist/{types-CPAPl93j.d.mts → types-DO7whVYU.d.mts} +2 -2
- package/dist/{types-CPAPl93j.d.mts.map → types-DO7whVYU.d.mts.map} +1 -1
- package/dist/{types-S15DXXNi.d.mts → types-DdkL6fyv.d.mts} +1 -1
- package/dist/{types-S15DXXNi.d.mts.map → types-DdkL6fyv.d.mts.map} +1 -1
- package/dist/{types-DpFmlNyB.mjs → types-DejCHqWT.mjs} +1 -1
- package/dist/{types-DpFmlNyB.mjs.map → types-DejCHqWT.mjs.map} +1 -1
- package/dist/{types-BPzXTV9x.d.mts → types-Del0VMij.d.mts} +1 -1
- package/dist/{types-BPzXTV9x.d.mts.map → types-Del0VMij.d.mts.map} +1 -1
- package/dist/{types-D4kUqbHh.d.mts → types-u_XxjbS8.d.mts} +1 -1
- package/dist/{types-D4kUqbHh.d.mts.map → types-u_XxjbS8.d.mts.map} +1 -1
- package/dist/{utils-C4Ih4DML.mjs → utils-C4M981Br.mjs} +1 -1
- package/dist/{utils-C4Ih4DML.mjs.map → utils-C4M981Br.mjs.map} +1 -1
- package/dist/{validate-Bz4vqcX1.mjs → validate-DGhQPXzI.mjs} +2 -2
- package/dist/{validate-Bz4vqcX1.mjs.map → validate-DGhQPXzI.mjs.map} +1 -1
- package/dist/{validate-CNwkPWzz.d.mts → validate-cJOiOvT2.d.mts} +5 -5
- package/dist/{validate-CNwkPWzz.d.mts.map → validate-cJOiOvT2.d.mts.map} +1 -1
- package/dist/{validation-DgGTJm3u.mjs → validation-DVHjPM1M.mjs} +5 -5
- package/dist/{validation-DgGTJm3u.mjs.map → validation-DVHjPM1M.mjs.map} +1 -1
- package/dist/version-BOjj_cfz.mjs +7 -0
- package/dist/{version-D-5txk2m.mjs.map → version-BOjj_cfz.mjs.map} +1 -1
- package/dist/{widgets-DZfmAbE4.mjs → widgets-Ci6hLwfO.mjs} +4 -4
- package/dist/{widgets-DZfmAbE4.mjs.map → widgets-Ci6hLwfO.mjs.map} +1 -1
- package/dist/{zod-generator-Djo_VHCt.mjs → zod-generator-CarzgPAu.mjs} +2 -2
- package/dist/{zod-generator-Djo_VHCt.mjs.map → zod-generator-CarzgPAu.mjs.map} +1 -1
- package/package.json +5 -5
- package/src/api/handlers/redirects.ts +24 -13
- package/src/api/schemas/redirects.ts +11 -4
- package/src/astro/integration/index.ts +44 -8
- package/src/astro/integration/routes.ts +46 -9
- package/src/astro/middleware/redirect.ts +12 -0
- package/src/bylines/field-defs-cache.ts +70 -20
- package/src/cli/commands/doctor.ts +1 -1
- package/src/config/secrets.ts +28 -14
- package/src/emdash-runtime.ts +5 -5
- package/src/redirects/status.ts +27 -0
- package/src/settings/index.ts +13 -13
- package/src/utils/{isolate-cache.ts → single-flight-cache.ts} +26 -21
- package/dist/byline-DUx48sJp.mjs.map +0 -1
- package/dist/redirects-DxVoR7PI.mjs.map +0 -1
- package/dist/secrets-ChPTmy9x.mjs.map +0 -1
- package/dist/settings-DfxiWY_s.mjs.map +0 -1
- package/dist/version-D-5txk2m.mjs +0 -7
- /package/dist/{api-tokens-Oq39ba-Z.mjs → api-tokens-C7ywRx7l.mjs} +0 -0
- /package/dist/{ssrf-BvgVcfNQ.mjs → ssrf-CRZGzjdL.mjs} +0 -0
- /package/dist/{types-CZI4E3qG.mjs → types-BoRm8-pp.mjs} +0 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ssrf-BsVGIE0Z.mjs","names":[],"sources":["../src/security/ssrf.ts"],"sourcesContent":["/**\n * SSRF protection for import URLs.\n *\n * Validates that URLs don't target internal/private network addresses.\n * Applied before any fetch() call in the import pipeline.\n */\n\nconst IPV4_MAPPED_IPV6_DOTTED_PATTERN = /^::ffff:(\\d+\\.\\d+\\.\\d+\\.\\d+)$/i;\nconst IPV4_MAPPED_IPV6_HEX_PATTERN = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i;\nconst IPV4_TRANSLATED_HEX_PATTERN = /^::ffff:0:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i;\nconst IPV6_EXPANDED_MAPPED_PATTERN =\n\t/^0{0,4}:0{0,4}:0{0,4}:0{0,4}:0{0,4}:ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i;\n\n/**\n * IPv4-compatible (deprecated) addresses: ::XXXX:XXXX\n *\n * The WHATWG URL parser normalizes [::127.0.0.1] to [::7f00:1] (no ffff prefix).\n * These are deprecated but still parsed, and bypass the ffff-based checks.\n */\nconst IPV4_COMPATIBLE_HEX_PATTERN = /^::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i;\n\n/**\n * NAT64 prefix (RFC 6052): 64:ff9b::XXXX:XXXX\n *\n * Used by NAT64 gateways to embed IPv4 addresses in IPv6.\n * [64:ff9b::127.0.0.1] normalizes to [64:ff9b::7f00:1].\n */\nconst NAT64_HEX_PATTERN = /^64:ff9b::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i;\n\nconst IPV6_BRACKET_PATTERN = /^\\[|\\]$/g;\n\n/** Match fc00::/7 ULA — first byte 0xfc or 0xfd followed by any byte. */\nconst IPV6_ULA_FC_PATTERN = /^fc[0-9a-f]{2}:/;\nconst IPV6_ULA_FD_PATTERN = /^fd[0-9a-f]{2}:/;\n\n/** Strip trailing dots from an FQDN-form hostname (\"localhost.\" -> \"localhost\"). */\nconst TRAILING_DOT_PATTERN = /\\.+$/;\n\n/**\n * Private and reserved IP ranges that should never be fetched.\n *\n * Includes:\n * - Loopback (127.0.0.0/8)\n * - Private (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)\n * - Link-local (169.254.0.0/16)\n * - Cloud metadata (169.254.169.254 — AWS/GCP/Azure)\n * - IPv6 loopback and link-local\n */\nconst BLOCKED_PATTERNS: Array<{ start: number; end: number }> = [\n\t// 127.0.0.0/8 — loopback\n\t{ start: ip4ToNum(127, 0, 0, 0), end: ip4ToNum(127, 255, 255, 255) },\n\t// 10.0.0.0/8 — private\n\t{ start: ip4ToNum(10, 0, 0, 0), end: ip4ToNum(10, 255, 255, 255) },\n\t// 172.16.0.0/12 — private\n\t{ start: ip4ToNum(172, 16, 0, 0), end: ip4ToNum(172, 31, 255, 255) },\n\t// 192.168.0.0/16 — private\n\t{ start: ip4ToNum(192, 168, 0, 0), end: ip4ToNum(192, 168, 255, 255) },\n\t// 169.254.0.0/16 — link-local (includes cloud metadata endpoint)\n\t{ start: ip4ToNum(169, 254, 0, 0), end: ip4ToNum(169, 254, 255, 255) },\n\t// 0.0.0.0/8 — current network\n\t{ start: ip4ToNum(0, 0, 0, 0), end: ip4ToNum(0, 255, 255, 255) },\n];\n\n// Bracket-stripped form is used for lookups (validateExternalUrl strips\n// brackets from parsed.hostname before checking), so \"::1\" appears here\n// without brackets. The \"::1\" case is already covered by isPrivateIp, but\n// keeping it here makes the intent explicit and gives a clearer error\n// message for the common `http://[::1]/` form.\nconst BLOCKED_HOSTNAMES = new Set([\n\t\"localhost\",\n\t\"metadata.google.internal\",\n\t\"metadata.google\",\n\t\"::1\",\n]);\n\n/**\n * Wildcard DNS services that publicly resolve arbitrary IPs embedded in the\n * hostname. Commonly used in local dev and by SSRF exploit tooling to bypass\n * hostname-only blocklists (e.g. 127.0.0.1.nip.io -> 127.0.0.1).\n *\n * Matched case-insensitively as a suffix, so both the apex and any subdomain\n * are blocked.\n */\nconst BLOCKED_HOSTNAME_SUFFIXES = [\n\t\"nip.io\",\n\t\"sslip.io\",\n\t\"xip.io\",\n\t\"traefik.me\",\n\t\"lvh.me\",\n\t\"localtest.me\",\n];\n\n/** Blocked URL schemes */\nconst ALLOWED_SCHEMES = new Set([\"http:\", \"https:\"]);\n\nfunction ip4ToNum(a: number, b: number, c: number, d: number): number {\n\treturn ((a << 24) | (b << 16) | (c << 8) | d) >>> 0;\n}\n\nfunction parseIpv4(ip: string): number | null {\n\tconst parts = ip.split(\".\");\n\tif (parts.length !== 4) return null;\n\n\tconst nums = parts.map(Number);\n\tif (nums.some((n) => isNaN(n) || n < 0 || n > 255)) return null;\n\n\treturn ip4ToNum(nums[0], nums[1], nums[2], nums[3]);\n}\n\n/**\n * Convert IPv4-mapped/translated IPv6 addresses from hex form back to IPv4.\n *\n * The WHATWG URL parser normalizes dotted-decimal to hex:\n * [::ffff:127.0.0.1] -> [::ffff:7f00:1]\n * [::ffff:169.254.169.254] -> [::ffff:a9fe:a9fe]\n *\n * Without this conversion, the hex forms bypass isPrivateIp() regex checks.\n */\nexport function normalizeIPv6MappedToIPv4(ip: string): string | null {\n\t// Match hex-form IPv4-mapped IPv6: ::ffff:XXXX:XXXX\n\tlet match = ip.match(IPV4_MAPPED_IPV6_HEX_PATTERN);\n\tif (!match) {\n\t\t// Match IPv4-translated (RFC 6052): ::ffff:0:XXXX:XXXX\n\t\tmatch = ip.match(IPV4_TRANSLATED_HEX_PATTERN);\n\t}\n\tif (!match) {\n\t\t// Match fully expanded form: 0000:0000:0000:0000:0000:ffff:XXXX:XXXX\n\t\tmatch = ip.match(IPV6_EXPANDED_MAPPED_PATTERN);\n\t}\n\tif (!match) {\n\t\t// Match IPv4-compatible (deprecated) form: ::XXXX:XXXX (no ffff prefix)\n\t\tmatch = ip.match(IPV4_COMPATIBLE_HEX_PATTERN);\n\t}\n\tif (!match) {\n\t\t// Match NAT64 prefix (RFC 6052): 64:ff9b::XXXX:XXXX\n\t\tmatch = ip.match(NAT64_HEX_PATTERN);\n\t}\n\tif (match) {\n\t\tconst high = parseInt(match[1] ?? \"\", 16);\n\t\tconst low = parseInt(match[2] ?? \"\", 16);\n\t\treturn `${(high >> 8) & 0xff}.${high & 0xff}.${(low >> 8) & 0xff}.${low & 0xff}`;\n\t}\n\treturn null;\n}\n\nfunction isPrivateIp(ip: string): boolean {\n\t// Normalize IPv6 strings to lowercase. `new URL().hostname` already\n\t// lowercases, but resolver output (from DoH or an injected resolver) may\n\t// not. Without this, \"FE80::1\" bypasses the link-local check.\n\tconst normalized = ip.toLowerCase();\n\n\t// Handle IPv6 loopback\n\tif (normalized === \"::1\" || normalized === \"::ffff:127.0.0.1\") return true;\n\n\t// Handle IPv4-mapped IPv6 in hex form (WHATWG URL parser normalizes to this)\n\t// e.g. ::ffff:7f00:1 -> 127.0.0.1, ::ffff:a9fe:a9fe -> 169.254.169.254\n\tconst hexIpv4 = normalizeIPv6MappedToIPv4(normalized);\n\tif (hexIpv4) return isPrivateIp(hexIpv4);\n\n\t// Handle IPv4-mapped IPv6 in dotted-decimal form\n\tconst v4Match = normalized.match(IPV4_MAPPED_IPV6_DOTTED_PATTERN);\n\tconst ipv4 = v4Match ? v4Match[1] : normalized;\n\n\tconst num = parseIpv4(ipv4);\n\tif (num === null) {\n\t\t// If we can't parse it, block IPv6 addresses that look internal.\n\t\t// fc00::/7 is Unique Local (first byte 0xfc or 0xfd), fe80::/10 is\n\t\t// link-local. Only match when followed by hex digit + colon to avoid\n\t\t// collisions with hypothetical non-address strings.\n\t\treturn (\n\t\t\tnormalized.startsWith(\"fe80:\") ||\n\t\t\tIPV6_ULA_FC_PATTERN.test(normalized) ||\n\t\t\tIPV6_ULA_FD_PATTERN.test(normalized)\n\t\t);\n\t}\n\n\treturn BLOCKED_PATTERNS.some((range) => num >= range.start && num <= range.end);\n}\n\n/**\n * Error thrown when SSRF protection blocks a URL.\n */\nexport class SsrfError extends Error {\n\tcode = \"SSRF_BLOCKED\" as const;\n\n\tconstructor(message: string) {\n\t\tsuper(message);\n\t\tthis.name = \"SsrfError\";\n\t}\n}\n\n/**\n * Validate that a URL is safe to fetch (not targeting internal networks).\n *\n * Checks:\n * 1. URL is well-formed with http/https scheme\n * 2. Hostname is not a known internal name (localhost, metadata endpoints)\n * 3. If hostname is an IP literal, it's not in a private range\n *\n * Note: DNS rebinding attacks are not fully mitigated (hostname could resolve\n * to a private IP). Full protection requires resolving DNS and checking the IP\n * before connecting, which needs a custom fetch implementation. This covers\n * the most common SSRF vectors.\n *\n * @throws SsrfError if the URL targets an internal address\n */\n/** Maximum number of redirects to follow in ssrfSafeFetch */\nconst MAX_REDIRECTS = 5;\n\nexport function validateExternalUrl(url: string): URL {\n\tlet parsed: URL;\n\ttry {\n\t\tparsed = new URL(url);\n\t} catch {\n\t\tthrow new SsrfError(\"Invalid URL\");\n\t}\n\n\t// Only allow http/https\n\tif (!ALLOWED_SCHEMES.has(parsed.protocol)) {\n\t\tthrow new SsrfError(`Scheme '${parsed.protocol}' is not allowed`);\n\t}\n\n\t// Strip brackets from IPv6 hostname\n\tconst hostname = parsed.hostname.replace(IPV6_BRACKET_PATTERN, \"\");\n\n\t// Normalize the hostname for blocklist matching: lowercase + strip any\n\t// trailing dots. WHATWG preserves trailing dots on .hostname, so without\n\t// this normalization \"localhost.\" and \"nip.io.\" bypass the checks.\n\tconst normalizedHost = hostname.toLowerCase().replace(TRAILING_DOT_PATTERN, \"\");\n\n\t// Check against known internal hostnames\n\tif (BLOCKED_HOSTNAMES.has(normalizedHost)) {\n\t\tthrow new SsrfError(\"URLs targeting internal hosts are not allowed\");\n\t}\n\n\t// Check against wildcard DNS services used by SSRF tooling to bypass\n\t// hostname-only checks. Match the apex and any subdomain.\n\tfor (const suffix of BLOCKED_HOSTNAME_SUFFIXES) {\n\t\tif (normalizedHost === suffix || normalizedHost.endsWith(`.${suffix}`)) {\n\t\t\tthrow new SsrfError(\"URLs targeting wildcard DNS services are not allowed\");\n\t\t}\n\t}\n\n\t// Check if hostname is an IP address in a private range. Use the\n\t// normalized form so \"127.0.0.1..\" and friends don't bypass parseIpv4\n\t// (which rejects extra trailing dots).\n\tif (isPrivateIp(normalizedHost)) {\n\t\tthrow new SsrfError(\"URLs targeting private IP addresses are not allowed\");\n\t}\n\n\treturn parsed;\n}\n\n// ---------------------------------------------------------------------------\n// DNS-aware validation\n// ---------------------------------------------------------------------------\n\n/**\n * A resolver that maps a hostname to a list of IPv4/IPv6 addresses.\n * Injectable so callers can swap in OS-level DNS on Node, stub it in tests,\n * or point to a different DoH endpoint.\n */\nexport type DnsResolver = (hostname: string) => Promise<string[]>;\n\n/**\n * Module-level default resolver. Tests can swap this with a stub so fetch\n * mocks don't see unexpected DoH round-trips. Production code should leave\n * it alone.\n */\nlet defaultResolver: DnsResolver | null = null;\n\n/** Override the default DNS resolver. Returns the previous value. */\nexport function setDefaultDnsResolver(resolver: DnsResolver | null): DnsResolver | null {\n\tconst previous = defaultResolver;\n\tdefaultResolver = resolver;\n\treturn previous;\n}\n\n/** Timeout for a single DoH request, in milliseconds. */\nconst DOH_TIMEOUT_MS = 3000;\n\n/** Default DoH endpoint — Cloudflare's public resolver. */\nconst DEFAULT_DOH_URL = \"https://cloudflare-dns.com/dns-query\";\n\ninterface DohAnswer {\n\tdata: string;\n}\n\ninterface DohResponse {\n\tStatus: number;\n\tAnswer: DohAnswer[];\n}\n\nfunction hasProperty<K extends string>(obj: unknown, key: K): obj is Record<K, unknown> {\n\treturn typeof obj === \"object\" && obj !== null && key in obj;\n}\n\n/**\n * Narrow an unknown JSON body to a DohResponse shape we can read safely.\n * Throws if the body doesn't look like a DoH response — a malformed body is\n * indistinguishable from a failure and must not be silently treated as empty.\n */\nfunction parseDohResponse(raw: unknown): DohResponse {\n\tif (!hasProperty(raw, \"Status\") || typeof raw.Status !== \"number\") {\n\t\tthrow new Error(\"DoH response missing Status field\");\n\t}\n\tconst answers: DohAnswer[] = [];\n\tif (hasProperty(raw, \"Answer\") && Array.isArray(raw.Answer)) {\n\t\tfor (const entry of raw.Answer) {\n\t\t\tif (hasProperty(entry, \"data\") && typeof entry.data === \"string\") {\n\t\t\t\tanswers.push({ data: entry.data });\n\t\t\t}\n\t\t}\n\t}\n\treturn { Status: raw.Status, Answer: answers };\n}\n\n/**\n * Resolve a hostname via DNS over HTTPS (Cloudflare). Returns all A and AAAA\n * records. Works in both Workers and Node without requiring node:dns.\n *\n * Fails closed: any network error, non-2xx response, or DNS rcode != 0\n * causes a rejected promise so the calling validator treats it as a block.\n */\nexport const cloudflareDohResolver: DnsResolver = async (hostname) => {\n\tasync function query(type: \"A\" | \"AAAA\"): Promise<string[]> {\n\t\tconst params = new URLSearchParams({ name: hostname, type });\n\t\tconst controller = new AbortController();\n\t\tconst timeout = setTimeout(() => controller.abort(), DOH_TIMEOUT_MS);\n\t\ttry {\n\t\t\tconst response = await globalThis.fetch(`${DEFAULT_DOH_URL}?${params.toString()}`, {\n\t\t\t\theaders: { Accept: \"application/dns-json\" },\n\t\t\t\tsignal: controller.signal,\n\t\t\t});\n\t\t\tif (!response.ok) {\n\t\t\t\tthrow new Error(`DoH lookup failed: ${response.status}`);\n\t\t\t}\n\t\t\tconst raw = await response.json();\n\t\t\tconst body = parseDohResponse(raw);\n\t\t\t// NXDOMAIN (3) is a legitimate \"does not exist\" — treat as empty.\n\t\t\t// Any other non-zero status (SERVFAIL=2, REFUSED=5, etc.) is\n\t\t\t// ambiguous and could be a split-view attacker hiding records\n\t\t\t// from our resolver. Fail closed.\n\t\t\tif (body.Status === 3) return [];\n\t\t\tif (body.Status !== 0) {\n\t\t\t\tthrow new Error(`DoH ${type} lookup failed: rcode=${body.Status}`);\n\t\t\t}\n\t\t\t// DoH Answer arrays often include CNAME records alongside A/AAAA\n\t\t\t// records. Their `data` is a hostname, not an IP. Filter to just\n\t\t\t// IP literals so isPrivateIp sees real addresses.\n\t\t\treturn body.Answer.map((a) => a.data).filter(isIpLiteral);\n\t\t} finally {\n\t\t\tclearTimeout(timeout);\n\t\t}\n\t}\n\n\tconst [a, aaaa] = await Promise.all([query(\"A\"), query(\"AAAA\")]);\n\treturn [...a, ...aaaa];\n};\n\n/**\n * Validate a URL and resolve its hostname to check the actual IPs against\n * the private-range blocklist. This catches DNS rebinding attacks using\n * attacker-controlled domains that publicly resolve to private addresses,\n * and wildcard DNS services like nip.io used by exploit tooling.\n *\n * Runs `validateExternalUrl` first for cheap pre-flight checks (scheme,\n * literal IP, known-bad hostnames). Then resolves the hostname and rejects\n * if ANY returned address is private.\n *\n * Fails closed: if resolution fails or returns no records, throws SsrfError.\n *\n * **Caveats.** This does NOT fully close the TOCTOU between check and\n * connect. Attacks that still work against this layer include:\n *\n * - TTL=0 rebind: authoritative server returns public IP to the check, then\n * private IP to the subsequent fetch() a few milliseconds later.\n * - Split-view via EDNS Client Subnet or source-IP inspection: the\n * authoritative server returns public IP to Cloudflare's DoH resolver and\n * private IP to the victim's own resolver (used by fetch()).\n * - Host-file overrides or split-horizon corporate DNS on self-hosted Node.\n * - Attacker-controlled rebinding services the caller has allowlisted.\n *\n * The only complete defense is a network-layer egress firewall. On\n * Cloudflare Workers, the platform fetch pipeline provides most of that.\n * On self-hosted Node, operators must restrict egress themselves.\n */\nexport async function resolveAndValidateExternalUrl(\n\turl: string,\n\toptions?: { resolver?: DnsResolver },\n): Promise<URL> {\n\tconst parsed = validateExternalUrl(url);\n\n\t// Strip brackets from IPv6 hostnames\n\tconst hostname = parsed.hostname.replace(IPV6_BRACKET_PATTERN, \"\");\n\n\t// If the hostname is already an IP literal, validateExternalUrl has\n\t// already checked it against the private-range list. Skip DNS.\n\tif (isIpLiteral(hostname)) {\n\t\treturn parsed;\n\t}\n\n\tconst resolver = options?.resolver ?? defaultResolver ?? cloudflareDohResolver;\n\n\tlet addresses: string[];\n\ttry {\n\t\taddresses = await resolver(hostname);\n\t} catch (error) {\n\t\tthrow new SsrfError(\n\t\t\t`Could not resolve hostname: ${error instanceof Error ? error.message : String(error)}`,\n\t\t);\n\t}\n\n\tif (addresses.length === 0) {\n\t\tthrow new SsrfError(\"Hostname resolved to no addresses\");\n\t}\n\n\tfor (const ip of addresses) {\n\t\tif (isPrivateIp(ip)) {\n\t\t\tthrow new SsrfError(\"Hostname resolves to a private IP address\");\n\t\t}\n\t}\n\n\treturn parsed;\n}\n\n/** True when a string looks like an IPv4 or IPv6 literal. */\nfunction isIpLiteral(host: string): boolean {\n\tif (parseIpv4(host) !== null) return true;\n\t// Very loose IPv6 heuristic — matches anything with a colon, which is\n\t// never valid in DNS hostnames, so this is safe.\n\treturn host.includes(\":\");\n}\n\n/**\n * Fetch a URL with SSRF protection on redirects.\n *\n * Uses `redirect: \"manual\"` to intercept redirects and re-validate each\n * redirect target against SSRF rules before following it. This prevents\n * an attacker from setting up an allowed external URL that redirects to\n * an internal IP (e.g. 169.254.169.254 for cloud metadata).\n *\n * @throws SsrfError if the initial URL or any redirect target is internal\n */\n/** Headers that must be stripped when a redirect crosses origins */\nconst CREDENTIAL_HEADERS = [\"authorization\", \"cookie\", \"proxy-authorization\"];\n\nexport async function ssrfSafeFetch(\n\turl: string,\n\tinit?: RequestInit,\n\toptions?: { resolver?: DnsResolver },\n): Promise<Response> {\n\tlet currentUrl = url;\n\tlet currentInit = init;\n\n\tfor (let i = 0; i <= MAX_REDIRECTS; i++) {\n\t\tawait resolveAndValidateExternalUrl(currentUrl, options);\n\n\t\tconst response = await globalThis.fetch(currentUrl, {\n\t\t\t...currentInit,\n\t\t\tredirect: \"manual\",\n\t\t});\n\n\t\t// Not a redirect -- return directly\n\t\tif (response.status < 300 || response.status >= 400) {\n\t\t\treturn response;\n\t\t}\n\n\t\t// Extract redirect target\n\t\tconst location = response.headers.get(\"Location\");\n\t\tif (!location) {\n\t\t\treturn response;\n\t\t}\n\n\t\t// Resolve relative redirects against the current URL\n\t\tconst previousOrigin = new URL(currentUrl).origin;\n\t\tcurrentUrl = new URL(location, currentUrl).href;\n\t\tconst nextOrigin = new URL(currentUrl).origin;\n\n\t\t// Strip credential headers on cross-origin redirects\n\t\tif (previousOrigin !== nextOrigin && currentInit) {\n\t\t\tcurrentInit = stripCredentialHeaders(currentInit);\n\t\t}\n\t}\n\n\tthrow new SsrfError(`Too many redirects (max ${MAX_REDIRECTS})`);\n}\n\n/**\n * Return a copy of init with credential headers removed.\n */\nexport function stripCredentialHeaders(init: RequestInit): RequestInit {\n\tif (!init.headers) return init;\n\n\tconst headers = new Headers(init.headers);\n\tfor (const name of CREDENTIAL_HEADERS) {\n\t\theaders.delete(name);\n\t}\n\n\treturn { ...init, headers };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"ssrf-XO05Voq6.mjs","names":[],"sources":["../src/security/ssrf.ts"],"sourcesContent":["/**\n * SSRF protection for import URLs.\n *\n * Validates that URLs don't target internal/private network addresses.\n * Applied before any fetch() call in the import pipeline.\n */\n\nconst IPV4_MAPPED_IPV6_DOTTED_PATTERN = /^::ffff:(\\d+\\.\\d+\\.\\d+\\.\\d+)$/i;\nconst IPV4_MAPPED_IPV6_HEX_PATTERN = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i;\nconst IPV4_TRANSLATED_HEX_PATTERN = /^::ffff:0:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i;\nconst IPV6_EXPANDED_MAPPED_PATTERN =\n\t/^0{0,4}:0{0,4}:0{0,4}:0{0,4}:0{0,4}:ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i;\n\n/**\n * IPv4-compatible (deprecated) addresses: ::XXXX:XXXX\n *\n * The WHATWG URL parser normalizes [::127.0.0.1] to [::7f00:1] (no ffff prefix).\n * These are deprecated but still parsed, and bypass the ffff-based checks.\n */\nconst IPV4_COMPATIBLE_HEX_PATTERN = /^::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i;\n\n/**\n * NAT64 prefix (RFC 6052): 64:ff9b::XXXX:XXXX\n *\n * Used by NAT64 gateways to embed IPv4 addresses in IPv6.\n * [64:ff9b::127.0.0.1] normalizes to [64:ff9b::7f00:1].\n */\nconst NAT64_HEX_PATTERN = /^64:ff9b::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i;\n\nconst IPV6_BRACKET_PATTERN = /^\\[|\\]$/g;\n\n/** Match fc00::/7 ULA — first byte 0xfc or 0xfd followed by any byte. */\nconst IPV6_ULA_FC_PATTERN = /^fc[0-9a-f]{2}:/;\nconst IPV6_ULA_FD_PATTERN = /^fd[0-9a-f]{2}:/;\n\n/** Strip trailing dots from an FQDN-form hostname (\"localhost.\" -> \"localhost\"). */\nconst TRAILING_DOT_PATTERN = /\\.+$/;\n\n/**\n * Private and reserved IP ranges that should never be fetched.\n *\n * Includes:\n * - Loopback (127.0.0.0/8)\n * - Private (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)\n * - Link-local (169.254.0.0/16)\n * - Cloud metadata (169.254.169.254 — AWS/GCP/Azure)\n * - IPv6 loopback and link-local\n */\nconst BLOCKED_PATTERNS: Array<{ start: number; end: number }> = [\n\t// 127.0.0.0/8 — loopback\n\t{ start: ip4ToNum(127, 0, 0, 0), end: ip4ToNum(127, 255, 255, 255) },\n\t// 10.0.0.0/8 — private\n\t{ start: ip4ToNum(10, 0, 0, 0), end: ip4ToNum(10, 255, 255, 255) },\n\t// 172.16.0.0/12 — private\n\t{ start: ip4ToNum(172, 16, 0, 0), end: ip4ToNum(172, 31, 255, 255) },\n\t// 192.168.0.0/16 — private\n\t{ start: ip4ToNum(192, 168, 0, 0), end: ip4ToNum(192, 168, 255, 255) },\n\t// 169.254.0.0/16 — link-local (includes cloud metadata endpoint)\n\t{ start: ip4ToNum(169, 254, 0, 0), end: ip4ToNum(169, 254, 255, 255) },\n\t// 0.0.0.0/8 — current network\n\t{ start: ip4ToNum(0, 0, 0, 0), end: ip4ToNum(0, 255, 255, 255) },\n];\n\n// Bracket-stripped form is used for lookups (validateExternalUrl strips\n// brackets from parsed.hostname before checking), so \"::1\" appears here\n// without brackets. The \"::1\" case is already covered by isPrivateIp, but\n// keeping it here makes the intent explicit and gives a clearer error\n// message for the common `http://[::1]/` form.\nconst BLOCKED_HOSTNAMES = new Set([\n\t\"localhost\",\n\t\"metadata.google.internal\",\n\t\"metadata.google\",\n\t\"::1\",\n]);\n\n/**\n * Wildcard DNS services that publicly resolve arbitrary IPs embedded in the\n * hostname. Commonly used in local dev and by SSRF exploit tooling to bypass\n * hostname-only blocklists (e.g. 127.0.0.1.nip.io -> 127.0.0.1).\n *\n * Matched case-insensitively as a suffix, so both the apex and any subdomain\n * are blocked.\n */\nconst BLOCKED_HOSTNAME_SUFFIXES = [\n\t\"nip.io\",\n\t\"sslip.io\",\n\t\"xip.io\",\n\t\"traefik.me\",\n\t\"lvh.me\",\n\t\"localtest.me\",\n];\n\n/** Blocked URL schemes */\nconst ALLOWED_SCHEMES = new Set([\"http:\", \"https:\"]);\n\nfunction ip4ToNum(a: number, b: number, c: number, d: number): number {\n\treturn ((a << 24) | (b << 16) | (c << 8) | d) >>> 0;\n}\n\nfunction parseIpv4(ip: string): number | null {\n\tconst parts = ip.split(\".\");\n\tif (parts.length !== 4) return null;\n\n\tconst nums = parts.map(Number);\n\tif (nums.some((n) => isNaN(n) || n < 0 || n > 255)) return null;\n\n\treturn ip4ToNum(nums[0], nums[1], nums[2], nums[3]);\n}\n\n/**\n * Convert IPv4-mapped/translated IPv6 addresses from hex form back to IPv4.\n *\n * The WHATWG URL parser normalizes dotted-decimal to hex:\n * [::ffff:127.0.0.1] -> [::ffff:7f00:1]\n * [::ffff:169.254.169.254] -> [::ffff:a9fe:a9fe]\n *\n * Without this conversion, the hex forms bypass isPrivateIp() regex checks.\n */\nexport function normalizeIPv6MappedToIPv4(ip: string): string | null {\n\t// Match hex-form IPv4-mapped IPv6: ::ffff:XXXX:XXXX\n\tlet match = ip.match(IPV4_MAPPED_IPV6_HEX_PATTERN);\n\tif (!match) {\n\t\t// Match IPv4-translated (RFC 6052): ::ffff:0:XXXX:XXXX\n\t\tmatch = ip.match(IPV4_TRANSLATED_HEX_PATTERN);\n\t}\n\tif (!match) {\n\t\t// Match fully expanded form: 0000:0000:0000:0000:0000:ffff:XXXX:XXXX\n\t\tmatch = ip.match(IPV6_EXPANDED_MAPPED_PATTERN);\n\t}\n\tif (!match) {\n\t\t// Match IPv4-compatible (deprecated) form: ::XXXX:XXXX (no ffff prefix)\n\t\tmatch = ip.match(IPV4_COMPATIBLE_HEX_PATTERN);\n\t}\n\tif (!match) {\n\t\t// Match NAT64 prefix (RFC 6052): 64:ff9b::XXXX:XXXX\n\t\tmatch = ip.match(NAT64_HEX_PATTERN);\n\t}\n\tif (match) {\n\t\tconst high = parseInt(match[1] ?? \"\", 16);\n\t\tconst low = parseInt(match[2] ?? \"\", 16);\n\t\treturn `${(high >> 8) & 0xff}.${high & 0xff}.${(low >> 8) & 0xff}.${low & 0xff}`;\n\t}\n\treturn null;\n}\n\nfunction isPrivateIp(ip: string): boolean {\n\t// Normalize IPv6 strings to lowercase. `new URL().hostname` already\n\t// lowercases, but resolver output (from DoH or an injected resolver) may\n\t// not. Without this, \"FE80::1\" bypasses the link-local check.\n\tconst normalized = ip.toLowerCase();\n\n\t// Handle IPv6 loopback\n\tif (normalized === \"::1\" || normalized === \"::ffff:127.0.0.1\") return true;\n\n\t// Handle IPv4-mapped IPv6 in hex form (WHATWG URL parser normalizes to this)\n\t// e.g. ::ffff:7f00:1 -> 127.0.0.1, ::ffff:a9fe:a9fe -> 169.254.169.254\n\tconst hexIpv4 = normalizeIPv6MappedToIPv4(normalized);\n\tif (hexIpv4) return isPrivateIp(hexIpv4);\n\n\t// Handle IPv4-mapped IPv6 in dotted-decimal form\n\tconst v4Match = normalized.match(IPV4_MAPPED_IPV6_DOTTED_PATTERN);\n\tconst ipv4 = v4Match ? v4Match[1] : normalized;\n\n\tconst num = parseIpv4(ipv4);\n\tif (num === null) {\n\t\t// If we can't parse it, block IPv6 addresses that look internal.\n\t\t// fc00::/7 is Unique Local (first byte 0xfc or 0xfd), fe80::/10 is\n\t\t// link-local. Only match when followed by hex digit + colon to avoid\n\t\t// collisions with hypothetical non-address strings.\n\t\treturn (\n\t\t\tnormalized.startsWith(\"fe80:\") ||\n\t\t\tIPV6_ULA_FC_PATTERN.test(normalized) ||\n\t\t\tIPV6_ULA_FD_PATTERN.test(normalized)\n\t\t);\n\t}\n\n\treturn BLOCKED_PATTERNS.some((range) => num >= range.start && num <= range.end);\n}\n\n/**\n * Error thrown when SSRF protection blocks a URL.\n */\nexport class SsrfError extends Error {\n\tcode = \"SSRF_BLOCKED\" as const;\n\n\tconstructor(message: string) {\n\t\tsuper(message);\n\t\tthis.name = \"SsrfError\";\n\t}\n}\n\n/**\n * Validate that a URL is safe to fetch (not targeting internal networks).\n *\n * Checks:\n * 1. URL is well-formed with http/https scheme\n * 2. Hostname is not a known internal name (localhost, metadata endpoints)\n * 3. If hostname is an IP literal, it's not in a private range\n *\n * Note: DNS rebinding attacks are not fully mitigated (hostname could resolve\n * to a private IP). Full protection requires resolving DNS and checking the IP\n * before connecting, which needs a custom fetch implementation. This covers\n * the most common SSRF vectors.\n *\n * @throws SsrfError if the URL targets an internal address\n */\n/** Maximum number of redirects to follow in ssrfSafeFetch */\nconst MAX_REDIRECTS = 5;\n\nexport function validateExternalUrl(url: string): URL {\n\tlet parsed: URL;\n\ttry {\n\t\tparsed = new URL(url);\n\t} catch {\n\t\tthrow new SsrfError(\"Invalid URL\");\n\t}\n\n\t// Only allow http/https\n\tif (!ALLOWED_SCHEMES.has(parsed.protocol)) {\n\t\tthrow new SsrfError(`Scheme '${parsed.protocol}' is not allowed`);\n\t}\n\n\t// Strip brackets from IPv6 hostname\n\tconst hostname = parsed.hostname.replace(IPV6_BRACKET_PATTERN, \"\");\n\n\t// Normalize the hostname for blocklist matching: lowercase + strip any\n\t// trailing dots. WHATWG preserves trailing dots on .hostname, so without\n\t// this normalization \"localhost.\" and \"nip.io.\" bypass the checks.\n\tconst normalizedHost = hostname.toLowerCase().replace(TRAILING_DOT_PATTERN, \"\");\n\n\t// Check against known internal hostnames\n\tif (BLOCKED_HOSTNAMES.has(normalizedHost)) {\n\t\tthrow new SsrfError(\"URLs targeting internal hosts are not allowed\");\n\t}\n\n\t// Check against wildcard DNS services used by SSRF tooling to bypass\n\t// hostname-only checks. Match the apex and any subdomain.\n\tfor (const suffix of BLOCKED_HOSTNAME_SUFFIXES) {\n\t\tif (normalizedHost === suffix || normalizedHost.endsWith(`.${suffix}`)) {\n\t\t\tthrow new SsrfError(\"URLs targeting wildcard DNS services are not allowed\");\n\t\t}\n\t}\n\n\t// Check if hostname is an IP address in a private range. Use the\n\t// normalized form so \"127.0.0.1..\" and friends don't bypass parseIpv4\n\t// (which rejects extra trailing dots).\n\tif (isPrivateIp(normalizedHost)) {\n\t\tthrow new SsrfError(\"URLs targeting private IP addresses are not allowed\");\n\t}\n\n\treturn parsed;\n}\n\n// ---------------------------------------------------------------------------\n// DNS-aware validation\n// ---------------------------------------------------------------------------\n\n/**\n * A resolver that maps a hostname to a list of IPv4/IPv6 addresses.\n * Injectable so callers can swap in OS-level DNS on Node, stub it in tests,\n * or point to a different DoH endpoint.\n */\nexport type DnsResolver = (hostname: string) => Promise<string[]>;\n\n/**\n * Module-level default resolver. Tests can swap this with a stub so fetch\n * mocks don't see unexpected DoH round-trips. Production code should leave\n * it alone.\n */\nlet defaultResolver: DnsResolver | null = null;\n\n/** Override the default DNS resolver. Returns the previous value. */\nexport function setDefaultDnsResolver(resolver: DnsResolver | null): DnsResolver | null {\n\tconst previous = defaultResolver;\n\tdefaultResolver = resolver;\n\treturn previous;\n}\n\n/** Timeout for a single DoH request, in milliseconds. */\nconst DOH_TIMEOUT_MS = 3000;\n\n/** Default DoH endpoint — Cloudflare's public resolver. */\nconst DEFAULT_DOH_URL = \"https://cloudflare-dns.com/dns-query\";\n\ninterface DohAnswer {\n\tdata: string;\n}\n\ninterface DohResponse {\n\tStatus: number;\n\tAnswer: DohAnswer[];\n}\n\nfunction hasProperty<K extends string>(obj: unknown, key: K): obj is Record<K, unknown> {\n\treturn typeof obj === \"object\" && obj !== null && key in obj;\n}\n\n/**\n * Narrow an unknown JSON body to a DohResponse shape we can read safely.\n * Throws if the body doesn't look like a DoH response — a malformed body is\n * indistinguishable from a failure and must not be silently treated as empty.\n */\nfunction parseDohResponse(raw: unknown): DohResponse {\n\tif (!hasProperty(raw, \"Status\") || typeof raw.Status !== \"number\") {\n\t\tthrow new Error(\"DoH response missing Status field\");\n\t}\n\tconst answers: DohAnswer[] = [];\n\tif (hasProperty(raw, \"Answer\") && Array.isArray(raw.Answer)) {\n\t\tfor (const entry of raw.Answer) {\n\t\t\tif (hasProperty(entry, \"data\") && typeof entry.data === \"string\") {\n\t\t\t\tanswers.push({ data: entry.data });\n\t\t\t}\n\t\t}\n\t}\n\treturn { Status: raw.Status, Answer: answers };\n}\n\n/**\n * Resolve a hostname via DNS over HTTPS (Cloudflare). Returns all A and AAAA\n * records. Works in both Workers and Node without requiring node:dns.\n *\n * Fails closed: any network error, non-2xx response, or DNS rcode != 0\n * causes a rejected promise so the calling validator treats it as a block.\n */\nexport const cloudflareDohResolver: DnsResolver = async (hostname) => {\n\tasync function query(type: \"A\" | \"AAAA\"): Promise<string[]> {\n\t\tconst params = new URLSearchParams({ name: hostname, type });\n\t\tconst controller = new AbortController();\n\t\tconst timeout = setTimeout(() => controller.abort(), DOH_TIMEOUT_MS);\n\t\ttry {\n\t\t\tconst response = await globalThis.fetch(`${DEFAULT_DOH_URL}?${params.toString()}`, {\n\t\t\t\theaders: { Accept: \"application/dns-json\" },\n\t\t\t\tsignal: controller.signal,\n\t\t\t});\n\t\t\tif (!response.ok) {\n\t\t\t\tthrow new Error(`DoH lookup failed: ${response.status}`);\n\t\t\t}\n\t\t\tconst raw = await response.json();\n\t\t\tconst body = parseDohResponse(raw);\n\t\t\t// NXDOMAIN (3) is a legitimate \"does not exist\" — treat as empty.\n\t\t\t// Any other non-zero status (SERVFAIL=2, REFUSED=5, etc.) is\n\t\t\t// ambiguous and could be a split-view attacker hiding records\n\t\t\t// from our resolver. Fail closed.\n\t\t\tif (body.Status === 3) return [];\n\t\t\tif (body.Status !== 0) {\n\t\t\t\tthrow new Error(`DoH ${type} lookup failed: rcode=${body.Status}`);\n\t\t\t}\n\t\t\t// DoH Answer arrays often include CNAME records alongside A/AAAA\n\t\t\t// records. Their `data` is a hostname, not an IP. Filter to just\n\t\t\t// IP literals so isPrivateIp sees real addresses.\n\t\t\treturn body.Answer.map((a) => a.data).filter(isIpLiteral);\n\t\t} finally {\n\t\t\tclearTimeout(timeout);\n\t\t}\n\t}\n\n\tconst [a, aaaa] = await Promise.all([query(\"A\"), query(\"AAAA\")]);\n\treturn [...a, ...aaaa];\n};\n\n/**\n * Validate a URL and resolve its hostname to check the actual IPs against\n * the private-range blocklist. This catches DNS rebinding attacks using\n * attacker-controlled domains that publicly resolve to private addresses,\n * and wildcard DNS services like nip.io used by exploit tooling.\n *\n * Runs `validateExternalUrl` first for cheap pre-flight checks (scheme,\n * literal IP, known-bad hostnames). Then resolves the hostname and rejects\n * if ANY returned address is private.\n *\n * Fails closed: if resolution fails or returns no records, throws SsrfError.\n *\n * **Caveats.** This does NOT fully close the TOCTOU between check and\n * connect. Attacks that still work against this layer include:\n *\n * - TTL=0 rebind: authoritative server returns public IP to the check, then\n * private IP to the subsequent fetch() a few milliseconds later.\n * - Split-view via EDNS Client Subnet or source-IP inspection: the\n * authoritative server returns public IP to Cloudflare's DoH resolver and\n * private IP to the victim's own resolver (used by fetch()).\n * - Host-file overrides or split-horizon corporate DNS on self-hosted Node.\n * - Attacker-controlled rebinding services the caller has allowlisted.\n *\n * The only complete defense is a network-layer egress firewall. On\n * Cloudflare Workers, the platform fetch pipeline provides most of that.\n * On self-hosted Node, operators must restrict egress themselves.\n */\nexport async function resolveAndValidateExternalUrl(\n\turl: string,\n\toptions?: { resolver?: DnsResolver },\n): Promise<URL> {\n\tconst parsed = validateExternalUrl(url);\n\n\t// Strip brackets from IPv6 hostnames\n\tconst hostname = parsed.hostname.replace(IPV6_BRACKET_PATTERN, \"\");\n\n\t// If the hostname is already an IP literal, validateExternalUrl has\n\t// already checked it against the private-range list. Skip DNS.\n\tif (isIpLiteral(hostname)) {\n\t\treturn parsed;\n\t}\n\n\tconst resolver = options?.resolver ?? defaultResolver ?? cloudflareDohResolver;\n\n\tlet addresses: string[];\n\ttry {\n\t\taddresses = await resolver(hostname);\n\t} catch (error) {\n\t\tthrow new SsrfError(\n\t\t\t`Could not resolve hostname: ${error instanceof Error ? error.message : String(error)}`,\n\t\t);\n\t}\n\n\tif (addresses.length === 0) {\n\t\tthrow new SsrfError(\"Hostname resolved to no addresses\");\n\t}\n\n\tfor (const ip of addresses) {\n\t\tif (isPrivateIp(ip)) {\n\t\t\tthrow new SsrfError(\"Hostname resolves to a private IP address\");\n\t\t}\n\t}\n\n\treturn parsed;\n}\n\n/** True when a string looks like an IPv4 or IPv6 literal. */\nfunction isIpLiteral(host: string): boolean {\n\tif (parseIpv4(host) !== null) return true;\n\t// Very loose IPv6 heuristic — matches anything with a colon, which is\n\t// never valid in DNS hostnames, so this is safe.\n\treturn host.includes(\":\");\n}\n\n/**\n * Fetch a URL with SSRF protection on redirects.\n *\n * Uses `redirect: \"manual\"` to intercept redirects and re-validate each\n * redirect target against SSRF rules before following it. This prevents\n * an attacker from setting up an allowed external URL that redirects to\n * an internal IP (e.g. 169.254.169.254 for cloud metadata).\n *\n * @throws SsrfError if the initial URL or any redirect target is internal\n */\n/** Headers that must be stripped when a redirect crosses origins */\nconst CREDENTIAL_HEADERS = [\"authorization\", \"cookie\", \"proxy-authorization\"];\n\nexport async function ssrfSafeFetch(\n\turl: string,\n\tinit?: RequestInit,\n\toptions?: { resolver?: DnsResolver },\n): Promise<Response> {\n\tlet currentUrl = url;\n\tlet currentInit = init;\n\n\tfor (let i = 0; i <= MAX_REDIRECTS; i++) {\n\t\tawait resolveAndValidateExternalUrl(currentUrl, options);\n\n\t\tconst response = await globalThis.fetch(currentUrl, {\n\t\t\t...currentInit,\n\t\t\tredirect: \"manual\",\n\t\t});\n\n\t\t// Not a redirect -- return directly\n\t\tif (response.status < 300 || response.status >= 400) {\n\t\t\treturn response;\n\t\t}\n\n\t\t// Extract redirect target\n\t\tconst location = response.headers.get(\"Location\");\n\t\tif (!location) {\n\t\t\treturn response;\n\t\t}\n\n\t\t// Resolve relative redirects against the current URL\n\t\tconst previousOrigin = new URL(currentUrl).origin;\n\t\tcurrentUrl = new URL(location, currentUrl).href;\n\t\tconst nextOrigin = new URL(currentUrl).origin;\n\n\t\t// Strip credential headers on cross-origin redirects\n\t\tif (previousOrigin !== nextOrigin && currentInit) {\n\t\t\tcurrentInit = stripCredentialHeaders(currentInit);\n\t\t}\n\t}\n\n\tthrow new SsrfError(`Too many redirects (max ${MAX_REDIRECTS})`);\n}\n\n/**\n * Return a copy of init with credential headers removed.\n */\nexport function stripCredentialHeaders(init: RequestInit): RequestInit {\n\tif (!init.headers) return init;\n\n\tconst headers = new Headers(init.headers);\n\tfor (const name of CREDENTIAL_HEADERS) {\n\t\theaders.delete(name);\n\t}\n\n\treturn { ...init, headers };\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
//#region src/redirects/status.ts
|
|
2
|
+
/**
|
|
3
|
+
* Redirect rule status codes.
|
|
4
|
+
*
|
|
5
|
+
* A redirect rule's `type` is either a *redirect* status (issues a `Location`
|
|
6
|
+
* header) or a *terminal* status (serves the status with no target). Terminal
|
|
7
|
+
* statuses let editors mark a URL as intentionally gone:
|
|
8
|
+
* - `410 Gone` — permanently and intentionally deleted (Google deindexes it
|
|
9
|
+
* faster than a 404).
|
|
10
|
+
* - `451 Unavailable For Legal Reasons`.
|
|
11
|
+
*/
|
|
12
|
+
/** Statuses that issue an HTTP redirect (require a destination). */
|
|
13
|
+
const REDIRECT_STATUSES = [
|
|
14
|
+
301,
|
|
15
|
+
302,
|
|
16
|
+
307,
|
|
17
|
+
308
|
|
18
|
+
];
|
|
19
|
+
/** Terminal statuses that serve a status with no `Location` / no destination. */
|
|
20
|
+
const TERMINAL_STATUSES = [410, 451];
|
|
21
|
+
/** All values accepted as a redirect rule `type`. */
|
|
22
|
+
const REDIRECT_RULE_STATUSES = [...REDIRECT_STATUSES, ...TERMINAL_STATUSES];
|
|
23
|
+
/** True for terminal statuses (410/451) — served directly, with no target. */
|
|
24
|
+
function isTerminalStatus(type) {
|
|
25
|
+
return TERMINAL_STATUSES.includes(type);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
//#endregion
|
|
29
|
+
export { isTerminalStatus as n, REDIRECT_RULE_STATUSES as t };
|
|
30
|
+
//# sourceMappingURL=status-2gZklYuj.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"status-2gZklYuj.mjs","names":[],"sources":["../src/redirects/status.ts"],"sourcesContent":["/**\n * Redirect rule status codes.\n *\n * A redirect rule's `type` is either a *redirect* status (issues a `Location`\n * header) or a *terminal* status (serves the status with no target). Terminal\n * statuses let editors mark a URL as intentionally gone:\n * - `410 Gone` — permanently and intentionally deleted (Google deindexes it\n * faster than a 404).\n * - `451 Unavailable For Legal Reasons`.\n */\n\n/** Statuses that issue an HTTP redirect (require a destination). */\nexport const REDIRECT_STATUSES = [301, 302, 307, 308] as const;\n\n/** Terminal statuses that serve a status with no `Location` / no destination. */\nexport const TERMINAL_STATUSES = [410, 451] as const;\n\n/** All values accepted as a redirect rule `type`. */\nexport const REDIRECT_RULE_STATUSES: readonly number[] = [\n\t...REDIRECT_STATUSES,\n\t...TERMINAL_STATUSES,\n];\n\n/** True for terminal statuses (410/451) — served directly, with no target. */\nexport function isTerminalStatus(type: number): boolean {\n\treturn (TERMINAL_STATUSES as readonly number[]).includes(type);\n}\n"],"mappings":";;;;;;;;;;;;AAYA,MAAa,oBAAoB;CAAC;CAAK;CAAK;CAAK;CAAI;;AAGrD,MAAa,oBAAoB,CAAC,KAAK,IAAI;;AAG3C,MAAa,yBAA4C,CACxD,GAAG,mBACH,GAAG,kBACH;;AAGD,SAAgB,iBAAiB,MAAuB;AACvD,QAAQ,kBAAwC,SAAS,KAAK"}
|
package/dist/storage/local.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as ListOptions, d as Storage, l as SignedUploadOptions, n as DownloadResult, o as ListResult, p as UploadResult, s as LocalStorageConfig, u as SignedUploadUrl } from "../types-
|
|
1
|
+
import { a as ListOptions, d as Storage, l as SignedUploadOptions, n as DownloadResult, o as ListResult, p as UploadResult, s as LocalStorageConfig, u as SignedUploadUrl } from "../types-u_XxjbS8.mjs";
|
|
2
2
|
|
|
3
3
|
//#region src/storage/local.d.ts
|
|
4
4
|
/**
|
package/dist/storage/local.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { t as EmDashStorageError } from "../types-
|
|
1
|
+
import { t as EmDashStorageError } from "../types-DejCHqWT.mjs";
|
|
2
2
|
import mime from "mime/lite";
|
|
3
|
-
import * as path from "node:path";
|
|
4
3
|
import { createReadStream, existsSync } from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
5
|
import * as fs from "node:fs/promises";
|
|
6
6
|
import { Readable } from "node:stream";
|
|
7
7
|
|
package/dist/storage/s3.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as ListOptions, c as S3StorageConfig, d as Storage, l as SignedUploadOptions, n as DownloadResult, o as ListResult, p as UploadResult, u as SignedUploadUrl } from "../types-
|
|
1
|
+
import { a as ListOptions, c as S3StorageConfig, d as Storage, l as SignedUploadOptions, n as DownloadResult, o as ListResult, p as UploadResult, u as SignedUploadUrl } from "../types-u_XxjbS8.mjs";
|
|
2
2
|
|
|
3
3
|
//#region src/storage/s3.d.ts
|
|
4
4
|
/**
|
package/dist/storage/s3.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { t as EmDashStorageError } from "../types-
|
|
1
|
+
import { t as EmDashStorageError } from "../types-DejCHqWT.mjs";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, ListObjectsV2Command, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
|
4
4
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { a as __exportAll } from "./runner--4wMWwKM.mjs";
|
|
2
2
|
import { n as chunks, t as SQL_BATCH_SIZE } from "./chunks-BerYVuve.mjs";
|
|
3
|
-
import { i as setRequestCacheEntry, n as peekRequestCache, r as requestCached } from "./request-cache-
|
|
4
|
-
import { n as isMissingTableError } from "./db-errors-
|
|
5
|
-
import { r as getDb } from "./loader-
|
|
6
|
-
import { i as resolveLocaleChain, r as resolveLocale } from "./resolve-
|
|
3
|
+
import { i as setRequestCacheEntry, n as peekRequestCache, r as requestCached } from "./request-cache-UwmBAiUK.mjs";
|
|
4
|
+
import { n as isMissingTableError } from "./db-errors-BluWkwGI.mjs";
|
|
5
|
+
import { r as getDb } from "./loader-BqWjcH3h.mjs";
|
|
6
|
+
import { i as resolveLocaleChain, r as resolveLocale } from "./resolve-B3NUUtVY.mjs";
|
|
7
7
|
|
|
8
8
|
//#region src/taxonomies/index.ts
|
|
9
9
|
/**
|
|
@@ -365,7 +365,7 @@ function primeEntryTermsCache(collection, entryId, byTaxonomy, applicableTaxonom
|
|
|
365
365
|
* the content query respect the active locale.
|
|
366
366
|
*/
|
|
367
367
|
async function getEntriesByTerm(collection, taxonomyName, termSlug, options = {}) {
|
|
368
|
-
const { getEmDashCollection } = await import("./query-
|
|
368
|
+
const { getEmDashCollection } = await import("./query-Crm038Mc.mjs").then((n) => n.o);
|
|
369
369
|
const queryOptions = { where: { [taxonomyName]: termSlug } };
|
|
370
370
|
if (options.locale !== void 0) queryOptions.locale = options.locale;
|
|
371
371
|
const { entries } = await getEmDashCollection(collection, queryOptions);
|
|
@@ -408,4 +408,4 @@ function buildTree(flatTerms, counts) {
|
|
|
408
408
|
|
|
409
409
|
//#endregion
|
|
410
410
|
export { getTaxonomyDefs as a, getTermsForEntries as c, getTaxonomyDef as i, invalidateTermCache as l, getEntriesByTerm as n, getTaxonomyTerms as o, getEntryTerms as r, getTerm as s, getAllTermsForEntries as t, taxonomies_exports as u };
|
|
411
|
-
//# sourceMappingURL=taxonomies-
|
|
411
|
+
//# sourceMappingURL=taxonomies-BBxYA38v.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"taxonomies-BdAmbOwx.mjs","names":[],"sources":["../src/taxonomies/index.ts"],"sourcesContent":["/**\n * Runtime API for taxonomies.\n *\n * All helpers are locale-aware. When a locale is not passed explicitly we fall\n * back to the request context or the configured `defaultLocale` (see\n * `i18n/resolve.ts`).\n *\n * Because `content_taxonomies.taxonomy_id` stores the translation_group (not a\n * specific term id), the joins here are `taxonomies.translation_group =\n * content_taxonomies.taxonomy_id` + filter by `taxonomies.locale`, which picks\n * the right per-locale term.\n */\n\nimport { resolveLocale, resolveLocaleChain } from \"../i18n/resolve.js\";\nimport { getDb } from \"../loader.js\";\nimport { peekRequestCache, requestCached, setRequestCacheEntry } from \"../request-cache.js\";\nimport { chunks, SQL_BATCH_SIZE } from \"../utils/chunks.js\";\nimport { isMissingTableError } from \"../utils/db-errors.js\";\nimport type { TaxonomyDef, TaxonomyTerm, TaxonomyTermRow } from \"./types.js\";\n\nexport interface TaxonomyQueryOptions {\n\tlocale?: string;\n}\n\n/**\n * No-op — kept for API compatibility.\n */\nexport function invalidateTermCache(): void {\n\t// Intentionally empty.\n}\n\n/**\n * Get every taxonomy definition. Definitions are per-locale (one row per\n * locale inside the same translation_group) — by default we resolve to the\n * active locale.\n */\nexport async function getTaxonomyDefs(options: TaxonomyQueryOptions = {}): Promise<TaxonomyDef[]> {\n\tconst locale = resolveLocale(options.locale);\n\treturn requestCached(`taxonomy-defs:${locale ?? \"*\"}`, async () => {\n\t\tconst db = await getDb();\n\t\tlet query = db.selectFrom(\"_emdash_taxonomy_defs\").selectAll();\n\t\tif (locale !== undefined) query = query.where(\"locale\", \"=\", locale);\n\t\tconst rows = await query.execute();\n\t\treturn rows.map(rowToTaxonomyDef);\n\t});\n}\n\n/**\n * Get a single taxonomy definition by name. Uses the fallback chain so even\n * if there is no translation for the active locale we still return something.\n *\n * If `getTaxonomyDefs()` has already loaded the full list in this request\n * (which happens during entry-term hydration on every page that renders a\n * collection), search the matching def in memory rather than running a\n * second query against `_emdash_taxonomy_defs`.\n */\nexport async function getTaxonomyDef(\n\tname: string,\n\toptions: TaxonomyQueryOptions = {},\n): Promise<TaxonomyDef | null> {\n\tconst chain = resolveLocaleChain(options.locale);\n\tconst peekKey = `taxonomy-defs:${resolveLocale(options.locale) ?? \"*\"}`;\n\tconst allDefs = peekRequestCache<TaxonomyDef[]>(peekKey);\n\tif (allDefs) {\n\t\tconst defs = await allDefs;\n\t\tif (chain.length === 0) return defs.find((d) => d.name === name) ?? null;\n\t\tfor (const locale of chain) {\n\t\t\tconst found = defs.find((d) => d.name === name && d.locale === locale);\n\t\t\tif (found) return found;\n\t\t}\n\t\treturn null;\n\t}\n\n\treturn requestCached(`taxonomy-def:${name}:${chain.join(\",\")}`, async () => {\n\t\tconst db = await getDb();\n\n\t\tif (chain.length === 0) {\n\t\t\tconst row = await db\n\t\t\t\t.selectFrom(\"_emdash_taxonomy_defs\")\n\t\t\t\t.selectAll()\n\t\t\t\t.where(\"name\", \"=\", name)\n\t\t\t\t.orderBy(\"locale\", \"asc\")\n\t\t\t\t.executeTakeFirst();\n\t\t\treturn row ? rowToTaxonomyDef(row) : null;\n\t\t}\n\n\t\tfor (const locale of chain) {\n\t\t\tconst row = await db\n\t\t\t\t.selectFrom(\"_emdash_taxonomy_defs\")\n\t\t\t\t.selectAll()\n\t\t\t\t.where(\"name\", \"=\", name)\n\t\t\t\t.where(\"locale\", \"=\", locale)\n\t\t\t\t.executeTakeFirst();\n\t\t\tif (row) return rowToTaxonomyDef(row);\n\t\t}\n\t\treturn null;\n\t});\n}\n\n/**\n * All terms of a taxonomy in a specific locale (flat for non-hierarchical,\n * tree for hierarchical).\n */\nexport async function getTaxonomyTerms(\n\ttaxonomyName: string,\n\toptions: TaxonomyQueryOptions = {},\n): Promise<TaxonomyTerm[]> {\n\tconst locale = resolveLocale(options.locale);\n\treturn requestCached(`taxonomy-terms:${taxonomyName}:${locale ?? \"*\"}`, async () => {\n\t\tconst db = await getDb();\n\n\t\tconst def = await getTaxonomyDef(taxonomyName, options);\n\t\tif (!def) return [];\n\n\t\tlet termsQuery = db\n\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t.selectAll()\n\t\t\t.where(\"name\", \"=\", taxonomyName)\n\t\t\t.orderBy(\"label\", \"asc\");\n\t\tif (locale !== undefined) termsQuery = termsQuery.where(\"locale\", \"=\", locale);\n\t\tconst rows = await termsQuery.execute();\n\n\t\t// Counts are keyed by translation_group (what the pivot stores) and are\n\t\t// locale-independent, so the aggregate is shared across every taxonomy\n\t\t// rendered in this request (Categories + Tags widgets, etc.).\n\t\tconst counts = await getTaxonomyTermCounts();\n\n\t\tconst flatTerms: TaxonomyTermRow[] = rows.map((row) => ({\n\t\t\tid: row.id,\n\t\t\tname: row.name,\n\t\t\tslug: row.slug,\n\t\t\tlabel: row.label,\n\t\t\tparent_id: row.parent_id,\n\t\t\tdata: row.data,\n\t\t\tlocale: row.locale,\n\t\t\ttranslation_group: row.translation_group,\n\t\t}));\n\n\t\tif (def.hierarchical) return buildTree(flatTerms, counts);\n\n\t\treturn flatTerms.map((term) => ({\n\t\t\tid: term.id,\n\t\t\tname: term.name,\n\t\t\tslug: term.slug,\n\t\t\tlabel: term.label,\n\t\t\tdescription: term.data ? JSON.parse(term.data).description : undefined,\n\t\t\tchildren: [],\n\t\t\tcount: counts.get(term.translation_group ?? term.id) ?? 0,\n\t\t\tlocale: term.locale,\n\t\t\ttranslationGroup: term.translation_group,\n\t\t}));\n\t});\n}\n\n/**\n * Per-translation-group usage counts across all taxonomies, in one aggregate\n * scan of `content_taxonomies`. Counts are locale-independent (the pivot stores\n * translation_group), so a single request-cached entry serves every taxonomy\n * that renders during the request.\n */\nfunction getTaxonomyTermCounts(): Promise<Map<string, number>> {\n\treturn requestCached(\"taxonomy-term-counts\", async () => {\n\t\tconst db = await getDb();\n\t\tconst countsResult = await db\n\t\t\t.selectFrom(\"content_taxonomies\")\n\t\t\t.select([\"taxonomy_id\"])\n\t\t\t.select((eb) => eb.fn.count<number>(\"entry_id\").as(\"count\"))\n\t\t\t.groupBy(\"taxonomy_id\")\n\t\t\t.execute();\n\t\tconst counts = new Map<string, number>();\n\t\tfor (const row of countsResult) counts.set(row.taxonomy_id, row.count);\n\t\treturn counts;\n\t});\n}\n\n/**\n * Get a single term by (taxonomy, slug). Honours the fallback chain — if the\n * slug exists in a fallback locale, we return that row (useful for deep-linking\n * to a term page when the translation is missing).\n */\nexport async function getTerm(\n\ttaxonomyName: string,\n\tslug: string,\n\toptions: TaxonomyQueryOptions = {},\n): Promise<TaxonomyTerm | null> {\n\tconst db = await getDb();\n\tconst chain = resolveLocaleChain(options.locale);\n\n\tlet row: Awaited<ReturnType<ReturnType<typeof selectTerm>[\"executeTakeFirst\"]>>;\n\tconst selectTerm = () =>\n\t\tdb\n\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t.selectAll()\n\t\t\t.where(\"name\", \"=\", taxonomyName)\n\t\t\t.where(\"slug\", \"=\", slug);\n\n\tif (chain.length === 0) {\n\t\trow = await selectTerm().orderBy(\"locale\", \"asc\").executeTakeFirst();\n\t} else {\n\t\trow = undefined;\n\t\tfor (const locale of chain) {\n\t\t\trow = await selectTerm().where(\"locale\", \"=\", locale).executeTakeFirst();\n\t\t\tif (row) break;\n\t\t}\n\t}\n\n\tif (!row) return null;\n\n\tlet childrenQuery = db\n\t\t.selectFrom(\"taxonomies\")\n\t\t.selectAll()\n\t\t.where(\"parent_id\", \"=\", row.id)\n\t\t.orderBy(\"label\", \"asc\");\n\tconst termLocale = row.locale;\n\tif (termLocale) childrenQuery = childrenQuery.where(\"locale\", \"=\", termLocale);\n\n\t// The usage-count and children queries both depend only on the term row,\n\t// so run them concurrently to save a round trip on remote databases.\n\tconst [countResult, childRows] = await Promise.all([\n\t\tdb\n\t\t\t.selectFrom(\"content_taxonomies\")\n\t\t\t.select((eb) => eb.fn.count<number>(\"entry_id\").as(\"count\"))\n\t\t\t.where(\"taxonomy_id\", \"=\", row.translation_group ?? row.id)\n\t\t\t.executeTakeFirst(),\n\t\tchildrenQuery.execute(),\n\t]);\n\tconst count = countResult?.count ?? 0;\n\n\tconst children = childRows.map<TaxonomyTerm>((child) => ({\n\t\tid: child.id,\n\t\tname: child.name,\n\t\tslug: child.slug,\n\t\tlabel: child.label,\n\t\tparentId: child.parent_id ?? undefined,\n\t\tchildren: [],\n\t\tlocale: child.locale,\n\t\ttranslationGroup: child.translation_group,\n\t}));\n\n\treturn {\n\t\tid: row.id,\n\t\tname: row.name,\n\t\tslug: row.slug,\n\t\tlabel: row.label,\n\t\tparentId: row.parent_id ?? undefined,\n\t\tdescription: row.data ? JSON.parse(row.data).description : undefined,\n\t\tchildren,\n\t\tcount,\n\t\tlocale: row.locale,\n\t\ttranslationGroup: row.translation_group,\n\t};\n}\n\n/**\n * Terms assigned to a content entry, resolved into the active locale. Terms\n * whose translation_group lacks a row in the requested locale are omitted.\n */\nexport function getEntryTerms(\n\tcollection: string,\n\tentryId: string,\n\ttaxonomyName?: string,\n\toptions: TaxonomyQueryOptions = {},\n): Promise<TaxonomyTerm[]> {\n\tconst locale = resolveLocale(options.locale);\n\treturn requestCached(\n\t\t`terms:${collection}:${entryId}:${taxonomyName ?? \"*\"}:${locale ?? \"*\"}`,\n\t\tasync () => {\n\t\t\tconst db = await getDb();\n\n\t\t\tlet query = db\n\t\t\t\t.selectFrom(\"content_taxonomies\")\n\t\t\t\t.innerJoin(\"taxonomies\", \"taxonomies.translation_group\", \"content_taxonomies.taxonomy_id\")\n\t\t\t\t.selectAll(\"taxonomies\")\n\t\t\t\t.where(\"content_taxonomies.collection\", \"=\", collection)\n\t\t\t\t.where(\"content_taxonomies.entry_id\", \"=\", entryId);\n\n\t\t\tif (taxonomyName) query = query.where(\"taxonomies.name\", \"=\", taxonomyName);\n\t\t\tif (locale !== undefined) query = query.where(\"taxonomies.locale\", \"=\", locale);\n\n\t\t\tconst rows = await query.execute();\n\t\t\treturn rows.map<TaxonomyTerm>((row) => ({\n\t\t\t\tid: row.id,\n\t\t\t\tname: row.name,\n\t\t\t\tslug: row.slug,\n\t\t\t\tlabel: row.label,\n\t\t\t\tparentId: row.parent_id ?? undefined,\n\t\t\t\tchildren: [],\n\t\t\t\tlocale: row.locale,\n\t\t\t\ttranslationGroup: row.translation_group,\n\t\t\t}));\n\t\t},\n\t);\n}\n\n/**\n * Terms for multiple entries of one taxonomy, single query.\n */\nexport async function getTermsForEntries(\n\tcollection: string,\n\tentryIds: string[],\n\ttaxonomyName: string,\n\toptions: TaxonomyQueryOptions = {},\n): Promise<Map<string, TaxonomyTerm[]>> {\n\tconst result = new Map<string, TaxonomyTerm[]>();\n\tconst uniqueIds = [...new Set(entryIds)];\n\tfor (const id of uniqueIds) result.set(id, []);\n\tif (uniqueIds.length === 0) return result;\n\n\tconst locale = resolveLocale(options.locale);\n\tconst localeKey = locale ?? \"*\";\n\n\t// Entry-term hydration (getAllTermsForEntries -> primeEntryTermsCache)\n\t// seeds the per-entry cache under the same key getEntryTerms uses:\n\t// `terms:${collection}:${entryId}:${taxonomyName}:${localeKey}`, storing a\n\t// TaxonomyTerm[] (including `[]` for entries with no terms). Satisfy those\n\t// from cache and run the batched query only for the ids that missed.\n\tconst missedIds: string[] = [];\n\ttype CacheRead = { id: string; terms: TaxonomyTerm[] } | { id: string; miss: true };\n\tconst cacheReads: Array<Promise<CacheRead>> = [];\n\tfor (const id of uniqueIds) {\n\t\tconst cached = peekRequestCache<TaxonomyTerm[]>(\n\t\t\t`terms:${collection}:${id}:${taxonomyName}:${localeKey}`,\n\t\t);\n\t\tif (cached) {\n\t\t\t// A peeked promise can reject (e.g. a sibling getEntryTerms hit a\n\t\t\t// missing table). Treat a rejection as a cache miss so the batched\n\t\t\t// query path -- and its isMissingTableError guard below -- still runs,\n\t\t\t// rather than propagating an uncaught error.\n\t\t\tcacheReads.push(\n\t\t\t\tcached.then(\n\t\t\t\t\t(terms): CacheRead => ({ id, terms }),\n\t\t\t\t\t(): CacheRead => ({ id, miss: true }),\n\t\t\t\t),\n\t\t\t);\n\t\t} else {\n\t\t\tmissedIds.push(id);\n\t\t}\n\t}\n\tfor (const read of await Promise.all(cacheReads)) {\n\t\tif (\"miss\" in read) {\n\t\t\tmissedIds.push(read.id);\n\t\t\tcontinue;\n\t\t}\n\t\t// Return a private copy. The cached array and its term objects are shared\n\t\t// with getEntryTerms/getAllTermsForEntries (primeEntryTermsCache stores\n\t\t// the same references), so a caller that mutates the result -- sorting in\n\t\t// place, pushing into `children` -- must not poison the cache. The\n\t\t// pre-cache implementation always returned freshly built arrays.\n\t\tresult.set(\n\t\t\tread.id,\n\t\t\tread.terms.map((t) => ({ ...t, children: [...t.children] })),\n\t\t);\n\t}\n\n\tif (missedIds.length === 0) return result;\n\n\tconst db = await getDb();\n\n\tfor (const chunk of chunks(missedIds, SQL_BATCH_SIZE)) {\n\t\tlet rows;\n\t\ttry {\n\t\t\tlet query = db\n\t\t\t\t.selectFrom(\"content_taxonomies\")\n\t\t\t\t.innerJoin(\"taxonomies\", \"taxonomies.translation_group\", \"content_taxonomies.taxonomy_id\")\n\t\t\t\t.select([\n\t\t\t\t\t\"content_taxonomies.entry_id\",\n\t\t\t\t\t\"taxonomies.id\",\n\t\t\t\t\t\"taxonomies.name\",\n\t\t\t\t\t\"taxonomies.slug\",\n\t\t\t\t\t\"taxonomies.label\",\n\t\t\t\t\t\"taxonomies.parent_id\",\n\t\t\t\t\t\"taxonomies.locale\",\n\t\t\t\t\t\"taxonomies.translation_group\",\n\t\t\t\t])\n\t\t\t\t.where(\"content_taxonomies.collection\", \"=\", collection)\n\t\t\t\t.where(\"content_taxonomies.entry_id\", \"in\", chunk)\n\t\t\t\t.where(\"taxonomies.name\", \"=\", taxonomyName)\n\t\t\t\t// Match the order getAllTermsForEntries (the cache primer) uses, so\n\t\t\t\t// cache-hit and DB-miss entries in one result are ordered consistently.\n\t\t\t\t.orderBy(\"taxonomies.label\", \"asc\");\n\t\t\tif (locale !== undefined) query = query.where(\"taxonomies.locale\", \"=\", locale);\n\t\t\trows = await query.execute();\n\t\t} catch (error) {\n\t\t\tif (isMissingTableError(error)) return result;\n\t\t\tthrow error;\n\t\t}\n\n\t\tfor (const row of rows) {\n\t\t\tconst term: TaxonomyTerm = {\n\t\t\t\tid: row.id,\n\t\t\t\tname: row.name,\n\t\t\t\tslug: row.slug,\n\t\t\t\tlabel: row.label,\n\t\t\t\tparentId: row.parent_id ?? undefined,\n\t\t\t\tchildren: [],\n\t\t\t\tlocale: row.locale,\n\t\t\t\ttranslationGroup: row.translation_group,\n\t\t\t};\n\t\t\tconst terms = result.get(row.entry_id);\n\t\t\tif (terms) terms.push(term);\n\t\t}\n\t}\n\n\treturn result;\n}\n\n/**\n * Batch-fetch terms for multiple entries across ALL taxonomies in one query.\n * Primes the request-cache for subsequent per-entry calls to `getEntryTerms`.\n */\nexport async function getAllTermsForEntries(\n\tcollection: string,\n\tentryIds: string[],\n\toptions: TaxonomyQueryOptions = {},\n): Promise<Map<string, Record<string, TaxonomyTerm[]>>> {\n\tconst result = new Map<string, Record<string, TaxonomyTerm[]>>();\n\tconst uniqueIds = [...new Set(entryIds)];\n\tfor (const id of uniqueIds) result.set(id, {});\n\tif (uniqueIds.length === 0) return result;\n\n\tconst db = await getDb();\n\tconst locale = resolveLocale(options.locale);\n\tconst applicableTaxonomyNames = await getCollectionTaxonomyNames(collection, { locale });\n\n\tfor (const chunk of chunks(uniqueIds, SQL_BATCH_SIZE)) {\n\t\tlet rows;\n\t\ttry {\n\t\t\tlet query = db\n\t\t\t\t.selectFrom(\"content_taxonomies\")\n\t\t\t\t.innerJoin(\"taxonomies\", \"taxonomies.translation_group\", \"content_taxonomies.taxonomy_id\")\n\t\t\t\t.select([\n\t\t\t\t\t\"content_taxonomies.entry_id\",\n\t\t\t\t\t\"taxonomies.id\",\n\t\t\t\t\t\"taxonomies.name\",\n\t\t\t\t\t\"taxonomies.slug\",\n\t\t\t\t\t\"taxonomies.label\",\n\t\t\t\t\t\"taxonomies.parent_id\",\n\t\t\t\t\t\"taxonomies.locale\",\n\t\t\t\t\t\"taxonomies.translation_group\",\n\t\t\t\t])\n\t\t\t\t.where(\"content_taxonomies.collection\", \"=\", collection)\n\t\t\t\t.where(\"content_taxonomies.entry_id\", \"in\", chunk)\n\t\t\t\t.orderBy(\"taxonomies.label\", \"asc\");\n\t\t\tif (locale !== undefined) query = query.where(\"taxonomies.locale\", \"=\", locale);\n\t\t\trows = await query.execute();\n\t\t} catch (error) {\n\t\t\tif (isMissingTableError(error)) {\n\t\t\t\tfor (const id of uniqueIds) {\n\t\t\t\t\tprimeEntryTermsCache(collection, id, {}, applicableTaxonomyNames, locale);\n\t\t\t\t}\n\t\t\t\treturn result;\n\t\t\t}\n\t\t\tthrow error;\n\t\t}\n\n\t\tfor (const row of rows) {\n\t\t\tconst term: TaxonomyTerm = {\n\t\t\t\tid: row.id,\n\t\t\t\tname: row.name,\n\t\t\t\tslug: row.slug,\n\t\t\t\tlabel: row.label,\n\t\t\t\tparentId: row.parent_id ?? undefined,\n\t\t\t\tchildren: [],\n\t\t\t\tlocale: row.locale,\n\t\t\t\ttranslationGroup: row.translation_group,\n\t\t\t};\n\t\t\tconst byTaxonomy = result.get(row.entry_id);\n\t\t\tif (!byTaxonomy) continue;\n\t\t\tconst existing = byTaxonomy[row.name];\n\t\t\tif (existing) existing.push(term);\n\t\t\telse byTaxonomy[row.name] = [term];\n\t\t}\n\t}\n\n\tfor (const [entryId, byTaxonomy] of result) {\n\t\tprimeEntryTermsCache(collection, entryId, byTaxonomy, applicableTaxonomyNames, locale);\n\t}\n\n\treturn result;\n}\n\n/**\n * Return the list of taxonomy names applicable to a collection, request-\n * cached so a page render only pays for it once.\n *\n * Returns an empty list when taxonomies haven't been defined yet.\n */\nasync function getCollectionTaxonomyNames(\n\tcollection: string,\n\toptions: TaxonomyQueryOptions,\n): Promise<string[]> {\n\ttry {\n\t\tconst defs = await getTaxonomyDefs(options);\n\t\treturn defs.filter((d) => d.collections.includes(collection)).map((d) => d.name);\n\t} catch (error) {\n\t\tif (isMissingTableError(error)) return [];\n\t\tthrow error;\n\t}\n}\n\n/**\n * Pre-populate the request-cache for every getEntryTerms call-shape that\n * could hit this entry:\n *\n * getEntryTerms(collection, entryId) -> key `terms:C:E:*`\n * getEntryTerms(collection, entryId, \"tag\") -> key `terms:C:E:tag`\n * getEntryTerms(collection, entryId, \"category\") -> key `terms:C:E:category`\n * ...one per taxonomy that applies to this collection\n *\n * Taxonomies with no rows on this entry are seeded with `[]` so legacy\n * callers short-circuit to the cached empty array instead of re-querying.\n */\nfunction primeEntryTermsCache(\n\tcollection: string,\n\tentryId: string,\n\tbyTaxonomy: Record<string, TaxonomyTerm[]>,\n\tapplicableTaxonomyNames: string[],\n\tlocale: string | undefined,\n): void {\n\tconst localeKey = locale ?? \"*\";\n\tfor (const name of applicableTaxonomyNames) {\n\t\tsetRequestCacheEntry(\n\t\t\t`terms:${collection}:${entryId}:${name}:${localeKey}`,\n\t\t\tbyTaxonomy[name] ?? [],\n\t\t);\n\t}\n\tfor (const [name, terms] of Object.entries(byTaxonomy)) {\n\t\tsetRequestCacheEntry(`terms:${collection}:${entryId}:${name}:${localeKey}`, terms);\n\t}\n\tconst allTerms = Object.values(byTaxonomy).flat();\n\tsetRequestCacheEntry(`terms:${collection}:${entryId}:*:${localeKey}`, allTerms);\n}\n\n/**\n * Get entries by term. Both the lookup (term slug in the active locale) and\n * the content query respect the active locale.\n */\nexport async function getEntriesByTerm(\n\tcollection: string,\n\ttaxonomyName: string,\n\ttermSlug: string,\n\toptions: TaxonomyQueryOptions = {},\n): Promise<Array<{ id: string; data: Record<string, unknown> }>> {\n\tconst { getEmDashCollection } = await import(\"../query.js\");\n\n\tconst queryOptions: Record<string, unknown> = {\n\t\twhere: { [taxonomyName]: termSlug },\n\t};\n\tif (options.locale !== undefined) queryOptions.locale = options.locale;\n\tconst { entries } = await getEmDashCollection(collection, queryOptions);\n\treturn entries;\n}\n\nfunction rowToTaxonomyDef(row: {\n\tid: string;\n\tname: string;\n\tlabel: string;\n\tlabel_singular: string | null;\n\thierarchical: number;\n\tcollections: string | null;\n\tlocale: string;\n\ttranslation_group: string | null;\n}): TaxonomyDef {\n\treturn {\n\t\tid: row.id,\n\t\tname: row.name,\n\t\tlabel: row.label,\n\t\tlabelSingular: row.label_singular ?? undefined,\n\t\thierarchical: row.hierarchical === 1,\n\t\tcollections: row.collections ? JSON.parse(row.collections) : [],\n\t\tlocale: row.locale,\n\t\ttranslationGroup: row.translation_group,\n\t};\n}\n\n/**\n * Build tree structure from flat terms\n */\nfunction buildTree(flatTerms: TaxonomyTermRow[], counts: Map<string, number>): TaxonomyTerm[] {\n\tconst map = new Map<string, TaxonomyTerm>();\n\tconst roots: TaxonomyTerm[] = [];\n\n\tfor (const term of flatTerms) {\n\t\tmap.set(term.id, {\n\t\t\tid: term.id,\n\t\t\tname: term.name,\n\t\t\tslug: term.slug,\n\t\t\tlabel: term.label,\n\t\t\tparentId: term.parent_id ?? undefined,\n\t\t\tdescription: term.data ? JSON.parse(term.data).description : undefined,\n\t\t\tchildren: [],\n\t\t\tcount: counts.get(term.translation_group ?? term.id) ?? 0,\n\t\t\tlocale: term.locale,\n\t\t\ttranslationGroup: term.translation_group,\n\t\t});\n\t}\n\n\tfor (const term of map.values()) {\n\t\tif (term.parentId && map.has(term.parentId)) {\n\t\t\tmap.get(term.parentId)!.children.push(term);\n\t\t} else {\n\t\t\troots.push(term);\n\t\t}\n\t}\n\n\treturn roots;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"taxonomies-BBxYA38v.mjs","names":[],"sources":["../src/taxonomies/index.ts"],"sourcesContent":["/**\n * Runtime API for taxonomies.\n *\n * All helpers are locale-aware. When a locale is not passed explicitly we fall\n * back to the request context or the configured `defaultLocale` (see\n * `i18n/resolve.ts`).\n *\n * Because `content_taxonomies.taxonomy_id` stores the translation_group (not a\n * specific term id), the joins here are `taxonomies.translation_group =\n * content_taxonomies.taxonomy_id` + filter by `taxonomies.locale`, which picks\n * the right per-locale term.\n */\n\nimport { resolveLocale, resolveLocaleChain } from \"../i18n/resolve.js\";\nimport { getDb } from \"../loader.js\";\nimport { peekRequestCache, requestCached, setRequestCacheEntry } from \"../request-cache.js\";\nimport { chunks, SQL_BATCH_SIZE } from \"../utils/chunks.js\";\nimport { isMissingTableError } from \"../utils/db-errors.js\";\nimport type { TaxonomyDef, TaxonomyTerm, TaxonomyTermRow } from \"./types.js\";\n\nexport interface TaxonomyQueryOptions {\n\tlocale?: string;\n}\n\n/**\n * No-op — kept for API compatibility.\n */\nexport function invalidateTermCache(): void {\n\t// Intentionally empty.\n}\n\n/**\n * Get every taxonomy definition. Definitions are per-locale (one row per\n * locale inside the same translation_group) — by default we resolve to the\n * active locale.\n */\nexport async function getTaxonomyDefs(options: TaxonomyQueryOptions = {}): Promise<TaxonomyDef[]> {\n\tconst locale = resolveLocale(options.locale);\n\treturn requestCached(`taxonomy-defs:${locale ?? \"*\"}`, async () => {\n\t\tconst db = await getDb();\n\t\tlet query = db.selectFrom(\"_emdash_taxonomy_defs\").selectAll();\n\t\tif (locale !== undefined) query = query.where(\"locale\", \"=\", locale);\n\t\tconst rows = await query.execute();\n\t\treturn rows.map(rowToTaxonomyDef);\n\t});\n}\n\n/**\n * Get a single taxonomy definition by name. Uses the fallback chain so even\n * if there is no translation for the active locale we still return something.\n *\n * If `getTaxonomyDefs()` has already loaded the full list in this request\n * (which happens during entry-term hydration on every page that renders a\n * collection), search the matching def in memory rather than running a\n * second query against `_emdash_taxonomy_defs`.\n */\nexport async function getTaxonomyDef(\n\tname: string,\n\toptions: TaxonomyQueryOptions = {},\n): Promise<TaxonomyDef | null> {\n\tconst chain = resolveLocaleChain(options.locale);\n\tconst peekKey = `taxonomy-defs:${resolveLocale(options.locale) ?? \"*\"}`;\n\tconst allDefs = peekRequestCache<TaxonomyDef[]>(peekKey);\n\tif (allDefs) {\n\t\tconst defs = await allDefs;\n\t\tif (chain.length === 0) return defs.find((d) => d.name === name) ?? null;\n\t\tfor (const locale of chain) {\n\t\t\tconst found = defs.find((d) => d.name === name && d.locale === locale);\n\t\t\tif (found) return found;\n\t\t}\n\t\treturn null;\n\t}\n\n\treturn requestCached(`taxonomy-def:${name}:${chain.join(\",\")}`, async () => {\n\t\tconst db = await getDb();\n\n\t\tif (chain.length === 0) {\n\t\t\tconst row = await db\n\t\t\t\t.selectFrom(\"_emdash_taxonomy_defs\")\n\t\t\t\t.selectAll()\n\t\t\t\t.where(\"name\", \"=\", name)\n\t\t\t\t.orderBy(\"locale\", \"asc\")\n\t\t\t\t.executeTakeFirst();\n\t\t\treturn row ? rowToTaxonomyDef(row) : null;\n\t\t}\n\n\t\tfor (const locale of chain) {\n\t\t\tconst row = await db\n\t\t\t\t.selectFrom(\"_emdash_taxonomy_defs\")\n\t\t\t\t.selectAll()\n\t\t\t\t.where(\"name\", \"=\", name)\n\t\t\t\t.where(\"locale\", \"=\", locale)\n\t\t\t\t.executeTakeFirst();\n\t\t\tif (row) return rowToTaxonomyDef(row);\n\t\t}\n\t\treturn null;\n\t});\n}\n\n/**\n * All terms of a taxonomy in a specific locale (flat for non-hierarchical,\n * tree for hierarchical).\n */\nexport async function getTaxonomyTerms(\n\ttaxonomyName: string,\n\toptions: TaxonomyQueryOptions = {},\n): Promise<TaxonomyTerm[]> {\n\tconst locale = resolveLocale(options.locale);\n\treturn requestCached(`taxonomy-terms:${taxonomyName}:${locale ?? \"*\"}`, async () => {\n\t\tconst db = await getDb();\n\n\t\tconst def = await getTaxonomyDef(taxonomyName, options);\n\t\tif (!def) return [];\n\n\t\tlet termsQuery = db\n\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t.selectAll()\n\t\t\t.where(\"name\", \"=\", taxonomyName)\n\t\t\t.orderBy(\"label\", \"asc\");\n\t\tif (locale !== undefined) termsQuery = termsQuery.where(\"locale\", \"=\", locale);\n\t\tconst rows = await termsQuery.execute();\n\n\t\t// Counts are keyed by translation_group (what the pivot stores) and are\n\t\t// locale-independent, so the aggregate is shared across every taxonomy\n\t\t// rendered in this request (Categories + Tags widgets, etc.).\n\t\tconst counts = await getTaxonomyTermCounts();\n\n\t\tconst flatTerms: TaxonomyTermRow[] = rows.map((row) => ({\n\t\t\tid: row.id,\n\t\t\tname: row.name,\n\t\t\tslug: row.slug,\n\t\t\tlabel: row.label,\n\t\t\tparent_id: row.parent_id,\n\t\t\tdata: row.data,\n\t\t\tlocale: row.locale,\n\t\t\ttranslation_group: row.translation_group,\n\t\t}));\n\n\t\tif (def.hierarchical) return buildTree(flatTerms, counts);\n\n\t\treturn flatTerms.map((term) => ({\n\t\t\tid: term.id,\n\t\t\tname: term.name,\n\t\t\tslug: term.slug,\n\t\t\tlabel: term.label,\n\t\t\tdescription: term.data ? JSON.parse(term.data).description : undefined,\n\t\t\tchildren: [],\n\t\t\tcount: counts.get(term.translation_group ?? term.id) ?? 0,\n\t\t\tlocale: term.locale,\n\t\t\ttranslationGroup: term.translation_group,\n\t\t}));\n\t});\n}\n\n/**\n * Per-translation-group usage counts across all taxonomies, in one aggregate\n * scan of `content_taxonomies`. Counts are locale-independent (the pivot stores\n * translation_group), so a single request-cached entry serves every taxonomy\n * that renders during the request.\n */\nfunction getTaxonomyTermCounts(): Promise<Map<string, number>> {\n\treturn requestCached(\"taxonomy-term-counts\", async () => {\n\t\tconst db = await getDb();\n\t\tconst countsResult = await db\n\t\t\t.selectFrom(\"content_taxonomies\")\n\t\t\t.select([\"taxonomy_id\"])\n\t\t\t.select((eb) => eb.fn.count<number>(\"entry_id\").as(\"count\"))\n\t\t\t.groupBy(\"taxonomy_id\")\n\t\t\t.execute();\n\t\tconst counts = new Map<string, number>();\n\t\tfor (const row of countsResult) counts.set(row.taxonomy_id, row.count);\n\t\treturn counts;\n\t});\n}\n\n/**\n * Get a single term by (taxonomy, slug). Honours the fallback chain — if the\n * slug exists in a fallback locale, we return that row (useful for deep-linking\n * to a term page when the translation is missing).\n */\nexport async function getTerm(\n\ttaxonomyName: string,\n\tslug: string,\n\toptions: TaxonomyQueryOptions = {},\n): Promise<TaxonomyTerm | null> {\n\tconst db = await getDb();\n\tconst chain = resolveLocaleChain(options.locale);\n\n\tlet row: Awaited<ReturnType<ReturnType<typeof selectTerm>[\"executeTakeFirst\"]>>;\n\tconst selectTerm = () =>\n\t\tdb\n\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t.selectAll()\n\t\t\t.where(\"name\", \"=\", taxonomyName)\n\t\t\t.where(\"slug\", \"=\", slug);\n\n\tif (chain.length === 0) {\n\t\trow = await selectTerm().orderBy(\"locale\", \"asc\").executeTakeFirst();\n\t} else {\n\t\trow = undefined;\n\t\tfor (const locale of chain) {\n\t\t\trow = await selectTerm().where(\"locale\", \"=\", locale).executeTakeFirst();\n\t\t\tif (row) break;\n\t\t}\n\t}\n\n\tif (!row) return null;\n\n\tlet childrenQuery = db\n\t\t.selectFrom(\"taxonomies\")\n\t\t.selectAll()\n\t\t.where(\"parent_id\", \"=\", row.id)\n\t\t.orderBy(\"label\", \"asc\");\n\tconst termLocale = row.locale;\n\tif (termLocale) childrenQuery = childrenQuery.where(\"locale\", \"=\", termLocale);\n\n\t// The usage-count and children queries both depend only on the term row,\n\t// so run them concurrently to save a round trip on remote databases.\n\tconst [countResult, childRows] = await Promise.all([\n\t\tdb\n\t\t\t.selectFrom(\"content_taxonomies\")\n\t\t\t.select((eb) => eb.fn.count<number>(\"entry_id\").as(\"count\"))\n\t\t\t.where(\"taxonomy_id\", \"=\", row.translation_group ?? row.id)\n\t\t\t.executeTakeFirst(),\n\t\tchildrenQuery.execute(),\n\t]);\n\tconst count = countResult?.count ?? 0;\n\n\tconst children = childRows.map<TaxonomyTerm>((child) => ({\n\t\tid: child.id,\n\t\tname: child.name,\n\t\tslug: child.slug,\n\t\tlabel: child.label,\n\t\tparentId: child.parent_id ?? undefined,\n\t\tchildren: [],\n\t\tlocale: child.locale,\n\t\ttranslationGroup: child.translation_group,\n\t}));\n\n\treturn {\n\t\tid: row.id,\n\t\tname: row.name,\n\t\tslug: row.slug,\n\t\tlabel: row.label,\n\t\tparentId: row.parent_id ?? undefined,\n\t\tdescription: row.data ? JSON.parse(row.data).description : undefined,\n\t\tchildren,\n\t\tcount,\n\t\tlocale: row.locale,\n\t\ttranslationGroup: row.translation_group,\n\t};\n}\n\n/**\n * Terms assigned to a content entry, resolved into the active locale. Terms\n * whose translation_group lacks a row in the requested locale are omitted.\n */\nexport function getEntryTerms(\n\tcollection: string,\n\tentryId: string,\n\ttaxonomyName?: string,\n\toptions: TaxonomyQueryOptions = {},\n): Promise<TaxonomyTerm[]> {\n\tconst locale = resolveLocale(options.locale);\n\treturn requestCached(\n\t\t`terms:${collection}:${entryId}:${taxonomyName ?? \"*\"}:${locale ?? \"*\"}`,\n\t\tasync () => {\n\t\t\tconst db = await getDb();\n\n\t\t\tlet query = db\n\t\t\t\t.selectFrom(\"content_taxonomies\")\n\t\t\t\t.innerJoin(\"taxonomies\", \"taxonomies.translation_group\", \"content_taxonomies.taxonomy_id\")\n\t\t\t\t.selectAll(\"taxonomies\")\n\t\t\t\t.where(\"content_taxonomies.collection\", \"=\", collection)\n\t\t\t\t.where(\"content_taxonomies.entry_id\", \"=\", entryId);\n\n\t\t\tif (taxonomyName) query = query.where(\"taxonomies.name\", \"=\", taxonomyName);\n\t\t\tif (locale !== undefined) query = query.where(\"taxonomies.locale\", \"=\", locale);\n\n\t\t\tconst rows = await query.execute();\n\t\t\treturn rows.map<TaxonomyTerm>((row) => ({\n\t\t\t\tid: row.id,\n\t\t\t\tname: row.name,\n\t\t\t\tslug: row.slug,\n\t\t\t\tlabel: row.label,\n\t\t\t\tparentId: row.parent_id ?? undefined,\n\t\t\t\tchildren: [],\n\t\t\t\tlocale: row.locale,\n\t\t\t\ttranslationGroup: row.translation_group,\n\t\t\t}));\n\t\t},\n\t);\n}\n\n/**\n * Terms for multiple entries of one taxonomy, single query.\n */\nexport async function getTermsForEntries(\n\tcollection: string,\n\tentryIds: string[],\n\ttaxonomyName: string,\n\toptions: TaxonomyQueryOptions = {},\n): Promise<Map<string, TaxonomyTerm[]>> {\n\tconst result = new Map<string, TaxonomyTerm[]>();\n\tconst uniqueIds = [...new Set(entryIds)];\n\tfor (const id of uniqueIds) result.set(id, []);\n\tif (uniqueIds.length === 0) return result;\n\n\tconst locale = resolveLocale(options.locale);\n\tconst localeKey = locale ?? \"*\";\n\n\t// Entry-term hydration (getAllTermsForEntries -> primeEntryTermsCache)\n\t// seeds the per-entry cache under the same key getEntryTerms uses:\n\t// `terms:${collection}:${entryId}:${taxonomyName}:${localeKey}`, storing a\n\t// TaxonomyTerm[] (including `[]` for entries with no terms). Satisfy those\n\t// from cache and run the batched query only for the ids that missed.\n\tconst missedIds: string[] = [];\n\ttype CacheRead = { id: string; terms: TaxonomyTerm[] } | { id: string; miss: true };\n\tconst cacheReads: Array<Promise<CacheRead>> = [];\n\tfor (const id of uniqueIds) {\n\t\tconst cached = peekRequestCache<TaxonomyTerm[]>(\n\t\t\t`terms:${collection}:${id}:${taxonomyName}:${localeKey}`,\n\t\t);\n\t\tif (cached) {\n\t\t\t// A peeked promise can reject (e.g. a sibling getEntryTerms hit a\n\t\t\t// missing table). Treat a rejection as a cache miss so the batched\n\t\t\t// query path -- and its isMissingTableError guard below -- still runs,\n\t\t\t// rather than propagating an uncaught error.\n\t\t\tcacheReads.push(\n\t\t\t\tcached.then(\n\t\t\t\t\t(terms): CacheRead => ({ id, terms }),\n\t\t\t\t\t(): CacheRead => ({ id, miss: true }),\n\t\t\t\t),\n\t\t\t);\n\t\t} else {\n\t\t\tmissedIds.push(id);\n\t\t}\n\t}\n\tfor (const read of await Promise.all(cacheReads)) {\n\t\tif (\"miss\" in read) {\n\t\t\tmissedIds.push(read.id);\n\t\t\tcontinue;\n\t\t}\n\t\t// Return a private copy. The cached array and its term objects are shared\n\t\t// with getEntryTerms/getAllTermsForEntries (primeEntryTermsCache stores\n\t\t// the same references), so a caller that mutates the result -- sorting in\n\t\t// place, pushing into `children` -- must not poison the cache. The\n\t\t// pre-cache implementation always returned freshly built arrays.\n\t\tresult.set(\n\t\t\tread.id,\n\t\t\tread.terms.map((t) => ({ ...t, children: [...t.children] })),\n\t\t);\n\t}\n\n\tif (missedIds.length === 0) return result;\n\n\tconst db = await getDb();\n\n\tfor (const chunk of chunks(missedIds, SQL_BATCH_SIZE)) {\n\t\tlet rows;\n\t\ttry {\n\t\t\tlet query = db\n\t\t\t\t.selectFrom(\"content_taxonomies\")\n\t\t\t\t.innerJoin(\"taxonomies\", \"taxonomies.translation_group\", \"content_taxonomies.taxonomy_id\")\n\t\t\t\t.select([\n\t\t\t\t\t\"content_taxonomies.entry_id\",\n\t\t\t\t\t\"taxonomies.id\",\n\t\t\t\t\t\"taxonomies.name\",\n\t\t\t\t\t\"taxonomies.slug\",\n\t\t\t\t\t\"taxonomies.label\",\n\t\t\t\t\t\"taxonomies.parent_id\",\n\t\t\t\t\t\"taxonomies.locale\",\n\t\t\t\t\t\"taxonomies.translation_group\",\n\t\t\t\t])\n\t\t\t\t.where(\"content_taxonomies.collection\", \"=\", collection)\n\t\t\t\t.where(\"content_taxonomies.entry_id\", \"in\", chunk)\n\t\t\t\t.where(\"taxonomies.name\", \"=\", taxonomyName)\n\t\t\t\t// Match the order getAllTermsForEntries (the cache primer) uses, so\n\t\t\t\t// cache-hit and DB-miss entries in one result are ordered consistently.\n\t\t\t\t.orderBy(\"taxonomies.label\", \"asc\");\n\t\t\tif (locale !== undefined) query = query.where(\"taxonomies.locale\", \"=\", locale);\n\t\t\trows = await query.execute();\n\t\t} catch (error) {\n\t\t\tif (isMissingTableError(error)) return result;\n\t\t\tthrow error;\n\t\t}\n\n\t\tfor (const row of rows) {\n\t\t\tconst term: TaxonomyTerm = {\n\t\t\t\tid: row.id,\n\t\t\t\tname: row.name,\n\t\t\t\tslug: row.slug,\n\t\t\t\tlabel: row.label,\n\t\t\t\tparentId: row.parent_id ?? undefined,\n\t\t\t\tchildren: [],\n\t\t\t\tlocale: row.locale,\n\t\t\t\ttranslationGroup: row.translation_group,\n\t\t\t};\n\t\t\tconst terms = result.get(row.entry_id);\n\t\t\tif (terms) terms.push(term);\n\t\t}\n\t}\n\n\treturn result;\n}\n\n/**\n * Batch-fetch terms for multiple entries across ALL taxonomies in one query.\n * Primes the request-cache for subsequent per-entry calls to `getEntryTerms`.\n */\nexport async function getAllTermsForEntries(\n\tcollection: string,\n\tentryIds: string[],\n\toptions: TaxonomyQueryOptions = {},\n): Promise<Map<string, Record<string, TaxonomyTerm[]>>> {\n\tconst result = new Map<string, Record<string, TaxonomyTerm[]>>();\n\tconst uniqueIds = [...new Set(entryIds)];\n\tfor (const id of uniqueIds) result.set(id, {});\n\tif (uniqueIds.length === 0) return result;\n\n\tconst db = await getDb();\n\tconst locale = resolveLocale(options.locale);\n\tconst applicableTaxonomyNames = await getCollectionTaxonomyNames(collection, { locale });\n\n\tfor (const chunk of chunks(uniqueIds, SQL_BATCH_SIZE)) {\n\t\tlet rows;\n\t\ttry {\n\t\t\tlet query = db\n\t\t\t\t.selectFrom(\"content_taxonomies\")\n\t\t\t\t.innerJoin(\"taxonomies\", \"taxonomies.translation_group\", \"content_taxonomies.taxonomy_id\")\n\t\t\t\t.select([\n\t\t\t\t\t\"content_taxonomies.entry_id\",\n\t\t\t\t\t\"taxonomies.id\",\n\t\t\t\t\t\"taxonomies.name\",\n\t\t\t\t\t\"taxonomies.slug\",\n\t\t\t\t\t\"taxonomies.label\",\n\t\t\t\t\t\"taxonomies.parent_id\",\n\t\t\t\t\t\"taxonomies.locale\",\n\t\t\t\t\t\"taxonomies.translation_group\",\n\t\t\t\t])\n\t\t\t\t.where(\"content_taxonomies.collection\", \"=\", collection)\n\t\t\t\t.where(\"content_taxonomies.entry_id\", \"in\", chunk)\n\t\t\t\t.orderBy(\"taxonomies.label\", \"asc\");\n\t\t\tif (locale !== undefined) query = query.where(\"taxonomies.locale\", \"=\", locale);\n\t\t\trows = await query.execute();\n\t\t} catch (error) {\n\t\t\tif (isMissingTableError(error)) {\n\t\t\t\tfor (const id of uniqueIds) {\n\t\t\t\t\tprimeEntryTermsCache(collection, id, {}, applicableTaxonomyNames, locale);\n\t\t\t\t}\n\t\t\t\treturn result;\n\t\t\t}\n\t\t\tthrow error;\n\t\t}\n\n\t\tfor (const row of rows) {\n\t\t\tconst term: TaxonomyTerm = {\n\t\t\t\tid: row.id,\n\t\t\t\tname: row.name,\n\t\t\t\tslug: row.slug,\n\t\t\t\tlabel: row.label,\n\t\t\t\tparentId: row.parent_id ?? undefined,\n\t\t\t\tchildren: [],\n\t\t\t\tlocale: row.locale,\n\t\t\t\ttranslationGroup: row.translation_group,\n\t\t\t};\n\t\t\tconst byTaxonomy = result.get(row.entry_id);\n\t\t\tif (!byTaxonomy) continue;\n\t\t\tconst existing = byTaxonomy[row.name];\n\t\t\tif (existing) existing.push(term);\n\t\t\telse byTaxonomy[row.name] = [term];\n\t\t}\n\t}\n\n\tfor (const [entryId, byTaxonomy] of result) {\n\t\tprimeEntryTermsCache(collection, entryId, byTaxonomy, applicableTaxonomyNames, locale);\n\t}\n\n\treturn result;\n}\n\n/**\n * Return the list of taxonomy names applicable to a collection, request-\n * cached so a page render only pays for it once.\n *\n * Returns an empty list when taxonomies haven't been defined yet.\n */\nasync function getCollectionTaxonomyNames(\n\tcollection: string,\n\toptions: TaxonomyQueryOptions,\n): Promise<string[]> {\n\ttry {\n\t\tconst defs = await getTaxonomyDefs(options);\n\t\treturn defs.filter((d) => d.collections.includes(collection)).map((d) => d.name);\n\t} catch (error) {\n\t\tif (isMissingTableError(error)) return [];\n\t\tthrow error;\n\t}\n}\n\n/**\n * Pre-populate the request-cache for every getEntryTerms call-shape that\n * could hit this entry:\n *\n * getEntryTerms(collection, entryId) -> key `terms:C:E:*`\n * getEntryTerms(collection, entryId, \"tag\") -> key `terms:C:E:tag`\n * getEntryTerms(collection, entryId, \"category\") -> key `terms:C:E:category`\n * ...one per taxonomy that applies to this collection\n *\n * Taxonomies with no rows on this entry are seeded with `[]` so legacy\n * callers short-circuit to the cached empty array instead of re-querying.\n */\nfunction primeEntryTermsCache(\n\tcollection: string,\n\tentryId: string,\n\tbyTaxonomy: Record<string, TaxonomyTerm[]>,\n\tapplicableTaxonomyNames: string[],\n\tlocale: string | undefined,\n): void {\n\tconst localeKey = locale ?? \"*\";\n\tfor (const name of applicableTaxonomyNames) {\n\t\tsetRequestCacheEntry(\n\t\t\t`terms:${collection}:${entryId}:${name}:${localeKey}`,\n\t\t\tbyTaxonomy[name] ?? [],\n\t\t);\n\t}\n\tfor (const [name, terms] of Object.entries(byTaxonomy)) {\n\t\tsetRequestCacheEntry(`terms:${collection}:${entryId}:${name}:${localeKey}`, terms);\n\t}\n\tconst allTerms = Object.values(byTaxonomy).flat();\n\tsetRequestCacheEntry(`terms:${collection}:${entryId}:*:${localeKey}`, allTerms);\n}\n\n/**\n * Get entries by term. Both the lookup (term slug in the active locale) and\n * the content query respect the active locale.\n */\nexport async function getEntriesByTerm(\n\tcollection: string,\n\ttaxonomyName: string,\n\ttermSlug: string,\n\toptions: TaxonomyQueryOptions = {},\n): Promise<Array<{ id: string; data: Record<string, unknown> }>> {\n\tconst { getEmDashCollection } = await import(\"../query.js\");\n\n\tconst queryOptions: Record<string, unknown> = {\n\t\twhere: { [taxonomyName]: termSlug },\n\t};\n\tif (options.locale !== undefined) queryOptions.locale = options.locale;\n\tconst { entries } = await getEmDashCollection(collection, queryOptions);\n\treturn entries;\n}\n\nfunction rowToTaxonomyDef(row: {\n\tid: string;\n\tname: string;\n\tlabel: string;\n\tlabel_singular: string | null;\n\thierarchical: number;\n\tcollections: string | null;\n\tlocale: string;\n\ttranslation_group: string | null;\n}): TaxonomyDef {\n\treturn {\n\t\tid: row.id,\n\t\tname: row.name,\n\t\tlabel: row.label,\n\t\tlabelSingular: row.label_singular ?? undefined,\n\t\thierarchical: row.hierarchical === 1,\n\t\tcollections: row.collections ? JSON.parse(row.collections) : [],\n\t\tlocale: row.locale,\n\t\ttranslationGroup: row.translation_group,\n\t};\n}\n\n/**\n * Build tree structure from flat terms\n */\nfunction buildTree(flatTerms: TaxonomyTermRow[], counts: Map<string, number>): TaxonomyTerm[] {\n\tconst map = new Map<string, TaxonomyTerm>();\n\tconst roots: TaxonomyTerm[] = [];\n\n\tfor (const term of flatTerms) {\n\t\tmap.set(term.id, {\n\t\t\tid: term.id,\n\t\t\tname: term.name,\n\t\t\tslug: term.slug,\n\t\t\tlabel: term.label,\n\t\t\tparentId: term.parent_id ?? undefined,\n\t\t\tdescription: term.data ? JSON.parse(term.data).description : undefined,\n\t\t\tchildren: [],\n\t\t\tcount: counts.get(term.translation_group ?? term.id) ?? 0,\n\t\t\tlocale: term.locale,\n\t\t\ttranslationGroup: term.translation_group,\n\t\t});\n\t}\n\n\tfor (const term of map.values()) {\n\t\tif (term.parentId && map.has(term.parentId)) {\n\t\t\tmap.get(term.parentId)!.children.push(term);\n\t\t} else {\n\t\t\troots.push(term);\n\t\t}\n\t}\n\n\treturn roots;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA2BA,SAAgB,sBAA4B;;;;;;AAS5C,eAAsB,gBAAgB,UAAgC,EAAE,EAA0B;CACjG,MAAM,SAAS,cAAc,QAAQ,OAAO;AAC5C,QAAO,cAAc,iBAAiB,UAAU,OAAO,YAAY;EAElE,IAAI,SADO,MAAM,OAAO,EACT,WAAW,wBAAwB,CAAC,WAAW;AAC9D,MAAI,WAAW,OAAW,SAAQ,MAAM,MAAM,UAAU,KAAK,OAAO;AAEpE,UADa,MAAM,MAAM,SAAS,EACtB,IAAI,iBAAiB;GAChC;;;;;;;;;;;AAYH,eAAsB,eACrB,MACA,UAAgC,EAAE,EACJ;CAC9B,MAAM,QAAQ,mBAAmB,QAAQ,OAAO;CAEhD,MAAM,UAAU,iBADA,iBAAiB,cAAc,QAAQ,OAAO,IAAI,MACV;AACxD,KAAI,SAAS;EACZ,MAAM,OAAO,MAAM;AACnB,MAAI,MAAM,WAAW,EAAG,QAAO,KAAK,MAAM,MAAM,EAAE,SAAS,KAAK,IAAI;AACpE,OAAK,MAAM,UAAU,OAAO;GAC3B,MAAM,QAAQ,KAAK,MAAM,MAAM,EAAE,SAAS,QAAQ,EAAE,WAAW,OAAO;AACtE,OAAI,MAAO,QAAO;;AAEnB,SAAO;;AAGR,QAAO,cAAc,gBAAgB,KAAK,GAAG,MAAM,KAAK,IAAI,IAAI,YAAY;EAC3E,MAAM,KAAK,MAAM,OAAO;AAExB,MAAI,MAAM,WAAW,GAAG;GACvB,MAAM,MAAM,MAAM,GAChB,WAAW,wBAAwB,CACnC,WAAW,CACX,MAAM,QAAQ,KAAK,KAAK,CACxB,QAAQ,UAAU,MAAM,CACxB,kBAAkB;AACpB,UAAO,MAAM,iBAAiB,IAAI,GAAG;;AAGtC,OAAK,MAAM,UAAU,OAAO;GAC3B,MAAM,MAAM,MAAM,GAChB,WAAW,wBAAwB,CACnC,WAAW,CACX,MAAM,QAAQ,KAAK,KAAK,CACxB,MAAM,UAAU,KAAK,OAAO,CAC5B,kBAAkB;AACpB,OAAI,IAAK,QAAO,iBAAiB,IAAI;;AAEtC,SAAO;GACN;;;;;;AAOH,eAAsB,iBACrB,cACA,UAAgC,EAAE,EACR;CAC1B,MAAM,SAAS,cAAc,QAAQ,OAAO;AAC5C,QAAO,cAAc,kBAAkB,aAAa,GAAG,UAAU,OAAO,YAAY;EACnF,MAAM,KAAK,MAAM,OAAO;EAExB,MAAM,MAAM,MAAM,eAAe,cAAc,QAAQ;AACvD,MAAI,CAAC,IAAK,QAAO,EAAE;EAEnB,IAAI,aAAa,GACf,WAAW,aAAa,CACxB,WAAW,CACX,MAAM,QAAQ,KAAK,aAAa,CAChC,QAAQ,SAAS,MAAM;AACzB,MAAI,WAAW,OAAW,cAAa,WAAW,MAAM,UAAU,KAAK,OAAO;EAC9E,MAAM,OAAO,MAAM,WAAW,SAAS;EAKvC,MAAM,SAAS,MAAM,uBAAuB;EAE5C,MAAM,YAA+B,KAAK,KAAK,SAAS;GACvD,IAAI,IAAI;GACR,MAAM,IAAI;GACV,MAAM,IAAI;GACV,OAAO,IAAI;GACX,WAAW,IAAI;GACf,MAAM,IAAI;GACV,QAAQ,IAAI;GACZ,mBAAmB,IAAI;GACvB,EAAE;AAEH,MAAI,IAAI,aAAc,QAAO,UAAU,WAAW,OAAO;AAEzD,SAAO,UAAU,KAAK,UAAU;GAC/B,IAAI,KAAK;GACT,MAAM,KAAK;GACX,MAAM,KAAK;GACX,OAAO,KAAK;GACZ,aAAa,KAAK,OAAO,KAAK,MAAM,KAAK,KAAK,CAAC,cAAc;GAC7D,UAAU,EAAE;GACZ,OAAO,OAAO,IAAI,KAAK,qBAAqB,KAAK,GAAG,IAAI;GACxD,QAAQ,KAAK;GACb,kBAAkB,KAAK;GACvB,EAAE;GACF;;;;;;;;AASH,SAAS,wBAAsD;AAC9D,QAAO,cAAc,wBAAwB,YAAY;EAExD,MAAM,eAAe,OADV,MAAM,OAAO,EAEtB,WAAW,qBAAqB,CAChC,OAAO,CAAC,cAAc,CAAC,CACvB,QAAQ,OAAO,GAAG,GAAG,MAAc,WAAW,CAAC,GAAG,QAAQ,CAAC,CAC3D,QAAQ,cAAc,CACtB,SAAS;EACX,MAAM,yBAAS,IAAI,KAAqB;AACxC,OAAK,MAAM,OAAO,aAAc,QAAO,IAAI,IAAI,aAAa,IAAI,MAAM;AACtE,SAAO;GACN;;;;;;;AAQH,eAAsB,QACrB,cACA,MACA,UAAgC,EAAE,EACH;CAC/B,MAAM,KAAK,MAAM,OAAO;CACxB,MAAM,QAAQ,mBAAmB,QAAQ,OAAO;CAEhD,IAAI;CACJ,MAAM,mBACL,GACE,WAAW,aAAa,CACxB,WAAW,CACX,MAAM,QAAQ,KAAK,aAAa,CAChC,MAAM,QAAQ,KAAK,KAAK;AAE3B,KAAI,MAAM,WAAW,EACpB,OAAM,MAAM,YAAY,CAAC,QAAQ,UAAU,MAAM,CAAC,kBAAkB;MAC9D;AACN,QAAM;AACN,OAAK,MAAM,UAAU,OAAO;AAC3B,SAAM,MAAM,YAAY,CAAC,MAAM,UAAU,KAAK,OAAO,CAAC,kBAAkB;AACxE,OAAI,IAAK;;;AAIX,KAAI,CAAC,IAAK,QAAO;CAEjB,IAAI,gBAAgB,GAClB,WAAW,aAAa,CACxB,WAAW,CACX,MAAM,aAAa,KAAK,IAAI,GAAG,CAC/B,QAAQ,SAAS,MAAM;CACzB,MAAM,aAAa,IAAI;AACvB,KAAI,WAAY,iBAAgB,cAAc,MAAM,UAAU,KAAK,WAAW;CAI9E,MAAM,CAAC,aAAa,aAAa,MAAM,QAAQ,IAAI,CAClD,GACE,WAAW,qBAAqB,CAChC,QAAQ,OAAO,GAAG,GAAG,MAAc,WAAW,CAAC,GAAG,QAAQ,CAAC,CAC3D,MAAM,eAAe,KAAK,IAAI,qBAAqB,IAAI,GAAG,CAC1D,kBAAkB,EACpB,cAAc,SAAS,CACvB,CAAC;CACF,MAAM,QAAQ,aAAa,SAAS;CAEpC,MAAM,WAAW,UAAU,KAAmB,WAAW;EACxD,IAAI,MAAM;EACV,MAAM,MAAM;EACZ,MAAM,MAAM;EACZ,OAAO,MAAM;EACb,UAAU,MAAM,aAAa;EAC7B,UAAU,EAAE;EACZ,QAAQ,MAAM;EACd,kBAAkB,MAAM;EACxB,EAAE;AAEH,QAAO;EACN,IAAI,IAAI;EACR,MAAM,IAAI;EACV,MAAM,IAAI;EACV,OAAO,IAAI;EACX,UAAU,IAAI,aAAa;EAC3B,aAAa,IAAI,OAAO,KAAK,MAAM,IAAI,KAAK,CAAC,cAAc;EAC3D;EACA;EACA,QAAQ,IAAI;EACZ,kBAAkB,IAAI;EACtB;;;;;;AAOF,SAAgB,cACf,YACA,SACA,cACA,UAAgC,EAAE,EACR;CAC1B,MAAM,SAAS,cAAc,QAAQ,OAAO;AAC5C,QAAO,cACN,SAAS,WAAW,GAAG,QAAQ,GAAG,gBAAgB,IAAI,GAAG,UAAU,OACnE,YAAY;EAGX,IAAI,SAFO,MAAM,OAAO,EAGtB,WAAW,qBAAqB,CAChC,UAAU,cAAc,gCAAgC,iCAAiC,CACzF,UAAU,aAAa,CACvB,MAAM,iCAAiC,KAAK,WAAW,CACvD,MAAM,+BAA+B,KAAK,QAAQ;AAEpD,MAAI,aAAc,SAAQ,MAAM,MAAM,mBAAmB,KAAK,aAAa;AAC3E,MAAI,WAAW,OAAW,SAAQ,MAAM,MAAM,qBAAqB,KAAK,OAAO;AAG/E,UADa,MAAM,MAAM,SAAS,EACtB,KAAmB,SAAS;GACvC,IAAI,IAAI;GACR,MAAM,IAAI;GACV,MAAM,IAAI;GACV,OAAO,IAAI;GACX,UAAU,IAAI,aAAa;GAC3B,UAAU,EAAE;GACZ,QAAQ,IAAI;GACZ,kBAAkB,IAAI;GACtB,EAAE;GAEJ;;;;;AAMF,eAAsB,mBACrB,YACA,UACA,cACA,UAAgC,EAAE,EACK;CACvC,MAAM,yBAAS,IAAI,KAA6B;CAChD,MAAM,YAAY,CAAC,GAAG,IAAI,IAAI,SAAS,CAAC;AACxC,MAAK,MAAM,MAAM,UAAW,QAAO,IAAI,IAAI,EAAE,CAAC;AAC9C,KAAI,UAAU,WAAW,EAAG,QAAO;CAEnC,MAAM,SAAS,cAAc,QAAQ,OAAO;CAC5C,MAAM,YAAY,UAAU;CAO5B,MAAM,YAAsB,EAAE;CAE9B,MAAM,aAAwC,EAAE;AAChD,MAAK,MAAM,MAAM,WAAW;EAC3B,MAAM,SAAS,iBACd,SAAS,WAAW,GAAG,GAAG,GAAG,aAAa,GAAG,YAC7C;AACD,MAAI,OAKH,YAAW,KACV,OAAO,MACL,WAAsB;GAAE;GAAI;GAAO,UAClB;GAAE;GAAI,MAAM;GAAM,EACpC,CACD;MAED,WAAU,KAAK,GAAG;;AAGpB,MAAK,MAAM,QAAQ,MAAM,QAAQ,IAAI,WAAW,EAAE;AACjD,MAAI,UAAU,MAAM;AACnB,aAAU,KAAK,KAAK,GAAG;AACvB;;AAOD,SAAO,IACN,KAAK,IACL,KAAK,MAAM,KAAK,OAAO;GAAE,GAAG;GAAG,UAAU,CAAC,GAAG,EAAE,SAAS;GAAE,EAAE,CAC5D;;AAGF,KAAI,UAAU,WAAW,EAAG,QAAO;CAEnC,MAAM,KAAK,MAAM,OAAO;AAExB,MAAK,MAAM,SAAS,OAAO,WAAW,eAAe,EAAE;EACtD,IAAI;AACJ,MAAI;GACH,IAAI,QAAQ,GACV,WAAW,qBAAqB,CAChC,UAAU,cAAc,gCAAgC,iCAAiC,CACzF,OAAO;IACP;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA,CAAC,CACD,MAAM,iCAAiC,KAAK,WAAW,CACvD,MAAM,+BAA+B,MAAM,MAAM,CACjD,MAAM,mBAAmB,KAAK,aAAa,CAG3C,QAAQ,oBAAoB,MAAM;AACpC,OAAI,WAAW,OAAW,SAAQ,MAAM,MAAM,qBAAqB,KAAK,OAAO;AAC/E,UAAO,MAAM,MAAM,SAAS;WACpB,OAAO;AACf,OAAI,oBAAoB,MAAM,CAAE,QAAO;AACvC,SAAM;;AAGP,OAAK,MAAM,OAAO,MAAM;GACvB,MAAM,OAAqB;IAC1B,IAAI,IAAI;IACR,MAAM,IAAI;IACV,MAAM,IAAI;IACV,OAAO,IAAI;IACX,UAAU,IAAI,aAAa;IAC3B,UAAU,EAAE;IACZ,QAAQ,IAAI;IACZ,kBAAkB,IAAI;IACtB;GACD,MAAM,QAAQ,OAAO,IAAI,IAAI,SAAS;AACtC,OAAI,MAAO,OAAM,KAAK,KAAK;;;AAI7B,QAAO;;;;;;AAOR,eAAsB,sBACrB,YACA,UACA,UAAgC,EAAE,EACqB;CACvD,MAAM,yBAAS,IAAI,KAA6C;CAChE,MAAM,YAAY,CAAC,GAAG,IAAI,IAAI,SAAS,CAAC;AACxC,MAAK,MAAM,MAAM,UAAW,QAAO,IAAI,IAAI,EAAE,CAAC;AAC9C,KAAI,UAAU,WAAW,EAAG,QAAO;CAEnC,MAAM,KAAK,MAAM,OAAO;CACxB,MAAM,SAAS,cAAc,QAAQ,OAAO;CAC5C,MAAM,0BAA0B,MAAM,2BAA2B,YAAY,EAAE,QAAQ,CAAC;AAExF,MAAK,MAAM,SAAS,OAAO,WAAW,eAAe,EAAE;EACtD,IAAI;AACJ,MAAI;GACH,IAAI,QAAQ,GACV,WAAW,qBAAqB,CAChC,UAAU,cAAc,gCAAgC,iCAAiC,CACzF,OAAO;IACP;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA,CAAC,CACD,MAAM,iCAAiC,KAAK,WAAW,CACvD,MAAM,+BAA+B,MAAM,MAAM,CACjD,QAAQ,oBAAoB,MAAM;AACpC,OAAI,WAAW,OAAW,SAAQ,MAAM,MAAM,qBAAqB,KAAK,OAAO;AAC/E,UAAO,MAAM,MAAM,SAAS;WACpB,OAAO;AACf,OAAI,oBAAoB,MAAM,EAAE;AAC/B,SAAK,MAAM,MAAM,UAChB,sBAAqB,YAAY,IAAI,EAAE,EAAE,yBAAyB,OAAO;AAE1E,WAAO;;AAER,SAAM;;AAGP,OAAK,MAAM,OAAO,MAAM;GACvB,MAAM,OAAqB;IAC1B,IAAI,IAAI;IACR,MAAM,IAAI;IACV,MAAM,IAAI;IACV,OAAO,IAAI;IACX,UAAU,IAAI,aAAa;IAC3B,UAAU,EAAE;IACZ,QAAQ,IAAI;IACZ,kBAAkB,IAAI;IACtB;GACD,MAAM,aAAa,OAAO,IAAI,IAAI,SAAS;AAC3C,OAAI,CAAC,WAAY;GACjB,MAAM,WAAW,WAAW,IAAI;AAChC,OAAI,SAAU,UAAS,KAAK,KAAK;OAC5B,YAAW,IAAI,QAAQ,CAAC,KAAK;;;AAIpC,MAAK,MAAM,CAAC,SAAS,eAAe,OACnC,sBAAqB,YAAY,SAAS,YAAY,yBAAyB,OAAO;AAGvF,QAAO;;;;;;;;AASR,eAAe,2BACd,YACA,SACoB;AACpB,KAAI;AAEH,UADa,MAAM,gBAAgB,QAAQ,EAC/B,QAAQ,MAAM,EAAE,YAAY,SAAS,WAAW,CAAC,CAAC,KAAK,MAAM,EAAE,KAAK;UACxE,OAAO;AACf,MAAI,oBAAoB,MAAM,CAAE,QAAO,EAAE;AACzC,QAAM;;;;;;;;;;;;;;;AAgBR,SAAS,qBACR,YACA,SACA,YACA,yBACA,QACO;CACP,MAAM,YAAY,UAAU;AAC5B,MAAK,MAAM,QAAQ,wBAClB,sBACC,SAAS,WAAW,GAAG,QAAQ,GAAG,KAAK,GAAG,aAC1C,WAAW,SAAS,EAAE,CACtB;AAEF,MAAK,MAAM,CAAC,MAAM,UAAU,OAAO,QAAQ,WAAW,CACrD,sBAAqB,SAAS,WAAW,GAAG,QAAQ,GAAG,KAAK,GAAG,aAAa,MAAM;CAEnF,MAAM,WAAW,OAAO,OAAO,WAAW,CAAC,MAAM;AACjD,sBAAqB,SAAS,WAAW,GAAG,QAAQ,KAAK,aAAa,SAAS;;;;;;AAOhF,eAAsB,iBACrB,YACA,cACA,UACA,UAAgC,EAAE,EAC8B;CAChE,MAAM,EAAE,wBAAwB,MAAM,OAAO;CAE7C,MAAM,eAAwC,EAC7C,OAAO,GAAG,eAAe,UAAU,EACnC;AACD,KAAI,QAAQ,WAAW,OAAW,cAAa,SAAS,QAAQ;CAChE,MAAM,EAAE,YAAY,MAAM,oBAAoB,YAAY,aAAa;AACvE,QAAO;;AAGR,SAAS,iBAAiB,KASV;AACf,QAAO;EACN,IAAI,IAAI;EACR,MAAM,IAAI;EACV,OAAO,IAAI;EACX,eAAe,IAAI,kBAAkB;EACrC,cAAc,IAAI,iBAAiB;EACnC,aAAa,IAAI,cAAc,KAAK,MAAM,IAAI,YAAY,GAAG,EAAE;EAC/D,QAAQ,IAAI;EACZ,kBAAkB,IAAI;EACtB;;;;;AAMF,SAAS,UAAU,WAA8B,QAA6C;CAC7F,MAAM,sBAAM,IAAI,KAA2B;CAC3C,MAAM,QAAwB,EAAE;AAEhC,MAAK,MAAM,QAAQ,UAClB,KAAI,IAAI,KAAK,IAAI;EAChB,IAAI,KAAK;EACT,MAAM,KAAK;EACX,MAAM,KAAK;EACX,OAAO,KAAK;EACZ,UAAU,KAAK,aAAa;EAC5B,aAAa,KAAK,OAAO,KAAK,MAAM,KAAK,KAAK,CAAC,cAAc;EAC7D,UAAU,EAAE;EACZ,OAAO,OAAO,IAAI,KAAK,qBAAqB,KAAK,GAAG,IAAI;EACxD,QAAQ,KAAK;EACb,kBAAkB,KAAK;EACvB,CAAC;AAGH,MAAK,MAAM,QAAQ,IAAI,QAAQ,CAC9B,KAAI,KAAK,YAAY,IAAI,IAAI,KAAK,SAAS,CAC1C,KAAI,IAAI,KAAK,SAAS,CAAE,SAAS,KAAK,KAAK;KAE3C,OAAM,KAAK,KAAK;AAIlB,QAAO"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { a as __exportAll } from "./runner--4wMWwKM.mjs";
|
|
2
2
|
import { t as TaxonomyRepository } from "./taxonomy-CdllE4oq.mjs";
|
|
3
|
-
import { l as invalidateTermCache } from "./taxonomies-
|
|
3
|
+
import { l as invalidateTermCache } from "./taxonomies-BBxYA38v.mjs";
|
|
4
4
|
import { ulid } from "ulidx";
|
|
5
5
|
|
|
6
6
|
//#region src/api/handlers/taxonomies.ts
|
|
@@ -505,4 +505,4 @@ async function handleTermDelete(db, taxonomyName, termSlug, options = {}) {
|
|
|
505
505
|
|
|
506
506
|
//#endregion
|
|
507
507
|
export { handleTermGet as a, handleTermUpdate as c, handleTermDelete as i, taxonomies_exports as l, handleTaxonomyList as n, handleTermList as o, handleTermCreate as r, handleTermTranslations as s, handleTaxonomyCreate as t };
|
|
508
|
-
//# sourceMappingURL=taxonomies-
|
|
508
|
+
//# sourceMappingURL=taxonomies-DuESHWKI.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"taxonomies-BILwiyGk.mjs","names":[],"sources":["../src/api/handlers/taxonomies.ts"],"sourcesContent":["/**\n * Taxonomy and term CRUD handlers.\n *\n * i18n: terms and defs are per-locale. `(name, slug, locale)` is unique for\n * terms; `(name, locale)` for defs. Translations of the same term/def share a\n * `translation_group`. The content_taxonomies pivot stores translation_groups\n * so assignments span every locale of a post.\n */\n\nimport type { Kysely, Selectable } from \"kysely\";\nimport { ulid } from \"ulidx\";\n\nimport { TaxonomyRepository } from \"../../database/repositories/taxonomy.js\";\nimport type { Database, TaxonomyDefTable } from \"../../database/types.js\";\nimport { invalidateTermCache } from \"../../taxonomies/index.js\";\nimport type { ApiResult } from \"../types.js\";\n\nconst NAME_PATTERN = /^[a-z][a-z0-9_]*$/;\n\n// ---------------------------------------------------------------------------\n// Response types\n// ---------------------------------------------------------------------------\n\nexport interface TaxonomyDef {\n\tid: string;\n\tname: string;\n\tlabel: string;\n\tlabelSingular?: string;\n\thierarchical: boolean;\n\tcollections: string[];\n\tlocale: string;\n\ttranslationGroup: string | null;\n}\n\nexport interface TaxonomyListResponse {\n\ttaxonomies: TaxonomyDef[];\n}\n\nexport interface TermData {\n\tid: string;\n\tname: string;\n\tslug: string;\n\tlabel: string;\n\tparentId: string | null;\n\tdescription?: string;\n\tlocale: string;\n\ttranslationGroup: string | null;\n}\n\nexport interface TermWithCount extends TermData {\n\tcount: number;\n\tchildren: TermWithCount[];\n}\n\nexport interface TermListResponse {\n\tterms: TermWithCount[];\n}\n\nexport interface TermResponse {\n\tterm: TermData;\n}\n\nexport interface TermGetResponse {\n\tterm: TermData & {\n\t\tcount: number;\n\t\tchildren: Array<{ id: string; slug: string; label: string }>;\n\t};\n}\n\nexport interface TermTranslationsResponse {\n\ttranslationGroup: string | null;\n\ttranslations: Array<{\n\t\tid: string;\n\t\tslug: string;\n\t\tlabel: string;\n\t\tlocale: string;\n\t}>;\n}\n\nexport interface TaxonomyDefTranslationsResponse {\n\ttranslationGroup: string | null;\n\ttranslations: Array<{\n\t\tid: string;\n\t\tname: string;\n\t\tlabel: string;\n\t\tlocale: string;\n\t}>;\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Build tree structure from flat terms\n */\nfunction buildTree(flatTerms: TermWithCount[]): TermWithCount[] {\n\tconst map = new Map<string, TermWithCount>();\n\tconst roots: TermWithCount[] = [];\n\tfor (const term of flatTerms) map.set(term.id, term);\n\tfor (const term of flatTerms) {\n\t\tif (term.parentId && map.has(term.parentId)) {\n\t\t\tmap.get(term.parentId)!.children.push(term);\n\t\t} else {\n\t\t\troots.push(term);\n\t\t}\n\t}\n\treturn roots;\n}\n\n/**\n * Look up a taxonomy definition by name (optionally scoped to a locale).\n * Returns the lowest-locale match when no locale is provided.\n */\nasync function requireTaxonomyDef(\n\tdb: Kysely<Database>,\n\tname: string,\n\tlocale?: string,\n): Promise<\n\t| { success: true; def: Selectable<TaxonomyDefTable> }\n\t| { success: false; error: { code: string; message: string } }\n> {\n\tlet query = db.selectFrom(\"_emdash_taxonomy_defs\").selectAll().where(\"name\", \"=\", name);\n\tif (locale !== undefined) query = query.where(\"locale\", \"=\", locale);\n\tconst def = await query.orderBy(\"locale\", \"asc\").executeTakeFirst();\n\tif (!def) {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"NOT_FOUND\", message: `Taxonomy '${name}' not found` },\n\t\t};\n\t}\n\treturn { success: true, def };\n}\n\nfunction rowToDef(row: Selectable<TaxonomyDefTable>): TaxonomyDef {\n\treturn {\n\t\tid: row.id,\n\t\tname: row.name,\n\t\tlabel: row.label,\n\t\tlabelSingular: row.label_singular ?? undefined,\n\t\thierarchical: row.hierarchical === 1,\n\t\tcollections: row.collections ? JSON.parse(row.collections) : [],\n\t\tlocale: row.locale,\n\t\ttranslationGroup: row.translation_group,\n\t};\n}\n\n// ---------------------------------------------------------------------------\n// Taxonomy definition handlers\n// ---------------------------------------------------------------------------\n\n/**\n * List all taxonomy definitions\n */\nexport async function handleTaxonomyList(\n\tdb: Kysely<Database>,\n\toptions: { locale?: string } = {},\n): Promise<ApiResult<TaxonomyListResponse>> {\n\ttry {\n\t\tlet query = db.selectFrom(\"_emdash_taxonomy_defs\").selectAll();\n\t\tif (options.locale !== undefined) query = query.where(\"locale\", \"=\", options.locale);\n\t\tconst [rows, collectionRows] = await Promise.all([\n\t\t\tquery.execute(),\n\t\t\tdb.selectFrom(\"_emdash_collections\").select(\"slug\").execute(),\n\t\t]);\n\n\t\t// Filter orphan collection references on read so the response stays\n\t\t// consistent with `schema_list_collections`. Storage is untouched —\n\t\t// re-creating the collection re-links automatically.\n\t\tconst realCollections = new Set(collectionRows.map((r) => r.slug));\n\n\t\tconst taxonomies: TaxonomyDef[] = rows.map((row) => {\n\t\t\tconst def = rowToDef(row);\n\t\t\treturn { ...def, collections: def.collections.filter((slug) => realCollections.has(slug)) };\n\t\t});\n\n\t\treturn { success: true, data: { taxonomies } };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"TAXONOMY_LIST_ERROR\", message: \"Failed to list taxonomies\" },\n\t\t};\n\t}\n}\n\n/**\n * Create a new taxonomy definition\n */\nexport async function handleTaxonomyCreate(\n\tdb: Kysely<Database>,\n\tinput: {\n\t\tname: string;\n\t\tlabel: string;\n\t\tlabelSingular?: string;\n\t\thierarchical?: boolean;\n\t\tcollections?: string[];\n\t\tlocale?: string;\n\t\ttranslationOf?: string;\n\t},\n): Promise<ApiResult<{ taxonomy: TaxonomyDef }>> {\n\ttry {\n\t\tif (!NAME_PATTERN.test(input.name)) {\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:\n\t\t\t\t\t\t\"Taxonomy name must start with a letter and contain only lowercase letters, numbers, and underscores\",\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\tconst collections = [...new Set(input.collections ?? [])];\n\t\tif (collections.length > 0) {\n\t\t\tconst existingCollections = await db\n\t\t\t\t.selectFrom(\"_emdash_collections\")\n\t\t\t\t.select(\"slug\")\n\t\t\t\t.where(\"slug\", \"in\", collections)\n\t\t\t\t.execute();\n\t\t\tconst existingSlugs = new Set(existingCollections.map((c) => c.slug));\n\t\t\tconst invalid = collections.filter((c) => !existingSlugs.has(c));\n\t\t\tif (invalid.length > 0) {\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: `Unknown collection(s): ${invalid.join(\", \")}`,\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\tlet translationGroup: string | null = null;\n\t\tif (input.translationOf) {\n\t\t\tconst source = await db\n\t\t\t\t.selectFrom(\"_emdash_taxonomy_defs\")\n\t\t\t\t.selectAll()\n\t\t\t\t.where(\"id\", \"=\", input.translationOf)\n\t\t\t\t.executeTakeFirst();\n\t\t\tif (!source) {\n\t\t\t\treturn {\n\t\t\t\t\tsuccess: false,\n\t\t\t\t\terror: { code: \"NOT_FOUND\", message: \"Source taxonomy for translation not found\" },\n\t\t\t\t};\n\t\t\t}\n\t\t\ttranslationGroup = source.translation_group ?? source.id;\n\t\t}\n\n\t\t// Duplicate guard scoped to locale (so the same name can exist in ES\n\t\t// and EN).\n\t\tif (input.locale !== undefined) {\n\t\t\tconst existing = await db\n\t\t\t\t.selectFrom(\"_emdash_taxonomy_defs\")\n\t\t\t\t.select(\"id\")\n\t\t\t\t.where(\"name\", \"=\", input.name)\n\t\t\t\t.where(\"locale\", \"=\", input.locale)\n\t\t\t\t.executeTakeFirst();\n\t\t\tif (existing) {\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: `Taxonomy '${input.name}' already exists in locale '${input.locale}'`,\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\tconst id = ulid();\n\t\tawait db\n\t\t\t.insertInto(\"_emdash_taxonomy_defs\")\n\t\t\t.values({\n\t\t\t\tid,\n\t\t\t\tname: input.name,\n\t\t\t\tlabel: input.label,\n\t\t\t\tlabel_singular: input.labelSingular ?? null,\n\t\t\t\thierarchical: input.hierarchical ? 1 : 0,\n\t\t\t\tcollections: JSON.stringify(collections),\n\t\t\t\t...(input.locale !== undefined ? { locale: input.locale } : {}),\n\t\t\t\ttranslation_group: translationGroup ?? id,\n\t\t\t})\n\t\t\t.execute();\n\n\t\tconst row = await db\n\t\t\t.selectFrom(\"_emdash_taxonomy_defs\")\n\t\t\t.selectAll()\n\t\t\t.where(\"id\", \"=\", id)\n\t\t\t.executeTakeFirstOrThrow();\n\t\treturn { success: true, data: { taxonomy: rowToDef(row) } };\n\t} catch (error) {\n\t\tif (error instanceof Error && error.message.includes(\"UNIQUE constraint failed\")) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"CONFLICT\", message: `Taxonomy '${input.name}' already exists` },\n\t\t\t};\n\t\t}\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"TAXONOMY_CREATE_ERROR\", message: \"Failed to create taxonomy\" },\n\t\t};\n\t}\n}\n\n/**\n * List every locale translation of a taxonomy def (by id or translation_group).\n */\nexport async function handleTaxonomyDefTranslations(\n\tdb: Kysely<Database>,\n\tidOrGroup: string,\n): Promise<ApiResult<TaxonomyDefTranslationsResponse>> {\n\ttry {\n\t\tconst anchor = await db\n\t\t\t.selectFrom(\"_emdash_taxonomy_defs\")\n\t\t\t.selectAll()\n\t\t\t.where((eb) => eb.or([eb(\"id\", \"=\", idOrGroup), eb(\"translation_group\", \"=\", idOrGroup)]))\n\t\t\t.executeTakeFirst();\n\t\tif (!anchor) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"NOT_FOUND\", message: \"Taxonomy not found\" },\n\t\t\t};\n\t\t}\n\t\tconst group = anchor.translation_group ?? anchor.id;\n\t\tconst rows = await db\n\t\t\t.selectFrom(\"_emdash_taxonomy_defs\")\n\t\t\t.selectAll()\n\t\t\t.where(\"translation_group\", \"=\", group)\n\t\t\t.orderBy(\"locale\", \"asc\")\n\t\t\t.execute();\n\t\treturn {\n\t\t\tsuccess: true,\n\t\t\tdata: {\n\t\t\t\ttranslationGroup: group,\n\t\t\t\ttranslations: rows.map((r) => ({\n\t\t\t\t\tid: r.id,\n\t\t\t\t\tname: r.name,\n\t\t\t\t\tlabel: r.label,\n\t\t\t\t\tlocale: r.locale,\n\t\t\t\t})),\n\t\t\t},\n\t\t};\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: {\n\t\t\t\tcode: \"TAXONOMY_TRANSLATIONS_ERROR\",\n\t\t\t\tmessage: \"Failed to list taxonomy translations\",\n\t\t\t},\n\t\t};\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// Term handlers\n// ---------------------------------------------------------------------------\n\n/**\n * List all terms for a taxonomy (returns tree for hierarchical taxonomies)\n */\nexport async function handleTermList(\n\tdb: Kysely<Database>,\n\ttaxonomyName: string,\n\toptions: { locale?: string } = {},\n): Promise<ApiResult<TermListResponse>> {\n\ttry {\n\t\t// Definitions are per-locale but terms aren't bound to the def's locale —\n\t\t// just ensure the taxonomy exists somewhere.\n\t\tconst lookup = await requireTaxonomyDef(db, taxonomyName);\n\t\tif (!lookup.success) return lookup;\n\n\t\tconst repo = new TaxonomyRepository(db);\n\t\tconst terms = await repo.findByName(taxonomyName, { locale: options.locale });\n\n\t\t// Batch count entries per term in a single query (replaces N+1 pattern).\n\t\t// content_taxonomies.taxonomy_id stores the translation_group, so we\n\t\t// look up by group and map back to each term's id.\n\t\tconst groups = terms.map((t) => t.translationGroup ?? t.id);\n\t\tconst countsByGroup = await repo.countEntriesForTerms(groups);\n\n\t\tconst termData: TermWithCount[] = terms.map((term) => ({\n\t\t\tid: term.id,\n\t\t\tname: term.name,\n\t\t\tslug: term.slug,\n\t\t\tlabel: term.label,\n\t\t\tparentId: term.parentId,\n\t\t\tdescription: typeof term.data?.description === \"string\" ? term.data.description : undefined,\n\t\t\tchildren: [],\n\t\t\tcount: countsByGroup.get(term.translationGroup ?? term.id) ?? 0,\n\t\t\tlocale: term.locale,\n\t\t\ttranslationGroup: term.translationGroup,\n\t\t}));\n\n\t\tconst isHierarchical = lookup.def.hierarchical === 1;\n\t\tconst result = isHierarchical ? buildTree(termData) : termData;\n\t\treturn { success: true, data: { terms: result } };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"TERM_LIST_ERROR\", message: \"Failed to list terms\" },\n\t\t};\n\t}\n}\n\n/**\n * Validate a parent term reference for create/update.\n *\n * Returns `null` on success or a structured error message that callers\n * wrap in their own ApiResult.\n *\n * - `parentId === undefined` -> no-op (no parent change requested).\n * - `parentId === null` -> caller intends to detach; no-op here.\n * - parent must exist (FK exists -> term row not soft-deleted).\n * - parent must live in the same taxonomy.\n * - if `termId` is provided (update path), reject `parentId === termId`\n * (self-parent) and walk up the parent chain to detect cycles.\n */\nasync function validateParentTerm(\n\trepo: TaxonomyRepository,\n\ttaxonomyName: string,\n\ttermId: string | undefined,\n\tparentId: string | null | undefined,\n): Promise<{ code: \"VALIDATION_ERROR\"; message: string } | null> {\n\tif (parentId === undefined || parentId === null) return null;\n\n\tif (termId !== undefined && parentId === termId) {\n\t\treturn {\n\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\tmessage: \"A term cannot be its own parent\",\n\t\t};\n\t}\n\n\tconst parent = await repo.findById(parentId);\n\tif (!parent) {\n\t\treturn {\n\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\tmessage: `Parent term '${parentId}' not found`,\n\t\t};\n\t}\n\tif (parent.name !== taxonomyName) {\n\t\treturn {\n\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\tmessage: `Parent term '${parentId}' belongs to taxonomy '${parent.name}', not '${taxonomyName}'`,\n\t\t};\n\t}\n\n\t// Walk up the parent chain. Two checks fold into one walk:\n\t// - Cycle detection (only on update — a non-existent term-being-\n\t// created can't be its own ancestor): if the walk revisits termId\n\t// the proposed parent makes the term a descendant of itself.\n\t// - Depth bound: refuse to extend a chain past MAX_DEPTH ancestors.\n\t// Runs on both create and update so a malicious or buggy caller\n\t// can't grow the tree without limit.\n\t//\n\t// The depth-exceeded error fires only when we hit the limit AND there\n\t// was still chain to walk — a legitimate chain of exactly MAX_DEPTH\n\t// ancestors exits with `cursor === null` and is accepted.\n\tconst MAX_DEPTH = 100;\n\tlet cursor: string | null = parent.parentId;\n\tlet steps = 0;\n\twhile (cursor !== null && steps < MAX_DEPTH) {\n\t\tif (termId !== undefined && cursor === termId) {\n\t\t\treturn {\n\t\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\t\tmessage: \"Cycle detected: cannot make a descendant the parent\",\n\t\t\t};\n\t\t}\n\t\tconst next = await repo.findById(cursor);\n\t\tif (!next) break;\n\t\tcursor = next.parentId;\n\t\tsteps++;\n\t}\n\tif (cursor !== null && steps >= MAX_DEPTH) {\n\t\treturn {\n\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\tmessage: \"Parent chain exceeds maximum depth\",\n\t\t};\n\t}\n\n\treturn null;\n}\n\n/**\n * Create a new term in a taxonomy\n */\nexport async function handleTermCreate(\n\tdb: Kysely<Database>,\n\ttaxonomyName: string,\n\tinput: {\n\t\tslug: string;\n\t\tlabel: string;\n\t\tparentId?: string | null;\n\t\tdescription?: string;\n\t\tlocale?: string;\n\t\ttranslationOf?: string;\n\t},\n): Promise<ApiResult<TermResponse>> {\n\ttry {\n\t\t// Taxonomy definitions are per-locale, but terms can exist in any locale\n\t\t// regardless of whether the def has been translated there. Look up the\n\t\t// def across all locales — we only care that it *exists*.\n\t\tconst lookup = await requireTaxonomyDef(db, taxonomyName);\n\t\tif (!lookup.success) return lookup;\n\n\t\tconst repo = new TaxonomyRepository(db);\n\n\t\t// Coerce empty-string parentId to undefined (treat as \"no parent\").\n\t\tlet parentId =\n\t\t\tinput.parentId === \"\" || input.parentId === undefined ? undefined : input.parentId;\n\n\t\t// Conflict check is scoped to locale (per-locale slugs are unique).\n\t\tconst existing = await repo.findBySlug(taxonomyName, input.slug, input.locale);\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: input.locale\n\t\t\t\t\t\t? `Term '${input.slug}' already exists in '${taxonomyName}' (${input.locale})`\n\t\t\t\t\t\t: `Term with slug '${input.slug}' already exists in taxonomy '${taxonomyName}'`,\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\t// If creating a translation whose parent is the translated sibling of\n\t\t// the source's parent, try to resolve the parent in the same locale.\n\t\tif (input.translationOf && parentId) {\n\t\t\tconst source = await repo.findById(input.translationOf);\n\t\t\tif (source?.parentId === parentId && input.locale) {\n\t\t\t\tconst sourceParent = await repo.findById(parentId);\n\t\t\t\tif (sourceParent?.translationGroup) {\n\t\t\t\t\tconst translatedParent = await db\n\t\t\t\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t\t\t\t.select(\"id\")\n\t\t\t\t\t\t.where(\"translation_group\", \"=\", sourceParent.translationGroup)\n\t\t\t\t\t\t.where(\"locale\", \"=\", input.locale)\n\t\t\t\t\t\t.executeTakeFirst();\n\t\t\t\t\tif (translatedParent) parentId = translatedParent.id;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Validate parentId: must exist AND belong to the same taxonomy.\n\t\t// (Cycle check is N/A on create — the term doesn't exist yet.)\n\t\tconst parentError = await validateParentTerm(repo, taxonomyName, undefined, parentId);\n\t\tif (parentError) {\n\t\t\treturn { success: false, error: parentError };\n\t\t}\n\n\t\tconst term = await repo.create({\n\t\t\tname: taxonomyName,\n\t\t\tslug: input.slug,\n\t\t\tlabel: input.label,\n\t\t\tparentId: parentId ?? undefined,\n\t\t\tdata: input.description ? { description: input.description } : undefined,\n\t\t\tlocale: input.locale,\n\t\t\ttranslationOf: input.translationOf,\n\t\t});\n\n\t\tinvalidateTermCache();\n\n\t\treturn {\n\t\t\tsuccess: true,\n\t\t\tdata: {\n\t\t\t\tterm: {\n\t\t\t\t\tid: term.id,\n\t\t\t\t\tname: term.name,\n\t\t\t\t\tslug: term.slug,\n\t\t\t\t\tlabel: term.label,\n\t\t\t\t\tparentId: term.parentId,\n\t\t\t\t\tdescription:\n\t\t\t\t\t\ttypeof term.data?.description === \"string\" ? term.data.description : undefined,\n\t\t\t\t\tlocale: term.locale,\n\t\t\t\t\ttranslationGroup: term.translationGroup,\n\t\t\t\t},\n\t\t\t},\n\t\t};\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"TERM_CREATE_ERROR\", message: \"Failed to create term\" },\n\t\t};\n\t}\n}\n\n/**\n * Get a single term by slug\n */\nexport async function handleTermGet(\n\tdb: Kysely<Database>,\n\ttaxonomyName: string,\n\ttermSlug: string,\n\toptions: { locale?: string } = {},\n): Promise<ApiResult<TermGetResponse>> {\n\ttry {\n\t\tconst repo = new TaxonomyRepository(db);\n\t\tconst term = await repo.findBySlug(taxonomyName, termSlug, options.locale);\n\n\t\tif (!term) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"NOT_FOUND\",\n\t\t\t\t\tmessage: `Term '${termSlug}' not found in taxonomy '${taxonomyName}'`,\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\tconst count = await repo.countEntriesWithTerm(term.id);\n\t\tconst children = await repo.findChildren(term.id);\n\n\t\treturn {\n\t\t\tsuccess: true,\n\t\t\tdata: {\n\t\t\t\tterm: {\n\t\t\t\t\tid: term.id,\n\t\t\t\t\tname: term.name,\n\t\t\t\t\tslug: term.slug,\n\t\t\t\t\tlabel: term.label,\n\t\t\t\t\tparentId: term.parentId,\n\t\t\t\t\tdescription:\n\t\t\t\t\t\ttypeof term.data?.description === \"string\" ? term.data.description : undefined,\n\t\t\t\t\tcount,\n\t\t\t\t\tchildren: children.map((c) => ({ id: c.id, slug: c.slug, label: c.label })),\n\t\t\t\t\tlocale: term.locale,\n\t\t\t\t\ttranslationGroup: term.translationGroup,\n\t\t\t\t},\n\t\t\t},\n\t\t};\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"TERM_GET_ERROR\", message: \"Failed to get term\" },\n\t\t};\n\t}\n}\n\n/** List every translation of a term (by id or translation_group). */\nexport async function handleTermTranslations(\n\tdb: Kysely<Database>,\n\tidOrGroup: string,\n): Promise<ApiResult<TermTranslationsResponse>> {\n\ttry {\n\t\tconst anchor = await db\n\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t.selectAll()\n\t\t\t.where((eb) => eb.or([eb(\"id\", \"=\", idOrGroup), eb(\"translation_group\", \"=\", idOrGroup)]))\n\t\t\t.executeTakeFirst();\n\t\tif (!anchor) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"NOT_FOUND\", message: \"Term not found\" },\n\t\t\t};\n\t\t}\n\t\tconst group = anchor.translation_group ?? anchor.id;\n\t\tconst rows = await db\n\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t.selectAll()\n\t\t\t.where(\"translation_group\", \"=\", group)\n\t\t\t.orderBy(\"locale\", \"asc\")\n\t\t\t.execute();\n\t\treturn {\n\t\t\tsuccess: true,\n\t\t\tdata: {\n\t\t\t\ttranslationGroup: group,\n\t\t\t\ttranslations: rows.map((r) => ({\n\t\t\t\t\tid: r.id,\n\t\t\t\t\tslug: r.slug,\n\t\t\t\t\tlabel: r.label,\n\t\t\t\t\tlocale: r.locale,\n\t\t\t\t})),\n\t\t\t},\n\t\t};\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"TERM_TRANSLATIONS_ERROR\", message: \"Failed to list term translations\" },\n\t\t};\n\t}\n}\n\n/**\n * Update a term\n */\nexport async function handleTermUpdate(\n\tdb: Kysely<Database>,\n\ttaxonomyName: string,\n\ttermSlug: string,\n\tinput: { slug?: string; label?: string; parentId?: string | null; description?: string },\n\toptions: { locale?: string } = {},\n): Promise<ApiResult<TermResponse>> {\n\ttry {\n\t\tconst repo = new TaxonomyRepository(db);\n\t\tconst term = await repo.findBySlug(taxonomyName, termSlug, options.locale);\n\n\t\tif (!term) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"NOT_FOUND\",\n\t\t\t\t\tmessage: `Term '${termSlug}' not found in taxonomy '${taxonomyName}'`,\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\t// Coerce empty-string slug/parentId to undefined (treat as \"no change\").\n\t\t// `null` parentId is a valid request meaning \"detach from parent\".\n\t\tconst newSlug = input.slug === \"\" || input.slug === undefined ? undefined : input.slug;\n\t\tconst newParentId =\n\t\t\tinput.parentId === \"\" || input.parentId === undefined ? undefined : input.parentId;\n\n\t\t// Check if new slug conflicts (per-locale uniqueness).\n\t\tif (newSlug !== undefined && newSlug !== termSlug) {\n\t\t\tconst existing = await repo.findBySlug(taxonomyName, newSlug, options.locale);\n\t\t\tif (existing && existing.id !== term.id) {\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: `Term with slug '${newSlug}' already exists in taxonomy '${taxonomyName}'`,\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\t// Validate parentId: existence, same-taxonomy, no self-parent, no cycle.\n\t\tconst parentError = await validateParentTerm(repo, taxonomyName, term.id, newParentId);\n\t\tif (parentError) {\n\t\t\treturn { success: false, error: parentError };\n\t\t}\n\n\t\tconst updated = await repo.update(term.id, {\n\t\t\tslug: newSlug,\n\t\t\tlabel: input.label,\n\t\t\tparentId: newParentId,\n\t\t\tdata: input.description !== undefined ? { description: input.description } : undefined,\n\t\t});\n\n\t\tinvalidateTermCache();\n\n\t\tif (!updated) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"TERM_UPDATE_ERROR\", message: \"Failed to update term\" },\n\t\t\t};\n\t\t}\n\n\t\treturn {\n\t\t\tsuccess: true,\n\t\t\tdata: {\n\t\t\t\tterm: {\n\t\t\t\t\tid: updated.id,\n\t\t\t\t\tname: updated.name,\n\t\t\t\t\tslug: updated.slug,\n\t\t\t\t\tlabel: updated.label,\n\t\t\t\t\tparentId: updated.parentId,\n\t\t\t\t\tdescription:\n\t\t\t\t\t\ttypeof updated.data?.description === \"string\" ? updated.data.description : undefined,\n\t\t\t\t\tlocale: updated.locale,\n\t\t\t\t\ttranslationGroup: updated.translationGroup,\n\t\t\t\t},\n\t\t\t},\n\t\t};\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"TERM_UPDATE_ERROR\", message: \"Failed to update term\" },\n\t\t};\n\t}\n}\n\n/**\n * Delete a term\n */\nexport async function handleTermDelete(\n\tdb: Kysely<Database>,\n\ttaxonomyName: string,\n\ttermSlug: string,\n\toptions: { locale?: string } = {},\n): Promise<ApiResult<{ deleted: true }>> {\n\ttry {\n\t\tconst repo = new TaxonomyRepository(db);\n\t\tconst term = await repo.findBySlug(taxonomyName, termSlug, options.locale);\n\n\t\tif (!term) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"NOT_FOUND\",\n\t\t\t\t\tmessage: `Term '${termSlug}' not found in taxonomy '${taxonomyName}'`,\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\tconst children = await repo.findChildren(term.id);\n\t\tif (children.length > 0) {\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: \"Cannot delete term with children. Delete children first.\",\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\tconst deleted = await repo.delete(term.id);\n\t\tif (!deleted) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"TERM_DELETE_ERROR\", message: \"Failed to delete term\" },\n\t\t\t};\n\t\t}\n\n\t\tinvalidateTermCache();\n\t\treturn { success: true, data: { deleted: true } };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"TERM_DELETE_ERROR\", message: \"Failed to delete term\" },\n\t\t};\n\t}\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"taxonomies-DuESHWKI.mjs","names":[],"sources":["../src/api/handlers/taxonomies.ts"],"sourcesContent":["/**\n * Taxonomy and term CRUD handlers.\n *\n * i18n: terms and defs are per-locale. `(name, slug, locale)` is unique for\n * terms; `(name, locale)` for defs. Translations of the same term/def share a\n * `translation_group`. The content_taxonomies pivot stores translation_groups\n * so assignments span every locale of a post.\n */\n\nimport type { Kysely, Selectable } from \"kysely\";\nimport { ulid } from \"ulidx\";\n\nimport { TaxonomyRepository } from \"../../database/repositories/taxonomy.js\";\nimport type { Database, TaxonomyDefTable } from \"../../database/types.js\";\nimport { invalidateTermCache } from \"../../taxonomies/index.js\";\nimport type { ApiResult } from \"../types.js\";\n\nconst NAME_PATTERN = /^[a-z][a-z0-9_]*$/;\n\n// ---------------------------------------------------------------------------\n// Response types\n// ---------------------------------------------------------------------------\n\nexport interface TaxonomyDef {\n\tid: string;\n\tname: string;\n\tlabel: string;\n\tlabelSingular?: string;\n\thierarchical: boolean;\n\tcollections: string[];\n\tlocale: string;\n\ttranslationGroup: string | null;\n}\n\nexport interface TaxonomyListResponse {\n\ttaxonomies: TaxonomyDef[];\n}\n\nexport interface TermData {\n\tid: string;\n\tname: string;\n\tslug: string;\n\tlabel: string;\n\tparentId: string | null;\n\tdescription?: string;\n\tlocale: string;\n\ttranslationGroup: string | null;\n}\n\nexport interface TermWithCount extends TermData {\n\tcount: number;\n\tchildren: TermWithCount[];\n}\n\nexport interface TermListResponse {\n\tterms: TermWithCount[];\n}\n\nexport interface TermResponse {\n\tterm: TermData;\n}\n\nexport interface TermGetResponse {\n\tterm: TermData & {\n\t\tcount: number;\n\t\tchildren: Array<{ id: string; slug: string; label: string }>;\n\t};\n}\n\nexport interface TermTranslationsResponse {\n\ttranslationGroup: string | null;\n\ttranslations: Array<{\n\t\tid: string;\n\t\tslug: string;\n\t\tlabel: string;\n\t\tlocale: string;\n\t}>;\n}\n\nexport interface TaxonomyDefTranslationsResponse {\n\ttranslationGroup: string | null;\n\ttranslations: Array<{\n\t\tid: string;\n\t\tname: string;\n\t\tlabel: string;\n\t\tlocale: string;\n\t}>;\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Build tree structure from flat terms\n */\nfunction buildTree(flatTerms: TermWithCount[]): TermWithCount[] {\n\tconst map = new Map<string, TermWithCount>();\n\tconst roots: TermWithCount[] = [];\n\tfor (const term of flatTerms) map.set(term.id, term);\n\tfor (const term of flatTerms) {\n\t\tif (term.parentId && map.has(term.parentId)) {\n\t\t\tmap.get(term.parentId)!.children.push(term);\n\t\t} else {\n\t\t\troots.push(term);\n\t\t}\n\t}\n\treturn roots;\n}\n\n/**\n * Look up a taxonomy definition by name (optionally scoped to a locale).\n * Returns the lowest-locale match when no locale is provided.\n */\nasync function requireTaxonomyDef(\n\tdb: Kysely<Database>,\n\tname: string,\n\tlocale?: string,\n): Promise<\n\t| { success: true; def: Selectable<TaxonomyDefTable> }\n\t| { success: false; error: { code: string; message: string } }\n> {\n\tlet query = db.selectFrom(\"_emdash_taxonomy_defs\").selectAll().where(\"name\", \"=\", name);\n\tif (locale !== undefined) query = query.where(\"locale\", \"=\", locale);\n\tconst def = await query.orderBy(\"locale\", \"asc\").executeTakeFirst();\n\tif (!def) {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"NOT_FOUND\", message: `Taxonomy '${name}' not found` },\n\t\t};\n\t}\n\treturn { success: true, def };\n}\n\nfunction rowToDef(row: Selectable<TaxonomyDefTable>): TaxonomyDef {\n\treturn {\n\t\tid: row.id,\n\t\tname: row.name,\n\t\tlabel: row.label,\n\t\tlabelSingular: row.label_singular ?? undefined,\n\t\thierarchical: row.hierarchical === 1,\n\t\tcollections: row.collections ? JSON.parse(row.collections) : [],\n\t\tlocale: row.locale,\n\t\ttranslationGroup: row.translation_group,\n\t};\n}\n\n// ---------------------------------------------------------------------------\n// Taxonomy definition handlers\n// ---------------------------------------------------------------------------\n\n/**\n * List all taxonomy definitions\n */\nexport async function handleTaxonomyList(\n\tdb: Kysely<Database>,\n\toptions: { locale?: string } = {},\n): Promise<ApiResult<TaxonomyListResponse>> {\n\ttry {\n\t\tlet query = db.selectFrom(\"_emdash_taxonomy_defs\").selectAll();\n\t\tif (options.locale !== undefined) query = query.where(\"locale\", \"=\", options.locale);\n\t\tconst [rows, collectionRows] = await Promise.all([\n\t\t\tquery.execute(),\n\t\t\tdb.selectFrom(\"_emdash_collections\").select(\"slug\").execute(),\n\t\t]);\n\n\t\t// Filter orphan collection references on read so the response stays\n\t\t// consistent with `schema_list_collections`. Storage is untouched —\n\t\t// re-creating the collection re-links automatically.\n\t\tconst realCollections = new Set(collectionRows.map((r) => r.slug));\n\n\t\tconst taxonomies: TaxonomyDef[] = rows.map((row) => {\n\t\t\tconst def = rowToDef(row);\n\t\t\treturn { ...def, collections: def.collections.filter((slug) => realCollections.has(slug)) };\n\t\t});\n\n\t\treturn { success: true, data: { taxonomies } };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"TAXONOMY_LIST_ERROR\", message: \"Failed to list taxonomies\" },\n\t\t};\n\t}\n}\n\n/**\n * Create a new taxonomy definition\n */\nexport async function handleTaxonomyCreate(\n\tdb: Kysely<Database>,\n\tinput: {\n\t\tname: string;\n\t\tlabel: string;\n\t\tlabelSingular?: string;\n\t\thierarchical?: boolean;\n\t\tcollections?: string[];\n\t\tlocale?: string;\n\t\ttranslationOf?: string;\n\t},\n): Promise<ApiResult<{ taxonomy: TaxonomyDef }>> {\n\ttry {\n\t\tif (!NAME_PATTERN.test(input.name)) {\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:\n\t\t\t\t\t\t\"Taxonomy name must start with a letter and contain only lowercase letters, numbers, and underscores\",\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\tconst collections = [...new Set(input.collections ?? [])];\n\t\tif (collections.length > 0) {\n\t\t\tconst existingCollections = await db\n\t\t\t\t.selectFrom(\"_emdash_collections\")\n\t\t\t\t.select(\"slug\")\n\t\t\t\t.where(\"slug\", \"in\", collections)\n\t\t\t\t.execute();\n\t\t\tconst existingSlugs = new Set(existingCollections.map((c) => c.slug));\n\t\t\tconst invalid = collections.filter((c) => !existingSlugs.has(c));\n\t\t\tif (invalid.length > 0) {\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: `Unknown collection(s): ${invalid.join(\", \")}`,\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\tlet translationGroup: string | null = null;\n\t\tif (input.translationOf) {\n\t\t\tconst source = await db\n\t\t\t\t.selectFrom(\"_emdash_taxonomy_defs\")\n\t\t\t\t.selectAll()\n\t\t\t\t.where(\"id\", \"=\", input.translationOf)\n\t\t\t\t.executeTakeFirst();\n\t\t\tif (!source) {\n\t\t\t\treturn {\n\t\t\t\t\tsuccess: false,\n\t\t\t\t\terror: { code: \"NOT_FOUND\", message: \"Source taxonomy for translation not found\" },\n\t\t\t\t};\n\t\t\t}\n\t\t\ttranslationGroup = source.translation_group ?? source.id;\n\t\t}\n\n\t\t// Duplicate guard scoped to locale (so the same name can exist in ES\n\t\t// and EN).\n\t\tif (input.locale !== undefined) {\n\t\t\tconst existing = await db\n\t\t\t\t.selectFrom(\"_emdash_taxonomy_defs\")\n\t\t\t\t.select(\"id\")\n\t\t\t\t.where(\"name\", \"=\", input.name)\n\t\t\t\t.where(\"locale\", \"=\", input.locale)\n\t\t\t\t.executeTakeFirst();\n\t\t\tif (existing) {\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: `Taxonomy '${input.name}' already exists in locale '${input.locale}'`,\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\tconst id = ulid();\n\t\tawait db\n\t\t\t.insertInto(\"_emdash_taxonomy_defs\")\n\t\t\t.values({\n\t\t\t\tid,\n\t\t\t\tname: input.name,\n\t\t\t\tlabel: input.label,\n\t\t\t\tlabel_singular: input.labelSingular ?? null,\n\t\t\t\thierarchical: input.hierarchical ? 1 : 0,\n\t\t\t\tcollections: JSON.stringify(collections),\n\t\t\t\t...(input.locale !== undefined ? { locale: input.locale } : {}),\n\t\t\t\ttranslation_group: translationGroup ?? id,\n\t\t\t})\n\t\t\t.execute();\n\n\t\tconst row = await db\n\t\t\t.selectFrom(\"_emdash_taxonomy_defs\")\n\t\t\t.selectAll()\n\t\t\t.where(\"id\", \"=\", id)\n\t\t\t.executeTakeFirstOrThrow();\n\t\treturn { success: true, data: { taxonomy: rowToDef(row) } };\n\t} catch (error) {\n\t\tif (error instanceof Error && error.message.includes(\"UNIQUE constraint failed\")) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"CONFLICT\", message: `Taxonomy '${input.name}' already exists` },\n\t\t\t};\n\t\t}\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"TAXONOMY_CREATE_ERROR\", message: \"Failed to create taxonomy\" },\n\t\t};\n\t}\n}\n\n/**\n * List every locale translation of a taxonomy def (by id or translation_group).\n */\nexport async function handleTaxonomyDefTranslations(\n\tdb: Kysely<Database>,\n\tidOrGroup: string,\n): Promise<ApiResult<TaxonomyDefTranslationsResponse>> {\n\ttry {\n\t\tconst anchor = await db\n\t\t\t.selectFrom(\"_emdash_taxonomy_defs\")\n\t\t\t.selectAll()\n\t\t\t.where((eb) => eb.or([eb(\"id\", \"=\", idOrGroup), eb(\"translation_group\", \"=\", idOrGroup)]))\n\t\t\t.executeTakeFirst();\n\t\tif (!anchor) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"NOT_FOUND\", message: \"Taxonomy not found\" },\n\t\t\t};\n\t\t}\n\t\tconst group = anchor.translation_group ?? anchor.id;\n\t\tconst rows = await db\n\t\t\t.selectFrom(\"_emdash_taxonomy_defs\")\n\t\t\t.selectAll()\n\t\t\t.where(\"translation_group\", \"=\", group)\n\t\t\t.orderBy(\"locale\", \"asc\")\n\t\t\t.execute();\n\t\treturn {\n\t\t\tsuccess: true,\n\t\t\tdata: {\n\t\t\t\ttranslationGroup: group,\n\t\t\t\ttranslations: rows.map((r) => ({\n\t\t\t\t\tid: r.id,\n\t\t\t\t\tname: r.name,\n\t\t\t\t\tlabel: r.label,\n\t\t\t\t\tlocale: r.locale,\n\t\t\t\t})),\n\t\t\t},\n\t\t};\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: {\n\t\t\t\tcode: \"TAXONOMY_TRANSLATIONS_ERROR\",\n\t\t\t\tmessage: \"Failed to list taxonomy translations\",\n\t\t\t},\n\t\t};\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// Term handlers\n// ---------------------------------------------------------------------------\n\n/**\n * List all terms for a taxonomy (returns tree for hierarchical taxonomies)\n */\nexport async function handleTermList(\n\tdb: Kysely<Database>,\n\ttaxonomyName: string,\n\toptions: { locale?: string } = {},\n): Promise<ApiResult<TermListResponse>> {\n\ttry {\n\t\t// Definitions are per-locale but terms aren't bound to the def's locale —\n\t\t// just ensure the taxonomy exists somewhere.\n\t\tconst lookup = await requireTaxonomyDef(db, taxonomyName);\n\t\tif (!lookup.success) return lookup;\n\n\t\tconst repo = new TaxonomyRepository(db);\n\t\tconst terms = await repo.findByName(taxonomyName, { locale: options.locale });\n\n\t\t// Batch count entries per term in a single query (replaces N+1 pattern).\n\t\t// content_taxonomies.taxonomy_id stores the translation_group, so we\n\t\t// look up by group and map back to each term's id.\n\t\tconst groups = terms.map((t) => t.translationGroup ?? t.id);\n\t\tconst countsByGroup = await repo.countEntriesForTerms(groups);\n\n\t\tconst termData: TermWithCount[] = terms.map((term) => ({\n\t\t\tid: term.id,\n\t\t\tname: term.name,\n\t\t\tslug: term.slug,\n\t\t\tlabel: term.label,\n\t\t\tparentId: term.parentId,\n\t\t\tdescription: typeof term.data?.description === \"string\" ? term.data.description : undefined,\n\t\t\tchildren: [],\n\t\t\tcount: countsByGroup.get(term.translationGroup ?? term.id) ?? 0,\n\t\t\tlocale: term.locale,\n\t\t\ttranslationGroup: term.translationGroup,\n\t\t}));\n\n\t\tconst isHierarchical = lookup.def.hierarchical === 1;\n\t\tconst result = isHierarchical ? buildTree(termData) : termData;\n\t\treturn { success: true, data: { terms: result } };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"TERM_LIST_ERROR\", message: \"Failed to list terms\" },\n\t\t};\n\t}\n}\n\n/**\n * Validate a parent term reference for create/update.\n *\n * Returns `null` on success or a structured error message that callers\n * wrap in their own ApiResult.\n *\n * - `parentId === undefined` -> no-op (no parent change requested).\n * - `parentId === null` -> caller intends to detach; no-op here.\n * - parent must exist (FK exists -> term row not soft-deleted).\n * - parent must live in the same taxonomy.\n * - if `termId` is provided (update path), reject `parentId === termId`\n * (self-parent) and walk up the parent chain to detect cycles.\n */\nasync function validateParentTerm(\n\trepo: TaxonomyRepository,\n\ttaxonomyName: string,\n\ttermId: string | undefined,\n\tparentId: string | null | undefined,\n): Promise<{ code: \"VALIDATION_ERROR\"; message: string } | null> {\n\tif (parentId === undefined || parentId === null) return null;\n\n\tif (termId !== undefined && parentId === termId) {\n\t\treturn {\n\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\tmessage: \"A term cannot be its own parent\",\n\t\t};\n\t}\n\n\tconst parent = await repo.findById(parentId);\n\tif (!parent) {\n\t\treturn {\n\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\tmessage: `Parent term '${parentId}' not found`,\n\t\t};\n\t}\n\tif (parent.name !== taxonomyName) {\n\t\treturn {\n\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\tmessage: `Parent term '${parentId}' belongs to taxonomy '${parent.name}', not '${taxonomyName}'`,\n\t\t};\n\t}\n\n\t// Walk up the parent chain. Two checks fold into one walk:\n\t// - Cycle detection (only on update — a non-existent term-being-\n\t// created can't be its own ancestor): if the walk revisits termId\n\t// the proposed parent makes the term a descendant of itself.\n\t// - Depth bound: refuse to extend a chain past MAX_DEPTH ancestors.\n\t// Runs on both create and update so a malicious or buggy caller\n\t// can't grow the tree without limit.\n\t//\n\t// The depth-exceeded error fires only when we hit the limit AND there\n\t// was still chain to walk — a legitimate chain of exactly MAX_DEPTH\n\t// ancestors exits with `cursor === null` and is accepted.\n\tconst MAX_DEPTH = 100;\n\tlet cursor: string | null = parent.parentId;\n\tlet steps = 0;\n\twhile (cursor !== null && steps < MAX_DEPTH) {\n\t\tif (termId !== undefined && cursor === termId) {\n\t\t\treturn {\n\t\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\t\tmessage: \"Cycle detected: cannot make a descendant the parent\",\n\t\t\t};\n\t\t}\n\t\tconst next = await repo.findById(cursor);\n\t\tif (!next) break;\n\t\tcursor = next.parentId;\n\t\tsteps++;\n\t}\n\tif (cursor !== null && steps >= MAX_DEPTH) {\n\t\treturn {\n\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\tmessage: \"Parent chain exceeds maximum depth\",\n\t\t};\n\t}\n\n\treturn null;\n}\n\n/**\n * Create a new term in a taxonomy\n */\nexport async function handleTermCreate(\n\tdb: Kysely<Database>,\n\ttaxonomyName: string,\n\tinput: {\n\t\tslug: string;\n\t\tlabel: string;\n\t\tparentId?: string | null;\n\t\tdescription?: string;\n\t\tlocale?: string;\n\t\ttranslationOf?: string;\n\t},\n): Promise<ApiResult<TermResponse>> {\n\ttry {\n\t\t// Taxonomy definitions are per-locale, but terms can exist in any locale\n\t\t// regardless of whether the def has been translated there. Look up the\n\t\t// def across all locales — we only care that it *exists*.\n\t\tconst lookup = await requireTaxonomyDef(db, taxonomyName);\n\t\tif (!lookup.success) return lookup;\n\n\t\tconst repo = new TaxonomyRepository(db);\n\n\t\t// Coerce empty-string parentId to undefined (treat as \"no parent\").\n\t\tlet parentId =\n\t\t\tinput.parentId === \"\" || input.parentId === undefined ? undefined : input.parentId;\n\n\t\t// Conflict check is scoped to locale (per-locale slugs are unique).\n\t\tconst existing = await repo.findBySlug(taxonomyName, input.slug, input.locale);\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: input.locale\n\t\t\t\t\t\t? `Term '${input.slug}' already exists in '${taxonomyName}' (${input.locale})`\n\t\t\t\t\t\t: `Term with slug '${input.slug}' already exists in taxonomy '${taxonomyName}'`,\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\t// If creating a translation whose parent is the translated sibling of\n\t\t// the source's parent, try to resolve the parent in the same locale.\n\t\tif (input.translationOf && parentId) {\n\t\t\tconst source = await repo.findById(input.translationOf);\n\t\t\tif (source?.parentId === parentId && input.locale) {\n\t\t\t\tconst sourceParent = await repo.findById(parentId);\n\t\t\t\tif (sourceParent?.translationGroup) {\n\t\t\t\t\tconst translatedParent = await db\n\t\t\t\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t\t\t\t.select(\"id\")\n\t\t\t\t\t\t.where(\"translation_group\", \"=\", sourceParent.translationGroup)\n\t\t\t\t\t\t.where(\"locale\", \"=\", input.locale)\n\t\t\t\t\t\t.executeTakeFirst();\n\t\t\t\t\tif (translatedParent) parentId = translatedParent.id;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Validate parentId: must exist AND belong to the same taxonomy.\n\t\t// (Cycle check is N/A on create — the term doesn't exist yet.)\n\t\tconst parentError = await validateParentTerm(repo, taxonomyName, undefined, parentId);\n\t\tif (parentError) {\n\t\t\treturn { success: false, error: parentError };\n\t\t}\n\n\t\tconst term = await repo.create({\n\t\t\tname: taxonomyName,\n\t\t\tslug: input.slug,\n\t\t\tlabel: input.label,\n\t\t\tparentId: parentId ?? undefined,\n\t\t\tdata: input.description ? { description: input.description } : undefined,\n\t\t\tlocale: input.locale,\n\t\t\ttranslationOf: input.translationOf,\n\t\t});\n\n\t\tinvalidateTermCache();\n\n\t\treturn {\n\t\t\tsuccess: true,\n\t\t\tdata: {\n\t\t\t\tterm: {\n\t\t\t\t\tid: term.id,\n\t\t\t\t\tname: term.name,\n\t\t\t\t\tslug: term.slug,\n\t\t\t\t\tlabel: term.label,\n\t\t\t\t\tparentId: term.parentId,\n\t\t\t\t\tdescription:\n\t\t\t\t\t\ttypeof term.data?.description === \"string\" ? term.data.description : undefined,\n\t\t\t\t\tlocale: term.locale,\n\t\t\t\t\ttranslationGroup: term.translationGroup,\n\t\t\t\t},\n\t\t\t},\n\t\t};\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"TERM_CREATE_ERROR\", message: \"Failed to create term\" },\n\t\t};\n\t}\n}\n\n/**\n * Get a single term by slug\n */\nexport async function handleTermGet(\n\tdb: Kysely<Database>,\n\ttaxonomyName: string,\n\ttermSlug: string,\n\toptions: { locale?: string } = {},\n): Promise<ApiResult<TermGetResponse>> {\n\ttry {\n\t\tconst repo = new TaxonomyRepository(db);\n\t\tconst term = await repo.findBySlug(taxonomyName, termSlug, options.locale);\n\n\t\tif (!term) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"NOT_FOUND\",\n\t\t\t\t\tmessage: `Term '${termSlug}' not found in taxonomy '${taxonomyName}'`,\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\tconst count = await repo.countEntriesWithTerm(term.id);\n\t\tconst children = await repo.findChildren(term.id);\n\n\t\treturn {\n\t\t\tsuccess: true,\n\t\t\tdata: {\n\t\t\t\tterm: {\n\t\t\t\t\tid: term.id,\n\t\t\t\t\tname: term.name,\n\t\t\t\t\tslug: term.slug,\n\t\t\t\t\tlabel: term.label,\n\t\t\t\t\tparentId: term.parentId,\n\t\t\t\t\tdescription:\n\t\t\t\t\t\ttypeof term.data?.description === \"string\" ? term.data.description : undefined,\n\t\t\t\t\tcount,\n\t\t\t\t\tchildren: children.map((c) => ({ id: c.id, slug: c.slug, label: c.label })),\n\t\t\t\t\tlocale: term.locale,\n\t\t\t\t\ttranslationGroup: term.translationGroup,\n\t\t\t\t},\n\t\t\t},\n\t\t};\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"TERM_GET_ERROR\", message: \"Failed to get term\" },\n\t\t};\n\t}\n}\n\n/** List every translation of a term (by id or translation_group). */\nexport async function handleTermTranslations(\n\tdb: Kysely<Database>,\n\tidOrGroup: string,\n): Promise<ApiResult<TermTranslationsResponse>> {\n\ttry {\n\t\tconst anchor = await db\n\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t.selectAll()\n\t\t\t.where((eb) => eb.or([eb(\"id\", \"=\", idOrGroup), eb(\"translation_group\", \"=\", idOrGroup)]))\n\t\t\t.executeTakeFirst();\n\t\tif (!anchor) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"NOT_FOUND\", message: \"Term not found\" },\n\t\t\t};\n\t\t}\n\t\tconst group = anchor.translation_group ?? anchor.id;\n\t\tconst rows = await db\n\t\t\t.selectFrom(\"taxonomies\")\n\t\t\t.selectAll()\n\t\t\t.where(\"translation_group\", \"=\", group)\n\t\t\t.orderBy(\"locale\", \"asc\")\n\t\t\t.execute();\n\t\treturn {\n\t\t\tsuccess: true,\n\t\t\tdata: {\n\t\t\t\ttranslationGroup: group,\n\t\t\t\ttranslations: rows.map((r) => ({\n\t\t\t\t\tid: r.id,\n\t\t\t\t\tslug: r.slug,\n\t\t\t\t\tlabel: r.label,\n\t\t\t\t\tlocale: r.locale,\n\t\t\t\t})),\n\t\t\t},\n\t\t};\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"TERM_TRANSLATIONS_ERROR\", message: \"Failed to list term translations\" },\n\t\t};\n\t}\n}\n\n/**\n * Update a term\n */\nexport async function handleTermUpdate(\n\tdb: Kysely<Database>,\n\ttaxonomyName: string,\n\ttermSlug: string,\n\tinput: { slug?: string; label?: string; parentId?: string | null; description?: string },\n\toptions: { locale?: string } = {},\n): Promise<ApiResult<TermResponse>> {\n\ttry {\n\t\tconst repo = new TaxonomyRepository(db);\n\t\tconst term = await repo.findBySlug(taxonomyName, termSlug, options.locale);\n\n\t\tif (!term) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"NOT_FOUND\",\n\t\t\t\t\tmessage: `Term '${termSlug}' not found in taxonomy '${taxonomyName}'`,\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\t// Coerce empty-string slug/parentId to undefined (treat as \"no change\").\n\t\t// `null` parentId is a valid request meaning \"detach from parent\".\n\t\tconst newSlug = input.slug === \"\" || input.slug === undefined ? undefined : input.slug;\n\t\tconst newParentId =\n\t\t\tinput.parentId === \"\" || input.parentId === undefined ? undefined : input.parentId;\n\n\t\t// Check if new slug conflicts (per-locale uniqueness).\n\t\tif (newSlug !== undefined && newSlug !== termSlug) {\n\t\t\tconst existing = await repo.findBySlug(taxonomyName, newSlug, options.locale);\n\t\t\tif (existing && existing.id !== term.id) {\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: `Term with slug '${newSlug}' already exists in taxonomy '${taxonomyName}'`,\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\t// Validate parentId: existence, same-taxonomy, no self-parent, no cycle.\n\t\tconst parentError = await validateParentTerm(repo, taxonomyName, term.id, newParentId);\n\t\tif (parentError) {\n\t\t\treturn { success: false, error: parentError };\n\t\t}\n\n\t\tconst updated = await repo.update(term.id, {\n\t\t\tslug: newSlug,\n\t\t\tlabel: input.label,\n\t\t\tparentId: newParentId,\n\t\t\tdata: input.description !== undefined ? { description: input.description } : undefined,\n\t\t});\n\n\t\tinvalidateTermCache();\n\n\t\tif (!updated) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"TERM_UPDATE_ERROR\", message: \"Failed to update term\" },\n\t\t\t};\n\t\t}\n\n\t\treturn {\n\t\t\tsuccess: true,\n\t\t\tdata: {\n\t\t\t\tterm: {\n\t\t\t\t\tid: updated.id,\n\t\t\t\t\tname: updated.name,\n\t\t\t\t\tslug: updated.slug,\n\t\t\t\t\tlabel: updated.label,\n\t\t\t\t\tparentId: updated.parentId,\n\t\t\t\t\tdescription:\n\t\t\t\t\t\ttypeof updated.data?.description === \"string\" ? updated.data.description : undefined,\n\t\t\t\t\tlocale: updated.locale,\n\t\t\t\t\ttranslationGroup: updated.translationGroup,\n\t\t\t\t},\n\t\t\t},\n\t\t};\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"TERM_UPDATE_ERROR\", message: \"Failed to update term\" },\n\t\t};\n\t}\n}\n\n/**\n * Delete a term\n */\nexport async function handleTermDelete(\n\tdb: Kysely<Database>,\n\ttaxonomyName: string,\n\ttermSlug: string,\n\toptions: { locale?: string } = {},\n): Promise<ApiResult<{ deleted: true }>> {\n\ttry {\n\t\tconst repo = new TaxonomyRepository(db);\n\t\tconst term = await repo.findBySlug(taxonomyName, termSlug, options.locale);\n\n\t\tif (!term) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"NOT_FOUND\",\n\t\t\t\t\tmessage: `Term '${termSlug}' not found in taxonomy '${taxonomyName}'`,\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\tconst children = await repo.findChildren(term.id);\n\t\tif (children.length > 0) {\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: \"Cannot delete term with children. Delete children first.\",\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\tconst deleted = await repo.delete(term.id);\n\t\tif (!deleted) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"TERM_DELETE_ERROR\", message: \"Failed to delete term\" },\n\t\t\t};\n\t\t}\n\n\t\tinvalidateTermCache();\n\t\treturn { success: true, data: { deleted: true } };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"TERM_DELETE_ERROR\", message: \"Failed to delete term\" },\n\t\t};\n\t}\n}\n"],"mappings":"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"}
|