@zenera/view 0.0.0-stage → 1.1.26
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 +21 -0
- package/README.md +61 -2
- package/dist/client/app.css +1 -0
- package/dist/client/app.js +10 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/paths.d.ts +9 -0
- package/dist/paths.js +55 -0
- package/dist/render.d.ts +15 -0
- package/dist/render.js +372 -0
- package/dist/server.d.ts +30 -0
- package/dist/server.js +406 -0
- package/dist/types.d.ts +26 -0
- package/dist/types.js +2 -0
- package/dist/watch.d.ts +13 -0
- package/dist/watch.js +110 -0
- package/package.json +61 -5
package/dist/server.js
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { closeSync, createReadStream, existsSync, openSync, readSync, realpathSync, statSync, } from 'node:fs';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { dirname, extname, join, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { locate } from "./paths.js";
|
|
7
|
+
import { drawn, MAX_BYTES, renderPath } from "./render.js";
|
|
8
|
+
import { stampOf, Watcher } from "./watch.js";
|
|
9
|
+
const HOST = '127.0.0.1';
|
|
10
|
+
const PORT_BASE = 20000;
|
|
11
|
+
const PORT_SPAN = 20000;
|
|
12
|
+
const BEAT_MS = 25_000;
|
|
13
|
+
const THROTTLE_MS = 1000;
|
|
14
|
+
const CACHED = 64;
|
|
15
|
+
/** The same root asks for the same port, so an open tab reconnects after a restart. */
|
|
16
|
+
export function stablePort(root) {
|
|
17
|
+
let h = 0x811c9dc5;
|
|
18
|
+
for (const ch of realpathSync(resolve(root))) {
|
|
19
|
+
h = Math.imul(h ^ ch.charCodeAt(0), 0x01000193) >>> 0;
|
|
20
|
+
}
|
|
21
|
+
return PORT_BASE + (h % PORT_SPAN);
|
|
22
|
+
}
|
|
23
|
+
/** The platform opener, without a shell, so the URL stays one argument. */
|
|
24
|
+
export function openBrowser(url) {
|
|
25
|
+
const command = process.platform === 'darwin'
|
|
26
|
+
? 'open'
|
|
27
|
+
: process.platform === 'win32'
|
|
28
|
+
? 'explorer'
|
|
29
|
+
: 'xdg-open';
|
|
30
|
+
spawn(command, [url], { stdio: 'ignore', detached: true })
|
|
31
|
+
.on('error', () => { })
|
|
32
|
+
.unref();
|
|
33
|
+
}
|
|
34
|
+
export async function serveView(options) {
|
|
35
|
+
const root = realpathSync(resolve(options.root));
|
|
36
|
+
if (!statSync(root).isDirectory()) {
|
|
37
|
+
throw new Error(`not a directory: ${root}`);
|
|
38
|
+
}
|
|
39
|
+
const hidden = options.hidden ?? false;
|
|
40
|
+
const render = { maxBytes: options.maxBytes ?? MAX_BYTES, hidden };
|
|
41
|
+
const maxClients = options.maxClients ?? 64;
|
|
42
|
+
const throttleMs = options.throttleMs ?? THROTTLE_MS;
|
|
43
|
+
const watcher = new Watcher({ pollMs: options.pollMs });
|
|
44
|
+
const clients = new Set();
|
|
45
|
+
const feeds = new Map();
|
|
46
|
+
const cache = new Map();
|
|
47
|
+
let hosts = new Set();
|
|
48
|
+
const server = createServer((req, res) => {
|
|
49
|
+
try {
|
|
50
|
+
handle(req, res);
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
if (!res.headersSent) {
|
|
54
|
+
plain(res, 500, err instanceof Error ? err.message : String(err));
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
res.end();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
function handle(req, res) {
|
|
62
|
+
if (!hosts.has(req.headers.host ?? '')) {
|
|
63
|
+
return plain(res, 403, 'unknown host');
|
|
64
|
+
}
|
|
65
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
66
|
+
return plain(res, 405, 'read only');
|
|
67
|
+
}
|
|
68
|
+
const url = new URL(req.url ?? '/', 'http://view');
|
|
69
|
+
if (url.pathname.startsWith('/_view/')) {
|
|
70
|
+
return internal(req, res, url);
|
|
71
|
+
}
|
|
72
|
+
const at = locate(root, url.pathname, hidden);
|
|
73
|
+
if (!at) {
|
|
74
|
+
return plain(res, 404, 'not found');
|
|
75
|
+
}
|
|
76
|
+
const file = isFile(at.abs);
|
|
77
|
+
if (url.searchParams.has('raw') || (file && !drawn(at.abs))) {
|
|
78
|
+
return file ? sendFile(res, at.abs, rawHeaders(at.abs)) : plain(res, 404, 'not found');
|
|
79
|
+
}
|
|
80
|
+
return page(res);
|
|
81
|
+
}
|
|
82
|
+
function internal(req, res, url) {
|
|
83
|
+
const name = url.pathname.slice('/_view/'.length);
|
|
84
|
+
if (name === 'events') {
|
|
85
|
+
return events(req, res, url);
|
|
86
|
+
}
|
|
87
|
+
if (name === 'render') {
|
|
88
|
+
const at = locate(root, url.searchParams.get('path') ?? '/', hidden);
|
|
89
|
+
return at
|
|
90
|
+
? send(res, 200, 'application/json', rendered(at.abs, at.path))
|
|
91
|
+
: plain(res, 404, 'not found');
|
|
92
|
+
}
|
|
93
|
+
if (name === 'health') {
|
|
94
|
+
return send(res, 200, 'application/json', JSON.stringify({ ok: true, root }));
|
|
95
|
+
}
|
|
96
|
+
if (name === 'app.js' || name === 'app.css') {
|
|
97
|
+
const file = join(clientDir(), name);
|
|
98
|
+
return existsSync(file)
|
|
99
|
+
? sendFile(res, file, {
|
|
100
|
+
'content-type': TYPES[extname(name)],
|
|
101
|
+
'cache-control': 'no-cache',
|
|
102
|
+
})
|
|
103
|
+
: plain(res, 404, `${name} is not built: npm run build`);
|
|
104
|
+
}
|
|
105
|
+
if (name.startsWith('mermaid/')) {
|
|
106
|
+
const dir = mermaidDir();
|
|
107
|
+
const at = dir && locate(dir, name.slice('mermaid/'.length));
|
|
108
|
+
return at && isFile(at.abs)
|
|
109
|
+
? sendFile(res, at.abs, {
|
|
110
|
+
'content-type': TYPES[extname(at.abs)] ?? 'text/javascript',
|
|
111
|
+
'cache-control': 'max-age=3600',
|
|
112
|
+
})
|
|
113
|
+
: plain(res, 404, 'not found');
|
|
114
|
+
}
|
|
115
|
+
return plain(res, 404, 'not found');
|
|
116
|
+
}
|
|
117
|
+
function view(abs, path) {
|
|
118
|
+
try {
|
|
119
|
+
return renderPath(abs, path, render);
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
123
|
+
return {
|
|
124
|
+
path,
|
|
125
|
+
kind: 'text',
|
|
126
|
+
title: path,
|
|
127
|
+
toc: [],
|
|
128
|
+
hast: {
|
|
129
|
+
type: 'root',
|
|
130
|
+
children: [
|
|
131
|
+
{
|
|
132
|
+
type: 'element',
|
|
133
|
+
tagName: 'pre',
|
|
134
|
+
properties: {},
|
|
135
|
+
children: [{ type: 'text', value: `cannot read it: ${message}` }],
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** The view as JSON, rendered again only when the file's stamp moved. */
|
|
143
|
+
function rendered(abs, path) {
|
|
144
|
+
const stamp = stampOf(abs);
|
|
145
|
+
const hit = cache.get(path);
|
|
146
|
+
if (hit?.stamp === stamp) {
|
|
147
|
+
return hit.json;
|
|
148
|
+
}
|
|
149
|
+
const json = JSON.stringify(view(abs, path));
|
|
150
|
+
cache.delete(path);
|
|
151
|
+
cache.set(path, { stamp, json });
|
|
152
|
+
if (cache.size > CACHED) {
|
|
153
|
+
cache.delete(cache.keys().next().value);
|
|
154
|
+
}
|
|
155
|
+
return json;
|
|
156
|
+
}
|
|
157
|
+
function refresh(feed) {
|
|
158
|
+
clearTimeout(feed.timer);
|
|
159
|
+
feed.timer = undefined;
|
|
160
|
+
feed.at = Date.now();
|
|
161
|
+
const json = rendered(feed.abs, feed.path);
|
|
162
|
+
if (json === feed.json) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
feed.json = json;
|
|
166
|
+
for (const res of feed.clients) {
|
|
167
|
+
res.write(`event: view\ndata: ${json}\n\n`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/** Leading and trailing: a change after a quiet spell goes out at once, a burst once per window. */
|
|
171
|
+
function changed(feed) {
|
|
172
|
+
if (feed.timer) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const wait = feed.at + throttleMs - Date.now();
|
|
176
|
+
if (wait <= 0) {
|
|
177
|
+
return refresh(feed);
|
|
178
|
+
}
|
|
179
|
+
feed.timer = setTimeout(() => refresh(feed), wait);
|
|
180
|
+
feed.timer.unref();
|
|
181
|
+
}
|
|
182
|
+
function feedOf(abs, path) {
|
|
183
|
+
let feed = feeds.get(path);
|
|
184
|
+
if (!feed) {
|
|
185
|
+
const f = { abs, path, clients: new Set(), json: '', at: 0, off: () => { } };
|
|
186
|
+
// Subscribed before the first render, so a write in between is not missed.
|
|
187
|
+
f.off = watcher.subscribe(abs, () => changed(f));
|
|
188
|
+
refresh(f);
|
|
189
|
+
feeds.set(path, f);
|
|
190
|
+
feed = f;
|
|
191
|
+
}
|
|
192
|
+
return feed;
|
|
193
|
+
}
|
|
194
|
+
function events(req, res, url) {
|
|
195
|
+
const at = locate(root, url.searchParams.get('path') ?? '/', hidden);
|
|
196
|
+
if (!at) {
|
|
197
|
+
return plain(res, 404, 'not found');
|
|
198
|
+
}
|
|
199
|
+
if (clients.size >= maxClients) {
|
|
200
|
+
return plain(res, 503, 'too many pages open');
|
|
201
|
+
}
|
|
202
|
+
res.writeHead(200, {
|
|
203
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
204
|
+
'cache-control': 'no-cache',
|
|
205
|
+
'x-content-type-options': 'nosniff',
|
|
206
|
+
});
|
|
207
|
+
res.write('retry: 1000\n\n');
|
|
208
|
+
const feed = feedOf(at.abs, at.path);
|
|
209
|
+
res.write(`event: view\ndata: ${feed.json}\n\n`);
|
|
210
|
+
feed.clients.add(res);
|
|
211
|
+
const beat = setInterval(() => res.write(': beat\n\n'), BEAT_MS);
|
|
212
|
+
beat.unref();
|
|
213
|
+
clients.add(res);
|
|
214
|
+
req.on('close', () => {
|
|
215
|
+
clearInterval(beat);
|
|
216
|
+
clients.delete(res);
|
|
217
|
+
feed.clients.delete(res);
|
|
218
|
+
if (feed.clients.size === 0 && feeds.get(feed.path) === feed) {
|
|
219
|
+
feed.off();
|
|
220
|
+
clearTimeout(feed.timer);
|
|
221
|
+
feeds.delete(feed.path);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
const preferred = options.port ?? stablePort(root);
|
|
226
|
+
let port;
|
|
227
|
+
try {
|
|
228
|
+
port = await listen(server, preferred);
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
if (options.port !== undefined || err.code !== 'EADDRINUSE') {
|
|
232
|
+
throw err;
|
|
233
|
+
}
|
|
234
|
+
port = await listen(server, 0);
|
|
235
|
+
}
|
|
236
|
+
hosts = new Set([`${HOST}:${port}`, `localhost:${port}`]);
|
|
237
|
+
const origin = `http://${HOST}:${port}`;
|
|
238
|
+
return {
|
|
239
|
+
url: `${origin}${encodeURI(options.start ?? '/')}`,
|
|
240
|
+
origin,
|
|
241
|
+
port,
|
|
242
|
+
root,
|
|
243
|
+
close: async () => {
|
|
244
|
+
for (const feed of feeds.values()) {
|
|
245
|
+
clearTimeout(feed.timer);
|
|
246
|
+
}
|
|
247
|
+
feeds.clear();
|
|
248
|
+
cache.clear();
|
|
249
|
+
watcher.close();
|
|
250
|
+
for (const res of clients) {
|
|
251
|
+
res.end();
|
|
252
|
+
}
|
|
253
|
+
clients.clear();
|
|
254
|
+
server.closeAllConnections();
|
|
255
|
+
await new Promise((done) => server.close(() => done()));
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
function listen(server, port) {
|
|
260
|
+
return new Promise((done, fail) => {
|
|
261
|
+
server.once('error', fail);
|
|
262
|
+
server.listen(port, HOST, () => {
|
|
263
|
+
server.off('error', fail);
|
|
264
|
+
done(server.address().port);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// Responses
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
const TYPES = {
|
|
272
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
273
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
274
|
+
'.css': 'text/css; charset=utf-8',
|
|
275
|
+
'.html': 'text/html; charset=utf-8',
|
|
276
|
+
'.htm': 'text/html; charset=utf-8',
|
|
277
|
+
'.svg': 'image/svg+xml',
|
|
278
|
+
'.png': 'image/png',
|
|
279
|
+
'.jpg': 'image/jpeg',
|
|
280
|
+
'.jpeg': 'image/jpeg',
|
|
281
|
+
'.gif': 'image/gif',
|
|
282
|
+
'.webp': 'image/webp',
|
|
283
|
+
'.avif': 'image/avif',
|
|
284
|
+
'.bmp': 'image/bmp',
|
|
285
|
+
'.ico': 'image/x-icon',
|
|
286
|
+
'.pdf': 'application/pdf',
|
|
287
|
+
'.json': 'application/json; charset=utf-8',
|
|
288
|
+
};
|
|
289
|
+
const PAGE_CSP = [
|
|
290
|
+
"default-src 'none'",
|
|
291
|
+
"script-src 'self'",
|
|
292
|
+
// Mermaid writes a <style> into every diagram it draws.
|
|
293
|
+
"style-src 'self' 'unsafe-inline'",
|
|
294
|
+
"img-src 'self' data:",
|
|
295
|
+
"font-src 'self' data:",
|
|
296
|
+
"connect-src 'self'",
|
|
297
|
+
"base-uri 'none'",
|
|
298
|
+
"form-action 'none'",
|
|
299
|
+
"frame-ancestors 'none'",
|
|
300
|
+
].join('; ');
|
|
301
|
+
/** A served file is untrusted: it runs, if at all, in an origin of its own. */
|
|
302
|
+
function rawHeaders(abs) {
|
|
303
|
+
const ext = extname(abs).toLowerCase();
|
|
304
|
+
const type = TYPES[ext] ?? (looksBinary(abs) ? 'application/octet-stream' : 'text/plain; charset=utf-8');
|
|
305
|
+
const html = ext === '.html' || ext === '.htm';
|
|
306
|
+
return {
|
|
307
|
+
'content-type': type,
|
|
308
|
+
'content-security-policy': html ? 'sandbox allow-scripts allow-popups' : 'sandbox',
|
|
309
|
+
'cache-control': 'no-cache',
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
function page(res) {
|
|
313
|
+
send(res, 200, 'text/html; charset=utf-8', `<!doctype html>
|
|
314
|
+
<html lang="en">
|
|
315
|
+
<head>
|
|
316
|
+
<meta charset="utf-8">
|
|
317
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
318
|
+
<title>view</title>
|
|
319
|
+
<link rel="stylesheet" href="/_view/app.css">
|
|
320
|
+
</head>
|
|
321
|
+
<body>
|
|
322
|
+
<div id="root"></div>
|
|
323
|
+
<script type="module" src="/_view/app.js"></script>
|
|
324
|
+
</body>
|
|
325
|
+
</html>
|
|
326
|
+
`, { 'content-security-policy': PAGE_CSP, 'cache-control': 'no-cache' });
|
|
327
|
+
}
|
|
328
|
+
function send(res, status, type, body, headers = {}) {
|
|
329
|
+
res.writeHead(status, {
|
|
330
|
+
'content-type': type,
|
|
331
|
+
'content-length': Buffer.byteLength(body),
|
|
332
|
+
'x-content-type-options': 'nosniff',
|
|
333
|
+
'referrer-policy': 'no-referrer',
|
|
334
|
+
...headers,
|
|
335
|
+
});
|
|
336
|
+
res.end(res.req.method === 'HEAD' ? undefined : body);
|
|
337
|
+
}
|
|
338
|
+
function plain(res, status, text) {
|
|
339
|
+
send(res, status, 'text/plain; charset=utf-8', `${text}\n`);
|
|
340
|
+
}
|
|
341
|
+
function sendFile(res, abs, headers) {
|
|
342
|
+
const size = statSync(abs).size;
|
|
343
|
+
res.writeHead(200, {
|
|
344
|
+
'content-length': size,
|
|
345
|
+
'x-content-type-options': 'nosniff',
|
|
346
|
+
'referrer-policy': 'no-referrer',
|
|
347
|
+
...headers,
|
|
348
|
+
});
|
|
349
|
+
if (res.req.method === 'HEAD') {
|
|
350
|
+
res.end();
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
createReadStream(abs)
|
|
354
|
+
.on('error', () => res.destroy())
|
|
355
|
+
.pipe(res);
|
|
356
|
+
}
|
|
357
|
+
function isFile(abs) {
|
|
358
|
+
try {
|
|
359
|
+
return statSync(abs).isFile();
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
function looksBinary(abs) {
|
|
366
|
+
let fd;
|
|
367
|
+
try {
|
|
368
|
+
fd = openSync(abs, 'r');
|
|
369
|
+
const head = Buffer.alloc(8192);
|
|
370
|
+
return head.subarray(0, readSync(fd, head, 0, head.length, 0)).includes(0);
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
375
|
+
finally {
|
|
376
|
+
if (fd !== undefined) {
|
|
377
|
+
closeSync(fd);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
// ---------------------------------------------------------------------------
|
|
382
|
+
// Where the browser half and Mermaid come from
|
|
383
|
+
// ---------------------------------------------------------------------------
|
|
384
|
+
function clientDir() {
|
|
385
|
+
// dist/client beside the compiled server; ../dist/client when run from source.
|
|
386
|
+
for (const rel of ['./client/', '../dist/client/']) {
|
|
387
|
+
const dir = fileURLToPath(new URL(rel, import.meta.url));
|
|
388
|
+
if (existsSync(join(dir, 'app.js'))) {
|
|
389
|
+
return dir;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return fileURLToPath(new URL('./client/', import.meta.url));
|
|
393
|
+
}
|
|
394
|
+
let mermaidAt;
|
|
395
|
+
function mermaidDir() {
|
|
396
|
+
if (mermaidAt === undefined) {
|
|
397
|
+
try {
|
|
398
|
+
mermaidAt = realpathSync(dirname(fileURLToPath(import.meta.resolve('mermaid'))));
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
mermaidAt = null;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return mermaidAt ?? undefined;
|
|
405
|
+
}
|
|
406
|
+
//# sourceMappingURL=server.js.map
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Root } from 'hast';
|
|
2
|
+
export type ViewKind = 'markdown' | 'code' | 'mermaid' | 'text' | 'directory' | 'binary' | 'missing';
|
|
3
|
+
export interface Heading {
|
|
4
|
+
depth: number;
|
|
5
|
+
id: string;
|
|
6
|
+
text: string;
|
|
7
|
+
}
|
|
8
|
+
/** One file or directory, rendered: what the page draws, and nothing it must work out. */
|
|
9
|
+
export interface View {
|
|
10
|
+
/** the URL path, `/` separated, always starting with `/` */
|
|
11
|
+
path: string;
|
|
12
|
+
kind: ViewKind;
|
|
13
|
+
title: string;
|
|
14
|
+
/** sanitized: every element and attribute in it is safe to hand to React */
|
|
15
|
+
hast: Root;
|
|
16
|
+
toc: Heading[];
|
|
17
|
+
language?: string;
|
|
18
|
+
bytes?: number;
|
|
19
|
+
mtimeMs?: number;
|
|
20
|
+
/** only the first `shown` bytes of `bytes` were rendered */
|
|
21
|
+
truncated?: {
|
|
22
|
+
shown: number;
|
|
23
|
+
bytes: number;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=types.d.ts.map
|
package/dist/types.js
ADDED
package/dist/watch.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface WatchOptions {
|
|
2
|
+
debounceMs?: number;
|
|
3
|
+
pollMs?: number;
|
|
4
|
+
}
|
|
5
|
+
export declare function stampOf(abs: string): string;
|
|
6
|
+
export declare class Watcher {
|
|
7
|
+
#private;
|
|
8
|
+
constructor(options?: WatchOptions);
|
|
9
|
+
/** Calls `notify` whenever `abs` changes, until the returned function is called. */
|
|
10
|
+
subscribe(abs: string, notify: () => void): () => void;
|
|
11
|
+
close(): void;
|
|
12
|
+
}
|
|
13
|
+
//# sourceMappingURL=watch.d.ts.map
|
package/dist/watch.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { readdirSync, statSync, watch } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
export function stampOf(abs) {
|
|
4
|
+
try {
|
|
5
|
+
const s = statSync(abs);
|
|
6
|
+
if (!s.isDirectory()) {
|
|
7
|
+
return `${s.ino}:${s.size}:${s.mtimeMs}`;
|
|
8
|
+
}
|
|
9
|
+
return readdirSync(abs)
|
|
10
|
+
.sort()
|
|
11
|
+
.map((name) => {
|
|
12
|
+
try {
|
|
13
|
+
const e = statSync(join(abs, name));
|
|
14
|
+
return `${name}:${e.size}:${e.mtimeMs}`;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return name;
|
|
18
|
+
}
|
|
19
|
+
})
|
|
20
|
+
.join('/');
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return 'missing';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export class Watcher {
|
|
27
|
+
#dirs = new Map();
|
|
28
|
+
#due = new Set();
|
|
29
|
+
#debounceMs;
|
|
30
|
+
#timer;
|
|
31
|
+
#poll;
|
|
32
|
+
constructor(options = {}) {
|
|
33
|
+
this.#debounceMs = options.debounceMs ?? 75;
|
|
34
|
+
this.#poll = setInterval(() => this.#check(this.#all()), options.pollMs ?? 2000);
|
|
35
|
+
this.#poll.unref();
|
|
36
|
+
}
|
|
37
|
+
/** Calls `notify` whenever `abs` changes, until the returned function is called. */
|
|
38
|
+
subscribe(abs, notify) {
|
|
39
|
+
const dir = isDir(abs) ? abs : dirname(abs);
|
|
40
|
+
const sub = { abs, dir, stamp: stampOf(abs), notify };
|
|
41
|
+
let d = this.#dirs.get(dir);
|
|
42
|
+
if (!d) {
|
|
43
|
+
d = { subs: new Set(), ...this.#open(dir) };
|
|
44
|
+
this.#dirs.set(dir, d);
|
|
45
|
+
}
|
|
46
|
+
d.subs.add(sub);
|
|
47
|
+
return () => {
|
|
48
|
+
const at = this.#dirs.get(dir);
|
|
49
|
+
at?.subs.delete(sub);
|
|
50
|
+
this.#due.delete(sub);
|
|
51
|
+
if (at && at.subs.size === 0) {
|
|
52
|
+
at.watcher?.close();
|
|
53
|
+
this.#dirs.delete(dir);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
close() {
|
|
58
|
+
clearInterval(this.#poll);
|
|
59
|
+
clearTimeout(this.#timer);
|
|
60
|
+
for (const d of this.#dirs.values()) {
|
|
61
|
+
d.watcher?.close();
|
|
62
|
+
}
|
|
63
|
+
this.#dirs.clear();
|
|
64
|
+
this.#due.clear();
|
|
65
|
+
}
|
|
66
|
+
#open(dir) {
|
|
67
|
+
try {
|
|
68
|
+
const watcher = watch(dir, { persistent: false }, () => this.#schedule(dir));
|
|
69
|
+
// A directory removed under a watch errors; the poll carries on without it.
|
|
70
|
+
watcher.on('error', () => watcher.close());
|
|
71
|
+
return { watcher };
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return {};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
#schedule(dir) {
|
|
78
|
+
for (const sub of this.#dirs.get(dir)?.subs ?? []) {
|
|
79
|
+
this.#due.add(sub);
|
|
80
|
+
}
|
|
81
|
+
clearTimeout(this.#timer);
|
|
82
|
+
this.#timer = setTimeout(() => {
|
|
83
|
+
const due = [...this.#due];
|
|
84
|
+
this.#due.clear();
|
|
85
|
+
this.#check(due);
|
|
86
|
+
}, this.#debounceMs);
|
|
87
|
+
this.#timer.unref();
|
|
88
|
+
}
|
|
89
|
+
#all() {
|
|
90
|
+
return [...this.#dirs.values()].flatMap((d) => [...d.subs]);
|
|
91
|
+
}
|
|
92
|
+
#check(subs) {
|
|
93
|
+
for (const sub of subs) {
|
|
94
|
+
const stamp = stampOf(sub.abs);
|
|
95
|
+
if (stamp !== sub.stamp) {
|
|
96
|
+
sub.stamp = stamp;
|
|
97
|
+
sub.notify();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function isDir(abs) {
|
|
103
|
+
try {
|
|
104
|
+
return statSync(abs).isDirectory();
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
//# sourceMappingURL=watch.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@zenera/view",
|
|
3
|
+
"version": "1.1.26",
|
|
4
|
+
"description": "A live local viewer for a folder: markdown with mermaid, highlighted code, updated in place as the files change.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"markdown",
|
|
7
|
+
"mermaid",
|
|
8
|
+
"viewer",
|
|
9
|
+
"live-reload",
|
|
10
|
+
"syntax-highlighting"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"author": "Andrey Ryabov",
|
|
17
|
+
"homepage": "https://github.com/andreyryabov/ZeneraNeo#readme",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/andreyryabov/ZeneraNeo.git",
|
|
21
|
+
"directory": "packages/view"
|
|
22
|
+
},
|
|
23
|
+
"bugs": "https://github.com/andreyryabov/ZeneraNeo/issues",
|
|
24
|
+
"type": "module",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"default": "./dist/index.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"!dist/**/*.map"
|
|
34
|
+
],
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=24"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsc -b && node scripts/bundle.mjs",
|
|
40
|
+
"bundle": "node scripts/bundle.mjs",
|
|
41
|
+
"clean": "tsc -b --clean && rm -rf dist",
|
|
42
|
+
"prepack": "tsc -b && node scripts/bundle.mjs"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/react-dom": "^19.3.0",
|
|
46
|
+
"esbuild": "^0.28.2",
|
|
47
|
+
"hast-util-to-jsx-runtime": "^2.3.6",
|
|
48
|
+
"react": "^19.3.1",
|
|
49
|
+
"react-dom": "^19.3.1"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"@types/hast": "^3.0.5",
|
|
53
|
+
"lowlight": "^3.3.0",
|
|
54
|
+
"mermaid": "^12.2.0",
|
|
55
|
+
"rehype-sanitize": "^6.0.0",
|
|
56
|
+
"rehype-slug": "^6.0.0",
|
|
57
|
+
"remark-gfm": "^4.0.1",
|
|
58
|
+
"remark-parse": "^11.0.0",
|
|
59
|
+
"remark-rehype": "^11.1.2",
|
|
60
|
+
"unified": "^11.0.5"
|
|
61
|
+
}
|
|
62
|
+
}
|