@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.
Files changed (245) hide show
  1. package/README.md +242 -60
  2. package/dist/admin.css +2 -3
  3. package/dist/components/SeoButton/ScoreBadge.d.ts.map +1 -1
  4. package/dist/components/SeoButton/ScoreBadge.js +10 -7
  5. package/dist/components/SeoButton/ScoreBadge.js.map +1 -1
  6. package/dist/components/SeoButton/SeoButtonInner.d.ts +2 -2
  7. package/dist/components/SeoButton/SeoButtonInner.d.ts.map +1 -1
  8. package/dist/components/SeoButton/SeoButtonInner.js +11 -5
  9. package/dist/components/SeoButton/SeoButtonInner.js.map +1 -1
  10. package/dist/components/SeoDrawer/TabsNav/index.d.ts.map +1 -1
  11. package/dist/components/SeoDrawer/TabsNav/index.js +15 -4
  12. package/dist/components/SeoDrawer/TabsNav/index.js.map +1 -1
  13. package/dist/components/SeoDrawer/TabsNav/variants.d.ts.map +1 -1
  14. package/dist/components/SeoDrawer/TabsNav/variants.js +12 -9
  15. package/dist/components/SeoDrawer/TabsNav/variants.js.map +1 -1
  16. package/dist/components/SeoDrawer/analysisDecision.d.ts.map +1 -1
  17. package/dist/components/SeoDrawer/analysisDecision.js.map +1 -1
  18. package/dist/components/SeoDrawer/build-analysis-input.d.ts +3 -8
  19. package/dist/components/SeoDrawer/build-analysis-input.d.ts.map +1 -1
  20. package/dist/components/SeoDrawer/build-analysis-input.js +28 -12
  21. package/dist/components/SeoDrawer/build-analysis-input.js.map +1 -1
  22. package/dist/components/SeoDrawer/buildInput.d.ts +1 -1
  23. package/dist/components/SeoDrawer/buildInput.d.ts.map +1 -1
  24. package/dist/components/SeoDrawer/buildInput.js +13 -3
  25. package/dist/components/SeoDrawer/buildInput.js.map +1 -1
  26. package/dist/components/SeoDrawer/components/Header.d.ts.map +1 -1
  27. package/dist/components/SeoDrawer/components/Header.js +20 -2
  28. package/dist/components/SeoDrawer/components/Header.js.map +1 -1
  29. package/dist/components/SeoDrawer/components/HeadingsSection/HeadingLevelTiles.d.ts.map +1 -1
  30. package/dist/components/SeoDrawer/components/HeadingsSection/HeadingLevelTiles.js +23 -5
  31. package/dist/components/SeoDrawer/components/HeadingsSection/HeadingLevelTiles.js.map +1 -1
  32. package/dist/components/SeoDrawer/components/HeadingsSection/HeadingTree/Chevron.d.ts.map +1 -1
  33. package/dist/components/SeoDrawer/components/HeadingsSection/HeadingTree/Chevron.js +12 -1
  34. package/dist/components/SeoDrawer/components/HeadingsSection/HeadingTree/Chevron.js.map +1 -1
  35. package/dist/components/SeoDrawer/components/HeadingsSection/HeadingTree/HeadingTreeGroup.d.ts.map +1 -1
  36. package/dist/components/SeoDrawer/components/HeadingsSection/HeadingTree/HeadingTreeGroup.js +43 -4
  37. package/dist/components/SeoDrawer/components/HeadingsSection/HeadingTree/HeadingTreeGroup.js.map +1 -1
  38. package/dist/components/SeoDrawer/components/HeadingsSection/HeadingTree/HeadingTreeRow.d.ts +1 -1
  39. package/dist/components/SeoDrawer/components/HeadingsSection/HeadingTree/HeadingTreeRow.d.ts.map +1 -1
  40. package/dist/components/SeoDrawer/components/HeadingsSection/HeadingTree/HeadingTreeRow.js +45 -10
  41. package/dist/components/SeoDrawer/components/HeadingsSection/HeadingTree/HeadingTreeRow.js.map +1 -1
  42. package/dist/components/SeoDrawer/components/HeadingsSection/HeadingTree/index.d.ts.map +1 -1
  43. package/dist/components/SeoDrawer/components/HeadingsSection/HeadingTree/index.js.map +1 -1
  44. package/dist/components/SeoDrawer/components/HeadingsSection/HeadingTree/useHeadingRails.d.ts +1 -1
  45. package/dist/components/SeoDrawer/components/HeadingsSection/HeadingTree/useHeadingRails.d.ts.map +1 -1
  46. package/dist/components/SeoDrawer/components/HeadingsSection/HeadingTree/useHeadingRails.js +6 -1
  47. package/dist/components/SeoDrawer/components/HeadingsSection/HeadingTree/useHeadingRails.js.map +1 -1
  48. package/dist/components/SeoDrawer/components/HeadingsSection/index.d.ts.map +1 -1
  49. package/dist/components/SeoDrawer/components/HeadingsSection/index.js.map +1 -1
  50. package/dist/components/SeoDrawer/components/SerpPreview/highlight-keyphrase.d.ts.map +1 -1
  51. package/dist/components/SeoDrawer/components/SerpPreview/highlight-keyphrase.js +3 -1
  52. package/dist/components/SeoDrawer/components/SerpPreview/highlight-keyphrase.js.map +1 -1
  53. package/dist/components/SeoDrawer/components/SerpPreview/index.d.ts.map +1 -1
  54. package/dist/components/SeoDrawer/components/SerpPreview/index.js +8 -1
  55. package/dist/components/SeoDrawer/components/SerpPreview/index.js.map +1 -1
  56. package/dist/components/SeoDrawer/components/SerpPreview/serp-favicon.d.ts.map +1 -1
  57. package/dist/components/SeoDrawer/components/SerpPreview/serp-favicon.js.map +1 -1
  58. package/dist/components/SeoDrawer/components/SerpPreview/variants.d.ts.map +1 -1
  59. package/dist/components/SeoDrawer/components/SerpPreview/variants.js +9 -6
  60. package/dist/components/SeoDrawer/components/SerpPreview/variants.js.map +1 -1
  61. package/dist/components/SeoDrawer/index.d.ts.map +1 -1
  62. package/dist/components/SeoDrawer/index.js +50 -15
  63. package/dist/components/SeoDrawer/index.js.map +1 -1
  64. package/dist/components/SeoDrawer/keyphrasePending.d.ts.map +1 -1
  65. package/dist/components/SeoDrawer/keyphrasePending.js.map +1 -1
  66. package/dist/components/SeoDrawer/languagePacks.d.ts.map +1 -1
  67. package/dist/components/SeoDrawer/languagePacks.js.map +1 -1
  68. package/dist/components/SeoDrawer/tabs/InclusiveTab.d.ts.map +1 -1
  69. package/dist/components/SeoDrawer/tabs/InclusiveTab.js +31 -10
  70. package/dist/components/SeoDrawer/tabs/InclusiveTab.js.map +1 -1
  71. package/dist/components/SeoDrawer/tabs/KeyphraseTab.d.ts +1 -1
  72. package/dist/components/SeoDrawer/tabs/KeyphraseTab.d.ts.map +1 -1
  73. package/dist/components/SeoDrawer/tabs/KeyphraseTab.js +8 -1
  74. package/dist/components/SeoDrawer/tabs/KeyphraseTab.js.map +1 -1
  75. package/dist/components/SeoDrawer/tabs/SerpTab.d.ts +1 -1
  76. package/dist/components/SeoDrawer/tabs/SerpTab.d.ts.map +1 -1
  77. package/dist/components/SeoDrawer/tabs/SerpTab.js +5 -1
  78. package/dist/components/SeoDrawer/tabs/SerpTab.js.map +1 -1
  79. package/dist/components/SeoDrawer/tabs/VitalsTab.d.ts.map +1 -1
  80. package/dist/components/SeoDrawer/tabs/VitalsTab.js +32 -9
  81. package/dist/components/SeoDrawer/tabs/VitalsTab.js.map +1 -1
  82. package/dist/components/SeoDrawer/useAnalysis.d.ts +1 -1
  83. package/dist/components/SeoDrawer/useAnalysis.d.ts.map +1 -1
  84. package/dist/components/SeoDrawer/useAnalysis.js +6 -1
  85. package/dist/components/SeoDrawer/useAnalysis.js.map +1 -1
  86. package/dist/components/SeoDrawer/useLiveDocument.d.ts +3 -4
  87. package/dist/components/SeoDrawer/useLiveDocument.d.ts.map +1 -1
  88. package/dist/components/SeoDrawer/useLiveDocument.js +45 -48
  89. package/dist/components/SeoDrawer/useLiveDocument.js.map +1 -1
  90. package/dist/components/icons.d.ts.map +1 -1
  91. package/dist/components/icons.js +16 -1
  92. package/dist/components/icons.js.map +1 -1
  93. package/dist/constants/checkIds.d.ts.map +1 -1
  94. package/dist/constants/checkIds.js +18 -2
  95. package/dist/constants/checkIds.js.map +1 -1
  96. package/dist/content/index.d.ts +5 -0
  97. package/dist/content/index.d.ts.map +1 -0
  98. package/dist/content/index.js +15 -0
  99. package/dist/content/index.js.map +1 -0
  100. package/dist/content/registry.d.ts +4 -0
  101. package/dist/content/registry.d.ts.map +1 -0
  102. package/dist/content/registry.js +22 -0
  103. package/dist/content/registry.js.map +1 -0
  104. package/dist/content/resolve/resolve-docs.d.ts +3 -0
  105. package/dist/content/resolve/resolve-docs.d.ts.map +1 -0
  106. package/dist/content/resolve/resolve-docs.js +48 -0
  107. package/dist/content/resolve/resolve-docs.js.map +1 -0
  108. package/dist/content/schema/helpers.d.ts +10 -0
  109. package/dist/content/schema/helpers.d.ts.map +1 -0
  110. package/dist/content/schema/helpers.js +64 -0
  111. package/dist/content/schema/helpers.js.map +1 -0
  112. package/dist/content/schema/nodes.d.ts +26 -0
  113. package/dist/content/schema/nodes.d.ts.map +1 -0
  114. package/dist/content/schema/nodes.js +8 -0
  115. package/dist/content/schema/nodes.js.map +1 -0
  116. package/dist/content/schema/serialize.d.ts +3 -0
  117. package/dist/content/schema/serialize.d.ts.map +1 -0
  118. package/dist/content/schema/serialize.js +31 -0
  119. package/dist/content/schema/serialize.js.map +1 -0
  120. package/dist/engine/assessorAdapter.d.ts.map +1 -1
  121. package/dist/engine/assessorAdapter.js.map +1 -1
  122. package/dist/engine/extractCheckData.d.ts.map +1 -1
  123. package/dist/engine/extractCheckData.js +19 -5
  124. package/dist/engine/extractCheckData.js.map +1 -1
  125. package/dist/engine/helpers/title-progress.d.ts +1 -1
  126. package/dist/engine/helpers/title-progress.js.map +1 -1
  127. package/dist/engine/inclusiveScore.d.ts.map +1 -1
  128. package/dist/engine/inclusiveScore.js.map +1 -1
  129. package/dist/engine/recommendations.d.ts.map +1 -1
  130. package/dist/engine/recommendations.js.map +1 -1
  131. package/dist/engine/runAnalysis/services/derive-inclusive.js.map +1 -1
  132. package/dist/engine/runAnalysis/services/derive-readability.d.ts.map +1 -1
  133. package/dist/engine/runAnalysis/services/derive-readability.js +5 -1
  134. package/dist/engine/runAnalysis/services/derive-readability.js.map +1 -1
  135. package/dist/engine/runAnalysis/services/derive-vitals/heading-tree.d.ts.map +1 -1
  136. package/dist/engine/runAnalysis/services/derive-vitals/heading-tree.js.map +1 -1
  137. package/dist/engine/runAnalysis/services/derive-vitals/index.d.ts.map +1 -1
  138. package/dist/engine/runAnalysis/services/derive-vitals/index.js +9 -1
  139. package/dist/engine/runAnalysis/services/derive-vitals/index.js.map +1 -1
  140. package/dist/engine/runAnalysis/services/derive-vitals/researches.d.ts +1 -1
  141. package/dist/engine/runAnalysis/services/derive-vitals/researches.d.ts.map +1 -1
  142. package/dist/engine/runAnalysis/services/derive-vitals/researches.js +8 -2
  143. package/dist/engine/runAnalysis/services/derive-vitals/researches.js.map +1 -1
  144. package/dist/engine/runAnalysis/utils/enrich.d.ts.map +1 -1
  145. package/dist/engine/runAnalysis/utils/enrich.js.map +1 -1
  146. package/dist/engine/visualization/resolveVisualization/constants.d.ts.map +1 -1
  147. package/dist/engine/visualization/resolveVisualization/constants.js +9 -1
  148. package/dist/engine/visualization/resolveVisualization/constants.js.map +1 -1
  149. package/dist/engine/visualization/resolveVisualization/index.d.ts.map +1 -1
  150. package/dist/engine/visualization/resolveVisualization/index.js.map +1 -1
  151. package/dist/engine/visualization/resolveVisualization/resolves/resolveDrilldown.d.ts.map +1 -1
  152. package/dist/engine/visualization/resolveVisualization/resolves/resolveDrilldown.js.map +1 -1
  153. package/dist/engine/visualization/resolveVisualization/resolves/resolveLinks.d.ts.map +1 -1
  154. package/dist/engine/visualization/resolveVisualization/resolves/resolveLinks.js.map +1 -1
  155. package/dist/engine/visualization/resolveVisualization/resolves/resolveProportionCount.d.ts.map +1 -1
  156. package/dist/engine/visualization/resolveVisualization/resolves/resolveProportionCount.js.map +1 -1
  157. package/dist/engine/visualization/resolveVisualization/resolves/resolveValueRange.d.ts.map +1 -1
  158. package/dist/engine/visualization/resolveVisualization/resolves/resolveValueRange.js.map +1 -1
  159. package/dist/engine/visualization/resolveVisualization/utils/pluralize.d.ts.map +1 -1
  160. package/dist/engine/visualization/resolveVisualization/utils/pluralize.js.map +1 -1
  161. package/dist/engine/visualization/resolveVisualization/utils/readDrilldownItems.d.ts.map +1 -1
  162. package/dist/engine/visualization/resolveVisualization/utils/readDrilldownItems.js.map +1 -1
  163. package/dist/engine/visualization/resolveVisualization/utils/readNumber.d.ts.map +1 -1
  164. package/dist/engine/visualization/resolveVisualization/utils/readNumber.js.map +1 -1
  165. package/dist/index.d.ts +1 -1
  166. package/dist/index.d.ts.map +1 -1
  167. package/dist/index.js.map +1 -1
  168. package/dist/plugin.d.ts.map +1 -1
  169. package/dist/plugin.js +24 -4
  170. package/dist/plugin.js.map +1 -1
  171. package/dist/types/config.d.ts +42 -9
  172. package/dist/types/config.d.ts.map +1 -1
  173. package/dist/ui/CheckRow/CheckVisualization/visualizations/DensityGauge.d.ts.map +1 -1
  174. package/dist/ui/CheckRow/CheckVisualization/visualizations/DensityGauge.js +24 -5
  175. package/dist/ui/CheckRow/CheckVisualization/visualizations/DensityGauge.js.map +1 -1
  176. package/dist/ui/CheckRow/CheckVisualization/visualizations/DistributionBar.d.ts.map +1 -1
  177. package/dist/ui/CheckRow/CheckVisualization/visualizations/DistributionBar.js +8 -1
  178. package/dist/ui/CheckRow/CheckVisualization/visualizations/DistributionBar.js.map +1 -1
  179. package/dist/ui/CheckRow/CheckVisualization/visualizations/DrillDown.d.ts.map +1 -1
  180. package/dist/ui/CheckRow/CheckVisualization/visualizations/DrillDown.js +11 -4
  181. package/dist/ui/CheckRow/CheckVisualization/visualizations/DrillDown.js.map +1 -1
  182. package/dist/ui/CheckRow/CheckVisualization/visualizations/SegmentBar.d.ts.map +1 -1
  183. package/dist/ui/CheckRow/CheckVisualization/visualizations/SegmentBar.js +7 -1
  184. package/dist/ui/CheckRow/CheckVisualization/visualizations/SegmentBar.js.map +1 -1
  185. package/dist/ui/CheckRow/index.d.ts.map +1 -1
  186. package/dist/ui/CheckRow/index.js +23 -9
  187. package/dist/ui/CheckRow/index.js.map +1 -1
  188. package/dist/ui/FilterPills.d.ts.map +1 -1
  189. package/dist/ui/FilterPills.js +78 -19
  190. package/dist/ui/FilterPills.js.map +1 -1
  191. package/dist/ui/KpiCard.d.ts.map +1 -1
  192. package/dist/ui/KpiCard.js.map +1 -1
  193. package/dist/ui/Pill.d.ts.map +1 -1
  194. package/dist/ui/Pill.js +11 -8
  195. package/dist/ui/Pill.js.map +1 -1
  196. package/dist/ui/ScoreRing.d.ts.map +1 -1
  197. package/dist/ui/ScoreRing.js +12 -2
  198. package/dist/ui/ScoreRing.js.map +1 -1
  199. package/dist/ui/SectionWrapper.d.ts.map +1 -1
  200. package/dist/ui/SectionWrapper.js.map +1 -1
  201. package/dist/ui/SegmentedControl.d.ts +1 -1
  202. package/dist/ui/SegmentedControl.d.ts.map +1 -1
  203. package/dist/ui/SegmentedControl.js +49 -33
  204. package/dist/ui/SegmentedControl.js.map +1 -1
  205. package/dist/ui/Tooltip.d.ts +1 -1
  206. package/dist/ui/Tooltip.d.ts.map +1 -1
  207. package/dist/ui/Tooltip.js +8 -1
  208. package/dist/ui/Tooltip.js.map +1 -1
  209. package/dist/utils/config/overrideAdmin.js +1 -1
  210. package/dist/utils/config/overrideAdmin.js.map +1 -1
  211. package/dist/utils/style.d.ts.map +1 -1
  212. package/dist/utils/style.js.map +1 -1
  213. package/package.json +9 -7
  214. package/dist/content/extractContent.d.ts +0 -3
  215. package/dist/content/extractContent.d.ts.map +0 -1
  216. package/dist/content/extractContent.js +0 -31
  217. package/dist/content/extractContent.js.map +0 -1
  218. package/dist/content/uploads/collect-upload-refs.d.ts +0 -5
  219. package/dist/content/uploads/collect-upload-refs.d.ts.map +0 -1
  220. package/dist/content/uploads/collect-upload-refs.js +0 -19
  221. package/dist/content/uploads/collect-upload-refs.js.map +0 -1
  222. package/dist/content/uploads/hydrate-values.d.ts +0 -5
  223. package/dist/content/uploads/hydrate-values.d.ts.map +0 -1
  224. package/dist/content/uploads/hydrate-values.js +0 -15
  225. package/dist/content/uploads/hydrate-values.js.map +0 -1
  226. package/dist/content/uploads/media-resolver.d.ts +0 -7
  227. package/dist/content/uploads/media-resolver.d.ts.map +0 -1
  228. package/dist/content/uploads/media-resolver.js +0 -60
  229. package/dist/content/uploads/media-resolver.js.map +0 -1
  230. package/dist/content/uploads/transform-lexical-uploads.d.ts +0 -5
  231. package/dist/content/uploads/transform-lexical-uploads.d.ts.map +0 -1
  232. package/dist/content/uploads/transform-lexical-uploads.js +0 -34
  233. package/dist/content/uploads/transform-lexical-uploads.js.map +0 -1
  234. package/dist/content/uploads/transform-upload-values.d.ts +0 -10
  235. package/dist/content/uploads/transform-upload-values.d.ts.map +0 -1
  236. package/dist/content/uploads/transform-upload-values.js +0 -108
  237. package/dist/content/uploads/transform-upload-values.js.map +0 -1
  238. package/dist/content/uploads/types.d.ts +0 -8
  239. package/dist/content/uploads/types.d.ts.map +0 -1
  240. package/dist/content/uploads/types.js +0 -7
  241. package/dist/content/uploads/types.js.map +0 -1
  242. package/dist/content/walkValue.d.ts +0 -14
  243. package/dist/content/walkValue.d.ts.map +0 -1
  244. package/dist/content/walkValue.js +0 -62
  245. 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, extracts the title, meta description, slug, body content, and images, and runs the Yoast analysis engine **entirely in the browser**. Nothing is persisted — there are zero new collections, globals, or fields.
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
- - Extracts title, meta description, slug, body content and images using dot-path config
23
- - Resolves upload/relationship media into <img> tags via the Payload REST API
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
- - `fields.content` should point at your primary body field. The built-in extractor walks
72
- blocks, arrays, groups, tabs, lexical richText, and uploads, converting them to HTML.
73
- - If the built-in extractor can't reach your content shape, supply `extractContentPath`:
74
- an importMap module path to a `(formValues) => string | Promise<string>` returning HTML.
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 resolving media URLs from your own Payload API.
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, content, keyphrase)
94
- ├─ collect upload/relationship refs resolve via /api/{collection}?depth=0&locale=…
95
- ├─ hydrate values → walk tree → build HTML (lexical → HTML, images <img>)
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 SEO inputs. */
237
+ /** Dot-paths telling the plugin which fields hold the title / meta description / slug. */
205
238
  fields?: SeoFieldPaths;
