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.
- package/cli/commands/add-themes-cli.js +85 -0
- package/cli/commands/create-project.js +199 -0
- package/cli/utils/file-utils.js +106 -0
- package/cli/utils/prompt-utils.js +89 -0
- package/docs/plans/2025-02-19-marp-template-design.md +207 -0
- package/docs/plans/2025-02-19-marp-template-implementation.md +848 -0
- package/docs/plans/2026-02-20-example-slides-design.md +179 -0
- package/docs/plans/2026-02-20-example-slides-implementation.md +811 -0
- package/docs/plans/2026-02-23-theme-management-design.md +836 -0
- package/docs/plans/2026-02-23-theme-management-implementation.md +3585 -0
- package/docs/plans/2026-02-26-theme-addition-refactoring-design.md +172 -0
- package/docs/plans/2026-02-26-theme-addition-refactoring.md +456 -0
- package/docs/plans/2026-02-27-theme-add-fix-design.md +136 -0
- package/docs/plans/2026-02-27-theme-add-fix.md +353 -0
- package/docs/plans/TODO.md +5 -0
- package/docs/reqs/themes-requirements.md +49 -0
- package/docs/theme-management.md +261 -0
- package/package.json +3 -1
|
@@ -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?**
|