docrev 0.11.1 → 0.11.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.
Files changed (144) hide show
  1. package/.gitattributes +1 -1
  2. package/CHANGELOG.md +207 -197
  3. package/PLAN-tables-and-postprocess.md +850 -850
  4. package/README.md +433 -433
  5. package/bin/rev.js +11 -11
  6. package/bin/rev.ts +145 -145
  7. package/completions/rev.bash +127 -127
  8. package/completions/rev.ps1 +210 -210
  9. package/completions/rev.zsh +207 -207
  10. package/dist/lib/annotations.d.ts.map +1 -1
  11. package/dist/lib/annotations.js +28 -0
  12. package/dist/lib/annotations.js.map +1 -1
  13. package/dist/lib/build.d.ts +28 -0
  14. package/dist/lib/build.d.ts.map +1 -1
  15. package/dist/lib/build.js +81 -4
  16. package/dist/lib/build.js.map +1 -1
  17. package/dist/lib/commands/comments.d.ts.map +1 -1
  18. package/dist/lib/commands/comments.js +58 -28
  19. package/dist/lib/commands/comments.js.map +1 -1
  20. package/dist/lib/commands/context.d.ts +1 -0
  21. package/dist/lib/commands/context.d.ts.map +1 -1
  22. package/dist/lib/commands/context.js +1 -0
  23. package/dist/lib/commands/context.js.map +1 -1
  24. package/dist/lib/commands/core.d.ts.map +1 -1
  25. package/dist/lib/commands/core.js +25 -8
  26. package/dist/lib/commands/core.js.map +1 -1
  27. package/dist/lib/commands/response.js +1 -1
  28. package/dist/lib/commands/response.js.map +1 -1
  29. package/dist/lib/commands/utilities.js +164 -164
  30. package/dist/lib/commands/word-tools.js +8 -8
  31. package/dist/lib/grammar.js +3 -3
  32. package/dist/lib/import.d.ts +14 -0
  33. package/dist/lib/import.d.ts.map +1 -1
  34. package/dist/lib/import.js +23 -0
  35. package/dist/lib/import.js.map +1 -1
  36. package/dist/lib/input.d.ts +67 -0
  37. package/dist/lib/input.d.ts.map +1 -0
  38. package/dist/lib/input.js +164 -0
  39. package/dist/lib/input.js.map +1 -0
  40. package/dist/lib/macro-filter.lua +201 -201
  41. package/dist/lib/pdf-comments.js +44 -44
  42. package/dist/lib/plugins.js +57 -57
  43. package/dist/lib/pptx-color-filter.lua +37 -37
  44. package/dist/lib/pptx-themes.js +115 -115
  45. package/dist/lib/response.d.ts +1 -1
  46. package/dist/lib/response.d.ts.map +1 -1
  47. package/dist/lib/response.js +5 -2
  48. package/dist/lib/response.js.map +1 -1
  49. package/dist/lib/schema.d.ts.map +1 -1
  50. package/dist/lib/schema.js +8 -2
  51. package/dist/lib/schema.js.map +1 -1
  52. package/dist/lib/spelling.js +2 -2
  53. package/dist/lib/templates.js +387 -387
  54. package/dist/lib/themes.js +51 -51
  55. package/eslint.config.js +27 -27
  56. package/lib/anchor-match.ts +307 -307
  57. package/lib/annotations.ts +695 -664
  58. package/lib/build.ts +2047 -1956
  59. package/lib/citations.ts +160 -160
  60. package/lib/commands/build.ts +885 -885
  61. package/lib/commands/citations.ts +515 -515
  62. package/lib/commands/comments.ts +1077 -1050
  63. package/lib/commands/context.ts +185 -176
  64. package/lib/commands/core.ts +328 -309
  65. package/lib/commands/doi.ts +451 -451
  66. package/lib/commands/file-ops.ts +372 -372
  67. package/lib/commands/history.ts +320 -320
  68. package/lib/commands/index.ts +87 -87
  69. package/lib/commands/init.ts +259 -259
  70. package/lib/commands/merge-resolve.ts +378 -378
  71. package/lib/commands/preview.ts +178 -178
  72. package/lib/commands/project-info.ts +244 -244
  73. package/lib/commands/quality.ts +517 -517
  74. package/lib/commands/response.ts +454 -454
  75. package/lib/commands/section-boundaries.ts +82 -82
  76. package/lib/commands/sections.ts +451 -451
  77. package/lib/commands/sync.ts +689 -689
  78. package/lib/commands/text-ops.ts +449 -449
  79. package/lib/commands/utilities.ts +448 -448
  80. package/lib/commands/verify-anchors.ts +272 -272
  81. package/lib/commands/word-tools.ts +340 -340
  82. package/lib/comment-realign.ts +99 -99
  83. package/lib/config.ts +84 -84
  84. package/lib/crossref.ts +781 -781
  85. package/lib/csl.ts +191 -191
  86. package/lib/dependencies.ts +132 -132
  87. package/lib/diff-engine.ts +465 -465
  88. package/lib/doi-cache.ts +115 -115
  89. package/lib/doi.ts +879 -879
  90. package/lib/equations.ts +506 -506
  91. package/lib/errors.ts +350 -350
  92. package/lib/format.ts +541 -541
  93. package/lib/git.ts +326 -326
  94. package/lib/grammar.ts +303 -303
  95. package/lib/image-registry.ts +180 -180
  96. package/lib/import.ts +932 -906
  97. package/lib/input.ts +174 -0
  98. package/lib/journals.ts +543 -543
  99. package/lib/macro-filter.lua +201 -201
  100. package/lib/macros.ts +273 -273
  101. package/lib/merge.ts +633 -633
  102. package/lib/ooxml.ts +768 -768
  103. package/lib/orcid.ts +144 -144
  104. package/lib/pdf-comments.ts +263 -263
  105. package/lib/pdf-import.ts +524 -524
  106. package/lib/plugins.ts +362 -362
  107. package/lib/postprocess.ts +188 -188
  108. package/lib/pptx-color-filter.lua +37 -37
  109. package/lib/pptx-template.ts +469 -469
  110. package/lib/pptx-themes.ts +483 -483
  111. package/lib/protect-restore.ts +520 -520
  112. package/lib/rate-limiter.ts +127 -127
  113. package/lib/response.ts +200 -197
  114. package/lib/restore-references.ts +240 -240
  115. package/lib/review.ts +327 -327
  116. package/lib/schema.ts +494 -488
  117. package/lib/scientific-words.ts +73 -73
  118. package/lib/sections.ts +428 -428
  119. package/lib/slides.ts +756 -756
  120. package/lib/spelling.ts +334 -334
  121. package/lib/templates.ts +526 -526
  122. package/lib/themes.ts +742 -742
  123. package/lib/trackchanges.ts +166 -166
  124. package/lib/tui.ts +450 -450
  125. package/lib/types.ts +546 -546
  126. package/lib/undo.ts +250 -250
  127. package/lib/utils.ts +69 -69
  128. package/lib/variables.ts +179 -179
  129. package/lib/word-extraction.ts +525 -525
  130. package/lib/word.ts +526 -526
  131. package/lib/wordcomments.ts +789 -789
  132. package/package.json +1 -1
  133. package/scripts/postbuild.js +47 -47
  134. package/skill/REFERENCE.md +550 -550
  135. package/skill/SKILL.md +302 -302
  136. package/tsconfig.json +26 -26
  137. package/types/index.d.ts +531 -531
  138. package/issues.md +0 -180
  139. package/site/assets/extra.css +0 -208
  140. package/site/commands.html +0 -926
  141. package/site/configuration.html +0 -469
  142. package/site/index.html +0 -288
  143. package/site/troubleshooting.html +0 -461
  144. package/site/workflow.html +0 -518
