@cyber-dash-tech/revela 0.15.1 → 0.15.2

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.
@@ -24,6 +24,8 @@ export interface EditCommentPayload extends EditSelectedElementPayload {
24
24
  comment: string
25
25
  elements?: EditSelectedElementPayload[]
26
26
  comments?: EditCommentDraftPayload[]
27
+ asset?: Record<string, unknown>
28
+ drop?: Record<string, unknown>
27
29
  }
28
30
 
29
31
  export function buildEditPrompt(payload: EditCommentPayload): string {
@@ -54,6 +56,8 @@ export function buildEditPrompt(payload: EditCommentPayload): string {
54
56
  deck: payload.deck,
55
57
  file: payload.file,
56
58
  comments,
59
+ asset: payload.asset,
60
+ drop: payload.drop,
57
61
  }
58
62
 
59
63
  return `The user left a visual edit comment on a Revela slide deck.
@@ -75,6 +79,14 @@ Instructions:
75
79
  - Pure artifact polish such as layout, spacing, typography, alignment, color, image crop, animation, export fidelity, runtime JavaScript fixes, or deck HTML contract fixes may remain an artifact-level edit.
76
80
  - If the request mixes content meaning and visual polish, treat it as narrative-impacting unless the user clarifies otherwise.
77
81
  - Preserve the existing deck structure, active design language, typography, spacing system, animations, and slide count unless the comment explicitly asks otherwise.
82
+ - If an asset/drop payload is present, this is an asset placement request. Use only the saved local asset path from the asset payload in deck HTML. Prefer asset.deckPath when present because it is relative to the target HTML file; otherwise use asset.path.
83
+ - Do not write remote imageUrl, thumbnailUrl, source page URLs, or ${"`/__revela_asset`"} proxy URLs into deck HTML.
84
+ - Logo assets should remain small, clear, and brand-like; do not use logos as decorative backgrounds.
85
+ - Photography can be cropped or masked when appropriate, but must not cover text, charts, tables, evidence, or important claims.
86
+ - Screenshots, diagrams, charts, tables, and evidence images must remain readable and should not be converted into decorative hero imagery.
87
+ - For asset targetMode ${"`replace`"}, prefer replacing the targeted image or visual element. For ${"`insert-into`"}, place the asset inside the targeted card, media box, or semantic container while preserving that element's layout role. For ${"`add`"}, place the asset near the drop coordinates within the existing layout or semantic box. Do not invent a new visual system when the existing deck grammar can express the placement.
88
+ - If an asset payload is present without drop coordinates, use the user's comment and selected element context to choose placement; if placement remains ambiguous, ask one concise clarification question instead of guessing.
89
+ - Preserve source/license/attribution facts if you surface them in visible notes; do not invent missing licensing or attribution.
78
90
  - Do not rewrite unrelated slides or broad sections of the deck.
79
91
  - Locate each target primarily with slideIndex, slideTitle, selected text, nearbyText, and outerHTMLExcerpt. Use selector/domPath as hints; they may be approximate.
80
92
  - For targeted artifact-level edits, patch ${"`decks/*.html`"} directly. Do not call ${"`revela-decks`"} action ${"`review`"} as a precondition, and do not let ${"`writeReadiness`"}, ${"`planReview`"}, or ${"`slide_plan_unconfirmed`"} block the patch.
@@ -5,21 +5,25 @@ export function buildInspectionPrompt(input: {
5
5
  file: string
6
6
  projection: InspectionPromptProjection
7
7
  language?: string
8
+ comment?: string
8
9
  }): string {
9
10
  const language = normalizeInspectLanguage(input.language)
11
+ const comment = typeof input.comment === "string" && input.comment.trim() ? input.comment.trim() : ""
10
12
  return `A user selected slide content in Revela Evidence Inspector. The selection may contain one referenced element, a whole slide, or multiple referenced elements selected with Cmd/Ctrl-click.
11
13
 
12
14
  Target file: ${input.file}
13
15
  Inspection request id: ${input.requestId}
14
16
  Display language: ${language}
17
+ User inspect comment: ${comment || "(none; explain purpose and source only)"}
15
18
 
16
- Use the structured projection below to produce the final inspector cards. This is LLM judgment with grounded boundaries: explain the selected object's narrative reading context, bounded exploratory reading context, purpose, and source credibility only. Do not edit files. Do not mutate DECKS.json. Do not invent claim ids, evidence binding ids, sources, quotes, URLs, page references, caveats, objections, risks, artifact coverage, or evidence not present in the projection.
19
+ Use the structured projection below to produce the final inspector cards. This is LLM judgment with grounded boundaries. The user's inspect comment is the complete request about the selected reference; do not parse it into a separate question field. The user primarily wants to understand the selected component: what purpose it serves and what source support exists. Use narrative reading and exploratory reading only as internal grounding unless needed to answer the user's comment. Do not edit files. Do not mutate DECKS.json. Do not invent claim ids, evidence binding ids, sources, quotes, URLs, page references, caveats, objections, risks, artifact coverage, or evidence not present in the projection.
17
20
 
18
21
  Language boundary: the selected display language affects only human-readable card copy. Preserve all claim ids, canonical claim ids, evidence binding ids, source paths, findings files, URLs, numbers, quoted/source facts, caveats, artifact ids, and coverage statuses exactly as grounded in the projection. If the display language is Auto, use projection.deck.language when available; otherwise follow the user's/browser context or default to English.
19
22
 
20
23
  Return the result only by calling the \`revela-inspection-result\` tool with this request id. Do not answer in chat.
21
24
 
22
25
  Required card model:
26
+ - User inspect comment: if present, answer it through the Purpose and Source cards first. If it asks about trust, provenance, evidence, factuality, or where a number came from, prioritize Source. If it asks why something is on the slide or what it is doing, prioritize Purpose.
23
27
  - Narrative Reading: when the projection includes a matched claim, preserve its claim id, canonical claim id, evidence binding ids, supported scope, unsupported scope, caveats, related objections, related risks, and artifact coverage. Artifact coverage must come only from projection.cards.artifacts; do not invent where a claim appears or whether an artifact is stale/current/partial/missing. If canonical narrative linkage is missing, say so and fall back to the matched slide claim; do not invent canonical ids.
24
28
  - Candidate boundary: when projection.match.claim is absent but projection.match.candidateClaims is present, explain the selected child element only within those candidate claim boundaries. You may describe that the child element functions as a detail, prerequisite, source note, risk cue, or evidence cue inside the slide, but you must not select one candidate claim id by semantic guess. If projection.match.confidence is none or candidateClaims is empty, explain the mapping gap instead of inventing a plausible claim.
25
29
  - Exploratory Reading: provide bounded, non-official reading cues for objection prep, audience reframing, appendix leads, and meeting prep only from the projection. Mark official as false. Keep missing evidence, caveats, unsupported scope, and stale artifacts visible. Do not make exploratory text sound like approved artifact content, and do not turn this into chat or a fix plan.
@@ -29,6 +33,7 @@ Required card model:
29
33
  Boundaries:
30
34
  - Do not hunt for problems. If it works, say it works.
31
35
  - Do not recommend edits or fixes; this inspector view only explains narrative context, bounded exploratory reading context, purpose, and source credibility.
36
+ - Keep Purpose and Source concise and directly useful. Avoid long narrative-reading exposition unless the selected content cannot be explained without it.
32
37
  - Do not turn every caveat into a problem.
33
38
  - If confidence is low, use unclear or unknown instead of pretending certainty.
34
39
 
@@ -3,11 +3,15 @@ import { extname } from "path"
3
3
  const MIME_TO_EXT: Record<string, string> = {
4
4
  "image/png": ".png",
5
5
  "image/jpeg": ".jpg",
6
+ "image/svg+xml": ".svg",
6
7
  "image/webp": ".webp",
7
8
  "image/gif": ".gif",
9
+ "image/x-icon": ".ico",
10
+ "image/vnd.microsoft.icon": ".ico",
8
11
  }
9
12
 
10
- const ALLOWED_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".webp", ".gif"])
13
+ const ALLOWED_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".svg", ".webp", ".gif", ".ico"])
14
+ const DEFAULT_DOWNLOAD_TIMEOUT_MS = 10_000
11
15
 
12
16
  function normalizeExtension(ext: string): string {
13
17
  const value = ext.toLowerCase()
@@ -30,6 +34,7 @@ export function inferImageExtension(contentType: string | null, sourceName = "")
30
34
 
31
35
  export async function downloadImageFromUrl(
32
36
  url: string,
37
+ options: { timeoutMs?: number } = {},
33
38
  ): Promise<{ buffer: Buffer; contentType: string | null; extension: string }> {
34
39
  let parsed: URL
35
40
  try {
@@ -42,16 +47,36 @@ export async function downloadImageFromUrl(
42
47
  throw new Error("INVALID_URL")
43
48
  }
44
49
 
45
- const response = await fetch(parsed, {
46
- headers: {
47
- "User-Agent":
48
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
49
- "(KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
50
- },
51
- })
50
+ const controller = new AbortController()
51
+ let timedOut = false
52
+ const timer = setTimeout(() => {
53
+ timedOut = true
54
+ controller.abort()
55
+ }, Math.max(1, options.timeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS))
52
56
 
53
- if (!response.ok) {
54
- throw new Error(`DOWNLOAD_FAILED:${response.status}`)
57
+ let response: Response
58
+ let buffer: Buffer
59
+ try {
60
+ response = await fetch(parsed, {
61
+ headers: {
62
+ Accept: "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
63
+ "User-Agent":
64
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
65
+ "(KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
66
+ },
67
+ signal: controller.signal,
68
+ })
69
+
70
+ if (!response.ok) {
71
+ throw new Error(`DOWNLOAD_FAILED:${response.status}`)
72
+ }
73
+
74
+ buffer = Buffer.from(await response.arrayBuffer())
75
+ } catch (error) {
76
+ if (timedOut) throw new Error("DOWNLOAD_TIMEOUT")
77
+ throw error
78
+ } finally {
79
+ clearTimeout(timer)
55
80
  }
56
81
 
57
82
  const contentType = response.headers.get("content-type")
@@ -61,7 +86,7 @@ export async function downloadImageFromUrl(
61
86
  }
62
87
 
63
88
  return {
64
- buffer: Buffer.from(await response.arrayBuffer()),
89
+ buffer,
65
90
  contentType,
66
91
  extension,
67
92
  }
package/lib/media/save.ts CHANGED
@@ -69,6 +69,12 @@ function buildFailureRecord(
69
69
  alt: input.alt,
70
70
  notes: input.notes,
71
71
  failureReason,
72
+ provider: input.provider,
73
+ sourcePageUrl: input.sourcePageUrl,
74
+ license: input.license,
75
+ attribution: input.attribution,
76
+ width: input.width,
77
+ height: input.height,
72
78
  savedAt: nowIso(),
73
79
  }
74
80
  }
@@ -112,6 +118,12 @@ function saveFailureResult(
112
118
  sourcePath: sourcePath ?? existing.sourcePath,
113
119
  alt: input.alt ?? existing.alt,
114
120
  notes: input.notes ?? existing.notes,
121
+ provider: input.provider ?? existing.provider,
122
+ sourcePageUrl: input.sourcePageUrl ?? existing.sourcePageUrl,
123
+ license: input.license ?? existing.license,
124
+ attribution: input.attribution ?? existing.attribution,
125
+ width: input.width ?? existing.width,
126
+ height: input.height ?? existing.height,
115
127
  failureReason,
116
128
  }
117
129
  : buildFailureRecord(input, topic, status, failureReason, sourcePath)
@@ -196,6 +208,12 @@ export async function saveMediaAsset(input: MediaSaveInput, workspaceDir: string
196
208
  intendedSection: input.intendedSection,
197
209
  alt: input.alt,
198
210
  notes: input.notes,
211
+ provider: input.provider,
212
+ sourcePageUrl: input.sourcePageUrl,
213
+ license: input.license,
214
+ attribution: input.attribution,
215
+ width: input.width,
216
+ height: input.height,
199
217
  savedAt: nowIso(),
200
218
  }
201
219
  const { manifest: nextManifest } = upsertAsset(manifest, record)
@@ -248,6 +266,12 @@ export async function saveMediaAsset(input: MediaSaveInput, workspaceDir: string
248
266
  intendedSection: input.intendedSection,
249
267
  alt: input.alt,
250
268
  notes: input.notes,
269
+ provider: input.provider,
270
+ sourcePageUrl: input.sourcePageUrl,
271
+ license: input.license,
272
+ attribution: input.attribution,
273
+ width: input.width,
274
+ height: input.height,
251
275
  savedAt: nowIso(),
252
276
  }
253
277
  const { manifest: nextManifest } = upsertAsset(manifest, record)
@@ -0,0 +1,385 @@
1
+ import type { MediaPurpose } from "./types"
2
+
3
+ export interface ImageCandidate {
4
+ candidateId: string
5
+ provider: string
6
+ title: string
7
+ thumbnailUrl: string
8
+ imageUrl: string
9
+ sourcePageUrl?: string
10
+ width?: number
11
+ height?: number
12
+ alt?: string
13
+ license?: string
14
+ attribution?: string
15
+ purpose?: MediaPurpose
16
+ }
17
+
18
+ export interface ImageSearchInput {
19
+ query: string
20
+ purpose?: MediaPurpose
21
+ limit?: number
22
+ page?: number
23
+ signal?: AbortSignal
24
+ providerTimeoutMs?: number
25
+ }
26
+
27
+ export interface ImageSearchProvider {
28
+ name: string
29
+ search(input: ImageSearchInput): Promise<ImageCandidate[]>
30
+ }
31
+
32
+ const PROVIDER_TIMEOUT_MS: Record<string, number> = {
33
+ "website-metadata": 3000,
34
+ unsplash: 6000,
35
+ "wikimedia-commons": 7000,
36
+ }
37
+
38
+ function slugify(value: string): string {
39
+ return value
40
+ .toLowerCase()
41
+ .replace(/[^a-z0-9]+/g, "-")
42
+ .replace(/^-+|-+$/g, "")
43
+ }
44
+
45
+ function normalizeLimit(limit: number | undefined): number {
46
+ if (!Number.isFinite(limit ?? Number.NaN)) return 12
47
+ return Math.max(1, Math.min(48, Math.floor(limit!)))
48
+ }
49
+
50
+ function normalizePage(page: number | undefined): number {
51
+ if (!Number.isFinite(page ?? Number.NaN)) return 1
52
+ return Math.max(1, Math.floor(page!))
53
+ }
54
+
55
+ function domainFromQuery(query: string): string | null {
56
+ const value = query.trim().toLowerCase()
57
+ if (!value) return null
58
+ const withoutProtocol = value.replace(/^https?:\/\//, "").replace(/^www\./, "")
59
+ const first = withoutProtocol.split(/[\s/]+/)[0]
60
+ if (/^[a-z0-9.-]+\.[a-z]{2,}$/.test(first)) return first
61
+ const compact = value.replace(/\b(logo|company|brand)\b/g, "").replace(/[^a-z0-9]+/g, "")
62
+ if (!compact) return null
63
+ return `${compact}.com`
64
+ }
65
+
66
+ function textFromMetadata(value: unknown): string | undefined {
67
+ if (!value || typeof value !== "object") return undefined
68
+ const raw = (value as { value?: unknown }).value
69
+ return typeof raw === "string" && raw.trim() ? raw.replace(/<[^>]+>/g, "").trim() : undefined
70
+ }
71
+
72
+ function compactBrandName(query: string): string | null {
73
+ const value = query
74
+ .toLowerCase()
75
+ .replace(/^https?:\/\//, "")
76
+ .replace(/^www\./, "")
77
+ .split(/[\s/]+/)[0]
78
+ .replace(/\.[a-z]{2,}$/, "")
79
+ .replace(/\b(logo|company|brand)\b/g, "")
80
+ .replace(/[^a-z0-9]+/g, "")
81
+ return value || null
82
+ }
83
+
84
+ function absoluteUrl(value: string, base: string): string | undefined {
85
+ try {
86
+ return new URL(value, base).toString()
87
+ } catch {
88
+ return undefined
89
+ }
90
+ }
91
+
92
+ function uniqueValues(values: Array<string | undefined>): string[] {
93
+ const seen = new Set<string>()
94
+ return values.flatMap((value) => {
95
+ if (!value || seen.has(value)) return []
96
+ seen.add(value)
97
+ return [value]
98
+ })
99
+ }
100
+
101
+ function htmlAttr(tag: string, attr: string): string | undefined {
102
+ const pattern = new RegExp(`${attr}=["']([^"']+)["']`, "i")
103
+ return tag.match(pattern)?.[1]
104
+ }
105
+
106
+ function htmlMetaContent(html: string, property: string): string | undefined {
107
+ const tags = html.match(/<meta\b[^>]*>/gi) ?? []
108
+ const needle = property.toLowerCase()
109
+ for (const tag of tags) {
110
+ const key = htmlAttr(tag, "property") || htmlAttr(tag, "name")
111
+ if (key?.toLowerCase() === needle) return htmlAttr(tag, "content")
112
+ }
113
+ return undefined
114
+ }
115
+
116
+ function htmlIconLinks(html: string): string[] {
117
+ const tags = html.match(/<link\b[^>]*>/gi) ?? []
118
+ return tags.flatMap((tag) => {
119
+ const rel = htmlAttr(tag, "rel")?.toLowerCase() ?? ""
120
+ if (!rel.includes("icon") && !rel.includes("apple-touch-icon")) return []
121
+ return htmlAttr(tag, "href") ?? []
122
+ })
123
+ }
124
+
125
+ function queryForPurpose(query: string, purpose: MediaPurpose | undefined): string {
126
+ const value = query.trim()
127
+ if (!value) return value
128
+ if (purpose === "hero") return `${value} hero background professional photography`
129
+ if (purpose === "portrait") return `${value} portrait headshot`
130
+ if (purpose === "screenshot") return `${value} screenshot interface product`
131
+ if (purpose === "illustration") return `${value} professional photo concept visual`
132
+ return value
133
+ }
134
+
135
+ function wikimediaQueryForPurpose(query: string, purpose: MediaPurpose | undefined): string {
136
+ const value = query.trim()
137
+ if (!value) return value
138
+ if (purpose === "portrait") return `${value} portrait`
139
+ if (purpose === "screenshot") return `${value} screenshot`
140
+ return value
141
+ }
142
+
143
+ export const clearbitLogoProvider: ImageSearchProvider = {
144
+ name: "clearbit-logo",
145
+ async search(input) {
146
+ if (normalizePage(input.page) > 1) return []
147
+ if (input.purpose && input.purpose !== "logo") return []
148
+ const domain = domainFromQuery(input.query)
149
+ if (!domain) return []
150
+ const title = `${domain} logo`
151
+ return [{
152
+ candidateId: `clearbit-${slugify(domain)}`,
153
+ provider: this.name,
154
+ title,
155
+ thumbnailUrl: `https://logo.clearbit.com/${encodeURIComponent(domain)}`,
156
+ imageUrl: `https://logo.clearbit.com/${encodeURIComponent(domain)}`,
157
+ sourcePageUrl: `https://${domain}`,
158
+ alt: title,
159
+ purpose: "logo",
160
+ }]
161
+ },
162
+ }
163
+
164
+ export const simpleIconsProvider: ImageSearchProvider = {
165
+ name: "simple-icons",
166
+ async search(input) {
167
+ if (normalizePage(input.page) > 1) return []
168
+ if (input.purpose && input.purpose !== "logo") return []
169
+ const brand = compactBrandName(input.query)
170
+ if (!brand) return []
171
+ const title = `${brand} logo`
172
+ const cdnUrl = `https://cdn.simpleicons.org/${encodeURIComponent(brand)}`
173
+ const imageUrl = `https://raw.githubusercontent.com/simple-icons/simple-icons/develop/icons/${encodeURIComponent(brand)}.svg`
174
+ return [{
175
+ candidateId: `simple-icons-${slugify(brand)}`,
176
+ provider: this.name,
177
+ title,
178
+ thumbnailUrl: cdnUrl,
179
+ imageUrl,
180
+ sourcePageUrl: `https://simpleicons.org/?q=${encodeURIComponent(brand)}`,
181
+ alt: title,
182
+ license: "See Simple Icons source license",
183
+ attribution: "Simple Icons",
184
+ purpose: "logo",
185
+ }]
186
+ },
187
+ }
188
+
189
+ export const websiteMetadataProvider: ImageSearchProvider = {
190
+ name: "website-metadata",
191
+ async search(input) {
192
+ if (normalizePage(input.page) > 1) return []
193
+ const domain = domainFromQuery(input.query)
194
+ if (!domain) return []
195
+ const homepage = `https://${domain}`
196
+ const response = await fetch(homepage, {
197
+ headers: { "user-agent": "Revela/0.15 asset search" },
198
+ signal: input.signal,
199
+ })
200
+ if (!response.ok) throw new Error(`Website metadata fetch failed: ${response.status}`)
201
+ const html = await response.text()
202
+ const title = htmlMetaContent(html, "og:site_name") || html.match(/<title[^>]*>([^<]+)<\/title>/i)?.[1]?.trim() || domain
203
+ const socialImages = [htmlMetaContent(html, "og:image"), htmlMetaContent(html, "twitter:image")]
204
+ const icons = [...htmlIconLinks(html), "/favicon.ico"]
205
+ const sourceUrls = input.purpose === "logo"
206
+ ? icons
207
+ : input.purpose === "screenshot"
208
+ ? socialImages
209
+ : socialImages
210
+ const urls = uniqueValues(sourceUrls.map((value) => value ? absoluteUrl(value, homepage) : undefined))
211
+ return urls.slice(0, normalizeLimit(input.limit)).map((imageUrl, index) => ({
212
+ candidateId: `website-${slugify(domain)}-${index + 1}`,
213
+ provider: this.name,
214
+ title: index === 0 ? `${title} image` : `${title} icon ${index}`,
215
+ thumbnailUrl: imageUrl,
216
+ imageUrl,
217
+ sourcePageUrl: homepage,
218
+ alt: `${title} image`,
219
+ purpose: input.purpose,
220
+ }))
221
+ },
222
+ }
223
+
224
+ export const unsplashProvider: ImageSearchProvider = {
225
+ name: "unsplash",
226
+ async search(input) {
227
+ const accessKey = process.env.UNSPLASH_ACCESS_KEY?.trim()
228
+ if (!accessKey) return []
229
+ if (input.purpose === "logo" || input.purpose === "screenshot") return []
230
+ const limit = normalizeLimit(input.limit)
231
+ const page = normalizePage(input.page)
232
+ const url = new URL("https://api.unsplash.com/search/photos")
233
+ url.searchParams.set("query", input.query)
234
+ url.searchParams.set("per_page", String(Math.min(30, limit)))
235
+ url.searchParams.set("page", String(page))
236
+ url.searchParams.set("orientation", input.purpose === "portrait" ? "portrait" : "landscape")
237
+ const response = await fetch(url, {
238
+ headers: {
239
+ authorization: `Client-ID ${accessKey}`,
240
+ "accept-version": "v1",
241
+ "user-agent": "Revela/0.15 asset search",
242
+ },
243
+ signal: input.signal,
244
+ })
245
+ if (!response.ok) throw new Error(`Unsplash search failed: ${response.status}`)
246
+ const body = await response.json() as any
247
+ const results = Array.isArray(body?.results) ? body.results : []
248
+ return results.flatMap((item: any) => {
249
+ const imageUrl = typeof item?.urls?.regular === "string" ? item.urls.regular : ""
250
+ if (!imageUrl) return []
251
+ const title = item.description || item.alt_description || "Unsplash photo"
252
+ const author = item.user?.name ? String(item.user.name) : undefined
253
+ return [{
254
+ candidateId: `unsplash-${slugify(String(item.id || title))}`,
255
+ provider: this.name,
256
+ title,
257
+ thumbnailUrl: typeof item.urls?.small === "string" ? item.urls.small : imageUrl,
258
+ imageUrl,
259
+ sourcePageUrl: typeof item.links?.html === "string" ? item.links.html : undefined,
260
+ width: typeof item.width === "number" ? item.width : undefined,
261
+ height: typeof item.height === "number" ? item.height : undefined,
262
+ alt: item.alt_description || title,
263
+ license: "Unsplash License",
264
+ attribution: author ? `Photo by ${author} on Unsplash` : "Unsplash",
265
+ purpose: input.purpose,
266
+ }]
267
+ }).slice(0, limit)
268
+ },
269
+ }
270
+
271
+ export const wikimediaProvider: ImageSearchProvider = {
272
+ name: "wikimedia-commons",
273
+ async search(input) {
274
+ const limit = normalizeLimit(input.limit)
275
+ const page = normalizePage(input.page)
276
+ const url = new URL("https://commons.wikimedia.org/w/api.php")
277
+ url.searchParams.set("action", "query")
278
+ url.searchParams.set("format", "json")
279
+ url.searchParams.set("origin", "*")
280
+ url.searchParams.set("generator", "search")
281
+ url.searchParams.set("gsrnamespace", "6")
282
+ url.searchParams.set("gsrlimit", String(limit))
283
+ url.searchParams.set("gsroffset", String((page - 1) * limit))
284
+ url.searchParams.set("gsrsearch", input.query)
285
+ url.searchParams.set("prop", "imageinfo|info")
286
+ url.searchParams.set("iiprop", "url|thumburl|mime|size|extmetadata")
287
+ url.searchParams.set("iiurlwidth", "320")
288
+ url.searchParams.set("inprop", "url")
289
+
290
+ const response = await fetch(url, {
291
+ headers: { "user-agent": "Revela/0.15 asset search" },
292
+ signal: input.signal,
293
+ })
294
+ if (!response.ok) throw new Error(`Wikimedia search failed: ${response.status}`)
295
+ const body = await response.json() as any
296
+ const pages = Object.values(body?.query?.pages ?? {}) as any[]
297
+ return pages.flatMap((page) => {
298
+ const info = page?.imageinfo?.[0]
299
+ const mime = typeof info?.mime === "string" ? info.mime : ""
300
+ if (!mime.startsWith("image/")) return []
301
+ const imageUrl = typeof info?.url === "string" ? info.url : ""
302
+ if (!imageUrl) return []
303
+ const thumbnailUrl = typeof info?.thumburl === "string" ? info.thumburl : imageUrl
304
+ const metadata = info?.extmetadata ?? {}
305
+ const title = String(page.title || "Wikimedia image").replace(/^File:/, "")
306
+ return [{
307
+ candidateId: `wikimedia-${slugify(title).slice(0, 80)}`,
308
+ provider: this.name,
309
+ title,
310
+ thumbnailUrl,
311
+ imageUrl,
312
+ sourcePageUrl: typeof page.fullurl === "string" ? page.fullurl : undefined,
313
+ width: typeof info.width === "number" ? info.width : undefined,
314
+ height: typeof info.height === "number" ? info.height : undefined,
315
+ alt: title,
316
+ license: textFromMetadata(metadata.LicenseShortName) || textFromMetadata(metadata.UsageTerms),
317
+ attribution: textFromMetadata(metadata.Artist) || textFromMetadata(metadata.Credit),
318
+ purpose: input.purpose,
319
+ }]
320
+ }).slice(0, limit)
321
+ },
322
+ }
323
+
324
+ export async function searchRemoteImages(input: ImageSearchInput): Promise<ImageCandidate[]> {
325
+ const query = input.query.trim()
326
+ if (!query) return []
327
+ const limit = normalizeLimit(input.limit)
328
+ const page = normalizePage(input.page)
329
+ const mediaQuery = queryForPurpose(query, input.purpose)
330
+ const wikimediaQuery = wikimediaQueryForPurpose(query, input.purpose)
331
+ const searches: Array<Promise<ImageCandidate[]>> = input.purpose === "logo"
332
+ ? [
333
+ runProvider(clearbitLogoProvider, { ...input, query, limit, page }),
334
+ runProvider(simpleIconsProvider, { ...input, query, limit, page }),
335
+ runProvider(websiteMetadataProvider, { ...input, query, limit, page }),
336
+ ]
337
+ : input.purpose === "screenshot"
338
+ ? [
339
+ runProvider(websiteMetadataProvider, { ...input, query, limit, page }),
340
+ runProvider(wikimediaProvider, { ...input, query: wikimediaQuery, limit, page }),
341
+ ]
342
+ : [
343
+ runProvider(websiteMetadataProvider, { ...input, query, limit, page }),
344
+ runProvider(unsplashProvider, { ...input, query: mediaQuery, limit, page }),
345
+ runProvider(wikimediaProvider, { ...input, query: wikimediaQuery, limit, page }),
346
+ ]
347
+ const batches = await Promise.allSettled(searches)
348
+ const candidates = batches.flatMap((result) => result.status === "fulfilled" ? result.value : [])
349
+ const seen = new Set<string>()
350
+ return candidates.filter((candidate) => {
351
+ const key = candidate.imageUrl || candidate.thumbnailUrl || candidate.candidateId
352
+ if (seen.has(key)) return false
353
+ seen.add(key)
354
+ return true
355
+ }).slice(0, limit)
356
+ }
357
+
358
+ async function runProvider(provider: ImageSearchProvider, input: ImageSearchInput): Promise<ImageCandidate[]> {
359
+ const timeoutMs = input.providerTimeoutMs ?? PROVIDER_TIMEOUT_MS[provider.name]
360
+ if (!timeoutMs) return provider.search(input).catch(() => [])
361
+
362
+ const controller = new AbortController()
363
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
364
+ const signal = input.signal ? anySignal([input.signal, controller.signal]) : controller.signal
365
+ try {
366
+ return await provider.search({ ...input, signal })
367
+ } catch {
368
+ return []
369
+ } finally {
370
+ clearTimeout(timer)
371
+ }
372
+ }
373
+
374
+ function anySignal(signals: AbortSignal[]): AbortSignal {
375
+ const controller = new AbortController()
376
+ const abort = () => controller.abort()
377
+ for (const signal of signals) {
378
+ if (signal.aborted) {
379
+ controller.abort()
380
+ break
381
+ }
382
+ signal.addEventListener("abort", abort, { once: true })
383
+ }
384
+ return controller.signal
385
+ }
@@ -15,6 +15,12 @@ export interface MediaSaveInput {
15
15
  alt?: string
16
16
  notes?: string
17
17
  failureReason?: string
18
+ provider?: string
19
+ sourcePageUrl?: string
20
+ license?: string
21
+ attribution?: string
22
+ width?: number
23
+ height?: number
18
24
  }
19
25
 
20
26
  export interface MediaAssetRecord {
@@ -30,6 +36,12 @@ export interface MediaAssetRecord {
30
36
  alt?: string
31
37
  notes?: string
32
38
  failureReason?: string
39
+ provider?: string
40
+ sourcePageUrl?: string
41
+ license?: string
42
+ attribution?: string
43
+ width?: number
44
+ height?: number
33
45
  savedAt: string
34
46
  }
35
47
 
package/lib/qa/checks.ts CHANGED
@@ -23,13 +23,14 @@ import { CANVAS_W, CANVAS_H } from "./measure"
23
23
  export type IssueSeverity = "error" | "warning" | "info"
24
24
 
25
25
  export interface LayoutIssue {
26
- type: "canvas" | "scrollbar" | "overflow" | "text_overflow" | "density" | "balance" | "symmetry" | "rhythm" | "compliance"
26
+ type: "canvas" | "scrollbar" | "overflow" | "text_overflow" | "density" | "balance" | "symmetry" | "rhythm" | "compliance" | "asset"
27
27
  /** Sub-category within the dimension */
28
28
  sub?: "size_mismatch" | "page_scroll" | "text_clipped" | "thin_content"
29
29
  | "centroid_offset" | "bottom_gap" | "sparse"
30
30
  | "height_mismatch" | "density_mismatch"
31
31
  | "gap_variance"
32
32
  | "unknown_class" | "novel_css_rule"
33
+ | "remote_url" | "refine_proxy" | "missing_file"
33
34
  severity: IssueSeverity
34
35
  /** Human-readable description for the LLM to act on */
35
36
  detail: string