Initial commit: code playground with multi-framework support

Express + SQLite backend with Monaco editor frontend.
Supports HTML/CSS/JS, TypeScript, React (JSX/TSX), Vue SFC,
and Svelte with live preview, console output, save/fork/share.

Includes CSS preprocessors (SCSS, Less), framework-specific
compilation (Babel, TypeScript, Svelte compiler), and
CDN-loaded runtime libraries for preview rendering.
This commit is contained in:
root
2026-02-26 08:12:39 -06:00
commit 463b563423
14 changed files with 2550 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
data/

43
db.js Normal file
View File

@@ -0,0 +1,43 @@
import Database from 'better-sqlite3';
import { mkdirSync } from 'fs';
mkdirSync('data', { recursive: true });
const db = new Database('data/fiddles.db');
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS fiddles (
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT 'Untitled',
html TEXT NOT NULL DEFAULT '',
css TEXT NOT NULL DEFAULT '',
css_type TEXT NOT NULL DEFAULT 'css',
js TEXT NOT NULL DEFAULT '',
js_type TEXT NOT NULL DEFAULT 'javascript',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// Migration: add js_type column for existing databases
try {
db.exec(`ALTER TABLE fiddles ADD COLUMN js_type TEXT NOT NULL DEFAULT 'javascript'`);
} catch (_) { /* column already exists */ }
export const stmts = {
insert: db.prepare(`
INSERT INTO fiddles (id, title, html, css, css_type, js, js_type)
VALUES (@id, @title, @html, @css, @css_type, @js, @js_type)
`),
get: db.prepare('SELECT * FROM fiddles WHERE id = ?'),
update: db.prepare(`
UPDATE fiddles SET title = @title, html = @html, css = @css,
css_type = @css_type, js = @js, js_type = @js_type, updated_at = datetime('now')
WHERE id = @id
`),
list: db.prepare('SELECT id, title, css_type, js_type, created_at, updated_at FROM fiddles ORDER BY updated_at DESC LIMIT 50'),
};
export default db;

1362
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

16
package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "fiddle",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"better-sqlite3": "^11.0.0",
"express": "^4.21.0",
"nanoid": "^5.0.0"
},
"devDependencies": {
"svelte": "^4.2.19"
}
}

148
public/css/style.css Normal file
View File

