@coywolf/emdash 0.0.0-stage → 0.35.13

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 (261) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +201 -2
  3. package/package.json +70 -5
  4. package/src/admin/admin-css.ts +57 -0
  5. package/src/admin/ai.tsx +915 -0
  6. package/src/admin/backups.tsx +613 -0
  7. package/src/admin/breadcrumbs.tsx +542 -0
  8. package/src/admin/codeBlocks.tsx +204 -0
  9. package/src/admin/customBlocks.tsx +321 -0
  10. package/src/admin/dirty.ts +20 -0
  11. package/src/admin/discovery.tsx +463 -0
  12. package/src/admin/download.ts +28 -0
  13. package/src/admin/feature-switches.tsx +124 -0
  14. package/src/admin/features.tsx +142 -0
  15. package/src/admin/files.tsx +791 -0
  16. package/src/admin/formUploads.tsx +445 -0
  17. package/src/admin/guides.tsx +18 -0
  18. package/src/admin/headings.tsx +262 -0
  19. package/src/admin/images.tsx +825 -0
  20. package/src/admin/index.tsx +62 -0
  21. package/src/admin/links.tsx +980 -0
  22. package/src/admin/performance.tsx +330 -0
  23. package/src/admin/preview.tsx +63 -0
  24. package/src/admin/redirects-removed.tsx +266 -0
  25. package/src/admin/redirects.tsx +470 -0
  26. package/src/admin/reviews.tsx +232 -0
  27. package/src/admin/robots-bots.tsx +647 -0
  28. package/src/admin/robots-shared.tsx +109 -0
  29. package/src/admin/robots-wizard.tsx +748 -0
  30. package/src/admin/robots.tsx +911 -0
  31. package/src/admin/save-bar.tsx +134 -0
  32. package/src/admin/schema.tsx +1187 -0
  33. package/src/admin/search.tsx +355 -0
  34. package/src/admin/settings-ui.tsx +87 -0
  35. package/src/admin/trash-prompt-core.ts +135 -0
  36. package/src/admin/trash-prompt.tsx +156 -0
  37. package/src/admin/usage.tsx +301 -0
  38. package/src/admin/videos-display.tsx +248 -0
  39. package/src/admin/videos.tsx +1202 -0
  40. package/src/admin/wpImport.tsx +1516 -0
  41. package/src/ai/entities.ts +59 -0
  42. package/src/ai/export.ts +61 -0
  43. package/src/ai/logic.test.ts +187 -0
  44. package/src/ai/logic.ts +606 -0
  45. package/src/ai/pack.ts +121 -0
  46. package/src/ai/prompts.ts +87 -0
  47. package/src/ai/providers.ts +276 -0
  48. package/src/ai/queue.test.ts +97 -0
  49. package/src/ai/routes.ts +359 -0
  50. package/src/ai/store.ts +301 -0
  51. package/src/ai/wikidata.test.ts +184 -0
  52. package/src/ai/wikidata.ts +87 -0
  53. package/src/ai/worker.ts +530 -0
  54. package/src/astro/astro-modules.d.ts +6 -0
  55. package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
  56. package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
  57. package/src/astro/codeBlocks/CodeBlock.astro +62 -0
  58. package/src/astro/codeBlocks/EmDashCode.astro +61 -0
  59. package/src/astro/customBlocks/CustomBlock.astro +64 -0
  60. package/src/astro/customBlocks/Details.astro +8 -0
  61. package/src/astro/customBlocks/Disclosure.astro +8 -0
  62. package/src/astro/customBlocks/Note.astro +8 -0
  63. package/src/astro/customBlocks/Podcast.astro +8 -0
  64. package/src/astro/customBlocks/Quote.astro +8 -0
  65. package/src/astro/customBlocks/Testimonial.astro +8 -0
  66. package/src/astro/files/FileCard.astro +103 -0
  67. package/src/astro/headings/HeadingBlock.astro +129 -0
  68. package/src/astro/headings/TableOfContents.astro +45 -0
  69. package/src/astro/headings/request.ts +19 -0
  70. package/src/astro/images/ResponsiveImage.astro +60 -0
  71. package/src/astro/index.ts +84 -0
  72. package/src/astro/reviews/CoywolfReview.astro +27 -0
  73. package/src/astro/reviews/Review.astro +49 -0
  74. package/src/astro/search/SearchBox.astro +61 -0
  75. package/src/astro/search/SearchBoxForm.astro +509 -0
  76. package/src/astro/tables/CaptionedTable.astro +72 -0
  77. package/src/astro/videos/CoywolfVideo.astro +448 -0
  78. package/src/backups/download.ts +112 -0
  79. package/src/backups/dump.test.mjs +287 -0
  80. package/src/backups/dump.ts +288 -0
  81. package/src/backups/module.ts +276 -0
  82. package/src/backups/pack.ts +33 -0
  83. package/src/backups/restore.test.mjs +103 -0
  84. package/src/backups/restore.ts +209 -0
  85. package/src/backups/store.ts +409 -0
  86. package/src/breadcrumbs/pack.ts +113 -0
  87. package/src/breadcrumbs/settings.ts +102 -0
  88. package/src/breadcrumbs/stamp.ts +40 -0
  89. package/src/breadcrumbs/trail.ts +123 -0
  90. package/src/codeBlocks/contrast.ts +179 -0
  91. package/src/codeBlocks/hljs.d.ts +5 -0
  92. package/src/codeBlocks/pack.ts +110 -0
  93. package/src/codeBlocks/render.test.ts +152 -0
  94. package/src/codeBlocks/render.ts +304 -0
  95. package/src/codeBlocks/settings.ts +18 -0
  96. package/src/codeBlocks/themes.generated.ts +304 -0
  97. package/src/codeBlocks/themes.ts +114 -0
  98. package/src/core/compose.ts +244 -0
  99. package/src/core/content-url.ts +888 -0
  100. package/src/core/csv.ts +119 -0
  101. package/src/core/d1-batch.ts +65 -0
  102. package/src/core/entry-docs.ts +175 -0
  103. package/src/core/features-module.ts +48 -0
  104. package/src/core/features.ts +392 -0
  105. package/src/core/generation.ts +88 -0
  106. package/src/core/guide-content.ts +166 -0
  107. package/src/core/module.ts +104 -0
  108. package/src/core/read-failures.ts +57 -0
  109. package/src/core/request-memo.ts +71 -0
  110. package/src/core/secrets.ts +83 -0
  111. package/src/core/settings-enhance.ts +74 -0
  112. package/src/core/site.ts +36 -0
  113. package/src/core/storage.ts +27 -0
  114. package/src/customBlocks/pack.ts +260 -0
  115. package/src/customBlocks/render.ts +424 -0
  116. package/src/customBlocks/rich.ts +313 -0
  117. package/src/customBlocks/settings.ts +46 -0
  118. package/src/discovery/build.ts +252 -0
  119. package/src/discovery/feed-build.ts +217 -0
  120. package/src/discovery/feed.ts +172 -0
  121. package/src/discovery/indexnow.ts +114 -0
  122. package/src/discovery/llms.ts +79 -0
  123. package/src/discovery/markdown.ts +294 -0
  124. package/src/discovery/memory-cache.ts +97 -0
  125. package/src/discovery/middleware.ts +186 -0
  126. package/src/discovery/module.ts +353 -0
  127. package/src/discovery/news.ts +99 -0
  128. package/src/discovery/pack.ts +58 -0
  129. package/src/discovery/settings.ts +91 -0
  130. package/src/discovery/urls.ts +35 -0
  131. package/src/env.d.ts +10 -0
  132. package/src/files/card.ts +231 -0
  133. package/src/files/counts.test.ts +114 -0
  134. package/src/files/counts.ts +60 -0
  135. package/src/files/export.ts +44 -0
  136. package/src/files/format.test.ts +93 -0
  137. package/src/files/format.ts +163 -0
  138. package/src/files/module.ts +596 -0
  139. package/src/files/pack.ts +92 -0
  140. package/src/files/r2-rules.test.ts +30 -0
  141. package/src/files/r2-rules.ts +41 -0
  142. package/src/files/range.ts +50 -0
  143. package/src/files/s3.ts +114 -0
  144. package/src/files/serve.ts +141 -0
  145. package/src/files/sigv4.test.ts +64 -0
  146. package/src/files/sigv4.ts +160 -0
  147. package/src/files/site.ts +137 -0
  148. package/src/files/walker.ts +58 -0
  149. package/src/formUploads/lib.ts +266 -0
  150. package/src/formUploads/pack.ts +55 -0
  151. package/src/formUploads/wrap.ts +592 -0
  152. package/src/forms.ts +16 -0
  153. package/src/headings/headings.test.ts +159 -0
  154. package/src/headings/pack.ts +154 -0
  155. package/src/headings/render.ts +139 -0
  156. package/src/headings/settings.ts +117 -0
  157. package/src/headings/slug.ts +68 -0
  158. package/src/headings/stamp.ts +142 -0
  159. package/src/headings/test-hooks.mjs +14 -0
  160. package/src/headings/toc.ts +48 -0
  161. package/src/images/cloudflare.ts +329 -0
  162. package/src/images/lib.ts +221 -0
  163. package/src/images/module.ts +372 -0
  164. package/src/images/pack.ts +804 -0
  165. package/src/images/pending.ts +37 -0
  166. package/src/images/settings.ts +48 -0
  167. package/src/images/setup.ts +192 -0
  168. package/src/images/variants-estimate.ts +162 -0
  169. package/src/images/variants-store.ts +475 -0
  170. package/src/images/variants.ts +311 -0
  171. package/src/index.ts +143 -0
  172. package/src/links/check.ts +101 -0
  173. package/src/links/classify.ts +270 -0
  174. package/src/links/export.ts +59 -0
  175. package/src/links/module.ts +652 -0
  176. package/src/links/pack.ts +43 -0
  177. package/src/links/pt.ts +325 -0
  178. package/src/links/store.ts +704 -0
  179. package/src/middleware.ts +287 -0
  180. package/src/modules.ts +53 -0
  181. package/src/options.ts +66 -0
  182. package/src/pageCache/lib.test.mjs +224 -0
  183. package/src/pageCache/lib.ts +274 -0
  184. package/src/pageCache/pack.ts +240 -0
  185. package/src/pageCache/usage-core.ts +152 -0
  186. package/src/pageCache/usage.ts +262 -0
  187. package/src/pageCache/warm.test.mjs +670 -0
  188. package/src/pageCache/warm.ts +722 -0
  189. package/src/redirects/middleware.ts +169 -0
  190. package/src/redirects/module.ts +124 -0
  191. package/src/redirects/pack.ts +89 -0
  192. package/src/redirects/removed-core.ts +151 -0
  193. package/src/redirects/removed.ts +293 -0
  194. package/src/redirects/rules.ts +355 -0
  195. package/src/redirects/slug-changes.ts +207 -0
  196. package/src/redirects/slug-core.test.ts +50 -0
  197. package/src/redirects/slug-core.ts +49 -0
  198. package/src/redirects/transfer.ts +92 -0
  199. package/src/reviews/lib.ts +624 -0
  200. package/src/reviews/pack.ts +135 -0
  201. package/src/reviews/schema.ts +128 -0
  202. package/src/reviews/settings.ts +26 -0
  203. package/src/robots/bots.ts +321 -0
  204. package/src/robots/data/bots.json +696 -0
  205. package/src/robots/data/verified.json +582 -0
  206. package/src/robots/directory.ts +26 -0
  207. package/src/robots/explain.ts +191 -0
  208. package/src/robots/guided.ts +300 -0
  209. package/src/robots/importer.ts +263 -0
  210. package/src/robots/middleware.ts +151 -0
  211. package/src/robots/module.ts +446 -0
  212. package/src/robots/pack.ts +69 -0
  213. package/src/robots/radar.ts +129 -0
  214. package/src/robots/rep.test.ts +335 -0
  215. package/src/robots/rep.ts +362 -0
  216. package/src/robots/rules.test.ts +251 -0
  217. package/src/robots/rules.ts +883 -0
  218. package/src/robots/transfer.ts +45 -0
  219. package/src/robots/validate.ts +524 -0
  220. package/src/schema/catalog.ts +146 -0
  221. package/src/schema/graph.test.mjs +522 -0
  222. package/src/schema/graph.ts +791 -0
  223. package/src/schema/module.ts +1138 -0
  224. package/src/schema/og-image.ts +100 -0
  225. package/src/schema/pack.ts +58 -0
  226. package/src/search/engine.ts +578 -0
  227. package/src/search/fallback.ts +80 -0
  228. package/src/search/live-cache.ts +116 -0
  229. package/src/search/live-client.ts +770 -0
  230. package/src/search/live-serve.ts +282 -0
  231. package/src/search/module.ts +261 -0
  232. package/src/search/pack.ts +57 -0
  233. package/src/search/query.ts +132 -0
  234. package/src/search/ratelimit-core.ts +118 -0
  235. package/src/search/ratelimit.ts +79 -0
  236. package/src/search/snippet.ts +135 -0
  237. package/src/search/title-index.ts +139 -0
  238. package/src/search/title-match.ts +192 -0
  239. package/src/shared.ts +71 -0
  240. package/src/version.ts +2 -0
  241. package/src/videos/date.ts +40 -0
  242. package/src/videos/engagement.ts +75 -0
  243. package/src/videos/lib.ts +679 -0
  244. package/src/videos/module.ts +1160 -0
  245. package/src/videos/pack.ts +187 -0
  246. package/src/videos/pause.ts +42 -0
  247. package/src/videos/poster.ts +324 -0
  248. package/src/videos/render.ts +336 -0
  249. package/src/videos/store.ts +551 -0
  250. package/src/videos/stream.ts +134 -0
  251. package/src/wpImport/convert.ts +702 -0
  252. package/src/wpImport/guests.ts +329 -0
  253. package/src/wpImport/gutenberg.ts +157 -0
  254. package/src/wpImport/markers.ts +66 -0
  255. package/src/wpImport/pack.ts +451 -0
  256. package/src/wpImport/parents.ts +202 -0
  257. package/src/wpImport/prepare.ts +592 -0
  258. package/src/wpImport/redirects.ts +358 -0
  259. package/src/wpImport/sources.ts +67 -0
  260. package/src/wpImport/stream.ts +160 -0
  261. package/src/wpImport/urls.ts +354 -0
