@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,1138 @@
1
+ /**
2
+ * Schema & Social module: the page:metadata hook (JSON-LD @graph, robots
3
+ * directives, Open Graph extras) and the routes behind the Schema admin page.
4
+ *
5
+ * Simple values are settings (settingsSchema); structured data lives in
6
+ * plugin storage: schemaDocs ("site" = Site Details, "types" = per-collection
7
+ * type defaults), schemaEntries (per-entry type overrides and main
8
+ * subject, id "<collection>:<entry id>") and schemaAuthors (Person property
9
+ * rows per byline id; a row may be marked profile-only).
10
+ */
11
+ import { getManyBatched } from "../core/storage.js";
12
+ import { FORMATS, cdnOriginalUrl, imageCdn, parseCdnUrl, parseImagePath } from "../images/lib.js";
13
+ import { refreshMediaHost } from "../images/settings.js";
14
+ import { siteName } from "../core/site.js";
15
+ import type { PageMetadataContribution, PluginContext, PublicPageContext } from "emdash";
16
+ import { PluginRouteError, definePluginRoute } from "emdash";
17
+ import { z } from "zod";
18
+
19
+ import { absoluteUrl, entryUrl } from "../core/content-url.js";
20
+ import {
21
+ type FeatureMap,
22
+ cachedCtxFeatures,
23
+ ctxFeatures,
24
+ invalidateFeatures,
25
+ isCurrent,
26
+ isOn,
27
+ readSiteSetting,
28
+ registerSiteSetting,
29
+ requireFeature,
30
+ settingsEpoch,
31
+ } from "../core/features.js";
32
+ import { batchedFirst } from "../core/d1-batch.js";
33
+ import { readEntryDocs } from "../core/entry-docs.js";
34
+ import { noteReadFailure, readFailures } from "../core/read-failures.js";
35
+ import { requestMemo, requestStore } from "../core/request-memo.js";
36
+ import { parseInput, workerEnv } from "../shared.js";
37
+ import { type MediaRow, cleanDefaultOgImage, mediaHostImageInfo, ogImageTags, ownImageOnMediaHost, packOwnsOgImage } from "./og-image.js";
38
+ import { ARTICLE_TYPES, ORGANIZATION_PROPERTIES, ORGANIZATION_TYPES, PAGE_TYPES, PERSON_PROPERTIES, PROPERTY_INPUTS } from "./catalog.js";
39
+ import {
40
+ type BylineFacts,
41
+ CUSTOM_KEY,
42
+ HOME_KEY,
43
+ type ImageInfo,
44
+ type MainSubject,
45
+ type Node,
46
+ type PropertyRow,
47
+ type SiteDetails,
48
+ type TypeChoice,
49
+ type TypeMap,
50
+ absolute,
51
+ authorId,
52
+ authorPath,
53
+ breadcrumbDocument,
54
+ buildGraph,
55
+ enrichImageObjects,
56
+ mainSubjectByline,
57
+ mediaRefFromUrl,
58
+ ogLocale,
59
+ originOf,
60
+ pageUrl,
61
+ parseLimit,
62
+ personNode,
63
+ publisherNode,
64
+ resolveTypes,
65
+ robotsContent,
66
+ attachVideos,
67
+ themeVideoNode,
68
+ } from "./graph.js";
69
+
70
+ export interface SchemaOptions {
71
+ /** D1 binding of the site database (for media dimension lookups). Default "DB". */
72
+ database?: string;
73
+ }
74
+
75
+ export const SCHEMA_FEATURES = {
76
+ main: "schema",
77
+ graph: "schema.graph",
78
+ breadcrumbs: "schema.breadcrumbs",
79
+ robots: "schema.robots",
80
+ openGraph: "schema.openGraph",
81
+ authors: "schema.authors",
82
+ } as const;
83
+
84
+ export const schemaSettingsSchema = {
85
+ schemaSearchUrl: {
86
+ type: "string",
87
+ label: "Schema: search URL template",
88
+ description: "Adds a SearchAction to the WebSite node. Use {search_term_string} for the query. Leave empty for none.",
89
+ default: "/?s={search_term_string}",
90
+ },
91
+ schemaAuthorUrlPattern: {
92
+ type: "string",
93
+ label: "Schema: author page URL",
94
+ description:
95
+ "Your site's author page URL, e.g. /author/{slug}/ ({slug} is the byline slug). Used for an author's url and @id when the byline has no website. Leave empty if the site has no author pages.",
96
+ default: "",
97
+ },
98
+ schemaBreadcrumbHome: {
99
+ type: "string",
100
+ label: "Schema: breadcrumb home label",
101
+ description: "First crumb of breadcrumb trails derived from the URL path.",
102
+ default: "Home",
103
+ },
104
+ schemaRobotsMaxImage: {
105
+ type: "select",
106
+ label: "Robots: max-image-preview",
107
+ options: [
108
+ { value: "large", label: "Large" },
109
+ { value: "standard", label: "Standard" },
110
+ { value: "none", label: "None" },
111
+ { value: "", label: "Don't set" },
112
+ ],
113
+ default: "large",
114
+ },
115
+ schemaRobotsMaxSnippet: {
116
+ type: "number",
117
+ label: "Robots: max-snippet",
118
+ description: "Characters; -1 for no limit.",
119
+ default: -1,
120
+ min: -1,
121
+ },
122
+ schemaRobotsMaxVideo: {
123
+ type: "number",
124
+ label: "Robots: max-video-preview",
125
+ description: "Seconds; -1 for no limit.",
126
+ default: -1,
127
+ min: -1,
128
+ },
129
+ schemaRobotsNofollow: {
130
+ type: "boolean",
131
+ label: "Robots: nofollow every page",
132
+ default: false,
133
+ },
134
+ schemaOgLocale: {
135
+ type: "string",
136
+ label: "Open Graph: og:locale",
137
+ description: "For example en_US. Leave empty to derive it from the site locale.",
138
+ default: "",
139
+ },
140
+ } as const;
141
+
142
+ type SettingKey = keyof typeof schemaSettingsSchema;
143
+ // Read with the feature switches (one query for all of them, cached per isolate).
144
+ for (const key of Object.keys(schemaSettingsSchema)) registerSiteSetting(key);
145
+ type SimpleSettings = { [K in SettingKey]: (typeof schemaSettingsSchema)[K]["default"] extends number ? number : (typeof schemaSettingsSchema)[K]["default"] extends boolean ? boolean : string };
146
+
147
+ export const schemaStorage = {
148
+ schemaDocs: { indexes: [] as string[] },
149
+ schemaEntries: { indexes: ["collection"] },
150
+ schemaAuthors: { indexes: [] as string[] },
151
+ };
152
+
153
+ interface EntryOverride extends TypeChoice {
154
+ collection: string;
155
+ entryId: string;
156
+ title?: string;
157
+ /** What the entry is about: a byline (Person) or the site publisher. */
158
+ mainSubject?: MainSubject;
159
+ updatedAt?: string;
160
+ }
161
+
162
+ // biome-ignore lint/suspicious/noExplicitAny: storage collections are untyped at this layer.
163
+ type Coll = any;
164
+ const docs = (ctx: PluginContext): Coll => (ctx.storage as Record<string, Coll>).schemaDocs;
165
+ const entries = (ctx: PluginContext): Coll => (ctx.storage as Record<string, Coll>).schemaEntries;
166
+ const authorsStore = (ctx: PluginContext): Coll => (ctx.storage as Record<string, Coll>).schemaAuthors;
167
+
168
+ // ── Config (cached per isolate) ──────────────────────────────────
169
+
170
+ interface Config {
171
+ settings: SimpleSettings;
172
+ site: SiteDetails;
173
+ types: TypeMap;
174
+ /** The publisher Person's byline and rows, when Site Details say "person". */
175
+ person: { byline: BylineFacts | null; rows: PropertyRow[] | null } | null;
176
+ /** Every byline's saved Person rows, by byline id; null when there are too many to keep (read per page then). */
177
+ authors: Map<string, { rows: PropertyRow[] }> | null;
178
+ }
179
+
180
+ /**
181
+ * Kept until a pack settings save (here, or in another isolate: settingsEpoch)
182
+ * or ten minutes, whichever comes first. Byline profile edits (made in
183
+ * EmDash, not here) show up within the ten minutes.
184
+ */
185
+ const CONFIG_TTL_MS = 10 * 60_000;
186
+ /** Saved author rows kept in the config; more than this and they're read per page instead. */
187
+ const MAX_CACHED_AUTHORS = 500;
188
+ let configCache: { at: number; epoch: number; config: Config } | null = null;
189
+
190
+ export function invalidateSchemaConfig(): void {
191
+ configCache = null;
192
+ }
193
+
194
+ const DEFAULT_SITE: SiteDetails = { publisherType: "organization", personBylineId: null, orgRows: [] };
195
+
196
+ async function readSettings(ctx: PluginContext, database?: string, fresh = false): Promise<SimpleSettings> {
197
+ const keys = Object.keys(schemaSettingsSchema) as SettingKey[];
198
+ // From the feature switches' query (usually already cached); else (and for the admin) one read for every schema* setting (they share the prefix).
199
+ const reads = fresh ? [] : await Promise.all(keys.map((k) => readSiteSetting(k, database ?? "DB").catch(() => null)));
200
+ const stored = !fresh && reads.every(Boolean)
201
+ ? new Map(keys.map((k, i) => [k as string, reads[i]?.value ?? undefined]))
202
+ : new Map((await ctx.settings.list("schema")).map((e) => [e.key, e.value]));
203
+ const out: Record<string, unknown> = {};
204
+ for (const k of keys) out[k] = stored.get(k) ?? schemaSettingsSchema[k].default;
205
+ return out as SimpleSettings;
206
+ }
207
+
208
+ /** Every saved author row (one query), or null when there are more than MAX_CACHED_AUTHORS. */
209
+ async function readAllAuthors(ctx: PluginContext): Promise<Map<string, { rows: PropertyRow[] }> | null> {
210
+ const out = new Map<string, { rows: PropertyRow[] }>();
211
+ let cursor: string | undefined;
212
+ for (let page = 0; page * 100 < MAX_CACHED_AUTHORS; page++) {
213
+ const result = await authorsStore(ctx).query({ limit: 100, ...(cursor ? { cursor } : {}) });
214
+ for (const item of result.items as Array<{ id: string; data: { rows: PropertyRow[] } }>) out.set(item.id, item.data);
215
+ if (!result.hasMore || !result.cursor) return out;
216
+ cursor = result.cursor;
217
+ }
218
+ return null;
219
+ }
220
+
221
+ /** A read that may fail on a page: null instead (a missing byline or media item is null too), and the failure noted (src/core/read-failures.ts). */
222
+ const orNull = <T,>(read: Promise<T> | undefined): Promise<T | null> =>
223
+ read
224
+ ? read.catch(() => {
225
+ noteReadFailure("schema: bylines or media");
226
+ return null;
227
+ })
228
+ : Promise.resolve(null);
229
+
230
+ async function bylineFacts(ctx: PluginContext, id: string): Promise<BylineFacts | null> {
231
+ const byline = await orNull(ctx.bylines?.get(id));
232
+ if (!byline) return null;
233
+ let avatarUrl: string | null = null;
234
+ if (byline.avatarMediaId) avatarUrl = (await orNull(ctx.media?.get(byline.avatarMediaId)))?.url ?? null;
235
+ return { id: byline.id, slug: byline.slug, displayName: byline.displayName, bio: byline.bio, websiteUrl: byline.websiteUrl, avatarUrl };
236
+ }
237
+
238
+ /** The module's settings and docs (cached per isolate). `fresh`: read them now (admin pages). */
239
+ async function loadConfig(ctx: PluginContext, database?: string, fresh = false): Promise<Config> {
240
+ if (!fresh && configCache && isCurrent(configCache, CONFIG_TTL_MS)) return configCache.config;
241
+ const epoch = settingsEpoch();
242
+ const failures = readFailures();
243
+ const [settings, stored, authors] = await Promise.all([
244
+ readSettings(ctx, database, fresh),
245
+ docs(ctx).getMany(["site", "types"]) as Promise<Map<string, unknown>>,
246
+ authorsStore(ctx)?.query ? orNull(readAllAuthors(ctx)) : null,
247
+ ]);
248
+ const site = { ...DEFAULT_SITE, ...((stored.get("site") as SiteDetails | undefined) ?? {}) };
249
+ const types = (stored.get("types") as TypeMap | undefined) ?? {};
250
+ let person: Config["person"] = null;
251
+ if (site.publisherType === "person" && site.personBylineId) {
252
+ const [byline, rows] = await Promise.all([
253
+ bylineFacts(ctx, site.personBylineId),
254
+ authors ? authors.get(site.personBylineId) : (authorsStore(ctx).get(site.personBylineId) as Promise<{ rows: PropertyRow[] } | null>),
255
+ ]);
256
+ person = { byline, rows: rows?.rows ?? null };
257
+ }
258
+ const config = { settings, site, types, person, authors };
259
+ // A save while this read ran (the epoch moved) leaves it uncached, and so does a failed read (a missing byline
260
+ // would make the publisher a bare Organization on every page for the cache's lifetime).
261
+ if (epoch === settingsEpoch() && readFailures() === failures) configCache = { at: Date.now(), epoch, config };
262
+ return config;
263
+ }
264
+
265
+ /** Saved Person rows for these bylines: from the config when it holds them all, else one read. */
266
+ async function authorRows(ctx: PluginContext, config: Config, ids: string[]): Promise<Map<string, { rows: PropertyRow[] }>> {
267
+ if (config.authors) return new Map(ids.flatMap((id) => (config.authors?.has(id) ? [[id, config.authors.get(id) as { rows: PropertyRow[] }]] : [])));
268
+ return getManyBatched(authorsStore(ctx), ids);
269
+ }
270
+
271
+ // ── Media lookups (dimensions and alt, cached per isolate) ───────
272
+
273
+ const MEDIA_TTL_MS = 10 * 60_000;
274
+ const MEDIA_MAX = 500;
275
+ const mediaCache = new Map<string, { at: number; info: ImageInfo | null }>();
276
+ /** Media rows by storage key, for media-host URLs (any size of a file shares its row). */
277
+ const mediaRowCache = new Map<string, { at: number; row: MediaRow | null }>();
278
+
279
+ /**
280
+ * A clean image URL (Clean image URLs module): on the media host
281
+ * (https://media.example.com/<file> or /s/<w>[x<h>]/<file>) or the Worker
282
+ * route (/media/<file id>-<w>x<h>.<format>). The media item, at the requested
283
+ * dimensions. Undefined when it's neither.
284
+ */
285
+ async function lookupCleanMedia(options: SchemaOptions, url: string, origin: string): Promise<ImageInfo | null | undefined> {
286
+ const onHost = parseCdnUrl(url);
287
+ if (onHost) {
288
+ const key = `${onHost.id}.${onHost.ext}`;
289
+ const hit = mediaRowCache.get(key);
290
+ if (hit && Date.now() - hit.at < MEDIA_TTL_MS) return hit.row ? mediaHostImageInfo(url, hit.row) : null;
291
+ let row: MediaRow | null = null;
292
+ try {
293
+ const env = await workerEnv();
294
+ const db = env[options.database ?? "DB"] as D1Database | undefined;
295
+ // With the page's other pack reads of this tick (one D1 batch).
296
+ row = db ? await batchedFirst<MediaRow>(db, db.prepare("SELECT width, height, alt, mime_type FROM media WHERE storage_key = ? LIMIT 1").bind(key)) : null;
297
+ } catch {
298
+ noteReadFailure("schema: media details");
299
+ return null; // Don't cache failures.
300
+ }
301
+ if (mediaRowCache.size >= MEDIA_MAX) mediaRowCache.delete(mediaRowCache.keys().next().value as string);
302
+ mediaRowCache.set(key, { at: Date.now(), row });
303
+ return row ? mediaHostImageInfo(url, row) : null;
304
+ }
305
+ let pathname: string;
306
+ try {
307
+ const parsed = new URL(url, origin || "https://site.invalid");
308
+ if (origin && /^https?:/i.test(url) && parsed.origin !== new URL(origin).origin) return undefined;
309
+ pathname = parsed.pathname;
310
+ } catch {
311
+ return undefined;
312
+ }
313
+ const clean = parseImagePath(pathname);
314
+ if (!clean) return undefined;
315
+ try {
316
+ const env = await workerEnv();
317
+ const db = env[options.database ?? "DB"] as D1Database | undefined;
318
+ const row = db
319
+ ? await batchedFirst<{ width: number | null; height: number | null; alt: string | null }>(
320
+ db,
321
+ db.prepare("SELECT width, height, alt FROM media WHERE storage_key LIKE ? LIMIT 1").bind(`${clean.id}.%`),
322
+ )
323
+ : null;
324
+ if (!row) return null;
325
+ const height = clean.height ?? (row.width && row.height ? Math.round((clean.width * row.height) / row.width) : null);
326
+ return { url, width: clean.width, height, alt: row.alt, mimeType: FORMATS[clean.format] };
327
+ } catch {
328
+ return null;
329
+ }
330
+ }
331
+
332
+ async function lookupMedia(options: SchemaOptions, url: string, origin: string): Promise<ImageInfo | null> {
333
+ const cleanInfo = await lookupCleanMedia(options, url, origin);
334
+ if (cleanInfo !== undefined) return cleanInfo;
335
+ const ref = mediaRefFromUrl(url, origin);
336
+ if (!ref) return null;
337
+ const cacheKey = `${ref.by}:${ref.value}`;
338
+ const hit = mediaCache.get(cacheKey);
339
+ if (hit && Date.now() - hit.at < MEDIA_TTL_MS) return hit.info ? { ...hit.info, url } : null;
340
+ let info: ImageInfo | null = null;
341
+ try {
342
+ const env = await workerEnv();
343
+ const db = env[options.database ?? "DB"] as D1Database | undefined;
344
+ const row = db
345
+ ? await batchedFirst<{ width: number | null; height: number | null; alt: string | null; mime_type: string | null }>(
346
+ db,
347
+ db.prepare(`SELECT width, height, alt, mime_type FROM media WHERE ${ref.by === "id" ? "id" : "storage_key"} = ? LIMIT 1`).bind(ref.value),
348
+ )
349
+ : null;
350
+ if (row) info = { url, width: row.width, height: row.height, alt: row.alt, mimeType: row.mime_type };
351
+ } catch {
352
+ noteReadFailure("schema: media details");
353
+ return null; // Don't cache failures.
354
+ }
355
+ if (mediaCache.size >= MEDIA_MAX) mediaCache.delete(mediaCache.keys().next().value as string);
356
+ mediaCache.set(cacheKey, { at: Date.now(), info });
357
+ return info;
358
+ }
359
+
360
+ // ── page:metadata ────────────────────────────────────────────────
361
+
362
+ interface SiteFacts {
363
+ origin: string;
364
+ siteName: string;
365
+ tagline: string | null;
366
+ logo: ImageInfo | null;
367
+ defaultOgImage: ImageInfo | null;
368
+ }
369
+
370
+ async function siteFacts(ctx: PluginContext, page: PublicPageContext): Promise<SiteFacts> {
371
+ // biome-ignore lint/suspicious/noExplicitAny: SiteSettings is request-cached by EmDash; read loosely.
372
+ let settings: any = {};
373
+ try {
374
+ const { getSiteSettings } = await import("emdash");
375
+ settings = await getSiteSettings();
376
+ } catch {
377
+ // Outside a request context (or no database): fall back to the plugin context. Inside one it's a failed read.
378
+ if (requestStore()) noteReadFailure("schema: site settings");
379
+ }
380
+ const base = page.siteUrl || settings.url || ctx.site.url || (URL.canParse(page.url) ? new URL(page.url).origin : "");
381
+ const origin = originOf(base);
382
+ const ref = (m: { url?: string; width?: number; height?: number; alt?: string } | undefined): ImageInfo | null =>
383
+ m?.url ? { url: absolute(m.url, origin) ?? m.url, width: m.width, height: m.height, alt: m.alt } : null;
384
+ return {
385
+ origin,
386
+ siteName: page.siteName || settings.title || ctx.site.name,
387
+ tagline: settings.tagline || null,
388
+ logo: ref(settings.logo),
389
+ defaultOgImage: ref(settings.seo?.defaultOgImage),
390
+ };
391
+ }
392
+
393
+ /**
394
+ * The page's og:image (same precedence as core). With Clean image URLs on:
395
+ * the default image at a clean 1200x630 URL, and the page's own
396
+ * media-library image as its original on the media host (when one is set).
397
+ */
398
+ async function primaryImage(options: SchemaOptions, page: PublicPageContext, site: SiteFacts, cleanUrls = false): Promise<ImageInfo | null> {
399
+ const own = page.seo?.ogImage || page.image;
400
+ if (!own) return cleanUrls ? (cleanDefaultOgImage(site.defaultOgImage, site.origin) ?? site.defaultOgImage) : site.defaultOgImage;
401
+ const original = cleanUrls ? ownImageOnMediaHost(own) : null;
402
+ if (original) return (await lookupMedia(options, original, site.origin)) ?? { url: original };
403
+ const url = absolute(own, site.origin) ?? own;
404
+ return (await lookupMedia(options, own, site.origin)) ?? { url };
405
+ }
406
+
407
+ type EntryBylines = Awaited<ReturnType<NonNullable<PluginContext["bylines"]>["getEntriesBylines"]>>;
408
+ type Credit = EntryBylines[number]["bylines"][number];
409
+
410
+ /**
411
+ * What the theme already loaded for the page's entry and passes through the
412
+ * page context, so the pack doesn't read it again:
413
+ * - `page.coywolf.entry`: the entry from getEmDashEntry() (its `data` has
414
+ * the fields and, hydrated by EmDash, `bylines`);
415
+ * - `page.coywolf.bylines`: just the credits (entry.data.bylines).
416
+ * An entry whose data.id isn't the page's content id is ignored.
417
+ */
418
+ export function themedEntry(page: PublicPageContext): { data?: Record<string, unknown>; bylines?: Credit[] } {
419
+ const coywolf = (page as PublicPageContext & { coywolf?: { entry?: unknown; bylines?: unknown } }).coywolf;
420
+ if (!coywolf || !page.content) return {};
421
+ const entry = coywolf.entry as { data?: unknown } | undefined;
422
+ const data = entry?.data && typeof entry.data === "object" ? (entry.data as Record<string, unknown>) : undefined;
423
+ const sameEntry = data && (typeof data.id !== "string" || data.id === page.content.id);
424
+ const credits = Array.isArray(coywolf.bylines) ? coywolf.bylines : sameEntry && Array.isArray(data?.bylines) ? data.bylines : undefined;
425
+ const bylines = credits?.filter((c): c is Credit => Boolean(c && typeof c === "object" && (c as Credit).byline && typeof (c as Credit).byline.id === "string"));
426
+ return { ...(sameEntry ? { data } : {}), ...(bylines ? { bylines } : {}) };
427
+ }
428
+
429
+ /** The entry's credited bylines (none without an entry, or on error): the theme's, else read. Started early, alongside the page's other reads. */
430
+ function entryBylines(ctx: PluginContext, page: PublicPageContext, themed?: Credit[]): Promise<EntryBylines> {
431
+ if (!page.content) return Promise.resolve([]);
432
+ if (themed) return Promise.resolve([{ entryId: page.content.id, bylines: themed }]);
433
+ const bylines = ctx.bylines;
434
+ if (!bylines) return Promise.resolve([]);
435
+ const { collection, id } = page.content;
436
+ // Once per request, however many times the hook runs for the page.
437
+ return requestMemo(`coywolf-entry-bylines:${collection}:${id}`, async () => (await orNull(bylines.getEntriesBylines(collection, [id]))) ?? []);
438
+ }
439
+
440
+ async function authorNodes(ctx: PluginContext, credited: Promise<EntryBylines>, config: Config, site: SiteFacts, features: FeatureMap): Promise<Node[]> {
441
+ const [credits] = await credited;
442
+ const bylines = credits?.bylines.map((c) => c.byline) ?? [];
443
+ if (!bylines.length) return [];
444
+ const rowsById: Map<string, { rows: PropertyRow[] }> = isOn(features, SCHEMA_FEATURES.authors)
445
+ ? await authorRows(ctx, config, bylines.map((b) => b.id))
446
+ : new Map();
447
+ const nodes = await Promise.all(
448
+ bylines.map(async (b) => {
449
+ const rows = rowsById.get(b.id)?.rows ?? null;
450
+ const hasImage = rows?.some((r) => r.prop === "image" && r.value);
451
+ const avatarUrl = !hasImage && b.avatarMediaId ? ((await orNull(ctx.media?.get(b.avatarMediaId)))?.url ?? null) : null;
452
+ const path = authorPath(config.settings.schemaAuthorUrlPattern, b.slug);
453
+ const authorUrl = path ? absolute(path, site.origin) : null;
454
+ return personNode({
455
+ byline: { id: b.id, slug: b.slug, displayName: b.displayName, bio: b.bio, websiteUrl: b.websiteUrl, avatarUrl },
456
+ rows,
457
+ origin: site.origin,
458
+ defaultId: authorId({
459
+ bylineId: b.id,
460
+ slug: b.slug,
461
+ origin: site.origin,
462
+ authorUrl,
463
+ details: config.site,
464
+ personRows: config.person?.rows,
465
+ }),
466
+ authorUrl,
467
+ });
468
+ }),
469
+ );
470
+ return nodes.filter((n): n is Node => n !== null);
471
+ }
472
+
473
+ /**
474
+ * The Person for a byline chosen as a page's main subject: same @id as when
475
+ * they're credited as an author, plus their profile-only properties. Null
476
+ * when the byline no longer exists.
477
+ */
478
+ async function mainSubjectPerson(ctx: PluginContext, bylineId: string, config: Config, site: SiteFacts, features: FeatureMap): Promise<Node | null> {
479
+ const facts = await bylineFacts(ctx, bylineId);
480
+ if (!facts) return null;
481
+ const stored = isOn(features, SCHEMA_FEATURES.authors) ? ((await authorRows(ctx, config, [bylineId])).get(bylineId) ?? null) : null;
482
+ const path = authorPath(config.settings.schemaAuthorUrlPattern, facts.slug);
483
+ const authorUrl = path ? absolute(path, site.origin) : null;
484
+ return personNode({
485
+ byline: facts,
486
+ rows: stored?.rows ?? null,
487
+ origin: site.origin,
488
+ defaultId: authorId({ bylineId, slug: facts.slug, origin: site.origin, authorUrl, details: config.site, personRows: config.person?.rows }),
489
+ authorUrl,
490
+ profile: true,
491
+ });
492
+ }
493
+
494
+ /**
495
+ * A change to the page's graph whose data was loaded ahead of time, so the
496
+ * loads run alongside each other and the changes still apply in a fixed order.
497
+ */
498
+ type GraphStep = (graph: { "@graph": Node[] }) => void;
499
+ const noStep: GraphStep = () => {};
500
+
501
+ /**
502
+ * Load the page's videos; the step folds them into the graph (see
503
+ * attachVideos). `embeds`: the entry's embed-index doc, already read with the
504
+ * page's other docs. Errors are logged, never fatal.
505
+ */
506
+ async function pageVideosStep(ctx: PluginContext, page: PublicPageContext, origin: string, on: FeatureMap, embeds?: Promise<unknown>): Promise<GraphStep> {
507
+ const warn = (error: unknown) => ctx.log.warn("schema: could not attach videos", { error: String(error) });
508
+ try {
509
+ const videos: Node[] = [];
510
+ if (isOn(on, "videos.schema")) {
511
+ const [{ entryVideoObjects }, embedDoc] = await Promise.all([import("../videos/module.js"), embeds]);
512
+ videos.push(...(await entryVideoObjects(ctx as never, page, embeds ? (embedDoc as never) : undefined)));
513
+ }
514
+ const themed = (page as PublicPageContext & { coywolf?: { videos?: unknown } }).coywolf?.videos;
515
+ if (Array.isArray(themed)) for (const v of themed.slice(0, 50)) {
516
+ const node = themeVideoNode(v, origin);
517
+ if (node) videos.push(node);
518
+ }
519
+ return (graph) => {
520
+ try {
521
+ attachVideos(graph, videos);
522
+ } catch (error) {
523
+ warn(error);
524
+ }
525
+ };
526
+ } catch (error) {
527
+ warn(error);
528
+ noteReadFailure("schema: videos");
529
+ return noStep;
530
+ }
531
+ }
532
+
533
+ /**
534
+ * Load `about` / `mentions` from AI Enrichment; the step adds them to the
535
+ * page's Article node (or its WebPage when there's no Article). Loaded lazily
536
+ * so sites without the AI module don't pay for it.
537
+ */
538
+ async function entitiesStep(ctx: PluginContext, content: { collection: string; id: string }, record?: Promise<unknown>): Promise<GraphStep> {
539
+ const warn = (error: unknown) => ctx.log.warn("schema: could not attach AI entities", { error: String(error) });
540
+ try {
541
+ const [{ getEntryEntities }, preloaded] = await Promise.all([import("../ai/entities.js"), record]);
542
+ const { about, mentions } = await getEntryEntities(ctx, content.collection, content.id, record ? { record: preloaded as never } : undefined);
543
+ if (!about.length && !mentions.length) return noStep;
544
+ return (graph) => {
545
+ try {
546
+ mergeEntities(graph, about, mentions);
547
+ } catch (error) {
548
+ warn(error);
549
+ }
550
+ };
551
+ } catch (error) {
552
+ warn(error);
553
+ noteReadFailure("schema: AI entities");
554
+ return noStep;
555
+ }
556
+ }
557
+
558
+ function mergeEntities(graph: { "@graph": Node[] }, about: Record<string, unknown>[], mentions: Record<string, unknown>[]) {
559
+ const nodes = (graph["@graph"] ?? []) as Record<string, unknown>[];
560
+ const byId = (suffix: string) => nodes.find((n) => typeof n["@id"] === "string" && (n["@id"] as string).endsWith(suffix));
561
+ const target = byId("#article") ?? byId("#webpage");
562
+ if (!target) return;
563
+ const merge = (key: "about" | "mentions", extra: Record<string, unknown>[]) => {
564
+ if (!extra.length) return;
565
+ const existing = target[key];
566
+ const list = existing === undefined ? [] : Array.isArray(existing) ? existing : [existing];
567
+ const all = [...list, ...extra];
568
+ target[key] = all.length === 1 ? all[0] : all;
569
+ };
570
+ merge("about", about);
571
+ merge("mentions", mentions);
572
+ }
573
+
574
+ /**
575
+ * Every contribution this module makes for a page (also used by the admin
576
+ * preview). Reads that don't depend on each other run at once: config and
577
+ * site facts, then the page image, the entry's docs (override, embed index,
578
+ * AI entities, and its data for review blocks when the theme didn't pass it:
579
+ * one D1 batch), credited bylines (unless the theme passed them), logo
580
+ * dimensions, videos, reviews and entities; then the bylines' Person nodes
581
+ * (which need the override's article type). The graph is assembled in a
582
+ * fixed order afterwards, so the output doesn't depend on which read
583
+ * finishes first.
584
+ */
585
+ export async function schemaContributions(
586
+ ctx: PluginContext,
587
+ page: PublicPageContext,
588
+ options: SchemaOptions,
589
+ features?: FeatureMap,
590
+ ): Promise<PageMetadataContribution[]> {
591
+ const on = features ?? (await cachedCtxFeatures(ctx));
592
+ if (!isOn(on, SCHEMA_FEATURES.main)) return [];
593
+ const out: PageMetadataContribution[] = [];
594
+ const graphOn = isOn(on, SCHEMA_FEATURES.graph);
595
+ const ogOn = isOn(on, SCHEMA_FEATURES.openGraph);
596
+ const cleanImages = isOn(on, "images");
597
+ const [config, site] = await Promise.all([loadConfig(ctx, options.database), siteFacts(ctx, page), cleanImages ? refreshMediaHost() : undefined]);
598
+ const imageRead = graphOn || ogOn ? primaryImage(options, page, site, cleanImages) : Promise.resolve(null);
599
+
600
+ if (graphOn) {
601
+ const personPath = config.person?.byline ? authorPath(config.settings.schemaAuthorUrlPattern, config.person.byline.slug) : null;
602
+ const publisher = publisherNode({
603
+ details: config.site,
604
+ origin: site.origin,
605
+ siteName: site.siteName,
606
+ siteLogo: site.logo,
607
+ person: config.person,
608
+ authorUrl: personPath ? absolute(personPath, site.origin) : null,
609
+ });
610
+ const logoUrl = (publisher.logo as Node | undefined)?.url;
611
+ const themed = themedEntry(page);
612
+ // Credited bylines are read before knowing whether the page has an article (one read, usually needed), unless the theme passed them.
613
+ const credited = entryBylines(ctx, page, themed.bylines);
614
+ const reviewsOn = isOn(on, "reviews.schema");
615
+ const reviews = reviewsOn ? import("../reviews/schema.js") : null;
616
+ // The entry's docs in one read: its override, embed index and AI entities, and its data when review blocks must be looked for.
617
+ const entryRead = page.content
618
+ ? (reviews ?? Promise.resolve(null)).then((r) =>
619
+ readEntryDocs(
620
+ ctx,
621
+ {
622
+ collection: page.content!.collection,
623
+ id: page.content!.id,
624
+ collections: [
625
+ "schemaEntries",
626
+ ...(isOn(on, "videos.schema") ? ["videosEmbeds"] : []),
627
+ ...(isOn(on, "ai.entities") ? ["aiEntries"] : []),
628
+ ],
629
+ data: Boolean(r && !themed.data && r.needsEntryData(page)),
630
+ database: options.database,
631
+ },
632
+ r?.REVIEW_MARKER,
633
+ ),
634
+ )
635
+ : null;
636
+ const doc = (collection: string) => entryRead?.then((d) => d.docs.get(collection) ?? null);
637
+ const [image, override, logoInfo, videosStep, reviewsStep, entityStep] = await Promise.all([
638
+ imageRead,
639
+ (doc("schemaEntries") ?? null) as Promise<EntryOverride | null> | null,
640
+ typeof logoUrl === "string" ? lookupMedia(options, logoUrl, site.origin) : null,
641
+ // Videos: from the Videos module, and any the theme passes as page.coywolf.videos.
642
+ pageVideosStep(ctx, page, site.origin, on, doc("videosEmbeds")),
643
+ // Reviews: coywolf-review blocks in the entry, and any the theme passes as page.coywolf.reviews.
644
+ reviews
645
+ ? reviews.then(({ pageReviewsStep }) =>
646
+ pageReviewsStep(ctx, page, site.origin, themed.data ?? (entryRead ? () => entryRead.then((d) => d.data) : undefined)),
647
+ )
648
+ : noStep,
649
+ // AI Enrichment's Wikidata-grounded entities, when that feature is on.
650
+ page.content && isOn(on, "ai.entities") ? entitiesStep(ctx, page.content, doc("aiEntries")) : noStep,
651
+ ]);
652
+ enrichImageObjects(publisher, (url) => (url === logoUrl ? logoInfo : null));
653
+ const { articleType } = resolveTypes(page, config.types, override);
654
+ const subjectByline = mainSubjectByline(override?.mainSubject);
655
+ const [authors, mainEntity] = await Promise.all([
656
+ articleType !== "none" ? authorNodes(ctx, credited, config, site, on) : [],
657
+ override?.mainSubject === "publisher" ? publisher : subjectByline ? mainSubjectPerson(ctx, subjectByline, config, site, on) : null,
658
+ ]);
659
+ const graph = buildGraph({
660
+ page,
661
+ origin: site.origin,
662
+ siteName: site.siteName,
663
+ tagline: site.tagline,
664
+ language: page.locale || ctx.site.locale || null,
665
+ types: config.types,
666
+ override,
667
+ searchUrl: config.settings.schemaSearchUrl,
668
+ publisher,
669
+ authors,
670
+ mainEntity,
671
+ image,
672
+ breadcrumbs: isOn(on, SCHEMA_FEATURES.breadcrumbs),
673
+ homeLabel: config.settings.schemaBreadcrumbHome || "Home",
674
+ });
675
+ // Videos, then reviews, then entities: each step may look at what the one before added.
676
+ for (const step of [videosStep, reviewsStep, entityStep]) step(graph as { "@graph": Node[] });
677
+ // Same id as EmDash's own JSON-LD, so this graph replaces it (first contribution wins).
678
+ // With a media host, every media-library file in the graph (author images, logo, …) points at it.
679
+ if (cleanImages && imageCdn()) rewriteMediaUrls(graph);
680
+ out.push({ kind: "jsonld", id: "primary", graph });
681
+ } else if (isOn(on, SCHEMA_FEATURES.breadcrumbs)) {
682
+ const doc = breadcrumbDocument(page, site.origin, config.settings.schemaBreadcrumbHome || "Home");
683
+ if (doc) out.push({ kind: "jsonld", id: "coywolf-breadcrumbs", graph: doc });
684
+ }
685
+
686
+ if (isOn(on, SCHEMA_FEATURES.robots)) {
687
+ const s = config.settings;
688
+ out.push({
689
+ kind: "meta",
690
+ name: "robots",
691
+ content: robotsContent(page.seo?.robots, {
692
+ maxImagePreview: s.schemaRobotsMaxImage,
693
+ maxSnippet: parseLimit(s.schemaRobotsMaxSnippet),
694
+ maxVideoPreview: parseLimit(s.schemaRobotsMaxVideo),
695
+ nofollow: !!s.schemaRobotsNofollow,
696
+ }),
697
+ });
698
+ }
699
+
700
+ if (ogOn) {
701
+ const image = await imageRead;
702
+ const locale = config.settings.schemaOgLocale?.trim() || ogLocale(page.locale || ctx.site.locale);
703
+ if (locale) out.push({ kind: "property", property: "og:locale", content: locale });
704
+ // The default OG image at its clean URL, or the page's image on the media host: these win over EmDash's own og:image/twitter:image (first contribution wins).
705
+ const own = Boolean(page.seo?.ogImage || page.image);
706
+ out.push(...ogImageTags(image, Boolean(image && cleanImages && packOwnsOgImage(image.url, site.origin, own))));
707
+ }
708
+ return out;
709
+ }
710
+
711
+ // ── Admin routes ─────────────────────────────────────────────────
712
+
713
+ const ORG_SET = new Set(ORGANIZATION_PROPERTIES);
714
+ const PERSON_SET = new Set(PERSON_PROPERTIES);
715
+ const TYPE_SET = new Set(PAGE_TYPES.map(([t]) => t));
716
+ const ARTICLE_SET = new Set(ARTICLE_TYPES.map(([t]) => t));
717
+
718
+ const rowInput = z.object({
719
+ prop: z.string().max(100),
720
+ value: z.union([z.string().max(5000), z.record(z.string(), z.string().max(2000))]),
721
+ profileOnly: z.boolean().optional(),
722
+ });
723
+ /** max-snippet / max-video-preview: blank or missing means -1 (no limit); anything non-numeric is rejected. */
724
+ const limitInput = z
725
+ .union([z.number(), z.string().max(20), z.null()])
726
+ .optional()
727
+ .transform((v, zctx) => {
728
+ if (v === undefined || v === null || (typeof v === "string" && !v.trim())) return -1;
729
+ const n = Number(v);
730
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n < -1 || n > 1_000_000) {
731
+ zctx.addIssue({ code: "custom", message: "Robots limits must be whole numbers, -1 or more (-1 = no limit)." });
732
+ return z.NEVER;
733
+ }
734
+ return n;
735
+ });
736
+ const typeChoiceInput = z.object({ pageType: z.string().max(60).optional(), articleType: z.string().max(60).optional() });
737
+
738
+ function cleanValue(value: string, input: string | undefined): string {
739
+ const v = value.trim();
740
+ if (!v) return "";
741
+ if (input === "url" || input === "image") return /^https?:\/\//i.test(v) || (v.startsWith("/") && !v.startsWith("//")) ? v : "";
742
+ if (input === "email") return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) ? v : "";
743
+ if (input === "number") return /^\d+$/.test(v) ? v : "";
744
+ return v.replace(/[\u0000-\u001f]/g, " ");
745
+ }
746
+
747
+ /**
748
+ * Keep only catalog properties with valid values, like Coywolf SEO's
749
+ * sanitize_properties(). `profileOnly` keeps the rows' profile-only marks
750
+ * (Person rows only).
751
+ */
752
+ export function sanitizeRows(rows: z.infer<typeof rowInput>[], allowed: Set<string>, profileOnly = false): PropertyRow[] {
753
+ const out: PropertyRow[] = [];
754
+ // Never @id: the person must keep one @id on every page.
755
+ const mark = (row: z.infer<typeof rowInput>) => (profileOnly && row.profileOnly === true && row.prop !== "@id" ? { profileOnly: true } : {});
756
+ for (const row of rows) {
757
+ if (!allowed.has(row.prop)) continue;
758
+ const meta = PROPERTY_INPUTS[row.prop] ?? { input: "text" };
759
+ if (meta.fields) {
760
+ if (typeof row.value !== "object") continue;
761
+ const value: Record<string, string> = {};
762
+ for (const [sub, subMeta] of Object.entries(meta.fields)) {
763
+ const v = cleanValue(row.value[sub] ?? "", subMeta.input);
764
+ if (v) value[sub] = v;
765
+ }
766
+ if (Object.keys(value).length) out.push({ prop: row.prop, value, ...mark(row) });
767
+ } else {
768
+ if (typeof row.value !== "string") continue;
769
+ const value = cleanValue(row.value, meta.input);
770
+ if (value) out.push({ prop: row.prop, value, ...mark(row) });
771
+ }
772
+ }
773
+ return out;
774
+ }
775
+
776
+ function cleanChoice(choice: TypeChoice): TypeChoice {
777
+ const out: TypeChoice = {};
778
+ if (choice.pageType && TYPE_SET.has(choice.pageType)) out.pageType = choice.pageType;
779
+ if (choice.articleType && ARTICLE_SET.has(choice.articleType)) out.articleType = choice.articleType;
780
+ return out;
781
+ }
782
+
783
+ function entryTitle(data: Record<string, unknown>, titleField: string | null | undefined): string {
784
+ const value = (titleField && data[titleField]) || data.title || data.name || data.headline;
785
+ return typeof value === "string" ? value : "";
786
+ }
787
+
788
+ const MAIN = SCHEMA_FEATURES.main;
789
+
790
+ export function schemaModule(options: SchemaOptions) {
791
+ const routes = {
792
+ /** Everything the admin page needs to render. */
793
+ "schema/config": {
794
+ permission: "plugins:manage" as const,
795
+ handler: async (ctx: PluginContext) => {
796
+ await requireFeature(ctx, MAIN);
797
+ invalidateSchemaConfig();
798
+ const [config, collections, features] = await Promise.all([
799
+ loadConfig(ctx, options.database, true),
800
+ ctx.schema?.listCollections().catch(() => []) ?? [],
801
+ ctxFeatures(ctx),
802
+ ]);
803
+ return {
804
+ settings: config.settings,
805
+ site: config.site,
806
+ types: config.types,
807
+ collections: collections
808
+ .filter((c) => !c.hidden)
809
+ .map((c) => ({ slug: c.slug, label: c.label, routable: c.routable, dated: !!c.dateField })),
810
+ features: Object.fromEntries(Object.values(SCHEMA_FEATURES).map((id) => [id, isOn(features, id)])),
811
+ catalog: {
812
+ pageTypes: PAGE_TYPES,
813
+ articleTypes: ARTICLE_TYPES,
814
+ organization: ORGANIZATION_PROPERTIES,
815
+ organizationTypes: ORGANIZATION_TYPES,
816
+ person: PERSON_PROPERTIES,
817
+ inputs: PROPERTY_INPUTS,
818
+ },
819
+ keys: { home: HOME_KEY, custom: CUSTOM_KEY },
820
+ };
821
+ },
822
+ },
823
+
824
+ "schema/settings/save": definePluginRoute({
825
+ permission: "plugins:manage",
826
+ methods: ["POST"],
827
+ request: { body: "json" },
828
+ handler: async (ctx) => {
829
+ await requireFeature(ctx, MAIN);
830
+ const input = parseInput(
831
+ z.object({
832
+ schemaSearchUrl: z.string().max(500).optional(),
833
+ schemaAuthorUrlPattern: z.string().max(500).optional(),
834
+ schemaBreadcrumbHome: z.string().max(100).optional(),
835
+ schemaRobotsMaxImage: z.enum(["large", "standard", "none", ""]).optional(),
836
+ schemaRobotsMaxSnippet: limitInput,
837
+ schemaRobotsMaxVideo: limitInput,
838
+ schemaRobotsNofollow: z.boolean().optional(),
839
+ schemaOgLocale: z
840
+ .string()
841
+ .max(20)
842
+ .regex(/^([a-z]{2,3}_[A-Z]{2})?$/, "og:locale looks like en_US")
843
+ .optional(),
844
+ }),
845
+ ctx.input,
846
+ );
847
+ if (input.schemaSearchUrl && !input.schemaSearchUrl.includes("{search_term_string}"))
848
+ throw PluginRouteError.badRequest("The search URL needs {search_term_string} where the query goes.");
849
+ for (const [key, value] of Object.entries(input)) if (value !== undefined) await ctx.settings.set(key, value);
850
+ // The settings are read with the feature switches: drop that cache too.
851
+ invalidateFeatures();
852
+ invalidateSchemaConfig();
853
+ return { settings: (await loadConfig(ctx, options.database, true)).settings };
854
+ },
855
+ }),
856
+
857
+ "schema/site/save": definePluginRoute({
858
+ permission: "plugins:manage",
859
+ methods: ["POST"],
860
+ request: { body: "json" },
861
+ handler: async (ctx) => {
862
+ await requireFeature(ctx, MAIN);
863
+ const input = parseInput(
864
+ z.object({
865
+ publisherType: z.enum(["organization", "person"]),
866
+ organizationType: z.string().nullish(),
867
+ personBylineId: z.string().max(100).nullish(),
868
+ orgRows: z.array(rowInput).max(200),
869
+ }),
870
+ ctx.input,
871
+ );
872
+ const site: SiteDetails = {
873
+ publisherType: input.publisherType,
874
+ organizationType: ORGANIZATION_TYPES.some(([t]) => t === input.organizationType) ? input.organizationType : "Organization",
875
+ personBylineId: input.personBylineId || null,
876
+ orgRows: sanitizeRows(input.orgRows, ORG_SET),
877
+ };
878
+ await docs(ctx).put("site", site);
879
+ invalidateSchemaConfig();
880
+ return { site };
881
+ },
882
+ }),
883
+
884
+ "schema/types/save": definePluginRoute({
885
+ permission: "plugins:manage",
886
+ methods: ["POST"],
887
+ request: { body: "json" },
888
+ handler: async (ctx) => {
889
+ await requireFeature(ctx, MAIN);
890
+ const { types } = parseInput(z.object({ types: z.record(z.string().max(100), typeChoiceInput) }), ctx.input);
891
+ const clean: TypeMap = {};
892
+ for (const [key, choice] of Object.entries(types)) {
893
+ const c = cleanChoice(choice);
894
+ if (c.pageType || c.articleType) clean[key] = c;
895
+ }
896
+ await docs(ctx).put("types", clean);
897
+ invalidateSchemaConfig();
898
+ return { types: clean };
899
+ },
900
+ }),
901
+
902
+ /** Bylines with their saved Person rows. */
903
+ "schema/authors": {
904
+ permission: "plugins:manage" as const,
905
+ handler: async (ctx: PluginContext) => {
906
+ await requireFeature(ctx, MAIN);
907
+ if (!ctx.bylines) return { items: [], authorsOn: false };
908
+ const items: Array<{ id: string; slug: string; displayName: string; bio: string | null; websiteUrl: string | null; locale: string; rows: PropertyRow[] | null }> = [];
909
+ let cursor: string | undefined;
910
+ for (let page = 0; page < 10; page++) {
911
+ const result = await ctx.bylines.list({ limit: 100, cursor });
912
+ for (const b of result.items)
913
+ items.push({ id: b.id, slug: b.slug, displayName: b.displayName, bio: b.bio, websiteUrl: b.websiteUrl, locale: b.locale, rows: null });
914
+ if (!result.hasMore || !result.cursor) break;
915
+ cursor = result.cursor;
916
+ }
917
+ const saved: Map<string, { rows: PropertyRow[] }> = items.length ? await getManyBatched(authorsStore(ctx), items.map((i) => i.id)) : new Map();
918
+ for (const item of items) item.rows = saved.get(item.id)?.rows ?? null;
919
+ return { items, authorsOn: isOn(await ctxFeatures(ctx), SCHEMA_FEATURES.authors) };
920
+ },
921
+ },
922
+
923
+ "schema/authors/save": definePluginRoute({
924
+ permission: "plugins:manage",
925
+ methods: ["POST"],
926
+ request: { body: "json" },
927
+ handler: async (ctx) => {
928
+ await requireFeature(ctx, SCHEMA_FEATURES.authors);
929
+ const { bylineId, rows } = parseInput(z.object({ bylineId: z.string().min(1).max(100), rows: z.array(rowInput).max(200) }), ctx.input);
930
+ if (ctx.bylines && !(await ctx.bylines.get(bylineId))) throw PluginRouteError.notFound("Unknown byline.");
931
+ const clean = sanitizeRows(rows, PERSON_SET, true);
932
+ if (clean.length) await authorsStore(ctx).put(bylineId, { rows: clean, updatedAt: new Date().toISOString() });
933
+ else await authorsStore(ctx).delete(bylineId);
934
+ invalidateSchemaConfig();
935
+ return { rows: clean };
936
+ },
937
+ }),
938
+
939
+ /** Per-entry overrides. */
940
+ "schema/entries": {
941
+ permission: "plugins:manage" as const,
942
+ handler: async (ctx: PluginContext) => {
943
+ await requireFeature(ctx, MAIN);
944
+ const items: EntryOverride[] = [];
945
+ let cursor: string | undefined;
946
+ for (let page = 0; page < 20; page++) {
947
+ const result = await entries(ctx).query({ limit: 100, cursor });
948
+ for (const row of result.items as Array<{ data: EntryOverride }>) items.push(row.data);
949
+ if (!result.hasMore || !result.cursor) break;
950
+ cursor = result.cursor;
951
+ }
952
+ items.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
953
+ return { items };
954
+ },
955
+ },
956
+
957
+ /** An entry's credited byline ids, so the main-subject picker can list them first. */
958
+ "schema/entries/bylines": definePluginRoute({
959
+ permission: "plugins:manage",
960
+ methods: ["POST"],
961
+ request: { body: "json" },
962
+ handler: async (ctx) => {
963
+ await requireFeature(ctx, MAIN);
964
+ const { collection, entryId } = parseInput(z.object({ collection: z.string().min(1).max(100), entryId: z.string().min(1).max(100) }), ctx.input);
965
+ if (!ctx.bylines) return { ids: [] };
966
+ const [credits] = await ctx.bylines.getEntriesBylines(collection, [entryId]).catch(() => []);
967
+ return { ids: credits?.bylines.map((c) => c.byline.id) ?? [] };
968
+ },
969
+ }),
970
+
971
+ /** Recent entries of a collection, for picking one to override or preview. */
972
+ "schema/entries/find": definePluginRoute({
973
+ permission: "plugins:manage",
974
+ methods: ["POST"],
975
+ request: { body: "json" },
976
+ handler: async (ctx) => {
977
+ await requireFeature(ctx, MAIN);
978
+ const { collection, query } = parseInput(z.object({ collection: z.string().min(1).max(100), query: z.string().max(200).optional() }), ctx.input);
979
+ if (!ctx.content) throw PluginRouteError.badRequest("Content access is unavailable.");
980
+ const info = await ctx.schema?.getCollection(collection);
981
+ if (!info) throw PluginRouteError.notFound("Unknown collection.");
982
+ const result = await ctx.content.list(collection, { limit: 100, orderBy: { updatedAt: "desc" } }).catch(async () => ctx.content!.list(collection, { limit: 100 }));
983
+ const q = query?.trim().toLowerCase();
984
+ const items = result.items
985
+ .map((item) => ({ id: item.id, slug: item.slug, status: item.status, title: entryTitle(item.data, info.titleField) || item.slug || item.id }))
986
+ .filter((i) => !q || i.title.toLowerCase().includes(q) || (i.slug ?? "").toLowerCase().includes(q));
987
+ return { items: items.slice(0, 50) };
988
+ },
989
+ }),
990
+
991
+ "schema/entries/save": definePluginRoute({
992
+ permission: "plugins:manage",
993
+ methods: ["POST"],
994
+ request: { body: "json" },
995
+ handler: async (ctx) => {
996
+ await requireFeature(ctx, MAIN);
997
+ const input = parseInput(
998
+ z
999
+ .object({
1000
+ collection: z.string().min(1).max(100),
1001
+ entryId: z.string().min(1).max(100),
1002
+ title: z.string().max(500).optional(),
1003
+ mainSubject: z.string().max(120).nullish(),
1004
+ })
1005
+ .merge(typeChoiceInput),
1006
+ ctx.input,
1007
+ );
1008
+ const choice = cleanChoice(input);
1009
+ const id = `${input.collection}:${input.entryId}`;
1010
+ let mainSubject: MainSubject | undefined;
1011
+ if (input.mainSubject === "publisher") mainSubject = "publisher";
1012
+ else if (input.mainSubject) {
1013
+ const bylineId = mainSubjectByline(input.mainSubject);
1014
+ if (!bylineId) throw PluginRouteError.badRequest("Choose a byline or the site publisher as the main subject.");
1015
+ if (ctx.bylines && !(await ctx.bylines.get(bylineId).catch(() => null))) throw PluginRouteError.notFound("That byline doesn't exist.");
1016
+ mainSubject = `byline:${bylineId}`;
1017
+ }
1018
+ if (!choice.pageType && !choice.articleType && !mainSubject) {
1019
+ await entries(ctx).delete(id);
1020
+ return { deleted: true };
1021
+ }
1022
+ if (!ctx.content) throw PluginRouteError.badRequest("Content access is unavailable.");
1023
+ const entry = await ctx.content.get(input.collection, input.entryId).catch(() => null);
1024
+ if (!entry) throw PluginRouteError.notFound("That entry doesn't exist.");
1025
+ const item: EntryOverride = {
1026
+ collection: input.collection,
1027
+ entryId: input.entryId,
1028
+ title: input.title,
1029
+ ...choice,
1030
+ ...(mainSubject ? { mainSubject } : {}),
1031
+ updatedAt: new Date().toISOString(),
1032
+ };
1033
+ await entries(ctx).put(id, item);
1034
+ return { item };
1035
+ },
1036
+ }),
1037
+
1038
+ "schema/entries/delete": definePluginRoute({
1039
+ permission: "plugins:manage",
1040
+ methods: ["POST"],
1041
+ request: { body: "json" },
1042
+ handler: async (ctx) => {
1043
+ await requireFeature(ctx, MAIN);
1044
+ const { collection, entryId } = parseInput(z.object({ collection: z.string().max(100), entryId: z.string().max(100) }), ctx.input);
1045
+ return { deleted: await entries(ctx).delete(`${collection}:${entryId}`) };
1046
+ },
1047
+ }),
1048
+
1049
+ /** Images from the media library, for the logo/image pickers. */
1050
+ "schema/media": definePluginRoute({
1051
+ permission: "plugins:manage",
1052
+ methods: ["POST"],
1053
+ request: { body: "json" },
1054
+ handler: async (ctx) => {
1055
+ await requireFeature(ctx, MAIN);
1056
+ const { cursor } = parseInput(z.object({ cursor: z.string().max(500).optional() }), ctx.input);
1057
+ if (!ctx.media) return { items: [], hasMore: false };
1058
+ const result = await ctx.media.list({ limit: 30, cursor, mimeType: "image/" });
1059
+ return {
1060
+ items: result.items.map((m) => ({ id: m.id, url: m.url, filename: m.filename, alt: m.alt ?? null, width: m.width ?? null, height: m.height ?? null })),
1061
+ cursor: result.cursor,
1062
+ hasMore: result.hasMore,
1063
+ };
1064
+ },
1065
+ }),
1066
+
1067
+ /** What this module would add to the head of the home page or an entry. */
1068
+ "schema/preview": definePluginRoute({
1069
+ permission: "plugins:manage",
1070
+ methods: ["POST"],
1071
+ request: { body: "json" },
1072
+ handler: async (ctx) => {
1073
+ await requireFeature(ctx, MAIN);
1074
+ const input = parseInput(
1075
+ z.object({ target: z.enum(["home", "entry"]), collection: z.string().max(100).optional(), entryId: z.string().max(100).optional() }),
1076
+ ctx.input,
1077
+ );
1078
+ invalidateSchemaConfig();
1079
+ // Fresh settings and docs for the preview (and the cache it then renders from).
1080
+ await loadConfig(ctx, options.database, true);
1081
+ const siteUrl = originOf(ctx.site.url);
1082
+ const name = await siteName(ctx);
1083
+ let page: PublicPageContext;
1084
+ if (input.target === "home") {
1085
+ page = { url: `${siteUrl}/`, path: "/", locale: null, kind: "custom", pageType: "website", title: name, pageTitle: name, description: null, canonical: `${siteUrl}/`, image: null, siteName: name };
1086
+ } else {
1087
+ if (!input.collection || !input.entryId || !ctx.content) throw PluginRouteError.badRequest("Choose an entry.");
1088
+ const [item, info] = await Promise.all([ctx.content.get(input.collection, input.entryId), ctx.schema?.getCollection(input.collection)]);
1089
+ if (!item) throw PluginRouteError.notFound("Entry not found.");
1090
+ const publicPath = await entryUrl(ctx, input.collection, item).catch(() => null);
1091
+ const publicUrl = publicPath ? absoluteUrl(publicPath, siteUrl) : `${siteUrl}/${item.slug ?? item.id}/`;
1092
+ const url = absolute(publicUrl, siteUrl) ?? publicUrl;
1093
+ const title = item.seo?.title || entryTitle(item.data, info?.titleField) || item.slug || item.id;
1094
+ const image = item.seo?.image ? (item.seo.image.startsWith("/") || /^https?:/i.test(item.seo.image) ? item.seo.image : `/_emdash/api/media/file/${item.seo.image}`) : null;
1095
+ page = {
1096
+ url,
1097
+ path: URL.canParse(url) ? new URL(url).pathname : "/",
1098
+ locale: item.locale,
1099
+ kind: "content",
1100
+ pageType: info?.dateField ? "article" : "website",
1101
+ title,
1102
+ pageTitle: title,
1103
+ description: item.seo?.description || (typeof item.data.excerpt === "string" ? item.data.excerpt : null),
1104
+ canonical: item.seo?.canonical ? (absolute(item.seo.canonical, siteUrl) ?? url) : url,
1105
+ image,
1106
+ content: { collection: input.collection, id: item.id, slug: item.slug },
1107
+ seo: { robots: item.seo?.noIndex ? "noindex, nofollow" : null },
1108
+ articleMeta: { publishedTime: item.publishedAt, modifiedTime: item.updatedAt },
1109
+ siteName: name,
1110
+ };
1111
+ }
1112
+ const contributions = await schemaContributions(ctx, page, options);
1113
+ return { page: { url: pageUrl(page), pageType: page.pageType }, contributions };
1114
+ },
1115
+ }),
1116
+ };
1117
+
1118
+ return { routes };
1119
+ }
1120
+
1121
+ /** The hook EmDash calls for each rendered page. */
1122
+ export function schemaMetadataHook(options: SchemaOptions) {
1123
+ return async (event: { page: PublicPageContext }, ctx: PluginContext) => schemaContributions(ctx, event.page, options);
1124
+ }
1125
+
1126
+ /** Replace media-library file URLs (/_emdash/api/media/file/<file>) anywhere in a JSON-LD value with their media-host originals, in place. */
1127
+ export function rewriteMediaUrls(value: unknown): unknown {
1128
+ if (typeof value === "string") return cdnOriginalUrl(value) ?? value;
1129
+ if (Array.isArray(value)) {
1130
+ for (let i = 0; i < value.length; i++) value[i] = rewriteMediaUrls(value[i]);
1131
+ return value;
1132
+ }
1133
+ if (value && typeof value === "object") {
1134
+ const obj = value as Record<string, unknown>;
1135
+ for (const key of Object.keys(obj)) obj[key] = rewriteMediaUrls(obj[key]);
1136
+ }
1137
+ return value;
1138
+ }