@focus-reactive/payload-plugin-seo 1.4.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 (75) hide show
  1. package/README.md +139 -124
  2. package/dist/components/SeoButton/SeoButtonInner.d.ts +2 -4
  3. package/dist/components/SeoButton/SeoButtonInner.d.ts.map +1 -1
  4. package/dist/components/SeoButton/SeoButtonInner.js +10 -7
  5. package/dist/components/SeoButton/SeoButtonInner.js.map +1 -1
  6. package/dist/components/SeoDrawer/build-analysis-input.d.ts +1 -8
  7. package/dist/components/SeoDrawer/build-analysis-input.d.ts.map +1 -1
  8. package/dist/components/SeoDrawer/build-analysis-input.js +22 -34
  9. package/dist/components/SeoDrawer/build-analysis-input.js.map +1 -1
  10. package/dist/components/SeoDrawer/buildInput.js +1 -1
  11. package/dist/components/SeoDrawer/buildInput.js.map +1 -1
  12. package/dist/components/SeoDrawer/useLiveDocument.d.ts +2 -5
  13. package/dist/components/SeoDrawer/useLiveDocument.d.ts.map +1 -1
  14. package/dist/components/SeoDrawer/useLiveDocument.js +14 -62
  15. package/dist/components/SeoDrawer/useLiveDocument.js.map +1 -1
  16. package/dist/content/index.d.ts +2 -2
  17. package/dist/content/index.d.ts.map +1 -1
  18. package/dist/content/index.js +2 -1
  19. package/dist/content/index.js.map +1 -1
  20. package/dist/content/resolve/resolve-docs.d.ts +3 -0
  21. package/dist/content/resolve/resolve-docs.d.ts.map +1 -0
  22. package/dist/content/resolve/resolve-docs.js +48 -0
  23. package/dist/content/resolve/resolve-docs.js.map +1 -0
  24. package/dist/content/schema/helpers.d.ts +1 -0
  25. package/dist/content/schema/helpers.d.ts.map +1 -1
  26. package/dist/content/schema/helpers.js +4 -0
  27. package/dist/content/schema/helpers.js.map +1 -1
  28. package/dist/index.d.ts +1 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js.map +1 -1
  31. package/dist/plugin.d.ts.map +1 -1
  32. package/dist/plugin.js +18 -3
  33. package/dist/plugin.js.map +1 -1
  34. package/dist/types/config.d.ts +35 -25
  35. package/dist/types/config.d.ts.map +1 -1
  36. package/dist/utils/config/overrideAdmin.d.ts.map +1 -1
  37. package/dist/utils/config/overrideAdmin.js +2 -10
  38. package/dist/utils/config/overrideAdmin.js.map +1 -1
  39. package/package.json +1 -1
  40. package/dist/content/extract/context.d.ts +0 -11
  41. package/dist/content/extract/context.d.ts.map +0 -1
  42. package/dist/content/extract/context.js +0 -1
  43. package/dist/content/extract/context.js.map +0 -1
  44. package/dist/content/extract/extract.d.ts +0 -18
  45. package/dist/content/extract/extract.d.ts.map +0 -1
  46. package/dist/content/extract/extract.js +0 -211
  47. package/dist/content/extract/extract.js.map +0 -1
  48. package/dist/content/extract/selection.d.ts +0 -3
  49. package/dist/content/extract/selection.d.ts.map +0 -1
  50. package/dist/content/extract/selection.js +0 -32
  51. package/dist/content/extract/selection.js.map +0 -1
  52. package/dist/content/lexical/transform.d.ts +0 -5
  53. package/dist/content/lexical/transform.d.ts.map +0 -1
  54. package/dist/content/lexical/transform.js +0 -56
  55. package/dist/content/lexical/transform.js.map +0 -1
  56. package/dist/content/resolve/collect-refs.d.ts +0 -8
  57. package/dist/content/resolve/collect-refs.d.ts.map +0 -1
  58. package/dist/content/resolve/collect-refs.js +0 -79
  59. package/dist/content/resolve/collect-refs.js.map +0 -1
  60. package/dist/content/resolve/hydrate.d.ts +0 -7
  61. package/dist/content/resolve/hydrate.d.ts.map +0 -1
  62. package/dist/content/resolve/hydrate.js +0 -123
  63. package/dist/content/resolve/hydrate.js.map +0 -1
  64. package/dist/content/resolve/resolver.d.ts +0 -7
  65. package/dist/content/resolve/resolver.d.ts.map +0 -1
  66. package/dist/content/resolve/resolver.js +0 -65
  67. package/dist/content/resolve/resolver.js.map +0 -1
  68. package/dist/content/resolve/types.d.ts +0 -12
  69. package/dist/content/resolve/types.d.ts.map +0 -1
  70. package/dist/content/resolve/types.js +0 -7
  71. package/dist/content/resolve/types.js.map +0 -1
  72. package/dist/content/walk/walkFields.d.ts +0 -17
  73. package/dist/content/walk/walkFields.d.ts.map +0 -1
  74. package/dist/content/walk/walkFields.js +0 -88
  75. package/dist/content/walk/walkFields.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,18 +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