@@ -0,0 +1,148 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #1e1e1e;
--surface: #252526;
--border: #3c3c3c;
--text: #cccccc;
--text-dim: #888;
--accent: #0078d4;
--accent-hover: #1a8ceb;
--toolbar-h: 44px;
--tab-h: 32px;
--label-h: 26px;
}
html, body { height: 100%; overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); }
/* Toolbar */
.toolbar {
height: var(--toolbar-h);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
background: var(--surface);
border-bottom: 1px solid var(--border);
gap: 8px;
}
.toolbar-left, .toolbar-right { display: flex; align-items: center; gap: 8px; }
.logo { font-weight: 700; font-size: 16px; color: var(--accent); text-decoration: none; }
#title-input {
background: transparent; border: 1px solid transparent; color: var(--text);
padding: 4px 8px; border-radius: 4px; font-size: 13px; width: 180px;
}
#title-input:hover, #title-input:focus { border-color: var(--border); outline: none; }
#framework-mode {
background: var(--bg); color: var(--text); border: 1px solid var(--border);
padding: 4px 6px; border-radius: 4px; font-size: 12px; cursor: pointer;
}
button {
background: var(--accent); color: #fff; border: none; padding: 5px 14px;
border-radius: 4px; font-size: 12px; cursor: pointer; font-weight: 500;
}
button:hover { background: var(--accent-hover); }
.btn-small {
background: transparent; color: var(--text-dim); padding: 2px 6px; font-size: 11px;
}
.btn-small:hover { color: var(--text); background: var(--border); }
/* Grid layout — 2 columns: editor | preview+console */
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
height: calc(100vh - var(--toolbar-h));
}
.panel { position: relative; border: 1px solid var(--border); overflow: hidden; display: flex; flex-direction: column; }
.panel-label {
height: var(--label-h); line-height: var(--label-h);
padding: 0 10px; font-size: 11px; font-weight: 600;
text-transform: uppercase; color: var(--text-dim);
background: var(--surface); border-bottom: 1px solid var(--border);
display: flex; align-items: center; justify-content: space-between;
flex-shrink: 0;
}
/* Editor panel — full left column */
.panel-editor { grid-column: 1; grid-row: 1 / 3; display: flex; flex-direction: column; }
.panel-preview { grid-column: 2; grid-row: 1; }
.panel-console { grid-column: 2; grid-row: 2; }
/* Tab bar */
.tab-bar {
height: var(--tab-h);
display: flex;
align-items: stretch;
background: var(--surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
gap: 0;
overflow-x: auto;
}
.tab-btn {
background: transparent;
color: var(--text-dim);
border: none;
border-bottom: 2px solid transparent;
padding: 0 14px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
border-radius: 0;
white-space: nowrap;
display: flex;
align-items: center;
gap: 6px;
}
.tab-btn:hover {
color: var(--text);
background: rgba(255,255,255,0.04);
}
.tab-btn.active {
color: var(--text);
border-bottom-color: var(--accent);
background: rgba(255,255,255,0.06);
}
/* CSS type selector inside tab bar */
.tab-css-type {
background: var(--bg);
color: var(--text-dim);
border: 1px solid var(--border);
padding: 1px 4px;
border-radius: 3px;
font-size: 10px;
cursor: pointer;
}
/* Editor area — holds all editor containers, only one visible at a time */
.editor-area { flex: 1; min-height: 0; position: relative; }
.editor-container {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
display: none;
}
.editor-container.active { display: block; }
#preview-frame { flex: 1; border: none; background: #fff; width: 100%; }
/* Console */
#console-output {
flex: 1; overflow-y: auto; padding: 6px 10px; font-family: 'Cascadia Code', 'Fira Code', monospace;
font-size: 12px; line-height: 1.6; background: var(--bg);
}
.console-line { padding: 1px 0; border-bottom: 1px solid #2a2a2a; white-space: pre-wrap; word-break: break-all; }
.console-log { color: var(--text); }
.console-warn { color: #cca700; }
.console-error { color: #f44747; }
.console-info { color: #3dc9b0; }
.console-debug { color: #888; }
/* Toast */
.toast {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
background: var(--accent); color: #fff; padding: 8px 18px; border-radius: 6px;
font-size: 13px; z-index: 999; transition: opacity 0.3s;
}
.toast.hidden { opacity: 0; pointer-events: none; }

70
public/index.html Normal file
View File

@@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fiddle</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<header class="toolbar">
<div class="toolbar-left">
<a href="/" class="logo">Fiddle</a>
<select id="framework-mode">
<option value="html-css-js">HTML / CSS / JS</option>
<option value="typescript">TypeScript</option>
<option value="react">React (JSX)</option>
<option value="react-ts">React + TS</option>
<option value="vue">Vue</option>
<option value="svelte">Svelte</option>
</select>
<input type="text" id="title-input" placeholder="Untitled" spellcheck="false">
</div>
<div class="toolbar-right">
<button id="btn-run" title="Run (Ctrl+Enter)">Run</button>
<button id="btn-save" title="Save (Ctrl+S)">Save</button>
<button id="btn-fork" title="Fork">Fork</button>
</div>
</header>
<main class="grid">
<div class="panel panel-editor">
<div class="tab-bar" id="tab-bar"></div>
<div id="editor-area" class="editor-area"></div>
</div>
<div class="panel panel-preview">
<div class="panel-label">Preview</div>
<iframe id="preview-frame" sandbox="allow-scripts allow-same-origin"></iframe>
</div>
<div class="panel panel-console">
<div class="panel-label">Console <button id="btn-clear-console" class="btn-small">Clear</button></div>
<div id="console-output"></div>
</div>
</main>
<div id="share-toast" class="toast hidden"></div>
<script>
// Monaco AMD loader
const MONACO_CDN = 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.52.2';
const script = document.createElement('script');
script.src = `${MONACO_CDN}/min/vs/loader.min.js`;
script.onload = () => {
require.config({ paths: { vs: `${MONACO_CDN}/min/vs` } });
// Cross-origin worker proxy
window.MonacoEnvironment = {
getWorkerUrl: function (_workerId, label) {
return `data:text/javascript;charset=utf-8,${encodeURIComponent(`
self.MonacoEnvironment = { baseUrl: '${MONACO_CDN}/min/' };
importScripts('${MONACO_CDN}/min/vs/base/worker/workerMain.js');
`)}`;
}
};
require(['vs/editor/editor.main'], () => {
import('/js/app.js');
});
};
document.head.appendChild(script);
</script>
</body>
</html>

29
public/js/api.js Normal file
View File

@@ -0,0 +1,29 @@
const BASE = '/api/fiddles';
async function request(url, opts = {}) {
const res = await fetch(url, {
headers: { 'Content-Type': 'application/json' },
...opts,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
return res.json();
}
export function createFiddle(data) {
return request(BASE, { method: 'POST', body: JSON.stringify(data) });
}
export function loadFiddle(id) {
return request(`${BASE}/${id}`);
}
export function updateFiddle(id, data) {
return request(`${BASE}/${id}`, { method: 'PUT', body: JSON.stringify(data) });
}
export function listFiddles() {
return request(BASE);
}

195
public/js/app.js Normal file
View File

@@ -0,0 +1,195 @@
import {
initEditors, switchMode, getEditorValues, setEditorValues,
setOnChange, getCurrentMode, getCssType, setCssType,
MODE_TABS, MODE_TO_JS_TYPE, JS_TYPE_TO_MODE,
} from './editors.js';
import { renderPreview } from './preview.js';
import { initConsole, clearConsole } from './console-panel.js';
import { compileCss } from './preprocessors.js';
import { compileJs } from './js-preprocessors.js';
import { createFiddle, loadFiddle, updateFiddle } from './api.js';
let currentId = null;
let debounceTimer = null;
const $ = (sel) => document.querySelector(sel);
const STARTER_TEMPLATES = {
'html-css-js': { html: '', css: '', js: '' },
'typescript': {
html: '<div id="app"></div>',
css: '',
js: `interface Greeting {\n name: string;\n message: string;\n}\n\nconst greet = (g: Greeting): string => \`\${g.message}, \${g.name}!\`;\n\nconst result = greet({ name: "World", message: "Hello" });\ndocument.getElementById("app")!.innerHTML = \`<h1>\${result}</h1>\`;\nconsole.log(result);`,
},
'react': {
js: `const App = () => {\n const [count, setCount] = React.useState(0);\n return (\n <div>\n <h1>Hello React</h1>\n <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>\n </div>\n );\n};\n\nReactDOM.createRoot(document.getElementById('root')).render(<App />);`,
css: '',
html: '',
},
'react-ts': {
js: `const App: React.FC = () => {\n const [count, setCount] = React.useState<number>(0);\n return (\n <div>\n <h1>Hello React + TypeScript</h1>\n <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>\n </div>\n );\n};\n\nReactDOM.createRoot(document.getElementById('root')!).render(<App />);`,
css: '',
html: '',
},
'vue': {
js: `<template>\n <div>\n <h1>{{ msg }}</h1>\n <button @click="count++">Count: {{ count }}</button>\n </div>\n</template>\n\n<script>\nconst component = {\n data() {\n return {\n msg: 'Hello Vue!',\n count: 0,\n };\n },\n};\n</script>`,
css: '',
},
'svelte': {
js: `<script>\n let count = 0;\n</script>\n\n<h1>Hello Svelte</h1>\n<button on:click={() => count++}>Count: {count}</button>\n\n<style>\n h1 { color: #ff3e00; }\n button { padding: 8px 16px; cursor: pointer; }\n</style>`,
css: '',
},
};
async function run() {
const mode = getCurrentMode();
const { html, css, js } = getEditorValues();
const cssType = getCssType();
try {
const compiledCss = await compileCss(css, cssType);
const result = await compileJs(js, mode);
clearConsole();
// Show warnings from compilation (e.g., Svelte)
if (result.warnings && result.warnings.length) {
result.warnings.forEach((w) => {
window.postMessage({ type: 'console', method: 'warn', args: [w] }, '*');
});
}
renderPreview(html, compiledCss, result.js, mode, result.extraCss || '');
} catch (e) {
clearConsole();
renderPreview(html, '', '', mode);
window.postMessage({ type: 'console', method: 'error', args: [`Compile error: ${e.message}`] }, '*');
}
}
function scheduleRun() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(run, 500);
}
function showToast(msg) {
const toast = $('#share-toast');
toast.textContent = msg;
toast.classList.remove('hidden');
setTimeout(() => toast.classList.add('hidden'), 3000);
}
async function save() {
const { html, css, js } = getEditorValues();
const title = $('#title-input').value || 'Untitled';
const css_type = getCssType();
const js_type = MODE_TO_JS_TYPE[getCurrentMode()] || 'javascript';
try {
if (currentId) {
await updateFiddle(currentId, { title, html, css, css_type, js, js_type });
showToast(`Saved! Share: ${location.origin}/f/${currentId}`);
} else {
const result = await createFiddle({ title, html, css, css_type, js, js_type });
currentId = result.id;
history.pushState(null, '', `/f/${currentId}`);
showToast(`Saved! Share: ${location.origin}/f/${currentId}`);
}
} catch (e) {
showToast(`Save failed: ${e.message}`);
}
}
async function fork() {
const { html, css, js } = getEditorValues();
const title = ($('#title-input').value || 'Untitled') + ' (fork)';
const css_type = getCssType();
const js_type = MODE_TO_JS_TYPE[getCurrentMode()] || 'javascript';
try {
const result = await createFiddle({ title, html, css, css_type, js, js_type });
currentId = result.id;
$('#title-input').value = title;
history.pushState(null, '', `/f/${currentId}`);
showToast(`Forked! New URL: ${location.origin}/f/${currentId}`);
} catch (e) {
showToast(`Fork failed: ${e.message}`);
}
}
async function loadFromUrl() {
const match = location.pathname.match(/^\/f\/([a-zA-Z0-9_-]+)$/);
if (!match) return;
try {
const fiddle = await loadFiddle(match[1]);
currentId = fiddle.id;
$('#title-input').value = fiddle.title;
// Restore mode from js_type
const mode = JS_TYPE_TO_MODE[fiddle.js_type] || 'html-css-js';
$('#framework-mode').value = mode;
switchMode(mode);
// Restore CSS type
setCssType(fiddle.css_type || 'css');
setEditorValues(fiddle);
setTimeout(run, 100);
} catch (e) {
showToast(`Failed to load fiddle: ${e.message}`);
}
}
function handleModeChange(newMode) {
const oldMode = getCurrentMode();
if (newMode === oldMode) return;
switchMode(newMode);
// Insert starter template if editors are empty
const { html, css, js } = getEditorValues();
if (!html && !css && !js) {
const template = STARTER_TEMPLATES[newMode];
if (template) {
setEditorValues(template);
}
}
scheduleRun();
}
function init() {
initEditors('html-css-js');
setOnChange(scheduleRun);
initConsole();
// Mode selector
$('#framework-mode').addEventListener('change', (e) => {
handleModeChange(e.target.value);
});
// Toolbar buttons
$('#btn-run').addEventListener('click', run);
$('#btn-save').addEventListener('click', save);
$('#btn-fork').addEventListener('click', fork);
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
save();
}
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
run();
}
});
// Load fiddle from URL if present
loadFromUrl();
// Handle browser back/forward
window.addEventListener('popstate', () => {
currentId = null;
loadFromUrl();
});
}
init();

View File

@@ -0,0 +1,27 @@
const output = () => document.getElementById('console-output');
export function initConsole() {
window.addEventListener('message', (e) => {
if (!e.data || e.data.type !== 'console') return;
if (e.data.method === 'clear') {
clearConsole();
return;
}
appendLine(e.data.method, (e.data.args || []).join(' '));
});
document.getElementById('btn-clear-console').addEventListener('click', clearConsole);
}
function appendLine(method, text) {
const el = document.createElement('div');
el.className = `console-line console-${method}`;
el.textContent = text;
const out = output();
out.appendChild(el);
out.scrollTop = out.scrollHeight;
}
export function clearConsole() {
output().innerHTML = '';
}

258
public/js/editors.js Normal file
View File

@@ -0,0 +1,258 @@
const editorOpts = {
minimap: { enabled: false },
automaticLayout: true,
fontSize: 13,
lineNumbers: 'on',
scrollBeyondLastLine: false,
theme: 'vs-dark',
tabSize: 2,
renderWhitespace: 'none',
padding: { top: 6 },
};
export const MODE_TABS = {
'html-css-js': [
{ id: 'html', label: 'HTML', lang: 'html' },
{ id: 'css', label: 'CSS', lang: 'css' },
{ id: 'js', label: 'JavaScript', lang: 'javascript' },
],
'typescript': [
{ id: 'html', label: 'HTML', lang: 'html' },
{ id: 'css', label: 'CSS', lang: 'css' },
{ id: 'js', label: 'TypeScript', lang: 'typescript' },
],
'react': [
{ id: 'js', label: 'JSX', lang: 'javascript' },
{ id: 'css', label: 'CSS', lang: 'css' },
{ id: 'html', label: 'HTML', lang: 'html' },
],
'react-ts': [
{ id: 'js', label: 'TSX', lang: 'typescript' },
{ id: 'css', label: 'CSS', lang: 'css' },
{ id: 'html', label: 'HTML', lang: 'html' },
],
'vue': [
{ id: 'js', label: 'Vue SFC', lang: 'html' },
{ id: 'css', label: 'CSS', lang: 'css' },
],
'svelte': [
{ id: 'js', label: 'Svelte', lang: 'html' },
{ id: 'css', label: 'CSS', lang: 'css' },
],
};
// Map mode names to js_type values stored in DB
export const MODE_TO_JS_TYPE = {
'html-css-js': 'javascript',
'typescript': 'typescript',
'react': 'jsx',
'react-ts': 'tsx',
'vue': 'vue',
'svelte': 'svelte',
};
export const JS_TYPE_TO_MODE = Object.fromEntries(
Object.entries(MODE_TO_JS_TYPE).map(([k, v]) => [v, k])
);
// Editor instances keyed by tab id ('html', 'css', 'js')
let editors = {};
let currentMode = 'html-css-js';
let activeTab = null;
let cssType = 'css';
let onChangeCallback = null;
const tabBar = () => document.getElementById('tab-bar');
const editorArea = () => document.getElementById('editor-area');
export function setOnChange(cb) {
onChangeCallback = cb;
}
export function getCurrentMode() {
return currentMode;
}
export function getCssType() {
return cssType;
}
export function setCssType(type) {
cssType = type;
const sel = document.getElementById('css-type-select');
if (sel) sel.value = type;
// Update Monaco language for the CSS editor
if (editors.css) {
const model = editors.css.getModel();
monaco.editor.setModelLanguage(model, type === 'scss' ? 'scss' : type === 'less' ? 'less' : 'css');
}
}
function configureJsxSupport(mode) {
if (mode === 'react' || mode === 'react-ts') {
// Enable JSX in JavaScript defaults
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
target: monaco.languages.typescript.ScriptTarget.ESNext,
jsx: monaco.languages.typescript.JsxEmit.React,
jsxFactory: 'React.createElement',
allowNonTsExtensions: true,
allowJs: true,
});
monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
target: monaco.languages.typescript.ScriptTarget.ESNext,
jsx: monaco.languages.typescript.JsxEmit.React,
jsxFactory: 'React.createElement',
allowNonTsExtensions: true,
});
}
}
function createEditor(tabDef) {
const container = document.createElement('div');
container.className = 'editor-container';
container.id = `editor-${tabDef.id}`;
editorArea().appendChild(container);
const editor = monaco.editor.create(container, {
...editorOpts,
language: tabDef.lang,
value: '',
});
if (onChangeCallback) {
editor.onDidChangeModelContent(onChangeCallback);
}
return { container, editor };
}
function renderTabBar(tabs) {
const bar = tabBar();
bar.innerHTML = '';
tabs.forEach((tab) => {
const btn = document.createElement('button');
btn.className = 'tab-btn';
btn.dataset.tab = tab.id;
btn.textContent = tab.label;
// Add CSS type selector inside the CSS tab
if (tab.id === 'css') {
const sel = document.createElement('select');
sel.className = 'tab-css-type';
sel.id = 'css-type-select';
sel.innerHTML = `
<option value="css">CSS</option>
<option value="scss">SCSS</option>
<option value="less">Less</option>
`;
sel.value = cssType;
sel.addEventListener('click', (e) => e.stopPropagation());
sel.addEventListener('change', (e) => {
cssType = e.target.value;
if (editors.css) {
const model = editors.css.getModel();
monaco.editor.setModelLanguage(model, cssType === 'scss' ? 'scss' : cssType === 'less' ? 'less' : 'css');
}
if (onChangeCallback) onChangeCallback();
});
btn.appendChild(sel);
}
btn.addEventListener('click', () => switchTab(tab.id));
bar.appendChild(btn);
});
}
export function initEditors(mode = 'html-css-js') {
currentMode = mode;
const tabs = MODE_TABS[mode];
configureJsxSupport(mode);
renderTabBar(tabs);
// Create editors for each tab
tabs.forEach((tab) => {
const { container, editor } = createEditor(tab);
editors[tab.id] = editor;
editors[`_container_${tab.id}`] = container;
});
// Activate the first tab
switchTab(tabs[0].id);
return editors;
}
export function switchMode(mode) {
if (mode === currentMode) return;
// Dispose existing editors
const oldTabs = MODE_TABS[currentMode];
oldTabs.forEach((tab) => {
if (editors[tab.id]) {
editors[tab.id].dispose();
}
const container = editors[`_container_${tab.id}`];
if (container && container.parentNode) {
container.parentNode.removeChild(container);
}
});
editors = {};
currentMode = mode;
const tabs = MODE_TABS[mode];
configureJsxSupport(mode);
renderTabBar(tabs);
tabs.forEach((tab) => {
const { container, editor } = createEditor(tab);
editors[tab.id] = editor;
editors[`_container_${tab.id}`] = container;
});
switchTab(tabs[0].id);
}
export function switchTab(tabId) {
activeTab = tabId;
const tabs = MODE_TABS[currentMode];
// Update tab button states
tabBar().querySelectorAll('.tab-btn').forEach((btn) => {
btn.classList.toggle('active', btn.dataset.tab === tabId);
});
// Show/hide editor containers
tabs.forEach((tab) => {
const container = editors[`_container_${tab.id}`];
if (container) {
container.classList.toggle('active', tab.id === tabId);
}
});
// Trigger layout on the active editor
if (editors[tabId]) {
editors[tabId].layout();
editors[tabId].focus();
}
}
export function getEditorValues() {
const values = { html: '', css: '', js: '' };
if (editors.html) values.html = editors.html.getValue();
if (editors.css) values.css = editors.css.getValue();
if (editors.js) values.js = editors.js.getValue();
return values;
}
export function setEditorValues({ html = '', css = '', js = '' }) {
if (editors.html) editors.html.setValue(html);
if (editors.css) editors.css.setValue(css);
if (editors.js) editors.js.setValue(js);
}
export function getActiveTab() {
return activeTab;
}

