added a way to bundle the whole app

This commit is contained in:
Stefan Stefanov 2026-06-05 21:26:24 +03:00
parent 872d29f3e1
commit 249c88da83
5 changed files with 119 additions and 116 deletions

1
.gitignore vendored
View file

@ -1,2 +1,3 @@
dist/
node_modules/ node_modules/
public/client.js public/client.js

View file

@ -32,7 +32,7 @@ if [ ${#ARGS[@]} -lt 3 ]; then
fi fi
script_dir="$(cd "$(dirname "$0")" && pwd)" script_dir="$(cd "$(dirname "$0")" && pwd)"
node_args=("$script_dir/server.js") node_args=("$script_dir/dist/server.cjs")
[ -n "$STATIC" ] && node_args+=("$STATIC") [ -n "$STATIC" ] && node_args+=("$STATIC")
[ -n "$OUTFILE" ] && node_args+=("--out" "$OUTFILE") [ -n "$OUTFILE" ] && node_args+=("--out" "$OUTFILE")
node_args+=("${ARGS[@]}") node_args+=("${ARGS[@]}")

12
package-lock.json generated
View file

@ -7,13 +7,14 @@
"": { "": {
"name": "difftool", "name": "difftool",
"version": "0.1.0", "version": "0.1.0",
"hasInstallScript": true,
"dependencies": { "dependencies": {
"@pierre/diffs": "^1.2.7", "@pierre/diffs": "1.2.7",
"@pierre/trees": "^1.0.0-beta.4", "express": "5.2.1"
"express": "^5.1.0"
}, },
"devDependencies": { "devDependencies": {
"esbuild": "^0.28.0" "@pierre/trees": "1.0.0-beta.4",
"esbuild": "0.28.0"
} }
}, },
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
@ -489,6 +490,7 @@
"version": "1.0.0-beta.4", "version": "1.0.0-beta.4",
"resolved": "https://registry.npmjs.org/@pierre/trees/-/trees-1.0.0-beta.4.tgz", "resolved": "https://registry.npmjs.org/@pierre/trees/-/trees-1.0.0-beta.4.tgz",
"integrity": "sha512-OfT1yk9ne8Te5+GB5zUY8yqE6B8BqjBHQJleH4lu8ltwNpoocZl4vXt1AzlEExpxI/pp+AFX5QG+lR3JjtTEag==", "integrity": "sha512-OfT1yk9ne8Te5+GB5zUY8yqE6B8BqjBHQJleH4lu8ltwNpoocZl4vXt1AzlEExpxI/pp+AFX5QG+lR3JjtTEag==",
"dev": true,
"license": "apache-2.0", "license": "apache-2.0",
"dependencies": { "dependencies": {
"preact": "11.0.0-beta.0", "preact": "11.0.0-beta.0",
@ -1460,6 +1462,7 @@
"version": "11.0.0-beta.0", "version": "11.0.0-beta.0",
"resolved": "https://registry.npmjs.org/preact/-/preact-11.0.0-beta.0.tgz", "resolved": "https://registry.npmjs.org/preact/-/preact-11.0.0-beta.0.tgz",
"integrity": "sha512-IcODoASASYwJ9kxz7+MJeiJhvLriwSb4y4mHIyxdgaRZp6kPUud7xytrk/6GZw8U3y6EFJaRb5wi9SrEK+8+lg==", "integrity": "sha512-IcODoASASYwJ9kxz7+MJeiJhvLriwSb4y4mHIyxdgaRZp6kPUud7xytrk/6GZw8U3y6EFJaRb5wi9SrEK+8+lg==",
"dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@ -1470,6 +1473,7 @@
"version": "6.6.5", "version": "6.6.5",
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.6.5.tgz", "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.6.5.tgz",
"integrity": "sha512-O6MHzYNIKYaiSX3bOw0gGZfEbOmlIDtDfWwN1JJdc/T3ihzRT6tGGSEWE088dWrEDGa1u7101q+6fzQnO9XCPA==", "integrity": "sha512-O6MHzYNIKYaiSX3bOw0gGZfEbOmlIDtDfWwN1JJdc/T3ihzRT6tGGSEWE088dWrEDGa1u7101q+6fzQnO9XCPA==",
"dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"preact": ">=10 || >= 11.0.0-0" "preact": ">=10 || >= 11.0.0-0"

View file

@ -4,15 +4,17 @@
"type": "module", "type": "module",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@pierre/diffs": "^1.2.7", "@pierre/diffs": "1.2.7",
"@pierre/trees": "^1.0.0-beta.4", "express": "5.2.1"
"express": "^5.1.0"
}, },
"scripts": { "scripts": {
"build": "esbuild src/client.js --bundle --outfile=public/client.js --format=esm", "build:client": "esbuild src/client.js --bundle --outfile=public/client.js --format=esm",
"build:server": "esbuild server.js --bundle --platform=node --target=node20 --outfile=dist/server.cjs --format=cjs && mkdir -p dist/public && cp -r public/. dist/public",
"build": "npm run build:server && npm run build:client",
"postinstall": "npm run build" "postinstall": "npm run build"
}, },
"devDependencies": { "devDependencies": {
"esbuild": "^0.28.0" "@pierre/trees": "1.0.0-beta.4",
"esbuild": "0.28.0"
} }
} }

