@geekity/plugin-wordpress 0.3.0 → 0.4.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 CHANGED
@@ -1,23 +1,25 @@
1
1
  # @geekity/plugin-wordpress
2
2
 
3
- A [Geekity CMS](../cms/README.md) plugin for a site that moved from the
4
- WordPress ActivityPub plugin. It answers the old plugin's inbox and collection
5
- paths until every follower's server has refetched the actor, records when each
6
- path was last asked for, and adds two imports: `geekity import wordpress`,
7
- which brings the content of a WordPress export across, and
8
- `geekity import wordpress-actor`, which brings each person's key pair, actor id
9
- and followers across.
10
-
11
- A site born on the CMS never needs it. Stored actor ids, post object ids,
12
- WebFinger aliases and the feed and archive layout are the site's own and are in
13
- `@geekity/cms` (decision-14).
3
+ A [Geekity CMS](https://github.com/geekitycom/cms/blob/main/packages/cms/README.md) plugin for a site that moved from the
4
+ WordPress ActivityPub plugin. It does three things:
5
+
6
+ - It answers the old plugin's inbox and collection paths until every
7
+ follower's server has refetched the actor.
8
+ - It records when each path was last requested.
9
+ - It adds two imports. `geekity import wordpress` imports the content of a
10
+ WordPress export. `geekity import wordpress-actor` imports each person's key
11
+ pair, actor id and followers.
12
+
13
+ A site that started on the CMS never needs it. Stored actor ids, post object
14
+ ids, WebFinger aliases and the feed and archive layout belong to every site and
15
+ are in `@geekity/cms` (decision-14).
14
16
 
15
17
  ## Installing it
16
18
 
17
19
  It works with every 0.x release of `@geekity/cms` from the one it was built
18
20
  against, which its peer dependency names.
19
21
 
20
- Add the package to the site and to `plugins` in its config:
22
+ Add the package to the site and to `plugins` in the site's config:
21
23
 
22
24
  ```sh
23
25
  pnpm add @geekity/plugin-wordpress
@@ -39,46 +41,47 @@ under `/admin/plugins`:
39
41
  docker compose exec geekity geekity plugin add @geekity/plugin-wordpress
40
42
  ```
41
43
 
42
- Both imports work as soon as the plugin is installed. The
43
- old paths, and the redirects from WordPress media URLs, are served only while
44
- the plugin is enabled under `/admin/plugins`, and disabling it takes them away
45
- on the next request, with nothing restarted.
44
+ Both imports work as soon as the plugin is installed. The old paths, and the
45
+ redirects from WordPress media URLs, are served only while the plugin is
46
+ enabled under `/admin/plugins`. If you disable it, they stop on the next
47
+ request, with no restart.
46
48
 
47
- The plugin keeps two files in `data/plugins/@geekity/plugin-wordpress/`:
49
+ The plugin keeps three files in `data/plugins/@geekity/plugin-wordpress/`:
48
50
 
49
- | File | What it holds |
50
- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
51
- | `actors.json` | Each username and the number WordPress gave that person's actor, which the old paths are built from. Written by the import. |
52
- | `requests.json` | When each old path was last asked for, per user, and when the shared inbox was. Losing it resets what the screen says. |
53
- | `import.json` | A hash of every file `geekity import wordpress` wrote, by path, and each `site.json`, `media.json` and redirect key it set. Losing it makes every imported file a clash on the next run. |
51
+ | File | What it holds |
52
+ | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
53
+ | `actors.json` | Each username and the number WordPress gave that person's actor. The old paths are built from the number. The import writes this file. |
54
+ | `requests.json` | When each old path was last requested, per user, and when the shared inbox was. If you lose it, the screen's times reset. |
55
+ | `import.json` | A hash of every file `geekity import wordpress` wrote, by path, and each `site.json`, `media.json` and redirect key it set. If you lose it, every imported file is a clash on the next run. |
54
56
 
55
57
  ## The old paths
56
58
 
57
59
  A follower's server still holds the WordPress plugin's endpoints, such as
58
60
  `/wp-json/activitypub/1.0/actors/2/inbox`, the shared
59
- `/wp-json/activitypub/1.0/inbox`, and the collections beside them. Those are
60
- cache rather than identity: a follower's server replaces them the next time it
61
- refetches the actor. So the site carries them for a while, and is meant to
62
- stop.
63
-
64
- While the plugin is enabled, those paths are real inbox routes,
65
- signature-verified exactly as the site's own are, so an unsigned or badly
66
- signed delivery is refused, plus GET routes for the actor, its outbox, its
67
- followers and its following. What a peer reads back is always the canonical
68
- document: the same `id`, the same key, and the _canonical_ inbox and
69
- collections, so a follower refetching the actor at the old URL is the follower
70
- that learns the new endpoints. A user with no number in `actors.json` is not
71
- reachable through any of it.
72
-
73
- Every one of those paths records the instant it was last asked for, and the
74
- plugin's screen under Plugins, `/admin/plugins/@geekity/plugin-wordpress`,
75
- lists each path with that instant, or _Never_. Once every follower's server has
76
- refetched the actor, nothing asks any more, and the plugin can be disabled.
61
+ `/wp-json/activitypub/1.0/inbox`, and the collections beside them. These
62
+ paths are cached data, not identity. A follower's server replaces them the next
63
+ time it refetches the actor. The site serves them for a while, then stops.
64
+
65
+ While the plugin is enabled, the plugin serves:
66
+
67
+ - real inbox routes on those paths. They verify signatures the same way the
68
+ site's own inboxes do, so an unsigned or badly signed delivery is refused.
69
+ - GET routes for the actor, its outbox, its followers and its following
70
+
71
+ A peer always reads back the canonical document: the same `id`, the same key,
72
+ and the _canonical_ inbox and collections. A follower that refetches the actor
73
+ at the old URL learns the new endpoints. A user with no number in
74
+ `actors.json` is not reachable through any of these paths.
75
+
76
+ Each path records the time it was last requested. The plugin's screen under
77
+ Plugins, `/admin/plugins/@geekity/plugin-wordpress`, lists each path with that
78
+ time, or _Never_. Once every follower's server has refetched the actor, no
79
+ more requests arrive, and you can disable the plugin.
77
80
 
78
81
  ## Bringing the content across
79
82
 
80
83
  `geekity import wordpress` reads the WXR file WordPress writes under Tools >
81
- Export (or `wp export`) and writes what it holds into the content directory:
84
+ Export (or `wp export`) and writes its contents into the content directory:
82
85
 
83
86
  ```sh
84
87
  geekity import wordpress example.WordPress.2026-10-08.xml \
@@ -86,113 +89,120 @@ geekity import wordpress example.WordPress.2026-10-08.xml \
86
89
  --origins https://staging.example.com
87
90
  ```
88
91
 
89
- `--uploads` is a copy of the site's `wp-content/uploads` (an export carries
90
- no files), and `--origins` names any other host the posts wrote media URLs
91
- on, such as the staging host the site was built on, comma-separated. Both are
92
- optional; see [Media](#media).
92
+ `--uploads` is a copy of the site's `wp-content/uploads`. An export carries
93
+ no files. `--origins` is a comma-separated list of any other hosts in the
94
+ posts' media URLs, such as the staging host the site was built on. Both are
95
+ optional. See [Media](#media).
93
96
 
94
97
  A file that is not a WordPress export is refused before anything is written,
95
98
  with each problem named, and the command exits 1.
96
99
 
97
100
  The command prints the totals, then one tab-separated row per item: its
98
- outcome, post type, WordPress id, title, the file it went to, and why.
101
+ outcome, post type, WordPress id, title, the file it went to, and the
102
+ reason.
99
103
 
100
104
  | Outcome | What it means |
101
105
  | ----------- | -------------------------------------------------------------------------------------------------------- |
102
106
  | `written` | The file is new, was changed on WordPress since the last run, or was removed here and is written again. |
103
107
  | `unchanged` | The file already holds what WordPress has. |
104
108
  | `kept` | The file was edited on this site and WordPress has not changed it since. The edit stays. |
105
- | `conflict` | The file was edited on this site and changed on WordPress too. The edit stays; the row gives both dates. |
109
+ | `conflict` | The file was edited on this site and changed on WordPress too. The edit stays. The row gives both dates. |
106
110
  | `clash` | A file the import never wrote is at the path. It is left alone. |
107
111
  | `skipped` | The item has no Geekity counterpart, such as a revision, a menu or the ActivityPub plugin's records. |
108
112
  | `warned` | The item was imported, with something the operator should check. |
109
113
 
110
- **Run it as often as you like.** Two runs over one export leave the content
111
- directory byte for byte as one run did, and a run over a newer export picks up
112
- what was added or edited on WordPress since. The import never writes over a file
113
- it did not write, and never deletes one, so a site can layer its own fixes on
114
- top of an import. To take WordPress's side of a conflict, remove the file and
115
- run the import again.
114
+ **You can run it any number of times.** Two runs over one export leave the
115
+ content directory byte for byte the same as one run. A run over a newer export
116
+ picks up what was added or edited on WordPress since. The import never
117
+ overwrites a file it did not write, and never deletes a file, so you can edit
118
+ imported files and keep the edits. To take WordPress's side of a conflict,
119
+ remove the file and run the import again.
116
120
 
117
121
  ### Posts and pages
118
122
 
119
123
  Each post becomes `posts/YYYY-MM-DD-slug.md`, dated by its local publish date,
120
- and each page becomes `pages/slug.md`. The front matter keeps what readers,
121
- feed readers and followers already know of it:
122
-
123
- | Key | Where it comes from |
124
- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
125
- | `permalink` | The URL WordPress served, from the export's link. A draft has none yet, so it gets the one the published posts' structure gives it; a page gets its parents' slugs. |
126
- | `redirect_from` | The URLs the post had before WordPress renamed or redated it: its permalink with each `_wp_old_slug` in place of its slug, and each `_wp_old_date` in place of its date. |
127
- | `date` | `post_date_gmt`. A draft WordPress never dated has none. |
128
- | `updated` | `post_modified_gmt`, when it differs from the date. |
129
- | `tags` | The post's tags, by name. |
130
- | `categories` | The post's categories, by name. |
131
- | `draft` | `true` for a draft, a pending post, and a private or password-protected one, which the report names. A scheduled post keeps its date and publishes then. |
132
- | `author` | The WordPress login, when the site has a user by that name. A post by any other login has no author, and the report names it. |
133
- | `in-reply-to` | The URL an ActivityPub plugin reply block answers. |
134
- | `activitypub` | On a published post, `id` is `https://example.com/?p=ID`, the object id the ActivityPub plugin served. A post the plugin federated (`activitypub_status` is `federated`) also has `published`. |
135
- | `guid` | The WordPress guid, when it is not the post's `?p=` address, so feed readers see no old post as new. |
136
- | `migrated` | `true` on everything but a scheduled post, so its arrival here, or a draft's publication later, sends nothing to anyone. A scheduled post publishes here as news, as it would have on WordPress. |
137
-
138
- **Expect one difference in a feed diff.** WordPress prints the escapes in a
124
+ and each page becomes `pages/slug.md`. The front matter keeps the URLs and ids
125
+ that visitors, feed readers and followers already know:
126
+
127
+ | Key | Where it comes from |
128
+ | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
129
+ | `permalink` | The URL WordPress served, from the export's link. A draft has none yet, so it gets the one the published posts' structure gives it. A page gets its parents' slugs. |
130
+ | `redirect_from` | The URLs the post had before WordPress renamed or redated it: its permalink with each `_wp_old_slug` in place of its slug, and each `_wp_old_date` in place of its date. |
131
+ | `date` | `post_date_gmt`. A draft WordPress never dated has none. |
132
+ | `updated` | `post_modified_gmt`, when it differs from the date. |
133
+ | `tags` | The post's tags, by name. |
134
+ | `categories` | The post's categories, by name. |
135
+ | `draft` | `true` for a draft, a pending post, and a private or password-protected one, which the report names. A scheduled post keeps its date and publishes then. |
136
+ | `author` | The WordPress login, when the site has a user by that name. A post by any other login has no author, and the report names it. |
137
+ | `in-reply-to` | The URL an ActivityPub plugin reply block answers. |
138
+ | `activitypub` | On a published post, `id` is `https://example.com/?p=ID`, the object id the ActivityPub plugin served. A post the plugin federated (`activitypub_status` is `federated`) also has `published`. |
139
+ | `guid` | The WordPress guid, when it is not the post's `?p=` address, so feed readers see no old post as new. |
140
+ | `migrated` | `true` on everything but a scheduled post, so importing it, or publishing a draft later, sends nothing to anyone. A scheduled post publishes here as news, as it would have on WordPress. |
141
+
142
+ **Expect one difference when you diff the feeds.** WordPress prints the escapes in a
139
143
  non-ASCII slug in lower case, as in `/2026/07/i-%e2%99%a5-rss/`. The site
140
144
  prints them in upper case, as in `/2026/07/i-%E2%99%A5-rss/`, in the RSS,
141
145
  Atom and JSON feeds, the comments feeds, `rel="canonical"`, `og:url`, the
142
- sitemap and the ActivityStreams `url`. RFC 3986 makes the two spellings the
143
- same URL, and both answer `200` with the post. A feed reader keys an item on
144
- its `guid`, which the import keeps, so the post does not show as new.
146
+ sitemap and the ActivityStreams `url`. Under RFC 3986 the two spellings are
147
+ the same URL, and both answer `200` with the post. A feed reader keys an item
148
+ on its `guid`, which the import keeps, so the post does not show as new.
145
149
 
146
- A post in the status format has no title, so it reads as a note. The page
150
+ A post in the status format has no title, so it becomes a note. The page
147
151
  WordPress served at the home URL becomes `homepage` in `_data/site.json`. The
148
152
  import sets that key only while the site has not set it, or still holds what
149
153
  the import last set.
150
154
 
151
- The body becomes Markdown. Block comments go, a classic post's line breaks
152
- become paragraphs as WordPress showed them, and a YouTube or Vimeo iframe or
153
- embed becomes the video's URL on a line of its own, which the site plays.
154
- What Markdown cannot say stays HTML: other iframes, a figure with a caption, a
155
- table with spans, and an element carrying a `style` or microformats class.
156
- `<!--more-->` stays where it was. Media URLs point at the files under
157
- `/uploads/`, as [Media](#media) describes.
155
+ The body becomes Markdown:
156
+
157
+ - Block comments are removed.
158
+ - A classic post's line breaks become paragraphs, as WordPress showed them.
159
+ - A YouTube or Vimeo iframe or embed becomes the video's URL on a line of its
160
+ own. The site plays it.
161
+ - HTML that Markdown cannot express stays HTML: other iframes, a figure with a
162
+ caption, a table with spans, and an element that carries a `style` or
163
+ microformats class.
164
+ `<!--more-->` stays where it was. Media URLs point at the files under
165
+ `/uploads/`, as [Media](#media) describes.
158
166
 
159
167
  ### Media
160
168
 
161
169
  Each attachment's original is copied from `--uploads` into `content/uploads/`
162
170
  at the path WordPress kept it under, such as `uploads/2024/03/photo.png`.
163
171
  WordPress's size variants (`photo-1024x575.png`, `photo-scaled.png`) are not
164
- copied: the site makes its own. A file a post or page shows that is no
165
- attachment is copied too, for that post.
172
+ copied, because the site makes its own. A file that a post or page shows but
173
+ that is not an attachment is copied too, for that post.
166
174
 
167
- Every media URL in a body, in Markdown and in the HTML it keeps, on the
168
- export's own origin, on an `--origins` host, or root-relative, becomes a
169
- root-relative `/uploads/` URL of the original. One pointing at a size variant
170
- points at its original. An upload URL on any other host is left alone.
175
+ Every media URL in a body becomes a root-relative `/uploads/` URL of the
176
+ original. This applies in Markdown and in the HTML the import keeps, for URLs
177
+ on the export's own origin, on an `--origins` host, or root-relative. A URL of
178
+ a size variant points at its original. An upload URL on any other host is left
179
+ unchanged.
171
180
 
172
- Each file is held to the rules an upload through the editor is: the site's
173
- allowed types and size limits, and location and camera details removed. A
174
- file the site would refuse is not copied. The report names it, and names
175
- each file missing from `--uploads`. Without `--uploads` nothing is copied and
176
- the report names each attachment.
181
+ Each file must pass the same rules as an upload through the editor: the site's
182
+ allowed types and size limits. The import removes location and camera details.
183
+ A file the site would refuse is not copied. The report names it, and names each
184
+ file missing from `--uploads`. Without `--uploads`, nothing is copied and the
185
+ report names each attachment.
177
186
 
178
187
  An attachment's alt text (`_wp_attachment_image_alt`) goes to
179
- `_data/media.json` under its upload path, which is where the editor offers it
180
- from. The import owns each key it set there, never the file.
188
+ `_data/media.json` under its upload path. The editor offers alt text from that
189
+ file. The import owns each key it set there, never the file.
181
190
 
182
191
  While the plugin is enabled, a request for `/wp-content/uploads/<path>`
183
- answers 301 at `/uploads/<path>`, so links from elsewhere still land on the
184
- file. A size variant the import did not copy lands on its original, as
185
- `import.json` records it, and the query string is kept. No `redirects.json`
186
- entry is written per file. Keep the plugin enabled for as long as links to the
187
- old media URLs matter.
192
+ answers 301 at `/uploads/<path>`, so links from elsewhere still reach the file.
193
+ A request for a size variant the import did not copy redirects to its original,
194
+ as `import.json` records it. The query string is kept. The import writes no
195
+ `redirects.json` entry per file. Keep the plugin enabled for as long as links to
196
+ the old media URLs matter.
188
197
 
189
198
  ### Comments and reactions
190
199
 
191
- Every comment WordPress kept on a post or page the import writes goes into the
192
- post's comment file, `_data/comments/<slug>.json`, the file the site's own
193
- comments go to. That covers form comments, and the likes, reposts, replies,
194
- mentions, bookmarks and pingbacks the ActivityPub and Webmention plugins stored
195
- as comments. Each lands under the post as one the site received itself would:
200
+ For each post or page the import writes, every comment WordPress kept on it
201
+ goes into the post's comment file, `_data/comments/<slug>.json`. The site's own
202
+ comments go to the same file. This covers form comments, and the likes,
203
+ reposts, replies, mentions, bookmarks and pingbacks that the ActivityPub and
204
+ Webmention plugins stored as comments. Each one appears under the post the
205
+ same way as a comment the site received itself:
196
206
 
197
207
  | WordPress | Here |
198
208
  | --------------------------------------------------------- | -------------------------------------------------------------- |
@@ -202,20 +212,24 @@ as comments. Each lands under the post as one the site received itself would:
202
212
  | `pingback`, `trackback` | A mention, linked to the page that pinged. |
203
213
  | `comment` with no protocol | A comment left on the page. |
204
214
 
205
- A reply keeps the comment it answers. An approved comment is approved here,
206
- and one WordPress held for moderation waits here too. Spam and trash are left
215
+ A commenter's name is the text WordPress showed. WordPress stores a name as
216
+ HTML, so `Andy Sylvester&#039;s Web` arrives as `Andy Sylvester's Web`. A
217
+ post's title and its tag and category names are decoded the same way.
218
+
219
+ A reply keeps the comment it answers. An approved comment is approved here. A
220
+ comment WordPress held for moderation is held here too. Spam and trash are left
207
221
  out and named in the report. A commenter's email goes only to
208
- `data/comments/`. Nothing is announced: no moderator is emailed and nothing is
209
- delivered.
222
+ `data/comments/`. The import announces nothing. It emails no moderator and
223
+ delivers nothing.
210
224
 
211
225
  Every imported comment is `migrated: true`. It shows on its post and in its
212
226
  thread like any other comment, and replies to it thread under it. It is in no
213
227
  feed: not `/comments/feed/`, not a post's `{permalink}feed/`, not a
214
- `/replies/{key}/` feed, and not in the reply counts the feeds give. A reader
215
- of WordPress's comments feed sees nothing new when the site moves.
228
+ `/replies/{key}/` feed, and not in the reply counts the feeds give. A
229
+ subscriber to WordPress's comments feed sees nothing new when the site moves.
216
230
 
217
- Each comment keeps its `origin`, the name it was first published under, and
218
- its id is the key the site gives the same comment when it arrives here itself.
231
+ Each comment keeps its `origin`, the name it was first published under. Its id
232
+ is the key the site gives the same comment when the site receives it directly.
219
233
  A key is the first 16 hex digits of a SHA-256.
220
234
 
221
235
  | Comment | `origin` | Id, the key of |
@@ -226,48 +240,50 @@ A key is the first 16 hex digits of a SHA-256.
226
240
  | A like or repost with no protocol | `source_id`, such as a Bluesky `at://` URI | The origin and the post's permalink |
227
241
  | A comment left on the page | None | `https://example.com/?p=ID#comment-N` |
228
242
 
229
- So a webmention sent again is the comment the site already holds, and so is
230
- a fediverse reply or reaction delivered again or read by the site's reply
231
- backfill: it shows once, as the imported comment, and answers to the note
232
- thread under it. A fediverse reply's `url` stays the note's web page. A comment left on the page
233
- has a page of its own, `/comment/{key}/`.
243
+ A webmention sent again therefore matches the comment the site already
244
+ holds. So does a fediverse reply or reaction delivered again or read by the
245
+ site's reply backfill. It shows once, as the imported comment, and replies to
246
+ the note thread under it. A fediverse reply's `url` stays the note's web page.
247
+ A comment left on the page has a page of its own, `/comment/{key}/`.
234
248
 
235
- A fediverse comment names its actor in `_activitypub_remote_actor_id`: one of
236
- the export's `ap_actor` items, the ActivityPub plugin's cached copy of that
237
- actor's profile. The import reads that profile and gives the comment its
249
+ A fediverse comment names its actor in `_activitypub_remote_actor_id`. That is
250
+ one of the export's `ap_actor` items, the ActivityPub plugin's cached copy of
251
+ the actor's profile. The import reads that profile and gives the comment its
238
252
  `actor`, the actor's id, and its `author.avatar`, the actor's icon. The page
239
253
  shows the icon through the site's avatar cache, so a like or a boost shows a
240
- face, and a like delivered here since from the same actor is counted with the
241
- imported one. A comment whose actor is not in the export imports without
242
- either.
254
+ face. A like from the same actor delivered here after the move is counted with
255
+ the imported one. A comment whose actor is not in the export imports without
256
+ `actor` or `author.avatar`.
243
257
 
244
- A comment written signed in to WordPress names its writer in
245
- `comment_user_id`. When that WordPress user's login is a user on this site, the
246
- rule a post's author follows, the comment keeps that user as its `user`. The
247
- thread then shows it as theirs, as it shows their reply posts: their display
248
- name and avatar, and a link to their author archive with no `rel="nofollow
249
- ugc"`. It stays an imported comment.
258
+ A comment written while signed in to WordPress names its writer in
259
+ `comment_user_id`. If that WordPress user's login matches a user on this site
260
+ (the same rule a post's author follows), the comment keeps that user as its
261
+ `user`. The thread then shows the comment as that user's, the same way it shows
262
+ their reply posts: their display name and avatar, and a link to their author
263
+ archive with no `rel="nofollow ugc"`. It stays an imported comment.
250
264
 
251
265
  The import merges by comment id. A rerun adds the comments WordPress received
252
- since and picks up one edited there. It leaves alone a comment this site
253
- received itself, and the words and status of an imported one a moderator
254
- approved, filed as spam, edited or deleted. It still marks such a comment
255
- migrated and gives it its origin, its user and its actor.
266
+ since and picks up comments edited there. It leaves unchanged a comment this
267
+ site received itself. It also leaves unchanged the words and status of an
268
+ imported comment that a moderator approved, filed as spam, edited or deleted.
269
+ It still marks such a comment migrated and gives it its origin, its user and
270
+ its actor.
256
271
 
257
272
  **Upgrading from 0.2.0.** Version 0.2.0 used each comment's WordPress URL as
258
- its id. The site gives every such id the key of that URL when it next boots,
259
- or when it next runs a plugin command. A rerun of the import then moves each
260
- fediverse comment and webmention to the key of its origin, in its place, and
261
- any reply here that names it follows. Each comment stays one record. The
273
+ its id. The site replaces every such id with the key of that URL when it next
274
+ boots, or when it next runs a plugin command. A rerun of the import then moves
275
+ each fediverse comment and webmention, in place, to the key of its origin. Any
276
+ reply here that names the comment moves with it. Each comment stays one
277
+ record. The
262
278
  import's record of what it wrote, `import.json`, moves to the new keys, so the
263
279
  rerun reports no clash.
264
280
 
265
281
  ### Redirects
266
282
 
267
- Links from elsewhere use URL forms WordPress answered and the site has no
268
- document at. The import declares a redirect for each in
269
- `_data/redirects/wordpress.json`, a file of its own, which the site reads beside
270
- `_data/redirects.json` with no plugin enabled:
283
+ Links from elsewhere use URL forms that WordPress answered and the site has
284
+ no document at. The import declares a redirect for each in its own file,
285
+ `_data/redirects/wordpress.json`. The site reads that file beside
286
+ `_data/redirects.json`, with or without the plugin enabled:
271
287
 
272
288
  | URL | Answers 301 at |
273
289
  | ------------------------------------------- | --------------------------- |
@@ -276,31 +292,35 @@ document at. The import declares a redirect for each in
276
292
  | An attachment's page, such as `/photo-jpg/` | Its file under `/uploads/`. |
277
293
  | `/?attachment_id=ID` and `/?p=ID`, likewise | Its file under `/uploads/`. |
278
294
 
279
- The page WordPress used as its static front page is the exception: its
280
- `?p=` and `?page_id=` answer at `/`, where the site serves its homepage, since
281
- the page's own permalink redirects there too.
295
+ The page WordPress used as its static front page is the exception. Its
296
+ `?p=` and `?page_id=` answer at `/`, where the site serves its homepage. The
297
+ page's own permalink redirects there too.
282
298
 
283
- A draft, a pending, private, password-protected or scheduled post or page gets
284
- no redirect, so its `?p=` answers as an id WordPress never had. An attachment
285
- whose file was not copied gets none either, until a run copies it. A post's
286
- old slugs and dates go into its own `redirect_from`, as
287
- [Posts and pages](#posts-and-pages) says. A published post's `?p=` is also its
288
- `activitypub.id`, which the site answers first, at the same permalink.
299
+ A draft, or a pending, private, password-protected or scheduled post or page,
300
+ gets no redirect. Its `?p=` answers the same as an id WordPress never had. An
301
+ attachment whose file was not copied gets no redirect either, until a run
302
+ copies the file. A post's old slugs and dates go into its own `redirect_from`,
303
+ as [Posts and pages](#posts-and-pages) says. A published post's `?p=` is also
304
+ its `activitypub.id`. The site answers that first, at the same permalink.
289
305
 
290
- The import owns each key it set in `wordpress.json`, never the file, and never
291
- touches `_data/redirects.json`. A key the site added there, or changed, is left
292
- alone on every rerun. Where both files declare one URL, `redirects.json` wins.
293
- A link to a comment, `#comment-N`, reaches the post: the fragment never
294
- reaches the server. The post's anchors are `#comment-{key}`, so the browser
295
- opens the post at the top.
306
+ The import owns each key it set in `wordpress.json`, never the file. It never
307
+ touches `_data/redirects.json`. The import leaves a key that the site added or
308
+ changed unchanged on every rerun. If both files declare one URL,
309
+ `redirects.json` wins. A link to a comment, `#comment-N`, reaches the post,
310
+ because the browser never sends the fragment to the server. The post's anchors
311
+ are `#comment-{key}`, so the browser opens the post at the top.
296
312
 
297
313
  ## Bringing a person across
298
314
 
299
- `geekity import wordpress-actor` brings one person across. It writes the three
300
- things the site cannot mint for itself, the RSA key pair the followers have
301
- cached, the actor id they key the account by, and the followers, and it is a
302
- command rather than a screen because a stored actor id is identity for the life
303
- of the account.
315
+ `geekity import wordpress-actor` imports one person. It writes the three
316
+ things the site cannot create for itself:
317
+
318
+ - the RSA key pair that the followers have cached
319
+ - the actor id that the followers use to identify the account
320
+ - the followers
321
+
322
+ It is a command and not a screen, because a stored actor id is the account's
323
+ identity for the life of the account.
304
324
 
305
325
  ```sh
306
326
  geekity import wordpress-actor ada \
@@ -309,23 +329,26 @@ geekity import wordpress-actor ada \
309
329
  --keypair ada.keypair.json
310
330
  ```
311
331
 
312
- It writes `data/keys/ada.rsassa-pkcs1-v1_5.jwk` (and mints the Ed25519 pair
313
- beside it, which WordPress never had), puts `actorId` on the user in
314
- `data/users.json` and the number in the plugin's `actors.json`, and fetches the
315
- WordPress plugin's public followers collection into
316
- `content/_data/federation/ada/followers.json`, dereferencing each follower for
317
- its inbox, shared inbox, handle, name, avatar and profile URL.
332
+ The command:
333
+
334
+ 1. writes `data/keys/ada.rsassa-pkcs1-v1_5.jwk`, and creates the Ed25519 pair
335
+ beside it, which WordPress never had
336
+ 2. puts `actorId` on the user in `data/users.json` and the number in the
337
+ plugin's `actors.json`
338
+ 3. fetches the WordPress plugin's public followers collection into
339
+ `content/_data/federation/ada/followers.json`. It dereferences each follower
340
+ for its inbox, shared inbox, handle, name, avatar and profile URL.
318
341
 
319
342
  | Option | What it is |
320
343
  | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
321
- | `--actor-id <url>` | The id WordPress published, query string and all. The one thing the import cannot work out for itself. |
344
+ | `--actor-id <url>` | The id WordPress published, including the query string. The import cannot derive it. |
322
345
  | `--wordpress-id <n>` | The WordPress user id, which is the number in the plugin's paths. |
323
346
  | `--keypair <file>` | The JSON the plugin's option holds: `{"private_key": …, "public_key": …}`. |
324
- | `--private-key <file>`, `--public-key <file>` | The two PEMs instead, for a site still on the legacy user meta. The public key is only checked against the private half; it is derived, never stored. |
325
- | `--followers <url\|file\|none>` | Where the followers come from. Left off, the plugin's own collection on the actor id's origin. |
347
+ | `--private-key <file>`, `--public-key <file>` | The two PEMs instead, for a site still on the legacy user meta. The public key is only checked against the private half. It is derived, never stored. |
348
+ | `--followers <url\|file\|none>` | Where the followers come from. The default is the plugin's own collection on the actor id's origin. |
326
349
  | `--force` | Import over a key pair the user already has. |
327
350
 
328
- **Exporting the key pair with wp-cli.** The WordPress plugin keeps a user's
351
+ **Exporting the key pair with wp-cli.** The WordPress plugin stores a user's
329
352
  pair in an option named after their login:
330
353
 
331
354
  ```sh
@@ -333,43 +356,45 @@ pair in an option named after their login:
333
356
  wp option get activitypub_keypair_for_ada --format=json > ada.keypair.json
334
357
  ```
335
358
 
336
- A site old enough to still be on the plugin's legacy storage has the pair in
337
- user meta instead, one PEM per key:
359
+ An older site still on the plugin's legacy storage has the pair in user meta
360
+ instead, one PEM per key:
338
361
 
339
362
  ```sh
340
363
  wp user meta get 2 magic_sig_private_key > ada.private.pem
341
364
  wp user meta get 2 magic_sig_public_key > ada.public.pem
342
365
  ```
343
366
 
344
- Either export is accepted, and either PEM encoding, `-----BEGIN PRIVATE
345
- KEY-----` (PKCS#8) or `-----BEGIN RSA PRIVATE KEY-----` (PKCS#1). Treat the
346
- files the way you would treat a password: the private key is the account.
367
+ The command accepts either export, and either PEM encoding:
368
+ `-----BEGIN PRIVATE KEY-----` (PKCS#8) or `-----BEGIN RSA PRIVATE KEY-----`
369
+ (PKCS#1). Treat the files like a password. Whoever holds the private key
370
+ controls the account.
347
371
 
348
- The followers can be saved before the old site goes away, and the saved file
349
- passed to `--followers`:
372
+ You can save the followers before you shut down the old site, and pass the
373
+ saved file to `--followers`:
350
374
 
351
375
  ```sh
352
376
  curl -H 'Accept: application/activity+json' \
353
377
  'https://example.com/wp-json/activitypub/1.0/actors/2/followers?page=1' > followers.json
354
378
  ```
355
379
 
356
- **Running it twice changes nothing**, and says so, which is what makes it safe
357
- to run again after an instance that was down comes back: a follower already in
358
- the file keeps its place and its follow time. A follower whose server will not
359
- answer is listed and skipped rather than failing the import. A user who already
360
- has a _different_ key pair is refused: importing over one would change that
361
- person's identity, and every follower has cached the public half of the key
362
- that is there. `--force` is the way past that, and is only right when you are
363
- certain the pair being imported is the one the followers hold. An actor id or a
364
- WordPress number another user already carries is refused too, before anything
365
- is written.
380
+ **Running it twice changes nothing**, and the command says so. You can run it
381
+ again after an instance that was down comes back. A follower already in the
382
+ file keeps its place and its follow time. A follower whose server does not
383
+ answer is listed and skipped. The import does not fail.
384
+
385
+ The command refuses a user who already has a _different_ key pair. Importing
386
+ over it would change that person's identity, and every follower has cached the
387
+ public half of the existing key. `--force` overrides the refusal. Use it only
388
+ when you are certain the pair you import is the one the followers hold. The
389
+ command also refuses an actor id or a WordPress number that another user
390
+ already has, before it writes anything.
366
391
 
367
- The cutover, end to end, is in the `@geekity/cms` README under _Moving a site
368
- from the WordPress ActivityPub plugin_.
392
+ The cutover, end to end, is in
393
+ [Moving a site from the WordPress ActivityPub plugin](https://github.com/geekitycom/cms/blob/main/docs/federation.md#moving-a-site-from-the-wordpress-activitypub-plugin).
369
394
 
370
395
  ## The bundle
371
396
 
372
397
  The package's build also writes `dist/bundle/index.js`, one module with its
373
- dependencies inlined and the plugin as its default export. It is what a
374
- plugins folder holds on a site that runs the Docker image, which has no
375
- `node_modules` of its own (decision-33).
398
+ dependencies inlined and the plugin as its default export. A site that runs
399
+ the Docker image has no `node_modules` of its own, so its plugins folder holds
400
+ this file (decision-33).