@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,192 @@
1
+ /**
2
+ * Instant title matching in the browser (live results' title index): the
3
+ * matcher, title highlighting and the merge of local and server results.
4
+ * Plain ES2020 inside String.raw (no backticks or "${"), embedded in the live
5
+ * results script and evaluated as-is by the tests, so both run the same code.
6
+ *
7
+ * Matching folds case and diacritics ("ecole" finds "École"). Every typed
8
+ * word has to match. Ranking, best first:
9
+ * 0. the title starts with what was typed (word by word),
10
+ * 1. every word starts a word in the title,
11
+ * 2. every word (3+ characters) appears somewhere in the title,
12
+ * then the earlier the first match, the shorter the title, and the newer the
13
+ * entry (the index is newest first).
14
+ */
15
+ export const MATCH_SOURCE = String.raw`function () {
16
+ "use strict";
17
+ var MARKS = /[̀-ͯ]/g;
18
+ var SPLIT = /[^\p{L}\p{N}]+/u;
19
+ var WORDCHAR = /[\p{L}\p{N}]/u;
20
+ var ASCII = /^[\x00-\x7f]*$/;
21
+
22
+ function fold(text) {
23
+ text = String(text || "");
24
+ return ASCII.test(text) ? text.toLowerCase() : text.normalize("NFD").replace(MARKS, "").toLowerCase();
25
+ }
26
+
27
+ /** Folded text and, for each folded position, the original position it came from. */
28
+ function foldMap(text) {
29
+ var out = "";
30
+ var map = [];
31
+ for (var i = 0; i < text.length; ) {
32
+ var cp = text.codePointAt(i);
33
+ var ch = String.fromCodePoint(cp);
34
+ var f = fold(ch);
35
+ for (var k = 0; k < f.length; k++) map.push(i);
36
+ out += f;
37
+ i += ch.length;
38
+ }
39
+ map.push(text.length);
40
+ return { text: out, map: map };
41
+ }
42
+
43
+ function words(folded) {
44
+ return folded.split(SPLIT).filter(Boolean);
45
+ }
46
+
47
+ /** Index rows ([title, url, typeIndex]) ready to match. */
48
+ function prepare(index) {
49
+ var types = (index && index.types) || [];
50
+ var rows = (index && index.entries) || [];
51
+ var out = [];
52
+ for (var i = 0; i < rows.length; i++) {
53
+ var r = rows[i];
54
+ if (!r || typeof r[0] !== "string" || typeof r[1] !== "string") continue;
55
+ var f = fold(r[0]);
56
+ out.push({ title: r[0], url: r[1], type: types[r[2]] || "", folded: f, spaced: " " + words(f).join(" ") });
57
+ }
58
+ return out;
59
+ }
60
+
61
+ function queryWords(query) {
62
+ var seen = {};
63
+ return words(fold(query)).filter(function (w) {
64
+ if (seen[w]) return false;
65
+ seen[w] = 1;
66
+ return true;
67
+ });
68
+ }
69
+
70
+ /** Ranked matches: [{ entry, tier }], at most limit. */
71
+ function match(prepared, query, limit) {
72
+ var terms = queryWords(query);
73
+ if (!terms.length) return [];
74
+ var phrase = " " + terms.join(" ");
75
+ var hits = [];
76
+ for (var i = 0; i < prepared.length; i++) {
77
+ var e = prepared[i];
78
+ var tier = -1;
79
+ if (e.spaced.indexOf(phrase) === 0) tier = 0;
80
+ else {
81
+ var prefix = true;
82
+ var loose = true;
83
+ for (var t = 0; t < terms.length; t++) {
84
+ var term = terms[t];
85
+ var starts = e.spaced.indexOf(" " + term) !== -1;
86
+ if (!starts) prefix = false;
87
+ if (!starts && (term.length < 3 || e.folded.indexOf(term) === -1)) loose = false;
88
+ }
89
+ tier = prefix ? 1 : loose ? 2 : -1;
90
+ }
91
+ if (tier < 0) continue;
92
+ var pos = tier === 2 ? e.folded.indexOf(terms[0]) : e.spaced.indexOf(" " + terms[0]);
93
+ hits.push({ entry: e, tier: tier, pos: pos < 0 ? 999 : pos, i: i });
94
+ }
95
+ hits.sort(function (a, b) {
96
+ return a.tier - b.tier || a.pos - b.pos || a.entry.title.length - b.entry.title.length || a.i - b.i;
97
+ });
98
+ return hits.slice(0, limit);
99
+ }
100
+
101
+ function escapeHtml(text) {
102
+ return String(text).replace(/[&<>"']/g, function (c) {
103
+ return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
104
+ });
105
+ }
106
+
107
+ /** Words to highlight, as the server picks them: two or more characters, longest first, at most 10. */
108
+ function highlightTerms(query) {
109
+ return queryWords(query)
110
+ .filter(function (w) {
111
+ return Array.from(w).length >= 2;
112
+ })
113
+ .sort(function (a, b) {
114
+ return b.length - a.length;
115
+ })
116
+ .slice(0, 10);
117
+ }
118
+
119
+ /** The title, escaped, with matches in <mark>: at word starts, or anywhere when anywhere is true. */
120
+ function highlight(title, query, anywhere) {
121
+ var terms = highlightTerms(query);
122
+ if (!terms.length) return escapeHtml(title);
123
+ var fm = foldMap(title);
124
+ var ranges = [];
125
+ var at = 0;
126
+ while (at < fm.text.length) {
127
+ var found = null;
128
+ if (anywhere || at === 0 || !WORDCHAR.test(fm.text.charAt(at - 1))) {
129
+ for (var t = 0; t < terms.length; t++) {
130
+ if (fm.text.substr(at, terms[t].length) === terms[t]) {
131
+ found = terms[t];
132
+ break;
133
+ }
134
+ }
135
+ }
136
+ if (found) {
137
+ ranges.push([fm.map[at], fm.map[at + found.length]]);
138
+ at += found.length;
139
+ } else at++;
140
+ }
141
+ var out = "";
142
+ var last = 0;
143
+ for (var r = 0; r < ranges.length; r++) {
144
+ if (ranges[r][0] < last) continue;
145
+ out += escapeHtml(title.slice(last, ranges[r][0])) + "<mark>" + escapeHtml(title.slice(ranges[r][0], ranges[r][1])) + "</mark>";
146
+ last = ranges[r][1];
147
+ }
148
+ return out + escapeHtml(title.slice(last));
149
+ }
150
+
151
+ /** Local results in the server's item shape (no excerpt), marked local. */
152
+ function localItems(prepared, query, limit) {
153
+ return match(prepared, query, limit).map(function (h) {
154
+ return { title: h.entry.title, titleHtml: highlight(h.entry.title, query, h.tier === 2), url: h.entry.url, type: h.entry.type, snippet: "", local: true };
155
+ });
156
+ }
157
+
158
+ function urlKey(url) {
159
+ return String(url || "").replace(/[?#].*$/, "").replace(/\/+$/, "");
160
+ }
161
+
162
+ // Server results merged into the local ones without reshuffling them: a local row the server
163
+ // also found is replaced by the server's (with its excerpt) in place, the server's other results
164
+ // follow, and when that's too many, local rows the server didn't confirm give way from the bottom.
165
+ function merge(local, server, limit) {
166
+ var byUrl = {};
167
+ server.forEach(function (s) {
168
+ var k = urlKey(s.url);
169
+ if (!byUrl[k]) byUrl[k] = s;
170
+ });
171
+ var used = {};
172
+ var out = [];
173
+ local.forEach(function (l) {
174
+ var k = urlKey(l.url);
175
+ if (used[k]) return;
176
+ used[k] = 1;
177
+ out.push(byUrl[k] || l);
178
+ });
179
+ server.forEach(function (s) {
180
+ var k = urlKey(s.url);
181
+ if (used[k]) return;
182
+ used[k] = 1;
183
+ out.push(s);
184
+ });
185
+ for (var i = out.length - 1; out.length > limit && i >= 0; i--) {
186
+ if (out[i].local) out.splice(i, 1);
187
+ }
188
+ return out.slice(0, limit);
189
+ }
190
+
191
+ return { fold: fold, prepare: prepare, match: match, highlight: highlight, localItems: localItems, merge: merge };
192
+ }`;
package/src/shared.ts ADDED
@@ -0,0 +1,71 @@
1
+ import { PluginRouteError } from "emdash";
2
+ import type { z } from "zod";
3
+
4
+ interface Workers {
5
+ env: Record<string, unknown>;
6
+ waitUntil?: (p: Promise<unknown>) => void;
7
+ }
8
+
9
+ /**
10
+ * cloudflare:workers, loaded once when the isolate starts. A dynamic import,
11
+ * not a static one: astro.config.mjs loads the plugin in Node, where the module
12
+ * doesn't exist (`workers` stays null there). It's loaded long before the first
13
+ * request, so request-time callers never wait for it.
14
+ */
15
+ let workers: Workers | null = null;
16
+ const workersReady: Promise<Workers | null> = import("cloudflare:workers").then(
17
+ (module) => (workers = module as unknown as Workers),
18
+ () => null,
19
+ );
20
+
21
+ /** Cloudflare bindings. Rejects outside a Worker (callers catch). */
22
+ export async function workerEnv(): Promise<Record<string, unknown>> {
23
+ const module = workers ?? (await workersReady);
24
+ if (!module) throw new Error("cloudflare:workers isn't available here");
25
+ return module.env;
26
+ }
27
+
28
+ /**
29
+ * Run `promise` after the response: hand it to waitUntil right now, before
30
+ * anything else happens. Synchronous on purpose. A Worker invocation ends as
31
+ * soon as its response is complete unless waitUntil holds it open, and work
32
+ * started from a hook EmDash doesn't await (media:afterUpload runs
33
+ * fire-and-forget right before the upload's response) has no time to spare: this
34
+ * used to await a dynamic import first, the hook awaited a settings read before
35
+ * that, and the request finished before waitUntil was reached, dropping the
36
+ * work with it without a word (0.30.0–0.33.0). Never throws: outside a Worker,
37
+ * or outside a request (waitUntil refuses), the promise just runs on its own; in
38
+ * the first instant of an isolate, before cloudflare:workers has loaded, it's
39
+ * handed over as soon as it has.
40
+ */
41
+ export function afterResponse(promise: Promise<unknown>): void {
42
+ const handOver = (module: Workers | null) => {
43
+ try {
44
+ module?.waitUntil?.(promise);
45
+ } catch {
46
+ // No request to extend: the promise is already running.
47
+ }
48
+ };
49
+ if (workers) handOver(workers);
50
+ else void workersReady.then(handOver);
51
+ }
52
+
53
+ /** Read a Worker secret (env binding, or process.env under nodejs_compat). */
54
+ export function secret(env: Record<string, unknown>, name: string): string | undefined {
55
+ return (env[name] as string | undefined) ?? (globalThis as { process?: { env?: Record<string, string> } }).process?.env?.[name];
56
+ }
57
+
58
+ /** Validate a route body, reporting problems to the admin instead of a generic 500. */
59
+ export function parseInput<T>(schema: z.ZodType<T>, input: unknown): T {
60
+ const result = schema.safeParse(input);
61
+ if (!result.success) throw PluginRouteError.badRequest(result.error.issues.map((i) => i.message).join("; "));
62
+ return result.data;
63
+ }
64
+
65
+ export interface SettingsReader {
66
+ settings: { get<T>(key: string): Promise<T | null | undefined> };
67
+ }
68
+
69
+ export interface CronScheduler {
70
+ cron?: { schedule(name: string, opts: { schedule: string }): Promise<void> };
71
+ }
package/src/version.ts ADDED
@@ -0,0 +1,2 @@
1
+ /** The pack's version (keep in step with package.json). */
2
+ export const VERSION = "0.35.13";
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Upload dates under a video: "Oct 6, 2026" or "7 months ago" (Video
3
+ * Manager's style). No imports, so the page script can use it to refresh a
4
+ * relative date on pages served from a cache.
5
+ */
6
+
7
+ export type DateStyle = "absolute" | "relative";
8
+
9
+ // UTC, as the site renders on Workers (also in the admin preview and tests).
10
+ const absolute = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeZone: "UTC" });
11
+
12
+ const UNITS: Array<[string, number]> = [
13
+ ["year", 365 * 86_400],
14
+ ["month", 30 * 86_400],
15
+ ["week", 7 * 86_400],
16
+ ["day", 86_400],
17
+ ["hour", 3_600],
18
+ ["minute", 60],
19
+ ];
20
+
21
+ /** "7 months ago", "1 day ago"; anything under a minute (or in the future) is "just now". */
22
+ export function relativeDate(iso: string, now = Date.now()): string {
23
+ const at = new Date(iso).getTime();
24
+ if (Number.isNaN(at)) return "";
25
+ const seconds = Math.floor((now - at) / 1000);
26
+ for (const [unit, size] of UNITS) {
27
+ if (seconds < size) continue;
28
+ const n = Math.floor(seconds / size);
29
+ return `${n} ${unit}${n === 1 ? "" : "s"} ago`;
30
+ }
31
+ return "just now";
32
+ }
33
+
34
+ /** The date text for a video's upload date, or "" when it isn't a date. */
35
+ export function videoDateText(iso: string | null | undefined, style: DateStyle, now = Date.now()): string {
36
+ if (!iso) return "";
37
+ const date = new Date(iso);
38
+ if (Number.isNaN(date.getTime())) return "";
39
+ return style === "relative" ? relativeDate(iso, now) : absolute.format(date);
40
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Abuse limits for the public plays and likes routes (pure; unit tested in Node).
3
+ *
4
+ * Visitors are told apart by a daily-salted hash of the IP address alone: the
5
+ * user agent is set by the client, so mixing it in let one address count as
6
+ * any number of visitors. On top of that, each address may only play and like
7
+ * so often per minute (per isolate, like the search fallback limiter), and
8
+ * only add so many likes per day (counted in plugin KV, so across isolates).
9
+ */
10
+ import { MemoryWindowCounter } from "../search/ratelimit-core.js";
11
+
12
+ /**
13
+ * Sent by the Coywolf Video script with every play and like. A cross-site form
14
+ * or image can't set a custom header, and a cross-site fetch with one needs a
15
+ * CORS preflight the route never grants, so likes can't be forged from another site.
16
+ */
17
+ export const ENGAGEMENT_HEADER = "x-coywolf-video";
18
+
19
+ export const ENGAGEMENT_LIMITS = {
20
+ /** Play requests per address per minute. */
21
+ playsPerMinute: 10,
22
+ /** Like and unlike requests per address per minute. */
23
+ likesPerMinute: 20,
24
+ /** New likes per address per day. */
25
+ likesPerDay: 100,
26
+ } as const;
27
+
28
+ const DAY_MS = 86_400_000;
29
+
30
+ export function hasEngagementHeader(headers: Headers): boolean {
31
+ return headers.get(ENGAGEMENT_HEADER) === "1";
32
+ }
33
+
34
+ /** Per-isolate counters for one address (by its visitor hash, never the address itself). */
35
+ export class EngagementLimiter {
36
+ private readonly plays = new MemoryWindowCounter(10_000, 60_000);
37
+ private readonly likes = new MemoryWindowCounter(10_000, 60_000);
38
+ private readonly daily = new MemoryWindowCounter(10_000, DAY_MS);
39
+
40
+ /** Count a play request; false when the address is over its per-minute limit. */
41
+ play(visitor: string, now = Date.now()): boolean {
42
+ return this.plays.hit(visitor, ENGAGEMENT_LIMITS.playsPerMinute, now).allowed;
43
+ }
44
+
45
+ /** Count a like or unlike request; false when the address is over its per-minute limit. */
46
+ like(visitor: string, now = Date.now()): boolean {
47
+ return this.likes.hit(visitor, ENGAGEMENT_LIMITS.likesPerMinute, now).allowed;
48
+ }
49
+
50
+ /** Count a new like in this isolate; false once the address has used the day's likes. */
51
+ newLike(visitor: string, now = Date.now()): boolean {
52
+ return this.daily.hit(visitor, ENGAGEMENT_LIMITS.likesPerDay, now).allowed;
53
+ }
54
+ }
55
+
56
+ /** KV key for the number of likes one address added on `day` (pruned by the daily likes task). */
57
+ export const LIKES_BY_PREFIX = "state:videos:likesBy:";
58
+ export const likesByKey = (day: string, visitor: string) => `${LIKES_BY_PREFIX}${day}:${visitor}`;
59
+
60
+ /** True when a likesBy key is from before `cutoff` (YYYY-MM-DD). */
61
+ export function staleLikesByKey(key: string, cutoff: string): boolean {
62
+ const day = key.slice(LIKES_BY_PREFIX.length, LIKES_BY_PREFIX.length + 10);
63
+ return /^\d{4}-\d{2}-\d{2}$/.test(day) && day < cutoff;
64
+ }
65
+
66
+ /**
67
+ * What a like request does. New scripts send the state the visitor wants, so
68
+ * two people behind one address (who now share a visitor id) don't undo each
69
+ * other's like; without it the request toggles, as it always did.
70
+ */
71
+ export function likeAction(liked: boolean | undefined, exists: boolean): "add" | "remove" | "keep" {
72
+ if (liked === undefined) return exists ? "remove" : "add";
73
+ if (liked) return exists ? "keep" : "add";
74
+ return exists ? "remove" : "keep";
75
+ }