@cyber-dash-tech/revela 0.8.5 → 0.8.6

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.
@@ -42,6 +42,6 @@ export async function handleHelp(
42
42
  `\`/revela designs-rm <name>\` — remove an installed design\n` +
43
43
  `\`/revela domains-rm <name>\` — remove an installed domain\n` +
44
44
  `\`/revela pdf <file>\` — export HTML slide deck to PDF\n` +
45
- `\`/revela pptx <file>\` — export HTML slide deck to PPTX`
45
+ `\`/revela pptx [file] [--notes]\` — export HTML slide deck to PPTX`
46
46
  )
47
47
  }
@@ -1,42 +1,124 @@
1
1
  /**
2
2
  * lib/commands/pptx.ts
3
3
  *
4
- * Handler for `/revela pptx <file_path>` — exports an HTML slide deck to PPTX.
4
+ * Handler for `/revela pptx [file_path]` — exports an HTML slide deck to PPTX.
5
5
  *
6
6
  * Output: same directory and base name as the input, with .pptx extension.
7
7
  * Example: decks/my-deck.html → decks/my-deck.pptx
8
8
  */
9
9
 
10
- import { resolve } from "path"
10
+ import { existsSync, readdirSync } from "fs"
11
+ import { relative, resolve, sep } from "path"
12
+ import { hasDecksState, isDeckHtmlPath, readDecksState } from "../decks-state"
11
13
  import { exportToPptx } from "../pptx/export"
12
- import { assertExportQAPassed } from "../qa/export-gate"
14
+
15
+ export interface PptxArgs {
16
+ filePath: string
17
+ notes: boolean
18
+ }
19
+
20
+ export interface ResolvedPptxDeck {
21
+ file: string
22
+ absoluteFile: string
23
+ source: "decks-state" | "fallback" | "file-path"
24
+ }
13
25
 
14
26
  function formatSecs(ms: number): string {
15
27
  return `${(ms / 1000).toFixed(1)}s`
16
28
  }
17
29
 