View File

@@ -0,0 +1,204 @@
let tsLoaded = false;
let babelLoaded = false;
let svelteLoaded = false;
function loadScript(src) {
return new Promise((resolve, reject) => {
if (document.querySelector(`script[src="${src}"]`)) { resolve(); return; }
// Temporarily hide AMD define so UMD scripts register as globals
// instead of AMD modules (Monaco's RequireJS sets window.define)
const savedDefine = window.define;
window.define = undefined;
const s = document.createElement('script');
s.src = src;
s.onload = () => { window.define = savedDefine; resolve(); };
s.onerror = () => { window.define = savedDefine; reject(new Error(`Failed to load ${src}`)); };
document.head.appendChild(s);
});
}
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 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':
return { js: code };
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);
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 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>',
};
default:
return { scripts: [], bodyHtml: '' };
}
}

View File

@@ -0,0 +1,54 @@
let sassLoaded = false;
let lessLoaded = false;
function loadScript(src) {
return new Promise((resolve, reject) => {
if (document.querySelector(`script[src="${src}"]`)) { resolve(); return; }
const savedDefine = window.define;
window.define = undefined;
const s = document.createElement('script');
s.src = src;
s.onload = () => { window.define = savedDefine; resolve(); };
s.onerror = () => { window.define = savedDefine; reject(new Error(`Failed to load ${src}`)); };
document.head.appendChild(s);
});
}
async function ensureSass() {
if (sassLoaded) return;
await loadScript('https://cdnjs.cloudflare.com/ajax/libs/sass.js/0.11.1/sass.sync.min.js');
sassLoaded = true;
}
async function ensureLess() {
if (lessLoaded) return;
window.less = { env: 'production' };
await loadScript('https://cdnjs.cloudflare.com/ajax/libs/less.js/4.2.0/less.min.js');
lessLoaded = true;
}
export async function compileCss(code, type) {
if (type === 'css') return code;
if (type === 'scss') {
await ensureSass();
return new Promise((resolve, reject) => {
Sass.compile(code, (result) => {
if (result.status === 0) resolve(result.text);
else reject(new Error(result.formatted || result.message));
});
});
}
if (type === 'less') {
await ensureLess();
try {
const result = await less.render(code);
return result.css;
} catch (e) {
throw new Error(e.message);
}
}
return code;
}

