@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,679 @@
1
+ /**
2
+ * Videos module: pure helpers (no I/O, no imports) for Stream URLs, the
3
+ * coywolf-video block, VideoObject schema, the video sitemap, Portable Text
4
+ * walking and webhook signatures. Unit-tested in test/videos.test.mjs.
5
+ */
6
+
7
+ export const BLOCK_TYPE = "coywolf-video";
8
+ /** Thumbnail width for schema and the sitemap (Google wants large images). */
9
+ export const SCHEMA_THUMB_WIDTH = 1200;
10
+ const UID = /^[0-9a-f]{32}$/;
11
+ const HOST = /^customer-[a-z0-9]+\.cloudflarestream\.com$/;
12
+ const HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
13
+
14
+ export const isUid = (value: unknown): value is string => typeof value === "string" && UID.test(value);
15
+ export const isHex = (value: unknown): value is string => typeof value === "string" && HEX.test(value);
16
+
17
+ // ── Stream URLs ──────────────────────────────────────────────────
18
+
19
+ /** "customer-abc.cloudflarestream.com", a URL on it, or the bare code → the host; anything else → null. */
20
+ export function normalizeCustomerHost(value: unknown): string | null {
21
+ if (typeof value !== "string") return null;
22
+ let v = value.trim().toLowerCase();
23
+ if (!v) return null;
24
+ v = v.replace(/^https?:\/\//, "").split("/")[0];
25
+ if (/^[a-z0-9]+$/.test(v)) v = `customer-${v}.cloudflarestream.com`;
26
+ return HOST.test(v) ? v : null;
27
+ }
28
+
29
+ /** Pull the customer host out of any Stream delivery URL (thumbnail, preview, HLS). */
30
+ export function customerHostFromUrl(url: unknown): string | null {
31
+ if (typeof url !== "string") return null;
32
+ const m = url.match(/\/\/(customer-[a-z0-9]+\.cloudflarestream\.com)\//i);
33
+ return m ? m[1].toLowerCase() : null;
34
+ }
35
+
36
+ type Params = Record<string, string | number | boolean | null | undefined>;
37
+
38
+ function query(params: Params): string {
39
+ const q = new URLSearchParams();
40
+ for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== "") q.set(k, String(v));
41
+ const s = q.toString();
42
+ return s ? `?${s}` : "";
43
+ }
44
+
45
+ /** Iframe player URL. Without a customer host, Stream's account-independent hostnames work too. */
46
+ export function iframeUrl(host: string | null, uid: string, params: Params = {}): string {
47
+ const base = host ? `https://${host}/${uid}/iframe` : `https://iframe.videodelivery.net/${uid}`;
48
+ return base + query(params);
49
+ }
50
+
51
+ export function thumbnailUrl(host: string | null, uid: string, params: Params = {}): string {
52
+ const base = host ? `https://${host}/${uid}` : `https://videodelivery.net/${uid}`;
53
+ return `${base}/thumbnails/thumbnail.jpg${query(params)}`;
54
+ }
55
+
56
+ export function watchUrl(host: string | null, uid: string): string {
57
+ return host ? `https://${host}/${uid}/watch` : `https://iframe.videodelivery.net/${uid}`;
58
+ }
59
+
60
+ /** An http(s) URL or a root-relative path (EmDash media is /_emdash/api/media/file/<key>). */
61
+ export function isImageUrl(value: unknown): value is string {
62
+ return typeof value === "string" && (/^https?:\/\/[^\s]+$/i.test(value) || /^\/(?!\/)[^\s]*$/.test(value));
63
+ }
64
+
65
+ /** Resolve a root-relative URL against the site origin; absolute http(s) URLs pass through; anything else → undefined. */
66
+ export function absoluteUrl(url: unknown, origin?: string | null): string | undefined {
67
+ if (!isImageUrl(url)) return undefined;
68
+ if (/^https?:/i.test(url)) return url;
69
+ if (!origin) return undefined;
70
+ try {
71
+ return new URL(url, origin).href;
72
+ } catch {
73
+ return undefined;
74
+ }
75
+ }
76
+
77
+ /** Largest caption track kept (D1 rows max out at 2 MB). */
78
+ export const MAX_CAPTION_BYTES = 1_500_000;
79
+ export const utf8Bytes = (text: string) => new TextEncoder().encode(text).byteLength;
80
+
81
+ // ── Tags ─────────────────────────────────────────────────────────
82
+
83
+ /** Most tags kept on a video, and the longest tag (Video Manager's limits). */
84
+ export const MAX_TAGS = 25;
85
+ export const MAX_TAG_LENGTH = 50;
86
+
87
+ /**
88
+ * Tags from a comma/newline list or an array, as Video Manager keeps them in
89
+ * Stream's meta.tags: no leading #, spaces as hyphens, only letters, digits,
90
+ * _ . and -, de-duplicated without regard to case.
91
+ */
92
+ export function parseTags(raw: unknown): string[] {
93
+ const parts = Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split(/[,\r\n]+/) : [];
94
+ const out: string[] = [];
95
+ const seen = new Set<string>();
96
+ for (const part of parts) {
97
+ let tag = String(part ?? "").trim().replace(/^#+/, "").replace(/\s+/g, "-").replace(/[^A-Za-z0-9_.-]+/g, "").replace(/^[-.]+|[-.]+$/g, "");
98
+ if (!tag) continue;
99
+ tag = tag.slice(0, MAX_TAG_LENGTH);
100
+ const key = tag.toLowerCase();
101
+ if (seen.has(key)) continue;
102
+ seen.add(key);
103
+ out.push(tag);
104
+ if (out.length >= MAX_TAGS) break;
105
+ }
106
+ return out;
107
+ }
108
+
109
+ // ── Formatting ───────────────────────────────────────────────────
110
+
111
+ /** Seconds → ISO 8601 duration (PT1H2M3S). Zero or invalid → null. */
112
+ export function isoDuration(seconds: unknown): string | null {
113
+ const n = typeof seconds === "number" ? seconds : Number(seconds);
114
+ if (!Number.isFinite(n) || n <= 0) return null;
115
+ const total = Math.max(1, Math.round(n));
116
+ const h = Math.floor(total / 3600);
117
+ const m = Math.floor((total % 3600) / 60);
118
+ const s = total % 60;
119
+ return `PT${h ? `${h}H` : ""}${m ? `${m}M` : ""}${s || (!h && !m) ? `${s}S` : ""}`;
120
+ }
121
+
122
+ /** Seconds → "1:02:03" / "4:05" for display. */
123
+ export function clockDuration(seconds: unknown): string {
124
+ const n = typeof seconds === "number" ? seconds : Number(seconds);
125
+ if (!Number.isFinite(n) || n <= 0) return "";
126
+ const total = Math.round(n);
127
+ const h = Math.floor(total / 3600);
128
+ const m = Math.floor((total % 3600) / 60);
129
+ const s = String(total % 60).padStart(2, "0");
130
+ return h ? `${h}:${String(m).padStart(2, "0")}:${s}` : `${m}:${s}`;
131
+ }
132
+
133
+ const ENTITIES: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" };
134
+ export const escapeXml = (text: string) => text.replace(/[&<>"']/g, (c) => ENTITIES[c]);
135
+
136
+ const NAMED: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " };
137
+
138
+ /** Strip tags and decode the common entities (descriptions may hold simple HTML). */
139
+ export function plainText(html: unknown): string {
140
+ if (typeof html !== "string") return "";
141
+ return html
142
+ .replace(/<[^>]*>/g, " ")
143
+ .replace(/&(#\d+|#x[0-9a-f]+|[a-z]+);/gi, (all, e: string) => {
144
+ if (e[0] === "#") {
145
+ const code = e[1] === "x" || e[1] === "X" ? Number.parseInt(e.slice(2), 16) : Number(e.slice(1));
146
+ return Number.isFinite(code) && code > 0 && code < 0x110000 ? String.fromCodePoint(code) : all;
147
+ }
148
+ return NAMED[e.toLowerCase()] ?? all;
149
+ })
150
+ .replace(/\s+/g, " ")
151
+ .trim();
152
+ }
153
+
154
+ /** WebVTT → running prose (headers, NOTE/STYLE/REGION blocks, cue ids, timings and tags removed). */
155
+ export function vttToTranscript(vtt: string): string {
156
+ const pieces: string[] = [];
157
+ const text = vtt.replace(/^/, "").replace(/\r\n?/g, "\n");
158
+ for (const block of text.split(/\n{2,}/)) {
159
+ const lines = block.trim().split("\n");
160
+ if (/^(WEBVTT|NOTE|STYLE|REGION)/.test(lines[0] ?? "")) continue;
161
+ const timing = lines.findIndex((l) => l.includes("-->"));
162
+ if (timing < 0) continue;
163
+ const cue = lines.slice(timing + 1).join(" ").trim();
164
+ if (cue) pieces.push(cue);
165
+ }
166
+ return plainText(pieces.join(" "));
167
+ }
168
+
169
+ // ── The coywolf-video block ─────────────────────────────────────
170
+
171
+ export interface VideoBlock {
172
+ _type?: string;
173
+ _key?: string;
174
+ uid?: string;
175
+ /** "standard" or "gif" (muted, autoplaying, looping, no controls: like an animated GIF). */
176
+ preset?: string;
177
+ title?: string;
178
+ caption?: string;
179
+ posterTime?: number;
180
+ posterImage?: string;
181
+ startTime?: number;
182
+ controls?: boolean;
183
+ autoplay?: boolean;
184
+ loop?: boolean;
185
+ muted?: boolean;
186
+ preload?: string;
187
+ sizeMode?: string;
188
+ maxWidth?: number;
189
+ /**
190
+ * Show/hide choices: "show", "hide", or empty/missing for the site default
191
+ * (Videos → Settings). Booleans are from before the site defaults existed
192
+ * (the old on/off toggles, and WordPress imports): kept as they were
193
+ * unless the site is set to make every block follow its defaults.
194
+ */
195
+ showName?: BlockShow;
196
+ showDescription?: BlockShow;
197
+ showPlays?: BlockShow;
198
+ showLikes?: BlockShow;
199
+ showLikeCount?: BlockShow;
200
+ /** Per-video look (empty/missing: the site's Appearance settings). See render.ts blockDisplay. */
201
+ contentAlign?: string;
202
+ metaAlign?: string;
203
+ radius?: number;
204
+ /** "show"/"hide" (true/false from WordPress imports). */
205
+ showBorder?: BlockShow;
206
+ borderWidth?: number;
207
+ borderColor?: string;
208
+ showDate?: BlockShow;
209
+ /** Legacy WordPress cloudflare-stream marker attributes (wellbeing.io). */
210
+ id?: string;
211
+ host?: string;
212
+ aspect?: number;
213
+ name?: string;
214
+ description?: string;
215
+ seconds?: number;
216
+ url?: string;
217
+ }
218
+
219
+ /** What the index keeps about each embedded video. */
220
+ export interface VideoRef {
221
+ uid: string;
222
+ title?: string;
223
+ caption?: string;
224
+ posterTime?: number;
225
+ posterImage?: string;
226
+ /** Found in a WordPress marker block rendered by the theme, not a coywolf-video block. */
227
+ legacy?: boolean;
228
+ }
229
+
230
+ const str = (v: unknown) => (typeof v === "string" && v.trim() ? v.trim() : undefined);
231
+ const num = (v: unknown) => (typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : undefined);
232
+
233
+ function refFromBlock(node: Record<string, unknown>, legacy = false): VideoRef | null {
234
+ const uid = isUid(node.uid) ? node.uid : isUid(node.id) ? node.id : null;
235
+ if (!uid) return null;
236
+ const ref: VideoRef = { uid };
237
+ const title = str(node.title) ?? str(node.name);
238
+ const caption = str(node.caption) ?? str(node.description);
239
+ const posterTime = num(node.posterTime);
240
+ const posterImage = str(node.posterImage);
241
+ if (title) ref.title = title;
242
+ if (caption) ref.caption = caption;
243
+ if (posterTime !== undefined) ref.posterTime = posterTime;
244
+ if (isImageUrl(posterImage)) ref.posterImage = posterImage;
245
+ if (legacy) ref.legacy = true;
246
+ return ref;
247
+ }
248
+
249
+ const MARKER = /^\s*<div data-wb-block="cloudflare-stream" data-wb-attrs="([^"]*)"><\/div>\s*$/;
250
+ const MARKER_ENTITIES: Record<string, string> = { "&quot;": '"', "&#x27;": "'", "&#39;": "'", "&lt;": "<", "&gt;": ">", "&amp;": "&" };
251
+
252
+ /**
253
+ * Every video embedded anywhere in an entry's data: coywolf-video blocks at
254
+ * any depth (columns, nested arrays), plus WordPress cloudflare-stream marker
255
+ * HTML blocks. Duplicates of the same video are kept once (first wins).
256
+ */
257
+ export function findVideoBlocks(data: unknown): VideoRef[] {
258
+ const out: VideoRef[] = [];
259
+ const seen = new Set<string>();
260
+ const visit = (value: unknown, depth: number) => {
261
+ if (depth > 12 || value === null || typeof value !== "object") return;
262
+ if (Array.isArray(value)) {
263
+ for (const item of value) visit(item, depth + 1);
264
+ return;
265
+ }
266
+ const node = value as Record<string, unknown>;
267
+ let ref: VideoRef | null = null;
268
+ if (node._type === BLOCK_TYPE) ref = refFromBlock(node);
269
+ else if (node._type === "htmlBlock" && typeof node.html === "string") {
270
+ const m = node.html.match(MARKER);
271
+ if (m) {
272
+ try {
273
+ ref = refFromBlock(JSON.parse(m[1].replace(/&(?:quot|#x27|#39|lt|gt|amp);/g, (e) => MARKER_ENTITIES[e])), true);
274
+ } catch {
275
+ ref = null;
276
+ }
277
+ }
278
+ }
279
+ if (ref) {
280
+ if (!seen.has(ref.uid)) {
281
+ seen.add(ref.uid);
282
+ out.push(ref);
283
+ }
284
+ return;
285
+ }
286
+ for (const child of Object.values(node)) visit(child, depth + 1);
287
+ };
288
+ visit(data, 0);
289
+ return out;
290
+ }
291
+
292
+ /**
293
+ * Which copy of an entry to index. Save hooks can carry draft-hydrated data
294
+ * (with the live status), so prefer the live row read back from the database;
295
+ * without one, only trust the event when the entry has no pending draft.
296
+ */
297
+ export function indexSource<T extends Record<string, unknown>>(event: T, live: T | null | undefined): T | null {
298
+ if (live && typeof live.data === "object" && live.data !== null) return live;
299
+ if (event.draftRevisionId) return null;
300
+ return typeof event.data === "object" && event.data !== null ? event : null;
301
+ }
302
+
303
+ export type BlockShow = boolean | "show" | "hide" | "";
304
+
305
+ /** The site-wide show/hide defaults blocks inherit (Videos → Settings → Views & likes). */
306
+ export interface ShowDefaults {
307
+ showName: boolean;
308
+ showDescription: boolean;
309
+ showPlays: boolean;
310
+ showLikes: boolean;
311
+ showLikeCount: boolean;
312
+ showDate: boolean;
313
+ /** Ignore the booleans older blocks carry, so they follow the defaults above too. */
314
+ followSiteDefaults: boolean;
315
+ }
316
+
317
+ /** Video Manager's defaults: everything shown. */
318
+ export const SHOW_DEFAULTS: ShowDefaults = {
319
+ showName: true,
320
+ showDescription: true,
321
+ showPlays: true,
322
+ showLikes: true,
323
+ showLikeCount: true,
324
+ showDate: true,
325
+ followSiteDefaults: false,
326
+ };
327
+
328
+ /** A block's show/hide choice: "show"/"hide" win; an older boolean counts unless the site follows its defaults; else the site default. */
329
+ export function resolveShow(value: unknown, siteDefault: boolean, followSiteDefaults: boolean): boolean {
330
+ if (value === "show") return true;
331
+ if (value === "hide") return false;
332
+ if (typeof value === "boolean" && !followSiteDefaults) return value;
333
+ return siteDefault;
334
+ }
335
+
336
+ const SHOW_KEYS = ["showName", "showDescription", "showPlays", "showLikes", "showLikeCount", "showDate"] as const;
337
+
338
+ /**
339
+ * A coywolf-video block with its true/false show choices (older blocks and
340
+ * WordPress imports) turned into what the block editor shows, rendering the
341
+ * same: "show"/"hide", or, when the site makes every block follow its
342
+ * defaults (so the booleans were ignored), removed (Site default). The
343
+ * border was always honored, so true/false → "show"/"hide" either way.
344
+ * Strings and missing values are left alone. Returns the same object when
345
+ * nothing changes.
346
+ */
347
+ export function normalizeVideoShow<T extends Record<string, unknown>>(block: T, followSiteDefaults: boolean): T {
348
+ let out: Record<string, unknown> | null = null;
349
+ for (const key of [...SHOW_KEYS, "showBorder"] as const) {
350
+ const value = block[key];
351
+ if (typeof value !== "boolean") continue;
352
+ out ??= { ...block };
353
+ if (followSiteDefaults && key !== "showBorder") delete out[key];
354
+ else out[key] = value ? "show" : "hide";
355
+ }
356
+ return (out ?? block) as T;
357
+ }
358
+
359
+ /**
360
+ * normalizeVideoShow on every coywolf-video block in a content value (a
361
+ * Portable Text array, an entry's data, at any depth). Returns a copy only
362
+ * along changed paths; `changed` counts the blocks rewritten.
363
+ */
364
+ export function normalizeVideoShows<T>(value: T, followSiteDefaults: boolean): { value: T; changed: number } {
365
+ let changed = 0;
366
+ const visit = (node: unknown, depth: number): unknown => {
367
+ if (depth > 12 || node === null || typeof node !== "object") return node;
368
+ if (Array.isArray(node)) {
369
+ let copy: unknown[] | null = null;
370
+ node.forEach((item, i) => {
371
+ const next = visit(item, depth + 1);
372
+ if (next !== item) (copy ??= [...node])[i] = next;
373
+ });
374
+ return copy ?? node;
375
+ }
376
+ const record = node as Record<string, unknown>;
377
+ if (record._type === BLOCK_TYPE) {
378
+ const next = normalizeVideoShow(record, followSiteDefaults);
379
+ if (next !== record) changed++;
380
+ return next;
381
+ }
382
+ let copy: Record<string, unknown> | null = null;
383
+ for (const [key, child] of Object.entries(record)) {
384
+ const next = visit(child, depth + 1);
385
+ if (next !== child) (copy ??= { ...record })[key] = next;
386
+ }
387
+ return copy ?? record;
388
+ };
389
+ return { value: visit(value, 0) as T, changed };
390
+ }
391
+
392
+ export interface PlayerConfig {
393
+ controls: boolean;
394
+ autoplay: boolean;
395
+ loop: boolean;
396
+ muted: boolean;
397
+ preload: "none" | "metadata" | "auto";
398
+ showName: boolean;
399
+ showDescription: boolean;
400
+ showPlays: boolean;
401
+ showLikes: boolean;
402
+ showLikeCount: boolean;
403
+ showDate: boolean;
404
+ gif: boolean;
405
+ }
406
+
407
+ /**
408
+ * Resolve a block's playback options, applying the GIF preset (legacy marker
409
+ * blocks default to it) and the site's show/hide defaults.
410
+ */
411
+ export function playerConfig(block: VideoBlock, site: ShowDefaults = SHOW_DEFAULTS): PlayerConfig {
412
+ const legacy = !block.uid && !!block.host;
413
+ const gif = block.preset === "gif" || (legacy && block.preset !== "standard");
414
+ if (gif) {
415
+ return { controls: false, autoplay: true, loop: true, muted: true, preload: "auto", showName: false, showDescription: false, showPlays: false, showLikes: false, showLikeCount: false, showDate: false, gif };
416
+ }
417
+ const preload = block.preload === "none" || block.preload === "auto" ? block.preload : "metadata";
418
+ const autoplay = block.autoplay === true;
419
+ const show = (key: keyof Omit<ShowDefaults, "followSiteDefaults">) => resolveShow(block[key], site[key], site.followSiteDefaults);
420
+ return {
421
+ controls: block.controls !== false,
422
+ autoplay,
423
+ loop: block.loop === true,
424
+ muted: autoplay || block.muted === true,
425
+ preload,
426
+ showName: show("showName"),
427
+ showDescription: show("showDescription"),
428
+ showPlays: show("showPlays"),
429
+ showLikes: show("showLikes"),
430
+ showLikeCount: show("showLikeCount"),
431
+ showDate: show("showDate"),
432
+ gif,
433
+ };
434
+ }
435
+
436
+ /** The poster for a video: an explicit image, else a frame at the block's (or the video's) poster time. */
437
+ export function posterUrl(
438
+ host: string | null,
439
+ uid: string,
440
+ ref: { posterImage?: string; posterTime?: number },
441
+ fallback: { posterImage?: string; posterTime?: number } = {},
442
+ width = SCHEMA_THUMB_WIDTH,
443
+ origin?: string | null,
444
+ ): string {
445
+ const image = absoluteUrl(ref.posterImage, origin) ?? (ref.posterTime === undefined ? absoluteUrl(fallback.posterImage, origin) : undefined);
446
+ if (image) return image;
447
+ const time = ref.posterTime ?? fallback.posterTime ?? 0;
448
+ return thumbnailUrl(host, uid, { time: `${time}s`, width });
449
+ }
450
+
451
+ /** Poster widths for the page's poster image (the player isn't loaded until it's needed). */
452
+ export const POSTER_WIDTHS = [400, 800, 1200] as const;
453
+
454
+ /**
455
+ * The poster as a responsive image: Stream thumbnails in a few widths, so a
456
+ * phone downloads a small one. An explicit poster image is used as it is.
457
+ */
458
+ export function posterImage(
459
+ host: string | null,
460
+ uid: string,
461
+ ref: { posterImage?: string; posterTime?: number },
462
+ fallback: { posterImage?: string; posterTime?: number } = {},
463
+ origin?: string | null,
464
+ ): { src: string; srcset?: string } {
465
+ const explicit = absoluteUrl(ref.posterImage, origin) ?? (ref.posterTime === undefined ? absoluteUrl(fallback.posterImage, origin) : undefined);
466
+ if (explicit) return { src: explicit };
467
+ const at = (width: number) => posterUrl(host, uid, ref, fallback, width, origin);
468
+ return { src: at(800), srcset: POSTER_WIDTHS.map((w) => `${at(w)} ${w}w`).join(", ") };
469
+ }
470
+
471
+ /** The Stream iframe src for a block. */
472
+ export function playerSrc(host: string | null, uid: string, cfg: PlayerConfig, extra: { startTime?: number; poster?: string; accent?: string; background?: string } = {}): string {
473
+ return iframeUrl(host, uid, {
474
+ preload: cfg.preload,
475
+ autoplay: cfg.autoplay ? "true" : undefined,
476
+ muted: cfg.muted ? "true" : undefined,
477
+ loop: cfg.loop ? "true" : undefined,
478
+ controls: cfg.controls ? undefined : "false",
479
+ startTime: extra.startTime && extra.startTime > 0 ? `${Math.floor(extra.startTime)}s` : undefined,
480
+ primaryColor: isHex(extra.accent) ? extra.accent : undefined,
481
+ letterboxColor: isHex(extra.background) ? extra.background : "transparent",
482
+ poster: extra.poster,
483
+ });
484
+ }
485
+
486
+ /** Aspect ratio as "w / h" for CSS, from dimensions or a legacy padding percentage. Default 16 / 9. */
487
+ export function aspectRatio(width?: number, height?: number, paddingPct?: number): string {
488
+ if (width && height && width > 0 && height > 0) return `${Math.round(width)} / ${Math.round(height)}`;
489
+ if (paddingPct && paddingPct > 0 && paddingPct < 400) return `100 / ${Math.round(paddingPct * 100) / 100}`;
490
+ return "16 / 9";
491
+ }
492
+
493
+ // ── Schema ───────────────────────────────────────────────────────
494
+
495
+ export interface VideoFacts {
496
+ uid: string;
497
+ name?: string;
498
+ description?: string;
499
+ duration?: number;
500
+ created?: string;
501
+ width?: number;
502
+ height?: number;
503
+ posterTime?: number;
504
+ posterImage?: string;
505
+ downloadUrl?: string;
506
+ captions?: Array<{ language: string; label?: string }>;
507
+ transcript?: string;
508
+ }
509
+
510
+ export interface SchemaInput {
511
+ ref: VideoRef;
512
+ video?: VideoFacts;
513
+ host: string | null;
514
+ /** Absolute URL of the site, for caption file URLs. */
515
+ siteUrl: string;
516
+ page: { title?: string | null; description?: string | null; publishedTime?: string | null; url?: string };
517
+ counts?: { plays: number; likes: number };
518
+ likesEnabled?: boolean;
519
+ captionsEnabled?: boolean;
520
+ /** Transcript cap (characters). */
521
+ maxTranscript?: number;
522
+ }
523
+
524
+ /** Path of the public caption file served by the pack middleware. */
525
+ export const captionPath = (uid: string, language: string) => `/coywolf-video-captions/${uid}/${encodeURIComponent(language)}.vtt`;
526
+
527
+ /** VideoObject JSON-LD for one embedded video. */
528
+ export function buildVideoObject(input: SchemaInput): Record<string, unknown> {
529
+ const { ref, video = { uid: ref.uid }, host, page } = input;
530
+ const name = ref.title ?? video.name ?? page.title ?? "Video";
531
+ const description = plainText(ref.caption ?? video.description ?? "") || plainText(page.description ?? "") || name;
532
+ const thumb = posterUrl(host, ref.uid, ref, video, SCHEMA_THUMB_WIDTH, input.siteUrl);
533
+ const schema: Record<string, unknown> = {
534
+ "@context": "https://schema.org",
535
+ "@type": "VideoObject",
536
+ name,
537
+ description,
538
+ thumbnailUrl: [thumb],
539
+ uploadDate: video.created ?? page.publishedTime ?? undefined,
540
+ embedUrl: iframeUrl(host, ref.uid),
541
+ };
542
+ const duration = isoDuration(video.duration);
543
+ if (duration) schema.duration = duration;
544
+ if (video.downloadUrl) schema.contentUrl = video.downloadUrl;
545
+ if (input.counts) {
546
+ const stats: Record<string, unknown>[] = [
547
+ { "@type": "InteractionCounter", interactionType: { "@type": "WatchAction" }, userInteractionCount: input.counts.plays },
548
+ ];
549
+ if (input.likesEnabled) stats.push({ "@type": "InteractionCounter", interactionType: { "@type": "LikeAction" }, userInteractionCount: input.counts.likes });
550
+ schema.interactionStatistic = stats;
551
+ }
552
+ if (input.captionsEnabled && video.captions?.length) {
553
+ const site = input.siteUrl.replace(/\/$/, "");
554
+ const tracks = video.captions.map((c) => ({
555
+ "@type": "MediaObject",
556
+ contentUrl: `${site}${captionPath(ref.uid, c.language)}`,
557
+ encodingFormat: "text/vtt",
558
+ inLanguage: c.language,
559
+ name: c.label || c.language,
560
+ }));
561
+ schema.caption = tracks.length === 1 ? tracks[0] : tracks;
562
+ const max = input.maxTranscript ?? 10_000;
563
+ if (video.transcript) schema.transcript = video.transcript.length > max ? `${video.transcript.slice(0, max).replace(/\s+\S*$/, "")}…` : video.transcript;
564
+ }
565
+ if (schema.uploadDate === undefined) delete schema.uploadDate;
566
+ return schema;
567
+ }
568
+
569
+ // ── Sitemap ──────────────────────────────────────────────────────
570
+
571
+ export interface SitemapVideo {
572
+ thumbnail: string;
573
+ title: string;
574
+ description: string;
575
+ playerLoc: string;
576
+ contentLoc?: string;
577
+ duration?: number;
578
+ viewCount?: number;
579
+ publicationDate?: string;
580
+ }
581
+
582
+ export interface SitemapEntry {
583
+ loc: string;
584
+ videos: SitemapVideo[];
585
+ }
586
+
587
+ /** A Google video sitemap (sitemap-video 1.1; element order follows the schema). */
588
+ export function buildVideoSitemap(entries: SitemapEntry[]): string {
589
+ const lines = [
590
+ '<?xml version="1.0" encoding="UTF-8"?>',
591
+ '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">',
592
+ ];
593
+ for (const entry of entries) {
594
+ if (!entry.videos.length) continue;
595
+ lines.push("\t<url>", `\t\t<loc>${escapeXml(entry.loc)}</loc>`);
596
+ for (const v of entry.videos) {
597
+ lines.push("\t\t<video:video>");
598
+ lines.push(`\t\t\t<video:thumbnail_loc>${escapeXml(v.thumbnail)}</video:thumbnail_loc>`);
599
+ lines.push(`\t\t\t<video:title>${escapeXml(v.title.slice(0, 100))}</video:title>`);
600
+ lines.push(`\t\t\t<video:description>${escapeXml((v.description || v.title).slice(0, 2048))}</video:description>`);
601
+ if (v.contentLoc) lines.push(`\t\t\t<video:content_loc>${escapeXml(v.contentLoc)}</video:content_loc>`);
602
+ lines.push(`\t\t\t<video:player_loc>${escapeXml(v.playerLoc)}</video:player_loc>`);
603
+ if (v.duration && v.duration >= 1 && v.duration <= 28_800) lines.push(`\t\t\t<video:duration>${Math.round(v.duration)}</video:duration>`);
604
+ if (v.viewCount !== undefined) lines.push(`\t\t\t<video:view_count>${Math.max(0, Math.floor(v.viewCount))}</video:view_count>`);
605
+ if (v.publicationDate) lines.push(`\t\t\t<video:publication_date>${escapeXml(v.publicationDate)}</video:publication_date>`);
606
+ lines.push("\t\t\t<video:family_friendly>yes</video:family_friendly>");
607
+ lines.push("\t\t\t<video:requires_subscription>no</video:requires_subscription>");
608
+ lines.push("\t\t\t<video:live>no</video:live>");
609
+ lines.push("\t\t</video:video>");
610
+ }
611
+ lines.push("\t</url>");
612
+ }
613
+ lines.push("</urlset>", "");
614
+ return lines.join("\n");
615
+ }
616
+
617
+ // ── Webhook signatures ──────────────────────────────────────────
618
+
619
+ const toHex = (buf: ArrayBuffer) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
620
+
621
+ export async function hmacHex(secret: string, message: string): Promise<string> {
622
+ const enc = new TextEncoder();
623
+ const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
624
+ return toHex(await crypto.subtle.sign("HMAC", key, enc.encode(message)));
625
+ }
626
+
627
+ export async function sha256Hex(text: string): Promise<string> {
628
+ return toHex(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)));
629
+ }
630
+
631
+ /**
632
+ * Verify Cloudflare Stream's Webhook-Signature header ("time=<unix>,sig1=<hex>",
633
+ * HMAC-SHA256 of "<time>.<body>"). Rejects timestamps more than 10 minutes off.
634
+ */
635
+ export async function verifyWebhookSignature(header: string | null, body: string, secret: string, nowSeconds = Math.floor(Date.now() / 1000)): Promise<boolean> {
636
+ if (!header || !secret) return false;
637
+ let time = "";
638
+ let sig = "";
639
+ for (const part of header.split(",")) {
640
+ const [k, v] = part.trim().split("=", 2);
641
+ if (k === "time") time = v ?? "";
642
+ else if (k === "sig1") sig = (v ?? "").toLowerCase();
643
+ }
644
+ if (!/^\d+$/.test(time) || !/^[0-9a-f]{64}$/.test(sig)) return false;
645
+ if (Math.abs(nowSeconds - Number(time)) > 600) return false;
646
+ const expected = await hmacHex(secret, `${time}.${body}`);
647
+ let diff = 0;
648
+ for (let i = 0; i < expected.length; i++) diff |= expected.charCodeAt(i) ^ sig.charCodeAt(i);
649
+ return diff === 0;
650
+ }
651
+
652
+ // ── Bounded recent-key memory ───────────────────────────────────
653
+
654
+ /** An LRU of keys seen within a time window, capped at `max` entries (oldest evicted first). */
655
+ export class RecentKeys {
656
+ private readonly map = new Map<string, number>();
657
+ private readonly max: number;
658
+ private readonly windowMs: number;
659
+ constructor(max: number, windowMs: number) {
660
+ this.max = max;
661
+ this.windowMs = windowMs;
662
+ }
663
+
664
+ /** Record `key`; true when it was already seen within the window. */
665
+ seen(key: string, now = Date.now()): boolean {
666
+ const last = this.map.get(key);
667
+ this.map.delete(key);
668
+ this.map.set(key, now);
669
+ while (this.map.size > this.max) {
670
+ const oldest = this.map.keys().next().value as string;
671
+ this.map.delete(oldest);
672
+ }
673
+ return last !== undefined && now - last < this.windowMs;
674
+ }
675
+
676
+ get size(): number {
677
+ return this.map.size;
678
+ }
679
+ }