@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,329 @@
1
+ /**
2
+ * Bylines WordPress showed that EmDash's importer doesn't carry over. The
3
+ * importer credits each post to its WordPress user (`dc:creator`) only, so it
4
+ * misses:
5
+ *
6
+ * - co-authors and guest authors from Co-Authors Plus and PublishPress
7
+ * Authors. Both attach `author` taxonomy terms to the post
8
+ * (`<category domain="author" nicename="…">`). Co-Authors Plus keeps guest
9
+ * profiles as `guest-author` posts (meta cap-display_name, cap-user_login,
10
+ * cap-website, cap-description, _thumbnail_id) and names a user's term
11
+ * after their login (slug `cap-<login>`); PublishPress Authors keeps
12
+ * profiles in term meta (user_url, description, avatar);
13
+ * - guests from the Coywolf Guest Author plugin, which stores one guest per
14
+ * post in post meta (no users, no taxonomy) and overrides the byline:
15
+ * _guest_author name (an empty name means "no guest")
16
+ * _guest_author_url website (optional)
17
+ * _guest_author_bio biographical info (optional, may hold HTML)
18
+ * _guest_author_avatar_id attachment id of the avatar (optional)
19
+ *
20
+ * The WordPress import page reads them from the export (wxrGuestAuthors),
21
+ * groups them into bylines (groupGuests) and posts (postCredits), and credits
22
+ * each post through EmDash's own admin API (bylines are EmDash core data that
23
+ * plugins can only read).
24
+ *
25
+ * Pure, no imports: runs in the admin (browser), a Node script and tests.
26
+ */
27
+
28
+ export interface GuestAuthor {
29
+ /** WordPress post id. */
30
+ postId: number | null;
31
+ postType: string;
32
+ /** The post's slug, which EmDash's importer keeps. */
33
+ slug: string;
34
+ title: string;
35
+ name: string;
36
+ url: string;
37
+ /** Plain text. */
38
+ bio: string;
39
+ avatarId: number | null;
40
+ avatarUrl: string;
41
+ /** Order among the post's authors (0 = first). */
42
+ position?: number;
43
+ /** Where it came from: the Coywolf Guest Author plugin, or author terms (Co-Authors Plus, PublishPress Authors). */
44
+ source?: "guest-author" | "co-authors";
45
+ }
46
+
47
+ const CDATA = /<!\[CDATA\[([\s\S]*?)\]\]>/g;
48
+
49
+ function cdataText(body: string): string {
50
+ if (!body.includes("<![CDATA[")) return body.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#039;/g, "'").replace(/&amp;/g, "&");
51
+ let text = "";
52
+ for (const m of body.matchAll(CDATA)) text += m[1];
53
+ return text;
54
+ }
55
+
56
+ function tag(item: string, name: string): string | null {
57
+ const m = item.match(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`));
58
+ return m ? cdataText(m[1] as string) : null;
59
+ }
60
+
61
+ /** An item's post meta (key → value; the first value wins). */
62
+ export function itemMeta(item: string): Map<string, string> {
63
+ const meta = new Map<string, string>();
64
+ for (const m of item.matchAll(/<wp:postmeta>([\s\S]*?)<\/wp:postmeta>/g)) {
65
+ const key = tag(m[1] as string, "wp:meta_key");
66
+ const value = tag(m[1] as string, "wp:meta_value");
67
+ if (key !== null && value !== null && !meta.has(key)) meta.set(key, value);
68
+ }
69
+ return meta;
70
+ }
71
+
72
+ const ENTITIES: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", rsquo: "’", lsquo: "‘", rdquo: "”", ldquo: "“", ndash: "–", mdash: "—", hellip: "…" };
73
+
74
+ /** Bios as plain text (EmDash bylines hold plain text; the Coywolf plugin's schema helper strips tags too). */
75
+ export function bioText(html: string): string {
76
+ return html
77
+ .replace(/<(script|style)\b[\s\S]*?<\/\1\s*>/gi, "")
78
+ .replace(/<br\s*\/?>|<\/p>/gi, "\n")
79
+ .replace(/<[^>]*>/g, "")
80
+ .replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, code: string) => {
81
+ if (code[0] !== "#") return ENTITIES[code.toLowerCase()] ?? whole;
82
+ const n = code[1] === "x" || code[1] === "X" ? Number.parseInt(code.slice(2), 16) : Number.parseInt(code.slice(1), 10);
83
+ return Number.isFinite(n) && n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : "";
84
+ })
85
+ .replace(/[ \t]+/g, " ")
86
+ .replace(/\s*\n\s*/g, "\n")
87
+ .trim();
88
+ }
89
+
90
+ interface AuthorProfile {
91
+ name: string;
92
+ url: string;
93
+ bio: string;
94
+ avatarId: number | null;
95
+ /** The WordPress user's login, when the author is a user. */
96
+ login: string;
97
+ }
98
+
99
+ const validUrl = (url: string) => (/^https?:\/\/\S+$/i.test(url.trim()) ? url.trim() : "");
100
+ const attachmentId = (v: string | undefined) => {
101
+ const n = Number(v);
102
+ return Number.isInteger(n) && n > 0 ? n : null;
103
+ };
104
+
105
+ function decodeSlug(text: string): string {
106
+ try {
107
+ return decodeURIComponent(text);
108
+ } catch {
109
+ return text;
110
+ }
111
+ }
112
+
113
+ /** WordPress users in the export: login → display name. */
114
+ function wxrUsers(xml: string): Map<string, string> {
115
+ const users = new Map<string, string>();
116
+ for (const m of xml.matchAll(/<wp:author>([\s\S]*?)<\/wp:author>/g)) {
117
+ const login = (tag(m[1] as string, "wp:author_login") ?? "").trim();
118
+ if (login) users.set(login, (tag(m[1] as string, "wp:author_display_name") ?? "").trim() || login);
119
+ }
120
+ return users;
121
+ }
122
+
123
+ /** Author profiles by term slug: Co-Authors Plus guest-author posts and PublishPress Authors terms. */
124
+ function authorProfiles(xml: string, users: Map<string, string>): Map<string, AuthorProfile> {
125
+ const profiles = new Map<string, AuthorProfile>();
126
+ for (const m of xml.matchAll(/<item>([\s\S]*?)<\/item>/g)) {
127
+ const item = m[1] as string;
128
+ if ((tag(item, "wp:post_type") ?? "").trim() !== "guest-author") continue;
129
+ const meta = itemMeta(item);
130
+ const login = (meta.get("cap-user_login") ?? "").trim();
131
+ const profile: AuthorProfile = {
132
+ name: (meta.get("cap-display_name") ?? "").trim() || (tag(item, "title") ?? "").trim() || login,
133
+ url: validUrl(meta.get("cap-website") ?? ""),
134
+ bio: bioText(meta.get("cap-description") ?? ""),
135
+ avatarId: attachmentId(meta.get("_thumbnail_id")),
136
+ login: "",
137
+ };
138
+ if (!profile.name) continue;
139
+ const slug = decodeSlug((tag(item, "wp:post_name") ?? "").trim());
140
+ if (slug) profiles.set(slug, profile);
141
+ if (login) profiles.set(`cap-${login.toLowerCase()}`, profile);
142
+ }
143
+ for (const m of xml.matchAll(/<wp:term>([\s\S]*?)<\/wp:term>/g)) {
144
+ const term = m[1] as string;
145
+ if ((tag(term, "wp:term_taxonomy") ?? "").trim() !== "author") continue;
146
+ const slug = decodeSlug((tag(term, "wp:term_slug") ?? "").trim());
147
+ if (!slug || profiles.has(slug)) continue;
148
+ const meta = new Map<string, string>();
149
+ for (const tm of term.matchAll(/<wp:termmeta>([\s\S]*?)<\/wp:termmeta>/g)) {
150
+ const key = tag(tm[1] as string, "wp:meta_key");
151
+ const value = tag(tm[1] as string, "wp:meta_value");
152
+ if (key !== null && value !== null && !meta.has(key)) meta.set(key, value);
153
+ }
154
+ const name = (tag(term, "wp:term_name") ?? "").trim();
155
+ // Co-Authors Plus names a user's term after their login: show the user's display name.
156
+ const login = slug.startsWith("cap-") && users.has(name) ? name : "";
157
+ profiles.set(slug, {
158
+ name: login ? (users.get(login) as string) : name,
159
+ url: validUrl(meta.get("user_url") ?? ""),
160
+ bio: bioText(meta.get("description") ?? ""),
161
+ avatarId: attachmentId(meta.get("avatar")),
162
+ login,
163
+ });
164
+ }
165
+ return profiles;
166
+ }
167
+
168
+ const SKIP_TYPES = new Set(["attachment", "nav_menu_item", "revision", "guest-author", "wp_block"]);
169
+
170
+ /**
171
+ * Every post whose byline isn't (only) its WordPress user, one record per
172
+ * post and author: the Coywolf Guest Author plugin's guest, or the post's
173
+ * author terms (Co-Authors Plus, PublishPress Authors) in export order. A post
174
+ * whose only author term is its own WordPress user is left out (the importer
175
+ * already credits it). `attachments` maps attachment ids to URLs (for avatars).
176
+ */
177
+ export function wxrGuestAuthors(xml: string, attachments?: Map<number, string>): GuestAuthor[] {
178
+ const out: GuestAuthor[] = [];
179
+ const hasTerms = xml.includes('domain="author"');
180
+ const users = hasTerms ? wxrUsers(xml) : new Map<string, string>();
181
+ const profiles = hasTerms ? authorProfiles(xml, users) : new Map<string, AuthorProfile>();
182
+ const avatar = (id: number | null) => (id ? (attachments?.get(id) ?? "") : "");
183
+ for (const m of xml.matchAll(/<item>([\s\S]*?)<\/item>/g)) {
184
+ const item = m[1] as string;
185
+ const hasGuest = item.includes("_guest_author");
186
+ if (!hasGuest && !(hasTerms && item.includes('domain="author"'))) continue;
187
+ const postType = (tag(item, "wp:post_type") ?? "post").trim();
188
+ const status = (tag(item, "wp:status") ?? "").trim();
189
+ if (SKIP_TYPES.has(postType) || status === "trash" || status === "auto-draft") continue;
190
+ const post = {
191
+ postId: Number(tag(item, "wp:post_id")) || null,
192
+ postType,
193
+ slug: (tag(item, "wp:post_name") ?? "").trim(),
194
+ title: (tag(item, "title") ?? "").trim(),
195
+ };
196
+ if (hasGuest) {
197
+ const meta = itemMeta(item);
198
+ const name = (meta.get("_guest_author") ?? "").trim();
199
+ if (name) {
200
+ const avatarId = attachmentId(meta.get("_guest_author_avatar_id"));
201
+ out.push({
202
+ ...post,
203
+ name,
204
+ url: validUrl(meta.get("_guest_author_url") ?? ""),
205
+ bio: bioText(meta.get("_guest_author_bio") ?? ""),
206
+ avatarId,
207
+ avatarUrl: avatar(avatarId),
208
+ position: 0,
209
+ source: "guest-author",
210
+ });
211
+ // The plugin replaced the byline, so author terms don't apply.
212
+ continue;
213
+ }
214
+ }
215
+ if (!hasTerms) continue;
216
+ const authors: AuthorProfile[] = [];
217
+ const seen = new Set<string>();
218
+ for (const t of item.matchAll(/<category\s+domain="author"\s+nicename="([^"]*)"\s*>([\s\S]*?)<\/category>/g)) {
219
+ const slug = decodeSlug(t[1] as string);
220
+ const text = cdataText(t[2] as string).trim();
221
+ const known = profiles.get(slug);
222
+ const login = known?.login || (slug.startsWith("cap-") && users.has(text) ? text : "");
223
+ const profile: AuthorProfile = known ?? { name: login ? (users.get(login) as string) : text, url: "", bio: "", avatarId: null, login };
224
+ const key = nameKey(profile.name);
225
+ if (!key || seen.has(key)) continue;
226
+ seen.add(key);
227
+ authors.push({ ...profile, login });
228
+ }
229
+ if (!authors.length) continue;
230
+ const creator = (tag(item, "dc:creator") ?? "").trim();
231
+ const only = authors.length === 1 ? authors[0] : undefined;
232
+ if (only && creator && (only.login === creator || (users.has(creator) && sameName(only.name, users.get(creator) as string)))) continue;
233
+ authors.forEach((a, position) => {
234
+ out.push({ ...post, name: a.name, url: a.url, bio: a.bio, avatarId: a.avatarId, avatarUrl: avatar(a.avatarId), position, source: "co-authors" });
235
+ });
236
+ }
237
+ return out;
238
+ }
239
+
240
+ /** EmDash's collection for a WordPress post type (its importer's mapping for posts and pages). */
241
+ export function collectionFor(postType: string): string {
242
+ return postType === "post" ? "posts" : postType === "page" ? "pages" : postType;
243
+ }
244
+
245
+ /** A byline slug for a name: lowercase letters, digits and hyphens, starting with a letter. */
246
+ export function bylineSlug(name: string): string {
247
+ const slug = name
248
+ .normalize("NFKD")
249
+ .replace(/[̀-ͯ]/g, "")
250
+ .toLowerCase()
251
+ .replace(/[^a-z0-9]+/g, "-")
252
+ .replace(/^[^a-z]+|-+$/g, "")
253
+ .slice(0, 60)
254
+ .replace(/-+$/g, "");
255
+ return slug || "guest";
256
+ }
257
+
258
+ /** One byline to create (or find), with the posts it's credited on. */
259
+ export interface GuestByline {
260
+ name: string;
261
+ slug: string;
262
+ url: string;
263
+ bio: string;
264
+ /** Avatar file name (to find it in the media library) and its WordPress URL. */
265
+ avatarFile: string;
266
+ avatarUrl: string;
267
+ posts: GuestAuthor[];
268
+ }
269
+
270
+ const nameKey = (name: string) => name.normalize("NFKC").trim().replace(/\s+/g, " ").toLowerCase();
271
+
272
+ /**
273
+ * Group records by author (same name, case-insensitive). Each byline takes
274
+ * the first non-empty URL, bio and avatar among its records.
275
+ */
276
+ export function groupGuests(guests: GuestAuthor[]): GuestByline[] {
277
+ const groups = new Map<string, GuestByline>();
278
+ for (const g of guests) {
279
+ const key = nameKey(g.name);
280
+ let group = groups.get(key);
281
+ if (!group) {
282
+ group = { name: g.name.trim().replace(/\s+/g, " "), slug: bylineSlug(g.name), url: "", bio: "", avatarFile: "", avatarUrl: "", posts: [] };
283
+ groups.set(key, group);
284
+ }
285
+ group.url ||= g.url;
286
+ group.bio ||= g.bio;
287
+ if (!group.avatarUrl && g.avatarUrl) {
288
+ group.avatarUrl = g.avatarUrl;
289
+ group.avatarFile = decodeURIComponent(g.avatarUrl.split(/[?#]/)[0]?.split("/").pop() ?? "");
290
+ }
291
+ group.posts.push(g);
292
+ }
293
+ return [...groups.values()];
294
+ }
295
+
296
+ /** Each post with its authors' names in order, for crediting. */
297
+ export interface PostCredit {
298
+ collection: string;
299
+ slug: string;
300
+ title: string;
301
+ names: string[];
302
+ }
303
+
304
+ /** One entry per post (by collection and slug), its authors in order and without repeats. */
305
+ export function postCredits(guests: GuestAuthor[]): PostCredit[] {
306
+ const posts = new Map<string, { credit: PostCredit; records: GuestAuthor[] }>();
307
+ for (const g of guests) {
308
+ const collection = collectionFor(g.postType);
309
+ const key = `${collection}/${g.slug}`;
310
+ let post = posts.get(key);
311
+ if (!post) {
312
+ post = { credit: { collection, slug: g.slug, title: g.title, names: [] }, records: [] };
313
+ posts.set(key, post);
314
+ }
315
+ post.records.push(g);
316
+ }
317
+ return [...posts.values()].map(({ credit, records }) => {
318
+ for (const g of records.sort((a, b) => (a.position ?? 0) - (b.position ?? 0))) {
319
+ const name = g.name.trim().replace(/\s+/g, " ");
320
+ if (!credit.names.some((n) => sameName(n, name))) credit.names.push(name);
321
+ }
322
+ return credit;
323
+ });
324
+ }
325
+
326
+ /** Does an existing byline's name match a guest? */
327
+ export function sameName(a: string, b: string): boolean {
328
+ return nameKey(a) === nameKey(b);
329
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * A small Gutenberg block parser and serializer for the prepare step: it
3
+ * follows WordPress's block-serialization-default-parser grammar, keeps each
4
+ * untouched block's original text byte for byte, and lets the prepare step
5
+ * swap blocks for others (replace an entry in `innerBlocks` and its `null`
6
+ * slot in `innerContent` stays in place). No imports, so it runs in the browser and Node.
7
+ */
8
+
9
+ export interface GBlock {
10
+ /** Full name ("core/paragraph", "coywolf/video"), or null for HTML between blocks. */
11
+ name: string | null;
12
+ attrs: Record<string, unknown>;
13
+ /** HTML of this block without its inner blocks. */
14
+ innerHTML: string;
15
+ innerBlocks: GBlock[];
16
+ /** Text chunks, with null where each inner block goes. */
17
+ innerContent: Array<string | null>;
18
+ /** The opening comment as written ("" for freeform HTML). */
19
+ opener: string;
20
+ /** The closing comment as written ("" when void, freeform or never closed). */
21
+ closer: string;
22
+ /** Self-closing (`<!-- wp:name /-->`). */
23
+ void: boolean;
24
+ }
25
+
26
+ const TOKEN = /<!--\s+(\/)?wp:([a-z][a-z0-9_-]*\/)?([a-z][a-z0-9_-]*)\s+(\{[\s\S]*?\}\s+)?(\/)?-->/g;
27
+
28
+ const fullName = (ns: string | undefined, name: string) => `${ns ?? "core/"}${name}`;
29
+
30
+ function freeform(html: string): GBlock {
31
+ return { name: null, attrs: {}, innerHTML: html, innerBlocks: [], innerContent: [html], opener: "", closer: "", void: false };
32
+ }
33
+
34
+ interface Frame {
35
+ block: GBlock;
36
+ start: number;
37
+ /** Where the next text chunk inside this block starts. */
38
+ cursor: number;
39
+ }
40
+
41
+ /** Parse post content into blocks (top-level HTML between blocks becomes freeform blocks). */
42
+ export function parseBlocks(content: string): GBlock[] {
43
+ const out: GBlock[] = [];
44
+ const stack: Frame[] = [];
45
+ let topCursor = 0;
46
+ TOKEN.lastIndex = 0;
47
+ const pushText = (frame: Frame | undefined, end: number) => {
48
+ if (frame) {
49
+ const text = content.slice(frame.cursor, end);
50
+ if (text) {
51
+ frame.block.innerContent.push(text);
52
+ frame.block.innerHTML += text;
53
+ }
54
+ } else if (end > topCursor) {
55
+ out.push(freeform(content.slice(topCursor, end)));
56
+ }
57
+ };
58
+ for (let m = TOKEN.exec(content); m; m = TOKEN.exec(content)) {
59
+ const [whole, closer, ns, short, json, slash] = m;
60
+ const name = fullName(ns, short as string);
61
+ const start = m.index;
62
+ const end = start + whole.length;
63
+ const parent = stack[stack.length - 1];
64
+ if (closer) {
65
+ // Close the nearest open block with this name; ignore stray closers.
66
+ const at = stack.map((f) => f.block.name).lastIndexOf(name);
67
+ if (at < 0) continue;
68
+ while (stack.length - 1 > at) {
69
+ // Unclosed inner blocks: treat their opener as void-ish and keep their text.
70
+ const frame = stack.pop() as Frame;
71
+ pushText(frame, start);
72
+ const up = stack[stack.length - 1] as Frame;
73
+ up.block.innerBlocks.push(frame.block);
74
+ up.block.innerContent.push(null);
75
+ up.cursor = start;
76
+ }
77
+ const frame = stack.pop() as Frame;
78
+ pushText(frame, start);
79
+ frame.block.closer = whole;
80
+ const up = stack[stack.length - 1];
81
+ if (up) {
82
+ up.block.innerBlocks.push(frame.block);
83
+ up.block.innerContent.push(null);
84
+ up.cursor = end;
85
+ } else {
86
+ out.push(frame.block);
87
+ topCursor = end;
88
+ }
89
+ continue;
90
+ }
91
+ let attrs: Record<string, unknown> = {};
92
+ if (json) {
93
+ try {
94
+ const parsed = JSON.parse(json.trim()) as unknown;
95
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) attrs = parsed as Record<string, unknown>;
96
+ } catch {
97
+ attrs = {};
98
+ }
99
+ }
100
+ const block: GBlock = { name, attrs, innerHTML: "", innerBlocks: [], innerContent: [], opener: whole, closer: "", void: Boolean(slash) };
101
+ pushText(parent, start);
102
+ if (slash) {
103
+ if (parent) {
104
+ parent.block.innerBlocks.push(block);
105
+ parent.block.innerContent.push(null);
106
+ parent.cursor = end;
107
+ } else {
108
+ out.push(block);
109
+ topCursor = end;
110
+ }
111
+ } else {
112
+ if (parent) parent.cursor = start;
113
+ stack.push({ block, start, cursor: end });
114
+ }
115
+ }
116
+ // Unclosed blocks at the end: keep their text as they were.
117
+ while (stack.length) {
118
+ const frame = stack.pop() as Frame;
119
+ pushText(frame, content.length);
120
+ const up = stack[stack.length - 1];
121
+ if (up) {
122
+ up.block.innerBlocks.push(frame.block);
123
+ up.block.innerContent.push(null);
124
+ up.cursor = content.length;
125
+ } else {
126
+ out.push(frame.block);
127
+ topCursor = content.length;
128
+ }
129
+ }
130
+ if (topCursor < content.length) out.push(freeform(content.slice(topCursor)));
131
+ return out;
132
+ }
133
+
134
+ /** The block's rendered-ish HTML: its inner content with inner blocks' HTML filled in (no comments). */
135
+ export function blockHtml(block: GBlock): string {
136
+ let i = 0;
137
+ return block.innerContent.map((chunk) => (chunk === null ? blockHtml(block.innerBlocks[i++] as GBlock) : chunk)).join("");
138
+ }
139
+
140
+ /** A Custom HTML block (core/html) holding `html`. */
141
+ export function htmlBlock(html: string): GBlock {
142
+ const inner = `\n${html}\n`;
143
+ return { name: "core/html", attrs: {}, innerHTML: inner, innerBlocks: [], innerContent: [inner], opener: "<!-- wp:html -->", closer: "<!-- /wp:html -->", void: false };
144
+ }
145
+
146
+ /** Serialize one block. An untouched block comes back exactly as it was parsed. */
147
+ export function serializeBlock(block: GBlock): string {
148
+ if (block.void) return block.opener;
149
+ let i = 0;
150
+ const body = block.innerContent.map((chunk) => (chunk === null ? serializeBlock(block.innerBlocks[i++] as GBlock) : chunk)).join("");
151
+ return block.opener + body + block.closer;
152
+ }
153
+
154
+ /** Serialize blocks back to post content. */
155
+ export function serializeBlocks(blocks: GBlock[]): string {
156
+ return blocks.map(serializeBlock).join("");
157
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * WordPress import markers. EmDash's importer turns Gutenberg into Portable
3
+ * Text with @emdash-cms/gutenberg-to-portable-text, which has no hook for
4
+ * plugins and drops every self-closing block it doesn't know (most Coywolf
5
+ * blocks store everything in attributes, so they'd vanish). The prepare step
6
+ * (./prepare.ts) rewrites those blocks into Custom HTML blocks holding a
7
+ * marker element:
8
+ *
9
+ * <div data-coywolf-wp="NAME" data-coywolf-attrs="{…html-escaped JSON…}">fallback HTML</div>
10
+ *
11
+ * which the importer keeps verbatim as an `htmlBlock`. The convert step
12
+ * (./convert.ts) then turns markers into native Coywolf Pack blocks. The
13
+ * attributes live in the HTML itself, so they survive an editor round trip
14
+ * (the editor drops the importer's `originalAttrs`).
15
+ *
16
+ * Also reads the markers wellbeing.io's import script wrote
17
+ * (`<div data-wb-block="NAME" data-wb-attrs="{…}"></div>`).
18
+ *
19
+ * Pure, no imports: runs in the Worker, the admin (browser) and tests.
20
+ */
21
+
22
+ export interface Marker {
23
+ /** Short block name, e.g. "cloudflare-stream", "review", "anchor". */
24
+ name: string;
25
+ attrs: Record<string, unknown>;
26
+ /** HTML inside the marker element (the fallback rendering), possibly empty. */
27
+ inner: string;
28
+ /** Which marker format it came in. */
29
+ source: "coywolf" | "wellbeing";
30
+ }
31
+
32
+ const ESCAPES: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
33
+ const UNESCAPES: Record<string, string> = { "&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": '"', "&#39;": "'", "&#x27;": "'", "&apos;": "'" };
34
+
35
+ /** Escape text for an HTML attribute value or text node. */
36
+ export function escapeAttr(text: string): string {
37
+ return text.replace(/[&<>"']/g, (c) => ESCAPES[c] as string);
38
+ }
39
+
40
+ function unescapeAttr(text: string): string {
41
+ return text.replace(/&(?:amp|lt|gt|quot|#39|#x27|apos);/g, (e) => UNESCAPES[e] as string);
42
+ }
43
+
44
+ /** The marker element for a block. `inner` is trusted fallback HTML (already escaped where needed). */
45
+ export function markerHtml(name: string, attrs: Record<string, unknown>, inner = ""): string {
46
+ return `<div data-coywolf-wp="${escapeAttr(name)}" data-coywolf-attrs="${escapeAttr(JSON.stringify(attrs))}">${inner}</div>`;
47
+ }
48
+
49
+ const COYWOLF = /^\s*<div data-coywolf-wp="([a-z0-9-]+)" data-coywolf-attrs="([^"]*)">([\s\S]*)<\/div>\s*$/;
50
+ const WELLBEING = /^\s*<div data-wb-block="([a-z0-9-]+)" data-wb-attrs="([^"]*)"><\/div>\s*$/;
51
+
52
+ /** Parse a marker HTML block, or null when the HTML isn't one (or its JSON is unreadable). */
53
+ export function parseMarker(html: unknown): Marker | null {
54
+ if (typeof html !== "string" || html.length > 2_000_000) return null;
55
+ const m = html.match(COYWOLF);
56
+ const w = m ? null : html.match(WELLBEING);
57
+ const hit = m ?? w;
58
+ if (!hit) return null;
59
+ try {
60
+ const attrs = JSON.parse(unescapeAttr(hit[2] as string)) as unknown;
61
+ if (!attrs || typeof attrs !== "object" || Array.isArray(attrs)) return null;
62
+ return { name: hit[1] as string, attrs: attrs as Record<string, unknown>, inner: m ? (m[3] as string) : "", source: m ? "coywolf" : "wellbeing" };
63
+ } catch {
64
+ return null;
65
+ }
66
+ }