@coywolf/emdash 0.0.0-stage → 0.35.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (267) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +203 -2
  3. package/docs/README.md +45 -0
  4. package/docs/ai.md +39 -0
  5. package/docs/backups.md +88 -0
  6. package/docs/breadcrumbs.md +59 -0
  7. package/docs/code-blocks.md +21 -0
  8. package/docs/content-urls.md +41 -0
  9. package/docs/custom-blocks.md +27 -0
  10. package/docs/discovery.md +32 -0
  11. package/docs/files.md +44 -0
  12. package/docs/form-uploads.md +59 -0
  13. package/docs/headings.md +37 -0
  14. package/docs/images.md +141 -0
  15. package/docs/internals.md +13 -0
  16. package/docs/links.md +34 -0
  17. package/docs/performance.md +72 -0
  18. package/docs/redirects.md +84 -0
  19. package/docs/releasing.md +21 -0
  20. package/docs/reviews.md +89 -0
  21. package/docs/robots.md +81 -0
  22. package/docs/schema.md +79 -0
  23. package/docs/search.md +114 -0
  24. package/docs/videos.md +48 -0
  25. package/docs/wordpress-import.md +198 -0
  26. package/package.json +79 -5
  27. package/src/admin/admin-css.ts +57 -0
  28. package/src/admin/ai.tsx +915 -0
  29. package/src/admin/backups.tsx +613 -0
  30. package/src/admin/breadcrumbs.tsx +542 -0
  31. package/src/admin/codeBlocks.tsx +204 -0
  32. package/src/admin/customBlocks.tsx +321 -0
  33. package/src/admin/dirty.ts +20 -0
  34. package/src/admin/discovery.tsx +463 -0
  35. package/src/admin/download.ts +28 -0
  36. package/src/admin/feature-switches.tsx +124 -0
  37. package/src/admin/features.tsx +142 -0
  38. package/src/admin/files.tsx +791 -0
  39. package/src/admin/formUploads.tsx +445 -0
  40. package/src/admin/guides.tsx +18 -0
  41. package/src/admin/headings.tsx +262 -0
  42. package/src/admin/images.tsx +825 -0
  43. package/src/admin/index.tsx +62 -0
  44. package/src/admin/links.tsx +980 -0
  45. package/src/admin/performance.tsx +330 -0
  46. package/src/admin/preview.tsx +63 -0
  47. package/src/admin/redirects-removed.tsx +266 -0
  48. package/src/admin/redirects.tsx +470 -0
  49. package/src/admin/reviews.tsx +232 -0
  50. package/src/admin/robots-bots.tsx +647 -0
  51. package/src/admin/robots-shared.tsx +109 -0
  52. package/src/admin/robots-wizard.tsx +748 -0
  53. package/src/admin/robots.tsx +911 -0
  54. package/src/admin/save-bar.tsx +134 -0
  55. package/src/admin/schema.tsx +1187 -0
  56. package/src/admin/search.tsx +355 -0
  57. package/src/admin/settings-ui.tsx +87 -0
  58. package/src/admin/trash-prompt-core.ts +135 -0
  59. package/src/admin/trash-prompt.tsx +156 -0
  60. package/src/admin/usage.tsx +301 -0
  61. package/src/admin/videos-display.tsx +248 -0
  62. package/src/admin/videos.tsx +1202 -0
  63. package/src/admin/wpImport.tsx +1516 -0
  64. package/src/ai/entities.ts +59 -0
  65. package/src/ai/export.ts +61 -0
  66. package/src/ai/logic.ts +606 -0
  67. package/src/ai/pack.ts +121 -0
  68. package/src/ai/prompts.ts +87 -0
  69. package/src/ai/providers.ts +276 -0
  70. package/src/ai/routes.ts +359 -0
  71. package/src/ai/store.ts +301 -0
  72. package/src/ai/wikidata.ts +87 -0
  73. package/src/ai/worker.ts +530 -0
  74. package/src/astro/astro-modules.d.ts +6 -0
  75. package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
  76. package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
  77. package/src/astro/codeBlocks/CodeBlock.astro +62 -0
  78. package/src/astro/codeBlocks/EmDashCode.astro +61 -0
  79. package/src/astro/customBlocks/CustomBlock.astro +64 -0
  80. package/src/astro/customBlocks/Details.astro +8 -0
  81. package/src/astro/customBlocks/Disclosure.astro +8 -0
  82. package/src/astro/customBlocks/Note.astro +8 -0
  83. package/src/astro/customBlocks/Podcast.astro +8 -0
  84. package/src/astro/customBlocks/Quote.astro +8 -0
  85. package/src/astro/customBlocks/Testimonial.astro +8 -0
  86. package/src/astro/files/FileCard.astro +103 -0
  87. package/src/astro/headings/HeadingBlock.astro +129 -0
  88. package/src/astro/headings/TableOfContents.astro +45 -0
  89. package/src/astro/headings/request.ts +19 -0
  90. package/src/astro/images/ResponsiveImage.astro +60 -0
  91. package/src/astro/index.ts +84 -0
  92. package/src/astro/reviews/CoywolfReview.astro +27 -0
  93. package/src/astro/reviews/Review.astro +49 -0
  94. package/src/astro/search/SearchBox.astro +61 -0
  95. package/src/astro/search/SearchBoxForm.astro +509 -0
  96. package/src/astro/tables/CaptionedTable.astro +72 -0
  97. package/src/astro/videos/CoywolfVideo.astro +448 -0
  98. package/src/backups/download.ts +112 -0
  99. package/src/backups/dump.ts +288 -0
  100. package/src/backups/module.ts +276 -0
  101. package/src/backups/pack.ts +33 -0
  102. package/src/backups/restore.ts +209 -0
  103. package/src/backups/store.ts +409 -0
  104. package/src/breadcrumbs/pack.ts +113 -0
  105. package/src/breadcrumbs/settings.ts +102 -0
  106. package/src/breadcrumbs/stamp.ts +40 -0
  107. package/src/breadcrumbs/trail.ts +123 -0
  108. package/src/codeBlocks/contrast.ts +179 -0
  109. package/src/codeBlocks/hljs.d.ts +5 -0
  110. package/src/codeBlocks/pack.ts +110 -0
  111. package/src/codeBlocks/render.ts +304 -0
  112. package/src/codeBlocks/settings.ts +18 -0
  113. package/src/codeBlocks/themes.generated.ts +304 -0
  114. package/src/codeBlocks/themes.ts +114 -0
  115. package/src/core/compose.ts +244 -0
  116. package/src/core/content-url.ts +888 -0
  117. package/src/core/csv.ts +119 -0
  118. package/src/core/d1-batch.ts +65 -0
  119. package/src/core/entry-docs.ts +175 -0
  120. package/src/core/features-module.ts +48 -0
  121. package/src/core/features.ts +392 -0
  122. package/src/core/generation.ts +88 -0
  123. package/src/core/guide-content.ts +166 -0
  124. package/src/core/module.ts +104 -0
  125. package/src/core/read-failures.ts +57 -0
  126. package/src/core/request-memo.ts +71 -0
  127. package/src/core/secrets.ts +83 -0
  128. package/src/core/settings-enhance.ts +74 -0
  129. package/src/core/site.ts +36 -0
  130. package/src/core/storage.ts +27 -0
  131. package/src/customBlocks/pack.ts +260 -0
  132. package/src/customBlocks/render.ts +424 -0
  133. package/src/customBlocks/rich.ts +313 -0
  134. package/src/customBlocks/settings.ts +46 -0
  135. package/src/discovery/build.ts +252 -0
  136. package/src/discovery/feed-build.ts +217 -0
  137. package/src/discovery/feed.ts +172 -0
  138. package/src/discovery/indexnow.ts +114 -0
  139. package/src/discovery/llms.ts +79 -0
  140. package/src/discovery/markdown.ts +294 -0
  141. package/src/discovery/memory-cache.ts +97 -0
  142. package/src/discovery/middleware.ts +186 -0
  143. package/src/discovery/module.ts +353 -0
  144. package/src/discovery/news.ts +99 -0
  145. package/src/discovery/pack.ts +58 -0
  146. package/src/discovery/settings.ts +91 -0
  147. package/src/discovery/urls.ts +35 -0
  148. package/src/env.d.ts +10 -0
  149. package/src/files/card.ts +231 -0
  150. package/src/files/counts.ts +60 -0
  151. package/src/files/export.ts +44 -0
  152. package/src/files/format.ts +163 -0
  153. package/src/files/module.ts +596 -0
  154. package/src/files/pack.ts +92 -0
  155. package/src/files/r2-rules.ts +41 -0
  156. package/src/files/range.ts +50 -0
  157. package/src/files/s3.ts +114 -0
  158. package/src/files/serve.ts +141 -0
  159. package/src/files/sigv4.ts +160 -0
  160. package/src/files/site.ts +137 -0
  161. package/src/files/walker.ts +58 -0
  162. package/src/formUploads/lib.ts +266 -0
  163. package/src/formUploads/pack.ts +55 -0
  164. package/src/formUploads/wrap.ts +592 -0
  165. package/src/forms.ts +16 -0
  166. package/src/headings/pack.ts +154 -0
  167. package/src/headings/render.ts +139 -0
  168. package/src/headings/settings.ts +117 -0
  169. package/src/headings/slug.ts +68 -0
  170. package/src/headings/stamp.ts +142 -0
  171. package/src/headings/test-hooks.mjs +14 -0
  172. package/src/headings/toc.ts +48 -0
  173. package/src/images/cloudflare.ts +329 -0
  174. package/src/images/lib.ts +221 -0
  175. package/src/images/module.ts +372 -0
  176. package/src/images/pack.ts +804 -0
  177. package/src/images/pending.ts +37 -0
  178. package/src/images/settings.ts +48 -0
  179. package/src/images/setup.ts +192 -0
  180. package/src/images/variants-estimate.ts +162 -0
  181. package/src/images/variants-store.ts +475 -0
  182. package/src/images/variants.ts +311 -0
  183. package/src/index.ts +143 -0
  184. package/src/links/check.ts +101 -0
  185. package/src/links/classify.ts +270 -0
  186. package/src/links/export.ts +59 -0
  187. package/src/links/module.ts +652 -0
  188. package/src/links/pack.ts +43 -0
  189. package/src/links/pt.ts +325 -0
  190. package/src/links/store.ts +704 -0
  191. package/src/middleware.ts +287 -0
  192. package/src/modules.ts +53 -0
  193. package/src/options.ts +66 -0
  194. package/src/pageCache/lib.ts +274 -0
  195. package/src/pageCache/pack.ts +240 -0
  196. package/src/pageCache/usage-core.ts +152 -0
  197. package/src/pageCache/usage.ts +262 -0
  198. package/src/pageCache/warm.ts +722 -0
  199. package/src/redirects/middleware.ts +169 -0
  200. package/src/redirects/module.ts +124 -0
  201. package/src/redirects/pack.ts +89 -0
  202. package/src/redirects/removed-core.ts +151 -0
  203. package/src/redirects/removed.ts +293 -0
  204. package/src/redirects/rules.ts +355 -0
  205. package/src/redirects/slug-changes.ts +207 -0
  206. package/src/redirects/slug-core.ts +49 -0
  207. package/src/redirects/transfer.ts +92 -0
  208. package/src/reviews/lib.ts +624 -0
  209. package/src/reviews/pack.ts +135 -0
  210. package/src/reviews/schema.ts +128 -0
  211. package/src/reviews/settings.ts +26 -0
  212. package/src/robots/bots.ts +321 -0
  213. package/src/robots/data/bots.json +696 -0
  214. package/src/robots/data/verified.json +582 -0
  215. package/src/robots/directory.ts +26 -0
  216. package/src/robots/explain.ts +191 -0
  217. package/src/robots/guided.ts +300 -0
  218. package/src/robots/importer.ts +263 -0
  219. package/src/robots/middleware.ts +151 -0
  220. package/src/robots/module.ts +446 -0
  221. package/src/robots/pack.ts +69 -0
  222. package/src/robots/radar.ts +129 -0
  223. package/src/robots/rep.ts +362 -0
  224. package/src/robots/rules.ts +883 -0
  225. package/src/robots/transfer.ts +45 -0
  226. package/src/robots/validate.ts +524 -0
  227. package/src/schema/catalog.ts +146 -0
  228. package/src/schema/graph.ts +791 -0
  229. package/src/schema/module.ts +1138 -0
  230. package/src/schema/og-image.ts +100 -0
  231. package/src/schema/pack.ts +58 -0
  232. package/src/search/engine.ts +578 -0
  233. package/src/search/fallback.ts +80 -0
  234. package/src/search/live-cache.ts +116 -0
  235. package/src/search/live-client.ts +770 -0
  236. package/src/search/live-serve.ts +282 -0
  237. package/src/search/module.ts +261 -0
  238. package/src/search/pack.ts +57 -0
  239. package/src/search/query.ts +132 -0
  240. package/src/search/ratelimit-core.ts +118 -0
  241. package/src/search/ratelimit.ts +79 -0
  242. package/src/search/snippet.ts +135 -0
  243. package/src/search/title-index.ts +139 -0
  244. package/src/search/title-match.ts +192 -0
  245. package/src/shared.ts +71 -0
  246. package/src/version.ts +2 -0
  247. package/src/videos/date.ts +40 -0
  248. package/src/videos/engagement.ts +75 -0
  249. package/src/videos/lib.ts +679 -0
  250. package/src/videos/module.ts +1160 -0
  251. package/src/videos/pack.ts +187 -0
  252. package/src/videos/pause.ts +42 -0
  253. package/src/videos/poster.ts +324 -0
  254. package/src/videos/render.ts +336 -0
  255. package/src/videos/store.ts +551 -0
  256. package/src/videos/stream.ts +134 -0
  257. package/src/wpImport/convert.ts +702 -0
  258. package/src/wpImport/guests.ts +329 -0
  259. package/src/wpImport/gutenberg.ts +157 -0
  260. package/src/wpImport/markers.ts +66 -0
  261. package/src/wpImport/pack.ts +451 -0
  262. package/src/wpImport/parents.ts +202 -0
  263. package/src/wpImport/prepare.ts +592 -0
  264. package/src/wpImport/redirects.ts +358 -0
  265. package/src/wpImport/sources.ts +67 -0
  266. package/src/wpImport/stream.ts +160 -0
  267. package/src/wpImport/urls.ts +354 -0
