create-marp-presentation 1.2.0 → 1.2.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,3585 @@
1
+ # Theme Management System Implementation Plan
2
+
3
+ > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+ **Goal:** Build a complete theme management system for Marp presentations with CLI commands, VSCode integration, and interactive theme selection.
6
+
7
+ **Architecture:** Monolithic ThemeManager class delegating to focused helper classes (ThemeResolver, VSCodeIntegration, Prompts). Shared addThemesCommand function used by both project creation and post-creation theme addition. Template themes copied to project, lib/ modules duplicated to scripts/lib/ for standalone project CLI.
8
+
9
+ **Tech Stack:** Node.js >=20.0.0, @inquirer/prompts ^7.0.0 (interactive prompts), gray-matter ^4.0.3 (frontmatter parsing), Jest ^29.7.0 (testing), fs.cpSync (file copying)
10
+
11
+ ---
12
+
13
+ ## Task 1: Create lib/ directory structure and errors.js
14
+
15
+ **Files:**
16
+ - Create: `lib/errors.js`
17
+ - Test: `tests/unit/errors.test.js`
18
+
19
+ **Step 1: Write the failing test**
20
+
21
+ ```javascript
22
+ // tests/unit/errors.test.js
23
+ const {
24
+ ThemeError,
25
+ ThemeNotFoundError,
26
+ ThemeAlreadyExistsError,
27
+ PresentationNotFoundError,
28
+ InvalidCSSError,
29
+ VSCodeIntegrationError
30
+ } = require('../../lib/errors');
31
+
32
+ describe('ThemeError classes', () => {
33
+ test('ThemeError should be instance of Error', () => {
34
+ const error = new ThemeError('test message');
35
+ expect(error).toBeInstanceOf(Error);
36
+ expect(error.message).toBe('test message');
37
+ expect(error.name).toBe('ThemeError');
38
+ });
39
+
40
+ test('ThemeNotFoundError should have correct name', () => {
41
+ const error = new ThemeNotFoundError('my-theme');
42
+ expect(error.name).toBe('ThemeNotFoundError');
43
+ expect(error.message).toContain('my-theme');
44
+ });
45
+
46
+ test('ThemeAlreadyExistsError should have correct name', () => {
47
+ const error = new ThemeAlreadyExistsError('my-theme');
48
+ expect(error.name).toBe('ThemeAlreadyExistsError');
49
+ expect(error.message).toContain('my-theme');
50
+ });
51
+
52
+ test('PresentationNotFoundError should have correct name', () => {
53
+ const error = new PresentationNotFoundError('/path/to/presentation.md');
54
+ expect(error.name).toBe('PresentationNotFoundError');
55
+ expect(error.message).toContain('/path/to/presentation.md');
56
+ });
57
+
58
+ test('InvalidCSSError should have correct name', () => {
59
+ const error = new InvalidCSSError('/path/to/theme.css', 'syntax error');
60
+ expect(error.name).toBe('InvalidCSSError');
61
+ expect(error.message).toContain('/path/to/theme.css');
62
+ });
63
+
64
+ test('VSCodeIntegrationError should have correct name', () => {
65
+ const error = new VSCodeIntegrationError('Failed to update settings');
66
+ expect(error.name).toBe('VSCodeIntegrationError');
67
+ expect(error.message).toBe('Failed to update settings');
68
+ });
69
+ });
70
+ ```
71
+
72
+ **Step 2: Run test to verify it fails**
73
+
74
+ Run: `npm test -- tests/unit/errors.test.js`
75
+ Expected: FAIL with "Cannot find module '../../lib/errors'"
76
+
77
+ **Step 3: Write minimal implementation**
78
+
79
+ ```javascript
80
+ // lib/errors.js
81
+ class ThemeError extends Error {
82
+ constructor(message) {
83
+ super(message);
84
+ this.name = 'ThemeError';
85
+ }
86
+ }
87
+
88
+ class ThemeNotFoundError extends ThemeError {
89
+ constructor(themeName) {
90
+ super(`Theme '${themeName}' not found`);
91
+ this.name = 'ThemeNotFoundError';
92
+ this.themeName = themeName;
93
+ }
94
+ }
95
+
96
+ class ThemeAlreadyExistsError extends ThemeError {
97
+ constructor(themeName) {
98
+ super(`Theme '${themeName}' already exists`);
99
+ this.name = 'ThemeAlreadyExistsError';
100
+ this.themeName = themeName;
101
+ }
102
+ }
103
+
104
+ class PresentationNotFoundError extends ThemeError {
105
+ constructor(presentationPath) {
106
+ super(`Presentation file not found: ${presentationPath}`);
107
+ this.name = 'PresentationNotFoundError';
108
+ this.presentationPath = presentationPath;
109
+ }
110
+ }
111
+
112
+ class InvalidCSSError extends ThemeError {
113
+ constructor(cssPath, reason) {
114
+ super(`Invalid CSS file '${cssPath}': ${reason}`);
115
+ this.name = 'InvalidCSSError';
116
+ this.cssPath = cssPath;
117
+ this.reason = reason;
118
+ }
119
+ }
120
+
121
+ class VSCodeIntegrationError extends ThemeError {
122
+ constructor(message) {
123
+ super(message);
124
+ this.name = 'VSCodeIntegrationError';
125
+ }
126
+ }
127
+
128
+ module.exports = {
129
+ ThemeError,
130
+ ThemeNotFoundError,
131
+ ThemeAlreadyExistsError,
132
+ PresentationNotFoundError,
133
+ InvalidCSSError,
134
+ VSCodeIntegrationError
135
+ };
136
+ ```
137
+
138
+ **Step 4: Run test to verify it passes**
139
+
140
+ Run: `npm test -- tests/unit/errors.test.js`
141
+ Expected: PASS
142
+
143
+ **Step 5: Commit**
144
+
145
+ ```bash
146
+ git add lib/errors.js tests/unit/errors.test.js
147
+ git commit -m "feat: add custom error classes for theme management"
148
+ ```
149
+
150
+ ---
151
+
152
+ ## Task 2: Create Theme class and ThemeResolver.extractThemeName
153
+
154
+ **Files:**
155
+ - Create: `lib/theme-resolver.js`
156
+ - Test: `tests/unit/theme-resolver.test.js`
157
+
158
+ **Step 1: Write the failing test**
159
+
160
+ ```javascript
161
+ // tests/unit/theme-resolver.test.js
162
+ const { ThemeResolver, Theme } = require('../../lib/theme-resolver');
163
+ const fs = require('fs');
164
+ const path = require('path');
165
+
166
+ describe('ThemeResolver.extractThemeName', () => {
167
+ test('should extract theme name from CSS comment directive', () => {
168
+ const css = '/* @theme my-custom-theme */\n:root { color: red; }';
169
+ const result = ThemeResolver.extractThemeName(css);
170
+ expect(result).toBe('my-custom-theme');
171
+ });
172
+
173
+ test('should handle theme directive with extra spaces', () => {
174
+ const css = '/* @theme spaced-theme */\n:root { }';
175
+ const result = ThemeResolver.extractThemeName(css);
176
+ expect(result).toBe('spaced-theme');
177
+ });
178
+
179
+ test('should handle theme directive on multiple lines', () => {
180
+ const css = `/*
181
+ * @theme multi-line-theme
182
+ */\n:root { }`;
183
+ const result = ThemeResolver.extractThemeName(css);
184
+ expect(result).toBe('multi-line-theme');
185
+ });
186
+
187
+ test('should return null when no theme directive found', () => {
188
+ const css = ':root { color: red; }';
189
+ const result = ThemeResolver.extractThemeName(css);
190
+ expect(result).toBeNull();
191
+ });
192
+
193
+ test('should extract theme name from filename as fallback', () => {
194
+ const result = ThemeResolver.extractThemeName(
195
+ ':root { color: red; }',
196
+ 'my-theme.css'
197
+ );
198
+ expect(result).toBe('my-theme');
199
+ });
200
+
201
+ test('should handle filename without .css extension', () => {
202
+ const result = ThemeResolver.extractThemeName(
203
+ ':root { color: red; }',
204
+ 'my-theme'
205
+ );
206
+ expect(result).toBe('my-theme');
207
+ });
208
+
209
+ test('should return null when no directive and no filename provided', () => {
210
+ const result = ThemeResolver.extractThemeName(':root { }');
211
+ expect(result).toBeNull();
212
+ });
213
+ });
214
+ ```
215
+
216
+ **Step 2: Run test to verify it fails**
217
+
218
+ Run: `npm test -- tests/unit/theme-resolver.test.js -t "extractThemeName"`
219
+ Expected: FAIL with "Cannot find module '../../lib/theme-resolver'"
220
+
221
+ **Step 3: Write minimal implementation**
222
+
223
+ ```javascript
224
+ // lib/theme-resolver.js
225
+ const fs = require('fs');
226
+ const path = require('path');
227
+
228
+ /**
229
+ * Represents a Marp theme
230
+ */
231
+ class Theme {
232
+ constructor(name, cssPath, css, dependencies = []) {
233
+ this.name = name;
234
+ this.path = cssPath;
235
+ this.css = css;
236
+ this.dependencies = dependencies;
237
+ this.isSystem = ['default', 'gaia', 'uncover'].includes(name);
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Resolves theme information from CSS files
243
+ */
244
+ class ThemeResolver {
245
+ static SYSTEM_THEMES = ['default', 'gaia', 'uncover'];
246
+
247
+ /**
248
+ * Extract theme name from CSS comment directive
249
+ * Pattern: /* @theme name *\/
250
+ *
251
+ * @param {string} cssContent - CSS file content
252
+ * @param {string} fallbackFilename - Filename to use as fallback
253
+ * @returns {string|null} Theme name or null if not found
254
+ */
255
+ static extractThemeName(cssContent, fallbackFilename = null) {
256
+ // Match /* @theme name */ - supports multi-line comments
257
+ const themeDirectiveRegex = /\/\*\s*@theme\s+([\w-]+)\s*\*\//;
258
+ const match = cssContent.match(themeDirectiveRegex);
259
+
260
+ if (match && match[1]) {
261
+ return match[1].trim();
262
+ }
263
+
264
+ // Fallback to filename (with or without .css extension)
265
+ if (fallbackFilename) {
266
+ const basename = path.basename(fallbackFilename, '.css');
267
+ return basename;
268
+ }
269
+
270
+ return null;
271
+ }
272
+ }
273
+
274
+ module.exports = { ThemeResolver, Theme };
275
+ ```
276
+
277
+ **Step 4: Run test to verify it passes**
278
+
279
+ Run: `npm test -- tests/unit/theme-resolver.test.js -t "extractThemeName"`
280
+ Expected: PASS
281
+
282
+ **Step 5: Commit**
283
+
284
+ ```bash
285
+ git add lib/theme-resolver.js tests/unit/theme-resolver.test.js
286
+ git commit -m "feat: add Theme class and extractThemeName method"
287
+ ```
288
+
289
+ ---
290
+
291
+ ## Task 3: Implement ThemeResolver.extractDependencies
292
+
293
+ **Files:**
294
+ - Modify: `lib/theme-resolver.js`
295
+ - Modify: `tests/unit/theme-resolver.test.js`
296
+
297
+ **Step 1: Write the failing test**
298
+
299
+ Add to `tests/unit/theme-resolver.test.js`:
300
+
301
+ ```javascript
302
+ describe('ThemeResolver.extractDependencies', () => {
303
+ test('should extract single @import dependency', () => {
304
+ const css = '/* @theme my-theme */\n@import "gaia";';
305
+ const result = ThemeResolver.extractDependencies(css);
306
+ expect(result).toEqual(['gaia']);
307
+ });
308
+
309
+ test('should extract multiple @import dependencies', () => {
310
+ const css = `/* @theme my-theme */
311
+ @import "default";
312
+ @import "marpx";`;
313
+ const result = ThemeResolver.extractDependencies(css);
314
+ expect(result).toEqual(['default', 'marpx']);
315
+ });
316
+
317
+ test('should ignore url() imports', () => {
318
+ const css = `/* @theme my-theme */
319
+ @import "gaia";
320
+ @import url("https://example.com/style.css");`;
321
+ const result = ThemeResolver.extractDependencies(css);
322
+ expect(result).toEqual(['gaia']);
323
+ });
324
+
325
+ test('should handle @import with single quotes', () => {
326
+ const css = `@import 'default';`;
327
+ const result = ThemeResolver.extractDependencies(css);
328
+ expect(result).toEqual(['default']);
329
+ });
330
+
331
+ test('should handle @import with extra whitespace', () => {
332
+ const css = `@import "gaia" ;`;
333
+ const result = ThemeResolver.extractDependencies(css);
334
+ expect(result).toEqual(['gaia']);
335
+ });
336
+
337
+ test('should return empty array when no dependencies', () => {
338
+ const css = '/* @theme standalone */\n:root { }';
339
+ const result = ThemeResolver.extractDependencies(css);
340
+ expect(result).toEqual([]);
341
+ });
342
+
343
+ test('should handle relative path imports', () => {
344
+ const css = `@import "../marpx/marpx.css";`;
345
+ const result = ThemeResolver.extractDependencies(css);
346
+ expect(result).toEqual(['../marpx/marpx.css']);
347
+ });
348
+ });
349
+ ```
350
+
351
+ **Step 2: Run test to verify it fails**
352
+
353
+ Run: `npm test -- tests/unit/theme-resolver.test.js -t "extractDependencies"`
354
+ Expected: FAIL with "ThemeResolver.extractDependencies is not a function"
355
+
356
+ **Step 3: Write minimal implementation**
357
+
358
+ Add to `lib/theme-resolver.js` in ThemeResolver class:
359
+
360
+ ```javascript
361
+ /**
362
+ * Extract @import dependencies from CSS
363
+ * Ignores url() imports
364
+ *
365
+ * @param {string} cssContent - CSS file content
366
+ * @returns {string[]} Array of theme names from @import statements
367
+ */
368
+ static extractDependencies(cssContent) {
369
+ // Match @import "theme" or @import 'theme' - ignore url()
370
+ // This regex matches non-url imports
371
+ const importRegex = /@import\s+(?:url\()?['"]([^'"]+)['"]\)?\s*;/g;
372
+ const dependencies = [];
373
+
374
+ let match;
375
+ while ((match = importRegex.exec(cssContent)) !== null) {
376
+ const importPath = match[1];
377
+
378
+ // Skip if it's clearly a url() import (contains :// or starts with http)
379
+ if (!importPath.match(/^https?:\/\//)) {
380
+ dependencies.push(importPath);
381
+ }
382
+ }
383
+
384
+ return dependencies;
385
+ }
386
+ ```
387
+
388
+ **Step 4: Run test to verify it passes**
389
+
390
+ Run: `npm test -- tests/unit/theme-resolver.test.js -t "extractDependencies"`
391
+ Expected: PASS
392
+
393
+ **Step 5: Commit**
394
+
395
+ ```bash
396
+ git add lib/theme-resolver.js tests/unit/theme-resolver.test.js
397
+ git commit -m "feat: add extractDependencies method for @import parsing"
398
+ ```
399
+
400
+ ---
401
+
402
+ ## Task 4: Implement ThemeResolver.resolveTheme
403
+
404
+ **Files:**
405
+ - Modify: `lib/theme-resolver.js`
406
+ - Modify: `tests/unit/theme-resolver.test.js`
407
+
408
+ **Step 1: Write the failing test**
409
+
410
+ Add to `tests/unit/theme-resolver.test.js`:
411
+
412
+ ```javascript
413
+ describe('ThemeResolver.resolveTheme', () => {
414
+ const fixturesDir = path.join(__dirname, '..', 'fixtures', 'themes');
415
+ const tempDir = path.join(__dirname, '..', 'temp');
416
+
417
+ beforeEach(() => {
418
+ if (!fs.existsSync(fixturesDir)) {
419
+ fs.mkdirSync(fixturesDir, { recursive: true });
420
+ }
421
+ if (!fs.existsSync(tempDir)) {
422
+ fs.mkdirSync(tempDir, { recursive: true });
423
+ }
424
+ });
425
+
426
+ afterEach(() => {
427
+ if (fs.existsSync(tempDir)) {
428
+ fs.rmSync(tempDir, { recursive: true, force: true });
429
+ }
430
+ });
431
+
432
+ test('should resolve theme from CSS file with directive', () => {
433
+ const cssPath = path.join(tempDir, 'test-theme.css');
434
+ fs.writeFileSync(cssPath, '/* @theme test */\n@import "gaia";');
435
+
436
+ const theme = ThemeResolver.resolveTheme(cssPath);
437
+ expect(theme.name).toBe('test');
438
+ expect(theme.path).toBe(cssPath);
439
+ expect(theme.dependencies).toEqual(['gaia']);
440
+ expect(theme.isSystem).toBe(false);
441
+ });
442
+
443
+ test('should resolve theme using filename fallback', () => {
444
+ const cssPath = path.join(tempDir, 'fallback.css');
445
+ fs.writeFileSync(cssPath, ':root { }');
446
+
447
+ const theme = ThemeResolver.resolveTheme(cssPath);
448
+ expect(theme.name).toBe('fallback');
449
+ expect(theme.dependencies).toEqual([]);
450
+ });
451
+
452
+ test('should mark system themes correctly', () => {
453
+ const cssPath = path.join(tempDir, 'gaia.css');
454
+ fs.writeFileSync(cssPath, '/* @theme gaia */');
455
+
456
+ const theme = ThemeResolver.resolveTheme(cssPath);
457
+ expect(theme.isSystem).toBe(true);
458
+ });
459
+
460
+ test('should throw for non-existent file', () => {
461
+ const cssPath = path.join(tempDir, 'non-existent.css');
462
+ expect(() => ThemeResolver.resolveTheme(cssPath)).toThrow();
463
+ });
464
+ });
465
+ ```
466
+
467
+ **Step 2: Run test to verify it fails**
468
+
469
+ Run: `npm test -- tests/unit/theme-resolver.test.js -t "resolveTheme"`
470
+ Expected: FAIL with "ThemeResolver.resolveTheme is not a function"
471
+
472
+ **Step 3: Write minimal implementation**
473
+
474
+ Add to `lib/theme-resolver.js` in ThemeResolver class:
475
+
476
+ ```javascript
477
+ /**
478
+ * Resolve theme from a CSS file
479
+ *
480
+ * @param {string} cssPath - Path to CSS file
481
+ * @returns {Theme} Theme object
482
+ * @throws {Error} If file does not exist
483
+ */
484
+ static resolveTheme(cssPath) {
485
+ if (!fs.existsSync(cssPath)) {
486
+ throw new Error(`CSS file not found: ${cssPath}`);
487
+ }
488
+
489
+ const css = fs.readFileSync(cssPath, 'utf-8');
490
+ const filename = path.basename(cssPath);
491
+ const name = this.extractThemeName(css, filename) || filename;
492
+ const dependencies = this.extractDependencies(css);
493
+
494
+ return new Theme(name, cssPath, css, dependencies);
495
+ }
496
+ ```
497
+
498
+ **Step 4: Run test to verify it passes**
499
+
500
+ Run: `npm test -- tests/unit/theme-resolver.test.js -t "resolveTheme"`
501
+ Expected: PASS
502
+
503
+ **Step 5: Commit**
504
+
505
+ ```bash
506
+ git add lib/theme-resolver.js tests/unit/theme-resolver.test.js
507
+ git commit -m "feat: add resolveTheme method for single file resolution"
508
+ ```
509
+
510
+ ---
511
+
512
+ ## Task 5: Implement ThemeResolver.scanDirectory
513
+
514
+ **Files:**
515
+ - Modify: `lib/theme-resolver.js`
516
+ - Modify: `tests/unit/theme-resolver.test.js`
517
+
518
+ **Step 1: Write the failing test**
519
+
520
+ Add to `tests/unit/theme-resolver.test.js`:
521
+
522
+ ```javascript
523
+ describe('ThemeResolver.scanDirectory', () => {
524
+ const fixturesDir = path.join(__dirname, '..', 'fixtures', 'themes');
525
+ const tempDir = path.join(__dirname, '..', 'temp');
526
+
527
+ beforeEach(() => {
528
+ if (!fs.existsSync(tempDir)) {
529
+ fs.mkdirSync(tempDir, { recursive: true });
530
+ }
531
+ });
532
+
533
+ afterEach(() => {
534
+ if (fs.existsSync(tempDir)) {
535
+ fs.rmSync(tempDir, { recursive: true, force: true });
536
+ }
537
+ });
538
+
539
+ test('should scan single CSS file in flat directory', () => {
540
+ fs.writeFileSync(path.join(tempDir, 'theme1.css'), '/* @theme theme1 */');
541
+ const themes = ThemeResolver.scanDirectory(tempDir);
542
+ expect(themes).toHaveLength(1);
543
+ expect(themes[0].name).toBe('theme1');
544
+ });
545
+
546
+ test('should scan nested CSS files recursively', () => {
547
+ fs.mkdirSync(path.join(tempDir, 'subfolder'), { recursive: true });
548
+ fs.writeFileSync(path.join(tempDir, 'root.css'), '/* @theme root */');
549
+ fs.writeFileSync(path.join(tempDir, 'subfolder', 'nested.css'), '/* @theme nested */');
550
+
551
+ const themes = ThemeResolver.scanDirectory(tempDir);
552
+ expect(themes).toHaveLength(2);
553
+ const themeNames = themes.map(t => t.name).sort();
554
+ expect(themeNames).toEqual(['nested', 'root']);
555
+ });
556
+
557
+ test('should return empty array for empty directory', () => {
558
+ const emptyDir = path.join(tempDir, 'empty');
559
+ fs.mkdirSync(emptyDir, { recursive: true });
560
+
561
+ const themes = ThemeResolver.scanDirectory(emptyDir);
562
+ expect(themes).toEqual([]);
563
+ });
564
+
565
+ test('should handle directory with non-CSS files', () => {
566
+ fs.writeFileSync(path.join(tempDir, 'readme.md'), '# README');
567
+ fs.writeFileSync(path.join(tempDir, 'theme.css'), '/* @theme theme */');
568
+
569
+ const themes = ThemeResolver.scanDirectory(tempDir);
570
+ expect(themes).toHaveLength(1);
571
+ expect(themes[0].name).toBe('theme');
572
+ });
573
+
574
+ test('should throw for non-existent directory', () => {
575
+ const nonExistent = path.join(tempDir, 'does-not-exist');
576
+ expect(() => ThemeResolver.scanDirectory(nonExistent)).toThrow();
577
+ });
578
+ });
579
+ ```
580
+
581
+ **Step 2: Run test to verify it fails**
582
+
583
+ Run: `npm test -- tests/unit/theme-resolver.test.js -t "scanDirectory"`
584
+ Expected: FAIL with "ThemeResolver.scanDirectory is not a function"
585
+
586
+ **Step 3: Write minimal implementation**
587
+
588
+ Add to `lib/theme-resolver.js`:
589
+
590
+ ```javascript
591
+ const { readdirSync, statSync } = require('fs');
592
+ ```
593
+
594
+ Add to ThemeResolver class:
595
+
596
+ ```javascript
597
+ /**
598
+ * Scan directory recursively for CSS theme files
599
+ *
600
+ * @param {string} themesPath - Path to themes directory
601
+ * @returns {Theme[]} Array of Theme objects
602
+ * @throws {Error} If directory does not exist
603
+ */
604
+ static scanDirectory(themesPath) {
605
+ if (!fs.existsSync(themesPath)) {
606
+ throw new Error(`Themes directory not found: ${themesPath}`);
607
+ }
608
+
609
+ const themes = [];
610
+ const cssFiles = this._findCssFiles(themesPath);
611
+
612
+ for (const cssPath of cssFiles) {
613
+ try {
614
+ const theme = this.resolveTheme(cssPath);
615
+ themes.push(theme);
616
+ } catch (error) {
617
+ // Skip files that can't be resolved
618
+ console.warn(`Warning: Could not resolve theme from ${cssPath}: ${error.message}`);
619
+ }
620
+ }
621
+
622
+ return themes;
623
+ }
624
+
625
+ /**
626
+ * Find all CSS files in directory recursively
627
+ * @private
628
+ */
629
+ static _findCssFiles(dirPath, results = []) {
630
+ const entries = readdirSync(dirPath, { withFileTypes: true });
631
+
632
+ for (const entry of entries) {
633
+ const fullPath = path.join(dirPath, entry.name);
634
+
635
+ if (entry.isDirectory()) {
636
+ this._findCssFiles(fullPath, results);
637
+ } else if (entry.isFile() && entry.name.endsWith('.css')) {
638
+ results.push(fullPath);
639
+ }
640
+ }
641
+
642
+ return results;
643
+ }
644
+ ```
645
+
646
+ **Step 4: Run test to verify it passes**
647
+
648
+ Run: `npm test -- tests/unit/theme-resolver.test.js -t "scanDirectory"`
649
+ Expected: PASS
650
+
651
+ **Step 5: Commit**
652
+
653
+ ```bash
654
+ git add lib/theme-resolver.js tests/unit/theme-resolver.test.js
655
+ git commit -m "feat: add scanDirectory method for recursive theme discovery"
656
+ ```
657
+
658
+ ---
659
+
660
+ ## Task 6: Implement ThemeResolver.resolveDependencies
661
+
662
+ **Files:**
663
+ - Modify: `lib/theme-resolver.js`
664
+ - Modify: `tests/unit/theme-resolver.test.js`
665
+
666
+ **Step 1: Write the failing test**
667
+
668
+ Add to `tests/unit/theme-resolver.test.js`:
669
+
670
+ ```javascript
671
+ describe('ThemeResolver.resolveDependencies', () => {
672
+ test('should return only selected themes with no dependencies', () => {
673
+ const selectedThemes = [
674
+ new Theme('standalone', '/path/standalone.css', ':root {}', [])
675
+ ];
676
+
677
+ const result = ThemeResolver.resolveDependencies(selectedThemes, []);
678
+ expect(result).toHaveLength(1);
679
+ expect(result[0].name).toBe('standalone');
680
+ });
681
+
682
+ test('should include parent themes from dependencies', () => {
683
+ const allThemes = [
684
+ new Theme('default', '/path/default.css', 'css', [], true),
685
+ new Theme('marpx', '/path/marpx.css', 'css', ['default']),
686
+ new Theme('socrates', '/path/socrates.css', 'css', ['marpx'])
687
+ ];
688
+
689
+ const selectedThemes = [allThemes[2]]; // socrates
690
+ const result = ThemeResolver.resolveDependencies(selectedThemes, allThemes);
691
+
692
+ const names = result.map(t => t.name);
693
+ expect(names).toContain('socrates');
694
+ expect(names).toContain('marpx');
695
+ expect(names).not.toContain('default'); // system theme excluded
696
+ });
697
+
698
+ test('should exclude system themes from result', () => {
699
+ const allThemes = [
700
+ new Theme('gaia', '/path/gaia.css', 'css', [], true),
701
+ new Theme('my-theme', '/path/my.css', 'css', ['gaia'])
702
+ ];
703
+
704
+ const selectedThemes = [allThemes[1]];
705
+ const result = ThemeResolver.resolveDependencies(selectedThemes, allThemes);
706
+
707
+ expect(result.map(t => t.name)).toEqual(['my-theme']);
708
+ });
709
+
710
+ test('should handle multiple selected themes with shared dependencies', () => {
711
+ const allThemes = [
712
+ new Theme('default', '/path/default.css', 'css', [], true),
713
+ new Theme('base', '/path/base.css', 'css', ['default']),
714
+ new Theme('theme1', '/path/t1.css', 'css', ['base']),
715
+ new Theme('theme2', '/path/t2.css', 'css', ['base'])
716
+ ];
717
+
718
+ const selectedThemes = [allThemes[2], allThemes[3]];
719
+ const result = ThemeResolver.resolveDependencies(selectedThemes, allThemes);
720
+
721
+ const names = result.map(t => t.name);
722
+ expect(names).toContain('theme1');
723
+ expect(names).toContain('theme2');
724
+ expect(names).toContain('base');
725
+ expect(names).not.toContain('default');
726
+ // base should appear only once
727
+ expect(names.filter(n => n === 'base')).toHaveLength(1);
728
+ });
729
+
730
+ test('should handle circular dependencies gracefully', () => {
731
+ const allThemes = [
732
+ new Theme('a', '/path/a.css', 'css', ['b']),
733
+ new Theme('b', '/path/b.css', 'css', ['a'])
734
+ ];
735
+
736
+ const selectedThemes = [allThemes[0]];
737
+ const result = ThemeResolver.resolveDependencies(selectedThemes, allThemes);
738
+
739
+ // Should include both a and b without infinite loop
740
+ expect(result.map(t => t.name).sort()).toEqual(['a', 'b']);
741
+ });
742
+
743
+ test('should handle missing parent themes', () => {
744
+ const selectedThemes = [
745
+ new Theme('orphan', '/path/orphan.css', 'css', ['missing-parent'])
746
+ ];
747
+
748
+ const result = ThemeResolver.resolveDependencies(selectedThemes, []);
749
+ expect(result.map(t => t.name)).toEqual(['orphan']);
750
+ });
751
+ });
752
+ ```
753
+
754
+ **Step 2: Run test to verify it fails**
755
+
756
+ Run: `npm test -- tests/unit/theme-resolver.test.js -t "resolveDependencies"`
757
+ Expected: FAIL with "ThemeResolver.resolveDependencies is not a function"
758
+
759
+ **Step 3: Write minimal implementation**
760
+
761
+ Add to `lib/theme-resolver.js` in ThemeResolver class:
762
+
763
+ ```javascript
764
+ /**
765
+ * Resolve all dependencies for selected themes
766
+ * Returns complete set of themes to copy (including parents)
767
+ * Excludes system themes (default, gaia, uncover)
768
+ *
769
+ * @param {Theme[]} selectedThemes - Themes user selected
770
+ * @param {Theme[]} allThemes - All available themes
771
+ * @returns {Theme[]} Themes to copy (selected + dependencies)
772
+ */
773
+ static resolveDependencies(selectedThemes, allThemes) {
774
+ const toCopy = new Map(); // Use Map for deduplication by name
775
+ const visited = new Set();
776
+ const visiting = new Set();
777
+
778
+ const addTheme = (themeName) => {
779
+ if (visited.has(themeName)) {
780
+ return;
781
+ }
782
+
783
+ // Check for circular dependency
784
+ if (visiting.has(themeName)) {
785
+ console.warn(`Warning: Circular dependency detected for theme '${themeName}'`);
786
+ return;
787
+ }
788
+
789
+ visiting.add(themeName);
790
+
791
+ // Find theme in allThemes
792
+ const theme = allThemes.find(t => t.name === themeName);
793
+
794
+ if (!theme) {
795
+ console.warn(`Warning: Dependency '${themeName}' not found in available themes`);
796
+ visiting.delete(themeName);
797
+ return;
798
+ }
799
+
800
+ // Skip system themes
801
+ if (theme.isSystem) {
802
+ visiting.delete(themeName);
803
+ return;
804
+ }
805
+
806
+ // First resolve dependencies
807
+ for (const depName of theme.dependencies) {
808
+ // Extract just the theme name from path (e.g., "../marpx/marpx.css" -> "marpx")
809
+ const simpleDepName = depName.replace(/\.(css)?$/g, '').split('/').pop();
810
+ addTheme(simpleDepName);
811
+ }
812
+
813
+ // Then add this theme
814
+ toCopy.set(theme.name, theme);
815
+ visited.add(theme.name);
816
+ visiting.delete(themeName);
817
+ };
818
+
819
+ // Start with selected themes
820
+ for (const theme of selectedThemes) {
821
+ addTheme(theme.name);
822
+ }
823
+
824
+ return Array.from(toCopy.values());
825
+ }
826
+ ```
827
+
828
+ **Step 4: Run test to verify it passes**
829
+
830
+ Run: `npm test -- tests/unit/theme-resolver.test.js -t "resolveDependencies"`
831
+ Expected: PASS
832
+
833
+ **Step 5: Commit**
834
+
835
+ ```bash
836
+ git add lib/theme-resolver.js tests/unit/theme-resolver.test.js
837
+ git commit -m "feat: add resolveDependencies method with circular dependency handling"
838
+ ```
839
+
840
+ ---
841
+
842
+ ## Task 7: Implement VSCodeIntegration class
843
+
844
+ **Files:**
845
+ - Create: `lib/vscode-integration.js`
846
+ - Test: `tests/unit/vscode-integration.test.js`
847
+
848
+ **Step 1: Write the failing test**
849
+
850
+ ```javascript
851
+ // tests/unit/vscode-integration.test.js
852
+ const fs = require('fs');
853
+ const path = require('path');
854
+ const { VSCodeIntegration } = require('../../lib/vscode-integration');
855
+ const { Theme } = require('../../lib/theme-resolver');
856
+
857
+ describe('VSCodeIntegration', () => {
858
+ const tempDir = path.join(__dirname, '..', 'temp');
859
+ const vscodeDir = path.join(tempDir, '.vscode');
860
+
861
+ beforeEach(() => {
862
+ if (!fs.existsSync(tempDir)) {
863
+ fs.mkdirSync(tempDir, { recursive: true });
864
+ }
865
+ });
866
+
867
+ afterEach(() => {
868
+ if (fs.existsSync(tempDir)) {
869
+ fs.rmSync(tempDir, { recursive: true, force: true });
870
+ }
871
+ });
872
+
873
+ describe('constructor', () => {
874
+ test('should create instance with project path', () => {
875
+ const integration = new VSCodeIntegration(tempDir);
876
+ expect(integration.projectPath).toBe(tempDir);
877
+ });
878
+ });
879
+
880
+ describe('getSettingsPath', () => {
881
+ test('should return path to settings.json', () => {
882
+ const integration = new VSCodeIntegration(tempDir);
883
+ const settingsPath = integration.getSettingsPath();
884
+ expect(settingsPath).toBe(path.join(vscodeDir, 'settings.json'));
885
+ });
886
+ });
887
+
888
+ describe('readSettings', () => {
889
+ test('should return empty object when settings.json does not exist', () => {
890
+ const integration = new VSCodeIntegration(tempDir);
891
+ const settings = integration.readSettings();
892
+ expect(settings).toEqual({});
893
+ });
894
+
895
+ test('should read existing settings.json', () => {
896
+ fs.mkdirSync(vscodeDir, { recursive: true });
897
+ const existingSettings = {
898
+ 'editor.tabSize': 2,
899
+ 'other.setting': true
900
+ };
901
+ fs.writeFileSync(
902
+ path.join(vscodeDir, 'settings.json'),
903
+ JSON.stringify(existingSettings, null, 2)
904
+ );
905
+
906
+ const integration = new VSCodeIntegration(tempDir);
907
+ const settings = integration.readSettings();
908
+ expect(settings).toEqual(existingSettings);
909
+ });
910
+
911
+ test('should handle corrupted JSON by backing up and returning empty', () => {
912
+ fs.mkdirSync(vscodeDir, { recursive: true });
913
+ const settingsPath = path.join(vscodeDir, 'settings.json');
914
+ fs.writeFileSync(settingsPath, '{ invalid json }');
915
+
916
+ const integration = new VSCodeIntegration(tempDir);
917
+ const settings = integration.readSettings();
918
+
919
+ // Should backup the corrupted file
920
+ expect(fs.existsSync(settingsPath + '.backup')).toBe(true);
921
+ expect(settings).toEqual({});
922
+ });
923
+ });
924
+
925
+ describe('writeSettings', () => {
926
+ test('should create .vscode directory and settings.json', () => {
927
+ const integration = new VSCodeIntegration(tempDir);
928
+ integration.writeSettings({ 'test.key': 'value' });
929
+
930
+ expect(fs.existsSync(path.join(vscodeDir, 'settings.json'))).toBe(true);
931
+ });
932
+
933
+ test('should write settings with proper formatting', () => {
934
+ const integration = new VSCodeIntegration(tempDir);
935
+ const settings = { 'markdown.marp.themes': ['themes/test.css'] };
936
+ integration.writeSettings(settings);
937
+
938
+ const content = fs.readFileSync(path.join(vscodeDir, 'settings.json'), 'utf-8');
939
+ expect(() => JSON.parse(content)).not.toThrow();
940
+ expect(JSON.parse(content)).toEqual(settings);
941
+ });
942
+ });
943
+
944
+ describe('syncThemes', () => {
945
+ test('should create settings.json with theme paths', () => {
946
+ const themes = [
947
+ new Theme('beam', '/path/beam/beam.css', 'css', []),
948
+ new Theme('marpx', '/path/marpx/marpx.css', 'css', [])
949
+ ];
950
+
951
+ const integration = new VSCodeIntegration(tempDir);
952
+ integration.syncThemes(themes);
953
+
954
+ const settings = integration.readSettings();
955
+ expect(settings['markdown.marp.themes']).toEqual([
956
+ 'themes/beam/beam.css',
957
+ 'themes/marpx/marpx.css'
958
+ ]);
959
+ });
960
+
961
+ test('should merge with existing settings', () => {
962
+ fs.mkdirSync(vscodeDir, { recursive: true });
963
+ const existingSettings = {
964
+ 'editor.tabSize': 2,
965
+ 'markdown.marp.themes': ['themes/old.css']
966
+ };
967
+ fs.writeFileSync(
968
+ path.join(vscodeDir, 'settings.json'),
969
+ JSON.stringify(existingSettings, null, 2)
970
+ );
971
+
972
+ const themes = [
973
+ new Theme('new', '/path/new/new.css', 'css', [])
974
+ ];
975
+
976
+ const integration = new VSCodeIntegration(tempDir);
977
+ integration.syncThemes(themes);
978
+
979
+ const settings = integration.readSettings();
980
+ expect(settings['editor.tabSize']).toBe(2);
981
+ expect(settings['markdown.marp.themes']).toEqual(['themes/new/new.css']);
982
+ });
983
+
984
+ test('should handle empty themes array', () => {
985
+ const integration = new VSCodeIntegration(tempDir);
986
+ integration.syncThemes([]);
987
+
988
+ const settings = integration.readSettings();
989
+ expect(settings['markdown.marp.themes']).toEqual([]);
990
+ });
991
+ });
992
+ });
993
+ ```
994
+
995
+ **Step 2: Run test to verify it fails**
996
+
997
+ Run: `npm test -- tests/unit/vscode-integration.test.js`
998
+ Expected: FAIL with "Cannot find module '../../lib/vscode-integration'"
999
+
1000
+ **Step 3: Write minimal implementation**
1001
+
1002
+ ```javascript
1003
+ // lib/vscode-integration.js
1004
+ const fs = require('fs');
1005
+ const path = require('path');
1006
+
1007
+ /**
1008
+ * Manages VSCode settings for Marp extension integration
1009
+ */
1010
+ class VSCodeIntegration {
1011
+ constructor(projectPath) {
1012
+ this.projectPath = projectPath;
1013
+ }
1014
+
1015
+ /**
1016
+ * Get path to .vscode/settings.json
1017
+ */
1018
+ getSettingsPath() {
1019
+ return path.join(this.projectPath, '.vscode', 'settings.json');
1020
+ }
1021
+
1022
+ /**
1023
+ * Read VSCode settings
1024
+ * Returns empty object if file doesn't exist
1025
+ * Backs up corrupted JSON and returns empty object
1026
+ */
1027
+ readSettings() {
1028
+ const settingsPath = this.getSettingsPath();
1029
+
1030
+ if (!fs.existsSync(settingsPath)) {
1031
+ return {};
1032
+ }
1033
+
1034
+ try {
1035
+ const content = fs.readFileSync(settingsPath, 'utf-8');
1036
+ return JSON.parse(content);
1037
+ } catch (error) {
1038
+ // Backup corrupted file
1039
+ const backupPath = settingsPath + '.backup';
1040
+ fs.copyFileSync(settingsPath, backupPath);
1041
+ console.warn(`Warning: Corrupted settings.json backed up to ${backupPath}`);
1042
+ return {};
1043
+ }
1044
+ }
1045
+
1046
+ /**
1047
+ * Write VSCode settings
1048
+ * Creates .vscode directory if it doesn't exist
1049
+ */
1050
+ writeSettings(settings) {
1051
+ const settingsPath = this.getSettingsPath();
1052
+ const vscodeDir = path.dirname(settingsPath);
1053
+
1054
+ if (!fs.existsSync(vscodeDir)) {
1055
+ fs.mkdirSync(vscodeDir, { recursive: true });
1056
+ }
1057
+
1058
+ fs.writeFileSync(
1059
+ settingsPath,
1060
+ JSON.stringify(settings, null, 2) + '\n'
1061
+ );
1062
+ }
1063
+
1064
+ /**
1065
+ * Sync themes to markdown.marp.themes in settings.json
1066
+ * Creates settings.json if not exists
1067
+ * Merges with existing settings
1068
+ */
1069
+ syncThemes(themes) {
1070
+ const settings = this.readSettings();
1071
+
1072
+ // Convert themes to relative paths for VSCode
1073
+ const themePaths = themes.map(theme => {
1074
+ // Get relative path from project root
1075
+ const relativePath = path.relative(this.projectPath, theme.path);
1076
+ // Use forward slashes for cross-platform compatibility
1077
+ return relativePath.split(path.sep).join('/');
1078
+ });
1079
+
1080
+ settings['markdown.marp.themes'] = themePaths;
1081
+ this.writeSettings(settings);
1082
+ }
1083
+ }
1084
+
1085
+ module.exports = { VSCodeIntegration };
1086
+ ```
1087
+
1088
+ **Step 4: Run test to verify it passes**
1089
+
1090
+ Run: `npm test -- tests/unit/vscode-integration.test.js`
1091
+ Expected: PASS
1092
+
1093
+ **Step 5: Commit**
1094
+
1095
+ ```bash
1096
+ git add lib/vscode-integration.js tests/unit/vscode-integration.test.js
1097
+ git commit -m "feat: add VSCodeIntegration class for settings management"
1098
+ ```
1099
+
1100
+ ---
1101
+
1102
+ ## Task 8: Create frontmatter.js using gray-matter library
1103
+
1104
+ **Files:**
1105
+ - Create: `lib/frontmatter.js`
1106
+ - Test: `tests/unit/frontmatter.test.js`
1107
+
1108
+ **Step 1: Write the failing test**
1109
+
1110
+ ```javascript
1111
+ // tests/unit/frontmatter.test.js
1112
+ const fs = require('fs');
1113
+ const path = require('path');
1114
+ const { Frontmatter } = require('../../lib/frontmatter');
1115
+
1116
+ describe('Frontmatter', () => {
1117
+ const tempDir = path.join(__dirname, '..', 'temp');
1118
+
1119
+ beforeEach(() => {
1120
+ if (!fs.existsSync(tempDir)) {
1121
+ fs.mkdirSync(tempDir, { recursive: true });
1122
+ }
1123
+ });
1124
+
1125
+ afterEach(() => {
1126
+ if (fs.existsSync(tempDir)) {
1127
+ fs.rmSync(tempDir, { recursive: true, force: true });
1128
+ }
1129
+ });
1130
+
1131
+ describe('parse', () => {
1132
+ test('should parse frontmatter from markdown file', () => {
1133
+ const content = `---
1134
+ marp: true
1135
+ theme: default
1136
+ ---
1137
+
1138
+ # Slide Content`;
1139
+ const result = Frontmatter.parse(content);
1140
+ expect(result.marp).toBe(true);
1141
+ expect(result.theme).toBe('default');
1142
+ });
1143
+
1144
+ test('should return empty object for file without frontmatter', () => {
1145
+ const content = '# Just markdown content';
1146
+ const result = Frontmatter.parse(content);
1147
+ expect(result).toEqual({});
1148
+ });
1149
+
1150
+ test('should handle frontmatter with various data types', () => {
1151
+ const content = `---
1152
+ string: value
1153
+ number: 42
1154
+ boolean: true
1155
+ array:
1156
+ - one
1157
+ - two
1158
+ ---
1159
+
1160
+ Content`;
1161
+ const result = Frontmatter.parse(content);
1162
+ expect(result.string).toBe('value');
1163
+ expect(result.number).toBe(42);
1164
+ expect(result.boolean).toBe(true);
1165
+ expect(result.array).toEqual(['one', 'two']);
1166
+ });
1167
+ });
1168
+
1169
+ describe('getTheme', () => {
1170
+ test('should extract theme value from frontmatter', () => {
1171
+ const content = `---
1172
+ theme: gaia
1173
+ ---
1174
+
1175
+ Content`;
1176
+ expect(Frontmatter.getTheme(content)).toBe('gaia');
1177
+ });
1178
+
1179
+ test('should return null if no theme in frontmatter', () => {
1180
+ const content = `---
1181
+ marp: true
1182
+ ---
1183
+
1184
+ Content`;
1185
+ expect(Frontmatter.getTheme(content)).toBeNull();
1186
+ });
1187
+
1188
+ test('should return null for content without frontmatter', () => {
1189
+ expect(Frontmatter.getTheme('# No frontmatter')).toBeNull();
1190
+ });
1191
+ });
1192
+
1193
+ describe('setTheme', () => {
1194
+ test('should update existing theme in frontmatter', () => {
1195
+ const content = `---
1196
+ theme: default
1197
+ ---
1198
+
1199
+ # Content`;
1200
+ const result = Frontmatter.setTheme(content, 'gaia');
1201
+ expect(result).toContain('theme: gaia');
1202
+ expect(result).toContain('# Content');
1203
+ });
1204
+
1205
+ test('should add theme to existing frontmatter', () => {
1206
+ const content = `---
1207
+ marp: true
1208
+ ---
1209
+
1210
+ # Content`;
1211
+ const result = Frontmatter.setTheme(content, 'beam');
1212
+ expect(result).toContain('marp: true');
1213
+ expect(result).toContain('theme: beam');
1214
+ });
1215
+
1216
+ test('should create frontmatter if it does not exist', () => {
1217
+ const content = '# Content without frontmatter';
1218
+ const result = Frontmatter.setTheme(content, 'marpx');
1219
+ expect(result).toMatch(/^---\ntheme: marpx\n---/);
1220
+ expect(result).toContain('# Content');
1221
+ });
1222
+
1223
+ test('should preserve other frontmatter attributes', () => {
1224
+ const content = `---
1225
+ theme: default
1226
+ paginate: true
1227
+ ---
1228
+
1229
+ Content`;
1230
+ const result = Frontmatter.setTheme(content, 'new-theme');
1231
+ expect(result).toContain('theme: new-theme');
1232
+ expect(result).toContain('paginate: true');
1233
+ });
1234
+ });
1235
+
1236
+ describe('writeToFile', () => {
1237
+ test('should write content to file', () => {
1238
+ const filePath = path.join(tempDir, 'test.md');
1239
+ const content = '# Test Content';
1240
+
1241
+ Frontmatter.writeToFile(filePath, content);
1242
+ expect(fs.readFileSync(filePath, 'utf-8')).toBe(content);
1243
+ });
1244
+ });
1245
+ });
1246
+ ```
1247
+
1248
+ **Step 2: Run test to verify it fails**
1249
+
1250
+ Run: `npm test -- tests/unit/frontmatter.test.js`
1251
+ Expected: FAIL with "Cannot find module '../../lib/frontmatter'"
1252
+
1253
+ **Step 3: Add gray-matter dependency and write implementation**
1254
+
1255
+ First, add dependency to root package.json:
1256
+
1257
+ ```bash
1258
+ npm install --save-dev gray-matter@^4.0.3
1259
+ ```
1260
+
1261
+ Then create `lib/frontmatter.js`:
1262
+
1263
+ ```javascript
1264
+ // lib/frontmatter.js
1265
+ const fs = require('fs');
1266
+ const matter = require('gray-matter');
1267
+
1268
+ /**
1269
+ * Parse and manipulate markdown frontmatter
1270
+ */
1271
+ class Frontmatter {
1272
+ /**
1273
+ * Parse frontmatter from markdown content
1274
+ *
1275
+ * @param {string} content - Markdown content
1276
+ * @returns {object} Parsed frontmatter data
1277
+ */
1278
+ static parse(content) {
1279
+ const { data } = matter(content);
1280
+ return data;
1281
+ }
1282
+
1283
+ /**
1284
+ * Extract theme value from frontmatter
1285
+ *
1286
+ * @param {string} content - Markdown content
1287
+ * @returns {string|null} Theme name or null if not found
1288
+ */
1289
+ static getTheme(content) {
1290
+ const data = this.parse(content);
1291
+ return data.theme || null;
1292
+ }
1293
+
1294
+ /**
1295
+ * Set or update theme in frontmatter
1296
+ * Preserves all other attributes and content
1297
+ *
1298
+ * @param {string} content - Markdown content
1299
+ * @param {string} themeName - New theme name
1300
+ * @returns {string} Updated markdown content
1301
+ */
1302
+ static setTheme(content, themeName) {
1303
+ const { data, content: body } = matter(content);
1304
+
1305
+ // Update theme
1306
+ data.theme = themeName;
1307
+
1308
+ // Reconstruct with gray-matter
1309
+ return matter.stringify(body, data);
1310
+ }
1311
+
1312
+ /**
1313
+ * Write content to file
1314
+ *
1315
+ * @param {string} filePath - Path to file
1316
+ * @param {string} content - Content to write
1317
+ */
1318
+ static writeToFile(filePath, content) {
1319
+ fs.writeFileSync(filePath, content, 'utf-8');
1320
+ }
1321
+ }
1322
+
1323
+ module.exports = { Frontmatter };
1324
+ ```
1325
+
1326
+ **Step 4: Run test to verify it passes**
1327
+
1328
+ Run: `npm test -- tests/unit/frontmatter.test.js`
1329
+ Expected: PASS
1330
+
1331
+ **Step 5: Commit**
1332
+
1333
+ ```bash
1334
+ git add lib/frontmatter.js tests/unit/frontmatter.test.js package.json package-lock.json
1335
+ git commit -m "feat: add Frontmatter class using gray-matter library"
1336
+ ```
1337
+
1338
+ ---
1339
+
1340
+ ## Task 9: Implement prompts.js using @inquirer/prompts
1341
+
1342
+ **Files:**
1343
+ - Create: `lib/prompts.js`
1344
+ - Test: `tests/unit/prompts.test.js` (mock tests only)
1345
+
1346
+ **Step 1: Add @inquirer/prompts dependency**
1347
+
1348
+ ```bash
1349
+ npm install @inquirer/prompts@^7.0.0
1350
+ ```
1351
+
1352
+ **Step 2: Write the implementation**
1353
+
1354
+ ```javascript
1355
+ // lib/prompts.js
1356
+ const inquirer = require('@inquirer/prompts');
1357
+ const { THEME_NOT_FOUND } = require('./errors');
1358
+
1359
+ /**
1360
+ * Interactive prompts for theme management
1361
+ */
1362
+ class Prompts {
1363
+ /**
1364
+ * Prompt user to select themes from available options
1365
+ *
1366
+ * @param {Array} availableThemes - Array of {name, description} objects
1367
+ * @returns {Promise<string[]>} Array of selected theme names
1368
+ */
1369
+ static async promptThemes(availableThemes) {
1370
+ if (availableThemes.length === 0) {
1371
+ return [];
1372
+ }
1373
+
1374
+ const choices = availableThemes.map(theme => ({
1375
+ name: theme.name,
1376
+ value: theme.name,
1377
+ checked: false
1378
+ }));
1379
+
1380
+ return await inquirer.checkbox({
1381
+ message: 'Select themes to add:',
1382
+ choices
1383
+ });
1384
+ }
1385
+
1386
+ /**
1387
+ * Prompt user to select active theme from selected themes
1388
+ *
1389
+ * @param {Array} selectedThemes - Array of theme names
1390
+ * @returns {Promise<string>} Selected theme name
1391
+ */
1392
+ static async promptActiveTheme(selectedThemes) {
1393
+ if (selectedThemes.length === 0) {
1394
+ throw new Error('No themes available to select');
1395
+ }
1396
+
1397
+ if (selectedThemes.length === 1) {
1398
+ return selectedThemes[0];
1399
+ }
1400
+
1401
+ return await inquirer.select({
1402
+ message: 'Select active theme:',
1403
+ choices: selectedThemes
1404
+ });
1405
+ }
1406
+
1407
+ /**
1408
+ * Prompt user for new theme name with validation
1409
+ *
1410
+ * @returns {Promise<string>} Validated theme name
1411
+ */
1412
+ static async promptNewThemeName() {
1413
+ return await inquirer.input({
1414
+ message: 'Theme name:',
1415
+ validate: (input) => {
1416
+ if (!input || input.trim().length === 0) {
1417
+ return 'Theme name is required';
1418
+ }
1419
+ if (!/^[a-z0-9-]+$/.test(input)) {
1420
+ return 'Theme name must contain only lowercase letters, numbers, and hyphens';
1421
+ }
1422
+ return true;
1423
+ }
1424
+ });
1425
+ }
1426
+
1427
+ /**
1428
+ * Prompt user to select parent theme
1429
+ *
1430
+ * @param {Array} existingThemes - Array of {name, isSystem} objects
1431
+ * @returns {Promise<string|null>} Selected parent theme name or null for none
1432
+ */
1433
+ static async promptParentTheme(existingThemes) {
1434
+ const choices = [
1435
+ { name: 'none (create from scratch)', value: null },
1436
+ new inquirer.Separator('--- System Themes ---'),
1437
+ { name: 'default (system built-in)', value: 'default' },
1438
+ { name: 'gaia (system built-in)', value: 'gaia' },
1439
+ { name: 'uncover (system built-in)', value: 'uncover' }
1440
+ ];
1441
+
1442
+ // Add custom themes
1443
+ const customThemes = existingThemes.filter(t => !t.isSystem);
1444
+ if (customThemes.length > 0) {
1445
+ choices.push(new inquirer.Separator('--- Custom Themes ---'));
1446
+ customThemes.forEach(theme => {
1447
+ choices.push({ name: theme.name, value: theme.name });
1448
+ });
1449
+ }
1450
+
1451
+ return await inquirer.select({
1452
+ message: 'Parent theme:',
1453
+ choices
1454
+ });
1455
+ }
1456
+
1457
+ /**
1458
+ * Prompt user for directory location for new theme
1459
+ *
1460
+ * @param {Array} existingDirs - Array of existing directory names
1461
+ * @returns {Promise<string>} Selected option: 'root', 'existing', or 'new'
1462
+ */
1463
+ static async promptDirectoryLocation(existingDirs) {
1464
+ const choices = [
1465
+ { name: 'In root (themes/<name>.css)', value: 'root' }
1466
+ ];
1467
+
1468
+ if (existingDirs.length > 0) {
1469
+ choices.push(new inquirer.Separator('--- Existing Folders ---'));
1470
+ existingDirs.forEach(dir => {
1471
+ choices.push({
1472
+ name: `In existing folder: themes/${dir}/`,
1473
+ value: dir
1474
+ });
1475
+ });
1476
+ }
1477
+
1478
+ choices.push(new inquirer.Separator('--- New Folder ---'));
1479
+ choices.push({ name: 'In new folder (enter name)', value: 'new' });
1480
+
1481
+ return await inquirer.select({
1482
+ message: 'Where to create the theme CSS file?',
1483
+ choices
1484
+ });
1485
+ }
1486
+
1487
+ /**
1488
+ * Prompt user for new folder name
1489
+ *
1490
+ * @returns {Promise<string>} Folder name
1491
+ */
1492
+ static async promptNewFolderName() {
1493
+ return await inquirer.input({
1494
+ message: 'Folder name:',
1495
+ validate: (input) => {
1496
+ if (!input || input.trim().length === 0) {
1497
+ return 'Folder name is required';
1498
+ }
1499
+ if (!/^[a-z0-9-]+$/.test(input)) {
1500
+ return 'Folder name must contain only lowercase letters, numbers, and hyphens';
1501
+ }
1502
+ return true;
1503
+ }
1504
+ });
1505
+ }
1506
+
1507
+ /**
1508
+ * Prompt user for conflict resolution
1509
+ *
1510
+ * @param {Array} conflicts - Array of conflicting theme names
1511
+ * @returns {Promise<string>} Selected action: 'skip', 'overwrite', 'skip-all', 'overwrite-all', or 'cancel'
1512
+ */
1513
+ static async promptConflictResolution(conflicts) {
1514
+ const isMultiple = conflicts.length > 1;
1515
+
1516
+ if (isMultiple) {
1517
+ const conflictList = conflicts.map(c => ` - ${c.name}`).join('\n');
1518
+ console.log(`\n${conflicts.length} themes already exist in your project:\n${conflictList}\n`);
1519
+
1520
+ return await inquirer.select({
1521
+ message: 'Apply to all conflicts?',
1522
+ choices: [
1523
+ { name: 'Skip all', value: 'skip-all' },
1524
+ { name: 'Overwrite all', value: 'overwrite-all' },
1525
+ { name: 'Choose for each', value: 'choose-each' },
1526
+ { name: 'Cancel', value: 'cancel' }
1527
+ ]
1528
+ });
1529
+ }
1530
+
1531
+ const conflict = conflicts[0];
1532
+ return await inquirer.select({
1533
+ message: `Theme "${conflict.name}" already exists. What would you like to do?`,
1534
+ choices: [
1535
+ { name: 'Skip (keep existing)', value: 'skip' },
1536
+ { name: 'Overwrite (replace with template version)', value: 'overwrite' },
1537
+ { name: 'Cancel (stop adding themes)', value: 'cancel' }
1538
+ ]
1539
+ });
1540
+ }
1541
+
1542
+ /**
1543
+ * Prompt user for single theme conflict resolution
1544
+ *
1545
+ * @param {string} themeName - Name of conflicting theme
1546
+ * @returns {Promise<string>} Selected action: 'skip', 'overwrite', or 'cancel'
1547
+ */
1548
+ static async promptSingleConflict(themeName) {
1549
+ return await inquirer.select({
1550
+ message: `Theme "${themeName}" already exists. What would you like to do?`,
1551
+ choices: [
1552
+ { name: 'Skip (keep existing)', value: 'skip' },
1553
+ { name: 'Overwrite (replace with template version)', value: 'overwrite' },
1554
+ { name: 'Cancel (stop adding themes)', value: 'cancel' }
1555
+ ]
1556
+ });
1557
+ }
1558
+
1559
+ /**
1560
+ * Confirm action with user
1561
+ *
1562
+ * @param {string} message - Confirmation message
1563
+ * @param {boolean} defaultValue - Default value (true: yes, false: no)
1564
+ * @returns {Promise<boolean>} User's choice
1565
+ */
1566
+ static async confirm(message, defaultValue = true) {
1567
+ return await inquirer.confirm({
1568
+ message,
1569
+ default: defaultValue
1570
+ });
1571
+ }
1572
+ }
1573
+
1574
+ module.exports = { Prompts };
1575
+ ```
1576
+
1577
+ **Step 3: Write mock-based tests**
1578
+
1579
+ ```javascript
1580
+ // tests/unit/prompts.test.js
1581
+ const { Prompts } = require('../../lib/prompts');
1582
+
1583
+ // Mock @inquirer/prompts
1584
+ jest.mock('@inquirer/prompts', () => ({
1585
+ checkbox: jest.fn(),
1586
+ select: jest.fn(),
1587
+ input: jest.fn(),
1588
+ confirm: jest.fn(),
1589
+ Separator: class Separator {}
1590
+ }));
1591
+
1592
+ const inquirer = require('@inquirer/prompts');
1593
+
1594
+ describe('Prompts', () => {
1595
+ beforeEach(() => {
1596
+ jest.clearAllMocks();
1597
+ });
1598
+
1599
+ describe('promptThemes', () => {
1600
+ test('should return selected themes', async () => {
1601
+ const availableThemes = [
1602
+ { name: 'beam', description: 'Beamer theme' },
1603
+ { name: 'gaia-dark', description: 'Dark gaia' }
1604
+ ];
1605
+ inquirer.checkbox.mockResolvedValue(['beam']);
1606
+
1607
+ const result = await Prompts.promptThemes(availableThemes);
1608
+ expect(result).toEqual(['beam']);
1609
+ expect(inquirer.checkbox).toHaveBeenCalledWith({
1610
+ message: 'Select themes to add:',
1611
+ choices: [
1612
+ { name: 'beam', value: 'beam', checked: false },
1613
+ { name: 'gaia-dark', value: 'gaia-dark', checked: false }
1614
+ ]
1615
+ });
1616
+ });
1617
+
1618
+ test('should return empty array for no available themes', async () => {
1619
+ const result = await Prompts.promptThemes([]);
1620
+ expect(result).toEqual([]);
1621
+ expect(inquirer.checkbox).not.toHaveBeenCalled();
1622
+ });
1623
+ });
1624
+
1625
+ describe('promptActiveTheme', () => {
1626
+ test('should return single theme if only one available', async () => {
1627
+ const result = await Prompts.promptActiveTheme(['only-theme']);
1628
+ expect(result).toBe('only-theme');
1629
+ expect(inquirer.select).not.toHaveBeenCalled();
1630
+ });
1631
+
1632
+ test('should prompt user if multiple themes', async () => {
1633
+ inquirer.select.mockResolvedValue('theme-a');
1634
+ const result = await Prompts.promptActiveTheme(['theme-a', 'theme-b']);
1635
+ expect(result).toBe('theme-a');
1636
+ });
1637
+
1638
+ test('should throw if no themes available', async () => {
1639
+ await expect(Prompts.promptActiveTheme([])).rejects.toThrow('No themes available');
1640
+ });
1641
+ });
1642
+
1643
+ describe('promptNewThemeName', () => {
1644
+ test('should return validated theme name', async () => {
1645
+ inquirer.input.mockResolvedValue('my-theme');
1646
+ const result = await Prompts.promptNewThemeName();
1647
+ expect(result).toBe('my-theme');
1648
+ });
1649
+
1650
+ test('should validate theme name format', async () => {
1651
+ inquirer.input.mockImplementation(({ validate }) => {
1652
+ const invalid = validate('Invalid Name!');
1653
+ expect(invalid).toBeTruthy();
1654
+ const valid = validate('valid-name');
1655
+ expect(valid).toBe(true);
1656
+ return Promise.resolve('valid-name');
1657
+ });
1658
+
1659
+ await Prompts.promptNewThemeName();
1660
+ });
1661
+ });
1662
+
1663
+ describe('promptParentTheme', () => {
1664
+ test('should show system and custom themes', async () => {
1665
+ const existingThemes = [
1666
+ { name: 'marpx', isSystem: false },
1667
+ { name: 'beam', isSystem: false }
1668
+ ];
1669
+ inquirer.select.mockResolvedValue('marpx');
1670
+
1671
+ const result = await Prompts.promptParentTheme(existingThemes);
1672
+ expect(result).toBe('marpx');
1673
+ });
1674
+
1675
+ test('should return null for "none" option', async () => {
1676
+ inquirer.select.mockResolvedValue(null);
1677
+ const result = await Prompts.promptParentTheme([]);
1678
+ expect(result).toBeNull();
1679
+ });
1680
+ });
1681
+
1682
+ describe('confirm', () => {
1683
+ test('should return boolean choice', async () => {
1684
+ inquirer.confirm.mockResolvedValue(true);
1685
+ const result = await Prompts.confirm('Continue?', false);
1686
+ expect(result).toBe(true);
1687
+ expect(inquirer.confirm).toHaveBeenCalledWith({
1688
+ message: 'Continue?',
1689
+ default: false
1690
+ });
1691
+ });
1692
+ });
1693
+ });
1694
+ ```
1695
+
1696
+ **Step 4: Run test to verify it passes**
1697
+
1698
+ Run: `npm test -- tests/unit/prompts.test.js`
1699
+ Expected: PASS
1700
+
1701
+ **Step 5: Commit**
1702
+
1703
+ ```bash
1704
+ git add lib/prompts.js tests/unit/prompts.test.js package.json package-lock.json
1705
+ git commit -m "feat: add Prompts class using @inquirer/prompts"
1706
+ ```
1707
+
1708
+ ---
1709
+
1710
+ ## Task 10: Implement ThemeManager class
1711
+
1712
+ **Files:**
1713
+ - Create: `lib/theme-manager.js`
1714
+ - Test: `tests/unit/theme-manager.test.js`
1715
+
1716
+ **Step 1: Write the failing test**
1717
+
1718
+ ```javascript
1719
+ // tests/unit/theme-manager.test.js
1720
+ const fs = require('fs');
1721
+ const path = require('path');
1722
+ const { ThemeManager } = require('../../lib/theme-manager');
1723
+ const { ThemeResolver, Theme } = require('../../lib/theme-resolver');
1724
+ const { VSCodeIntegration } = require('../../lib/vscode-integration');
1725
+ const { Frontmatter } = require('../../lib/frontmatter');
1726
+ const { ThemeNotFoundError, PresentationNotFoundError } = require('../../lib/errors');
1727
+
1728
+ describe('ThemeManager', () => {
1729
+ const tempDir = path.join(__dirname, '..', 'temp');
1730
+ const themesDir = path.join(tempDir, 'themes');
1731
+ const presentationPath = path.join(tempDir, 'presentation.md');
1732
+
1733
+ beforeEach(() => {
1734
+ if (!fs.existsSync(tempDir)) {
1735
+ fs.mkdirSync(tempDir, { recursive: true });
1736
+ }
1737
+ if (!fs.existsSync(themesDir)) {
1738
+ fs.mkdirSync(themesDir, { recursive: true });
1739
+ }
1740
+ // Create default presentation
1741
+ fs.writeFileSync(presentationPath, `---
1742
+ marp: true
1743
+ theme: default
1744
+ ---
1745
+
1746
+ # Presentation`);
1747
+ });
1748
+
1749
+ afterEach(() => {
1750
+ if (fs.existsSync(tempDir)) {
1751
+ fs.rmSync(tempDir, { recursive: true, force: true });
1752
+ }
1753
+ });
1754
+
1755
+ describe('constructor', () => {
1756
+ test('should create instance with project path', () => {
1757
+ const manager = new ThemeManager(tempDir);
1758
+ expect(manager.projectPath).toBe(tempDir);
1759
+ expect(manager.themesPath).toBe(themesDir);
1760
+ });
1761
+ });
1762
+
1763
+ describe('scanThemes', () => {
1764
+ test('should delegate to ThemeResolver.scanDirectory', () => {
1765
+ jest.spyOn(ThemeResolver, 'scanDirectory').mockReturnValue([]);
1766
+
1767
+ const manager = new ThemeManager(tempDir);
1768
+ const themes = manager.scanThemes();
1769
+
1770
+ expect(ThemeResolver.scanDirectory).toHaveBeenCalledWith(themesDir);
1771
+ expect(themes).toEqual([]);
1772
+ });
1773
+
1774
+ test('should return themes from directory', () => {
1775
+ fs.writeFileSync(path.join(themesDir, 'test.css'), '/* @theme test */');
1776
+
1777
+ const manager = new ThemeManager(tempDir);
1778
+ const themes = manager.scanThemes();
1779
+
1780
+ expect(themes).toHaveLength(1);
1781
+ expect(themes[0].name).toBe('test');
1782
+ });
1783
+ });
1784
+
1785
+ describe('getTheme', () => {
1786
+ test('should return theme by name', () => {
1787
+ fs.writeFileSync(path.join(themesDir, 'my-theme.css'), '/* @theme my-theme */');
1788
+
1789
+ const manager = new ThemeManager(tempDir);
1790
+ const theme = manager.getTheme('my-theme');
1791
+
1792
+ expect(theme).toBeInstanceOf(Theme);
1793
+ expect(theme.name).toBe('my-theme');
1794
+ });
1795
+
1796
+ test('should return null for non-existent theme', () => {
1797
+ const manager = new ThemeManager(tempDir);
1798
+ const theme = manager.getTheme('non-existent');
1799
+ expect(theme).toBeNull();
1800
+ });
1801
+ });
1802
+
1803
+ describe('getActiveTheme', () => {
1804
+ test('should return theme from presentation frontmatter', () => {
1805
+ const manager = new ThemeManager(tempDir);
1806
+ const theme = manager.getActiveTheme();
1807
+ expect(theme).toBe('default');
1808
+ });
1809
+
1810
+ test('should return null if no theme in frontmatter', () => {
1811
+ fs.writeFileSync(presentationPath, '# No frontmatter');
1812
+ const manager = new ThemeManager(tempDir);
1813
+ const theme = manager.getActiveTheme();
1814
+ expect(theme).toBeNull();
1815
+ });
1816
+
1817
+ test('should return null if presentation does not exist', () => {
1818
+ fs.unlinkSync(presentationPath);
1819
+ const manager = new ThemeManager(tempDir);
1820
+ const theme = manager.getActiveTheme();
1821
+ expect(theme).toBeNull();
1822
+ });
1823
+ });
1824
+
1825
+ describe('setActiveTheme', () => {
1826
+ test('should update theme in presentation frontmatter', () => {
1827
+ const manager = new ThemeManager(tempDir);
1828
+ manager.setActiveTheme('gaia');
1829
+
1830
+ const content = fs.readFileSync(presentationPath, 'utf-8');
1831
+ expect(content).toContain('theme: gaia');
1832
+ expect(content).not.toContain('theme: default');
1833
+ });
1834
+
1835
+ test('should throw ThemeNotFoundError for non-existent theme', () => {
1836
+ fs.writeFileSync(path.join(themesDir, 'available.css'), '/* @theme available */');
1837
+
1838
+ const manager = new ThemeManager(tempDir);
1839
+ expect(() => manager.setActiveTheme('non-existent')).toThrow(ThemeNotFoundError);
1840
+ });
1841
+
1842
+ test('should throw PresentationNotFoundError if presentation missing', () => {
1843
+ fs.unlinkSync(presentationPath);
1844
+ const manager = new ThemeManager(tempDir);
1845
+ expect(() => manager.setActiveTheme('default')).toThrow(PresentationNotFoundError);
1846
+ });
1847
+ });
1848
+
1849
+ describe('createTheme', () => {
1850
+ test('should create theme CSS file in root', () => {
1851
+ const manager = new ThemeManager(tempDir);
1852
+ manager.createTheme('new-theme', null, 'root');
1853
+
1854
+ const themePath = path.join(themesDir, 'new-theme.css');
1855
+ expect(fs.existsSync(themePath)).toBe(true);
1856
+
1857
+ const content = fs.readFileSync(themePath, 'utf-8');
1858
+ expect(content).toContain('/* @theme new-theme */');
1859
+ });
1860
+
1861
+ test('should create theme with parent import', () => {
1862
+ const manager = new ThemeManager(tempDir);
1863
+ manager.createTheme('child', 'gaia', 'root');
1864
+
1865
+ const content = fs.readFileSync(path.join(themesDir, 'child.css'), 'utf-8');
1866
+ expect(content).toContain('@import "gaia"');
1867
+ });
1868
+
1869
+ test('should create theme in new folder', () => {
1870
+ const manager = new ThemeManager(tempDir);
1871
+ manager.createTheme('foldered', null, 'new', 'my-folder');
1872
+
1873
+ const themePath = path.join(themesDir, 'my-folder', 'foldered.css');
1874
+ expect(fs.existsSync(themePath)).toBe(true);
1875
+ });
1876
+
1877
+ test('should throw for duplicate theme name', () => {
1878
+ fs.writeFileSync(path.join(themesDir, 'existing.css'), '/* @theme existing */');
1879
+
1880
+ const manager = new ThemeManager(tempDir);
1881
+ expect(() => manager.createTheme('existing', null, 'root')).toThrow();
1882
+ });
1883
+ });
1884
+
1885
+ describe('updateVSCodeSettings', () => {
1886
+ test('should sync themes to VSCode settings', () => {
1887
+ fs.writeFileSync(path.join(themesDir, 'theme1.css'), '/* @theme theme1 */');
1888
+ fs.writeFileSync(path.join(themesDir, 'theme2.css'), '/* @theme theme2 */');
1889
+
1890
+ const manager = new ThemeManager(tempDir);
1891
+ manager.updateVSCodeSettings();
1892
+
1893
+ const settingsPath = path.join(tempDir, '.vscode', 'settings.json');
1894
+ expect(fs.existsSync(settingsPath)).toBe(true);
1895
+
1896
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
1897
+ expect(settings['markdown.marp.themes']).toContain('themes/theme1.css');
1898
+ expect(settings['markdown.marp.themes']).toContain('themes/theme2.css');
1899
+ });
1900
+ });
1901
+ });
1902
+ ```
1903
+
1904
+ **Step 2: Run test to verify it fails**
1905
+
1906
+ Run: `npm test -- tests/unit/theme-manager.test.js`
1907
+ Expected: FAIL with "Cannot find module '../../lib/theme-manager'"
1908
+
1909
+ **Step 3: Write minimal implementation**
1910
+
1911
+ ```javascript
1912
+ // lib/theme-manager.js
1913
+ const fs = require('fs');
1914
+ const path = require('path');
1915
+ const { ThemeResolver } = require('./theme-resolver');
1916
+ const { VSCodeIntegration } = require('./vscode-integration');
1917
+ const { Frontmatter } = require('./frontmatter');
1918
+ const {
1919
+ ThemeNotFoundError,
1920
+ ThemeAlreadyExistsError,
1921
+ PresentationNotFoundError
1922
+ } = require('./errors');
1923
+
1924
+ /**
1925
+ * Main class for theme operations
1926
+ */
1927
+ class ThemeManager {
1928
+ constructor(projectPath) {
1929
+ this.projectPath = projectPath;
1930
+ this.themesPath = path.join(projectPath, 'themes');
1931
+ this.presentationPath = path.join(projectPath, 'presentation.md');
1932
+ }
1933
+
1934
+ /**
1935
+ * Scan themes in project
1936
+ * IMPLEMENTATION: delegates to ThemeResolver.scanDirectory(this.themesPath)
1937
+ */
1938
+ scanThemes() {
1939
+ return ThemeResolver.scanDirectory(this.themesPath);
1940
+ }
1941
+
1942
+ /**
1943
+ * Get theme by name
1944
+ *
1945
+ * @param {string} name - Theme name
1946
+ * @returns {Theme|null} Theme object or null if not found
1947
+ */
1948
+ getTheme(name) {
1949
+ const themes = this.scanThemes();
1950
+ return themes.find(theme => theme.name === name) || null;
1951
+ }
1952
+
1953
+ /**
1954
+ * Get active theme from presentation.md
1955
+ *
1956
+ * @returns {string|null} Theme name or null if not found
1957
+ */
1958
+ getActiveTheme() {
1959
+ if (!fs.existsSync(this.presentationPath)) {
1960
+ return null;
1961
+ }
1962
+
1963
+ const content = fs.readFileSync(this.presentationPath, 'utf-8');
1964
+ return Frontmatter.getTheme(content);
1965
+ }
1966
+
1967
+ /**
1968
+ * Set active theme in presentation.md
1969
+ *
1970
+ * @param {string} themeName - Theme name to set
1971
+ * @throws {ThemeNotFoundError} If theme does not exist
1972
+ * @throws {PresentationNotFoundError} If presentation.md does not exist
1973
+ */
1974
+ setActiveTheme(themeName) {
1975
+ if (!fs.existsSync(this.presentationPath)) {
1976
+ throw new PresentationNotFoundError(this.presentationPath);
1977
+ }
1978
+
1979
+ // Validate theme exists
1980
+ const availableThemes = this.scanThemes().map(t => t.name);
1981
+ const systemThemes = ['default', 'gaia', 'uncover'];
1982
+ const allThemes = [...availableThemes, ...systemThemes];
1983
+
1984
+ if (!allThemes.includes(themeName)) {
1985
+ throw new ThemeNotFoundError(themeName);
1986
+ }
1987
+
1988
+ const content = fs.readFileSync(this.presentationPath, 'utf-8');
1989
+ const updated = Frontmatter.setTheme(content, themeName);
1990
+ Frontmatter.writeToFile(this.presentationPath, updated);
1991
+ }
1992
+
1993
+ /**
1994
+ * Create new theme
1995
+ *
1996
+ * @param {string} name - Theme name
1997
+ * @param {string|null} parent - Parent theme name or null
1998
+ * @param {string} location - 'root', 'existing' folder name, or 'new'
1999
+ * @param {string} newFolderName - Required if location is 'new'
2000
+ */
2001
+ createTheme(name, parent, location, newFolderName = null) {
2002
+ // Check for duplicate
2003
+ if (this.getTheme(name)) {
2004
+ throw new ThemeAlreadyExistsError(name);
2005
+ }
2006
+
2007
+ let cssPath;
2008
+ if (location === 'root') {
2009
+ cssPath = path.join(this.themesPath, `${name}.css`);
2010
+ } else if (location === 'new') {
2011
+ if (!newFolderName) {
2012
+ throw new Error('newFolderName required when location is "new"');
2013
+ }
2014
+ const folderPath = path.join(this.themesPath, newFolderName);
2015
+ fs.mkdirSync(folderPath, { recursive: true });
2016
+ cssPath = path.join(folderPath, `${name}.css`);
2017
+ } else {
2018
+ // Existing folder
2019
+ cssPath = path.join(this.themesPath, location, `${name}.css`);
2020
+ }
2021
+
2022
+ // Generate CSS content
2023
+ let css = `/* @theme ${name} */\n\n`;
2024
+
2025
+ if (parent) {
2026
+ css += `@import "${parent}";\n\n`;
2027
+ }
2028
+
2029
+ css += `:root {\n /* Your theme variables */\n}\n`;
2030
+
2031
+ fs.writeFileSync(cssPath, css, 'utf-8');
2032
+
2033
+ // Update VSCode settings
2034
+ this.updateVSCodeSettings();
2035
+ }
2036
+
2037
+ /**
2038
+ * Update VSCode settings with current themes
2039
+ */
2040
+ updateVSCodeSettings() {
2041
+ const themes = this.scanThemes();
2042
+ const vscode = new VSCodeIntegration(this.projectPath);
2043
+ vscode.syncThemes(themes);
2044
+ }
2045
+ }
2046
+
2047
+ module.exports = { ThemeManager };
2048
+ ```
2049
+
2050
+ **Step 4: Run test to verify it passes**
2051
+
2052
+ Run: `npm test -- tests/unit/theme-manager.test.js`
2053
+ Expected: PASS
2054
+
2055
+ **Step 5: Commit**
2056
+
2057
+ ```bash
2058
+ git add lib/theme-manager.js tests/unit/theme-manager.test.js
2059
+ git commit -m "feat: add ThemeManager class with theme operations"
2060
+ ```
2061
+
2062
+ ---
2063
+
2064
+ ## Task 11: Implement addThemesCommand shared function
2065
+
2066
+ **Files:**
2067
+ - Create: `lib/add-themes-command.js`
2068
+ - Test: `tests/unit/add-themes-command.test.js`
2069
+
2070
+ **Step 1: Write the failing test**
2071
+
2072
+ ```javascript
2073
+ // tests/unit/add-themes-command.test.js
2074
+ const fs = require('fs');
2075
+ const path = require('path');
2076
+ const { addThemesCommand } = require('../../lib/add-themes-command');
2077
+ const { ThemeResolver } = require('../../lib/theme-resolver');
2078
+ const { VSCodeIntegration } = require('../../lib/vscode-integration');
2079
+ const { Prompts } = require('../../lib/prompts');
2080
+
2081
+ describe('addThemesCommand', () => {
2082
+ const tempDir = path.join(__dirname, '..', 'temp');
2083
+ const templateThemesDir = path.join(__dirname, '..', 'fixtures', 'template-themes');
2084
+ const projectThemesDir = path.join(tempDir, 'themes');
2085
+
2086
+ beforeEach(() => {
2087
+ if (!fs.existsSync(tempDir)) {
2088
+ fs.mkdirSync(tempDir, { recursive: true });
2089
+ }
2090
+ if (!fs.existsSync(projectThemesDir)) {
2091
+ fs.mkdirSync(projectThemesDir, { recursive: true });
2092
+ }
2093
+ // Create template themes fixtures
2094
+ if (!fs.existsSync(templateThemesDir)) {
2095
+ fs.mkdirSync(templateThemesDir, { recursive: true });
2096
+ fs.writeFileSync(
2097
+ path.join(templateThemesDir, 'theme1.css'),
2098
+ '/* @theme theme1 */\n@import "default";'
2099
+ );
2100
+ fs.mkdirSync(path.join(templateThemesDir, 'folder'));
2101
+ fs.writeFileSync(
2102
+ path.join(templateThemesDir, 'folder', 'theme2.css'),
2103
+ '/* @theme theme2 */'
2104
+ );
2105
+ }
2106
+ });
2107
+
2108
+ afterEach(() => {
2109
+ if (fs.existsSync(tempDir)) {
2110
+ fs.rmSync(tempDir, { recursive: true, force: true });
2111
+ }
2112
+ });
2113
+
2114
+ describe('getTemplateThemesPath', () => {
2115
+ test('should return path to template themes directory', () => {
2116
+ const result = addThemesCommand.getTemplateThemesPath();
2117
+ expect(result).toBeTruthy();
2118
+ expect(fs.existsSync(result)).toBe(true);
2119
+ });
2120
+ });
2121
+
2122
+ describe('execute', () => {
2123
+ test('should copy selected themes to project', async () => {
2124
+ jest.spyOn(Prompts, 'promptThemes').mockResolvedValue(['theme1']);
2125
+ jest.spyOn(ThemeResolver, 'scanDirectory').mockImplementation((dir) => {
2126
+ if (dir.includes('template-themes')) {
2127
+ return [
2128
+ new Theme('theme1', path.join(templateThemesDir, 'theme1.css'), 'css', ['default'])
2129
+ ];
2130
+ }
2131
+ return [];
2132
+ });
2133
+
2134
+ const result = await addThemesCommand.execute(tempDir);
2135
+
2136
+ expect(result.added).toHaveLength(1);
2137
+ expect(result.added[0].name).toBe('theme1');
2138
+ });
2139
+
2140
+ test('should resolve dependencies', async () => {
2141
+ jest.spyOn(Prompts, 'promptThemes').mockResolvedValue(['child']);
2142
+ jest.spyOn(ThemeResolver, 'scanDirectory').mockImplementation((dir) => {
2143
+ if (dir.includes('template-themes')) {
2144
+ return [
2145
+ new Theme('parent', path.join(templateThemesDir, 'parent.css'), 'css', ['default']),
2146
+ new Theme('child', path.join(templateThemesDir, 'child.css'), 'css', ['parent'])
2147
+ ];
2148
+ }
2149
+ return [];
2150
+ });
2151
+
2152
+ const result = await addThemesCommand.execute(tempDir);
2153
+
2154
+ expect(result.added.map(t => t.name)).toContain('parent');
2155
+ expect(result.added.map(t => t.name)).toContain('child');
2156
+ });
2157
+
2158
+ test('should skip system themes', async () => {
2159
+ jest.spyOn(Prompts, 'promptThemes').mockResolvedValue(['uses-gaia']);
2160
+ jest.spyOn(ThemeResolver, 'scanDirectory').mockReturnValue([
2161
+ new Theme('uses-gaia', path.join(templateThemesDir, 'uses.css'), 'css', ['gaia'])
2162
+ ]);
2163
+
2164
+ const result = await addThemesCommand.execute(tempDir);
2165
+
2166
+ expect(result.added.map(t => t.name)).toContain('uses-gaia');
2167
+ expect(result.added.map(t => t.name)).not.toContain('gaia');
2168
+ });
2169
+
2170
+ test('should use options.themes if provided (non-interactive)', async () => {
2171
+ jest.spyOn(Prompts, 'promptThemes').mockImplementation(() => {
2172
+ throw new Error('Should not prompt in non-interactive mode');
2173
+ });
2174
+
2175
+ const result = await addThemesCommand.execute(tempDir, { themes: ['theme1'] });
2176
+
2177
+ // Verify prompt was not called
2178
+ expect(Prompts.promptThemes).not.toHaveBeenCalled();
2179
+ });
2180
+ });
2181
+
2182
+ describe('copyThemes', () => {
2183
+ test('should copy themes preserving structure', () => {
2184
+ const themes = [
2185
+ new Theme('theme1', path.join(templateThemesDir, 'theme1.css'), 'css', []),
2186
+ new Theme('theme2', path.join(templateThemesDir, 'folder', 'theme2.css'), 'css', [])
2187
+ ];
2188
+
2189
+ addThemesCommand.copyThemes(themes, templateThemesDir, tempDir);
2190
+
2191
+ expect(fs.existsSync(path.join(projectThemesDir, 'theme1.css'))).toBe(true);
2192
+ expect(fs.existsSync(path.join(projectThemesDir, 'folder', 'theme2.css'))).toBe(true);
2193
+ });
2194
+
2195
+ test('should skip existing themes if specified', () => {
2196
+ const themes = [
2197
+ new Theme('theme1', path.join(templateThemesDir, 'theme1.css'), 'css', [])
2198
+ ];
2199
+
2200
+ // Create existing theme
2201
+ fs.writeFileSync(path.join(projectThemesDir, 'theme1.css'), '/* @theme theme1 */ old content');
2202
+
2203
+ const skipList = ['theme1'];
2204
+ addThemesCommand.copyThemes(themes, templateThemesDir, tempDir, skipList);
2205
+
2206
+ const content = fs.readFileSync(path.join(projectThemesDir, 'theme1.css'), 'utf-8');
2207
+ expect(content).toContain('old content');
2208
+ });
2209
+ });
2210
+ });
2211
+ ```
2212
+
2213
+ **Step 2: Run test to verify it fails**
2214
+
2215
+ Run: `npm test -- tests/unit/add-themes-command.test.js`
2216
+ Expected: FAIL with "Cannot find module '../../lib/add-themes-command'"
2217
+
2218
+ **Step 3: Write minimal implementation**
2219
+
2220
+ ```javascript
2221
+ // lib/add-themes-command.js
2222
+ const fs = require('fs');
2223
+ const path = require('path');
2224
+ const { ThemeResolver } = require('./theme-resolver');
2225
+ const { VSCodeIntegration } = require('./vscode-integration');
2226
+ const { Prompts } = require('./prompts');
2227
+
2228
+ /**
2229
+ * Shared function for adding themes from template
2230
+ * Used by both project creation and theme:add-from-template command
2231
+ */
2232
+ class AddThemesCommand {
2233
+ /**
2234
+ * Get path to template themes directory
2235
+ * Works both when running from source and from installed npm package
2236
+ */
2237
+ static getTemplateThemesPath() {
2238
+ // Try multiple possible paths
2239
+ const candidates = [
2240
+ // When running from source (development)
2241
+ path.join(__dirname, '..', 'template', 'themes'),
2242
+ // When installed as npm package
2243
+ path.join(__dirname, 'template', 'themes'),
2244
+ // When running from installed package's lib directory
2245
+ path.join(__dirname, '..', '..', 'template', 'themes'),
2246
+ ];
2247
+
2248
+ for (const candidate of candidates) {
2249
+ if (fs.existsSync(candidate)) {
2250
+ return candidate;
2251
+ }
2252
+ }
2253
+
2254
+ throw new Error('Template themes directory not found');
2255
+ }
2256
+
2257
+ /**
2258
+ * Execute the add-themes command
2259
+ *
2260
+ * @param {string} targetPath - Target project path
2261
+ * @param {object} options - Options
2262
+ * @param {string[]} options.themes - Pre-selected themes (non-interactive mode)
2263
+ * @returns {Promise<object>} Result with { added, skipped, dependencies }
2264
+ */
2265
+ static async execute(targetPath, options = {}) {
2266
+ const templatePath = this.getTemplateThemesPath();
2267
+
2268
+ // Scan available themes in template
2269
+ const allThemes = ThemeResolver.scanDirectory(templatePath);
2270
+
2271
+ if (allThemes.length === 0) {
2272
+ console.log('No themes available in template');
2273
+ return { added: [], skipped: [], dependencies: [] };
2274
+ }
2275
+
2276
+ // Select themes
2277
+ let selectedThemes;
2278
+ if (options.themes && options.themes.length > 0) {
2279
+ // Non-interactive mode
2280
+ selectedThemes = allThemes.filter(t => options.themes.includes(t.name));
2281
+ } else {
2282
+ // Interactive mode
2283
+ const themeChoices = allThemes.map(t => ({
2284
+ name: t.name,
2285
+ description: `Extends: ${t.dependencies.join(', ') || 'none'}`
2286
+ }));
2287
+ const selectedNames = await Prompts.promptThemes(themeChoices);
2288
+ selectedThemes = allThemes.filter(t => selectedNames.includes(t.name));
2289
+ }
2290
+
2291
+ if (selectedThemes.length === 0) {
2292
+ console.log('No themes selected');
2293
+ return { added: [], skipped: [], dependencies: [] };
2294
+ }
2295
+
2296
+ // Resolve dependencies
2297
+ const themesToCopy = ThemeResolver.resolveDependencies(selectedThemes, allThemes);
2298
+
2299
+ // Check for conflicts
2300
+ const existingThemes = this._scanProjectThemes(targetPath);
2301
+ const conflicts = this._findConflicts(themesToCopy, existingThemes);
2302
+
2303
+ let skipList = [];
2304
+ if (conflicts.length > 0) {
2305
+ const resolution = await Prompts.promptConflictResolution(conflicts);
2306
+
2307
+ if (resolution === 'cancel') {
2308
+ console.log('Operation cancelled');
2309
+ return { added: [], skipped: [], dependencies: [] };
2310
+ } else if (resolution === 'skip-all') {
2311
+ skipList = conflicts.map(c => c.name);
2312
+ } else if (resolution === 'overwrite-all') {
2313
+ // No skipping
2314
+ } else if (resolution === 'choose-each') {
2315
+ // Handle each conflict individually
2316
+ for (const conflict of conflicts) {
2317
+ const action = await Prompts.promptSingleConflict(conflict.name);
2318
+ if (action === 'skip') {
2319
+ skipList.push(conflict.name);
2320
+ } else if (action === 'cancel') {
2321
+ console.log('Operation cancelled');
2322
+ return { added: [], skipped: [], dependencies: [] };
2323
+ }
2324
+ }
2325
+ }
2326
+ }
2327
+
2328
+ // Filter out skipped themes
2329
+ const finalThemes = themesToCopy.filter(t => !skipList.includes(t.name));
2330
+
2331
+ // Copy themes
2332
+ this.copyThemes(finalThemes, templatePath, targetPath, skipList);
2333
+
2334
+ // Update VSCode settings
2335
+ const allProjectThemes = this._scanProjectThemes(targetPath);
2336
+ const vscode = new VSCodeIntegration(targetPath);
2337
+ vscode.syncThemes(allProjectThemes);
2338
+
2339
+ // Calculate result
2340
+ const added = finalThemes;
2341
+ const skipped = themesToCopy.filter(t => skipList.includes(t.name));
2342
+ const dependencies = themesToCopy.filter(t => !selectedThemes.find(st => st.name === t.name));
2343
+
2344
+ console.log(`✓ Added ${added.length} theme${added.length !== 1 ? 's' : ''} with dependencies`);
2345
+ added.forEach(theme => {
2346
+ const relPath = path.relative(targetPath, theme.path);
2347
+ console.log(` - ${theme.name} (${relPath})`);
2348
+ });
2349
+
2350
+ if (skipped.length > 0) {
2351
+ console.log(`✓ Skipped ${skipped.length} theme${skipped.length !== 1 ? 's' : ''}`);
2352
+ skipped.forEach(theme => {
2353
+ console.log(` - ${theme.name} (already exists)`);
2354
+ });
2355
+ }
2356
+
2357
+ console.log('✓ VSCode settings updated');
2358
+
2359
+ return { added, skipped, dependencies };
2360
+ }
2361
+
2362
+ /**
2363
+ * Copy themes to project directory
2364
+ *
2365
+ * @param {Theme[]} themes - Themes to copy
2366
+ * @param {string} templatePath - Template themes path
2367
+ * @param {string} targetPath - Target project path
2368
+ * @param {string[]} skipList - Theme names to skip
2369
+ */
2370
+ static copyThemes(themes, templatePath, targetPath, skipList = []) {
2371
+ const themesPath = path.join(targetPath, 'themes');
2372
+
2373
+ for (const theme of themes) {
2374
+ if (skipList.includes(theme.name)) {
2375
+ continue;
2376
+ }
2377
+
2378
+ const relativePath = path.relative(templatePath, theme.path);
2379
+ const targetFilePath = path.join(themesPath, relativePath);
2380
+ const targetDir = path.dirname(targetFilePath);
2381
+
2382
+ // Create directory if needed
2383
+ if (!fs.existsSync(targetDir)) {
2384
+ fs.mkdirSync(targetDir, { recursive: true });
2385
+ }
2386
+
2387
+ // Copy file
2388
+ fs.copyFileSync(theme.path, targetFilePath);
2389
+ }
2390
+ }
2391
+
2392
+ /**
2393
+ * Scan themes in project directory
2394
+ * @private
2395
+ */
2396
+ static _scanProjectThemes(projectPath) {
2397
+ const themesPath = path.join(projectPath, 'themes');
2398
+ if (!fs.existsSync(themesPath)) {
2399
+ return [];
2400
+ }
2401
+ return ThemeResolver.scanDirectory(themesPath);
2402
+ }
2403
+
2404
+ /**
2405
+ * Find conflicts between themes to copy and existing themes
2406
+ * @private
2407
+ */
2408
+ static _findConflicts(themesToCopy, existingThemes) {
2409
+ const existingNames = new Set(existingThemes.map(t => t.name));
2410
+ return themesToCopy.filter(t => existingNames.has(t.name));
2411
+ }
2412
+ }
2413
+
2414
+ // Export both class and convenience function
2415
+ const addThemesCommand = {
2416
+ execute: (targetPath, options) => AddThemesCommand.execute(targetPath, options),
2417
+ getTemplateThemesPath: () => AddThemesCommand.getTemplateThemesPath(),
2418
+ copyThemes: (themes, templatePath, targetPath, skipList) =>
2419
+ AddThemesCommand.copyThemes(themes, templatePath, targetPath, skipList)
2420
+ };
2421
+
2422
+ module.exports = { AddThemesCommand, addThemesCommand };
2423
+ ```
2424
+
2425
+ **Step 4: Run test to verify it passes**
2426
+
2427
+ Run: `npm test -- tests/unit/add-themes-command.test.js`
2428
+ Expected: PASS
2429
+
2430
+ **Step 5: Commit**
2431
+
2432
+ ```bash
2433
+ git add lib/add-themes-command.js tests/unit/add-themes-command.test.js
2434
+ git commit -m "feat: add addThemesCommand shared function"
2435
+ ```
2436
+
2437
+ ---
2438
+
2439
+ ## Task 12: Create template/themes directory and copy theme templates
2440
+
2441
+ **Files:**
2442
+ - Create: `template/themes/beam/beam.css`
2443
+ - Create: `template/themes/marpx/marpx.css`
2444
+ - Create: `template/themes/marpx/socrates.css`
2445
+ - Create: `template/themes/gaia-dark/dark.css`
2446
+ - Create: `template/themes/uncover-minimal/minimal.css`
2447
+ - Create: `template/themes/default-clean/clean.css`
2448
+
2449
+ **Step 1: Create theme directories**
2450
+
2451
+ ```bash
2452
+ mkdir -p template/themes/beam
2453
+ mkdir -p template/themes/marpx
2454
+ mkdir -p template/themes/gaia-dark
2455
+ mkdir -p template/themes/uncover-minimal
2456
+ mkdir -p template/themes/default-clean
2457
+ ```
2458
+
2459
+ **Step 2: Copy theme CSS files from docs/reqs/theme-templates**
2460
+
2461
+ ```bash
2462
+ # Copy beam theme
2463
+ cp docs/reqs/theme-templates/beam/beam.css template/themes/beam/
2464
+
2465
+ # Copy marpx themes
2466
+ cp docs/reqs/theme-templates/marpx/marpx.css template/themes/marpx/
2467
+ cp docs/reqs/theme-templates/marpx/socrates.css template/themes/marpx/
2468
+
2469
+ # For gaia-dark, uncover-minimal, default-clean - create simple variants
2470
+ ```
2471
+
2472
+ **Step 3: Create gaia-dark theme**
2473
+
2474
+ ```css
2475
+ /* @theme gaia-dark */
2476
+
2477
+ @import "gaia";
2478
+
2479
+ :root {
2480
+ --marp-theme-gradient: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
2481
+ --marp-theme-color: #eee;
2482
+ --marp-heading-color: #4ecca3;
2483
+ --marp-text-color: #ddd;
2484
+ --marp-background-color: #0f0f1a;
2485
+ }
2486
+ ```
2487
+
2488
+ **Step 4: Create uncover-minimal theme**
2489
+
2490
+ ```css
2491
+ /* @theme uncover-minimal */
2492
+
2493
+ @import "uncover";
2494
+
2495
+ :root {
2496
+ --marp-uncover-color: #333;
2497
+ --marp-uncover-background: #fff;
2498
+ --marp-uncover-highlight: #007acc;
2499
+ }
2500
+ ```
2501
+
2502
+ **Step 5: Create default-clean theme**
2503
+
2504
+ ```css
2505
+ /* @theme default-clean */
2506
+
2507
+ @import "default";
2508
+
2509
+ :root {
2510
+ --color-background: #ffffff;
2511
+ --color-text: #333333;
2512
+ --color-heading: #1a1a1a;
2513
+ --color-link: #0066cc;
2514
+ --font-family: "Inter", -apple-system, sans-serif;
2515
+ }
2516
+ ```
2517
+
2518
+ **Step 6: Verify themes are parseable**
2519
+
2520
+ ```javascript
2521
+ // Quick verification
2522
+ const { ThemeResolver } = require('./lib/theme-resolver');
2523
+ const themes = ThemeResolver.scanDirectory('template/themes');
2524
+ console.log('Found themes:', themes.map(t => t.name));
2525
+ ```
2526
+
2527
+ **Step 7: Commit**
2528
+
2529
+ ```bash
2530
+ git add template/themes/
2531
+ git commit -m "feat: add initial theme templates from docs/reqs"
2532
+ ```
2533
+
2534
+ ---
2535
+
2536
+ ## Task 13: Create template/scripts/lib/ directory
2537
+
2538
+ **Files:**
2539
+ - Copy: `lib/theme-resolver.js` → `template/scripts/lib/theme-resolver.js`
2540
+ - Copy: `lib/theme-manager.js` → `template/scripts/lib/theme-manager.js`
2541
+ - Copy: `lib/vscode-integration.js` → `template/scripts/lib/vscode-integration.js`
2542
+ - Copy: `lib/frontmatter.js` → `template/scripts/lib/frontmatter.js`
2543
+ - Copy: `lib/prompts.js` → `template/scripts/lib/prompts.js`
2544
+ - Copy: `lib/errors.js` → `template/scripts/lib/errors.js`
2545
+
2546
+ **Step 1: Create scripts/lib directory**
2547
+
2548
+ ```bash
2549
+ mkdir -p template/scripts/lib
2550
+ ```
2551
+
2552
+ **Step 2: Copy lib files to template/scripts/lib**
2553
+
2554
+ ```bash
2555
+ cp lib/errors.js template/scripts/lib/
2556
+ cp lib/theme-resolver.js template/scripts/lib/
2557
+ cp lib/theme-manager.js template/scripts/lib/
2558
+ cp lib/vscode-integration.js template/scripts/lib/
2559
+ cp lib/frontmatter.js template/scripts/lib/
2560
+ cp lib/prompts.js template/scripts/lib/
2561
+ ```
2562
+
2563
+ **Step 3: Verify files were copied correctly**
2564
+
2565
+ ```bash
2566
+ ls -la template/scripts/lib/
2567
+ # Should see: errors.js, theme-resolver.js, theme-manager.js, vscode-integration.js, frontmatter.js, prompts.js
2568
+ ```
2569
+
2570
+ **Step 4: Update root package.json files field to include template/scripts/lib**
2571
+
2572
+ ```json
2573
+ {
2574
+ "files": [
2575
+ "index.js",
2576
+ "template",
2577
+ "lib"
2578
+ ]
2579
+ }
2580
+ ```
2581
+
2582
+ **Step 5: Commit**
2583
+
2584
+ ```bash
2585
+ git add template/scripts/lib/ package.json
2586
+ git commit -m "feat: copy lib modules to template/scripts/lib for project CLI"
2587
+ ```
2588
+
2589
+ ---
2590
+
2591
+ ## Task 14: Create theme-cli.js entry point
2592
+
2593
+ **Files:**
2594
+ - Create: `template/scripts/theme-cli.js`
2595
+
2596
+ **Step 1: Write the implementation**
2597
+
2598
+ ```javascript
2599
+ // template/scripts/theme-cli.js
2600
+ #!/usr/bin/env node
2601
+
2602
+ const { ThemeManager } = require('./lib/theme-manager');
2603
+ const { Prompts } = require('./lib/prompts');
2604
+ const { execSync } = require('child_process');
2605
+
2606
+ const command = process.argv[2];
2607
+ const args = process.argv.slice(3);
2608
+
2609
+ async function main() {
2610
+ const projectPath = process.cwd();
2611
+ const manager = new ThemeManager(projectPath);
2612
+
2613
+ try {
2614
+ switch (command) {
2615
+ case 'list': {
2616
+ await cmdList(manager);
2617
+ break;
2618
+ }
2619
+
2620
+ case 'add': {
2621
+ await cmdAdd(manager);
2622
+ break;
2623
+ }
2624
+
2625
+ case 'add-from-template': {
2626
+ await cmdAddFromTemplate(projectPath);
2627
+ break;
2628
+ }
2629
+
2630
+ case 'switch': {
2631
+ await cmdSwitch(manager, args);
2632
+ break;
2633
+ }
2634
+
2635
+ default:
2636
+ console.log(`Unknown command: ${command}`);
2637
+ console.log('');
2638
+ console.log('Available commands:');
2639
+ console.log(' list - List all themes in project');
2640
+ console.log(' add - Create new theme');
2641
+ console.log(' add-from-template - Add themes from template');
2642
+ console.log(' switch [<name>] - Switch active theme');
2643
+ process.exit(1);
2644
+ }
2645
+ } catch (error) {
2646
+ console.error(`Error: ${error.message}`);
2647
+ process.exit(1);
2648
+ }
2649
+ }
2650
+
2651
+ async function cmdList(manager) {
2652
+ const themes = manager.scanThemes();
2653
+
2654
+ if (themes.length === 0) {
2655
+ console.log('No themes found in project');
2656
+ return;
2657
+ }
2658
+
2659
+ console.log('Themes in project:');
2660
+ for (const theme of themes) {
2661
+ const deps = theme.dependencies.length > 0
2662
+ ? ` extends: ${theme.dependencies.join(', ')}`
2663
+ : '';
2664
+ const relPath = theme.path.replace(process.cwd() + '/', '');
2665
+ console.log(` ✓ ${theme.name.padEnd(20)} (${relPath})${deps}`);
2666
+ }
2667
+
2668
+ const activeTheme = manager.getActiveTheme();
2669
+ if (activeTheme) {
2670
+ console.log(`\nActive theme: ${activeTheme}`);
2671
+ }
2672
+ }
2673
+
2674
+ async function cmdAdd(manager) {
2675
+ const themes = manager.scanThemes();
2676
+
2677
+ // Prompt for theme name
2678
+ const name = await Prompts.promptNewThemeName();
2679
+
2680
+ // Check if already exists
2681
+ if (manager.getTheme(name)) {
2682
+ console.error(`Theme '${name}' already exists`);
2683
+ process.exit(1);
2684
+ }
2685
+
2686
+ // Select parent theme
2687
+ const parent = await Prompts.promptParentTheme(themes);
2688
+
2689
+ // Get existing directories
2690
+ const fs = require('fs');
2691
+ const themesPath = manager.themesPath;
2692
+ let existingDirs = [];
2693
+ if (fs.existsSync(themesPath)) {
2694
+ const entries = fs.readdirSync(themesPath, { withFileTypes: true });
2695
+ existingDirs = entries
2696
+ .filter(e => e.isDirectory() && !e.name.startsWith('.'))
2697
+ .map(e => e.name);
2698
+ }
2699
+
2700
+ // Prompt for location
2701
+ const location = await Prompts.promptDirectoryLocation(existingDirs);
2702
+ let newFolderName = null;
2703
+
2704
+ if (location === 'new') {
2705
+ newFolderName = await Prompts.promptNewFolderName();
2706
+ }
2707
+
2708
+ // Create theme
2709
+ manager.createTheme(name, parent, location, newFolderName);
2710
+
2711
+ console.log(`✓ Created theme '${name}'`);
2712
+ console.log(`✓ Theme registered in VSCode settings`);
2713
+ }
2714
+
2715
+ async function cmdAddFromTemplate(projectPath) {
2716
+ // Delegate to npx command
2717
+ try {
2718
+ execSync(`npx create-marp-presentation add-themes "${projectPath}"`, {
2719
+ stdio: 'inherit'
2720
+ });
2721
+ } catch (error) {
2722
+ console.error('Failed to add themes from template');
2723
+ throw error;
2724
+ }
2725
+ }
2726
+
2727
+ async function cmdSwitch(manager, args) {
2728
+ const themes = manager.scanThemes();
2729
+ const availableThemes = [
2730
+ ...new Set([
2731
+ 'default',
2732
+ 'gaia',
2733
+ 'uncover',
2734
+ ...themes.map(t => t.name)
2735
+ ])
2736
+ ];
2737
+
2738
+ let themeName;
2739
+
2740
+ if (args.length > 0) {
2741
+ // Command line argument provided
2742
+ themeName = args[0];
2743
+ } else {
2744
+ // Interactive prompt
2745
+ themeName = await Prompts.promptActiveTheme(availableThemes);
2746
+ }
2747
+
2748
+ manager.setActiveTheme(themeName);
2749
+ console.log(`✓ Active theme changed to '${themeName}'`);
2750
+ console.log(` Updated presentation.md frontmatter`);
2751
+ }
2752
+
2753
+ main().catch(error => {
2754
+ console.error(error);
2755
+ process.exit(1);
2756
+ });
2757
+ ```
2758
+
2759
+ **Step 2: Make executable**
2760
+
2761
+ ```bash
2762
+ chmod +x template/scripts/theme-cli.js
2763
+ ```
2764
+
2765
+ **Step 3: Update template/package.json with theme scripts**
2766
+
2767
+ Add to template/package.json:
2768
+
2769
+ ```json
2770
+ {
2771
+ "scripts": {
2772
+ "dev": "marp -s . --html --allow-local-files",
2773
+ "build:html": "marp presentation.md -o output/index.html --html && npm run copy:static",
2774
+ "build:pdf": "marp presentation.md -o output/presentation.pdf --allow-local-files && npm run copy:static",
2775
+ "build:pptx": "marp presentation.md -o output/presentation.pptx --allow-local-files && npm run copy:static",
2776
+ "build:all": "npm run build:html && npm run build:pdf && npm run build:pptx",
2777
+ "copy:static": "node scripts/copy-static.js",
2778
+ "clean": "rimraf output",
2779
+ "theme:list": "node scripts/theme-cli.js list",
2780
+ "theme:add": "node scripts/theme-cli.js add",
2781
+ "theme:add-from-template": "node scripts/theme-cli.js add-from-template",
2782
+ "theme:switch": "node scripts/theme-cli.js switch"
2783
+ }
2784
+ }
2785
+ ```
2786
+
2787
+ **Step 4: Add dependencies to template/package.json**
2788
+
2789
+ ```json
2790
+ {
2791
+ "dependencies": {
2792
+ "@inquirer/prompts": "^7.0.0",
2793
+ "gray-matter": "^4.0.3"
2794
+ }
2795
+ }
2796
+ ```
2797
+
2798
+ **Step 5: Commit**
2799
+
2800
+ ```bash
2801
+ git add template/scripts/theme-cli.js template/package.json
2802
+ git commit -m "feat: add theme-cli.js entry point with npm scripts"
2803
+ ```
2804
+
2805
+ ---
2806
+
2807
+ ## Task 15: Update root index.js to integrate addThemesCommand
2808
+
2809
+ **Files:**
2810
+ - Modify: `index.js`
2811
+
2812
+ **Step 1: Read current index.js to understand structure**
2813
+
2814
+ ```javascript
2815
+ // Current index.js handles project creation
2816
+ ```
2817
+
2818
+ **Step 2: Modify index.js to add theme selection**
2819
+
2820
+ ```javascript
2821
+ #!/usr/bin/env node
2822
+
2823
+ const fs = require('fs');
2824
+ const path = require('path');
2825
+ const { spawnSync } = require('child_process');
2826
+ const { addThemesCommand } = require('./lib/add-themes-command');
2827
+ const { Prompts } = require('./lib/prompts');
2828
+ const { ThemeManager } = require('./lib/theme-manager');
2829
+
2830
+ function validateProjectName(name) {
2831
+ if (!name) {
2832
+ throw new Error('Project name is required');
2833
+ }
2834
+ if (!/^[a-z0-9-]+$/.test(name)) {
2835
+ throw new Error('Project name must contain only lowercase letters, numbers, and hyphens');
2836
+ }
2837
+ }
2838
+
2839
+ function copyRecursiveSync(src, dest) {
2840
+ const exists = fs.existsSync(src);
2841
+ const stats = exists && fs.statSync(src);
2842
+ const isDirectory = exists && stats.isDirectory();
2843
+
2844
+ if (isDirectory) {
2845
+ if (!fs.existsSync(dest)) {
2846
+ fs.mkdirSync(dest, { recursive: true });
2847
+ }
2848
+ fs.readdirSync(src).forEach(childItemName => {
2849
+ copyRecursiveSync(
2850
+ path.join(src, childItemName),
2851
+ path.join(dest, childItemName)
2852
+ );
2853
+ });
2854
+ } else {
2855
+ fs.copyFileSync(src, dest);
2856
+ }
2857
+ }
2858
+
2859
+ async function createProject(projectName, options = {}) {
2860
+ validateProjectName(projectName);
2861
+
2862
+ const targetDir = path.join(process.cwd(), projectName);
2863
+
2864
+ if (fs.existsSync(targetDir)) {
2865
+ throw new Error(`Directory '${projectName}' already exists`);
2866
+ }
2867
+
2868
+ // Create project directory
2869
+ fs.mkdirSync(targetDir, { recursive: true });
2870
+
2871
+ // Copy template (excluding themes initially)
2872
+ const templateDir = path.join(__dirname, 'template');
2873
+
2874
+ // Copy everything except themes (we'll add those via addThemesCommand)
2875
+ const entries = fs.readdirSync(templateDir, { withFileTypes: true });
2876
+ for (const entry of entries) {
2877
+ if (entry.name === 'themes') continue; // Skip themes, add via command
2878
+ const srcPath = path.join(templateDir, entry.name);
2879
+ const destPath = path.join(targetDir, entry.name);
2880
+ copyRecursiveSync(srcPath, destPath);
2881
+ }
2882
+
2883
+ // Copy scripts/lib to project/scripts/lib
2884
+ const libSource = path.join(__dirname, 'lib');
2885
+ const libDest = path.join(targetDir, 'scripts', 'lib');
2886
+ fs.mkdirSync(libDest, { recursive: true });
2887
+ const libFiles = fs.readdirSync(libSource);
2888
+ for (const file of libFiles) {
2889
+ fs.copyFileSync(path.join(libSource, file), path.join(libDest, file));
2890
+ }
2891
+
2892
+ // Add themes via addThemesCommand
2893
+ console.log('\nSelect themes for your project:');
2894
+ await addThemesCommand.execute(targetDir, options);
2895
+
2896
+ // Select active theme
2897
+ const manager = new ThemeManager(targetDir);
2898
+ const themes = manager.scanThemes();
2899
+ const availableThemes = [
2900
+ ...new Set([
2901
+ 'default',
2902
+ 'gaia',
2903
+ 'uncover',
2904
+ ...themes.map(t => t.name)
2905
+ ])
2906
+ ];
2907
+
2908
+ const activeTheme = await Prompts.promptActiveTheme(availableThemes);
2909
+ manager.setActiveTheme(activeTheme);
2910
+
2911
+ console.log(`\n✓ Active theme set to '${activeTheme}'`);
2912
+
2913
+ // Install dependencies
2914
+ console.log('\nInstalling dependencies...');
2915
+ spawnSync('npm', ['install'], {
2916
+ cwd: targetDir,
2917
+ stdio: 'inherit'
2918
+ });
2919
+
2920
+ console.log(`\n✓ Project '${projectName}' created successfully!`);
2921
+ console.log(`\n cd ${projectName}`);
2922
+ console.log(' npm run dev');
2923
+ }
2924
+
2925
+ // Handle CLI args
2926
+ const args = process.argv.slice(2);
2927
+
2928
+ if (args[0] === 'add-themes') {
2929
+ // Handle add-themes command
2930
+ const targetPath = args[1];
2931
+ if (!targetPath) {
2932
+ console.error('Usage: create-marp-presentation add-themes <target-dir> [--themes=name1,name2]');
2933
+ process.exit(1);
2934
+ }
2935
+
2936
+ const themeOptions = {};
2937
+ const themeIndex = args.findIndex(a => a.startsWith('--themes='));
2938
+ if (themeIndex !== -1) {
2939
+ themeOptions.themes = args[themeIndex].split('=')[1].split(',');
2940
+ }
2941
+
2942
+ addThemesCommand.execute(targetPath, themeOptions).catch(error => {
2943
+ console.error(`Error: ${error.message}`);
2944
+ process.exit(1);
2945
+ });
2946
+ } else {
2947
+ // Create project
2948
+ const projectName = args[0];
2949
+ createProject(projectName).catch(error => {
2950
+ console.error(`Error: ${error.message}`);
2951
+ process.exit(1);
2952
+ });
2953
+ }
2954
+ ```
2955
+
2956
+ **Step 3: Commit**
2957
+
2958
+ ```bash
2959
+ git add index.js
2960
+ git commit -m "feat: integrate addThemesCommand into project creation flow"
2961
+ ```
2962
+
2963
+ ---
2964
+
2965
+ ## Task 16: Update root package.json with dependencies
2966
+
2967
+ **Files:**
2968
+ - Modify: `package.json`
2969
+
2970
+ **Step 1: Add dependencies**
2971
+
2972
+ ```json
2973
+ {
2974
+ "name": "create-marp-presentation",
2975
+ "version": "1.0.0",
2976
+ "description": "Create a new Marp presentation",
2977
+ "bin": "./index.js",
2978
+ "files": [
2979
+ "index.js",
2980
+ "template",
2981
+ "lib"
2982
+ ],
2983
+ "dependencies": {
2984
+ "@inquirer/prompts": "^7.0.0"
2985
+ },
2986
+ "devDependencies": {
2987
+ "gray-matter": "^4.0.3",
2988
+ "jest": "^29.7.0"
2989
+ },
2990
+ "engines": {
2991
+ "node": ">=20.0.0"
2992
+ }
2993
+ }
2994
+ ```
2995
+
2996
+ **Step 2: Commit**
2997
+
2998
+ ```bash
2999
+ git add package.json package-lock.json
3000
+ git commit -m "feat: add dependencies to root package.json"
3001
+ ```
3002
+
3003
+ ---
3004
+
3005
+ ## Task 17: Write integration tests for theme-cli
3006
+
3007
+ **Files:**
3008
+ - Create: `tests/integration/theme-cli.test.js`
3009
+
3010
+ **Step 1: Write the integration test**
3011
+
3012
+ ```javascript
3013
+ // tests/integration/theme-cli.test.js
3014
+ const { execSync } = require('child_process');
3015
+ const fs = require('fs');
3016
+ const path = require('path');
3017
+
3018
+ describe('theme-cli integration tests', () => {
3019
+ const tempDir = path.join(__dirname, '..', 'temp-integration');
3020
+ const projectDir = path.join(tempDir, 'test-project');
3021
+
3022
+ beforeEach(() => {
3023
+ if (fs.existsSync(tempDir)) {
3024
+ fs.rmSync(tempDir, { recursive: true, force: true });
3025
+ }
3026
+ fs.mkdirSync(tempDir, { recursive: true });
3027
+ });
3028
+
3029
+ afterEach(() => {
3030
+ if (fs.existsSync(tempDir)) {
3031
+ fs.rmSync(tempDir, { recursive: true, force: true });
3032
+ }
3033
+ });
3034
+
3035
+ test('theme:list should show themes', () => {
3036
+ // Create test project structure
3037
+ fs.mkdirSync(projectDir, { recursive: true });
3038
+ fs.mkdirSync(path.join(projectDir, 'scripts'), { recursive: true });
3039
+ fs.mkdirSync(path.join(projectDir, 'themes'), { recursive: true });
3040
+
3041
+ // Copy lib files
3042
+ const libSource = path.join(__dirname, '..', '..', 'lib');
3043
+ const libDest = path.join(projectDir, 'scripts', 'lib');
3044
+ fs.cpSync(libSource, libDest, { recursive: true });
3045
+
3046
+ // Create theme-cli.js
3047
+ const cliSource = path.join(__dirname, '..', '..', 'template', 'scripts', 'theme-cli.js');
3048
+ fs.copyFileSync(cliSource, path.join(projectDir, 'scripts', 'theme-cli.js'));
3049
+
3050
+ // Create test themes
3051
+ fs.writeFileSync(
3052
+ path.join(projectDir, 'themes', 'test.css'),
3053
+ '/* @theme test */\n@import "default";'
3054
+ );
3055
+
3056
+ // Run theme:list
3057
+ const output = execSync('node scripts/theme-cli.js list', {
3058
+ cwd: projectDir,
3059
+ encoding: 'utf-8'
3060
+ });
3061
+
3062
+ expect(output).toContain('test');
3063
+ });
3064
+
3065
+ test('theme:switch should update frontmatter', () => {
3066
+ // Create test project
3067
+ fs.mkdirSync(projectDir, { recursive: true });
3068
+ fs.mkdirSync(path.join(projectDir, 'scripts'), { recursive: true });
3069
+ fs.mkdirSync(path.join(projectDir, 'themes'), { recursive: true });
3070
+
3071
+ // Copy lib files and theme-cli
3072
+ const libSource = path.join(__dirname, '..', '..', 'lib');
3073
+ const libDest = path.join(projectDir, 'scripts', 'lib');
3074
+ fs.cpSync(libSource, libDest, { recursive: true });
3075
+
3076
+ const cliSource = path.join(__dirname, '..', '..', 'template', 'scripts', 'theme-cli.js');
3077
+ fs.copyFileSync(cliSource, path.join(projectDir, 'scripts', 'theme-cli.js'));
3078
+
3079
+ // Create presentation.md
3080
+ const presentationPath = path.join(projectDir, 'presentation.md');
3081
+ fs.writeFileSync(
3082
+ presentationPath,
3083
+ '---\nmarp: true\ntheme: default\n---\n\n# Test'
3084
+ );
3085
+
3086
+ // Create test theme
3087
+ fs.writeFileSync(
3088
+ path.join(projectDir, 'themes', 'new.css'),
3089
+ '/* @theme new */'
3090
+ );
3091
+
3092
+ // Run theme:switch (non-interactive)
3093
+ execSync('node scripts/theme-cli.js switch new', {
3094
+ cwd: projectDir,
3095
+ encoding: 'utf-8'
3096
+ });
3097
+
3098
+ // Verify presentation.md was updated
3099
+ const content = fs.readFileSync(presentationPath, 'utf-8');
3100
+ expect(content).toContain('theme: new');
3101
+ });
3102
+ });
3103
+ ```
3104
+
3105
+ **Step 2: Run integration tests**
3106
+
3107
+ Run: `npm test -- tests/integration/theme-cli.test.js`
3108
+ Expected: PASS
3109
+
3110
+ **Step 3: Commit**
3111
+
3112
+ ```bash
3113
+ git add tests/integration/theme-cli.test.js
3114
+ git commit -m "test: add integration tests for theme-cli"
3115
+ ```
3116
+
3117
+ ---
3118
+
3119
+ ## Task 18: Update root package.json files field
3120
+
3121
+ **Files:**
3122
+ - Modify: `package.json`
3123
+
3124
+ **Step 1: Update files field to include lib/ directory**
3125
+
3126
+ ```json
3127
+ {
3128
+ "files": [
3129
+ "index.js",
3130
+ "template",
3131
+ "lib"
3132
+ ]
3133
+ }
3134
+ ```
3135
+
3136
+ **Step 2: Verify package contents**
3137
+
3138
+ ```bash
3139
+ npm pack --dry-run
3140
+ ```
3141
+
3142
+ Expected output should show files from lib/, template/, and index.js only.
3143
+
3144
+ **Step 3: Commit**
3145
+
3146
+ ```bash
3147
+ git add package.json
3148
+ git commit -m "chore: include lib/ in published package files"
3149
+ ```
3150
+
3151
+ ---
3152
+
3153
+ ## Task 19: End-to-end testing of complete flow
3154
+
3155
+ **Files:**
3156
+ - No file creation - verification task
3157
+
3158
+ **Step 1: Test project creation with theme selection**
3159
+
3160
+ ```bash
3161
+ # Clean up any previous test
3162
+ rm -rf test-theme-project
3163
+
3164
+ # Create new project (this will be interactive)
3165
+ node index.js test-theme-project
3166
+
3167
+ # In the interactive prompts:
3168
+ # - Select a few themes (e.g., beam, marpx)
3169
+ # - Select an active theme
3170
+
3171
+ # Verify project was created
3172
+ cd test-theme-project
3173
+ ls themes/ # Should show selected themes
3174
+ cat .vscode/settings.json # Should show themes
3175
+ cat presentation.md # Should show selected active theme
3176
+ ```
3177
+
3178
+ **Step 2: Test theme:add command**
3179
+
3180
+ ```bash
3181
+ npm run theme:add
3182
+
3183
+ # In prompts:
3184
+ # - Enter theme name: my-custom
3185
+ # - Select parent: none
3186
+ # - Select location: root
3187
+
3188
+ # Verify theme was created
3189
+ ls themes/my-custom.css
3190
+ ```
3191
+
3192
+ **Step 3: Test theme:list command**
3193
+
3194
+ ```bash
3195
+ npm run theme:list
3196
+
3197
+ # Should show all themes including my-custom
3198
+ ```
3199
+
3200
+ **Step 4: Test theme:switch command**
3201
+
3202
+ ```bash
3203
+ npm run theme:switch my-custom
3204
+
3205
+ # Verify presentation.md was updated
3206
+ grep "theme: my-custom" presentation.md
3207
+ ```
3208
+
3209
+ **Step 5: Test theme:add-from-template command**
3210
+
3211
+ ```bash
3212
+ # First remove a theme to test adding it back
3213
+ rm -rf themes/beam
3214
+
3215
+ npm run theme:add-from-template
3216
+
3217
+ # In prompts, select beam
3218
+
3219
+ # Verify beam was restored
3220
+ ls themes/beam/
3221
+ ```
3222
+
3223
+ **Step 6: Test VSCode integration**
3224
+
3225
+ ```bash
3226
+ # Check that .vscode/settings.json contains all themes
3227
+ cat .vscode/settings.json | grep "markdown.marp.themes"
3228
+ ```
3229
+
3230
+ **Step 7: Clean up**
3231
+
3232
+ ```bash
3233
+ cd ..
3234
+ rm -rf test-theme-project
3235
+ ```
3236
+
3237
+ **Step 8: Document any issues found**
3238
+
3239
+ If any issues discovered, create tasks to fix them.
3240
+
3241
+ ---
3242
+
3243
+ ## Task 20: Update documentation
3244
+
3245
+ **Files:**
3246
+ - Update: `README.md`
3247
+ - Create: `docs/theme-management.md`
3248
+
3249
+ **Step 1: Update README.md with theme management section**
3250
+
3251
+ Add to README.md:
3252
+
3253
+ ```markdown
3254
+ ## Theme Management
3255
+
3256
+ This template includes a powerful theme management system that lets you:
3257
+
3258
+ - **Select themes during project creation** - Choose from pre-built themes
3259
+ - **Manage themes after creation** - Add, list, and switch themes via CLI
3260
+ - **Create custom themes** - Build on top of existing themes or from scratch
3261
+ - **Automatic VSCode integration** - Themes are automatically registered with the Marp VSCode extension
3262
+
3263
+ ### Available Commands
3264
+
3265
+ ```bash
3266
+ # List all themes in project
3267
+ npm run theme:list
3268
+
3269
+ # Create a new theme
3270
+ npm run theme:add
3271
+
3272
+ # Add themes from template
3273
+ npm run theme:add-from-template
3274
+
3275
+ # Switch active theme
3276
+ npm run theme:switch [theme-name]
3277
+
3278
+ # Or interactively
3279
+ npm run theme:switch
3280
+ ```
3281
+
3282
+ ### Theme Structure
3283
+
3284
+ Themes are stored in the `themes/` directory:
3285
+
3286
+ ```
3287
+ themes/
3288
+ ├── beam/
3289
+ │ └── beam.css
3290
+ ├── marpx/
3291
+ │ ├── marpx.css
3292
+ │ └── socrates.css
3293
+ └── your-theme/
3294
+ └── your-theme.css
3295
+ ```
3296
+
3297
+ ### Creating a Custom Theme
3298
+
3299
+ Run `npm run theme:add` and follow the prompts:
3300
+
3301
+ 1. Enter a theme name (lowercase letters, numbers, hyphens only)
3302
+ 2. Optionally select a parent theme to extend
3303
+ 3. Choose where to create the theme file
3304
+
3305
+ The theme CSS will be created with the appropriate `@theme` directive and `@import` statement if a parent was selected.
3306
+
3307
+ ### Adding Themes from Template
3308
+
3309
+ If you didn't select a theme during project creation, you can add it later:
3310
+
3311
+ ```bash
3312
+ npm run theme:add-from-template
3313
+ ```
3314
+
3315
+ ### System Themes
3316
+
3317
+ Marp includes three built-in themes:
3318
+ - **default** - Clean, minimal design
3319
+ - **gaia** - Modern gradient themes
3320
+ - **uncover** - Progressive reveal animations
3321
+
3322
+ These can be used as parents for custom themes but don't need to be copied to your project.
3323
+ ```
3324
+
3325
+ **Step 2: Create comprehensive theme management docs**
3326
+
3327
+ ```markdown
3328
+ # docs/theme-management.md
3329
+
3330
+ # Theme Management Guide
3331
+
3332
+ ## Overview
3333
+
3334
+ The theme management system provides a complete solution for managing Marp themes in your presentation project.
3335
+
3336
+ ## Architecture
3337
+
3338
+ ### Components
3339
+
3340
+ - **ThemeResolver** - Parses CSS files to extract theme metadata and dependencies
3341
+ - **ThemeManager** - Main class for theme operations in a project
3342
+ - **VSCodeIntegration** - Manages VSCode settings for Marp extension
3343
+ - **Prompts** - Interactive CLI prompts using @inquirer/prompts
3344
+ - **Frontmatter** - Parses/edits markdown frontmatter using gray-matter
3345
+
3346
+ ### Theme Format
3347
+
3348
+ Themes are CSS files with a `/* @theme name */` directive:
3349
+
3350
+ ```css
3351
+ /* @theme my-theme */
3352
+
3353
+ @import "parent-theme";
3354
+
3355
+ :root {
3356
+ /* Your theme variables */
3357
+ }
3358
+ ```
3359
+
3360
+ ## Commands Reference
3361
+
3362
+ ### theme:list
3363
+
3364
+ Lists all themes in the project with their dependencies.
3365
+
3366
+ ```bash
3367
+ npm run theme:list
3368
+ ```
3369
+
3370
+ Output:
3371
+ ```
3372
+ Themes in project:
3373
+ ✓ beam (themes/beam/beam.css) extends: default
3374
+ ✓ marpx (themes/marpx/marpx.css) extends: default
3375
+ ✓ socrates (themes/marpx/socrates.css) extends: marpx
3376
+
3377
+ Active theme: beam
3378
+ ```
3379
+
3380
+ ### theme:add
3381
+
3382
+ Creates a new theme interactively.
3383
+
3384
+ ```bash
3385
+ npm run theme:add
3386
+ ```
3387
+
3388
+ Prompts:
3389
+ 1. Theme name (validation: lowercase, numbers, hyphens only)
3390
+ 2. Parent theme selection (none, system, or custom)
3391
+ 3. File location (root, existing folder, or new folder)
3392
+
3393
+ ### theme:add-from-template
3394
+
3395
+ Adds themes from the create-marp-presentation template.
3396
+
3397
+ ```bash
3398
+ npm run theme:add-from-template
3399
+ ```
3400
+
3401
+ ### theme:switch
3402
+
3403
+ Changes the active theme in presentation.md.
3404
+
3405
+ ```bash
3406
+ # Direct
3407
+ npm run theme:switch -- beam
3408
+
3409
+ # Interactive
3410
+ npm run theme:switch
3411
+ ```
3412
+
3413
+ ## Advanced Usage
3414
+
3415
+ ### Manual Theme Management
3416
+
3417
+ You can manually add CSS files to the `themes/` directory. Any CSS file with a `/* @theme */` directive will be automatically discovered.
3418
+
3419
+ ### VSCode Integration
3420
+
3421
+ The system automatically updates `.vscode/settings.json` with theme paths:
3422
+
3423
+ ```json
3424
+ {
3425
+ "markdown.marp.themes": [
3426
+ "themes/beam/beam.css",
3427
+ "themes/marpx/marpx.css"
3428
+ ]
3429
+ }
3430
+ ```
3431
+
3432
+ This enables the Marp VSCode extension to provide live preview with your custom themes.
3433
+
3434
+ ### Theme Dependencies
3435
+
3436
+ Themes can import other themes using `@import`:
3437
+
3438
+ ```css
3439
+ /* @theme child-theme */
3440
+
3441
+ @import "parent-theme";
3442
+ ```
3443
+
3444
+ The system automatically resolves dependencies when copying themes.
3445
+ ```
3446
+
3447
+ **Step 3: Commit**
3448
+
3449
+ ```bash
3450
+ git add README.md docs/theme-management.md
3451
+ git commit -m "docs: add theme management documentation"
3452
+ ```
3453
+
3454
+ ---
3455
+
3456
+ ## Task 21: Final verification and test coverage check
3457
+
3458
+ **Files:**
3459
+ - No file creation - verification task
3460
+
3461
+ **Step 1: Run all tests**
3462
+
3463
+ ```bash
3464
+ npm test
3465
+ ```
3466
+
3467
+ Expected: All tests pass
3468
+
3469
+ **Step 2: Check test coverage**
3470
+
3471
+ ```bash
3472
+ npm test -- --coverage
3473
+ ```
3474
+
3475
+ Expected:
3476
+ - ThemeResolver: 90%+
3477
+ - VSCodeIntegration: 85%+
3478
+ - ThemeManager: 90%+
3479
+ - Overall: 85%+
3480
+
3481
+ **Step 3: Verify package contents**
3482
+
3483
+ ```bash
3484
+ npm pack --dry-run
3485
+ ```
3486
+
3487
+ Verify only necessary files are included:
3488
+ - index.js
3489
+ - lib/
3490
+ - template/ (including scripts/lib/)
3491
+ - No tests or docs
3492
+
3493
+ **Step 4: Test installation from local tarball**
3494
+
3495
+ ```bash
3496
+ # Create tarball
3497
+ npm pack
3498
+
3499
+ # Install globally for testing
3500
+ npm install -g create-marp-presentation-1.0.0.tgz
3501
+
3502
+ # Test creating a project
3503
+ create-marp-presentation test-from-npm
3504
+
3505
+ # Clean up
3506
+ npm uninstall -g create-marp-presentation
3507
+ rm -rf test-from-npm
3508
+ rm create-marp-presentation-1.0.0.tgz
3509
+ ```
3510
+
3511
+ **Step 5: Verify git status**
3512
+
3513
+ ```bash
3514
+ git status
3515
+ ```
3516
+
3517
+ Expected: Clean working tree (all changes committed)
3518
+
3519
+ **Step 6: Create summary documentation**
3520
+
3521
+ Create a brief summary of what was implemented:
3522
+
3523
+ ```markdown
3524
+ ## Implementation Summary
3525
+
3526
+ ### Components Implemented
3527
+
3528
+ 1. **lib/errors.js** - Custom error classes
3529
+ 2. **lib/theme-resolver.js** - CSS parsing and theme discovery
3530
+ 3. **lib/vscode-integration.js** - VSCode settings management
3531
+ 4. **lib/frontmatter.js** - Markdown frontmatter parsing/editing
3532
+ 5. **lib/prompts.js** - Interactive CLI prompts
3533
+ 6. **lib/theme-manager.js** - Main theme operations class
3534
+ 7. **lib/add-themes-command.js** - Shared theme addition logic
3535
+ 8. **template/scripts/theme-cli.js** - CLI entry point for projects
3536
+ 9. **template/themes/** - Initial theme templates
3537
+
3538
+ ### Features
3539
+
3540
+ - Interactive theme selection during project creation
3541
+ - Post-creation theme management via npm scripts
3542
+ - Automatic VSCode integration
3543
+ - Dependency resolution for theme imports
3544
+ - Conflict handling when adding existing themes
3545
+ - Support for system themes (default, gaia, uncover)
3546
+
3547
+ ### Tests
3548
+
3549
+ - Unit tests for all components
3550
+ - Integration tests for CLI commands
3551
+ - Test coverage: 85%+
3552
+
3553
+ ### Documentation
3554
+
3555
+ - README.md updated with theme management section
3556
+ - docs/theme-management.md with comprehensive guide
3557
+ ```
3558
+
3559
+ ---
3560
+
3561
+ ## Completion Checklist
3562
+
3563
+ - [ ] All unit tests passing
3564
+ - [ ] All integration tests passing
3565
+ - [ ] Test coverage meets targets (85%+)
3566
+ - [ ] package.json files field correct
3567
+ - [ ] Template themes copied and verified
3568
+ - [ ] lib/ copied to template/scripts/lib/
3569
+ - [ ] theme-cli.js created and functional
3570
+ - [ ] index.js updated with theme selection
3571
+ - [ ] Documentation updated
3572
+ - [ ] No sensitive files in package (tests, docs excluded)
3573
+ - [ ] npm pack --dry-run verifies correct contents
3574
+
3575
+ ---
3576
+
3577
+ **Plan complete and saved to `docs/plans/2026-02-23-theme-management-implementation.md`**
3578
+
3579
+ Two execution options:
3580
+
3581
+ **1. Subagent-Driven (this session)** - I dispatch fresh subagent per task, review between tasks, fast iteration
3582
+
3583
+ **2. Parallel Session (separate)** - Open new session with executing-plans, batch execution with checkpoints
3584
+
3585
+ **Which approach?**