create-marp-presentation 1.1.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/README.md +45 -37
- 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/index.js +111 -164
- package/lib/add-themes-command.js +381 -0
- package/lib/errors.js +62 -0
- package/lib/frontmatter.js +71 -0
- package/lib/prompts.js +222 -0
- package/lib/theme-manager.js +238 -0
- package/lib/theme-resolver.js +227 -0
- package/lib/vscode-integration.js +198 -0
- package/package.json +15 -5
- package/template/README.md +28 -17
- package/template/package.json +13 -1
- package/template/scripts/theme-cli.js +248 -0
- package/themes/beam/beam.css +141 -0
- package/themes/default-clean/default-clean.css +57 -0
- package/themes/gaia-dark/gaia-dark.css +27 -0
- package/themes/marpx/marpx.css +1735 -0
- package/themes/marpx/socrates.css +105 -0
- package/themes/uncover-minimal/uncover-minimal.css +22 -0
package/lib/prompts.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// lib/prompts.js
|
|
2
|
+
const inquirer = require('@inquirer/prompts');
|
|
3
|
+
const { THEME_NOT_FOUND } = require('./errors');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Interactive prompts for theme management
|
|
7
|
+
*/
|
|
8
|
+
class Prompts {
|
|
9
|
+
/**
|
|
10
|
+
* Prompt user to select themes from available options
|
|
11
|
+
*
|
|
12
|
+
* @param {Array} availableThemes - Array of {name, description} objects
|
|
13
|
+
* @returns {Promise<string[]>} Array of selected theme names
|
|
14
|
+
*/
|
|
15
|
+
static async promptThemes(availableThemes) {
|
|
16
|
+
if (availableThemes.length === 0) {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const choices = availableThemes.map(theme => ({
|
|
21
|
+
name: theme.name,
|
|
22
|
+
value: theme.name,
|
|
23
|
+
checked: false
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
return await inquirer.checkbox({
|
|
27
|
+
message: 'Select themes to add:',
|
|
28
|
+
choices
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Prompt user to select active theme from available themes
|
|
34
|
+
*
|
|
35
|
+
* @param {string[]} selectedThemes - Array of available theme names
|
|
36
|
+
* @returns {Promise<string>} Selected theme name
|
|
37
|
+
* @throws {Error} If no themes available
|
|
38
|
+
*/
|
|
39
|
+
static async promptActiveTheme(selectedThemes) {
|
|
40
|
+
if (selectedThemes.length === 0) {
|
|
41
|
+
throw new Error('No themes available');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Return single theme if only one available
|
|
45
|
+
if (selectedThemes.length === 1) {
|
|
46
|
+
return selectedThemes[0];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return await inquirer.select({
|
|
50
|
+
message: 'Select active theme:',
|
|
51
|
+
choices: selectedThemes
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Prompt user for new theme name with validation
|
|
57
|
+
*
|
|
58
|
+
* @returns {Promise<string>} Validated theme name
|
|
59
|
+
*/
|
|
60
|
+
static async promptNewThemeName() {
|
|
61
|
+
return await inquirer.input({
|
|
62
|
+
message: 'Theme name:',
|
|
63
|
+
validate: (input) => {
|
|
64
|
+
if (!input || input.trim().length === 0) {
|
|
65
|
+
return 'Theme name is required';
|
|
66
|
+
}
|
|
67
|
+
if (!/^[a-z0-9-]+$/.test(input)) {
|
|
68
|
+
return 'Theme name must contain only lowercase letters, numbers, and hyphens';
|
|
69
|
+
}
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Prompt user to select parent theme
|
|
77
|
+
*
|
|
78
|
+
* @param {Array} existingThemes - Array of {name, isSystem} objects
|
|
79
|
+
* @returns {Promise<string|null>} Selected parent theme name or null for none
|
|
80
|
+
*/
|
|
81
|
+
static async promptParentTheme(existingThemes) {
|
|
82
|
+
const choices = [
|
|
83
|
+
{ name: 'none (create from scratch)', value: null },
|
|
84
|
+
new inquirer.Separator('--- System Themes ---'),
|
|
85
|
+
{ name: 'default (system built-in)', value: 'default' },
|
|
86
|
+
{ name: 'gaia (system built-in)', value: 'gaia' },
|
|
87
|
+
{ name: 'uncover (system built-in)', value: 'uncover' }
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
// Add custom themes
|
|
91
|
+
const customThemes = existingThemes.filter(t => !t.isSystem);
|
|
92
|
+
if (customThemes.length > 0) {
|
|
93
|
+
choices.push(new inquirer.Separator('--- Custom Themes ---'));
|
|
94
|
+
customThemes.forEach(theme => {
|
|
95
|
+
choices.push({ name: theme.name, value: theme.name });
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return await inquirer.select({
|
|
100
|
+
message: 'Parent theme:',
|
|
101
|
+
choices
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Prompt user for directory location for new theme
|
|
107
|
+
*
|
|
108
|
+
* @param {Array} existingDirs - Array of existing directory names
|
|
109
|
+
* @returns {Promise<string>} Selected option: 'root', 'existing', or 'new'
|
|
110
|
+
*/
|
|
111
|
+
static async promptDirectoryLocation(existingDirs) {
|
|
112
|
+
const choices = [
|
|
113
|
+
{ name: 'In root (themes/<name>.css)', value: 'root' }
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
if (existingDirs.length > 0) {
|
|
117
|
+
choices.push(new inquirer.Separator('--- Existing Folders ---'));
|
|
118
|
+
existingDirs.forEach(dir => {
|
|
119
|
+
choices.push({
|
|
120
|
+
name: `In existing folder: themes/${dir}/`,
|
|
121
|
+
value: dir
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
choices.push(new inquirer.Separator('--- New Folder ---'));
|
|
127
|
+
choices.push({ name: 'In new folder (enter name)', value: 'new' });
|
|
128
|
+
|
|
129
|
+
return await inquirer.select({
|
|
130
|
+
message: 'Where to create the theme CSS file?',
|
|
131
|
+
choices
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Prompt user for new folder name
|
|
137
|
+
*
|
|
138
|
+
* @returns {Promise<string>} Folder name
|
|
139
|
+
*/
|
|
140
|
+
static async promptNewFolderName() {
|
|
141
|
+
return await inquirer.input({
|
|
142
|
+
message: 'Folder name:',
|
|
143
|
+
validate: (input) => {
|
|
144
|
+
if (!input || input.trim().length === 0) {
|
|
145
|
+
return 'Folder name is required';
|
|
146
|
+
}
|
|
147
|
+
if (!/^[a-z0-9-]+$/.test(input)) {
|
|
148
|
+
return 'Folder name must contain only lowercase letters, numbers, and hyphens';
|
|
149
|
+
}
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Prompt user for conflict resolution
|
|
157
|
+
*
|
|
158
|
+
* @param {Array} conflicts - Array of conflicting theme names
|
|
159
|
+
* @returns {Promise<string>} Selected action: 'skip', 'overwrite', 'skip-all', 'overwrite-all', or 'cancel'
|
|
160
|
+
*/
|
|
161
|
+
static async promptConflictResolution(conflicts) {
|
|
162
|
+
const isMultiple = conflicts.length > 1;
|
|
163
|
+
|
|
164
|
+
if (isMultiple) {
|
|
165
|
+
const conflictList = conflicts.map(c => ` - ${c.name}`).join('\n');
|
|
166
|
+
console.log(`\n${conflicts.length} themes already exist in your project:\n${conflictList}\n`);
|
|
167
|
+
|
|
168
|
+
return await inquirer.select({
|
|
169
|
+
message: 'Apply to all conflicts?',
|
|
170
|
+
choices: [
|
|
171
|
+
{ name: 'Skip all', value: 'skip-all' },
|
|
172
|
+
{ name: 'Overwrite all', value: 'overwrite-all' },
|
|
173
|
+
{ name: 'Choose for each', value: 'choose-each' },
|
|
174
|
+
{ name: 'Cancel', value: 'cancel' }
|
|
175
|
+
]
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const conflict = conflicts[0];
|
|
180
|
+
return await inquirer.select({
|
|
181
|
+
message: `Theme "${conflict.name}" already exists. What would you like to do?`,
|
|
182
|
+
choices: [
|
|
183
|
+
{ name: 'Skip (keep existing)', value: 'skip' },
|
|
184
|
+
{ name: 'Overwrite (replace with template version)', value: 'overwrite' },
|
|
185
|
+
{ name: 'Cancel (stop adding themes)', value: 'cancel' }
|
|
186
|
+
]
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Prompt user for single theme conflict resolution
|
|
192
|
+
*
|
|
193
|
+
* @param {string} themeName - Name of conflicting theme
|
|
194
|
+
* @returns {Promise<string>} Selected action: 'skip', 'overwrite', or 'cancel'
|
|
195
|
+
*/
|
|
196
|
+
static async promptSingleConflict(themeName) {
|
|
197
|
+
return await inquirer.select({
|
|
198
|
+
message: `Theme "${themeName}" already exists. What would you like to do?`,
|
|
199
|
+
choices: [
|
|
200
|
+
{ name: 'Skip (keep existing)', value: 'skip' },
|
|
201
|
+
{ name: 'Overwrite (replace with template version)', value: 'overwrite' },
|
|
202
|
+
{ name: 'Cancel (stop adding themes)', value: 'cancel' }
|
|
203
|
+
]
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Confirm action with user
|
|
209
|
+
*
|
|
210
|
+
* @param {string} message - Confirmation message
|
|
211
|
+
* @param {boolean} defaultValue - Default value (true: yes, false: no)
|
|
212
|
+
* @returns {Promise<boolean>} User's choice
|
|
213
|
+
*/
|
|
214
|
+
static async confirm(message, defaultValue = true) {
|
|
215
|
+
return await inquirer.confirm({
|
|
216
|
+
message,
|
|
217
|
+
default: defaultValue
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
module.exports = { Prompts };
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// lib/theme-manager.js
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { ThemeResolver } = require('./theme-resolver');
|
|
5
|
+
const { VSCodeIntegration } = require('./vscode-integration');
|
|
6
|
+
const { Frontmatter } = require('./frontmatter');
|
|
7
|
+
const {
|
|
8
|
+
ThemeNotFoundError,
|
|
9
|
+
ThemeAlreadyExistsError,
|
|
10
|
+
PresentationNotFoundError
|
|
11
|
+
} = require('./errors');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Main class for theme operations
|
|
15
|
+
* Manages Marp themes in a project, including scanning, creating, and updating themes
|
|
16
|
+
*/
|
|
17
|
+
class ThemeManager {
|
|
18
|
+
// System themes that are always available
|
|
19
|
+
static SYSTEM_THEMES = ['default', 'gaia', 'uncover'];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Ensure marp.themeSet is configured in package.json
|
|
23
|
+
* This is a static utility that can be called without instantiating ThemeManager
|
|
24
|
+
*
|
|
25
|
+
* @param {string} projectPath - Path to the project directory
|
|
26
|
+
* @param {Object} options - Optional configuration
|
|
27
|
+
* @param {boolean} options.silent - If true, don't log messages (default: false)
|
|
28
|
+
* @returns {boolean} - True if configuration was added, false if already present or error occurred
|
|
29
|
+
*/
|
|
30
|
+
static ensureThemeSetConfig(projectPath, options = {}) {
|
|
31
|
+
const { silent = false } = options;
|
|
32
|
+
const pkgPath = path.join(projectPath, 'package.json');
|
|
33
|
+
|
|
34
|
+
if (!fs.existsSync(pkgPath)) {
|
|
35
|
+
if (!silent) console.warn(' Warning: package.json not found');
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const pkgContent = fs.readFileSync(pkgPath, 'utf-8');
|
|
41
|
+
const pkg = JSON.parse(pkgContent);
|
|
42
|
+
|
|
43
|
+
// Check if marp.themeSet is already configured
|
|
44
|
+
if (pkg.marp && pkg.marp.themeSet) {
|
|
45
|
+
return false; // Already configured
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Add marp.themeSet configuration
|
|
49
|
+
if (!pkg.marp) {
|
|
50
|
+
pkg.marp = {};
|
|
51
|
+
}
|
|
52
|
+
pkg.marp.themeSet = './themes';
|
|
53
|
+
|
|
54
|
+
// Write back to package.json with proper formatting
|
|
55
|
+
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
|
56
|
+
|
|
57
|
+
if (!silent) {
|
|
58
|
+
console.log('✓ Added marp.themeSet configuration to package.json');
|
|
59
|
+
}
|
|
60
|
+
return true; // Configuration was added
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (!silent) {
|
|
63
|
+
console.warn(` Warning: Could not ensure marp.themeSet: ${error.message}`);
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Create a new ThemeManager instance
|
|
71
|
+
*
|
|
72
|
+
* @param {string} projectPath - Path to the project root
|
|
73
|
+
*/
|
|
74
|
+
constructor(projectPath) {
|
|
75
|
+
if (!projectPath) {
|
|
76
|
+
throw new Error('Project path is required');
|
|
77
|
+
}
|
|
78
|
+
this.projectPath = projectPath;
|
|
79
|
+
this.themesPath = path.join(projectPath, 'themes');
|
|
80
|
+
this.presentationPath = path.join(projectPath, 'presentation.md');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Scan themes in project
|
|
85
|
+
* Delegates to ThemeResolver.scanDirectory
|
|
86
|
+
*
|
|
87
|
+
* @returns {Theme[]} Array of Theme objects found in themes directory
|
|
88
|
+
*/
|
|
89
|
+
scanThemes() {
|
|
90
|
+
return ThemeResolver.scanDirectory(this.themesPath);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* List theme names (for theme-cli.js compatibility)
|
|
95
|
+
*
|
|
96
|
+
* @returns {string[]} Array of theme names including system themes
|
|
97
|
+
*/
|
|
98
|
+
listThemes() {
|
|
99
|
+
const themes = this.scanThemes().map(t => t.name);
|
|
100
|
+
return [...themes, ...ThemeManager.SYSTEM_THEMES];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* List theme directory names (for theme-cli.js compatibility)
|
|
105
|
+
*
|
|
106
|
+
* @returns {string[]} Array of directory names in themes folder
|
|
107
|
+
*/
|
|
108
|
+
listDirectories() {
|
|
109
|
+
if (!fs.existsSync(this.themesPath)) {
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const entries = fs.readdirSync(this.themesPath, { withFileTypes: true });
|
|
114
|
+
return entries
|
|
115
|
+
.filter(entry => entry.isDirectory())
|
|
116
|
+
.map(entry => entry.name);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Get theme by name
|
|
121
|
+
*
|
|
122
|
+
* @param {string} name - Theme name
|
|
123
|
+
* @returns {Theme|null} Theme object or null if not found
|
|
124
|
+
*/
|
|
125
|
+
getTheme(name) {
|
|
126
|
+
const themes = this.scanThemes();
|
|
127
|
+
return themes.find((theme) => theme.name === name) || null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Get active theme from presentation.md
|
|
132
|
+
*
|
|
133
|
+
* @returns {string|null} Theme name or null if not found
|
|
134
|
+
*/
|
|
135
|
+
getActiveTheme() {
|
|
136
|
+
if (!fs.existsSync(this.presentationPath)) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const content = fs.readFileSync(this.presentationPath, 'utf-8');
|
|
141
|
+
return Frontmatter.getTheme(content);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Set active theme in presentation.md
|
|
146
|
+
*
|
|
147
|
+
* @param {string} themeName - Theme name to set
|
|
148
|
+
* @throws {ThemeNotFoundError} If theme does not exist
|
|
149
|
+
* @throws {PresentationNotFoundError} If presentation.md does not exist
|
|
150
|
+
*/
|
|
151
|
+
setActiveTheme(themeName) {
|
|
152
|
+
if (!fs.existsSync(this.presentationPath)) {
|
|
153
|
+
throw new PresentationNotFoundError(this.presentationPath);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Validate theme exists
|
|
157
|
+
const availableThemes = this.scanThemes().map((t) => t.name);
|
|
158
|
+
const allThemes = [...availableThemes, ...ThemeManager.SYSTEM_THEMES];
|
|
159
|
+
|
|
160
|
+
if (!allThemes.includes(themeName)) {
|
|
161
|
+
throw new ThemeNotFoundError(themeName);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const content = fs.readFileSync(this.presentationPath, 'utf-8');
|
|
165
|
+
const updated = Frontmatter.setTheme(content, themeName);
|
|
166
|
+
Frontmatter.writeToFile(this.presentationPath, updated);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Create new theme
|
|
171
|
+
*
|
|
172
|
+
* @param {string} name - Theme name
|
|
173
|
+
* @param {string|null} parent - Parent theme name or null
|
|
174
|
+
* @param {string} location - 'root', 'existing' folder name, or 'new'
|
|
175
|
+
* @param {string} newFolderName - Required if location is 'new'
|
|
176
|
+
* @throws {ThemeAlreadyExistsError} If theme with same name already exists
|
|
177
|
+
* @throws {Error} If location is 'new' but newFolderName not provided
|
|
178
|
+
*/
|
|
179
|
+
createTheme(name, parent, location, newFolderName = null) {
|
|
180
|
+
// Check for duplicate
|
|
181
|
+
if (this.getTheme(name)) {
|
|
182
|
+
throw new ThemeAlreadyExistsError(name);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let cssPath;
|
|
186
|
+
if (location === 'root') {
|
|
187
|
+
cssPath = path.join(this.themesPath, `${name}.css`);
|
|
188
|
+
} else if (location === 'new') {
|
|
189
|
+
if (!newFolderName) {
|
|
190
|
+
throw new Error('newFolderName required when location is "new"');
|
|
191
|
+
}
|
|
192
|
+
const folderPath = path.join(this.themesPath, newFolderName);
|
|
193
|
+
fs.mkdirSync(folderPath, { recursive: true });
|
|
194
|
+
cssPath = path.join(folderPath, `${name}.css`);
|
|
195
|
+
} else {
|
|
196
|
+
// Existing folder
|
|
197
|
+
cssPath = path.join(this.themesPath, location, `${name}.css`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Generate CSS content
|
|
201
|
+
let css = `/* @theme ${name} */\n\n`;
|
|
202
|
+
|
|
203
|
+
if (parent) {
|
|
204
|
+
css += `@import "${parent}";\n\n`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
css += `:root {\n /* Your theme variables */\n}\n`;
|
|
208
|
+
|
|
209
|
+
fs.writeFileSync(cssPath, css, 'utf-8');
|
|
210
|
+
|
|
211
|
+
// Update VSCode settings
|
|
212
|
+
this.updateVSCodeSettings();
|
|
213
|
+
|
|
214
|
+
return { path: cssPath };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Update VSCode settings with current themes
|
|
219
|
+
* Syncs all discovered themes plus system themes to .vscode/settings.json
|
|
220
|
+
*/
|
|
221
|
+
updateVSCodeSettings() {
|
|
222
|
+
const themes = this.scanThemes();
|
|
223
|
+
const vscode = new VSCodeIntegration(this.projectPath);
|
|
224
|
+
|
|
225
|
+
// Convert theme objects to relative paths for VSCode
|
|
226
|
+
const themePaths = themes.map((theme) => {
|
|
227
|
+
return path.relative(this.projectPath, theme.path);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// Add system theme paths for VSCode (system themes use flat structure)
|
|
231
|
+
const systemThemePaths = ThemeManager.SYSTEM_THEMES.map(name => `themes/${name}.css`);
|
|
232
|
+
const allThemePaths = [...themePaths, ...systemThemePaths];
|
|
233
|
+
|
|
234
|
+
vscode.syncThemes(allThemePaths);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
module.exports = { ThemeManager };
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// lib/theme-resolver.js
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const { readdirSync } = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Represents a Marp theme
|
|
8
|
+
*/
|
|
9
|
+
class Theme {
|
|
10
|
+
constructor(name, cssPath, css, dependencies = []) {
|
|
11
|
+
this.name = name;
|
|
12
|
+
this.path = cssPath;
|
|
13
|
+
this.css = css;
|
|
14
|
+
this.dependencies = dependencies;
|
|
15
|
+
this.isSystem = ['default', 'gaia', 'uncover'].includes(name);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Resolves theme information from CSS files
|
|
21
|
+
*/
|
|
22
|
+
class ThemeResolver {
|
|
23
|
+
static SYSTEM_THEMES = ['default', 'gaia', 'uncover'];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Extract theme name from CSS comment directive
|
|
27
|
+
* Pattern: /* @theme name *\/
|
|
28
|
+
*
|
|
29
|
+
* @param {string} cssContent - CSS file content
|
|
30
|
+
* @param {string} fallbackFilename - Filename to use as fallback
|
|
31
|
+
* @returns {string|null} Theme name or null if not found
|
|
32
|
+
*/
|
|
33
|
+
static extractThemeName(cssContent, fallbackFilename = null) {
|
|
34
|
+
// Match /* @theme name */ - supports multi-line comments
|
|
35
|
+
// The [\s\S]*? allows matching across newlines (non-greedy)
|
|
36
|
+
const themeDirectiveRegex = /\/\*[\s\S]*?@theme\s+([\w-]+)[\s\S]*?\*\//;
|
|
37
|
+
const match = cssContent.match(themeDirectiveRegex);
|
|
38
|
+
|
|
39
|
+
if (match && match[1]) {
|
|
40
|
+
return match[1].trim();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Fallback to filename (with or without .css extension)
|
|
44
|
+
if (fallbackFilename) {
|
|
45
|
+
const basename = path.basename(fallbackFilename, '.css');
|
|
46
|
+
return basename;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Extract @import dependencies from CSS
|
|
54
|
+
* Ignores url() imports
|
|
55
|
+
*
|
|
56
|
+
* @param {string} cssContent - CSS file content
|
|
57
|
+
* @returns {string[]} Array of theme names from @import statements
|
|
58
|
+
*/
|
|
59
|
+
static extractDependencies(cssContent) {
|
|
60
|
+
// Match @import "theme" or @import 'theme' - exclude url() imports using negative lookahead
|
|
61
|
+
const importRegex = /@import\s+(?!url\()['"]([^'"]+)['"]\s*;/g;
|
|
62
|
+
const dependencies = [];
|
|
63
|
+
|
|
64
|
+
let match;
|
|
65
|
+
while ((match = importRegex.exec(cssContent)) !== null) {
|
|
66
|
+
const importPath = match[1];
|
|
67
|
+
|
|
68
|
+
// Skip URL imports (data:, http:, https:, etc.)
|
|
69
|
+
if (importPath.includes('://') || importPath.startsWith('data:')) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Skip if it looks like inline CSS content
|
|
74
|
+
if (importPath.includes('{') || importPath.includes('}')) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
dependencies.push(importPath);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return dependencies;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Resolve theme from a CSS file
|
|
86
|
+
*
|
|
87
|
+
* @param {string} cssPath - Path to CSS file
|
|
88
|
+
* @returns {Theme} Theme object
|
|
89
|
+
* @throws {Error} If file does not exist
|
|
90
|
+
*/
|
|
91
|
+
static resolveTheme(cssPath) {
|
|
92
|
+
if (!fs.existsSync(cssPath)) {
|
|
93
|
+
throw new Error(`CSS file not found: ${cssPath}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const css = fs.readFileSync(cssPath, 'utf-8');
|
|
97
|
+
const filename = path.basename(cssPath);
|
|
98
|
+
const name = this.extractThemeName(css, filename) || filename;
|
|
99
|
+
const dependencies = this.extractDependencies(css);
|
|
100
|
+
|
|
101
|
+
return new Theme(name, cssPath, css, dependencies);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Scan directory recursively for CSS theme files
|
|
106
|
+
*
|
|
107
|
+
* @param {string} themesPath - Path to themes directory
|
|
108
|
+
* @returns {Theme[]} Array of Theme objects (empty if directory doesn't exist)
|
|
109
|
+
*/
|
|
110
|
+
static scanDirectory(themesPath) {
|
|
111
|
+
if (!fs.existsSync(themesPath)) {
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const themes = [];
|
|
116
|
+
const cssFiles = this._findCssFiles(themesPath);
|
|
117
|
+
|
|
118
|
+
for (const cssPath of cssFiles) {
|
|
119
|
+
try {
|
|
120
|
+
const theme = this.resolveTheme(cssPath);
|
|
121
|
+
themes.push(theme);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
// Skip files that can't be resolved
|
|
124
|
+
console.warn(`Warning: Could not resolve theme from ${cssPath}: ${error.message}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return themes;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Find all CSS files in directory recursively
|
|
133
|
+
* @private
|
|
134
|
+
*/
|
|
135
|
+
static _findCssFiles(dirPath, results = []) {
|
|
136
|
+
const entries = readdirSync(dirPath, { withFileTypes: true });
|
|
137
|
+
|
|
138
|
+
for (const entry of entries) {
|
|
139
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
140
|
+
|
|
141
|
+
if (entry.isDirectory()) {
|
|
142
|
+
this._findCssFiles(fullPath, results);
|
|
143
|
+
} else if (entry.isFile() && entry.name.endsWith('.css')) {
|
|
144
|
+
results.push(fullPath);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return results;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Resolve all dependencies for selected themes
|
|
153
|
+
* Returns complete set of themes to copy (including parents)
|
|
154
|
+
* Excludes system themes (default, gaia, uncover)
|
|
155
|
+
*
|
|
156
|
+
* @param {Theme[]} selectedThemes - Themes user selected
|
|
157
|
+
* @param {Theme[]} allThemes - All available themes
|
|
158
|
+
* @returns {Theme[]} Themes to copy (selected + dependencies)
|
|
159
|
+
*/
|
|
160
|
+
static resolveDependencies(selectedThemes, allThemes) {
|
|
161
|
+
const toCopy = new Map(); // Use Map for deduplication by name
|
|
162
|
+
const visited = new Set();
|
|
163
|
+
const visiting = new Set();
|
|
164
|
+
|
|
165
|
+
// Create a map of all available themes for quick lookup
|
|
166
|
+
// Also include selected themes in case they're not in allThemes
|
|
167
|
+
const allThemesMap = new Map();
|
|
168
|
+
for (const theme of allThemes) {
|
|
169
|
+
allThemesMap.set(theme.name, theme);
|
|
170
|
+
}
|
|
171
|
+
for (const theme of selectedThemes) {
|
|
172
|
+
if (!allThemesMap.has(theme.name)) {
|
|
173
|
+
allThemesMap.set(theme.name, theme);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const addTheme = (themeName) => {
|
|
178
|
+
if (visited.has(themeName)) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Check for circular dependency
|
|
183
|
+
if (visiting.has(themeName)) {
|
|
184
|
+
console.warn(`Warning: Circular dependency detected for theme '${themeName}'`);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
visiting.add(themeName);
|
|
189
|
+
|
|
190
|
+
// Find theme in allThemesMap
|
|
191
|
+
const theme = allThemesMap.get(themeName);
|
|
192
|
+
|
|
193
|
+
if (!theme) {
|
|
194
|
+
console.warn(`Warning: Dependency '${themeName}' not found in available themes`);
|
|
195
|
+
visiting.delete(themeName);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Skip system themes
|
|
200
|
+
if (theme.isSystem) {
|
|
201
|
+
visiting.delete(themeName);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// First resolve dependencies
|
|
206
|
+
for (const depName of theme.dependencies) {
|
|
207
|
+
// Extract just the theme name from path (e.g., "../marpx/marpx.css" -> "marpx")
|
|
208
|
+
const simpleDepName = depName.replace(/\.css$/g, '').split('/').pop();
|
|
209
|
+
addTheme(simpleDepName);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Then add this theme
|
|
213
|
+
toCopy.set(theme.name, theme);
|
|
214
|
+
visited.add(theme.name);
|
|
215
|
+
visiting.delete(themeName);
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
// Start with selected themes
|
|
219
|
+
for (const theme of selectedThemes) {
|
|
220
|
+
addTheme(theme.name);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return Array.from(toCopy.values());
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
module.exports = { ThemeResolver, Theme };
|