@cyber-dash-tech/revela 0.3.1 → 0.4.1
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 +227 -9
- package/README.zh-CN.md +188 -8
- package/designs/monet/preview.html +2293 -0
- package/designs/starter/DESIGN.md +778 -0
- package/designs/starter/preview.html +271 -0
- package/designs/summit/DESIGN.md +54 -51
- package/designs/summit/preview.html +2284 -0
- package/lib/commands/designs-new.ts +167 -0
- package/lib/commands/designs-preview.ts +36 -0
- package/lib/commands/help.ts +3 -0
- package/lib/design/designs.ts +176 -3
- package/lib/media/batch-save.ts +146 -0
- package/lib/media/download.ts +68 -0
- package/lib/media/save.ts +273 -0
- package/lib/media/types.ts +54 -0
- package/lib/research/image-leads.ts +175 -0
- package/package.json +5 -1
- package/plugin.ts +45 -0
- package/skill/SKILL.md +7 -1
- package/tools/designs-author.ts +62 -0
- package/tools/media-batch-save.ts +25 -0
- package/tools/media-save.ts +40 -0
- package/tools/research-images-list.ts +34 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import {
|
|
2
|
+
copyFileSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "fs"
|
|
9
|
+
import { dirname, join, relative, resolve, sep } from "path"
|
|
10
|
+
import { downloadImageFromUrl, inferImageExtension } from "./download"
|
|
11
|
+
import type {
|
|
12
|
+
MediaAssetRecord,
|
|
13
|
+
MediaManifest,
|
|
14
|
+
MediaSaveInput,
|
|
15
|
+
MediaSaveResult,
|
|
16
|
+
MediaStatus,
|
|
17
|
+
} from "./types"
|
|
18
|
+
|
|
19
|
+
function nowIso(): string {
|
|
20
|
+
return new Date().toISOString()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function slugify(value: string): string {
|
|
24
|
+
return value
|
|
25
|
+
.toLowerCase()
|
|
26
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
27
|
+
.replace(/^-+|-+$/g, "")
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function ensureInsideWorkspace(pathValue: string, workspaceDir: string): string {
|
|
31
|
+
const abs = resolve(workspaceDir, pathValue)
|
|
32
|
+
const root = resolve(workspaceDir)
|
|
33
|
+
if (abs !== root && !abs.startsWith(root + sep)) {
|
|
34
|
+
throw new Error("sourcePath must be within workspace")
|
|
35
|
+
}
|
|
36
|
+
return abs
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readManifest(manifestPath: string, topic: string): MediaManifest {
|
|
40
|
+
if (!existsSync(manifestPath)) {
|
|
41
|
+
return { topic, updatedAt: nowIso(), assets: [] }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const parsed = JSON.parse(readFileSync(manifestPath, "utf-8")) as Partial<MediaManifest>
|
|
45
|
+
return {
|
|
46
|
+
topic,
|
|
47
|
+
updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : nowIso(),
|
|
48
|
+
assets: Array.isArray(parsed.assets) ? parsed.assets as MediaAssetRecord[] : [],
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function buildFailureRecord(
|
|
53
|
+
input: MediaSaveInput,
|
|
54
|
+
topic: string,
|
|
55
|
+
status: Exclude<MediaStatus, "success">,
|
|
56
|
+
failureReason: string,
|
|
57
|
+
sourcePath?: string,
|
|
58
|
+
): MediaAssetRecord {
|
|
59
|
+
return {
|
|
60
|
+
id: slugify(input.id),
|
|
61
|
+
type: "image",
|
|
62
|
+
purpose: input.purpose,
|
|
63
|
+
brief: input.brief,
|
|
64
|
+
status,
|
|
65
|
+
path: null,
|
|
66
|
+
sourceUrl: input.sourceUrl,
|
|
67
|
+
sourcePath,
|
|
68
|
+
intendedSection: input.intendedSection,
|
|
69
|
+
alt: input.alt,
|
|
70
|
+
notes: input.notes,
|
|
71
|
+
failureReason,
|
|
72
|
+
savedAt: nowIso(),
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function upsertAsset(manifest: MediaManifest, record: MediaAssetRecord): { manifest: MediaManifest; previous?: MediaAssetRecord } {
|
|
77
|
+
const assets = [...manifest.assets]
|
|
78
|
+
const index = assets.findIndex((asset) => asset.id === record.id)
|
|
79
|
+
const previous = index === -1 ? undefined : assets[index]
|
|
80
|
+
if (index === -1) assets.push(record)
|
|
81
|
+
else assets[index] = record
|
|
82
|
+
return {
|
|
83
|
+
previous,
|
|
84
|
+
manifest: {
|
|
85
|
+
topic: manifest.topic,
|
|
86
|
+
updatedAt: nowIso(),
|
|
87
|
+
assets,
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function writeManifest(manifestPath: string, manifest: MediaManifest): void {
|
|
93
|
+
mkdirSync(dirname(manifestPath), { recursive: true })
|
|
94
|
+
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf-8")
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function saveFailureResult(
|
|
98
|
+
input: MediaSaveInput,
|
|
99
|
+
workspaceDir: string,
|
|
100
|
+
topic: string,
|
|
101
|
+
manifestPath: string,
|
|
102
|
+
status: Exclude<MediaStatus, "success">,
|
|
103
|
+
failureReason: string,
|
|
104
|
+
sourcePath?: string,
|
|
105
|
+
): MediaSaveResult {
|
|
106
|
+
const manifest = readManifest(manifestPath, topic)
|
|
107
|
+
const existing = manifest.assets.find((asset) => asset.id === slugify(input.id))
|
|
108
|
+
const record = existing?.path
|
|
109
|
+
? {
|
|
110
|
+
...existing,
|
|
111
|
+
sourceUrl: input.sourceUrl ?? existing.sourceUrl,
|
|
112
|
+
sourcePath: sourcePath ?? existing.sourcePath,
|
|
113
|
+
alt: input.alt ?? existing.alt,
|
|
114
|
+
notes: input.notes ?? existing.notes,
|
|
115
|
+
failureReason,
|
|
116
|
+
}
|
|
117
|
+
: buildFailureRecord(input, topic, status, failureReason, sourcePath)
|
|
118
|
+
const { manifest: nextManifest, previous } = upsertAsset(manifest, record)
|
|
119
|
+
writeManifest(manifestPath, nextManifest)
|
|
120
|
+
|
|
121
|
+
if (!existing?.path && previous?.path) {
|
|
122
|
+
rmSync(join(workspaceDir, previous.path), { force: true })
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
ok: true,
|
|
127
|
+
assetId: record.id,
|
|
128
|
+
status,
|
|
129
|
+
path: null,
|
|
130
|
+
manifestPath: relative(workspaceDir, manifestPath),
|
|
131
|
+
updated: true,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function saveMediaAsset(input: MediaSaveInput, workspaceDir: string): Promise<MediaSaveResult> {
|
|
136
|
+
try {
|
|
137
|
+
const topic = slugify(input.topic)
|
|
138
|
+
const assetId = slugify(input.id)
|
|
139
|
+
if (!topic) return { ok: false, error: "topic is required" }
|
|
140
|
+
if (!assetId) return { ok: false, error: "id is required" }
|
|
141
|
+
if (input.type !== "image") return { ok: false, error: "type must be 'image'" }
|
|
142
|
+
|
|
143
|
+
const topicDir = join(workspaceDir, "assets", topic)
|
|
144
|
+
const mediaDir = join(topicDir, "media")
|
|
145
|
+
const manifestPath = join(topicDir, "media-manifest.json")
|
|
146
|
+
mkdirSync(mediaDir, { recursive: true })
|
|
147
|
+
|
|
148
|
+
const relativeSourcePath = input.sourcePath
|
|
149
|
+
? relative(workspaceDir, ensureInsideWorkspace(input.sourcePath, workspaceDir))
|
|
150
|
+
: undefined
|
|
151
|
+
|
|
152
|
+
if (input.status !== "success") {
|
|
153
|
+
if (!input.failureReason?.trim()) {
|
|
154
|
+
return { ok: false, error: "failureReason is required when status is not 'success'" }
|
|
155
|
+
}
|
|
156
|
+
return saveFailureResult(
|
|
157
|
+
input,
|
|
158
|
+
workspaceDir,
|
|
159
|
+
topic,
|
|
160
|
+
manifestPath,
|
|
161
|
+
input.status,
|
|
162
|
+
input.failureReason.trim(),
|
|
163
|
+
relativeSourcePath,
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!input.sourcePath && !input.sourceUrl) {
|
|
168
|
+
return { ok: false, error: "sourcePath or sourceUrl is required when status is 'success'" }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let buffer: Buffer
|
|
172
|
+
let extension: string | null = null
|
|
173
|
+
|
|
174
|
+
if (input.sourcePath) {
|
|
175
|
+
const absPath = ensureInsideWorkspace(input.sourcePath, workspaceDir)
|
|
176
|
+
if (!existsSync(absPath)) {
|
|
177
|
+
return { ok: false, error: `sourcePath not found: ${input.sourcePath}` }
|
|
178
|
+
}
|
|
179
|
+
extension = inferImageExtension(null, absPath)
|
|
180
|
+
if (!extension) {
|
|
181
|
+
return { ok: false, error: `unsupported local image type: ${input.sourcePath}` }
|
|
182
|
+
}
|
|
183
|
+
const destinationPath = join(mediaDir, `${assetId}${extension}`)
|
|
184
|
+
const manifest = readManifest(manifestPath, topic)
|
|
185
|
+
const previous = manifest.assets.find((asset) => asset.id === assetId)
|
|
186
|
+
copyFileSync(absPath, destinationPath)
|
|
187
|
+
const record: MediaAssetRecord = {
|
|
188
|
+
id: assetId,
|
|
189
|
+
type: "image",
|
|
190
|
+
purpose: input.purpose,
|
|
191
|
+
brief: input.brief,
|
|
192
|
+
status: "success",
|
|
193
|
+
path: relative(workspaceDir, destinationPath),
|
|
194
|
+
sourceUrl: input.sourceUrl,
|
|
195
|
+
sourcePath: relativeSourcePath,
|
|
196
|
+
intendedSection: input.intendedSection,
|
|
197
|
+
alt: input.alt,
|
|
198
|
+
notes: input.notes,
|
|
199
|
+
savedAt: nowIso(),
|
|
200
|
+
}
|
|
201
|
+
const { manifest: nextManifest } = upsertAsset(manifest, record)
|
|
202
|
+
writeManifest(manifestPath, nextManifest)
|
|
203
|
+
if (previous?.path && previous.path !== record.path) {
|
|
204
|
+
rmSync(join(workspaceDir, previous.path), { force: true })
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
ok: true,
|
|
208
|
+
assetId,
|
|
209
|
+
status: "success",
|
|
210
|
+
path: record.path,
|
|
211
|
+
manifestPath: relative(workspaceDir, manifestPath),
|
|
212
|
+
updated: true,
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
const downloaded = await downloadImageFromUrl(input.sourceUrl!)
|
|
218
|
+
buffer = downloaded.buffer
|
|
219
|
+
extension = downloaded.extension
|
|
220
|
+
} catch (error) {
|
|
221
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
222
|
+
if (message === "INVALID_URL") {
|
|
223
|
+
return saveFailureResult(input, workspaceDir, topic, manifestPath, "invalid-url", "Invalid image URL")
|
|
224
|
+
}
|
|
225
|
+
return saveFailureResult(
|
|
226
|
+
input,
|
|
227
|
+
workspaceDir,
|
|
228
|
+
topic,
|
|
229
|
+
manifestPath,
|
|
230
|
+
"cannot-download",
|
|
231
|
+
`Failed to download image: ${message}`,
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const destinationPath = join(mediaDir, `${assetId}${extension}`)
|
|
236
|
+
const manifest = readManifest(manifestPath, topic)
|
|
237
|
+
const previous = manifest.assets.find((asset) => asset.id === assetId)
|
|
238
|
+
writeFileSync(destinationPath, new Uint8Array(buffer))
|
|
239
|
+
const record: MediaAssetRecord = {
|
|
240
|
+
id: assetId,
|
|
241
|
+
type: "image",
|
|
242
|
+
purpose: input.purpose,
|
|
243
|
+
brief: input.brief,
|
|
244
|
+
status: "success",
|
|
245
|
+
path: relative(workspaceDir, destinationPath),
|
|
246
|
+
sourceUrl: input.sourceUrl,
|
|
247
|
+
sourcePath: relativeSourcePath,
|
|
248
|
+
intendedSection: input.intendedSection,
|
|
249
|
+
alt: input.alt,
|
|
250
|
+
notes: input.notes,
|
|
251
|
+
savedAt: nowIso(),
|
|
252
|
+
}
|
|
253
|
+
const { manifest: nextManifest } = upsertAsset(manifest, record)
|
|
254
|
+
writeManifest(manifestPath, nextManifest)
|
|
255
|
+
if (previous?.path && previous.path !== record.path) {
|
|
256
|
+
rmSync(join(workspaceDir, previous.path), { force: true })
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
ok: true,
|
|
261
|
+
assetId,
|
|
262
|
+
status: "success",
|
|
263
|
+
path: record.path,
|
|
264
|
+
manifestPath: relative(workspaceDir, manifestPath),
|
|
265
|
+
updated: true,
|
|
266
|
+
}
|
|
267
|
+
} catch (error) {
|
|
268
|
+
return {
|
|
269
|
+
ok: false,
|
|
270
|
+
error: error instanceof Error ? error.message : String(error),
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export type MediaPurpose = "hero" | "illustration" | "portrait" | "logo" | "screenshot"
|
|
2
|
+
|
|
3
|
+
export type MediaStatus = "success" | "cannot-download" | "invalid-url" | "cannot-generate"
|
|
4
|
+
|
|
5
|
+
export interface MediaSaveInput {
|
|
6
|
+
topic: string
|
|
7
|
+
id: string
|
|
8
|
+
type: "image"
|
|
9
|
+
purpose: MediaPurpose
|
|
10
|
+
brief: string
|
|
11
|
+
status: MediaStatus
|
|
12
|
+
intendedSection?: string
|
|
13
|
+
sourcePath?: string
|
|
14
|
+
sourceUrl?: string
|
|
15
|
+
alt?: string
|
|
16
|
+
notes?: string
|
|
17
|
+
failureReason?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface MediaAssetRecord {
|
|
21
|
+
id: string
|
|
22
|
+
type: "image"
|
|
23
|
+
purpose: MediaPurpose
|
|
24
|
+
brief: string
|
|
25
|
+
status: MediaStatus
|
|
26
|
+
path: string | null
|
|
27
|
+
sourceUrl?: string
|
|
28
|
+
sourcePath?: string
|
|
29
|
+
intendedSection?: string
|
|
30
|
+
alt?: string
|
|
31
|
+
notes?: string
|
|
32
|
+
failureReason?: string
|
|
33
|
+
savedAt: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface MediaManifest {
|
|
37
|
+
topic: string
|
|
38
|
+
updatedAt: string
|
|
39
|
+
assets: MediaAssetRecord[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type MediaSaveResult =
|
|
43
|
+
| {
|
|
44
|
+
ok: true
|
|
45
|
+
assetId: string
|
|
46
|
+
status: MediaStatus
|
|
47
|
+
path: string | null
|
|
48
|
+
manifestPath: string
|
|
49
|
+
updated: boolean
|
|
50
|
+
}
|
|
51
|
+
| {
|
|
52
|
+
ok: false
|
|
53
|
+
error: string
|
|
54
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "fs"
|
|
2
|
+
import { basename, join } from "path"
|
|
3
|
+
|
|
4
|
+
export type ResearchImageUse = "logo" | "portrait" | "screenshot" | "unknown"
|
|
5
|
+
|
|
6
|
+
export interface ResearchImageLead {
|
|
7
|
+
candidateId: string
|
|
8
|
+
topic: string
|
|
9
|
+
axis: string
|
|
10
|
+
sourceFile: string
|
|
11
|
+
description: string
|
|
12
|
+
url: string
|
|
13
|
+
alt: string
|
|
14
|
+
use: ResearchImageUse
|
|
15
|
+
line: number
|
|
16
|
+
valid: boolean
|
|
17
|
+
warnings: string[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ListResearchImageLeadOptions {
|
|
21
|
+
uses?: string[]
|
|
22
|
+
axis?: string[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ResearchImageLeadListResult {
|
|
26
|
+
topic: string
|
|
27
|
+
items: ResearchImageLead[]
|
|
28
|
+
warnings: string[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const LEAD_LINE_RE = /^-\s*(.+?):\s*(\S+)(?:\s*\|\s*Alt:\s*(.*?))?(?:\s*\|\s*Use:\s*(.*?))?\s*$/
|
|
32
|
+
|
|
33
|
+
function slugify(value: string): string {
|
|
34
|
+
return value
|
|
35
|
+
.toLowerCase()
|
|
36
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
37
|
+
.replace(/^-+|-+$/g, "")
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizeUse(value: string | undefined): ResearchImageUse {
|
|
41
|
+
const normalized = (value ?? "").trim().toLowerCase()
|
|
42
|
+
if (normalized === "logo" || normalized === "portrait" || normalized === "screenshot") {
|
|
43
|
+
return normalized
|
|
44
|
+
}
|
|
45
|
+
return "unknown"
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeAxisFilter(values: string[] | undefined): string[] | undefined {
|
|
49
|
+
if (!values?.length) return undefined
|
|
50
|
+
const normalized = values
|
|
51
|
+
.map((value) => slugify(value))
|
|
52
|
+
.filter(Boolean)
|
|
53
|
+
return normalized.length ? normalized : undefined
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizeUseFilter(values: string[] | undefined): ResearchImageUse[] | undefined {
|
|
57
|
+
if (!values?.length) return undefined
|
|
58
|
+
const normalized = values
|
|
59
|
+
.map((value) => normalizeUse(value))
|
|
60
|
+
return normalized.length ? normalized : undefined
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isHttpUrl(value: string): boolean {
|
|
64
|
+
try {
|
|
65
|
+
const url = new URL(value)
|
|
66
|
+
return url.protocol === "http:" || url.protocol === "https:"
|
|
67
|
+
} catch {
|
|
68
|
+
return false
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function shouldIncludeLead(lead: ResearchImageLead, options?: ListResearchImageLeadOptions): boolean {
|
|
73
|
+
const uses = normalizeUseFilter(options?.uses)
|
|
74
|
+
const axis = normalizeAxisFilter(options?.axis)
|
|
75
|
+
if (uses?.length && !uses.includes(lead.use)) return false
|
|
76
|
+
if (axis?.length && !axis.includes(lead.axis)) return false
|
|
77
|
+
return true
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parseImageSection(
|
|
81
|
+
topic: string,
|
|
82
|
+
axis: string,
|
|
83
|
+
sourceFile: string,
|
|
84
|
+
content: string,
|
|
85
|
+
): { items: ResearchImageLead[]; warnings: string[] } {
|
|
86
|
+
const lines = content.split(/\r?\n/)
|
|
87
|
+
const items: ResearchImageLead[] = []
|
|
88
|
+
const warnings: string[] = []
|
|
89
|
+
let inImagesSection = false
|
|
90
|
+
let candidateIndex = 0
|
|
91
|
+
|
|
92
|
+
for (let index = 0; index < lines.length; index++) {
|
|
93
|
+
const line = lines[index]
|
|
94
|
+
const trimmed = line.trim()
|
|
95
|
+
|
|
96
|
+
if (/^##\s+Images\s*$/i.test(trimmed)) {
|
|
97
|
+
inImagesSection = true
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (inImagesSection && /^##\s+/.test(trimmed)) {
|
|
102
|
+
break
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!inImagesSection || !trimmed.startsWith("-")) continue
|
|
106
|
+
if (trimmed === "- None") continue
|
|
107
|
+
|
|
108
|
+
const match = trimmed.match(LEAD_LINE_RE)
|
|
109
|
+
if (!match) {
|
|
110
|
+
warnings.push(`${sourceFile}:${index + 1} could not parse image lead`)
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
candidateIndex += 1
|
|
115
|
+
const description = match[1].trim()
|
|
116
|
+
const url = match[2].trim()
|
|
117
|
+
const alt = (match[3] ?? "").trim()
|
|
118
|
+
const use = normalizeUse(match[4])
|
|
119
|
+
const itemWarnings: string[] = []
|
|
120
|
+
const valid = isHttpUrl(url)
|
|
121
|
+
|
|
122
|
+
if (!valid) itemWarnings.push("invalid-url")
|
|
123
|
+
if (use === "unknown") itemWarnings.push("unknown-use")
|
|
124
|
+
|
|
125
|
+
items.push({
|
|
126
|
+
candidateId: `${axis}:${candidateIndex}`,
|
|
127
|
+
topic,
|
|
128
|
+
axis,
|
|
129
|
+
sourceFile,
|
|
130
|
+
description,
|
|
131
|
+
url,
|
|
132
|
+
alt,
|
|
133
|
+
use,
|
|
134
|
+
line: index + 1,
|
|
135
|
+
valid,
|
|
136
|
+
warnings: itemWarnings,
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { items, warnings }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function listResearchImageLeads(
|
|
144
|
+
topic: string,
|
|
145
|
+
workspaceDir: string,
|
|
146
|
+
options?: ListResearchImageLeadOptions,
|
|
147
|
+
): ResearchImageLeadListResult {
|
|
148
|
+
const topicSlug = slugify(topic)
|
|
149
|
+
const researchDir = join(workspaceDir, "researches", topicSlug)
|
|
150
|
+
if (!existsSync(researchDir)) {
|
|
151
|
+
return { topic: topicSlug, items: [], warnings: [] }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const items: ResearchImageLead[] = []
|
|
155
|
+
const warnings: string[] = []
|
|
156
|
+
const entries = readdirSync(researchDir)
|
|
157
|
+
.filter((entry) => entry.endsWith(".md"))
|
|
158
|
+
.sort()
|
|
159
|
+
|
|
160
|
+
for (const entry of entries) {
|
|
161
|
+
const filePath = join(researchDir, entry)
|
|
162
|
+
const axis = basename(entry, ".md")
|
|
163
|
+
const sourceFile = `researches/${topicSlug}/${entry}`
|
|
164
|
+
const content = readFileSync(filePath, "utf-8")
|
|
165
|
+
const parsed = parseImageSection(topicSlug, axis, sourceFile, content)
|
|
166
|
+
|
|
167
|
+
for (const item of parsed.items) {
|
|
168
|
+
if (shouldIncludeLead(item, options)) items.push(item)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
warnings.push(...parsed.warnings)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return { topic: topicSlug, items, warnings }
|
|
175
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cyber-dash-tech/revela",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "OpenCode plugin that turns AI into an HTML slide deck generator",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.ts",
|
|
@@ -13,8 +13,12 @@
|
|
|
13
13
|
"tools/",
|
|
14
14
|
"skill/",
|
|
15
15
|
"designs/aurora/DESIGN.md",
|
|
16
|
+
"designs/starter/DESIGN.md",
|
|
17
|
+
"designs/starter/preview.html",
|
|
16
18
|
"designs/summit/DESIGN.md",
|
|
19
|
+
"designs/summit/preview.html",
|
|
17
20
|
"designs/monet/DESIGN.md",
|
|
21
|
+
"designs/monet/preview.html",
|
|
18
22
|
"domains/general/INDUSTRY.md",
|
|
19
23
|
"domains/deeptech-investment/INDUSTRY.md",
|
|
20
24
|
"domains/consulting/INDUSTRY.md",
|
package/plugin.ts
CHANGED
|
@@ -45,8 +45,19 @@ import {
|
|
|
45
45
|
} from "./lib/commands/domains"
|
|
46
46
|
import { handlePdf } from "./lib/commands/pdf"
|
|
47
47
|
import { handlePptx } from "./lib/commands/pptx"
|
|
48
|
+
import { handleDesignsPreview } from "./lib/commands/designs-preview"
|
|
49
|
+
import {
|
|
50
|
+
parseDesignsNewArgs,
|
|
51
|
+
buildDesignsNewPrompt,
|
|
52
|
+
parseDesignsEditArgs,
|
|
53
|
+
buildDesignsEditPrompt,
|
|
54
|
+
} from "./lib/commands/designs-new"
|
|
55
|
+
import designsAuthorTool from "./tools/designs-author"
|
|
48
56
|
import designsTool from "./tools/designs"
|
|
49
57
|
import domainsTool from "./tools/domains"
|
|
58
|
+
import mediaBatchSaveTool from "./tools/media-batch-save"
|
|
59
|
+
import mediaSaveTool from "./tools/media-save"
|
|
60
|
+
import researchImagesListTool from "./tools/research-images-list"
|
|
50
61
|
import researchSaveTool from "./tools/research-save"
|
|
51
62
|
import workspaceScanTool from "./tools/workspace-scan"
|
|
52
63
|
import extractDocumentMaterialsTool from "./tools/extract-document-materials"
|
|
@@ -200,6 +211,36 @@ const server: Plugin = (async (pluginCtx) => {
|
|
|
200
211
|
await handleDesignsAdd(param, send)
|
|
201
212
|
throw new Error("__REVELA_DESIGNS_ADD_HANDLED__")
|
|
202
213
|
}
|
|
214
|
+
if (sub === "designs-new") {
|
|
215
|
+
const parsed = parseDesignsNewArgs(param)
|
|
216
|
+
if (!parsed.ok) {
|
|
217
|
+
await send(parsed.error)
|
|
218
|
+
throw new Error("__REVELA_DESIGNS_NEW_USAGE_HANDLED__")
|
|
219
|
+
}
|
|
220
|
+
output.parts.length = 0
|
|
221
|
+
output.parts.push({
|
|
222
|
+
type: "text",
|
|
223
|
+
text: buildDesignsNewPrompt({ name: parsed.name, base: parsed.base }),
|
|
224
|
+
} as any)
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
if (sub === "designs-edit") {
|
|
228
|
+
const parsed = parseDesignsEditArgs(param)
|
|
229
|
+
if (!parsed.ok) {
|
|
230
|
+
await send(parsed.error)
|
|
231
|
+
throw new Error("__REVELA_DESIGNS_EDIT_USAGE_HANDLED__")
|
|
232
|
+
}
|
|
233
|
+
output.parts.length = 0
|
|
234
|
+
output.parts.push({
|
|
235
|
+
type: "text",
|
|
236
|
+
text: buildDesignsEditPrompt({ name: parsed.name }),
|
|
237
|
+
} as any)
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
if (sub === "designs-preview") {
|
|
241
|
+
await handleDesignsPreview(param, send)
|
|
242
|
+
throw new Error("__REVELA_DESIGNS_PREVIEW_HANDLED__")
|
|
243
|
+
}
|
|
203
244
|
if (sub === "domains-add") {
|
|
204
245
|
await handleDomainsAdd(param, send)
|
|
205
246
|
throw new Error("__REVELA_DOMAINS_ADD_HANDLED__")
|
|
@@ -228,7 +269,11 @@ const server: Plugin = (async (pluginCtx) => {
|
|
|
228
269
|
// ── LLM tools: designs, domains, research, document materials, qa ─────
|
|
229
270
|
tool: {
|
|
230
271
|
"revela-designs": designsTool,
|
|
272
|
+
"revela-designs-author": designsAuthorTool,
|
|
231
273
|
"revela-domains": domainsTool,
|
|
274
|
+
"revela-media-batch-save": mediaBatchSaveTool,
|
|
275
|
+
"revela-media-save": mediaSaveTool,
|
|
276
|
+
"revela-research-images-list": researchImagesListTool,
|
|
232
277
|
"revela-research-save": researchSaveTool,
|
|
233
278
|
"revela-workspace-scan": workspaceScanTool,
|
|
234
279
|
"revela-extract-document-materials": extractDocumentMaterialsTool,
|
package/skill/SKILL.md
CHANGED
|
@@ -427,6 +427,12 @@ element selector list, and `window.getEditedHTML()` definition.
|
|
|
427
427
|
|
|
428
428
|
### Image Rules
|
|
429
429
|
|
|
430
|
+
- When research findings contain image leads that should appear in the final deck,
|
|
431
|
+
first call `revela-research-images-list` to inspect structured candidates from
|
|
432
|
+
`researches/{slug}/*.md`. When multiple images are needed, prefer
|
|
433
|
+
`revela-media-batch-save` to save the selected candidates in one call. Use
|
|
434
|
+
`revela-media-save` for one-off cases. Then reference the returned local file
|
|
435
|
+
path in HTML. Do not use remote image URLs directly in final slides.
|
|
430
436
|
- Use direct file paths (`src="assets/logo.png"`) in HTML — not base64
|
|
431
437
|
- Always use the **original** file path in HTML `<img src>` for full-quality rendering
|
|
432
438
|
- Never repeat the same image on multiple slides (logos: title + closing only)
|
|
@@ -493,4 +499,4 @@ The active design name is in the HTML comment at the top of this prompt:
|
|
|
493
499
|
The active design's complete visual specification — Component Library, Layout
|
|
494
500
|
Primitives, Composition Guide, and Data Visualization rules — is injected
|
|
495
501
|
below after the `---` separator. This is your sole visual reference for
|
|
496
|
-
generating slides.
|
|
502
|
+
generating slides.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin"
|
|
2
|
+
import { createDesignPackage, validateDesignPackage } from "../lib/design/designs"
|
|
3
|
+
|
|
4
|
+
export default tool({
|
|
5
|
+
description:
|
|
6
|
+
"Create and validate local Revela design packages generated by the AI design authoring workflow. " +
|
|
7
|
+
"Use action 'create' to save DESIGN.md and preview.html into ~/.config/revela/designs/{name}/. " +
|
|
8
|
+
"Use action 'validate' to check a generated design package before asking the user to activate it.",
|
|
9
|
+
args: {
|
|
10
|
+
action: tool.schema
|
|
11
|
+
.enum(["create", "validate"])
|
|
12
|
+
.describe("Operation to perform"),
|
|
13
|
+
name: tool.schema
|
|
14
|
+
.string()
|
|
15
|
+
.optional()
|
|
16
|
+
.describe("Design name in kebab-case. Required for create and validate."),
|
|
17
|
+
base: tool.schema
|
|
18
|
+
.string()
|
|
19
|
+
.optional()
|
|
20
|
+
.describe("Base design used as structural scaffold, e.g. 'summit'. Recorded in the result only."),
|
|
21
|
+
designMd: tool.schema
|
|
22
|
+
.string()
|
|
23
|
+
.optional()
|
|
24
|
+
.describe("Complete DESIGN.md content. Required for create."),
|
|
25
|
+
previewHtml: tool.schema
|
|
26
|
+
.string()
|
|
27
|
+
.optional()
|
|
28
|
+
.describe("Complete preview.html content. Required for create."),
|
|
29
|
+
overwrite: tool.schema
|
|
30
|
+
.boolean()
|
|
31
|
+
.optional()
|
|
32
|
+
.describe("Whether to replace an existing local design package. Defaults to false."),
|
|
33
|
+
},
|
|
34
|
+
async execute(args) {
|
|
35
|
+
try {
|
|
36
|
+
if (!args.name) return JSON.stringify({ error: "name is required" })
|
|
37
|
+
|
|
38
|
+
switch (args.action) {
|
|
39
|
+
case "create": {
|
|
40
|
+
if (!args.designMd) return JSON.stringify({ error: "designMd is required for create" })
|
|
41
|
+
if (!args.previewHtml) return JSON.stringify({ error: "previewHtml is required for create" })
|
|
42
|
+
const result = createDesignPackage({
|
|
43
|
+
name: args.name,
|
|
44
|
+
base: args.base,
|
|
45
|
+
designMd: args.designMd,
|
|
46
|
+
previewHtml: args.previewHtml,
|
|
47
|
+
overwrite: args.overwrite ?? false,
|
|
48
|
+
})
|
|
49
|
+
return JSON.stringify(result, null, 2)
|
|
50
|
+
}
|
|
51
|
+
case "validate": {
|
|
52
|
+
return JSON.stringify(validateDesignPackage(args.name), null, 2)
|
|
53
|
+
}
|
|
54
|
+
default: {
|
|
55
|
+
return JSON.stringify({ error: `Unknown action: ${args.action}` })
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
} catch (e: any) {
|
|
59
|
+
return JSON.stringify({ error: e.message || String(e) })
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin"
|
|
2
|
+
import { batchSaveMediaAssets } from "../lib/media/batch-save"
|
|
3
|
+
|
|
4
|
+
export default tool({
|
|
5
|
+
description:
|
|
6
|
+
"Save a selected batch of research-found image leads into workspace assets and update the media manifest. " +
|
|
7
|
+
"Use this after the primary agent has chosen multiple images from researches/{topic}/*.md.",
|
|
8
|
+
args: {
|
|
9
|
+
topic: tool.schema.string().describe("Topic slug shared by one presentation, e.g. 'ev-market'."),
|
|
10
|
+
items: tool.schema.array(tool.schema.object({
|
|
11
|
+
candidateId: tool.schema.string().describe("Stable candidate id returned by revela-research-images-list."),
|
|
12
|
+
description: tool.schema.string().describe("Candidate description from research findings."),
|
|
13
|
+
url: tool.schema.string().describe("Remote image URL to save."),
|
|
14
|
+
alt: tool.schema.string().optional().describe("Optional alt text."),
|
|
15
|
+
use: tool.schema.enum(["logo", "portrait", "screenshot"]).describe("Structured image use from research findings."),
|
|
16
|
+
sourceFile: tool.schema.string().optional().describe("Optional source research file path."),
|
|
17
|
+
intendedSection: tool.schema.string().optional().describe("Optional section override for this one item."),
|
|
18
|
+
})).describe("Explicitly selected image leads to save. This tool does not auto-select candidates."),
|
|
19
|
+
intendedSection: tool.schema.string().optional().describe("Optional default narrative section for all items in this batch."),
|
|
20
|
+
},
|
|
21
|
+
async execute(args, context) {
|
|
22
|
+
const workspaceDir = context.directory ?? process.cwd()
|
|
23
|
+
return JSON.stringify(await batchSaveMediaAssets(args, workspaceDir), null, 2)
|
|
24
|
+
},
|
|
25
|
+
})
|