208
server.js
View file

@ -7,7 +7,9 @@ import { fileURLToPath } from 'url';
import { parsePatchFiles, DEFAULT_THEMES } from '@pierre/diffs'; import { parsePatchFiles, DEFAULT_THEMES } from '@pierre/diffs';
import { preloadFileDiff } from '@pierre/diffs/ssr'; import { preloadFileDiff } from '@pierre/diffs/ssr';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = typeof __filename !== 'undefined'
? dirname(__filename)
: dirname(fileURLToPath(import.meta.url));
// --- arg parsing --- // --- arg parsing ---
@ -86,76 +88,75 @@ if (files.length === 0) {
process.exit(0); process.exit(0);
} }
// --- SSR diffs --- // --- SSR diffs + helpers + mode dispatch ---
const ssrOptions = { (async () => {
theme: DEFAULT_THEMES, const ssrOptions = {
themeType: 'system', theme: DEFAULT_THEMES,
diffStyle: 'unified', themeType: 'system',
useTokenTransformer: true, diffStyle: 'unified',
}; useTokenTransformer: true,
};
const renderedFiles = []; const renderedFiles = [];
for (const fileDiff of files) { for (const fileDiff of files) {
try { try {
const result = await preloadFileDiff({ fileDiff, options: ssrOptions }); const result = await preloadFileDiff({ fileDiff, options: ssrOptions });
renderedFiles.push({ renderedFiles.push({
name: fileDiff.name || 'unknown', name: fileDiff.name || 'unknown',
prerenderedHTML: result.prerenderedHTML, prerenderedHTML: result.prerenderedHTML,
}); });
} catch (err) { } catch (err) {
console.warn(`Failed to SSR-render ${fileDiff.name || 'unknown'}: ${err.message}`); console.warn(`Failed to SSR-render ${fileDiff.name || 'unknown'}: ${err.message}`);
renderedFiles.push({ renderedFiles.push({
name: fileDiff.name || 'unknown', name: fileDiff.name || 'unknown',
prerenderedHTML: null, prerenderedHTML: null,
}); });
}
} }
}
// --- helpers --- const finalPatch = patch;
function escapeHtml(str) { function escapeHtml(str) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
} }
function fileId(name) { function fileId(name) {
return 'DF-' + name.replace(/[^a-zA-Z0-9_-]/g, '_'); return 'DF-' + name.replace(/[^a-zA-Z0-9_-]/g, '_');
} }
// --- HTML template --- function pageHtml(opts) {
const { mode, sidebarHtml, extraScript } = opts;
function pageHtml(opts) { const fileSections = renderedFiles.map((f) => {
const { mode, sidebarHtml, extraScript } = opts; const escapedName = escapeHtml(f.name);
const id = fileId(f.name);
const fileSections = renderedFiles.map((f) => { if (f.prerenderedHTML !== null) {
const escapedName = escapeHtml(f.name); return `
const id = fileId(f.name);
if (f.prerenderedHTML !== null) {
return `
<div class="diff-file" id="${id}"> <div class="diff-file" id="${id}">
<div class="diff-file-header">${escapedName}</div> <div class="diff-file-header">${escapedName}</div>
<diffs-container> <diffs-container>
<template shadowrootmode="open">${f.prerenderedHTML}</template> <template shadowrootmode="open">${f.prerenderedHTML}</template>
</diffs-container> </diffs-container>
</div>`; </div>`;
} }
return ` return `
<div class="diff-file" id="${id}"> <div class="diff-file" id="${id}">
<div class="diff-file-header">${escapedName}</div> <div class="diff-file-header">${escapedName}</div>
<div class="diff-fallback"><a href="/patch">View raw patch</a></div> <div class="diff-fallback"><a href="/patch">View raw patch</a></div>
</div>`; </div>`;
}).join('\n'); }).join('\n');
const hasLocal = stagedDiff || unstagedDiff || untrackedPatch; const hasLocal = stagedDiff || unstagedDiff || untrackedPatch;
const subtitle = hasLocal const subtitle = hasLocal
? `${escapeHtml(mainBranch)} \u2192 ${escapeHtml(featureBranch)} (includes local changes)` ? `${escapeHtml(mainBranch)} \u2192 ${escapeHtml(featureBranch)} (includes local changes)`
: `${escapeHtml(mainBranch)} \u2192 ${escapeHtml(featureBranch)}`; : `${escapeHtml(mainBranch)} \u2192 ${escapeHtml(featureBranch)}`;
const title = `Diff: ${escapeHtml(mainBranch)} \u2192 ${escapeHtml(featureBranch)}`; const title = `Diff: ${escapeHtml(mainBranch)} \u2192 ${escapeHtml(featureBranch)}`;
const rawLink = mode === 'server' const rawLink = mode === 'server'
? '<a class="raw-link" href="/patch">View raw patch \u2192</a>' ? '<a class="raw-link" href="/patch">View raw patch \u2192</a>'
: ''; : '';
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
@ -262,21 +263,19 @@ function pageHtml(opts) {
${extraScript} ${extraScript}
</body> </body>
</html>`; </html>`;
} }
// --- static HTML generation (shared by --static and --out) --- function buildStaticHtml() {
const sidebarHtml = `
function buildStaticHtml() {
const sidebarHtml = `
<div class="sidebar"> <div class="sidebar">
<div class="file-list"> <div class="file-list">
${renderedFiles.map(f => ` <a class="file-item" href="#${fileId(f.name)}">${escapeHtml(f.name)}</a>`).join('\n')} ${renderedFiles.map(f => ` <a class="file-item" href="#${fileId(f.name)}">${escapeHtml(f.name)}</a>`).join('\n')}
</div> </div>
</div>`; </div>`;
const escapedPatch = escapeHtml(patch); const escapedPatch = escapeHtml(finalPatch);
const extraScript = ` <script> const extraScript = ` <script>
document.querySelectorAll('.file-item').forEach(el => { document.querySelectorAll('.file-item').forEach(el => {
el.addEventListener('click', () => { el.addEventListener('click', () => {
const id = el.getAttribute('href').slice(1); const id = el.getAttribute('href').slice(1);
@ -293,64 +292,61 @@ ${renderedFiles.map(f => ` <a class="file-item" href="#${fileId(f.name)
}); });
</script>`; </script>`;
return pageHtml({ mode: 'static', sidebarHtml, extraScript }) return pageHtml({ mode: 'static', sidebarHtml, extraScript })
.replace('</body>', ` <details style="padding:0 24px 16px"> .replace('</body>', ` <details style="padding:0 24px 16px">
<summary id="patch-toggle" style="cursor:pointer;font-size:0.8125rem;color:#2563eb">View raw patch</summary> <summary id="patch-toggle" style="cursor:pointer;font-size:0.8125rem;color:#2563eb">View raw patch</summary>
<pre id="patch-content" class="raw-patch">${escapedPatch}</pre> <pre id="patch-content" class="raw-patch">${escapedPatch}</pre>
</details>\n</body>`); </details>\n</body>`);
}
// --- mode dispatch ---
if (outFile) {
// -- out mode: write HTML to specified path, exit --
const html = buildStaticHtml();
writeFileSync(outFile, html, 'utf-8');
console.log(`Written to ${outFile}`);
process.exit(0);
}
if (isStatic) {
// -- static mode: generate self-contained HTML file, open in browser, exit --
const html = buildStaticHtml();
const tmpFile = join(tmpdir(), `difftool-${Date.now()}.html`);
writeFileSync(tmpFile, html, 'utf-8');
const openCmd = process.platform === 'darwin' ? 'open'
: process.platform === 'win32' ? 'start'
: 'xdg-open';
console.log(`Opening ${tmpFile}`);
try {
spawn(openCmd, [tmpFile], { detached: true, stdio: 'ignore' }).unref();
} catch {
console.log(`HTML file saved to ${tmpFile}`);
} }
} else {
// -- server mode: start Express --
const app = express(); // --- mode dispatch ---
app.use(express.static(join(__dirname, 'public')));
app.get('/patch', (req, res) => { if (outFile) {
res.type('text/plain').send(patch); const html = buildStaticHtml();
}); writeFileSync(outFile, html, 'utf-8');
console.log(`Written to ${outFile}`);
process.exit(0);
}
app.get('/', (req, res) => { if (isStatic) {
const fileDataJson = JSON.stringify(renderedFiles.map(f => ({ name: f.name }))) const html = buildStaticHtml();
.replace(/<\/script>/gi, '<\\/script>'); const tmpFile = join(tmpdir(), `difftool-${Date.now()}.html`);
writeFileSync(tmpFile, html, 'utf-8');
const sidebarHtml = '<div class="sidebar"><div id="file-tree"></div></div>'; const openCmd = process.platform === 'darwin' ? 'open'
: process.platform === 'win32' ? 'start'
: 'xdg-open';
console.log(`Opening ${tmpFile}`);
try {
spawn(openCmd, [tmpFile], { detached: true, stdio: 'ignore' }).unref();
} catch {
console.log(`HTML file saved to ${tmpFile}`);
}
} else {
const app = express();
app.use(express.static(join(__dirname, 'public')));
const extraScript = ` app.get('/patch', (req, res) => {
res.type('text/plain').send(finalPatch);
});
app.get('/', (req, res) => {
const fileDataJson = JSON.stringify(renderedFiles.map(f => ({ name: f.name })))
.replace(/<\/script>/gi, '<\\/script>');
const sidebarHtml = '<div class="sidebar"><div id="file-tree"></div></div>';
const extraScript = `
<script id="diff-files-data" type="application/json">${fileDataJson}</script> <script id="diff-files-data" type="application/json">${fileDataJson}</script>
<script type="module" src="/client.js"></script>`; <script type="module" src="/client.js"></script>`;
const html = pageHtml({ mode: 'server', sidebarHtml, extraScript }); const html = pageHtml({ mode: 'server', sidebarHtml, extraScript });
res.type('html').send(html); res.type('html').send(html);
}); });
app.listen(port, () => { app.listen(port, () => {
console.log(`Difftool running at http://localhost:${port}`); console.log(`Difftool running at http://localhost:${port}`);
console.log(`Patch endpoint: http://localhost:${port}/patch`); console.log(`Patch endpoint: http://localhost:${port}/patch`);
}); });
} }
})();