@composius/payload-plugin-import-wordpress 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,152 @@
1
+ # @composius/payload-plugin-import-wordpress
2
+
3
+ Imports WordPress posts into Payload via the WordPress REST API: content,
4
+ categories, authors, featured images and in-content images. Images are fetched
5
+ at their original size (and resized by your media collection), uploaded only
6
+ once even when reused across posts, and internal links are rewritten (with 301
7
+ redirects created for the rest). Imports run on the Payload jobs queue and are
8
+ **idempotent and resumable**, writing a full report onto each job document.
9
+
10
+ By default it targets the collections from
11
+ [`@composius/payload-plugin-articles`](../payload-plugin-articles) (`articles`,
12
+ `categories`, `authors`) and a `media` uploads collection.
13
+
14
+ ## Collections
15
+
16
+ This plugin adds two collections (grouped under **WordPress import**):
17
+
18
+ | Collection | Purpose |
19
+ | ------------------ | ------------------------------------------------------------------------------------------- |
20
+ | `wp-import-jobs` | The import "form" and report surface. Creating a document starts an import run. |
21
+ | `wp-import-records`| Source→target mappings that make imports idempotent, resumable and image-deduplicating. |
22
+
23
+ ### `wp-import-jobs` fields
24
+
25
+ The edit form is organized into one tab per import step:
26
+
27
+ | Tab | Fields |
28
+ | ------------------ | ------------------------------------------------------------------------------------------------------------ |
29
+ | Configuration | `sourceUrl`, `credentials` (`username` + `applicationPassword`, optional), `dateFrom`/`dateTo`, `limit`, `dryRun`, `resume` |
30
+ | Authors | `authorsReport` (read-only) — authors imported by this job. |
31
+ | Categories | `categoriesReport` (read-only) — categories imported, hierarchy preserved. |
32
+ | Media | `mediaReport` (read-only) — images uploaded + how many were reused (deduped). |
33
+ | Posts | `postsReport` (read-only) — posts imported and remaining. |
34
+ | Links & redirects | `linksReport` (read-only) — every internal link: rewritten / redirect created / unresolved. |
35
+ | Report | `runs` + `progress` + `errorsReport` (read-only) — run history, live counts and per-item errors. |
36
+
37
+ `status`, `startedAt` and `finishedAt` sit in the sidebar (read-only).
38
+
39
+ **Run history**: a job can run several times (resume/retry). Each run gets a
40
+ number; `runs` lists every run with its status, timestamps and a final progress
41
+ snapshot, and every reported item (post, category, author, media, link, error)
42
+ carries the `run` that produced it. A resume **merges** with the previous
43
+ reports instead of overwriting them — only entries from dry runs are dropped
44
+ (they were plans, not imports).
45
+
46
+ **Credentials** are optional: a WordPress username + [application password](https://make.wordpress.org/core/2020/11/05/application-passwords-integration-guide/)
47
+ (Users → Profile → Application Passwords). When set, requests are authenticated
48
+ with HTTP Basic auth, which unlocks non-public data — notably **author emails**
49
+ (fetched via `?context=edit`), so the `users` author strategy can use real
50
+ addresses instead of synthesized ones. The password is stored in plain text on
51
+ the job document — delete the job (or clear the field) once the import is done.
52
+
53
+ ## What gets imported
54
+
55
+ - **Posts** (published only) → the target content collection, preserving the
56
+ WordPress slug and publish date, created as published.
57
+ - **Categories** → the categories collection, preserving the parent hierarchy.
58
+ Each post is assigned its **primary (first)** category.
59
+ - **Authors** → the users collection by default (matched/created by email,
60
+ linked via `editor`), or the authors collection, or a fixed user.
61
+ - **Featured + in-content images** → the media collection, original size,
62
+ de-duplicated across posts. When the embed is missing or a stub, the featured
63
+ image is re-fetched from the media endpoint; when a post has no featured image
64
+ at all, the first in-content image is promoted to the cover (see
65
+ `firstImageAsCover`), and a cover that also leads the content is removed from
66
+ it so the hero doesn't render twice.
67
+ - **SEO meta** (when the target collection has a `meta` group) — `meta.title`
68
+ from the post title, `meta.image` from the cover image, and
69
+ `meta.description` from the excerpt (see `excerptToSeoDescription`).
70
+ - **Links** — internal links to imported posts are rewritten; every other old
71
+ permalink gets a 301 redirect; anything unresolvable is listed in the report.
72
+
73
+ ## Requirements
74
+
75
+ This plugin expects the following to be installed and configured in your project:
76
+
77
+ - `payload` (`^3.84.1`)
78
+ - `@payloadcms/richtext-lexical` (`^3.84.1`) — the target content field's editor.
79
+ - `@payloadcms/ui` (`^3.84.1`) and `react` (`^19.0.0`) — the masked
80
+ application-password input in the admin form.
81
+ - `@payloadcms/plugin-redirects` (`^3.84.1`) — required only when `redirects`
82
+ is enabled (the default); optional otherwise.
83
+
84
+ ```bash
85
+ pnpm add @composius/payload-plugin-import-wordpress @payloadcms/richtext-lexical @payloadcms/ui react @payloadcms/plugin-redirects
86
+ ```
87
+
88
+ You also need a target content collection with a rich text field (e.g. the
89
+ `articles` collection from `@composius/payload-plugin-articles`), a `media`
90
+ uploads collection, and the Payload **jobs queue** enabled to run (see
91
+ `autoRun` below).
92
+
93
+ ## Usage
94
+
95
+ ```ts
96
+ import { buildConfig } from 'payload'
97
+ import { ComposiusPayloadPluginArticles } from '@composius/payload-plugin-articles'
98
+ import { ComposiusPayloadPluginMedia } from '@composius/payload-plugin-media'
99
+ import { ComposiusPayloadPluginImportWordpress } from '@composius/payload-plugin-import-wordpress'
100
+
101
+ export default buildConfig({
102
+ // ...
103
+ plugins: [
104
+ ComposiusPayloadPluginMedia(),
105
+ ComposiusPayloadPluginArticles({ authors: true }),
106
+ ComposiusPayloadPluginImportWordpress({
107
+ // Auto-process queued imports so creating a job runs it.
108
+ autoRun: true,
109
+ }),
110
+ ],
111
+ })
112
+ ```
113
+
114
+ Then, in the admin panel, create a **WordPress import** document with the site
115
+ URL (optionally a date range, a limit, or `dryRun`) and watch `status`,
116
+ `progress` and `report` update. Or trigger programmatically:
117
+
118
+ ```bash
119
+ curl -X POST /api/wp-import/start \
120
+ -H 'Content-Type: application/json' \
121
+ -b '<auth cookie>' \
122
+ -d '{ "sourceUrl": "https://example.com", "dryRun": true }'
123
+ # → { "jobId": "..." } then poll:
124
+ curl /api/wp-import/status/<jobId>
125
+ ```
126
+
127
+ ## Options
128
+
129
+ | Option | Type | Default | Description |
130
+ | ------------------------- | ------------------------------------------------ | -------------------------------- | ------------------------------------------------------------------------------------------- |
131
+ | `access` | per-operation access | authenticated | Access control for the jobs/records collections and the endpoints. |
132
+ | `collections` | `{ articles, categories, media, authors, users }`| `articles`/`categories`/`media`/`authors`/`users` | Target collection slugs. |
133
+ | `articleUrl` | `(slug?) => string` | `…/articles/<slug>` | Builds the front-end URL used for redirects and rewritten links. |
134
+ | `authorMapping` | `{ strategy, defaultUserId, syntheticEmailDomain }` | `{ strategy: 'users', syntheticEmailDomain: 'imported.invalid' }` | `users` (default), `authors`, or `fixed`. WordPress's public REST API hides author emails, so `users` synthesizes `<author-slug>@<syntheticEmailDomain>`; set your own domain, or `false` to skip creating such users (falls back to `defaultUserId` and reports the author). |
135
+ | `excerptToSeoDescription` | boolean | `true` | Map the WordPress excerpt onto the article SEO `meta.description`. |
136
+ | `firstImageAsCover` | boolean | `true` | When a post has no usable featured image, promote the first in-content image to the cover and remove it from the content. |
137
+ | `redirects` | boolean | `true` | Create 301 redirects and apply `@payloadcms/plugin-redirects`. |
138
+ | `fieldMap` | article field overrides | `title`/`slug`/`content`/`coverImage`/`category`/`publishedAt` | Article field names the importer writes to. |
139
+ | `autoRun` | boolean \| `{ cron, queue }` | `false` | Auto-process queued imports on a schedule (`true` = every minute). |
140
+ | `dryRunPageLimit` | number | `1` | REST pages a dry run samples. |
141
+ | `request` | `{ concurrency, timeoutMs, userAgent }` | `{ concurrency: 5, timeoutMs: 30000 }` | HTTP tuning for WordPress fetches and image downloads. |
142
+ | `disabled` | boolean | `false` | Keep the collections (schema consistency) but skip endpoints, redirects and auto-run. |
143
+
144
+ ## Notes
145
+
146
+ - Only the **primary (first)** WordPress category is assigned to each post
147
+ (the `articles.category` relationship is single); the others are still created
148
+ in the categories collection and noted in the report.
149
+ - The original image is recovered by stripping WordPress's `-WxH` size suffix;
150
+ if the original 404s the importer falls back to the URL WordPress provided.
151
+ - Re-running (or toggling `resume`) skips already-imported items via
152
+ `wp-import-records`, so imports are safe to repeat.
@@ -0,0 +1,10 @@
1
+ import { TextFieldClientComponent } from 'payload';
2
+
3
+ /**
4
+ * Renders the WordPress application-password input masked (•••) instead of as
5
+ * plain text, reusing Payload's own password field UI. The value is still a
6
+ * regular text field in the database — this only changes the input widget.
7
+ */
8
+ declare const ApplicationPasswordFieldClient: TextFieldClientComponent;
9
+
10
+ export { ApplicationPasswordFieldClient };
@@ -0,0 +1,10 @@
1
+ 'use client'
2
+
3
+ // src/components/ApplicationPasswordField.tsx
4
+ import { PasswordField } from "@payloadcms/ui";
5
+ import { jsx } from "react/jsx-runtime";
6
+ var ApplicationPasswordFieldClient = ({ field, path }) => /* @__PURE__ */ jsx(PasswordField, { autoComplete: "new-password", field, path });
7
+ export {
8
+ ApplicationPasswordFieldClient
9
+ };
10
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/components/ApplicationPasswordField.tsx"],"sourcesContent":["'use client'\nimport type { TextFieldClientComponent } from 'payload'\n\nimport { PasswordField } from '@payloadcms/ui'\n\n/**\n * Renders the WordPress application-password input masked (•••) instead of as\n * plain text, reusing Payload's own password field UI. The value is still a\n * regular text field in the database — this only changes the input widget.\n */\nexport const ApplicationPasswordFieldClient: TextFieldClientComponent = ({ field, path }) => (\n <PasswordField autoComplete=\"new-password\" field={field} path={path} />\n)\n"],"mappings":";;;AAGA,SAAS,qBAAqB;AAQ5B;AADK,IAAM,iCAA2D,CAAC,EAAE,OAAO,KAAK,MACrF,oBAAC,iBAAc,cAAa,gBAAe,OAAc,MAAY;","names":[]}
@@ -0,0 +1,182 @@
1
+ import { Access, Config } from 'payload';
2
+
3
+ type ImportAccess = {
4
+ create?: Access;
5
+ delete?: Access;
6
+ read?: Access;
7
+ update?: Access;
8
+ };
9
+ /** Slugs of the collections the import writes into. */
10
+ type TargetCollections = {
11
+ /** Target content collection for posts. @default 'articles' */
12
+ articles?: string;
13
+ /** Taxonomy collection for categories. @default 'categories' */
14
+ categories?: string;
15
+ /** Uploads collection for images. @default 'media' */
16
+ media?: string;
17
+ /** Authors collection (used when `authorMapping.strategy` is `'authors'`). @default 'authors' */
18
+ authors?: string;
19
+ /** Users collection (used when `authorMapping.strategy` is `'users'`). @default 'users' */
20
+ users?: string;
21
+ };
22
+ type AuthorStrategy = 'authors' | 'fixed' | 'users';
23
+ type AuthorMapping = {
24
+ /**
25
+ * How WordPress authors are mapped:
26
+ * - `users` (default): find-or-create in the users collection (matched by email),
27
+ * linked via the article `editor` field.
28
+ * - `authors`: create in the authors collection, linked via the article `author` field
29
+ * (requires the articles plugin to be configured with `authors: true`).
30
+ * - `fixed`: assign every imported article to `defaultUserId`.
31
+ */
32
+ strategy?: AuthorStrategy;
33
+ /** User id assigned when `strategy` is `fixed` (or as a fallback when an author has no email). */
34
+ defaultUserId?: number | string;
35
+ /**
36
+ * The public WordPress REST API does not expose author emails, so with the
37
+ * `users` strategy an email is synthesized as `<author-slug>@<domain>`.
38
+ * Configure the domain here, or pass `false` to skip creating users without
39
+ * a real email — the article then falls back to `defaultUserId` (or no
40
+ * editor) and the skipped author is listed in the report.
41
+ * @default 'imported.invalid' (a reserved TLD that can never be a real address)
42
+ */
43
+ syntheticEmailDomain?: false | string;
44
+ };
45
+ type AutoRunConfig = {
46
+ /** Cron expression for the auto-run schedule. @default '* * * * *' (every minute) */
47
+ cron?: string;
48
+ /** Queue processed by the schedule. @default 'default' */
49
+ queue?: string;
50
+ };
51
+ type FieldMap = {
52
+ /** Article field the post title maps to. @default 'title' */
53
+ title?: string;
54
+ /** Article field the post slug maps to. @default 'slug' */
55
+ slug?: string;
56
+ /** Article rich text field the post content maps to. @default 'content' */
57
+ content?: string;
58
+ /** Article upload field the featured image maps to. @default 'coverImage' */
59
+ coverImage?: string;
60
+ /** Article relationship field the primary category maps to. @default 'category' */
61
+ category?: string;
62
+ /** Article date field the publish date maps to. @default 'publishedAt' */
63
+ publishedAt?: string;
64
+ };
65
+ type RequestOptions = {
66
+ /** Per-image download concurrency. @default 5 */
67
+ concurrency?: number;
68
+ /** Per-request timeout in ms. @default 30000 */
69
+ timeoutMs?: number;
70
+ /** User-Agent header sent to WordPress. */
71
+ userAgent?: string;
72
+ };
73
+ type ComposiusPayloadPluginImportWordpressConfig = {
74
+ /**
75
+ * Access control for the import jobs/records collections, per operation.
76
+ * Defaults: `read`/`create`/`update`/`delete` require an authenticated user.
77
+ */
78
+ access?: ImportAccess;
79
+ /** Target collection slugs. Defaults: articles/categories/media/authors/users. */
80
+ collections?: TargetCollections;
81
+ /**
82
+ * Builds the front-end URL of an imported article from its slug. Used to
83
+ * rewrite internal links and as the target of created redirects.
84
+ * @default `${NEXT_PUBLIC_SERVER_URL || 'http://localhost:3000'}/articles/${slug}`
85
+ */
86
+ articleUrl?: (slug?: null | string) => string;
87
+ /** How WordPress authors are mapped. @default { strategy: 'users' } */
88
+ authorMapping?: AuthorMapping;
89
+ /** Map the WordPress excerpt onto the article SEO meta.description. @default true */
90
+ excerptToSeoDescription?: boolean;
91
+ /**
92
+ * When a post has no usable featured image, promote the first in-content
93
+ * image to the cover field and remove it from the content (themes often lead
94
+ * the content with the hero image instead of setting `featured_media`).
95
+ * @default true
96
+ */
97
+ firstImageAsCover?: boolean;
98
+ /**
99
+ * Create 301 redirects (via @payloadcms/plugin-redirects) from old WordPress
100
+ * permalinks to the new article URLs, and apply the redirects plugin.
101
+ * @default true
102
+ */
103
+ redirects?: boolean;
104
+ /** Override the article field names the importer writes to. */
105
+ fieldMap?: FieldMap;
106
+ /**
107
+ * Automatically process queued imports on a schedule so creating a job runs it
108
+ * without an external worker. Pass `true` for an every-minute schedule or a
109
+ * cron/queue config. @default false (the host app runs the jobs queue).
110
+ */
111
+ autoRun?: AutoRunConfig | boolean;
112
+ /** Number of REST pages a dry run samples. @default 1 */
113
+ dryRunPageLimit?: number;
114
+ /** HTTP request tuning for WordPress fetches and image downloads. */
115
+ request?: RequestOptions;
116
+ disabled?: boolean;
117
+ };
118
+ type ImportedItem = {
119
+ /** Number of the job run that imported this item. */
120
+ run?: number;
121
+ slug?: string;
122
+ sourceId: number;
123
+ targetId: number | string;
124
+ title?: string;
125
+ };
126
+ type LinkAction = 'redirect' | 'rewritten' | 'unresolved';
127
+ type LinkMapping = {
128
+ action: LinkAction;
129
+ from: string;
130
+ post?: number;
131
+ /** Number of the job run that handled this link. */
132
+ run?: number;
133
+ to?: string;
134
+ };
135
+ type ImportError = {
136
+ message: string;
137
+ /** Number of the job run that hit this error. */
138
+ run?: number;
139
+ scope: string;
140
+ sourceId?: number;
141
+ };
142
+ type ImportReport = {
143
+ dryRun: boolean;
144
+ errors: ImportError[];
145
+ imported: {
146
+ authors: ImportedItem[];
147
+ categories: ImportedItem[];
148
+ media: ImportedItem[];
149
+ posts: ImportedItem[];
150
+ };
151
+ links: LinkMapping[];
152
+ remaining: {
153
+ posts: number;
154
+ };
155
+ };
156
+ type ImportProgress = {
157
+ currentPhase: string;
158
+ cursorPage: number;
159
+ importedAuthors: number;
160
+ importedCategories: number;
161
+ importedMedia: number;
162
+ importedPosts: number;
163
+ linksRewritten: number;
164
+ linksUnresolved: number;
165
+ redirectsCreated: number;
166
+ reusedMedia: number;
167
+ skippedPosts: number;
168
+ failedPosts: number;
169
+ totalPosts: number;
170
+ };
171
+
172
+ /**
173
+ * Imports WordPress posts (via the REST API) into a target content collection —
174
+ * by default the `@composius/payload-plugin-articles` `articles` collection —
175
+ * along with their categories, authors, featured and in-content images. Images
176
+ * are uploaded once and resized by the media collection; internal links are
177
+ * rewritten and the rest get 301 redirects. Runs on the jobs queue and is
178
+ * idempotent + resumable, writing a full report onto each job document.
179
+ */
180
+ declare const ComposiusPayloadPluginImportWordpress: (pluginOptions?: ComposiusPayloadPluginImportWordpressConfig) => (config: Config) => Promise<Config>;
181
+
182
+ export { ComposiusPayloadPluginImportWordpress, type ComposiusPayloadPluginImportWordpressConfig, type ImportProgress, type ImportReport };