Files
fiddle/public/js/js-preprocessors.js
root 77f64d2862 Add Tailwind CSS toggle, Markdown/WASM modes, and npm import resolution
- Tailwind CSS: toolbar checkbox injects Play CDN into preview, persisted
  per-fiddle via new options JSON column
- Markdown mode: uses marked.js CDN, renders markdown to HTML preview with
  CSS tab for custom styling
- WASM mode: starter template with inline WebAssembly add function, supports
  top-level await via module detection
- npm imports: auto-detect bare import specifiers in module code and inject
  importmap pointing to esm.sh CDN
- Module auto-detection for html-css-js mode (import/export statements)
- DB migration adds options column, server passes through all API endpoints
- All features work across preview, export, and embed
2026-02-26 15:15:53 -06:00

230 lines
6.2 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 };
}
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 };
}
/**
* 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: '' };
default:
return { scripts: [], bodyHtml: '' };
}
}