206
239
  /**
207
- * importMap module-path to a custom client extractor
208
- * `(formData) => string | Promise<string>` returning HTML.
209
- * Example: "@/seo/my-extractor#default".
210
- * Default: built-in smart extractor.
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?: string;
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
- ### Custom content extractor
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 ExtractorFn = (data: Record<string, unknown>) => string | Promise<string>;
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
- Provide one when the built-in extractor can't reconstruct your content shape. It receives the raw (unhydrated) form values and must return an HTML string. Reference it from config by importMap path:
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
- // payload.config.ts
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
- { slug: "pages", extractContentPath: "@/seo/my-extractor#default" },
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
- ```ts
265
- // src/seo/my-extractor.ts
266
- export default function extractContent(data: Record<string, unknown>): string {
267
- return `<h1>${data.title}</h1><p>${data.body}</p>`;
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
- ## Content Extraction
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
- When `extractContentPath` is **not** set, the plugin's built-in extractor:
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
- 1. **Reads** the value at `fields.content` from the live form values.
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 means image checks (alt text, keyphrase in alt, image count) work against the real, resolved media not raw relationship IDs.
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 | What it checks |
297
- | --------------------- | ------------------------------------------------------------------------------ |
298
- | **Keyphrase** | Focus keyphrase usage — in title, slug, meta description, first paragraph, density, image alt, synonyms. Enter a keyphrase to unlock these checks. |
299
- | **On-page SEO** | Title width, meta description presence/length, internal & outbound links, heading structure. |
300
- | **Readability** | Sentence/paragraph length, transition words, passive voice, consecutive sentences. |
301
- | **Inclusive** | Flags potentially exclusionary or non-inclusive language. |
302
- | **Content vitals** | Word count, sentence/paragraph counts, image & video counts, reading time, prominent words. |
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: [{ slug: "pages", fields: { content: "sections" } }],
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`). Media is resolved per-locale so localized URLs and alt text are analyzed correctly. A locale not listed in `supportedLocales` falls back to English processing.
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 | Exports |
327
- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------- |
328
- | `@focus-reactive/payload-plugin-seo` | `seoPlugin`, and types `SeoPluginConfig`, `SeoCollectionConfig`, `SeoFieldPaths`, `SeoSiteConfig`, `ExtractorFn` |
329
- | `@focus-reactive/payload-plugin-seo/admin.css` | Compiled admin styles for the drawer & button |
330
- | `@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) |
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