@cyber-dash-tech/revela 0.4.6 → 0.5.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.
@@ -0,0 +1,452 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"
2
+ import { basename, dirname, join } from "path"
3
+
4
+ export const DECKS_STATE_FILE = "DECKS.json"
5
+
6
+ export type DeckProductionStatus = "planning" | "blocked" | "ready" | "written"
7
+ export type SlideProductionStatus = "planned" | "ready" | "written" | "qa_passed" | "qa_failed"
8
+ export type WriteReadinessStatus = "blocked" | "ready" | "written"
9
+
10
+ export interface DecksState {
11
+ version: 1
12
+ activeDeck?: string
13
+ workspace: {
14
+ brief?: string
15
+ sourceMaterials: SourceMaterial[]
16
+ preferences: {
17
+ user: string[]
18
+ workflow: string[]
19
+ }
20
+ deckMemory: DeckMemoryEntry[]
21
+ openQuestions: string[]
22
+ }
23
+ decks: Record<string, DeckSpec>
24
+ }
25
+
26
+ export interface SourceMaterial {
27
+ path: string
28
+ type?: string
29
+ summary?: string
30
+ bestUsedFor?: string
31
+ lastChecked?: string
32
+ }
33
+
34
+ export interface DeckMemoryEntry {
35
+ slug: string
36
+ topic?: string
37
+ keyDecisions?: string[]
38
+ outputPath?: string
39
+ date?: string
40
+ }
41
+
42
+ export interface DeckSpec {
43
+ slug: string
44
+ status: DeckProductionStatus
45
+ goal: string
46
+ audience?: string
47
+ language?: string
48
+ slideCount?: number
49
+ outputPath: string
50
+ theme: {
51
+ design?: string
52
+ domain?: string
53
+ }
54
+ requiredInputs: RequiredInputs
55
+ researchPlan: ResearchAxis[]
56
+ slides: SlideSpec[]
57
+ assets: DeckAsset[]
58
+ writeReadiness: {
59
+ status: WriteReadinessStatus
60
+ blockers: string[]
61
+ lastReviewedAt?: string
62
+ }
63
+ }
64
+
65
+ export interface RequiredInputs {
66
+ topicClarified: boolean
67
+ audienceClarified: boolean
68
+ slideCountDecided: boolean
69
+ languageDecided: boolean
70
+ visualStyleSelected: boolean
71
+ sourceMaterialsIdentified: boolean
72
+ researchNeedAssessed: boolean
73
+ researchFindingsRead: boolean
74
+ slidePlanConfirmed: boolean
75
+ designLayoutsFetched: boolean
76
+ }
77
+
78
+ export interface ResearchAxis {
79
+ axis: string
80
+ needed: boolean
81
+ status: "pending" | "in_progress" | "done" | "read" | "skipped"
82
+ findingsFile?: string
83
+ notes?: string
84
+ }
85
+
86
+ export interface SlideSpec {
87
+ index: number
88
+ title: string
89
+ purpose?: string
90
+ layout: string
91
+ qa?: boolean
92
+ components: string[]
93
+ content: {
94
+ headline?: string
95
+ body?: string[]
96
+ bullets?: string[]
97
+ speakerNotes?: string
98
+ data?: unknown
99
+ }
100
+ evidence: EvidenceRef[]
101
+ visuals?: VisualBrief[]
102
+ status: SlideProductionStatus
103
+ notes?: string
104
+ }
105
+
106
+ export interface EvidenceRef {
107
+ source: string
108
+ quote?: string
109
+ page?: string
110
+ url?: string
111
+ }
112
+
113
+ export interface VisualBrief {
114
+ id?: string
115
+ purpose?: string
116
+ brief: string
117
+ assetPath?: string
118
+ }
119
+
120
+ export interface DeckAsset {
121
+ id: string
122
+ type: "image" | "chart" | "file"
123
+ path?: string
124
+ purpose?: string
125
+ notes?: string
126
+ }
127
+
128
+ export interface DeckStateReadinessResult {
129
+ ready: boolean
130
+ slug: string
131
+ status?: WriteReadinessStatus
132
+ blocker: string
133
+ blockers: string[]
134
+ }
135
+
136
+ export function decksStatePath(workspaceRoot: string): string {
137
+ return join(workspaceRoot, DECKS_STATE_FILE)
138
+ }
139
+
140
+ export function hasDecksState(workspaceRoot: string): boolean {
141
+ return existsSync(decksStatePath(workspaceRoot))
142
+ }
143
+
144
+ export function createEmptyDecksState(): DecksState {
145
+ return {
146
+ version: 1,
147
+ workspace: {
148
+ sourceMaterials: [],
149
+ preferences: { user: [], workflow: [] },
150
+ deckMemory: [],
151
+ openQuestions: [],
152
+ },
153
+ decks: {},
154
+ }
155
+ }
156
+
157
+ export function defaultRequiredInputs(overrides?: Partial<RequiredInputs>): RequiredInputs {
158
+ return {
159
+ topicClarified: false,
160
+ audienceClarified: false,
161
+ slideCountDecided: false,
162
+ languageDecided: false,
163
+ visualStyleSelected: false,
164
+ sourceMaterialsIdentified: false,
165
+ researchNeedAssessed: false,
166
+ researchFindingsRead: false,
167
+ slidePlanConfirmed: false,
168
+ designLayoutsFetched: false,
169
+ ...overrides,
170
+ }
171
+ }
172
+
173
+ export function createDeckSpec(input: Partial<DeckSpec> & { slug: string }): DeckSpec {
174
+ const slug = normalizeSlug(input.slug)
175
+ return {
176
+ slug,
177
+ status: input.status ?? "planning",
178
+ goal: input.goal ?? "",
179
+ audience: input.audience,
180
+ language: input.language,
181
+ slideCount: input.slideCount,
182
+ outputPath: normalizeDeckPath(input.outputPath || `decks/${slug}.html`),
183
+ theme: input.theme ?? {},
184
+ requiredInputs: defaultRequiredInputs(input.requiredInputs),
185
+ researchPlan: input.researchPlan ?? [],
186
+ slides: normalizeSlides(input.slides ?? []),
187
+ assets: input.assets ?? [],
188
+ writeReadiness: input.writeReadiness ?? { status: "blocked", blockers: [] },
189
+ }
190
+ }
191
+
192
+ export function readDecksState(workspaceRoot: string): DecksState {
193
+ const parsed = JSON.parse(readFileSync(decksStatePath(workspaceRoot), "utf-8")) as DecksState
194
+ return normalizeDecksState(parsed)
195
+ }
196
+
197
+ export function writeDecksState(workspaceRoot: string, state: DecksState): void {
198
+ const filePath = decksStatePath(workspaceRoot)
199
+ mkdirSync(dirname(filePath), { recursive: true })
200
+ writeFileSync(filePath, JSON.stringify(normalizeDecksState(state), null, 2) + "\n", "utf-8")
201
+ }
202
+
203
+ export function readOrCreateDecksState(workspaceRoot: string): DecksState {
204
+ if (hasDecksState(workspaceRoot)) return readDecksState(workspaceRoot)
205
+ const state = createEmptyDecksState()
206
+ writeDecksState(workspaceRoot, state)
207
+ return state
208
+ }
209
+
210
+ export function upsertDeck(state: DecksState, input: Partial<DeckSpec> & { slug: string }): DecksState {
211
+ const normalized = normalizeDecksState(state)
212
+ const slug = normalizeSlug(input.slug)
213
+ const existing = normalized.decks[slug]
214
+ const next = createDeckSpec({ ...existing, ...input, slug })
215
+ normalized.decks[slug] = next
216
+ normalized.activeDeck = slug
217
+ return normalized
218
+ }
219
+
220
+ export function upsertSlides(state: DecksState, slug: string, slides: SlideSpec[]): DecksState {
221
+ const normalized = normalizeDecksState(state)
222
+ const key = normalizeSlug(slug)
223
+ const deck = normalized.decks[key] ?? createDeckSpec({ slug: key })
224
+ const byIndex = new Map(deck.slides.map((slide) => [slide.index, slide]))
225
+ for (const slide of normalizeSlides(slides)) byIndex.set(slide.index, slide)
226
+ deck.slides = [...byIndex.values()].sort((a, b) => a.index - b.index)
227
+ normalized.decks[key] = deck
228
+ normalized.activeDeck = key
229
+ return normalized
230
+ }
231
+
232
+ export function reviewDeckState(state: DecksState, slug?: string): { state: DecksState; result: DeckStateReadinessResult } {
233
+ const normalized = normalizeDecksState(state)
234
+ const key = normalizeSlug(slug || normalized.activeDeck || "")
235
+ const deck = key ? normalized.decks[key] : undefined
236
+ if (!deck) {
237
+ const missing = key || "active deck"
238
+ return {
239
+ state: normalized,
240
+ result: {
241
+ ready: false,
242
+ slug: missing,
243
+ blocker: `Deck ${missing} does not exist in ${DECKS_STATE_FILE}.`,
244
+ blockers: [`Deck ${missing} does not exist in ${DECKS_STATE_FILE}.`],
245
+ },
246
+ }
247
+ }
248
+
249
+ const blockers = computeDeckBlockers(deck)
250
+ deck.writeReadiness = {
251
+ status: blockers.length === 0 ? "ready" : "blocked",
252
+ blockers,
253
+ lastReviewedAt: new Date().toISOString(),
254
+ }
255
+ deck.status = blockers.length === 0 ? "ready" : "blocked"
256
+ normalized.decks[deck.slug] = deck
257
+ normalized.activeDeck = deck.slug
258
+ return {
259
+ state: normalized,
260
+ result: {
261
+ ready: blockers.length === 0,
262
+ slug: deck.slug,
263
+ status: deck.writeReadiness.status,
264
+ blocker: blockers.join("; "),
265
+ blockers,
266
+ },
267
+ }
268
+ }
269
+
270
+ export function checkDeckStateWriteReadiness(workspaceRoot: string, filePath: string): DeckStateReadinessResult | undefined {
271
+ if (!hasDecksState(workspaceRoot)) return undefined
272
+ return evaluateDeckStateWriteReadiness(readDecksState(workspaceRoot), filePath)
273
+ }
274
+
275
+ export function evaluateDeckStateWriteReadiness(state: DecksState, filePath: string): DeckStateReadinessResult {
276
+ const targetPath = normalizeDeckPath(filePath)
277
+ const targetSlug = deckSlugFromPath(targetPath)
278
+ const normalized = normalizeDecksState(state)
279
+ const deck = findDeckForTarget(normalized, targetPath, targetSlug)
280
+ if (!deck) {
281
+ return {
282
+ ready: false,
283
+ slug: targetSlug,
284
+ blocker: `No deck in ${DECKS_STATE_FILE} matches ${targetPath}. Use revela-decks upsertDeck/upsertSlides, then review before writing.`,
285
+ blockers: [`No deck in ${DECKS_STATE_FILE} matches ${targetPath}.`],
286
+ }
287
+ }
288
+
289
+ const blockers = computeDeckBlockers(deck)
290
+ if (normalizeDeckPath(deck.outputPath) !== targetPath) {
291
+ blockers.unshift(`Deck outputPath is ${deck.outputPath || "missing"}, not ${targetPath}`)
292
+ }
293
+ if (deck.writeReadiness.status !== "ready") {
294
+ blockers.unshift(`Deck writeReadiness is ${deck.writeReadiness.status || "missing"}, not ready`)
295
+ }
296
+ if (deck.writeReadiness.blockers.length > 0) {
297
+ blockers.unshift(`Deck still has readiness blockers: ${deck.writeReadiness.blockers.join("; ")}`)
298
+ }
299
+
300
+ return {
301
+ ready: blockers.length === 0,
302
+ slug: deck.slug,
303
+ status: deck.writeReadiness.status,
304
+ blocker: blockers.join("; "),
305
+ blockers,
306
+ }
307
+ }
308
+
309
+ export function isDecksStatePath(filePath: string): boolean {
310
+ return normalizePath(filePath).split("/").pop() === DECKS_STATE_FILE
311
+ }
312
+
313
+ export function extractDecksStateTargetsFromPatch(patchText: string): string[] {
314
+ const targets = new Set<string>()
315
+ for (const line of patchText.replace(/\r\n/g, "\n").split("\n")) {
316
+ const match = /^\*\*\*\s+(?:Add File|Update File|Delete File|Move to):\s*(.+?)\s*$/.exec(line)
317
+ if (!match) continue
318
+ const target = cleanMarkdownText(match[1])
319
+ if (isDecksStatePath(target)) targets.add(target)
320
+ }
321
+ return [...targets]
322
+ }
323
+
324
+ export function buildDecksStatePromptLayer(workspaceRoot: string, maxChars = 14000): string {
325
+ if (!hasDecksState(workspaceRoot)) return ""
326
+ const state = readDecksState(workspaceRoot)
327
+ const active = state.activeDeck ? state.decks[state.activeDeck] : undefined
328
+ const compact = {
329
+ sourceOfTruth: DECKS_STATE_FILE,
330
+ activeDeck: state.activeDeck,
331
+ workspace: state.workspace,
332
+ deck: active,
333
+ }
334
+ let text = JSON.stringify(compact, null, 2)
335
+ if (text.length > maxChars) text = text.slice(0, maxChars).trimEnd() + "\n[DECKS.json state truncated for prompt size.]"
336
+ return `---\n\n# Revela Workspace State From ${DECKS_STATE_FILE}\n\n\`\`\`json\n${text}\n\`\`\`\n\nRules for this state layer:\n- Treat ${DECKS_STATE_FILE} as the source of truth for deck specs, slide plans, and write readiness.\n- Do not edit ${DECKS_STATE_FILE} directly; use the revela-decks tool.\n- Before writing decks/*.html, the matching deck must have writeReadiness.status=ready and a complete slide spec.`
337
+ }
338
+
339
+ function normalizeDecksState(input: DecksState): DecksState {
340
+ const state: DecksState = {
341
+ version: 1,
342
+ activeDeck: input.activeDeck ? normalizeSlug(input.activeDeck) : undefined,
343
+ workspace: {
344
+ brief: input.workspace?.brief,
345
+ sourceMaterials: input.workspace?.sourceMaterials ?? [],
346
+ preferences: {
347
+ user: input.workspace?.preferences?.user ?? [],
348
+ workflow: input.workspace?.preferences?.workflow ?? [],
349
+ },
350
+ deckMemory: input.workspace?.deckMemory ?? [],
351
+ openQuestions: input.workspace?.openQuestions ?? [],
352
+ },
353
+ decks: {},
354
+ }
355
+ for (const [slug, deck] of Object.entries(input.decks ?? {})) {
356
+ const normalizedSlug = normalizeSlug(deck.slug || slug)
357
+ state.decks[normalizedSlug] = createDeckSpec({ ...deck, slug: normalizedSlug })
358
+ }
359
+ if (state.activeDeck && !state.decks[state.activeDeck]) state.activeDeck = undefined
360
+ return state
361
+ }
362
+
363
+ function computeDeckBlockers(deck: DeckSpec): string[] {
364
+ const blockers: string[] = []
365
+ if (!deck.goal.trim()) blockers.push("Deck goal is missing")
366
+ if (!isDeckHtmlPath(deck.outputPath)) blockers.push(`outputPath must be decks/*.html, got ${deck.outputPath || "missing"}`)
367
+
368
+ for (const [key, value] of Object.entries(deck.requiredInputs) as Array<[keyof RequiredInputs, boolean]>) {
369
+ if (value !== true) blockers.push(`requiredInputs.${key} is not true`)
370
+ }
371
+
372
+ if (typeof deck.slideCount === "number" && deck.slideCount > 0 && deck.slides.length !== deck.slideCount) {
373
+ blockers.push(`slides length ${deck.slides.length} does not match slideCount ${deck.slideCount}`)
374
+ }
375
+ if (deck.slides.length === 0) blockers.push("slides are missing")
376
+ for (const slide of deck.slides) {
377
+ if (!slide.title.trim()) blockers.push(`Slide ${slide.index} title is missing`)
378
+ if (!slide.layout.trim()) blockers.push(`Slide ${slide.index} layout is missing`)
379
+ if (slide.components.length === 0) blockers.push(`Slide ${slide.index} components are missing`)
380
+ if (!hasSlideContent(slide)) blockers.push(`Slide ${slide.index} content is missing`)
381
+ }
382
+
383
+ for (const axis of deck.researchPlan) {
384
+ if (axis.needed && axis.status !== "done" && axis.status !== "read" && axis.status !== "skipped") {
385
+ blockers.push(`Research axis ${axis.axis || "unnamed"} is needed but ${axis.status}`)
386
+ }
387
+ }
388
+ return blockers
389
+ }
390
+
391
+ function normalizeSlides(slides: SlideSpec[]): SlideSpec[] {
392
+ return slides
393
+ .map((slide) => ({
394
+ ...slide,
395
+ title: slide.title ?? "",
396
+ layout: slide.layout ?? "",
397
+ components: slide.components ?? [],
398
+ content: slide.content ?? {},
399
+ evidence: slide.evidence ?? [],
400
+ status: slide.status ?? "planned",
401
+ }))
402
+ .sort((a, b) => a.index - b.index)
403
+ }
404
+
405
+ function findDeckForTarget(state: DecksState, targetPath: string, targetSlug: string): DeckSpec | undefined {
406
+ return Object.values(state.decks).find((deck) => normalizeDeckPath(deck.outputPath) === targetPath) ?? state.decks[normalizeSlug(targetSlug)]
407
+ }
408
+
409
+ function hasSlideContent(slide: SlideSpec): boolean {
410
+ const content = slide.content ?? {}
411
+ return Boolean(
412
+ content.headline?.trim() ||
413
+ (content.body && content.body.length > 0) ||
414
+ (content.bullets && content.bullets.length > 0) ||
415
+ content.data !== undefined,
416
+ )
417
+ }
418
+
419
+ export function isDeckHtmlPath(filePath: string): boolean {
420
+ return normalizePath(filePath).match(/(^|\/)decks\/[^/]+\.html$/) !== null
421
+ }
422
+
423
+ function deckSlugFromPath(filePath: string): string {
424
+ return normalizeSlug(basename(normalizePath(filePath), ".html"))
425
+ }
426
+
427
+ function normalizeDeckPath(filePath: string): string {
428
+ const normalized = normalizePath(cleanMarkdownText(filePath)).replace(/^\.\//, "")
429
+ const match = /(?:^|\/)(decks\/[^/]+\.html)$/.exec(normalized)
430
+ return match?.[1] ?? normalized
431
+ }
432
+
433
+ function normalizePath(filePath: string): string {
434
+ return filePath.replace(/\\/g, "/")
435
+ }
436
+
437
+ function normalizeSlug(value: string): string {
438
+ return cleanMarkdownText(value)
439
+ .toLowerCase()
440
+ .replace(/[^a-z0-9]+/g, "-")
441
+ .replace(/^-+|-+$/g, "")
442
+ }
443
+
444
+ function cleanMarkdownText(value: string): string {
445
+ let text = String(value ?? "").trim()
446
+ const link = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(text)
447
+ if (link) text = link[1] || link[2]
448
+ return text
449
+ .replace(/^`+|`+$/g, "")
450
+ .replace(/^\*+|\*+$/g, "")
451
+ .trim()
452
+ }
package/lib/pdf/export.ts CHANGED
@@ -57,6 +57,18 @@ const MIME_TO_EXT: Record<string, string> = {
57
57
  "image/avif": ".avif",
58
58
  }
59
59
 
60
+ const IMAGE_EXTS = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".avif", ".bmp"])
61
+ const EXT_TO_MIME: Record<string, string> = {
62
+ ".jpg": "image/jpeg",
63
+ ".jpeg": "image/jpeg",
64
+ ".png": "image/png",
65
+ ".gif": "image/gif",
66
+ ".webp": "image/webp",
67
+ ".svg": "image/svg+xml",
68
+ ".avif": "image/avif",
69
+ ".bmp": "image/bmp",
70
+ }
71
+
60
72
  // ── Helpers ──────────────────────────────────────────────────────────────────