94
public/js/preview.js Normal file
View File

@@ -0,0 +1,94 @@
import { getFrameworkRuntime } from './js-preprocessors.js';
const consoleInterceptor = `
<script>
(function() {
const methods = ['log', 'warn', 'error', 'info', 'debug', 'clear'];
methods.forEach(function(method) {
var original = console[method];
console[method] = function() {
if (method === 'clear') {
parent.postMessage({ type: 'console', method: 'clear' }, '*');
if (original) original.apply(console, arguments);
return;
}
var args = Array.from(arguments).map(function(a) {
if (a === null) return 'null';
if (a === undefined) return 'undefined';
if (typeof a === 'object') {
try { return JSON.stringify(a, null, 2); } catch(e) { return String(a); }
}
return String(a);
});
parent.postMessage({ type: 'console', method: method, args: args }, '*');
if (original) original.apply(console, arguments);
};
});
window.onerror = function(msg, url, line, col) {
parent.postMessage({ type: 'console', method: 'error', args: ['Error: ' + msg + ' (line ' + line + ')'] }, '*');
};
})();
<\/script>
`;
/**
* Escape </script in user code to prevent premature HTML script tag closing.
*/
function escapeScriptClose(code) {
return code.replace(/<\/script/gi, '<\\/script');
}
/**
* Build script tags for the preview iframe.
* Uses static <script src> tags for runtime libraries (parser-blocking per HTML spec)
* followed by an inline <script> for user code.
*/
function buildLoaderScript(runtimeUrls, userJs, isModule) {
if (isModule) {
return `\n<script type="module">\n${escapeScriptClose(userJs)}\n<\/script>`;
}
let parts = '';
for (const url of runtimeUrls) {
parts += `<script src="${url}"><\/script>\n`;
}
parts += `<script>\n${escapeScriptClose(userJs)}\n<\/script>`;
return parts;
}
export function renderPreview(html, css, js, mode = 'html-css-js', extraCss = '') {
const frame = document.getElementById('preview-frame');
const runtime = getFrameworkRuntime(mode);
// Combine CSS
const allCss = extraCss ? `${css}\n${extraCss}` : css;
// Determine body content
let bodyContent;
if (mode === 'vue' || mode === 'svelte') {
bodyContent = html ? `${html}\n${runtime.bodyHtml}` : runtime.bodyHtml;
} else if (runtime.bodyHtml) {
bodyContent = `${html}\n${runtime.bodyHtml}`;
} else {
bodyContent = html;
}
const isModule = mode === 'svelte';
const loaderScript = js
? buildLoaderScript(runtime.scripts, js, isModule)
: '';
const doc = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
${consoleInterceptor}
<style>${allCss}</style>
</head>
<body>
${bodyContent}
${loaderScript}
</body>
</html>`;
frame.srcdoc = doc;
}

48
server.js Normal file
View File

@@ -0,0 +1,48 @@
import express from 'express';
import { nanoid } from 'nanoid';
import { stmts } from './db.js';
const app = express();
app.use(express.json());
app.use(express.static('public'));
// API: Create fiddle
app.post('/api/fiddles', (req, res) => {
const id = nanoid(10);
const { title = 'Untitled', html = '', css = '', css_type = 'css', js = '', js_type = 'javascript' } = req.body;
try {
stmts.insert.run({ id, title, html, css, css_type, js, js_type });
res.json({ id, title, html, css, css_type, js, js_type });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// API: List recent fiddles
app.get('/api/fiddles', (_req, res) => {
res.json(stmts.list.all());
});
// API: Get fiddle
app.get('/api/fiddles/:id', (req, res) => {
const fiddle = stmts.get.get(req.params.id);
if (!fiddle) return res.status(404).json({ error: 'Not found' });
res.json(fiddle);
});
// API: Update fiddle
app.put('/api/fiddles/:id', (req, res) => {
const existing = stmts.get.get(req.params.id);
if (!existing) return res.status(404).json({ error: 'Not found' });
const { title = existing.title, html = existing.html, css = existing.css, css_type = existing.css_type, js = existing.js, js_type = existing.js_type || 'javascript' } = req.body;
stmts.update.run({ id: req.params.id, title, html, css, css_type, js, js_type });
res.json({ id: req.params.id, title, html, css, css_type, js, js_type });
});
// SPA route: serve index.html for /f/:id
app.get('/f/:id', (_req, res) => {
res.sendFile('index.html', { root: 'public' });
});
const port = process.env.PORT || 3000;
app.listen(port, () => console.log(`Fiddle server running on http://localhost:${port}`));