- the lookup key for a `ContentExtractor` you register via `registerContentExtractors` from
75
- `@focus-reactive/payload-plugin-seo/content`. The extractor receives hydrated form values
76
- and returns `ContentNode[]` — a structured Intermediate Representation the plugin serializes to HTML internally.
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().
77
102
  - Non-English analysis requires the locale code in `supportedLocales`; the matching Yoast
78
103
  language pack is dynamically imported on demand.
79
- - 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.
80
105
  ```
81
106
 
82
107
  ---
@@ -92,9 +117,10 @@ Document editor (configured collection)
92
117
 
93
118
  SEO Drawer (client-only)
94
119
 
95
- ├─ read live form values (title, description, slug, content, keyphrase)
96
- ├─ collect upload/relationship refs resolve via /api/{collection}?depth=0&locale=…
97
- ├─ 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
98
124
 
99
125
  Yoast engine (in browser): Paper + EnglishResearcher + SeoAssessor
100
126
 
@@ -137,8 +163,9 @@ export default buildConfig({
137
163
  seoTitle: "seoTitle",
138
164
  metaDescription: "metaDescription",
139
165
  slug: "slug",
140
- content: "sections",
141
166
  },
167
+ // Required: register a matching extractor (see "Content Extraction").
168
+ extractContentPath: "@/seo/extractPageContent#default",
142
169
  },
143
170
  ],
144
171
  site: {
@@ -176,6 +203,10 @@ const nextConfig = {
176
203
  export default nextConfig;
177
204
  ```
178
205
 
