@zenera/view 0.0.0-stage → 1.1.27

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.
@@ -0,0 +1,6 @@
1
+ export { inside, isHidden, locate, type Located } from './paths.ts';
2
+ export { drawn, languageOf, markdown, MAX_BYTES, renderPath, type RenderOptions, } from './render.ts';
3
+ export { openBrowser, serveView, stablePort, type ViewOptions, type ViewServer } from './server.ts';
4
+ export type { Heading, View, ViewKind } from './types.ts';
5
+ export { stampOf, Watcher, type WatchOptions } from './watch.ts';
6
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { inside, isHidden, locate } from "./paths.js";
2
+ export { drawn, languageOf, markdown, MAX_BYTES, renderPath, } from "./render.js";
3
+ export { openBrowser, serveView, stablePort } from "./server.js";
4
+ export { stampOf, Watcher } from "./watch.js";
5
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,9 @@
1
+ export interface Located {
2
+ abs: string;
3
+ /** the canonical URL path, `/` for the root */
4
+ path: string;
5
+ }
6
+ export declare function isHidden(name: string, hidden: boolean): boolean;
7
+ export declare function locate(root: string, urlPath: string, hidden?: boolean): Located | undefined;
8
+ export declare function inside(root: string, abs: string): boolean;
9
+ //# sourceMappingURL=paths.d.ts.map
package/dist/paths.js ADDED
@@ -0,0 +1,55 @@
1
+ import { existsSync, realpathSync } from 'node:fs';
2
+ import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path';
3
+ // ---------------------------------------------------------------------------
4
+ // From a URL path to a file under the root, or nothing
5
+ //
6
+ // The root is the whole of what the server will read. A path is decoded once,
7
+ // split, and refused on `..`, a NUL or a backslash before it is joined; then the
8
+ // result is realpath'd - the nearest existing ancestor, for a file not written
9
+ // yet - so a symlink inside the root cannot lead out of it.
10
+ // ---------------------------------------------------------------------------
11
+ /** Secrets and repository internals: never served, even with hidden files on. */
12
+ const NEVER = /^\.(env|git|npmrc|netrc)(\.|$)/i;
13
+ export function isHidden(name, hidden) {
14
+ return name.startsWith('.') && (!hidden || NEVER.test(name));
15
+ }
16
+ export function locate(root, urlPath, hidden = false) {
17
+ let decoded;
18
+ try {
19
+ decoded = decodeURIComponent(urlPath);
20
+ }
21
+ catch {
22
+ return undefined;
23
+ }
24
+ if (decoded.includes('\0') || decoded.includes('\\')) {
25
+ return undefined;
26
+ }
27
+ const parts = decoded.split('/').filter((p) => p !== '' && p !== '.');
28
+ if (parts.some((p) => p === '..' || isHidden(p, hidden))) {
29
+ return undefined;
30
+ }
31
+ const abs = join(root, ...parts);
32
+ if (!inside(root, real(abs))) {
33
+ return undefined;
34
+ }
35
+ return { abs, path: `/${parts.join('/')}` };
36
+ }
37
+ /** The real path, resolving the nearest ancestor that exists. */
38
+ function real(abs) {
39
+ let at = abs;
40
+ const rest = [];
41
+ while (!existsSync(at)) {
42
+ const up = dirname(at);
43
+ if (up === at) {
44
+ return abs;
45
+ }
46
+ rest.unshift(basename(at));
47
+ at = up;
48
+ }
49
+ return join(realpathSync(at), ...rest);
50
+ }
51
+ export function inside(root, abs) {
52
+ const rel = relative(root, abs);
53
+ return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
54
+ }
55
+ //# sourceMappingURL=paths.js.map
@@ -0,0 +1,15 @@
1
+ import type { Root } from 'hast';
2
+ import type { View } from './types.ts';
3
+ export declare const MAX_BYTES: number;
4
+ /** Documents the browser shows better itself; the server hands them over raw. */
5
+ export declare const NATIVE: Set<string>;
6
+ export interface RenderOptions {
7
+ maxBytes?: number;
8
+ hidden?: boolean;
9
+ }
10
+ export declare function languageOf(file: string): string | undefined;
11
+ /** Whether `path` is something the page draws, rather than a file handed over as it is. */
12
+ export declare function drawn(file: string): boolean;
13
+ export declare function renderPath(abs: string, path: string, options?: RenderOptions): View;
14
+ export declare function markdown(text: string): Root;
15
+ //# sourceMappingURL=render.d.ts.map
package/dist/render.js ADDED
@@ -0,0 +1,372 @@
1
+ import { common, createLowlight } from 'lowlight';
2
+ import { closeSync, openSync, readdirSync, readSync, statSync } from 'node:fs';
3
+ import { basename, extname, join } from 'node:path';
4
+ import rehypeSanitize from 'rehype-sanitize';
5
+ import rehypeSlug from 'rehype-slug';
6
+ import remarkGfm from 'remark-gfm';
7
+ import remarkParse from 'remark-parse';
8
+ import remarkRehype from 'remark-rehype';
9
+ import { unified } from 'unified';
10
+ import { isHidden } from "./paths.js";
11
+ // ---------------------------------------------------------------------------
12
+ // From a file to a tree the page can draw
13
+ //
14
+ // Everything becomes hast: markdown through remark and a sanitizer, code
15
+ // through lowlight, a directory as a table. The browser never parses anything
16
+ // and never sees raw HTML - it turns elements into React elements, so a file
17
+ // that changed by one number repaints one text node.
18
+ // ---------------------------------------------------------------------------
19
+ export const MAX_BYTES = 2 * 1024 * 1024;
20
+ const MAX_ENTRIES = 5000;
21
+ const SNIFF_BYTES = 8192;
22
+ const lowlight = createLowlight(common);
23
+ const LANGUAGE = {
24
+ '.json': 'json',
25
+ '.jsonl': 'json',
26
+ '.ndjson': 'json',
27
+ '.py': 'python',
28
+ '.ts': 'typescript',
29
+ '.tsx': 'typescript',
30
+ '.mts': 'typescript',
31
+ '.cts': 'typescript',
32
+ '.js': 'javascript',
33
+ '.jsx': 'javascript',
34
+ '.mjs': 'javascript',
35
+ '.cjs': 'javascript',
36
+ '.yaml': 'yaml',
37
+ '.yml': 'yaml',
38
+ '.sh': 'bash',
39
+ '.bash': 'bash',
40
+ '.zsh': 'bash',
41
+ '.patch': 'diff',
42
+ '.diff': 'diff',
43
+ '.toml': 'ini',
44
+ '.ini': 'ini',
45
+ '.css': 'css',
46
+ '.scss': 'scss',
47
+ '.xml': 'xml',
48
+ '.sql': 'sql',
49
+ '.go': 'go',
50
+ '.rs': 'rust',
51
+ '.java': 'java',
52
+ '.kt': 'kotlin',
53
+ '.c': 'c',
54
+ '.h': 'c',
55
+ '.cpp': 'cpp',
56
+ '.cs': 'csharp',
57
+ '.rb': 'ruby',
58
+ '.php': 'php',
59
+ '.lua': 'lua',
60
+ '.swift': 'swift',
61
+ '.graphql': 'graphql',
62
+ };
63
+ const NAMED = { makefile: 'makefile', dockerfile: 'bash' };
64
+ const MARKDOWN = new Set(['.md', '.markdown']);
65
+ const MERMAID = new Set(['.mmd', '.mermaid']);
66
+ const IMAGE = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif', '.bmp', '.ico']);
67
+ /** Documents the browser shows better itself; the server hands them over raw. */
68
+ export const NATIVE = new Set([...IMAGE, '.html', '.htm', '.svg', '.pdf']);
69
+ export function languageOf(file) {
70
+ return LANGUAGE[extname(file).toLowerCase()] ?? NAMED[basename(file).toLowerCase()];
71
+ }
72
+ /** Whether `path` is something the page draws, rather than a file handed over as it is. */
73
+ export function drawn(file) {
74
+ return !NATIVE.has(extname(file).toLowerCase());
75
+ }
76
+ export function renderPath(abs, path, options = {}) {
77
+ const name = path === '/' ? '/' : basename(path);
78
+ let stat;
79
+ try {
80
+ stat = statSync(abs);
81
+ }
82
+ catch {
83
+ return {
84
+ path,
85
+ kind: 'missing',
86
+ title: name,
87
+ toc: [],
88
+ hast: root([
89
+ el('p', { className: ['note'] }, [
90
+ txt('Not there yet. This page fills in when it is written.'),
91
+ ]),
92
+ ]),
93
+ };
94
+ }
95
+ if (stat.isDirectory()) {
96
+ return {
97
+ path,
98
+ kind: 'directory',
99
+ title: name,
100
+ toc: [],
101
+ hast: listing(abs, path, options),
102
+ mtimeMs: stat.mtimeMs,
103
+ };
104
+ }
105
+ const max = options.maxBytes ?? MAX_BYTES;
106
+ const { bytes, text } = readHead(abs, max);
107
+ const base = {
108
+ path,
109
+ title: name,
110
+ bytes: stat.size,
111
+ mtimeMs: stat.mtimeMs,
112
+ ...(stat.size > max ? { truncated: { shown: max, bytes: stat.size } } : {}),
113
+ };
114
+ const ext = extname(abs).toLowerCase();
115
+ if (NATIVE.has(ext) || (!MARKDOWN.has(ext) && binary(bytes))) {
116
+ const href = `${encodeURIComponent(basename(abs))}?raw`;
117
+ const shown = IMAGE.has(ext)
118
+ ? el('img', { src: href, alt: name })
119
+ : el('a', { href, target: '_blank' }, [txt(`open ${name}`)]);
120
+ return { ...base, kind: 'binary', toc: [], hast: root([el('p', {}, [shown])]) };
121
+ }
122
+ if (MARKDOWN.has(ext)) {
123
+ const hast = markdown(text);
124
+ const toc = headings(hast);
125
+ return {
126
+ ...base,
127
+ kind: 'markdown',
128
+ title: toc.find((h) => h.depth === 1)?.text ?? name,
129
+ toc,
130
+ hast,
131
+ };
132
+ }
133
+ if (MERMAID.has(ext)) {
134
+ return {
135
+ ...base,
136
+ kind: 'mermaid',
137
+ toc: [],
138
+ hast: root([codeBlock(text, 'mermaid', false)]),
139
+ };
140
+ }
141
+ const language = languageOf(abs);
142
+ return {
143
+ ...base,
144
+ kind: language ? 'code' : 'text',
145
+ ...(language ? { language } : {}),
146
+ toc: [],
147
+ hast: root([codeBlock(text, language ?? 'plaintext', true)]),
148
+ };
149
+ }
150
+ // ---------------------------------------------------------------------------
151
+ // Markdown
152
+ // ---------------------------------------------------------------------------
153
+ /** Raw HTML is dropped, except `<br>`: the one way a markdown table cell holds several lines. */
154
+ const lineBreak = (_state, node) => /^<br\s*\/?>$/i.test(node.value.trim())
155
+ ? { type: 'element', tagName: 'br', properties: {}, children: [] }
156
+ : undefined;
157
+ /** Sanitize before the slugs, so heading ids keep GitHub's form and links to them land. */
158
+ const pipeline = unified()
159
+ .use(remarkParse)
160
+ .use(remarkGfm)
161
+ .use(remarkRehype, { handlers: { html: lineBreak } })
162
+ .use(rehypeSanitize)
163
+ .use(rehypeSlug)
164
+ .use(() => highlightBlocks);
165
+ export function markdown(text) {
166
+ const tree = pipeline.runSync(pipeline.parse(text));
167
+ tidy(tree);
168
+ walk(tree, lineCell);
169
+ return tree;
170
+ }
171
+ /**
172
+ * A table cell of `<br>`-separated lines - a trailing `<br>` alone counts - is a list:
173
+ * one row per line, cut at the column's width by the stylesheet, whole in its tooltip.
174
+ */
175
+ function lineCell(cell) {
176
+ if ((cell.tagName !== 'td' && cell.tagName !== 'th') ||
177
+ !cell.children.some((c) => c.type === 'element' && c.tagName === 'br')) {
178
+ return;
179
+ }
180
+ const lines = [[]];
181
+ for (const c of cell.children) {
182
+ if (c.type === 'element' && c.tagName === 'br') {
183
+ lines.push([]);
184
+ }
185
+ else {
186
+ lines.at(-1).push(c);
187
+ }
188
+ }
189
+ cell.properties.className = [...classes(cell), 'lines'];
190
+ cell.children = lines
191
+ .filter((l) => l.some((c) => c.type !== 'text' || c.value.trim() !== ''))
192
+ .map((children) => {
193
+ const line = {
194
+ type: 'element',
195
+ tagName: 'div',
196
+ properties: { className: ['line'] },
197
+ children,
198
+ };
199
+ line.properties.title = textOf(line).trim();
200
+ return line;
201
+ });
202
+ }
203
+ const TABLE_PARTS = new Set(['table', 'thead', 'tbody', 'tfoot', 'tr']);
204
+ /** Source positions are most of the bytes sent and the page needs none of them. */
205
+ function tidy(node) {
206
+ delete node.position;
207
+ if (node.type === 'element' && TABLE_PARTS.has(node.tagName)) {
208
+ node.children = node.children.filter((c) => c.type !== 'text' || c.value.trim() !== '');
209
+ }
210
+ for (const child of node.children) {
211
+ if (child.type === 'element') {
212
+ tidy(child);
213
+ }
214
+ else {
215
+ delete child.position;
216
+ }
217
+ }
218
+ }
219
+ function highlightBlocks(tree) {
220
+ walk(tree, (node) => {
221
+ if (node.tagName !== 'pre') {
222
+ return;
223
+ }
224
+ const code = node.children.find((c) => c.type === 'element' && c.tagName === 'code');
225
+ const lang = classes(code)
226
+ .find((c) => c.startsWith('language-'))
227
+ ?.slice('language-'.length);
228
+ if (!code || !lang || lang === 'mermaid' || !lowlight.registered(lang)) {
229
+ return;
230
+ }
231
+ code.children = lowlight.highlight(lang, textOf(code)).children;
232
+ code.properties.className = [...classes(code), 'hljs'];
233
+ });
234
+ }
235
+ function headings(tree) {
236
+ const out = [];
237
+ walk(tree, (node) => {
238
+ const depth = /^h([1-3])$/.exec(node.tagName)?.[1];
239
+ const id = node.properties.id;
240
+ if (depth && typeof id === 'string') {
241
+ out.push({ depth: Number(depth), id, text: textOf(node).trim() });
242
+ }
243
+ });
244
+ return out;
245
+ }
246
+ // ---------------------------------------------------------------------------
247
+ // Code, text, directories
248
+ // ---------------------------------------------------------------------------
249
+ function codeBlock(text, language, highlight) {
250
+ const children = highlight && language !== 'plaintext' && lowlight.registered(language)
251
+ ? lowlight.highlight(language, text).children
252
+ : [txt(text)];
253
+ return el('pre', {}, [el('code', { className: ['hljs', `language-${language}`] }, children)]);
254
+ }
255
+ function listing(abs, path, options) {
256
+ const here = path === '/' ? '' : path;
257
+ const rows = [];
258
+ if (path !== '/') {
259
+ rows.push(row(el('a', { href: `${here.slice(0, here.lastIndexOf('/')) || '/'}` }, [txt('..')]), '', ''));
260
+ }
261
+ let names = [];
262
+ try {
263
+ names = readdirSync(abs, { withFileTypes: true })
264
+ .filter((d) => !isHidden(d.name, options.hidden ?? false))
265
+ .slice(0, MAX_ENTRIES)
266
+ .map((d) => {
267
+ try {
268
+ const s = statSync(join(abs, d.name));
269
+ return { name: d.name, dir: s.isDirectory(), size: s.size, mtimeMs: s.mtimeMs };
270
+ }
271
+ catch {
272
+ return { name: d.name, dir: d.isDirectory(), size: 0, mtimeMs: 0 };
273
+ }
274
+ });
275
+ }
276
+ catch {
277
+ // Unreadable: an empty listing says as much.
278
+ }
279
+ names.sort((a, b) => Number(b.dir) - Number(a.dir) || a.name.localeCompare(b.name));
280
+ for (const n of names) {
281
+ const href = `${here}/${encodeURIComponent(n.name)}${n.dir ? '/' : ''}`;
282
+ rows.push(row(el('a', { href }, [txt(n.dir ? `${n.name}/` : n.name)]), n.dir ? '' : size(n.size), stamp(n.mtimeMs)));
283
+ }
284
+ return root([
285
+ el('table', { className: ['listing'] }, [
286
+ el('thead', {}, [
287
+ el('tr', {}, ['name', 'size', 'modified'].map((h) => el('th', {}, [txt(h)]))),
288
+ ]),
289
+ el('tbody', {}, rows),
290
+ ]),
291
+ ]);
292
+ }
293
+ const row = (name, bytes, at) => el('tr', {}, [
294
+ el('td', {}, [name]),
295
+ el('td', { className: ['num'] }, [txt(bytes)]),
296
+ el('td', {}, [txt(at)]),
297
+ ]);
298
+ function size(n) {
299
+ if (n < 1000) {
300
+ return `${n} B`;
301
+ }
302
+ const units = ['kB', 'MB', 'GB'];
303
+ let v = n / 1000;
304
+ let i = 0;
305
+ while (v >= 1000 && i < units.length - 1) {
306
+ v /= 1000;
307
+ i++;
308
+ }
309
+ return `${v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`;
310
+ }
311
+ function stamp(ms) {
312
+ if (!ms) {
313
+ return '';
314
+ }
315
+ const d = new Date(ms);
316
+ const p = (n) => String(n).padStart(2, '0');
317
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
318
+ }
319
+ function readHead(abs, max) {
320
+ const fd = openSync(abs, 'r');
321
+ try {
322
+ const buf = Buffer.alloc(max);
323
+ let n = 0;
324
+ for (let got = -1; got !== 0 && n < max; n += got) {
325
+ got = readSync(fd, buf, n, max - n, null);
326
+ }
327
+ const bytes = buf.subarray(0, n);
328
+ return { bytes, text: new TextDecoder().decode(bytes) };
329
+ }
330
+ finally {
331
+ closeSync(fd);
332
+ }
333
+ }
334
+ function binary(bytes) {
335
+ return bytes.subarray(0, SNIFF_BYTES).includes(0);
336
+ }
337
+ // ---------------------------------------------------------------------------
338
+ // hast helpers
339
+ // ---------------------------------------------------------------------------
340
+ const root = (children) => ({ type: 'root', children });
341
+ const el = (tagName, properties = {}, children = []) => ({
342
+ type: 'element',
343
+ tagName,
344
+ properties,
345
+ children,
346
+ });
347
+ const txt = (value) => ({ type: 'text', value });
348
+ function classes(node) {
349
+ const c = node?.properties.className;
350
+ return Array.isArray(c) ? c.map(String) : typeof c === 'string' ? c.split(' ') : [];
351
+ }
352
+ function walk(node, visit) {
353
+ for (const child of node.children) {
354
+ if (child.type === 'element') {
355
+ visit(child);
356
+ walk(child, visit);
357
+ }
358
+ }
359
+ }
360
+ function textOf(node) {
361
+ let out = '';
362
+ for (const child of node.children) {
363
+ if (child.type === 'text') {
364
+ out += child.value;
365
+ }
366
+ else if (child.type === 'element') {
367
+ out += textOf(child);
368
+ }
369
+ }
370
+ return out;
371
+ }
372
+ //# sourceMappingURL=render.js.map
@@ -0,0 +1,30 @@
1
+ export interface ViewOptions {
2
+ /** the directory served; nothing outside it is read */
3
+ root: string;
4
+ /** default: a port derived from the root, or any free one if that is taken */
5
+ port?: number;
6
+ /** the page the returned `url` opens, as a URL path */
7
+ start?: string;
8
+ /** serve dotfiles too; `.env`, `.git` and the like never are */
9
+ hidden?: boolean;
10
+ /** most of one file rendered, in bytes */
11
+ maxBytes?: number;
12
+ /** most pages open at once */
13
+ maxClients?: number;
14
+ pollMs?: number;
15
+ /** least time between two pushes of one path, in ms; default 1000 */
16
+ throttleMs?: number;
17
+ }
18
+ export interface ViewServer {
19
+ readonly url: string;
20
+ readonly origin: string;
21
+ readonly port: number;
22
+ readonly root: string;
23
+ close(): Promise<void>;
24
+ }
25
+ /** The same root asks for the same port, so an open tab reconnects after a restart. */
26
+ export declare function stablePort(root: string): number;
27
+ /** The platform opener, without a shell, so the URL stays one argument. */
28
+ export declare function openBrowser(url: string): void;
29
+ export declare function serveView(options: ViewOptions): Promise<ViewServer>;
30
+ //# sourceMappingURL=server.d.ts.map