diff --git a/CLAUDE.md b/CLAUDE.md
index b9e2b42..0838ce4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -53,6 +53,9 @@
# General Claude Guidelines
- Don't run npm commands, just tell me what to run and I'll run them myself
+- generally tsx files should be used for React components and they should be less than 200 lines of code
+- if a file I should be warned when I ask for a code audit
+- if a function or method is more than 50 lines of code, I should be warned
# Architecture
## Themes
diff --git a/package-lock.json b/package-lock.json
index 25c2317..1859a62 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,7 @@
"name": "slideshare",
"version": "0.0.0",
"dependencies": {
+ "dompurify": "^3.2.6",
"loglevel": "^1.9.2",
"postcss": "^8.5.6",
"react": "^19.1.1",
@@ -16,6 +17,7 @@
},
"devDependencies": {
"@eslint/js": "^9.33.0",
+ "@types/dompurify": "^3.0.5",
"@types/node": "^24.3.0",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
@@ -1396,6 +1398,16 @@
"@babel/types": "^7.28.2"
}
},
+ "node_modules/@types/dompurify": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
+ "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/trusted-types": "*"
+ }
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1440,6 +1452,13 @@
"@types/react": "^19.0.0"
}
},
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "devOptional": true,
+ "license": "MIT"
+ },
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.40.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.40.0.tgz",
@@ -1997,6 +2016,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/dompurify": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz",
+ "integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==",
+ "license": "(MPL-2.0 OR Apache-2.0)",
+ "optionalDependencies": {
+ "@types/trusted-types": "^2.0.7"
+ }
+ },
"node_modules/electron-to-chromium": {
"version": "1.5.207",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.207.tgz",
diff --git a/package.json b/package.json
index 39b744a..a5efae2 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,7 @@
"generate-manifest": "node scripts/generate-themes-manifest.js"
},
"dependencies": {
+ "dompurify": "^3.2.6",
"loglevel": "^1.9.2",
"postcss": "^8.5.6",
"react": "^19.1.1",
@@ -19,6 +20,7 @@
},
"devDependencies": {
"@eslint/js": "^9.33.0",
+ "@types/dompurify": "^3.0.5",
"@types/node": "^24.3.0",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
diff --git a/public/themes-manifest.json b/public/themes-manifest.json
index 301b83d..e5faf01 100644
--- a/public/themes-manifest.json
+++ b/public/themes-manifest.json
@@ -12,5 +12,5 @@
"hasMasterSlide": true
}
},
- "generated": "2025-08-20T22:06:06.798Z"
+ "generated": "2025-08-20T22:36:56.857Z"
}
\ No newline at end of file
diff --git a/src/components/presentations/PresentationEditor.tsx b/src/components/presentations/PresentationEditor.tsx
index 9c5fbb9..d6fe1a5 100644
--- a/src/components/presentations/PresentationEditor.tsx
+++ b/src/components/presentations/PresentationEditor.tsx
@@ -4,6 +4,8 @@ import type { Presentation } from '../../types/presentation';
import type { Theme } from '../../types/theme';
import { getPresentationById, updatePresentation } from '../../utils/presentationStorage';
import { getTheme } from '../../themes';
+import { useDialog } from '../../hooks/useDialog';
+import { AlertDialog, ConfirmDialog } from '../../components/ui';
import './PresentationEditor.css';
export const PresentationEditor: React.FC = () => {
@@ -20,6 +22,20 @@ export const PresentationEditor: React.FC = () => {
const [saving, setSaving] = useState(false);
const currentSlideIndex = slideNumber ? parseInt(slideNumber, 10) - 1 : 0;
+
+ const {
+ isAlertOpen,
+ isConfirmOpen,
+ alertOptions,
+ confirmOptions,
+ showAlert,
+ showConfirm,
+ closeAlert,
+ closeConfirm,
+ handleConfirm,
+ showError,
+ confirmDelete
+ } = useDialog();
useEffect(() => {
const loadPresentationAndTheme = async () => {
@@ -150,7 +166,8 @@ export const PresentationEditor: React.FC = () => {
confirmMessage += ` This will remove the slide and renumber all subsequent slides. This action cannot be undone.`;
}
- if (!confirm(confirmMessage)) {
+ const confirmed = await confirmDelete(confirmMessage);
+ if (!confirmed) {
return;
}
@@ -206,11 +223,15 @@ export const PresentationEditor: React.FC = () => {
// TODO: Implement presentation saving
console.log('Save presentation functionality to be implemented');
- alert('Auto-save will be implemented. Changes are saved automatically.');
+ showAlert({
+ message: 'Auto-save will be implemented. Changes are saved automatically.',
+ type: 'info',
+ title: 'Auto-save Feature'
+ });
} catch (err) {
console.error('Error saving presentation:', err);
- alert('Failed to save presentation');
+ showError('Failed to save presentation. Please try again.');
} finally {
setSaving(false);
}
@@ -508,6 +529,28 @@ export const PresentationEditor: React.FC = () => {
)}
+
+ {/* Dialog Components */}
+
+
+
);
};
\ No newline at end of file
diff --git a/src/components/presentations/SlideEditor.tsx b/src/components/presentations/SlideEditor.tsx
index b4514b5..a8a7b30 100644
--- a/src/components/presentations/SlideEditor.tsx
+++ b/src/components/presentations/SlideEditor.tsx
@@ -5,6 +5,7 @@ import type { Theme, SlideLayout } from '../../types/theme';
import { getPresentationById, updatePresentation } from '../../utils/presentationStorage';
import { getTheme } from '../../themes';
import { renderTemplateWithSampleData } from '../../utils/templateRenderer';
+import { sanitizeSlideTemplate } from '../../utils/htmlSanitizer';
import './SlideEditor.css';
export const SlideEditor: React.FC = () => {
@@ -272,7 +273,7 @@ export const SlideEditor: React.FC = () => {
@@ -388,7 +389,7 @@ export const SlideEditor: React.FC = () => {
diff --git a/src/components/themes/LayoutPreviewPage.tsx b/src/components/themes/LayoutPreviewPage.tsx
index 1a21036..a009684 100644
--- a/src/components/themes/LayoutPreviewPage.tsx
+++ b/src/components/themes/LayoutPreviewPage.tsx
@@ -3,6 +3,7 @@ import { useParams, Link } from 'react-router-dom';
import type { Theme, SlideLayout } from '../../types/theme';
import { getTheme } from '../../themes';
import { renderTemplateWithSampleData } from '../../utils/templateRenderer';
+import { sanitizeSlideTemplate } from '../../utils/htmlSanitizer';
import './LayoutPreviewPage.css';
export const LayoutPreviewPage: React.FC = () => {
@@ -152,7 +153,7 @@ export const LayoutPreviewPage: React.FC = () => {
void;
+ title?: string;
+ message: string;
+ type?: 'info' | 'warning' | 'error' | 'success';
+ confirmText?: string;
+}
+
+export const AlertDialog: React.FC = ({
+ isOpen,
+ onClose,
+ title,
+ message,
+ type = 'info',
+ confirmText = 'OK'
+}) => {
+ const getIcon = () => {
+ switch (type) {
+ case 'error':
+ return '❌';
+ case 'warning':
+ return '⚠️';
+ case 'success':
+ return '✅';
+ default:
+ return 'ℹ️';
+ }
+ };
+
+ const getTitle = () => {
+ if (title) return title;
+
+ switch (type) {
+ case 'error':
+ return 'Error';
+ case 'warning':
+ return 'Warning';
+ case 'success':
+ return 'Success';
+ default:
+ return 'Information';
+ }
+ };
+
+ return (
+
+
+
+
{getIcon()}
+
{message}
+
+
+
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/components/ui/ConfirmDialog.tsx b/src/components/ui/ConfirmDialog.tsx
new file mode 100644
index 0000000..b8ba3ce
--- /dev/null
+++ b/src/components/ui/ConfirmDialog.tsx
@@ -0,0 +1,179 @@
+import React from 'react';
+import { Modal } from './Modal';
+
+interface ConfirmDialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onConfirm: () => void;
+ title?: string;
+ message: string;
+ type?: 'info' | 'warning' | 'danger';
+ confirmText?: string;
+ cancelText?: string;
+ isDestructive?: boolean;
+}
+
+export const ConfirmDialog: React.FC = ({
+ isOpen,
+ onClose,
+ onConfirm,
+ title,
+ message,
+ type = 'info',
+ confirmText,
+ cancelText = 'Cancel',
+ isDestructive = false
+}) => {
+ const getIcon = () => {
+ switch (type) {
+ case 'danger':
+ return '⚠️';
+ case 'warning':
+ return '⚠️';
+ default:
+ return '❓';
+ }
+ };
+
+ const getTitle = () => {
+ if (title) return title;
+
+ switch (type) {
+ case 'danger':
+ return 'Confirm Deletion';
+ case 'warning':
+ return 'Confirm Action';
+ default:
+ return 'Confirm';
+ }
+ };
+
+ const getConfirmText = () => {
+ if (confirmText) return confirmText;
+
+ switch (type) {
+ case 'danger':
+ return 'Delete';
+ case 'warning':
+ return 'Continue';
+ default:
+ return 'Confirm';
+ }
+ };
+
+ const handleConfirm = () => {
+ onConfirm();
+ onClose();
+ };
+
+ return (
+
+
+
+
{getIcon()}
+
{message}
+
+
+
+
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/components/ui/Modal.css b/src/components/ui/Modal.css
new file mode 100644
index 0000000..f16b1ed
--- /dev/null
+++ b/src/components/ui/Modal.css
@@ -0,0 +1,123 @@
+/* Modal Overlay */
+.modal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ padding: 1rem;
+ box-sizing: border-box;
+}
+
+/* Modal Content */
+.modal-content {
+ background: white;
+ border-radius: 0.75rem;
+ box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
+ max-width: 100%;
+ max-height: 90vh;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ animation: modalSlideIn 0.2s ease-out;
+}
+
+@keyframes modalSlideIn {
+ from {
+ opacity: 0;
+ transform: scale(0.95) translateY(-10px);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1) translateY(0);
+ }
+}
+
+/* Modal Sizes */
+.modal-small {
+ width: 100%;
+ max-width: 400px;
+}
+
+.modal-medium {
+ width: 100%;
+ max-width: 600px;
+}
+
+.modal-large {
+ width: 100%;
+ max-width: 800px;
+}
+
+/* Modal Header */
+.modal-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 1.5rem 1.5rem 0 1.5rem;
+ border-bottom: 1px solid #e5e7eb;
+ margin-bottom: 1rem;
+}
+
+.modal-title {
+ margin: 0;
+ font-size: 1.25rem;
+ font-weight: 600;
+ color: #111827;
+}
+
+.modal-close-button {
+ background: none;
+ border: none;
+ color: #6b7280;
+ cursor: pointer;
+ font-size: 1.25rem;
+ padding: 0.25rem;
+ border-radius: 0.25rem;
+ transition: all 0.2s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 2rem;
+ height: 2rem;
+}
+
+.modal-close-button:hover {
+ background: #f3f4f6;
+ color: #374151;
+}
+
+/* Modal Body */
+.modal-body {
+ padding: 1.5rem;
+ overflow-y: auto;
+ flex: 1;
+}
+
+/* Responsive Design */
+@media (max-width: 640px) {
+ .modal-overlay {
+ padding: 0.5rem;
+ }
+
+ .modal-content {
+ max-height: 95vh;
+ }
+
+ .modal-header {
+ padding: 1rem 1rem 0 1rem;
+ }
+
+ .modal-body {
+ padding: 1rem;
+ }
+
+ .modal-title {
+ font-size: 1.125rem;
+ }
+}
\ No newline at end of file
diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx
new file mode 100644
index 0000000..e8e9814
--- /dev/null
+++ b/src/components/ui/Modal.tsx
@@ -0,0 +1,80 @@
+import React, { useEffect } from 'react';
+import './Modal.css';
+
+interface ModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ title?: string;
+ children: React.ReactNode;
+ size?: 'small' | 'medium' | 'large';
+ closeOnOverlayClick?: boolean;
+ closeOnEscape?: boolean;
+}
+
+export const Modal: React.FC = ({
+ isOpen,
+ onClose,
+ title,
+ children,
+ size = 'medium',
+ closeOnOverlayClick = true,
+ closeOnEscape = true
+}) => {
+ // Handle escape key
+ useEffect(() => {
+ if (!isOpen || !closeOnEscape) return;
+
+ const handleEscape = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ onClose();
+ }
+ };
+
+ document.addEventListener('keydown', handleEscape);
+ return () => document.removeEventListener('keydown', handleEscape);
+ }, [isOpen, closeOnEscape, onClose]);
+
+ // Handle body scroll lock
+ useEffect(() => {
+ if (isOpen) {
+ document.body.style.overflow = 'hidden';
+ } else {
+ document.body.style.overflow = 'unset';
+ }
+
+ return () => {
+ document.body.style.overflow = 'unset';
+ };
+ }, [isOpen]);
+
+ if (!isOpen) return null;
+
+ const handleOverlayClick = (event: React.MouseEvent) => {
+ if (event.target === event.currentTarget && closeOnOverlayClick) {
+ onClose();
+ }
+ };
+
+ return (
+
+
+ {title && (
+
+ )}
+
+ {children}
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts
new file mode 100644
index 0000000..692c325
--- /dev/null
+++ b/src/components/ui/index.ts
@@ -0,0 +1,3 @@
+export { Modal } from './Modal';
+export { AlertDialog } from './AlertDialog';
+export { ConfirmDialog } from './ConfirmDialog';
\ No newline at end of file
diff --git a/src/hooks/useDialog.ts b/src/hooks/useDialog.ts
new file mode 100644
index 0000000..d988632
--- /dev/null
+++ b/src/hooks/useDialog.ts
@@ -0,0 +1,117 @@
+import { useState, useCallback } from 'react';
+
+interface AlertOptions {
+ title?: string;
+ message: string;
+ type?: 'info' | 'warning' | 'error' | 'success';
+ confirmText?: string;
+}
+
+interface ConfirmOptions {
+ title?: string;
+ message: string;
+ type?: 'info' | 'warning' | 'danger';
+ confirmText?: string;
+ cancelText?: string;
+ isDestructive?: boolean;
+}
+
+interface DialogState {
+ isAlertOpen: boolean;
+ isConfirmOpen: boolean;
+ alertOptions: AlertOptions;
+ confirmOptions: ConfirmOptions;
+ confirmCallback: (() => void) | null;
+}
+
+export function useDialog() {
+ const [state, setState] = useState({
+ isAlertOpen: false,
+ isConfirmOpen: false,
+ alertOptions: { message: '' },
+ confirmOptions: { message: '' },
+ confirmCallback: null
+ });
+
+ const showAlert = useCallback((options: AlertOptions) => {
+ setState(prev => ({
+ ...prev,
+ isAlertOpen: true,
+ alertOptions: options
+ }));
+ }, []);
+
+ const showConfirm = useCallback((options: ConfirmOptions): Promise => {
+ return new Promise((resolve) => {
+ setState(prev => ({
+ ...prev,
+ isConfirmOpen: true,
+ confirmOptions: options,
+ confirmCallback: () => resolve(true)
+ }));
+ });
+ }, []);
+
+ const closeAlert = useCallback(() => {
+ setState(prev => ({
+ ...prev,
+ isAlertOpen: false
+ }));
+ }, []);
+
+ const closeConfirm = useCallback(() => {
+ setState(prev => ({
+ ...prev,
+ isConfirmOpen: false,
+ confirmCallback: null
+ }));
+ }, []);
+
+ const handleConfirm = useCallback(() => {
+ if (state.confirmCallback) {
+ state.confirmCallback();
+ }
+ closeConfirm();
+ }, [state.confirmCallback, closeConfirm]);
+
+ const handleConfirmCancel = useCallback(() => {
+ closeConfirm();
+ }, [closeConfirm]);
+
+ return {
+ // State
+ isAlertOpen: state.isAlertOpen,
+ isConfirmOpen: state.isConfirmOpen,
+ alertOptions: state.alertOptions,
+ confirmOptions: state.confirmOptions,
+
+ // Actions
+ showAlert,
+ showConfirm,
+ closeAlert,
+ closeConfirm: handleConfirmCancel,
+ handleConfirm,
+
+ // Convenience methods
+ showError: useCallback((message: string, title?: string) => {
+ showAlert({ message, title, type: 'error' });
+ }, [showAlert]),
+
+ showSuccess: useCallback((message: string, title?: string) => {
+ showAlert({ message, title, type: 'success' });
+ }, [showAlert]),
+
+ showWarning: useCallback((message: string, title?: string) => {
+ showAlert({ message, title, type: 'warning' });
+ }, [showAlert]),
+
+ confirmDelete: useCallback((message: string, title?: string) => {
+ return showConfirm({
+ message,
+ title: title || 'Confirm Deletion',
+ type: 'danger',
+ isDestructive: true
+ });
+ }, [showConfirm])
+ };
+}
\ No newline at end of file
diff --git a/src/utils/htmlSanitizer.ts b/src/utils/htmlSanitizer.ts
new file mode 100644
index 0000000..1bea39b
--- /dev/null
+++ b/src/utils/htmlSanitizer.ts
@@ -0,0 +1,165 @@
+import DOMPurify from 'dompurify';
+
+/**
+ * Configuration for HTML sanitization based on context
+ */
+interface SanitizeConfig {
+ /**
+ * Whether to allow basic HTML formatting tags (b, i, em, strong, etc.)
+ */
+ allowFormatting?: boolean;
+
+ /**
+ * Whether to allow structural HTML elements (div, span, p, etc.)
+ */
+ allowStructural?: boolean;
+
+ /**
+ * Whether to allow image tags (with strict src validation)
+ */
+ allowImages?: boolean;
+
+ /**
+ * Custom allowed tags (overrides other options if provided)
+ */
+ allowedTags?: string[];
+
+ /**
+ * Custom allowed attributes (overrides defaults if provided)
+ */
+ allowedAttributes?: string[];
+}
+
+/**
+ * Default safe configuration for slide content
+ */
+const DEFAULT_SLIDE_CONFIG: Required = {
+ allowFormatting: true,
+ allowStructural: true,
+ allowImages: false,
+ allowedTags: [
+ // Text formatting
+ 'b', 'i', 'em', 'strong', 'u', 'mark', 'small', 'sub', 'sup',
+ // Structure
+ 'div', 'span', 'p', 'br', 'hr',
+ // Lists
+ 'ul', 'ol', 'li',
+ // Headings
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
+ // Quotes
+ 'blockquote', 'cite'
+ ],
+ allowedAttributes: [
+ 'class', 'id', 'style', 'data-*'
+ ]
+};
+
+/**
+ * Sanitizes HTML content to prevent XSS attacks while preserving safe formatting
+ *
+ * @param html - The HTML string to sanitize
+ * @param config - Configuration options for sanitization
+ * @returns Sanitized HTML string safe for rendering
+ */
+export function sanitizeHtml(html: string, config: SanitizeConfig = {}): string {
+ if (!html || typeof html !== 'string') {
+ return '';
+ }
+
+ const finalConfig = { ...DEFAULT_SLIDE_CONFIG, ...config };
+
+ // Build DOMPurify configuration
+ const purifyConfig: any = {
+ ALLOWED_TAGS: finalConfig.allowedTags,
+ ALLOWED_ATTR: finalConfig.allowedAttributes,
+ // Remove any scripts or dangerous content
+ FORBID_TAGS: ['script', 'object', 'embed', 'base', 'link', 'meta', 'style'],
+ FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur'],
+ // Keep content structure but remove dangerous elements
+ KEEP_CONTENT: true,
+ // Allow data attributes for slots and styling
+ ALLOW_DATA_ATTR: true
+ };
+
+ // Add image support if requested
+ if (finalConfig.allowImages) {
+ purifyConfig.ALLOWED_TAGS.push('img');
+ purifyConfig.ALLOWED_ATTR.push('src', 'alt', 'width', 'height');
+
+ // Add hook to validate image sources
+ DOMPurify.addHook('beforeSanitizeAttributes', (node) => {
+ if (node.tagName === 'IMG') {
+ const src = node.getAttribute('src');
+ if (src && !isValidImageSource(src)) {
+ node.removeAttribute('src');
+ }
+ }
+ });
+ }
+
+ const sanitized = DOMPurify.sanitize(html, purifyConfig);
+
+ // Clean up hooks to prevent memory leaks
+ DOMPurify.removeAllHooks();
+
+ return sanitized;
+}
+
+/**
+ * Validates image sources to ensure they're safe
+ *
+ * @param src - Image source URL
+ * @returns Whether the source is considered safe
+ */
+function isValidImageSource(src: string): boolean {
+ try {
+ const url = new URL(src, window.location.origin);
+
+ // Allow data URLs for base64 images
+ if (url.protocol === 'data:') {
+ return src.startsWith('data:image/');
+ }
+
+ // Allow HTTPS and HTTP from trusted domains
+ if (url.protocol === 'https:' || url.protocol === 'http:') {
+ // Add any domain restrictions here if needed
+ return true;
+ }
+
+ return false;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Specific sanitization for slide template content
+ * Allows structural elements and formatting but restricts dangerous content
+ *
+ * @param html - Template HTML to sanitize
+ * @returns Sanitized HTML safe for slide rendering
+ */
+export function sanitizeSlideTemplate(html: string): string {
+ return sanitizeHtml(html, {
+ allowFormatting: true,
+ allowStructural: true,
+ allowImages: true
+ });
+}
+
+/**
+ * Specific sanitization for user-provided content
+ * More restrictive than template sanitization
+ *
+ * @param html - User content to sanitize
+ * @returns Sanitized HTML safe for user content
+ */
+export function sanitizeUserContent(html: string): string {
+ return sanitizeHtml(html, {
+ allowFormatting: true,
+ allowStructural: false,
+ allowImages: false,
+ allowedTags: ['b', 'i', 'em', 'strong', 'u', 'br'],
+ allowedAttributes: []
+ });
+}
\ No newline at end of file