Skip to content
Draft
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/vscode-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions apps/vscode-extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
TextTypingService,
TerminalService,
ResourceService,
ThemeBuilderService,
} from './services';
import { DemoPanel } from './panels/DemoPanel';
import { ResourcesPanel } from './panels/ResourcesPanel';
Expand Down Expand Up @@ -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!`);
}
Expand Down
2 changes: 1 addition & 1 deletion apps/vscode-extension/src/models/WebviewType.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export type WebviewType = 'presenter' | 'preview' | 'settings' | 'config-editor' | 'overview';
export type WebviewType = 'presenter' | 'preview' | 'settings' | 'config-editor' | 'overview' | 'themeBuilder';
355 changes: 355 additions & 0 deletions apps/vscode-extension/src/panels/ThemeBuilderPanel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,355 @@
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;
}
Comment on lines +39 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overriding messageListener drops the base handling for getSetting, getFileContents, and runCommand. Consider delegating unhandled commands to super.messageListener(message) so core webview commands keep working.

+      default:
+        await super.messageListener(message);
+        break;

🚀 Want me to fix this? Reply ex: "fix it for me".

}

/**
* 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 slidesFolder = Uri.joinPath(wsFolder.uri, General.demoFolder, General.slidesFolder);
const themeFiles: { name: string; path: string }[] = [];

// Look for CSS files in slides folder
try {
const files = await Extension.getInstance().context.fs.readDirectory(slidesFolder);
for (const [file, type] of files) {
if (type === 1 && file.endsWith('.css')) {
// File type
themeFiles.push({
name: file.replace('.css', ''),
path: file,
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In getExistingThemes, file.replace('.css', '') removes the first .css anywhere in the name, not just the extension. Consider using file.replace(/\.css$/, '') so only the file suffix is stripped.

Suggested change
}
name: file.replace(/\.css$/, ''),

🚀 Want me to fix this? Reply ex: "fix it for me".

}
} 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,
General.slidesFolder,
`${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`,
);
Comment on lines +183 to +188

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loadLayout forces a .hbs suffix, so selecting a .handlebars file fails. Suggest preserve the provided extension, or try .hbs then .handlebars when resolving the file.

-      const layoutPath = Uri.joinPath(
-        wsFolder.uri,
-        General.demoFolder,
-        'layouts',
-        `${payload.name}.hbs`,
-      );
+      const layoutsFolder = Uri.joinPath(
+        wsFolder.uri,
+        General.demoFolder,
+        'layouts',
+      );
+      const fileBase = payload.name;
+      const fileName =
+        fileBase.endsWith('.hbs') || fileBase.endsWith('.handlebars')
+          ? fileBase
+          : (await fileExists(Uri.joinPath(layoutsFolder, `${fileBase}.hbs`)))
+          ? `${fileBase}.hbs`
+          : `${fileBase}.handlebars`;
+      const layoutPath = Uri.joinPath(layoutsFolder, fileName);

🚀 Want me to fix this? Reply ex: "fix it for me".


if (!(await fileExists(layoutPath))) {
ThemeBuilderPanel.postRequestMessage(command, requestId, null);
return;
Comment on lines +186 to +192

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multiple handlers use payload.* without validation, which can throw. Suggest consistently validate payload and required fields (types) before use, and return a safe default (e.g., null or { success: false }) when invalid.

Suggested change
'layouts',
`${payload.name}.hbs`,
);
if (!(await fileExists(layoutPath))) {
ThemeBuilderPanel.postRequestMessage(command, requestId, null);
return;
if (!payload || !payload.name) {
ThemeBuilderPanel.postRequestMessage(command, requestId, null);
return;
}
const layoutPath = Uri.joinPath(
wsFolder.uri,
General.demoFolder,
'layouts',
`${payload.name}.hbs`,
);

🚀 Want me to fix this? Reply ex: "fix it for me".

}

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;
}

const fileName = sanitizeFileName(payload.name, '.css');
const themePath = Uri.joinPath(
wsFolder.uri,
General.demoFolder,
General.slidesFolder,
fileName,
);

Comment thread
macroscopeapp[bot] marked this conversation as resolved.
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, {
Comment on lines +266 to +268

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sanitizeFileName can return an empty string. That would make Uri.joinPath(layoutsFolder, fileName) target the folder itself, and writeFile would fail. Consider guarding for an empty fileName and return { success: false } early.

Suggested change
await writeFile(layoutPath, payload.content);
ThemeBuilderPanel.postRequestMessage(command, requestId, {
const fileName = sanitizeFileName(payload.name, '.hbs');
if (!fileName) {
ThemeBuilderPanel.postRequestMessage(command, requestId, { success: false });
return;
}
const layoutPath = Uri.joinPath(layoutsFolder, fileName);

🚀 Want me to fix this? Reply ex: "fix it for me".

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.
let sanitizedHtml = (payload.html || '<h1>Preview</h1><p>Add your HTML content to see the preview</p>');

// Remove script tags (handle all whitespace variations including newlines and tabs)
// Using a more comprehensive pattern that matches any whitespace in closing tags

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The script-tag sanitizer misses self-closing tags like <script/> (and similar variants), so scripts may slip through. Consider expanding the regex to also match self-closing <script .../> in addition to paired tags.

Suggested change
// Using a more comprehensive pattern that matches any whitespace in closing tags
sanitizedHtml = sanitizedHtml.replace(/<script\b[^>]*>([\s\S]*?)<\/\s*script\s*>|<script\b[^>]*\/\s*>/gi, '<!-- script blocked -->');

🚀 Want me to fix this? Reply ex: "fix it for me".

sanitizedHtml = sanitizedHtml.replace(/<script\b[^>]*>[\s\S]*?<\/script\s*>/gi, '<!-- script blocked -->');

// Remove iframe tags (handle all whitespace variations)
sanitizedHtml = sanitizedHtml.replace(/<iframe\b[^>]*>[\s\S]*?<\/iframe\s*>/gi, '<!-- iframe blocked -->');

// Remove event handlers with a single comprehensive pass
// This removes any attribute starting with 'on' followed by word characters
sanitizedHtml = sanitizedHtml.replace(/\s+on[a-z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)/gi, '');

// Remove dangerous URL schemes
sanitizedHtml = sanitizedHtml.replace(/(javascript|data|vbscript):/gi, '');

// Generate a simple preview HTML
const previewHtml = `
<!DOCTYPE html>
<html>
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; font-src 'self';">
<style>
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
background: var(--vscode-editor-background);
color: var(--vscode-editor-foreground);
}
.slide {
width: 100%;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
box-sizing: border-box;
}
${sanitizedCss}
</style>
</head>
<body>
<div class="slide">
${sanitizedHtml}
</div>
</body>
</html>
`;

ThemeBuilderPanel.postRequestMessage(command, requestId, { html: previewHtml });
} catch (error) {
console.error('Error generating preview HTML:', error);
ThemeBuilderPanel.postRequestMessage(command, requestId, { html: '' });
}
}
}
Loading