206
+ ### Step 4 — Write and register a content extractor
207
+
208
+ Required — see [Content Extraction](#content-extraction).
209
+
179
210
  ---
180
211
 
181
212
  ## Configuration Reference
@@ -203,30 +234,27 @@ interface SeoPluginConfig {
203
234
  interface SeoCollectionConfig {
204
235
  /** Collection slug to attach the drawer to. */
205
236
  slug: string;
206
- /** 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. */
207
238
  fields?: SeoFieldPaths;
208
239
  /**
209
- * Lookup key for a registered ContentExtractor.
210
- * Set this to the same string you pass as the key in registerContentExtractors().
211
- * Convention: use the module path of the extractor file, e.g. "@/seo/my-extractor#default".
212
- * The extractor runs in the browser on hydrated, unflattened form values and returns ContentNode[].
213
- * Register it from an admin-mounted client module see "Content Extraction" below.
214
- * Default: the built-in document walker.
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.
215
250
  */
216
- extractContentPath?: string;
251
+ extractContentPath: string;
217
252
  }
218
253
  ```
219
254
 
220
255
  ### SeoFieldPaths
221
256
 
222
257
  ```ts
223
- interface ContentSelection {
224
- /** Dot-paths to walk, in order. Omitted or empty = whole document root. */
225
- include?: string[];
226
- /** Dot-paths to skip (merged with auto-excluded seoTitle/metaDescription/slug). */
227
- exclude?: string[];
228
- }
229
-
230
258
  interface SeoFieldPaths {
231
259
  /** Dot-path to the SEO title. Falls back to the collection's useAsTitle / `title`. */
232
260
  seoTitle?: string;
@@ -235,27 +263,10 @@ interface SeoFieldPaths {
235
263
  metaDescription?: string;
236
264
  /** Dot-path to the slug. Default: 'slug' */
237
265
  slug?: string;
238
- /**
239
- * Built-in content selection. A string is a single field path (back-compat).
240
- * An object selects include/exclude paths over the whole document.
241
- * Ignored when extractContentPath is set and registered.
242
- */
243
- content?: string | ContentSelection;
244
266
  }
245
267
  ```
246
268
 
247
- Dot-paths support nesting, e.g. `"meta.description"` or `"content.body"`.
248
-
249
- **`content` selection semantics:**
250
-
251
- | Value | Behavior |
252
- | ------------------------------------ | ---------------------------------------------------------- |
253
- | `"blocks"` (string) | Walk that one field path. Back-compat — unchanged from v1. |
254
- | `{ include: ["blocks", "excerpt"] }` | Walk each listed path in order, concatenated. |
255
- | `{ exclude: ["meta"] }` (no include) | Walk the whole document root, skipping excluded subtrees. |
256
- | `{}` / `{ include: [] }` | Walk the whole document root. |
257
-
258
- **Automatic metadata exclusion:** the configured `seoTitle`, `metaDescription`, and `slug` paths are always excluded from body content so their text is not double-counted in the Yoast analysis.
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).
259
270
 
260
271
  ### SeoSiteConfig
261
272
 
@@ -272,6 +283,8 @@ interface SeoSiteConfig {
272
283
 
273
284
  ## Content Extraction
274
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
+
275
288
  ### The `ContentNode` Intermediate Representation
276
289
 
277
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`:
@@ -286,11 +299,11 @@ type ContentNode =
286
299
  | { type: "html"; html: string }; // lexical-converted or raw HTML escape hatch
287
300
  ```
288
301
 
289
- Serialization to HTML (for the Yoast engine) happens entirely inside the plugin. Custom extractors produce `ContentNode[]`; they never construct HTML strings directly.
302
+ Serialization to HTML (for the Yoast engine) happens entirely inside the plugin. Extractors produce `ContentNode[]`; they never construct HTML strings directly.
290
303
 
291
304
  ### Builder helpers
292
305
 
293
- The `/content` subpath exports pure builder functions. Each helper returns `null` for empty or missing input, so you can call `.filter(Boolean)` on an array of helper results:
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.
294
307
 
295
308
  ```ts
296
309
  import {
@@ -299,8 +312,9 @@ import {
299
312
  link, // link(href?: string | null, text?: string | null): ContentNode | null
300
313
  image, // image(src?: string | null, alt?: string | null): ContentNode | null
301
314
  video, // video(src?: string | null, poster?: string | null): ContentNode | null
302
- richText, // richText(lexicalValue: unknown): ContentNode | null (converts via convertLexicalToHTML; null when empty)
315
+ richText, // richText(lexicalValue: unknown): ContentNode | null (lexical HTML; null when empty)
303
316
  html, // html(raw?: string | null): ContentNode | null
317
+ compact, // compact(nodes: (ContentNode | null | undefined)[]): ContentNode[]
304
318
  } from "@focus-reactive/payload-plugin-seo/content";
305
319
  import type {
306
320
  ContentNode,
@@ -308,77 +322,73 @@ import type {
308
322
  } from "@focus-reactive/payload-plugin-seo/content";
309
323
  ```
310
324
 
311
- ### Built-in extractor
312
-
313
- When `extractContentPath` is not set (or points to an unregistered key), the plugin's built-in extractor runs:
314
-
315
- 1. **Selects** the subtree(s) specified by `fields.content` — a single field path, an `include` list, or the whole document root.
316
- 2. **Collects upload / relationship references** by walking the form schema (arrays, blocks, groups, tabs, rows, collapsibles, and lexical richText, including inline media nodes).
317
- 3. **Resolves media** by calling your Payload REST API per collection:
318
- `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.
319
- 4. **Hydrates** the value tree (upload IDs → full docs) and walks it to emit `ContentNode[]`:
320
- - Lexical richText → `{ type: "html", html: "…" }` via `@payloadcms/richtext-lexical/html`
321
- - `{ url, mimeType: "image/*", alt? }` → `{ type: "image", … }`
322
- - `{ url, label | text | title }` → `{ type: "link", … }`
323
- - Strings → `{ type: "paragraph", … }`
324
- - Structural keys (`id`, `blockType`, `blockName`, `_template`, `order`) are skipped
325
- 5. **Serializes** the Intermediate Representation to an HTML string that is fed to the Yoast engine.
326
-
327
- Image checks (alt text, keyphrase in alt, image count) work against the real, resolved media — not raw relationship IDs.
328
-
329
- ### Custom extractor (`ContentExtractor`)
325
+ ### The extractor contract
330
326
 
331
327
  ```ts
332
328
  type ContentExtractor = (
333
- values: Record<string, unknown>,
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 }
334
332
  ) => ContentNode[] | Promise<ContentNode[]>;
335
- ```
336
333
 
337
- Supply a custom extractor when the built-in walker cannot reconstruct your content shape (e.g. a complex block schema with a specific heading hierarchy). The extractor:
334
+ interface ExtractToolkit {
335
+ resolveDocs: (queries: DocQuery[]) => Promise<DocStore>;
336
+ helpers: ContentHelpers; // heading, paragraph, link, image, video, richText, html, compact
337
+ }
338
338
 
339
- - Receives **hydrated, unflattened** form values — upload IDs have already been resolved to full media objects before your function is called.
340
- - Returns `ContentNode[]` (not an HTML string).
341
- - Runs **entirely in the browser** on the live, unsaved form state.
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
+ }
342
345
 
343
- Use the `/content` helpers to build the Intermediate Representation:
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.
344
357
 
345
358
  ```ts
346
- // src/seo/extract-page-content.ts
347
- import {
348
- heading,
349
- paragraph,
350
- image,
351
- link,
352
- richText,
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,
353
364
  } from "@focus-reactive/payload-plugin-seo/content";
354
- import type { ContentNode } from "@focus-reactive/payload-plugin-seo/content";
355
-
356
- export default function extractPageContent(
357
- values: Record<string, unknown>,
358
- ): ContentNode[] {
359
- const blocks = (values as { blocks?: unknown[] }).blocks ?? [];
360
- return blocks.flatMap((block) => {
361
- const b = block as Record<string, unknown>;
362
- switch (b.blockType) {
363
- case "hero":
364
- return [
365
- heading(2, b.title as string),
366
- paragraph(b.subtitle as string),
367
- image(
368
- (b.image as { url?: string; alt?: string } | null)?.url,
369
- (b.image as { url?: string; alt?: string } | null)?.alt,
370
- ),
371
- link(b.ctaUrl as string, b.ctaLabel as string),
372
- ].filter((n): n is ContentNode => n !== null);
373
- case "richText":
374
- return [richText(b.content)].filter(
375
- (n): n is ContentNode => n !== null,
376
- );
377
- default:
378
- return [];
379
- }
380
- });
381
- }
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;
382
392
  ```
383
393
 
384
394
  ### The registry: why it exists and how to use it
@@ -401,7 +411,6 @@ seoPlugin({
401
411
  seoTitle: "meta.title",
402
412
  metaDescription: "meta.description",
403
413
  slug: "slug",
404
- content: "blocks",
405
414
  },
406
415
  extractContentPath: "@/collections/Page/extractPageContent#default",
407
416
  },
@@ -447,11 +456,11 @@ export default buildConfig({
447
456
  });
448
457
  ```
449
458
 
450
- 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 warning and falls back to the built-in document walker.
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.
451
460
 
452
- ### Limitation: `reference`-type action links
461
+ ### Limitation: links and uploads embedded inside richText
453
462
 
454
- Only actions with a literal `url` string become `link` nodes in the Intermediate Representation. Internal relationship references (Payload `relationship` fields pointing to a page or post) cannot be resolved to a URL client-side only upload media is hydrated before the extractor runs. Omit `reference`-type actions from your extractor or return nothing for them.
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.
455
464
 
456
465
  ---
457
466
 
@@ -478,23 +487,29 @@ The drawer presents six tabs, all derived from a single in-browser Yoast analysi
478
487
 
479
488
  ```ts
480
489
  seoPlugin({
481
- collections: [{ slug: "pages", fields: { content: "sections" } }],
490
+ collections: [
491
+ {
492
+ slug: "pages",
493
+ fields: { slug: "slug" },
494
+ extractContentPath: "@/seo/extractPageContent#default",
495
+ },
496
+ ],
482
497
  supportedLocales: ["en", "de", "fr", "es"],
483
498
  });
484
499
  ```
485
500
 
486
- 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.
487
502
 
488
503
  ---
489
504
 
490
505
  ## Exports Reference
491
506
 
492
- | Import path | Exports |
493
- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
494
- | `@focus-reactive/payload-plugin-seo` | `seoPlugin`, and types `SeoPluginConfig`, `SeoCollectionConfig`, `SeoFieldPaths`, `SeoSiteConfig`, `ContentExtractor`, `ContentSelection` |
495
- | `@focus-reactive/payload-plugin-seo/content` | Builder helpers `heading`, `paragraph`, `link`, `image`, `video`, `richText`, `html`; `registerContentExtractors`, `resolveContentExtractor`; types `ContentNode`, `HeadingLevel`, `ContentExtractor`, `ContentSelection` |
496
- | `@focus-reactive/payload-plugin-seo/admin.css` | Compiled admin styles for the drawer & button |
497
- | `@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) |
498
513
 
499
514
  ---
500
515
 
@@ -1,15 +1,13 @@
1
1
  export interface SeoButtonProps {
2
2
  collectionSlug: string;
3
3
  fields: Record<string, string>;
4
- extractContentPath: string | null;
4
+ extractContentPath: string;
5
5
  site: {
6
6
  name: string;
7
7
  baseUrl: string;
8
8
  faviconUrl: string;
9
9
  };
10
10
  supportedLocales: string[];
11
- resolveDepth: number;
12
- slugPaths: Record<string, string>;
13
11
  }
14
- export declare function SeoButtonInner({ collectionSlug, fields, site, supportedLocales, extractContentPath, resolveDepth, slugPaths }: SeoButtonProps): import("react/jsx-runtime").JSX.Element;
12
+ export declare function SeoButtonInner({ collectionSlug, fields, site, supportedLocales, extractContentPath, }: SeoButtonProps): import("react/jsx-runtime").JSX.Element;
15
13
  //# sourceMappingURL=SeoButtonInner.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"SeoButtonInner.d.ts","sourceRoot":"","sources":["../../../src/components/SeoButton/SeoButtonInner.tsx"],"names":[],"mappings":"AAWA,MAAM,WAAW,cAAc;IAC7B,cAAc,EAAE,MAAM,CAAC;IACvB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/B,kBAAkB,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,CAAC;IAC5D,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACnC;AAID,wBAAgB,cAAc,CAAC,EAAE,cAAc,EAAE,MAAM,EAAE,IAAI,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,YAAY,EAAE,SAAS,EAAE,EAAE,cAAc,2CAwD7I"}
1
+ {"version":3,"file":"SeoButtonInner.d.ts","sourceRoot":"","sources":["../../../src/components/SeoButton/SeoButtonInner.tsx"],"names":[],"mappings":"AAWA,MAAM,WAAW,cAAc;IAC7B,cAAc,EAAE,MAAM,CAAC;IACvB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/B,kBAAkB,EAAE,MAAM,CAAC;IAC3B,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,CAAC;IAC5D,gBAAgB,EAAE,MAAM,EAAE,CAAC;CAC5B;AAID,wBAAgB,cAAc,CAAC,EAC7B,cAAc,EACd,MAAM,EACN,IAAI,EACJ,gBAAgB,EAChB,kBAAkB,GACnB,EAAE,cAAc,2CAqDhB"}
@@ -9,17 +9,21 @@ import { isKeyphrasePending } from "../SeoDrawer/keyphrasePending";
9
9
  import { useAnalysis } from "../SeoDrawer/useAnalysis";
10
10
  import { useLiveDocument } from "../SeoDrawer/useLiveDocument";
11
11
  const DRAWER_SLUG = "seo-analytics-drawer";
12
- function SeoButtonInner({ collectionSlug, fields, site, supportedLocales, extractContentPath, resolveDepth, slugPaths }) {
12
+ function SeoButtonInner({
13
+ collectionSlug,
14
+ fields,
15
+ site,
16
+ supportedLocales,
17
+ extractContentPath
18
+ }) {
13
19
  const { openModal } = useModal();
14
20
  const [keyphrase, setKeyphrase] = useState("");
15
- const { signature, getInput, invalidateMedia } = useLiveDocument({
21
+ const { signature, getInput } = useLiveDocument({
16
22
  collectionSlug,
17
23
  fields,
18
24
  site: { name: site.name, baseUrl: site.baseUrl },
19
25
  keyphrase,
20
- extractContentPath,
21
- resolveDepth,
22
- slugPaths
26
+ extractContentPath
23
27
  });
24
28
  const { result, analyzing, analyzedKeyphrase, analyzeNow } = useAnalysis({
25
29
  getInput,
@@ -29,10 +33,9 @@ function SeoButtonInner({ collectionSlug, fields, site, supportedLocales, extrac
29
33
  const keyphrasePending = isKeyphrasePending(keyphrase, analyzedKeyphrase);
30
34
  const overall = result?.overall ?? null;
31
35
  const open = useCallback(() => {
32
- invalidateMedia();
33
36
  analyzeNow();
34
37
  openModal(DRAWER_SLUG);
35
- }, [analyzeNow, invalidateMedia, openModal]);
38
+ }, [analyzeNow, openModal]);
36
39
  return /* @__PURE__ */ jsxs("span", { className: "relative inline-flex", children: [
37
40
  /* @__PURE__ */ jsx(
38
41
  Button,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/components/SeoButton/SeoButtonInner.tsx"],"sourcesContent":["\"use client\";\n\nimport { Button, useModal } from \"@payloadcms/ui\";\nimport { Gauge } from \"lucide-react\";\nimport { useCallback, useState } from \"react\";\nimport { ScoreBadge } from \"./ScoreBadge\";\nimport { SeoDrawer } from \"../SeoDrawer\";\nimport { isKeyphrasePending } from \"../SeoDrawer/keyphrasePending\";\nimport { useAnalysis } from \"../SeoDrawer/useAnalysis\";\nimport { useLiveDocument } from \"../SeoDrawer/useLiveDocument\";\n\nexport interface SeoButtonProps {\n collectionSlug: string;\n fields: Record<string, string>;\n extractContentPath: string | null;\n site: { name: string; baseUrl: string; faviconUrl: string };\n supportedLocales: string[];\n resolveDepth: number;\n slugPaths: Record<string, string>;\n}\n\nconst DRAWER_SLUG = \"seo-analytics-drawer\";\n\nexport function SeoButtonInner({ collectionSlug, fields, site, supportedLocales, extractContentPath, resolveDepth, slugPaths }: SeoButtonProps) {\n const { openModal } = useModal();\n const [keyphrase, setKeyphrase] = useState(\"\");\n\n const { signature, getInput, invalidateMedia } = useLiveDocument({\n collectionSlug,\n fields,\n site: { name: site.name, baseUrl: site.baseUrl },\n keyphrase,\n extractContentPath,\n resolveDepth,\n slugPaths,\n });\n const { result, analyzing, analyzedKeyphrase, analyzeNow } = useAnalysis({\n getInput,\n signature,\n supportedLocales,\n });\n\n const keyphrasePending = isKeyphrasePending(keyphrase, analyzedKeyphrase);\n const overall = result?.overall ?? null;\n\n const open = useCallback(() => {\n invalidateMedia();\n analyzeNow();\n openModal(DRAWER_SLUG);\n }, [analyzeNow, invalidateMedia, openModal]);\n\n return (\n <span className=\"relative inline-flex\">\n <Button\n aria-label=\"SEO Analytics\"\n buttonStyle=\"none\"\n className=\"seo-doc-btn m-0 w-[calc(var(--base)*1.6)] h-[calc(var(--base)*1.6)] inline-flex items-center justify-center border border-[var(--theme-elevation-100)] rounded-rs bg-transparent text-neutral-800 transition-[border-color,background-color] duration-100 hover:border-neutral-300 hover:bg-neutral-100\"\n extraButtonProps={{ title: undefined }}\n icon={<Gauge />}\n iconStyle=\"without-border\"\n margin={false}\n onClick={open}\n size=\"small\"\n tooltip=\"SEO Analytics\"\n />\n {overall && <ScoreBadge score={overall.seoScore} status={overall.status} />}\n\n <SeoDrawer\n analyzeNow={analyzeNow}\n analyzing={analyzing}\n drawerSlug={DRAWER_SLUG}\n keyphrase={keyphrase}\n keyphrasePending={keyphrasePending}\n result={result}\n setKeyphrase={setKeyphrase}\n site={site}\n />\n </span>\n );\n}\n"],"mappings":";AAoDI,SAMU,KANV;AAlDJ,SAAS,QAAQ,gBAAgB;AACjC,SAAS,aAAa;AACtB,SAAS,aAAa,gBAAgB;AACtC,SAAS,kBAAkB;AAC3B,SAAS,iBAAiB;AAC1B,SAAS,0BAA0B;AACnC,SAAS,mBAAmB;AAC5B,SAAS,uBAAuB;AAYhC,MAAM,cAAc;AAEb,SAAS,eAAe,EAAE,gBAAgB,QAAQ,MAAM,kBAAkB,oBAAoB,cAAc,UAAU,GAAmB;AAC9I,QAAM,EAAE,UAAU,IAAI,SAAS;AAC/B,QAAM,CAAC,WAAW,YAAY,IAAI,SAAS,EAAE;AAE7C,QAAM,EAAE,WAAW,UAAU,gBAAgB,IAAI,gBAAgB;AAAA,IAC/D;AAAA,IACA;AAAA,IACA,MAAM,EAAE,MAAM,KAAK,MAAM,SAAS,KAAK,QAAQ;AAAA,IAC/C;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF,CAAC;AACD,QAAM,EAAE,QAAQ,WAAW,mBAAmB,WAAW,IAAI,YAAY;AAAA,IACvE;AAAA,IACA;AAAA,IACA;AAAA,EACF,CAAC;AAED,QAAM,mBAAmB,mBAAmB,WAAW,iBAAiB;AACxE,QAAM,UAAU,QAAQ,WAAW;AAEnC,QAAM,OAAO,YAAY,MAAM;AAC7B,oBAAgB;AAChB,eAAW;AACX,cAAU,WAAW;AAAA,EACvB,GAAG,CAAC,YAAY,iBAAiB,SAAS,CAAC;AAE3C,SACE,qBAAC,UAAK,WAAU,wBACd;AAAA;AAAA,MAAC;AAAA;AAAA,QACC,cAAW;AAAA,QACX,aAAY;AAAA,QACZ,WAAU;AAAA,QACV,kBAAkB,EAAE,OAAO,OAAU;AAAA,QACrC,MAAM,oBAAC,SAAM;AAAA,QACb,WAAU;AAAA,QACV,QAAQ;AAAA,QACR,SAAS;AAAA,QACT,MAAK;AAAA,QACL,SAAQ;AAAA;AAAA,IACV;AAAA,IACC,WAAW,oBAAC,cAAW,OAAO,QAAQ,UAAU,QAAQ,QAAQ,QAAQ;AAAA,IAEzE;AAAA,MAAC;AAAA;AAAA,QACC;AAAA,QACA;AAAA,QACA,YAAY;AAAA,QACZ;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA;AAAA,IACF;AAAA,KACF;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../../src/components/SeoButton/SeoButtonInner.tsx"],"sourcesContent":["\"use client\";\n\nimport { Button, useModal } from \"@payloadcms/ui\";\nimport { Gauge } from \"lucide-react\";\nimport { useCallback, useState } from \"react\";\nimport { ScoreBadge } from \"./ScoreBadge\";\nimport { SeoDrawer } from \"../SeoDrawer\";\nimport { isKeyphrasePending } from \"../SeoDrawer/keyphrasePending\";\nimport { useAnalysis } from \"../SeoDrawer/useAnalysis\";\nimport { useLiveDocument } from \"../SeoDrawer/useLiveDocument\";\n\nexport interface SeoButtonProps {\n collectionSlug: string;\n fields: Record<string, string>;\n extractContentPath: string;\n site: { name: string; baseUrl: string; faviconUrl: string };\n supportedLocales: string[];\n}\n\nconst DRAWER_SLUG = \"seo-analytics-drawer\";\n\nexport function SeoButtonInner({\n collectionSlug,\n fields,\n site,\n supportedLocales,\n extractContentPath,\n}: SeoButtonProps) {\n const { openModal } = useModal();\n const [keyphrase, setKeyphrase] = useState(\"\");\n\n const { signature, getInput } = useLiveDocument({\n collectionSlug,\n fields,\n site: { name: site.name, baseUrl: site.baseUrl },\n keyphrase,\n extractContentPath,\n });\n const { result, analyzing, analyzedKeyphrase, analyzeNow } = useAnalysis({\n getInput,\n signature,\n supportedLocales,\n });\n\n const keyphrasePending = isKeyphrasePending(keyphrase, analyzedKeyphrase);\n const overall = result?.overall ?? null;\n\n const open = useCallback(() => {\n analyzeNow();\n openModal(DRAWER_SLUG);\n }, [analyzeNow, openModal]);\n\n return (\n <span className=\"relative inline-flex\">\n <Button\n aria-label=\"SEO Analytics\"\n buttonStyle=\"none\"\n className=\"seo-doc-btn m-0 w-[calc(var(--base)*1.6)] h-[calc(var(--base)*1.6)] inline-flex items-center justify-center border border-[var(--theme-elevation-100)] rounded-rs bg-transparent text-neutral-800 transition-[border-color,background-color] duration-100 hover:border-neutral-300 hover:bg-neutral-100\"\n extraButtonProps={{ title: undefined }}\n icon={<Gauge />}\n iconStyle=\"without-border\"\n margin={false}\n onClick={open}\n size=\"small\"\n tooltip=\"SEO Analytics\"\n />\n {overall && <ScoreBadge score={overall.seoScore} status={overall.status} />}\n\n <SeoDrawer\n analyzeNow={analyzeNow}\n analyzing={analyzing}\n drawerSlug={DRAWER_SLUG}\n keyphrase={keyphrase}\n keyphrasePending={keyphrasePending}\n result={result}\n setKeyphrase={setKeyphrase}\n site={site}\n />\n </span>\n );\n}\n"],"mappings":";AAqDI,SAMU,KANV;AAnDJ,SAAS,QAAQ,gBAAgB;AACjC,SAAS,aAAa;AACtB,SAAS,aAAa,gBAAgB;AACtC,SAAS,kBAAkB;AAC3B,SAAS,iBAAiB;AAC1B,SAAS,0BAA0B;AACnC,SAAS,mBAAmB;AAC5B,SAAS,uBAAuB;AAUhC,MAAM,cAAc;AAEb,SAAS,eAAe;AAAA,EAC7B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAAmB;AACjB,QAAM,EAAE,UAAU,IAAI,SAAS;AAC/B,QAAM,CAAC,WAAW,YAAY,IAAI,SAAS,EAAE;AAE7C,QAAM,EAAE,WAAW,SAAS,IAAI,gBAAgB;AAAA,IAC9C;AAAA,IACA;AAAA,IACA,MAAM,EAAE,MAAM,KAAK,MAAM,SAAS,KAAK,QAAQ;AAAA,IAC/C;AAAA,IACA;AAAA,EACF,CAAC;AACD,QAAM,EAAE,QAAQ,WAAW,mBAAmB,WAAW,IAAI,YAAY;AAAA,IACvE;AAAA,IACA;AAAA,IACA;AAAA,EACF,CAAC;AAED,QAAM,mBAAmB,mBAAmB,WAAW,iBAAiB;AACxE,QAAM,UAAU,QAAQ,WAAW;AAEnC,QAAM,OAAO,YAAY,MAAM;AAC7B,eAAW;AACX,cAAU,WAAW;AAAA,EACvB,GAAG,CAAC,YAAY,SAAS,CAAC;AAE1B,SACE,qBAAC,UAAK,WAAU,wBACd;AAAA;AAAA,MAAC;AAAA;AAAA,QACC,cAAW;AAAA,QACX,aAAY;AAAA,QACZ,WAAU;AAAA,QACV,kBAAkB,EAAE,OAAO,OAAU;AAAA,QACrC,MAAM,oBAAC,SAAM;AAAA,QACb,WAAU;AAAA,QACV,QAAQ;AAAA,QACR,SAAS;AAAA,QACT,MAAK;AAAA,QACL,SAAQ;AAAA;AAAA,IACV;AAAA,IACC,WAAW,oBAAC,cAAW,OAAO,QAAQ,UAAU,QAAQ,QAAQ,QAAQ;AAAA,IAEzE;AAAA,MAAC;AAAA;AAAA,QACC;AAAA,QACA;AAAA,QACA,YAAY;AAAA,QACZ;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA;AAAA,IACF;AAAA,KACF;AAEJ;","names":[]}
@@ -1,6 +1,3 @@
1
- import type { ClientField } from "payload";
2
- import type { ExtractContext } from "../../content/extract/context";
3
- import type { DocResolver } from "../../content/resolve/resolver";
4
1
  import type { AnalysisInput } from "../../engine/types/analysis";
5
2
  import type { ContentExtractor, SeoFieldPaths } from "../../types/config";
6
3
  export interface BuildAnalysisInputArgs {
@@ -16,11 +13,7 @@ export interface BuildAnalysisInputArgs {
16
13
  name: string;
17
14
  baseUrl: string;
18
15
  };
19
- hostFields: ClientField[];
20
- ctx: ExtractContext;
21
- resolver: DocResolver;
22
- resolveDepth: number;
23
- override?: ContentExtractor;
16
+ extractor?: ContentExtractor;
24
17
  }
25
18
  export declare function buildAnalysisInput(args: BuildAnalysisInputArgs): Promise<AnalysisInput>;
26
19
  //# sourceMappingURL=build-analysis-input.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"build-analysis-input.d.ts","sourceRoot":"","sources":["../../../src/components/SeoDrawer/build-analysis-input.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAI3C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,+BAA+B,CAAC;AAGpE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gCAAgC,CAAC;AAGlE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAC;AACjE,OAAO,KAAK,EAAE,gBAAgB,EAAoB,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAG5F,MAAM,WAAW,sBAAsB;IACrC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAChC,MAAM,EAAE,MAAM,GAAG;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,GAAG,SAAS,CAAC;IACtD,aAAa,EAAE,MAAM,GAAG,SAAS,CAAC;IAClC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,aAAa,CAAC;IACtB,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IACxC,UAAU,EAAE,WAAW,EAAE,CAAC;IAC1B,GAAG,EAAE,cAAc,CAAC;IACpB,QAAQ,EAAE,WAAW,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,gBAAgB,CAAC;CAC7B;AAgBD,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,sBAAsB,GAAG,OAAO,CAAC,aAAa,CAAC,CAY7F"}
1
+ {"version":3,"file":"build-analysis-input.d.ts","sourceRoot":"","sources":["../../../src/components/SeoDrawer/build-analysis-input.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAC;AACjE,OAAO,KAAK,EACV,gBAAgB,EAIhB,aAAa,EACd,MAAM,oBAAoB,CAAC;AAK5B,MAAM,WAAW,sBAAsB;IACrC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAChC,MAAM,EAAE,MAAM,GAAG;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,GAAG,SAAS,CAAC;IACtD,aAAa,EAAE,MAAM,GAAG,SAAS,CAAC;IAClC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,aAAa,CAAC;IACtB,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IACxC,SAAS,CAAC,EAAE,gBAAgB,CAAC;CAC9B;AAED,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,sBAAsB,GAAG,OAAO,CAAC,aAAa,CAAC,CAY7F"}
@@ -1,20 +1,17 @@
1
1
  import { serialize } from "../../content/schema/serialize";
2
- import { extractContent } from "../../content/extract/extract";
3
- import { collectRefs } from "../../content/resolve/collect-refs";
4
- import { hydrate } from "../../content/resolve/hydrate";
5
- import { makeExcluded, makeIncluded } from "../../content/extract/selection";
2
+ import {
3
+ compact,
4
+ heading,
5
+ html,
6
+ image,
7
+ link,
8
+ paragraph,
9
+ richText,
10
+ video
11
+ } from "../../content/schema/helpers";
12
+ import { createResolveDocs } from "../../content/resolve/resolve-docs";
6
13
  import { buildInput } from "./buildInput";
7
- function normalizeSelection(content) {
8
- if (content == null)
9
- return { include: [], exclude: [] };
10
- if (typeof content === "string")
11
- return { include: [content], exclude: [] };
12
- const sel = content;
13
- return { include: sel.include ?? [], exclude: sel.exclude ?? [] };
14
- }
15
- function metadataPaths(fields) {
16
- return [fields.seoTitle, fields.metaDescription, fields.slug ?? "slug"].filter((p) => p !== void 0);
17
- }
14
+ const helpers = { heading, paragraph, link, image, video, html, richText, compact };
18
15
  async function buildAnalysisInput(args) {
19
16
  const ir = await extractIntermediateRepresentation(args);
20
17
  const contentHtml = serialize(ir);
@@ -28,26 +25,17 @@ async function buildAnalysisInput(args) {
28
25
  });
29
26
  }
30
27
  async function extractIntermediateRepresentation(args) {
31
- const selection = normalizeSelection(args.fields.content);
32
- const meta = metadataPaths(args.fields);
33
- if (args.fields.content == null)
28
+ if (!args.extractor)
34
29
  return [];
35
- const excluded = makeExcluded(meta, selection.exclude);
36
- const included = makeIncluded(selection.include);
37
- const prune = (path) => excluded(path) || !included(path);
38
- const refs = args.resolveDepth >= 1 ? collectRefs(args.values, args.hostFields, args.ctx, prune) : [];
39
- const resolved = refs.length > 0 ? await args.resolver.resolve(refs, args.payloadLocale, args.resolveDepth - 1) : /* @__PURE__ */ new Map();
40
- const ctx = { ...args.ctx, resolved };
41
- if (args.override)
42
- return await args.override(hydrate(args.values, args.hostFields, ctx, resolved), { locale: args.payloadLocale, apiRoute: args.apiRoute });
43
- return extractContent({
44
- values: args.values,
45
- fields: args.hostFields,
46
- ctx,
47
- selection,
48
- metadataPaths: meta,
49
- depth: args.resolveDepth
50
- });
30
+ const ctx = {
31
+ locale: args.payloadLocale,
32
+ apiRoute: args.apiRoute
33
+ };
34
+ const toolkit = {
35
+ resolveDocs: createResolveDocs(args.apiRoute, args.payloadLocale),
36
+ helpers
37
+ };
38
+ return await args.extractor(args.values, ctx, toolkit);
51
39
  }
52
40
  export {
53
41
  buildAnalysisInput