@@ -0,0 +1,606 @@
1
+ /**
2
+ * AI Enrichment: pure logic (no imports, no I/O) so it can be unit tested
3
+ * with `node --test`. Model-output parsing and validation, Wikidata grounding
4
+ * filters, Portable Text → plain text, and queue batching.
5
+ *
6
+ * Ported from Coywolf SEO for WordPress (class-coywolf-seo-ai.php and
7
+ * class-coywolf-seo-image-ai.php): the model extracts entity MENTIONS only
8
+ * (never identifiers); real Wikidata candidates are looked up
9
+ * deterministically; the model may only choose among those candidates; and
10
+ * each chosen item's P31 (instance of) claims are checked against the
11
+ * expected type.
12
+ */
13
+
14
+ export const ENTITY_TYPES = ["Person", "Organization", "Place", "Thing"] as const;
15
+ export type EntityType = (typeof ENTITY_TYPES)[number];
16
+
17
+ export const MAX_ENTITIES = 12;
18
+ export const DESCRIPTION_MAX = 155;
19
+ export const ALT_MAX = 250;
20
+ export const CAPTION_MAX = 300;
21
+
22
+ /** Q-number of a Wikimedia disambiguation page and of "human". */
23
+ const DISAMBIGUATION = "Q4167410";
24
+ const HUMAN = "Q5";
25
+
26
+ /**
27
+ * Common `instance of` classes for creative works and publications. An
28
+ * entity the article calls a Person, Organization, or Place must not resolve
29
+ * to one of these (e.g. the band's album instead of the band).
30
+ */
31
+ const CREATIVE_WORKS = new Set([
32
+ "Q7725634", // literary work
33
+ "Q571", // book
34
+ "Q47461344", // written work
35
+ "Q11424", // film
36
+ "Q5398426", // television series
37
+ "Q21191270", // television series episode
38
+ "Q482994", // album
39
+ "Q134556", // single
40
+ "Q7366", // song
41
+ "Q105543609", // musical work/composition
42
+ "Q7889", // video game
43
+ "Q13442814", // scholarly article
44
+ "Q191067", // article
45
+ "Q3305213", // painting
46
+ "Q838948", // work of art
47
+ "Q17537576", // creative work
48
+ "Q87167", // manuscript
49
+ "Q1004", // comics
50
+ "Q25379", // play
51
+ "Q24862", // short film
52
+ "Q506240", // television film
53
+ "Q63952888", // anime television series
54
+ "Q4167836", // Wikimedia category
55
+ "Q11266439", // Wikimedia template
56
+ "Q13406463", // Wikimedia list article
57
+ "Q17633526", // Wikinews article
58
+ ]);
59
+
60
+ /** Geographic classes an Organization must not resolve to. */
61
+ const GEOGRAPHIC = new Set([
62
+ "Q515", // city
63
+ "Q1549591", // big city
64
+ "Q5119", // capital
65
+ "Q6256", // country
66
+ "Q3624078", // sovereign state
67
+ "Q35657", // U.S. state
68
+ "Q486972", // human settlement
69
+ "Q532", // village
70
+ "Q3957", // town
71
+ "Q23442", // island
72
+ "Q8502", // mountain
73
+ "Q4022", // river
74
+ "Q23397", // lake
75
+ "Q1620908", // historical region
76
+ "Q82794", // geographic region
77
+ "Q5107", // continent
78
+ ]);
79
+
80
+ /** Lowercase, strip diacritics and punctuation, collapse whitespace: "Café-Müller, Inc." → "cafe muller inc". */
81
+ export function normalizeName(value: string): string {
82
+ return value
83
+ .normalize("NFKD")
84
+ .replace(/\p{M}+/gu, "")
85
+ .toLowerCase()
86
+ .replace(/[\p{P}\p{S}]+/gu, " ")
87
+ .replace(/\s+/g, " ")
88
+ .trim();
89
+ }
90
+
91
+ export interface Mention {
92
+ surface: string;
93
+ name: string;
94
+ type: EntityType;
95
+ description: string;
96
+ primary: boolean;
97
+ }
98
+
99
+ export interface Candidate {
100
+ id: string;
101
+ label: string;
102
+ description: string;
103
+ /** Aliases and the matched text wbsearchentities reports, for the name check. */
104
+ aliases?: string[];
105
+ }
106
+
107
+ export interface GroundedMention extends Mention {
108
+ qid: string;
109
+ candidates: Candidate[];
110
+ }
111
+
112
+ export interface WikidataDetails {
113
+ p31: string[];
114
+ wikipedia: string;
115
+ website: string;
116
+ }
117
+
118
+ /** A stored, verified entity. */
119
+ export interface Entity {
120
+ name: string;
121
+ type: EntityType;
122
+ description: string;
123
+ qid: string;
124
+ wikipedia: string;
125
+ website: string;
126
+ primary: boolean;
127
+ }
128
+
129
+ export interface ImageText {
130
+ alt: string;
131
+ caption: string;
132
+ title: string;
133
+ }
134
+
135
+ // ── Model output ─────────────────────────────────────────────────
136
+
137
+ /** Decode a model response that should be bare JSON, tolerating code fences and leading prose. */
138
+ export function decodeJson(text: unknown): unknown {
139
+ if (text !== null && typeof text === "object") return text; // Workers AI may already return parsed JSON.
140
+ let s = String(text ?? "").trim();
141
+ s = s.replace(/^```(?:json)?\s*/i, "").replace(/\s*```\s*$/, "");
142
+ const start = s.search(/[[{]/);
143
+ if (start < 0) return null;
144
+ s = s.slice(start);
145
+ try {
146
+ return JSON.parse(s);
147
+ } catch {
148
+ // Trailing prose after the JSON: cut at the last matching closer.
149
+ const closer = s[0] === "[" ? "]" : "}";
150
+ const end = s.lastIndexOf(closer);
151
+ if (end <= 0) return null;
152
+ try {
153
+ return JSON.parse(s.slice(0, end + 1));
154
+ } catch {
155
+ return null;
156
+ }
157
+ }
158
+ }
159
+
160
+ /** Strip tags and control characters, collapse whitespace, cap length (on code points). */
161
+ export function cleanText(value: unknown, max = 500): string {
162
+ if (typeof value !== "string" && typeof value !== "number") return "";
163
+ let s = String(value)
164
+ .replace(/<[^>]*>/g, " ")
165
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping control characters is the point.
166
+ .replace(/[\u0000-\u001f\u007f]/g, " ")
167
+ .replace(/\s+/gu, " ")
168
+ .trim();
169
+ const chars = [...s];
170
+ if (chars.length > max) s = chars.slice(0, max).join("").trim();
171
+ return s;
172
+ }
173
+
174
+ /** Parse and sanitize the entity-extraction response (a JSON array of mentions). */
175
+ export function parseMentions(text: unknown): Mention[] {
176
+ const rows = decodeJson(text);
177
+ const list = Array.isArray(rows) ? rows : rows && typeof rows === "object" && Array.isArray((rows as { entities?: unknown }).entities) ? (rows as { entities: unknown[] }).entities : null;
178
+ if (!list) return [];
179
+ const out: Mention[] = [];
180
+ const seen = new Set<string>();
181
+ for (const row of list) {
182
+ if (!row || typeof row !== "object" || Array.isArray(row)) continue;
183
+ const r = row as Record<string, unknown>;
184
+ const name = cleanText(r.name, 120);
185
+ if (!name || !r.type) continue;
186
+ const key = name.toLowerCase();
187
+ if (seen.has(key)) continue;
188
+ seen.add(key);
189
+ const type = (ENTITY_TYPES as readonly string[]).includes(String(r.type)) ? (String(r.type) as EntityType) : "Thing";
190
+ out.push({
191
+ surface: cleanText(r.surface, 120),
192
+ name,
193
+ type,
194
+ description: cleanText(r.description, 200),
195
+ primary: r.primary === true || r.primary === "true",
196
+ });
197
+ if (out.length >= MAX_ENTITIES) break;
198
+ }
199
+ return out;
200
+ }
201
+
202
+ /** Parse the disambiguation response: { "Entity name": "Q123" | null }. */
203
+ export function parseChoices(text: unknown): Record<string, string | null> {
204
+ const decoded = decodeJson(text);
205
+ if (!decoded || typeof decoded !== "object" || Array.isArray(decoded)) return {};
206
+ const out: Record<string, string | null> = {};
207
+ for (const [name, value] of Object.entries(decoded as Record<string, unknown>)) {
208
+ out[name] = typeof value === "string" && /^Q\d+$/i.test(value.trim()) ? value.trim().toUpperCase() : null;
209
+ }
210
+ return out;
211
+ }
212
+
213
+ /** Normalize a generated meta description: plain single line, no wrapping quotes, ≤ max chars cut on a word. */
214
+ export function cleanDescription(text: unknown, max = DESCRIPTION_MAX): string {
215
+ let s = cleanText(typeof text === "string" ? text : "", 2000);
216
+ const unquote = (v: string) => v.replace(/^["'“‘]+|["'”’]+$/gu, "").trim();
217
+ s = unquote(unquote(s).replace(/^(meta description|description)\s*:\s*/i, ""));
218
+ const chars = [...s];
219
+ if (chars.length <= max) return s;
220
+ const cut = chars.slice(0, max - 1).join("");
221
+ const space = cut.lastIndexOf(" ");
222
+ return `${(space > max * 0.6 ? cut.slice(0, space) : cut).replace(/[\s,;:.–—-]+$/u, "")}…`;
223
+ }
224
+
225
+ /** Parse the image-text response ({ alt_text, title, caption, description }). Throws when nothing usable came back. */
226
+ export function parseImageText(text: unknown): ImageText {
227
+ if (typeof text === "string" && !text.trim()) {
228
+ throw new Error("The AI service returned an empty response (it may have run out of output tokens). Try again, or pick a different model.");
229
+ }
230
+ const fields = decodeJson(text);
231
+ if (!fields || typeof fields !== "object" || Array.isArray(fields)) {
232
+ throw new Error("The AI service returned a response that could not be parsed. Try again.");
233
+ }
234
+ const f = fields as Record<string, unknown>;
235
+ const alt = cleanText(f.alt_text ?? f.alt, ALT_MAX).replace(/^(an? )?(image|photo|picture|graphic) of /i, "");
236
+ const result: ImageText = {
237
+ alt: alt ? alt[0].toUpperCase() + alt.slice(1) : "",
238
+ caption: cleanText(f.caption, CAPTION_MAX),
239
+ title: cleanText(f.title, 120),
240
+ };
241
+ if (!result.alt && !result.title) throw new Error("The AI service did not return usable image text. Try again.");
242
+ return result;
243
+ }
244
+
245
+ // ── Wikidata grounding ───────────────────────────────────────────
246
+
247
+ /** Parse a wbsearchentities response into candidates. */
248
+ export function parseSearch(body: unknown): Candidate[] {
249
+ const search = (body as { search?: unknown })?.search;
250
+ if (!Array.isArray(search)) return [];
251
+ const out: Candidate[] = [];
252
+ for (const hit of search) {
253
+ const id = (hit as { id?: unknown })?.id;
254
+ if (typeof id !== "string" || !/^Q\d+$/.test(id)) continue;
255
+ const h = hit as { label?: unknown; description?: unknown; aliases?: unknown; match?: { text?: unknown } };
256
+ const aliases = [...(Array.isArray(h.aliases) ? h.aliases : []), h.match?.text]
257
+ .filter((a): a is string => typeof a === "string" && a.trim().length > 0)
258
+ .map((a) => cleanText(a, 200));
259
+ out.push({
260
+ id,
261
+ label: cleanText(h.label, 200),
262
+ description: cleanText(h.description, 300),
263
+ ...(aliases.length ? { aliases: [...new Set(aliases)] } : {}),
264
+ });
265
+ }
266
+ return out;
267
+ }
268
+
269
+ /** The Wikipedia article URL for a sitelink title. */
270
+ export function wikipediaUrl(language: string, title: string): string {
271
+ return `https://${language}.wikipedia.org/wiki/${encodeURIComponent(title.replace(/ /g, "_")).replace(/%2F/g, "/").replace(/%3A/g, ":")}`;
272
+ }
273
+
274
+ /**
275
+ * One Wikidata Query Service request for what verification needs: each item's
276
+ * P31 (instance of) and P856 (official website) values, deprecated statements
277
+ * excluded, and its `<language>wiki` sitelink title. Only Q-ids get in; items
278
+ * that don't exist (or are redirects) return no rows. The empty last branch
279
+ * keeps an item with none of these in the results.
280
+ */
281
+ export function detailsQuery(qids: string[], language: string): string {
282
+ const ids = [...new Set(qids.filter((q) => /^Q\d+$/.test(q)))];
283
+ const wiki = /^[a-z]{2,3}$/.test(language) ? `https://${language}.wikipedia.org/` : null;
284
+ return [
285
+ "SELECT ?item ?class ?website ?rank ?title WHERE {",
286
+ ` VALUES ?item { ${ids.map((q) => `wd:${q}`).join(" ")} }`,
287
+ " ?item wikibase:sitelinks [] .",
288
+ " { ?item p:P31 ?s . ?s ps:P31 ?class ; wikibase:rank ?r . FILTER(?r != wikibase:DeprecatedRank) }",
289
+ " UNION { ?item p:P856 ?s . ?s ps:P856 ?website ; wikibase:rank ?rank . FILTER(?rank != wikibase:DeprecatedRank) }",
290
+ ...(wiki ? [` UNION { ?article schema:about ?item ; schema:isPartOf <${wiki}> ; schema:name ?title . }`] : []),
291
+ " UNION {}",
292
+ "}",
293
+ ].join("\n");
294
+ }
295
+
296
+ const ENTITY_IRI = "http://www.wikidata.org/entity/";
297
+ const entityId = (iri: unknown) => (typeof iri === "string" && iri.startsWith(ENTITY_IRI) && /^Q\d+$/.test(iri.slice(ENTITY_IRI.length)) ? iri.slice(ENTITY_IRI.length) : "");
298
+
299
+ const PREFERRED_RANK = "http://wikiba.se/ontology#PreferredRank";
300
+ /** Sort key for choosing one P856 among several (rows have no order): preferred rank, then https over http, then the smallest URL. */
301
+ const websiteKey = (url: string, rank: string) => `${rank === PREFERRED_RANK ? 0 : 1}${/^https:/i.test(url) ? 0 : 1}${url}`;
302
+
303
+ /**
304
+ * Parse the detailsQuery results (SPARQL JSON): P31 values, one http(s) P856
305
+ * (preferred rank first, then https, then the lexicographically smallest, so
306
+ * the choice doesn't depend on row order), and the Wikipedia sitelink. null
307
+ * when the body isn't a SPARQL result.
308
+ */
309
+ export function parseSparqlDetails(body: unknown, language: string): Record<string, WikidataDetails> | null {
310
+ const rows = (body as { results?: { bindings?: unknown } })?.results?.bindings;
311
+ if (!Array.isArray(rows)) return null;
312
+ const out: Record<string, WikidataDetails> = {};
313
+ const websiteKeys = new Map<string, string>();
314
+ for (const row of rows) {
315
+ const value = (key: string) => {
316
+ const v = (row as Record<string, { value?: unknown } | undefined>)?.[key]?.value;
317
+ return typeof v === "string" ? v : "";
318
+ };
319
+ const qid = entityId(value("item"));
320
+ if (!qid) continue;
321
+ const details = (out[qid] ??= { p31: [], wikipedia: "", website: "" });
322
+ const instanceOf = entityId(value("class"));
323
+ if (instanceOf && !details.p31.includes(instanceOf)) details.p31.push(instanceOf);
324
+ const website = value("website");
325
+ if (/^https?:\/\//i.test(website)) {
326
+ const key = websiteKey(website, value("rank"));
327
+ const current = websiteKeys.get(qid);
328
+ if (current === undefined || key < current) {
329
+ websiteKeys.set(qid, key);
330
+ details.website = website;
331
+ }
332
+ }
333
+ const title = value("title");
334
+ if (!details.wikipedia && title) details.wikipedia = wikipediaUrl(language, title);
335
+ }
336
+ return out;
337
+ }
338
+
339
+ /** Whether a candidate's label or one of its aliases is the mention's name (or its surface form), after normalization. */
340
+ export function nameMatches(mention: Pick<Mention, "name" | "surface">, candidate: Candidate): boolean {
341
+ const wanted = new Set([normalizeName(mention.name), normalizeName(mention.surface ?? "")].filter(Boolean));
342
+ return [candidate.label, ...(candidate.aliases ?? [])].some((text) => wanted.has(normalizeName(text)));
343
+ }
344
+
345
+ /**
346
+ * Stage 2: keep only candidates whose label or alias matches the mention's
347
+ * name (search hits on a description or a partial word don't count). One
348
+ * match resolves immediately; several go to disambiguation; none drops the
349
+ * mention.
350
+ */
351
+ export function attachCandidates(mentions: Mention[], candidates: Candidate[][]): { mentions: GroundedMention[]; ambiguous: number[] } {
352
+ const ambiguous: number[] = [];
353
+ const grounded = mentions.map((m, i) => {
354
+ const list = (candidates[i] ?? []).filter((c) => nameMatches(m, c));
355
+ if (list.length > 1) ambiguous.push(i);
356
+ return { ...m, candidates: list, qid: list.length === 1 ? list[0].id : "" };
357
+ });
358
+ return { mentions: grounded, ambiguous };
359
+ }
360
+
361
+ /** Stage 3: trust a choice only when it is one of the real candidates. */
362
+ export function applyChoices(mentions: GroundedMention[], ambiguous: number[], choices: Record<string, string | null>): GroundedMention[] {
363
+ const out = mentions.map((m) => ({ ...m }));
364
+ for (const i of ambiguous) {
365
+ const mention = out[i];
366
+ if (!mention) continue;
367
+ const chosen = choices[mention.name];
368
+ if (!chosen) continue;
369
+ if (mention.candidates.some((c) => c.id === chosen)) mention.qid = chosen;
370
+ }
371
+ return out;
372
+ }
373
+
374
+ /** Whether an item's P31 classes fit the type the article gave the entity. */
375
+ export function typeCompatible(type: EntityType, p31: string[]): boolean {
376
+ if (p31.includes(DISAMBIGUATION)) return false;
377
+ const human = p31.includes(HUMAN);
378
+ const creative = p31.some((q) => CREATIVE_WORKS.has(q));
379
+ switch (type) {
380
+ case "Person":
381
+ return human; // An item with no P31 can't be confirmed as a person.
382
+ case "Organization":
383
+ return p31.length > 0 && !human && !creative && !p31.some((q) => GEOGRAPHIC.has(q));
384
+ case "Place":
385
+ return p31.length > 0 && !human && !creative;
386
+ default:
387
+ return !human && !p31.some((q) => q === "Q4167836" || q === "Q11266439" || q === "Q13406463");
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Stage 4: drop unresolved mentions, items Wikidata didn't return, and type
393
+ * mismatches. The entity's name is Wikidata's label (the canonical name),
394
+ * falling back to the model's name.
395
+ */
396
+ export function verifyEntities(mentions: GroundedMention[], details: Record<string, WikidataDetails>): Entity[] {
397
+ const out: Entity[] = [];
398
+ const seen = new Set<string>();
399
+ for (const m of mentions) {
400
+ if (!m.qid || seen.has(m.qid)) continue;
401
+ const info = details[m.qid];
402
+ if (!info || !typeCompatible(m.type, info.p31)) continue;
403
+ seen.add(m.qid);
404
+ const label = m.candidates.find((c) => c.id === m.qid)?.label;
405
+ out.push({
406
+ name: label || m.name,
407
+ type: m.type,
408
+ description: m.description,
409
+ qid: m.qid,
410
+ wikipedia: info.wikipedia,
411
+ website: info.website,
412
+ primary: m.primary,
413
+ });
414
+ }
415
+ return out;
416
+ }
417
+
418
+ /** Schema.org nodes for stored entities: { about, mentions }. */
419
+ export function entityNodes(entities: Entity[] | undefined | null): { about: Record<string, unknown>[]; mentions: Record<string, unknown>[] } {
420
+ const about: Record<string, unknown>[] = [];
421
+ const mentions: Record<string, unknown>[] = [];
422
+ for (const e of entities ?? []) {
423
+ if (!e?.name || !e.qid || !/^Q\d+$/.test(e.qid)) continue;
424
+ const sameAs = [`https://www.wikidata.org/wiki/${e.qid}`];
425
+ if (e.wikipedia) sameAs.push(e.wikipedia);
426
+ if (e.website) sameAs.push(e.website);
427
+ const node: Record<string, unknown> = {
428
+ "@type": (ENTITY_TYPES as readonly string[]).includes(e.type) ? e.type : "Thing",
429
+ name: e.name,
430
+ sameAs: sameAs.length > 1 ? sameAs : sameAs[0],
431
+ };
432
+ if (e.description) node.description = e.description;
433
+ (e.primary ? about : mentions).push(node);
434
+ }
435
+ return { about, mentions };
436
+ }
437
+
438
+ // ── Content → plain text ─────────────────────────────────────────
439
+
440
+ /** Plain text from a Portable Text value (or any nested structure holding blocks), paragraphs separated by blank lines. */
441
+ export function portableTextToPlain(value: unknown, depth = 0): string {
442
+ if (depth > 8 || value == null) return "";
443
+ if (typeof value === "string") return "";
444
+ if (Array.isArray(value)) {
445
+ return value
446
+ .map((v) => portableTextToPlain(v, depth + 1))
447
+ .filter(Boolean)
448
+ .join("\n\n");
449
+ }
450
+ if (typeof value !== "object") return "";
451
+ const node = value as Record<string, unknown>;
452
+ if (node._type === "block" && Array.isArray(node.children)) {
453
+ return node.children
454
+ .map((c) => (c && typeof c === "object" && typeof (c as { text?: unknown }).text === "string" ? (c as { text: string }).text : ""))
455
+ .join("")
456
+ .trim();
457
+ }
458
+ // Custom blocks: look inside nested arrays (columns, callouts…) and plain text props.
459
+ const parts: string[] = [];
460
+ for (const [key, child] of Object.entries(node)) {
461
+ if (key.startsWith("_") || key === "markDefs") continue;
462
+ if (Array.isArray(child) || (child && typeof child === "object")) {
463
+ const text = portableTextToPlain(child, depth + 1);
464
+ if (text) parts.push(text);
465
+ } else if ((key === "text" || key === "caption" || key === "quote") && typeof child === "string" && child.trim()) {
466
+ parts.push(child.trim());
467
+ }
468
+ }
469
+ return parts.join("\n\n");
470
+ }
471
+
472
+ /** A bounded plain-text rendition of an entry: its text-like fields in order. */
473
+ export function entryPlainText(data: Record<string, unknown>, fields: Array<{ slug: string; type: string }>, limit = 24000): string {
474
+ const parts: string[] = [];
475
+ for (const field of fields) {
476
+ const value = data[field.slug];
477
+ if (field.type === "portableText" || field.type === "blocks") parts.push(portableTextToPlain(value));
478
+ else if (field.type === "text" && typeof value === "string") parts.push(value.replace(/<[^>]*>/g, " "));
479
+ }
480
+ const text = parts
481
+ .map((p) => p.trim())
482
+ .filter(Boolean)
483
+ .join("\n\n")
484
+ .replace(/[ \t]+/g, " ");
485
+ return [...text].slice(0, limit).join("");
486
+ }
487
+
488
+ // ── Queue ────────────────────────────────────────────────────────
489
+
490
+ export interface QueueJob {
491
+ kind: "entry" | "media";
492
+ collection?: string;
493
+ entryId?: string;
494
+ mediaId?: string;
495
+ /** Epoch ms when the job may run (debounce / backoff). */
496
+ due: number;
497
+ attempts: number;
498
+ /** Times in a row Wikidata's Retry-After pushed the job back; reset when an attempt fails. */
499
+ deferrals?: number;
500
+ force?: boolean;
501
+ enqueuedAt: number;
502
+ lastError?: string;
503
+ /** Model outputs already paid for, so a retry resumes after them. Valid only for the same text hash. */
504
+ cache?: StageCache;
505
+ }
506
+
507
+ export interface StageCache {
508
+ hash: string;
509
+ mentions?: Mention[];
510
+ choices?: Record<string, string | null>;
511
+ description?: string;
512
+ }
513
+
514
+ export const MAX_ATTEMPTS = 3;
515
+ /** Wikidata "slow down" deferrals a job gets before one counts as a failed attempt. */
516
+ export const MAX_DEFERRALS = 10;
517
+
518
+ export function queueId(job: Pick<QueueJob, "kind" | "collection" | "entryId" | "mediaId">): string {
519
+ return job.kind === "media" ? `media:${job.mediaId}` : `entry:${job.collection}:${job.entryId}`;
520
+ }
521
+
522
+ /**
523
+ * Pick the jobs to run this tick: due jobs, at most `perTick`, taking entries
524
+ * and images in turns (so a backlog of one kind can't starve the other), each
525
+ * kind in due order, and never more model calls than the day's remaining
526
+ * allowance (a job costs up to `callsPerJob` calls) or outbound requests than
527
+ * `subrequests` (a job costs up to `subrequestsPerJob`). When a kind's next
528
+ * job doesn't fit, that kind stops (its later jobs don't jump the line).
529
+ */
530
+ export function planBatch(
531
+ jobs: Array<{ id: string; data: QueueJob }>,
532
+ opts: {
533
+ now: number;
534
+ perTick: number;
535
+ remainingCalls: number;
536
+ callsPerJob: (job: QueueJob) => number;
537
+ subrequests?: number;
538
+ subrequestsPerJob?: (job: QueueJob) => number;
539
+ },
540
+ ): Array<{ id: string; data: QueueJob }> {
541
+ const byDue = (a: { id: string; data: QueueJob }, b: { id: string; data: QueueJob }) => a.data.due - b.data.due || a.id.localeCompare(b.id);
542
+ const lanes = (["entry", "media"] as const)
543
+ .map((kind) => jobs.filter((j) => j.data.kind === kind && j.data.due <= opts.now).sort(byDue))
544
+ .filter((lane) => lane.length)
545
+ .sort((a, b) => byDue(a[0], b[0])); // The lane with the oldest job goes first.
546
+ const out: Array<{ id: string; data: QueueJob }> = [];
547
+ let calls = Math.max(0, opts.remainingCalls);
548
+ let requests = opts.subrequests ?? Number.POSITIVE_INFINITY;
549
+ const open = lanes.map(() => true);
550
+ const next = lanes.map(() => 0);
551
+ while (out.length < opts.perTick && open.some(Boolean)) {
552
+ for (let lane = 0; lane < lanes.length && out.length < opts.perTick; lane++) {
553
+ if (!open[lane]) continue;
554
+ const job = lanes[lane][next[lane]];
555
+ if (!job) {
556
+ open[lane] = false;
557
+ continue;
558
+ }
559
+ const cost = opts.callsPerJob(job.data);
560
+ const reqs = opts.subrequestsPerJob?.(job.data) ?? 0;
561
+ if (cost > calls || reqs > requests) {
562
+ open[lane] = false;
563
+ continue;
564
+ }
565
+ calls -= cost;
566
+ requests -= reqs;
567
+ next[lane]++;
568
+ out.push(job);
569
+ }
570
+ }
571
+ return out;
572
+ }
573
+
574
+ /**
575
+ * Push a job back for Wikidata's Retry-After without using up an attempt.
576
+ * null once it has been deferred MAX_DEFERRALS times in a row (a lasting 429),
577
+ * when the caller counts it as a failed attempt instead.
578
+ */
579
+ export function deferJob(job: QueueJob, now: number, retryAfterMs: number): QueueJob | null {
580
+ const deferrals = (job.deferrals ?? 0) + 1;
581
+ if (deferrals > MAX_DEFERRALS) return null;
582
+ return { ...job, deferrals, due: now + retryAfterMs };
583
+ }
584
+
585
+ /** Wikimedia User-Agent policy format: client/version (contact) library. The repository is the contact when the site has no URL. */
586
+ export function userAgent(version: string, siteUrl: string | undefined): string {
587
+ return `CoywolfPack/${version} (${siteUrl || "https://github.com/coywolf-llc/coywolf-pack"}) EmDash`;
588
+ }
589
+
590
+ /** Retry schedule after a failure: 5, then 20 minutes; null after MAX_ATTEMPTS (3) tries, when the job should be dropped. */
591
+ export function retryAt(job: QueueJob, now: number): number | null {
592
+ const attempts = job.attempts + 1;
593
+ if (attempts >= MAX_ATTEMPTS) return null;
594
+ return now + 5 * 60_000 * 4 ** (attempts - 1);
595
+ }
596
+
597
+ /** Merge a re-enqueue into an existing job: push the debounce later, keep attempts reset, keep force if either asked. */
598
+ export function mergeJob(existing: QueueJob | null, next: QueueJob): QueueJob {
599
+ if (!existing) return next;
600
+ return { ...next, force: Boolean(existing.force || next.force), enqueuedAt: Math.min(existing.enqueuedAt, next.enqueuedAt), attempts: 0 };
601
+ }
602
+
603
+ /** UTC day key for the daily call counter. */
604
+ export function dayKey(now: number): string {
605
+ return new Date(now).toISOString().slice(0, 10);
606
+ }