@prenta/core 0.118.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/CHANGELOG.md +2974 -0
- package/LICENSE +21 -0
- package/README.md +33 -0
- package/dist/a11y/index.d.ts +25 -0
- package/dist/a11y/index.d.ts.map +1 -0
- package/dist/a11y/index.js +91 -0
- package/dist/a11y/index.js.map +1 -0
- package/dist/actions.d.ts +130 -0
- package/dist/actions.d.ts.map +1 -0
- package/dist/actions.js +1324 -0
- package/dist/actions.js.map +1 -0
- package/dist/ai/brand-voice-alignment.d.ts +17 -0
- package/dist/ai/brand-voice-alignment.d.ts.map +1 -0
- package/dist/ai/brand-voice-alignment.js +40 -0
- package/dist/ai/brand-voice-alignment.js.map +1 -0
- package/dist/ai/brand-voice-analyze.d.ts +71 -0
- package/dist/ai/brand-voice-analyze.d.ts.map +1 -0
- package/dist/ai/brand-voice-analyze.js +151 -0
- package/dist/ai/brand-voice-analyze.js.map +1 -0
- package/dist/ai/brand-voice-document.d.ts +56 -0
- package/dist/ai/brand-voice-document.d.ts.map +1 -0
- package/dist/ai/brand-voice-document.js +335 -0
- package/dist/ai/brand-voice-document.js.map +1 -0
- package/dist/ai/brand-voice-fix.d.ts +26 -0
- package/dist/ai/brand-voice-fix.d.ts.map +1 -0
- package/dist/ai/brand-voice-fix.js +97 -0
- package/dist/ai/brand-voice-fix.js.map +1 -0
- package/dist/ai/brand-voice-save.d.ts +11 -0
- package/dist/ai/brand-voice-save.d.ts.map +1 -0
- package/dist/ai/brand-voice-save.js +51 -0
- package/dist/ai/brand-voice-save.js.map +1 -0
- package/dist/ai/brand-voice-sources.d.ts +16 -0
- package/dist/ai/brand-voice-sources.d.ts.map +1 -0
- package/dist/ai/brand-voice-sources.js +106 -0
- package/dist/ai/brand-voice-sources.js.map +1 -0
- package/dist/ai/brand-voice-structured.d.ts +87 -0
- package/dist/ai/brand-voice-structured.d.ts.map +1 -0
- package/dist/ai/brand-voice-structured.js +438 -0
- package/dist/ai/brand-voice-structured.js.map +1 -0
- package/dist/ai/brand-voice.d.ts +40 -0
- package/dist/ai/brand-voice.d.ts.map +1 -0
- package/dist/ai/brand-voice.js +103 -0
- package/dist/ai/brand-voice.js.map +1 -0
- package/dist/ai/catalog.d.ts +35 -0
- package/dist/ai/catalog.d.ts.map +1 -0
- package/dist/ai/catalog.js +370 -0
- package/dist/ai/catalog.js.map +1 -0
- package/dist/ai/config-store.d.ts +55 -0
- package/dist/ai/config-store.d.ts.map +1 -0
- package/dist/ai/config-store.js +177 -0
- package/dist/ai/config-store.js.map +1 -0
- package/dist/ai/content-writing-generate.d.ts +19 -0
- package/dist/ai/content-writing-generate.d.ts.map +1 -0
- package/dist/ai/content-writing-generate.js +64 -0
- package/dist/ai/content-writing-generate.js.map +1 -0
- package/dist/ai/feature-defaults.d.ts +20 -0
- package/dist/ai/feature-defaults.d.ts.map +1 -0
- package/dist/ai/feature-defaults.js +104 -0
- package/dist/ai/feature-defaults.js.map +1 -0
- package/dist/ai/index.d.ts +24 -0
- package/dist/ai/index.d.ts.map +1 -0
- package/dist/ai/index.js +24 -0
- package/dist/ai/index.js.map +1 -0
- package/dist/ai/models.d.ts +37 -0
- package/dist/ai/models.d.ts.map +1 -0
- package/dist/ai/models.js +126 -0
- package/dist/ai/models.js.map +1 -0
- package/dist/ai/providers/anthropic.d.ts +10 -0
- package/dist/ai/providers/anthropic.d.ts.map +1 -0
- package/dist/ai/providers/anthropic.js +63 -0
- package/dist/ai/providers/anthropic.js.map +1 -0
- package/dist/ai/providers/custom.d.ts +12 -0
- package/dist/ai/providers/custom.d.ts.map +1 -0
- package/dist/ai/providers/custom.js +76 -0
- package/dist/ai/providers/custom.js.map +1 -0
- package/dist/ai/providers/http.d.ts +66 -0
- package/dist/ai/providers/http.d.ts.map +1 -0
- package/dist/ai/providers/http.js +185 -0
- package/dist/ai/providers/http.js.map +1 -0
- package/dist/ai/providers/index.d.ts +18 -0
- package/dist/ai/providers/index.d.ts.map +1 -0
- package/dist/ai/providers/index.js +28 -0
- package/dist/ai/providers/index.js.map +1 -0
- package/dist/ai/providers/openai.d.ts +11 -0
- package/dist/ai/providers/openai.d.ts.map +1 -0
- package/dist/ai/providers/openai.js +44 -0
- package/dist/ai/providers/openai.js.map +1 -0
- package/dist/ai/providers/xai.d.ts +10 -0
- package/dist/ai/providers/xai.d.ts.map +1 -0
- package/dist/ai/providers/xai.js +38 -0
- package/dist/ai/providers/xai.js.map +1 -0
- package/dist/ai/resolve.d.ts +41 -0
- package/dist/ai/resolve.d.ts.map +1 -0
- package/dist/ai/resolve.js +143 -0
- package/dist/ai/resolve.js.map +1 -0
- package/dist/ai/runtime.d.ts +88 -0
- package/dist/ai/runtime.d.ts.map +1 -0
- package/dist/ai/runtime.js +192 -0
- package/dist/ai/runtime.js.map +1 -0
- package/dist/ai/sampling.d.ts +17 -0
- package/dist/ai/sampling.d.ts.map +1 -0
- package/dist/ai/sampling.js +51 -0
- package/dist/ai/sampling.js.map +1 -0
- package/dist/ai/types.d.ts +193 -0
- package/dist/ai/types.d.ts.map +1 -0
- package/dist/ai/types.js +11 -0
- package/dist/ai/types.js.map +1 -0
- package/dist/ai/usage.d.ts +18 -0
- package/dist/ai/usage.d.ts.map +1 -0
- package/dist/ai/usage.js +70 -0
- package/dist/ai/usage.js.map +1 -0
- package/dist/api/collection-introspection.d.ts +46 -0
- package/dist/api/collection-introspection.d.ts.map +1 -0
- package/dist/api/collection-introspection.js +82 -0
- package/dist/api/collection-introspection.js.map +1 -0
- package/dist/api/guarded-db.d.ts +14 -0
- package/dist/api/guarded-db.d.ts.map +1 -0
- package/dist/api/guarded-db.js +62 -0
- package/dist/api/guarded-db.js.map +1 -0
- package/dist/api/handler-factory.d.ts +7 -0
- package/dist/api/handler-factory.d.ts.map +1 -0
- package/dist/api/handler-factory.js +380 -0
- package/dist/api/handler-factory.js.map +1 -0
- package/dist/api/handlers.d.ts +8 -0
- package/dist/api/handlers.d.ts.map +1 -0
- package/dist/api/handlers.js +62 -0
- package/dist/api/handlers.js.map +1 -0
- package/dist/api/index.d.ts +8 -0
- package/dist/api/index.d.ts.map +1 -0
- package/dist/api/index.js +8 -0
- package/dist/api/index.js.map +1 -0
- package/dist/api/openapi.d.ts +3 -0
- package/dist/api/openapi.d.ts.map +1 -0
- package/dist/api/openapi.js +539 -0
- package/dist/api/openapi.js.map +1 -0
- package/dist/api/route-helpers.d.ts +369 -0
- package/dist/api/route-helpers.d.ts.map +1 -0
- package/dist/api/route-helpers.js +1574 -0
- package/dist/api/route-helpers.js.map +1 -0
- package/dist/api/router.d.ts +21 -0
- package/dist/api/router.d.ts.map +1 -0
- package/dist/api/router.js +75 -0
- package/dist/api/router.js.map +1 -0
- package/dist/api/routes/ai-seo.d.ts +3 -0
- package/dist/api/routes/ai-seo.d.ts.map +1 -0
- package/dist/api/routes/ai-seo.js +482 -0
- package/dist/api/routes/ai-seo.js.map +1 -0
- package/dist/api/routes/ai-settings.d.ts +3 -0
- package/dist/api/routes/ai-settings.d.ts.map +1 -0
- package/dist/api/routes/ai-settings.js +1078 -0
- package/dist/api/routes/ai-settings.js.map +1 -0
- package/dist/api/routes/auth.d.ts +3 -0
- package/dist/api/routes/auth.d.ts.map +1 -0
- package/dist/api/routes/auth.js +877 -0
- package/dist/api/routes/auth.js.map +1 -0
- package/dist/api/routes/collab.d.ts +3 -0
- package/dist/api/routes/collab.d.ts.map +1 -0
- package/dist/api/routes/collab.js +426 -0
- package/dist/api/routes/collab.js.map +1 -0
- package/dist/api/routes/dashboard.d.ts +7 -0
- package/dist/api/routes/dashboard.d.ts.map +1 -0
- package/dist/api/routes/dashboard.js +540 -0
- package/dist/api/routes/dashboard.js.map +1 -0
- package/dist/api/routes/documents.d.ts +11 -0
- package/dist/api/routes/documents.d.ts.map +1 -0
- package/dist/api/routes/documents.js +340 -0
- package/dist/api/routes/documents.js.map +1 -0
- package/dist/api/routes/email-settings.d.ts +3 -0
- package/dist/api/routes/email-settings.d.ts.map +1 -0
- package/dist/api/routes/email-settings.js +248 -0
- package/dist/api/routes/email-settings.js.map +1 -0
- package/dist/api/routes/folders.d.ts +3 -0
- package/dist/api/routes/folders.d.ts.map +1 -0
- package/dist/api/routes/folders.js +195 -0
- package/dist/api/routes/folders.js.map +1 -0
- package/dist/api/routes/forms.d.ts +3 -0
- package/dist/api/routes/forms.d.ts.map +1 -0
- package/dist/api/routes/forms.js +776 -0
- package/dist/api/routes/forms.js.map +1 -0
- package/dist/api/routes/globals.d.ts +3 -0
- package/dist/api/routes/globals.d.ts.map +1 -0
- package/dist/api/routes/globals.js +157 -0
- package/dist/api/routes/globals.js.map +1 -0
- package/dist/api/routes/media.d.ts +3 -0
- package/dist/api/routes/media.d.ts.map +1 -0
- package/dist/api/routes/media.js +807 -0
- package/dist/api/routes/media.js.map +1 -0
- package/dist/api/routes/meta.d.ts +3 -0
- package/dist/api/routes/meta.d.ts.map +1 -0
- package/dist/api/routes/meta.js +97 -0
- package/dist/api/routes/meta.js.map +1 -0
- package/dist/api/routes/navigations.d.ts +7 -0
- package/dist/api/routes/navigations.d.ts.map +1 -0
- package/dist/api/routes/navigations.js +81 -0
- package/dist/api/routes/navigations.js.map +1 -0
- package/dist/api/routes/page-templates.d.ts +3 -0
- package/dist/api/routes/page-templates.d.ts.map +1 -0
- package/dist/api/routes/page-templates.js +265 -0
- package/dist/api/routes/page-templates.js.map +1 -0
- package/dist/api/routes/presence.d.ts +3 -0
- package/dist/api/routes/presence.d.ts.map +1 -0
- package/dist/api/routes/presence.js +35 -0
- package/dist/api/routes/presence.js.map +1 -0
- package/dist/api/routes/preview.d.ts +3 -0
- package/dist/api/routes/preview.d.ts.map +1 -0
- package/dist/api/routes/preview.js +111 -0
- package/dist/api/routes/preview.js.map +1 -0
- package/dist/api/routes/redirects.d.ts +3 -0
- package/dist/api/routes/redirects.d.ts.map +1 -0
- package/dist/api/routes/redirects.js +937 -0
- package/dist/api/routes/redirects.js.map +1 -0
- package/dist/api/routes/saved-sections.d.ts +3 -0
- package/dist/api/routes/saved-sections.d.ts.map +1 -0
- package/dist/api/routes/saved-sections.js +1071 -0
- package/dist/api/routes/saved-sections.js.map +1 -0
- package/dist/api/routes/scheduling.d.ts +3 -0
- package/dist/api/routes/scheduling.d.ts.map +1 -0
- package/dist/api/routes/scheduling.js +391 -0
- package/dist/api/routes/scheduling.js.map +1 -0
- package/dist/api/routes/sections.d.ts +3 -0
- package/dist/api/routes/sections.d.ts.map +1 -0
- package/dist/api/routes/sections.js +512 -0
- package/dist/api/routes/sections.js.map +1 -0
- package/dist/api/routes/security-center.d.ts +3 -0
- package/dist/api/routes/security-center.d.ts.map +1 -0
- package/dist/api/routes/security-center.js +71 -0
- package/dist/api/routes/security-center.js.map +1 -0
- package/dist/api/routes/seo-public.d.ts +14 -0
- package/dist/api/routes/seo-public.d.ts.map +1 -0
- package/dist/api/routes/seo-public.js +1366 -0
- package/dist/api/routes/seo-public.js.map +1 -0
- package/dist/api/routes/seo.d.ts +8 -0
- package/dist/api/routes/seo.d.ts.map +1 -0
- package/dist/api/routes/seo.js +2256 -0
- package/dist/api/routes/seo.js.map +1 -0
- package/dist/api/routes/system.d.ts +3 -0
- package/dist/api/routes/system.d.ts.map +1 -0
- package/dist/api/routes/system.js +353 -0
- package/dist/api/routes/system.js.map +1 -0
- package/dist/api/routes/url-resolution.d.ts +3 -0
- package/dist/api/routes/url-resolution.d.ts.map +1 -0
- package/dist/api/routes/url-resolution.js +522 -0
- package/dist/api/routes/url-resolution.js.map +1 -0
- package/dist/api/routes/users.d.ts +3 -0
- package/dist/api/routes/users.d.ts.map +1 -0
- package/dist/api/routes/users.js +1177 -0
- package/dist/api/routes/users.js.map +1 -0
- package/dist/api/routes/webhooks.d.ts +3 -0
- package/dist/api/routes/webhooks.d.ts.map +1 -0
- package/dist/api/routes/webhooks.js +422 -0
- package/dist/api/routes/webhooks.js.map +1 -0
- package/dist/appearance/color.d.ts +24 -0
- package/dist/appearance/color.d.ts.map +1 -0
- package/dist/appearance/color.js +90 -0
- package/dist/appearance/color.js.map +1 -0
- package/dist/appearance/favicon-icon.d.ts +12 -0
- package/dist/appearance/favicon-icon.d.ts.map +1 -0
- package/dist/appearance/favicon-icon.js +58 -0
- package/dist/appearance/favicon-icon.js.map +1 -0
- package/dist/appearance/favicon-paths.d.ts +6 -0
- package/dist/appearance/favicon-paths.d.ts.map +1 -0
- package/dist/appearance/favicon-paths.js +6 -0
- package/dist/appearance/favicon-paths.js.map +1 -0
- package/dist/appearance/index.d.ts +15 -0
- package/dist/appearance/index.d.ts.map +1 -0
- package/dist/appearance/index.js +7 -0
- package/dist/appearance/index.js.map +1 -0
- package/dist/appearance/logo-shape.d.ts +26 -0
- package/dist/appearance/logo-shape.d.ts.map +1 -0
- package/dist/appearance/logo-shape.js +62 -0
- package/dist/appearance/logo-shape.js.map +1 -0
- package/dist/appearance/resolve.d.ts +52 -0
- package/dist/appearance/resolve.d.ts.map +1 -0
- package/dist/appearance/resolve.js +219 -0
- package/dist/appearance/resolve.js.map +1 -0
- package/dist/appearance/theme-tokens.d.ts +49 -0
- package/dist/appearance/theme-tokens.d.ts.map +1 -0
- package/dist/appearance/theme-tokens.js +229 -0
- package/dist/appearance/theme-tokens.js.map +1 -0
- package/dist/appearance/types.d.ts +110 -0
- package/dist/appearance/types.d.ts.map +1 -0
- package/dist/appearance/types.js +16 -0
- package/dist/appearance/types.js.map +1 -0
- package/dist/astro-cache.d.ts +80 -0
- package/dist/astro-cache.d.ts.map +1 -0
- package/dist/astro-cache.js +108 -0
- package/dist/astro-cache.js.map +1 -0
- package/dist/astro-content.d.ts +81 -0
- package/dist/astro-content.d.ts.map +1 -0
- package/dist/astro-content.js +151 -0
- package/dist/astro-content.js.map +1 -0
- package/dist/astro-redirects.d.ts +38 -0
- package/dist/astro-redirects.d.ts.map +1 -0
- package/dist/astro-redirects.js +45 -0
- package/dist/astro-redirects.js.map +1 -0
- package/dist/astro-security.d.ts +34 -0
- package/dist/astro-security.d.ts.map +1 -0
- package/dist/astro-security.js +31 -0
- package/dist/astro-security.js.map +1 -0
- package/dist/astro-site.d.ts +69 -0
- package/dist/astro-site.d.ts.map +1 -0
- package/dist/astro-site.js +81 -0
- package/dist/astro-site.js.map +1 -0
- package/dist/auth/index.d.ts +17 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +12 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/invite-email.d.ts +12 -0
- package/dist/auth/invite-email.d.ts.map +1 -0
- package/dist/auth/invite-email.js +57 -0
- package/dist/auth/invite-email.js.map +1 -0
- package/dist/auth/invites.d.ts +72 -0
- package/dist/auth/invites.d.ts.map +1 -0
- package/dist/auth/invites.js +279 -0
- package/dist/auth/invites.js.map +1 -0
- package/dist/auth/mfa-pending.d.ts +24 -0
- package/dist/auth/mfa-pending.d.ts.map +1 -0
- package/dist/auth/mfa-pending.js +38 -0
- package/dist/auth/mfa-pending.js.map +1 -0
- package/dist/auth/oauth.d.ts +139 -0
- package/dist/auth/oauth.d.ts.map +1 -0
- package/dist/auth/oauth.js +376 -0
- package/dist/auth/oauth.js.map +1 -0
- package/dist/auth/password.d.ts +57 -0
- package/dist/auth/password.d.ts.map +1 -0
- package/dist/auth/password.js +150 -0
- package/dist/auth/password.js.map +1 -0
- package/dist/auth/providers/github.d.ts +9 -0
- package/dist/auth/providers/github.d.ts.map +1 -0
- package/dist/auth/providers/github.js +10 -0
- package/dist/auth/providers/github.js.map +1 -0
- package/dist/auth/providers/google.d.ts +9 -0
- package/dist/auth/providers/google.d.ts.map +1 -0
- package/dist/auth/providers/google.js +10 -0
- package/dist/auth/providers/google.js.map +1 -0
- package/dist/auth/providers/microsoft.d.ts +9 -0
- package/dist/auth/providers/microsoft.d.ts.map +1 -0
- package/dist/auth/providers/microsoft.js +11 -0
- package/dist/auth/providers/microsoft.js.map +1 -0
- package/dist/auth/reset-email.d.ts +10 -0
- package/dist/auth/reset-email.d.ts.map +1 -0
- package/dist/auth/reset-email.js +30 -0
- package/dist/auth/reset-email.js.map +1 -0
- package/dist/auth/reset.d.ts +42 -0
- package/dist/auth/reset.d.ts.map +1 -0
- package/dist/auth/reset.js +142 -0
- package/dist/auth/reset.js.map +1 -0
- package/dist/auth/roles.d.ts +51 -0
- package/dist/auth/roles.d.ts.map +1 -0
- package/dist/auth/roles.js +159 -0
- package/dist/auth/roles.js.map +1 -0
- package/dist/auth/session.d.ts +38 -0
- package/dist/auth/session.d.ts.map +1 -0
- package/dist/auth/session.js +106 -0
- package/dist/auth/session.js.map +1 -0
- package/dist/auth/team.d.ts +99 -0
- package/dist/auth/team.d.ts.map +1 -0
- package/dist/auth/team.js +327 -0
- package/dist/auth/team.js.map +1 -0
- package/dist/auth/totp.d.ts +31 -0
- package/dist/auth/totp.d.ts.map +1 -0
- package/dist/auth/totp.js +134 -0
- package/dist/auth/totp.js.map +1 -0
- package/dist/auth/user-admin.d.ts +119 -0
- package/dist/auth/user-admin.d.ts.map +1 -0
- package/dist/auth/user-admin.js +292 -0
- package/dist/auth/user-admin.js.map +1 -0
- package/dist/backup/index.d.ts +19 -0
- package/dist/backup/index.d.ts.map +1 -0
- package/dist/backup/index.js +24 -0
- package/dist/backup/index.js.map +1 -0
- package/dist/billing/stripe-meter.d.ts +22 -0
- package/dist/billing/stripe-meter.d.ts.map +1 -0
- package/dist/billing/stripe-meter.js +101 -0
- package/dist/billing/stripe-meter.js.map +1 -0
- package/dist/brand-voice/index.d.ts +7 -0
- package/dist/brand-voice/index.d.ts.map +1 -0
- package/dist/brand-voice/index.js +7 -0
- package/dist/brand-voice/index.js.map +1 -0
- package/dist/cache/http.d.ts +140 -0
- package/dist/cache/http.d.ts.map +1 -0
- package/dist/cache/http.js +213 -0
- package/dist/cache/http.js.map +1 -0
- package/dist/cache/index.d.ts +15 -0
- package/dist/cache/index.d.ts.map +1 -0
- package/dist/cache/index.js +32 -0
- package/dist/cache/index.js.map +1 -0
- package/dist/client.d.ts +51 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +59 -0
- package/dist/client.js.map +1 -0
- package/dist/codegen/index.d.ts +4 -0
- package/dist/codegen/index.d.ts.map +1 -0
- package/dist/codegen/index.js +366 -0
- package/dist/codegen/index.js.map +1 -0
- package/dist/collections/index.d.ts +19 -0
- package/dist/collections/index.d.ts.map +1 -0
- package/dist/collections/index.js +30 -0
- package/dist/collections/index.js.map +1 -0
- package/dist/collections/presets.d.ts +90 -0
- package/dist/collections/presets.d.ts.map +1 -0
- package/dist/collections/presets.js +573 -0
- package/dist/collections/presets.js.map +1 -0
- package/dist/config/define.d.ts +8 -0
- package/dist/config/define.d.ts.map +1 -0
- package/dist/config/define.js +7 -0
- package/dist/config/define.js.map +1 -0
- package/dist/config/index.d.ts +31 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +196 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config/runtime.d.ts +107 -0
- package/dist/config/runtime.d.ts.map +1 -0
- package/dist/config/runtime.js +77 -0
- package/dist/config/runtime.js.map +1 -0
- package/dist/config/types.d.ts +924 -0
- package/dist/config/types.d.ts.map +1 -0
- package/dist/config/types.js +2 -0
- package/dist/config/types.js.map +1 -0
- package/dist/content/ai-api.d.ts +21 -0
- package/dist/content/ai-api.d.ts.map +1 -0
- package/dist/content/ai-api.js +25 -0
- package/dist/content/ai-api.js.map +1 -0
- package/dist/content/content-graph.d.ts +25 -0
- package/dist/content/content-graph.d.ts.map +1 -0
- package/dist/content/content-graph.js +40 -0
- package/dist/content/content-graph.js.map +1 -0
- package/dist/content/extract.d.ts +7 -0
- package/dist/content/extract.d.ts.map +1 -0
- package/dist/content/extract.js +33 -0
- package/dist/content/extract.js.map +1 -0
- package/dist/content/index.d.ts +9 -0
- package/dist/content/index.d.ts.map +1 -0
- package/dist/content/index.js +6 -0
- package/dist/content/index.js.map +1 -0
- package/dist/content/slug.d.ts +40 -0
- package/dist/content/slug.d.ts.map +1 -0
- package/dist/content/slug.js +78 -0
- package/dist/content/slug.js.map +1 -0
- package/dist/content/structured-data.d.ts +163 -0
- package/dist/content/structured-data.d.ts.map +1 -0
- package/dist/content/structured-data.js +479 -0
- package/dist/content/structured-data.js.map +1 -0
- package/dist/content-health/cron.d.ts +68 -0
- package/dist/content-health/cron.d.ts.map +1 -0
- package/dist/content-health/cron.js +167 -0
- package/dist/content-health/cron.js.map +1 -0
- package/dist/content-health/scanner.d.ts +119 -0
- package/dist/content-health/scanner.d.ts.map +1 -0
- package/dist/content-health/scanner.js +257 -0
- package/dist/content-health/scanner.js.map +1 -0
- package/dist/cron/index.d.ts +90 -0
- package/dist/cron/index.d.ts.map +1 -0
- package/dist/cron/index.js +299 -0
- package/dist/cron/index.js.map +1 -0
- package/dist/db/adapters/mysql.d.ts +5 -0
- package/dist/db/adapters/mysql.d.ts.map +1 -0
- package/dist/db/adapters/mysql.js +26 -0
- package/dist/db/adapters/mysql.js.map +1 -0
- package/dist/db/adapters/postgres.d.ts +7 -0
- package/dist/db/adapters/postgres.d.ts.map +1 -0
- package/dist/db/adapters/postgres.js +25 -0
- package/dist/db/adapters/postgres.js.map +1 -0
- package/dist/db/adapters/sqlite.d.ts +5 -0
- package/dist/db/adapters/sqlite.d.ts.map +1 -0
- package/dist/db/adapters/sqlite.js +27 -0
- package/dist/db/adapters/sqlite.js.map +1 -0
- package/dist/db/client-types.d.ts +75 -0
- package/dist/db/client-types.d.ts.map +1 -0
- package/dist/db/client-types.js +2 -0
- package/dist/db/client-types.js.map +1 -0
- package/dist/db/create-adapter.d.ts +11 -0
- package/dist/db/create-adapter.d.ts.map +1 -0
- package/dist/db/create-adapter.js +43 -0
- package/dist/db/create-adapter.js.map +1 -0
- package/dist/db/find-in-batches.d.ts +12 -0
- package/dist/db/find-in-batches.d.ts.map +1 -0
- package/dist/db/find-in-batches.js +20 -0
- package/dist/db/find-in-batches.js.map +1 -0
- package/dist/db/index.d.ts +9 -0
- package/dist/db/index.d.ts.map +1 -0
- package/dist/db/index.js +5 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/model-types.d.ts +602 -0
- package/dist/db/model-types.d.ts.map +1 -0
- package/dist/db/model-types.js +14 -0
- package/dist/db/model-types.js.map +1 -0
- package/dist/db.d.ts +20 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +35 -0
- package/dist/db.js.map +1 -0
- package/dist/diagnostics/api-metrics.d.ts +135 -0
- package/dist/diagnostics/api-metrics.d.ts.map +1 -0
- package/dist/diagnostics/api-metrics.js +376 -0
- package/dist/diagnostics/api-metrics.js.map +1 -0
- package/dist/diagnostics/env.d.ts +44 -0
- package/dist/diagnostics/env.d.ts.map +1 -0
- package/dist/diagnostics/env.js +354 -0
- package/dist/diagnostics/env.js.map +1 -0
- package/dist/diagnostics/logger.d.ts +38 -0
- package/dist/diagnostics/logger.d.ts.map +1 -0
- package/dist/diagnostics/logger.js +89 -0
- package/dist/diagnostics/logger.js.map +1 -0
- package/dist/diagnostics/sentry.d.ts +8 -0
- package/dist/diagnostics/sentry.d.ts.map +1 -0
- package/dist/diagnostics/sentry.js +54 -0
- package/dist/diagnostics/sentry.js.map +1 -0
- package/dist/email/resolve.d.ts +27 -0
- package/dist/email/resolve.d.ts.map +1 -0
- package/dist/email/resolve.js +68 -0
- package/dist/email/resolve.js.map +1 -0
- package/dist/email/sender.d.ts +71 -0
- package/dist/email/sender.d.ts.map +1 -0
- package/dist/email/sender.js +41 -0
- package/dist/email/sender.js.map +1 -0
- package/dist/email/settings-store.d.ts +116 -0
- package/dist/email/settings-store.d.ts.map +1 -0
- package/dist/email/settings-store.js +222 -0
- package/dist/email/settings-store.js.map +1 -0
- package/dist/fields/component-block.d.ts +23 -0
- package/dist/fields/component-block.d.ts.map +1 -0
- package/dist/fields/component-block.js +137 -0
- package/dist/fields/component-block.js.map +1 -0
- package/dist/fields/index.d.ts +19 -0
- package/dist/fields/index.d.ts.map +1 -0
- package/dist/fields/index.js +95 -0
- package/dist/fields/index.js.map +1 -0
- package/dist/fields/presets.d.ts +144 -0
- package/dist/fields/presets.d.ts.map +1 -0
- package/dist/fields/presets.js +350 -0
- package/dist/fields/presets.js.map +1 -0
- package/dist/fields/relations.d.ts +87 -0
- package/dist/fields/relations.d.ts.map +1 -0
- package/dist/fields/relations.js +231 -0
- package/dist/fields/relations.js.map +1 -0
- package/dist/forms/analytics.d.ts +62 -0
- package/dist/forms/analytics.d.ts.map +1 -0
- package/dist/forms/analytics.js +95 -0
- package/dist/forms/analytics.js.map +1 -0
- package/dist/forms/attribution.d.ts +175 -0
- package/dist/forms/attribution.d.ts.map +1 -0
- package/dist/forms/attribution.js +577 -0
- package/dist/forms/attribution.js.map +1 -0
- package/dist/forms/client-validation.d.ts +50 -0
- package/dist/forms/client-validation.d.ts.map +1 -0
- package/dist/forms/client-validation.js +215 -0
- package/dist/forms/client-validation.js.map +1 -0
- package/dist/forms/delivery.d.ts +89 -0
- package/dist/forms/delivery.d.ts.map +1 -0
- package/dist/forms/delivery.js +477 -0
- package/dist/forms/delivery.js.map +1 -0
- package/dist/forms/encryption.d.ts +20 -0
- package/dist/forms/encryption.d.ts.map +1 -0
- package/dist/forms/encryption.js +80 -0
- package/dist/forms/encryption.js.map +1 -0
- package/dist/forms/entries.d.ts +68 -0
- package/dist/forms/entries.d.ts.map +1 -0
- package/dist/forms/entries.js +557 -0
- package/dist/forms/entries.js.map +1 -0
- package/dist/forms/files.d.ts +38 -0
- package/dist/forms/files.d.ts.map +1 -0
- package/dist/forms/files.js +134 -0
- package/dist/forms/files.js.map +1 -0
- package/dist/forms/index.d.ts +21 -0
- package/dist/forms/index.d.ts.map +1 -0
- package/dist/forms/index.js +20 -0
- package/dist/forms/index.js.map +1 -0
- package/dist/forms/notify.d.ts +80 -0
- package/dist/forms/notify.d.ts.map +1 -0
- package/dist/forms/notify.js +357 -0
- package/dist/forms/notify.js.map +1 -0
- package/dist/forms/prefetch.d.ts +17 -0
- package/dist/forms/prefetch.d.ts.map +1 -0
- package/dist/forms/prefetch.js +36 -0
- package/dist/forms/prefetch.js.map +1 -0
- package/dist/forms/public-html.d.ts +22 -0
- package/dist/forms/public-html.d.ts.map +1 -0
- package/dist/forms/public-html.js +145 -0
- package/dist/forms/public-html.js.map +1 -0
- package/dist/forms/service.d.ts +102 -0
- package/dist/forms/service.d.ts.map +1 -0
- package/dist/forms/service.js +625 -0
- package/dist/forms/service.js.map +1 -0
- package/dist/forms/spam.d.ts +33 -0
- package/dist/forms/spam.d.ts.map +1 -0
- package/dist/forms/spam.js +93 -0
- package/dist/forms/spam.js.map +1 -0
- package/dist/forms/submission.d.ts +132 -0
- package/dist/forms/submission.d.ts.map +1 -0
- package/dist/forms/submission.js +348 -0
- package/dist/forms/submission.js.map +1 -0
- package/dist/forms/types.d.ts +259 -0
- package/dist/forms/types.d.ts.map +1 -0
- package/dist/forms/types.js +20 -0
- package/dist/forms/types.js.map +1 -0
- package/dist/forms/validation.d.ts +19 -0
- package/dist/forms/validation.d.ts.map +1 -0
- package/dist/forms/validation.js +166 -0
- package/dist/forms/validation.js.map +1 -0
- package/dist/forms/webhooks.d.ts +60 -0
- package/dist/forms/webhooks.d.ts.map +1 -0
- package/dist/forms/webhooks.js +360 -0
- package/dist/forms/webhooks.js.map +1 -0
- package/dist/forms/webmcp.d.ts +36 -0
- package/dist/forms/webmcp.d.ts.map +1 -0
- package/dist/forms/webmcp.js +67 -0
- package/dist/forms/webmcp.js.map +1 -0
- package/dist/forms/xlsx.d.ts +10 -0
- package/dist/forms/xlsx.d.ts.map +1 -0
- package/dist/forms/xlsx.js +207 -0
- package/dist/forms/xlsx.js.map +1 -0
- package/dist/generated/browser.d.ts +220 -0
- package/dist/generated/browser.js +17 -0
- package/dist/generated/client.d.ts +239 -0
- package/dist/generated/client.js +34 -0
- package/dist/generated/commonInputTypes.d.ts +887 -0
- package/dist/generated/commonInputTypes.js +10 -0
- package/dist/generated/enums.d.ts +83 -0
- package/dist/generated/enums.js +81 -0
- package/dist/generated/internal/class.d.ts +517 -0
- package/dist/generated/internal/class.js +52 -0
- package/dist/generated/internal/prismaNamespace.d.ts +4184 -0
- package/dist/generated/internal/prismaNamespace.js +932 -0
- package/dist/generated/internal/prismaNamespaceBrowser.d.ts +968 -0
- package/dist/generated/internal/prismaNamespaceBrowser.js +903 -0
- package/dist/generated/models/AiCrawlerHit.d.ts +1115 -0
- package/dist/generated/models/AiCrawlerHit.js +1 -0
- package/dist/generated/models/ApiKey.d.ts +1388 -0
- package/dist/generated/models/ApiKey.js +1 -0
- package/dist/generated/models/ApiRequestMetric.d.ts +1229 -0
- package/dist/generated/models/ApiRequestMetric.js +1 -0
- package/dist/generated/models/AuditLog.d.ts +1062 -0
- package/dist/generated/models/AuditLog.js +1 -0
- package/dist/generated/models/BackupRecord.d.ts +1100 -0
- package/dist/generated/models/BackupRecord.js +1 -0
- package/dist/generated/models/ContentIssue.d.ts +1318 -0
- package/dist/generated/models/ContentIssue.js +1 -0
- package/dist/generated/models/ContentLock.d.ts +1295 -0
- package/dist/generated/models/ContentLock.js +1 -0
- package/dist/generated/models/ContentTemplate.d.ts +1254 -0
- package/dist/generated/models/ContentTemplate.js +1 -0
- package/dist/generated/models/Document.d.ts +3443 -0
- package/dist/generated/models/Document.js +1 -0
- package/dist/generated/models/Folder.d.ts +1689 -0
- package/dist/generated/models/Folder.js +1 -0
- package/dist/generated/models/FormDeliveryAttempt.d.ts +1705 -0
- package/dist/generated/models/FormDeliveryAttempt.js +1 -0
- package/dist/generated/models/FormSchemaVersion.d.ts +1105 -0
- package/dist/generated/models/FormSchemaVersion.js +1 -0
- package/dist/generated/models/FormSubmission.d.ts +2125 -0
- package/dist/generated/models/FormSubmission.js +1 -0
- package/dist/generated/models/FormSubmissionFile.d.ts +1438 -0
- package/dist/generated/models/FormSubmissionFile.js +1 -0
- package/dist/generated/models/FormWebhook.d.ts +1271 -0
- package/dist/generated/models/FormWebhook.js +1 -0
- package/dist/generated/models/InAppNotification.d.ts +1295 -0
- package/dist/generated/models/InAppNotification.js +1 -0
- package/dist/generated/models/Invite.d.ts +1240 -0
- package/dist/generated/models/Invite.js +1 -0
- package/dist/generated/models/Media.d.ts +2759 -0
- package/dist/generated/models/Media.js +1 -0
- package/dist/generated/models/MediaUsage.d.ts +1301 -0
- package/dist/generated/models/MediaUsage.js +1 -0
- package/dist/generated/models/OAuthAccount.d.ts +1300 -0
- package/dist/generated/models/OAuthAccount.js +1 -0
- package/dist/generated/models/PageTemplate.d.ts +1118 -0
- package/dist/generated/models/PageTemplate.js +1 -0
- package/dist/generated/models/PasswordResetToken.d.ts +1225 -0
- package/dist/generated/models/PasswordResetToken.js +1 -0
- package/dist/generated/models/Redirect.d.ts +1342 -0
- package/dist/generated/models/Redirect.js +1 -0
- package/dist/generated/models/Redirect404Hit.d.ts +1107 -0
- package/dist/generated/models/Redirect404Hit.js +1 -0
- package/dist/generated/models/RedirectSuggestion.d.ts +1185 -0
- package/dist/generated/models/RedirectSuggestion.js +1 -0
- package/dist/generated/models/SavedSection.d.ts +1156 -0
- package/dist/generated/models/SavedSection.js +1 -0
- package/dist/generated/models/ScriptTag.d.ts +1291 -0
- package/dist/generated/models/ScriptTag.js +1 -0
- package/dist/generated/models/SeoAuditRun.d.ts +1570 -0
- package/dist/generated/models/SeoAuditRun.js +1 -0
- package/dist/generated/models/SeoIssue.d.ts +1873 -0
- package/dist/generated/models/SeoIssue.js +1 -0
- package/dist/generated/models/Session.d.ts +1330 -0
- package/dist/generated/models/Session.js +1 -0
- package/dist/generated/models/Site.d.ts +1062 -0
- package/dist/generated/models/Site.js +1 -0
- package/dist/generated/models/User.d.ts +4344 -0
- package/dist/generated/models/User.js +1 -0
- package/dist/generated/models/Version.d.ts +1334 -0
- package/dist/generated/models/Version.js +1 -0
- package/dist/generated/models/WebhookDeliveryLog.d.ts +1479 -0
- package/dist/generated/models/WebhookDeliveryLog.js +1 -0
- package/dist/generated/models/WebhookEndpoint.d.ts +1331 -0
- package/dist/generated/models/WebhookEndpoint.js +1 -0
- package/dist/generated/models/WorkflowState.d.ts +1436 -0
- package/dist/generated/models/WorkflowState.js +1 -0
- package/dist/generated/models.d.ts +37 -0
- package/dist/generated/models.js +1 -0
- package/dist/graphql/index.d.ts +11 -0
- package/dist/graphql/index.d.ts.map +1 -0
- package/dist/graphql/index.js +74 -0
- package/dist/graphql/index.js.map +1 -0
- package/dist/graphql/resolvers.d.ts +8 -0
- package/dist/graphql/resolvers.d.ts.map +1 -0
- package/dist/graphql/resolvers.js +89 -0
- package/dist/graphql/resolvers.js.map +1 -0
- package/dist/graphql/schema-builder.d.ts +3 -0
- package/dist/graphql/schema-builder.d.ts.map +1 -0
- package/dist/graphql/schema-builder.js +103 -0
- package/dist/graphql/schema-builder.js.map +1 -0
- package/dist/health/index.d.ts +27 -0
- package/dist/health/index.d.ts.map +1 -0
- package/dist/health/index.js +43 -0
- package/dist/health/index.js.map +1 -0
- package/dist/i18n/index.d.ts +22 -0
- package/dist/i18n/index.d.ts.map +1 -0
- package/dist/i18n/index.js +37 -0
- package/dist/i18n/index.js.map +1 -0
- package/dist/index.d.ts +125 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +132 -0
- package/dist/index.js.map +1 -0
- package/dist/media/content-hash.d.ts +3 -0
- package/dist/media/content-hash.d.ts.map +1 -0
- package/dist/media/content-hash.js +11 -0
- package/dist/media/content-hash.js.map +1 -0
- package/dist/media/denormalize.d.ts +23 -0
- package/dist/media/denormalize.d.ts.map +1 -0
- package/dist/media/denormalize.js +170 -0
- package/dist/media/denormalize.js.map +1 -0
- package/dist/media/index.d.ts +8 -0
- package/dist/media/index.d.ts.map +1 -0
- package/dist/media/index.js +5 -0
- package/dist/media/index.js.map +1 -0
- package/dist/media/merge-document-media.d.ts +23 -0
- package/dist/media/merge-document-media.d.ts.map +1 -0
- package/dist/media/merge-document-media.js +146 -0
- package/dist/media/merge-document-media.js.map +1 -0
- package/dist/media/metadata-patch.d.ts +21 -0
- package/dist/media/metadata-patch.d.ts.map +1 -0
- package/dist/media/metadata-patch.js +37 -0
- package/dist/media/metadata-patch.js.map +1 -0
- package/dist/media/optimize.d.ts +61 -0
- package/dist/media/optimize.d.ts.map +1 -0
- package/dist/media/optimize.js +168 -0
- package/dist/media/optimize.js.map +1 -0
- package/dist/media/seo.d.ts +110 -0
- package/dist/media/seo.d.ts.map +1 -0
- package/dist/media/seo.js +263 -0
- package/dist/media/seo.js.map +1 -0
- package/dist/media/usage-sync.d.ts +37 -0
- package/dist/media/usage-sync.d.ts.map +1 -0
- package/dist/media/usage-sync.js +234 -0
- package/dist/media/usage-sync.js.map +1 -0
- package/dist/middleware.d.ts +17 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +159 -0
- package/dist/middleware.js.map +1 -0
- package/dist/multisite/index.d.ts +20 -0
- package/dist/multisite/index.d.ts.map +1 -0
- package/dist/multisite/index.js +26 -0
- package/dist/multisite/index.js.map +1 -0
- package/dist/navigation/unresolved-targets.d.ts +35 -0
- package/dist/navigation/unresolved-targets.d.ts.map +1 -0
- package/dist/navigation/unresolved-targets.js +109 -0
- package/dist/navigation/unresolved-targets.js.map +1 -0
- package/dist/next/preview.d.ts +10 -0
- package/dist/next/preview.d.ts.map +1 -0
- package/dist/next/preview.js +17 -0
- package/dist/next/preview.js.map +1 -0
- package/dist/next/script-tags.d.ts +15 -0
- package/dist/next/script-tags.d.ts.map +1 -0
- package/dist/next/script-tags.js +58 -0
- package/dist/next/script-tags.js.map +1 -0
- package/dist/next-site.d.ts +29 -0
- package/dist/next-site.d.ts.map +1 -0
- package/dist/next-site.js +60 -0
- package/dist/next-site.js.map +1 -0
- package/dist/next.d.ts +33 -0
- package/dist/next.d.ts.map +1 -0
- package/dist/next.js +142 -0
- package/dist/next.js.map +1 -0
- package/dist/notifications/index.d.ts +20 -0
- package/dist/notifications/index.d.ts.map +1 -0
- package/dist/notifications/index.js +22 -0
- package/dist/notifications/index.js.map +1 -0
- package/dist/page-builder/a11y-fix.d.ts +20 -0
- package/dist/page-builder/a11y-fix.d.ts.map +1 -0
- package/dist/page-builder/a11y-fix.js +182 -0
- package/dist/page-builder/a11y-fix.js.map +1 -0
- package/dist/page-builder/ai-pipeline.d.ts +43 -0
- package/dist/page-builder/ai-pipeline.d.ts.map +1 -0
- package/dist/page-builder/ai-pipeline.js +165 -0
- package/dist/page-builder/ai-pipeline.js.map +1 -0
- package/dist/page-builder/blocks.d.ts +27 -0
- package/dist/page-builder/blocks.d.ts.map +1 -0
- package/dist/page-builder/blocks.js +313 -0
- package/dist/page-builder/blocks.js.map +1 -0
- package/dist/page-builder/design-scorer.d.ts +27 -0
- package/dist/page-builder/design-scorer.d.ts.map +1 -0
- package/dist/page-builder/design-scorer.js +612 -0
- package/dist/page-builder/design-scorer.js.map +1 -0
- package/dist/page-builder/index.d.ts +18 -0
- package/dist/page-builder/index.d.ts.map +1 -0
- package/dist/page-builder/index.js +20 -0
- package/dist/page-builder/index.js.map +1 -0
- package/dist/page-builder/schema.d.ts +107 -0
- package/dist/page-builder/schema.d.ts.map +1 -0
- package/dist/page-builder/schema.js +90 -0
- package/dist/page-builder/schema.js.map +1 -0
- package/dist/page-builder/seo-analyzer.d.ts +61 -0
- package/dist/page-builder/seo-analyzer.d.ts.map +1 -0
- package/dist/page-builder/seo-analyzer.js +778 -0
- package/dist/page-builder/seo-analyzer.js.map +1 -0
- package/dist/page-builder/templates.d.ts +9 -0
- package/dist/page-builder/templates.d.ts.map +1 -0
- package/dist/page-builder/templates.js +742 -0
- package/dist/page-builder/templates.js.map +1 -0
- package/dist/page-builder/tree.d.ts +17 -0
- package/dist/page-builder/tree.d.ts.map +1 -0
- package/dist/page-builder/tree.js +160 -0
- package/dist/page-builder/tree.js.map +1 -0
- package/dist/page-builder/types.d.ts +112 -0
- package/dist/page-builder/types.d.ts.map +1 -0
- package/dist/page-builder/types.js +4 -0
- package/dist/page-builder/types.js.map +1 -0
- package/dist/page-builder/validate.d.ts +6 -0
- package/dist/page-builder/validate.d.ts.map +1 -0
- package/dist/page-builder/validate.js +87 -0
- package/dist/page-builder/validate.js.map +1 -0
- package/dist/plan/features.d.ts +17 -0
- package/dist/plan/features.d.ts.map +1 -0
- package/dist/plan/features.js +73 -0
- package/dist/plan/features.js.map +1 -0
- package/dist/posts/query.d.ts +50 -0
- package/dist/posts/query.d.ts.map +1 -0
- package/dist/posts/query.js +105 -0
- package/dist/posts/query.js.map +1 -0
- package/dist/presence/index.d.ts +24 -0
- package/dist/presence/index.d.ts.map +1 -0
- package/dist/presence/index.js +99 -0
- package/dist/presence/index.js.map +1 -0
- package/dist/preview/event-bus.d.ts +39 -0
- package/dist/preview/event-bus.d.ts.map +1 -0
- package/dist/preview/event-bus.js +73 -0
- package/dist/preview/event-bus.js.map +1 -0
- package/dist/preview/index.d.ts +36 -0
- package/dist/preview/index.d.ts.map +1 -0
- package/dist/preview/index.js +68 -0
- package/dist/preview/index.js.map +1 -0
- package/dist/preview/sse-stream.d.ts +47 -0
- package/dist/preview/sse-stream.d.ts.map +1 -0
- package/dist/preview/sse-stream.js +111 -0
- package/dist/preview/sse-stream.js.map +1 -0
- package/dist/privacy/index.d.ts +33 -0
- package/dist/privacy/index.d.ts.map +1 -0
- package/dist/privacy/index.js +15 -0
- package/dist/privacy/index.js.map +1 -0
- package/dist/public-redirects.d.ts +78 -0
- package/dist/public-redirects.d.ts.map +1 -0
- package/dist/public-redirects.js +187 -0
- package/dist/public-redirects.js.map +1 -0
- package/dist/public-security-headers.d.ts +27 -0
- package/dist/public-security-headers.d.ts.map +1 -0
- package/dist/public-security-headers.js +40 -0
- package/dist/public-security-headers.js.map +1 -0
- package/dist/publishing/publish-sync.d.ts +50 -0
- package/dist/publishing/publish-sync.d.ts.map +1 -0
- package/dist/publishing/publish-sync.js +124 -0
- package/dist/publishing/publish-sync.js.map +1 -0
- package/dist/purge/adapters.d.ts +60 -0
- package/dist/purge/adapters.d.ts.map +1 -0
- package/dist/purge/adapters.js +81 -0
- package/dist/purge/adapters.js.map +1 -0
- package/dist/purge/index.d.ts +12 -0
- package/dist/purge/index.d.ts.map +1 -0
- package/dist/purge/index.js +10 -0
- package/dist/purge/index.js.map +1 -0
- package/dist/purge/receiver.d.ts +95 -0
- package/dist/purge/receiver.d.ts.map +1 -0
- package/dist/purge/receiver.js +176 -0
- package/dist/purge/receiver.js.map +1 -0
- package/dist/realtime/comments.d.ts +217 -0
- package/dist/realtime/comments.d.ts.map +1 -0
- package/dist/realtime/comments.js +282 -0
- package/dist/realtime/comments.js.map +1 -0
- package/dist/realtime/gateway.d.ts +107 -0
- package/dist/realtime/gateway.d.ts.map +1 -0
- package/dist/realtime/gateway.js +215 -0
- package/dist/realtime/gateway.js.map +1 -0
- package/dist/realtime/index.d.ts +21 -0
- package/dist/realtime/index.d.ts.map +1 -0
- package/dist/realtime/index.js +27 -0
- package/dist/realtime/index.js.map +1 -0
- package/dist/realtime/notification-bus.d.ts +60 -0
- package/dist/realtime/notification-bus.d.ts.map +1 -0
- package/dist/realtime/notification-bus.js +77 -0
- package/dist/realtime/notification-bus.js.map +1 -0
- package/dist/realtime/notification-stream.d.ts +50 -0
- package/dist/realtime/notification-stream.d.ts.map +1 -0
- package/dist/realtime/notification-stream.js +148 -0
- package/dist/realtime/notification-stream.js.map +1 -0
- package/dist/realtime/notifications.d.ts +201 -0
- package/dist/realtime/notifications.d.ts.map +1 -0
- package/dist/realtime/notifications.js +233 -0
- package/dist/realtime/notifications.js.map +1 -0
- package/dist/realtime/persistence.d.ts +72 -0
- package/dist/realtime/persistence.d.ts.map +1 -0
- package/dist/realtime/persistence.js +53 -0
- package/dist/realtime/persistence.js.map +1 -0
- package/dist/realtime/sse.d.ts +20 -0
- package/dist/realtime/sse.d.ts.map +1 -0
- package/dist/realtime/sse.js +31 -0
- package/dist/realtime/sse.js.map +1 -0
- package/dist/redirects/auto.d.ts +45 -0
- package/dist/redirects/auto.d.ts.map +1 -0
- package/dist/redirects/auto.js +60 -0
- package/dist/redirects/auto.js.map +1 -0
- package/dist/redirects/fix-link.d.ts +15 -0
- package/dist/redirects/fix-link.d.ts.map +1 -0
- package/dist/redirects/fix-link.js +66 -0
- package/dist/redirects/fix-link.js.map +1 -0
- package/dist/redirects/graph.d.ts +92 -0
- package/dist/redirects/graph.d.ts.map +1 -0
- package/dist/redirects/graph.js +176 -0
- package/dist/redirects/graph.js.map +1 -0
- package/dist/redirects/index.d.ts +17 -0
- package/dist/redirects/index.d.ts.map +1 -0
- package/dist/redirects/index.js +10 -0
- package/dist/redirects/index.js.map +1 -0
- package/dist/redirects/legitimacy.d.ts +23 -0
- package/dist/redirects/legitimacy.d.ts.map +1 -0
- package/dist/redirects/legitimacy.js +64 -0
- package/dist/redirects/legitimacy.js.map +1 -0
- package/dist/redirects/recovery.d.ts +92 -0
- package/dist/redirects/recovery.d.ts.map +1 -0
- package/dist/redirects/recovery.js +210 -0
- package/dist/redirects/recovery.js.map +1 -0
- package/dist/redirects/rerank.d.ts +24 -0
- package/dist/redirects/rerank.d.ts.map +1 -0
- package/dist/redirects/rerank.js +82 -0
- package/dist/redirects/rerank.js.map +1 -0
- package/dist/redirects/suggest.d.ts +23 -0
- package/dist/redirects/suggest.d.ts.map +1 -0
- package/dist/redirects/suggest.js +80 -0
- package/dist/redirects/suggest.js.map +1 -0
- package/dist/redirects/typo.d.ts +10 -0
- package/dist/redirects/typo.d.ts.map +1 -0
- package/dist/redirects/typo.js +51 -0
- package/dist/redirects/typo.js.map +1 -0
- package/dist/redirects/validate-destination.d.ts +13 -0
- package/dist/redirects/validate-destination.d.ts.map +1 -0
- package/dist/redirects/validate-destination.js +51 -0
- package/dist/redirects/validate-destination.js.map +1 -0
- package/dist/relationships/index.d.ts +13 -0
- package/dist/relationships/index.d.ts.map +1 -0
- package/dist/relationships/index.js +12 -0
- package/dist/relationships/index.js.map +1 -0
- package/dist/scheduling/index.d.ts +45 -0
- package/dist/scheduling/index.d.ts.map +1 -0
- package/dist/scheduling/index.js +141 -0
- package/dist/scheduling/index.js.map +1 -0
- package/dist/script-tags/index.d.ts +3 -0
- package/dist/script-tags/index.d.ts.map +1 -0
- package/dist/script-tags/index.js +3 -0
- package/dist/script-tags/index.js.map +1 -0
- package/dist/script-tags/invalidate.d.ts +3 -0
- package/dist/script-tags/invalidate.d.ts.map +1 -0
- package/dist/script-tags/invalidate.js +16 -0
- package/dist/script-tags/invalidate.js.map +1 -0
- package/dist/script-tags/resolve.d.ts +41 -0
- package/dist/script-tags/resolve.d.ts.map +1 -0
- package/dist/script-tags/resolve.js +130 -0
- package/dist/script-tags/resolve.js.map +1 -0
- package/dist/search/index.d.ts +25 -0
- package/dist/search/index.d.ts.map +1 -0
- package/dist/search/index.js +189 -0
- package/dist/search/index.js.map +1 -0
- package/dist/sections/bridge.d.ts +8 -0
- package/dist/sections/bridge.d.ts.map +1 -0
- package/dist/sections/bridge.js +211 -0
- package/dist/sections/bridge.js.map +1 -0
- package/dist/sections/define.d.ts +21 -0
- package/dist/sections/define.d.ts.map +1 -0
- package/dist/sections/define.js +160 -0
- package/dist/sections/define.js.map +1 -0
- package/dist/sections/embed.d.ts +24 -0
- package/dist/sections/embed.d.ts.map +1 -0
- package/dist/sections/embed.js +78 -0
- package/dist/sections/embed.js.map +1 -0
- package/dist/sections/fields.d.ts +37 -0
- package/dist/sections/fields.d.ts.map +1 -0
- package/dist/sections/fields.js +50 -0
- package/dist/sections/fields.js.map +1 -0
- package/dist/sections/helpers.d.ts +149 -0
- package/dist/sections/helpers.d.ts.map +1 -0
- package/dist/sections/helpers.js +373 -0
- package/dist/sections/helpers.js.map +1 -0
- package/dist/sections/index.d.ts +22 -0
- package/dist/sections/index.d.ts.map +1 -0
- package/dist/sections/index.js +18 -0
- package/dist/sections/index.js.map +1 -0
- package/dist/sections/media-enrich.d.ts +45 -0
- package/dist/sections/media-enrich.d.ts.map +1 -0
- package/dist/sections/media-enrich.js +137 -0
- package/dist/sections/media-enrich.js.map +1 -0
- package/dist/sections/migrate.d.ts +42 -0
- package/dist/sections/migrate.d.ts.map +1 -0
- package/dist/sections/migrate.js +66 -0
- package/dist/sections/migrate.js.map +1 -0
- package/dist/sections/registry.d.ts +46 -0
- package/dist/sections/registry.d.ts.map +1 -0
- package/dist/sections/registry.js +598 -0
- package/dist/sections/registry.js.map +1 -0
- package/dist/sections/types.d.ts +192 -0
- package/dist/sections/types.d.ts.map +1 -0
- package/dist/sections/types.js +21 -0
- package/dist/sections/types.js.map +1 -0
- package/dist/sections/validate.d.ts +12 -0
- package/dist/sections/validate.d.ts.map +1 -0
- package/dist/sections/validate.js +186 -0
- package/dist/sections/validate.js.map +1 -0
- package/dist/sections/widget-embed.d.ts +45 -0
- package/dist/sections/widget-embed.d.ts.map +1 -0
- package/dist/sections/widget-embed.js +129 -0
- package/dist/sections/widget-embed.js.map +1 -0
- package/dist/security/access.d.ts +35 -0
- package/dist/security/access.d.ts.map +1 -0
- package/dist/security/access.js +133 -0
- package/dist/security/access.js.map +1 -0
- package/dist/security/anomaly-detection.d.ts +17 -0
- package/dist/security/anomaly-detection.d.ts.map +1 -0
- package/dist/security/anomaly-detection.js +39 -0
- package/dist/security/anomaly-detection.js.map +1 -0
- package/dist/security/api-key-enhanced.d.ts +68 -0
- package/dist/security/api-key-enhanced.d.ts.map +1 -0
- package/dist/security/api-key-enhanced.js +76 -0
- package/dist/security/api-key-enhanced.js.map +1 -0
- package/dist/security/audit.d.ts +46 -0
- package/dist/security/audit.d.ts.map +1 -0
- package/dist/security/audit.js +67 -0
- package/dist/security/audit.js.map +1 -0
- package/dist/security/breach-check.d.ts +3 -0
- package/dist/security/breach-check.d.ts.map +1 -0
- package/dist/security/breach-check.js +27 -0
- package/dist/security/breach-check.js.map +1 -0
- package/dist/security/captcha.d.ts +67 -0
- package/dist/security/captcha.d.ts.map +1 -0
- package/dist/security/captcha.js +186 -0
- package/dist/security/captcha.js.map +1 -0
- package/dist/security/center/gather.d.ts +20 -0
- package/dist/security/center/gather.d.ts.map +1 -0
- package/dist/security/center/gather.js +240 -0
- package/dist/security/center/gather.js.map +1 -0
- package/dist/security/center/index.d.ts +13 -0
- package/dist/security/center/index.d.ts.map +1 -0
- package/dist/security/center/index.js +4 -0
- package/dist/security/center/index.js.map +1 -0
- package/dist/security/center/settings.d.ts +48 -0
- package/dist/security/center/settings.d.ts.map +1 -0
- package/dist/security/center/settings.js +218 -0
- package/dist/security/center/settings.js.map +1 -0
- package/dist/security/center/status.d.ts +10 -0
- package/dist/security/center/status.d.ts.map +1 -0
- package/dist/security/center/status.js +249 -0
- package/dist/security/center/status.js.map +1 -0
- package/dist/security/center/summaries.d.ts +21 -0
- package/dist/security/center/summaries.d.ts.map +1 -0
- package/dist/security/center/summaries.js +65 -0
- package/dist/security/center/summaries.js.map +1 -0
- package/dist/security/center/types.d.ts +142 -0
- package/dist/security/center/types.d.ts.map +1 -0
- package/dist/security/center/types.js +12 -0
- package/dist/security/center/types.js.map +1 -0
- package/dist/security/client-ip.d.ts +23 -0
- package/dist/security/client-ip.d.ts.map +1 -0
- package/dist/security/client-ip.js +97 -0
- package/dist/security/client-ip.js.map +1 -0
- package/dist/security/cors.d.ts +11 -0
- package/dist/security/cors.d.ts.map +1 -0
- package/dist/security/cors.js +33 -0
- package/dist/security/cors.js.map +1 -0
- package/dist/security/csp-nonces.d.ts +5 -0
- package/dist/security/csp-nonces.d.ts.map +1 -0
- package/dist/security/csp-nonces.js +24 -0
- package/dist/security/csp-nonces.js.map +1 -0
- package/dist/security/csrf.d.ts +5 -0
- package/dist/security/csrf.d.ts.map +1 -0
- package/dist/security/csrf.js +20 -0
- package/dist/security/csrf.js.map +1 -0
- package/dist/security/encrypted-fields.d.ts +14 -0
- package/dist/security/encrypted-fields.d.ts.map +1 -0
- package/dist/security/encrypted-fields.js +94 -0
- package/dist/security/encrypted-fields.js.map +1 -0
- package/dist/security/headers.d.ts +11 -0
- package/dist/security/headers.d.ts.map +1 -0
- package/dist/security/headers.js +32 -0
- package/dist/security/headers.js.map +1 -0
- package/dist/security/index.d.ts +40 -0
- package/dist/security/index.d.ts.map +1 -0
- package/dist/security/index.js +26 -0
- package/dist/security/index.js.map +1 -0
- package/dist/security/internal-keys.d.ts +15 -0
- package/dist/security/internal-keys.d.ts.map +1 -0
- package/dist/security/internal-keys.js +33 -0
- package/dist/security/internal-keys.js.map +1 -0
- package/dist/security/ip-allowlist.d.ts +15 -0
- package/dist/security/ip-allowlist.d.ts.map +1 -0
- package/dist/security/ip-allowlist.js +141 -0
- package/dist/security/ip-allowlist.js.map +1 -0
- package/dist/security/ip-canon.d.ts +71 -0
- package/dist/security/ip-canon.d.ts.map +1 -0
- package/dist/security/ip-canon.js +352 -0
- package/dist/security/ip-canon.js.map +1 -0
- package/dist/security/middleware.d.ts +20 -0
- package/dist/security/middleware.d.ts.map +1 -0
- package/dist/security/middleware.js +45 -0
- package/dist/security/middleware.js.map +1 -0
- package/dist/security/rate-limit-policy.d.ts +40 -0
- package/dist/security/rate-limit-policy.d.ts.map +1 -0
- package/dist/security/rate-limit-policy.js +57 -0
- package/dist/security/rate-limit-policy.js.map +1 -0
- package/dist/security/rate-limit.d.ts +55 -0
- package/dist/security/rate-limit.d.ts.map +1 -0
- package/dist/security/rate-limit.js +298 -0
- package/dist/security/rate-limit.js.map +1 -0
- package/dist/security/reauth.d.ts +16 -0
- package/dist/security/reauth.d.ts.map +1 -0
- package/dist/security/reauth.js +56 -0
- package/dist/security/reauth.js.map +1 -0
- package/dist/security/redact.d.ts +12 -0
- package/dist/security/redact.d.ts.map +1 -0
- package/dist/security/redact.js +44 -0
- package/dist/security/redact.js.map +1 -0
- package/dist/security/safe-fetch.d.ts +57 -0
- package/dist/security/safe-fetch.d.ts.map +1 -0
- package/dist/security/safe-fetch.js +71 -0
- package/dist/security/safe-fetch.js.map +1 -0
- package/dist/security/sanitize-document.d.ts +14 -0
- package/dist/security/sanitize-document.d.ts.map +1 -0
- package/dist/security/sanitize-document.js +117 -0
- package/dist/security/sanitize-document.js.map +1 -0
- package/dist/security/sanitize.d.ts +13 -0
- package/dist/security/sanitize.d.ts.map +1 -0
- package/dist/security/sanitize.js +64 -0
- package/dist/security/sanitize.js.map +1 -0
- package/dist/security/secret-storage.d.ts +22 -0
- package/dist/security/secret-storage.d.ts.map +1 -0
- package/dist/security/secret-storage.js +75 -0
- package/dist/security/secret-storage.js.map +1 -0
- package/dist/security/security-txt.d.ts +12 -0
- package/dist/security/security-txt.d.ts.map +1 -0
- package/dist/security/security-txt.js +19 -0
- package/dist/security/security-txt.js.map +1 -0
- package/dist/security/session-limits.d.ts +17 -0
- package/dist/security/session-limits.d.ts.map +1 -0
- package/dist/security/session-limits.js +14 -0
- package/dist/security/session-limits.js.map +1 -0
- package/dist/security/upload.d.ts +32 -0
- package/dist/security/upload.d.ts.map +1 -0
- package/dist/security/upload.js +129 -0
- package/dist/security/upload.js.map +1 -0
- package/dist/security/webhook.d.ts +30 -0
- package/dist/security/webhook.d.ts.map +1 -0
- package/dist/security/webhook.js +133 -0
- package/dist/security/webhook.js.map +1 -0
- package/dist/seo/ai-bots.d.ts +56 -0
- package/dist/seo/ai-bots.d.ts.map +1 -0
- package/dist/seo/ai-bots.js +125 -0
- package/dist/seo/ai-bots.js.map +1 -0
- package/dist/seo/ai-crawler-log.d.ts +14 -0
- package/dist/seo/ai-crawler-log.d.ts.map +1 -0
- package/dist/seo/ai-crawler-log.js +48 -0
- package/dist/seo/ai-crawler-log.js.map +1 -0
- package/dist/seo/alt-text.d.ts +39 -0
- package/dist/seo/alt-text.d.ts.map +1 -0
- package/dist/seo/alt-text.js +67 -0
- package/dist/seo/alt-text.js.map +1 -0
- package/dist/seo/analysis.d.ts +75 -0
- package/dist/seo/analysis.d.ts.map +1 -0
- package/dist/seo/analysis.js +641 -0
- package/dist/seo/analysis.js.map +1 -0
- package/dist/seo/audit-engine.d.ts +124 -0
- package/dist/seo/audit-engine.d.ts.map +1 -0
- package/dist/seo/audit-engine.js +534 -0
- package/dist/seo/audit-engine.js.map +1 -0
- package/dist/seo/audit-runner.d.ts +84 -0
- package/dist/seo/audit-runner.d.ts.map +1 -0
- package/dist/seo/audit-runner.js +372 -0
- package/dist/seo/audit-runner.js.map +1 -0
- package/dist/seo/audit-signals.d.ts +28 -0
- package/dist/seo/audit-signals.d.ts.map +1 -0
- package/dist/seo/audit-signals.js +133 -0
- package/dist/seo/audit-signals.js.map +1 -0
- package/dist/seo/config-store.d.ts +99 -0
- package/dist/seo/config-store.d.ts.map +1 -0
- package/dist/seo/config-store.js +194 -0
- package/dist/seo/config-store.js.map +1 -0
- package/dist/seo/confirm-broken-links.d.ts +48 -0
- package/dist/seo/confirm-broken-links.d.ts.map +1 -0
- package/dist/seo/confirm-broken-links.js +135 -0
- package/dist/seo/confirm-broken-links.js.map +1 -0
- package/dist/seo/content-markdown.d.ts +26 -0
- package/dist/seo/content-markdown.d.ts.map +1 -0
- package/dist/seo/content-markdown.js +235 -0
- package/dist/seo/content-markdown.js.map +1 -0
- package/dist/seo/document-content.d.ts +18 -0
- package/dist/seo/document-content.d.ts.map +1 -0
- package/dist/seo/document-content.js +80 -0
- package/dist/seo/document-content.js.map +1 -0
- package/dist/seo/document-path.d.ts +33 -0
- package/dist/seo/document-path.d.ts.map +1 -0
- package/dist/seo/document-path.js +44 -0
- package/dist/seo/document-path.js.map +1 -0
- package/dist/seo/feeds.d.ts +44 -0
- package/dist/seo/feeds.d.ts.map +1 -0
- package/dist/seo/feeds.js +95 -0
- package/dist/seo/feeds.js.map +1 -0
- package/dist/seo/fix-brief-export.d.ts +47 -0
- package/dist/seo/fix-brief-export.d.ts.map +1 -0
- package/dist/seo/fix-brief-export.js +224 -0
- package/dist/seo/fix-brief-export.js.map +1 -0
- package/dist/seo/index-status.d.ts +56 -0
- package/dist/seo/index-status.d.ts.map +1 -0
- package/dist/seo/index-status.js +193 -0
- package/dist/seo/index-status.js.map +1 -0
- package/dist/seo/index.d.ts +68 -0
- package/dist/seo/index.d.ts.map +1 -0
- package/dist/seo/index.js +36 -0
- package/dist/seo/index.js.map +1 -0
- package/dist/seo/issue-batch-briefs.d.ts +37 -0
- package/dist/seo/issue-batch-briefs.d.ts.map +1 -0
- package/dist/seo/issue-batch-briefs.js +85 -0
- package/dist/seo/issue-batch-briefs.js.map +1 -0
- package/dist/seo/issue-content-brief.d.ts +51 -0
- package/dist/seo/issue-content-brief.d.ts.map +1 -0
- package/dist/seo/issue-content-brief.js +267 -0
- package/dist/seo/issue-content-brief.js.map +1 -0
- package/dist/seo/issue-content-research.d.ts +51 -0
- package/dist/seo/issue-content-research.d.ts.map +1 -0
- package/dist/seo/issue-content-research.js +303 -0
- package/dist/seo/issue-content-research.js.map +1 -0
- package/dist/seo/issue-fix-ai.d.ts +39 -0
- package/dist/seo/issue-fix-ai.d.ts.map +1 -0
- package/dist/seo/issue-fix-ai.js +320 -0
- package/dist/seo/issue-fix-ai.js.map +1 -0
- package/dist/seo/issue-fix-redirect.d.ts +18 -0
- package/dist/seo/issue-fix-redirect.d.ts.map +1 -0
- package/dist/seo/issue-fix-redirect.js +153 -0
- package/dist/seo/issue-fix-redirect.js.map +1 -0
- package/dist/seo/issue-fix.d.ts +72 -0
- package/dist/seo/issue-fix.d.ts.map +1 -0
- package/dist/seo/issue-fix.js +266 -0
- package/dist/seo/issue-fix.js.map +1 -0
- package/dist/seo/link-graph.d.ts +54 -0
- package/dist/seo/link-graph.d.ts.map +1 -0
- package/dist/seo/link-graph.js +0 -0
- package/dist/seo/link-graph.js.map +1 -0
- package/dist/seo/link-probe.d.ts +49 -0
- package/dist/seo/link-probe.d.ts.map +1 -0
- package/dist/seo/link-probe.js +147 -0
- package/dist/seo/link-probe.js.map +1 -0
- package/dist/seo/llms-select.d.ts +34 -0
- package/dist/seo/llms-select.d.ts.map +1 -0
- package/dist/seo/llms-select.js +67 -0
- package/dist/seo/llms-select.js.map +1 -0
- package/dist/seo/llms-txt.d.ts +40 -0
- package/dist/seo/llms-txt.d.ts.map +1 -0
- package/dist/seo/llms-txt.js +177 -0
- package/dist/seo/llms-txt.js.map +1 -0
- package/dist/seo/meta-tags.d.ts +108 -0
- package/dist/seo/meta-tags.d.ts.map +1 -0
- package/dist/seo/meta-tags.js +353 -0
- package/dist/seo/meta-tags.js.map +1 -0
- package/dist/seo/migrate-meta.d.ts +23 -0
- package/dist/seo/migrate-meta.d.ts.map +1 -0
- package/dist/seo/migrate-meta.js +114 -0
- package/dist/seo/migrate-meta.js.map +1 -0
- package/dist/seo/migrate-schema.d.ts +28 -0
- package/dist/seo/migrate-schema.d.ts.map +1 -0
- package/dist/seo/migrate-schema.js +119 -0
- package/dist/seo/migrate-schema.js.map +1 -0
- package/dist/seo/page-meta.d.ts +179 -0
- package/dist/seo/page-meta.d.ts.map +1 -0
- package/dist/seo/page-meta.js +710 -0
- package/dist/seo/page-meta.js.map +1 -0
- package/dist/seo/public-site-url.d.ts +16 -0
- package/dist/seo/public-site-url.d.ts.map +1 -0
- package/dist/seo/public-site-url.js +39 -0
- package/dist/seo/public-site-url.js.map +1 -0
- package/dist/seo/resolve-path.d.ts +22 -0
- package/dist/seo/resolve-path.d.ts.map +1 -0
- package/dist/seo/resolve-path.js +53 -0
- package/dist/seo/resolve-path.js.map +1 -0
- package/dist/seo/rewrite-public-origin.d.ts +12 -0
- package/dist/seo/rewrite-public-origin.d.ts.map +1 -0
- package/dist/seo/rewrite-public-origin.js +17 -0
- package/dist/seo/rewrite-public-origin.js.map +1 -0
- package/dist/seo/robots.d.ts +45 -0
- package/dist/seo/robots.d.ts.map +1 -0
- package/dist/seo/robots.js +90 -0
- package/dist/seo/robots.js.map +1 -0
- package/dist/seo/schema-sync.d.ts +65 -0
- package/dist/seo/schema-sync.d.ts.map +1 -0
- package/dist/seo/schema-sync.js +163 -0
- package/dist/seo/schema-sync.js.map +1 -0
- package/dist/seo/score.d.ts +132 -0
- package/dist/seo/score.d.ts.map +1 -0
- package/dist/seo/score.js +216 -0
- package/dist/seo/score.js.map +1 -0
- package/dist/seo/serving-origin.d.ts +42 -0
- package/dist/seo/serving-origin.d.ts.map +1 -0
- package/dist/seo/serving-origin.js +83 -0
- package/dist/seo/serving-origin.js.map +1 -0
- package/dist/seo/site-identity.d.ts +60 -0
- package/dist/seo/site-identity.d.ts.map +1 -0
- package/dist/seo/site-identity.js +109 -0
- package/dist/seo/site-identity.js.map +1 -0
- package/dist/seo/sitemap-notify.d.ts +123 -0
- package/dist/seo/sitemap-notify.d.ts.map +1 -0
- package/dist/seo/sitemap-notify.js +270 -0
- package/dist/seo/sitemap-notify.js.map +1 -0
- package/dist/seo/sitemap-public.d.ts +43 -0
- package/dist/seo/sitemap-public.d.ts.map +1 -0
- package/dist/seo/sitemap-public.js +213 -0
- package/dist/seo/sitemap-public.js.map +1 -0
- package/dist/seo/title-templates.d.ts +80 -0
- package/dist/seo/title-templates.d.ts.map +1 -0
- package/dist/seo/title-templates.js +175 -0
- package/dist/seo/title-templates.js.map +1 -0
- package/dist/server-site.d.ts +55 -0
- package/dist/server-site.d.ts.map +1 -0
- package/dist/server-site.js +184 -0
- package/dist/server-site.js.map +1 -0
- package/dist/setup/index.d.ts +68 -0
- package/dist/setup/index.d.ts.map +1 -0
- package/dist/setup/index.js +153 -0
- package/dist/setup/index.js.map +1 -0
- package/dist/site.d.ts +178 -0
- package/dist/site.d.ts.map +1 -0
- package/dist/site.js +222 -0
- package/dist/site.js.map +1 -0
- package/dist/storage/auto-wire.d.ts +14 -0
- package/dist/storage/auto-wire.d.ts.map +1 -0
- package/dist/storage/auto-wire.js +60 -0
- package/dist/storage/auto-wire.js.map +1 -0
- package/dist/storage/index.d.ts +21 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/dist/storage/index.js +14 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/templates/index.d.ts +16 -0
- package/dist/templates/index.d.ts.map +1 -0
- package/dist/templates/index.js +23 -0
- package/dist/templates/index.js.map +1 -0
- package/dist/upgrade/changelog.d.ts +18 -0
- package/dist/upgrade/changelog.d.ts.map +1 -0
- package/dist/upgrade/changelog.js +60 -0
- package/dist/upgrade/changelog.js.map +1 -0
- package/dist/upgrade/index.d.ts +7 -0
- package/dist/upgrade/index.d.ts.map +1 -0
- package/dist/upgrade/index.js +4 -0
- package/dist/upgrade/index.js.map +1 -0
- package/dist/upgrade/upgrade-pr.d.ts +16 -0
- package/dist/upgrade/upgrade-pr.d.ts.map +1 -0
- package/dist/upgrade/upgrade-pr.js +128 -0
- package/dist/upgrade/upgrade-pr.js.map +1 -0
- package/dist/upgrade/version-check.d.ts +59 -0
- package/dist/upgrade/version-check.d.ts.map +1 -0
- package/dist/upgrade/version-check.js +142 -0
- package/dist/upgrade/version-check.js.map +1 -0
- package/dist/validation/publish.d.ts +53 -0
- package/dist/validation/publish.d.ts.map +1 -0
- package/dist/validation/publish.js +75 -0
- package/dist/validation/publish.js.map +1 -0
- package/dist/webhooks/index.d.ts +99 -0
- package/dist/webhooks/index.d.ts.map +1 -0
- package/dist/webhooks/index.js +387 -0
- package/dist/webhooks/index.js.map +1 -0
- package/dist/webhooks/signing.d.ts +68 -0
- package/dist/webhooks/signing.d.ts.map +1 -0
- package/dist/webhooks/signing.js +98 -0
- package/dist/webhooks/signing.js.map +1 -0
- package/dist/workflow/index.d.ts +8 -0
- package/dist/workflow/index.d.ts.map +1 -0
- package/dist/workflow/index.js +56 -0
- package/dist/workflow/index.js.map +1 -0
- package/dist/workflows/index.d.ts +30 -0
- package/dist/workflows/index.d.ts.map +1 -0
- package/dist/workflows/index.js +14 -0
- package/dist/workflows/index.js.map +1 -0
- package/package.json +250 -0
- package/prisma/migrations/0001_init/migration.sql +941 -0
- package/prisma/migrations/0002_media_usage_field_path/migration.sql +11 -0
- package/prisma/migrations/0003_ai_crawler_hits/migration.sql +21 -0
- package/prisma/migrations/0004_drop_search_vector/migration.sql +4 -0
- package/prisma/migrations/0005_script_tag_delivery_controls/migration.sql +8 -0
- package/prisma/migrations/0006_totp_last_step/migration.sql +4 -0
- package/prisma/migrations/0007_workflow_fk_drop_dead_enums/migration.sql +27 -0
- package/prisma/migrations/0008_form_delivery_attempts/migration.sql +29 -0
- package/prisma/migrations/0009_webhook_secret_rotation/migration.sql +6 -0
- package/prisma/migrations/0010_media_is_decorative/migration.sql +3 -0
- package/prisma/migrations/0011_media_content_hash/migration.sql +4 -0
- package/prisma/migrations/0012_rename_to_prenta/migration.sql +89 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema.prisma +995 -0
- package/prisma/seed.ts +82 -0
- package/schema-fragments/cms-schema.prisma +526 -0
|
@@ -0,0 +1,2256 @@
|
|
|
1
|
+
import { updateDocumentSeoFields } from '../../actions.js';
|
|
2
|
+
import { logEvent } from '../../security/audit.js';
|
|
3
|
+
import { processSeoScan } from '../../cron/index.js';
|
|
4
|
+
import { isResolvedIp } from '../../security/client-ip.js';
|
|
5
|
+
import { safeFetch, SsrfBlockedError } from '../../security/safe-fetch.js';
|
|
6
|
+
import { extractOutboundUrlsFromDocumentData, isLinkProbeIssueStatus, LINK_PROBE_HEADERS, } from '../../seo/link-probe.js';
|
|
7
|
+
import { getPrentaConfig } from '../../config/runtime.js';
|
|
8
|
+
import { assertPlanFeature, hasPlanFeature } from '../../plan/features.js';
|
|
9
|
+
import { syncSchemaTypeFields } from '../../seo/schema-sync.js';
|
|
10
|
+
import { guardedDb } from '../guarded-db.js';
|
|
11
|
+
import { recordAiCrawlerFromRequest, recordAiCrawlerHit } from '../../seo/ai-crawler-log.js';
|
|
12
|
+
import { detectAiBot } from '../../seo/ai-bots.js';
|
|
13
|
+
import { json, errorResponse, internalError, asRecord, normalizeSeoPage, hasModel, formatCompactNumber, modelNotAvailable, requireAuth, buildActionContext, WRITE_ROLES, ADMIN_ROLES, requireRole, linkHealthLimiter, seoAuditLimiter, aiGenerateLimiter, checkRateLimitAsync, clientIp, enforceDocumentReadAccess, importAIPlugin, } from '../route-helpers.js';
|
|
14
|
+
// Explicit risk order. A plain `severity: 'asc'` DB sort orders the values
|
|
15
|
+
// alphabetically (critical, info, warning) which wrongly ranks `info` above
|
|
16
|
+
// `warning`. Unknown severities sort last.
|
|
17
|
+
const SEVERITY_RANK = { critical: 0, warning: 1, info: 2 };
|
|
18
|
+
function severityRank(severity) {
|
|
19
|
+
return SEVERITY_RANK[String(severity)] ?? 99;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Collect internal link targets from global navigation menus (the `navigations`
|
|
23
|
+
* collection) so the link-graph can treat nav-linked pages as non-orphaned.
|
|
24
|
+
* Best-effort: returns `[]` when the navigation plugin isn't installed or on any
|
|
25
|
+
* read error.
|
|
26
|
+
*/
|
|
27
|
+
async function gatherNavigationLinkTargets(db) {
|
|
28
|
+
try {
|
|
29
|
+
if (!hasModel(db, 'document'))
|
|
30
|
+
return [];
|
|
31
|
+
const { coercePublicNavItems } = await import('../../site.js');
|
|
32
|
+
const navDocs = await db.document
|
|
33
|
+
.findMany({
|
|
34
|
+
where: { collection: 'navigations', deletedAt: null, status: { not: 'ARCHIVED' } },
|
|
35
|
+
select: { data: true },
|
|
36
|
+
take: 50,
|
|
37
|
+
})
|
|
38
|
+
.catch(() => []);
|
|
39
|
+
const targets = [];
|
|
40
|
+
const walk = (items) => {
|
|
41
|
+
for (const item of items) {
|
|
42
|
+
if (typeof item.url === 'string' && item.url.trim())
|
|
43
|
+
targets.push(item.url);
|
|
44
|
+
if (item.children)
|
|
45
|
+
walk(item.children);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
for (const doc of navDocs) {
|
|
49
|
+
const data = doc?.data && typeof doc.data === 'object' ? doc.data : {};
|
|
50
|
+
walk(coercePublicNavItems(data.items));
|
|
51
|
+
}
|
|
52
|
+
return targets;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function planRequiredResponse(denial) {
|
|
59
|
+
return json({
|
|
60
|
+
error: denial.message,
|
|
61
|
+
code: 'plan_required',
|
|
62
|
+
requiredTier: denial.requiredTier,
|
|
63
|
+
feature: denial.feature,
|
|
64
|
+
}, 403);
|
|
65
|
+
}
|
|
66
|
+
function seoAiGenerationError(result) {
|
|
67
|
+
if (result.code === 'unavailable' || result.code === 'feature_disabled') {
|
|
68
|
+
return errorResponse(result.message, 503);
|
|
69
|
+
}
|
|
70
|
+
if (result.code === 'rate_limited' || result.code === 'budget_exceeded') {
|
|
71
|
+
return errorResponse(result.message, 429);
|
|
72
|
+
}
|
|
73
|
+
return errorResponse(result.message, 422);
|
|
74
|
+
}
|
|
75
|
+
/** Sort issues by risk (critical → warning → info), then most-recent first. */
|
|
76
|
+
export function sortIssuesBySeverity(issues) {
|
|
77
|
+
return [...issues].sort((a, b) => {
|
|
78
|
+
const rankDiff = severityRank(a.severity) - severityRank(b.severity);
|
|
79
|
+
if (rankDiff !== 0)
|
|
80
|
+
return rankDiff;
|
|
81
|
+
const at = a.lastDetectedAt ? new Date(a.lastDetectedAt).getTime() : 0;
|
|
82
|
+
const bt = b.lastDetectedAt ? new Date(b.lastDetectedAt).getTime() : 0;
|
|
83
|
+
return bt - at;
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
export function registerSeoRoutes(router) {
|
|
87
|
+
const db = guardedDb;
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// SEO routes
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
router.get('/seo/pages', async (request) => {
|
|
92
|
+
try {
|
|
93
|
+
const auth = await requireAuth(request);
|
|
94
|
+
if (auth.error)
|
|
95
|
+
return auth.error;
|
|
96
|
+
// Bound the scan: a large site can have tens of thousands of published
|
|
97
|
+
// docs, and each carries a full `data` JSONB. Default to a high cap so
|
|
98
|
+
// typical sites are unaffected; expose `?limit`/`?offset` so clients can
|
|
99
|
+
// page rather than pull everything into memory at once. A full page
|
|
100
|
+
// (data.length === limit) signals there may be more.
|
|
101
|
+
const params = new URL(request.url).searchParams;
|
|
102
|
+
const limit = Math.min(Math.max(Number(params.get('limit')) || 1000, 1), 5000);
|
|
103
|
+
const offset = Math.max(Number(params.get('offset')) || 0, 0);
|
|
104
|
+
const pages = await db().document.findMany({
|
|
105
|
+
where: { deletedAt: null, status: 'PUBLISHED' },
|
|
106
|
+
select: {
|
|
107
|
+
id: true,
|
|
108
|
+
collection: true,
|
|
109
|
+
data: true,
|
|
110
|
+
updatedAt: true,
|
|
111
|
+
structuredData: true,
|
|
112
|
+
},
|
|
113
|
+
orderBy: { updatedAt: 'desc' },
|
|
114
|
+
take: limit,
|
|
115
|
+
skip: offset,
|
|
116
|
+
});
|
|
117
|
+
return json({ data: pages.map(normalizeSeoPage), limit, offset });
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
return internalError(err);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
router.get('/seo/link-health', async (request) => {
|
|
124
|
+
try {
|
|
125
|
+
const auth = await requireAuth(request);
|
|
126
|
+
if (auth.error)
|
|
127
|
+
return auth.error;
|
|
128
|
+
// EDITOR+ only — this endpoint hits arbitrary URLs and can be expensive.
|
|
129
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
130
|
+
if (roleErr)
|
|
131
|
+
return roleErr;
|
|
132
|
+
// Tight rate limit because each call fans out to many outbound requests.
|
|
133
|
+
const ip = clientIp(request);
|
|
134
|
+
const rateKey = isResolvedIp(ip)
|
|
135
|
+
? `link-health:${ip}`
|
|
136
|
+
: `link-health-user:${auth.session.userId}`;
|
|
137
|
+
if (!(await checkRateLimitAsync(linkHealthLimiter, rateKey))) {
|
|
138
|
+
return errorResponse('Too many link-health scans. Please try again later.', 429);
|
|
139
|
+
}
|
|
140
|
+
const MAX_LINKS_PER_PAGE = 50;
|
|
141
|
+
const MAX_TOTAL_LINKS = 500;
|
|
142
|
+
const DOC_BATCH_SIZE = 100;
|
|
143
|
+
const PER_LINK_TIMEOUT_MS = 4000;
|
|
144
|
+
const CONCURRENCY = 8;
|
|
145
|
+
const siteUrl = (process.env.NEXT_PUBLIC_SITE_URL ?? '').replace(/\/+$/, '');
|
|
146
|
+
const queue = [];
|
|
147
|
+
const seenGlobal = new Set();
|
|
148
|
+
let docsScanned = 0;
|
|
149
|
+
let skip = 0;
|
|
150
|
+
scanLoop: while (queue.length < MAX_TOTAL_LINKS) {
|
|
151
|
+
const batch = await db().document.findMany({
|
|
152
|
+
where: { deletedAt: null, status: 'PUBLISHED' },
|
|
153
|
+
select: { id: true, title: true, data: true, collection: true },
|
|
154
|
+
orderBy: { updatedAt: 'desc' },
|
|
155
|
+
skip,
|
|
156
|
+
take: DOC_BATCH_SIZE,
|
|
157
|
+
});
|
|
158
|
+
if (batch.length === 0)
|
|
159
|
+
break;
|
|
160
|
+
docsScanned += batch.length;
|
|
161
|
+
skip += batch.length;
|
|
162
|
+
for (const doc of batch) {
|
|
163
|
+
const pageTitle = doc.title ?? doc.data?.title ?? doc.id;
|
|
164
|
+
// Walk unescaped string fields — never JSON.stringify (captures `\`
|
|
165
|
+
// from `\"` and produces mass false positives).
|
|
166
|
+
const urls = extractOutboundUrlsFromDocumentData(doc.data ?? {});
|
|
167
|
+
let countInPage = 0;
|
|
168
|
+
for (const clean of urls) {
|
|
169
|
+
if (seenGlobal.has(clean))
|
|
170
|
+
continue;
|
|
171
|
+
seenGlobal.add(clean);
|
|
172
|
+
if (countInPage >= MAX_LINKS_PER_PAGE)
|
|
173
|
+
break;
|
|
174
|
+
if (queue.length >= MAX_TOTAL_LINKS)
|
|
175
|
+
break scanLoop;
|
|
176
|
+
const isInternal = !!siteUrl && clean.startsWith(siteUrl);
|
|
177
|
+
queue.push({ docId: doc.id, pageTitle, clean, isInternal });
|
|
178
|
+
countInPage++;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const linkResults = [];
|
|
183
|
+
let cursor = 0;
|
|
184
|
+
const worker = async () => {
|
|
185
|
+
while (cursor < queue.length) {
|
|
186
|
+
const idx = cursor++;
|
|
187
|
+
const job = queue[idx];
|
|
188
|
+
let status = 0;
|
|
189
|
+
try {
|
|
190
|
+
// GET + browser UA: many hosts reject HEAD or non-browser agents
|
|
191
|
+
// (403/405/500) while the same URL is fine in a browser.
|
|
192
|
+
// safeFetch rejects private/loopback IPs and disables redirect
|
|
193
|
+
// following so 302->internal can't smuggle the scanner past SSRF.
|
|
194
|
+
const resp = await safeFetch(job.clean, {
|
|
195
|
+
method: 'GET',
|
|
196
|
+
headers: { ...LINK_PROBE_HEADERS },
|
|
197
|
+
timeoutMs: PER_LINK_TIMEOUT_MS,
|
|
198
|
+
});
|
|
199
|
+
status = resp.status;
|
|
200
|
+
// Cancel the body — we only need the status line.
|
|
201
|
+
try {
|
|
202
|
+
await resp.body?.cancel();
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
/* noop */
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
status = err instanceof SsrfBlockedError ? -1 : 0;
|
|
210
|
+
}
|
|
211
|
+
// 2xx/3xx = healthy; 403/405/429/999 = unverifiable (not issues).
|
|
212
|
+
if (isLinkProbeIssueStatus(status)) {
|
|
213
|
+
linkResults.push({
|
|
214
|
+
id: `${job.docId}-${idx}`,
|
|
215
|
+
page: job.pageTitle,
|
|
216
|
+
url: job.clean,
|
|
217
|
+
status,
|
|
218
|
+
type: job.isInternal ? 'internal' : 'external',
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, queue.length) }, worker));
|
|
224
|
+
return json({
|
|
225
|
+
data: {
|
|
226
|
+
truncated: queue.length >= MAX_TOTAL_LINKS,
|
|
227
|
+
docsScanned,
|
|
228
|
+
checked: queue.length,
|
|
229
|
+
issues: linkResults,
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
return internalError(err);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
router.get('/seo/links/snapshot', async (request) => {
|
|
238
|
+
try {
|
|
239
|
+
const auth = await requireAuth(request);
|
|
240
|
+
if (auth.error)
|
|
241
|
+
return auth.error;
|
|
242
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
243
|
+
if (roleErr)
|
|
244
|
+
return roleErr;
|
|
245
|
+
const { gatherAuditEntities, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
|
|
246
|
+
const { buildLinkGraphSnapshot } = await import('../../seo/link-graph.js');
|
|
247
|
+
const { getSeoOverrides, resolveSeoModuleSettings } = await import('../../seo/config-store.js');
|
|
248
|
+
const cfg = getPrentaConfig();
|
|
249
|
+
const collectionTypes = frontEndContentCollectionTypes(cfg);
|
|
250
|
+
const siteUrl = cfg?.seo?.siteUrl?.replace(/\/+$/, '') ?? '';
|
|
251
|
+
const entities = await gatherAuditEntities(db(), { collectionTypes });
|
|
252
|
+
const docRows = await db()
|
|
253
|
+
.document.findMany({
|
|
254
|
+
where: {
|
|
255
|
+
deletedAt: null,
|
|
256
|
+
status: 'PUBLISHED',
|
|
257
|
+
collection: { in: Object.keys(collectionTypes) },
|
|
258
|
+
},
|
|
259
|
+
select: { id: true, collection: true },
|
|
260
|
+
})
|
|
261
|
+
.catch(() => []);
|
|
262
|
+
const collections = Object.fromEntries(docRows.map((d) => [d.id, d.collection]));
|
|
263
|
+
let brokenLinkCount = 0;
|
|
264
|
+
if (hasModel(db(), 'seoIssue')) {
|
|
265
|
+
brokenLinkCount = await db()
|
|
266
|
+
.seoIssue.count({
|
|
267
|
+
where: {
|
|
268
|
+
status: { in: ['open', 'recurring'] },
|
|
269
|
+
type: 'broken-internal-link',
|
|
270
|
+
},
|
|
271
|
+
})
|
|
272
|
+
.catch(() => 0);
|
|
273
|
+
}
|
|
274
|
+
const overrides = await getSeoOverrides(db()).catch(() => null);
|
|
275
|
+
const moduleSettings = resolveSeoModuleSettings(overrides);
|
|
276
|
+
// Global nav/footer menus link to top-level pages from site chrome, not
|
|
277
|
+
// from document bodies. Feed those targets in so nav-only reachable pages
|
|
278
|
+
// are not mis-reported as orphaned. Best-effort: no-op when the navigation
|
|
279
|
+
// plugin isn't installed.
|
|
280
|
+
const navLinks = await gatherNavigationLinkTargets(db());
|
|
281
|
+
const snapshot = buildLinkGraphSnapshot({
|
|
282
|
+
entities,
|
|
283
|
+
siteUrl,
|
|
284
|
+
collections,
|
|
285
|
+
brokenLinkCount,
|
|
286
|
+
aiIndexLastRunAt: moduleSettings.linkAiIndexLastRunAt,
|
|
287
|
+
navLinks,
|
|
288
|
+
});
|
|
289
|
+
return json({ data: snapshot });
|
|
290
|
+
}
|
|
291
|
+
catch (err) {
|
|
292
|
+
return internalError(err, 'seo/links/snapshot');
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
router.post('/seo/links/ai-index', async (request) => {
|
|
296
|
+
try {
|
|
297
|
+
const auth = await requireAuth(request);
|
|
298
|
+
if (auth.error)
|
|
299
|
+
return auth.error;
|
|
300
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
301
|
+
if (roleErr)
|
|
302
|
+
return roleErr;
|
|
303
|
+
if (!(await checkRateLimitAsync(aiGenerateLimiter, `link-ai-index:${auth.session.userId}`))) {
|
|
304
|
+
return errorResponse('AI link index rate limit reached. Try again later.', 429);
|
|
305
|
+
}
|
|
306
|
+
const { gatherAuditEntities, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
|
|
307
|
+
const { patchSeoModuleSettings } = await import('../../seo/config-store.js');
|
|
308
|
+
const cfg = getPrentaConfig();
|
|
309
|
+
const collectionTypes = frontEndContentCollectionTypes(cfg);
|
|
310
|
+
const entities = await gatherAuditEntities(db(), { collectionTypes });
|
|
311
|
+
let aiAvailable = false;
|
|
312
|
+
try {
|
|
313
|
+
await importAIPlugin();
|
|
314
|
+
aiAvailable = true;
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
aiAvailable = false;
|
|
318
|
+
}
|
|
319
|
+
const now = new Date().toISOString();
|
|
320
|
+
await patchSeoModuleSettings(db(), { linkAiIndexLastRunAt: now }, auth.session.userId);
|
|
321
|
+
await logEvent({
|
|
322
|
+
event: 'seo_link_ai_index',
|
|
323
|
+
userId: auth.session.userId,
|
|
324
|
+
details: { entitiesIndexed: entities.length, aiAvailable },
|
|
325
|
+
});
|
|
326
|
+
return json({
|
|
327
|
+
data: {
|
|
328
|
+
indexedAt: now,
|
|
329
|
+
entitiesIndexed: entities.length,
|
|
330
|
+
aiAvailable,
|
|
331
|
+
// No standing index/embeddings are persisted here — /seo/links/suggest
|
|
332
|
+
// rebuilds the corpus on demand and works with or without this run.
|
|
333
|
+
// Keep the copy honest: this refreshes the link corpus and records the
|
|
334
|
+
// run; it does not "build" anything suggestions depend on.
|
|
335
|
+
message: aiAvailable
|
|
336
|
+
? 'Link corpus refreshed. AI link suggestions are generated on demand — run them on orphaned or under-linked pages.'
|
|
337
|
+
: 'Link corpus refreshed. Install @prenta/plugin-ai to enable semantic AI link suggestions.',
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
catch (err) {
|
|
342
|
+
return internalError(err, 'seo/links/ai-index');
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
router.post('/seo/links/suggest', async (request) => {
|
|
346
|
+
try {
|
|
347
|
+
const auth = await requireAuth(request);
|
|
348
|
+
if (auth.error)
|
|
349
|
+
return auth.error;
|
|
350
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
351
|
+
if (roleErr)
|
|
352
|
+
return roleErr;
|
|
353
|
+
if (!(await checkRateLimitAsync(aiGenerateLimiter, `link-suggest:${auth.session.userId}`))) {
|
|
354
|
+
return errorResponse('Link suggestion rate limit reached. Try again later.', 429);
|
|
355
|
+
}
|
|
356
|
+
const body = (await request.json());
|
|
357
|
+
if (!body.targetEntityId || !body.targetEntityType) {
|
|
358
|
+
return errorResponse('targetEntityId and targetEntityType are required', 400);
|
|
359
|
+
}
|
|
360
|
+
const limit = Math.max(1, Math.min(20, typeof body.limit === 'number' ? body.limit : 8));
|
|
361
|
+
const { gatherAuditEntities, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
|
|
362
|
+
const cfg = getPrentaConfig();
|
|
363
|
+
const collectionTypes = frontEndContentCollectionTypes(cfg);
|
|
364
|
+
const entities = await gatherAuditEntities(db(), { collectionTypes });
|
|
365
|
+
const target = entities.find((e) => e.entityId === body.targetEntityId);
|
|
366
|
+
if (!target)
|
|
367
|
+
return errorResponse('Target document not found in link graph', 404);
|
|
368
|
+
const sources = entities.filter((e) => e.entityId !== target.entityId);
|
|
369
|
+
const query = [target.title, target.focusKeyword, target.metaDescription]
|
|
370
|
+
.filter(Boolean)
|
|
371
|
+
.join(' — ');
|
|
372
|
+
const docRows = await db()
|
|
373
|
+
.document.findMany({
|
|
374
|
+
where: { id: { in: sources.map((s) => s.entityId) } },
|
|
375
|
+
select: { id: true, collection: true },
|
|
376
|
+
})
|
|
377
|
+
.catch(() => []);
|
|
378
|
+
const collections = Object.fromEntries(docRows.map((d) => [d.id, d.collection]));
|
|
379
|
+
let suggestions = [];
|
|
380
|
+
try {
|
|
381
|
+
const aiModule = await importAIPlugin();
|
|
382
|
+
if (typeof aiModule?.semanticSearch === 'function') {
|
|
383
|
+
const corpus = sources.map((s) => ({
|
|
384
|
+
id: s.entityId,
|
|
385
|
+
collectionSlug: collections[s.entityId] ?? 'pages',
|
|
386
|
+
title: s.title,
|
|
387
|
+
content: (s.content ?? '').slice(0, 8000),
|
|
388
|
+
}));
|
|
389
|
+
const results = await aiModule.semanticSearch(query, { limit, threshold: 0.35, corpus });
|
|
390
|
+
suggestions = results.map((r) => ({
|
|
391
|
+
sourceEntityId: r.id,
|
|
392
|
+
sourceTitle: r.title,
|
|
393
|
+
sourceUrl: sources.find((s) => s.entityId === r.id)?.url ?? '',
|
|
394
|
+
anchorText: target.title,
|
|
395
|
+
confidence: Math.round(r.score * 100) / 100,
|
|
396
|
+
excerpt: r.excerpt,
|
|
397
|
+
}));
|
|
398
|
+
}
|
|
399
|
+
else if (typeof aiModule?.suggestInternalLinks === 'function') {
|
|
400
|
+
for (const source of sources.slice(0, 40)) {
|
|
401
|
+
const found = await aiModule.suggestInternalLinks(source.content ?? '', [
|
|
402
|
+
{
|
|
403
|
+
id: target.entityId,
|
|
404
|
+
title: target.title,
|
|
405
|
+
url: target.url,
|
|
406
|
+
keywords: target.focusKeyword ? [target.focusKeyword] : [],
|
|
407
|
+
},
|
|
408
|
+
]);
|
|
409
|
+
for (const f of found.slice(0, 2)) {
|
|
410
|
+
suggestions.push({
|
|
411
|
+
sourceEntityId: source.entityId,
|
|
412
|
+
sourceTitle: source.title,
|
|
413
|
+
sourceUrl: source.url,
|
|
414
|
+
anchorText: f.anchorText,
|
|
415
|
+
confidence: f.confidence,
|
|
416
|
+
excerpt: (source.content ?? '').slice(f.position.start, f.position.end + 40),
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
suggestions.sort((a, b) => b.confidence - a.confidence);
|
|
421
|
+
suggestions = suggestions.slice(0, limit);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
catch {
|
|
425
|
+
suggestions = [];
|
|
426
|
+
}
|
|
427
|
+
return json({
|
|
428
|
+
data: {
|
|
429
|
+
target: { id: target.entityId, title: target.title, url: target.url },
|
|
430
|
+
suggestions,
|
|
431
|
+
},
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
catch (err) {
|
|
435
|
+
return internalError(err, 'seo/links/suggest');
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
router.post('/seo/scan', async (request) => {
|
|
439
|
+
try {
|
|
440
|
+
const auth = await requireAuth(request);
|
|
441
|
+
if (auth.error)
|
|
442
|
+
return auth.error;
|
|
443
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
444
|
+
if (roleErr)
|
|
445
|
+
return roleErr;
|
|
446
|
+
// Expensive: loads + regex-scans every published doc. Rate-limit it (same
|
|
447
|
+
// budget as the full audit) so it can't be hammered into an OOM/CPU spike.
|
|
448
|
+
const scanIp = clientIp(request);
|
|
449
|
+
const scanKey = isResolvedIp(scanIp)
|
|
450
|
+
? `seo-scan:${scanIp}`
|
|
451
|
+
: `seo-scan-user:${auth.session.userId}`;
|
|
452
|
+
if (!(await checkRateLimitAsync(seoAuditLimiter, scanKey))) {
|
|
453
|
+
return errorResponse('Too many scans. Please try again later.', 429);
|
|
454
|
+
}
|
|
455
|
+
// Scope the scan to configured front-end content collections so structural
|
|
456
|
+
// (navigation/footer) and benchmark documents are never reported. Shares
|
|
457
|
+
// the exact scan logic with the cron path via processSeoScan.
|
|
458
|
+
const { frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
|
|
459
|
+
const allowedCollections = Object.keys(frontEndContentCollectionTypes(getPrentaConfig()));
|
|
460
|
+
const result = await processSeoScan(db(), { allowedCollections, maxDocuments: 5000 });
|
|
461
|
+
return json({ data: result });
|
|
462
|
+
}
|
|
463
|
+
catch (err) {
|
|
464
|
+
return internalError(err);
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
// ---------------------------------------------------------------------------
|
|
468
|
+
// SEO Operations Center — audit engine, issues, overview, content records
|
|
469
|
+
// ---------------------------------------------------------------------------
|
|
470
|
+
function seoIssueToApi(i) {
|
|
471
|
+
return {
|
|
472
|
+
id: i.id,
|
|
473
|
+
auditRunId: i.auditRunId ?? null,
|
|
474
|
+
entityType: i.entityType ?? null,
|
|
475
|
+
entityId: i.entityId ?? null,
|
|
476
|
+
entityTitle: i.entityTitle ?? null,
|
|
477
|
+
url: i.url ?? null,
|
|
478
|
+
severity: i.severity,
|
|
479
|
+
category: i.category,
|
|
480
|
+
type: i.type,
|
|
481
|
+
title: i.title,
|
|
482
|
+
description: i.description ?? null,
|
|
483
|
+
impact: i.impact ?? null,
|
|
484
|
+
recommendation: i.recommendation ?? null,
|
|
485
|
+
status: i.status,
|
|
486
|
+
fixable: i.fixable ?? false,
|
|
487
|
+
fixActionType: i.fixActionType ?? null,
|
|
488
|
+
relatedEntityIds: Array.isArray(i.relatedEntityIds) ? i.relatedEntityIds : [],
|
|
489
|
+
source: i.source ?? 'audit',
|
|
490
|
+
firstDetectedAt: i.firstDetectedAt ?? null,
|
|
491
|
+
lastDetectedAt: i.lastDetectedAt ?? null,
|
|
492
|
+
resolvedAt: i.resolvedAt ?? null,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Compute the set of front-end content collections the caller may read.
|
|
497
|
+
* Mirrors the per-document `enforceDocumentReadAccess` check the sibling
|
|
498
|
+
* `/seo/analysis|readability|…` endpoints run, but resolved once for the
|
|
499
|
+
* collection list so the aggregate `/seo/content` view can never surface SEO
|
|
500
|
+
* fields for a collection the caller's scope/role forbids.
|
|
501
|
+
*/
|
|
502
|
+
async function readableSeoCollections(session) {
|
|
503
|
+
const { frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
|
|
504
|
+
const collectionTypes = frontEndContentCollectionTypes(getPrentaConfig());
|
|
505
|
+
const allowed = new Set();
|
|
506
|
+
for (const slug of Object.keys(collectionTypes)) {
|
|
507
|
+
const err = await enforceDocumentReadAccess(session, slug);
|
|
508
|
+
if (!err)
|
|
509
|
+
allowed.add(slug);
|
|
510
|
+
}
|
|
511
|
+
return allowed;
|
|
512
|
+
}
|
|
513
|
+
async function buildSeoContentRecords(allowedCollections) {
|
|
514
|
+
const { gatherAuditEntities, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
|
|
515
|
+
const { applySeoOverrides, getSeoOverrides } = await import('../../seo/config-store.js');
|
|
516
|
+
const { calculatePageSeoScore } = await import('../../seo/score.js');
|
|
517
|
+
const allTypes = frontEndContentCollectionTypes(getPrentaConfig());
|
|
518
|
+
// When a readable-collection allow-list is supplied, scope the gather to it
|
|
519
|
+
// so records for forbidden collections are never even built.
|
|
520
|
+
const collectionTypes = allowedCollections
|
|
521
|
+
? Object.fromEntries(Object.entries(allTypes).filter(([slug]) => allowedCollections.has(slug)))
|
|
522
|
+
: allTypes;
|
|
523
|
+
const overrides = await getSeoOverrides(db()).catch(() => null);
|
|
524
|
+
const siteSeo = applySeoOverrides(getPrentaConfig(), overrides)?.seo;
|
|
525
|
+
const entities = await gatherAuditEntities(db(), { collectionTypes });
|
|
526
|
+
return entities.map((e) => {
|
|
527
|
+
const score = calculatePageSeoScore(e);
|
|
528
|
+
return {
|
|
529
|
+
id: `${e.entityType}:${e.entityId}`,
|
|
530
|
+
entityType: e.entityType,
|
|
531
|
+
entityId: e.entityId,
|
|
532
|
+
title: e.title,
|
|
533
|
+
url: e.url,
|
|
534
|
+
canonicalUrl: e.canonicalUrl ?? null,
|
|
535
|
+
/** Rendered public title (after site title template). */
|
|
536
|
+
metaTitle: e.metaTitle ?? null,
|
|
537
|
+
/** Stored page-specific portion (`%page%` token). */
|
|
538
|
+
metaTitlePage: e.metaTitlePage ?? null,
|
|
539
|
+
siteName: siteSeo?.siteName ?? null,
|
|
540
|
+
titleTemplate: siteSeo?.titleTemplate ?? null,
|
|
541
|
+
metaDescription: e.metaDescription ?? null,
|
|
542
|
+
focusKeyword: e.focusKeyword ?? null,
|
|
543
|
+
keyTakeaway: e.keyTakeaway ?? null,
|
|
544
|
+
ogTitle: e.ogTitle ?? null,
|
|
545
|
+
ogDescription: e.ogDescription ?? null,
|
|
546
|
+
ogImageId: null,
|
|
547
|
+
ogImage: e.ogImage ?? null,
|
|
548
|
+
twitterTitle: null,
|
|
549
|
+
twitterDescription: null,
|
|
550
|
+
twitterImageId: null,
|
|
551
|
+
noindex: e.noindex ?? false,
|
|
552
|
+
nofollow: false,
|
|
553
|
+
structuredDataType: e.structuredDataType ?? null,
|
|
554
|
+
structuredData: null,
|
|
555
|
+
// llms.txt curation defaults; the single-record GET overlays the real
|
|
556
|
+
// stored values (the list endpoint doesn't surface these).
|
|
557
|
+
llmsInclude: 'AUTO',
|
|
558
|
+
llmsDescription: null,
|
|
559
|
+
llmsSection: null,
|
|
560
|
+
llmsOptional: false,
|
|
561
|
+
seoScore: score.score,
|
|
562
|
+
seoGrade: score.grade,
|
|
563
|
+
updatedAt: null,
|
|
564
|
+
};
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
// Demo/seed search-performance rows live in a hidden collection so they can
|
|
568
|
+
// be inserted by the dev seed script and never ship to npm. In production
|
|
569
|
+
// with a connected Search Console these would come from the GSC API.
|
|
570
|
+
const SEO_PERF_COLLECTION = '__seo_perf__';
|
|
571
|
+
async function loadSearchPerformance(limit = 10) {
|
|
572
|
+
try {
|
|
573
|
+
const docs = await db().document.findMany({
|
|
574
|
+
where: { collection: SEO_PERF_COLLECTION, deletedAt: null },
|
|
575
|
+
select: { data: true },
|
|
576
|
+
});
|
|
577
|
+
const rows = docs
|
|
578
|
+
.map((d) => {
|
|
579
|
+
const data = asRecord(d.data);
|
|
580
|
+
return {
|
|
581
|
+
url: String(data.url ?? ''),
|
|
582
|
+
title: String(data.title ?? ''),
|
|
583
|
+
impressions: Number(data.impressions ?? 0),
|
|
584
|
+
clicks: Number(data.clicks ?? 0),
|
|
585
|
+
ctr: Number(data.ctr ?? 0),
|
|
586
|
+
averagePosition: Number(data.averagePosition ?? 0),
|
|
587
|
+
};
|
|
588
|
+
})
|
|
589
|
+
.filter((r) => r.url);
|
|
590
|
+
rows.sort((a, b) => b.impressions - a.impressions);
|
|
591
|
+
return rows.slice(0, limit);
|
|
592
|
+
}
|
|
593
|
+
catch {
|
|
594
|
+
return [];
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
router.post('/seo/audit', async (request) => {
|
|
598
|
+
try {
|
|
599
|
+
const auth = await requireAuth(request);
|
|
600
|
+
if (auth.error)
|
|
601
|
+
return auth.error;
|
|
602
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
603
|
+
if (roleErr)
|
|
604
|
+
return roleErr;
|
|
605
|
+
if (!hasModel(db(), 'seoAuditRun') || !hasModel(db(), 'seoIssue')) {
|
|
606
|
+
return modelNotAvailable('SeoAuditRun');
|
|
607
|
+
}
|
|
608
|
+
const ip = clientIp(request);
|
|
609
|
+
const rateKey = isResolvedIp(ip) ? `seo-audit:${ip}` : `seo-audit-user:${auth.session.userId}`;
|
|
610
|
+
if (!(await checkRateLimitAsync(seoAuditLimiter, rateKey))) {
|
|
611
|
+
return errorResponse('Too many audits. Please try again later.', 429);
|
|
612
|
+
}
|
|
613
|
+
const body = (await request.json().catch(() => ({})));
|
|
614
|
+
const scope = body?.scope ?? 'full';
|
|
615
|
+
const { runAndPersistAudit, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
|
|
616
|
+
const run = await runAndPersistAudit(db(), {
|
|
617
|
+
scope,
|
|
618
|
+
startedById: auth.session.userId,
|
|
619
|
+
collectionTypes: frontEndContentCollectionTypes(getPrentaConfig()),
|
|
620
|
+
});
|
|
621
|
+
try {
|
|
622
|
+
await logEvent({
|
|
623
|
+
event: 'settings_changed',
|
|
624
|
+
userId: auth.session.userId,
|
|
625
|
+
details: { action: 'seo_audit', auditRunId: run.id, scope },
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
catch {
|
|
629
|
+
/* audit-log failures never block */
|
|
630
|
+
}
|
|
631
|
+
return json({ data: run });
|
|
632
|
+
}
|
|
633
|
+
catch (err) {
|
|
634
|
+
return internalError(err, 'seo/audit');
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
router.get('/seo/audits', async (request) => {
|
|
638
|
+
try {
|
|
639
|
+
const auth = await requireAuth(request);
|
|
640
|
+
if (auth.error)
|
|
641
|
+
return auth.error;
|
|
642
|
+
if (!hasModel(db(), 'seoAuditRun'))
|
|
643
|
+
return json({ data: { runs: [] } });
|
|
644
|
+
const runs = await db().seoAuditRun.findMany({
|
|
645
|
+
orderBy: { startedAt: 'desc' },
|
|
646
|
+
take: 50,
|
|
647
|
+
});
|
|
648
|
+
return json({ data: { runs } });
|
|
649
|
+
}
|
|
650
|
+
catch (err) {
|
|
651
|
+
return internalError(err, 'seo/audits');
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
router.get('/seo/audits/:id', async (request, params) => {
|
|
655
|
+
try {
|
|
656
|
+
const auth = await requireAuth(request);
|
|
657
|
+
if (auth.error)
|
|
658
|
+
return auth.error;
|
|
659
|
+
if (!hasModel(db(), 'seoAuditRun'))
|
|
660
|
+
return modelNotAvailable('SeoAuditRun');
|
|
661
|
+
const run = await db().seoAuditRun.findUnique({ where: { id: params.id } });
|
|
662
|
+
if (!run)
|
|
663
|
+
return errorResponse('Audit run not found', 404);
|
|
664
|
+
return json({ data: run });
|
|
665
|
+
}
|
|
666
|
+
catch (err) {
|
|
667
|
+
return internalError(err, 'seo/audits/:id');
|
|
668
|
+
}
|
|
669
|
+
});
|
|
670
|
+
router.get('/seo/issues', async (request) => {
|
|
671
|
+
try {
|
|
672
|
+
const auth = await requireAuth(request);
|
|
673
|
+
if (auth.error)
|
|
674
|
+
return auth.error;
|
|
675
|
+
if (!hasModel(db(), 'seoIssue'))
|
|
676
|
+
return json({ data: { issues: [] } });
|
|
677
|
+
const url = new URL(request.url);
|
|
678
|
+
const where = {};
|
|
679
|
+
const severity = url.searchParams.get('severity');
|
|
680
|
+
const category = url.searchParams.get('category');
|
|
681
|
+
const status = url.searchParams.get('status');
|
|
682
|
+
const auditRunId = url.searchParams.get('auditRunId');
|
|
683
|
+
if (severity)
|
|
684
|
+
where.severity = severity;
|
|
685
|
+
if (category)
|
|
686
|
+
where.category = category;
|
|
687
|
+
if (status)
|
|
688
|
+
where.status = status;
|
|
689
|
+
else
|
|
690
|
+
where.status = { in: ['open', 'recurring'] };
|
|
691
|
+
if (auditRunId)
|
|
692
|
+
where.auditRunId = auditRunId;
|
|
693
|
+
const issues = await db().seoIssue.findMany({
|
|
694
|
+
where,
|
|
695
|
+
orderBy: { lastDetectedAt: 'desc' },
|
|
696
|
+
take: 500,
|
|
697
|
+
});
|
|
698
|
+
return json({ data: { issues: sortIssuesBySeverity(issues).map(seoIssueToApi) } });
|
|
699
|
+
}
|
|
700
|
+
catch (err) {
|
|
701
|
+
return internalError(err, 'seo/issues');
|
|
702
|
+
}
|
|
703
|
+
});
|
|
704
|
+
router.get('/seo/issues/export-brief', async (request) => {
|
|
705
|
+
try {
|
|
706
|
+
const auth = await requireAuth(request);
|
|
707
|
+
if (auth.error)
|
|
708
|
+
return auth.error;
|
|
709
|
+
if (!hasModel(db(), 'seoIssue')) {
|
|
710
|
+
const { buildSeoFixBriefMarkdown, fixBriefFilename } = await import('../../seo/fix-brief-export.js');
|
|
711
|
+
return json({
|
|
712
|
+
data: {
|
|
713
|
+
markdown: buildSeoFixBriefMarkdown({ issues: [], manualOnly: true }),
|
|
714
|
+
filename: fixBriefFilename(),
|
|
715
|
+
issueCount: 0,
|
|
716
|
+
},
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
const url = new URL(request.url);
|
|
720
|
+
const severity = url.searchParams.get('severity');
|
|
721
|
+
const status = url.searchParams.get('status');
|
|
722
|
+
const auditRunId = url.searchParams.get('auditRunId');
|
|
723
|
+
const manualOnly = url.searchParams.get('manualOnly') !== 'false';
|
|
724
|
+
const includeContentBriefs = url.searchParams.get('includeContentBriefs') !== 'false';
|
|
725
|
+
const includeContentResearch = url.searchParams.get('includeContentResearch') !== 'false';
|
|
726
|
+
const where = {};
|
|
727
|
+
if (severity)
|
|
728
|
+
where.severity = severity;
|
|
729
|
+
if (status)
|
|
730
|
+
where.status = status;
|
|
731
|
+
else
|
|
732
|
+
where.status = { in: ['open', 'recurring'] };
|
|
733
|
+
if (auditRunId)
|
|
734
|
+
where.auditRunId = auditRunId;
|
|
735
|
+
const issues = sortIssuesBySeverity(await db().seoIssue.findMany({
|
|
736
|
+
where,
|
|
737
|
+
orderBy: { lastDetectedAt: 'desc' },
|
|
738
|
+
take: 500,
|
|
739
|
+
}));
|
|
740
|
+
let runAuditDate = null;
|
|
741
|
+
let runId = auditRunId;
|
|
742
|
+
if (auditRunId && hasModel(db(), 'seoAuditRun')) {
|
|
743
|
+
const run = await db().seoAuditRun.findUnique({ where: { id: auditRunId } });
|
|
744
|
+
runAuditDate = run?.finishedAt ?? run?.startedAt ?? null;
|
|
745
|
+
}
|
|
746
|
+
else if (hasModel(db(), 'seoAuditRun')) {
|
|
747
|
+
const lastRun = await db().seoAuditRun.findFirst({
|
|
748
|
+
where: { status: 'COMPLETED' },
|
|
749
|
+
orderBy: { startedAt: 'desc' },
|
|
750
|
+
});
|
|
751
|
+
runId = lastRun?.id ?? null;
|
|
752
|
+
runAuditDate = lastRun?.finishedAt ?? lastRun?.startedAt ?? null;
|
|
753
|
+
}
|
|
754
|
+
const { applySeoOverrides, getSeoOverrides } = await import('../../seo/config-store.js');
|
|
755
|
+
const overrides = await getSeoOverrides(db()).catch(() => null);
|
|
756
|
+
const mergedConfig = applySeoOverrides(getPrentaConfig(), overrides);
|
|
757
|
+
const siteSeo = mergedConfig?.seo;
|
|
758
|
+
const { buildSeoFixBriefMarkdown, filterFixBriefIssues, fixBriefFilename } = await import('../../seo/fix-brief-export.js');
|
|
759
|
+
const entityIds = [
|
|
760
|
+
...new Set(issues
|
|
761
|
+
.map((i) => i.entityId)
|
|
762
|
+
.filter((id) => typeof id === 'string' && id.length > 0)),
|
|
763
|
+
];
|
|
764
|
+
const updatedAtByEntity = new Map();
|
|
765
|
+
if ((includeContentBriefs || includeContentResearch) && entityIds.length > 0) {
|
|
766
|
+
const docs = await db().document.findMany({
|
|
767
|
+
where: { id: { in: entityIds }, deletedAt: null },
|
|
768
|
+
select: { id: true, updatedAt: true },
|
|
769
|
+
});
|
|
770
|
+
for (const doc of docs) {
|
|
771
|
+
updatedAtByEntity.set(doc.id, doc.updatedAt instanceof Date ? doc.updatedAt : new Date(doc.updatedAt));
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
const { contentBriefFromMetadata } = await import('../../seo/issue-content-brief.js');
|
|
775
|
+
const { contentResearchFromMetadata } = await import('../../seo/issue-content-research.js');
|
|
776
|
+
const briefIssues = issues.map((i) => {
|
|
777
|
+
let contentBrief = null;
|
|
778
|
+
let contentResearch = null;
|
|
779
|
+
if (i.entityId) {
|
|
780
|
+
const updatedAt = updatedAtByEntity.get(i.entityId);
|
|
781
|
+
if (updatedAt) {
|
|
782
|
+
if (includeContentBriefs) {
|
|
783
|
+
contentBrief = contentBriefFromMetadata(i.metadata, i.type, i.entityId, updatedAt);
|
|
784
|
+
}
|
|
785
|
+
if (includeContentResearch) {
|
|
786
|
+
contentResearch = contentResearchFromMetadata(i.metadata, i.type, i.entityId, updatedAt);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
return {
|
|
791
|
+
id: i.id,
|
|
792
|
+
type: i.type,
|
|
793
|
+
title: i.title,
|
|
794
|
+
description: i.description ?? null,
|
|
795
|
+
impact: i.impact ?? null,
|
|
796
|
+
recommendation: i.recommendation ?? null,
|
|
797
|
+
severity: i.severity,
|
|
798
|
+
category: i.category,
|
|
799
|
+
status: i.status,
|
|
800
|
+
entityType: i.entityType ?? null,
|
|
801
|
+
entityId: i.entityId ?? null,
|
|
802
|
+
entityTitle: i.entityTitle ?? null,
|
|
803
|
+
url: i.url ?? null,
|
|
804
|
+
fixActionType: i.fixActionType ?? null,
|
|
805
|
+
fixable: i.fixable ?? false,
|
|
806
|
+
contentBrief,
|
|
807
|
+
contentResearch,
|
|
808
|
+
};
|
|
809
|
+
});
|
|
810
|
+
const markdown = buildSeoFixBriefMarkdown({
|
|
811
|
+
siteName: siteSeo?.siteName ?? null,
|
|
812
|
+
siteUrl: siteSeo?.siteUrl ?? null,
|
|
813
|
+
auditRunId: runId,
|
|
814
|
+
auditDate: runAuditDate,
|
|
815
|
+
issues: briefIssues,
|
|
816
|
+
manualOnly,
|
|
817
|
+
});
|
|
818
|
+
const issueCount = filterFixBriefIssues(briefIssues, manualOnly).length;
|
|
819
|
+
return json({
|
|
820
|
+
data: {
|
|
821
|
+
markdown,
|
|
822
|
+
filename: fixBriefFilename(siteSeo?.siteName, runAuditDate ?? undefined),
|
|
823
|
+
issueCount,
|
|
824
|
+
},
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
catch (err) {
|
|
828
|
+
return internalError(err, 'seo/issues/export-brief');
|
|
829
|
+
}
|
|
830
|
+
});
|
|
831
|
+
router.post('/seo/issues/batch-content-briefs', async (request) => {
|
|
832
|
+
try {
|
|
833
|
+
const auth = await requireAuth(request);
|
|
834
|
+
if (auth.error)
|
|
835
|
+
return auth.error;
|
|
836
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
837
|
+
if (roleErr)
|
|
838
|
+
return roleErr;
|
|
839
|
+
const batchPlanErr = assertPlanFeature('seo.batchContentBriefs');
|
|
840
|
+
if (batchPlanErr)
|
|
841
|
+
return planRequiredResponse(batchPlanErr);
|
|
842
|
+
if (!hasModel(db(), 'seoIssue'))
|
|
843
|
+
return modelNotAvailable('SeoIssue');
|
|
844
|
+
if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-seo-batch-briefs:${auth.session.userId}`))) {
|
|
845
|
+
return errorResponse('AI rate limit reached. Try again in an hour.', 429);
|
|
846
|
+
}
|
|
847
|
+
const body = (await request.json().catch(() => ({})));
|
|
848
|
+
const severity = typeof body?.severity === 'string' ? body.severity : null;
|
|
849
|
+
const status = typeof body?.status === 'string' ? body.status : null;
|
|
850
|
+
const skipExisting = body?.skipExisting !== false;
|
|
851
|
+
const includeResearch = body?.includeResearch === true;
|
|
852
|
+
const competitorUrls = Array.isArray(body?.competitorUrls)
|
|
853
|
+
? body.competitorUrls.filter((u) => typeof u === 'string')
|
|
854
|
+
: undefined;
|
|
855
|
+
const issueIds = Array.isArray(body?.issueIds)
|
|
856
|
+
? body.issueIds.filter((id) => typeof id === 'string' && id.length > 0)
|
|
857
|
+
: null;
|
|
858
|
+
const where = {};
|
|
859
|
+
if (severity)
|
|
860
|
+
where.severity = severity;
|
|
861
|
+
if (status)
|
|
862
|
+
where.status = status;
|
|
863
|
+
else
|
|
864
|
+
where.status = { in: ['open', 'recurring'] };
|
|
865
|
+
let issues = await db().seoIssue.findMany({
|
|
866
|
+
where,
|
|
867
|
+
orderBy: { lastDetectedAt: 'desc' },
|
|
868
|
+
take: 500,
|
|
869
|
+
});
|
|
870
|
+
if (issueIds?.length) {
|
|
871
|
+
const idSet = new Set(issueIds);
|
|
872
|
+
issues = issues.filter((i) => idSet.has(i.id));
|
|
873
|
+
}
|
|
874
|
+
const { supportsContentBrief } = await import('../../seo/issue-content-brief.js');
|
|
875
|
+
const eligible = issues.filter((i) => supportsContentBrief(i.type));
|
|
876
|
+
const entityIds = [
|
|
877
|
+
...new Set(eligible
|
|
878
|
+
.map((i) => i.entityId)
|
|
879
|
+
.filter((id) => typeof id === 'string' && id.length > 0)),
|
|
880
|
+
];
|
|
881
|
+
const { documentToAuditEntity, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
|
|
882
|
+
const { applySeoOverrides, getSeoOverrides } = await import('../../seo/config-store.js');
|
|
883
|
+
const collectionTypes = frontEndContentCollectionTypes(getPrentaConfig());
|
|
884
|
+
const overrides = await getSeoOverrides(db()).catch(() => null);
|
|
885
|
+
const mergedConfig = applySeoOverrides(getPrentaConfig(), overrides);
|
|
886
|
+
const contexts = new Map();
|
|
887
|
+
if (entityIds.length > 0) {
|
|
888
|
+
const docs = await db().document.findMany({
|
|
889
|
+
where: { id: { in: entityIds }, deletedAt: null },
|
|
890
|
+
select: {
|
|
891
|
+
id: true,
|
|
892
|
+
collection: true,
|
|
893
|
+
title: true,
|
|
894
|
+
slug: true,
|
|
895
|
+
data: true,
|
|
896
|
+
structuredData: true,
|
|
897
|
+
plainText: true,
|
|
898
|
+
publishedAt: true,
|
|
899
|
+
updatedAt: true,
|
|
900
|
+
},
|
|
901
|
+
});
|
|
902
|
+
for (const doc of docs) {
|
|
903
|
+
contexts.set(doc.id, {
|
|
904
|
+
collection: doc.collection,
|
|
905
|
+
entity: documentToAuditEntity(doc, collectionTypes, mergedConfig?.seo),
|
|
906
|
+
updatedAt: doc.updatedAt instanceof Date ? doc.updatedAt : new Date(doc.updatedAt),
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
const { batchGenerateContentBriefs } = await import('../../seo/issue-batch-briefs.js');
|
|
911
|
+
const result = await batchGenerateContentBriefs(eligible.map((i) => ({
|
|
912
|
+
id: i.id,
|
|
913
|
+
type: i.type,
|
|
914
|
+
title: i.title,
|
|
915
|
+
description: i.description,
|
|
916
|
+
recommendation: i.recommendation,
|
|
917
|
+
entityId: i.entityId,
|
|
918
|
+
entityType: i.entityType,
|
|
919
|
+
metadata: i.metadata,
|
|
920
|
+
})), contexts, { db: db(), userId: auth.session.userId, collection: 'pages' }, { skipExisting, includeResearch, competitorUrls });
|
|
921
|
+
return json({ data: result });
|
|
922
|
+
}
|
|
923
|
+
catch (err) {
|
|
924
|
+
return internalError(err, 'seo/issues/batch-content-briefs');
|
|
925
|
+
}
|
|
926
|
+
});
|
|
927
|
+
router.get('/seo/issues/:id/content-brief', async (request, params) => {
|
|
928
|
+
try {
|
|
929
|
+
const auth = await requireAuth(request);
|
|
930
|
+
if (auth.error)
|
|
931
|
+
return auth.error;
|
|
932
|
+
if (!hasModel(db(), 'seoIssue'))
|
|
933
|
+
return modelNotAvailable('SeoIssue');
|
|
934
|
+
const issue = await db().seoIssue.findUnique({ where: { id: params.id } });
|
|
935
|
+
if (!issue)
|
|
936
|
+
return errorResponse('Issue not found', 404);
|
|
937
|
+
const { supportsContentBrief, contentBriefFromMetadata } = await import('../../seo/issue-content-brief.js');
|
|
938
|
+
if (!supportsContentBrief(issue.type)) {
|
|
939
|
+
return json({ data: { supported: false, brief: null, stale: false } });
|
|
940
|
+
}
|
|
941
|
+
if (!issue.entityId) {
|
|
942
|
+
return json({ data: { supported: true, brief: null, stale: false } });
|
|
943
|
+
}
|
|
944
|
+
const loaded = await loadIssueFixContext(issue);
|
|
945
|
+
if ('error' in loaded)
|
|
946
|
+
return loaded.error;
|
|
947
|
+
const brief = contentBriefFromMetadata(issue.metadata, issue.type, issue.entityId, loaded.updatedAt);
|
|
948
|
+
const raw = asRecord(issue.metadata)?.contentBrief;
|
|
949
|
+
const stale = !!raw &&
|
|
950
|
+
typeof raw === 'object' &&
|
|
951
|
+
!Array.isArray(raw) &&
|
|
952
|
+
typeof raw.fingerprint === 'string' &&
|
|
953
|
+
!brief;
|
|
954
|
+
return json({ data: { supported: true, brief, stale } });
|
|
955
|
+
}
|
|
956
|
+
catch (err) {
|
|
957
|
+
return internalError(err, 'seo/issues/:id/content-brief');
|
|
958
|
+
}
|
|
959
|
+
});
|
|
960
|
+
router.post('/seo/issues/:id/content-brief', async (request, params) => {
|
|
961
|
+
try {
|
|
962
|
+
const auth = await requireAuth(request);
|
|
963
|
+
if (auth.error)
|
|
964
|
+
return auth.error;
|
|
965
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
966
|
+
if (roleErr)
|
|
967
|
+
return roleErr;
|
|
968
|
+
const planErr = assertPlanFeature('seo.contentBrief');
|
|
969
|
+
if (planErr)
|
|
970
|
+
return planRequiredResponse(planErr);
|
|
971
|
+
if (!hasModel(db(), 'seoIssue'))
|
|
972
|
+
return modelNotAvailable('SeoIssue');
|
|
973
|
+
if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-seo-content-brief:${auth.session.userId}`))) {
|
|
974
|
+
return errorResponse('AI rate limit reached. Try again in an hour.', 429);
|
|
975
|
+
}
|
|
976
|
+
const issue = await db().seoIssue.findUnique({ where: { id: params.id } });
|
|
977
|
+
if (!issue)
|
|
978
|
+
return errorResponse('Issue not found', 404);
|
|
979
|
+
const loaded = await loadIssueFixContext(issue);
|
|
980
|
+
if ('error' in loaded)
|
|
981
|
+
return loaded.error;
|
|
982
|
+
const { generateContentBrief, contentBriefToMetadata, supportsContentBrief } = await import('../../seo/issue-content-brief.js');
|
|
983
|
+
if (!supportsContentBrief(issue.type)) {
|
|
984
|
+
return errorResponse('This issue type does not support content briefs.', 422);
|
|
985
|
+
}
|
|
986
|
+
const result = await generateContentBrief({
|
|
987
|
+
id: issue.id,
|
|
988
|
+
type: issue.type,
|
|
989
|
+
title: issue.title,
|
|
990
|
+
description: issue.description,
|
|
991
|
+
recommendation: issue.recommendation,
|
|
992
|
+
entityId: issue.entityId,
|
|
993
|
+
}, { entity: loaded.entity, updatedAt: loaded.updatedAt }, { db: db(), userId: auth.session.userId, collection: loaded.collection });
|
|
994
|
+
if (!result.ok) {
|
|
995
|
+
return seoAiGenerationError(result);
|
|
996
|
+
}
|
|
997
|
+
const existingMeta = asRecord(issue.metadata);
|
|
998
|
+
await db().seoIssue.update({
|
|
999
|
+
where: { id: issue.id },
|
|
1000
|
+
data: { metadata: { ...existingMeta, ...contentBriefToMetadata(result.brief) } },
|
|
1001
|
+
});
|
|
1002
|
+
return json({ data: { brief: result.brief } });
|
|
1003
|
+
}
|
|
1004
|
+
catch (err) {
|
|
1005
|
+
return internalError(err, 'seo/issues/:id/content-brief');
|
|
1006
|
+
}
|
|
1007
|
+
});
|
|
1008
|
+
router.get('/seo/issues/:id/content-research', async (request, params) => {
|
|
1009
|
+
try {
|
|
1010
|
+
const auth = await requireAuth(request);
|
|
1011
|
+
if (auth.error)
|
|
1012
|
+
return auth.error;
|
|
1013
|
+
if (!hasModel(db(), 'seoIssue'))
|
|
1014
|
+
return modelNotAvailable('SeoIssue');
|
|
1015
|
+
const issue = await db().seoIssue.findUnique({ where: { id: params.id } });
|
|
1016
|
+
if (!issue)
|
|
1017
|
+
return errorResponse('Issue not found', 404);
|
|
1018
|
+
const { supportsContentResearch, contentResearchFromMetadata } = await import('../../seo/issue-content-research.js');
|
|
1019
|
+
if (!supportsContentResearch(issue.type)) {
|
|
1020
|
+
return json({ data: { supported: false, research: null, stale: false } });
|
|
1021
|
+
}
|
|
1022
|
+
if (!issue.entityId) {
|
|
1023
|
+
return json({ data: { supported: true, research: null, stale: false } });
|
|
1024
|
+
}
|
|
1025
|
+
const loaded = await loadIssueFixContext(issue);
|
|
1026
|
+
if ('error' in loaded)
|
|
1027
|
+
return loaded.error;
|
|
1028
|
+
const research = contentResearchFromMetadata(issue.metadata, issue.type, issue.entityId, loaded.updatedAt);
|
|
1029
|
+
const raw = asRecord(issue.metadata)?.contentResearch;
|
|
1030
|
+
const stale = !!raw &&
|
|
1031
|
+
typeof raw === 'object' &&
|
|
1032
|
+
!Array.isArray(raw) &&
|
|
1033
|
+
typeof raw.fingerprint === 'string' &&
|
|
1034
|
+
!research;
|
|
1035
|
+
return json({ data: { supported: true, research, stale } });
|
|
1036
|
+
}
|
|
1037
|
+
catch (err) {
|
|
1038
|
+
return internalError(err, 'seo/issues/:id/content-research');
|
|
1039
|
+
}
|
|
1040
|
+
});
|
|
1041
|
+
router.post('/seo/issues/:id/content-research', async (request, params) => {
|
|
1042
|
+
try {
|
|
1043
|
+
const auth = await requireAuth(request);
|
|
1044
|
+
if (auth.error)
|
|
1045
|
+
return auth.error;
|
|
1046
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
1047
|
+
if (roleErr)
|
|
1048
|
+
return roleErr;
|
|
1049
|
+
const researchPlanErr = assertPlanFeature('seo.contentResearch');
|
|
1050
|
+
if (researchPlanErr)
|
|
1051
|
+
return planRequiredResponse(researchPlanErr);
|
|
1052
|
+
if (!hasModel(db(), 'seoIssue'))
|
|
1053
|
+
return modelNotAvailable('SeoIssue');
|
|
1054
|
+
if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-seo-content-research:${auth.session.userId}`))) {
|
|
1055
|
+
return errorResponse('AI rate limit reached. Try again in an hour.', 429);
|
|
1056
|
+
}
|
|
1057
|
+
const body = (await request.json().catch(() => ({})));
|
|
1058
|
+
const competitorUrls = Array.isArray(body?.competitorUrls)
|
|
1059
|
+
? body.competitorUrls.filter((u) => typeof u === 'string')
|
|
1060
|
+
: undefined;
|
|
1061
|
+
const issue = await db().seoIssue.findUnique({ where: { id: params.id } });
|
|
1062
|
+
if (!issue)
|
|
1063
|
+
return errorResponse('Issue not found', 404);
|
|
1064
|
+
const loaded = await loadIssueFixContext(issue);
|
|
1065
|
+
if ('error' in loaded)
|
|
1066
|
+
return loaded.error;
|
|
1067
|
+
const { generateContentResearch, contentResearchToMetadata, supportsContentResearch } = await import('../../seo/issue-content-research.js');
|
|
1068
|
+
if (!supportsContentResearch(issue.type)) {
|
|
1069
|
+
return errorResponse('This issue type does not support topic research.', 422);
|
|
1070
|
+
}
|
|
1071
|
+
const result = await generateContentResearch({
|
|
1072
|
+
id: issue.id,
|
|
1073
|
+
type: issue.type,
|
|
1074
|
+
title: issue.title,
|
|
1075
|
+
description: issue.description,
|
|
1076
|
+
recommendation: issue.recommendation,
|
|
1077
|
+
entityId: issue.entityId,
|
|
1078
|
+
}, { entity: loaded.entity, updatedAt: loaded.updatedAt }, { db: db(), userId: auth.session.userId, collection: loaded.collection }, { competitorUrls });
|
|
1079
|
+
if (!result.ok) {
|
|
1080
|
+
return seoAiGenerationError(result);
|
|
1081
|
+
}
|
|
1082
|
+
const existingMeta = asRecord(issue.metadata);
|
|
1083
|
+
await db().seoIssue.update({
|
|
1084
|
+
where: { id: issue.id },
|
|
1085
|
+
data: { metadata: { ...existingMeta, ...contentResearchToMetadata(result.research) } },
|
|
1086
|
+
});
|
|
1087
|
+
return json({ data: { research: result.research } });
|
|
1088
|
+
}
|
|
1089
|
+
catch (err) {
|
|
1090
|
+
return internalError(err, 'seo/issues/:id/content-research');
|
|
1091
|
+
}
|
|
1092
|
+
});
|
|
1093
|
+
router.post('/seo/issues/:id/research-and-brief', async (request, params) => {
|
|
1094
|
+
try {
|
|
1095
|
+
const auth = await requireAuth(request);
|
|
1096
|
+
if (auth.error)
|
|
1097
|
+
return auth.error;
|
|
1098
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
1099
|
+
if (roleErr)
|
|
1100
|
+
return roleErr;
|
|
1101
|
+
const comboPlanErr = assertPlanFeature('seo.contentResearch');
|
|
1102
|
+
if (comboPlanErr)
|
|
1103
|
+
return planRequiredResponse(comboPlanErr);
|
|
1104
|
+
if (!hasModel(db(), 'seoIssue'))
|
|
1105
|
+
return modelNotAvailable('SeoIssue');
|
|
1106
|
+
if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-seo-research-brief:${auth.session.userId}`))) {
|
|
1107
|
+
return errorResponse('AI rate limit reached. Try again in an hour.', 429);
|
|
1108
|
+
}
|
|
1109
|
+
const body = (await request.json().catch(() => ({})));
|
|
1110
|
+
const competitorUrls = Array.isArray(body?.competitorUrls)
|
|
1111
|
+
? body.competitorUrls.filter((u) => typeof u === 'string')
|
|
1112
|
+
: undefined;
|
|
1113
|
+
const issue = await db().seoIssue.findUnique({ where: { id: params.id } });
|
|
1114
|
+
if (!issue)
|
|
1115
|
+
return errorResponse('Issue not found', 404);
|
|
1116
|
+
const loaded = await loadIssueFixContext(issue);
|
|
1117
|
+
if ('error' in loaded)
|
|
1118
|
+
return loaded.error;
|
|
1119
|
+
const { generateContentResearch, contentResearchToMetadata, supportsContentResearch, contentResearchFromMetadata, } = await import('../../seo/issue-content-research.js');
|
|
1120
|
+
const { generateContentBrief, contentBriefToMetadata, supportsContentBrief } = await import('../../seo/issue-content-brief.js');
|
|
1121
|
+
if (!supportsContentBrief(issue.type)) {
|
|
1122
|
+
return errorResponse('This issue type does not support content briefs.', 422);
|
|
1123
|
+
}
|
|
1124
|
+
const issueInput = {
|
|
1125
|
+
id: issue.id,
|
|
1126
|
+
type: issue.type,
|
|
1127
|
+
title: issue.title,
|
|
1128
|
+
description: issue.description,
|
|
1129
|
+
recommendation: issue.recommendation,
|
|
1130
|
+
entityId: issue.entityId,
|
|
1131
|
+
};
|
|
1132
|
+
const briefCtx = { entity: loaded.entity, updatedAt: loaded.updatedAt };
|
|
1133
|
+
const deps = { db: db(), userId: auth.session.userId, collection: loaded.collection };
|
|
1134
|
+
let research = supportsContentResearch(issue.type)
|
|
1135
|
+
? contentResearchFromMetadata(issue.metadata, issue.type, issue.entityId, loaded.updatedAt)
|
|
1136
|
+
: null;
|
|
1137
|
+
if (supportsContentResearch(issue.type) && !research) {
|
|
1138
|
+
const researchResult = await generateContentResearch(issueInput, briefCtx, deps, {
|
|
1139
|
+
competitorUrls,
|
|
1140
|
+
});
|
|
1141
|
+
if (!researchResult.ok) {
|
|
1142
|
+
return seoAiGenerationError(researchResult);
|
|
1143
|
+
}
|
|
1144
|
+
research = researchResult.research;
|
|
1145
|
+
}
|
|
1146
|
+
const briefResult = await generateContentBrief(issueInput, briefCtx, deps, { research });
|
|
1147
|
+
if (!briefResult.ok) {
|
|
1148
|
+
return seoAiGenerationError(briefResult);
|
|
1149
|
+
}
|
|
1150
|
+
const existingMeta = asRecord(issue.metadata);
|
|
1151
|
+
const metadata = {
|
|
1152
|
+
...existingMeta,
|
|
1153
|
+
...contentBriefToMetadata(briefResult.brief),
|
|
1154
|
+
...(research ? contentResearchToMetadata(research) : {}),
|
|
1155
|
+
};
|
|
1156
|
+
await db().seoIssue.update({
|
|
1157
|
+
where: { id: issue.id },
|
|
1158
|
+
data: { metadata },
|
|
1159
|
+
});
|
|
1160
|
+
return json({ data: { research, brief: briefResult.brief } });
|
|
1161
|
+
}
|
|
1162
|
+
catch (err) {
|
|
1163
|
+
return internalError(err, 'seo/issues/:id/research-and-brief');
|
|
1164
|
+
}
|
|
1165
|
+
});
|
|
1166
|
+
router.get('/seo/issues/:id', async (request, params) => {
|
|
1167
|
+
try {
|
|
1168
|
+
const auth = await requireAuth(request);
|
|
1169
|
+
if (auth.error)
|
|
1170
|
+
return auth.error;
|
|
1171
|
+
if (!hasModel(db(), 'seoIssue'))
|
|
1172
|
+
return modelNotAvailable('SeoIssue');
|
|
1173
|
+
const issue = await db().seoIssue.findUnique({ where: { id: params.id } });
|
|
1174
|
+
if (!issue)
|
|
1175
|
+
return errorResponse('Issue not found', 404);
|
|
1176
|
+
return json({ data: seoIssueToApi(issue) });
|
|
1177
|
+
}
|
|
1178
|
+
catch (err) {
|
|
1179
|
+
return internalError(err, 'seo/issues/:id');
|
|
1180
|
+
}
|
|
1181
|
+
});
|
|
1182
|
+
async function setIssueStatus(request, issueId, status) {
|
|
1183
|
+
const auth = await requireAuth(request);
|
|
1184
|
+
if (auth.error)
|
|
1185
|
+
return auth.error;
|
|
1186
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
1187
|
+
if (roleErr)
|
|
1188
|
+
return roleErr;
|
|
1189
|
+
if (!hasModel(db(), 'seoIssue'))
|
|
1190
|
+
return modelNotAvailable('SeoIssue');
|
|
1191
|
+
const existing = await db().seoIssue.findUnique({ where: { id: issueId } });
|
|
1192
|
+
if (!existing)
|
|
1193
|
+
return errorResponse('Issue not found', 404);
|
|
1194
|
+
const issue = await db().seoIssue.update({
|
|
1195
|
+
where: { id: issueId },
|
|
1196
|
+
data: {
|
|
1197
|
+
status,
|
|
1198
|
+
resolvedAt: status === 'resolved' ? new Date() : null,
|
|
1199
|
+
resolvedById: status === 'resolved' ? auth.session.userId : null,
|
|
1200
|
+
},
|
|
1201
|
+
});
|
|
1202
|
+
try {
|
|
1203
|
+
await logEvent({
|
|
1204
|
+
event: 'settings_changed',
|
|
1205
|
+
userId: auth.session.userId,
|
|
1206
|
+
details: { action: 'seo_issue_status', issueId, status },
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
catch {
|
|
1210
|
+
/* noop */
|
|
1211
|
+
}
|
|
1212
|
+
return json({ data: seoIssueToApi(issue) });
|
|
1213
|
+
}
|
|
1214
|
+
router.post('/seo/issues/:id/resolve', (request, params) => setIssueStatus(request, params.id, 'resolved').catch((e) => internalError(e)));
|
|
1215
|
+
router.post('/seo/issues/:id/ignore', (request, params) => setIssueStatus(request, params.id, 'ignored').catch((e) => internalError(e)));
|
|
1216
|
+
router.post('/seo/issues/:id/reopen', (request, params) => setIssueStatus(request, params.id, 'open').catch((e) => internalError(e)));
|
|
1217
|
+
/** Load the audit entity + collection for an issue's linked document. */
|
|
1218
|
+
async function loadIssueFixContext(issue) {
|
|
1219
|
+
if (!issue.entityId) {
|
|
1220
|
+
return { error: errorResponse('This issue is not linked to a content item.', 422) };
|
|
1221
|
+
}
|
|
1222
|
+
const doc = await db().document.findFirst({
|
|
1223
|
+
where: { id: issue.entityId, deletedAt: null },
|
|
1224
|
+
select: {
|
|
1225
|
+
id: true,
|
|
1226
|
+
collection: true,
|
|
1227
|
+
title: true,
|
|
1228
|
+
slug: true,
|
|
1229
|
+
data: true,
|
|
1230
|
+
structuredData: true,
|
|
1231
|
+
plainText: true,
|
|
1232
|
+
publishedAt: true,
|
|
1233
|
+
updatedAt: true,
|
|
1234
|
+
},
|
|
1235
|
+
});
|
|
1236
|
+
if (!doc)
|
|
1237
|
+
return { error: errorResponse('Content not found', 404) };
|
|
1238
|
+
const { documentToAuditEntity, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
|
|
1239
|
+
const { applySeoOverrides, getSeoOverrides } = await import('../../seo/config-store.js');
|
|
1240
|
+
const collectionTypes = frontEndContentCollectionTypes(getPrentaConfig());
|
|
1241
|
+
const overrides = await getSeoOverrides(db()).catch(() => null);
|
|
1242
|
+
const mergedConfig = applySeoOverrides(getPrentaConfig(), overrides);
|
|
1243
|
+
const entity = documentToAuditEntity(doc, collectionTypes, mergedConfig?.seo);
|
|
1244
|
+
if (issue.entityType && entity.entityType !== issue.entityType) {
|
|
1245
|
+
return { error: errorResponse('Issue entity type does not match the linked document.', 409) };
|
|
1246
|
+
}
|
|
1247
|
+
return {
|
|
1248
|
+
collection: doc.collection,
|
|
1249
|
+
entity,
|
|
1250
|
+
updatedAt: doc.updatedAt instanceof Date ? doc.updatedAt : new Date(doc.updatedAt),
|
|
1251
|
+
siteSeo: mergedConfig?.seo,
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
router.post('/seo/issues/:id/suggest-fix', async (request, params) => {
|
|
1255
|
+
try {
|
|
1256
|
+
const auth = await requireAuth(request);
|
|
1257
|
+
if (auth.error)
|
|
1258
|
+
return auth.error;
|
|
1259
|
+
if (!hasModel(db(), 'seoIssue'))
|
|
1260
|
+
return modelNotAvailable('SeoIssue');
|
|
1261
|
+
const issue = await db().seoIssue.findUnique({ where: { id: params.id } });
|
|
1262
|
+
if (!issue)
|
|
1263
|
+
return errorResponse('Issue not found', 404);
|
|
1264
|
+
const loaded = await loadIssueFixContext(issue);
|
|
1265
|
+
if ('error' in loaded)
|
|
1266
|
+
return loaded.error;
|
|
1267
|
+
const issueInput = {
|
|
1268
|
+
type: issue.type,
|
|
1269
|
+
title: issue.title,
|
|
1270
|
+
recommendation: issue.recommendation,
|
|
1271
|
+
entityType: issue.entityType,
|
|
1272
|
+
entityId: issue.entityId,
|
|
1273
|
+
};
|
|
1274
|
+
const fixCtx = {
|
|
1275
|
+
entity: loaded.entity,
|
|
1276
|
+
siteSeo: loaded.siteSeo,
|
|
1277
|
+
updatedAt: loaded.updatedAt,
|
|
1278
|
+
metadata: asRecord(issue.metadata),
|
|
1279
|
+
};
|
|
1280
|
+
let aiDeps;
|
|
1281
|
+
const { REDIRECT_FIX_TYPES } = await import('../../seo/issue-fix-redirect.js');
|
|
1282
|
+
if (REDIRECT_FIX_TYPES.has(issue.type)) {
|
|
1283
|
+
aiDeps = {
|
|
1284
|
+
db: db(),
|
|
1285
|
+
userId: auth.session.userId,
|
|
1286
|
+
collection: loaded.collection,
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
else {
|
|
1290
|
+
const adminErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
1291
|
+
if (!adminErr &&
|
|
1292
|
+
hasPlanFeature('seo.inlineApply') &&
|
|
1293
|
+
(await checkRateLimitAsync(aiGenerateLimiter, `ai-seo-fix-suggest:${auth.session.userId}`))) {
|
|
1294
|
+
aiDeps = {
|
|
1295
|
+
db: db(),
|
|
1296
|
+
userId: auth.session.userId,
|
|
1297
|
+
collection: loaded.collection,
|
|
1298
|
+
};
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
const { suggestIssueFix } = await import('../../seo/issue-fix.js');
|
|
1302
|
+
const { pendingFixToMetadata } = await import('../../seo/issue-fix-ai.js');
|
|
1303
|
+
const result = await suggestIssueFix(issueInput, fixCtx, aiDeps);
|
|
1304
|
+
const existingMeta = asRecord(issue.metadata);
|
|
1305
|
+
const nextMetadata = result.cacheForApply
|
|
1306
|
+
? { ...existingMeta, ...pendingFixToMetadata(result.suggestion) }
|
|
1307
|
+
: { ...existingMeta, pendingFix: null };
|
|
1308
|
+
await db().seoIssue.update({
|
|
1309
|
+
where: { id: issue.id },
|
|
1310
|
+
data: { metadata: nextMetadata },
|
|
1311
|
+
});
|
|
1312
|
+
return json({ data: { suggestion: result.suggestion } });
|
|
1313
|
+
}
|
|
1314
|
+
catch (err) {
|
|
1315
|
+
return internalError(err, 'seo/issues/:id/suggest-fix');
|
|
1316
|
+
}
|
|
1317
|
+
});
|
|
1318
|
+
router.post('/seo/issues/:id/apply-fix', async (request, params) => {
|
|
1319
|
+
try {
|
|
1320
|
+
const auth = await requireAuth(request);
|
|
1321
|
+
if (auth.error)
|
|
1322
|
+
return auth.error;
|
|
1323
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
1324
|
+
if (roleErr)
|
|
1325
|
+
return roleErr;
|
|
1326
|
+
if (!hasModel(db(), 'seoIssue'))
|
|
1327
|
+
return modelNotAvailable('SeoIssue');
|
|
1328
|
+
const body = (await request.json().catch(() => ({})));
|
|
1329
|
+
const issue = await db().seoIssue.findUnique({ where: { id: params.id } });
|
|
1330
|
+
if (!issue)
|
|
1331
|
+
return errorResponse('Issue not found', 404);
|
|
1332
|
+
if (issue.status === 'ignored') {
|
|
1333
|
+
return errorResponse('Cannot apply a fix to an ignored issue. Reopen it first.', 409);
|
|
1334
|
+
}
|
|
1335
|
+
const loaded = await loadIssueFixContext(issue);
|
|
1336
|
+
if ('error' in loaded)
|
|
1337
|
+
return loaded.error;
|
|
1338
|
+
const issueInput = {
|
|
1339
|
+
type: issue.type,
|
|
1340
|
+
title: issue.title,
|
|
1341
|
+
recommendation: issue.recommendation,
|
|
1342
|
+
entityType: issue.entityType,
|
|
1343
|
+
entityId: issue.entityId,
|
|
1344
|
+
};
|
|
1345
|
+
const fixCtx = {
|
|
1346
|
+
entity: loaded.entity,
|
|
1347
|
+
siteSeo: loaded.siteSeo,
|
|
1348
|
+
updatedAt: loaded.updatedAt,
|
|
1349
|
+
metadata: asRecord(issue.metadata),
|
|
1350
|
+
};
|
|
1351
|
+
const { REDIRECT_FIX_TYPES, applyBrokenLinkRedirectFix, applyBrokenLinkContentFix } = await import('../../seo/issue-fix-redirect.js');
|
|
1352
|
+
if (REDIRECT_FIX_TYPES.has(issue.type)) {
|
|
1353
|
+
const { pendingFixFromMetadata } = await import('../../seo/issue-fix-ai.js');
|
|
1354
|
+
const { buildFixFingerprint } = await import('../../seo/issue-fix.js');
|
|
1355
|
+
const cached = pendingFixFromMetadata(issue.metadata);
|
|
1356
|
+
const liveFingerprint = buildFixFingerprint(loaded.entity.entityId, issue.type, loaded.updatedAt);
|
|
1357
|
+
if (!cached?.autoFixable) {
|
|
1358
|
+
return errorResponse('This issue does not support an automatic fix.', 422);
|
|
1359
|
+
}
|
|
1360
|
+
if (body?.fingerprint && body.fingerprint !== cached.fingerprint) {
|
|
1361
|
+
return errorResponse('This fix is stale — the page changed since the suggestion was loaded. Expand the row again.', 409);
|
|
1362
|
+
}
|
|
1363
|
+
if (cached.fingerprint !== liveFingerprint) {
|
|
1364
|
+
return errorResponse('This fix is stale — the page changed since the suggestion was loaded. Expand the row again.', 409);
|
|
1365
|
+
}
|
|
1366
|
+
const fixStrategy = cached.patch.fixStrategy === 'fix-link' ? 'fix-link' : 'redirect';
|
|
1367
|
+
try {
|
|
1368
|
+
if (fixStrategy === 'fix-link') {
|
|
1369
|
+
const ctx = buildActionContext(auth.session, db());
|
|
1370
|
+
await applyBrokenLinkContentFix(loaded.collection, loaded.entity.entityId, cached.patch, ctx);
|
|
1371
|
+
}
|
|
1372
|
+
else {
|
|
1373
|
+
await applyBrokenLinkRedirectFix(db(), cached.patch, auth.session.userId);
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
catch (err) {
|
|
1377
|
+
const msg = err instanceof Error ? err.message : 'Fix application failed';
|
|
1378
|
+
return errorResponse(msg, 400);
|
|
1379
|
+
}
|
|
1380
|
+
const existingMeta = asRecord(issue.metadata);
|
|
1381
|
+
const resolved = await db().seoIssue.update({
|
|
1382
|
+
where: { id: issue.id },
|
|
1383
|
+
data: {
|
|
1384
|
+
status: 'resolved',
|
|
1385
|
+
resolvedAt: new Date(),
|
|
1386
|
+
resolvedById: auth.session.userId,
|
|
1387
|
+
metadata: { ...existingMeta, pendingFix: null },
|
|
1388
|
+
},
|
|
1389
|
+
});
|
|
1390
|
+
try {
|
|
1391
|
+
await logEvent({
|
|
1392
|
+
event: 'document_updated',
|
|
1393
|
+
userId: auth.session.userId,
|
|
1394
|
+
details: {
|
|
1395
|
+
action: fixStrategy === 'fix-link'
|
|
1396
|
+
? 'seo_issue_link_fix_applied'
|
|
1397
|
+
: 'seo_issue_redirect_applied',
|
|
1398
|
+
issueId: issue.id,
|
|
1399
|
+
issueType: issue.type,
|
|
1400
|
+
...(fixStrategy === 'fix-link'
|
|
1401
|
+
? {
|
|
1402
|
+
fixLinkFrom: cached.patch.fixLinkFrom,
|
|
1403
|
+
fixLinkTo: cached.patch.fixLinkTo,
|
|
1404
|
+
}
|
|
1405
|
+
: {
|
|
1406
|
+
redirectFrom: cached.patch.redirectFrom,
|
|
1407
|
+
redirectTo: cached.patch.redirectTo,
|
|
1408
|
+
}),
|
|
1409
|
+
},
|
|
1410
|
+
});
|
|
1411
|
+
}
|
|
1412
|
+
catch {
|
|
1413
|
+
/* noop */
|
|
1414
|
+
}
|
|
1415
|
+
return json({
|
|
1416
|
+
data: {
|
|
1417
|
+
issue: seoIssueToApi(resolved),
|
|
1418
|
+
applied: cached.changes,
|
|
1419
|
+
},
|
|
1420
|
+
});
|
|
1421
|
+
}
|
|
1422
|
+
const { suggestDeterministicFix, buildFixFingerprint } = await import('../../seo/issue-fix.js');
|
|
1423
|
+
const { pendingFixFromMetadata } = await import('../../seo/issue-fix-ai.js');
|
|
1424
|
+
const deterministic = suggestDeterministicFix(issueInput, fixCtx);
|
|
1425
|
+
let suggestion = deterministic;
|
|
1426
|
+
if (!deterministic.autoFixable || Object.keys(deterministic.patch).length === 0) {
|
|
1427
|
+
const inlinePlanErr = assertPlanFeature('seo.inlineApply');
|
|
1428
|
+
if (inlinePlanErr)
|
|
1429
|
+
return planRequiredResponse(inlinePlanErr);
|
|
1430
|
+
const cached = pendingFixFromMetadata(issue.metadata);
|
|
1431
|
+
const liveFingerprint = buildFixFingerprint(loaded.entity.entityId, issue.type, loaded.updatedAt);
|
|
1432
|
+
if (!cached || !cached.autoFixable) {
|
|
1433
|
+
return errorResponse('This issue does not support an automatic fix.', 422);
|
|
1434
|
+
}
|
|
1435
|
+
if (body?.fingerprint && body.fingerprint !== cached.fingerprint) {
|
|
1436
|
+
return errorResponse('This fix is stale — the page changed since the suggestion was loaded. Expand the row again.', 409);
|
|
1437
|
+
}
|
|
1438
|
+
if (cached.fingerprint !== liveFingerprint) {
|
|
1439
|
+
return errorResponse('This fix is stale — the page changed since the suggestion was loaded. Expand the row again.', 409);
|
|
1440
|
+
}
|
|
1441
|
+
suggestion = {
|
|
1442
|
+
autoFixable: true,
|
|
1443
|
+
source: 'ai',
|
|
1444
|
+
fingerprint: cached.fingerprint,
|
|
1445
|
+
patch: cached.patch,
|
|
1446
|
+
changes: cached.changes,
|
|
1447
|
+
justification: cached.justification,
|
|
1448
|
+
};
|
|
1449
|
+
}
|
|
1450
|
+
else if (body?.fingerprint && body.fingerprint !== suggestion.fingerprint) {
|
|
1451
|
+
return errorResponse('This fix is stale — the page changed since the suggestion was loaded. Expand the row again.', 409);
|
|
1452
|
+
}
|
|
1453
|
+
if (!suggestion.autoFixable || Object.keys(suggestion.patch).length === 0) {
|
|
1454
|
+
return errorResponse('This issue does not support an automatic fix.', 422);
|
|
1455
|
+
}
|
|
1456
|
+
const ctx = buildActionContext(auth.session, db());
|
|
1457
|
+
try {
|
|
1458
|
+
await updateDocumentSeoFields(loaded.collection, loaded.entity.entityId, suggestion.patch, ctx);
|
|
1459
|
+
}
|
|
1460
|
+
catch (err) {
|
|
1461
|
+
const msg = err instanceof Error ? err.message : '';
|
|
1462
|
+
if (msg.includes('Access denied'))
|
|
1463
|
+
return errorResponse('Insufficient permissions', 403);
|
|
1464
|
+
if (msg.includes('not found'))
|
|
1465
|
+
return errorResponse('Document not found', 404);
|
|
1466
|
+
throw err;
|
|
1467
|
+
}
|
|
1468
|
+
const existingMeta = asRecord(issue.metadata);
|
|
1469
|
+
const resolved = await db().seoIssue.update({
|
|
1470
|
+
where: { id: issue.id },
|
|
1471
|
+
data: {
|
|
1472
|
+
status: 'resolved',
|
|
1473
|
+
resolvedAt: new Date(),
|
|
1474
|
+
resolvedById: auth.session.userId,
|
|
1475
|
+
metadata: { ...existingMeta, pendingFix: null },
|
|
1476
|
+
},
|
|
1477
|
+
});
|
|
1478
|
+
try {
|
|
1479
|
+
await logEvent({
|
|
1480
|
+
event: 'document_updated',
|
|
1481
|
+
userId: auth.session.userId,
|
|
1482
|
+
details: {
|
|
1483
|
+
action: 'seo_issue_fix_applied',
|
|
1484
|
+
issueId: issue.id,
|
|
1485
|
+
issueType: issue.type,
|
|
1486
|
+
entityId: loaded.entity.entityId,
|
|
1487
|
+
fields: Object.keys(suggestion.patch),
|
|
1488
|
+
source: suggestion.source ?? 'deterministic',
|
|
1489
|
+
},
|
|
1490
|
+
});
|
|
1491
|
+
}
|
|
1492
|
+
catch {
|
|
1493
|
+
/* noop */
|
|
1494
|
+
}
|
|
1495
|
+
return json({
|
|
1496
|
+
data: {
|
|
1497
|
+
issue: seoIssueToApi(resolved),
|
|
1498
|
+
applied: suggestion.changes,
|
|
1499
|
+
},
|
|
1500
|
+
});
|
|
1501
|
+
}
|
|
1502
|
+
catch (err) {
|
|
1503
|
+
return internalError(err, 'seo/issues/:id/apply-fix');
|
|
1504
|
+
}
|
|
1505
|
+
});
|
|
1506
|
+
router.get('/seo/overview', async (request) => {
|
|
1507
|
+
try {
|
|
1508
|
+
const auth = await requireAuth(request);
|
|
1509
|
+
if (auth.error)
|
|
1510
|
+
return auth.error;
|
|
1511
|
+
const { resolveSeoModuleSettings, getSeoOverrides } = await import('../../seo/config-store.js');
|
|
1512
|
+
const { buildQuickWins } = await import('../../seo/audit-engine.js');
|
|
1513
|
+
const { gradeForSeoScore } = await import('../../seo/score.js');
|
|
1514
|
+
const overrides = await getSeoOverrides(db()).catch(() => null);
|
|
1515
|
+
const moduleSettings = resolveSeoModuleSettings(overrides);
|
|
1516
|
+
let lastRun = null;
|
|
1517
|
+
let openIssues = [];
|
|
1518
|
+
if (hasModel(db(), 'seoAuditRun')) {
|
|
1519
|
+
lastRun = await db().seoAuditRun.findFirst({
|
|
1520
|
+
where: { status: 'COMPLETED' },
|
|
1521
|
+
orderBy: { startedAt: 'desc' },
|
|
1522
|
+
});
|
|
1523
|
+
}
|
|
1524
|
+
if (hasModel(db(), 'seoIssue')) {
|
|
1525
|
+
const rawIssues = await db().seoIssue.findMany({
|
|
1526
|
+
where: { status: { in: ['open', 'recurring'] } },
|
|
1527
|
+
orderBy: { lastDetectedAt: 'desc' },
|
|
1528
|
+
take: 200,
|
|
1529
|
+
});
|
|
1530
|
+
openIssues = sortIssuesBySeverity(rawIssues);
|
|
1531
|
+
}
|
|
1532
|
+
const records = await buildSeoContentRecords().catch(() => []);
|
|
1533
|
+
const indexable = records.filter((r) => !r.noindex).length;
|
|
1534
|
+
const perf = await loadSearchPerformance(10);
|
|
1535
|
+
const summary = {
|
|
1536
|
+
total: openIssues.length,
|
|
1537
|
+
critical: openIssues.filter((i) => i.severity === 'critical').length,
|
|
1538
|
+
warning: openIssues.filter((i) => i.severity === 'warning').length,
|
|
1539
|
+
info: openIssues.filter((i) => i.severity === 'info').length,
|
|
1540
|
+
};
|
|
1541
|
+
const scoreAfter = lastRun?.siteScoreAfter ?? null;
|
|
1542
|
+
const scoreBefore = lastRun?.siteScoreBefore ?? null;
|
|
1543
|
+
const breakdown = lastRun?.metadata?.breakdown ?? [];
|
|
1544
|
+
const avgPosition = perf.length > 0
|
|
1545
|
+
? perf.reduce((s, r) => s + r.averagePosition, 0) / perf.length
|
|
1546
|
+
: null;
|
|
1547
|
+
const weeklyImpressions = perf.reduce((s, r) => s + r.impressions, 0);
|
|
1548
|
+
const metrics = [
|
|
1549
|
+
{
|
|
1550
|
+
label: 'Pages Indexed',
|
|
1551
|
+
value: String(indexable),
|
|
1552
|
+
helper: `${records.length} total pages`,
|
|
1553
|
+
trend: 'flat',
|
|
1554
|
+
},
|
|
1555
|
+
{
|
|
1556
|
+
label: 'Open Issues',
|
|
1557
|
+
value: String(summary.total),
|
|
1558
|
+
helper: `${summary.critical} critical`,
|
|
1559
|
+
trend: summary.critical > 0 ? 'down' : 'flat',
|
|
1560
|
+
},
|
|
1561
|
+
{
|
|
1562
|
+
label: 'Avg. Position',
|
|
1563
|
+
value: avgPosition !== null ? avgPosition.toFixed(1) : '—',
|
|
1564
|
+
helper: moduleSettings.searchConsoleConnected ? 'last 28 days' : 'Connect Search Console',
|
|
1565
|
+
trend: 'flat',
|
|
1566
|
+
},
|
|
1567
|
+
{
|
|
1568
|
+
label: 'Weekly Impressions',
|
|
1569
|
+
value: weeklyImpressions > 0 ? formatCompactNumber(weeklyImpressions) : '—',
|
|
1570
|
+
helper: moduleSettings.searchConsoleConnected ? 'last 28 days' : 'Connect Search Console',
|
|
1571
|
+
trend: 'flat',
|
|
1572
|
+
},
|
|
1573
|
+
];
|
|
1574
|
+
const quickWins = buildQuickWins(openIssues.map((i) => ({
|
|
1575
|
+
issueKey: i.issueKey,
|
|
1576
|
+
entityType: i.entityType,
|
|
1577
|
+
entityId: i.entityId,
|
|
1578
|
+
entityTitle: i.entityTitle,
|
|
1579
|
+
url: i.url,
|
|
1580
|
+
severity: i.severity,
|
|
1581
|
+
category: i.category,
|
|
1582
|
+
type: i.type,
|
|
1583
|
+
title: i.title,
|
|
1584
|
+
description: i.description ?? '',
|
|
1585
|
+
impact: i.impact ?? 'Low',
|
|
1586
|
+
recommendation: i.recommendation ?? '',
|
|
1587
|
+
fixable: i.fixable ?? false,
|
|
1588
|
+
fixActionType: i.fixActionType ?? null,
|
|
1589
|
+
relatedEntityIds: Array.isArray(i.relatedEntityIds) ? i.relatedEntityIds : [],
|
|
1590
|
+
}))).map((w) => ({
|
|
1591
|
+
id: w.id,
|
|
1592
|
+
title: w.title,
|
|
1593
|
+
explanation: w.explanation,
|
|
1594
|
+
impact: w.impact,
|
|
1595
|
+
issueId: openIssues.find((i) => i.type === w.issueType)?.id ?? null,
|
|
1596
|
+
fixActionType: w.fixActionType,
|
|
1597
|
+
}));
|
|
1598
|
+
return json({
|
|
1599
|
+
data: {
|
|
1600
|
+
siteScore: {
|
|
1601
|
+
score: scoreAfter ?? 0,
|
|
1602
|
+
grade: gradeForSeoScore(scoreAfter ?? 0),
|
|
1603
|
+
delta: scoreAfter !== null && scoreBefore !== null ? scoreAfter - scoreBefore : null,
|
|
1604
|
+
breakdown,
|
|
1605
|
+
},
|
|
1606
|
+
metrics,
|
|
1607
|
+
issues: openIssues.slice(0, 7).map(seoIssueToApi),
|
|
1608
|
+
issuesSummary: summary,
|
|
1609
|
+
quickWins,
|
|
1610
|
+
searchPerformance: perf,
|
|
1611
|
+
searchConsoleConnected: moduleSettings.searchConsoleConnected,
|
|
1612
|
+
lastAuditRunId: lastRun?.id ?? null,
|
|
1613
|
+
},
|
|
1614
|
+
});
|
|
1615
|
+
}
|
|
1616
|
+
catch (err) {
|
|
1617
|
+
return internalError(err, 'seo/overview');
|
|
1618
|
+
}
|
|
1619
|
+
});
|
|
1620
|
+
router.get('/seo/search-performance', async (request) => {
|
|
1621
|
+
try {
|
|
1622
|
+
const auth = await requireAuth(request);
|
|
1623
|
+
if (auth.error)
|
|
1624
|
+
return auth.error;
|
|
1625
|
+
const url = new URL(request.url);
|
|
1626
|
+
const limit = Math.min(50, Math.max(1, Number(url.searchParams.get('limit') ?? 10)));
|
|
1627
|
+
const rows = await loadSearchPerformance(limit);
|
|
1628
|
+
return json({ data: { rows } });
|
|
1629
|
+
}
|
|
1630
|
+
catch (err) {
|
|
1631
|
+
return internalError(err, 'seo/search-performance');
|
|
1632
|
+
}
|
|
1633
|
+
});
|
|
1634
|
+
router.get('/seo/content', async (request) => {
|
|
1635
|
+
try {
|
|
1636
|
+
const auth = await requireAuth(request);
|
|
1637
|
+
if (auth.error)
|
|
1638
|
+
return auth.error;
|
|
1639
|
+
// Scope the list to the collections this caller may read — otherwise a
|
|
1640
|
+
// collection-scoped API key (or a collection-restricted role) could pull
|
|
1641
|
+
// metaTitle/description/canonical/OG for every collection, the exact
|
|
1642
|
+
// bypass the sibling document-level SEO endpoints already prevent.
|
|
1643
|
+
const allowed = await readableSeoCollections(auth.session);
|
|
1644
|
+
const records = await buildSeoContentRecords(allowed);
|
|
1645
|
+
return json({ data: { records } });
|
|
1646
|
+
}
|
|
1647
|
+
catch (err) {
|
|
1648
|
+
return internalError(err, 'seo/content');
|
|
1649
|
+
}
|
|
1650
|
+
});
|
|
1651
|
+
router.get('/seo/content/:entityType/:entityId', async (request, params) => {
|
|
1652
|
+
try {
|
|
1653
|
+
const auth = await requireAuth(request);
|
|
1654
|
+
if (auth.error)
|
|
1655
|
+
return auth.error;
|
|
1656
|
+
// Load the target first so we can enforce the collection's read-access
|
|
1657
|
+
// rule (requireCollectionScope + assertCollectionAccess) before returning
|
|
1658
|
+
// any SEO fields — parity with /seo/analysis and friends.
|
|
1659
|
+
const doc = await db().document.findFirst({
|
|
1660
|
+
where: { id: params.entityId, deletedAt: null },
|
|
1661
|
+
select: { data: true, collection: true },
|
|
1662
|
+
});
|
|
1663
|
+
if (!doc)
|
|
1664
|
+
return errorResponse('Content record not found', 404);
|
|
1665
|
+
const accessErr = await enforceDocumentReadAccess(auth.session, doc.collection);
|
|
1666
|
+
if (accessErr)
|
|
1667
|
+
return accessErr;
|
|
1668
|
+
const records = await buildSeoContentRecords();
|
|
1669
|
+
const record = records.find((r) => r.entityType === params.entityType && r.entityId === params.entityId);
|
|
1670
|
+
if (!record)
|
|
1671
|
+
return errorResponse('Content record not found', 404);
|
|
1672
|
+
// Overlay the real llms.txt curation values from the stored document
|
|
1673
|
+
// (the audit-entity pipeline that builds the base record doesn't carry
|
|
1674
|
+
// these convention keys).
|
|
1675
|
+
const data = asRecord(doc.data);
|
|
1676
|
+
const includeRaw = String(data.llmsInclude ?? '').toUpperCase();
|
|
1677
|
+
const merged = {
|
|
1678
|
+
...record,
|
|
1679
|
+
llmsInclude: includeRaw === 'ALWAYS' || includeRaw === 'NEVER'
|
|
1680
|
+
? includeRaw
|
|
1681
|
+
: 'AUTO',
|
|
1682
|
+
llmsDescription: typeof data.llmsDescription === 'string' ? data.llmsDescription : null,
|
|
1683
|
+
llmsSection: typeof data.llmsSection === 'string' ? data.llmsSection : null,
|
|
1684
|
+
llmsOptional: data.llmsOptional === true,
|
|
1685
|
+
};
|
|
1686
|
+
return json({ data: merged });
|
|
1687
|
+
}
|
|
1688
|
+
catch (err) {
|
|
1689
|
+
return internalError(err, 'seo/content/:id');
|
|
1690
|
+
}
|
|
1691
|
+
});
|
|
1692
|
+
router.put('/seo/content/:entityType/:entityId', async (request, params) => {
|
|
1693
|
+
try {
|
|
1694
|
+
const auth = await requireAuth(request);
|
|
1695
|
+
if (auth.error)
|
|
1696
|
+
return auth.error;
|
|
1697
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
1698
|
+
if (roleErr)
|
|
1699
|
+
return roleErr;
|
|
1700
|
+
const body = (await request.json().catch(() => null));
|
|
1701
|
+
if (!body || typeof body !== 'object') {
|
|
1702
|
+
return errorResponse('Request body must be an object', 400);
|
|
1703
|
+
}
|
|
1704
|
+
const doc = await db().document.findFirst({
|
|
1705
|
+
where: { id: params.entityId, deletedAt: null },
|
|
1706
|
+
select: { id: true, collection: true },
|
|
1707
|
+
});
|
|
1708
|
+
if (!doc)
|
|
1709
|
+
return errorResponse('Document not found', 404);
|
|
1710
|
+
// String SEO fields: payload key -> stored data key, each with a max
|
|
1711
|
+
// length so we never persist unbounded/megabyte values into the content
|
|
1712
|
+
// JSON. Canonical is stored as `canonical` to match normalizeSeoPage.
|
|
1713
|
+
const TEXT_FIELDS = {
|
|
1714
|
+
metaTitle: { target: 'metaTitle', max: 300 },
|
|
1715
|
+
metaDescription: { target: 'metaDescription', max: 1000 },
|
|
1716
|
+
canonicalUrl: { target: 'canonical', max: 2000 },
|
|
1717
|
+
focusKeyword: { target: 'focusKeyword', max: 200 },
|
|
1718
|
+
keyTakeaway: { target: 'keyTakeaway', max: 500 },
|
|
1719
|
+
ogTitle: { target: 'ogTitle', max: 300 },
|
|
1720
|
+
ogDescription: { target: 'ogDescription', max: 1000 },
|
|
1721
|
+
ogImage: { target: 'ogImage', max: 2000 },
|
|
1722
|
+
twitterTitle: { target: 'twitterTitle', max: 300 },
|
|
1723
|
+
twitterDescription: { target: 'twitterDescription', max: 1000 },
|
|
1724
|
+
twitterImage: { target: 'twitterImage', max: 2000 },
|
|
1725
|
+
structuredDataType: { target: 'structuredDataType', max: 100 },
|
|
1726
|
+
schemaType: { target: 'schemaType', max: 100 },
|
|
1727
|
+
llmsDescription: { target: 'llmsDescription', max: 500 },
|
|
1728
|
+
llmsSection: { target: 'llmsSection', max: 100 },
|
|
1729
|
+
};
|
|
1730
|
+
const patch = {};
|
|
1731
|
+
for (const [key, { target, max }] of Object.entries(TEXT_FIELDS)) {
|
|
1732
|
+
if (!(key in body))
|
|
1733
|
+
continue;
|
|
1734
|
+
const value = body[key];
|
|
1735
|
+
if (value === null || value === '') {
|
|
1736
|
+
patch[target] = '';
|
|
1737
|
+
continue;
|
|
1738
|
+
}
|
|
1739
|
+
if (typeof value !== 'string') {
|
|
1740
|
+
return errorResponse(`Field "${key}" must be a string`, 400);
|
|
1741
|
+
}
|
|
1742
|
+
if (value.length > max) {
|
|
1743
|
+
return errorResponse(`Field "${key}" exceeds the ${max}-character limit`, 400);
|
|
1744
|
+
}
|
|
1745
|
+
patch[target] = value;
|
|
1746
|
+
}
|
|
1747
|
+
if ('noindex' in body)
|
|
1748
|
+
patch.noindex = body.noindex === true;
|
|
1749
|
+
if ('nofollow' in body)
|
|
1750
|
+
patch.nofollow = body.nofollow === true;
|
|
1751
|
+
if ('llmsOptional' in body)
|
|
1752
|
+
patch.llmsOptional = body.llmsOptional === true;
|
|
1753
|
+
if ('llmsInclude' in body) {
|
|
1754
|
+
const v = String(body.llmsInclude ?? '').toUpperCase();
|
|
1755
|
+
if (v !== 'AUTO' && v !== 'ALWAYS' && v !== 'NEVER') {
|
|
1756
|
+
return errorResponse('llmsInclude must be one of AUTO, ALWAYS, NEVER', 400);
|
|
1757
|
+
}
|
|
1758
|
+
// AUTO is the default — store empty so the field falls back cleanly.
|
|
1759
|
+
patch.llmsInclude = v === 'AUTO' ? '' : v;
|
|
1760
|
+
}
|
|
1761
|
+
if ('llmsPriority' in body) {
|
|
1762
|
+
const n = body.llmsPriority;
|
|
1763
|
+
if (n !== null && typeof n !== 'number') {
|
|
1764
|
+
return errorResponse('llmsPriority must be a number', 400);
|
|
1765
|
+
}
|
|
1766
|
+
patch.llmsPriority = n ?? 0;
|
|
1767
|
+
}
|
|
1768
|
+
if ('structuredData' in body) {
|
|
1769
|
+
const sd = body.structuredData;
|
|
1770
|
+
if (sd !== null && (typeof sd !== 'object' || Array.isArray(sd))) {
|
|
1771
|
+
return errorResponse('structuredData must be a JSON object', 400);
|
|
1772
|
+
}
|
|
1773
|
+
if (sd && JSON.stringify(sd).length > 50_000) {
|
|
1774
|
+
return errorResponse('structuredData payload is too large', 400);
|
|
1775
|
+
}
|
|
1776
|
+
patch.structuredData = sd;
|
|
1777
|
+
}
|
|
1778
|
+
if (Object.keys(patch).length === 0) {
|
|
1779
|
+
return errorResponse('No recognized SEO fields in request body', 400);
|
|
1780
|
+
}
|
|
1781
|
+
if ('structuredDataType' in patch || 'schemaType' in patch) {
|
|
1782
|
+
Object.assign(patch, syncSchemaTypeFields(patch));
|
|
1783
|
+
}
|
|
1784
|
+
// Route through the data layer so the edit gets per-collection access
|
|
1785
|
+
// control, a Version snapshot, search re-indexing and the afterUpdate
|
|
1786
|
+
// hook — never write document.data directly. (updateDocumentSeoFields
|
|
1787
|
+
// preserves the undeclared SEO convention keys that the generic
|
|
1788
|
+
// updateDocument field-access layer would strip.)
|
|
1789
|
+
const ctx = buildActionContext(auth.session, db());
|
|
1790
|
+
try {
|
|
1791
|
+
await updateDocumentSeoFields(doc.collection, doc.id, patch, ctx);
|
|
1792
|
+
}
|
|
1793
|
+
catch (err) {
|
|
1794
|
+
const msg = err instanceof Error ? err.message : '';
|
|
1795
|
+
if (msg.includes('Access denied'))
|
|
1796
|
+
return errorResponse('Insufficient permissions', 403);
|
|
1797
|
+
if (msg.includes('not found'))
|
|
1798
|
+
return errorResponse('Document not found', 404);
|
|
1799
|
+
throw err;
|
|
1800
|
+
}
|
|
1801
|
+
try {
|
|
1802
|
+
await logEvent({
|
|
1803
|
+
event: 'document_updated',
|
|
1804
|
+
userId: auth.session.userId,
|
|
1805
|
+
details: { action: 'seo_fields_update', documentId: doc.id },
|
|
1806
|
+
});
|
|
1807
|
+
}
|
|
1808
|
+
catch {
|
|
1809
|
+
/* noop */
|
|
1810
|
+
}
|
|
1811
|
+
const records = await buildSeoContentRecords();
|
|
1812
|
+
const record = records.find((r) => r.entityId === doc.id);
|
|
1813
|
+
return json({ data: record ?? { ok: true } });
|
|
1814
|
+
}
|
|
1815
|
+
catch (err) {
|
|
1816
|
+
return internalError(err, 'seo/content PUT');
|
|
1817
|
+
}
|
|
1818
|
+
});
|
|
1819
|
+
// ---------------------------------------------------------------------------
|
|
1820
|
+
// SEO analysis, readability, internal links, schema, meta
|
|
1821
|
+
// ---------------------------------------------------------------------------
|
|
1822
|
+
router.get('/seo/analysis/:documentId', async (request, params) => {
|
|
1823
|
+
try {
|
|
1824
|
+
const auth = await requireAuth(request);
|
|
1825
|
+
if (auth.error)
|
|
1826
|
+
return auth.error;
|
|
1827
|
+
const doc = await db().document.findFirst({
|
|
1828
|
+
where: { id: params.documentId, deletedAt: null },
|
|
1829
|
+
});
|
|
1830
|
+
if (!doc)
|
|
1831
|
+
return errorResponse('Not found', 404);
|
|
1832
|
+
const accessErr = await enforceDocumentReadAccess(auth.session, doc.collection);
|
|
1833
|
+
if (accessErr)
|
|
1834
|
+
return accessErr;
|
|
1835
|
+
const { analyzeContent } = await import('../../seo/analysis.js');
|
|
1836
|
+
const { documentToAuditEntity, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
|
|
1837
|
+
const { applySeoOverrides, getSeoOverrides } = await import('../../seo/config-store.js');
|
|
1838
|
+
const data = doc.data || {};
|
|
1839
|
+
// Same content assembly as readability + audit entity (`documentContentForSeoAnalysis`)
|
|
1840
|
+
// so MCP/admin scores agree and section-built pages still get plainText/sections.
|
|
1841
|
+
const collectionTypes = frontEndContentCollectionTypes(getPrentaConfig());
|
|
1842
|
+
const overrides = await getSeoOverrides(db()).catch(() => null);
|
|
1843
|
+
const siteSeo = applySeoOverrides(getPrentaConfig(), overrides)?.seo;
|
|
1844
|
+
const entity = documentToAuditEntity(doc, collectionTypes, siteSeo);
|
|
1845
|
+
const result = analyzeContent({
|
|
1846
|
+
title: entity.title,
|
|
1847
|
+
slug: doc.slug || data.slug || '',
|
|
1848
|
+
content: entity.content ?? '',
|
|
1849
|
+
// Rendered title (site template applied) — the same value the page
|
|
1850
|
+
// score measures, so both agree instead of one reading the page portion.
|
|
1851
|
+
metaTitle: entity.metaTitle ?? undefined,
|
|
1852
|
+
metaDescription: entity.metaDescription ?? undefined,
|
|
1853
|
+
focusKeyphrase: entity.focusKeyword ?? undefined,
|
|
1854
|
+
canonical: entity.canonicalUrl ?? undefined,
|
|
1855
|
+
ogTitle: entity.ogTitle ?? undefined,
|
|
1856
|
+
ogDescription: entity.ogDescription ?? undefined,
|
|
1857
|
+
ogImage: entity.ogImage ?? undefined,
|
|
1858
|
+
isCornerstone: data.isCornerstone,
|
|
1859
|
+
});
|
|
1860
|
+
return new Response(JSON.stringify(result), {
|
|
1861
|
+
status: 200,
|
|
1862
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1863
|
+
});
|
|
1864
|
+
}
|
|
1865
|
+
catch (err) {
|
|
1866
|
+
return internalError(err, 'seo/analysis');
|
|
1867
|
+
}
|
|
1868
|
+
});
|
|
1869
|
+
router.get('/seo/readability/:documentId', async (request, params) => {
|
|
1870
|
+
try {
|
|
1871
|
+
const auth = await requireAuth(request);
|
|
1872
|
+
if (auth.error)
|
|
1873
|
+
return auth.error;
|
|
1874
|
+
const doc = await db().document.findFirst({
|
|
1875
|
+
where: { id: params.documentId, deletedAt: null },
|
|
1876
|
+
});
|
|
1877
|
+
if (!doc)
|
|
1878
|
+
return errorResponse('Not found', 404);
|
|
1879
|
+
const accessErr = await enforceDocumentReadAccess(auth.session, doc.collection);
|
|
1880
|
+
if (accessErr)
|
|
1881
|
+
return accessErr;
|
|
1882
|
+
const { calculateReadability, stripHtmlTags } = await import('../../seo/analysis.js');
|
|
1883
|
+
const { documentContentForSeoAnalysis } = await import('../../seo/document-content.js');
|
|
1884
|
+
const text = stripHtmlTags(documentContentForSeoAnalysis(doc));
|
|
1885
|
+
const result = calculateReadability(text);
|
|
1886
|
+
return new Response(JSON.stringify(result), {
|
|
1887
|
+
status: 200,
|
|
1888
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1889
|
+
});
|
|
1890
|
+
}
|
|
1891
|
+
catch (err) {
|
|
1892
|
+
return internalError(err, 'seo/readability');
|
|
1893
|
+
}
|
|
1894
|
+
});
|
|
1895
|
+
router.get('/seo/internal-links/:documentId', async (request, params) => {
|
|
1896
|
+
try {
|
|
1897
|
+
const auth = await requireAuth(request);
|
|
1898
|
+
if (auth.error)
|
|
1899
|
+
return auth.error;
|
|
1900
|
+
const doc = await db().document.findFirst({
|
|
1901
|
+
where: { id: params.documentId, deletedAt: null },
|
|
1902
|
+
});
|
|
1903
|
+
if (!doc)
|
|
1904
|
+
return errorResponse('Not found', 404);
|
|
1905
|
+
const accessErr = await enforceDocumentReadAccess(auth.session, doc.collection);
|
|
1906
|
+
if (accessErr)
|
|
1907
|
+
return accessErr;
|
|
1908
|
+
const data = doc.data || {};
|
|
1909
|
+
const title = doc.title || data.title || '';
|
|
1910
|
+
const keywords = title
|
|
1911
|
+
.split(/\s+/)
|
|
1912
|
+
.filter((w) => w.length > 3)
|
|
1913
|
+
.slice(0, 5);
|
|
1914
|
+
if (keywords.length === 0) {
|
|
1915
|
+
return new Response(JSON.stringify({ suggestions: [] }), {
|
|
1916
|
+
status: 200,
|
|
1917
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1918
|
+
});
|
|
1919
|
+
}
|
|
1920
|
+
const suggestions = await db().document.findMany({
|
|
1921
|
+
where: {
|
|
1922
|
+
id: { not: params.documentId },
|
|
1923
|
+
status: 'PUBLISHED',
|
|
1924
|
+
deletedAt: null,
|
|
1925
|
+
OR: keywords.map((kw) => ({
|
|
1926
|
+
title: { contains: kw, mode: 'insensitive' },
|
|
1927
|
+
})),
|
|
1928
|
+
},
|
|
1929
|
+
select: { id: true, title: true, slug: true, collection: true },
|
|
1930
|
+
take: 10,
|
|
1931
|
+
orderBy: { updatedAt: 'desc' },
|
|
1932
|
+
});
|
|
1933
|
+
return new Response(JSON.stringify({ suggestions }), {
|
|
1934
|
+
status: 200,
|
|
1935
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1936
|
+
});
|
|
1937
|
+
}
|
|
1938
|
+
catch (err) {
|
|
1939
|
+
return internalError(err, 'seo/internal-links');
|
|
1940
|
+
}
|
|
1941
|
+
});
|
|
1942
|
+
router.get('/llms.txt', async (request) => {
|
|
1943
|
+
try {
|
|
1944
|
+
// Public surface: must exclude soft-deleted docs and internal/system
|
|
1945
|
+
// collections (e.g. `__seo_config__`). Over-fetch then filter so the
|
|
1946
|
+
// 50-page cap still lands on real content after exclusion.
|
|
1947
|
+
const { isInternalCollection } = await import('../../seo/audit-runner.js');
|
|
1948
|
+
const { siteUrlFromRequest, loadEffectiveConfig } = await import('./seo-public.js');
|
|
1949
|
+
// Layer DB overrides (Settings → SEO) so an admin-edited siteName /
|
|
1950
|
+
// defaultMetaDescription flows into the llms heading + summary too, exactly
|
|
1951
|
+
// as sitemap.xml, robots.txt, /og.png and the feeds already do.
|
|
1952
|
+
const cfg = (await loadEffectiveConfig()) ?? getPrentaConfig();
|
|
1953
|
+
const llmsCfg = cfg?.seo?.llms;
|
|
1954
|
+
const base = siteUrlFromRequest(request, cfg);
|
|
1955
|
+
// Operators can disable the file entirely (it's optionality, not a
|
|
1956
|
+
// guaranteed traffic channel).
|
|
1957
|
+
if (llmsCfg?.disabled) {
|
|
1958
|
+
return new Response('Not found', { status: 404 });
|
|
1959
|
+
}
|
|
1960
|
+
const maxEntries = llmsCfg?.maxEntries ?? 50;
|
|
1961
|
+
const maxPerSection = llmsCfg?.maxPerSection ?? 25;
|
|
1962
|
+
// Restrict AUTO selection to the configured high-value collections when
|
|
1963
|
+
// set, so blog archives don't flood the curated guide.
|
|
1964
|
+
const eligible = Array.isArray(llmsCfg?.eligibleCollections) && llmsCfg.eligibleCollections.length > 0
|
|
1965
|
+
? new Set(llmsCfg.eligibleCollections)
|
|
1966
|
+
: null;
|
|
1967
|
+
const rawDocs = await db().document.findMany({
|
|
1968
|
+
where: { status: 'PUBLISHED', deletedAt: null },
|
|
1969
|
+
select: { title: true, slug: true, collection: true, updatedAt: true, data: true },
|
|
1970
|
+
orderBy: { updatedAt: 'desc' },
|
|
1971
|
+
take: 500,
|
|
1972
|
+
});
|
|
1973
|
+
// Per-doc curation + privacy boundary (noindex exclusion) live in the
|
|
1974
|
+
// shared selector so `/llms.txt` and `/llms-full.txt` can never diverge.
|
|
1975
|
+
const { selectLlmsDocs, toLlmsPage } = await import('../../seo/llms-select.js');
|
|
1976
|
+
const selected = selectLlmsDocs(rawDocs, { isInternalCollection, eligible });
|
|
1977
|
+
const { generateLlmsTxt } = await import('../../seo/llms-txt.js');
|
|
1978
|
+
const pages = selected.map((d) => toLlmsPage(d, { base, urlPrefix: cfg?.collections?.[d.collection]?.urlPrefix }));
|
|
1979
|
+
const txt = generateLlmsTxt({
|
|
1980
|
+
siteName: cfg?.seo?.siteName || 'Prenta CMS',
|
|
1981
|
+
siteUrl: base,
|
|
1982
|
+
siteDescription: llmsCfg?.summary || cfg?.seo?.defaultMetaDescription || 'Content managed by Prenta CMS',
|
|
1983
|
+
intro: llmsCfg?.intro,
|
|
1984
|
+
maxPages: maxEntries,
|
|
1985
|
+
maxPerSection,
|
|
1986
|
+
}, pages);
|
|
1987
|
+
// Opt-in honeypot: append a unique trap link. No human reads llms.txt, so
|
|
1988
|
+
// a fetch of this URL is an automated reader — recorded under the
|
|
1989
|
+
// "honeypot" surface in the AI-crawler panel.
|
|
1990
|
+
let body = txt;
|
|
1991
|
+
if (llmsCfg?.honeypot) {
|
|
1992
|
+
const trapUrl = `${base.replace(/\/$/, '')}/api/cms/ai-crawlers/trap`;
|
|
1993
|
+
body += `\n## Reference\n\n- [Complete content index](${trapUrl}): Full machine-readable content map.\n`;
|
|
1994
|
+
}
|
|
1995
|
+
// Measurement: if a known AI crawler fetched the index, count it.
|
|
1996
|
+
await recordAiCrawlerFromRequest(db(), request, 'llms');
|
|
1997
|
+
return new Response(body, {
|
|
1998
|
+
status: 200,
|
|
1999
|
+
headers: {
|
|
2000
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
2001
|
+
// The file itself is a machine-facing index, not a page to rank.
|
|
2002
|
+
'X-Robots-Tag': 'noindex',
|
|
2003
|
+
},
|
|
2004
|
+
});
|
|
2005
|
+
}
|
|
2006
|
+
catch (err) {
|
|
2007
|
+
return internalError(err, 'llms.txt');
|
|
2008
|
+
}
|
|
2009
|
+
});
|
|
2010
|
+
// `/llms-full.txt` — same curated selection as `/llms.txt`, but inlines each
|
|
2011
|
+
// page's content rendered to clean Markdown so an agent can read the actual
|
|
2012
|
+
// body without fetching every URL. Opt-in (these files get large) and bounded
|
|
2013
|
+
// by a byte budget. noindex + never in the sitemap; HTML stays canonical.
|
|
2014
|
+
router.get('/llms-full.txt', async (request) => {
|
|
2015
|
+
try {
|
|
2016
|
+
const { isInternalCollection } = await import('../../seo/audit-runner.js');
|
|
2017
|
+
const { siteUrlFromRequest, loadEffectiveConfig } = await import('./seo-public.js');
|
|
2018
|
+
// Honor admin overrides (siteName / summary / defaultMetaDescription) the
|
|
2019
|
+
// same way `/llms.txt` and the other public SEO surfaces do.
|
|
2020
|
+
const cfg = (await loadEffectiveConfig()) ?? getPrentaConfig();
|
|
2021
|
+
const llmsCfg = cfg?.seo?.llms;
|
|
2022
|
+
const base = siteUrlFromRequest(request, cfg);
|
|
2023
|
+
// Gated independently from `/llms.txt`: must be both enabled overall and
|
|
2024
|
+
// have the full variant turned on.
|
|
2025
|
+
if (llmsCfg?.disabled || !llmsCfg?.fullEnabled) {
|
|
2026
|
+
return new Response('Not found', { status: 404 });
|
|
2027
|
+
}
|
|
2028
|
+
const maxEntries = llmsCfg?.maxEntries ?? 50;
|
|
2029
|
+
const maxBytes = llmsCfg?.maxFullBytes ?? 2_000_000;
|
|
2030
|
+
const eligible = Array.isArray(llmsCfg?.eligibleCollections) && llmsCfg.eligibleCollections.length > 0
|
|
2031
|
+
? new Set(llmsCfg.eligibleCollections)
|
|
2032
|
+
: null;
|
|
2033
|
+
const rawDocs = await db().document.findMany({
|
|
2034
|
+
where: { status: 'PUBLISHED', deletedAt: null },
|
|
2035
|
+
select: {
|
|
2036
|
+
title: true,
|
|
2037
|
+
slug: true,
|
|
2038
|
+
collection: true,
|
|
2039
|
+
updatedAt: true,
|
|
2040
|
+
data: true,
|
|
2041
|
+
plainText: true,
|
|
2042
|
+
},
|
|
2043
|
+
orderBy: { updatedAt: 'desc' },
|
|
2044
|
+
take: 500,
|
|
2045
|
+
});
|
|
2046
|
+
const { selectLlmsDocs, toLlmsPage } = await import('../../seo/llms-select.js');
|
|
2047
|
+
const { documentToMarkdown } = await import('../../seo/content-markdown.js');
|
|
2048
|
+
const selected = selectLlmsDocs(rawDocs, { isInternalCollection, eligible }).slice(0, maxEntries);
|
|
2049
|
+
const header = [
|
|
2050
|
+
`# ${cfg?.seo?.siteName || 'Prenta CMS'}`,
|
|
2051
|
+
'',
|
|
2052
|
+
`> ${llmsCfg?.summary || cfg?.seo?.defaultMetaDescription || 'Full content export for AI agents.'}`,
|
|
2053
|
+
'',
|
|
2054
|
+
].join('\n');
|
|
2055
|
+
const chunks = [header];
|
|
2056
|
+
let bytes = Buffer.byteLength(header, 'utf8');
|
|
2057
|
+
let truncated = false;
|
|
2058
|
+
for (const d of selected) {
|
|
2059
|
+
const page = toLlmsPage(d, {
|
|
2060
|
+
base,
|
|
2061
|
+
urlPrefix: cfg?.collections?.[d.collection]?.urlPrefix,
|
|
2062
|
+
});
|
|
2063
|
+
const md = documentToMarkdown({
|
|
2064
|
+
title: d.title,
|
|
2065
|
+
data: d.data,
|
|
2066
|
+
plainText: d.plainText,
|
|
2067
|
+
}, { baseUrl: base });
|
|
2068
|
+
// Each entry is delimited by a horizontal rule and carries its canonical
|
|
2069
|
+
// URL so the inlined content stays traceable back to the live page.
|
|
2070
|
+
const block = `\n---\n\nURL: ${page.url}\n\n${md}\n`;
|
|
2071
|
+
const blockBytes = Buffer.byteLength(block, 'utf8');
|
|
2072
|
+
if (bytes + blockBytes > maxBytes) {
|
|
2073
|
+
truncated = true;
|
|
2074
|
+
break;
|
|
2075
|
+
}
|
|
2076
|
+
chunks.push(block);
|
|
2077
|
+
bytes += blockBytes;
|
|
2078
|
+
}
|
|
2079
|
+
if (truncated) {
|
|
2080
|
+
chunks.push('\n---\n\n> Output truncated at the configured size limit.\n');
|
|
2081
|
+
}
|
|
2082
|
+
await recordAiCrawlerFromRequest(db(), request, 'llms-full');
|
|
2083
|
+
return new Response(chunks.join(''), {
|
|
2084
|
+
status: 200,
|
|
2085
|
+
headers: {
|
|
2086
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
2087
|
+
'X-Robots-Tag': 'noindex',
|
|
2088
|
+
},
|
|
2089
|
+
});
|
|
2090
|
+
}
|
|
2091
|
+
catch (err) {
|
|
2092
|
+
return internalError(err, 'llms-full.txt');
|
|
2093
|
+
}
|
|
2094
|
+
});
|
|
2095
|
+
// Per-page Markdown: `GET /md?path=/about` returns a clean Markdown rendering
|
|
2096
|
+
// of a single published page so an agent can fetch the content, not just the
|
|
2097
|
+
// link. Fresh installs expose this at clean `/{path}.md` URLs via a scaffolded
|
|
2098
|
+
// rewrite. CRITICAL: always noindex, never in the sitemap — the HTML page
|
|
2099
|
+
// stays canonical (per-page `.md` is the most common duplicate-content trap).
|
|
2100
|
+
router.get('/md', async (request) => {
|
|
2101
|
+
try {
|
|
2102
|
+
const cfg = getPrentaConfig();
|
|
2103
|
+
const url = new URL(request.url);
|
|
2104
|
+
const pathParam = url.searchParams.get('path');
|
|
2105
|
+
if (!pathParam) {
|
|
2106
|
+
return errorResponse('Missing required "path" query parameter', 400);
|
|
2107
|
+
}
|
|
2108
|
+
// Part of the AI/agent layer — honor the same master switch as llms.txt.
|
|
2109
|
+
if (cfg?.seo?.llms?.disabled) {
|
|
2110
|
+
return new Response('Not found', { status: 404 });
|
|
2111
|
+
}
|
|
2112
|
+
const { matchPathToCollection } = await import('../../seo/resolve-path.js');
|
|
2113
|
+
const match = matchPathToCollection(cfg, pathParam);
|
|
2114
|
+
if (!match) {
|
|
2115
|
+
return new Response('Not found', { status: 404 });
|
|
2116
|
+
}
|
|
2117
|
+
const slugFilter = match.isRoot
|
|
2118
|
+
? {
|
|
2119
|
+
OR: [
|
|
2120
|
+
{ data: { path: ['slug'], equals: 'home' } },
|
|
2121
|
+
{ data: { path: ['slug'], equals: 'index' } },
|
|
2122
|
+
{ slug: 'home' },
|
|
2123
|
+
{ slug: 'index' },
|
|
2124
|
+
],
|
|
2125
|
+
}
|
|
2126
|
+
: {
|
|
2127
|
+
OR: [{ data: { path: ['slug'], equals: match.slug } }, { slug: match.slug }],
|
|
2128
|
+
};
|
|
2129
|
+
const doc = await db().document.findFirst({
|
|
2130
|
+
where: {
|
|
2131
|
+
collection: match.collection,
|
|
2132
|
+
deletedAt: null,
|
|
2133
|
+
status: 'PUBLISHED',
|
|
2134
|
+
...slugFilter,
|
|
2135
|
+
},
|
|
2136
|
+
select: { title: true, data: true, plainText: true },
|
|
2137
|
+
});
|
|
2138
|
+
if (!doc) {
|
|
2139
|
+
return new Response('Not found', { status: 404 });
|
|
2140
|
+
}
|
|
2141
|
+
const data = doc.data || {};
|
|
2142
|
+
// The selection filter is the privacy boundary: noindexed or explicitly
|
|
2143
|
+
// excluded pages must not be fetchable as Markdown either.
|
|
2144
|
+
const { isLlmsNoindex, readLlmsInclude } = await import('../../seo/llms-select.js');
|
|
2145
|
+
if (isLlmsNoindex(data) || readLlmsInclude(data) === 'NEVER') {
|
|
2146
|
+
return new Response('Not found', { status: 404 });
|
|
2147
|
+
}
|
|
2148
|
+
const { documentToMarkdown } = await import('../../seo/content-markdown.js');
|
|
2149
|
+
const { siteUrlFromRequest } = await import('./seo-public.js');
|
|
2150
|
+
const base = siteUrlFromRequest(request, cfg);
|
|
2151
|
+
const md = documentToMarkdown({ title: doc.title, data, plainText: doc.plainText }, { baseUrl: base });
|
|
2152
|
+
await recordAiCrawlerFromRequest(db(), request, 'md');
|
|
2153
|
+
return new Response(md, {
|
|
2154
|
+
status: 200,
|
|
2155
|
+
headers: {
|
|
2156
|
+
'Content-Type': 'text/markdown; charset=utf-8',
|
|
2157
|
+
'X-Robots-Tag': 'noindex',
|
|
2158
|
+
},
|
|
2159
|
+
});
|
|
2160
|
+
}
|
|
2161
|
+
catch (err) {
|
|
2162
|
+
return internalError(err, 'md');
|
|
2163
|
+
}
|
|
2164
|
+
});
|
|
2165
|
+
// Honeypot: a unique trap URL appended to llms.txt only when
|
|
2166
|
+
// `seo.llms.honeypot` is enabled. No human visits it, so any fetch is an
|
|
2167
|
+
// automated reader that followed a link from the file. Always records (even
|
|
2168
|
+
// if the flag was later turned off, a cached llms.txt may still point here)
|
|
2169
|
+
// and returns a tiny noindex body.
|
|
2170
|
+
router.get('/ai-crawlers/trap', async (request) => {
|
|
2171
|
+
try {
|
|
2172
|
+
const detected = detectAiBot(request.headers.get('user-agent'));
|
|
2173
|
+
await recordAiCrawlerHit(db(), {
|
|
2174
|
+
bot: detected?.token ?? 'unknown',
|
|
2175
|
+
surface: 'honeypot',
|
|
2176
|
+
category: detected?.category ?? 'other',
|
|
2177
|
+
});
|
|
2178
|
+
return new Response('This endpoint exists to detect automated indexers.\n', {
|
|
2179
|
+
status: 200,
|
|
2180
|
+
headers: {
|
|
2181
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
2182
|
+
'X-Robots-Tag': 'noindex',
|
|
2183
|
+
},
|
|
2184
|
+
});
|
|
2185
|
+
}
|
|
2186
|
+
catch (err) {
|
|
2187
|
+
return internalError(err, 'ai-crawlers/trap');
|
|
2188
|
+
}
|
|
2189
|
+
});
|
|
2190
|
+
// Admin: aggregated AI-crawler activity for the SEO → Technical panel.
|
|
2191
|
+
// Any authenticated admin may read it (analytics, not a mutation).
|
|
2192
|
+
router.get('/ai-crawlers', async (request) => {
|
|
2193
|
+
try {
|
|
2194
|
+
const auth = await requireAuth(request);
|
|
2195
|
+
if (auth.error)
|
|
2196
|
+
return auth.error;
|
|
2197
|
+
if (!hasModel(db(), 'aiCrawlerHit')) {
|
|
2198
|
+
return json({
|
|
2199
|
+
data: { enabled: false, rows: [], totals: { hits: 0, bots: 0, honeypot: 0 } },
|
|
2200
|
+
});
|
|
2201
|
+
}
|
|
2202
|
+
const rows = await db().aiCrawlerHit.findMany({
|
|
2203
|
+
orderBy: { lastSeenAt: 'desc' },
|
|
2204
|
+
take: 500,
|
|
2205
|
+
});
|
|
2206
|
+
const totalHits = rows.reduce((sum, r) => sum + Number(r.hits ?? 0), 0);
|
|
2207
|
+
const honeypotHits = rows
|
|
2208
|
+
.filter((r) => r.surface === 'honeypot')
|
|
2209
|
+
.reduce((sum, r) => sum + Number(r.hits ?? 0), 0);
|
|
2210
|
+
const uniqueBots = new Set(rows.map((r) => r.bot)).size;
|
|
2211
|
+
// Recency windowing. `hits` is a lifetime cumulative counter (one row per
|
|
2212
|
+
// bot×surface), so we can't derive per-window hit *counts* from it — but
|
|
2213
|
+
// `lastSeenAt` lets us answer the actionable question "which bots/surfaces
|
|
2214
|
+
// were active in the last 24h / 7d". Per-window hit-count trends would
|
|
2215
|
+
// need a (bot, surface, day) bucket table (a future additive migration);
|
|
2216
|
+
// this keeps storage bounded while making recency visible today.
|
|
2217
|
+
const nowMs = Date.now();
|
|
2218
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
2219
|
+
const within = (v, ms) => {
|
|
2220
|
+
if (!v)
|
|
2221
|
+
return false;
|
|
2222
|
+
const t = v instanceof Date ? v.getTime() : new Date(v).getTime();
|
|
2223
|
+
return Number.isFinite(t) && nowMs - t <= ms;
|
|
2224
|
+
};
|
|
2225
|
+
const windowSummary = (ms) => {
|
|
2226
|
+
const active = rows.filter((r) => within(r.lastSeenAt, ms));
|
|
2227
|
+
return {
|
|
2228
|
+
bots: new Set(active.map((r) => r.bot)).size,
|
|
2229
|
+
surfaces: new Set(active.map((r) => r.surface)).size,
|
|
2230
|
+
rows: active.length,
|
|
2231
|
+
};
|
|
2232
|
+
};
|
|
2233
|
+
return json({
|
|
2234
|
+
data: {
|
|
2235
|
+
enabled: true,
|
|
2236
|
+
rows: rows.map((r) => ({
|
|
2237
|
+
bot: r.bot,
|
|
2238
|
+
surface: r.surface,
|
|
2239
|
+
category: r.category,
|
|
2240
|
+
hits: Number(r.hits ?? 0),
|
|
2241
|
+
firstSeenAt: r.firstSeenAt,
|
|
2242
|
+
lastSeenAt: r.lastSeenAt,
|
|
2243
|
+
activeLast24h: within(r.lastSeenAt, DAY_MS),
|
|
2244
|
+
activeLast7d: within(r.lastSeenAt, 7 * DAY_MS),
|
|
2245
|
+
})),
|
|
2246
|
+
totals: { hits: totalHits, bots: uniqueBots, honeypot: honeypotHits },
|
|
2247
|
+
windows: { last24h: windowSummary(DAY_MS), last7d: windowSummary(7 * DAY_MS) },
|
|
2248
|
+
},
|
|
2249
|
+
});
|
|
2250
|
+
}
|
|
2251
|
+
catch (err) {
|
|
2252
|
+
return internalError(err, 'ai-crawlers');
|
|
2253
|
+
}
|
|
2254
|
+
});
|
|
2255
|
+
}
|
|
2256
|
+
//# sourceMappingURL=seo.js.map
|