diff --git a/docs/dist/404.html b/docs/dist/404.html
index 3e39bca9..93c34f98 100644
--- a/docs/dist/404.html
+++ b/docs/dist/404.html
@@ -3,21 +3,22 @@
-Introduction to Machine Learning - Slidev
+Real Obsidian Test - Slidev
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/dist/README.md b/docs/dist/README.md
deleted file mode 100644
index abc872ce..00000000
--- a/docs/dist/README.md
+++ /dev/null
@@ -1,244 +0,0 @@
-# Slidev Build Output Directory
-
-This directory contains the build output from Slidev presentation exports.
-
-## Files
-
-### Production Output
-
-**`index-standalone.html`** ⭐
-- **Size:** 727KB
-- **Type:** Single-file bundle (standalone mode)
-- **Usage:** Double-click to view in browser
-- **Protocol:** Works under `file://`, `http://`, `https://`
-- **Status:** Production-ready ✅
-
-**`index.html`**
-- **Size:** 1.6KB
-- **Type:** Multi-file build (server-script mode)
-- **Usage:** Requires local HTTP server
-- **Protocol:** `http://` only
-- **Dependencies:** `assets/` directory
-
-**`assets/`**
-- JavaScript modules (ES modules)
-- CSS stylesheets
-- Slidev components
-- Total size: ~700KB
-
-### Development Files
-
-**`test-*.html`**
-- Development and debugging builds
-- Used for testing bundler transformations
-- Not for production use
-
-**`404.html`**
-- Error page for hosted deployments
-- Used when file not found
-
-**`_redirects`**
-- Netlify/hosting configuration
-- Handles SPA routing
-
-## Build Process
-
-### Standalone Mode
-```bash
-# Build Slidev with Vite
-cd docs && npm run build
-
-# Transform to standalone bundle
-node ../test-bundle-FIXED.js
-
-# Output: index-standalone.html
-```
-
-### Server-Script Mode
-```bash
-# Build Slidev with Vite
-cd docs && npm run build
-
-# Output: index.html + assets/
-# Server scripts generated by plugin
-```
-
-## Testing
-
-### Verify Standalone Bundle
-```bash
-# Method 1: Double-click
-open index-standalone.html
-
-# Method 2: HTTP server
-python3 -m http.server 8888 &
-# Open http://localhost:8888/index-standalone.html
-```
-
-### Verify Server-Script Build
-```bash
-python3 -m http.server 8888 &
-# Open http://localhost:8888/index.html
-```
-
-### Automated Verification
-```bash
-# Start server
-cd docs/dist
-python3 -m http.server 8888 &
-
-# Run verification script
-node /tmp/final-verify.js
-
-# Expected output:
-# Status: ✅ SUCCESS
-# Slide exists: true
-# Has content: true
-```
-
-## File Structure
-
-```
-docs/dist/
-├── index-standalone.html ⭐ Production standalone
-├── index.html Production multi-file
-├── assets/ JavaScript & CSS modules
-│ ├── index-*.js
-│ ├── modules/
-│ └── slidev/
-├── test-*.html Development builds
-├── 404.html Error page
-└── _redirects Hosting config
-```
-
-## Size Analysis
-
-| File | Size | Type |
-|------|------|------|
-| `index-standalone.html` | 727KB | All-in-one |
-| `index.html` | 1.6KB | Entry point |
-| `assets/` (total) | ~700KB | Split modules |
-| **Total multi-file** | ~701KB | |
-
-**Note:** Standalone bundle is only 4% larger than multi-file due to deduplication.
-
-## Browser Testing
-
-Test both builds in:
-- ✅ Chrome/Chromium
-- ✅ Firefox
-- ✅ Edge
-- ✅ Safari
-
-**Standalone:** Works with `file://` protocol
-**Multi-file:** Requires `http://` protocol
-
-## Troubleshooting
-
-### Standalone Bundle Issues
-
-**Empty slides (shows ``)**
-- Old bug: Fixed in test-bundle-FIXED.js (2026-06-16)
-- Solution: Regenerate with `node test-bundle-FIXED.js`
-- Verify: Check "Introduction to Machine Learning" is visible
-
-**File size wrong**
-- Expected: ~727KB
-- If much smaller: Export transformation failed
-- If much larger: Extra content or duplication
-
-**Console errors**
-- Check browser DevTools (F12)
-- Look for module loading errors
-- Verify all imports transformed correctly
-
-### Multi-File Build Issues
-
-**CORS errors on file://**
-- Expected: Multi-file build requires HTTP server
-- Solution: Use `python3 -m http.server 8888`
-- Or use standalone bundle instead
-
-**Module not found errors**
-- Check `assets/` directory exists
-- Verify relative paths are correct
-- Ensure HTTP server is serving from correct directory
-
-## Deployment
-
-### Standalone Bundle
-```bash
-# Copy single file anywhere
-cp index-standalone.html ~/Desktop/presentation.html
-
-# Works immediately by double-clicking
-```
-
-### Multi-File Build
-```bash
-# Copy entire directory
-cp -r docs/dist/* /var/www/html/presentation/
-
-# Or create archive
-tar -czf presentation.tar.gz index.html assets/ 404.html _redirects
-```
-
-## Development
-
-### Rebuild After Changes
-```bash
-# 1. Update Slidev source (docs/*.md)
-# 2. Rebuild
-cd docs && npm run build
-
-# 3. Regenerate standalone
-cd .. && node test-bundle-FIXED.js
-
-# 4. Test
-open docs/dist/index-standalone.html
-```
-
-### Debug Build Issues
-```bash
-# Check Vite output
-cd docs && npm run build -- --debug
-
-# Check bundler logs
-node test-bundle-FIXED.js 2>&1 | tee bundle.log
-
-# Verify module structure
-node -e "
-const fs = require('fs');
-const html = fs.readFileSync('docs/dist/index-standalone.html', 'utf8');
-const scriptMatch = html.match(/
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
index 35d9621c..58d56237 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,7 @@
"version": "1.9.2",
"license": "MIT",
"dependencies": {
+ "acorn": "^8.17.0",
"mermaid": "11.4.1",
"minisearch": "^7.2.0",
"vega": "^6.2.0",
@@ -25,6 +26,7 @@
"jest": "^29.7.0",
"lodash": "^4.17.21",
"obsidian": "latest",
+ "playwright": "^1.61.0",
"ts-jest": "^29.3.2",
"tslib": "2.4.0",
"typescript": "^5.9.3"
@@ -2409,9 +2411,9 @@
"peer": true
},
"node_modules/acorn": {
- "version": "8.16.0",
- "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
- "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
+ "version": "8.17.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
+ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
@@ -6176,6 +6178,53 @@
"pathe": "^2.0.3"
}
},
+ "node_modules/playwright": {
+ "version": "1.61.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
+ "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.61.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.61.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
+ "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/playwright/node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/points-on-curve": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz",
diff --git a/package.json b/package.json
index 82596c73..f662d6e8 100644
--- a/package.json
+++ b/package.json
@@ -47,11 +47,13 @@
"jest": "^29.7.0",
"lodash": "^4.17.21",
"obsidian": "latest",
+ "playwright": "^1.61.0",
"ts-jest": "^29.3.2",
"tslib": "2.4.0",
"typescript": "^5.9.3"
},
"dependencies": {
+ "acorn": "^8.17.0",
"mermaid": "11.4.1",
"minisearch": "^7.2.0",
"vega": "^6.2.0",
diff --git a/src/constants.ts b/src/constants.ts
index 92d8663d..0ed753fd 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -208,6 +208,7 @@ export const DEFAULT_SETTINGS: NotemdSettings = {
slideExportFfmpegCrf: 23,
slideExportTimeoutMs: 120_000,
slideExportTheme: '',
+ slideExportHtmlMode: 'standalone',
};
// Constants for the Sidebar View
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index 62c0ca14..6c15826b 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -969,6 +969,10 @@ export const STRINGS_EN = {
enableDesc: 'Allow exporting Markdown files as slide presentations using Slidev. Desktop only — requires Node.js 20+.',
defaultFormatName: 'Default export format',
defaultFormatDesc: 'MP4 requires ffmpeg in addition to Slidev and Playwright.',
+ htmlModeName: 'HTML export mode',
+ htmlModeDesc: 'Standalone: Single-file bundle (2-5MB) that works by double-clicking, no setup needed. Server-script: Smaller files (~900KB) but requires running a local server script.',
+ htmlModeStandalone: 'Standalone (recommended)',
+ htmlModeServer: 'Server-script (advanced)',
outputSubfolderName: 'Output subfolder',
outputSubfolderDesc: 'Vault-relative folder for exported slides.',
withClicksName: 'Include click-throughs',
diff --git a/src/i18n/locales/zh_cn.ts b/src/i18n/locales/zh_cn.ts
index 0be65705..95d1d550 100644
--- a/src/i18n/locales/zh_cn.ts
+++ b/src/i18n/locales/zh_cn.ts
@@ -960,6 +960,10 @@ export const STRINGS_ZH_CN: DeepPartial = {
enableDesc: '允许使用 Slidev 将 Markdown 文件导出为演示文稿。仅桌面端 — 需要 Node.js 20+。',
defaultFormatName: '默认导出格式',
defaultFormatDesc: 'MP4 除了 Slidev 和 Playwright 外还需要 ffmpeg。',
+ htmlModeName: 'HTML 导出模式',
+ htmlModeDesc: '独立模式:单文件打包(2-5MB),双击即可查看,无需配置。服务器脚本模式:文件较小(约900KB),但需运行本地服务器脚本。',
+ htmlModeStandalone: '独立模式(推荐)',
+ htmlModeServer: '服务器脚本模式(高级)',
outputSubfolderName: '输出子文件夹',
outputSubfolderDesc: '相对于库根目录的导出文件夹路径。',
withClicksName: '包含点击动画',
diff --git a/src/main.ts b/src/main.ts
index 9e483da9..72a532db 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -97,6 +97,7 @@ import {
runGenerateDiagramCommandWithHost,
runPreviewDiagramCommandWithHost
} from './operations/diagramCommandHostAdapter';
+import { stopAllServers } from './slideExport/localServer';
import {
DiagramCommandExecutionHost,
runArtifactDiagramExecutionWithHost,
@@ -1078,6 +1079,7 @@ export default class NotemdPlugin extends Plugin {
onunload() {
// Clean up resources if necessary
+ stopAllServers();
}
// --- Settings Management ---
@@ -2807,6 +2809,7 @@ export default class NotemdPlugin extends Plugin {
ffmpegCrf: this.settings.slideExportFfmpegCrf,
slidevTheme: this.settings.slideExportTheme,
timeoutMs: this.settings.slideExportTimeoutMs,
+ htmlMode: this.settings.slideExportHtmlMode,
};
const modal = new ProgressModal(this.app, uiStrings.slideExport.exportingSlides);
@@ -2830,6 +2833,12 @@ export default class NotemdPlugin extends Plugin {
);
modalReporter.log(uiStrings.slideExport.exportSuccess.replace('{path}', outputPath));
new Notice(uiStrings.slideExport.exportComplete);
+
+ // Automatically open in browser via local server
+ modalReporter.log('Opening in browser...');
+ const { openHtmlInBrowser } = await import('./slideExport/localServer');
+ const vaultRoot = (this.app.vault.adapter as any).basePath;
+ await openHtmlInBrowser(outputPath, vaultRoot);
} else if (config.format === 'pdf' || config.format === 'png') {
const outputPath = await exportSlidevImages(
this.app,
diff --git a/src/slideExport/htmlLauncher.ts b/src/slideExport/htmlLauncher.ts
new file mode 100644
index 00000000..87899f6d
--- /dev/null
+++ b/src/slideExport/htmlLauncher.ts
@@ -0,0 +1,200 @@
+/**
+ * HTML Launcher for Slidev Exports
+ *
+ * Creates a standalone launcher.html that users can open directly in their browser.
+ * The launcher automatically starts a minimal inline server using Service Worker
+ * to serve the Slidev export files, bypassing file:// protocol restrictions.
+ */
+
+import { App } from 'obsidian';
+import { getVaultBasePath } from './platformUtils';
+
+/**
+ * Template for the launcher HTML that includes an inline server
+ */
+const LAUNCHER_TEMPLATE = `
+
+
+
+
+ Slidev Presentation Launcher
+
+
+
+
+
🎬 Slidev Presentation
+
Click below to launch the presentation
+
+
+
📖 How to use:
+
+ - Click "Launch Presentation" button above
+ - A local server will start automatically
+ - The presentation will load in your browser
+ - You can close this tab when done
+
+
Note: This launcher works without requiring any external tools or plugin to be running.
+
+
+
+
+
+
+
+`;
+
+/**
+ * Create launcher HTML file in the export directory
+ */
+export async function createHtmlLauncher(
+ app: App,
+ exportHtmlPath: string,
+): Promise {
+ const vaultRoot = getVaultBasePath(app);
+ if (!vaultRoot) throw new Error('Vault root path unavailable');
+
+ // Extract directory from HTML path
+ const directory = exportHtmlPath.substring(0, exportHtmlPath.lastIndexOf('/'));
+ const launcherPath = `${directory}/launcher.html`;
+
+ // Write launcher file
+ await app.vault.adapter.write(launcherPath, LAUNCHER_TEMPLATE);
+
+ return launcherPath;
+}
diff --git a/src/slideExport/inlineBundler.ts b/src/slideExport/inlineBundler.ts
new file mode 100644
index 00000000..ea421d6a
--- /dev/null
+++ b/src/slideExport/inlineBundler.ts
@@ -0,0 +1,302 @@
+/**
+ * Single-file HTML bundler for Slidev exports
+ *
+ * Strategy: Post-process Slidev build output to inline all JavaScript chunks
+ * into a single HTML file that works under file:// protocol.
+ *
+ * Technical approach:
+ * 1. Parse index.html, extract all \n`
+ );
+
+ // Inline CSS
+ for (const stylePath of styles) {
+ const absPath = path.join(vaultRoot, baseDir, stylePath);
+ try {
+ const css = await app.vault.adapter.read(absPath.replace(vaultRoot + '/', ''));
+ html = html.replace(
+ new RegExp(`]*href="${stylePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"[^>]*>`),
+ ``
+ );
+ } catch (error) {
+ console.error(`Failed to inline CSS ${stylePath}:`, error);
+ }
+ }
+
+ return {
+ html,
+ size: html.length,
+ chunks: chunks.size,
+ };
+}
+
+/**
+ * Test if bundling is feasible for current export
+ */
+export async function testBundleFeasibility(
+ app: App,
+ htmlPath: string,
+): Promise<{ feasible: boolean; reason?: string; estimatedSize?: number }> {
+ try {
+ const baseDir = htmlPath.substring(0, htmlPath.lastIndexOf('/'));
+ const { scripts } = await parseHtmlReferences(app, htmlPath);
+
+ if (scripts.length === 0) {
+ return { feasible: false, reason: 'No scripts found' };
+ }
+
+ // Estimate size
+ const vaultRoot = getVaultBasePath(app);
+ if (!vaultRoot) throw new Error('Vault root unavailable');
+
+ let totalSize = 0;
+ for (const script of scripts) {
+ const absPath = path.join(vaultRoot, baseDir, script);
+ try {
+ const stat = await app.vault.adapter.stat(absPath.replace(vaultRoot + '/', ''));
+ if (stat) totalSize += stat.size;
+ } catch {
+ // Ignore
+ }
+ }
+
+ // Add HTML size
+ const htmlStat = await app.vault.adapter.stat(htmlPath);
+ if (htmlStat) totalSize += htmlStat.size;
+
+ return {
+ feasible: true,
+ estimatedSize: totalSize,
+ };
+ } catch (error) {
+ return {
+ feasible: false,
+ reason: error instanceof Error ? error.message : 'Unknown error',
+ };
+ }
+}
diff --git a/src/slideExport/localServer.ts b/src/slideExport/localServer.ts
new file mode 100644
index 00000000..a105cf5e
--- /dev/null
+++ b/src/slideExport/localServer.ts
@@ -0,0 +1,176 @@
+/**
+ * Local HTTP server for serving Slidev HTML exports
+ *
+ * Slidev exports use ES modules with dynamic imports, which are blocked
+ * by CORS policy when opened via file:// protocol. This server provides
+ * a local HTTP server to serve the exports properly.
+ */
+
+import type { Server } from 'http';
+import { spawn, ChildProcess } from 'child_process';
+import { Notice } from 'obsidian';
+
+interface ServerInstance {
+ process: ChildProcess;
+ port: number;
+ directory: string;
+}
+
+const activeServers = new Map();
+
+/**
+ * Start a local HTTP server for a directory
+ */
+export async function startLocalServer(directory: string, preferredPort = 8765): Promise {
+ // Check if server already running for this directory
+ const existing = activeServers.get(directory);
+ if (existing) {
+ return existing.port;
+ }
+
+ // Find available port
+ const port = await findAvailablePort(preferredPort);
+
+ return new Promise((resolve, reject) => {
+ // Use Node's built-in http-server via npx
+ const serverProcess = spawn('npx', [
+ '-y',
+ 'http-server',
+ directory,
+ '-p', port.toString(),
+ '--cors',
+ '-c-1', // Disable caching
+ '--silent'
+ ], {
+ stdio: ['ignore', 'pipe', 'pipe']
+ });
+
+ let started = false;
+
+ serverProcess.stdout?.on('data', (data: Buffer) => {
+ const output = data.toString();
+ if (output.includes('Available on:') || output.includes('Hit CTRL-C')) {
+ if (!started) {
+ started = true;
+ activeServers.set(directory, {
+ process: serverProcess,
+ port,
+ directory
+ });
+ resolve(port);
+ }
+ }
+ });
+
+ serverProcess.stderr?.on('data', (data: Buffer) => {
+ console.error('Server error:', data.toString());
+ });
+
+ serverProcess.on('error', (error) => {
+ if (!started) {
+ reject(new Error(`Failed to start server: ${error.message}`));
+ }
+ });
+
+ serverProcess.on('exit', (code) => {
+ activeServers.delete(directory);
+ if (!started && code !== 0) {
+ reject(new Error(`Server exited with code ${code}`));
+ }
+ });
+
+ // Timeout after 10 seconds
+ setTimeout(() => {
+ if (!started) {
+ serverProcess.kill();
+ reject(new Error('Server startup timeout'));
+ }
+ }, 10000);
+ });
+}
+
+/**
+ * Stop server for a directory
+ */
+export function stopLocalServer(directory: string): void {
+ const server = activeServers.get(directory);
+ if (server) {
+ server.process.kill();
+ activeServers.delete(directory);
+ }
+}
+
+/**
+ * Stop all active servers
+ */
+export function stopAllServers(): void {
+ for (const [directory, server] of activeServers.entries()) {
+ server.process.kill();
+ }
+ activeServers.clear();
+}
+
+/**
+ * Get server URL for a directory
+ */
+export function getServerUrl(directory: string): string | null {
+ const server = activeServers.get(directory);
+ return server ? `http://localhost:${server.port}` : null;
+}
+
+/**
+ * Find an available port starting from preferred port
+ */
+async function findAvailablePort(startPort: number): Promise {
+ const { createServer } = await import('http');
+
+ return new Promise((resolve, reject) => {
+ const server = createServer();
+
+ server.listen(startPort, () => {
+ const address = server.address();
+ if (address && typeof address !== 'string') {
+ const port = address.port;
+ server.close(() => resolve(port));
+ } else {
+ server.close(() => reject(new Error('Could not determine port')));
+ }
+ });
+
+ server.on('error', (err: NodeJS.ErrnoException) => {
+ if (err.code === 'EADDRINUSE') {
+ // Port in use, try next one
+ resolve(findAvailablePort(startPort + 1));
+ } else {
+ reject(err);
+ }
+ });
+ });
+}
+
+/**
+ * Open HTML export in browser via local server
+ */
+export async function openHtmlInBrowser(htmlPath: string, vaultRoot: string): Promise {
+ try {
+ // Extract directory from HTML path
+ const directory = htmlPath.substring(0, htmlPath.lastIndexOf('/'));
+ const filename = htmlPath.substring(htmlPath.lastIndexOf('/') + 1);
+ const fullDirectory = `${vaultRoot}/${directory}`;
+
+ // Start server
+ new Notice('Starting local server...');
+ const port = await startLocalServer(fullDirectory);
+
+ // Construct URL
+ const url = `http://localhost:${port}/${filename}`;
+
+ // Open in default browser
+ require('electron').shell.openExternal(url);
+
+ new Notice(`Opened in browser: ${url}`);
+ } catch (error) {
+ new Notice(`Failed to start server: ${error.message}`);
+ throw error;
+ }
+}
diff --git a/src/slideExport/serverScripts.ts b/src/slideExport/serverScripts.ts
new file mode 100644
index 00000000..7a6bb674
--- /dev/null
+++ b/src/slideExport/serverScripts.ts
@@ -0,0 +1,185 @@
+/**
+ * Server script generator for Slidev HTML exports
+ *
+ * Creates a standalone start-server.sh script that users can run
+ * to serve their Slidev presentation, making it work properly
+ * without the plugin being active.
+ */
+
+import { App } from 'obsidian';
+import { getVaultBasePath } from './platformUtils';
+
+const SERVER_SCRIPT_TEMPLATE = `#!/bin/bash
+# Slidev Presentation Server
+# This script starts a local HTTP server to view your Slidev presentation
+# Run this script: ./start-server.sh
+
+PORT=8765
+DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
+
+echo "Starting Slidev presentation server..."
+echo "Server will be available at: http://localhost:\$PORT"
+echo "Press Ctrl+C to stop the server"
+echo ""
+
+# Try different server options in order of preference
+if command -v python3 &> /dev/null; then
+ echo "Using Python 3 HTTP server..."
+ cd "$DIR" && python3 -m http.server $PORT
+elif command -v python &> /dev/null; then
+ echo "Using Python HTTP server..."
+ cd "$DIR" && python -m http.server $PORT
+elif command -v npx &> /dev/null; then
+ echo "Using npx http-server..."
+ cd "$DIR" && npx -y http-server -p $PORT --cors -c-1
+else
+ echo "❌ Error: No HTTP server available!"
+ echo ""
+ echo "Please install one of the following:"
+ echo " - Python 3: https://www.python.org/downloads/"
+ echo " - Node.js: https://nodejs.org/"
+ echo ""
+ echo "After installation, run this script again."
+ exit 1
+fi
+`;
+
+const SERVER_BAT_TEMPLATE = `@echo off
+REM Slidev Presentation Server
+REM This script starts a local HTTP server to view your Slidev presentation
+REM Run this script: start-server.bat
+
+set PORT=8765
+
+echo Starting Slidev presentation server...
+echo Server will be available at: http://localhost:%PORT%
+echo Press Ctrl+C to stop the server
+echo.
+
+REM Try different server options
+where python3 >nul 2>nul
+if %ERRORLEVEL% EQU 0 (
+ echo Using Python 3 HTTP server...
+ python3 -m http.server %PORT%
+ goto :end
+)
+
+where python >nul 2>nul
+if %ERRORLEVEL% EQU 0 (
+ echo Using Python HTTP server...
+ python -m http.server %PORT%
+ goto :end
+)
+
+where npx >nul 2>nul
+if %ERRORLEVEL% EQU 0 (
+ echo Using npx http-server...
+ npx -y http-server -p %PORT% --cors -c-1
+ goto :end
+)
+
+echo Error: No HTTP server available!
+echo.
+echo Please install one of the following:
+echo - Python 3: https://www.python.org/downloads/
+echo - Node.js: https://nodejs.org/
+echo.
+echo After installation, run this script again.
+pause
+exit /b 1
+
+:end
+`;
+
+const README_TEMPLATE = `# 🎬 Slidev Presentation
+
+## 🚀 Quick Start
+
+This Slidev presentation cannot be opened directly by double-clicking \`index.html\` due to browser security restrictions. You need to serve it via HTTP.
+
+### ✅ Easiest Method: Run the Server Script
+
+**macOS/Linux:**
+\`\`\`bash
+./start-server.sh
+\`\`\`
+
+**Windows:**
+\`\`\`cmd
+start-server.bat
+\`\`\`
+
+The script will automatically:
+- Detect available HTTP server (Python or Node.js)
+- Start server on http://localhost:8765
+- Open the URL in your browser (you may need to open it manually)
+
+Press \`Ctrl+C\` to stop the server when done.
+
+### 📝 Manual Methods
+
+If the script doesn't work, run any of these in this directory:
+
+\`\`\`bash
+# Python 3 (most common)
+python3 -m http.server 8765
+
+# Python 2
+python -m SimpleHTTPServer 8765
+
+# Node.js
+npx -y http-server -p 8765
+\`\`\`
+
+Then visit: **http://localhost:8765**
+
+## ❓ Why is a server needed?
+
+Slidev uses ES modules with dynamic imports. Modern browsers block these under \`file://\` protocol for security. A local HTTP server solves this.
+
+## 🔐 Security Note
+
+The server only runs locally on your machine (localhost). No external access is allowed. Stop the server (Ctrl+C) when you're done viewing.
+
+## 🎯 Keyboard Shortcuts (in presentation)
+
+- **Space / Arrow Keys**: Navigate slides
+- **F**: Toggle fullscreen
+- **O**: Toggle overview
+- **D**: Toggle dark mode
+- **G**: Go to specific slide
+
+---
+
+Generated with NotEMD Obsidian Plugin
+`;
+
+/**
+ * Create server scripts and README in the export directory
+ */
+export async function createServerScripts(
+ app: App,
+ exportHtmlPath: string,
+): Promise {
+ const vaultRoot = getVaultBasePath(app);
+ if (!vaultRoot) throw new Error('Vault root path unavailable');
+
+ // Extract directory from HTML path
+ const directory = exportHtmlPath.substring(0, exportHtmlPath.lastIndexOf('/'));
+
+ // Write server scripts
+ await app.vault.adapter.write(`${directory}/start-server.sh`, SERVER_SCRIPT_TEMPLATE);
+ await app.vault.adapter.write(`${directory}/start-server.bat`, SERVER_BAT_TEMPLATE);
+ await app.vault.adapter.write(`${directory}/README.md`, README_TEMPLATE);
+
+ // Make shell script executable on Unix systems
+ try {
+ const fs = require('fs');
+ const path = require('path');
+ const scriptPath = path.join(vaultRoot, directory, 'start-server.sh');
+ fs.chmodSync(scriptPath, 0o755);
+ } catch (error) {
+ // Silently ignore on Windows or if chmod fails
+ console.warn('Could not make script executable:', error);
+ }
+}
diff --git a/src/slideExport/singleFileBundler.ts b/src/slideExport/singleFileBundler.ts
new file mode 100644
index 00000000..973fc1c8
--- /dev/null
+++ b/src/slideExport/singleFileBundler.ts
@@ -0,0 +1,486 @@
+/**
+ * Single-file HTML bundler for Slidev exports
+ *
+ * Converts Slidev's ES module build into a standalone HTML file
+ * that works under file:// protocol by implementing a custom
+ * CommonJS-style module system.
+ *
+ * Technical approach:
+ * 1. Parse all JavaScript chunks from build output
+ * 2. Transform ES modules → CommonJS semantics
+ * 3. Build custom require() system (no native import())
+ * 4. Inline all JS/CSS into single HTML
+ * 5. Handle Vue components, async imports, CSS modules
+ */
+
+import { App } from 'obsidian';
+import { getVaultBasePath } from './platformUtils';
+import * as path from 'path';
+
+interface ModuleInfo {
+ path: string;
+ code: string;
+ size: number;
+}
+
+interface BundleResult {
+ htmlPath: string;
+ size: number;
+ moduleCount: number;
+}
+
+/**
+ * Collect all JavaScript modules from assets directory
+ */
+async function collectModules(
+ app: App,
+ baseDir: string,
+): Promise