package/lib/slides.ts CHANGED
@@ -1,756 +1,756 @@
1
- /**
2
- * Slide processing for Beamer and PPTX output
3
- *
4
- * Handles:
5
- * - ::: step blocks for incremental reveals
6
- * - ::: buildup blocks for progressive bullet reveals with greying
7
- * - ::: notes blocks for speaker notes
8
- * - Slide boundaries (---)
9
- * - Slide styles: {.dark}, {.light}, {.accent}, {.inverse}
10
- * - Special slides: {.cover}, {.thanks}, {.section}, {.plain}
11
- *
12
- * Syntax examples:
13
- * ## Title {.dark} - Dark background slide
14
- * ## Welcome {.cover} - Cover slide (no numbering, centered)
15
- * ## Thank You {.thanks} - Thanks slide (no numbering)
16
- * # Part 1 {.section} - Section divider slide
17
- * ## Image {.plain} - No header/footer, full content
18
- * ## Highlight {.accent .nonumber} - Accent color, no slide number
19
- *
20
- * Buildup syntax:
21
- * ::: buildup
22
- * - First point
23
- * - Sub A
24
- * - Sub B
25
- * - Second point
26
- * :::
27
- *
28
- * Generates slides where current point is colored, previous are greyed out.
29
- * Subpoints appear sequentially within their parent.
30
- */
31
-
32
- interface Step {
33
- index: number;
34
- content: string;
35
- }
36
-
37
- interface SlideStyle {
38
- background: string | null;
39
- type: string | null;
40
- nonumber: boolean;
41
- center: boolean;
42
- classes: string[];
43
- }
44
-
45
- interface Slide {
46
- title: string;
47
- titleLevel: number;
48
- steps: Step[];
49
- notes: string | null;
50
- preamble: string;
51
- style: SlideStyle;
52
- _frontmatter?: string;
53
- }
54
-
55
- interface BulletItem {
56
- text: string;
57
- indent: number;
58
- children: BulletItem[];
59
- }
60
-
61
- interface BuildupStep {
62
- itemIndex: number;
63
- subIndex: number | null;
64
- }
65
-
66
- interface BuildupResult {
67
- content: string;
68
- afterContent?: string;
69
- buildupSteps: string[] | null;
70
- }
71
-
72
- interface StepWithBuildup extends Step {
73
- beforeBuildup?: string;
74
- afterBuildup?: string;
75
- buildupSteps?: string[] | null;
76
- }
77
-
78
- /**
79
- * Grey color for "completed" buildup items
80
- */
81
- const GREY_COLOR = '#888888';
82
-
83
- /**
84
- * Background styles mapped to Beamer options
85
- */
86
- const BEAMER_BACKGROUNDS: Record<string, string> = {
87
- dark: '\\setbeamercolor{background canvas}{bg=black}\\setbeamercolor{normal text}{fg=white}\\usebeamercolor[fg]{normal text}',
88
- light: '\\setbeamercolor{background canvas}{bg=white}\\setbeamercolor{normal text}{fg=black}\\usebeamercolor[fg]{normal text}',
89
- accent: '\\setbeamercolor{background canvas}{bg=structure.fg}\\setbeamercolor{normal text}{fg=white}\\usebeamercolor[fg]{normal text}',
90
- inverse: '\\setbeamercolor{background canvas}{bg=structure.fg!90!black}\\setbeamercolor{normal text}{fg=white}\\usebeamercolor[fg]{normal text}',
91
- };
92
-
93
- /**
94
- * Parse a bullet list into a tree structure
95
- */
96
- function parseBulletList(content: string): BulletItem[] {
97
- const lines = content.split('\n');
98
- const items: BulletItem[] = [];
99
- const stack: Array<BulletItem & { children: BulletItem[] }> = [{ children: items, indent: -1 } as any];
100
-
101
- for (const line of lines) {
102
- // Match bullet lines: "- text" or " - text" etc.
103
- const match = line.match(/^(\s*)[-*]\s+(.+)$/);
104
- if (!match) continue;
105
-
106
- const indent = (match[1] || '').length;
107
- const text = (match[2] || '').trim();
108
-
109
- const item: BulletItem = { text, indent, children: [] };
110
-
111
- // Find parent based on indentation
112
- while (stack.length > 1) {
113
- const top = stack[stack.length - 1];
114
- if (!top || top.indent < indent) break;
115
- stack.pop();
116
- }
117
-
118
- const parent = stack[stack.length - 1];
119
- if (parent) {
120
- parent.children.push(item);
121
- }
122
- stack.push(item as any);
123
- }
124
-
125
- return items;
126
- }
127
-
128
- /**
129
- * Flatten bullet tree into sequential reveal steps
130
- * Each step is: { itemIndex, subIndex, isSubItem }
131
- */
132
- function flattenBuildupSteps(items: BulletItem[]): BuildupStep[] {
133
- const steps: BuildupStep[] = [];
134
-
135
- for (let i = 0; i < items.length; i++) {
136
- const item = items[i];
137
- if (!item) continue;
138
-
139
- if (item.children.length === 0) {
140
- // No children - single step for this item
141
- steps.push({ itemIndex: i, subIndex: null });
142
- } else {
143
- // Has children - first show parent, then each child
144
- steps.push({ itemIndex: i, subIndex: -1 }); // Parent only
145
-
146
- for (let j = 0; j < item.children.length; j++) {
147
- steps.push({ itemIndex: i, subIndex: j });
148
- }
149
- }
150
- }
151
-
152
- return steps;
153
- }
154
-
155
- /**
156
- * Render a bullet item with optional color
157
- */
158
- function renderBulletItem(
159
- item: BulletItem,
160
- color: string | null,
161
- indentLevel: number = 0,
162
- showChildrenUpTo: number | null = null
163
- ): string {
164
- const indent = ' '.repeat(indentLevel);
165
- const lines: string[] = [];
166
-
167
- // Render the main item
168
- const text = color ? `[${item.text}]{color=${color}}` : item.text;
169
- lines.push(`${indent}- ${text}`);
170
-
171
- // Render children if any should be shown
172
- if (showChildrenUpTo !== null && showChildrenUpTo >= 0 && item.children) {
173
- for (let i = 0; i <= showChildrenUpTo && i < item.children.length; i++) {
174
- const child = item.children[i];
175
- if (!child) continue;
176
- const childColor = color; // Children inherit parent's color state
177
- const childText = childColor ? `[${child.text}]{color=${childColor}}` : child.text;
178
- lines.push(`${indent} - ${childText}`);
179
- }
180
- }
181
-
182
- return lines.join('\n');
183
- }
184
-
185
- /**
186
- * Generate buildup slides from a bullet list
187
- */
188
- function expandBuildup(content: string, format: string): string[] {
189
- const items = parseBulletList(content);
190
- if (items.length === 0) return [content];
191
-
192
- const steps = flattenBuildupSteps(items);
193
- const slideContents: string[] = [];
194
-
195
- for (const step of steps) {
196
- const lines: string[] = [];
197
-
198
- for (let i = 0; i < items.length; i++) {
199
- const item = items[i];
200
- if (!item) continue;
201
-
202
- if (i < step.itemIndex) {
203
- // Previous top-level item - greyed out with all children
204
- const allChildrenIndex = item.children.length > 0 ? item.children.length - 1 : null;
205
- lines.push(renderBulletItem(item, GREY_COLOR, 0, allChildrenIndex));
206
- } else if (i === step.itemIndex) {
207
- // Current top-level item - colored
208
- if (step.subIndex === null) {
209
- // No children case - just show item
210
- lines.push(renderBulletItem(item, null, 0, null));
211
- } else if (step.subIndex === -1) {
212
- // Has children but showing parent only first
213
- lines.push(renderBulletItem(item, null, 0, null));
214
- } else {
215
- // Showing parent + children up to subIndex
216
- lines.push(renderBulletItem(item, null, 0, step.subIndex));
217
- }
218
- }
219
- // Future items (i > step.itemIndex) - not shown yet
220
- }
221
-
222
- slideContents.push(lines.join('\n'));
223
- }
224
-
225
- return slideContents;
226
- }
227
-
228
- /**
229
- * Process ::: buildup blocks in content and expand to steps
230
- */
231
- function processBuildupBlocks(content: string, format: string): BuildupResult {
232
- const buildupMatch = content.match(/^:::\s*buildup\s*\n([\s\S]*?)\n:::\s*$/m);
233
-
234
- if (!buildupMatch || buildupMatch.index === undefined) {
235
- return { content, buildupSteps: null };
236
- }
237
-
238
- const buildupContent = buildupMatch[1] || '';
239
- const buildupSteps = expandBuildup(buildupContent, format);
240
-
241
- // Remove the buildup block from content (will be replaced by steps)
242
- const beforeBuildup = content.slice(0, buildupMatch.index).trim();
243
- const afterBuildup = content.slice(buildupMatch.index + buildupMatch[0].length).trim();
244
-
245
- return {
246
- content: beforeBuildup,
247
- afterContent: afterBuildup,
248
- buildupSteps,
249
- };
250
- }
251
-
252
- /**
253
- * Parse slide style attributes from heading
254
- */
255
- function parseSlideStyle(heading: string): { title: string; style: SlideStyle } {
256
- const style: SlideStyle = {
257
- background: null,
258
- type: null,
259
- nonumber: false,
260
- center: false,
261
- classes: [],
262
- };
263
-
264
- // Match {.class1 .class2} at end of heading
265
- const attrMatch = heading.match(/\s*\{([^}]+)\}\s*$/);
266
- if (!attrMatch || attrMatch.index === undefined) {
267
- return { title: heading.trim(), style };
268
- }
269
-
270
- const title = heading.slice(0, attrMatch.index).trim();
271
- const attrs = attrMatch[1] || '';
272
-
273
- // Parse each .class
274
- const classMatches = attrs.matchAll(/\.(\w+)/g);
275
- for (const match of classMatches) {
276
- if (!match[1]) continue;
277
- const cls = match[1].toLowerCase();
278
- style.classes.push(cls);
279
-
280
- // Background styles
281
- if (['dark', 'light', 'accent', 'inverse'].includes(cls)) {
282
- style.background = cls;
283
- }
284
- // Special slide types
285
- else if (['cover', 'thanks', 'section', 'plain'].includes(cls)) {
286
- style.type = cls;
287
- // Cover, thanks, section slides default to no numbering
288
- if (['cover', 'thanks', 'section'].includes(cls)) {
289
- style.nonumber = true;
290
- style.center = true;
291
- }
292
- }
293
- // Explicit options
294
- else if (cls === 'nonumber' || cls === 'unnumbered') {
295
- style.nonumber = true;
296
- } else if (cls === 'center' || cls === 'centered') {
297
- style.center = true;
298
- }
299
- }
300
-
301
- return { title, style };
302
- }
303
-
304
- /**
305
- * Parse a single slide's content into steps and notes
306
- */
307
- export function parseSlide(slideContent: string): Slide {
308
- const lines = slideContent.split('\n');
309
-
310
- // Extract title (first heading)
311
- let title = '';
312
- let titleLevel = 2;
313
- let titleLineIndex = -1;
314
- let style: SlideStyle = {
315
- background: null,
316
- type: null,
317
- nonumber: false,
318
- center: false,
319
- classes: [],
320
- };
321
-
322
- for (let i = 0; i < lines.length; i++) {
323
- const line = lines[i];
324
- if (!line) continue;
325
- const match = line.match(/^(#{1,6})\s+(.+)$/);
326
- if (match && match[1] && match[2]) {
327
- titleLevel = match[1].length;
328
- // Parse style from heading
329
- const parsed = parseSlideStyle(match[2]);
330
- title = parsed.title;
331
- style = parsed.style;
332
- titleLineIndex = i;
333
- break;
334
- }
335
- }
336
-
337
- // Content after title
338
- const contentStart = titleLineIndex >= 0 ? titleLineIndex + 1 : 0;
339
- const bodyContent = lines.slice(contentStart).join('\n');
340
-
341
- // Parse ::: step and ::: notes blocks
342
- const steps: Step[] = [];
343
- let notes: string | null = null;
344
- let preamble = '';
345
-
346
- // Regex to match fenced div blocks
347
- const bodyLines = bodyContent.split('\n');
348
- let inBlock = false;
349
- let blockType: string | null = null;
350
- let currentBlockContent: string[] = [];
351
- let beforeFirstStep: string[] = [];
352
- let foundFirstStep = false;
353
-
354
- for (let i = 0; i < bodyLines.length; i++) {
355
- const line = bodyLines[i];
356
- if (!line && line !== '') continue;
357
-
358
- if (!inBlock) {
359
- // Check for block start
360
- const stepMatch = line.match(/^:::\s*step\s*$/);
361
- const notesMatch = line.match(/^:::\s*notes\s*$/);
362
-
363
- if (stepMatch) {
364
- inBlock = true;
365
- blockType = 'step';
366
- currentBlockContent = [];
367
- foundFirstStep = true;
368
- } else if (notesMatch) {
369
- inBlock = true;
370
- blockType = 'notes';
371
- currentBlockContent = [];
372
- } else if (!foundFirstStep) {
373
- beforeFirstStep.push(line);
374
- }
375
- } else {
376
- // Check for block end
377
- if (line.match(/^:::\s*$/)) {
378
- // End of block
379
- if (blockType === 'step') {
380
- steps.push({
381
- index: steps.length + 1,
382
- content: currentBlockContent.join('\n').trim(),
383
- });
384
- } else if (blockType === 'notes') {
385
- notes = currentBlockContent.join('\n').trim();
386
- }
387
- inBlock = false;
388
- blockType = null;
389
- currentBlockContent = [];
390
- } else {
391
- currentBlockContent.push(line);
392
- }
393
- }
394
- }
395
-
396
- // Handle content before first step as preamble
397
- preamble = beforeFirstStep.join('\n').trim();
398
-
399
- // If no explicit steps, treat entire body as single step
400
- if (steps.length === 0) {
401
- // Remove notes from body if present
402
- let bodyWithoutNotes = bodyContent;
403
- const notesBlockMatch = bodyContent.match(/^:::\s*notes\s*$[\s\S]*?^:::\s*$/m);
404
- if (notesBlockMatch) {
405
- bodyWithoutNotes = bodyContent.replace(notesBlockMatch[0], '').trim();
406
- }
407
-
408
- steps.push({
409
- index: 1,
410
- content: bodyWithoutNotes.trim(),
411
- });
412
- preamble = '';
413
- }
414
-
415
- return {
416
- title,
417
- titleLevel,
418
- steps,
419
- notes,
420
- preamble,
421
- style,
422
- };
423
- }
424
-
425
- /**
426
- * Parse markdown document into slides
427
- */
428
- export function parseSlides(markdown: string): Slide[] {
429
- // Normalize line endings to \n
430
- const normalized = markdown.replace(/\r\n/g, '\n');
431
-
432
- // Split by --- (horizontal rule / slide delimiter)
433
- // Handle YAML frontmatter by checking for --- at start
434
- let content = normalized;
435
- let frontmatter: string | null = null;
436
-
437
- // Extract YAML frontmatter if present
438
- if (normalized.startsWith('---')) {
439
- const endMatch = normalized.slice(3).indexOf('\n---');
440
- if (endMatch !== -1) {
441
- frontmatter = normalized.slice(0, endMatch + 7); // Include both ---
442
- content = normalized.slice(endMatch + 7).trim();
443
- }
444
- }
445
-
446
- // Split remaining content by ---
447
- const parts = content.split(/\n---\n/);
448
-
449
- const slides: Slide[] = [];
450
- for (const part of parts) {
451
- if (!part) continue;
452
- const trimmed = part.trim();
453
- if (trimmed) {
454
- slides.push(parseSlide(trimmed));
455
- }
456
- }
457
-
458
- // Attach frontmatter to first slide's preamble if exists
459
- if (frontmatter && slides.length > 0 && slides[0]) {
460
- slides[0]._frontmatter = frontmatter;
461
- }
462
-
463
- return slides;
464
- }
465
-
466
- /**
467
- * Generate Beamer markdown using pandoc's native slide structure
468
- * Works WITH pandoc, not against it - pandoc creates frames, we add overlays
469
- */
470
- export function generateBeamerMarkdown(slides: Slide[]): string {
471
- const output: string[] = [];
472
-
473
- // Check if first slide is a cover slide - if so, skip pandoc's auto title page
474
- const hasExplicitCover = slides.length > 0 && slides[0] && slides[0].style && slides[0].style.type === 'cover';
475
-
476
- for (const slide of slides) {
477
- // Include frontmatter if present
478
- if (slide._frontmatter) {
479
- let frontmatter = slide._frontmatter;
480
-
481
- // If we have an explicit cover slide, remove title/author/date to prevent
482
- // pandoc from generating a duplicate title frame
483
- if (hasExplicitCover) {
484
- // Remove title, author, date lines but keep other frontmatter
485
- frontmatter = frontmatter
486
- .replace(/^title:.*\n?/m, '')
487
- .replace(/^author:.*\n?/m, '')
488
- .replace(/^date:.*\n?/m, '')
489
- .replace(/\n{2,}/g, '\n'); // Clean up extra blank lines
490
-
491
- // Check if frontmatter is now empty (just --- and ---)
492
- const content = frontmatter.replace(/---/g, '').trim();
493
- if (!content) {
494
- // Skip empty frontmatter entirely - don't output anything
495
- // The slide content will follow directly
496
- } else {
497
- output.push(frontmatter);
498
- output.push('');
499
- }
500
- } else {
501
- output.push(frontmatter);
502
- output.push('');
503
- }
504
- }
505
-
506
- // Build pandoc heading with beamer attributes
507
- let headingAttrs = '';
508
- const attrList: string[] = [];
509
-
510
- if (slide.style) {
511
- if (slide.style.type === 'cover' || slide.style.type === 'thanks' || slide.style.type === 'section') {
512
- attrList.push('.plain');
513
- attrList.push('.noframenumbering');
514
- attrList.push('.c');
515
- } else if (slide.style.type === 'plain') {
516
- attrList.push('.plain');
517
- }
518
- if (slide.style.nonumber && !attrList.includes('.noframenumbering')) {
519
- attrList.push('.noframenumbering');
520
- }
521
- if (slide.style.center && !attrList.includes('.c')) {
522
- attrList.push('.c');
523
- }
524
- }
525
-
526
- if (attrList.length > 0) {
527
- headingAttrs = ' {' + attrList.join(' ') + '}';
528
- }
529
-
530
- // Frame heading - pandoc creates the frame from this
531
- const heading = '#'.repeat(slide.titleLevel) + ' ' + slide.title + headingAttrs;
532
- output.push(heading);
533
- output.push('');
534
-
535
- // Note: Per-frame background colors (.dark, .accent) are not yet supported
536
- // in the pandoc-based beamer output. The classes are preserved for PPTX.
537
-
538
- // Add preamble if present (visible on all overlays)
539
- if (slide.preamble) {
540
- output.push(slide.preamble);
541
- output.push('');
542
- }
543
-
544
- // Generate content
545
- if (slide.steps.length === 1 && slide.steps[0]) {
546
- // Single step - just output content, no overlays needed
547
- output.push(slide.steps[0].content);
548
- } else {
549
- // Multiple steps - use \pause between steps
550
- // This is the pandoc-friendly way to do incremental reveals
551
- for (let i = 0; i < slide.steps.length; i++) {
552
- const step = slide.steps[i];
553
- if (!step) continue;
554
- output.push(step.content);
555
- output.push('');
556
- // Add pause after each step except the last
557
- if (i < slide.steps.length - 1) {
558
- output.push('. . .');
559
- output.push('');
560
- }
561
- }
562
- }
563
-
564
- // Add speaker notes as LaTeX \note{} command
565
- if (slide.notes) {
566
- output.push('');
567
- // Escape special LaTeX characters in notes
568
- const notes = slide.notes || '';
569
- const escapedNotes = notes
570
- .replace(/\\/g, '\\textbackslash{}')
571
- .replace(/[&%$#_{}]/g, '\\$&')
572
- .replace(/\n/g, '\\\\');
573
- output.push('\\note{' + escapedNotes + '}');
574
- }
575
-
576
- output.push('');
577
- output.push('---');
578
- output.push('');
579
- }
580
-
581
- // Remove trailing ---
582
- while (output.length > 0) {
583
- const last = output[output.length - 1];
584
- if (!last || last.trim() !== '') break;
585
- output.pop();
586
- }
587
- if (output.length > 0 && output[output.length - 1] === '---') {
588
- output.pop();
589
- }
590
-
591
- return output.join('\n');
592
- }
593
-
594
- /**
595
- * Build PPTX slide class attribute string
596
- */
597
- function buildPptxSlideClasses(style: SlideStyle | null): string {
598
- if (!style || !style.classes || style.classes.length === 0) {
599
- return '';
600
- }
601
- return ' {.' + style.classes.join(' .') + '}';
602
- }
603
-
604
- /**
605
- * Generate PPTX markdown with duplicated slides for steps
606
- * Each step becomes a separate physical slide
607
- * Handles ::: buildup blocks by expanding them into multiple slides
608
- */
609
- export function generatePptxMarkdown(slides: Slide[]): string {
610
- const output: string[] = [];
611
-
612
- for (const slide of slides) {
613
- // Include frontmatter if present (only on first slide)
614
- if (slide._frontmatter) {
615
- output.push(slide._frontmatter);
616
- output.push('');
617
- }
618
-
619
- // Check if any step contains a buildup block
620
- const stepsWithBuildup: StepWithBuildup[] = slide.steps.map((step) => {
621
- const result = processBuildupBlocks(step.content, 'pptx');
622
- return {
623
- ...step,
624
- beforeBuildup: result.content,
625
- afterBuildup: result.afterContent || '',
626
- buildupSteps: result.buildupSteps,
627
- };
628
- });
629
-
630
- // Generate slides - handle buildup expansion
631
- for (let i = 0; i < stepsWithBuildup.length; i++) {
632
- const step = stepsWithBuildup[i];
633
- if (!step) continue;
634
-
635
- if (step.buildupSteps && step.buildupSteps.length > 0) {
636
- // This step has a buildup block - generate one slide per buildup step
637
- for (const buildupContent of step.buildupSteps) {
638
- const classes = buildPptxSlideClasses(slide.style);
639
- const heading = '#'.repeat(slide.titleLevel) + ' ' + slide.title + classes;
640
- output.push(heading);
641
- output.push('');
642
-
643
- // Add preamble if present
644
- if (slide.preamble) {
645
- output.push(slide.preamble);
646
- output.push('');
647
- }
648
-
649
- // Add content before buildup block
650
- if (step.beforeBuildup) {
651
- output.push(step.beforeBuildup);
652
- output.push('');
653
- }
654
-
655
- // Add this buildup step content
656
- output.push(buildupContent);
657
- output.push('');
658
-
659
- // Add content after buildup block
660
- if (step.afterBuildup) {
661
- output.push(step.afterBuildup);
662
- output.push('');
663
- }
664
-
665
- // Add speaker notes
666
- if (slide.notes) {
667
- output.push('::: notes');
668
- output.push(slide.notes);
669
- output.push(':::');
670
- }
671
-
672
- output.push('');
673
- output.push('---');
674
- output.push('');
675
- }
676
- } else {
677
- // Regular step - no buildup
678
- const classes = buildPptxSlideClasses(slide.style);
679
- const heading = '#'.repeat(slide.titleLevel) + ' ' + slide.title + classes;
680
- output.push(heading);
681
- output.push('');
682
-
683
- // Add preamble if present
684
- if (slide.preamble) {
685
- output.push(slide.preamble);
686
- output.push('');
687
- }
688
-
689
- // Add cumulative steps up to current
690
- for (let j = 0; j <= i; j++) {
691
- const s = stepsWithBuildup[j];
692
- if (s) {
693
- output.push(s.content);
694
- output.push('');
695
- }
696
- }
697
-
698
- // Add speaker notes
699
- if (slide.notes) {
700
- output.push('::: notes');
701
- output.push(slide.notes);
702
- output.push(':::');
703
- }
704
-
705
- output.push('');
706
- output.push('---');
707
- output.push('');
708
- }
709
- }
710
- }
711
-
712
- // Remove trailing ---
713
- while (output.length > 0) {
714
- const last = output[output.length - 1];
715
- if (!last || last.trim() !== '') break;
716
- output.pop();
717
- }
718
- if (output.length > 0 && output[output.length - 1] === '---') {
719
- output.pop();
720
- }
721
-
722
- return output.join('\n');
723
- }
724
-
725
- /**
726
- * Process markdown for slide output format
727
- */
728
- export function processSlideMarkdown(markdown: string, format: 'beamer' | 'pptx'): string {
729
- const slides = parseSlides(markdown);
730
-
731
- if (format === 'beamer') {
732
- return generateBeamerMarkdown(slides);
733
- } else if (format === 'pptx') {
734
- return generatePptxMarkdown(slides);
735
- }
736
-
737
- return markdown;
738
- }
739
-
740
- /**
741
- * Check if markdown contains slide syntax (steps, notes, buildup, or slide styles)
742
- */
743
- export function hasSlideSyntax(markdown: string): boolean {
744
- // Check for ::: step, ::: notes, or ::: buildup
745
- if (/^:::\s*(step|notes|buildup)\s*$/m.test(markdown)) {
746
- return true;
747
- }
748
- // Check for slide style attributes on headings: ## Title {.dark}
749
- if (/^#{1,6}\s+.+\{[^}]*\.(dark|light|accent|inverse|cover|thanks|section|plain|nonumber|center)/m.test(markdown)) {
750
- return true;
751
- }
752
- return false;
753
- }
754
-
755
- // Export style parser for testing
756
- export { parseSlideStyle };
1
+ /**
2
+ * Slide processing for Beamer and PPTX output
3
+ *
4
+ * Handles:
5
+ * - ::: step blocks for incremental reveals
6
+ * - ::: buildup blocks for progressive bullet reveals with greying
7
+ * - ::: notes blocks for speaker notes
8
+ * - Slide boundaries (---)
9
+ * - Slide styles: {.dark}, {.light}, {.accent}, {.inverse}
10
+ * - Special slides: {.cover}, {.thanks}, {.section}, {.plain}
11
+ *
12
+ * Syntax examples:
13
+ * ## Title {.dark} - Dark background slide
14
+ * ## Welcome {.cover} - Cover slide (no numbering, centered)
15
+ * ## Thank You {.thanks} - Thanks slide (no numbering)
16
+ * # Part 1 {.section} - Section divider slide
17
+ * ## Image {.plain} - No header/footer, full content
18
+ * ## Highlight {.accent .nonumber} - Accent color, no slide number
19
+ *
20
+ * Buildup syntax:
21
+ * ::: buildup
22
+ * - First point
23
+ * - Sub A
24
+ * - Sub B
25
+ * - Second point
26
+ * :::
27
+ *
28
+ * Generates slides where current point is colored, previous are greyed out.
29
+ * Subpoints appear sequentially within their parent.
30
+ */
31
+
32
+ interface Step {
33
+ index: number;
34
+ content: string;
35
+ }
36
+
37
+ interface SlideStyle {
38
+ background: string | null;
39
+ type: string | null;
40
+ nonumber: boolean;
41
+ center: boolean;
42
+ classes: string[];
43
+ }
44
+
45
+ interface Slide {
46
+ title: string;
47
+ titleLevel: number;
48
+ steps: Step[];
49
+ notes: string | null;
50
+ preamble: string;
51
+ style: SlideStyle;
52
+ _frontmatter?: string;
53
+ }
54
+
55
+ interface BulletItem {
56
+ text: string;
57
+ indent: number;
58
+ children: BulletItem[];
59
+ }
60
+
61
+ interface BuildupStep {
62
+ itemIndex: number;
63
+ subIndex: number | null;
64
+ }
65
+
66
+ interface BuildupResult {
67
+ content: string;
68
+ afterContent?: string;
69
+ buildupSteps: string[] | null;
70
+ }
71
+
72
+ interface StepWithBuildup extends Step {
73
+ beforeBuildup?: string;
74
+ afterBuildup?: string;
75
+ buildupSteps?: string[] | null;
76
+ }
77
+
78
+ /**
79
+ * Grey color for "completed" buildup items
80
+ */
81
+ const GREY_COLOR = '#888888';
82
+
83
+ /**
84
+ * Background styles mapped to Beamer options
85
+ */
86
+ const BEAMER_BACKGROUNDS: Record<string, string> = {
87
+ dark: '\\setbeamercolor{background canvas}{bg=black}\\setbeamercolor{normal text}{fg=white}\\usebeamercolor[fg]{normal text}',
88
+ light: '\\setbeamercolor{background canvas}{bg=white}\\setbeamercolor{normal text}{fg=black}\\usebeamercolor[fg]{normal text}',
89
+ accent: '\\setbeamercolor{background canvas}{bg=structure.fg}\\setbeamercolor{normal text}{fg=white}\\usebeamercolor[fg]{normal text}',
90
+ inverse: '\\setbeamercolor{background canvas}{bg=structure.fg!90!black}\\setbeamercolor{normal text}{fg=white}\\usebeamercolor[fg]{normal text}',
91
+ };
92
+
93
+ /**
94
+ * Parse a bullet list into a tree structure
95
+ */
96
+ function parseBulletList(content: string): BulletItem[] {
97
+ const lines = content.split('\n');
98
+ const items: BulletItem[] = [];
99
+ const stack: Array<BulletItem & { children: BulletItem[] }> = [{ children: items, indent: -1 } as any];
100
+
101
+ for (const line of lines) {
102
+ // Match bullet lines: "- text" or " - text" etc.
103
+ const match = line.match(/^(\s*)[-*]\s+(.+)$/);
104
+ if (!match) continue;
105
+
106
+ const indent = (match[1] || '').length;
107
+ const text = (match[2] || '').trim();
108
+
109
+ const item: BulletItem = { text, indent, children: [] };
110
+
111
+ // Find parent based on indentation
112
+ while (stack.length > 1) {
113
+ const top = stack[stack.length - 1];
114
+ if (!top || top.indent < indent) break;
115
+ stack.pop();
116
+ }
117
+
118
+ const parent = stack[stack.length - 1];
119
+ if (parent) {
120
+ parent.children.push(item);
121
+ }
122
+ stack.push(item as any);
123
+ }
124
+
125
+ return items;
126
+ }
127
+
128
+ /**
129
+ * Flatten bullet tree into sequential reveal steps
130
+ * Each step is: { itemIndex, subIndex, isSubItem }
131
+ */
132
+ function flattenBuildupSteps(items: BulletItem[]): BuildupStep[] {
133
+ const steps: BuildupStep[] = [];
134
+
135
+ for (let i = 0; i < items.length; i++) {
136
+ const item = items[i];
137
+ if (!item) continue;
138
+
139
+ if (item.children.length === 0) {
140
+ // No children - single step for this item
141
+ steps.push({ itemIndex: i, subIndex: null });
142
+ } else {
143
+ // Has children - first show parent, then each child
144
+ steps.push({ itemIndex: i, subIndex: -1 }); // Parent only
145
+
146
+ for (let j = 0; j < item.children.length; j++) {
147
+ steps.push({ itemIndex: i, subIndex: j });
148
+ }
149
+ }
150
+ }
151
+
152
+ return steps;
153
+ }
154
+
155
+ /**
156
+ * Render a bullet item with optional color
157
+ */
158
+ function renderBulletItem(
159
+ item: BulletItem,
160
+ color: string | null,
161
+ indentLevel: number = 0,
162
+ showChildrenUpTo: number | null = null
163
+ ): string {
164
+ const indent = ' '.repeat(indentLevel);
165
+ const lines: string[] = [];
166
+
167
+ // Render the main item
168
+ const text = color ? `[${item.text}]{color=${color}}` : item.text;
169
+ lines.push(`${indent}- ${text}`);
170
+
171
+ // Render children if any should be shown
172
+ if (showChildrenUpTo !== null && showChildrenUpTo >= 0 && item.children) {
173
+ for (let i = 0; i <= showChildrenUpTo && i < item.children.length; i++) {
174
+ const child = item.children[i];
175
+ if (!child) continue;
176
+ const childColor = color; // Children inherit parent's color state
177
+ const childText = childColor ? `[${child.text}]{color=${childColor}}` : child.text;
178
+ lines.push(`${indent} - ${childText}`);
179
+ }
180
+ }
181
+
182
+ return lines.join('\n');
183
+ }
184
+
185
+ /**
186
+ * Generate buildup slides from a bullet list
187
+ */
188
+ function expandBuildup(content: string, format: string): string[] {
189
+ const items = parseBulletList(content);
190
+ if (items.length === 0) return [content];
191
+
192
+ const steps = flattenBuildupSteps(items);
193
+ const slideContents: string[] = [];
194
+
195
+ for (const step of steps) {
196
+ const lines: string[] = [];
197
+
198
+ for (let i = 0; i < items.length; i++) {
199
+ const item = items[i];
200
+ if (!item) continue;
201
+
202
+ if (i < step.itemIndex) {
203
+ // Previous top-level item - greyed out with all children
204
+ const allChildrenIndex = item.children.length > 0 ? item.children.length - 1 : null;
205
+ lines.push(renderBulletItem(item, GREY_COLOR, 0, allChildrenIndex));
206
+ } else if (i === step.itemIndex) {
207
+ // Current top-level item - colored
208
+ if (step.subIndex === null) {
209
+ // No children case - just show item
210
+ lines.push(renderBulletItem(item, null, 0, null));
211
+ } else if (step.subIndex === -1) {
212
+ // Has children but showing parent only first
213
+ lines.push(renderBulletItem(item, null, 0, null));
214
+ } else {
215
+ // Showing parent + children up to subIndex
216
+ lines.push(renderBulletItem(item, null, 0, step.subIndex));
217
+ }
218
+ }
219
+ // Future items (i > step.itemIndex) - not shown yet
220
+ }
221
+
222
+ slideContents.push(lines.join('\n'));
223
+ }
224
+
225
+ return slideContents;
226
+ }
227
+
228
+ /**
229
+ * Process ::: buildup blocks in content and expand to steps
230
+ */
231
+ function processBuildupBlocks(content: string, format: string): BuildupResult {
232
+ const buildupMatch = content.match(/^:::\s*buildup\s*\n([\s\S]*?)\n:::\s*$/m);
233
+
234
+ if (!buildupMatch || buildupMatch.index === undefined) {
235
+ return { content, buildupSteps: null };
236
+ }
237
+
238
+ const buildupContent = buildupMatch[1] || '';
239
+ const buildupSteps = expandBuildup(buildupContent, format);
240
+
241
+ // Remove the buildup block from content (will be replaced by steps)
242
+ const beforeBuildup = content.slice(0, buildupMatch.index).trim();
243
+ const afterBuildup = content.slice(buildupMatch.index + buildupMatch[0].length).trim();
244
+
245
+ return {
246
+ content: beforeBuildup,
247
+ afterContent: afterBuildup,
248
+ buildupSteps,
249
+ };
250
+ }
251
+
252
+ /**
253
+ * Parse slide style attributes from heading
254
+ */
255
+ function parseSlideStyle(heading: string): { title: string; style: SlideStyle } {
256
+ const style: SlideStyle = {
257
+ background: null,
258
+ type: null,
259
+ nonumber: false,
260
+ center: false,
261
+ classes: [],
262
+ };
263
+
264
+ // Match {.class1 .class2} at end of heading
265
+ const attrMatch = heading.match(/\s*\{([^}]+)\}\s*$/);
266
+ if (!attrMatch || attrMatch.index === undefined) {
267
+ return { title: heading.trim(), style };
268
+ }
269
+
270
+ const title = heading.slice(0, attrMatch.index).trim();
271
+ const attrs = attrMatch[1] || '';
272
+
273
+ // Parse each .class
274
+ const classMatches = attrs.matchAll(/\.(\w+)/g);
275
+ for (const match of classMatches) {
276
+ if (!match[1]) continue;
277
+ const cls = match[1].toLowerCase();
278
+ style.classes.push(cls);
279
+
280
+ // Background styles
281
+ if (['dark', 'light', 'accent', 'inverse'].includes(cls)) {
282
+ style.background = cls;
283
+ }
284
+ // Special slide types
285
+ else if (['cover', 'thanks', 'section', 'plain'].includes(cls)) {
286
+ style.type = cls;
287
+ // Cover, thanks, section slides default to no numbering
288
+ if (['cover', 'thanks', 'section'].includes(cls)) {
289
+ style.nonumber = true;
290
+ style.center = true;
291
+ }
292
+ }
293
+ // Explicit options
294
+ else if (cls === 'nonumber' || cls === 'unnumbered') {
295
+ style.nonumber = true;
296
+ } else if (cls === 'center' || cls === 'centered') {
297
+ style.center = true;
298
+ }
299
+ }
300
+
301
+ return { title, style };
302
+ }
303
+
304
+ /**
305
+ * Parse a single slide's content into steps and notes
306
+ */
307
+ export function parseSlide(slideContent: string): Slide {
308
+ const lines = slideContent.split('\n');
309
+
310
+ // Extract title (first heading)
311
+ let title = '';
312
+ let titleLevel = 2;
313
+ let titleLineIndex = -1;
314
+ let style: SlideStyle = {
315
+ background: null,
316
+ type: null,
317
+ nonumber: false,
318
+ center: false,
319
+ classes: [],
320
+ };
321
+
322
+ for (let i = 0; i < lines.length; i++) {
323
+ const line = lines[i];
324
+ if (!line) continue;
325
+ const match = line.match(/^(#{1,6})\s+(.+)$/);
326
+ if (match && match[1] && match[2]) {
327
+ titleLevel = match[1].length;
328
+ // Parse style from heading
329
+ const parsed = parseSlideStyle(match[2]);
330
+ title = parsed.title;
331
+ style = parsed.style;
332
+ titleLineIndex = i;
333
+ break;
334
+ }
335
+ }
336
+
337
+ // Content after title
338
+ const contentStart = titleLineIndex >= 0 ? titleLineIndex + 1 : 0;
339
+ const bodyContent = lines.slice(contentStart).join('\n');
340
+
341
+ // Parse ::: step and ::: notes blocks
342
+ const steps: Step[] = [];
343
+ let notes: string | null = null;
344
+ let preamble = '';
345
+
346
+ // Regex to match fenced div blocks
347
+ const bodyLines = bodyContent.split('\n');
348
+ let inBlock = false;
349
+ let blockType: string | null = null;
350
+ let currentBlockContent: string[] = [];
351
+ let beforeFirstStep: string[] = [];
352
+ let foundFirstStep = false;
353
+
354
+ for (let i = 0; i < bodyLines.length; i++) {
355
+ const line = bodyLines[i];
356
+ if (!line && line !== '') continue;
357
+
358
+ if (!inBlock) {
359
+ // Check for block start
360
+ const stepMatch = line.match(/^:::\s*step\s*$/);
361
+ const notesMatch = line.match(/^:::\s*notes\s*$/);
362
+
363
+ if (stepMatch) {
364
+ inBlock = true;
365
+ blockType = 'step';
366
+ currentBlockContent = [];
367
+ foundFirstStep = true;
368
+ } else if (notesMatch) {
369
+ inBlock = true;
370
+ blockType = 'notes';
371
+ currentBlockContent = [];
372
+ } else if (!foundFirstStep) {
373
+ beforeFirstStep.push(line);
374
+ }
375
+ } else {
376
+ // Check for block end
377
+ if (line.match(/^:::\s*$/)) {
378
+ // End of block
379
+ if (blockType === 'step') {
380
+ steps.push({
381
+ index: steps.length + 1,
382
+ content: currentBlockContent.join('\n').trim(),
383
+ });
384
+ } else if (blockType === 'notes') {
385
+ notes = currentBlockContent.join('\n').trim();
386
+ }
387
+ inBlock = false;
388
+ blockType = null;
389
+ currentBlockContent = [];
390
+ } else {
391
+ currentBlockContent.push(line);
392
+ }
393
+ }
394
+ }
395
+
396
+ // Handle content before first step as preamble
397
+ preamble = beforeFirstStep.join('\n').trim();
398
+
399
+ // If no explicit steps, treat entire body as single step
400
+ if (steps.length === 0) {
401
+ // Remove notes from body if present
402
+ let bodyWithoutNotes = bodyContent;
403
+ const notesBlockMatch = bodyContent.match(/^:::\s*notes\s*$[\s\S]*?^:::\s*$/m);
404
+ if (notesBlockMatch) {
405
+ bodyWithoutNotes = bodyContent.replace(notesBlockMatch[0], '').trim();
406
+ }
407
+
408
+ steps.push({
409
+ index: 1,
410
+ content: bodyWithoutNotes.trim(),
411
+ });
412
+ preamble = '';
413
+ }
414
+
415
+ return {
416
+ title,
417
+ titleLevel,
418
+ steps,
419
+ notes,
420
+ preamble,
421
+ style,
422
+ };
423
+ }
424
+
425
+ /**
426
+ * Parse markdown document into slides
427
+ */
428
+ export function parseSlides(markdown: string): Slide[] {
429
+ // Normalize line endings to \n
430
+ const normalized = markdown.replace(/\r\n/g, '\n');
431
+
432
+ // Split by --- (horizontal rule / slide delimiter)
433
+ // Handle YAML frontmatter by checking for --- at start
434
+ let content = normalized;
435
+ let frontmatter: string | null = null;
436
+
437
+ // Extract YAML frontmatter if present
438
+ if (normalized.startsWith('---')) {
439
+ const endMatch = normalized.slice(3).indexOf('\n---');
440
+ if (endMatch !== -1) {
441
+ frontmatter = normalized.slice(0, endMatch + 7); // Include both ---
442
+ content = normalized.slice(endMatch + 7).trim();
443
+ }
444
+ }
445
+
446
+ // Split remaining content by ---
447
+ const parts = content.split(/\n---\n/);
448
+
449
+ const slides: Slide[] = [];
450
+ for (const part of parts) {
451
+ if (!part) continue;
452
+ const trimmed = part.trim();
453
+ if (trimmed) {
454
+ slides.push(parseSlide(trimmed));
455
+ }
456
+ }
457
+
458
+ // Attach frontmatter to first slide's preamble if exists
459
+ if (frontmatter && slides.length > 0 && slides[0]) {
460
+ slides[0]._frontmatter = frontmatter;
461
+ }
462
+
463
+ return slides;
464
+ }
465
+
466
+ /**
467
+ * Generate Beamer markdown using pandoc's native slide structure
468
+ * Works WITH pandoc, not against it - pandoc creates frames, we add overlays
469
+ */
470
+ export function generateBeamerMarkdown(slides: Slide[]): string {
471
+ const output: string[] = [];
472
+
473
+ // Check if first slide is a cover slide - if so, skip pandoc's auto title page
474
+ const hasExplicitCover = slides.length > 0 && slides[0] && slides[0].style && slides[0].style.type === 'cover';
475
+
476
+ for (const slide of slides) {
477
+ // Include frontmatter if present
478
+ if (slide._frontmatter) {
479
+ let frontmatter = slide._frontmatter;
480
+
481
+ // If we have an explicit cover slide, remove title/author/date to prevent
482
+ // pandoc from generating a duplicate title frame
483
+ if (hasExplicitCover) {
484
+ // Remove title, author, date lines but keep other frontmatter
485
+ frontmatter = frontmatter
486
+ .replace(/^title:.*\n?/m, '')
487
+ .replace(/^author:.*\n?/m, '')
488
+ .replace(/^date:.*\n?/m, '')
489
+ .replace(/\n{2,}/g, '\n'); // Clean up extra blank lines
490
+
491
+ // Check if frontmatter is now empty (just --- and ---)
492
+ const content = frontmatter.replace(/---/g, '').trim();
493
+ if (!content) {
494
+ // Skip empty frontmatter entirely - don't output anything
495
+ // The slide content will follow directly
496
+ } else {
497
+ output.push(frontmatter);
498
+ output.push('');
499
+ }
500
+ } else {
501
+ output.push(frontmatter);
502
+ output.push('');
503
+ }
504
+ }
505
+
506
+ // Build pandoc heading with beamer attributes
507
+ let headingAttrs = '';
508
+ const attrList: string[] = [];
509
+
510
+ if (slide.style) {
511
+ if (slide.style.type === 'cover' || slide.style.type === 'thanks' || slide.style.type === 'section') {
512
+ attrList.push('.plain');
513
+ attrList.push('.noframenumbering');
514
+ attrList.push('.c');
515
+ } else if (slide.style.type === 'plain') {
516
+ attrList.push('.plain');
517
+ }
518
+ if (slide.style.nonumber && !attrList.includes('.noframenumbering')) {
519
+ attrList.push('.noframenumbering');
520
+ }
521
+ if (slide.style.center && !attrList.includes('.c')) {
522
+ attrList.push('.c');
523
+ }
524
+ }
525
+
526
+ if (attrList.length > 0) {
527
+ headingAttrs = ' {' + attrList.join(' ') + '}';
528
+ }
529
+
530
+ // Frame heading - pandoc creates the frame from this
531
+ const heading = '#'.repeat(slide.titleLevel) + ' ' + slide.title + headingAttrs;
532
+ output.push(heading);
533
+ output.push('');
534
+
535
+ // Note: Per-frame background colors (.dark, .accent) are not yet supported
536
+ // in the pandoc-based beamer output. The classes are preserved for PPTX.
537
+
538
+ // Add preamble if present (visible on all overlays)
539
+ if (slide.preamble) {
540
+ output.push(slide.preamble);
541
+ output.push('');
542
+ }
543
+
544
+ // Generate content
545
+ if (slide.steps.length === 1 && slide.steps[0]) {
546
+ // Single step - just output content, no overlays needed
547
+ output.push(slide.steps[0].content);
548
+ } else {
549
+ // Multiple steps - use \pause between steps
550
+ // This is the pandoc-friendly way to do incremental reveals
551
+ for (let i = 0; i < slide.steps.length; i++) {
552
+ const step = slide.steps[i];
553
+ if (!step) continue;
554
+ output.push(step.content);
555
+ output.push('');
556
+ // Add pause after each step except the last
557
+ if (i < slide.steps.length - 1) {
558
+ output.push('. . .');
559
+ output.push('');
560
+ }
561
+ }
562
+ }
563
+
564
+ // Add speaker notes as LaTeX \note{} command
565
+ if (slide.notes) {
566
+ output.push('');
567
+ // Escape special LaTeX characters in notes
568
+ const notes = slide.notes || '';
569
+ const escapedNotes = notes
570
+ .replace(/\\/g, '\\textbackslash{}')
571
+ .replace(/[&%$#_{}]/g, '\\$&')
572
+ .replace(/\n/g, '\\\\');
573
+ output.push('\\note{' + escapedNotes + '}');
574
+ }
575
+
576
+ output.push('');
577
+ output.push('---');
578
+ output.push('');
579
+ }
580
+
581
+ // Remove trailing ---
582
+ while (output.length > 0) {
583
+ const last = output[output.length - 1];
584
+ if (!last || last.trim() !== '') break;
585
+ output.pop();
586
+ }
587
+ if (output.length > 0 && output[output.length - 1] === '---') {
588
+ output.pop();
589
+ }
590
+
591
+ return output.join('\n');
592
+ }
593
+
594
+ /**
595
+ * Build PPTX slide class attribute string
596
+ */
597
+ function buildPptxSlideClasses(style: SlideStyle | null): string {
598
+ if (!style || !style.classes || style.classes.length === 0) {
599
+ return '';
600
+ }
601
+ return ' {.' + style.classes.join(' .') + '}';
602
+ }
603
+
604
+ /**
605
+ * Generate PPTX markdown with duplicated slides for steps
606
+ * Each step becomes a separate physical slide
607
+ * Handles ::: buildup blocks by expanding them into multiple slides
608
+ */
609
+ export function generatePptxMarkdown(slides: Slide[]): string {
610
+ const output: string[] = [];
611
+
612
+ for (const slide of slides) {
613
+ // Include frontmatter if present (only on first slide)
614
+ if (slide._frontmatter) {
615
+ output.push(slide._frontmatter);
616
+ output.push('');
617
+ }
618
+
619
+ // Check if any step contains a buildup block
620
+ const stepsWithBuildup: StepWithBuildup[] = slide.steps.map((step) => {
621
+ const result = processBuildupBlocks(step.content, 'pptx');
622
+ return {
623
+ ...step,
624
+ beforeBuildup: result.content,
625
+ afterBuildup: result.afterContent || '',
626
+ buildupSteps: result.buildupSteps,
627
+ };
628
+ });
629
+
630
+ // Generate slides - handle buildup expansion
631
+ for (let i = 0; i < stepsWithBuildup.length; i++) {
632
+ const step = stepsWithBuildup[i];
633
+ if (!step) continue;
634
+
635
+ if (step.buildupSteps && step.buildupSteps.length > 0) {
636
+ // This step has a buildup block - generate one slide per buildup step
637
+ for (const buildupContent of step.buildupSteps) {
638
+ const classes = buildPptxSlideClasses(slide.style);
639
+ const heading = '#'.repeat(slide.titleLevel) + ' ' + slide.title + classes;
640
+ output.push(heading);
641
+ output.push('');
642
+
643
+ // Add preamble if present
644
+ if (slide.preamble) {
645
+ output.push(slide.preamble);
646
+ output.push('');
647
+ }
648
+
649
+ // Add content before buildup block
650
+ if (step.beforeBuildup) {
651
+ output.push(step.beforeBuildup);
652
+ output.push('');
653
+ }
654
+
655
+ // Add this buildup step content
656
+ output.push(buildupContent);
657
+ output.push('');
658
+
659
+ // Add content after buildup block
660
+ if (step.afterBuildup) {
661
+ output.push(step.afterBuildup);
662
+ output.push('');
663
+ }
664
+
665
+ // Add speaker notes
666
+ if (slide.notes) {
667
+ output.push('::: notes');
668
+ output.push(slide.notes);
669
+ output.push(':::');
670
+ }
671
+
672
+ output.push('');
673
+ output.push('---');
674
+ output.push('');
675
+ }
676
+ } else {
677
+ // Regular step - no buildup
678
+ const classes = buildPptxSlideClasses(slide.style);
679
+ const heading = '#'.repeat(slide.titleLevel) + ' ' + slide.title + classes;
680
+ output.push(heading);
681
+ output.push('');
682
+
683
+ // Add preamble if present
684
+ if (slide.preamble) {
685
+ output.push(slide.preamble);
686
+ output.push('');
687
+ }
688
+
689
+ // Add cumulative steps up to current
690
+ for (let j = 0; j <= i; j++) {
691
+ const s = stepsWithBuildup[j];
692
+ if (s) {
693
+ output.push(s.content);
694
+ output.push('');
695
+ }
696
+ }
697
+
698
+ // Add speaker notes
699
+ if (slide.notes) {
700
+ output.push('::: notes');
701
+ output.push(slide.notes);
702
+ output.push(':::');
703
+ }
704
+
705
+ output.push('');
706
+ output.push('---');
707
+ output.push('');
708
+ }
709
+ }
710
+ }
711
+
712
+ // Remove trailing ---
713
+ while (output.length > 0) {
714
+ const last = output[output.length - 1];
715
+ if (!last || last.trim() !== '') break;
716
+ output.pop();
717
+ }
718
+ if (output.length > 0 && output[output.length - 1] === '---') {
719
+ output.pop();
720
+ }
721
+
722
+ return output.join('\n');
723
+ }
724
+
725
+ /**
726
+ * Process markdown for slide output format
727
+ */
728
+ export function processSlideMarkdown(markdown: string, format: 'beamer' | 'pptx'): string {
729
+ const slides = parseSlides(markdown);
730
+
731
+ if (format === 'beamer') {
732
+ return generateBeamerMarkdown(slides);
733
+ } else if (format === 'pptx') {
734
+ return generatePptxMarkdown(slides);
735
+ }
736
+
737
+ return markdown;
738
+ }
739
+
740
+ /**
741
+ * Check if markdown contains slide syntax (steps, notes, buildup, or slide styles)
742
+ */
743
+ export function hasSlideSyntax(markdown: string): boolean {
744
+ // Check for ::: step, ::: notes, or ::: buildup
745
+ if (/^:::\s*(step|notes|buildup)\s*$/m.test(markdown)) {
746
+ return true;
747
+ }
748
+ // Check for slide style attributes on headings: ## Title {.dark}
749
+ if (/^#{1,6}\s+.+\{[^}]*\.(dark|light|accent|inverse|cover|thanks|section|plain|nonumber|center)/m.test(markdown)) {
750
+ return true;
751
+ }
752
+ return false;
753
+ }
754
+
755
+ // Export style parser for testing
756
+ export { parseSlideStyle };