@coywolf/emdash 0.0.0-stage → 0.35.14

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 (267) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +203 -2
  3. package/docs/README.md +45 -0
  4. package/docs/ai.md +39 -0
  5. package/docs/backups.md +88 -0
  6. package/docs/breadcrumbs.md +59 -0
  7. package/docs/code-blocks.md +21 -0
  8. package/docs/content-urls.md +41 -0
  9. package/docs/custom-blocks.md +27 -0
  10. package/docs/discovery.md +32 -0
  11. package/docs/files.md +44 -0
  12. package/docs/form-uploads.md +59 -0
  13. package/docs/headings.md +37 -0
  14. package/docs/images.md +141 -0
  15. package/docs/internals.md +13 -0
  16. package/docs/links.md +34 -0
  17. package/docs/performance.md +72 -0
  18. package/docs/redirects.md +84 -0
  19. package/docs/releasing.md +21 -0
  20. package/docs/reviews.md +89 -0
  21. package/docs/robots.md +81 -0
  22. package/docs/schema.md +79 -0
  23. package/docs/search.md +114 -0
  24. package/docs/videos.md +48 -0
  25. package/docs/wordpress-import.md +198 -0
  26. package/package.json +79 -5
  27. package/src/admin/admin-css.ts +57 -0
  28. package/src/admin/ai.tsx +915 -0
  29. package/src/admin/backups.tsx +613 -0
  30. package/src/admin/breadcrumbs.tsx +542 -0
  31. package/src/admin/codeBlocks.tsx +204 -0
  32. package/src/admin/customBlocks.tsx +321 -0
  33. package/src/admin/dirty.ts +20 -0
  34. package/src/admin/discovery.tsx +463 -0
  35. package/src/admin/download.ts +28 -0
  36. package/src/admin/feature-switches.tsx +124 -0
  37. package/src/admin/features.tsx +142 -0
  38. package/src/admin/files.tsx +791 -0
  39. package/src/admin/formUploads.tsx +445 -0
  40. package/src/admin/guides.tsx +18 -0
  41. package/src/admin/headings.tsx +262 -0
  42. package/src/admin/images.tsx +825 -0
  43. package/src/admin/index.tsx +62 -0
  44. package/src/admin/links.tsx +980 -0
  45. package/src/admin/performance.tsx +330 -0
  46. package/src/admin/preview.tsx +63 -0
  47. package/src/admin/redirects-removed.tsx +266 -0
  48. package/src/admin/redirects.tsx +470 -0
  49. package/src/admin/reviews.tsx +232 -0
  50. package/src/admin/robots-bots.tsx +647 -0
  51. package/src/admin/robots-shared.tsx +109 -0
  52. package/src/admin/robots-wizard.tsx +748 -0
  53. package/src/admin/robots.tsx +911 -0
  54. package/src/admin/save-bar.tsx +134 -0
  55. package/src/admin/schema.tsx +1187 -0
  56. package/src/admin/search.tsx +355 -0
  57. package/src/admin/settings-ui.tsx +87 -0
  58. package/src/admin/trash-prompt-core.ts +135 -0
  59. package/src/admin/trash-prompt.tsx +156 -0
  60. package/src/admin/usage.tsx +301 -0
  61. package/src/admin/videos-display.tsx +248 -0
  62. package/src/admin/videos.tsx +1202 -0
  63. package/src/admin/wpImport.tsx +1516 -0
  64. package/src/ai/entities.ts +59 -0
  65. package/src/ai/export.ts +61 -0
  66. package/src/ai/logic.ts +606 -0
  67. package/src/ai/pack.ts +121 -0
  68. package/src/ai/prompts.ts +87 -0
  69. package/src/ai/providers.ts +276 -0
  70. package/src/ai/routes.ts +359 -0
  71. package/src/ai/store.ts +301 -0
  72. package/src/ai/wikidata.ts +87 -0
  73. package/src/ai/worker.ts +530 -0
  74. package/src/astro/astro-modules.d.ts +6 -0
  75. package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
  76. package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
  77. package/src/astro/codeBlocks/CodeBlock.astro +62 -0
  78. package/src/astro/codeBlocks/EmDashCode.astro +61 -0
  79. package/src/astro/customBlocks/CustomBlock.astro +64 -0
  80. package/src/astro/customBlocks/Details.astro +8 -0
  81. package/src/astro/customBlocks/Disclosure.astro +8 -0
  82. package/src/astro/customBlocks/Note.astro +8 -0
  83. package/src/astro/customBlocks/Podcast.astro +8 -0
  84. package/src/astro/customBlocks/Quote.astro +8 -0
  85. package/src/astro/customBlocks/Testimonial.astro +8 -0
  86. package/src/astro/files/FileCard.astro +103 -0
  87. package/src/astro/headings/HeadingBlock.astro +129 -0
  88. package/src/astro/headings/TableOfContents.astro +45 -0
  89. package/src/astro/headings/request.ts +19 -0
  90. package/src/astro/images/ResponsiveImage.astro +60 -0
  91. package/src/astro/index.ts +84 -0
  92. package/src/astro/reviews/CoywolfReview.astro +27 -0
  93. package/src/astro/reviews/Review.astro +49 -0
  94. package/src/astro/search/SearchBox.astro +61 -0
  95. package/src/astro/search/SearchBoxForm.astro +509 -0
  96. package/src/astro/tables/CaptionedTable.astro +72 -0
  97. package/src/astro/videos/CoywolfVideo.astro +448 -0
  98. package/src/backups/download.ts +112 -0
  99. package/src/backups/dump.ts +288 -0
  100. package/src/backups/module.ts +276 -0
  101. package/src/backups/pack.ts +33 -0
  102. package/src/backups/restore.ts +209 -0
  103. package/src/backups/store.ts +409 -0
  104. package/src/breadcrumbs/pack.ts +113 -0
  105. package/src/breadcrumbs/settings.ts +102 -0
  106. package/src/breadcrumbs/stamp.ts +40 -0
  107. package/src/breadcrumbs/trail.ts +123 -0
  108. package/src/codeBlocks/contrast.ts +179 -0
  109. package/src/codeBlocks/hljs.d.ts +5 -0
  110. package/src/codeBlocks/pack.ts +110 -0
  111. package/src/codeBlocks/render.ts +304 -0
  112. package/src/codeBlocks/settings.ts +18 -0
  113. package/src/codeBlocks/themes.generated.ts +304 -0
  114. package/src/codeBlocks/themes.ts +114 -0
  115. package/src/core/compose.ts +244 -0
  116. package/src/core/content-url.ts +888 -0
  117. package/src/core/csv.ts +119 -0
  118. package/src/core/d1-batch.ts +65 -0
  119. package/src/core/entry-docs.ts +175 -0
  120. package/src/core/features-module.ts +48 -0
  121. package/src/core/features.ts +392 -0
  122. package/src/core/generation.ts +88 -0
  123. package/src/core/guide-content.ts +166 -0
  124. package/src/core/module.ts +104 -0
  125. package/src/core/read-failures.ts +57 -0
  126. package/src/core/request-memo.ts +71 -0
  127. package/src/core/secrets.ts +83 -0
  128. package/src/core/settings-enhance.ts +74 -0
  129. package/src/core/site.ts +36 -0
  130. package/src/core/storage.ts +27 -0
  131. package/src/customBlocks/pack.ts +260 -0
  132. package/src/customBlocks/render.ts +424 -0
  133. package/src/customBlocks/rich.ts +313 -0
  134. package/src/customBlocks/settings.ts +46 -0
  135. package/src/discovery/build.ts +252 -0
  136. package/src/discovery/feed-build.ts +217 -0
  137. package/src/discovery/feed.ts +172 -0
  138. package/src/discovery/indexnow.ts +114 -0
  139. package/src/discovery/llms.ts +79 -0
  140. package/src/discovery/markdown.ts +294 -0
  141. package/src/discovery/memory-cache.ts +97 -0
  142. package/src/discovery/middleware.ts +186 -0
  143. package/src/discovery/module.ts +353 -0
  144. package/src/discovery/news.ts +99 -0
  145. package/src/discovery/pack.ts +58 -0
  146. package/src/discovery/settings.ts +91 -0
  147. package/src/discovery/urls.ts +35 -0
  148. package/src/env.d.ts +10 -0
  149. package/src/files/card.ts +231 -0
  150. package/src/files/counts.ts +60 -0
  151. package/src/files/export.ts +44 -0
  152. package/src/files/format.ts +163 -0
  153. package/src/files/module.ts +596 -0
  154. package/src/files/pack.ts +92 -0
  155. package/src/files/r2-rules.ts +41 -0
  156. package/src/files/range.ts +50 -0
  157. package/src/files/s3.ts +114 -0
  158. package/src/files/serve.ts +141 -0
  159. package/src/files/sigv4.ts +160 -0
  160. package/src/files/site.ts +137 -0
  161. package/src/files/walker.ts +58 -0
  162. package/src/formUploads/lib.ts +266 -0
  163. package/src/formUploads/pack.ts +55 -0
  164. package/src/formUploads/wrap.ts +592 -0
  165. package/src/forms.ts +16 -0
  166. package/src/headings/pack.ts +154 -0
  167. package/src/headings/render.ts +139 -0
  168. package/src/headings/settings.ts +117 -0
  169. package/src/headings/slug.ts +68 -0
  170. package/src/headings/stamp.ts +142 -0
  171. package/src/headings/test-hooks.mjs +14 -0
  172. package/src/headings/toc.ts +48 -0
  173. package/src/images/cloudflare.ts +329 -0
  174. package/src/images/lib.ts +221 -0
  175. package/src/images/module.ts +372 -0
  176. package/src/images/pack.ts +804 -0
  177. package/src/images/pending.ts +37 -0
  178. package/src/images/settings.ts +48 -0
  179. package/src/images/setup.ts +192 -0
  180. package/src/images/variants-estimate.ts +162 -0
  181. package/src/images/variants-store.ts +475 -0
  182. package/src/images/variants.ts +311 -0
  183. package/src/index.ts +143 -0
  184. package/src/links/check.ts +101 -0
  185. package/src/links/classify.ts +270 -0
  186. package/src/links/export.ts +59 -0
  187. package/src/links/module.ts +652 -0
  188. package/src/links/pack.ts +43 -0
  189. package/src/links/pt.ts +325 -0
  190. package/src/links/store.ts +704 -0
  191. package/src/middleware.ts +287 -0
  192. package/src/modules.ts +53 -0
  193. package/src/options.ts +66 -0
  194. package/src/pageCache/lib.ts +274 -0
  195. package/src/pageCache/pack.ts +240 -0
  196. package/src/pageCache/usage-core.ts +152 -0
  197. package/src/pageCache/usage.ts +262 -0
  198. package/src/pageCache/warm.ts +722 -0
  199. package/src/redirects/middleware.ts +169 -0
  200. package/src/redirects/module.ts +124 -0
  201. package/src/redirects/pack.ts +89 -0
  202. package/src/redirects/removed-core.ts +151 -0
  203. package/src/redirects/removed.ts +293 -0
  204. package/src/redirects/rules.ts +355 -0
  205. package/src/redirects/slug-changes.ts +207 -0
  206. package/src/redirects/slug-core.ts +49 -0
  207. package/src/redirects/transfer.ts +92 -0
  208. package/src/reviews/lib.ts +624 -0
  209. package/src/reviews/pack.ts +135 -0
  210. package/src/reviews/schema.ts +128 -0
  211. package/src/reviews/settings.ts +26 -0
  212. package/src/robots/bots.ts +321 -0
  213. package/src/robots/data/bots.json +696 -0
  214. package/src/robots/data/verified.json +582 -0
  215. package/src/robots/directory.ts +26 -0
  216. package/src/robots/explain.ts +191 -0
  217. package/src/robots/guided.ts +300 -0
  218. package/src/robots/importer.ts +263 -0
  219. package/src/robots/middleware.ts +151 -0
  220. package/src/robots/module.ts +446 -0
  221. package/src/robots/pack.ts +69 -0
  222. package/src/robots/radar.ts +129 -0
  223. package/src/robots/rep.ts +362 -0
  224. package/src/robots/rules.ts +883 -0
  225. package/src/robots/transfer.ts +45 -0
  226. package/src/robots/validate.ts +524 -0
  227. package/src/schema/catalog.ts +146 -0
  228. package/src/schema/graph.ts +791 -0
  229. package/src/schema/module.ts +1138 -0
  230. package/src/schema/og-image.ts +100 -0
  231. package/src/schema/pack.ts +58 -0
  232. package/src/search/engine.ts +578 -0
  233. package/src/search/fallback.ts +80 -0
  234. package/src/search/live-cache.ts +116 -0
  235. package/src/search/live-client.ts +770 -0
  236. package/src/search/live-serve.ts +282 -0
  237. package/src/search/module.ts +261 -0
  238. package/src/search/pack.ts +57 -0
  239. package/src/search/query.ts +132 -0
  240. package/src/search/ratelimit-core.ts +118 -0
  241. package/src/search/ratelimit.ts +79 -0
  242. package/src/search/snippet.ts +135 -0
  243. package/src/search/title-index.ts +139 -0
  244. package/src/search/title-match.ts +192 -0
  245. package/src/shared.ts +71 -0
  246. package/src/version.ts +2 -0
  247. package/src/videos/date.ts +40 -0
  248. package/src/videos/engagement.ts +75 -0
  249. package/src/videos/lib.ts +679 -0
  250. package/src/videos/module.ts +1160 -0
  251. package/src/videos/pack.ts +187 -0
  252. package/src/videos/pause.ts +42 -0
  253. package/src/videos/poster.ts +324 -0
  254. package/src/videos/render.ts +336 -0
  255. package/src/videos/store.ts +551 -0
  256. package/src/videos/stream.ts +134 -0
  257. package/src/wpImport/convert.ts +702 -0
  258. package/src/wpImport/guests.ts +329 -0
  259. package/src/wpImport/gutenberg.ts +157 -0
  260. package/src/wpImport/markers.ts +66 -0
  261. package/src/wpImport/pack.ts +451 -0
  262. package/src/wpImport/parents.ts +202 -0
  263. package/src/wpImport/prepare.ts +592 -0
  264. package/src/wpImport/redirects.ts +358 -0
  265. package/src/wpImport/sources.ts +67 -0
  266. package/src/wpImport/stream.ts +160 -0
  267. package/src/wpImport/urls.ts +354 -0
