@cyber-dash-tech/revela 0.15.1 → 0.15.3
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.
- package/README.md +58 -104
- package/README.zh-CN.md +59 -104
- package/designs/starter/DESIGN.md +33 -14
- package/designs/starter/preview.html +23 -16
- package/designs/summit/DESIGN.md +35 -42
- package/designs/summit/preview.html +49 -49
- package/lib/commands/brief.ts +1 -1
- package/lib/commands/designs-new.ts +6 -6
- package/lib/commands/designs.ts +9 -9
- package/lib/commands/domains.ts +9 -9
- package/lib/commands/edit.ts +1 -1
- package/lib/commands/help.ts +34 -37
- package/lib/commands/init.ts +1 -1
- package/lib/commands/inspect.ts +2 -20
- package/lib/commands/narrative.ts +3 -3
- package/lib/commands/pdf.ts +3 -3
- package/lib/commands/pptx.ts +2 -2
- package/lib/commands/review.ts +13 -6
- package/lib/decks-state.ts +6 -6
- package/lib/edit/deck-state.ts +1 -1
- package/lib/edit/prompt.ts +12 -0
- package/lib/edit/resolve-deck.ts +1 -1
- package/lib/inspect/prompt.ts +6 -1
- package/lib/inspect/server.ts +2 -2
- package/lib/media/download.ts +36 -11
- package/lib/media/save.ts +24 -0
- package/lib/media/search.ts +385 -0
- package/lib/media/types.ts +12 -0
- package/lib/prompt-builder.ts +20 -14
- package/lib/qa/checks.ts +2 -1
- package/lib/qa/index.ts +73 -2
- package/lib/refine/server.ts +758 -68
- package/package.json +1 -1
- package/plugin.ts +133 -283
- package/skill/NARRATIVE_SKILL.md +15 -17
- package/skill/SKILL.md +220 -477
- package/tools/edit.ts +1 -1
- package/tools/inspection-result.ts +1 -1
- package/tools/media-save.ts +6 -0
- package/lib/commands/disable.ts +0 -14
- package/lib/commands/enable.ts +0 -48
|
@@ -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
|
+
}
|
package/lib/media/types.ts
CHANGED
|
@@ -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/prompt-builder.ts
CHANGED
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Deck-render mode:
|
|
9
9
|
* Layer 1: SKILL.md — legacy deck render protocol (HTML rules, quality)
|
|
10
|
-
* Layer 2:
|
|
11
|
-
*
|
|
10
|
+
* Layer 2: DESIGN.md — visual style (colors, fonts, animations, layout)
|
|
11
|
+
*
|
|
12
|
+
* Domain guidance is intentionally narrative-only. Deck-render mode must render
|
|
13
|
+
* the approved canonical narrative instead of re-interpreting domain semantics.
|
|
12
14
|
*
|
|
13
15
|
* When the active DESIGN.md has @section markers, only the global section,
|
|
14
16
|
* layouts section, and a generated component index are injected into the
|
|
@@ -87,19 +89,23 @@ export function buildPrompt(optionsOrDesignName?: BuildPromptOptions | string, l
|
|
|
87
89
|
? "<!-- - preview.html — canonical visual reference (read this before generating slides) -->"
|
|
88
90
|
: "<!-- - (no preview.html for this design) -->"
|
|
89
91
|
|
|
90
|
-
// Layer 2 — DOMAIN.md skill text (
|
|
92
|
+
// Layer 2 — DOMAIN.md skill text (narrative mode only). Deck-render mode
|
|
93
|
+
// renders the approved canonical narrative and must not re-interpret domain
|
|
94
|
+
// semantics from the full domain prompt.
|
|
91
95
|
let domainSkill = ""
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
domain,
|
|
98
|
-
|
|
99
|
-
|
|
96
|
+
if (mode === "narrative") {
|
|
97
|
+
try {
|
|
98
|
+
domainSkill = getDomainSkillMd(domain)
|
|
99
|
+
} catch (e) {
|
|
100
|
+
// Domain not installed or empty — proceed without domain layer
|
|
101
|
+
promptLog.warn("domain skill not found — building without domain layer", {
|
|
102
|
+
domain,
|
|
103
|
+
error: e instanceof Error ? e.message : String(e),
|
|
104
|
+
})
|
|
105
|
+
}
|
|
100
106
|
}
|
|
101
107
|
|
|
102
|
-
//
|
|
108
|
+
// DESIGN.md: deck-render only. Narrative mode must not inject
|
|
103
109
|
// visual CSS, layout catalogs, component indexes, or HTML skeleton rules.
|
|
104
110
|
const designSkill = mode === "deck-render" ? buildDesignLayer(design) : ""
|
|
105
111
|
|
|
@@ -107,7 +113,7 @@ export function buildPrompt(optionsOrDesignName?: BuildPromptOptions | string, l
|
|
|
107
113
|
const header = mode === "deck-render"
|
|
108
114
|
? `<!-- Revela prompt mode: deck-render -->\n` +
|
|
109
115
|
`<!-- Active design: ${design} -->\n` +
|
|
110
|
-
`<!-- Active domain: ${domain} -->\n` +
|
|
116
|
+
`<!-- Active domain: ${domain} (not injected in deck-render mode) -->\n` +
|
|
111
117
|
`<!-- Design files: ${designDir}/ -->\n` +
|
|
112
118
|
`<!-- - DESIGN.md — metadata + style instructions (injected below) -->\n` +
|
|
113
119
|
`${previewLine}\n\n`
|
|
@@ -115,7 +121,7 @@ export function buildPrompt(optionsOrDesignName?: BuildPromptOptions | string, l
|
|
|
115
121
|
`<!-- Active domain: ${domain} -->\n` +
|
|
116
122
|
`<!-- Design layer intentionally omitted in narrative mode. Use deck-render mode before writing deck artifacts. -->\n\n`
|
|
117
123
|
|
|
118
|
-
// Concatenation: Header → Skill → Domain → Design (deck-render only)
|
|
124
|
+
// Concatenation: Header → Skill → Domain (narrative only) → Design (deck-render only)
|
|
119
125
|
const parts = [header, coreSkill]
|
|
120
126
|
if (domainSkill) {
|
|
121
127
|
parts.push(`\n\n---\n\n${domainSkill}`)
|
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
|
package/lib/qa/index.ts
CHANGED
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
|
|
9
9
|
import { measureSlides } from "./measure"
|
|
10
10
|
import { runChecks, formatReport } from "./checks"
|
|
11
|
-
import type { QAReport } from "./checks"
|
|
11
|
+
import type { LayoutIssue, QAReport } from "./checks"
|
|
12
12
|
import type { DesignClassVocabulary } from "../design/designs"
|
|
13
|
+
import { existsSync, readFileSync } from "fs"
|
|
14
|
+
import { dirname, resolve } from "path"
|
|
13
15
|
|
|
14
16
|
export type { QAReport, SlideReport, LayoutIssue, IssueSeverity } from "./checks"
|
|
15
17
|
export type { RunChecksOptions } from "./checks"
|
|
@@ -34,7 +36,76 @@ export async function runQA(
|
|
|
34
36
|
_vocabulary?: DesignClassVocabulary,
|
|
35
37
|
): Promise<QAReport> {
|
|
36
38
|
const result = await measureSlides(htmlFilePath)
|
|
37
|
-
|
|
39
|
+
const report = runChecks(htmlFilePath, result.slides)
|
|
40
|
+
return withAssetChecks(report, htmlFilePath)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function withAssetChecks(report: QAReport, htmlFilePath: string): QAReport {
|
|
44
|
+
const issues = scanAssetRefs(htmlFilePath)
|
|
45
|
+
if (!issues.length) return report
|
|
46
|
+
const slides = [...report.slides]
|
|
47
|
+
const first = slides[0] ?? { index: 1, title: "Deck", issues: [] }
|
|
48
|
+
slides[0] = { ...first, issues: [...first.issues, ...issues] }
|
|
49
|
+
const errorCount = report.errorCount + issues.filter((issue) => issue.severity === "error").length
|
|
50
|
+
const warningCount = report.warningCount + issues.filter((issue) => issue.severity === "warning").length
|
|
51
|
+
return {
|
|
52
|
+
...report,
|
|
53
|
+
slides,
|
|
54
|
+
totalIssues: report.totalIssues + issues.length,
|
|
55
|
+
errorCount,
|
|
56
|
+
warningCount,
|
|
57
|
+
summary: errorCount === 0
|
|
58
|
+
? `QA passed with ${warningCount} warning${warningCount === 1 ? "" : "s"}.`
|
|
59
|
+
: `QA failed with ${errorCount} error${errorCount === 1 ? "" : "s"} and ${warningCount} warning${warningCount === 1 ? "" : "s"}.`,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function scanAssetRefs(htmlFilePath: string): LayoutIssue[] {
|
|
64
|
+
let html = ""
|
|
65
|
+
try {
|
|
66
|
+
html = readFileSync(htmlFilePath, "utf-8")
|
|
67
|
+
} catch {
|
|
68
|
+
return []
|
|
69
|
+
}
|
|
70
|
+
const refs = new Set<string>()
|
|
71
|
+
const attrPattern = /\b(?:src|href|poster)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi
|
|
72
|
+
let match: RegExpExecArray | null
|
|
73
|
+
while ((match = attrPattern.exec(html))) refs.add(match[1] ?? match[2] ?? match[3] ?? "")
|
|
74
|
+
const cssPattern = /url\(\s*(?:"([^"]*)"|'([^']*)'|([^\s)]+))\s*\)/gi
|
|
75
|
+
while ((match = cssPattern.exec(html))) refs.add(match[1] ?? match[2] ?? match[3] ?? "")
|
|
76
|
+
|
|
77
|
+
const issues: LayoutIssue[] = []
|
|
78
|
+
for (const raw of refs) {
|
|
79
|
+
const ref = raw.trim()
|
|
80
|
+
if (!ref || ref.startsWith("data:") || ref.startsWith("#") || ref.startsWith("mailto:") || ref.startsWith("tel:")) continue
|
|
81
|
+
if (/^https?:\/\//i.test(ref) || ref.startsWith("//")) {
|
|
82
|
+
issues.push({ type: "asset", sub: "remote_url", severity: "error", detail: `Deck HTML references remote asset URL \`${ref}\`. Save network images to workspace assets and reference the local file instead.` })
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
if (ref.includes("/__revela_asset")) {
|
|
86
|
+
issues.push({ type: "asset", sub: "refine_proxy", severity: "error", detail: `Deck HTML references Refine proxy URL \`${ref}\`. Use the saved workspace asset path instead.` })
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
if (!looksLikeImageRef(ref)) continue
|
|
90
|
+
const pathOnly = safeDecode(ref.split(/[?#]/)[0])
|
|
91
|
+
const resolved = resolve(dirname(htmlFilePath), pathOnly)
|
|
92
|
+
if (!existsSync(resolved)) {
|
|
93
|
+
issues.push({ type: "asset", sub: "missing_file", severity: "error", detail: `Deck HTML references missing image asset \`${ref}\`. Use a path relative to the deck HTML file or save the asset into workspace assets first.` })
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return issues
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function looksLikeImageRef(ref: string): boolean {
|
|
100
|
+
return /\.(?:png|jpe?g|webp|gif|svg)(?:[?#].*)?$/i.test(ref)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function safeDecode(value: string): string {
|
|
104
|
+
try {
|
|
105
|
+
return decodeURIComponent(value)
|
|
106
|
+
} catch {
|
|
107
|
+
return value
|
|
108
|
+
}
|
|
38
109
|
}
|
|
39
110
|
|
|
40
111
|
/**
|