18
- export async function handlePptx(
19
- filePath: string,
20
- send: (text: string) => Promise<void>,
21
- ): Promise<void> {
22
- if (!filePath) {
23
- await send(
24
- "**Usage:** `/revela pptx <file_path>`\n\n" +
25
- "Example: `/revela pptx decks/my-deck.html`"
26
- )
27
- return
30
+ export function parsePptxArgs(input: string): PptxArgs {
31
+ const parts = input.trim().split(/\s+/).filter(Boolean)
32
+ const notes = parts.includes("--notes")
33
+ const filePath = parts.filter((part) => part !== "--notes").join(" ").trim()
34
+ return { filePath, notes }
35
+ }
36
+
37
+ export function resolvePptxDeck(workspaceRoot: string, filePath = ""): ResolvedPptxDeck {
38
+ const root = resolve(workspaceRoot)
39
+ const explicit = filePath.trim()
40
+ if (explicit) {
41
+ const absoluteFile = resolve(root, explicit)
42
+ if (!existsSync(absoluteFile)) throw new Error(`Deck HTML not found: ${explicit}`)
43
+ if (!/\.html?$/i.test(absoluteFile)) throw new Error(`File must be an HTML file: ${explicit}`)
44
+ return { file: workspaceRelative(root, absoluteFile), absoluteFile, source: "file-path" }
45
+ }
46
+
47
+ if (hasDecksState(root)) {
48
+ const state = readDecksState(root)
49
+ const key = state.activeDeck || singleDeckKey(state.decks)
50
+ const outputPath = key ? state.decks[key]?.outputPath : undefined
51
+ if (outputPath && isDeckHtmlPath(outputPath)) {
52
+ const absoluteFile = resolve(root, outputPath)
53
+ if (existsSync(absoluteFile)) {
54
+ return { file: workspaceRelative(root, absoluteFile), absoluteFile, source: "decks-state" }
55
+ }
56
+ }
28
57
  }
29
58
 
30
- const abs = resolve(filePath)
31
- await send(`Running pre-export QA for \`${abs}\`...`)
59
+ const htmlFiles = listDeckHtmlFiles(root)
60
+ if (htmlFiles.length === 0) {
61
+ throw new Error("No deck HTML found in decks/. Generate a deck first or pass a file path.")
62
+ }
63
+ if (htmlFiles.length > 1) {
64
+ throw new Error("This workspace contains multiple deck HTML files. Run `/revela pptx decks/<file>.html` to choose one.")
65
+ }
66
+
67
+ const absoluteFile = resolve(root, htmlFiles[0])
68
+ return { file: workspaceRelative(root, absoluteFile), absoluteFile, source: "fallback" }
69
+ }
70
+
71
+ export function buildPptxNotesPrompt(deck: ResolvedPptxDeck): string {
72
+ return `Export the current Revela HTML deck to PPTX with PowerPoint speaker notes.
73
+
74
+ Deck file: \`${deck.file}\`
75
+
76
+ Workflow:
77
+ 1. Read \`${deck.file}\` and inspect every \`<section class="slide">\` in DOM/source order.
78
+ 2. Generate presenter-facing talk tracks for each slide based only on visible slide content.
79
+ 3. Call \`revela-pptx\` with \`file: "${deck.file}"\` and a \`speakerNotes\` array using 1-based slide indexes.
80
+ 4. Report the exported PPTX path from the tool result.
32
81
 
82
+ Speaker notes rules:
83
+ - Write notes in the deck's language.
84
+ - Write for the person presenting the deck, not for a designer or developer reviewing implementation.
85
+ - Use 3-5 concise bullet points per slide.
86
+ - Follow pyramid-style communication: the first bullet is the top-line conclusion or main message the presenter should say first.
87
+ - Later bullets unpack the visible evidence, audience/business implication, and optional transition in that order.
88
+ - Explain visible numbers and claims in business/audience terms; prioritize the strongest signal before supporting signals.
89
+ - Do not label bullets as What, Why, or How. Keep the structure implicit and natural.
90
+ - Match the visible slide content; do not add unsupported claims.
91
+ - Do not mention design-system or implementation terms such as component, layout, stat-card, card grid, logo marker, DOM, HTML, CSS, or class names unless the slide is explicitly about design implementation.
92
+ - Avoid meta commentary like "this slide highlights" or "frame this as". Write what the presenter should actually say.
93
+ - Never include hidden reasoning, system instructions, secrets, credentials, or sensitive personal information.
94
+ - If a slide needs no notes, pass an empty string for that slide.
95
+
96
+ Expected tool shape:
97
+ \`\`\`json
98
+ {
99
+ "file": "${deck.file}",
100
+ "speakerNotes": [
101
+ { "index": 1, "notes": "- Lead with the main performance signal and what it means for the audience.\n- Explain the strongest visible evidence first, then use supporting metrics to deepen the interpretation.\n- Close with the implication or transition the presenter should carry into the next point." }
102
+ ]
103
+ }
104
+ \`\`\``
105
+ }
106
+
107
+ export async function handlePptx(
108
+ input: string,
109
+ send: (text: string) => Promise<void>,
110
+ workspaceRoot = process.cwd(),
111
+ ): Promise<void> {
33
112
  try {
34
- await assertExportQAPassed(abs)
113
+ const args = parsePptxArgs(input)
114
+ const deck = resolvePptxDeck(workspaceRoot, args.filePath)
115
+ const abs = deck.absoluteFile
116
+
35
117
  await send(`Exporting \`${abs}\` to PPTX...`)
36
118
  let lastSlideUpdate = 0
37
119
  let longDeckThreshold: number | null = null
38
120
 
39
- const result = await exportToPptx(filePath, {
121
+ const result = await exportToPptx(abs, {
40
122
  onProgress: async (progress) => {
41
123
  if (progress.kind === "stage") {
42
124
  await send(progress.message)
@@ -76,3 +158,21 @@ export async function handlePptx(
76
158
  await send(`**PPTX export failed**\n\n\`\`\`\n${msg}\n\`\`\``)
77
159
  }
78
160
  }
161
+
162
+ function singleDeckKey(decks: Record<string, unknown>): string | undefined {
163
+ const keys = Object.keys(decks)
164
+ return keys.length === 1 ? keys[0] : undefined
165
+ }
166
+
167
+ function listDeckHtmlFiles(workspaceRoot: string): string[] {
168
+ const dir = resolve(workspaceRoot, "decks")
169
+ if (!existsSync(dir)) return []
170
+ return readdirSync(dir, { withFileTypes: true })
171
+ .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".html"))
172
+ .map((entry) => `decks/${entry.name}`)
173
+ .sort((a, b) => a.localeCompare(b))
174
+ }
175
+
176
+ function workspaceRelative(root: string, target: string): string {
177
+ return relative(root, target).split(sep).join("/")
178
+ }
@@ -65,6 +65,7 @@ interface SlideMeta {
65
65
  index: number
66
66
  pageNo: string | null
67
67
  title: string | null
68
+ speakerNotes: string | null
68
69
  }
69
70
 
70
71
  interface ExportedSlide extends SlideMeta {
@@ -100,6 +101,7 @@ export interface ExportPptxProgress {
100
101
 
101
102
  export interface ExportPptxOptions {
102
103
  onProgress?: (progress: ExportPptxProgress) => void | Promise<void>
104
+ speakerNotes?: Array<string | null | undefined>
103
105
  }
104
106
 
105
107
  export interface ExportPptxTimings {
@@ -474,12 +476,21 @@ async function readSlideMeta(
474
476
  .map((el) => el.textContent?.trim() ?? "")
475
477
  .find((text) => /^\d{2}$/.test(text)) ?? null
476
478
  const title = slide.querySelector("h1,h2,h3")?.textContent?.trim()?.slice(0, 120) ?? null
477
- return { index, pageNo, title }
479
+ const directChildren = Array.from(slide.children)
480
+ const notesEl =
481
+ directChildren.find((el) => el.matches("template[data-revela-speaker-notes]")) ??
482
+ directChildren.find((el) => el.hasAttribute("data-revela-speaker-notes")) ??
483
+ directChildren.find((el) => el.classList.contains("speaker-notes"))
484
+ const notesText = notesEl instanceof HTMLTemplateElement
485
+ ? notesEl.content.textContent
486
+ : notesEl?.textContent
487
+ const speakerNotes = notesText?.replace(/\r\n?/g, "\n").trim() || null
488
+ return { index, pageNo, title, speakerNotes }
478
489
  })
479
490
  })
480
491
 
481
492
  return Array.from({ length: slideCount }, (_, index) => {
482
- return meta[index] ?? { index, pageNo: null, title: null }
493
+ return meta[index] ?? { index, pageNo: null, title: null, speakerNotes: null }
483
494
  })
484
495
  }
485
496
 
@@ -553,6 +564,22 @@ async function exportSlidePptx(
553
564
  }
554
565
  }
555
566
 
567
+ function applySpeakerNotesOverride(
568
+ slides: SlideMeta[],
569
+ speakerNotes?: Array<string | null | undefined>,
570
+ ): SlideMeta[] {
571
+ if (!speakerNotes) return slides
572
+
573
+ return slides.map((slide) => {
574
+ if (speakerNotes[slide.index] === undefined) return slide
575
+ const notes = speakerNotes[slide.index]
576
+ return {
577
+ ...slide,
578
+ speakerNotes: notes?.replace(/\r\n?/g, "\n").trim() || null,
579
+ }
580
+ })
581
+ }
582
+
556
583
  function parseXml(xml: string) {
557
584
  return new DOMParser().parseFromString(xml, "text/xml")
558
585
  }
@@ -703,6 +730,31 @@ function setNotesSlideNumber(files: ZipFiles, notesPath: string, number: number)
703
730
  files[notesPath] = xmlToBytes(doc)
704
731
  }
705
732
 
733
+ function setSpeakerNotes(files: ZipFiles, notesPath: string, notes: string | null): void {
734
+ if (!files[notesPath]) return
735
+
736
+ const doc = parseXml(getFileText(files, notesPath))
737
+ const shapes = Array.from(doc.getElementsByTagName("p:sp"))
738
+ const notesShape = shapes.find((shape) => {
739
+ return Array.from(shape.getElementsByTagName("p:ph")).some((ph) => ph.getAttribute("type") === "body")
740
+ })
741
+ const textNode = notesShape?.getElementsByTagName("a:t")[0]
742
+ if (!textNode) return
743
+
744
+ textNode.textContent = notes ?? ""
745
+ files[notesPath] = xmlToBytes(doc)
746
+ }
747
+
748
+ export function applySpeakerNotesToPptx(pptxBytes: Uint8Array, notesBySlide: Array<string | null | undefined>): Uint8Array {
749
+ const files = unzipSync(pptxBytes)
750
+
751
+ notesBySlide.forEach((notes, index) => {
752
+ setSpeakerNotes(files, `ppt/notesSlides/notesSlide${index + 1}.xml`, notes ?? null)
753
+ })
754
+
755
+ return zipSync(files)
756
+ }
757
+
706
758
  function updateAppProperties(files: ZipFiles, slideCount: number): void {
707
759
  const doc = parseXml(getFileText(files, "docProps/app.xml"))
708
760
  const setText = (tag: string, value: string) => {
@@ -786,6 +838,7 @@ function mergeSingleSlidePptx(slides: ExportedSlide[]): Uint8Array {
786
838
 
787
839
  setSlideName(mergedFiles, "ppt/slides/slide1.xml", "Slide 1")
788
840
  setNotesSlideNumber(mergedFiles, "ppt/notesSlides/notesSlide1.xml", 1)
841
+ setSpeakerNotes(mergedFiles, "ppt/notesSlides/notesSlide1.xml", slides[0].speakerNotes)
789
842
 
790
843
  for (let slideIdx = 1; slideIdx < slides.length; slideIdx += 1) {
791
844
  const sourceFiles: ZipFiles = { ...unzipSync(slides[slideIdx].bytes) }
@@ -847,6 +900,7 @@ function mergeSingleSlidePptx(slides: ExportedSlide[]): Uint8Array {
847
900
  setSlideName(mergedFiles, slidePath, `Slide ${slideIdx + 1}`)
848
901
  if (notesPath && mergedFiles[notesPath]) {
849
902
  setNotesSlideNumber(mergedFiles, notesPath, slideIdx + 1)
903
+ setSpeakerNotes(mergedFiles, notesPath, slides[slideIdx].speakerNotes)
850
904
  }
851
905
 
852
906
  const relId = `rId${nextPresentationRelId}`
@@ -940,7 +994,7 @@ export async function exportToPptx(
940
994
  const failures: SlideFailure[] = []
941
995
 
942
996
  try {
943
- const slides = await readSlideMeta(page, slideCount)
997
+ const slides = applySpeakerNotesOverride(await readSlideMeta(page, slideCount), options?.speakerNotes)
944
998
  await emitProgress(options, {
945
999
  kind: "stage",
946
1000
  message: `Deck ready. Exporting ${slides.length} slide(s) to editable PPTX parts...`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyber-dash-tech/revela",
3
- "version": "0.8.5",
3
+ "version": "0.8.6",
4
4
  "description": "OpenCode plugin that turns AI into an HTML slide deck generator",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
package/plugin.ts CHANGED
@@ -44,7 +44,7 @@ import {
44
44
  handleDomainsRemove,
45
45
  } from "./lib/commands/domains"
46
46
  import { handlePdf } from "./lib/commands/pdf"
47
- import { handlePptx } from "./lib/commands/pptx"
47
+ import { buildPptxNotesPrompt, handlePptx, parsePptxArgs, resolvePptxDeck } from "./lib/commands/pptx"
48
48
  import { handleEdit } from "./lib/commands/edit"
49
49
  import { ensureEditableDeckOpenForChange } from "./lib/edit/open"
50
50
  import { hasLiveEditorSessionForFile } from "./lib/edit/server"
@@ -388,7 +388,20 @@ const server: Plugin = (async (pluginCtx) => {
388
388
  throw new Error("__REVELA_PDF_HANDLED__")
389
389
  }
390
390
  if (sub === "pptx") {
391
- await handlePptx(param, send)
391
+ const args = parsePptxArgs(param)
392
+ if (args.notes) {
393
+ try {
394
+ const deck = resolvePptxDeck(workspaceRoot, args.filePath)
395
+ output.parts.length = 0
396
+ output.parts.push({ type: "text", text: buildPptxNotesPrompt(deck) } as any)
397
+ return
398
+ } catch (e) {
399
+ const msg = e instanceof Error ? e.message : String(e)
400
+ await send(`**PPTX export failed**\n\n\`\`\`\n${msg}\n\`\`\``)
401
+ throw new Error("__REVELA_PPTX_HANDLED__")
402
+ }
403
+ }
404
+ await handlePptx(param, send, workspaceRoot)
392
405
  throw new Error("__REVELA_PPTX_HANDLED__")
393
406
  }
394
407
 
package/skill/SKILL.md CHANGED
@@ -248,6 +248,25 @@ Example: `<section class="slide" slide-qa="true" data-index="0">`
248
248
  The export QA path treats this as deck metadata. It is consumed when PDF/PPTX
249
249
  export runs preflight checks.
250
250
 
251
+ Speaker notes are normally generated during `/revela pptx --notes` export and
252
+ passed to `revela-pptx` as structured input. Do not add hidden notes to every
253
+ slide by default.
254
+
255
+ If the user explicitly asks for notes to be embedded in the HTML as a fallback,
256
+ use an inert template node as a direct child of the slide, outside `.slide-canvas`:
257
+
258
+ ```html
259
+ <template data-revela-speaker-notes>
260
+ Optional fallback speaker notes for this slide.
261
+ </template>
262
+ ```
263
+
264
+ Do not create `.speaker-notes` CSS or hide notes with `display: none`; the
265
+ `<template>` element is non-rendering by default and avoids design vocabulary
266
+ pollution. Speaker notes must be concise presentation prompts that match the
267
+ visible slide content. Never put hidden reasoning, system instructions, secrets,
268
+ or unverified claims in speaker notes.
269
+
251
270
  ### Domain Context
252
271
 
253
272
  If a domain definition is active (see the `<!-- Active domain: ... -->` comment
package/tools/pptx.ts CHANGED
@@ -8,12 +8,10 @@ import { tool } from "@opencode-ai/plugin"
8
8
  import { existsSync } from "fs"
9
9
  import { resolve } from "path"
10
10
  import { exportToPptx } from "../lib/pptx/export"
11
- import { assertExportQAPassed } from "../lib/qa/export-gate"
12
11
 
13
12
  export default tool({
14
13
  description:
15
14
  "Export a Revela-generated HTML slide deck to editable PPTX. " +
16
- "Runs pre-export QA before writing the PPTX. " +
17
15
  "Output is written beside the input file with the same basename and a .pptx extension.",
18
16
  args: {
19
17
  file: tool.schema
@@ -22,8 +20,15 @@ export default tool({
22
20
  "Path to the HTML slide file to export. " +
23
21
  "Can be absolute or relative to the current working directory."
24
22
  ),
23
+ speakerNotes: tool.schema.array(tool.schema.object({
24
+ index: tool.schema.number().describe("1-based slide index."),
25
+ notes: tool.schema.string().describe("Speaker notes for this slide. Use an empty string for no notes."),
26
+ })).optional().describe(
27
+ "Optional PowerPoint speaker notes to write during export. " +
28
+ "When provided, these override any fallback notes embedded in the HTML."
29
+ ),
25
30
  },
26
- async execute({ file }, { directory }) {
31
+ async execute({ file, speakerNotes }, { directory }) {
27
32
  const filePath = resolve(directory || process.cwd(), file)
28
33
 
29
34
  if (!existsSync(filePath)) {
@@ -37,8 +42,8 @@ export default tool({
37
42
  const progress: string[] = []
38
43
 
39
44
  try {
40
- await assertExportQAPassed(filePath)
41
45
  const result = await exportToPptx(filePath, {
46
+ speakerNotes: normalizeSpeakerNotes(speakerNotes),
42
47
  onProgress: (event) => {
43
48
  progress.push(event.message)
44
49
  },
@@ -49,3 +54,17 @@ export default tool({
49
54
  }
50
55
  },
51
56
  })
57
+
58
+ function normalizeSpeakerNotes(
59
+ input?: Array<{ index?: number; notes?: string }>,
60
+ ): Array<string | null | undefined> | undefined {
61
+ if (!input) return undefined
62
+
63
+ const notesBySlide: Array<string | null | undefined> = []
64
+ for (const item of input) {
65
+ const index = Math.floor(Number(item.index ?? 0))
66
+ if (index < 1) continue
67
+ notesBySlide[index - 1] = item.notes ?? ""
68
+ }
69
+ return notesBySlide
70
+ }