Support app.relay.emoji_confetti on jumbo emoji clicks with canvas physics, per-emoji particle profiles, and a fixed overlay that does not affect scroll. Also add a Vite playground for live component previews and theme hover preview.
150 lines
4.7 KiB
TypeScript
150 lines
4.7 KiB
TypeScript
import path from 'node:path';
|
|
import fs from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import type { Plugin, ViteDevServer } from 'vite';
|
|
|
|
export type LiveTsxApi = {
|
|
getSource: () => string;
|
|
setSource: (source: string) => void;
|
|
};
|
|
|
|
const ALLOWED_PREFIXES = ['src/app/components/', 'src/app/features/'];
|
|
|
|
function resolveAppSourceFile(projectRoot: string, relPath: string): string | null {
|
|
const normalized = relPath.replace(/\\/g, '/').replace(/^\/+/, '');
|
|
if (normalized.includes('..') || path.isAbsolute(normalized)) return null;
|
|
if (!ALLOWED_PREFIXES.some((p) => normalized.startsWith(p))) return null;
|
|
if (!/\.(tsx|ts)$/.test(normalized)) return null;
|
|
const abs = path.resolve(projectRoot, normalized);
|
|
const root = path.resolve(projectRoot);
|
|
if (!abs.startsWith(root + path.sep) && abs !== root) return null;
|
|
return abs;
|
|
}
|
|
|
|
function readBody(req: NodeJS.ReadableStream): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
const chunks: Buffer[] = [];
|
|
req.on('data', (c) => chunks.push(c));
|
|
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
req.on('error', reject);
|
|
});
|
|
}
|
|
|
|
function bumpLivePreview(server: ViteDevServer | undefined) {
|
|
if (!server) return;
|
|
server.ws.send({
|
|
type: 'custom',
|
|
event: 'playground:live-updated',
|
|
data: { revision: Date.now() },
|
|
});
|
|
}
|
|
|
|
export function liveTsxPlugin(projectRoot: string, initialSource: string): Plugin {
|
|
const liveFile = path.resolve(projectRoot, '.cache/playground-live.tsx');
|
|
|
|
fs.mkdirSync(path.dirname(liveFile), { recursive: true });
|
|
fs.writeFileSync(liveFile, initialSource, 'utf8');
|
|
|
|
let source = initialSource;
|
|
let server: ViteDevServer | undefined;
|
|
|
|
const api: LiveTsxApi = {
|
|
getSource: () => source,
|
|
setSource: (next) => {
|
|
source = next;
|
|
fs.writeFileSync(liveFile, next, 'utf8');
|
|
if (!server) return;
|
|
for (const m of server.moduleGraph.urlToModuleMap.values()) {
|
|
if (m.file === liveFile) server.moduleGraph.invalidateModule(m);
|
|
}
|
|
const byId = server.moduleGraph.getModuleById(liveFile);
|
|
if (byId) server.moduleGraph.invalidateModule(byId);
|
|
bumpLivePreview(server);
|
|
},
|
|
};
|
|
|
|
return {
|
|
name: 'playground-live-tsx',
|
|
configureServer(devServer) {
|
|
server = devServer;
|
|
(devServer as ViteDevServer & { playgroundLive?: LiveTsxApi }).playgroundLive = api;
|
|
|
|
devServer.middlewares.use('/__playground/live', (req, res, next) => {
|
|
if (req.method === 'GET') {
|
|
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
res.end(source);
|
|
return;
|
|
}
|
|
if (req.method === 'POST') {
|
|
void readBody(req).then((body) => {
|
|
api.setSource(body);
|
|
res.statusCode = 204;
|
|
res.end();
|
|
});
|
|
return;
|
|
}
|
|
next();
|
|
});
|
|
|
|
// Read/write real Cinny component sources (strings, markup, etc.).
|
|
devServer.middlewares.use('/__playground/source', (req, res, next) => {
|
|
const url = new URL(req.url ?? '', 'http://playground.local');
|
|
const rel = url.searchParams.get('path') ?? '';
|
|
const abs = resolveAppSourceFile(projectRoot, rel);
|
|
if (!abs) {
|
|
res.statusCode = 400;
|
|
res.end('Invalid or disallowed path');
|
|
return;
|
|
}
|
|
|
|
if (req.method === 'GET') {
|
|
if (!fs.existsSync(abs)) {
|
|
res.statusCode = 404;
|
|
res.end('Not found');
|
|
return;
|
|
}
|
|
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
res.end(fs.readFileSync(abs, 'utf8'));
|
|
return;
|
|
}
|
|
|
|
if (req.method === 'POST') {
|
|
void readBody(req).then((body) => {
|
|
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
fs.writeFileSync(abs, body, 'utf8');
|
|
const mod =
|
|
server?.moduleGraph.getModuleById(abs) ??
|
|
[...(server?.moduleGraph.urlToModuleMap.values() ?? [])].find((m) => m.file === abs);
|
|
if (mod && server) server.moduleGraph.invalidateModule(mod);
|
|
bumpLivePreview(server);
|
|
res.statusCode = 204;
|
|
res.end();
|
|
});
|
|
return;
|
|
}
|
|
|
|
next();
|
|
});
|
|
},
|
|
resolveId(id) {
|
|
const clean = id.split('?')[0];
|
|
if (
|
|
clean === '/@playground/live.tsx' ||
|
|
clean === 'virtual:playground-live' ||
|
|
clean.endsWith('/.cache/playground-live.tsx')
|
|
) {
|
|
return liveFile;
|
|
}
|
|
return undefined;
|
|
},
|
|
};
|
|
}
|
|
|
|
export function readDefaultLiveSource(projectRoot: string): string {
|
|
return fs.readFileSync(path.join(projectRoot, 'src/playground/defaultLive.tsx'), 'utf8');
|
|
}
|
|
|
|
export function projectRootDir(): string {
|
|
return path.dirname(fileURLToPath(import.meta.url));
|
|
}
|