create-marp-presentation 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,381 @@
1
+ // lib/add-themes-command.js
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { ThemeResolver, Theme } = require('./theme-resolver');
5
+ const { VSCodeIntegration, VSCodeSettingsDTO } = require('./vscode-integration');
6
+ const { ThemeManager } = require('./theme-manager');
7
+ const { Prompts } = require('./prompts');
8
+ const {
9
+ ThemeError,
10
+ ThemeNotFoundError,
11
+ ThemeAlreadyExistsError
12
+ } = require('./errors');
13
+
14
+ /**
15
+ * Shared function for adding Marp themes to a project
16
+ * Handles theme copying, dependency resolution, conflict detection, and VSCode sync
17
+ */
18
+ class AddThemesCommand {
19
+ /**
20
+ * @param {Object} options - Configuration options
21
+ * @param {string} options.templatePath - Path to themes library directory (optional, defaults to __dirname/../themes)
22
+ * @param {boolean} options.interactive - Enable interactive prompts (default: true)
23
+ * @param {Object} options.prompts - Prompt functions for testing (optional)
24
+ */
25
+ constructor(options = {}) {
26
+ this.options = {
27
+ interactive: options.interactive !== false,
28
+ templatePath: options.templatePath || path.join(__dirname, '..', 'themes'),
29
+ ...options
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Returns the path to the themes library directory
35
+ * @returns {string} Absolute path to themes library
36
+ */
37
+ getTemplateThemesPath() {
38
+ // templatePath now points directly to the themes library
39
+ return this.options.templatePath;
40
+ }
41
+
42
+ /**
43
+ * Main execution method - adds themes to target project
44
+ *
45
+ * @param {string} targetPath - Path to target project directory
46
+ * @param {Object} options - Execution options
47
+ * @param {string[]} options.themes - Theme names to add (optional, prompts if not provided)
48
+ * @param {string[]} options.skip - Theme names to skip conflicts (optional)
49
+ * @param {boolean} options.force - Overwrite existing themes (default: false)
50
+ * @param {boolean} options.noVscode - Skip VSCode settings sync (default: false)
51
+ * @returns {Promise<Object>} Result object with copied, skipped, and conflicts arrays
52
+ */
53
+ async execute(targetPath, options = {}) {
54
+ const execOptions = {
55
+ themes: options.themes || null,
56
+ skip: options.skip || [],
57
+ force: options.force || false,
58
+ noVscode: options.noVscode || false,
59
+ ...options
60
+ };
61
+
62
+ // Validate target path
63
+ if (!fs.existsSync(targetPath)) {
64
+ throw new ThemeError(`Target path does not exist: ${targetPath}`);
65
+ }
66
+
67
+ const templateThemesPath = this.getTemplateThemesPath();
68
+
69
+ // Scan available themes from themes library
70
+ if (!fs.existsSync(templateThemesPath)) {
71
+ throw new ThemeError(`Themes library directory not found: ${templateThemesPath}`);
72
+ }
73
+
74
+ const availableThemes = ThemeResolver.scanDirectory(templateThemesPath);
75
+
76
+ if (availableThemes.length === 0) {
77
+ throw new ThemeError('No themes found in themes library');
78
+ }
79
+
80
+ // Determine which themes to copy
81
+ let themesToCopy;
82
+ if (execOptions.themes != null) {
83
+ // themes explicitly provided (even if empty array = copy nothing)
84
+ if (execOptions.themes.length > 0) {
85
+ themesToCopy = this._resolveThemeNames(execOptions.themes, availableThemes);
86
+ } else {
87
+ // Empty array means user chose no themes
88
+ themesToCopy = [];
89
+ }
90
+ } else if (this.options.interactive) {
91
+ // Interactive mode - prompt user using built-in Prompts.promptThemes()
92
+ const selectedNames = await this._promptThemes(availableThemes);
93
+ // Filter availableThemes to only include selected ones
94
+ themesToCopy = availableThemes.filter(t => selectedNames.includes(t.name));
95
+ } else {
96
+ // Non-interactive without explicit themes: copy all available themes
97
+ themesToCopy = availableThemes;
98
+ }
99
+
100
+ // Resolve dependencies
101
+ const themesWithDependencies = ThemeResolver.resolveDependencies(
102
+ themesToCopy,
103
+ availableThemes
104
+ );
105
+
106
+ // Scan existing themes in project
107
+ const existingThemes = this._scanProjectThemes(targetPath);
108
+
109
+ // Find conflicts
110
+ const conflicts = this._findConflicts(themesWithDependencies, existingThemes);
111
+
112
+ // Handle conflicts
113
+ let skipList = new Set(execOptions.skip);
114
+
115
+ if (conflicts.length > 0 && !execOptions.force) {
116
+ if (this.options.interactive) {
117
+ const resolvedConflicts = await this._resolveConflictsInteractive(
118
+ conflicts,
119
+ execOptions.force
120
+ );
121
+ // Add overwrite choices to skip list (inverted logic - skip if not overwriting)
122
+ conflicts.forEach(conflict => {
123
+ if (!resolvedConflicts.overwrite.includes(conflict)) {
124
+ skipList.add(conflict);
125
+ }
126
+ });
127
+ } else {
128
+ // Non-interactive: skip all conflicts
129
+ conflicts.forEach(conflict => skipList.add(conflict));
130
+ }
131
+ }
132
+
133
+ // Copy themes
134
+ const copyResult = this.copyThemes(
135
+ themesWithDependencies,
136
+ templateThemesPath,
137
+ targetPath,
138
+ Array.from(skipList)
139
+ );
140
+
141
+ // Sync VSCode settings
142
+ if (!execOptions.noVscode && copyResult.copied.length > 0) {
143
+ this._syncVscodeSettings(targetPath, copyResult.copied);
144
+ }
145
+
146
+ // Ensure marp.themeSet is configured in package.json
147
+ if (copyResult.copied.length > 0) {
148
+ ThemeManager.ensureThemeSetConfig(targetPath, { silent: true });
149
+ }
150
+
151
+ // Return result with conflicts included
152
+ return {
153
+ ...copyResult,
154
+ conflicts
155
+ };
156
+ }
157
+
158
+ /**
159
+ * Copy themes from themes library to target project
160
+ *
161
+ * @param {Theme[]} themes - Theme objects to copy
162
+ * @param {string} templatePath - Path to themes library directory
163
+ * @param {string} targetPath - Path to target project directory
164
+ * @param {string[]} skipList - Theme names to skip
165
+ * @returns {Object} Result with copied, skipped arrays
166
+ */
167
+ copyThemes(themes, templatePath, targetPath, skipList = []) {
168
+ const skipSet = new Set(skipList);
169
+ const copied = [];
170
+ const skipped = [];
171
+
172
+ // Ensure target themes directory exists
173
+ const targetThemesDir = path.join(targetPath, 'themes');
174
+ if (!fs.existsSync(targetThemesDir)) {
175
+ fs.mkdirSync(targetThemesDir, { recursive: true });
176
+ }
177
+
178
+ for (const theme of themes) {
179
+ if (skipSet.has(theme.name)) {
180
+ skipped.push(theme.name);
181
+ continue;
182
+ }
183
+
184
+ // Calculate relative path from themes library
185
+ const relativePath = path.relative(templatePath, theme.path);
186
+ const targetFilePath = path.join(targetThemesDir, relativePath);
187
+
188
+ // Ensure target directory exists
189
+ const targetDir = path.dirname(targetFilePath);
190
+ if (!fs.existsSync(targetDir)) {
191
+ fs.mkdirSync(targetDir, { recursive: true });
192
+ }
193
+
194
+ // Copy file
195
+ fs.copyFileSync(theme.path, targetFilePath);
196
+ copied.push(theme); // Return Theme object, not just name
197
+ }
198
+
199
+ return { copied, skipped };
200
+ }
201
+
202
+ /**
203
+ * Scan project themes directory for existing theme files
204
+ * @private
205
+ *
206
+ * @param {string} projectPath - Path to project directory
207
+ * @returns {Theme[]} Array of existing theme objects
208
+ */
209
+ _scanProjectThemes(projectPath) {
210
+ const themesPath = path.join(projectPath, 'themes');
211
+
212
+ if (!fs.existsSync(themesPath)) {
213
+ return [];
214
+ }
215
+
216
+ try {
217
+ return ThemeResolver.scanDirectory(themesPath);
218
+ } catch (error) {
219
+ // If scan fails, return empty array
220
+ return [];
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Find conflicts between themes to copy and existing themes
226
+ * @private
227
+ *
228
+ * @param {Theme[]} themesToCopy - Themes that will be copied
229
+ * @param {Theme[]} existingThemes - Themes that already exist
230
+ * @returns {string[]} Array of conflicting theme names
231
+ */
232
+ _findConflicts(themesToCopy, existingThemes) {
233
+ const existingNames = new Set(existingThemes.map(t => t.name));
234
+ const conflicts = [];
235
+
236
+ for (const theme of themesToCopy) {
237
+ if (existingNames.has(theme.name)) {
238
+ conflicts.push(theme.name);
239
+ }
240
+ }
241
+
242
+ return conflicts;
243
+ }
244
+
245
+ /**
246
+ * Resolve theme names to Theme objects
247
+ * @private
248
+ *
249
+ * @param {string[]} themeNames - Theme names to resolve
250
+ * @param {Theme[]} availableThemes - Available themes from themes library
251
+ * @returns {Theme[]} Resolved theme objects
252
+ * @throws {ThemeNotFoundError} If a theme name is not found
253
+ */
254
+ _resolveThemeNames(themeNames, availableThemes) {
255
+ const themesMap = new Map();
256
+ for (const theme of availableThemes) {
257
+ themesMap.set(theme.name, theme);
258
+ }
259
+
260
+ const resolved = [];
261
+ const notFound = [];
262
+
263
+ for (const name of themeNames) {
264
+ const theme = themesMap.get(name);
265
+ if (theme) {
266
+ resolved.push(theme);
267
+ } else {
268
+ notFound.push(name);
269
+ }
270
+ }
271
+
272
+ if (notFound.length > 0) {
273
+ const message = notFound.length === 1
274
+ ? notFound[0]
275
+ : `${notFound.join(', ')}`;
276
+ throw new ThemeNotFoundError(message);
277
+ }
278
+
279
+ return resolved;
280
+ }
281
+
282
+ /**
283
+ * Prompt user to select themes interactively
284
+ * Uses Prompts.promptThemes() for built-in theme selection
285
+ * @private
286
+ *
287
+ * @param {Theme[]} availableThemes - Available themes
288
+ * @returns {Promise<string[]>} Selected theme names
289
+ */
290
+ async _promptThemes(availableThemes) {
291
+ // Use built-in Prompts.promptThemes() for interactive selection
292
+ return await Prompts.promptThemes(availableThemes);
293
+ }
294
+
295
+ /**
296
+ * Resolve conflicts interactively
297
+ * @private
298
+ *
299
+ * @param {string[]} conflicts - Conflicting theme names
300
+ * @param {boolean} force - Force overwrite all
301
+ * @returns {Promise<Object>} Resolution result with overwrite array
302
+ */
303
+ async _resolveConflictsInteractive(conflicts, force) {
304
+ if (force) {
305
+ return { overwrite: conflicts };
306
+ }
307
+
308
+ // Use custom prompt if provided (backward compatibility for tests)
309
+ if (this.options.prompts?.resolveConflicts) {
310
+ return await this.options.prompts.resolveConflicts(conflicts);
311
+ }
312
+
313
+ // Use built-in Prompts.promptConflictResolution
314
+ const result = await Prompts.promptConflictResolution(
315
+ conflicts.map(c => ({ name: c }))
316
+ );
317
+
318
+ if (result === 'skip-all') return { overwrite: [] };
319
+ if (result === 'overwrite-all') return { overwrite: conflicts };
320
+ if (result === 'cancel') return { overwrite: [] };
321
+
322
+ // Handle 'choose-each' or individual conflicts
323
+ const overwrite = [];
324
+ for (const conflict of conflicts) {
325
+ const choice = await Prompts.promptSingleConflict(conflict);
326
+ if (choice === 'overwrite') {
327
+ overwrite.push(conflict);
328
+ } else if (choice === 'cancel') {
329
+ break;
330
+ }
331
+ }
332
+ return { overwrite };
333
+ }
334
+
335
+ /**
336
+ * Sync themes to VSCode settings
337
+ * @private
338
+ *
339
+ * @param {string} projectPath - Path to project directory
340
+ * @param {string[]} copiedThemes - Names of copied themes
341
+ */
342
+ _syncVscodeSettings(projectPath, copiedThemes) {
343
+ const vscode = new VSCodeIntegration(projectPath);
344
+
345
+ // Use DTO for type-safe access
346
+ const dto = vscode.createSettingsDTO();
347
+ const existingThemes = dto.getMarpThemes();
348
+
349
+ // Add new theme paths (relative to themes/)
350
+ // copiedThemes are Theme objects - use their paths to compute relative paths
351
+ const newThemePaths = copiedThemes.map(theme => {
352
+ // theme.path is absolute, we need relative path from themes directory
353
+ // e.g., /library/themes/marpx/socrates.css -> themes/marpx/socrates.css
354
+ const filename = path.basename(theme.path);
355
+ const themeName = path.basename(path.dirname(theme.path));
356
+ // For single-file themes: themes/themeName/themeName.css
357
+ // For themes in subdirs: themes/subdir/themeName.css
358
+ return `themes/${themeName}/${filename}`;
359
+ });
360
+
361
+ // Merge without duplicates
362
+ const allThemes = [...new Set([...existingThemes, ...newThemePaths])];
363
+
364
+ dto.setMarpThemes(allThemes);
365
+ vscode.writeSettings(dto.toObject());
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Convenience function to create and execute AddThemesCommand
371
+ *
372
+ * @param {string} targetPath - Path to target project directory
373
+ * @param {Object} options - Options passed to AddThemesCommand
374
+ * @returns {Promise<Object>} Result object from execute()
375
+ */
376
+ async function addThemesCommand(targetPath, options = {}) {
377
+ const command = new AddThemesCommand(options);
378
+ return await command.execute(targetPath, options);
379
+ }
380
+
381
+ module.exports = { AddThemesCommand, addThemesCommand };
package/lib/errors.js ADDED
@@ -0,0 +1,62 @@
1
+ // lib/errors.js
2
+ class ThemeError extends Error {
3
+ constructor(message) {
4
+ super(message);
5
+ this.name = 'ThemeError';
6
+ Error.captureStackTrace(this, this.constructor);
7
+ }
8
+ }
9
+
10
+ class ThemeNotFoundError extends ThemeError {
11
+ constructor(themeName) {
12
+ super(`Theme '${themeName}' not found`);
13
+ this.name = 'ThemeNotFoundError';
14
+ this.themeName = themeName;
15
+ Error.captureStackTrace(this, this.constructor);
16
+ }
17
+ }
18
+
19
+ class ThemeAlreadyExistsError extends ThemeError {
20
+ constructor(themeName) {
21
+ super(`Theme '${themeName}' already exists`);
22
+ this.name = 'ThemeAlreadyExistsError';
23
+ this.themeName = themeName;
24
+ Error.captureStackTrace(this, this.constructor);
25
+ }
26
+ }
27
+
28
+ class PresentationNotFoundError extends ThemeError {
29
+ constructor(presentationPath) {
30
+ super(`Presentation file not found: ${presentationPath}`);
31
+ this.name = 'PresentationNotFoundError';
32
+ this.presentationPath = presentationPath;
33
+ Error.captureStackTrace(this, this.constructor);
34
+ }
35
+ }
36
+
37
+ class InvalidCSSError extends ThemeError {
38
+ constructor(cssPath, reason) {
39
+ super(`Invalid CSS file '${cssPath}': ${reason}`);
40
+ this.name = 'InvalidCSSError';
41
+ this.cssPath = cssPath;
42
+ this.reason = reason;
43
+ Error.captureStackTrace(this, this.constructor);
44
+ }
45
+ }
46
+
47
+ class VSCodeIntegrationError extends ThemeError {
48
+ constructor(message) {
49
+ super(message);
50
+ this.name = 'VSCodeIntegrationError';
51
+ Error.captureStackTrace(this, this.constructor);
52
+ }
53
+ }
54
+
55
+ module.exports = {
56
+ ThemeError,
57
+ ThemeNotFoundError,
58
+ ThemeAlreadyExistsError,
59
+ PresentationNotFoundError,
60
+ InvalidCSSError,
61
+ VSCodeIntegrationError
62
+ };
@@ -0,0 +1,71 @@
1
+ // lib/frontmatter.js
2
+ const matter = require('gray-matter');
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ /**
7
+ * Frontmatter class for handling Marp presentation frontmatter
8
+ * Uses gray-matter library for parsing and manipulating YAML frontmatter
9
+ */
10
+ class Frontmatter {
11
+ /**
12
+ * Parse frontmatter from markdown content
13
+ *
14
+ * @param {string} content - Markdown content with frontmatter
15
+ * @returns {Object} Parsed frontmatter data
16
+ */
17
+ static parse(content) {
18
+ const result = matter(content);
19
+ return result.data;
20
+ }
21
+
22
+ /**
23
+ * Extract theme value from frontmatter
24
+ *
25
+ * @param {string} content - Markdown content with frontmatter
26
+ * @returns {string|null} Theme name or null if not found
27
+ */
28
+ static getTheme(content) {
29
+ const data = this.parse(content);
30
+ return data.theme || null;
31
+ }
32
+
33
+ /**
34
+ * Set or update theme value in frontmatter
35
+ * Creates frontmatter if it doesn't exist
36
+ *
37
+ * @param {string} content - Markdown content
38
+ * @param {string} themeName - Theme name to set
39
+ * @returns {string} Updated markdown content
40
+ */
41
+ static setTheme(content, themeName) {
42
+ const result = matter(content);
43
+
44
+ // Set or update the theme
45
+ result.data.theme = themeName;
46
+
47
+ // Rebuild the markdown with updated frontmatter
48
+ return matter.stringify(result.content, result.data);
49
+ }
50
+
51
+ /**
52
+ * Write content to a file
53
+ *
54
+ * @param {string} filePath - Path to the file
55
+ * @param {string} content - Content to write
56
+ * @throws {Error} If file path is invalid or write fails
57
+ */
58
+ static writeToFile(filePath, content) {
59
+ const absolutePath = path.resolve(filePath);
60
+
61
+ // Ensure parent directory exists
62
+ const dir = path.dirname(absolutePath);
63
+ if (!fs.existsSync(dir)) {
64
+ fs.mkdirSync(dir, { recursive: true });
65
+ }
66
+
67
+ fs.writeFileSync(absolutePath, content, 'utf-8');
68
+ }
69
+ }
70
+
71
+ module.exports = { Frontmatter };