Files
fiddle/server.js
root ae8dbafb20 Add Python REPL, instant deploy, Gist import, presentation mode, and CSS visual tools
- 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
2026-02-27 15:50:55 -06:00

369 lines
13 KiB
JavaScript

import express from 'express';
import { nanoid } from 'nanoid';
import db, { stmts, setFiddleTags, snapshotVersion } from './db.js';
const app = express();
app.use(express.json({ limit: '2mb' }));
// HTML routes must be defined before static middleware (which would serve index.html for /)
app.get('/', (_req, res) => {
res.sendFile('browse.html', { root: 'public' });
});
app.get('/new', (_req, res) => {
res.sendFile('index.html', { root: 'public' });
});
app.get('/embed/:id', (_req, res) => {
res.sendFile('embed.html', { root: 'public' });
});
app.get('/f/:id', (_req, res) => {
res.sendFile('index.html', { root: 'public' });
});
// Published fiddle (clean URL)
app.get('/p/:slug', (req, res) => {
const row = stmts.getPublishedFiddle.get(req.params.slug);
if (!row || !row.published_html) return res.status(404).send('Not found');
res.type('html').send(row.published_html);
});
app.use(express.static('public', { index: false }));
// 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', listed = 1, options = '{}', tags = [] } = req.body;
try {
stmts.insert.run({ id, title, html, css, css_type, js, js_type, listed: listed ? 1 : 0, options });
if (tags.length) setFiddleTags(id, tags);
const fiddleTags = stmts.getTagsForFiddle.all(id);
res.json({ id, title, html, css, css_type, js, js_type, listed, options, tags: fiddleTags });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// API: List/search fiddles
app.get('/api/fiddles', (req, res) => {
const { q = '', js_type = '', tag = '', page = '1', limit = '20', sort = 'updated' } = req.query;
const pageNum = Math.max(1, parseInt(page, 10) || 1);
const limitNum = Math.min(100, Math.max(1, parseInt(limit, 10) || 20));
const offset = (pageNum - 1) * limitNum;
let where = 'WHERE f.listed = 1';
const params = {};
if (q) {
where += ' AND f.title LIKE @q';
params.q = `%${q}%`;
}
if (js_type) {
where += ' AND f.js_type = @js_type';
params.js_type = js_type;
}
if (tag) {
where += ' AND EXISTS (SELECT 1 FROM fiddle_tags ft2 JOIN tags t2 ON t2.id = ft2.tag_id WHERE ft2.fiddle_id = f.id AND t2.name = @tag COLLATE NOCASE)';
params.tag = tag;
}
const orderBy = sort === 'created' ? 'f.created_at DESC' : 'f.updated_at DESC';
try {
const countRow = db.prepare(`SELECT COUNT(*) as total FROM fiddles f ${where}`).get(params);
const fiddles = db.prepare(`
SELECT f.id, f.title, f.css_type, f.js_type, f.created_at, f.updated_at, f.screenshot,
SUBSTR(f.html, 1, 200) as html_preview, SUBSTR(f.js, 1, 200) as js_preview
FROM fiddles f ${where}
ORDER BY ${orderBy}
LIMIT @limit OFFSET @offset
`).all({ ...params, limit: limitNum, offset });
// Attach tags to each fiddle
for (const f of fiddles) {
f.tags = stmts.getTagsForFiddle.all(f.id);
}
res.json({ fiddles, total: countRow.total, page: pageNum, limit: limitNum });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// 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' });
fiddle.tags = stmts.getTagsForFiddle.all(fiddle.id);
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' });
// Snapshot current state as a version before overwriting
snapshotVersion(req.params.id);
const {
title = existing.title,
html = existing.html,
css = existing.css,
css_type = existing.css_type,
js = existing.js,
js_type = existing.js_type || 'javascript',
listed = existing.listed,
options = existing.options || '{}',
screenshot,
tags,
} = req.body;
stmts.update.run({ id: req.params.id, title, html, css, css_type, js, js_type, listed: listed ? 1 : 0, options });
if (screenshot !== undefined) stmts.updateScreenshot.run(screenshot, req.params.id);
if (Array.isArray(tags)) setFiddleTags(req.params.id, tags);
const fiddleTags = stmts.getTagsForFiddle.all(req.params.id);
res.json({ id: req.params.id, title, html, css, css_type, js, js_type, listed, options, tags: fiddleTags });
});
// API: List tags
app.get('/api/tags', (_req, res) => {
res.json({ tags: stmts.listTags.all() });
});
// ===================== Gist Import API =====================
app.post('/api/import/gist', async (req, res) => {
const { url } = req.body;
if (!url) return res.status(400).json({ error: 'url required' });
// Extract gist ID from URL
const match = url.match(/gist\.github\.com\/(?:[^/]+\/)?([a-f0-9]+)/);
if (!match) return res.status(400).json({ error: 'Invalid Gist URL' });
const gistId = match[1];
try {
const resp = await fetch(`https://api.github.com/gists/${gistId}`, {
headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'Fiddle-App' },
});
if (!resp.ok) throw new Error(`GitHub API returned ${resp.status}`);
const gist = await resp.json();
let html = '', css = '', js = '', mode = 'html-css-js', css_type = 'css';
const files = Object.values(gist.files);
for (const file of files) {
const ext = file.filename.split('.').pop().toLowerCase();
const content = file.content || '';
if (ext === 'html' || ext === 'htm') {
html = content;
} else if (ext === 'css') {
css = content;
} else if (ext === 'scss') {
css = content;
css_type = 'scss';
} else if (ext === 'less') {
css = content;
css_type = 'less';
} else if (ext === 'js' || ext === 'mjs') {
js = content;
} else if (ext === 'ts') {
js = content;
mode = 'typescript';
} else if (ext === 'jsx') {
js = content;
mode = 'react';
} else if (ext === 'tsx') {
js = content;
mode = 'react-ts';
} else if (ext === 'vue') {
js = content;
mode = 'vue';
} else if (ext === 'svelte') {
js = content;
mode = 'svelte';
} else if (ext === 'md' || ext === 'markdown') {
js = content;
mode = 'markdown';
} else if (ext === 'py') {
js = content;
mode = 'python';
}
}
res.json({ html, css, js, mode, css_type, title: gist.description || 'Imported Gist' });
} catch (e) {
res.status(502).json({ error: e.message });
}
});
// ===================== Publish API =====================
app.post('/api/fiddles/:id/publish', (req, res) => {
const fiddle = stmts.get.get(req.params.id);
if (!fiddle) return res.status(404).json({ error: 'Not found' });
// Check if already published
const existing = stmts.getPublishStatus.get(req.params.id);
if (existing && existing.published_slug) {
// Re-publish with updated HTML
const { html } = req.body;
if (html) stmts.publishFiddle.run({ id: req.params.id, slug: existing.published_slug, html });
return res.json({ slug: existing.published_slug, url: `/p/${existing.published_slug}` });
}
const slug = nanoid(8);
const { html } = req.body;
if (!html) return res.status(400).json({ error: 'html body required' });
stmts.publishFiddle.run({ id: req.params.id, slug, html });
res.json({ slug, url: `/p/${slug}` });
});
app.delete('/api/fiddles/:id/publish', (req, res) => {
const fiddle = stmts.get.get(req.params.id);
if (!fiddle) return res.status(404).json({ error: 'Not found' });
stmts.unpublishFiddle.run(req.params.id);
res.json({ ok: true });
});
app.get('/api/fiddles/:id/publish', (req, res) => {
const fiddle = stmts.get.get(req.params.id);
if (!fiddle) return res.status(404).json({ error: 'Not found' });
const status = stmts.getPublishStatus.get(req.params.id);
res.json({ published: !!(status && status.published_slug), slug: status?.published_slug || null });
});
// ===================== Version History API =====================
app.get('/api/fiddles/:id/versions', (req, res) => {
const fiddle = stmts.get.get(req.params.id);
if (!fiddle) return res.status(404).json({ error: 'Not found' });
const versions = stmts.listVersions.all(req.params.id);
res.json({ versions });
});
app.get('/api/fiddles/:id/versions/:ver', (req, res) => {
const ver = parseInt(req.params.ver, 10);
const version = stmts.getVersion.get(req.params.id, ver);
if (!version) return res.status(404).json({ error: 'Version not found' });
res.json(version);
});
app.post('/api/fiddles/:id/revert/:ver', (req, res) => {
const existing = stmts.get.get(req.params.id);
if (!existing) return res.status(404).json({ error: 'Not found' });
const ver = parseInt(req.params.ver, 10);
const version = stmts.getVersion.get(req.params.id, ver);
if (!version) return res.status(404).json({ error: 'Version not found' });
// Snapshot current state before reverting
snapshotVersion(req.params.id);
stmts.update.run({
id: req.params.id,
title: existing.title,
html: version.html,
css: version.css,
css_type: version.css_type,
js: version.js,
js_type: version.js_type,
listed: existing.listed,
options: version.options || existing.options || '{}',
});
const updated = stmts.get.get(req.params.id);
updated.tags = stmts.getTagsForFiddle.all(req.params.id);
res.json(updated);
});
// ===================== Collections API =====================
app.post('/api/collections', (req, res) => {
const id = nanoid(10);
const { name = 'Untitled Collection', description = '' } = req.body;
try {
stmts.insertCollection.run({ id, name, description });
res.json({ id, name, description, fiddle_count: 0 });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.get('/api/collections', (_req, res) => {
res.json({ collections: stmts.listCollections.all() });
});
app.get('/api/collections/:id', (req, res) => {
const col = stmts.getCollection.get(req.params.id);
if (!col) return res.status(404).json({ error: 'Not found' });
const fiddles = stmts.getCollectionFiddles.all(req.params.id);
for (const f of fiddles) f.tags = stmts.getTagsForFiddle.all(f.id);
res.json({ ...col, fiddles });
});
app.put('/api/collections/:id', (req, res) => {
const col = stmts.getCollection.get(req.params.id);
if (!col) return res.status(404).json({ error: 'Not found' });
const { name = col.name, description = col.description } = req.body;
stmts.updateCollection.run({ id: req.params.id, name, description });
res.json({ id: req.params.id, name, description });
});
app.delete('/api/collections/:id', (req, res) => {
const col = stmts.getCollection.get(req.params.id);
if (!col) return res.status(404).json({ error: 'Not found' });
stmts.deleteCollection.run(req.params.id);
res.json({ ok: true });
});
app.post('/api/collections/:id/fiddles', (req, res) => {
const col = stmts.getCollection.get(req.params.id);
if (!col) return res.status(404).json({ error: 'Collection not found' });
const { fiddle_id } = req.body;
if (!fiddle_id) return res.status(400).json({ error: 'fiddle_id required' });
stmts.addFiddleToCollection.run({ collection_id: req.params.id, fiddle_id });
res.json({ ok: true });
});
app.delete('/api/collections/:id/fiddles/:fid', (req, res) => {
stmts.removeFiddleFromCollection.run(req.params.id, req.params.fid);
res.json({ ok: true });
});
// ===================== Presentation Slides API =====================
app.get('/api/fiddles/:id/slides', (req, res) => {
const fiddle = stmts.get.get(req.params.id);
if (!fiddle) return res.status(404).json({ error: 'Not found' });
const slides = stmts.listSlides.all(req.params.id);
res.json({ slides });
});
app.post('/api/fiddles/:id/slides', (req, res) => {
const fiddle = stmts.get.get(req.params.id);
if (!fiddle) return res.status(404).json({ error: 'Not found' });
const id = nanoid(10);
const { html = '', css = '', js = '', notes = '' } = req.body;
const { max_order } = stmts.getMaxSlideOrder.get(req.params.id);
stmts.insertSlide.run({ id, fiddle_id: req.params.id, slide_order: max_order + 1, html, css, js, notes });
res.json({ id, fiddle_id: req.params.id, slide_order: max_order + 1, html, css, js, notes });
});
app.put('/api/slides/:slideId', (req, res) => {
const slide = stmts.getSlide.get(req.params.slideId);
if (!slide) return res.status(404).json({ error: 'Slide not found' });
const { html = slide.html, css = slide.css, js = slide.js, notes = slide.notes, slide_order = slide.slide_order } = req.body;
stmts.updateSlide.run({ id: req.params.slideId, html, css, js, notes, slide_order });
res.json({ ...slide, html, css, js, notes, slide_order });
});
app.delete('/api/slides/:slideId', (req, res) => {
const slide = stmts.getSlide.get(req.params.slideId);
if (!slide) return res.status(404).json({ error: 'Slide not found' });
stmts.deleteSlide.run(req.params.slideId);
res.json({ ok: true });
});
const port = process.env.PORT || 3000;
app.listen(port, () => console.log(`Fiddle server running on http://localhost:${port}`));