@@ -0,0 +1,1516 @@
1
+ /**
2
+ * WordPress import page: the steps for finishing a move from WordPress to
3
+ * EmDash, for any WordPress site, plus a section for sites that used
4
+ * Coywolf's WordPress plugins. Reading the export runs in the browser (the
5
+ * file never leaves the computer until it's imported); converting stored
6
+ * content runs on the site, a dry run first. Turning the module on or off
7
+ * happens on the Coywolf Pack page.
8
+ */
9
+ import { Banner, Button, Input, InputArea, Loader } from "@cloudflare/kumo";
10
+ import {
11
+ ArrowsClockwise,
12
+ ArrowsLeftRight,
13
+ DownloadSimple,
14
+ FileArrowUp,
15
+ ImageSquare,
16
+ MagnifyingGlass,
17
+ TreeStructure,
18
+ UploadSimple,
19
+ UserPlus,
20
+ } from "@phosphor-icons/react";
21
+ import { apiFetch, parseApiResponse } from "emdash/plugin-utils";
22
+ import * as React from "react";
23
+
24
+ import { type GuestAuthor, type GuestByline, type PostCredit, groupGuests, postCredits, sameName, wxrGuestAuthors } from "../wpImport/guests.js";
25
+ import { type ParentPlan, type SiteTerm, type WxrCategory, type WxrPage, optionSnippet, parentsMap, planCategoryParents, wxrCategories, wxrPages } from "../wpImport/parents.js";
26
+ import { type PrepareWxrResult, prepareWxr, wxrAttachments } from "../wpImport/prepare.js";
27
+ import {
28
+ type RedirectsResult,
29
+ type WpRedirect,
30
+ coywolfSeoRules,
31
+ mergeRedirects,
32
+ rankMathRules,
33
+ redirectionRules,
34
+ wxrOldSlugRules,
35
+ yoastRules,
36
+ } from "../wpImport/redirects.js";
37
+ import { parseFileRows } from "../wpImport/sources.js";
38
+ import { type MediaFile, type SiteInfo, type SiteUrlPlan, parseHosts, planSiteUrls, rewriteMap, sourceUrl, uploadRedirects, wxrSite } from "../wpImport/urls.js";
39
+ import { saveFile } from "./download.js";
40
+
41
+ const API = "/_emdash/api/plugins/coywolf-pack/wpImport";
42
+ const EMDASH = "/_emdash/api";
43
+
44
+ const errorText = (cause: unknown, fallback: string) => (cause instanceof Error && cause.message ? cause.message : fallback);
45
+
46
+ async function post<T>(path: string, body: unknown, fallback: string): Promise<T> {
47
+ const response = await apiFetch(`${API}/${path}`, {
48
+ method: "POST",
49
+ headers: { "Content-Type": "application/json" },
50
+ body: JSON.stringify(body),
51
+ });
52
+ return parseApiResponse<T>(response, fallback);
53
+ }
54
+
55
+ async function getJson<T>(url: string, fallback: string): Promise<T> {
56
+ return parseApiResponse<T>(await apiFetch(url), fallback);
57
+ }
58
+
59
+ async function send<T>(url: string, method: "POST" | "PUT", body: unknown, fallback: string): Promise<T> {
60
+ const response = await apiFetch(url, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
61
+ return parseApiResponse<T>(response, fallback);
62
+ }
63
+
64
+ const plural = (n: number, one: string, many: string) => `${n.toLocaleString()} ${n === 1 ? one : many}`;
65
+
66
+ function Section(props: { title: string; description: React.ReactNode; children?: React.ReactNode }) {
67
+ const id = `cw-wpi-${props.title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
68
+ return (
69
+ <section className="rounded-lg border border-kumo-line" aria-labelledby={id}>
70
+ <div className={props.children ? "border-b border-kumo-line p-4" : "p-4"}>
71
+ <h2 id={id} className="text-base font-semibold">
72
+ {props.title}
73
+ </h2>
74
+ <div className="mt-1 text-sm text-kumo-subtle">{props.description}</div>
75
+ </div>
76
+ {props.children && <div className="space-y-4 p-4">{props.children}</div>}
77
+ </section>
78
+ );
79
+ }
80
+
81
+ const Code = ({ children }: { children: string }) => <code className="rounded bg-kumo-tint px-1 py-0.5 font-mono text-xs break-all">{children}</code>;
82
+
83
+ function CountsTable({ counts, label = "Block" }: { counts: Record<string, number>; label?: string }) {
84
+ const rows = Object.entries(counts).sort((a, b) => b[1] - a[1]);
85
+ if (!rows.length) return <p className="text-sm text-kumo-subtle">Nothing to change.</p>;
86
+ return (
87
+ <div className="overflow-x-auto">
88
+ <table className="w-full text-left text-sm">
89
+ <thead className="text-kumo-subtle">
90
+ <tr>
91
+ <th className="py-1 pe-3 font-medium">{label}</th>
92
+ <th className="py-1 text-end font-medium">Count</th>
93
+ </tr>
94
+ </thead>
95
+ <tbody>
96
+ {rows.map(([k, v]) => (
97
+ <tr key={k} className="border-t border-kumo-line">
98
+ <td className="py-1 pe-3 font-mono text-xs">{k}</td>
99
+ <td className="py-1 text-end tabular-nums">{v.toLocaleString()}</td>
100
+ </tr>
101
+ ))}
102
+ </tbody>
103
+ </table>
104
+ </div>
105
+ );
106
+ }
107
+
108
+ // ── The export, read once ────────────────────────────────────────
109
+
110
+ interface WxrParents {
111
+ categories: WxrCategory[];
112
+ pages: WxrPage[];
113
+ }
114
+
115
+ /** What the steps after importing need from the export. */
116
+ interface WxrInfo {
117
+ guests: GuestAuthor[];
118
+ parents: WxrParents;
119
+ site: SiteInfo;
120
+ /** Attachment URLs (wp:attachment_url). */
121
+ attachmentUrls: string[];
122
+ oldSlugs: RedirectsResult;
123
+ }
124
+
125
+ function readWxr(xml: string, prepared?: PrepareWxrResult): WxrInfo {
126
+ const attachments = wxrAttachments(xml);
127
+ return {
128
+ guests: prepared?.guestAuthors ?? wxrGuestAuthors(xml, attachments),
129
+ parents: { categories: prepared?.categories ?? wxrCategories(xml), pages: prepared?.pages ?? wxrPages(xml) },
130
+ site: wxrSite(xml),
131
+ attachmentUrls: [...attachments.values()],
132
+ oldSlugs: wxrOldSlugRules(xml),
133
+ };
134
+ }
135
+
136
+ async function readExportFile(file: File): Promise<string> {
137
+ const xml = await file.text();
138
+ if (!xml.includes("<rss") || !xml.includes("<wp:")) throw new Error("That doesn't look like a WordPress export (WXR) file.");
139
+ return xml;
140
+ }
141
+
142
+ /** A hidden file input and the button that opens it. */
143
+ function ChooseExport({ onXml, disabled, label = "Choose export file" }: { onXml: (xml: string, name: string) => void | Promise<void>; disabled?: boolean; label?: string }) {
144
+ const fileRef = React.useRef<HTMLInputElement>(null);
145
+ const [busy, setBusy] = React.useState(false);
146
+ const [error, setError] = React.useState<string>();
147
+ const choose = async (file: File) => {
148
+ setBusy(true);
149
+ setError(undefined);
150
+ try {
151
+ const xml = await readExportFile(file);
152
+ // Let the "Reading…" state paint before the synchronous work.
153
+ await new Promise((r) => setTimeout(r, 0));
154
+ await onXml(xml, file.name);
155
+ } catch (cause) {
156
+ setError(errorText(cause, "Could not read that file."));
157
+ } finally {
158
+ setBusy(false);
159
+ }
160
+ };
161
+ return (
162
+ <>
163
+ <input
164
+ ref={fileRef}
165
+ type="file"
166
+ accept=".xml,text/xml,application/xml"
167
+ className="sr-only"
168
+ tabIndex={-1}
169
+ aria-hidden="true"
170
+ onChange={(e) => {
171
+ const file = e.target.files?.[0];
172
+ e.target.value = "";
173
+ if (file) void choose(file);
174
+ }}
175
+ />
176
+ <Button variant="secondary" icon={<FileArrowUp />} disabled={busy || disabled} onClick={() => fileRef.current?.click()}>
177
+ {busy ? "Reading…" : label}
178
+ </Button>
179
+ {error && <Banner variant="error" role="alert" description={error} />}
180
+ </>
181
+ );
182
+ }
183
+
184
+ // ── Before you import ────────────────────────────────────────────
185
+
186
+ function BeforeImport() {
187
+ return (
188
+ <Section
189
+ title="Before you import"
190
+ description={
191
+ <ul className="list-disc space-y-1 ps-5">
192
+ <li>
193
+ Install EmDash and Coywolf Pack first, and add API keys and tokens (under <strong>Plugins → Coywolf Pack → Settings</strong> or on each
194
+ module's page) before importing, so imported content comes over connected.
195
+ </li>
196
+ <li>
197
+ Turn on the modules imported content needs, under <strong>Plugins → Coywolf Pack</strong>: <strong>WordPress import</strong>,{" "}
198
+ <strong>Headings &amp; TOC</strong> with Heading anchors (WordPress heading ids), <strong>Custom Blocks</strong> with the Details block (core
199
+ Details blocks), and <strong>Redirects</strong> (old URLs). Sites that used Coywolf's WordPress plugins turn on more; see “Coywolf
200
+ WordPress plugins” below.
201
+ </li>
202
+ <li>
203
+ Keep the WordPress site online until you finish: EmDash imports media from it, and step 5 downloads files that weren't in the media
204
+ library. Make a backup of the EmDash site before converting.
205
+ </li>
206
+ </ul>
207
+ }
208
+ />
209
+ );
210
+ }
211
+
212
+ // ── 1. Prepare ───────────────────────────────────────────────────
213
+
214
+ function PrepareStep({ onWxr }: { onWxr: (info: WxrInfo) => void }) {
215
+ const [result, setResult] = React.useState<(PrepareWxrResult & { name: string }) | null>(null);
216
+ const [disclosures, setDisclosures] = React.useState("");
217
+
218
+ const prepare = (xml: string, name: string) => {
219
+ setResult(null);
220
+ const disclosureBlocks = disclosures
221
+ .split(/[\s,]+/)
222
+ .map((b) => b.trim())
223
+ .filter((b) => /^[a-z][a-z0-9_-]*\/[a-z][a-z0-9_-]*$/.test(b));
224
+ const prepared = prepareWxr(xml, { disclosureBlocks });
225
+ setResult({ ...prepared, name });
226
+ onWxr(readWxr(xml, prepared));
227
+ };
228
+
229
+ const changed: Record<string, number> = {};
230
+ const dropped: Record<string, number> = {};
231
+ for (const [k, v] of Object.entries(result?.counts ?? {})) {
232
+ if (k.endsWith("→ dropped") || k.endsWith("→ missing")) dropped[k.replace(/ → (dropped|missing)$/, (_, a: string) => (a === "missing" ? " (reusable block not in the export)" : ""))] = v;
233
+ else changed[k] = v;
234
+ }
235
+ const parentCount = result ? Object.keys(parentsMap(result.categories)).length + Object.keys(parentsMap(result.pages)).length : 0;
236
+
237
+ return (
238
+ <Section
239
+ title="1. Prepare the WordPress export"
240
+ description={
241
+ <>
242
+ EmDash's importer drops heading ids, reusable blocks, Details summaries and self-closing blocks it doesn't know. Choose the export from{" "}
243
+ <strong>Tools → Export → All content</strong> in WordPress, then import the prepared copy under <strong>Settings → Import</strong>. The file
244
+ is processed in your browser and isn't uploaded.
245
+ </>
246
+ }
247
+ >
248
+ <div className="max-w-xl space-y-1">
249
+ <Input
250
+ label="More affiliate disclosure blocks (optional)"
251
+ placeholder="genesis-custom-blocks/disclosure"
252
+ value={disclosures}
253
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDisclosures(e.target.value)}
254
+ />
255
+ <p className="text-xs text-kumo-subtle">
256
+ Block names, separated by commas, of self-closing blocks that printed an affiliate disclosure. They become Affiliate disclosure blocks with
257
+ the wording on the Custom Blocks page.
258
+ </p>
259
+ </div>
260
+ <div className="flex flex-wrap items-center gap-2">
261
+ <ChooseExport onXml={prepare} />
262
+ {result && (
263
+ <Button
264
+ variant="primary"
265
+ icon={<DownloadSimple />}
266
+ onClick={() => saveFile(new Blob([result.xml], { type: "application/xml" }), result.name.replace(/\.xml$/i, "") + "-prepared.xml")}
267
+ >
268
+ Download prepared file
269
+ </Button>
270
+ )}
271
+ </div>
272
+ {result && (
273
+ <div className="space-y-3" role="status">
274
+ <p className="text-sm">
275
+ {plural(result.posts.length, "entry", "entries")} changed. “→ anchor” keeps a heading id, “→ caption” keeps a table's caption, “→ inlined” puts a reusable block's content in
276
+ place, “→ details”, “→ note”, “→ quote”, “→ disclosure”, “→ testimonial” and “→ podcast” become Custom Blocks, “→ html” keeps content as an
277
+ HTML block, “→ flag” leaves an empty marker for the theme, and “→ removed” rendered nothing on WordPress.
278
+ {result.guestAuthors.length ? ` ${plural(postCredits(result.guestAuthors).length, "post has", "posts have")} co-authors or guest authors: credit them in step 3 after importing.` : ""}
279
+ {parentCount ? " Some categories or pages have parents, which EmDash's importer drops: restore them in step 4 after importing." : ""}
280
+ </p>
281
+ <CountsTable counts={changed} />
282
+ {Object.keys(dropped).length > 0 && (
283
+ <div>
284
+ <h3 className="mb-1 text-sm font-medium">Blocks EmDash will drop</h3>
285
+ <p className="mb-1 text-xs text-kumo-subtle">
286
+ Self-closing blocks keep everything in their settings, and EmDash's importer drops the ones it doesn't know. Rebuild them after
287
+ importing (a pack block, an embed, or the theme).
288
+ </p>
289
+ <CountsTable counts={dropped} />
290
+ </div>
291
+ )}
292
+ </div>
293
+ )}
294
+ </Section>
295
+ );
296
+ }
297
+
298
+ // ── 2. Convert (and rewrite URLs) ────────────────────────────────
299
+
300
+ interface ScanEntry {
301
+ collection: string;
302
+ id: string;
303
+ title: string;
304
+ status: string;
305
+ changes: Record<string, number>;
306
+ result?: string;
307
+ error?: string;
308
+ }
309
+
310
+ interface ScanPage {
311
+ done: boolean;
312
+ scanned: number;
313
+ entries: ScanEntry[];
314
+ leftovers: Record<string, number>;
315
+ videosAdded: number;
316
+ state: unknown;
317
+ progress: string;
318
+ }
319
+
320
+ interface ScanReport {
321
+ apply: boolean;
322
+ scanned: number;
323
+ entries: ScanEntry[];
324
+ leftovers: Record<string, number>;
325
+ videosAdded: number;
326
+ done: boolean;
327
+ }
328
+
329
+ const RESULT_LABEL: Record<string, string> = {
330
+ updated: "Updated",
331
+ published: "Updated and republished",
332
+ draft: "Updated in its draft (it had unpublished changes)",
333
+ conflict: "Skipped: changed while converting",
334
+ failed: "Failed",
335
+ };
336
+
337
+ /** Dry run and apply of the content scan; with `urlMap`, it also rewrites old-site URLs. */
338
+ function ScanPanel({ urlMap, applyLabel, confirmText }: { urlMap?: Record<string, string>; applyLabel: string; confirmText: string }) {
339
+ const [report, setReport] = React.useState<ScanReport | null>(null);
340
+ const [running, setRunning] = React.useState<false | "dry" | "apply">(false);
341
+ const [progress, setProgress] = React.useState("");
342
+ const [error, setError] = React.useState<string>();
343
+ const [dryRunDone, setDryRunDone] = React.useState(false);
344
+
345
+ React.useEffect(() => {
346
+ setReport(null);
347
+ setDryRunDone(false);
348
+ }, [urlMap]);
349
+
350
+ const run = async (apply: boolean) => {
351
+ if (apply && !window.confirm(confirmText)) return;
352
+ setRunning(apply ? "apply" : "dry");
353
+ setError(undefined);
354
+ const acc: ScanReport = { apply, scanned: 0, entries: [], leftovers: {}, videosAdded: 0, done: false };
355
+ setReport(acc);
356
+ try {
357
+ let state: unknown = null;
358
+ for (;;) {
359
+ const page = await post<ScanPage>("scan", { apply, state, ...(urlMap ? { urlMap } : {}) }, "The scan failed");
360
+ acc.scanned += page.scanned;
361
+ acc.entries.push(...page.entries);
362
+ acc.videosAdded += page.videosAdded;
363
+ for (const [k, v] of Object.entries(page.leftovers)) acc.leftovers[k] = (acc.leftovers[k] ?? 0) + v;
364
+ setProgress(`${page.progress}: ${acc.scanned.toLocaleString()} entries checked`);
365
+ setReport({ ...acc, entries: [...acc.entries] });
366
+ if (page.done) break;
367
+ state = page.state;
368
+ }
369
+ acc.done = true;
370
+ setReport({ ...acc, entries: [...acc.entries] });
371
+ if (!apply) setDryRunDone(true);
372
+ } catch (cause) {
373
+ setError(errorText(cause, "The scan failed"));
374
+ } finally {
375
+ setRunning(false);
376
+ setProgress("");
377
+ }
378
+ };
379
+
380
+ const totals: Record<string, number> = {};
381
+ for (const e of report?.entries ?? []) for (const [k, v] of Object.entries(e.changes)) totals[k] = (totals[k] ?? 0) + v;
382
+
383
+ return (
384
+ <div className="space-y-4">
385
+ <div className="flex flex-wrap gap-2">
386
+ <Button variant="secondary" icon={<MagnifyingGlass />} disabled={Boolean(running)} onClick={() => void run(false)}>
387
+ {running === "dry" ? "Checking…" : "Dry run"}
388
+ </Button>
389
+ <Button variant="primary" icon={<ArrowsClockwise />} disabled={Boolean(running) || !dryRunDone || !report?.entries.length} onClick={() => void run(true)}>
390
+ {running === "apply" ? "Working…" : applyLabel}
391
+ </Button>
392
+ {running && <Loader size="sm" />}
393
+ </div>
394
+ <p className="sr-only" role="status" aria-live="polite">
395
+ {progress}
396
+ </p>
397
+ {progress && <p className="text-sm text-kumo-subtle">{progress}</p>}
398
+ {error && <Banner variant="error" role="alert" description={error} />}
399
+ {report && (
400
+ <div className="space-y-4">
401
+ <p className="text-sm">
402
+ {report.done ? (report.apply ? "Done. " : "Dry run finished. ") : ""}
403
+ {report.entries.length.toLocaleString()} of {report.scanned.toLocaleString()} entries {report.apply ? "had" : "have"} something to change.
404
+ {report.apply && report.videosAdded ? ` Added details for ${plural(report.videosAdded, "video", "videos")}.` : ""}
405
+ </p>
406
+ {Object.keys(totals).length > 0 && (
407
+ <div>
408
+ <h3 className="mb-1 text-sm font-medium">{report.apply ? "Changed" : "Would change"}</h3>
409
+ <CountsTable counts={totals} label="Change" />
410
+ </div>
411
+ )}
412
+ {Object.keys(report.leftovers).length > 0 && (
413
+ <div>
414
+ <h3 className="mb-1 text-sm font-medium">Kept as HTML blocks</h3>
415
+ <p className="mb-1 text-xs text-kumo-subtle">
416
+ No Coywolf Pack block for these (yet), or the block is turned off (notes, details, quotes, disclosures, testimonials and podcast links
417
+ convert once their Custom Blocks switch is on: run this again). Their content is kept; “marker:gravity-form” is an empty placeholder
418
+ for the theme. See docs/wordpress-import.md in the Coywolf Pack repository.
419
+ </p>
420
+ <CountsTable counts={report.leftovers} />
421
+ </div>
422
+ )}
423
+ {report.entries.length > 0 && (
424
+ <div className="overflow-x-auto">
425
+ <table className="w-full text-left text-sm">
426
+ <thead className="text-kumo-subtle">
427
+ <tr>
428
+ <th className="py-1 pe-3 font-medium">Entry</th>
429
+ <th className="py-1 pe-3 font-medium">Status</th>
430
+ <th className="py-1 pe-3 font-medium">Changes</th>
431
+ {report.apply && <th className="py-1 font-medium">Result</th>}
432
+ </tr>
433
+ </thead>
434
+ <tbody>
435
+ {report.entries.slice(0, 1000).map((e) => (
436
+ <tr key={`${e.collection}/${e.id}`} className="border-t border-kumo-line align-top">
437
+ <td className="py-1 pe-3">
438
+ <a className="text-kumo-link underline" href={`/_emdash/admin/content/${encodeURIComponent(e.collection)}/${encodeURIComponent(e.id)}`}>
439
+ {e.title || e.id}
440
+ </a>
441
+ <span className="block text-xs text-kumo-subtle">{e.collection}</span>
442
+ </td>
443
+ <td className="py-1 pe-3">{e.status}</td>
444
+ <td className="py-1 pe-3 text-xs">
445
+ {Object.entries(e.changes)
446
+ .map(([k, v]) => `${k} × ${v}`)
447
+ .join(", ")}
448
+ </td>
449
+ {report.apply && (
450
+ <td className="py-1 text-xs">
451
+ {RESULT_LABEL[e.result ?? ""] ?? e.result ?? ""}
452
+ {e.error ? `: ${e.error}` : ""}
453
+ </td>
454
+ )}
455
+ </tr>
456
+ ))}
457
+ </tbody>
458
+ </table>
459
+ {report.entries.length > 1000 && <p className="mt-1 text-xs text-kumo-subtle">Showing the first 1,000 entries.</p>}
460
+ </div>
461
+ )}
462
+ </div>
463
+ )}
464
+ </div>
465
+ );
466
+ }
467
+
468
+ function ConvertStep() {
469
+ return (
470
+ <Section
471
+ title="2. Convert imported content"
472
+ description="While WordPress import is on, entries are converted as they're imported or saved. This converts entries that are already on the site (imported earlier, or before the module or a block was on). Run a dry run first: it lists what would change and changes nothing."
473
+ >
474
+ <ScanPanel
475
+ applyLabel="Convert"
476
+ confirmText="Convert the blocks listed in the dry run? Each entry is saved (published entries are republished). Make a backup first."
477
+ />
478
+ </Section>
479
+ );
480
+ }
481
+
482
+ // ── 3. Co-authors and guest authors ──────────────────────────────
483
+
484
+ interface BylineSummary {
485
+ id: string;
486
+ slug: string;
487
+ displayName: string;
488
+ isGuest: boolean;
489
+ avatarMediaId: string | null;
490
+ }
491
+
492
+ interface BylinePlan {
493
+ byline: GuestByline;
494
+ existing: BylineSummary | null;
495
+ avatarMediaId: string | null;
496
+ result?: string;
497
+ }
498
+
499
+ interface PostPlan {
500
+ credit: PostCredit;
501
+ entryId?: string;
502
+ entryTitle?: string;
503
+ /** Names of the bylines credited now. */
504
+ current: string[];
505
+ action: "credit" | "done" | "missing";
506
+ result?: string;
507
+ }
508
+
509
+ /** Look up what's on the site for each author (an existing byline with that name, the avatar in the media library) and each post (its current bylines). */
510
+ async function planBylines(groups: GuestByline[], credits: PostCredit[]): Promise<{ bylines: BylinePlan[]; posts: PostPlan[] }> {
511
+ const bylines: BylinePlan[] = [];
512
+ for (const byline of groups) {
513
+ const found = await getJson<{ items: BylineSummary[] }>(`${EMDASH}/admin/bylines?search=${encodeURIComponent(byline.name)}&limit=50`, "Could not list bylines");
514
+ const matches = found.items.filter((b) => sameName(b.displayName, byline.name));
515
+ const existing = matches.find((b) => b.isGuest) ?? matches[0] ?? null;
516
+ let avatarMediaId: string | null = existing?.avatarMediaId ?? null;
517
+ if (!avatarMediaId && byline.avatarFile) {
518
+ const media = await getJson<{ items: Array<{ id: string; filename: string }> }>(
519
+ `${EMDASH}/media?q=${encodeURIComponent(byline.avatarFile)}&limit=20`,
520
+ "Could not search the media library",
521
+ ).catch(() => ({ items: [] }));
522
+ avatarMediaId = media.items.find((m) => m.filename.toLowerCase() === byline.avatarFile.toLowerCase())?.id ?? null;
523
+ }
524
+ bylines.push({ byline, existing, avatarMediaId });
525
+ }
526
+ const idFor = (name: string) => bylines.find((b) => sameName(b.byline.name, name))?.existing?.id;
527
+ const posts: PostPlan[] = [];
528
+ for (const credit of credits) {
529
+ try {
530
+ const { item } = await getJson<{ item: { id: string; data?: Record<string, unknown>; bylines?: Array<{ byline: { id: string; displayName: string }; source?: string }> } }>(
531
+ `${EMDASH}/content/${encodeURIComponent(credit.collection)}/${encodeURIComponent(credit.slug)}`,
532
+ "Not found",
533
+ );
534
+ const explicit = (item.bylines ?? []).filter((c) => c.source !== "inferred").map((c) => c.byline.id);
535
+ const wanted = credit.names.map(idFor);
536
+ const done = wanted.every(Boolean) && wanted.length === explicit.length && wanted.every((id, i) => id === explicit[i]);
537
+ posts.push({
538
+ credit,
539
+ entryId: item.id,
540
+ entryTitle: typeof item.data?.title === "string" ? item.data.title : credit.title,
541
+ current: (item.bylines ?? []).map((c) => c.byline.displayName),
542
+ action: done ? "done" : "credit",
543
+ });
544
+ } catch {
545
+ posts.push({ credit, current: [], action: "missing" });
546
+ }
547
+ }
548
+ return { bylines, posts };
549
+ }
550
+
551
+ /** Create a guest byline, trying "-2", "-3", … if the slug is taken. */
552
+ async function createByline(plan: BylinePlan): Promise<BylineSummary> {
553
+ const b = plan.byline;
554
+ let lastError: unknown;
555
+ for (let n = 1; n <= 5; n++) {
556
+ try {
557
+ return await send<BylineSummary>(
558
+ `${EMDASH}/admin/bylines`,
559
+ "POST",
560
+ { slug: n === 1 ? b.slug : `${b.slug}-${n}`, displayName: b.name, bio: b.bio || null, websiteUrl: b.url || null, avatarMediaId: plan.avatarMediaId, isGuest: true },
561
+ "Could not create the byline",
562
+ );
563
+ } catch (error) {
564
+ lastError = error;
565
+ if (!/slug|exists|unique|conflict/i.test(String(error))) break;
566
+ }
567
+ }
568
+ throw lastError instanceof Error ? lastError : new Error("Could not create the byline");
569
+ }
570
+
571
+ function BylinesStep({ wxr, onWxr }: { wxr: WxrInfo | null; onWxr: (info: WxrInfo) => void }) {
572
+ const guests = wxr?.guests ?? null;
573
+ const [plan, setPlan] = React.useState<{ bylines: BylinePlan[]; posts: PostPlan[] } | null>(null);
574
+ const [running, setRunning] = React.useState<false | "dry" | "apply">(false);
575
+ const [applied, setApplied] = React.useState(false);
576
+ const [error, setError] = React.useState<string>();
577
+ const groups = React.useMemo(() => (guests ? groupGuests(guests) : []), [guests]);
578
+ const credits = React.useMemo(() => (guests ? postCredits(guests) : []), [guests]);
579
+
580
+ React.useEffect(() => {
581
+ setPlan(null);
582
+ setApplied(false);
583
+ }, [guests]);
584
+
585
+ const dryRun = async () => {
586
+ setRunning("dry");
587
+ setError(undefined);
588
+ setApplied(false);
589
+ try {
590
+ setPlan(await planBylines(groups, credits));
591
+ } catch (cause) {
592
+ setError(errorText(cause, "The dry run failed"));
593
+ } finally {
594
+ setRunning(false);
595
+ }
596
+ };
597
+
598
+ const apply = async () => {
599
+ if (!plan) return;
600
+ if (!window.confirm("Create the bylines and credit the posts listed? Each post's current bylines are replaced by its authors, in order.")) return;
601
+ setRunning("apply");
602
+ setError(undefined);
603
+ const bylines = plan.bylines.map((b) => ({ ...b }));
604
+ const posts = plan.posts.map((p) => ({ ...p }));
605
+ const needed = new Set(posts.filter((p) => p.action === "credit").flatMap((p) => p.credit.names.map((n) => n.toLowerCase())));
606
+ for (const b of bylines) {
607
+ if (b.existing || !needed.has(b.byline.name.toLowerCase())) continue;
608
+ try {
609
+ b.existing = await createByline(b);
610
+ b.result = `Created byline “${b.existing.displayName}” (${b.existing.slug})`;
611
+ } catch (cause) {
612
+ b.result = `Could not create the byline: ${errorText(cause, "unknown error")}`;
613
+ }
614
+ setPlan({ bylines: bylines.map((x) => ({ ...x })), posts });
615
+ }
616
+ const idFor = (name: string) => bylines.find((b) => sameName(b.byline.name, name))?.existing?.id;
617
+ for (const p of posts) {
618
+ if (p.action !== "credit" || !p.entryId) continue;
619
+ const ids = p.credit.names.map(idFor);
620
+ if (!ids.every(Boolean)) {
621
+ p.result = "Skipped: a byline couldn't be created";
622
+ continue;
623
+ }
624
+ try {
625
+ await send(`${EMDASH}/content/${encodeURIComponent(p.credit.collection)}/${encodeURIComponent(p.entryId)}`, "PUT", { bylines: ids.map((bylineId) => ({ bylineId })) }, "Could not update the entry");
626
+ p.action = "done";
627
+ p.result = "Credited";
628
+ } catch (cause) {
629
+ p.result = `Failed: ${errorText(cause, "unknown error")}`;
630
+ }
631
+ setPlan({ bylines, posts: posts.map((x) => ({ ...x })) });
632
+ }
633
+ setPlan({ bylines, posts });
634
+ setApplied(true);
635
+ setRunning(false);
636
+ };
637
+
638
+ const toCredit = plan?.posts.filter((p) => p.action === "credit").length ?? 0;
639
+ const toCreate = plan?.bylines.filter((b) => !b.existing).length ?? 0;
640
+
641
+ return (
642
+ <Section
643
+ title="3. Co-authors and guest authors"
644
+ description={
645
+ <>
646
+ EmDash's importer credits each post to its WordPress user only. Posts with co-authors or guest authors (Co-Authors Plus, PublishPress
647
+ Authors, or Coywolf Guest Author) get their authors back: this creates a guest byline for each author who has none (name, website, bio, and
648
+ avatar when it's in the media library) and credits each post to its authors in order, so the byline and author schema name them. It uses
649
+ EmDash's own byline and content API with your account, a dry run first. Running it again changes nothing.
650
+ </>
651
+ }
652
+ >
653
+ <div className="flex flex-wrap items-center gap-2">
654
+ {wxr === null && <ChooseExport disabled={Boolean(running)} onXml={(xml) => onWxr(readWxr(xml))} />}
655
+ <Button variant="secondary" icon={<MagnifyingGlass />} disabled={Boolean(running) || !credits.length} onClick={() => void dryRun()}>
656
+ {running === "dry" ? "Checking…" : "Dry run"}
657
+ </Button>
658
+ <Button variant="primary" icon={<UserPlus />} disabled={Boolean(running) || !plan || applied || (!toCredit && !toCreate)} onClick={() => void apply()}>
659
+ {running === "apply" ? "Crediting…" : "Create bylines and credit posts"}
660
+ </Button>
661
+ {running && <Loader size="sm" />}
662
+ </div>
663
+ {error && <Banner variant="error" role="alert" description={error} />}
664
+ {guests !== null && (
665
+ <p className="text-sm" role="status">
666
+ {credits.length
667
+ ? `${plural(credits.length, "post has", "posts have")} co-authors or guest authors (${plural(groups.length, "author", "authors")}).`
668
+ : "No co-authors or guest authors in this export."}
669
+ {plan && !applied ? ` Dry run: ${plural(toCreate, "byline", "bylines")} to create, ${plural(toCredit, "post", "posts")} to credit.` : ""}
670
+ </p>
671
+ )}
672
+ {plan && plan.bylines.length > 0 && (
673
+ <ul className="space-y-1 text-sm">
674
+ {plan.bylines.map((b) => (
675
+ <li key={b.byline.slug}>
676
+ <strong>{b.byline.name}</strong>
677
+ <span className="text-xs text-kumo-subtle">
678
+ {" "}
679
+ · {b.existing ? "Byline exists" : "New guest byline"}
680
+ {b.byline.url ? ` · ${b.byline.url}` : ""}
681
+ {b.byline.avatarFile ? (b.avatarMediaId ? " · avatar found" : ` · avatar ${b.byline.avatarFile} not in the media library`) : ""}
682
+ </span>
683
+ {b.result && <span className="block text-xs">{b.result}</span>}
684
+ </li>
685
+ ))}
686
+ </ul>
687
+ )}
688
+ {plan && plan.posts.length > 0 && (
689
+ <div className="overflow-x-auto">
690
+ <table className="w-full text-left text-sm">
691
+ <thead className="text-kumo-subtle">
692
+ <tr>
693
+ <th className="py-1 pe-3 font-medium">Post</th>
694
+ <th className="py-1 pe-3 font-medium">Byline now</th>
695
+ <th className="py-1 font-medium">{applied ? "Result" : "Will"}</th>
696
+ </tr>
697
+ </thead>
698
+ <tbody>
699
+ {plan.posts.map((p) => (
700
+ <tr key={`${p.credit.collection}/${p.credit.slug}`} className="border-t border-kumo-line align-top">
701
+ <td className="py-1 pe-3">
702
+ {p.entryId ? (
703
+ <a className="text-kumo-link underline" href={`/_emdash/admin/content/${encodeURIComponent(p.credit.collection)}/${encodeURIComponent(p.entryId)}`}>
704
+ {p.entryTitle || p.credit.title || p.credit.slug}
705
+ </a>
706
+ ) : (
707
+ p.credit.title || p.credit.slug
708
+ )}
709
+ </td>
710
+ <td className="py-1 pe-3 text-xs">{p.current.join(", ") || "—"}</td>
711
+ <td className="py-1 text-xs">
712
+ {p.result ??
713
+ (p.action === "done"
714
+ ? "Already credited"
715
+ : p.action === "missing"
716
+ ? `Not found: no ${p.credit.collection} entry with the slug “${p.credit.slug}”`
717
+ : `Credit ${p.credit.names.join(", ")}`)}
718
+ </td>
719
+ </tr>
720
+ ))}
721
+ </tbody>
722
+ </table>
723
+ </div>
724
+ )}
725
+ </Section>
726
+ );
727
+ }
728
+
729
+ // ── 4. Category and page parents ─────────────────────────────────
730
+
731
+ function ParentsStep({ wxr, onWxr }: { wxr: WxrInfo | null; onWxr: (info: WxrInfo) => void }) {
732
+ const parents = wxr?.parents ?? null;
733
+ const [plans, setPlans] = React.useState<ParentPlan[] | null>(null);
734
+ const [running, setRunning] = React.useState<false | "dry" | "apply">(false);
735
+ const [applied, setApplied] = React.useState(false);
736
+ const [error, setError] = React.useState<string>();
737
+ const pageMap = React.useMemo(() => (parents ? parentsMap(parents.pages) : {}), [parents]);
738
+ const withParent = React.useMemo(() => (parents ? parents.categories.filter((c) => c.parent && c.parent !== c.slug).length : 0), [parents]);
739
+
740
+ React.useEffect(() => {
741
+ setPlans(null);
742
+ setApplied(false);
743
+ }, [parents]);
744
+
745
+ const dryRun = async () => {
746
+ if (!parents) return;
747
+ setRunning("dry");
748
+ setError(undefined);
749
+ setApplied(false);
750
+ try {
751
+ const { terms } = await getJson<{ terms: SiteTerm[] }>(`${EMDASH}/taxonomies/category/terms?includeCounts=false`, "Could not list the categories");
752
+ setPlans(planCategoryParents(parents.categories, terms));
753
+ } catch (cause) {
754
+ setError(errorText(cause, "The dry run failed"));
755
+ } finally {
756
+ setRunning(false);
757
+ }
758
+ };
759
+
760
+ const apply = async () => {
761
+ if (!plans) return;
762
+ if (!window.confirm("Set the parent of each category listed as “Set”? Category archive and post URLs that include parent categories change to match WordPress.")) return;
763
+ setRunning("apply");
764
+ setError(undefined);
765
+ const next = plans.map((p) => ({ ...p }));
766
+ for (const plan of next) {
767
+ if (plan.action !== "set" || !plan.parentTermId) continue;
768
+ try {
769
+ await send(`${EMDASH}/taxonomies/category/terms/${encodeURIComponent(plan.slug)}`, "PUT", { parentId: plan.parentTermId }, "Could not update the category");
770
+ plan.action = "done";
771
+ plan.current = plan.parent;
772
+ plan.result = "Parent set";
773
+ } catch (cause) {
774
+ plan.result = `Failed: ${errorText(cause, "unknown error")}`;
775
+ }
776
+ setPlans(next.map((p) => ({ ...p })));
777
+ }
778
+ setPlans(next);
779
+ setApplied(true);
780
+ setRunning(false);
781
+ };
782
+
783
+ const toSet = plans?.filter((p) => p.action === "set").length ?? 0;
784
+ const pageCount = Object.keys(pageMap).length;
785
+
786
+ return (
787
+ <Section
788
+ title="4. Category and page parents"
789
+ description={
790
+ <>
791
+ EmDash's importer creates every category at the top level and drops page parents, so WordPress URLs with parent categories
792
+ (<Code>/news/local/a-post/</Code>) or parent pages (<Code>/about/team/</Code>) are lost. This sets each category's parent the way WordPress had
793
+ it, with EmDash's own taxonomy API and your account, a dry run first. Running it again changes nothing. EmDash pages have no parent, so page
794
+ parents are listed as a <Code>pageParents</Code> option to paste into <Code>coywolfPlugin()</Code> (see docs/content-urls.md in the Coywolf Pack repository).
795
+ </>
796
+ }
797
+ >
798
+ <div className="flex flex-wrap items-center gap-2">
799
+ {wxr === null && <ChooseExport disabled={Boolean(running)} onXml={(xml) => onWxr(readWxr(xml))} />}
800
+ <Button variant="secondary" icon={<MagnifyingGlass />} disabled={Boolean(running) || !withParent} onClick={() => void dryRun()}>
801
+ {running === "dry" ? "Checking…" : "Dry run"}
802
+ </Button>
803
+ <Button variant="primary" icon={<TreeStructure />} disabled={Boolean(running) || !plans || applied || !toSet} onClick={() => void apply()}>
804
+ {running === "apply" ? "Setting parents…" : "Set category parents"}
805
+ </Button>
806
+ {running && <Loader size="sm" />}
807
+ </div>
808
+ {error && <Banner variant="error" role="alert" description={error} />}
809
+ {parents !== null && (
810
+ <p className="text-sm" role="status">
811
+ {withParent ? `${plural(withParent, "category has", "categories have")} a parent in WordPress.` : "No categories with a parent in this export."}
812
+ {plans && !applied ? ` Dry run: ${toSet} to set.` : ""}
813
+ {applied ? " Done." : ""}
814
+ </p>
815
+ )}
816
+ {plans && plans.length > 0 && (
817
+ <div className="overflow-x-auto">
818
+ <table className="w-full text-left text-sm">
819
+ <thead className="text-kumo-subtle">
820
+ <tr>
821
+ <th className="py-1 pe-3 font-medium">Category</th>
822
+ <th className="py-1 pe-3 font-medium">Parent in WordPress</th>
823
+ <th className="py-1 pe-3 font-medium">Parent now</th>
824
+ <th className="py-1 font-medium">{applied ? "Result" : "Will"}</th>
825
+ </tr>
826
+ </thead>
827
+ <tbody>
828
+ {plans.map((p) => (
829
+ <tr key={p.slug} className="border-t border-kumo-line align-top">
830
+ <td className="py-1 pe-3">
831
+ {p.name}
832
+ <span className="block font-mono text-xs text-kumo-subtle">{p.slug}</span>
833
+ </td>
834
+ <td className="py-1 pe-3 font-mono text-xs">{p.parent}</td>
835
+ <td className="py-1 pe-3 font-mono text-xs">{p.current || "—"}</td>
836
+ <td className="py-1 text-xs">
837
+ {p.result ?? (p.action === "done" ? "Already set" : p.action === "missing" ? (p.note ?? "Not found") : p.current ? `Change to ${p.parent}` : `Set to ${p.parent}`)}
838
+ </td>
839
+ </tr>
840
+ ))}
841
+ </tbody>
842
+ </table>
843
+ </div>
844
+ )}
845
+ {parents !== null && (
846
+ <div className="space-y-1">
847
+ <h3 className="text-sm font-medium">Page parents</h3>
848
+ {pageCount ? (
849
+ <>
850
+ <p className="text-xs text-kumo-subtle">
851
+ {plural(pageCount, "page has", "pages have")} a parent page. Add this to <Code>coywolfPlugin()</Code> in astro.config.mjs and use{" "}
852
+ <Code>{"{pagepath}"}</Code> in the pages URL pattern, e.g. <Code>{'urls: { pages: "/{pagepath}/" }'}</Code>:
853
+ </p>
854
+ <pre className="overflow-x-auto rounded bg-kumo-tint p-2 font-mono text-xs">{optionSnippet("pageParents", pageMap)}</pre>
855
+ </>
856
+ ) : (
857
+ <p className="text-xs text-kumo-subtle">No pages with a parent page in this export.</p>
858
+ )}
859
+ </div>
860
+ )}
861
+ </Section>
862
+ );
863
+ }
864
+
865
+ // ── 5. Old-site media URLs ───────────────────────────────────────
866
+
867
+ interface RedirectRuleOut {
868
+ source: string;
869
+ target: string;
870
+ type: number;
871
+ isRegex: boolean;
872
+ note?: string;
873
+ }
874
+
875
+ /** Add rules through the Redirects module's import route, 5,000 at a time. */
876
+ async function addRedirects(rules: RedirectRuleOut[]): Promise<number> {
877
+ let added = 0;
878
+ for (let i = 0; i < rules.length; i += 5000) {
879
+ const r = await send<{ imported: number }>(
880
+ "/_emdash/api/plugins/coywolf-pack/redirects/import",
881
+ "POST",
882
+ { rules: rules.slice(i, i + 5000) },
883
+ "Could not add the redirects (is the Redirects module on?)",
884
+ );
885
+ added += r.imported;
886
+ }
887
+ return added;
888
+ }
889
+
890
+ /** The file name at the end of a URL, decoded when it can be. */
891
+ function fileName(url: string): string {
892
+ const last = url.split(/[?#]/)[0]?.split("/").pop() ?? "";
893
+ try {
894
+ return decodeURIComponent(last);
895
+ } catch {
896
+ return last;
897
+ }
898
+ }
899
+
900
+ const STATUS_LABEL: Record<SiteUrlPlan["status"], string> = { matched: "In the media library", missing: "Not in the media library", ambiguous: "Several files fit" };
901
+
902
+ function MediaUrlsStep({ wxr, onWxr }: { wxr: WxrInfo | null; onWxr: (info: WxrInfo) => void }) {
903
+ const [hosts, setHosts] = React.useState("");
904
+ const [found, setFound] = React.useState<Map<string, number> | null>(null);
905
+ const [media, setMedia] = React.useState<MediaFile[] | null>(null);
906
+ const [known, setKnown] = React.useState<Record<string, string>>({});
907
+ const [running, setRunning] = React.useState<false | "find" | "import" | "redirects">(false);
908
+ const [progress, setProgress] = React.useState("");
909
+ const [message, setMessage] = React.useState("");
910
+ const [error, setError] = React.useState<string>();
911
+
912
+ React.useEffect(() => {
913
+ if (wxr?.site.hosts.length) setHosts(wxr.site.hosts.join(", "));
914
+ }, [wxr]);
915
+
916
+ const hostList = React.useMemo(() => parseHosts(hosts), [hosts]);
917
+ const plans = React.useMemo(() => {
918
+ if (!found || !media) return null;
919
+ const all = new Map(found);
920
+ // Attachments not used in content still get a redirect (links from other sites, image search).
921
+ for (const url of wxr?.attachmentUrls ?? []) if (!all.has(url)) all.set(url, 0);
922
+ return planSiteUrls(all, media, wxr?.attachmentUrls ?? null, known);
923
+ }, [found, media, known, wxr]);
924
+ const urlMap = React.useMemo(() => (plans ? rewriteMap(plans.filter((p) => p.count > 0)) : null), [plans]);
925
+ const counts = { matched: 0, missing: 0, ambiguous: 0 };
926
+ for (const p of plans ?? []) counts[p.status]++;
927
+ const missing = plans?.filter((p) => p.status === "missing") ?? [];
928
+ const origin = wxr?.site.homeUrl || wxr?.site.siteUrl || (hostList[0] ? `https://${hostList[0]}` : "");
929
+
930
+ const find = async () => {
931
+ setRunning("find");
932
+ setError(undefined);
933
+ setMessage("");
934
+ try {
935
+ const urls = new Map<string, number>();
936
+ let state: unknown = null;
937
+ for (;;) {
938
+ const page = await post<{ done: boolean; scanned: number; urls: Record<string, number>; state: unknown; progress: string }>(
939
+ "urls",
940
+ { hosts: hostList, prefixes: wxr?.site.prefixes ?? [], state },
941
+ "The search failed",
942
+ );
943
+ for (const [u, n] of Object.entries(page.urls)) urls.set(u, (urls.get(u) ?? 0) + n);
944
+ setProgress(`${page.progress}: ${plural(urls.size, "URL", "URLs")} found`);
945
+ if (page.done) break;
946
+ state = page.state;
947
+ }
948
+ const files: MediaFile[] = [];
949
+ let cursor: string | undefined;
950
+ for (let n = 0; n < 500; n++) {
951
+ const page = await getJson<{ items: MediaFile[]; nextCursor?: string | null }>(
952
+ `${EMDASH}/media?limit=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`,
953
+ "Could not list the media library",
954
+ );
955
+ files.push(...page.items.map((m) => ({ id: m.id, filename: m.filename, url: m.url, storageKey: m.storageKey })));
956
+ setProgress(`Reading the media library: ${plural(files.length, "file", "files")}`);
957
+ if (!page.nextCursor) break;
958
+ cursor = page.nextCursor;
959
+ }
960
+ setFound(urls);
961
+ setMedia(files);
962
+ } catch (cause) {
963
+ setError(errorText(cause, "The search failed"));
964
+ } finally {
965
+ setRunning(false);
966
+ setProgress("");
967
+ }
968
+ };
969
+
970
+ const importMissing = async () => {
971
+ if (!missing.length || !origin) return;
972
+ if (!window.confirm(`Download ${plural(missing.length, "file", "files")} from the old site into the media library? The old site must still be online.`)) return;
973
+ setRunning("import");
974
+ setError(undefined);
975
+ setMessage("");
976
+ const next = { ...known };
977
+ let imported = 0;
978
+ const failed: string[] = [];
979
+ try {
980
+ const batch = missing.map((p, i) => ({ plan: p, id: i + 1, url: sourceUrl(p.url, origin) })).filter((x) => x.url);
981
+ for (let i = 0; i < batch.length; i += 10) {
982
+ const chunk = batch.slice(i, i + 10);
983
+ const r = await send<{ imported: Array<{ originalUrl: string; newUrl: string }>; failed: Array<{ originalUrl: string; error: string }> }>(
984
+ `${EMDASH}/import/wordpress/media`,
985
+ "POST",
986
+ { stream: false, attachments: chunk.map((x) => ({ id: x.id, url: x.url, filename: fileName(x.url as string) })) },
987
+ "The media import failed",
988
+ );
989
+ for (const ok of r.imported) {
990
+ const item = chunk.find((x) => x.url === ok.originalUrl);
991
+ if (item) {
992
+ next[item.plan.path] = ok.newUrl;
993
+ imported++;
994
+ }
995
+ }
996
+ for (const bad of r.failed) failed.push(`${bad.originalUrl} (${bad.error})`);
997
+ setProgress(`Imported ${imported} of ${batch.length}`);
998
+ setKnown({ ...next });
999
+ }
1000
+ setMessage(`Imported ${plural(imported, "file", "files")}.${failed.length ? ` ${failed.length} failed: ${failed.slice(0, 10).join("; ")}${failed.length > 10 ? "; …" : ""}` : ""}`);
1001
+ } catch (cause) {
1002
+ setError(errorText(cause, "The media import failed"));
1003
+ } finally {
1004
+ setKnown(next);
1005
+ setRunning(false);
1006
+ setProgress("");
1007
+ }
1008
+ };
1009
+
1010
+ const redirects = plans ? uploadRedirects(plans) : [];
1011
+ const addMediaRedirects = async () => {
1012
+ setRunning("redirects");
1013
+ setError(undefined);
1014
+ setMessage("");
1015
+ try {
1016
+ const added = await addRedirects(redirects);
1017
+ setMessage(`Added or updated ${plural(added, "redirect", "redirects")}.`);
1018
+ } catch (cause) {
1019
+ setError(errorText(cause, "Could not add the redirects"));
1020
+ } finally {
1021
+ setRunning(false);
1022
+ }
1023
+ };
1024
+
1025
+ const shown = (plans ?? []).filter((p) => p.status !== "matched" || p.count > 0);
1026
+
1027
+ return (
1028
+ <Section
1029
+ title="5. Old-site media URLs"
1030
+ description={
1031
+ <>
1032
+ EmDash's importer points image blocks at the media library, but URLs inside HTML blocks, links to files, other blocks' fields (like a
1033
+ testimonial photo or a video poster), size variants of large images (<Code>-1024x683</Code>, <Code>-scaled</Code>), files that were never in
1034
+ the media library, and theme or plugin files still point at the old site's <Code>/wp-content/</Code>. This finds them, matches each to a
1035
+ media library file (size variants to their original), downloads the missing ones from the old site with EmDash's media importer, rewrites
1036
+ content (a dry run first), and adds redirects from the old URLs.
1037
+ </>
1038
+ }
1039
+ >
1040
+ <div className="max-w-xl space-y-1">
1041
+ <Input label="Old site host names" placeholder="example.com, www.example.com" value={hosts} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setHosts(e.target.value)} />
1042
+ <p className="text-xs text-kumo-subtle">
1043
+ Filled in from the export. Add any other host the site's media was served from (a CDN host name). Site-relative <Code>/wp-content/</Code>{" "}
1044
+ URLs are always found.
1045
+ </p>
1046
+ </div>
1047
+ <div className="flex flex-wrap items-center gap-2">
1048
+ {wxr === null && <ChooseExport disabled={Boolean(running)} onXml={(xml) => onWxr(readWxr(xml))} />}
1049
+ <Button variant="secondary" icon={<MagnifyingGlass />} disabled={Boolean(running)} onClick={() => void find()}>
1050
+ {running === "find" ? "Searching…" : "Find old URLs"}
1051
+ </Button>
1052
+ <Button variant="secondary" icon={<ImageSquare />} disabled={Boolean(running) || !missing.length || !origin} onClick={() => void importMissing()}>
1053
+ {running === "import" ? "Importing…" : `Import missing files${missing.length ? ` (${missing.length})` : ""}`}
1054
+ </Button>
1055
+ {running && <Loader size="sm" />}
1056
+ </div>
1057
+ <p className="sr-only" role="status" aria-live="polite">
1058
+ {progress}
1059
+ </p>
1060
+ {progress && <p className="text-sm text-kumo-subtle">{progress}</p>}
1061
+ {error && <Banner variant="error" role="alert" description={error} />}
1062
+ {message && (
1063
+ <p className="text-sm" role="status">
1064
+ {message}
1065
+ </p>
1066
+ )}
1067
+ {wxr === null && plans && (
1068
+ <p className="text-xs text-kumo-subtle">Without the export, uploads match by file name only, and unused attachments get no redirect.</p>
1069
+ )}
1070
+ {plans && (
1071
+ <div className="space-y-4">
1072
+ <p className="text-sm">
1073
+ {plural(found?.size ?? 0, "old URL", "old URLs")} in content. {counts.matched.toLocaleString()} in the media library, {counts.missing.toLocaleString()}{" "}
1074
+ not there yet, {counts.ambiguous.toLocaleString()} with several possible files (pick those in the editor).
1075
+ </p>
1076
+ {shown.length > 0 && (
1077
+ <details>
1078
+ <summary className="cursor-pointer text-sm font-medium">URLs ({shown.length.toLocaleString()})</summary>
1079
+ <div className="mt-2 max-h-96 cw-overflow-auto">
1080
+ <table className="w-full text-left text-sm">
1081
+ <thead className="text-kumo-subtle">
1082
+ <tr>
1083
+ <th className="py-1 pe-3 font-medium">URL</th>
1084
+ <th className="py-1 pe-3 text-end font-medium">Uses</th>
1085
+ <th className="py-1 font-medium">Status</th>
1086
+ </tr>
1087
+ </thead>
1088
+ <tbody>
1089
+ {shown.slice(0, 2000).map((p) => (
1090
+ <tr key={p.url} className="border-t border-kumo-line align-top">
1091
+ <td className="py-1 pe-3 font-mono text-xs break-all">{p.url}</td>
1092
+ <td className="py-1 pe-3 text-end tabular-nums">{p.count}</td>
1093
+ <td className="py-1 text-xs">
1094
+ {STATUS_LABEL[p.status]}
1095
+ {p.note ? ` · ${p.note}` : ""}
1096
+ </td>
1097
+ </tr>
1098
+ ))}
1099
+ </tbody>
1100
+ </table>
1101
+ </div>
1102
+ </details>
1103
+ )}
1104
+ <div className="space-y-2">
1105
+ <h3 className="text-sm font-medium">Rewrite URLs in content</h3>
1106
+ <p className="text-xs text-kumo-subtle">
1107
+ {urlMap && Object.keys(urlMap).length
1108
+ ? `Points ${plural(Object.keys(urlMap).length, "URL", "URLs")} at the media library, in every entry's content and text fields. It also converts anything step 2 would.`
1109
+ : "Nothing to rewrite yet: find old URLs (and import missing files) first."}
1110
+ </p>
1111
+ {urlMap && Object.keys(urlMap).length > 0 && (
1112
+ <ScanPanel
1113
+ urlMap={urlMap}
1114
+ applyLabel="Rewrite URLs"
1115
+ confirmText="Rewrite the old URLs listed in the dry run? Each entry is saved (published entries are republished). Make a backup first."
1116
+ />
1117
+ )}
1118
+ </div>
1119
+ <div className="space-y-2">
1120
+ <h3 className="text-sm font-medium">Redirects from old media URLs</h3>
1121
+ <p className="text-xs text-kumo-subtle">
1122
+ {plural(redirects.length, "old file URL", "old file URLs")} (with the old site's paths) can redirect to their media library files, for
1123
+ links from other sites and image search. They're added to the Redirects module, which handles file paths.
1124
+ </p>
1125
+ <div className="flex flex-wrap gap-2">
1126
+ <Button variant="secondary" icon={<ArrowsLeftRight />} disabled={Boolean(running) || !redirects.length} onClick={() => void addMediaRedirects()}>
1127
+ {running === "redirects" ? "Adding…" : "Add to Redirects"}
1128
+ </Button>
1129
+ <Button
1130
+ variant="secondary"
1131
+ icon={<DownloadSimple />}
1132
+ disabled={!redirects.length}
1133
+ onClick={() => saveFile(`${JSON.stringify(redirects, null, "\t")}\n`, "wordpress-media-redirects.json", "application/json")}
1134
+ >
1135
+ Download JSON
1136
+ </Button>
1137
+ </div>
1138
+ </div>
1139
+ </div>
1140
+ )}
1141
+ </Section>
1142
+ );
1143
+ }
1144
+
1145
+ // ── 6. Redirects ─────────────────────────────────────────────────
1146
+
1147
+ const REDIRECT_SOURCES = [
1148
+ {
1149
+ key: "redirection",
1150
+ label: "Redirection plugin",
1151
+ command: 'wp db query "SELECT url, action_data, action_code, action_type, match_type, regex, status FROM wp_redirection_items"',
1152
+ },
1153
+ { key: "rankMath", label: "Rank Math", command: 'wp db query "SELECT sources, url_to, header_code, status FROM wp_rank_math_redirections"' },
1154
+ { key: "yoast", label: "Yoast SEO Premium (JSON)", command: "wp option get wpseo-premium-redirects-base --format=json" },
1155
+ { key: "coywolfSeo", label: "Coywolf SEO", command: 'wp db query "SELECT source, target, type, is_regex FROM wp_coywolf_seo_redirects"' },
1156
+ ] as const;
1157
+
1158
+ type RedirectSourceKey = (typeof REDIRECT_SOURCES)[number]["key"];
1159
+
1160
+ function RedirectsStep({ wxr, onWxr }: { wxr: WxrInfo | null; onWxr: (info: WxrInfo) => void }) {
1161
+ const [fields, setFields] = React.useState<Record<RedirectSourceKey, string>>({ redirection: "", rankMath: "", yoast: "", coywolfSeo: "" });
1162
+ const [built, setBuilt] = React.useState<{ rules: WpRedirect[]; skipped: RedirectsResult["skipped"] } | null>(null);
1163
+ const [running, setRunning] = React.useState(false);
1164
+ const [message, setMessage] = React.useState("");
1165
+ const [error, setError] = React.useState<string>();
1166
+
1167
+ React.useEffect(() => setBuilt(null), [wxr, fields]);
1168
+
1169
+ const build = () => {
1170
+ setError(undefined);
1171
+ setMessage("");
1172
+ try {
1173
+ const hosts = wxr?.site.hosts ?? [];
1174
+ const rows = (key: RedirectSourceKey) => (fields[key].trim() ? parseFileRows(fields[key]) : []);
1175
+ const parts: RedirectsResult[] = [wxr?.oldSlugs ?? { rules: [], skipped: [] }];
1176
+ parts.push(redirectionRules(rows("redirection"), hosts));
1177
+ parts.push(rankMathRules(rows("rankMath"), hosts));
1178
+ if (fields.yoast.trim()) {
1179
+ let value: unknown;
1180
+ try {
1181
+ value = JSON.parse(fields.yoast);
1182
+ } catch {
1183
+ throw new Error("The Yoast redirects aren't valid JSON.");
1184
+ }
1185
+ parts.push(yoastRules(value, hosts));
1186
+ }
1187
+ parts.push(coywolfSeoRules(rows("coywolfSeo"), hosts));
1188
+ // Rules from the plugins win over old slugs (a person set them).
1189
+ setBuilt({ rules: mergeRedirects(...parts.slice(1).map((p) => p.rules), parts[0]?.rules ?? []), skipped: parts.flatMap((p) => p.skipped) });
1190
+ } catch (cause) {
1191
+ setError(errorText(cause, "Could not read the redirects"));
1192
+ }
1193
+ };
1194
+
1195
+ const add = async () => {
1196
+ if (!built) return;
1197
+ setRunning(true);
1198
+ setError(undefined);
1199
+ try {
1200
+ const added = await addRedirects(built.rules);
1201
+ setMessage(`Added or updated ${plural(added, "redirect", "redirects")}.`);
1202
+ } catch (cause) {
1203
+ setError(errorText(cause, "Could not add the redirects"));
1204
+ } finally {
1205
+ setRunning(false);
1206
+ }
1207
+ };
1208
+
1209
+ return (
1210
+ <Section
1211
+ title="6. Redirects from WordPress"
1212
+ description={
1213
+ <>
1214
+ WordPress redirects a post's old slugs by itself, and redirect plugins keep their rules in the database; none of it comes over in the export.
1215
+ This turns old slugs (from the export) and the rules of Redirection, Rank Math, Yoast SEO Premium or Coywolf SEO (paste the output of the
1216
+ command under each box, run on the WordPress server with WP-CLI; change <Code>wp_</Code> if your tables use another prefix) into Redirects
1217
+ module rules. Old-slug redirects point at the URLs WordPress used: if your EmDash URLs differ, fix them on the Redirects page.
1218
+ </>
1219
+ }
1220
+ >
1221
+ <div className="flex flex-wrap items-center gap-2">
1222
+ {wxr === null && <ChooseExport label="Choose export file (old slugs)" onXml={(xml) => onWxr(readWxr(xml))} />}
1223
+ {wxr !== null && <p className="text-sm">{plural(wxr.oldSlugs.rules.length, "old-slug redirect", "old-slug redirects")} in the export.</p>}
1224
+ </div>
1225
+ <div className="grid gap-4 lg:grid-cols-2">
1226
+ {REDIRECT_SOURCES.map((s) => (
1227
+ <div key={s.key} className="space-y-1">
1228
+ <InputArea
1229
+ label={s.label}
1230
+ rows={3}
1231
+ className="font-mono text-xs"
1232
+ value={fields[s.key]}
1233
+ onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setFields((f) => ({ ...f, [s.key]: e.target.value }))}
1234
+ />
1235
+ <p className="text-xs text-kumo-subtle">
1236
+ <Code>{s.command}</Code>
1237
+ </p>
1238
+ </div>
1239
+ ))}
1240
+ </div>
1241
+ <div className="flex flex-wrap items-center gap-2">
1242
+ <Button variant="secondary" icon={<MagnifyingGlass />} disabled={running} onClick={build}>
1243
+ Build rules
1244
+ </Button>
1245
+ <Button variant="primary" icon={<ArrowsLeftRight />} disabled={running || !built?.rules.length} onClick={() => void add()}>
1246
+ {running ? "Adding…" : "Add to Redirects"}
1247
+ </Button>
1248
+ <Button
1249
+ variant="secondary"
1250
+ icon={<DownloadSimple />}
1251
+ disabled={!built?.rules.length}
1252
+ onClick={() => built && saveFile(`${JSON.stringify(built.rules, null, "\t")}\n`, "wordpress-redirects.json", "application/json")}
1253
+ >
1254
+ Download JSON
1255
+ </Button>
1256
+ {running && <Loader size="sm" />}
1257
+ </div>
1258
+ {error && <Banner variant="error" role="alert" description={error} />}
1259
+ {message && (
1260
+ <p className="text-sm" role="status">
1261
+ {message}
1262
+ </p>
1263
+ )}
1264
+ {built && (
1265
+ <div className="space-y-3">
1266
+ <p className="text-sm" role="status">
1267
+ {plural(built.rules.length, "rule", "rules")} ready.{built.skipped.length ? ` ${plural(built.skipped.length, "rule was", "rules were")} skipped (listed below).` : ""}
1268
+ </p>
1269
+ {built.rules.length > 0 && (
1270
+ <div className="max-h-96 cw-overflow-auto">
1271
+ <table className="w-full text-left text-sm">
1272
+ <thead className="text-kumo-subtle">
1273
+ <tr>
1274
+ <th className="py-1 pe-3 font-medium">From</th>
1275
+ <th className="py-1 pe-3 font-medium">To</th>
1276
+ <th className="py-1 pe-3 font-medium">Type</th>
1277
+ <th className="py-1 font-medium">Source</th>
1278
+ </tr>
1279
+ </thead>
1280
+ <tbody>
1281
+ {built.rules.slice(0, 2000).map((r) => (
1282
+ <tr key={`${r.isRegex}:${r.source}`} className="border-t border-kumo-line align-top">
1283
+ <td className="py-1 pe-3 font-mono text-xs break-all">
1284
+ {r.source}
1285
+ {r.isRegex ? " (pattern)" : ""}
1286
+ </td>
1287
+ <td className="py-1 pe-3 font-mono text-xs break-all">{r.target || "—"}</td>
1288
+ <td className="py-1 pe-3 tabular-nums">{r.type}</td>
1289
+ <td className="py-1 text-xs">{r.note}</td>
1290
+ </tr>
1291
+ ))}
1292
+ </tbody>
1293
+ </table>
1294
+ </div>
1295
+ )}
1296
+ {built.skipped.length > 0 && (
1297
+ <ul className="list-disc space-y-0.5 ps-5 text-xs text-kumo-subtle">
1298
+ {built.skipped.slice(0, 200).map((s, i) => (
1299
+ <li key={`${i}:${s.source}`}>
1300
+ <span className="font-mono">{s.source}</span>: {s.reason}
1301
+ </li>
1302
+ ))}
1303
+ </ul>
1304
+ )}
1305
+ </div>
1306
+ )}
1307
+ </Section>
1308
+ );
1309
+ }
1310
+
1311
+ // ── Coywolf WordPress plugins ────────────────────────────────────
1312
+
1313
+ function DefaultsStep() {
1314
+ const [cvm, setCvm] = React.useState("");
1315
+ const [files, setFiles] = React.useState("");
1316
+ const [saved, setSaved] = React.useState<unknown>(null);
1317
+ const [error, setError] = React.useState<string>();
1318
+ const [busy, setBusy] = React.useState(false);
1319
+
1320
+ React.useEffect(() => {
1321
+ void apiFetch(`${API}/settings`)
1322
+ .then((r) => parseApiResponse<{ defaults: unknown }>(r, "Could not load the settings"))
1323
+ .then((d) => setSaved(d.defaults))
1324
+ .catch(() => setSaved(null));
1325
+ }, []);
1326
+
1327
+ const save = async () => {
1328
+ setBusy(true);
1329
+ setError(undefined);
1330
+ try {
1331
+ const parse = (text: string, label: string) => {
1332
+ if (!text.trim()) return undefined;
1333
+ try {
1334
+ return JSON.parse(text) as unknown;
1335
+ } catch {
1336
+ throw new Error(`The ${label} isn't valid JSON.`);
1337
+ }
1338
+ };
1339
+ const d = await post<{ defaults: unknown }>("settings/save", { cvm: parse(cvm, "Video Manager settings"), files: parse(files, "Coywolf Files settings") }, "Could not save");
1340
+ setSaved(d.defaults);
1341
+ } catch (cause) {
1342
+ setError(errorText(cause, "Could not save"));
1343
+ } finally {
1344
+ setBusy(false);
1345
+ }
1346
+ };
1347
+
1348
+ return (
1349
+ <Section
1350
+ title="A. Player and download card defaults (before importing)"
1351
+ description="Video Manager and Coywolf Files blocks that didn't set an option used the plugin's settings. Paste them before importing so converted blocks look the same. Without them, the plugins' own defaults are used."
1352
+ >
1353
+ <div className="grid gap-4 lg:grid-cols-2">
1354
+ <div className="space-y-1">
1355
+ <InputArea label="Video Manager settings (JSON)" rows={4} className="font-mono text-xs" value={cvm} onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setCvm(e.target.value)} />
1356
+ <p className="text-xs text-kumo-subtle">
1357
+ <Code>wp option get coywolf_cvm_settings --format=json</Code>
1358
+ </p>
1359
+ </div>
1360
+ <div className="space-y-1">
1361
+ <InputArea label="Coywolf Files settings (JSON)" rows={4} className="font-mono text-xs" value={files} onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setFiles(e.target.value)} />
1362
+ <p className="text-xs text-kumo-subtle">
1363
+ <Code>wp option get coywolf_files_settings --format=json</Code>
1364
+ </p>
1365
+ </div>
1366
+ </div>
1367
+ <div className="flex items-center gap-3">
1368
+ <Button variant="secondary" disabled={busy || (!cvm.trim() && !files.trim())} onClick={() => void save()}>
1369
+ Save defaults
1370
+ </Button>
1371
+ {saved !== null && <span className="text-xs text-kumo-subtle">Saved: {JSON.stringify(saved)}</span>}
1372
+ </div>
1373
+ {error && <Banner variant="error" role="alert" description={error} />}
1374
+ </Section>
1375
+ );
1376
+ }
1377
+
1378
+ const FILES_QUERY = 'wp db query "SELECT file_id, object_key, filename, mime, size, downloads, created FROM wp_coywolf_files"';
1379
+
1380
+ function FilesStep() {
1381
+ const [text, setText] = React.useState("");
1382
+ const [busy, setBusy] = React.useState(false);
1383
+ const [message, setMessage] = React.useState("");
1384
+ const [error, setError] = React.useState<string>();
1385
+ const submit = async () => {
1386
+ setBusy(true);
1387
+ setError(undefined);
1388
+ setMessage("");
1389
+ try {
1390
+ const r = await post<{ added: number; errors: Array<{ row: number; error: string }> }>("files", { text }, "Import failed");
1391
+ setMessage(`Added ${plural(r.added, "file", "files")}.${r.errors.length ? ` Skipped ${r.errors.length}: ${r.errors.map((e) => `row ${e.row} (${e.error})`).join("; ")}` : ""}`);
1392
+ } catch (cause) {
1393
+ setError(errorText(cause, "Import failed"));
1394
+ } finally {
1395
+ setBusy(false);
1396
+ }
1397
+ };
1398
+ return (
1399
+ <Section
1400
+ title="B. Coywolf Files downloads"
1401
+ description={
1402
+ <>
1403
+ Registers files uploaded with Coywolf Files so File download blocks and old download links keep working (they keep their WordPress ids; set
1404
+ the download URL base on Files → Settings to the old link base, e.g. <Code>coywolf-file</Code>). The files themselves must be in the bucket
1405
+ bound as <Code>FILES</Code> (or <Code>MEDIA</Code>) under the same keys. Paste the output of (change <Code>wp_</Code> if your tables use
1406
+ another prefix):
1407
+ <span className="mt-1 block">
1408
+ <Code>{FILES_QUERY}</Code>
1409
+ </span>
1410
+ </>
1411
+ }
1412
+ >
1413
+ <InputArea label="Coywolf Files records" rows={4} className="font-mono text-xs" value={text} onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setText(e.target.value)} />
1414
+ <Button variant="secondary" icon={<UploadSimple />} disabled={busy || !text.trim()} onClick={() => void submit()}>
1415
+ Import file records
1416
+ </Button>
1417
+ {message && (
1418
+ <p className="text-sm" role="status">
1419
+ {message}
1420
+ </p>
1421
+ )}
1422
+ {error && <Banner variant="error" role="alert" description={error} />}
1423
+ </Section>
1424
+ );
1425
+ }
1426
+
1427
+ function VideosStep() {
1428
+ const [fields, setFields] = React.useState({ descriptions: "", posters: "", downloads: "" });
1429
+ const [busy, setBusy] = React.useState(false);
1430
+ const [message, setMessage] = React.useState("");
1431
+ const [error, setError] = React.useState<string>();
1432
+ const submit = async () => {
1433
+ setBusy(true);
1434
+ setError(undefined);
1435
+ setMessage("");
1436
+ try {
1437
+ const body: Record<string, unknown> = {};
1438
+ for (const [k, v] of Object.entries(fields)) {
1439
+ if (!v.trim()) continue;
1440
+ try {
1441
+ body[k] = JSON.parse(v);
1442
+ } catch {
1443
+ throw new Error(`The ${k} aren't valid JSON.`);
1444
+ }
1445
+ }
1446
+ const r = await post<{ updated: number }>("videos", body, "Import failed");
1447
+ setMessage(`Updated ${plural(r.updated, "video", "videos")}.`);
1448
+ } catch (cause) {
1449
+ setError(errorText(cause, "Import failed"));
1450
+ } finally {
1451
+ setBusy(false);
1452
+ }
1453
+ };
1454
+ const area = (key: keyof typeof fields, label: string, option: string) => (
1455
+ <div className="space-y-1">
1456
+ <InputArea label={label} rows={3} className="font-mono text-xs" value={fields[key]} onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setFields((f) => ({ ...f, [key]: e.target.value }))} />
1457
+ <p className="text-xs text-kumo-subtle">
1458
+ <Code>{`wp option get ${option} --format=json`}</Code>
1459
+ </p>
1460
+ </div>
1461
+ );
1462
+ return (
1463
+ <Section
1464
+ title="C. Video Manager library"
1465
+ description="Per-video descriptions, poster frames or images, and MP4 download links set on Video Manager's Edit Video page. They're used in VideoObject schema and the video sitemap."
1466
+ >
1467
+ <div className="grid gap-4 lg:grid-cols-3">
1468
+ {area("descriptions", "Descriptions (JSON)", "coywolf_cvm_descriptions")}
1469
+ {area("posters", "Posters (JSON)", "coywolf_cvm_posters")}
1470
+ {area("downloads", "MP4 downloads (JSON)", "coywolf_cvm_downloads")}
1471
+ </div>
1472
+ <Button variant="secondary" icon={<UploadSimple />} disabled={busy || !Object.values(fields).some((v) => v.trim())} onClick={() => void submit()}>
1473
+ Import video details
1474
+ </Button>
1475
+ {message && (
1476
+ <p className="text-sm" role="status">
1477
+ {message}
1478
+ </p>
1479
+ )}
1480
+ {error && <Banner variant="error" role="alert" description={error} />}
1481
+ </Section>
1482
+ );
1483
+ }
1484
+
1485
+ export function WpImportPage() {
1486
+ const [wxr, setWxr] = React.useState<WxrInfo | null>(null);
1487
+ return (
1488
+ <div className="space-y-6">
1489
+ <header className="grid min-w-0 gap-2 border-b border-kumo-line pb-4">
1490
+ <h1 className="flex min-h-9 items-center text-2xl font-semibold leading-tight">WordPress import</h1>
1491
+ <p className="text-sm leading-5 text-pretty text-kumo-subtle">
1492
+ Finish moving a WordPress site to EmDash. EmDash's own importer brings over posts, pages, categories, tags, authors and media; these steps
1493
+ keep what it drops or misses: heading ids, reusable blocks, Details blocks, co-authors and guest authors, category and page parents, old media
1494
+ URLs, and redirects. Work through them in order. docs/wordpress-import.md in the Coywolf Pack repository has the full checklist.
1495
+ </p>
1496
+ </header>
1497
+ <BeforeImport />
1498
+ <PrepareStep onWxr={setWxr} />
1499
+ <ConvertStep />
1500
+ <BylinesStep wxr={wxr} onWxr={setWxr} />
1501
+ <ParentsStep wxr={wxr} onWxr={setWxr} />
1502
+ <MediaUrlsStep wxr={wxr} onWxr={setWxr} />
1503
+ <RedirectsStep wxr={wxr} onWxr={setWxr} />
1504
+ <div className="space-y-2 border-t border-kumo-line pt-6">
1505
+ <h2 className="text-lg font-semibold">Coywolf WordPress plugins</h2>
1506
+ <p className="text-sm text-kumo-subtle">
1507
+ Only for sites that used Coywolf's WordPress plugins (Video Manager, Coywolf Files, Coywolf SEO, Custom Blocks, Guest Author). Their blocks
1508
+ convert in steps 1 and 2 when Videos, Reviews, Custom Blocks (and each block), Headings &amp; TOC, File Downloads and Code Blocks are on.
1509
+ </p>
1510
+ </div>
1511
+ <DefaultsStep />
1512
+ <FilesStep />
1513
+ <VideosStep />
1514
+ </div>
1515
+ );
1516
+ }