@focus-reactive/payload-plugin-seo 1.0.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 (2) hide show
  1. package/README.md +336 -0
  2. package/package.json +106 -0
package/README.md ADDED
@@ -0,0 +1,336 @@
1
+ # @focus-reactive/payload-plugin-seo
2
+
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
+
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.
6
+
7
+ ---
8
+
9
+ ## AI Integration Prompt
10
+
11
+ > Copy and paste this prompt into your AI assistant (Cursor, Claude, etc.) to integrate the plugin into an existing Payload + Next.js project.
12
+
13
+ ```
14
+ I want to add live SEO analysis to my Payload CMS v3 + Next.js project using @focus-reactive/payload-plugin-seo.
15
+
16
+ ## How it works
17
+
18
+ The plugin adds NO database fields, collections, or globals. It injects a button into the
19
+ document editor toolbar (admin.components.edit.beforeDocumentControls) of each configured
20
+ collection. The button opens a drawer that:
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
24
+ - Runs the Yoast engine (yoastseo + @yoast/search-metadata-previews) in the browser
25
+ - Shows tabs: Keyphrase, On-page SEO, Readability, Inclusive, Content vitals, SERP preview
26
+
27
+ It works with or without a focus keyphrase; keyphrase-specific checks unlock once you enter one.
28
+
29
+ ## Installation
30
+
31
+ pnpm add @focus-reactive/payload-plugin-seo
32
+
33
+ ## Step 1 — Register the plugin in payload.config.ts
34
+
35
+ import { seoPlugin } from '@focus-reactive/payload-plugin-seo'
36
+
37
+ // Inside buildConfig({ plugins: [...] })
38
+ seoPlugin({
39
+ collections: [
40
+ {
41
+ slug: 'pages',
42
+ fields: {
43
+ seoTitle: 'seoTitle', // dot-path; falls back to useAsTitle / 'title'
44
+ metaDescription: 'metaDescription',
45
+ slug: 'slug', // default: 'slug'
46
+ content: 'sections', // dot-path to the main content field (blocks/richText/textarea)
47
+ },
48
+ },
49
+ ],
50
+ site: { name: 'My Site', baseUrl: 'https://example.com', faviconUrl: '/favicon.ico' },
51
+ supportedLocales: ['en'], // language packs to load; default ['en']
52
+ })
53
+
54
+ ## Step 2 — Import the admin styles
55
+
56
+ In your Payload admin CSS (e.g. app/(payload)/custom.scss):
57
+
58
+ @import "@focus-reactive/payload-plugin-seo/admin.css";
59
+
60
+ ## Step 3 — Allow Next.js to transpile the Yoast UI packages
61
+
62
+ In next.config.mjs:
63
+
64
+ const nextConfig = {
65
+ transpilePackages: ['@yoast/search-metadata-previews', '@yoast/components'],
66
+ }
67
+
68
+ ## Important notes
69
+
70
+ - 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.
75
+ - Non-English analysis requires the locale code in `supportedLocales`; the matching Yoast
76
+ 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.
78
+ ```
79
+
80
+ ---
81
+
82
+ ## How It Works
83
+
84
+ ```
85
+ Document editor (configured collection)
86
+
87
+
88
+ [ SeoButton ] ← injected into admin.components.edit.beforeDocumentControls
89
+ │ click
90
+
91
+ SEO Drawer (client-only)
92
+
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>)
96
+
97
+ Yoast engine (in browser): Paper + EnglishResearcher + SeoAssessor
98
+
99
+
100
+ Tabs: Keyphrase · On-page SEO · Readability · Inclusive · Content vitals · SERP preview
101
+ ```
102
+
103
+ The analysis runs on a ~1 second debounce as form values change. **No data is written** — the drawer is a pure read-only overlay on top of the editor's current state.
104
+
105
+ ---
106
+
107
+ ## Installation
108
+
109
+ ```bash
110
+ pnpm add @focus-reactive/payload-plugin-seo
111
+ ```
112
+
113
+ **Peer dependencies:** `payload ^3.0.0`, `@payloadcms/next ^3.0.0`, `@payloadcms/ui ^3.0.0`, `@payloadcms/richtext-lexical ^3.0.0`, `lucide-react ^0.469.0`. `next ^14 || ^15` and `react`/`react-dom ^18 || ^19` are optional peers.
114
+
115
+ The Yoast engine (`yoastseo`, `@yoast/search-metadata-previews`) ships as a direct dependency — you don't install it yourself, but you do need to transpile the two UI packages (see Quick Start step 3).
116
+
117
+ ---
118
+
119
+ ## Quick Start
120
+
121
+ ### Step 1 — Register the plugin
122
+
123
+ ```ts
124
+ // payload.config.ts
125
+ import { buildConfig } from "payload";
126
+ import { seoPlugin } from "@focus-reactive/payload-plugin-seo";
127
+
128
+ export default buildConfig({
129
+ plugins: [
130
+ seoPlugin({
131
+ collections: [
132
+ {
133
+ slug: "pages",
134
+ fields: {
135
+ seoTitle: "seoTitle",
136
+ metaDescription: "metaDescription",
137
+ slug: "slug",
138
+ content: "sections",
139
+ },
140
+ },
141
+ ],
142
+ site: {
143
+ name: "My Site",
144
+ baseUrl: process.env.NEXT_PUBLIC_SERVER_URL ?? "http://localhost:3000",
145
+ },
146
+ supportedLocales: ["en", "de", "fr", "es"],
147
+ }),
148
+ ],
149
+ });
150
+ ```
151
+
152
+ This injects the **SEO** button into the document toolbar of every configured collection. A colored dot on the button reflects the current overall status (good / warn / bad).
153
+
154
+ ### Step 2 — Import the admin styles
155
+
156
+ The drawer's components import their compiled CSS internally, but the package also ships it at `./admin.css` so you can include it explicitly in your admin stylesheet:
157
+
158
+ ```scss
159
+ /* app/(payload)/custom.scss */
160
+ @import "@focus-reactive/payload-plugin-seo/admin.css";
161
+ ```
162
+
163
+ ### Step 3 — Transpile the Yoast UI packages
164
+
165
+ `@yoast/search-metadata-previews` and `@yoast/components` ship CSS inside `node_modules`, which Next.js (and Turbopack) won't process unless they're listed in `transpilePackages`:
166
+
167
+ ```js
168
+ // next.config.mjs
169
+ /** @type {import('next').NextConfig} */
170
+ const nextConfig = {
171
+ transpilePackages: ["@yoast/search-metadata-previews", "@yoast/components"],
172
+ };
173
+
174
+ export default nextConfig;
175
+ ```
176
+
177
+ ---
178
+
179
+ ## Configuration Reference
180
+
181
+ ### Plugin Options
182
+
183
+ ```ts
184
+ interface SeoPluginConfig {
185
+ /** Skip injection entirely. Default: false */
186
+ disabled?: boolean;
187
+ /** Collections to attach the SEO drawer to. At least one is required. */
188
+ collections: SeoCollectionConfig[];
189
+ /** Site identity used in the SERP preview and permalink. */
190
+ site?: SeoSiteConfig;
191
+ /** Locale codes whose Yoast language packs may be loaded. Default: ['en'] */
192
+ supportedLocales?: string[];
193
+ /** Override the English UI strings (merged with defaults). */
194
+ translations?: Translations;
195
+ }
196
+ ```
197
+
198
+ ### SeoCollectionConfig
199
+
200
+ ```ts
201
+ interface SeoCollectionConfig {
202
+ /** Collection slug to attach the drawer to. */
203
+ slug: string;
204
+ /** Dot-paths telling the plugin which fields hold the SEO inputs. */
205
+ fields?: SeoFieldPaths;
206
+ /**
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.
211
+ */
212
+ extractContentPath?: string;
213
+ }
214
+ ```
215
+
216
+ ### SeoFieldPaths
217
+
218
+ ```ts
219
+ interface SeoFieldPaths {
220
+ /** Dot-path to the SEO title. Falls back to the collection's useAsTitle / `title`. */
221
+ seoTitle?: string;
222
+ /** Dot-path to the meta description. Absent → meta-description checks are disabled
223
+ * and the SERP snippet shows no description. */
224
+ metaDescription?: string;
225
+ /** Dot-path to the slug. Default: 'slug' */
226
+ slug?: string;
227
+ /** Dot-path to the primary content field (blocks / richText / textarea). */
228
+ content?: string;
229
+ }
230
+ ```
231
+
232
+ Dot-paths support nesting, e.g. `"meta.description"` or `"content.body"`.
233
+
234
+ ### SeoSiteConfig
235
+
236
+ ```ts
237
+ interface SeoSiteConfig {
238
+ /** Site name shown in the SERP preview. */
239
+ name?: string;
240
+ /** Base URL used to build the permalink in the SERP preview. */
241
+ baseUrl?: string;
242
+ /** Favicon shown in the SERP preview. */
243
+ faviconUrl?: string;
244
+ }
245
+ ```
246
+
247
+ ### Custom content extractor
248
+
249
+ ```ts
250
+ type ExtractorFn = (data: Record<string, unknown>) => string | Promise<string>;
251
+ ```
252
+
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:
254
+
255
+ ```ts
256
+ // payload.config.ts
257
+ seoPlugin({
258
+ collections: [
259
+ { slug: "pages", extractContentPath: "@/seo/my-extractor#default" },
260
+ ],
261
+ });
262
+ ```
263
+
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>`;
268
+ }
269
+ ```
270
+
271
+ ---
272
+
273
+ ## Content Extraction
274
+
275
+ When `extractContentPath` is **not** set, the plugin's built-in extractor:
276
+
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
287
+
288
+ This means image checks (alt text, keyphrase in alt, image count) work against the real, resolved media — not raw relationship IDs.
289
+
290
+ ---
291
+
292
+ ## The Analysis Drawer
293
+
294
+ 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
+
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`. |
304
+
305
+ **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
+
307
+ ---
308
+
309
+ ## Localization
310
+
311
+ `supportedLocales` lists which locale codes the drawer may load Yoast language packs for. English is built in; other languages are dynamically imported on demand the first time the document is edited in that locale:
312
+
313
+ ```ts
314
+ seoPlugin({
315
+ collections: [{ slug: "pages", fields: { content: "sections" } }],
316
+ supportedLocales: ["en", "de", "fr", "es"],
317
+ });
318
+ ```
319
+
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.
321
+
322
+ ---
323
+
324
+ ## Exports Reference
325
+
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) |
331
+
332
+ ---
333
+
334
+ ## License
335
+
336
+ MIT © FocusReactive
package/package.json ADDED
@@ -0,0 +1,106 @@
1
+ {
2
+ "name": "@focus-reactive/payload-plugin-seo",
3
+ "version": "1.0.0",
4
+ "license": "MIT",
5
+ "author": "FocusReactive <ship@focusreactive.com>",
6
+ "keywords": [
7
+ "payload",
8
+ "payloadcms",
9
+ "payload-plugin",
10
+ "seo",
11
+ "yoast",
12
+ "cms",
13
+ "plugin",
14
+ "react",
15
+ "nextjs",
16
+ "typescript"
17
+ ],
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "https://github.com/focusreactive/payload-plugins",
21
+ "directory": "packages/payload-plugin-seo"
22
+ },
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "type": "module",
27
+ "main": "./dist/index.js",
28
+ "types": "./dist/index.d.ts",
29
+ "sideEffects": [
30
+ "**/*.css"
31
+ ],
32
+ "files": [
33
+ "dist"
34
+ ],
35
+ "exports": {
36
+ ".": {
37
+ "import": "./dist/index.js",
38
+ "types": "./dist/index.d.ts"
39
+ },
40
+ "./admin.css": "./dist/admin.css",
41
+ "./components/SeoButton": {
42
+ "import": "./dist/components/SeoButton/index.js",
43
+ "types": "./dist/components/SeoButton/index.d.ts"
44
+ }
45
+ },
46
+ "scripts": {
47
+ "build": "tsup && tsc --emitDeclarationOnly --declarationMap",
48
+ "dev": "tsup --watch",
49
+ "lint": "eslint src/ tests/",
50
+ "lint:fix": "eslint src/ tests/ --fix",
51
+ "test": "vitest run",
52
+ "test:watch": "vitest"
53
+ },
54
+ "peerDependencies": {
55
+ "@payloadcms/next": "^3.0.0",
56
+ "@payloadcms/richtext-lexical": "^3.0.0",
57
+ "@payloadcms/ui": "^3.0.0",
58
+ "lucide-react": "^0.469.0",
59
+ "next": "^14.0.0 || ^15.0.0",
60
+ "payload": "^3.0.0",
61
+ "react": "^18.0.0 || ^19.0.0",
62
+ "react-dom": "^18.0.0 || ^19.0.0"
63
+ },
64
+ "peerDependenciesMeta": {
65
+ "next": {
66
+ "optional": true
67
+ },
68
+ "react": {
69
+ "optional": true
70
+ },
71
+ "react-dom": {
72
+ "optional": true
73
+ }
74
+ },
75
+ "dependencies": {
76
+ "@yoast/search-metadata-previews": "^3.0.0",
77
+ "class-variance-authority": "0.7.1",
78
+ "clsx": "^2.1.1",
79
+ "tailwind-merge": "^3.6.0",
80
+ "yoastseo": "^3.6.0"
81
+ },
82
+ "devDependencies": {
83
+ "@payloadcms/next": "3.84.1",
84
+ "@payloadcms/richtext-lexical": "3.84.1",
85
+ "@payloadcms/ui": "3.84.1",
86
+ "@tailwindcss/postcss": "^4.0.0",
87
+ "@types/node": "^25.7.0",
88
+ "@types/react": "19.2.9",
89
+ "@types/react-dom": "19.2.3",
90
+ "eslint": "^9.18.0",
91
+ "eslint-config-prettier": "9.0.0",
92
+ "lucide-react": "^0.469.0",
93
+ "next": "15.4.10",
94
+ "payload": "3.84.1",
95
+ "postcss": "^8.5.1",
96
+ "postcss-cli": "^11.0.0",
97
+ "prettier": "3.0.0",
98
+ "react": "^19.0.0",
99
+ "react-dom": "^19.0.0",
100
+ "tailwindcss": "^4.0.0",
101
+ "tsup": "8.0.0",
102
+ "typescript": "5.7.3",
103
+ "typescript-eslint": "^8.20.0",
104
+ "vitest": "^2.1.8"
105
+ }
106
+ }