@cyber-dash-tech/revela 0.3.0 → 0.4.0

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.
@@ -0,0 +1,146 @@
1
+ import { saveMediaAsset } from "./save"
2
+ import type { MediaPurpose, MediaSaveResult } from "./types"
3
+
4
+ export interface MediaBatchItem {
5
+ candidateId: string
6
+ description: string
7
+ url: string
8
+ alt?: string
9
+ use: "logo" | "portrait" | "screenshot"
10
+ sourceFile?: string
11
+ intendedSection?: string
12
+ }
13
+
14
+ export interface MediaBatchSaveInput {
15
+ topic: string
16
+ items: MediaBatchItem[]
17
+ intendedSection?: string
18
+ }
19
+
20
+ export interface MediaBatchSaveResultItem {
21
+ candidateId: string
22
+ assetId: string
23
+ status: string
24
+ path: string | null
25
+ error?: string
26
+ }
27
+
28
+ export interface MediaBatchSaveResult {
29
+ ok: true
30
+ topic: string
31
+ manifestPath: string | null
32
+ saved: number
33
+ failed: number
34
+ results: MediaBatchSaveResultItem[]
35
+ }
36
+
37
+ function candidateKey(candidateId: string): string {
38
+ return slugify(candidateId) || "item"
39
+ }
40
+
41
+ function slugify(value: string): string {
42
+ return value
43
+ .toLowerCase()
44
+ .replace(/[^a-z0-9]+/g, "-")
45
+ .replace(/^-+|-+$/g, "")
46
+ }
47
+
48
+ function purposeForUse(use: MediaBatchItem["use"]): MediaPurpose {
49
+ return use
50
+ }
51
+
52
+ function baseAssetId(item: MediaBatchItem): string {
53
+ const description = slugify(item.description)
54
+ const fallback = slugify(item.sourceFile ?? item.candidateId)
55
+ return description || fallback || "image"
56
+ }
57
+
58
+ function buildDeterministicAssetIds(items: MediaBatchItem[]): Map<string, string> {
59
+ const assetIds = new Map<string, string>()
60
+ for (const item of items) {
61
+ const base = baseAssetId(item)
62
+ assetIds.set(item.candidateId, `${base}-${candidateKey(item.candidateId)}`)
63
+ }
64
+ return assetIds
65
+ }
66
+
67
+ export async function batchSaveMediaAssets(
68
+ input: MediaBatchSaveInput,
69
+ workspaceDir: string,
70
+ ): Promise<MediaBatchSaveResult> {
71
+ const topic = slugify(input.topic)
72
+ const results: MediaBatchSaveResultItem[] = []
73
+ const urlToResult = new Map<string, MediaBatchSaveResultItem>()
74
+ const assetIds = buildDeterministicAssetIds(input.items)
75
+ let manifestPath: string | null = null
76
+ let saved = 0
77
+ let failed = 0
78
+
79
+ for (const item of input.items) {
80
+ const normalizedUrl = item.url.trim()
81
+ const existing = urlToResult.get(normalizedUrl)
82
+ if (existing) {
83
+ results.push({
84
+ candidateId: item.candidateId,
85
+ assetId: existing.assetId,
86
+ status: existing.status,
87
+ path: existing.path,
88
+ error: existing.error,
89
+ })
90
+ continue
91
+ }
92
+
93
+ const assetId = assetIds.get(item.candidateId) ?? `${baseAssetId(item)}-01`
94
+ const brief = item.sourceFile
95
+ ? `${item.description} from ${item.sourceFile}`
96
+ : item.description
97
+ const saveResult: MediaSaveResult = await saveMediaAsset({
98
+ topic,
99
+ id: assetId,
100
+ type: "image",
101
+ purpose: purposeForUse(item.use),
102
+ brief,
103
+ status: "success",
104
+ intendedSection: item.intendedSection ?? input.intendedSection,
105
+ sourceUrl: normalizedUrl,
106
+ alt: item.alt,
107
+ }, workspaceDir)
108
+
109
+ const mapped: MediaBatchSaveResultItem = saveResult.ok
110
+ ? {
111
+ candidateId: item.candidateId,
112
+ assetId,
113
+ status: saveResult.status,
114
+ path: saveResult.path,
115
+ }
116
+ : {
117
+ candidateId: item.candidateId,
118
+ assetId,
119
+ status: "error",
120
+ path: null,
121
+ error: saveResult.error,
122
+ }
123
+
124
+ if (saveResult.ok) manifestPath = saveResult.manifestPath
125
+ if (mapped.path) saved += 1
126
+ else failed += 1
127
+
128
+ urlToResult.set(normalizedUrl, {
129
+ candidateId: item.candidateId,
130
+ assetId,
131
+ status: mapped.status,
132
+ path: mapped.path,
133
+ error: mapped.error,
134
+ })
135
+ results.push(mapped)
136
+ }
137
+
138
+ return {
139
+ ok: true,
140
+ topic,
141
+ manifestPath,
142
+ saved,
143
+ failed,
144
+ results,
145
+ }
146
+ }
@@ -0,0 +1,68 @@
1
+ import { extname } from "path"
2
+
3
+ const MIME_TO_EXT: Record<string, string> = {
4
+ "image/png": ".png",
5
+ "image/jpeg": ".jpg",
6
+ "image/webp": ".webp",
7
+ "image/gif": ".gif",
8
+ }
9
+
10
+ const ALLOWED_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".webp", ".gif"])
11
+
12
+ function normalizeExtension(ext: string): string {
13
+ const value = ext.toLowerCase()
14
+ if (value === ".jpeg") return ".jpg"
15
+ return value
16
+ }
17
+
18
+ export function inferImageExtension(contentType: string | null, sourceName = ""): string | null {
19
+ const mimeBase = contentType?.split(";")[0].trim().toLowerCase() ?? ""
20
+ if (mimeBase) {
21
+ const byMime = MIME_TO_EXT[mimeBase]
22
+ if (byMime) return byMime
23
+ if (!mimeBase.startsWith("image/")) return null
24
+ }
25
+
26
+ const ext = normalizeExtension(extname(sourceName))
27
+ if (ALLOWED_EXTENSIONS.has(ext)) return ext
28
+ return null
29
+ }
30
+
31
+ export async function downloadImageFromUrl(
32
+ url: string,
33
+ ): Promise<{ buffer: Buffer; contentType: string | null; extension: string }> {
34
+ let parsed: URL
35
+ try {
36
+ parsed = new URL(url)
37
+ } catch {
38
+ throw new Error("INVALID_URL")
39
+ }
40
+
41
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
42
+ throw new Error("INVALID_URL")
43
+ }
44
+
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
+ })
52
+
53
+ if (!response.ok) {
54
+ throw new Error(`DOWNLOAD_FAILED:${response.status}`)
55
+ }
56
+
57
+ const contentType = response.headers.get("content-type")
58
+ const extension = inferImageExtension(contentType, parsed.pathname)
59
+ if (!extension) {
60
+ throw new Error("UNSUPPORTED_IMAGE_TYPE")
61
+ }
62
+
63
+ return {
64
+ buffer: Buffer.from(await response.arrayBuffer()),
65
+ contentType,
66
+ extension,
67
+ }
68
+ }
@@ -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
+ }