import { Payload, Field, Plugin, PayloadHandler, CollectionConfig, CollectionAfterChangeHook } from 'payload'; /** * Public URL construction for a document — single source of truth. * * A Payload document is keyed by a bare slug (`my-post`), but its public URL is * usually prefixed by the collection route (`/posts/my-post`). Every generator * in this plugin used to concatenate `siteUrl + '/' + slug` unconditionally, so * `sitemap.xml`, the canonical link and the JSON-LD `@id` all pointed at URLs * that 404 for a `posts` document — on an endpoint whose only consumer is * Googlebot. The analysis side already knew about the prefix * (`resolveToDocSlug` in linkExtractor.ts, and llms.txt hardcoded `/posts/`); * this helper makes the generation side agree with it. * * The default keeps the convention the plugin already shipped in llms.txt: * `posts` is served at `/posts/`, everything else at `/`. * A site that serves posts flat opts out with `collectionRoutes: { posts: '' }`. */ /** Map of collection slug → public route prefix. An empty value means "no prefix". */ type CollectionRoutes = Record; declare const DEFAULT_COLLECTION_ROUTES: CollectionRoutes; /** * Build the site-relative path of a document. * Returns `''` for the home page, so callers can append it to a site URL * without producing a trailing slash. */ declare function buildDocPath(slug: string, collectionSlug?: string, routes?: CollectionRoutes): string; /** Absolute public URL of a document. `siteUrl` may carry a trailing slash. */ declare function buildDocUrl(siteUrl: string, slug: string, collectionSlug?: string, routes?: CollectionRoutes): string; /** * SEO Analyzer — Type definitions. * Shared across all rule modules and the main orchestrator. */ type CheckStatus = 'pass' | 'warning' | 'fail'; type CheckCategory = 'critical' | 'important' | 'bonus'; /** Rule group label — used by the UI to group checks by topic */ type RuleGroup = 'title' | 'meta-description' | 'url' | 'headings' | 'content' | 'images' | 'linking' | 'social' | 'schema' | 'readability' | 'quality' | 'secondary-keywords' | 'cornerstone' | 'freshness' | 'technical' | 'accessibility' | 'ecommerce' | 'eeat' | 'geo' | 'hreflang'; interface SeoCheck { id: string; label: string; status: CheckStatus; message: string; category: CheckCategory; weight: number; /** Which rule group this check belongs to */ group: RuleGroup; /** Actionable tip displayed below the message when status is not 'pass' */ tip?: string; } type SeoLevel = 'poor' | 'ok' | 'good' | 'excellent'; interface SeoAnalysis { score: number; level: SeoLevel; checks: SeoCheck[]; /** * Separate "AI-readiness" indicator (SEO 2026) — how well the page is structured to be * CITED by generative engines (AI Overviews, ChatGPT, Perplexity). Aggregated from the * `geo` + `eeat` groups and the `schema-coverage` check. Distinct from the SEO `score` * because ranking and AI-citation are governed by different signals. */ aiReadiness?: { score: number; level: SeoLevel; /** Number of checks contributing to this indicator */ checkCount: number; }; } interface SeoInput { metaTitle?: string; metaDescription?: string; metaImage?: unknown; slug?: string; focusKeyword?: string; /** Secondary focus keywords (in addition to the primary keyword) */ focusKeywords?: string[]; heroTitle?: string; heroRichText?: unknown; heroLinks?: unknown[]; heroMedia?: unknown; blocks?: unknown[]; /** For posts — the Lexical content field */ content?: unknown; /** Whether the content is a post (for word count thresholds) */ isPost?: boolean; /** Whether the content is a product (triggers e-commerce SEO checks) */ isProduct?: boolean; /** Whether the content is marked as cornerstone/pillar content */ isCornerstone?: boolean; /** ISO date string — last time the document was updated */ updatedAt?: string; /** ISO date string — when the content was first published (E-E-A-T datePublished) */ publishedAt?: string; /** ISO date string — last time the content was manually reviewed */ contentLastReviewed?: string; /** Display/published date shown to readers (used to detect fake "refresh" — date changed without real updatedAt change) */ displayedDate?: string; /** Attributed author name (E-E-A-T) — empty/undefined if none */ author?: string; /** Author profile URL or sameAs link (E-E-A-T entity signal) */ authorUrl?: string; /** Locale alternates for hreflang validation — empty/undefined on mono-locale sites */ localeAlternates?: Array<{ hreflang: string; href: string; }>; /** Canonical URL for this page (if explicitly set) */ canonicalUrl?: string; /** Robots meta directives (e.g. 'noindex', 'nofollow', 'noindex, nofollow') */ robotsMeta?: string; /** Whether this content is a Payload Global (disables URL/slug checks) */ isGlobal?: boolean; } /** Page type for adapting SEO rule severity */ type PageType = 'legal' | 'contact' | 'form' | 'home' | 'service' | 'local-seo' | 'blog' | 'agency' | 'resource' | 'generic'; /** * Overridable thresholds for the SEO analyzer. * All fields are optional — defaults from constants.ts are used when omitted. */ interface SeoThresholds { titleLengthMin?: number; titleLengthMax?: number; metaDescLengthMin?: number; metaDescLengthMax?: number; minWordsGeneric?: number; minWordsPost?: number; keywordDensityMin?: number; keywordDensityMax?: number; fleschScorePass?: number; slugMaxLength?: number; } /** * Configuration for the SEO analyzer. * All fields are optional — sensible defaults are used when omitted. * Use this to adapt the analyzer to different projects/sites. */ interface SeoConfig { /** Additional local SEO slugs to recognize (appended to pattern detection) */ localSeoSlugs?: string[]; /** Regex pattern for detecting local-SEO pages (checked in addition to slug list) */ localSeoPattern?: RegExp; /** Additional stop word compounds for slug analysis (appended to defaults) */ stopWordCompounds?: Array; /** Maximum recursion depth for Lexical tree extraction (default: 50) */ maxRecursionDepth?: number; /** Base URL of the site (used for canonical URL validation) */ siteUrl?: string; /** Site name (used for brand duplicate check in titles) */ siteName?: string; /** Rule groups to disable entirely */ disabledRules?: RuleGroup[]; /** Override the weight of all checks within a rule group */ overrideWeights?: Partial>; /** Custom thresholds (override defaults from constants.ts) */ thresholds?: SeoThresholds; /** Locale for language-specific analysis (default: 'fr') */ locale?: 'fr' | 'en'; /** * Maps Payload locale codes onto the analysis locale. Read by the plugin's * endpoints and hooks when they resolve the locale of a request (see * `resolveAnalysisLocale`); `analyzeSeo()` itself only reads `locale`. */ localeMapping?: Record; /** * Public route prefix per collection, used when generating URLs * (sitemap, canonical, JSON-LD, llms.txt, IndexNow). * Defaults to `{ posts: 'posts' }`. Set `{ posts: '' }` if your posts are * served flat at `/`. */ collectionRoutes?: CollectionRoutes; } /** * Granular feature flags for the SEO plugin. * All features default to `true`. Set a feature to `false` to disable it * and avoid loading its collections, endpoints, and admin views. * * The core analyzer sidebar, validate endpoint, and meta fields are always active. */ interface SeoFeatures { /** Core SEO analyzer sidebar — always enabled, cannot be disabled */ analyzer?: boolean; /** Dashboard view with overview of all pages (/admin/seo) */ dashboard?: boolean; /** Redirect manager (collection + CRUD endpoints + view) */ redirects?: boolean; /** Performance / GSC import (collection + endpoints + view) */ performance?: boolean; /** Link graph visualization (endpoint + view) */ linkGraph?: boolean; /** Keyword research (endpoint + view) */ keywords?: boolean; /** Cannibalization detection (endpoint + view) */ cannibalization?: boolean; /** Schema.org / JSON-LD builder (endpoint + view) */ schemaBuilder?: boolean; /** Sitemap audit (endpoint + view) */ sitemapAudit?: boolean; /** SEO Logs — 404 tracking (collection + endpoints) */ seoLogs?: boolean; /** Score history tracking (collection + hook + endpoint) */ scoreHistory?: boolean; /** External links checker (endpoint) */ externalLinks?: boolean; /** AI content generation/rewrite (endpoints) */ aiFeatures?: boolean; /** * Google Search Console API via OAuth (opt-in, default false). Requires host setup: * a Google Cloud OAuth client (GSC_OAUTH_CLIENT_ID/SECRET), the Search Console API * enabled, the redirect URI registered, and ideally SEO_GSC_ENCRYPTION_KEY for token-at-rest. */ gscApi?: boolean; /** * Background cache warm-up on init + hourly (pre-loads collection data). Enabled by * default. Set to `false` on low-memory hosting to avoid loading all documents at startup. */ warmCache?: boolean; /** Duplicate content detection (endpoint) */ duplicateContent?: boolean; /** Settings view (/admin/seo-config) */ settings?: boolean; /** * Proactive monitoring digest (opt-in, default false). Periodically reports score * regressions, new 404s and ranking drops via webhook and/or email. Delivery + thresholds * are configured with env vars (SEO_ALERT_WEBHOOK_URL, SEO_ALERT_EMAIL, SEO_ALERT_SCORE_DROP, * SEO_ALERT_POSITION_DROP, SEO_ALERT_INTERVAL_HOURS). Email uses Payload's email adapter. */ alerts?: boolean; /** * IndexNow proactive indexing (opt-in, default false). Pings Bing/Yandex/etc. when content is * published. Requires `SEO_INDEXNOW_KEY` (a key you generate once) — the plugin serves the * verification file at `/indexnow-key.txt`. */ indexNow?: boolean; } /** Pre-computed context shared across all rule modules to avoid redundant work */ interface AnalysisContext { /** All plain text extracted from the page */ fullText: string; /** * Same text as fullText but with sentence boundaries at block ends (paragraphs, * list items, headings). Used for readability metrics (Flesch + sentence * segmentation) so scannable list-heavy content is not merged into giant * pseudo-sentences. Use fullText for word count / keyword density. */ readabilityText: string; /** Word count of fullText */ wordCount: number; /** Normalised focus keyword (lowercase, trimmed) — empty string if none */ normalizedKeyword: string; /** Normalised secondary keywords (lowercase, trimmed, duplicates removed) */ secondaryNormalizedKeywords: string[]; /** All headings found in the content */ allHeadings: Array<{ tag: string; text: string; }>; /** All links found in the content (url + anchor text) */ allLinks: Array<{ url: string; text: string; }>; /** Image statistics from blocks + content */ imageStats: { total: number; withAlt: number; altTexts: string[]; }; /** All sentences extracted from fullText */ sentences: string[]; /** Whether the content is a blog post (affects thresholds) */ isPost: boolean; /** Detected page type for adapting rule severity */ pageType: PageType; /** SEO configuration (merged defaults + user overrides) */ config: SeoConfig; /** Active locale for language-specific rules */ locale: 'fr' | 'en'; } /** * SEO Analyzer — Helper utilities. * Pure functions for extracting data from Lexical JSON, counting words/syllables, * and performing French-language readability analysis. */ /** * Strip diacritical marks (accents) from text for comparison. * "Réalité augmentée" → "realite augmentee" * Used to avoid false negatives when comparing keywords against content. */ declare function normalizeForComparison(text: string): string; /** * Slugify a keyword for URL comparison. * Strips accents, special chars (.&/), and converts spaces to hyphens. * "Next.js & React" → "nextjs-react" * "design UX/UI" → "design-ux-ui" */ declare function slugifyKeyword(kw: string): string; /** * Smart keyword matching adapted for French. * * For 1-2 word keywords: exact substring match (accent-insensitive). * For 3+ word keywords: all significant words (>3 chars) must be present * in the text. This handles French articles/prepositions that naturally * separate keyword components ("politique de confidentialité" vs * "politique confidentialité RGPD"). * * Both `keyword` and `text` must be pre-normalized with `normalizeForComparison()`. */ declare function keywordMatchesText(normalizedKeyword: string, normalizedText: string): boolean; /** * Count keyword occurrences in text with smart French matching. * * Returns exact occurrence count plus a word-level match flag for multi-word * keywords where individual significant words appear but not as exact phrase. * Also returns an estimated effective density. */ declare function countKeywordOccurrences(normalizedKeyword: string, normalizedText: string, totalWordCount: number): { exactCount: number; wordLevelMatch: boolean; effectiveDensity: number; }; /** * Detect the page type from the slug (and optionally the collection) to adapt * SEO rule severity. Unified version used by both the analyzer and AI prompts. * * Priority order: * 1. Collection-based detection (posts → blog) * 2. Exact slug matches (home, legal) * 3. Pattern-based detection (local-seo, service, resource, agency, blog) * 4. Fallback → generic * * @param extraLocalSeoSlugs Additional local SEO slugs from SeoConfig */ declare function detectPageType(slug: string, collection?: string, extraLocalSeoSlugs?: string[], locale?: 'fr' | 'en'): PageType; /** * Recursively extract plain text from a Lexical JSON tree. * @param maxDepth Maximum recursion depth to prevent stack overflow (default: 50) */ declare function extractTextFromLexical(node: unknown, maxDepth?: number, sentenceBoundaries?: boolean): string; /** * Extract headings from a Lexical JSON tree. * Returns an array of { tag: 'h1' | 'h2' | ..., text: string } * @param maxDepth Maximum recursion depth to prevent stack overflow (default: 50) */ declare function extractHeadingsFromLexical(node: unknown, maxDepth?: number): Array<{ tag: string; text: string; }>; /** * Extract links from a Lexical JSON tree. * Returns an array of { url: string, text: string } * @param maxDepth Maximum recursion depth to prevent stack overflow (default: 50) */ declare function extractLinksFromLexical(node: unknown, maxDepth?: number): Array<{ url: string; text: string; }>; /** * Extract link URLs from Lexical (flat array — backward compat). */ declare function extractLinkUrlsFromLexical(node: unknown): string[]; /** * Extract images from a Lexical JSON tree. * @param maxDepth Maximum recursion depth to prevent stack overflow (default: 50) */ declare function extractImagesFromLexical(node: unknown, maxDepth?: number): { total: number; withAlt: number; altTexts: string[]; }; /** * Check images in blocks (MediaBlock, Content columns, etc.). */ declare function checkImagesInBlocks(blocks: unknown[]): { total: number; withAlt: number; altTexts: string[]; }; /** * Count words in a string. */ declare function countWords(text: string): number; /** * Split text into sentences (French/English-aware). * Handles common abbreviations (M., Mme., Mr., Mrs., etc.) to avoid false splits. */ declare function countSentences(text: string, locale?: 'fr' | 'en'): string[]; /** * Count syllables in a French word (Kandel-Moles approximation). * * Rules: * 1. Count vowel groups (a, e, i, o, u, y, accented variants) * 2. Silent final 'e' does not count (except single-syllable words) * 3. Common diphthongs (eau, ou, ai, ei, au, oi, eu) count as 1 vowel group */ declare function countSyllablesFR(word: string): number; /** * Count syllables in an English word. * Handles silent-e, -ed/-es endings, common exceptions. */ declare function countSyllablesEN(word: string): number; /** * Calculate Flesch reading ease adapted for French (Kandel-Moles formula). * Flesch FR = 207 - 1.015 * (words/sentences) - 73.6 * (syllables/words) * * Returns a score from 0 (very difficult) to ~100 (very easy). */ declare function calculateFleschFR(text: string): number; /** * Calculate Flesch reading ease — bilingual. * FR: Kandel-Moles (207 - 1.015xASL - 73.6xASW) * EN: Flesch-Kincaid (206.835 - 1.015xASL - 84.6xASW) */ declare function calculateFlesch(text: string, locale?: 'fr' | 'en'): number; /** * Detect passive voice in a French sentence. * Looks for "être" conjugations followed by a past participle, * while excluding common passé composé verbs (aller, venir, arriver, etc.) * that use "être" as auxiliary but are NOT passive voice. * * Also excludes state descriptions ("est basé à", "est situé à") that are * natural in French B2B copy and not true passive constructions. */ declare function detectPassiveVoice(sentence: string, locale?: 'fr' | 'en'): boolean; /** * Check whether a sentence contains a transition word/phrase. */ declare function hasTransitionWord(sentence: string, locale?: 'fr' | 'en'): boolean; /** * Get the list of French stop words (for slug analysis). */ declare function getStopWordsFR(): readonly string[]; /** * Compound expressions in French where stop words are meaningful. * These should NOT be flagged in slug analysis. * Checks if a stop word at position `idx` in slug parts is part of a known expression. */ declare function isStopWordInCompoundExpression(parts: string[], idx: number, extraCompounds?: Array): boolean; /** * French action verbs commonly used in CTAs. */ declare function getActionVerbsFR(): readonly string[]; /** * Check for long sections (>N words without a heading break). * Returns the number of sections that exceed the threshold. */ declare function countLongSections(node: unknown, threshold?: number): number; /** * Extract lists (ol/ul) from a Lexical JSON tree. * Returns an array of { listType: 'bullet' | 'number', items: number }. */ declare function extractListsFromLexical(node: unknown, maxDepth?: number): Array<{ listType: string; items: number; }>; /** * Check heading hierarchy: no level skipping (e.g. h2 -> h4 without h3). */ declare function checkHeadingHierarchy(headings: Array<{ tag: string; text: string; }>): boolean; /** * Dashboard i18n — FR/EN translations for all dashboard UI components. * Provides a simple, pragmatic translation system for the SEO Analyzer dashboard. */ type DashboardLocale = 'fr' | 'en' | (string & {}); interface DashboardTranslations { common: { loading: string; loadingError: string; retry: string; save: string; saving: string; cancel: string; refresh: string; exportCsv: string; exportJson: string; exportPdf: string; edit: string; delete: string; add: string; noResults: string; previous: string; next: string; page: string; selected: string; deselectAll: string; characters: string; generate: string; generating: string; copy: string; serverError: string; networkError: string; noTitle: string; none: string; article: string; ok: string; modify: string; }; nav: { dashboard: string; sitemapAudit: string; redirects: string; cannibalization: string; performance: string; keywords: string; schemaOrg: string; linkGraph: string; settings: string; seo: string; }; seoView: { loadingAudit: string; buildingAudit: string; buildTimeout: string; runAudit: string; auditNotRun: string; errorSaving: string; auditTitle: string; pagesAnalyzed: string; markCornerstone: string; unmarkCornerstone: string; bulkOptimizeMeta: string; bulkOptimizing: string; bulkConfirm: string; optimizeSite: string; optimizeSiteHint: string; bulkPreviewTitle: string; bulkApply: string; bulkApplying: string; bulkCancel: string; bulkExport: string; bulkNoChanges: string; bulkCappedNote: string; bulkAppliedNote: string; searchPlaceholder: string; allCollections: string; allScores: string; goodScores: string; needsWork: string; criticalScores: string; missingMeta: string; missingH1: string; lowReadability: string; averageScore: string; goodLabel: string; needsWorkLabel: string; criticalLabel: string; noKeyword: string; wordsAvg: string; metaTitle: string; metaDesc: string; cornerstone: string; collection: string; score: string; keyword: string; h1: string; og: string; internal: string; external: string; words: string; readability: string; updated: string; seoReport: string; overview: string; missingMetaTitle: string; missingMetaDescription: string; missingKeyword: string; shortContent: string; lowReadabilityIssue: string; missingOgImage: string; noInternalLinks: string; lowOverallScore: string; shortMetaTitle: string; longMetaTitle: string; shortMetaDesc: string; longMetaDesc: string; top5PriorityActions: string; perPageDetails: string; title: string; slug: string; issues: string; identifiedIssues: string; scoreDistribution: string; good: string; critical: string; generatedBy: string; resultsDisplayed: string; noKeywordLabel: string; wordsAverage: string; readabilityAvg: string; noTitleDesc: string; previousLabel: string; }; sitemapAudit: { loading404: string; title: string; totalPages: string; internalLinks: string; linksPerPage: string; average: string; orphaned: string; fragile: string; brokenLinks: string; searchPlaceholder: string; orphanedPages: string; fragilePages: string; linkHubs: string; logs404: string; externalLinks: string; noOrphanedPages: string; orphanedPagesDesc: string; zeroIncomingLinks: string; noFragilePages: string; fragilePagesDesc: string; oneIncomingLink: string; from: string; anchor: string; noLinkHubs: string; linkHubsDesc: string; links: string; brokenLinksDesc: string; created: string; errors: string; selectedItems: string; selectAll: string; suggestion: string; targetSlug: string; createdLabel: string; no404Errors: string; pages404Desc: string; last: string; ref: string; ignore: string; checkExternalLinksDesc: string; scanExternalLinks: string; verificationInProgress: string; noBrokenLinks: string; noExternalLinks: string; total: string; broken: string; timeout: string; all: string; brokenLabel: string; rescan: string; forceNewVerification: string; analyzingInternal: string; brokenLinkTo: string; linkFrom: string; }; seoConfig: { loading: string; title: string; subtitle: string; saved: string; siteName: string; siteNameDesc: string; ignoredPages: string; ignoredPagesDesc: string; pageSlugPlaceholder: string; noIgnoredSlugs: string; disabledRules: string; disabledRulesDesc: string; customThresholds: string; customThresholdsDesc: string; defaultLabel: string; ruleGroupTitle: string; ruleGroupMetaDescription: string; ruleGroupUrlSlug: string; ruleGroupHeadings: string; ruleGroupContent: string; ruleGroupImages: string; ruleGroupLinks: string; ruleGroupSocial: string; ruleGroupStructuredData: string; ruleGroupReadability: string; ruleGroupQuality: string; ruleGroupSecondaryKeywords: string; ruleGroupCornerstone: string; ruleGroupFreshness: string; ruleGroupTechnical: string; ruleGroupAccessibility: string; ruleGroupEcommerce: string; thresholdTitleMin: string; thresholdTitleMax: string; thresholdMetaDescMin: string; thresholdMetaDescMax: string; thresholdMinWordsPages: string; thresholdMinWordsPosts: string; thresholdKeywordDensityMin: string; thresholdKeywordDensityMax: string; thresholdFleschMin: string; thresholdSlugMaxLength: string; sitemapConfig: string; sitemapExcludedSlugs: string; slugToExcludePlaceholder: string; noExcludedSlugs: string; defaultChangeFrequency: string; daily: string; weekly: string; monthly: string; yearly: string; defaultPriority: string; defaultPriorityDesc: string; priorityOverrides: string; priorityOverridesDesc: string; patternPlaceholder: string; priority: string; defaultShort: string; addOverride: string; sitemapPreview: string; viewPreview: string; refreshPreview: string; pages: string; included: string; excluded: string; url: string; lastModified: string; frequency: string; breadcrumbConfig: string; breadcrumbConfigDesc: string; enableBreadcrumbs: string; homePageLabel: string; separator: string; separatorDesc: string; showOnHomePage: string; showOnHomePageDesc: string; preview: string; websiteCreation: string; }; redirectManager: { loading: string; title: string; totalRedirects: string; importCsv: string; total: string; permanent301: string; temporary302: string; addRedirect: string; sourceUrl: string; sourceUrlPlaceholder: string; destinationUrl: string; destinationUrlPlaceholder: string; type: string; adding: string; searchPlaceholder: string; urlToTestPlaceholder: string; test: string; noMatch: string; sourceFrom: string; destinationTo: string; date: string; actions: string; noMatchingRedirects: string; noRedirects: string; redirectCreated: string; redirectDeleted: string; redirectsDeleted: string; redirectModified: string; noValidCsvRedirects: string; redirectsExported: string; exportError: string; createdCount: string; duplicatesCount: string; errorsCount: string; }; performance: { days7: string; days30: string; days90: string; loading: string; title: string; subtitle: string; closeImport: string; import: string; importGscData: string; fileType: string; pasteJsonHint: string; importing: string; importedCount: string; updatedCount: string; noData: string; noDataDesc: string; importData: string; totalClicks: string; totalImpressions: string; averageCtr: string; averagePosition: string; topPages: string; clicks: string; impressions: string; ctrPercent: string; position: string; topQueries: string; query: string; xlsxEntriesLoaded: string; pages: string; queries: string; }; schemaBuilder: { localBusiness: string; article: string; product: string; faq: string; howTo: string; organization: string; event: string; choose: string; jsonLdPreview: string; liveUpdate: string; copyJsonLd: string; copyScriptTag: string; copied: string; copyError: string; tip: string; beforeDeploying: string; visualGeneratorDesc: string; addButton: string; name: string; description: string; address: string; city: string; postalCode: string; country: string; phone: string; email: string; openingHours: string; latitude: string; longitude: string; website: string; contactEmail: string; socialMediaUrls: string; knowsAbout: string; startDate: string; endDate: string; location: string; locationAddress: string; ticketUrl: string; title: string; author: string; authorSameAs: string; publisher: string; imageUrl: string; publicationDate: string; sku: string; brand: string; price: string; currency: string; availability: string; inStock: string; outOfStock: string; preOrder: string; madeToOrder: string; questionsAnswers: string; question: string; answer: string; steps: string; stepTitle: string; stepDescription: string; priceRange: string; modificationDate: string; publisherName: string; publisherLogo: string; productName: string; reviewCount: string; ratingValue: string; guideTitle: string; logoUrl: string; imageLabel: string; urlLabel: string; validateOnRichResults: string; }; keywordResearch: { loading: string; title: string; subtitle: string; activeKeywords: string; uniqueTerms: string; suggestions: string; searchPlaceholder: string; noSuggestions: string; noSuggestionsDesc: string; noMatchingSuggestions: string; unused: string; associated: string; trending: string; longTail: string; all: string; unusedPlural: string; associatedPlural: string; trendingPlural: string; keyword: string; freq: string; seeLess: string; type: string; score: string; frequency: string; usedBy: string; suggestedFor: string; }; cannibalization: { loading: string; title: string; subtitle: string; conflicts: string; affectedPages: string; searchPlaceholder: string; noCannibalization: string; noCannibalizationDesc: string; noMatchingConflicts: string; highRisk: string; warning: string; pages: string; }; seoAnalyzer: { groupTitle: string; groupDescription: string; groupUrlSlug: string; groupHeadings: string; groupContent: string; groupImages: string; groupLinks: string; groupSocial: string; groupStructuredData: string; groupReadability: string; groupQuality: string; groupSecondaryKeywords: string; groupCornerstone: string; groupFreshness: string; groupTechnical: string; groupAccessibility: string; groupEcommerce: string; groupEeat: string; groupGeo: string; groupHreflang: string; levelExcellent: string; levelGood: string; levelFair: string; levelNeedsImprovement: string; categoryCritical: string; categoryImportant: string; categoryBonus: string; seoScore: string; aiReadiness: string; aiReadinessTooltip: string; outOf100: string; cornerstoneLabel: string; checksPassed: string; errorsCount: string; warningsCount: string; improvementSuggestions: string; adjustTitle: string; currently: string; titleCharactersIdeal: string; writeMetaDesc: string; enrichContent: string; includeKeywordInTitle: string; includeKeywordInDesc: string; addAltText: string; addInternalLinks: string; seoCannibalization: string; highRisk: string; duplicationHarmsRanking: string; diluteRanking: string; viewPages: string; uniqueKeywordAdvice: string; suggestedInternalLinks: string; analyzingContent: string; readingTime: string; wordsLabel: string; secondaryKeywords: string; generateMeta: string; metaTitle: string; metaDescription: string; emptyValue: string; optimizeWithAi: string; optimizeIntro: string; optimizeRunning: string; applyAll: string; applied: string; whyChanges: string; labelCurrent: string; labelSuggested: string; labelFocusKeyword: string; heuristicNote: string; applySaveHint: string; noMetaChange: string; }; scoreHistory: { loading: string; loadingError: string; noHistory: string; noHistoryDesc: string; improving: string; declining: string; stable: string; scoreEvolution: string; measures: string; latestScore: string; }; contentDecay: { title: string; lessThan3Months: string; months3to6: string; months6to12: string; moreThan12Months: string; description: string; lastUpdate: string; lastReview: string; ageDays: string; action: string; reviewed: string; markReviewed: string; noData: string; }; socialPreview: { title: string; facebook: string; twitter: string; ogImage: string; cardImage: string; previewTitle: string; previewDescription: string; characters: string; pageTitlePlaceholder: string; pageDescriptionPlaceholder: string; }; serpPreview: { title: string; desktop: string; mobile: string; noMetaTitle: string; noMetaDescription: string; previewTitle: string; previewDescription: string; url: string; characters: string; }; metaTitle: { label: string; placeholder: string; characters: string; optimal: string; charactersMissing: string; charactersTooMany: string; idealLength: string; }; metaDescription: { label: string; placeholder: string; characters: string; optimal: string; charactersMissing: string; charactersTooMany: string; idealLength: string; }; metaImage: { label: string; imageSet: string; noImage: string; set: string; }; overview: { metaCompleteness: string; incomplete: string; partial: string; almostComplete: string; complete: string; titleLabel: string; descriptionLabel: string; imageLabel: string; set: string; missing: string; }; } /** * Register custom translations for a locale. * Supports partial overrides — only the keys you provide will be replaced. * Call this before the dashboard renders (e.g. in plugin init). * * @example * ```ts * registerDashboardTranslations('cs', { * common: { loading: 'Načítání...', save: 'Uložit', cancel: 'Zrušit', ... }, * nav: { dashboard: 'Přehled', seo: 'SEO', ... }, * ... * }) * ``` */ declare function registerDashboardTranslations(locale: string, custom: Partial | DashboardTranslations, /** Base locale to use for missing keys (default: 'en') */ fallbackLocale?: string): void; declare function getDashboardT(locale: DashboardLocale): DashboardTranslations; /** * Retention for the four time-series collections the plugin only ever appends to. * * `seo-rank-history` gains a row per tracked query per day, `seo-score-history` one * per document per save, `seo-performance` one per imported Search Console row and * `seo-logs` one per distinct 404 URL. Nothing ever deleted them, so on a busy site * they grow without bound — an operational problem (database size, slower dashboard * queries), not a privacy one: the only visitor-derived data is the `referrer` and * `userAgent` on `seo-logs`, and no IP address is stored anywhere. * * OPT-IN. With no `retentionDays` option nothing is ever deleted, which is the * behaviour every existing install already has. * * Three of these four collections declare `timestamps: false`, so they have no * `createdAt` column: each purge is driven by the collection's own date field. */ interface RetentionTarget { /** Collection slug. */ slug: string; /** * Date field the purge compares against. NOT `createdAt`: seo-rank-history, * seo-score-history and seo-performance are declared `timestamps: false`. */ dateField: string; } declare const RETENTION_TARGETS: readonly RetentionTarget[]; type RetentionCollection = 'seo-rank-history' | 'seo-score-history' | 'seo-performance' | 'seo-logs'; /** Days to keep, per collection. Omitted or invalid entries are never purged. */ type RetentionConfig = Partial>; interface ResolvedRetention extends RetentionTarget { days: number; } /** * Turn the user-supplied option into the list of purges to run. * * Deliberately strict: a value that is not a finite number of at least one day is * DROPPED, not clamped. `0`, `-1`, `NaN` and `'30'` would each mean "delete * everything" under a naive clamp, and this function deletes data. */ declare function resolveRetention(input: RetentionConfig | undefined): ResolvedRetention[]; interface PurgeCollectionResult { collection: string; /** Rows deleted. `0` when the collection is absent or the delete failed. */ deleted: number; /** Everything strictly older than this was removed. */ cutoff: string; /** Present only when nothing was deleted for a reason worth reporting. */ skipped?: 'not_registered'; error?: string; } interface PurgeResult { ran: boolean; results: PurgeCollectionResult[]; totalDeleted: number; } /** * Delete every row older than its collection's retention window. * * Sequential on purpose: the plugin's reference host runs SQLite, where parallel * writes produce `SQLITE_BUSY`. One failing collection is reported and the others * still run. */ declare function purgeRetention(payload: Payload, input: RetentionConfig | undefined, options?: { now?: Date; dryRun?: boolean; }): Promise; /** Preview what a purge would remove, without deleting anything. */ declare function describeRetention(input: RetentionConfig | undefined, now?: Date): { collection: string; dateField: string; days: number; cutoff: string; }[]; /** Arguments passed to generate functions (generateTitle, generateDescription, etc.) */ interface GenerateFnArgs$1 { doc: Record; locale?: string; req: unknown; collectionSlug?: string; globalSlug?: string; } interface SeoPluginConfig { /** Collections to add SEO fields to (default: ['pages', 'posts']) */ collections?: string[]; /** Globals to add SEO fields to (default: []) */ globals?: string[]; /** Whether to add the SEO dashboard view at /admin/seo (default: true) */ addDashboardView?: boolean; /** Rule groups to disable entirely */ disabledRules?: RuleGroup[]; /** Override the weight of all checks within a rule group */ overrideWeights?: Partial>; /** Custom thresholds (override defaults from constants) */ thresholds?: SeoThresholds; /** Additional local SEO slugs for the project */ localSeoSlugs?: string[]; /** Site name (used for brand duplicate check in titles) */ siteName?: string; /** Base URL of the site (used for canonical URL validation, e.g. 'https://example.com') */ siteUrl?: string; /** Base path for API endpoints (default: '/seo-plugin') */ endpointBasePath?: string; /** Whether to track SEO score history (adds a collection + afterChange hook, default: true) */ trackScoreHistory?: boolean; /** Whether to add the sitemap audit view at /admin/sitemap-audit (default: true) */ addSitemapAuditView?: boolean; /** Collection slug for redirects (default: 'seo-redirects'). The plugin auto-creates this collection. */ redirectsCollection?: string; /** * Allow redirect destinations pointing to another origin (absolute http(s) URLs). * Default: `false` — an off-site 301 served from your own paths is a site-hijack * primitive (phishing with your domain's authority, OAuth redirect_uri abuse), and * nothing in the plugin used to forbid it. Turn it on only if you genuinely need * cross-origin redirects. */ allowExternalRedirects?: boolean; /** Known dynamic routes that are not stored as document slugs (e.g. ['blog', 'réalisations', 'posts']). These won't be flagged as broken links or orphan pages. */ knownRoutes?: string[]; /** * Public route prefix per collection, used by every URL the plugin generates * (sitemap.xml, canonical, JSON-LD, llms.txt, IndexNow). * Default: `{ posts: 'posts' }` — the convention llms.txt already shipped. * Set `{ posts: '' }` if your posts are served flat at `/`, or add your * own entries (e.g. `{ projects: 'work' }`). */ collectionRoutes?: CollectionRoutes; /** * Secret header value for the seo-logs POST endpoint. If set, POST requests must carry the * `X-SEO-Secret` header with this value — which is what lets a host's 404 middleware log hits * from ANONYMOUS visitors. If not set, POST requires an SEO-admin session (the same gate as the * collection's own `create` ACL), so a panel editor cannot write rows the collection refuses them. */ seoLogsSecret?: string; /** Locale for language-specific analysis (default: 'fr') */ locale?: 'fr' | 'en'; /** Collection slug for uploads/media (used for meta.image relationTo). Default: 'media' */ uploadsCollection?: string; /** Auto-create meta fields (title, description, image) on target collections. Default: true */ autoCreateMetaFields?: boolean; /** * Path to a build-time audit cache file (JSON, produced by `buildAuditToFile()`). * When set, the heavy site-wide dashboard audit is hydrated from this file on a cache * miss instead of being recomputed live — offloading the cost to CI on memory-constrained * hosts (e.g. Infomaniak). Stale-guarded: ignored once content changes (a live rebuild * takes over). Runtime kill-switch: set env `SEO_AUDIT_FILE_CACHE=0` to ignore the file. */ auditCacheFile?: string; /** Granular feature flags — all default to true. Disable features you don't need * to reduce collections, endpoints, and admin views loaded by the plugin. * The core analyzer sidebar, validate endpoint, and meta fields are always active. */ features?: SeoFeatures; /** Custom function to generate meta title */ generateTitle?: (args: GenerateFnArgs$1) => string | Promise; /** Custom function to generate meta description */ generateDescription?: (args: GenerateFnArgs$1) => string | Promise; /** Custom function to generate meta image (returns media ID or URL) */ generateImage?: (args: GenerateFnArgs$1) => string | number | Promise; /** Custom function to generate page URL */ generateURL?: (args: GenerateFnArgs$1) => string | Promise; /** Mapping from Payload locale codes to analysis locale ('fr' | 'en') */ localeMapping?: Record; /** Custom dashboard translations for additional locales (e.g. 'cs', 'de', 'es'). * Partial overrides are supported — missing keys fall back to English. * @example * ```ts * customTranslations: { * cs: { * common: { loading: 'Načítání...', save: 'Uložit' }, * nav: { dashboard: 'Přehled', seo: 'SEO' }, * } * } * ``` */ customTranslations?: Record>; /** Override or reorganize the default meta fields inside the 'meta' group. * Receives the default fields (overview, title, description, image, preview) and must return a Field[]. * Use this to add custom fields, remove defaults, or reorder them. */ fields?: (args: { defaultFields: Field[]; }) => Field[]; /** If true, wraps collection/global fields in a tabbed UI with "Content" and "SEO" tabs. * Compatible with collections that already use a tabs field as their first field. */ tabbedUI?: boolean; /** Custom TypeScript interface name for the generated meta group type (e.g. 'SharedSEO') */ interfaceName?: string; /** * Days of history to keep, per time-series collection. OPT-IN: with this * option absent nothing is ever deleted, which is what every install does * today. * * `seo-rank-history`, `seo-score-history`, `seo-performance` and `seo-logs` * are append-only and grow without bound. Naming one here schedules a daily * purge of the rows older than the window, and registers * `GET/POST /retention` for SEO admins (dry run / run now). * * A value that is not a finite number of at least 1 is ignored rather than * clamped — `0` would mean "delete everything". * * @example * ```ts * retentionDays: { 'seo-rank-history': 365, 'seo-logs': 90 } * ``` */ retentionDays?: RetentionConfig; } declare const seoAnalyzerPlugin: (pluginConfig?: SeoPluginConfig) => Plugin; /** * SEO Plugin — Lightweight i18n translation system. * Provides French (default) and English translations for all plugin views * and all rule messages/labels/tips. * No external dependencies. */ type SeoLocale = 'fr' | 'en'; /** Analysis locale options forwarded from `SeoPluginConfig` to the sidebar field. */ interface SeoAnalysisLocaleOptions { /** `SeoPluginConfig.locale` — overrides everything, as on the server. */ locale?: SeoLocale; /** `SeoPluginConfig.localeMapping` — Payload locale code → analysis locale. */ localeMapping?: Record; } /** * SEO fields added automatically to collections by the plugin. * Includes: focusKeyword, focusKeywords array, isCornerstone, and the SeoAnalyzer UI. */ /** * @param options - `locale` and `localeMapping` from the plugin config, forwarded * to the sidebar analyzer through `admin.custom` so it resolves the analysis * locale exactly like the server endpoints do. */ declare function seoFields(options?: SeoAnalysisLocaleOptions): Field[]; /** * Auto-created meta fields definition. * Generates a "meta" group field with title, description, image, overview, and preview sub-fields. * These fields mirror @payloadcms/plugin-seo's field structure for compatibility. * * Usage: * import { metaFields } from '@consilioweb/payload-seo-analyzer' * // ...fields: [...metaFields({ uploadsCollection: 'media', hasGenerateTitle: true })] */ interface MetaFieldsConfig { /** Collection slug for image uploads (default: 'media') */ uploadsCollection?: string; /** Custom TypeScript interface name for the meta group (for generated types) */ interfaceName?: string; /** Whether a generateTitle function is configured */ hasGenerateTitle?: boolean; /** Whether a generateDescription function is configured */ hasGenerateDescription?: boolean; /** Whether a generateImage function is configured */ hasGenerateImage?: boolean; /** Base API path for the generate endpoint (default: '/api/seo-plugin') */ basePath?: string; } /** * Generate the meta field group (title, description, image, overview, preview). * Compatible with @payloadcms/plugin-seo's field naming for easy migration. */ declare function metaFields(config?: MetaFieldsConfig): Field[]; /** * Resolve the effective analysis locale from multiple sources. * * Cascade order: * 1. Explicit pluginLocale override (from SeoPluginConfig.locale) * 2. Custom mapping (pluginConfig.localeMapping) applied to reqLocale * 3. Auto-detect from reqLocale (req.locale from Payload's i18n) * 4. Fallback → 'fr' * * Mapping logic: 'fr*' → 'fr', 'en*' → 'en', anything else → fallback */ interface ResolveLocaleArgs { /** req.locale from Payload (e.g. 'fr', 'en', 'en-US', 'fr-FR') */ reqLocale?: string; /** Static locale from SeoPluginConfig.locale — overrides everything */ pluginLocale?: 'fr' | 'en'; /** Custom mapping from Payload locale → analysis locale */ customMapping?: Record; } /** * Resolve the effective locale for SEO analysis. * Returns 'fr' or 'en'. */ declare function resolveAnalysisLocale(args: ResolveLocaleArgs): SeoLocale; /** * Fetch all documents from target collections + globals in a uniform format. * Reused by audit, cannibalization, linkGraph, keywordResearch, etc. */ type DocSourceType = 'collection' | 'global'; interface FetchedDoc { doc: any; sourceType: DocSourceType; sourceSlug: string; } interface FetchAllDocsOptions { collections: string[]; globals?: string[]; /** * Hard cap on the TOTAL number of collection docs loaded into memory (across all * collections). Defaults to env `SEO_FETCH_MAX_DOCS` or 5000. Bounds memory while * paginating through everything below the cap (no silent 500-doc truncation). */ maxDocs?: number; /** @deprecated alias for `maxDocs` — kept for backwards compatibility. */ limit?: number; /** Depth for population (default: 1) */ depth?: number; /** * Restrict the fields read from the database, e.g. `{ title: true, slug: true }`. * Callers that only need a few scalar fields should pass this: without it every * document is loaded whole, Lexical trees included. * Applied best-effort — see the select-less retry below. */ select?: Record; } /** * Fetch all documents from the specified collections and globals. * Returns a unified array of { doc, sourceType, sourceSlug }. * * Paginates through every page (up to `maxDocs`) instead of a single capped * `find({ limit, pagination: false })` — the old behaviour silently dropped docs * beyond 500, producing false "orphan"/"broken link" results on larger sites. */ declare function fetchAllDocs(payload: Payload, options: FetchAllDocsOptions): Promise; /** * SEO Validate endpoint handler. * Runs the full SEO engine (50+ checks) on a page or post. * * NOTE: Rate limiting is not handled by this plugin. The consuming application * should implement rate limiting via its own middleware (e.g., express-rate-limit, * Next.js middleware, or a reverse proxy like Nginx/Caddy). */ /** * Build a SeoInput object from a Payload document (page or post). * Exported for reuse in other endpoints or custom integrations. */ declare function buildSeoInputFromDoc(doc: any, collection: string, options?: { isGlobal?: boolean; }): SeoInput; /** * Frontend metadata helper — turns a Payload document into a Next.js-compatible `Metadata` * object (title, description, canonical, hreflang, robots, Open Graph, Twitter). * * This closes the loop between analysis and production: instead of only grading the SEO in the * admin, the plugin can now PRODUCE the actual `` metadata. Use it in a Next.js page: * * export async function generateMetadata({ params }) { * const doc = await payload.findByID({ collection: 'pages', id, depth: 1 }) * return buildSeoMetadata(doc, { collection: 'pages', siteUrl, siteName: 'My Site' }) * } * * The return value is a structural subset of Next's `Metadata` (assignable to it) — kept * dependency-free so the plugin doesn't hard-depend on `next`. */ interface SeoMetadataOptions { /** Collection slug — used to pick the Open Graph type (posts → 'article') */ collection?: string; /** Absolute site URL (defaults to NEXT_PUBLIC_SERVER_URL / PAYLOAD_PUBLIC_SERVER_URL) */ siteUrl?: string; /** Site name for Open Graph */ siteName?: string; /** Template for the title, e.g. "%s | My Site" (%s = the page title) */ titleTemplate?: string; /** Fallback OG/Twitter image (absolute, or site-relative) when the document has none */ defaultImage?: string; /** Open Graph locale, e.g. 'fr_FR' */ locale?: string; /** * Public route prefix per collection, e.g. `{ posts: 'posts' }` (the default). * The canonical used to be `siteUrl + '/' + slug` for every collection, so a * post's canonical pointed at a 404 — which can deindex the real page. * Pass `{ posts: '' }` if your posts are served flat at `/`. */ collectionRoutes?: CollectionRoutes; } interface SeoMetadata { title?: string; description?: string; alternates?: { canonical?: string; languages?: Record; }; robots?: { index: boolean; follow: boolean; }; openGraph?: { title?: string; description?: string; url?: string; siteName?: string; type?: string; locale?: string; images?: Array<{ url: string; }>; }; twitter?: { card?: string; title?: string; description?: string; images?: string[]; }; } /** * Build a Next.js-compatible `Metadata` object from a Payload document. * Pure — safe to call inside `generateMetadata()` (server side). */ declare function buildSeoMetadata(doc: Record, options?: SeoMetadataOptions): SeoMetadata; /** * Schema.org / JSON-LD generation — pure, framework-agnostic builders. * * Single source of truth shared by the admin endpoint (`endpoints/schemaGenerator.ts`) and the * frontend render helper (`buildJsonLd` + the `` component), so the structured data the * dashboard previews is byte-for-byte what the site renders. */ declare const SCHEMA_TYPES: readonly ["Article", "LocalBusiness", "BreadcrumbList", "FAQPage", "Product", "Organization", "Person", "Event", "Recipe", "Video"]; type SchemaType = (typeof SCHEMA_TYPES)[number]; /** Resolve an absolute image URL from a populated meta.image or hero media object. */ declare function getSchemaImageUrl(metaImage: Record | undefined, heroMedia: Record | undefined, siteUrl: string): string | undefined; /** Detect schema type from collection slug and document content. */ declare function detectSchemaType(collection: string, doc: Record): SchemaType; interface BuildJsonLdOptions { /** Collection slug — used to auto-detect the schema type when `type` is omitted */ collection?: string; /** Absolute site URL (defaults to NEXT_PUBLIC_SERVER_URL / PAYLOAD_PUBLIC_SERVER_URL) */ siteUrl?: string; /** Force a specific schema type instead of auto-detecting */ type?: SchemaType; /** * Public route prefix per collection, e.g. `{ posts: 'posts' }` (the default). * Without it a `posts` document advertised a `@id` / `url` of `/`, which * 404s. Pass `{ posts: '' }` if your posts are served flat. */ collectionRoutes?: CollectionRoutes; } /** * Build a clean JSON-LD object from a Payload document. Pure — safe to call on the server * during rendering (e.g. in a Next.js Server Component) or from the admin endpoint. */ declare function buildJsonLd(doc: Record, options?: BuildJsonLdOptions): { type: SchemaType; jsonLd: Record; }; /** * Serialize a JSON-LD object for safe embedding inside a `` would close the tag and let the browser parse the rest * of the payload as HTML — a stored XSS on the public site, writable by anyone * with edit rights on a document. * * `\u003c` / `\u003e` / `\u0026` are JSON escape sequences: a JSON parser reads * them back as the exact same characters, so Google's rich-results parsing is * unaffected. U+2028 / U+2029 are escaped too, since they are raw line * terminators for a JS parser. */ declare function serializeJsonLd(jsonLd: unknown): string; /** * Convenience: build the JSON-LD and return a ready-to-inject `