diff --git a/apps/vscode-extension/package.json b/apps/vscode-extension/package.json index e6401a5c..f788d969 100644 --- a/apps/vscode-extension/package.json +++ b/apps/vscode-extension/package.json @@ -564,6 +564,12 @@ "title": "Authenticate with GitHub (Unlock Pro Features)", "category": "Demo Time", "icon": "$(github)" + }, + { + "command": "demo-time.openThemeBuilder", + "title": "Open Theme Builder (Pro)", + "category": "Demo Time", + "icon": "$(paintcan)" } ], "menus": { @@ -684,6 +690,10 @@ "command": "demo-time.showSettings", "when": "view == demo-time-scenes" }, + { + "command": "demo-time.openThemeBuilder", + "when": "view == demo-time-scenes" + }, { "command": "demo-time.openSupportTheProject", "when": "view == demo-time-scenes" diff --git a/apps/vscode-extension/src/extension.ts b/apps/vscode-extension/src/extension.ts index 07f9ec2c..5201df26 100644 --- a/apps/vscode-extension/src/extension.ts +++ b/apps/vscode-extension/src/extension.ts @@ -18,6 +18,7 @@ import { TextTypingService, TerminalService, ResourceService, + ThemeBuilderService, } from './services'; import { DemoPanel } from './panels/DemoPanel'; import { ResourcesPanel } from './panels/ResourcesPanel'; @@ -63,6 +64,7 @@ export async function activate(context: vscode.ExtensionContext) { TerminalService.register(); InputService.registerCommands(); SponsorService.init(context); + ThemeBuilderService.register(); console.log(`${Config.title} is active!`); } diff --git a/apps/vscode-extension/src/models/WebviewType.ts b/apps/vscode-extension/src/models/WebviewType.ts index 6439d4fc..9c382ba9 100644 --- a/apps/vscode-extension/src/models/WebviewType.ts +++ b/apps/vscode-extension/src/models/WebviewType.ts @@ -1 +1 @@ -export type WebviewType = 'presenter' | 'preview' | 'settings' | 'config-editor' | 'overview'; +export type WebviewType = 'presenter' | 'preview' | 'settings' | 'config-editor' | 'overview' | 'themeBuilder'; diff --git a/apps/vscode-extension/src/panels/ThemeBuilderPanel.ts b/apps/vscode-extension/src/panels/ThemeBuilderPanel.ts new file mode 100644 index 00000000..01e8efb5 --- /dev/null +++ b/apps/vscode-extension/src/panels/ThemeBuilderPanel.ts @@ -0,0 +1,368 @@ +import { Uri, ViewColumn, WebviewPanel } from 'vscode'; +import { WebviewType } from '../models'; +import { Extension } from '../services'; +import { Config, WebViewMessages } from '@demotime/common'; +import { BaseWebview } from '../webview/BaseWebviewPanel'; +import { General } from '../constants'; +import { fileExists, readFile, writeFile, sanitizeFileName } from '../utils'; + +export class ThemeBuilderPanel extends BaseWebview { + public static id: WebviewType = 'themeBuilder'; + public static title: string = `${Config.title}: Theme Builder`; + + /** + * Render the theme builder panel + */ + public static async render() { + if (ThemeBuilderPanel.isOpen) { + ThemeBuilderPanel.reveal(); + } else { + await ThemeBuilderPanel.create(); + } + } + + protected static onCreate() { + ThemeBuilderPanel.isDisposed = false; + } + + protected static onDispose() { + ThemeBuilderPanel.isDisposed = true; + } + + protected static async messageListener(message: any) { + const { command, requestId, payload } = message; + + if (!command) { + return; + } + + switch (command) { + case WebViewMessages.toVscode.themeBuilder.getExistingThemes: + await ThemeBuilderPanel.getExistingThemes(command, requestId); + break; + case WebViewMessages.toVscode.themeBuilder.getExistingLayouts: + await ThemeBuilderPanel.getExistingLayouts(command, requestId); + break; + case WebViewMessages.toVscode.themeBuilder.loadTheme: + await ThemeBuilderPanel.loadTheme(command, requestId, payload); + break; + case WebViewMessages.toVscode.themeBuilder.loadLayout: + await ThemeBuilderPanel.loadLayout(command, requestId, payload); + break; + case WebViewMessages.toVscode.themeBuilder.saveTheme: + await ThemeBuilderPanel.saveTheme(command, requestId, payload); + break; + case WebViewMessages.toVscode.themeBuilder.saveLayout: + await ThemeBuilderPanel.saveLayout(command, requestId, payload); + break; + case WebViewMessages.toVscode.themeBuilder.getPreviewHtml: + await ThemeBuilderPanel.getPreviewHtml(command, requestId, payload); + break; + } + } + + /** + * Get list of existing themes + */ + private static async getExistingThemes(command: string, requestId: string) { + try { + const wsFolder = Extension.getInstance().workspaceFolder; + if (!wsFolder) { + ThemeBuilderPanel.postRequestMessage(command, requestId, []); + return; + } + + const themeFolder = Uri.joinPath(wsFolder.uri, General.demoFolder, 'theme'); + const themeFiles: { name: string; path: string }[] = []; + + // Look for CSS files in theme folder + try { + const files = await Extension.getInstance().context.fs.readDirectory(themeFolder); + for (const [file, type] of files) { + if (type === 1 && file.endsWith('.css')) { + // File type + themeFiles.push({ + name: file.replace('.css', ''), + path: file, + }); + } + } + } catch (error) { + // Folder might not exist yet + } + + ThemeBuilderPanel.postRequestMessage(command, requestId, themeFiles); + } catch (error) { + console.error('Error getting existing themes:', error); + ThemeBuilderPanel.postRequestMessage(command, requestId, []); + } + } + + /** + * Get list of existing layouts + */ + private static async getExistingLayouts(command: string, requestId: string) { + try { + const wsFolder = Extension.getInstance().workspaceFolder; + if (!wsFolder) { + ThemeBuilderPanel.postRequestMessage(command, requestId, []); + return; + } + + const layoutsFolder = Uri.joinPath(wsFolder.uri, General.demoFolder, 'layouts'); + const layoutFiles: { name: string; path: string }[] = []; + + // Look for HBS/Handlebars files in layouts folder + try { + const files = await Extension.getInstance().context.fs.readDirectory(layoutsFolder); + for (const [file, type] of files) { + if (type === 1 && (file.endsWith('.hbs') || file.endsWith('.handlebars'))) { + // File type + layoutFiles.push({ + name: file.replace(/\.(hbs|handlebars)$/, ''), + path: file, + }); + } + } + } catch (error) { + // Folder might not exist yet + } + + ThemeBuilderPanel.postRequestMessage(command, requestId, layoutFiles); + } catch (error) { + console.error('Error getting existing layouts:', error); + ThemeBuilderPanel.postRequestMessage(command, requestId, []); + } + } + + /** + * Load a theme file + */ + private static async loadTheme(command: string, requestId: string, payload: { name: string }) { + try { + const wsFolder = Extension.getInstance().workspaceFolder; + if (!wsFolder) { + ThemeBuilderPanel.postRequestMessage(command, requestId, null); + return; + } + + const themePath = Uri.joinPath( + wsFolder.uri, + General.demoFolder, + 'theme', + `${payload.name}.css`, + ); + + if (!(await fileExists(themePath))) { + ThemeBuilderPanel.postRequestMessage(command, requestId, null); + return; + } + + const content = await readFile(themePath); + ThemeBuilderPanel.postRequestMessage(command, requestId, { + name: payload.name, + content: content?.toString() || '', + }); + } catch (error) { + console.error('Error loading theme:', error); + ThemeBuilderPanel.postRequestMessage(command, requestId, null); + } + } + + /** + * Load a layout file + */ + private static async loadLayout(command: string, requestId: string, payload: { name: string }) { + try { + const wsFolder = Extension.getInstance().workspaceFolder; + if (!wsFolder) { + ThemeBuilderPanel.postRequestMessage(command, requestId, null); + return; + } + + const layoutPath = Uri.joinPath( + wsFolder.uri, + General.demoFolder, + 'layouts', + `${payload.name}.hbs`, + ); + + if (!(await fileExists(layoutPath))) { + ThemeBuilderPanel.postRequestMessage(command, requestId, null); + return; + } + + const content = await readFile(layoutPath); + ThemeBuilderPanel.postRequestMessage(command, requestId, { + name: payload.name, + content: content?.toString() || '', + }); + } catch (error) { + console.error('Error loading layout:', error); + ThemeBuilderPanel.postRequestMessage(command, requestId, null); + } + } + + /** + * Save a theme file + */ + private static async saveTheme( + command: string, + requestId: string, + payload: { name: string; content: string }, + ) { + try { + const wsFolder = Extension.getInstance().workspaceFolder; + if (!wsFolder) { + ThemeBuilderPanel.postRequestMessage(command, requestId, { success: false }); + return; + } + + // Ensure theme folder exists + const themeFolder = Uri.joinPath(wsFolder.uri, General.demoFolder, 'theme'); + if (!(await fileExists(themeFolder))) { + await Extension.getInstance().context.fs.createDirectory(themeFolder); + } + + const fileName = sanitizeFileName(payload.name, '.css'); + const themePath = Uri.joinPath(themeFolder, fileName); + + await writeFile(themePath, payload.content); + + ThemeBuilderPanel.postRequestMessage(command, requestId, { + success: true, + path: fileName, + }); + } catch (error) { + console.error('Error saving theme:', error); + ThemeBuilderPanel.postRequestMessage(command, requestId, { success: false }); + } + } + + /** + * Save a layout file + */ + private static async saveLayout( + command: string, + requestId: string, + payload: { name: string; content: string }, + ) { + try { + const wsFolder = Extension.getInstance().workspaceFolder; + if (!wsFolder) { + ThemeBuilderPanel.postRequestMessage(command, requestId, { success: false }); + return; + } + + // Ensure layouts folder exists + const layoutsFolder = Uri.joinPath(wsFolder.uri, General.demoFolder, 'layouts'); + if (!(await fileExists(layoutsFolder))) { + await Extension.getInstance().context.fs.createDirectory(layoutsFolder); + } + + const fileName = sanitizeFileName(payload.name, '.hbs'); + const layoutPath = Uri.joinPath(layoutsFolder, fileName); + + await writeFile(layoutPath, payload.content); + + ThemeBuilderPanel.postRequestMessage(command, requestId, { + success: true, + path: fileName, + }); + } catch (error) { + console.error('Error saving layout:', error); + ThemeBuilderPanel.postRequestMessage(command, requestId, { success: false }); + } + } + + /** + * Get preview HTML for a slide with custom theme/layout + * Note: This generates HTML for preview in a sandboxed iframe. + * The content comes from the authenticated user's own files. + */ + private static async getPreviewHtml( + command: string, + requestId: string, + payload: { css?: string; html?: string }, + ) { + try { + // Sanitize CSS: Remove potentially dangerous URL schemes and patterns + const sanitizedCss = (payload.css || '') + .replace(/@import\s*[^;]*;?/gi, '/* @import blocked */') + .replace(/(javascript|data|vbscript):/gi, '/* $1: blocked */') + .replace(/expression\s*\(/gi, '/* expression() blocked */'); + + // Sanitize HTML: Remove dangerous tags and event handlers + // Note: This is basic sanitization for a trusted Pro user environment. + // The preview is also sandboxed with CSP and iframe sandbox attribute. + // Multiple layers of security: regex sanitization + CSP + iframe sandbox + let sanitizedHtml = (payload.html || '
Add your HTML content to see the preview
'); + + // Remove script tags iteratively to handle nested or malformed tags + // Matches