@@ -0,0 +1,358 @@
1
+ /**
2
+ * WordPress redirects in the Redirects module's import format. WordPress
3
+ * redirects a post's old slugs by itself (post meta `_wp_old_slug`), and
4
+ * redirect plugins keep their rules in their own tables or options; none of
5
+ * that comes over in an export. This reads:
6
+ *
7
+ * - `_wp_old_slug` from the export (wxrOldSlugRules);
8
+ * - Redirection (`wp_redirection_items` rows), Rank Math
9
+ * (`wp_rank_math_redirections` rows), Yoast SEO Premium (the
10
+ * `wpseo-premium-redirects-base` option as JSON) and Coywolf SEO
11
+ * (`wp_coywolf_seo_redirects` rows), pasted from WP-CLI.
12
+ *
13
+ * Absolute targets on the old site become site paths. Rules the Redirects
14
+ * module can't express (query-string sources, conditions on the referrer,
15
+ * login state, …) are skipped with a reason.
16
+ *
17
+ * Pure, no imports: runs in the admin (browser), a Node script and tests.
18
+ */
19
+ export interface WpRedirect {
20
+ source: string;
21
+ target: string;
22
+ type: number;
23
+ isRegex: boolean;
24
+ note?: string;
25
+ }
26
+
27
+ export interface RedirectsResult {
28
+ rules: WpRedirect[];
29
+ skipped: Array<{ source: string; reason: string }>;
30
+ }
31
+
32
+ const TYPES = new Set([301, 302, 307, 308, 410]);
33
+
34
+ /** The Redirects module's type for a WordPress status code (303 → 302, 404/451 → 410 Gone, anything else → 301). */
35
+ export function redirectType(code: unknown): number {
36
+ const n = Number(code);
37
+ if (TYPES.has(n)) return n;
38
+ if (n === 303) return 302;
39
+ if (n === 404 || n === 451) return 410;
40
+ return 301;
41
+ }
42
+
43
+ /** A target on the old site (absolute) as a site path; other targets as written. */
44
+ export function sitePath(target: string, hosts: string[]): string {
45
+ const t = target.trim();
46
+ if (!t) return "";
47
+ const m = t.match(/^(?:https?:)?\/\/([^/?#]+)(.*)$/i);
48
+ if (m && hosts.includes((m[1] as string).toLowerCase().replace(/:\d+$/, ""))) return (m[2] as string) || "/";
49
+ if (/^https?:\/\//i.test(t) || t.startsWith("/")) return t;
50
+ // Relative ("new-page/"), as Yoast and Rank Math store them.
51
+ return `/${t}`;
52
+ }
53
+
54
+ const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
55
+
56
+ const leadingSlash = (p: string) => (p.startsWith("/") ? p : `/${p}`);
57
+
58
+ /** A regex source matched against the path with a leading slash: `^old/(.*)` → `^/old/(.*)`. */
59
+ function anchorRegex(pattern: string): string {
60
+ const p = pattern.trim();
61
+ if (p.startsWith("^") && !p.startsWith("^/") && !p.startsWith("^\\/") && !p.startsWith("^(")) return `^/${p.slice(1)}`;
62
+ return p;
63
+ }
64
+
65
+ function validRegex(source: string): boolean {
66
+ try {
67
+ new RegExp(source);
68
+ return true;
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ /** Add a rule, or say why not. */
75
+ function push(out: RedirectsResult, rule: WpRedirect): void {
76
+ const source = rule.source.trim();
77
+ if (!source) return;
78
+ if (rule.isRegex) {
79
+ if (!validRegex(source)) {
80
+ out.skipped.push({ source, reason: "The pattern isn't a valid JavaScript regular expression" });
81
+ return;
82
+ }
83
+ } else {
84
+ if (!source.startsWith("/") || source.startsWith("//")) {
85
+ out.skipped.push({ source, reason: "The source isn't a site path" });
86
+ return;
87
+ }
88
+ if (source.includes("?")) {
89
+ out.skipped.push({ source, reason: "Sources with a query string aren't supported" });
90
+ return;
91
+ }
92
+ }
93
+ if (rule.type !== 410) {
94
+ if (!rule.target) {
95
+ out.skipped.push({ source, reason: "No destination" });
96
+ return;
97
+ }
98
+ if (!rule.isRegex && rule.target.startsWith("/") && rule.target.split("?")[0]?.replace(/\/+$/, "") === source.replace(/\/+$/, "")) {
99
+ out.skipped.push({ source, reason: "Redirects to itself" });
100
+ return;
101
+ }
102
+ }
103
+ out.rules.push({ ...rule, source, target: rule.type === 410 ? "" : rule.target });
104
+ }
105
+
106
+ // ── PHP serialize (Rank Math stores its sources serialized) ─────
107
+
108
+ /** A small reader for PHP's serialize() format: strings, numbers, booleans, null and arrays. Returns undefined when it can't read it. */
109
+ export function phpUnserialize(input: string): unknown {
110
+ let i = 0;
111
+ const bytes = new TextEncoder().encode(input);
112
+ const decoder = new TextDecoder();
113
+ const readUntil = (ch: string) => {
114
+ const code = ch.charCodeAt(0);
115
+ const start = i;
116
+ while (i < bytes.length && bytes[i] !== code) i++;
117
+ if (i >= bytes.length) throw new Error("Unexpected end");
118
+ const out = decoder.decode(bytes.slice(start, i));
119
+ i++;
120
+ return out;
121
+ };
122
+ const expect = (ch: string) => {
123
+ if (bytes[i] !== ch.charCodeAt(0)) throw new Error(`Expected ${ch}`);
124
+ i++;
125
+ };
126
+ const read = (depth: number): unknown => {
127
+ if (depth > 32) throw new Error("Too deep");
128
+ const type = String.fromCharCode(bytes[i] as number);
129
+ i++;
130
+ if (type === "N") {
131
+ expect(";");
132
+ return null;
133
+ }
134
+ expect(":");
135
+ switch (type) {
136
+ case "b":
137
+ return readUntil(";") === "1";
138
+ case "i":
139
+ case "d":
140
+ return Number(readUntil(";"));
141
+ case "s": {
142
+ const len = Number(readUntil(":"));
143
+ expect('"');
144
+ const value = decoder.decode(bytes.slice(i, i + len));
145
+ i += len;
146
+ expect('"');
147
+ expect(";");
148
+ return value;
149
+ }
150
+ case "a": {
151
+ const count = Number(readUntil(":"));
152
+ expect("{");
153
+ const entries: Array<[string | number, unknown]> = [];
154
+ for (let n = 0; n < count; n++) {
155
+ const key = read(depth + 1) as string | number;
156
+ entries.push([key, read(depth + 1)]);
157
+ }
158
+ expect("}");
159
+ const isList = entries.every(([k], n) => k === n);
160
+ return isList ? entries.map(([, v]) => v) : Object.fromEntries(entries);
161
+ }
162
+ default:
163
+ throw new Error(`Unsupported type ${type}`);
164
+ }
165
+ };
166
+ try {
167
+ const value = read(0);
168
+ return i === bytes.length ? value : undefined;
169
+ } catch {
170
+ return undefined;
171
+ }
172
+ }
173
+
174
+ // ── Plugins ──────────────────────────────────────────────────────
175
+
176
+ type Row = Record<string, string | number | null | undefined>;
177
+ const s = (v: unknown) => (v === null || v === undefined ? "" : String(v)).trim();
178
+
179
+ /**
180
+ * Redirection plugin: rows of
181
+ * `SELECT url, action_data, action_code, action_type, match_type, regex, status FROM wp_redirection_items`.
182
+ */
183
+ export function redirectionRules(rows: Row[], hosts: string[] = []): RedirectsResult {
184
+ const out: RedirectsResult = { rules: [], skipped: [] };
185
+ for (const row of rows) {
186
+ const source = s(row.url);
187
+ if (!source) continue;
188
+ if (s(row.status) && s(row.status) !== "enabled") continue;
189
+ const matchType = s(row.match_type) || "url";
190
+ if (matchType !== "url") {
191
+ out.skipped.push({ source, reason: `Redirection's “${matchType}” condition can't be expressed` });
192
+ continue;
193
+ }
194
+ const action = s(row.action_type) || "url";
195
+ const code = Number(s(row.action_code)) || 301;
196
+ const isRegex = s(row.regex) === "1";
197
+ if (action === "error") {
198
+ if (code === 410 || code === 451) push(out, { source: isRegex ? anchorRegex(source) : leadingSlash(source), target: "", type: 410, isRegex, note: "Redirection" });
199
+ else out.skipped.push({ source, reason: `Error ${code} (pages that 404 need no rule)` });
200
+ continue;
201
+ }
202
+ if (action !== "url") {
203
+ out.skipped.push({ source, reason: `Redirection's “${action}” action can't be expressed` });
204
+ continue;
205
+ }
206
+ push(out, { source: isRegex ? anchorRegex(source) : leadingSlash(source), target: sitePath(s(row.action_data), hosts), type: redirectType(code), isRegex, note: "Redirection" });
207
+ }
208
+ return out;
209
+ }
210
+
211
+ /**
212
+ * Rank Math: rows of
213
+ * `SELECT sources, url_to, header_code, status FROM wp_rank_math_redirections`.
214
+ * Each row can hold several sources (exact, start, end, contains or regex).
215
+ */
216
+ export function rankMathRules(rows: Row[], hosts: string[] = []): RedirectsResult {
217
+ const out: RedirectsResult = { rules: [], skipped: [] };
218
+ for (const row of rows) {
219
+ if (s(row.status) && s(row.status) !== "active") continue;
220
+ const sources = phpUnserialize(s(row.sources));
221
+ if (!Array.isArray(sources)) {
222
+ out.skipped.push({ source: s(row.sources).slice(0, 80), reason: "Couldn't read the sources" });
223
+ continue;
224
+ }
225
+ const type = redirectType(s(row.header_code) || 301);
226
+ const target = type === 410 ? "" : sitePath(s(row.url_to), hosts);
227
+ for (const src of sources) {
228
+ if (!src || typeof src !== "object") continue;
229
+ const pattern = s((src as Record<string, unknown>).pattern).replace(/^\/+/, "");
230
+ const comparison = s((src as Record<string, unknown>).comparison) || "exact";
231
+ if (!pattern) continue;
232
+ const note = "Rank Math";
233
+ if (comparison === "exact") push(out, { source: `/${pattern}`, target, type, isRegex: false, note });
234
+ else if (comparison === "regex") push(out, { source: anchorRegex(s((src as Record<string, unknown>).pattern)), target, type, isRegex: true, note });
235
+ else if (comparison === "start") push(out, { source: `^/${escapeRegex(pattern)}`, target, type, isRegex: true, note });
236
+ else if (comparison === "end") push(out, { source: `${escapeRegex(pattern)}$`, target, type, isRegex: true, note });
237
+ else if (comparison === "contains") push(out, { source: escapeRegex(pattern), target, type, isRegex: true, note });
238
+ else out.skipped.push({ source: pattern, reason: `Unknown comparison “${comparison}”` });
239
+ }
240
+ }
241
+ return out;
242
+ }
243
+
244
+ /** Yoast SEO Premium: `wp option get wpseo-premium-redirects-base --format=json` (a list of { origin, url, type, format }). */
245
+ export function yoastRules(value: unknown, hosts: string[] = []): RedirectsResult {
246
+ const out: RedirectsResult = { rules: [], skipped: [] };
247
+ const list = Array.isArray(value) ? value : value && typeof value === "object" ? Object.values(value) : [];
248
+ for (const item of list) {
249
+ if (!item || typeof item !== "object") continue;
250
+ const r = item as Record<string, unknown>;
251
+ const origin = s(r.origin);
252
+ if (!origin) continue;
253
+ const type = redirectType(r.type);
254
+ const isRegex = s(r.format) === "regex";
255
+ push(out, { source: isRegex ? anchorRegex(origin) : leadingSlash(origin), target: type === 410 ? "" : sitePath(s(r.url), hosts), type, isRegex, note: "Yoast" });
256
+ }
257
+ return out;
258
+ }
259
+
260
+ /** Coywolf SEO: rows of `SELECT source, target, type, is_regex FROM wp_coywolf_seo_redirects`. */
261
+ export function coywolfSeoRules(rows: Row[], hosts: string[] = []): RedirectsResult {
262
+ const out: RedirectsResult = { rules: [], skipped: [] };
263
+ for (const row of rows) {
264
+ const source = s(row.source);
265
+ if (!source) continue;
266
+ const isRegex = s(row.is_regex) === "1";
267
+ const type = redirectType(s(row.type) || 301);
268
+ push(out, { source: isRegex ? source : leadingSlash(source), target: type === 410 ? "" : sitePath(s(row.target), hosts), type, isRegex, note: "Coywolf SEO" });
269
+ }
270
+ return out;
271
+ }
272
+
273
+ // ── Old slugs ────────────────────────────────────────────────────
274
+
275
+ const CDATA = /<!\[CDATA\[([\s\S]*?)\]\]>/g;
276
+ function tag(item: string, name: string): string {
277
+ const m = item.match(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`));
278
+ if (!m) return "";
279
+ const body = m[1] as string;
280
+ if (!body.includes("<![CDATA[")) return body.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&").trim();
281
+ let out = "";
282
+ for (const c of body.matchAll(CDATA)) out += c[1];
283
+ return out.trim();
284
+ }
285
+
286
+ /** Every value of a meta key on an item (a key can repeat, as _wp_old_slug does). */
287
+ function metaValues(item: string, key: string): string[] {
288
+ const out: string[] = [];
289
+ for (const m of item.matchAll(/<wp:postmeta>([\s\S]*?)<\/wp:postmeta>/g)) {
290
+ if (tag(m[1] as string, "wp:meta_key") === key) out.push(tag(m[1] as string, "wp:meta_value"));
291
+ }
292
+ return out;
293
+ }
294
+
295
+ const pathOf = (url: string): string | null => {
296
+ try {
297
+ return new URL(url).pathname;
298
+ } catch {
299
+ return null;
300
+ }
301
+ };
302
+
303
+ const SKIP_TYPES = new Set(["attachment", "nav_menu_item", "revision", "wp_block", "wp_template", "wp_template_part", "wp_navigation", "wp_global_styles", "customize_changeset", "oembed_cache", "custom_css", "user_request"]);
304
+
305
+ /**
306
+ * A redirect from each old slug (`_wp_old_slug`) of a published entry to its
307
+ * current URL, both as WordPress built them: the entry's permalink in the
308
+ * export (`<link>`) with its slug swapped for the old one. Old URLs that are
309
+ * some other entry's current URL are skipped.
310
+ */
311
+ export function wxrOldSlugRules(xml: string): RedirectsResult {
312
+ const out: RedirectsResult = { rules: [], skipped: [] };
313
+ const current = new Set<string>();
314
+ const candidates: WpRedirect[] = [];
315
+ for (const m of xml.matchAll(/<item>([\s\S]*?)<\/item>/g)) {
316
+ const item = m[1] as string;
317
+ const type = tag(item, "wp:post_type");
318
+ if (SKIP_TYPES.has(type) || tag(item, "wp:status") !== "publish") continue;
319
+ const path = pathOf(tag(item, "link"));
320
+ if (!path) continue;
321
+ current.add(path.replace(/\/+$/, ""));
322
+ if (!item.includes("_wp_old_slug")) continue;
323
+ const slug = tag(item, "wp:post_name");
324
+ const segments = path.split("/");
325
+ const last = segments.length - (path.endsWith("/") ? 2 : 1);
326
+ if (!slug || last < 1 || segments[last] !== slug) continue;
327
+ for (const old of new Set(metaValues(item, "_wp_old_slug"))) {
328
+ if (!old || old === slug || !/^[^/?#\s]+$/.test(old)) continue;
329
+ const oldSegments = segments.slice();
330
+ oldSegments[last] = old;
331
+ candidates.push({ source: oldSegments.join("/"), target: path, type: 301, isRegex: false, note: "Old slug" });
332
+ }
333
+ }
334
+ const seen = new Set<string>();
335
+ for (const rule of candidates) {
336
+ const key = rule.source.replace(/\/+$/, "");
337
+ if (current.has(key)) {
338
+ out.skipped.push({ source: rule.source, reason: "Another entry uses this URL now" });
339
+ continue;
340
+ }
341
+ if (seen.has(key)) continue;
342
+ seen.add(key);
343
+ push(out, rule);
344
+ }
345
+ return out;
346
+ }
347
+
348
+ /** Merge rule lists: the first rule for a source wins. */
349
+ export function mergeRedirects(...lists: WpRedirect[][]): WpRedirect[] {
350
+ const out = new Map<string, WpRedirect>();
351
+ for (const list of lists) {
352
+ for (const rule of list) {
353
+ const key = `${rule.isRegex ? "re:" : ""}${rule.isRegex ? rule.source : rule.source.replace(/\/+$/, "") || "/"}`;
354
+ if (!out.has(key)) out.set(key, rule);
355
+ }
356
+ }
357
+ return [...out.values()];
358
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Reading data exported from WordPress (options and table rows pasted on the
3
+ * WordPress import page). Pure, no imports beyond types.
4
+ */
5
+ import type { FileDefaults, VideoDefaults } from "./convert.js";
6
+
7
+ export interface ImportDefaults {
8
+ video?: Partial<VideoDefaults>;
9
+ files?: Partial<FileDefaults>;
10
+ }
11
+
12
+ const bool = (v: unknown) => (typeof v === "boolean" ? v : v === 1 || v === "1" ? true : v === 0 || v === "0" || v === "" ? false : undefined);
13
+
14
+ /** Video Manager's coywolf_cvm_settings and Coywolf Files' coywolf_files_settings (as JSON) → converter defaults. */
15
+ export function defaultsFromWordPress(cvm: unknown, files: unknown): ImportDefaults {
16
+ const out: ImportDefaults = {};
17
+ if (cvm && typeof cvm === "object") {
18
+ const c = cvm as Record<string, unknown>;
19
+ const video: Partial<VideoDefaults> = {};
20
+ const map: Array<[keyof VideoDefaults, string]> = [
21
+ ["controls", "controls"],
22
+ ["autoplay", "autoplay"],
23
+ ["loop", "loop"],
24
+ ["mute", "mute"],
25
+ ["showName", "show_title"],
26
+ ["showDescription", "show_desc"],
27
+ ["showDate", "show_date"],
28
+ ["showPlays", "plays_enabled"],
29
+ ["showLikes", "likes_enabled"],
30
+ ["showLikeCount", "likes_show_count"],
31
+ ];
32
+ for (const [to, from] of map) {
33
+ const v = bool(c[from]);
34
+ if (v !== undefined) (video as Record<string, unknown>)[to] = v;
35
+ }
36
+ if (c.preload === "auto" || c.preload === "metadata" || c.preload === "none") video.preload = c.preload;
37
+ out.video = video;
38
+ }
39
+ if (files && typeof files === "object") {
40
+ const f = files as Record<string, unknown>;
41
+ const card: Partial<FileDefaults> = {};
42
+ const map: Array<[keyof FileDefaults, string]> = [
43
+ ["showIcon", "show_icon"],
44
+ ["showDescription", "show_description"],
45
+ ["showMeta", "show_meta"],
46
+ ["showDownload", "show_download"],
47
+ ["showCopyLink", "show_copy_link"],
48
+ ];
49
+ for (const [to, from] of map) {
50
+ const v = bool(f[from]);
51
+ if (v !== undefined) card[to] = v;
52
+ }
53
+ out.files = card;
54
+ }
55
+ return out;
56
+ }
57
+
58
+
59
+ /** `wp db query` output (tab-separated, header row) or JSON → rows. */
60
+ export function parseFileRows(text: string): Array<Record<string, string>> {
61
+ const trimmed = text.trim();
62
+ if (trimmed.startsWith("[")) return JSON.parse(trimmed) as Array<Record<string, string>>;
63
+ const lines = trimmed.split(/\r?\n/).filter((l) => l.trim());
64
+ const header = (lines.shift() ?? "").split("\t").map((h) => h.trim());
65
+ return lines.map((line) => Object.fromEntries(line.split("\t").map((v, i) => [header[i] ?? `c${i}`, v.trim()])));
66
+ }
67
+
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Read a WordPress Cloudflare Stream embed (a Custom HTML block) into the
3
+ * player settings the Coywolf Video block uses. Handles the embed shapes
4
+ * Cloudflare's dashboard and WordPress plugins produce (found on Coywolf's sites):
5
+ *
6
+ * - the responsive wrapper `<div style="position: relative; padding-top: 56.25%">`
7
+ * around a Stream `<iframe>` (customer subdomain or iframe.videodelivery.net),
8
+ * with player options in the query string (autoplay, loop, muted,
9
+ * controls=false, preload, poster=…?time=…);
10
+ * - the older `<stream src="UID" autoplay loop mute>` element plus its script;
11
+ * - a wrapper whose iframe was stripped long ago (only the sizing survives),
12
+ * optionally in a `<figure>` with a caption;
13
+ * - `max-width:360px` / `width:344px` wrappers (phone recordings).
14
+ *
15
+ * Pure, no imports.
16
+ */
17
+
18
+ export interface StreamEmbed {
19
+ uid: string | null;
20
+ host: string | null;
21
+ /** padding-top percentage (height / width × 100). */
22
+ aspect: number | null;
23
+ /** A fixed or maximum width in px from the wrapper. */
24
+ maxWidth: number | null;
25
+ autoplay: boolean;
26
+ loop: boolean;
27
+ muted: boolean;
28
+ controls: boolean;
29
+ preload: "auto" | "metadata" | "none" | null;
30
+ posterTime: number | null;
31
+ startTime: number | null;
32
+ /** <figcaption> text. */
33
+ caption: string | null;
34
+ /** The iframe's title attribute. */
35
+ title: string | null;
36
+ }
37
+
38
+ const UID = /^[0-9a-f]{32}$/;
39
+ const ENTITIES: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", "#39": "'", "#039": "'", "#8217": "’", "#8216": "‘", "#8220": "“", "#8221": "”", "#8211": "–", "#8212": "—" };
40
+
41
+ export function decodeHtml(text: string): string {
42
+ return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (all, e: string) => {
43
+ const named = ENTITIES[e.toLowerCase()];
44
+ if (named) return named;
45
+ if (e[0] === "#") {
46
+ const n = e[1] === "x" || e[1] === "X" ? Number.parseInt(e.slice(2), 16) : Number(e.slice(1));
47
+ return Number.isFinite(n) && n > 0 && n < 0x110000 ? String.fromCodePoint(n) : all;
48
+ }
49
+ return all;
50
+ });
51
+ }
52
+
53
+ export const textOf = (html: string) =>
54
+ decodeHtml(html.replace(/<(script|style)\b[\s\S]*?<\/\1\s*>/gi, "").replace(/<[^>]*>/g, " "))
55
+ .replace(/\s+/g, " ")
56
+ .trim();
57
+
58
+ const attr = (tag: string, name: string): string | null => {
59
+ const m = tag.match(new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i"));
60
+ return m ? decodeHtml(m[1] ?? m[2] ?? m[3] ?? "") : null;
61
+ };
62
+ const hasAttr = (tag: string, name: string) => new RegExp(`\\s${name}(?:\\s*=|[\\s>/])`, "i").test(tag);
63
+
64
+ const truthy = (v: string | null) => v !== null && v !== "false" && v !== "0";
65
+
66
+ function secondsParam(value: string | null): number | null {
67
+ if (!value) return null;
68
+ const m = value.trim().match(/^(\d+(?:\.\d+)?)s?$/);
69
+ return m ? Number(m[1]) : null;
70
+ }
71
+
72
+ /** Whether a Custom HTML block is a Stream embed (iframe or <stream> element). */
73
+ export function hasStreamPlayer(html: string): boolean {
74
+ return /(?:cloudflarestream\.com|videodelivery\.net)\/[0-9a-f]{32}|<stream\s[^>]*src="[0-9a-f]{32}"/i.test(html);
75
+ }
76
+
77
+ /**
78
+ * Parse an embed. Returns null when the HTML holds anything besides the
79
+ * player, its wrapper, script and caption (so real content is never folded
80
+ * into a video block), or when there's neither a player nor a sizing wrapper.
81
+ */
82
+ export function parseStreamEmbed(html: string): StreamEmbed | null {
83
+ if (typeof html !== "string" || html.length > 20_000) return null;
84
+ const caption = html.match(/<figcaption\b[^>]*>([\s\S]*?)<\/figcaption>/i);
85
+ const rest = html
86
+ .replace(/<figcaption\b[^>]*>[\s\S]*?<\/figcaption>/gi, "")
87
+ .replace(/<script\b[\s\S]*?<\/script\s*>/gi, "")
88
+ .replace(/<iframe\b[\s\S]*?<\/iframe\s*>/gi, "")
89
+ .replace(/<stream\b[\s\S]*?<\/stream\s*>/gi, "");
90
+ if (textOf(rest)) return null;
91
+ if (/<(?!\/?(?:div|figure|p|br)\b)[a-z]/i.test(rest)) return null;
92
+
93
+ const embed: StreamEmbed = {
94
+ uid: null,
95
+ host: null,
96
+ aspect: null,
97
+ maxWidth: null,
98
+ autoplay: false,
99
+ loop: false,
100
+ muted: false,
101
+ controls: true,
102
+ preload: null,
103
+ posterTime: null,
104
+ startTime: null,
105
+ caption: caption ? textOf(caption[1] as string) || null : null,
106
+ title: null,
107
+ };
108
+
109
+ const pad = html.match(/padding-top:\s*([\d.]+)%/i);
110
+ if (pad) embed.aspect = Number(pad[1]);
111
+ const width = html.match(/(?:max-width|[^-]width):\s*(\d+)px/i);
112
+ if (width) embed.maxWidth = Number(width[1]);
113
+
114
+ const iframe = html.match(/<iframe\b[^>]*>/i)?.[0];
115
+ const stream = html.match(/<stream\b[^>]*>/i)?.[0];
116
+ if (iframe) {
117
+ const src = attr(iframe, "src") ?? "";
118
+ const m = src.match(/^(?:https?:)?\/\/(customer-[a-z0-9]+\.cloudflarestream\.com|iframe\.videodelivery\.net)\/([0-9a-f]{32})(?:\/iframe)?\/?(?:\?(.*))?$/i);
119
+ if (!m) return null;
120
+ embed.uid = (m[2] as string).toLowerCase();
121
+ embed.host = /^customer-/i.test(m[1] as string) ? (m[1] as string).toLowerCase() : null;
122
+ const params = new URLSearchParams(m[3] ?? "");
123
+ embed.autoplay = truthy(params.get("autoplay"));
124
+ embed.loop = truthy(params.get("loop"));
125
+ embed.muted = truthy(params.get("muted"));
126
+ embed.controls = params.get("controls") !== "false";
127
+ const preload = params.get("preload");
128
+ embed.preload = preload === "true" || preload === "auto" ? "auto" : preload === "none" ? "none" : preload === "metadata" ? "metadata" : null;
129
+ const poster = params.get("poster");
130
+ if (poster) {
131
+ try {
132
+ embed.posterTime = secondsParam(new URL(poster).searchParams.get("time"));
133
+ } catch {
134
+ embed.posterTime = null;
135
+ }
136
+ }
137
+ embed.startTime = secondsParam(params.get("startTime"));
138
+ embed.title = attr(iframe, "title") || null;
139
+ } else if (stream) {
140
+ const uid = (attr(stream, "src") ?? "").toLowerCase();
141
+ if (!UID.test(uid)) return null;
142
+ embed.uid = uid;
143
+ embed.autoplay = hasAttr(stream, "autoplay");
144
+ embed.loop = hasAttr(stream, "loop");
145
+ embed.muted = hasAttr(stream, "mute") || hasAttr(stream, "muted");
146
+ embed.controls = hasAttr(stream, "controls") || !embed.autoplay;
147
+ embed.preload = hasAttr(stream, "preload") ? "auto" : null;
148
+ const poster = attr(stream, "poster");
149
+ if (poster) {
150
+ try {
151
+ embed.posterTime = secondsParam(new URL(poster).searchParams.get("time"));
152
+ } catch {
153
+ embed.posterTime = null;
154
+ }
155
+ }
156
+ } else if (embed.aspect === null && embed.maxWidth === null) {
157
+ return null;
158
+ }
159
+ return embed;
160
+ }