version-history-widget 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 bimanshu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,85 @@
1
+ # version-history-widget
2
+
3
+ Zero-dependency version history for web projects. Every change gets a titled,
4
+ restorable snapshot, and a floating "Versions" widget on the live site lets
5
+ anyone browse, search, and restore with one click.
6
+
7
+ Two modes:
8
+
9
+ - **Project mode** — multi-file sites/apps. Snapshots live in `.versions/`.
10
+ - **Single-file mode** — one HTML file. Snapshots live inside the file itself.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ npm install --save-dev version-history-widget
16
+ ```
17
+
18
+ Or use without installing:
19
+
20
+ ```bash
21
+ npx version-history-widget init
22
+ ```
23
+
24
+ ## Project mode
25
+
26
+ ```bash
27
+ npx vh init # sets up .versions/, injects the widget, saves version 1
28
+ npx vh record "Title" -d "what changed and why"
29
+ npx vh restore 4 # safety-snapshots current state, then restores version 4
30
+ npx vh list [query] # list versions, optionally filtered
31
+ ```
32
+
33
+ Serve it so the restore button works:
34
+
35
+ ```bash
36
+ node .versions/serve.js # standalone static server with restore endpoint
37
+ ```
38
+
39
+ Or mount the middleware in your own dev server:
40
+
41
+ ```js
42
+ // Express
43
+ app.use(require('./.versions/middleware.js')());
44
+
45
+ // Vite
46
+ export default {
47
+ plugins: [{
48
+ name: 'version-history',
49
+ configureServer(server) {
50
+ server.middlewares.use(require('./.versions/middleware.js')());
51
+ },
52
+ }],
53
+ };
54
+ ```
55
+
56
+ ## Single-file mode
57
+
58
+ ```bash
59
+ npx vh-singlefile init page.html
60
+ npx vh-singlefile record page.html "Title" -d "details"
61
+ npx vh-singlefile list page.html [query]
62
+ ```
63
+
64
+ Restoring is handled entirely in the browser by the injected widget — no
65
+ server needed.
66
+
67
+ ## How it works
68
+
69
+ - No runtime dependencies — only Node's built-in `fs`, `path`, `http`, and
70
+ `child_process`.
71
+ - Every snapshot stores full file contents plus a generated diff, so restores
72
+ are exact and history is human-readable.
73
+ - The widget (`widget.js`) is vanilla JS + inline CSS, scoped under `vh-`
74
+ class names so it never collides with your site's styles.
75
+
76
+ ## Customizing the widget UI
77
+
78
+ All of the widget's markup and styling live in `widget.js` as a single
79
+ template string. Open it directly and edit the `css` variable (colors,
80
+ spacing, fonts) or the HTML-building functions for layout changes — there's
81
+ no build step, so changes take effect on next reload.
82
+
83
+ ## License
84
+
85
+ MIT
package/middleware.js ADDED
@@ -0,0 +1,24 @@
1
+ /* Connect/Express/Vite-compatible middleware: serves /.versions/* and handles POST /__vh/restore/:id
2
+ Express: app.use(require('./.versions/middleware.js')())
3
+ Vite: plugins:[{ name:'vh', configureServer(s){ s.middlewares.use(require('./.versions/middleware.js')()) } }]
4
+ Next: use serve.js alongside, or add a route handler that calls require('./.versions/vh.js').restore(id) */
5
+ const fs = require('fs'), path = require('path');
6
+ module.exports = function (opts = {}) {
7
+ const root = path.resolve(opts.root || process.env.VH_ROOT || process.cwd()), vdir = path.join(root, '.versions');
8
+ const types = { '.js': 'text/javascript', '.json': 'application/json', '.diff': 'text/plain' };
9
+ return function (req, res, next) {
10
+ const url = (req.url || '').split('?')[0];
11
+ if (req.method === 'POST' && url.startsWith('/__vh/restore/')) {
12
+ const id = Number(url.split('/').pop());
13
+ try { const out = require(path.join(vdir, 'vh.js')).restore(id); res.statusCode = 200; res.setHeader('Content-Type', 'text/plain'); return res.end(out); }
14
+ catch (e) { res.statusCode = 500; return res.end(String(e.stderr || e.message)); }
15
+ }
16
+ if (url.startsWith('/.versions/')) {
17
+ const f = path.join(vdir, url.slice('/.versions/'.length));
18
+ if (!f.startsWith(vdir) || !fs.existsSync(f) || fs.statSync(f).isDirectory()) { res.statusCode = 404; return res.end(); }
19
+ res.setHeader('Content-Type', types[path.extname(f)] || 'application/octet-stream'); res.setHeader('Cache-Control', 'no-store');
20
+ return fs.createReadStream(f).pipe(res);
21
+ }
22
+ next && next();
23
+ };
24
+ };
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "version-history-widget",
3
+ "version": "0.1.0",
4
+ "description": "Zero-dependency version history for web projects: snapshots every change and adds a floating widget to browse, search, and restore any version.",
5
+ "keywords": ["version history", "undo", "snapshot", "restore", "dev-tool", "widget"],
6
+ "license": "MIT",
7
+ "author": "bimanshu <bimanshuvatsa@gmail.com>",
8
+ "engines": { "node": ">=14" },
9
+ "main": "./middleware.js",
10
+ "bin": {
11
+ "vh": "./vh.js",
12
+ "vh-singlefile": "./singlefile.js"
13
+ },
14
+ "files": [
15
+ "vh.js",
16
+ "widget.js",
17
+ "middleware.js",
18
+ "serve.js",
19
+ "singlefile.js"
20
+ ],
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/bimanshu/version-history-widget.git"
24
+ },
25
+ "homepage": "https://github.com/bimanshu/version-history-widget#readme",
26
+ "bugs": {
27
+ "url": "https://github.com/bimanshu/version-history-widget/issues"
28
+ }
29
+ }
package/serve.js ADDED
@@ -0,0 +1,14 @@
1
+ /* Static-site-agnostic dev server with the restore endpoint. Zero deps.
2
+ node .versions/serve.js [port] [dir] — serves the project folder (default: parent of .versions, port 4173) */
3
+ const http = require('http'), fs = require('fs'), path = require('path');
4
+ const port = Number(process.argv[2]) || 4173, root = path.resolve(process.argv[3] || path.join(__dirname, '..'));
5
+ process.env.VH_ROOT = root;
6
+ const vh = require(path.join(__dirname, 'middleware.js'))({ root });
7
+ const types = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml', '.ico': 'image/x-icon' };
8
+ http.createServer((req, res) => vh(req, res, () => {
9
+ let f = path.join(root, decodeURIComponent(req.url.split('?')[0]));
10
+ if (fs.existsSync(f) && fs.statSync(f).isDirectory()) f = path.join(f, 'index.html');
11
+ if (!f.startsWith(root) || !fs.existsSync(f)) { res.statusCode = 404; return res.end('Not found'); }
12
+ res.setHeader('Content-Type', types[path.extname(f)] || 'application/octet-stream'); res.setHeader('Cache-Control', 'no-store');
13
+ fs.createReadStream(f).pipe(res);
14
+ })).listen(port, () => console.log('version-history server: http://localhost:' + port + ' (root: ' + root + ')'));
package/singlefile.js ADDED
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ /* Single-file mode: embeds version history inside one HTML file. Zero deps.
3
+ node singlefile.js init page.html — inject store + widget, save version 1
4
+ node singlefile.js record page.html "Title" [-d "details" | -f file]
5
+ node singlefile.js list page.html [query] */
6
+ const fs = require('fs'), path = require('path');
7
+ const STORE = /<script type="application\/json" id="vh-store">([\s\S]*?)<\/script>/;
8
+ const [cmd, file, ...args] = process.argv.slice(2);
9
+ if (!cmd || !file) { console.log(fs.readFileSync(__filename, 'utf8').split('*/')[0].split('\n').slice(1).join('\n')); process.exit(0); }
10
+ let html = fs.readFileSync(file, 'utf8');
11
+ const m = html.match(STORE), versions = m ? JSON.parse(m[1].replace(/<\\\/script/g, '</script')) : [];
12
+ const bare = () => html.replace(/<script type="application\/json" id="vh-store">[\s\S]*?<\/script>\n?/, '').replace(/<script id="vh-widget">[\s\S]*?<\/script>\n?/, '');
13
+ const write = v => {
14
+ let out = bare();
15
+ const store = '<script type="application/json" id="vh-store">' + JSON.stringify(v).replace(/<\/script/g, '<\\/script') + '</script>';
16
+ const widget = '<script id="vh-widget">' + fs.readFileSync(path.join(__dirname, 'widget.js'), 'utf8') + '</script>';
17
+ out = /<\/body>/i.test(out) ? out.replace(/<\/body>/i, store + '\n' + widget + '\n</body>') : out + store + widget;
18
+ fs.writeFileSync(file, out);
19
+ };
20
+ if (cmd === 'init') {
21
+ if (versions.length) { console.log('Already initialised (' + versions.length + ' versions).'); process.exit(0); }
22
+ versions.push({ id: 1, title: 'Initial version', time: new Date().toISOString(), details: 'State of the file when version history was enabled.', html: bare() });
23
+ write(versions); console.log('version-history ready in ' + file + '. Saved version 1.');
24
+ } else if (cmd === 'record') {
25
+ const title = args[0]; if (!title) { console.error('usage: singlefile record page.html "Title" [-d details]'); process.exit(1); }
26
+ const di = args.indexOf('-d'), fi = args.indexOf('-f'), details = di >= 0 ? args[di + 1] : fi >= 0 ? fs.readFileSync(args[fi + 1], 'utf8') : '';
27
+ const snap = bare(); if (versions.length && versions[versions.length - 1].html === snap) { console.log('No changes since last version — nothing saved.'); process.exit(0); }
28
+ const id = versions.length ? versions[versions.length - 1].id + 1 : 1;
29
+ versions.push({ id, title, time: new Date().toISOString(), details, html: snap }); write(versions);
30
+ console.log('Saved as version ' + id + ': ' + title);
31
+ } else if (cmd === 'list') {
32
+ const q = (args[0] || '').toLowerCase();
33
+ for (const v of versions) if (!q || v.title.toLowerCase().includes(q) || (v.details || '').toLowerCase().includes(q)) console.log(String(v.id).padStart(3) + ' ' + v.time.slice(0, 16).replace('T', ' ') + ' ' + v.title);
34
+ }
package/vh.js ADDED
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env node
2
+ /* version-history CLI (project mode). Zero dependencies.
3
+ node vh.js init — set up .versions/, inject widget, snapshot as version 1
4
+ node vh.js record "Title" [-d "details" | -f details.txt] — snapshot changed files as a new version
5
+ node vh.js restore <id> — snapshot current state, then restore version <id>
6
+ node vh.js list [query] — list versions (optionally filtered by title/details)
7
+ Env: VH_ROOT (project root, default cwd) */
8
+ const fs = require('fs'), path = require('path');
9
+ const ROOT = path.resolve(process.env.VH_ROOT || process.cwd());
10
+ const VDIR = path.join(ROOT, '.versions'), SNAP = path.join(VDIR, 'snapshots'), MAN = path.join(VDIR, 'manifest.json');
11
+ const IGNORE = new Set(['node_modules', '.git', '.versions', 'dist', 'build', '.next', '.nuxt', 'coverage', '.cache']);
12
+ const MAX = 2 * 1024 * 1024;
13
+
14
+ const readMan = () => fs.existsSync(MAN) ? JSON.parse(fs.readFileSync(MAN, 'utf8')) : [];
15
+ const writeMan = m => fs.writeFileSync(MAN, JSON.stringify(m, null, 2));
16
+
17
+ function walk(dir, out = {}) {
18
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
19
+ if (IGNORE.has(e.name) || e.name.startsWith('.env')) continue;
20
+ const p = path.join(dir, e.name);
21
+ if (e.isDirectory()) walk(p, out);
22
+ else if (e.isFile() && fs.statSync(p).size <= MAX) out[path.relative(ROOT, p).split(path.sep).join('/')] = fs.readFileSync(p);
23
+ }
24
+ return out;
25
+ }
26
+
27
+ /* Reconstruct full file state at version id by replaying snapshots. */
28
+ function stateAt(id, man) {
29
+ const files = {};
30
+ for (const v of man) {
31
+ if (v.id > id) break;
32
+ for (const d of v.deleted || []) delete files[d];
33
+ for (const f of v.files || []) files[f] = fs.readFileSync(path.join(SNAP, String(v.id), f));
34
+ }
35
+ return files;
36
+ }
37
+
38
+ function lineDiff(a, b) { // small LCS diff, unified-ish output
39
+ const A = a.split('\n'), B = b.split('\n');
40
+ if (A.length * B.length > 4e6) return '(diff too large to display)';
41
+ const n = A.length, m = B.length, dp = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
42
+ for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) dp[i][j] = A[i] === B[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
43
+ const out = []; let i = 0, j = 0;
44
+ while (i < n && j < m) { if (A[i] === B[j]) { i++; j++; } else if (dp[i + 1][j] >= dp[i][j + 1]) out.push('- ' + A[i++]); else out.push('+ ' + B[j++]); }
45
+ while (i < n) out.push('- ' + A[i++]); while (j < m) out.push('+ ' + B[j++]);
46
+ return out.join('\n');
47
+ }
48
+
49
+ function snapshot(title, details, prevState) {
50
+ const man = readMan(), id = man.length ? man[man.length - 1].id + 1 : 1;
51
+ const cur = walk(ROOT), files = [], deleted = [], diffs = [];
52
+ for (const f of Object.keys(cur)) {
53
+ const old = prevState[f];
54
+ if (!old || !old.equals(cur[f])) {
55
+ files.push(f);
56
+ const dest = path.join(SNAP, String(id), f); fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.writeFileSync(dest, cur[f]);
57
+ const isText = !/\.(png|jpe?g|gif|webp|ico|woff2?|ttf|pdf|zip)$/i.test(f);
58
+ diffs.push('### ' + f + (old ? '' : ' (new)') + '\n' + (isText ? lineDiff(old ? old.toString('utf8') : '', cur[f].toString('utf8')) : '(binary)'));
59
+ }
60
+ }
61
+ for (const f of Object.keys(prevState)) if (!cur[f]) { deleted.push(f); diffs.push('### ' + f + ' (deleted)'); }
62
+ fs.mkdirSync(path.join(SNAP, String(id)), { recursive: true });
63
+ fs.writeFileSync(path.join(SNAP, String(id), 'changes.diff'), diffs.join('\n\n'));
64
+ const bundle = {}; for (const f of Object.keys(cur)) bundle[f] = cur[f].toString('base64');
65
+ fs.writeFileSync(path.join(SNAP, String(id), 'files.json'), JSON.stringify({ id, title, files: bundle }));
66
+ man.push({ id, title, time: new Date().toISOString(), details, files, deleted });
67
+ writeMan(man);
68
+ return { id, changed: files.length + deleted.length };
69
+ }
70
+
71
+ function injectWidget() {
72
+ const cands = ['index.html', 'public/index.html', 'src/index.html', 'app/layout.tsx', 'app/layout.jsx', 'src/app/layout.tsx', 'pages/_document.tsx', 'pages/_document.jsx']
73
+ .map(f => path.join(ROOT, f)).filter(fs.existsSync);
74
+ for (const f of cands) {
75
+ let s = fs.readFileSync(f, 'utf8'); if (s.includes('.versions/widget.js')) return f;
76
+ const tag = '<script src="/.versions/widget.js"></script>';
77
+ if (/<\/body>/i.test(s)) s = s.replace(/<\/body>/i, tag + '\n</body>');
78
+ else if (/<\/head>/i.test(s)) s = s.replace(/<\/head>/i, tag + '\n</head>');
79
+ else continue;
80
+ fs.writeFileSync(f, s); return f;
81
+ }
82
+ return null;
83
+ }
84
+
85
+ const cmd = process.argv[2], args = process.argv.slice(3);
86
+ if (cmd === 'init') {
87
+ fs.mkdirSync(SNAP, { recursive: true });
88
+ for (const f of ['widget.js', 'vh.js', 'serve.js', 'middleware.js']) { const src = path.join(__dirname, f); if (fs.existsSync(src) && src !== path.join(VDIR, f)) fs.copyFileSync(src, path.join(VDIR, f)); }
89
+ if (!fs.existsSync(MAN)) writeMan([]);
90
+ const gi = path.join(ROOT, '.gitignore'); const line = '.versions/snapshots/';
91
+ if (!fs.existsSync(gi) || !fs.readFileSync(gi, 'utf8').includes(line)) fs.appendFileSync(gi, '\n' + line + '\n');
92
+ const where = injectWidget();
93
+ const r = readMan().length ? null : snapshot('Initial version', 'State of the project when version history was enabled.', {});
94
+ console.log('version-history ready.' + (where ? ' Widget injected into ' + path.relative(ROOT, where) + '.' : ' Could not find an entry HTML — add <script src="/.versions/widget.js"></script> manually.') + (r ? ' Saved version 1.' : ''));
95
+ console.log('Serve with restore endpoint: node .versions/serve.js (or mount .versions/middleware.js in your dev server)');
96
+ } else if (cmd === 'record') {
97
+ const title = args[0]; if (!title) { console.error('usage: vh record "Title" [-d "details" | -f file]'); process.exit(1); }
98
+ const di = args.indexOf('-d'), fi = args.indexOf('-f');
99
+ const details = di >= 0 ? args[di + 1] : fi >= 0 ? fs.readFileSync(args[fi + 1], 'utf8') : '';
100
+ const man = readMan(), prev = man.length ? stateAt(man[man.length - 1].id, man) : {};
101
+ const r = snapshot(title, details, prev);
102
+ console.log(r.changed ? 'Saved as version ' + r.id + ': ' + title : 'No changes since last version — nothing saved.');
103
+ if (!r.changed) { const m = readMan(); m.pop(); writeMan(m); fs.rmSync(path.join(SNAP, String(r.id)), { recursive: true, force: true }); }
104
+ } else if (cmd === 'restore') {
105
+ const id = Number(args[0]), man = readMan(), target = man.find(v => v.id === id);
106
+ if (!target) { console.error('No version ' + args[0]); process.exit(1); }
107
+ const cur = man[man.length - 1], prev = stateAt(cur.id, man);
108
+ snapshot('Snapshot before restoring "' + target.title + '"', 'Automatic safety snapshot taken before restore.', prev);
109
+ const want = stateAt(id, readMan()), have = walk(ROOT);
110
+ for (const f of Object.keys(have)) if (!want[f]) fs.rmSync(path.join(ROOT, f));
111
+ for (const f of Object.keys(want)) { const p = path.join(ROOT, f); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, want[f]); }
112
+ injectWidget();
113
+ const r = snapshot('Restored "' + target.title + '"', 'Restored the state saved as version ' + id + '.', stateAt(readMan().slice(-1)[0].id, readMan()));
114
+ console.log('Restored version ' + id + ' (recorded as version ' + r.id + ').');
115
+ } else if (cmd === 'list') {
116
+ const q = (args[0] || '').toLowerCase();
117
+ for (const v of readMan()) if (!q || v.title.toLowerCase().includes(q) || (v.details || '').toLowerCase().includes(q)) console.log(String(v.id).padStart(3) + ' ' + v.time.slice(0, 16).replace('T', ' ') + ' ' + v.title);
118
+ } else { console.log(fs.readFileSync(__filename, 'utf8').split('*/')[0].split('\n').slice(1).join('\n')); }
119
+ module.exports = { restore: id => { const { execFileSync } = require('child_process'); return execFileSync(process.execPath, [__filename, 'restore', String(id)], { cwd: ROOT, env: process.env }).toString(); } };
package/widget.js ADDED
@@ -0,0 +1,84 @@
1
+ /* version-history widget - vanilla JS, no deps. Reads #vh-store (single-file) or /.versions/manifest.json (project). */
2
+ (function () {
3
+ if (window.__vhLoaded) return; window.__vhLoaded = true;
4
+ var Z = 2147483647, MODE = document.getElementById('vh-store') ? 'single' : 'project';
5
+ var css = '\
6
+ .vh-pill{position:fixed;right:16px;bottom:16px;z-index:' + Z + ';font:13px/1 system-ui,sans-serif;background:#111;color:#fff;border:0;border-radius:999px;padding:10px 14px;cursor:pointer;box-shadow:0 4px 14px rgba(0,0,0,.25)}\
7
+ .vh-panel{position:fixed;right:16px;bottom:56px;width:440px;max-width:calc(100vw - 32px);max-height:70vh;z-index:' + Z + ';background:#fff;color:#111;border:1px solid #ddd;border-radius:12px;box-shadow:0 12px 40px rgba(0,0,0,.25);font:13px/1.4 system-ui,sans-serif;display:flex;flex-direction:column;overflow:hidden}\
8
+ .vh-head{display:flex;gap:8px;padding:10px;border-bottom:1px solid #eee;align-items:center}\
9
+ .vh-search{flex:1;padding:8px 10px;border:1px solid #ccc;border-radius:8px;font:inherit;outline:none}.vh-search:focus{border-color:#111}\
10
+ .vh-clear{border:0;background:#eee;border-radius:6px;padding:6px 8px;cursor:pointer}\
11
+ .vh-list{overflow:auto;padding:6px}\
12
+ .vh-row{border-bottom:1px solid #f0f0f0;padding:8px 6px}\
13
+ .vh-top{display:flex;align-items:center;gap:8px;margin-top:6px}\
14
+ .vh-title{display:block;font-weight:600;line-height:1.35}.vh-title mark{background:#fff3a3;padding:0}\
15
+ .vh-time{flex:1;color:#777;font-size:11px;white-space:nowrap}\
16
+ .vh-btn{border:1px solid #ccc;background:#fafafa;border-radius:6px;padding:4px 8px;cursor:pointer;font:inherit;font-size:12px}\
17
+ .vh-btn:disabled{opacity:.4;cursor:default}.vh-btn.vh-restore{background:#111;color:#fff;border-color:#111}\
18
+ .vh-cur{color:#0a7d34;font-size:11px}.vh-tag{color:#777;font-size:11px;font-style:italic}\
19
+ .vh-details{display:none;margin-top:8px;padding:10px;background:#f7f7f7;border-radius:8px;white-space:pre-wrap;color:#333}\
20
+ .vh-details.vh-open{display:block}.vh-empty{padding:20px;text-align:center;color:#777}\
21
+ .vh-note{padding:8px 10px;font-size:12px;background:#fff8e1;border-top:1px solid #eee;display:none}';
22
+ var st = document.createElement('style'); st.textContent = css; document.head.appendChild(st);
23
+
24
+ var versions = [], pill, panel, open = false, openDetails = null, query = '';
25
+
26
+ function load(cb) {
27
+ if (MODE === 'single') { try { versions = JSON.parse(document.getElementById('vh-store').textContent || '[]'); } catch (e) { versions = []; } cb(); }
28
+ else fetch('/.versions/manifest.json', { cache: 'no-store' }).then(function (r) { return r.json(); }).then(function (m) { versions = m; cb(); }).catch(function () { versions = []; cb(); });
29
+ }
30
+ function esc(s) { return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]; }); }
31
+ function hl(text, q) { if (!q) return esc(text); var out = esc(text); q.split(/\s+/).filter(Boolean).forEach(function (w) { out = out.replace(new RegExp('(' + w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi'), '<mark>$1</mark>'); }); return out; }
32
+ function matches(v, q) { var words = q.toLowerCase().split(/\s+/).filter(Boolean); if (!words.length) return { ok: true }; var t = v.title.toLowerCase(), d = (v.details || '').toLowerCase(); var ok = words.every(function (w) { return t.indexOf(w) >= 0 || d.indexOf(w) >= 0; }); return { ok: ok, inTitleOnly: words.some(function (w) { return t.indexOf(w) >= 0; }) }; }
33
+ function fmt(t) { var d = new Date(t); return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); }
34
+
35
+ function render() {
36
+ var list = panel.querySelector('.vh-list'); list.innerHTML = '';
37
+ var cur = versions.length ? versions[versions.length - 1].id : null, shown = 0;
38
+ versions.slice().reverse().forEach(function (v) {
39
+ var m = matches(v, query); if (!m.ok) return; shown++;
40
+ var row = document.createElement('div'); row.className = 'vh-row';
41
+ row.innerHTML = '<span class="vh-title">' + hl(v.title, query) + (query && !m.inTitleOnly ? ' <span class="vh-tag">match in details</span>' : '') + (v.id === cur ? ' <span class="vh-cur">\u25CF current</span>' : '') + '</span><div class="vh-top">' +
42
+ '<span class="vh-time">' + esc(fmt(v.time)) + '</span>' +
43
+ '<button class="vh-btn vh-det">Details \u25BE</button>' +
44
+ '<button class="vh-btn vh-restore"' + (v.id === cur ? ' disabled' : '') + '>Restore</button></div>' +
45
+ '<div class="vh-details' + (openDetails === v.id ? ' vh-open' : '') + '">' + hl(v.details || '(no details)', query) + '</div>';
46
+ row.querySelector('.vh-det').onclick = function () { openDetails = openDetails === v.id ? null : v.id; render(); };
47
+ row.querySelector('.vh-restore').onclick = function () { if (confirm('Restore "' + v.title + '"? Current state will be saved first.')) restore(v); };
48
+ list.appendChild(row);
49
+ });
50
+ if (!shown) list.innerHTML = '<div class="vh-empty">' + (query ? 'No versions match "' + esc(query) + '"' : 'No versions yet') + '</div>';
51
+ pill.textContent = 'Versions \u00B7 ' + versions.length;
52
+ }
53
+
54
+ function restore(v) {
55
+ if (MODE === 'single') {
56
+ var snap = versions[versions.length - 1], now = new Date().toISOString();
57
+ var html = document.documentElement.outerHTML.replace(/<script type="application\/json" id="vh-store">[\s\S]*?<\/script>/, '');
58
+ versions.push({ id: versions.length + 1, title: 'Snapshot before restoring "' + v.title + '"', time: now, details: 'Automatic safety snapshot taken before restore.', html: html });
59
+ versions.push({ id: versions.length + 1, title: 'Restored "' + v.title + '"', time: now, details: 'Restored the state saved as version ' + v.id + '.', html: v.html });
60
+ var store = '<script type="application/json" id="vh-store">' + JSON.stringify(versions).replace(/<\/script/g, '<\\/script') + '<\/script>';
61
+ var out = v.html.replace(/<\/body>/i, store + '</body>');
62
+ document.open(); document.write(out); document.close(); return;
63
+ }
64
+ fetch('/__vh/restore/' + v.id, { method: 'POST' }).then(function (r) { if (!r.ok) throw 0; location.reload(); }).catch(function () {
65
+ fetch('/.versions/snapshots/' + v.id + '/files.json', { cache: 'no-store' }).then(function (r) { return r.blob(); }).then(function (b) {
66
+ var a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = 'snapshot-' + v.id + '.json'; a.click();
67
+ }).catch(function () { });
68
+ var n = panel.querySelector('.vh-note'); n.style.display = 'block'; n.textContent = 'No live restore endpoint here. Run locally: node .versions/vh.js restore ' + v.id;
69
+ });
70
+ }
71
+
72
+ function build() {
73
+ pill = document.createElement('button'); pill.className = 'vh-pill'; pill.textContent = 'Versions'; pill.onclick = toggle; document.body.appendChild(pill);
74
+ panel = document.createElement('div'); panel.className = 'vh-panel'; panel.style.display = 'none';
75
+ panel.innerHTML = '<div class="vh-head"><input class="vh-search" placeholder="Search versions\u2026"><button class="vh-clear" title="Clear">\u00D7</button></div><div class="vh-list"></div><div class="vh-note"></div>';
76
+ var inp = panel.querySelector('.vh-search'); inp.oninput = function () { query = inp.value; render(); };
77
+ panel.querySelector('.vh-clear').onclick = function () { inp.value = ''; query = ''; render(); inp.focus(); };
78
+ document.addEventListener('keydown', function (e) { if (e.key !== 'Escape' || !open) return; if (query) { inp.value = ''; query = ''; render(); } else toggle(); });
79
+ document.body.appendChild(panel);
80
+ }
81
+ function toggle() { open = !open; if (open) load(function () { panel.style.display = 'flex'; render(); panel.querySelector('.vh-search').focus(); }); else panel.style.display = 'none'; }
82
+ function init() { build(); load(render); }
83
+ if (document.body) init(); else document.addEventListener('DOMContentLoaded', init);
84
+ })();