61
73
 
62
74
  function findChromePath(): string {
@@ -149,6 +161,72 @@ async function localizeExternalImages(
149
161
  return patched
150
162
  }
151
163
 
164
+ function isLocalImageRef(ref: string): boolean {
165
+ const pathPart = ref.split(/[?#]/)[0]
166
+ return IMAGE_EXTS.has(extname(pathPart).toLowerCase())
167
+ }
168
+
169
+ export function extractImageAssetRefsForPdf(htmlContent: string): string[] {
170
+ const assetRefPattern = /\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))|url\(\s*(?:"([^"]*)"|'([^']*)'|([^\s)]+))\s*\)/g
171
+ const refs = new Set<string>()
172
+ let match: RegExpExecArray | null
173
+
174
+ while ((match = assetRefPattern.exec(htmlContent)) !== null) {
175
+ const ref = match.slice(1).find((value): value is string => value !== undefined)
176
+ if (ref) refs.add(ref.trim())
177
+ }
178
+
179
+ return Array.from(refs)
180
+ }
181
+
182
+ async function toDataUrlFromRef(ref: string, baseDir: string): Promise<string | null> {
183
+ if (!ref || ref.startsWith("data:") || ref.startsWith("blob:") || ref.startsWith("#")) {
184
+ return null
185
+ }
186
+
187
+ try {
188
+ if (ref.startsWith("http://") || ref.startsWith("https://") || ref.startsWith("//") || ref.startsWith("file://")) {
189
+ return null
190
+ }
191
+
192
+ let filePath: string | null = null
193
+ if (isLocalImageRef(ref)) {
194
+ filePath = resolve(baseDir, decodeURI(ref.split(/[?#]/)[0]))
195
+ }
196
+
197
+ if (!filePath || !existsSync(filePath)) return null
198
+ const ext = extname(filePath).toLowerCase()
199
+ const mime = EXT_TO_MIME[ext]
200
+ if (!mime) return null
201
+ const buf = readFileSync(filePath)
202
+ return `data:${mime};base64,${buf.toString("base64")}`
203
+ } catch {
204
+ return null
205
+ }
206
+ }
207
+
208
+ export async function inlineImageAssetsForPdf(htmlContent: string, htmlFilePath: string): Promise<string> {
209
+ const baseDir = dirname(resolve(htmlFilePath))
210
+ const refs = extractImageAssetRefsForPdf(htmlContent)
211
+
212
+ if (refs.length === 0) return htmlContent
213
+
214
+ const replacements = new Map<string, string>()
215
+ await Promise.allSettled(
216
+ refs.map(async (ref) => {
217
+ const dataUrl = await toDataUrlFromRef(ref, baseDir)
218
+ if (dataUrl) replacements.set(ref, dataUrl)
219
+ })
220
+ )
221
+
222
+ let patched = htmlContent
223
+ for (const [original, replacement] of replacements) {
224
+ const escaped = original.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
225
+ patched = patched.replace(new RegExp(escaped, "g"), replacement)
226
+ }
227
+ return patched
228
+ }
229
+
152
230
  // ── Main export ──────────────────────────────────────────────────────────────
153
231
 
154
232
  export interface ExportResult {
@@ -185,7 +263,8 @@ export async function exportToPdf(htmlFilePath: string): Promise<ExportResult> {
185
263
  let tmpHtmlPath: string
186
264
  try {
187
265
  const originalHtml = readFileSync(abs, "utf-8")
188
- const patchedHtml = await localizeExternalImages(originalHtml, tmpDir)
266
+ const localizedHtml = await localizeExternalImages(originalHtml, tmpDir)
267
+ const patchedHtml = await inlineImageAssetsForPdf(localizedHtml, abs)
189
268
  tmpHtmlPath = join(tmpDir, "index.html")
190
269
  writeFileSync(tmpHtmlPath, patchedHtml, "utf-8")
191
270
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyber-dash-tech/revela",
3
- "version": "0.4.6",
3
+ "version": "0.5.1",
4
4
  "description": "OpenCode plugin that turns AI into an HTML slide deck generator",
5
5
  "type": "module",
6
6
  "main": "./index.ts",