@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,592 @@
1
+ /**
2
+ * Prepare a WordPress export (WXR) for EmDash's importer.
3
+ *
4
+ * EmDash converts Gutenberg with @emdash-cms/gutenberg-to-portable-text,
5
+ * which plugins can't extend: blocks it doesn't know become an `htmlBlock`
6
+ * of the block's saved HTML, and self-closing blocks (which keep everything in
7
+ * their attributes) are dropped. Heading ids are dropped too. This rewrites,
8
+ * inside each post's content:
9
+ *
10
+ * For any WordPress site:
11
+ *
12
+ * - heading ids into "anchor" markers, so imported headings keep their ids and
13
+ * old #links keep working;
14
+ * - reusable blocks (synced patterns, `core/block` with a `ref`) into the
15
+ * blocks they hold, from the export's `wp_block` items (EmDash would drop
16
+ * the reference);
17
+ * - core Details blocks (EmDash would drop the summary) into markers that
18
+ * become Details blocks;
19
+ * - core Table captions (EmDash would drop the <figcaption>) into a marker
20
+ * after the table, which becomes the table's `caption` field;
21
+ * - WordPress's &#91; (and bold markup) inside code blocks, which would
22
+ * otherwise show up as literal text;
23
+ * - Yoast's related links into Custom HTML with the markup WordPress
24
+ * rendered, so their content isn't lost;
25
+ * - blocks with nothing to import (Gravity Forms) into empty markers a theme
26
+ * can find;
27
+ * - and it counts other self-closing third-party blocks, which EmDash drops,
28
+ * so the report lists what to rebuild.
29
+ *
30
+ * For sites that used Coywolf's WordPress plugins (each only runs when the
31
+ * block is in the content):
32
+ *
33
+ * - Coywolf blocks the pack has native blocks for (Cloudflare Stream videos and
34
+ * their embed HTML, Video Manager videos, reviews, tables of contents, file
35
+ * downloads) into marker HTML blocks (see ./markers.ts) that the pack's
36
+ * import hook turns into Coywolf Video, Coywolf Review, Table of Contents and
37
+ * File download blocks;
38
+ * - Coywolf Custom Blocks that were templates: sidenotes and editor's notes,
39
+ * transcripts and accordions, and blockquotes into markers with their exact
40
+ * content, which become Note, Details and Quote blocks (Custom Blocks
41
+ * module); affiliate disclosures (ftc, amazon, plus any block named in
42
+ * `disclosureBlocks`) into markers that become Affiliate disclosure blocks;
43
+ * testimonials into markers that become
44
+ * Testimonial blocks; the podcast links block (it had no fields: the
45
+ * template printed the show's links) into a marker that becomes a Podcast
46
+ * links block using the site's links. Each marker also holds the HTML
47
+ * WordPress rendered (where it had content of its own), so the content shows
48
+ * even if it isn't converted.
49
+ *
50
+ * Everything else is left byte for byte. Pure (no imports beyond siblings), so
51
+ * it runs in the admin (browser), a Node script, and tests.
52
+ */
53
+ import { type GBlock, blockHtml, htmlBlock, parseBlocks, serializeBlocks } from "./gutenberg.js";
54
+ import { type GuestAuthor, wxrGuestAuthors } from "./guests.js";
55
+ import { escapeAttr, markerHtml, parseMarker } from "./markers.js";
56
+ import { type WxrCategory, type WxrPage, wxrCategories, wxrPages } from "./parents.js";
57
+ import { decodeHtml, hasStreamPlayer, parseStreamEmbed } from "./stream.js";
58
+
59
+ export interface PrepareOptions {
60
+ /** WordPress attachment id → URL (from the WXR), for testimonial headshots. */
61
+ attachments?: Map<number, string>;
62
+ /** Reusable block (`wp_block`) id → its content (from the WXR), to inline `core/block` references. */
63
+ reusableBlocks?: Map<number, string>;
64
+ /**
65
+ * More self-closing blocks that printed an affiliate disclosure (the site's
66
+ * wording) and become Affiliate disclosure blocks, e.g.
67
+ * `genesis-custom-blocks/disclosure` on coywolf.com. Coywolf Custom Blocks'
68
+ * ftc and amazon blocks always do.
69
+ */
70
+ disclosureBlocks?: string[];
71
+ /** Internal: how many reusable blocks deep this content is. */
72
+ reusableDepth?: number;
73
+ }
74
+
75
+ /** How deep reusable blocks may nest inside each other. */
76
+ const MAX_REUSABLE_DEPTH = 5;
77
+
78
+ /** What happened to one kind of block. */
79
+ export type PrepareAction =
80
+ | "video"
81
+ | "video-embed"
82
+ | "review"
83
+ | "toc"
84
+ | "file"
85
+ | "anchor"
86
+ | "note"
87
+ | "details"
88
+ /** A table's caption (EmDash drops it), kept in a marker after the table. */
89
+ | "caption"
90
+ | "quote"
91
+ | "disclosure"
92
+ | "testimonial"
93
+ | "podcast"
94
+ | "html"
95
+ | "flag"
96
+ | "code"
97
+ | "removed"
98
+ /** A reusable block's content took the reference's place. */
99
+ | "inlined"
100
+ /** A reusable block reference whose block isn't in the export (left as is; EmDash drops it). */
101
+ | "missing"
102
+ /** A self-closing block EmDash will drop (counted only; nothing changes). */
103
+ | "dropped";
104
+
105
+ export interface PrepareCounts {
106
+ /** `${wordpress block name} → ${action}` → count. */
107
+ [key: string]: number;
108
+ }
109
+
110
+ export interface PrepareResult {
111
+ content: string;
112
+ counts: PrepareCounts;
113
+ changed: boolean;
114
+ }
115
+
116
+ const WRAP = (html: string) => htmlBlock(html);
117
+
118
+ /** Coywolf Custom Blocks' transcript block's default Summary (not saved when left as is). */
119
+ export const TRANSCRIPT_SUMMARY = "Read the audio transcript";
120
+
121
+ /** Blocks that render nothing on WordPress and are removed. */
122
+ const REMOVE = new Set(["coywolf-custom-blocks/newsletter"]);
123
+ /** Affiliate disclosures: no content of their own (the theme printed the wording); they become Affiliate disclosure blocks. */
124
+ const DISCLOSURES = new Set(["coywolf-custom-blocks/ftc", "coywolf-custom-blocks/amazon"]);
125
+ /** Blocks kept as empty markers for the theme (their output came from the theme or another plugin). */
126
+ const FLAG: Record<string, string> = {
127
+ "gravityforms/form": "gravity-form",
128
+ };
129
+
130
+ const str = (v: unknown) => (typeof v === "string" ? v : typeof v === "number" ? String(v) : "");
131
+
132
+ /** Repair JSON-escaped HTML that lost its backslashes on the way into the database ("u003cpu003e" → "<p>"). */
133
+ export function repairUnicodeEscapes(html: string): string {
134
+ if (!/u003[ce]/.test(html)) return html;
135
+ return html.replace(/u00([0-9a-f]{2})/gi, (_, hex: string) => String.fromCharCode(Number.parseInt(hex, 16)));
136
+ }
137
+
138
+ /** Wrap bare text (no block-level tags) in a paragraph. */
139
+ const asParagraphs = (html: string) => (/<(p|ul|ol|div|blockquote|figure|h[1-6]|table|pre)\b/i.test(html) ? html : `<p>${html}</p>`);
140
+
141
+ /** The custom blockquote's fields: the quote, who said it, and the URL from its ` cite="…"` field. */
142
+ export function blockquoteFields(attrs: Record<string, unknown>): { quote: string; cite: string; url: string } {
143
+ return {
144
+ quote: repairUnicodeEscapes(str(attrs["block-quote"])).trim(),
145
+ cite: repairUnicodeEscapes(str(attrs["block-cite"])).trim(),
146
+ // Some lost their backslashes: ` cite=u0022https://…u0022`.
147
+ url: str(attrs["block-url"]).replace(/\\?u0022/g, '"').match(/cite\s*=\s*"([^"]*)"/)?.[1]?.trim() ?? "",
148
+ };
149
+ }
150
+
151
+ function blockquoteHtml(f: { quote: string; cite: string; url: string }): string {
152
+ return `<figure class="wp-custom-blockquote"><blockquote${f.url ? ` cite="${escapeAttr(f.url)}"` : ""}>${asParagraphs(f.quote)}</blockquote>${
153
+ f.cite ? `<figcaption><cite>${f.cite}</cite></figcaption>` : ""
154
+ }</figure>`;
155
+ }
156
+
157
+ function noteHtml(kind: "sidenote" | "editorsnote", text: string): string {
158
+ const heading = kind === "sidenote" ? "&#x1F4CC; Sidenote" : "&#x1F4DD; Editor's Note";
159
+ return `<aside class="sidenote${kind === "editorsnote" ? " editorsnote" : ""}"><h2>${heading}</h2>${asParagraphs(text.trim())}</aside>`;
160
+ }
161
+
162
+ function detailsHtml(summary: string, body: string, className: string): string {
163
+ return `<details class="${className}"><summary>${summary}</summary><div class="${className}__body">${body}</div></details>`;
164
+ }
165
+
166
+ /**
167
+ * A core Quote block's fields: its paragraphs (the quote) and its <cite> (who
168
+ * said it). EmDash's own converter keeps only the citation, dropping the quote.
169
+ */
170
+ export function coreQuoteFields(block: GBlock): { quote: string; cite: string; url: string } {
171
+ const html = blockHtml(block).trim();
172
+ const m = html.match(/^<blockquote\b([^>]*)>([\s\S]*)<\/blockquote>$/i);
173
+ const inner = m?.[2] ?? html;
174
+ // Only a <cite> directly inside the blockquote is its citation; one inside a paragraph
175
+ // (a cited title) or a nested quote stays in the quote.
176
+ const cites = directChildren(inner, "cite");
177
+ let rest = inner;
178
+ for (const c of [...cites].reverse()) rest = rest.slice(0, c.start) + rest.slice(c.end);
179
+ const cite = cites.length
180
+ ? cites
181
+ .map((c) => c.inner.trim())
182
+ .filter(Boolean)
183
+ .join(", ")
184
+ : typeof block.attrs.citation === "string"
185
+ ? block.attrs.citation
186
+ : "";
187
+ const quote = collapseBlockWhitespace(stripTagAttributes(rest, /^(?:class|id)$/i)).trim();
188
+ const url = m?.[1]?.match(/\scite="([^"]*)"/i)?.[1]?.trim() ?? "";
189
+ return { quote, cite: cite.trim(), url };
190
+ }
191
+
192
+ /** Elements without a closing tag. */
193
+ const VOID_TAGS = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"]);
194
+ /** A start or end tag, with quoted attribute values that may contain ">". */
195
+ const TAG = /<(\/?)([a-z][a-z0-9-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/gi;
196
+
197
+ /** `tag` elements at the top level of `html` (not inside another element): their range and inner HTML. */
198
+ function directChildren(html: string, tag: string): { start: number; end: number; inner: string }[] {
199
+ const out: { start: number; end: number; inner: string }[] = [];
200
+ let depth = 0;
201
+ let open: { start: number; innerStart: number } | null = null;
202
+ for (const t of html.matchAll(TAG)) {
203
+ const closing = t[1] === "/";
204
+ const name = (t[2] as string).toLowerCase();
205
+ const at = t.index ?? 0;
206
+ if (closing) {
207
+ depth = Math.max(0, depth - 1);
208
+ if (open && depth === 0 && name === tag) {
209
+ out.push({ start: open.start, end: at + t[0].length, inner: html.slice(open.innerStart, at) });
210
+ open = null;
211
+ }
212
+ continue;
213
+ }
214
+ if (VOID_TAGS.has(name) || /\/\s*$/.test(t[3] as string)) continue;
215
+ if (depth === 0 && name === tag) open = { start: at, innerStart: at + t[0].length };
216
+ depth++;
217
+ }
218
+ return out;
219
+ }
220
+
221
+ /** Remove attributes whose name matches `names` from every tag (never from the text between tags). */
222
+ function stripTagAttributes(html: string, names: RegExp): string {
223
+ return html.replace(TAG, (whole, slash: string, name: string, attrs: string) => {
224
+ if (slash || !attrs) return whole;
225
+ const kept = attrs.replace(/\s+([^\s=/>"']+)(\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'>]+))?/g, (attr, attrName: string) => (names.test(attrName) ? "" : attr));
226
+ return `<${name}${kept}>`;
227
+ });
228
+ }
229
+
230
+ /** Block-level tags: whitespace between two of them is only source formatting. */
231
+ const BLOCK_TAG = "(?:address|article|aside|blockquote|dd|details|div|dl|dt|figcaption|figure|footer|h[1-6]|header|hr|li|ol|p|pre|section|summary|table|tbody|td|tfoot|th|thead|tr|ul)";
232
+ const AFTER_BLOCK = new RegExp(`(<\\/?${BLOCK_TAG}\\b(?:[^>"']|"[^"]*"|'[^']*')*>)\\s+(?=<)`, "gi");
233
+ const BEFORE_BLOCK = new RegExp(`>\\s+(?=<\\/?${BLOCK_TAG}\\b)`, "gi");
234
+
235
+ /**
236
+ * Drop whitespace between two tags when one of them is block-level (`</p>\n<p>`,
237
+ * `</p>\n<cite>`): it's source formatting that never renders. Whitespace between
238
+ * inline elements (`<a>one</a> <a>two</a>`) and in text is kept.
239
+ */
240
+ function collapseBlockWhitespace(html: string): string {
241
+ return html.replace(AFTER_BLOCK, "$1").replace(BEFORE_BLOCK, ">");
242
+ }
243
+
244
+ /** A core Details block's summary (HTML), body (its inner blocks' HTML) and whether it starts open. */
245
+ export function coreDetailsFields(block: GBlock): { summary: string; body: string; open: boolean } {
246
+ const html = blockHtml(block).trim();
247
+ const m = html.match(/^<details\b[^>]*>\s*<summary\b[^>]*>([\s\S]*?)<\/summary>([\s\S]*?)<\/details>$/i);
248
+ return { summary: (m?.[1] ?? "").trim(), body: (m?.[2] ?? html).trim(), open: block.attrs.showContent === true };
249
+ }
250
+
251
+ /** The testimonial's fields (Name, Title, Quote, Headshot, Social URL, Work URL); the headshot's URL comes from the export's attachments. */
252
+ export function testimonialFields(
253
+ attrs: Record<string, unknown>,
254
+ attachments?: Map<number, string>,
255
+ ): { quote: string; name: string; title: string; photo: string; photoId: number | null; nameUrl: string; titleUrl: string } {
256
+ const id = Number(attrs["t-image"]);
257
+ const photoId = Number.isInteger(id) && id > 0 ? id : null;
258
+ return {
259
+ quote: repairUnicodeEscapes(str(attrs["t-quote"])).trim(),
260
+ name: str(attrs["t-name"]).trim(),
261
+ title: str(attrs["t-title"]).trim(),
262
+ photo: photoId ? (attachments?.get(photoId) ?? "") : "",
263
+ photoId,
264
+ nameUrl: str(attrs["t-social"]).trim(),
265
+ titleUrl: str(attrs["t-work"]).trim(),
266
+ };
267
+ }
268
+
269
+ /** What WordPress's template printed (without its onclick handlers). */
270
+ function testimonialHtml(f: ReturnType<typeof testimonialFields>): string {
271
+ const link = (href: string, text: string) => (href ? `<a href="${escapeAttr(href)}">${text}</a>` : text);
272
+ return `<blockquote class="testimonial"><div class="quote"><p><q>${f.quote}</q></p></div><div class="influencer">${
273
+ f.photo ? `<img alt="${escapeAttr(f.name)}" height="60" width="60" src="${escapeAttr(f.photo)}">` : ""
274
+ }<p>${link(f.nameUrl, f.name)}</p><p>${link(f.titleUrl, f.title)}</p></div></blockquote>`;
275
+ }
276
+
277
+ /**
278
+ * Code Block Enhancer let authors bold parts of a code block (real <strong>
279
+ * tags inside <code>), and WordPress stores "[" as &#91; to keep shortcodes
280
+ * from running. EmDash's importer decodes only the basic entities and keeps
281
+ * tags as text, so both would show up literally: drop the formatting tags and
282
+ * decode the brackets. Returns null when there's nothing to fix.
283
+ */
284
+ export function cleanCodeHtml(html: string): string | null {
285
+ const m = html.match(/^([\s\S]*?<code\b[^>]*>)([\s\S]*)(<\/code>[\s\S]*)$/i);
286
+ if (!m) return null;
287
+ const body = (m[2] as string)
288
+ .replace(/<\/?(?:strong|b|em|i|mark|u|code|span)\b[^>]*>/gi, "")
289
+ .replace(/<br\s*\/?>/gi, "\n")
290
+ .replace(/&#0*91;/g, "[")
291
+ .replace(/&#0*93;/g, "]");
292
+ return body === m[2] ? null : `${m[1]}${body}${m[3]}`;
293
+ }
294
+
295
+ /** The marker HTML block replacing a WordPress block, plus its action for the report. */
296
+ function replacement(block: GBlock, opts: PrepareOptions): { blocks: GBlock[]; action: PrepareAction } | null {
297
+ const a = block.attrs;
298
+ switch (block.name) {
299
+ case "coywolf-custom-blocks/cloudflare-stream":
300
+ return { blocks: [WRAP(markerHtml("cloudflare-stream", { ...a, embed: null }))], action: "video" };
301
+ case "coywolf/video":
302
+ return { blocks: [WRAP(markerHtml("video", a, block.innerHTML.trim()))], action: "video" };
303
+ case "coywolf-custom-blocks/review":
304
+ return { blocks: [WRAP(markerHtml("review", a))], action: "review" };
305
+ case "coywolf-seo/table-of-contents":
306
+ return { blocks: [WRAP(markerHtml("toc", a))], action: "toc" };
307
+ case "coywolf/file":
308
+ return { blocks: [WRAP(markerHtml("file", a, block.innerHTML.trim()))], action: "file" };
309
+ case "coywolf-custom-blocks/blockquote": {
310
+ const f = blockquoteFields(a);
311
+ return { blocks: [WRAP(markerHtml("blockquote", f, blockquoteHtml(f)))], action: "quote" };
312
+ }
313
+ case "coywolf-custom-blocks/sidenote":
314
+ case "coywolf-custom-blocks/editorsnote": {
315
+ const kind = block.name === "coywolf-custom-blocks/sidenote" ? "sidenote" : "editorsnote";
316
+ const text = repairUnicodeEscapes(str(a[kind])).trim();
317
+ return { blocks: [WRAP(markerHtml(kind, { text }, noteHtml(kind, text)))], action: "note" };
318
+ }
319
+ case "coywolf-custom-blocks/transcript":
320
+ case "coywolf-custom-blocks/accordion": {
321
+ const kind = block.name === "coywolf-custom-blocks/transcript" ? "transcript" : "accordion";
322
+ // The transcript's Summary field defaulted to "Read the audio transcript" (not saved when left as is).
323
+ const summary = str(a.summary).trim() || (kind === "transcript" ? TRANSCRIPT_SUMMARY : "");
324
+ const body = repairUnicodeEscapes(str(a.details)).trim();
325
+ return { blocks: [WRAP(markerHtml(kind, { summary, body }, detailsHtml(summary, body, kind)))], action: "details" };
326
+ }
327
+ case "coywolf-custom-blocks/testimonial": {
328
+ const f = testimonialFields(a, opts.attachments);
329
+ return { blocks: [WRAP(markerHtml("testimonial", f, testimonialHtml(f)))], action: "testimonial" };
330
+ }
331
+ case "coywolf-custom-blocks/podcast-rss":
332
+ // Same marker name and attrs as 0.10/0.11 (then a theme placeholder), so older markers convert too.
333
+ return { blocks: [WRAP(markerHtml("podcast-links", { block: block.name, ...a }))], action: "podcast" };
334
+ case "core/quote": {
335
+ const f = coreQuoteFields(block);
336
+ if (!f.quote) return null;
337
+ return { blocks: [WRAP(markerHtml("blockquote", f, blockquoteHtml(f)))], action: "quote" };
338
+ }
339
+ case "core/details":
340
+ return { blocks: [WRAP(markerHtml("details", coreDetailsFields(block), blockHtml(block).trim()))], action: "details" };
341
+ case "yoast-seo/related-links":
342
+ // EmDash would turn each list item into its own HTML block.
343
+ return { blocks: [WRAP(markerHtml("related-links", {}, blockHtml(block).trim()))], action: "html" };
344
+ default:
345
+ if (block.name && REMOVE.has(block.name)) return { blocks: [], action: "removed" };
346
+ if (block.name && (DISCLOSURES.has(block.name) || opts.disclosureBlocks?.includes(block.name))) return { blocks: [WRAP(markerHtml("disclosure", { block: block.name, ...a }))], action: "disclosure" };
347
+ if (block.name && FLAG[block.name]) return { blocks: [WRAP(markerHtml(FLAG[block.name] as string, { block: block.name, ...a }))], action: "flag" };
348
+ return null;
349
+ }
350
+ }
351
+
352
+ const isBlank = (b: GBlock) => b.name === null && !b.innerHTML.trim();
353
+
354
+ /**
355
+ * A core Table block's caption (its <figcaption>), as plain text: tags
356
+ * dropped (a line break becomes a space), entities decoded. Empty when it has
357
+ * none.
358
+ */
359
+ export function tableCaption(html: string): string {
360
+ const m = html.match(/<figcaption\b[^>]*>([\s\S]*?)<\/figcaption>/i);
361
+ if (!m) return "";
362
+ return decodeHtml((m[1] as string).replace(/<br\s*\/?>/gi, " ").replace(/<[^>]*>/g, ""))
363
+ .replace(/\s+/g, " ")
364
+ .trim();
365
+ }
366
+
367
+ /** Transform one list of sibling blocks. Returns, for each original block, what replaces it. */
368
+ function transformList(list: GBlock[], opts: PrepareOptions, counts: PrepareCounts): GBlock[][] {
369
+ const out: GBlock[][] = list.map((b) => [b]);
370
+ const count = (name: string | null, action: PrepareAction) => {
371
+ const key = `${name ?? "html"} → ${action}`;
372
+ counts[key] = (counts[key] ?? 0) + 1;
373
+ };
374
+ for (let i = 0; i < list.length; i++) {
375
+ const block = list[i] as GBlock;
376
+ if (block.name === "coywolf-custom-blocks/cloudflare-stream") {
377
+ // Pair with the embed HTML right before it (the schema block followed the player).
378
+ let j = i - 1;
379
+ while (j >= 0 && isBlank(list[j] as GBlock)) j--;
380
+ const prev = j >= 0 ? (list[j] as GBlock) : null;
381
+ const embed = prev?.name === "core/html" && out[j]?.[0] === prev ? parseStreamEmbed(prev.innerHTML.trim()) : null;
382
+ const uid = typeof block.attrs["cs-id"] === "string" ? block.attrs["cs-id"].toLowerCase() : "";
383
+ if (embed && (embed.uid === null || embed.uid === uid)) {
384
+ out[j] = [];
385
+ count("core/html", "removed");
386
+ }
387
+ out[i] = [WRAP(markerHtml("cloudflare-stream", { ...block.attrs, embed: embed && (embed.uid === null || embed.uid === uid) ? embed : null }))];
388
+ count(block.name, "video");
389
+ continue;
390
+ }
391
+ if (block.name === "core/html" && hasStreamPlayer(block.innerHTML)) {
392
+ // A player with no schema block after it.
393
+ let k = i + 1;
394
+ while (k < list.length && isBlank(list[k] as GBlock)) k++;
395
+ if (list[k]?.name === "coywolf-custom-blocks/cloudflare-stream") continue; // Paired above.
396
+ const embed = parseStreamEmbed(block.innerHTML.trim());
397
+ if (embed?.uid) {
398
+ out[i] = [WRAP(markerHtml("stream-embed", { embed }))];
399
+ count(block.name, "video-embed");
400
+ }
401
+ continue;
402
+ }
403
+ if (block.name === "core/heading") {
404
+ const id = block.innerHTML.match(/<h[1-6]\b[^>]*\sid="([^"]+)"/i)?.[1];
405
+ let j = i - 1;
406
+ while (j >= 0 && isBlank(list[j] as GBlock)) j--;
407
+ const prev = j >= 0 ? parseMarker((list[j] as GBlock).innerHTML.trim()) : null;
408
+ const marked = prev?.name === "anchor" && prev.attrs.id === id;
409
+ if (id && !marked) {
410
+ out[i] = [WRAP(markerHtml("anchor", { id })), block];
411
+ count(block.name, "anchor");
412
+ }
413
+ continue;
414
+ }
415
+ if (block.name === "core/table") {
416
+ // EmDash's converter keeps the rows but drops the caption: a marker after the table carries it.
417
+ const caption = tableCaption(block.innerHTML);
418
+ let k = i + 1;
419
+ while (k < list.length && isBlank(list[k] as GBlock)) k++;
420
+ const next = k < list.length ? parseMarker((list[k] as GBlock).innerHTML.trim()) : null;
421
+ if (caption && next?.name !== "table-caption") {
422
+ out[i] = [block, WRAP(markerHtml("table-caption", { caption }, `<p>${escapeAttr(caption)}</p>`))];
423
+ count(block.name, "caption");
424
+ }
425
+ continue;
426
+ }
427
+ if (block.name === "core/code") {
428
+ const cleaned = cleanCodeHtml(block.innerHTML);
429
+ if (cleaned !== null) {
430
+ // innerContent is the HTML alone (code blocks have no inner blocks).
431
+ out[i] = [{ ...block, innerHTML: cleaned, innerContent: [cleaned] }];
432
+ count(block.name, "code");
433
+ }
434
+ continue;
435
+ }
436
+ if (block.name === "core/block") {
437
+ // A reusable block (synced pattern): put its blocks in place of the reference.
438
+ const ref = Number(block.attrs.ref);
439
+ const depth = opts.reusableDepth ?? 0;
440
+ const body = Number.isInteger(ref) && depth < MAX_REUSABLE_DEPTH ? opts.reusableBlocks?.get(ref) : undefined;
441
+ if (body !== undefined) {
442
+ out[i] = flatten(transformList(parseBlocks(body), { ...opts, reusableDepth: depth + 1 }, counts));
443
+ count(block.name, "inlined");
444
+ } else if (block.attrs.ref !== undefined) {
445
+ count(block.name, "missing");
446
+ }
447
+ continue;
448
+ }
449
+ const rep = replacement(block, opts);
450
+ if (rep) {
451
+ out[i] = rep.blocks;
452
+ count(block.name, rep.action);
453
+ continue;
454
+ }
455
+ // Self-closing blocks keep everything in attributes, and EmDash drops the ones it doesn't know.
456
+ if (block.void && block.name && !block.name.startsWith("core/")) count(block.name, "dropped");
457
+ if (block.innerBlocks.length) rebuildInner(block, opts, counts);
458
+ }
459
+ return out;
460
+ }
461
+
462
+ const SPACER = (): GBlock => ({ name: null, attrs: {}, innerHTML: "\n\n", innerBlocks: [], innerContent: ["\n\n"], opener: "", closer: "", void: false });
463
+
464
+ /** One list from the replacement groups, with a blank line between blocks that replaced one. */
465
+ function flatten(replaced: GBlock[][]): GBlock[] {
466
+ const flat: GBlock[] = [];
467
+ for (const group of replaced) {
468
+ group.forEach((b, n) => {
469
+ if (n > 0) flat.push(SPACER());
470
+ flat.push(b);
471
+ });
472
+ }
473
+ return flat;
474
+ }
475
+
476
+ /** Transform a block's inner blocks in place, keeping its own markup. */
477
+ function rebuildInner(block: GBlock, opts: PrepareOptions, counts: PrepareCounts): void {
478
+ const replaced = transformList(block.innerBlocks, opts, counts);
479
+ const innerBlocks: GBlock[] = [];
480
+ const innerContent: Array<string | null> = [];
481
+ let slot = 0;
482
+ for (const chunk of block.innerContent) {
483
+ if (chunk !== null) {
484
+ innerContent.push(chunk);
485
+ continue;
486
+ }
487
+ const group = replaced[slot++] ?? [];
488
+ group.forEach((b, n) => {
489
+ if (n > 0) innerContent.push("\n\n");
490
+ innerBlocks.push(b);
491
+ innerContent.push(null);
492
+ });
493
+ }
494
+ block.innerBlocks = innerBlocks;
495
+ block.innerContent = innerContent;
496
+ }
497
+
498
+ /** Prepare one post's content. */
499
+ export function prepareContent(content: string, opts: PrepareOptions = {}): PrepareResult {
500
+ const counts: PrepareCounts = {};
501
+ if (!content || !content.includes("<!-- wp:")) return { content, counts, changed: false };
502
+ const blocks = parseBlocks(content);
503
+ const next = serializeBlocks(flatten(transformList(blocks, opts, counts)));
504
+ return { content: next, counts, changed: next !== content };
505
+ }
506
+
507
+ // ── WXR ──────────────────────────────────────────────────────────
508
+
509
+ const CDATA = /<!\[CDATA\[([\s\S]*?)\]\]>/g;
510
+
511
+ /** The text of an element body that may be CDATA (WordPress splits "]]>" across sections). */
512
+ function cdataText(body: string): string {
513
+ if (!body.includes("<![CDATA[")) return body.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
514
+ let text = "";
515
+ for (const m of body.matchAll(CDATA)) text += m[1];
516
+ return text;
517
+ }
518
+
519
+ const toCdata = (text: string) => `<![CDATA[${text.replace(/\]\]>/g, "]]]]><![CDATA[>")}]]>`;
520
+
521
+ function tag(item: string, name: string): string | null {
522
+ const m = item.match(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`));
523
+ return m ? cdataText(m[1] as string) : null;
524
+ }
525
+
526
+ /** Attachment id → URL from a WXR file. */
527
+ export function wxrAttachments(xml: string): Map<number, string> {
528
+ const map = new Map<number, string>();
529
+ for (const m of xml.matchAll(/<item>([\s\S]*?)<\/item>/g)) {
530
+ const item = m[1] as string;
531
+ if (tag(item, "wp:post_type") !== "attachment") continue;
532
+ const id = Number(tag(item, "wp:post_id"));
533
+ const url = tag(item, "wp:attachment_url");
534
+ if (Number.isInteger(id) && url) map.set(id, url.trim());
535
+ }
536
+ return map;
537
+ }
538
+
539
+ /** Reusable block (`wp_block`, "synced pattern") id → its content, from a WXR file. */
540
+ export function wxrReusableBlocks(xml: string): Map<number, string> {
541
+ const map = new Map<number, string>();
542
+ for (const m of xml.matchAll(/<item>([\s\S]*?)<\/item>/g)) {
543
+ const item = m[1] as string;
544
+ if (tag(item, "wp:post_type") !== "wp_block") continue;
545
+ const id = Number(tag(item, "wp:post_id"));
546
+ const content = tag(item, "content:encoded");
547
+ if (Number.isInteger(id) && content !== null) map.set(id, content);
548
+ }
549
+ return map;
550
+ }
551
+
552
+ export interface PreparedPost {
553
+ id: number | null;
554
+ title: string;
555
+ type: string;
556
+ counts: PrepareCounts;
557
+ }
558
+
559
+ export interface PrepareWxrResult {
560
+ xml: string;
561
+ posts: PreparedPost[];
562
+ /** Totals over every post (including what's only counted, like blocks EmDash will drop). */
563
+ counts: PrepareCounts;
564
+ /** Posts credited to authors other than their WordPress user (co-authors, guest authors), for the bylines step. */
565
+ guestAuthors: GuestAuthor[];
566
+ /** Categories with their parents, and pages with their parent pages, for the parents step (EmDash's importer drops both). */
567
+ categories: WxrCategory[];
568
+ pages: WxrPage[];
569
+ }
570
+
571
+ /** Prepare a whole WXR export. Only `content:encoded` bodies change. */
572
+ export function prepareWxr(xml: string, opts: PrepareOptions = {}): PrepareWxrResult {
573
+ const attachments = opts.attachments ?? wxrAttachments(xml);
574
+ const reusableBlocks = opts.reusableBlocks ?? wxrReusableBlocks(xml);
575
+ const posts: PreparedPost[] = [];
576
+ const totals: PrepareCounts = {};
577
+ const out = xml.replace(/<item>([\s\S]*?)<\/item>/g, (whole, item: string) => {
578
+ const m = item.match(/<content:encoded>([\s\S]*?)<\/content:encoded>/);
579
+ if (!m) return whole;
580
+ const result = prepareContent(cdataText(m[1] as string), { ...opts, attachments, reusableBlocks });
581
+ for (const [k, v] of Object.entries(result.counts)) totals[k] = (totals[k] ?? 0) + v;
582
+ if (!result.changed) return whole;
583
+ posts.push({
584
+ id: Number(tag(item, "wp:post_id")) || null,
585
+ title: tag(item, "title") ?? "",
586
+ type: tag(item, "wp:post_type") ?? "",
587
+ counts: result.counts,
588
+ });
589
+ return `<item>${item.replace(m[0], () => `<content:encoded>${toCdata(result.content)}</content:encoded>`)}</item>`;
590
+ });
591
+ return { xml: out, posts, counts: totals, guestAuthors: wxrGuestAuthors(xml, attachments), categories: wxrCategories(xml), pages: wxrPages(xml) };
592
+ }