@focus-reactive/payload-plugin-seo 1.3.0 → 1.5.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/README.md +242 -60
- package/dist/admin.css +2 -3
- package/dist/components/SeoButton/ScoreBadge.d.ts.map +1 -1
- package/dist/components/SeoButton/ScoreBadge.js +10 -7
- package/dist/components/SeoButton/ScoreBadge.js.map +1 -1
- package/dist/components/SeoButton/SeoButtonInner.d.ts +2 -2
- package/dist/components/SeoButton/SeoButtonInner.d.ts.map +1 -1
- package/dist/components/SeoButton/SeoButtonInner.js +11 -5
- package/dist/components/SeoButton/SeoButtonInner.js.map +1 -1
- package/dist/components/SeoDrawer/TabsNav/index.d.ts.map +1 -1
- package/dist/components/SeoDrawer/TabsNav/index.js +15 -4
- package/dist/components/SeoDrawer/TabsNav/index.js.map +1 -1
- package/dist/components/SeoDrawer/TabsNav/variants.d.ts.map +1 -1
- package/dist/components/SeoDrawer/TabsNav/variants.js +12 -9
- package/dist/components/SeoDrawer/TabsNav/variants.js.map +1 -1
- package/dist/components/SeoDrawer/analysisDecision.d.ts.map +1 -1
- package/dist/components/SeoDrawer/analysisDecision.js.map +1 -1
- package/dist/components/SeoDrawer/build-analysis-input.d.ts +3 -8
- package/dist/components/SeoDrawer/build-analysis-input.d.ts.map +1 -1
- package/dist/components/SeoDrawer/build-analysis-input.js +28 -12
- package/dist/components/SeoDrawer/build-analysis-input.js.map +1 -1
- package/dist/components/SeoDrawer/buildInput.d.ts +1 -1
- package/dist/components/SeoDrawer/buildInput.d.ts.map +1 -1
- package/dist/components/SeoDrawer/buildInput.js +13 -3
- package/dist/components/SeoDrawer/buildInput.js.map +1 -1
- package/dist/components/SeoDrawer/components/Header.d.ts.map +1 -1
- package/dist/components/SeoDrawer/components/Header.js +20 -2
- package/dist/components/SeoDrawer/components/Header.js.map +1 -1
- package/dist/components/SeoDrawer/components/HeadingsSection/HeadingLevelTiles.d.ts.map +1 -1
- package/dist/components/SeoDrawer/components/HeadingsSection/HeadingLevelTiles.js +23 -5
- package/dist/components/SeoDrawer/components/HeadingsSection/HeadingLevelTiles.js.map +1 -1
- package/dist/components/SeoDrawer/components/HeadingsSection/HeadingTree/Chevron.d.ts.map +1 -1
- package/dist/components/SeoDrawer/components/HeadingsSection/HeadingTree/Chevron.js +12 -1
- package/dist/components/SeoDrawer/components/HeadingsSection/HeadingTree/Chevron.js.map +1 -1
- package/dist/components/SeoDrawer/components/HeadingsSection/HeadingTree/HeadingTreeGroup.d.ts.map +1 -1
- package/dist/components/SeoDrawer/components/HeadingsSection/HeadingTree/HeadingTreeGroup.js +43 -4
- package/dist/components/SeoDrawer/components/HeadingsSection/HeadingTree/HeadingTreeGroup.js.map +1 -1
- package/dist/components/SeoDrawer/components/HeadingsSection/HeadingTree/HeadingTreeRow.d.ts +1 -1
- package/dist/components/SeoDrawer/components/HeadingsSection/HeadingTree/HeadingTreeRow.d.ts.map +1 -1
- package/dist/components/SeoDrawer/components/HeadingsSection/HeadingTree/HeadingTreeRow.js +45 -10
- package/dist/components/SeoDrawer/components/HeadingsSection/HeadingTree/HeadingTreeRow.js.map +1 -1
- package/dist/components/SeoDrawer/components/HeadingsSection/HeadingTree/index.d.ts.map +1 -1
- package/dist/components/SeoDrawer/components/HeadingsSection/HeadingTree/index.js.map +1 -1
- package/dist/components/SeoDrawer/components/HeadingsSection/HeadingTree/useHeadingRails.d.ts +1 -1
- package/dist/components/SeoDrawer/components/HeadingsSection/HeadingTree/useHeadingRails.d.ts.map +1 -1
- package/dist/components/SeoDrawer/components/HeadingsSection/HeadingTree/useHeadingRails.js +6 -1
- package/dist/components/SeoDrawer/components/HeadingsSection/HeadingTree/useHeadingRails.js.map +1 -1
- package/dist/components/SeoDrawer/components/HeadingsSection/index.d.ts.map +1 -1
- package/dist/components/SeoDrawer/components/HeadingsSection/index.js.map +1 -1
- package/dist/components/SeoDrawer/components/SerpPreview/highlight-keyphrase.d.ts.map +1 -1
- package/dist/components/SeoDrawer/components/SerpPreview/highlight-keyphrase.js +3 -1
- package/dist/components/SeoDrawer/components/SerpPreview/highlight-keyphrase.js.map +1 -1
- package/dist/components/SeoDrawer/components/SerpPreview/index.d.ts.map +1 -1
- package/dist/components/SeoDrawer/components/SerpPreview/index.js +8 -1
- package/dist/components/SeoDrawer/components/SerpPreview/index.js.map +1 -1
- package/dist/components/SeoDrawer/components/SerpPreview/serp-favicon.d.ts.map +1 -1
- package/dist/components/SeoDrawer/components/SerpPreview/serp-favicon.js.map +1 -1
- package/dist/components/SeoDrawer/components/SerpPreview/variants.d.ts.map +1 -1
- package/dist/components/SeoDrawer/components/SerpPreview/variants.js +9 -6
- package/dist/components/SeoDrawer/components/SerpPreview/variants.js.map +1 -1
- package/dist/components/SeoDrawer/index.d.ts.map +1 -1
- package/dist/components/SeoDrawer/index.js +50 -15
- package/dist/components/SeoDrawer/index.js.map +1 -1
- package/dist/components/SeoDrawer/keyphrasePending.d.ts.map +1 -1
- package/dist/components/SeoDrawer/keyphrasePending.js.map +1 -1
- package/dist/components/SeoDrawer/languagePacks.d.ts.map +1 -1
- package/dist/components/SeoDrawer/languagePacks.js.map +1 -1
- package/dist/components/SeoDrawer/tabs/InclusiveTab.d.ts.map +1 -1
- package/dist/components/SeoDrawer/tabs/InclusiveTab.js +31 -10
- package/dist/components/SeoDrawer/tabs/InclusiveTab.js.map +1 -1
- package/dist/components/SeoDrawer/tabs/KeyphraseTab.d.ts +1 -1
- package/dist/components/SeoDrawer/tabs/KeyphraseTab.d.ts.map +1 -1
- package/dist/components/SeoDrawer/tabs/KeyphraseTab.js +8 -1
- package/dist/components/SeoDrawer/tabs/KeyphraseTab.js.map +1 -1
- package/dist/components/SeoDrawer/tabs/SerpTab.d.ts +1 -1
- package/dist/components/SeoDrawer/tabs/SerpTab.d.ts.map +1 -1
- package/dist/components/SeoDrawer/tabs/SerpTab.js +5 -1
- package/dist/components/SeoDrawer/tabs/SerpTab.js.map +1 -1
- package/dist/components/SeoDrawer/tabs/VitalsTab.d.ts.map +1 -1
- package/dist/components/SeoDrawer/tabs/VitalsTab.js +32 -9
- package/dist/components/SeoDrawer/tabs/VitalsTab.js.map +1 -1
- package/dist/components/SeoDrawer/useAnalysis.d.ts +1 -1
- package/dist/components/SeoDrawer/useAnalysis.d.ts.map +1 -1
- package/dist/components/SeoDrawer/useAnalysis.js +6 -1
- package/dist/components/SeoDrawer/useAnalysis.js.map +1 -1
- package/dist/components/SeoDrawer/useLiveDocument.d.ts +3 -4
- package/dist/components/SeoDrawer/useLiveDocument.d.ts.map +1 -1
- package/dist/components/SeoDrawer/useLiveDocument.js +45 -48
- package/dist/components/SeoDrawer/useLiveDocument.js.map +1 -1
- package/dist/components/icons.d.ts.map +1 -1
- package/dist/components/icons.js +16 -1
- package/dist/components/icons.js.map +1 -1
- package/dist/constants/checkIds.d.ts.map +1 -1
- package/dist/constants/checkIds.js +18 -2
- package/dist/constants/checkIds.js.map +1 -1
- package/dist/content/index.d.ts +5 -0
- package/dist/content/index.d.ts.map +1 -0
- package/dist/content/index.js +15 -0
- package/dist/content/index.js.map +1 -0
- package/dist/content/registry.d.ts +4 -0
- package/dist/content/registry.d.ts.map +1 -0
- package/dist/content/registry.js +22 -0
- package/dist/content/registry.js.map +1 -0
- package/dist/content/resolve/resolve-docs.d.ts +3 -0
- package/dist/content/resolve/resolve-docs.d.ts.map +1 -0
- package/dist/content/resolve/resolve-docs.js +48 -0
- package/dist/content/resolve/resolve-docs.js.map +1 -0
- package/dist/content/schema/helpers.d.ts +10 -0
- package/dist/content/schema/helpers.d.ts.map +1 -0
- package/dist/content/schema/helpers.js +64 -0
- package/dist/content/schema/helpers.js.map +1 -0
- package/dist/content/schema/nodes.d.ts +26 -0
- package/dist/content/schema/nodes.d.ts.map +1 -0
- package/dist/content/schema/nodes.js +8 -0
- package/dist/content/schema/nodes.js.map +1 -0
- package/dist/content/schema/serialize.d.ts +3 -0
- package/dist/content/schema/serialize.d.ts.map +1 -0
- package/dist/content/schema/serialize.js +31 -0
- package/dist/content/schema/serialize.js.map +1 -0
- package/dist/engine/assessorAdapter.d.ts.map +1 -1
- package/dist/engine/assessorAdapter.js.map +1 -1
- package/dist/engine/extractCheckData.d.ts.map +1 -1
- package/dist/engine/extractCheckData.js +19 -5
- package/dist/engine/extractCheckData.js.map +1 -1
- package/dist/engine/helpers/title-progress.d.ts +1 -1
- package/dist/engine/helpers/title-progress.js.map +1 -1
- package/dist/engine/inclusiveScore.d.ts.map +1 -1
- package/dist/engine/inclusiveScore.js.map +1 -1
- package/dist/engine/recommendations.d.ts.map +1 -1
- package/dist/engine/recommendations.js.map +1 -1
- package/dist/engine/runAnalysis/services/derive-inclusive.js.map +1 -1
- package/dist/engine/runAnalysis/services/derive-readability.d.ts.map +1 -1
- package/dist/engine/runAnalysis/services/derive-readability.js +5 -1
- package/dist/engine/runAnalysis/services/derive-readability.js.map +1 -1
- package/dist/engine/runAnalysis/services/derive-vitals/heading-tree.d.ts.map +1 -1
- package/dist/engine/runAnalysis/services/derive-vitals/heading-tree.js.map +1 -1
- package/dist/engine/runAnalysis/services/derive-vitals/index.d.ts.map +1 -1
- package/dist/engine/runAnalysis/services/derive-vitals/index.js +9 -1
- package/dist/engine/runAnalysis/services/derive-vitals/index.js.map +1 -1
- package/dist/engine/runAnalysis/services/derive-vitals/researches.d.ts +1 -1
- package/dist/engine/runAnalysis/services/derive-vitals/researches.d.ts.map +1 -1
- package/dist/engine/runAnalysis/services/derive-vitals/researches.js +8 -2
- package/dist/engine/runAnalysis/services/derive-vitals/researches.js.map +1 -1
- package/dist/engine/runAnalysis/utils/enrich.d.ts.map +1 -1
- package/dist/engine/runAnalysis/utils/enrich.js.map +1 -1
- package/dist/engine/visualization/resolveVisualization/constants.d.ts.map +1 -1
- package/dist/engine/visualization/resolveVisualization/constants.js +9 -1
- package/dist/engine/visualization/resolveVisualization/constants.js.map +1 -1
- package/dist/engine/visualization/resolveVisualization/index.d.ts.map +1 -1
- package/dist/engine/visualization/resolveVisualization/index.js.map +1 -1
- package/dist/engine/visualization/resolveVisualization/resolves/resolveDrilldown.d.ts.map +1 -1
- package/dist/engine/visualization/resolveVisualization/resolves/resolveDrilldown.js.map +1 -1
- package/dist/engine/visualization/resolveVisualization/resolves/resolveLinks.d.ts.map +1 -1
- package/dist/engine/visualization/resolveVisualization/resolves/resolveLinks.js.map +1 -1
- package/dist/engine/visualization/resolveVisualization/resolves/resolveProportionCount.d.ts.map +1 -1
- package/dist/engine/visualization/resolveVisualization/resolves/resolveProportionCount.js.map +1 -1
- package/dist/engine/visualization/resolveVisualization/resolves/resolveValueRange.d.ts.map +1 -1
- package/dist/engine/visualization/resolveVisualization/resolves/resolveValueRange.js.map +1 -1
- package/dist/engine/visualization/resolveVisualization/utils/pluralize.d.ts.map +1 -1
- package/dist/engine/visualization/resolveVisualization/utils/pluralize.js.map +1 -1
- package/dist/engine/visualization/resolveVisualization/utils/readDrilldownItems.d.ts.map +1 -1
- package/dist/engine/visualization/resolveVisualization/utils/readDrilldownItems.js.map +1 -1
- package/dist/engine/visualization/resolveVisualization/utils/readNumber.d.ts.map +1 -1
- package/dist/engine/visualization/resolveVisualization/utils/readNumber.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +24 -4
- package/dist/plugin.js.map +1 -1
- package/dist/types/config.d.ts +42 -9
- package/dist/types/config.d.ts.map +1 -1
- package/dist/ui/CheckRow/CheckVisualization/visualizations/DensityGauge.d.ts.map +1 -1
- package/dist/ui/CheckRow/CheckVisualization/visualizations/DensityGauge.js +24 -5
- package/dist/ui/CheckRow/CheckVisualization/visualizations/DensityGauge.js.map +1 -1
- package/dist/ui/CheckRow/CheckVisualization/visualizations/DistributionBar.d.ts.map +1 -1
- package/dist/ui/CheckRow/CheckVisualization/visualizations/DistributionBar.js +8 -1
- package/dist/ui/CheckRow/CheckVisualization/visualizations/DistributionBar.js.map +1 -1
- package/dist/ui/CheckRow/CheckVisualization/visualizations/DrillDown.d.ts.map +1 -1
- package/dist/ui/CheckRow/CheckVisualization/visualizations/DrillDown.js +11 -4
- package/dist/ui/CheckRow/CheckVisualization/visualizations/DrillDown.js.map +1 -1
- package/dist/ui/CheckRow/CheckVisualization/visualizations/SegmentBar.d.ts.map +1 -1
- package/dist/ui/CheckRow/CheckVisualization/visualizations/SegmentBar.js +7 -1
- package/dist/ui/CheckRow/CheckVisualization/visualizations/SegmentBar.js.map +1 -1
- package/dist/ui/CheckRow/index.d.ts.map +1 -1
- package/dist/ui/CheckRow/index.js +23 -9
- package/dist/ui/CheckRow/index.js.map +1 -1
- package/dist/ui/FilterPills.d.ts.map +1 -1
- package/dist/ui/FilterPills.js +78 -19
- package/dist/ui/FilterPills.js.map +1 -1
- package/dist/ui/KpiCard.d.ts.map +1 -1
- package/dist/ui/KpiCard.js.map +1 -1
- package/dist/ui/Pill.d.ts.map +1 -1
- package/dist/ui/Pill.js +11 -8
- package/dist/ui/Pill.js.map +1 -1
- package/dist/ui/ScoreRing.d.ts.map +1 -1
- package/dist/ui/ScoreRing.js +12 -2
- package/dist/ui/ScoreRing.js.map +1 -1
- package/dist/ui/SectionWrapper.d.ts.map +1 -1
- package/dist/ui/SectionWrapper.js.map +1 -1
- package/dist/ui/SegmentedControl.d.ts +1 -1
- package/dist/ui/SegmentedControl.d.ts.map +1 -1
- package/dist/ui/SegmentedControl.js +49 -33
- package/dist/ui/SegmentedControl.js.map +1 -1
- package/dist/ui/Tooltip.d.ts +1 -1
- package/dist/ui/Tooltip.d.ts.map +1 -1
- package/dist/ui/Tooltip.js +8 -1
- package/dist/ui/Tooltip.js.map +1 -1
- package/dist/utils/config/overrideAdmin.js +1 -1
- package/dist/utils/config/overrideAdmin.js.map +1 -1
- package/dist/utils/style.d.ts.map +1 -1
- package/dist/utils/style.js.map +1 -1
- package/package.json +9 -7
- package/dist/content/extractContent.d.ts +0 -3
- package/dist/content/extractContent.d.ts.map +0 -1
- package/dist/content/extractContent.js +0 -31
- package/dist/content/extractContent.js.map +0 -1
- package/dist/content/uploads/collect-upload-refs.d.ts +0 -5
- package/dist/content/uploads/collect-upload-refs.d.ts.map +0 -1
- package/dist/content/uploads/collect-upload-refs.js +0 -19
- package/dist/content/uploads/collect-upload-refs.js.map +0 -1
- package/dist/content/uploads/hydrate-values.d.ts +0 -5
- package/dist/content/uploads/hydrate-values.d.ts.map +0 -1
- package/dist/content/uploads/hydrate-values.js +0 -15
- package/dist/content/uploads/hydrate-values.js.map +0 -1
- package/dist/content/uploads/media-resolver.d.ts +0 -7
- package/dist/content/uploads/media-resolver.d.ts.map +0 -1
- package/dist/content/uploads/media-resolver.js +0 -60
- package/dist/content/uploads/media-resolver.js.map +0 -1
- package/dist/content/uploads/transform-lexical-uploads.d.ts +0 -5
- package/dist/content/uploads/transform-lexical-uploads.d.ts.map +0 -1
- package/dist/content/uploads/transform-lexical-uploads.js +0 -34
- package/dist/content/uploads/transform-lexical-uploads.js.map +0 -1
- package/dist/content/uploads/transform-upload-values.d.ts +0 -10
- package/dist/content/uploads/transform-upload-values.d.ts.map +0 -1
- package/dist/content/uploads/transform-upload-values.js +0 -108
- package/dist/content/uploads/transform-upload-values.js.map +0 -1
- package/dist/content/uploads/types.d.ts +0 -8
- package/dist/content/uploads/types.d.ts.map +0 -1
- package/dist/content/uploads/types.js +0 -7
- package/dist/content/uploads/types.js.map +0 -1
- package/dist/content/walkValue.d.ts +0 -14
- package/dist/content/walkValue.d.ts.map +0 -1
- package/dist/content/walkValue.js +0 -62
- package/dist/content/walkValue.js.map +0 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Live SEO analysis for [Payload CMS](https://payloadcms.com/) v3 + Next.js, powered by [Yoast](https://github.com/Yoast/wordpress-seo). Adds a real-time SEO drawer to the document editor — keyphrase optimization, on-page checks, readability, inclusive language, content vitals, and a Google SERP preview — without adding a single field to your database.
|
|
4
4
|
|
|
5
|
-
The plugin injects a button into the editor toolbar of each configured collection. Clicking it opens a drawer that reads the current (unsaved) form values,
|
|
5
|
+
The plugin injects a button into the editor toolbar of each configured collection. Clicking it opens a drawer that reads the current (unsaved) form values, derives the title, meta description, and slug from dot-path config, runs **your** registered content extractor to build the body content, and runs the Yoast analysis engine **entirely in the browser**. Nothing is persisted — there are zero new collections, globals, or fields.
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -19,8 +19,11 @@ The plugin adds NO database fields, collections, or globals. It injects a button
|
|
|
19
19
|
document editor toolbar (admin.components.edit.beforeDocumentControls) of each configured
|
|
20
20
|
collection. The button opens a drawer that:
|
|
21
21
|
- Reads the live (unsaved) form values for the document
|
|
22
|
-
-
|
|
23
|
-
-
|
|
22
|
+
- Derives title, meta description, and slug from dot-path config
|
|
23
|
+
- Runs YOUR registered content extractor to build the body content. Content extraction is
|
|
24
|
+
app-owned — there is no built-in walker. The extractor receives the raw form values plus a
|
|
25
|
+
toolkit ({ resolveDocs, helpers }); it fetches any referenced/upload docs it needs via
|
|
26
|
+
resolveDocs and returns a ContentNode[] the plugin serializes to HTML.
|
|
24
27
|
- Runs the Yoast engine (yoastseo + @yoast/search-metadata-previews) in the browser
|
|
25
28
|
- Shows tabs: Keyphrase, On-page SEO, Readability, Inclusive, Content vitals, SERP preview
|
|
26
29
|
|
|
@@ -43,8 +46,9 @@ seoPlugin({
|
|
|
43
46
|
seoTitle: 'seoTitle', // dot-path; falls back to useAsTitle / 'title'
|
|
44
47
|
metaDescription: 'metaDescription',
|
|
45
48
|
slug: 'slug', // default: 'slug'
|
|
46
|
-
content: 'sections', // dot-path to the main content field (blocks/richText/textarea)
|
|
47
49
|
},
|
|
50
|
+
// REQUIRED: lookup key for a content extractor you register (see Step 4).
|
|
51
|
+
extractContentPath: '@/seo/extractPageContent#default',
|
|
48
52
|
},
|
|
49
53
|
],
|
|
50
54
|
site: { name: 'My Site', baseUrl: 'https://example.com', faviconUrl: '/favicon.ico' },
|
|
@@ -65,16 +69,39 @@ const nextConfig = {
|
|
|
65
69
|
transpilePackages: ['@yoast/search-metadata-previews', '@yoast/components'],
|
|
66
70
|
}
|
|
67
71
|
|
|
72
|
+
## Step 4 — Write and register a content extractor (REQUIRED)
|
|
73
|
+
|
|
74
|
+
Content extraction is entirely yours — there is no built-in walker, and extractContentPath is
|
|
75
|
+
required. Write an extractor and register it under the same key, from an admin-mounted client
|
|
76
|
+
module:
|
|
77
|
+
|
|
78
|
+
// src/seo/extractPageContent.ts
|
|
79
|
+
import type { ContentExtractor } from '@focus-reactive/payload-plugin-seo/content'
|
|
80
|
+
const extractPageContent: ContentExtractor = async (values, ctx, { resolveDocs, helpers }) => {
|
|
81
|
+
// 1. collect ids from the RAW values (relationship/upload fields are ids)
|
|
82
|
+
// 2. const docs = await resolveDocs([{ collection: 'media', ids, select: ['url','alt'] }])
|
|
83
|
+
// 3. build the IR with helpers
|
|
84
|
+
return helpers.compact([helpers.heading(1, values.title as string) /* … */])
|
|
85
|
+
}
|
|
86
|
+
export default extractPageContent
|
|
87
|
+
|
|
88
|
+
// src/providers/SeoExtractorRegistrar.tsx ("use client")
|
|
89
|
+
import { registerContentExtractors } from '@focus-reactive/payload-plugin-seo/content'
|
|
90
|
+
import extractPageContent from '@/seo/extractPageContent'
|
|
91
|
+
registerContentExtractors({ '@/seo/extractPageContent#default': extractPageContent })
|
|
92
|
+
// export a component that renders {children} and mount it via admin.components.providers
|
|
93
|
+
|
|
68
94
|
## Important notes
|
|
69
95
|
|
|
70
96
|
- The plugin reads UNSAVED form values, so analysis updates live as you type (debounced ~1s).
|
|
71
|
-
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
97
|
+
- Content extraction is done by YOUR registered extractor; extractContentPath is required and
|
|
98
|
+
there is no built-in fallback. The extractor receives raw form values, a ctx
|
|
99
|
+
({ locale, apiRoute }), and a toolkit ({ resolveDocs, helpers }), and returns ContentNode[].
|
|
100
|
+
- relationship/upload fields arrive as ids; use toolkit.resolveDocs(queries) to fetch only the
|
|
101
|
+
docs/fields you need (one parallel request per collection), then read them with store.get().
|
|
75
102
|
- Non-English analysis requires the locale code in `supportedLocales`; the matching Yoast
|
|
76
103
|
language pack is dynamically imported on demand.
|
|
77
|
-
- No GA4, no API keys, no server calls except
|
|
104
|
+
- No GA4, no API keys, no server calls except the resolveDocs reads against your own Payload API.
|
|
78
105
|
```
|
|
79
106
|
|
|
80
107
|
---
|
|
@@ -90,9 +117,10 @@ Document editor (configured collection)
|
|
|
90
117
|
▼
|
|
91
118
|
SEO Drawer (client-only)
|
|
92
119
|
│
|
|
93
|
-
├─ read live form values (title, description, slug,
|
|
94
|
-
├─
|
|
95
|
-
|
|
120
|
+
├─ read live form values (title, description, slug, keyphrase)
|
|
121
|
+
├─ run your registered extractor(values, ctx, toolkit)
|
|
122
|
+
│ └─ toolkit.resolveDocs(): parallel, projected /api/{collection} fetches
|
|
123
|
+
├─ extractor returns ContentNode[] → plugin serializes to HTML
|
|
96
124
|
▼
|
|
97
125
|
Yoast engine (in browser): Paper + EnglishResearcher + SeoAssessor
|
|
98
126
|
│
|
|
@@ -135,8 +163,9 @@ export default buildConfig({
|
|
|
135
163
|
seoTitle: "seoTitle",
|
|
136
164
|
metaDescription: "metaDescription",
|
|
137
165
|
slug: "slug",
|
|
138
|
-
content: "sections",
|
|
139
166
|
},
|
|
167
|
+
// Required: register a matching extractor (see "Content Extraction").
|
|
168
|
+
extractContentPath: "@/seo/extractPageContent#default",
|
|
140
169
|
},
|
|
141
170
|
],
|
|
142
171
|
site: {
|
|
@@ -174,6 +203,10 @@ const nextConfig = {
|
|
|
174
203
|
export default nextConfig;
|
|
175
204
|
```
|
|
176
205
|
|
|
206
|
+
### Step 4 — Write and register a content extractor
|
|
207
|
+
|
|
208
|
+
Required — see [Content Extraction](#content-extraction).
|
|
209
|
+
|
|
177
210
|
---
|
|
178
211
|
|
|
179
212
|
## Configuration Reference
|
|
@@ -201,15 +234,21 @@ interface SeoPluginConfig {
|
|
|
201
234
|
interface SeoCollectionConfig {
|
|
202
235
|
/** Collection slug to attach the drawer to. */
|
|
203
236
|
slug: string;
|
|
204
|
-
/** Dot-paths telling the plugin which fields hold the
|
|
237
|
+
/** Dot-paths telling the plugin which fields hold the title / meta description / slug. */
|
|
205
238
|
fields?: SeoFieldPaths;
|
|
206
239
|
/**
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
240
|
+
* REQUIRED. Lookup key for a registered ContentExtractor — the only content path;
|
|
241
|
+
* there is no built-in walker. Set it to the same string you pass as the key in
|
|
242
|
+
* registerContentExtractors(). Convention: the module path of the extractor file,
|
|
243
|
+
* e.g. "@/collections/Page/extractPageContent#default". The extractor runs in the
|
|
244
|
+
* browser on the raw form values and returns ContentNode[]. See "Content Extraction".
|
|
245
|
+
*
|
|
246
|
+
* A collection whose extractContentPath is missing/empty is dropped at plugin init
|
|
247
|
+
* (with a warning); if no collection has a valid extractContentPath the plugin no-ops.
|
|
248
|
+
* If the key is set but not registered at runtime, content analysis for that collection
|
|
249
|
+
* is empty (a one-time console error is logged) — there is no built-in fallback.
|
|
211
250
|
*/
|
|
212
|
-
extractContentPath
|
|
251
|
+
extractContentPath: string;
|
|
213
252
|
}
|
|
214
253
|
```
|
|
215
254
|
|
|
@@ -224,12 +263,10 @@ interface SeoFieldPaths {
|
|
|
224
263
|
metaDescription?: string;
|
|
225
264
|
/** Dot-path to the slug. Default: 'slug' */
|
|
226
265
|
slug?: string;
|
|
227
|
-
/** Dot-path to the primary content field (blocks / richText / textarea). */
|
|
228
|
-
content?: string;
|
|
229
266
|
}
|
|
230
267
|
```
|
|
231
268
|
|
|
232
|
-
Dot-paths support nesting, e.g. `"meta.description"` or `"content.body"`.
|
|
269
|
+
Dot-paths support nesting, e.g. `"meta.description"` or `"content.body"`. Body content is **not** configured here — it is produced by your registered extractor (see below).
|
|
233
270
|
|
|
234
271
|
### SeoSiteConfig
|
|
235
272
|
|
|
@@ -244,48 +281,186 @@ interface SeoSiteConfig {
|
|
|
244
281
|
}
|
|
245
282
|
```
|
|
246
283
|
|
|
247
|
-
|
|
284
|
+
## Content Extraction
|
|
285
|
+
|
|
286
|
+
Content extraction is **app-owned**: you register one `ContentExtractor` per collection. The plugin makes no assumptions about your document schema, relationships, link types, or URL construction — it hands your extractor the raw values plus a small, generic toolkit, and serializes whatever `ContentNode[]` you return.
|
|
287
|
+
|
|
288
|
+
### The `ContentNode` Intermediate Representation
|
|
289
|
+
|
|
290
|
+
The plugin represents page content as a flat array of typed nodes before serializing to HTML. This is the `ContentNode` union exported from `@focus-reactive/payload-plugin-seo/content`:
|
|
248
291
|
|
|
249
292
|
```ts
|
|
250
|
-
type
|
|
293
|
+
type ContentNode =
|
|
294
|
+
| { type: "heading"; level: 1 | 2 | 3 | 4 | 5 | 6; text: string }
|
|
295
|
+
| { type: "paragraph"; text: string }
|
|
296
|
+
| { type: "link"; href: string; text: string }
|
|
297
|
+
| { type: "image"; src: string; alt?: string }
|
|
298
|
+
| { type: "video"; src: string; poster?: string }
|
|
299
|
+
| { type: "html"; html: string }; // lexical-converted or raw HTML escape hatch
|
|
251
300
|
```
|
|
252
301
|
|
|
253
|
-
|
|
302
|
+
Serialization to HTML (for the Yoast engine) happens entirely inside the plugin. Extractors produce `ContentNode[]`; they never construct HTML strings directly.
|
|
303
|
+
|
|
304
|
+
### Builder helpers
|
|
305
|
+
|
|
306
|
+
The `/content` subpath exports pure builder functions. Each helper returns `null` for empty or missing input, and `compact` drops the nulls — so you can build sparse arrays and clean them in one pass. The same helpers are also handed to your extractor as `toolkit.helpers`, so you can use either the imports or the injected object.
|
|
254
307
|
|
|
255
308
|
```ts
|
|
256
|
-
|
|
309
|
+
import {
|
|
310
|
+
heading, // heading(level: 1|2|3|4|5|6, text?: string | null): ContentNode | null
|
|
311
|
+
paragraph, // paragraph(text?: string | null): ContentNode | null
|
|
312
|
+
link, // link(href?: string | null, text?: string | null): ContentNode | null
|
|
313
|
+
image, // image(src?: string | null, alt?: string | null): ContentNode | null
|
|
314
|
+
video, // video(src?: string | null, poster?: string | null): ContentNode | null
|
|
315
|
+
richText, // richText(lexicalValue: unknown): ContentNode | null (lexical → HTML; null when empty)
|
|
316
|
+
html, // html(raw?: string | null): ContentNode | null
|
|
317
|
+
compact, // compact(nodes: (ContentNode | null | undefined)[]): ContentNode[]
|
|
318
|
+
} from "@focus-reactive/payload-plugin-seo/content";
|
|
319
|
+
import type {
|
|
320
|
+
ContentNode,
|
|
321
|
+
HeadingLevel,
|
|
322
|
+
} from "@focus-reactive/payload-plugin-seo/content";
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
### The extractor contract
|
|
326
|
+
|
|
327
|
+
```ts
|
|
328
|
+
type ContentExtractor = (
|
|
329
|
+
values: Record<string, unknown>, // RAW form values; relationship/upload fields are ids
|
|
330
|
+
ctx: ExtractContext, // { locale?: string; apiRoute?: string }
|
|
331
|
+
toolkit: ExtractToolkit, // { resolveDocs, helpers }
|
|
332
|
+
) => ContentNode[] | Promise<ContentNode[]>;
|
|
333
|
+
|
|
334
|
+
interface ExtractToolkit {
|
|
335
|
+
resolveDocs: (queries: DocQuery[]) => Promise<DocStore>;
|
|
336
|
+
helpers: ContentHelpers; // heading, paragraph, link, image, video, richText, html, compact
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
interface DocQuery {
|
|
340
|
+
collection: string;
|
|
341
|
+
ids: (string | number)[];
|
|
342
|
+
select?: string[]; // field projection → ?select[field]=true
|
|
343
|
+
depth?: number; // relationship population → ?depth=N (default 0)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
interface DocStore {
|
|
347
|
+
get(collection: string, id: string | number): Record<string, unknown> | undefined;
|
|
348
|
+
}
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
Your extractor:
|
|
352
|
+
|
|
353
|
+
- Receives the **raw**, unsaved form values. Relationship and upload fields are **ids** (or id arrays / `{ relationTo, value }`), **not** populated objects — the plugin does no hydration.
|
|
354
|
+
- Owns ref collection and any link/URL building. The plugin makes no assumptions about your link types (internal references, custom URLs, etc.) — you decide what to fetch and how to turn it into a node.
|
|
355
|
+
- Uses `toolkit.resolveDocs(queries)` to fetch referenced/upload documents. You pass one query per collection with the `ids` you collected and an optional `select` projection (fetch only the fields you need) and `depth`. **All queries run in parallel.** Read results with `store.get(collection, id)`.
|
|
356
|
+
- Returns `ContentNode[]` (built with the helpers); the plugin serializes it.
|
|
357
|
+
|
|
358
|
+
```ts
|
|
359
|
+
// src/collections/Page/extractPageContent.ts
|
|
360
|
+
import { heading, image, paragraph, richText } from "@focus-reactive/payload-plugin-seo/content";
|
|
361
|
+
import type {
|
|
362
|
+
ContentExtractor,
|
|
363
|
+
DocStore,
|
|
364
|
+
} from "@focus-reactive/payload-plugin-seo/content";
|
|
365
|
+
|
|
366
|
+
const extractPageContent: ContentExtractor = async (values, _ctx, { resolveDocs, helpers }) => {
|
|
367
|
+
const blocks = (values as { blocks?: Record<string, unknown>[] }).blocks ?? [];
|
|
368
|
+
|
|
369
|
+
// 1. Collect the ids you care about from the RAW values (you know your schema).
|
|
370
|
+
const mediaIds = blocks.flatMap((b) => (typeof b.image === "number" ? [b.image] : []));
|
|
371
|
+
|
|
372
|
+
// 2. Fetch them — one parallel request per collection, projected to only the fields you need.
|
|
373
|
+
const docs: DocStore = await resolveDocs([
|
|
374
|
+
{ collection: "media", ids: mediaIds, select: ["url", "alt", "mimeType"] },
|
|
375
|
+
]);
|
|
376
|
+
|
|
377
|
+
// 3. Build the Intermediate Representation.
|
|
378
|
+
return helpers.compact(
|
|
379
|
+
blocks.flatMap((b) => {
|
|
380
|
+
const media = typeof b.image === "number" ? docs.get("media", b.image) : undefined;
|
|
381
|
+
return [
|
|
382
|
+
heading(2, b.title as string),
|
|
383
|
+
paragraph(b.subtitle as string),
|
|
384
|
+
image((media as { url?: string })?.url, (media as { alt?: string })?.alt),
|
|
385
|
+
richText(b.content),
|
|
386
|
+
];
|
|
387
|
+
}),
|
|
388
|
+
);
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
export default extractPageContent;
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
### The registry: why it exists and how to use it
|
|
395
|
+
|
|
396
|
+
Payload 3.84 has no client-side import map — `admin.dependencies` resolves path strings to functions server-side only, and a resolved function cannot cross the server→client boundary as a prop. Because the Yoast analysis runs live in the browser, `extractContentPath` cannot be resolved by Payload machinery on the client.
|
|
397
|
+
|
|
398
|
+
The plugin bridges this with a `globalThis`-backed registry. `extractContentPath` in config is the **lookup key**; the consuming app registers the actual function under the same key in an admin-mounted client module. The registry uses `globalThis` (not a bare module-level `Map`) so the key and function resolve to the same instance across separate bundle chunks.
|
|
399
|
+
|
|
400
|
+
**Step 1 — Set `extractContentPath` in the plugin config (the lookup key):**
|
|
401
|
+
|
|
402
|
+
```ts
|
|
403
|
+
// payload.config.ts (or your plugins file)
|
|
404
|
+
import { seoPlugin } from "@focus-reactive/payload-plugin-seo";
|
|
405
|
+
|
|
257
406
|
seoPlugin({
|
|
258
407
|
collections: [
|
|
259
|
-
{
|
|
408
|
+
{
|
|
409
|
+
slug: "page",
|
|
410
|
+
fields: {
|
|
411
|
+
seoTitle: "meta.title",
|
|
412
|
+
metaDescription: "meta.description",
|
|
413
|
+
slug: "slug",
|
|
414
|
+
},
|
|
415
|
+
extractContentPath: "@/collections/Page/extractPageContent#default",
|
|
416
|
+
},
|
|
260
417
|
],
|
|
261
418
|
});
|
|
262
419
|
```
|
|
263
420
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
421
|
+
**Step 2 — Create a client registrar component:**
|
|
422
|
+
|
|
423
|
+
```tsx
|
|
424
|
+
// src/providers/SeoExtractorRegistrar.tsx
|
|
425
|
+
"use client";
|
|
426
|
+
|
|
427
|
+
import { registerContentExtractors } from "@focus-reactive/payload-plugin-seo/content";
|
|
428
|
+
import type { ReactNode } from "react";
|
|
429
|
+
|
|
430
|
+
import extractPageContent from "@/collections/Page/extractPageContent";
|
|
431
|
+
|
|
432
|
+
// registerContentExtractors runs once when this module loads in the admin bundle.
|
|
433
|
+
// The key must exactly match the extractContentPath string in your plugin config.
|
|
434
|
+
registerContentExtractors({
|
|
435
|
+
"@/collections/Page/extractPageContent#default": extractPageContent,
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
export function SeoExtractorRegistrar({ children }: { children: ReactNode }) {
|
|
439
|
+
return <>{children}</>;
|
|
268
440
|
}
|
|
441
|
+
|
|
442
|
+
export default SeoExtractorRegistrar;
|
|
269
443
|
```
|
|
270
444
|
|
|
271
|
-
|
|
445
|
+
**Step 3 — Mount the registrar as an admin provider:**
|
|
272
446
|
|
|
273
|
-
|
|
447
|
+
```ts
|
|
448
|
+
// payload.config.ts
|
|
449
|
+
export default buildConfig({
|
|
450
|
+
admin: {
|
|
451
|
+
components: {
|
|
452
|
+
providers: ["/providers/SeoExtractorRegistrar"],
|
|
453
|
+
},
|
|
454
|
+
},
|
|
455
|
+
// ...
|
|
456
|
+
});
|
|
457
|
+
```
|
|
274
458
|
|
|
275
|
-
|
|
459
|
+
If the configured `extractContentPath` is set but the function is not registered (e.g. the provider is missing), the plugin logs a one-time console error and content analysis for that collection is empty — there is no built-in fallback.
|
|
276
460
|
|
|
277
|
-
|
|
278
|
-
2. **Collects upload / relationship references** by walking the form schema (arrays, blocks, groups, tabs, rows, collapsibles, and lexical richText, including inline media nodes).
|
|
279
|
-
3. **Resolves media** by calling your Payload REST API per collection:
|
|
280
|
-
`GET /api/{collection}?depth=0&locale={locale}&where[id][in][]=…` — fetching each doc's `url`, `mimeType`, and `alt`. Results are cached in-memory and invalidated when the drawer re-opens or content changes.
|
|
281
|
-
4. **Hydrates** the value tree (upload IDs → full docs) and walks it to build HTML:
|
|
282
|
-
- Lexical richText → HTML via `@payloadcms/richtext-lexical/html`
|
|
283
|
-
- `{ url, mimeType: "image/*", alt? }` → `<img src="…" alt="…" />`
|
|
284
|
-
- `{ url, label | text | title }` → `<a href="…">…</a>`
|
|
285
|
-
- Strings → `<p>…</p>`
|
|
286
|
-
- Structural keys (`id`, `blockType`, `blockName`, `_template`, `order`) are skipped
|
|
461
|
+
### Limitation: links and uploads embedded inside richText
|
|
287
462
|
|
|
288
|
-
This
|
|
463
|
+
`helpers.richText(value)` serializes the lexical tree to HTML **as-is**. Internal-link nodes and upload nodes embedded inside richText *body content* are **not** resolved by the plugin — their `href`s / `src`s are left as the lexical tree provides them. This keeps the plugin fully schema-agnostic. If you need those resolved, walk the lexical tree yourself inside your extractor (its structure is standard Payload lexical), collect the referenced ids, fetch them with `resolveDocs`, and rewrite the nodes before building the IR.
|
|
289
464
|
|
|
290
465
|
---
|
|
291
466
|
|
|
@@ -293,14 +468,14 @@ This means image checks (alt text, keyphrase in alt, image count) work against t
|
|
|
293
468
|
|
|
294
469
|
The drawer presents six tabs, all derived from a single in-browser Yoast analysis pass (a `Paper` analyzed by `SeoAssessor` with the language-appropriate `Researcher`):
|
|
295
470
|
|
|
296
|
-
| Tab
|
|
297
|
-
|
|
|
298
|
-
| **Keyphrase**
|
|
299
|
-
| **On-page SEO**
|
|
300
|
-
| **Readability**
|
|
301
|
-
| **Inclusive**
|
|
302
|
-
| **Content vitals**
|
|
303
|
-
| **Search result preview** | Live Google SERP preview (desktop + mobile) with keyphrase highlighting, built on `@yoast/search-metadata-previews`.
|
|
471
|
+
| Tab | What it checks |
|
|
472
|
+
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
473
|
+
| **Keyphrase** | Focus keyphrase usage — in title, slug, meta description, first paragraph, density, image alt, synonyms. Enter a keyphrase to unlock these checks. |
|
|
474
|
+
| **On-page SEO** | Title width, meta description presence/length, internal & outbound links, heading structure. |
|
|
475
|
+
| **Readability** | Sentence/paragraph length, transition words, passive voice, consecutive sentences. |
|
|
476
|
+
| **Inclusive** | Flags potentially exclusionary or non-inclusive language. |
|
|
477
|
+
| **Content vitals** | Word count, sentence/paragraph counts, image & video counts, reading time, prominent words. |
|
|
478
|
+
| **Search result preview** | Live Google SERP preview (desktop + mobile) with keyphrase highlighting, built on `@yoast/search-metadata-previews`. |
|
|
304
479
|
|
|
305
480
|
**Without a keyphrase:** the drawer still runs and the On-page, Readability, Inclusive, Content vitals, and SERP tabs all populate. Only the keyphrase-specific assessments wait until you type a focus keyphrase and analysis runs.
|
|
306
481
|
|
|
@@ -312,22 +487,29 @@ The drawer presents six tabs, all derived from a single in-browser Yoast analysi
|
|
|
312
487
|
|
|
313
488
|
```ts
|
|
314
489
|
seoPlugin({
|
|
315
|
-
collections: [
|
|
490
|
+
collections: [
|
|
491
|
+
{
|
|
492
|
+
slug: "pages",
|
|
493
|
+
fields: { slug: "slug" },
|
|
494
|
+
extractContentPath: "@/seo/extractPageContent#default",
|
|
495
|
+
},
|
|
496
|
+
],
|
|
316
497
|
supportedLocales: ["en", "de", "fr", "es"],
|
|
317
498
|
});
|
|
318
499
|
```
|
|
319
500
|
|
|
320
|
-
The active locale is taken from the admin and normalized to Yoast's `xx_XX` form (e.g. `en` → `en_EN`).
|
|
501
|
+
The active locale is taken from the admin and normalized to Yoast's `xx_XX` form (e.g. `en` → `en_EN`). The locale is passed to your extractor as `ctx.locale` and to `resolveDocs` (so projected fetches are locale-correct). A locale not listed in `supportedLocales` falls back to English processing.
|
|
321
502
|
|
|
322
503
|
---
|
|
323
504
|
|
|
324
505
|
## Exports Reference
|
|
325
506
|
|
|
326
|
-
| Import path
|
|
327
|
-
|
|
|
328
|
-
| `@focus-reactive/payload-plugin-seo`
|
|
329
|
-
| `@focus-reactive/payload-plugin-seo/
|
|
330
|
-
| `@focus-reactive/payload-plugin-seo/
|
|
507
|
+
| Import path | Exports |
|
|
508
|
+
| --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
509
|
+
| `@focus-reactive/payload-plugin-seo` | `seoPlugin`, and types `SeoPluginConfig`, `SeoCollectionConfig`, `SeoFieldPaths`, `SeoSiteConfig`, `ContentExtractor` |
|
|
510
|
+
| `@focus-reactive/payload-plugin-seo/content` | Builder helpers `heading`, `paragraph`, `link`, `image`, `video`, `richText`, `html`, `compact`; `registerContentExtractors`, `resolveContentExtractor`; types `ContentNode`, `HeadingLevel`, `ContentExtractor`, `ExtractContext`, `ExtractToolkit`, `DocQuery`, `DocStore`, `ContentHelpers` |
|
|
511
|
+
| `@focus-reactive/payload-plugin-seo/admin.css` | Compiled admin styles for the drawer & button |
|
|
512
|
+
| `@focus-reactive/payload-plugin-seo/components/SeoButton` | `SeoButton` — the toolbar button component (wired automatically by the plugin via the importMap; you normally never import this directly) |
|
|
331
513
|
|
|
332
514
|
---
|
|
333
515
|
|