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:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
data/
|
||||
43
db.js
Normal file
43
db.js
Normal 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
1362
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
package.json
Normal file
16
package.json
Normal 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
148
public/css/style.css
Normal 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
70
public/index.html
Normal 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
29
public/js/api.js
Normal 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
195
public/js/app.js
Normal 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();
|
||||
27
public/js/console-panel.js
Normal file
27
public/js/console-panel.js
Normal 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
258
public/js/editors.js
Normal 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;
|
||||
}
|
||||
204
public/js/js-preprocessors.js
Normal file
204
public/js/js-preprocessors.js
Normal 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: '' };
|
||||
}
|
||||
}
|
||||
54
public/js/preprocessors.js
Normal file
54
public/js/preprocessors.js
Normal 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
94
public/js/preview.js
Normal 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
48
server.js
Normal 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}`));
|
||||
Reference in New Issue
Block a user