- Python mode via Pyodide WASM runtime with stdout/stderr console integration - Publish fiddles to clean /p/:slug URLs as standalone HTML pages - Import code from GitHub Gist URLs with auto-detection of language/mode - Presentation mode with slide management, fullscreen viewer, and keyboard nav - Enable Monaco color decorators for inline CSS color pickers - Extract reusable generateStandaloneHtml from export module
267 lines
7.4 KiB
JavaScript
267 lines
7.4 KiB
JavaScript
import { loadScript } from './utils.js';
|
|
|
|
let tsLoaded = false;
|
|
let babelLoaded = false;
|
|
let svelteLoaded = false;
|
|
let markedLoaded = false;
|
|
|
|
async function ensureTypeScript() {
|
|
if (tsLoaded) return;
|
|
await loadScript('https://cdnjs.cloudflare.com/ajax/libs/typescript/5.6.3/typescript.min.js');
|
|
if (typeof ts === 'undefined' && typeof window.ts === 'undefined') {
|
|
await new Promise((resolve) => {
|
|
const check = setInterval(() => {
|
|
if (typeof ts !== 'undefined') { clearInterval(check); resolve(); }
|
|
}, 50);
|
|
});
|
|
}
|
|
tsLoaded = true;
|
|
}
|
|
|
|
async function ensureBabel() {
|
|
if (babelLoaded) return;
|
|
await loadScript('https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/7.26.4/babel.min.js');
|
|
// Wait for global to be available
|
|
if (typeof Babel === 'undefined') {
|
|
await new Promise((resolve) => {
|
|
const check = setInterval(() => {
|
|
if (typeof Babel !== 'undefined') { clearInterval(check); resolve(); }
|
|
}, 50);
|
|
});
|
|
}
|
|
babelLoaded = true;
|
|
}
|
|
|
|
async function ensureMarked() {
|
|
if (markedLoaded) return;
|
|
await loadScript('https://cdnjs.cloudflare.com/ajax/libs/marked/15.0.7/marked.min.js');
|
|
if (typeof marked === 'undefined') {
|
|
await new Promise((resolve) => {
|
|
const check = setInterval(() => {
|
|
if (typeof marked !== 'undefined') { clearInterval(check); resolve(); }
|
|
}, 50);
|
|
});
|
|
}
|
|
markedLoaded = true;
|
|
}
|
|
|
|
async function ensureSvelte() {
|
|
if (svelteLoaded) return;
|
|
await loadScript('https://unpkg.com/svelte@4.2.19/compiler.cjs');
|
|
svelteLoaded = true;
|
|
}
|
|
|
|
/**
|
|
* Compile JS/TS/JSX/TSX/Vue/Svelte code based on mode.
|
|
* Returns { js, extraCss?, warnings? }
|
|
*/
|
|
export async function compileJs(code, mode) {
|
|
if (!code.trim()) return { js: '' };
|
|
|
|
switch (mode) {
|
|
case 'html-css-js': {
|
|
const isModule = /(?:^|\n)\s*(?:import\s|export\s)/m.test(code);
|
|
return { js: code, isModule };
|
|
}
|
|
|
|
case 'typescript':
|
|
return compileTypeScript(code);
|
|
|
|
case 'react':
|
|
return compileJsx(code);
|
|
|
|
case 'react-ts':
|
|
return compileTsx(code);
|
|
|
|
case 'vue':
|
|
return compileVue(code);
|
|
|
|
case 'svelte':
|
|
return compileSvelte(code);
|
|
|
|
case 'markdown':
|
|
return compileMarkdown(code);
|
|
|
|
case 'wasm': {
|
|
// WASM starter uses top-level await, always treat as module
|
|
const hasModuleSyntax = /(?:^|\n)\s*(?:import\s|export\s)/m.test(code);
|
|
const hasTopLevelAwait = /(?:^|\n)\s*(?:const|let|var)\s+.*=\s*await\b/m.test(code) || /(?:^|\n)\s*await\s/m.test(code);
|
|
return { js: code, isModule: hasModuleSyntax || hasTopLevelAwait };
|
|
}
|
|
|
|
case 'python':
|
|
return compilePython(code);
|
|
|
|
default:
|
|
return { js: code };
|
|
}
|
|
}
|
|
|
|
async function compileTypeScript(code) {
|
|
await ensureTypeScript();
|
|
const result = ts.transpileModule(code, {
|
|
compilerOptions: {
|
|
target: ts.ScriptTarget.ES2020,
|
|
module: ts.ModuleKind.None,
|
|
strict: false,
|
|
},
|
|
});
|
|
return { js: result.outputText };
|
|
}
|
|
|
|
async function compileJsx(code) {
|
|
await ensureBabel();
|
|
const result = Babel.transform(code, {
|
|
presets: ['react'],
|
|
filename: 'fiddle.jsx',
|
|
});
|
|
return { js: result.code };
|
|
}
|
|
|
|
async function compileTsx(code) {
|
|
await ensureBabel();
|
|
const result = Babel.transform(code, {
|
|
presets: ['react', 'typescript'],
|
|
filename: 'fiddle.tsx',
|
|
});
|
|
return { js: result.code };
|
|
}
|
|
|
|
function compileVue(code) {
|
|
// Simple regex-based SFC parser
|
|
const templateMatch = code.match(/<template>([\s\S]*?)<\/template>/);
|
|
const scriptMatch = code.match(/<script>([\s\S]*?)<\/script>/);
|
|
const styleMatch = code.match(/<style(?:\s[^>]*)?>([\s\S]*?)<\/style>/);
|
|
|
|
const template = templateMatch ? templateMatch[1].trim() : '<div></div>';
|
|
const script = scriptMatch ? scriptMatch[1].trim() : '';
|
|
const extraCss = styleMatch ? styleMatch[1].trim() : '';
|
|
|
|
// Build the Vue component + mount code
|
|
// The user script should export/define component options
|
|
const js = `
|
|
(function() {
|
|
${script}
|
|
|
|
// Detect if user defined a component variable
|
|
var _opts = typeof component !== 'undefined' ? component : {};
|
|
_opts.template = ${JSON.stringify(template)};
|
|
|
|
Vue.createApp(_opts).mount('#app');
|
|
})();
|
|
`;
|
|
|
|
return { js, extraCss };
|
|
}
|
|
|
|
async function compileMarkdown(code) {
|
|
await ensureMarked();
|
|
const renderedHtml = marked.parse(code);
|
|
return { js: '', renderedHtml };
|
|
}
|
|
|
|
async function compileSvelte(code) {
|
|
await ensureSvelte();
|
|
const result = svelte.compile(code, {
|
|
filename: 'App.svelte',
|
|
css: 'injected',
|
|
});
|
|
|
|
// Rewrite all svelte imports for browser use via esm.sh
|
|
// Handles both `import {...} from "svelte/..."` and `import "svelte/..."`
|
|
let js = result.js.code;
|
|
js = js.replace(
|
|
/from\s+["']svelte(\/[^"']*)?["']/g,
|
|
(_, path) => `from "https://esm.sh/svelte@4${path || ''}"`
|
|
);
|
|
js = js.replace(
|
|
/import\s+["']svelte(\/[^"']*)?["']/g,
|
|
(_, path) => `import "https://esm.sh/svelte@4${path || ''}"`
|
|
);
|
|
|
|
// Remove the default export and add mount code
|
|
// The compiled output exports the class as "export default class App ..."
|
|
// We replace the export to keep it as a local class, then instantiate it
|
|
js = js.replace(/export default /, '');
|
|
|
|
// Add mount code — the class name matches the filename ("App")
|
|
js += `\n\nnew App({ target: document.getElementById('app') });\n`;
|
|
|
|
const warnings = result.warnings ? result.warnings.map((w) => w.message) : [];
|
|
return { js, warnings, isModule: true };
|
|
}
|
|
|
|
function compilePython(code) {
|
|
// Wrap Python code in a JS loader that runs it via Pyodide in the iframe
|
|
const escapedCode = JSON.stringify(code);
|
|
const js = `
|
|
(async function() {
|
|
const status = document.getElementById('pyodide-status');
|
|
try {
|
|
if (status) status.textContent = 'Loading Python runtime...';
|
|
const pyodide = await loadPyodide();
|
|
pyodide.setStdout({ batched: (text) => {
|
|
if (window.__fiddle_console) window.__fiddle_console('log', text);
|
|
else console.log(text);
|
|
}});
|
|
pyodide.setStderr({ batched: (text) => {
|
|
if (window.__fiddle_console) window.__fiddle_console('error', text);
|
|
else console.error(text);
|
|
}});
|
|
if (status) status.textContent = '';
|
|
await pyodide.runPythonAsync(${escapedCode});
|
|
} catch(e) {
|
|
if (status) status.textContent = '';
|
|
console.error('Python error: ' + e.message);
|
|
}
|
|
})();
|
|
`;
|
|
return { js, isModule: false };
|
|
}
|
|
|
|
/**
|
|
* Get runtime scripts and body HTML to inject into the preview iframe.
|
|
*/
|
|
export function getFrameworkRuntime(mode) {
|
|
switch (mode) {
|
|
case 'react':
|
|
case 'react-ts':
|
|
return {
|
|
scripts: [
|
|
'https://cdnjs.cloudflare.com/ajax/libs/react/18.3.1/umd/react.production.min.js',
|
|
'https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.3.1/umd/react-dom.production.min.js',
|
|
],
|
|
bodyHtml: '<div id="root"></div>',
|
|
};
|
|
|
|
case 'vue':
|
|
return {
|
|
scripts: [
|
|
'https://cdnjs.cloudflare.com/ajax/libs/vue/3.5.13/vue.global.prod.min.js',
|
|
],
|
|
bodyHtml: '<div id="app"></div>',
|
|
};
|
|
|
|
case 'svelte':
|
|
return {
|
|
scripts: [],
|
|
bodyHtml: '<div id="app"></div>',
|
|
};
|
|
|
|
case 'markdown':
|
|
return { scripts: [], bodyHtml: '' };
|
|
|
|
case 'wasm':
|
|
return { scripts: [], bodyHtml: '' };
|
|
|
|
case 'python':
|
|
return {
|
|
scripts: ['https://cdn.jsdelivr.net/pyodide/v0.27.2/full/pyodide.js'],
|
|
bodyHtml: '<div id="pyodide-status" style="font-family:monospace;color:#888;padding:12px">Loading Python...</div>',
|
|
};
|
|
|
|
default:
|
|
return { scripts: [], bodyHtml: '' };
|
|
}
|
|
}
|