pageproof 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.
Files changed (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +90 -0
  3. package/THIRD_PARTY_NOTICES.md +74 -0
  4. package/assets/SKILL.md +58 -0
  5. package/assets/_paged.css +82 -0
  6. package/assets/chicago.csl +6006 -0
  7. package/assets/default.html +496 -0
  8. package/assets/doublespaced.css +8 -0
  9. package/assets/european-journal-of-international-law.csl +404 -0
  10. package/assets/favicon.svg +5 -0
  11. package/assets/footnotes-inline.lua +56 -0
  12. package/assets/latex.css +142 -0
  13. package/assets/msword.css +100 -0
  14. package/assets/numbered.css +75 -0
  15. package/assets/vendor/mathjax/LICENSE +202 -0
  16. package/assets/vendor/mathjax/sre/mathmaps/af.json +146 -0
  17. package/assets/vendor/mathjax/sre/mathmaps/base.json +140 -0
  18. package/assets/vendor/mathjax/sre/mathmaps/ca.json +140 -0
  19. package/assets/vendor/mathjax/sre/mathmaps/da.json +140 -0
  20. package/assets/vendor/mathjax/sre/mathmaps/de.json +146 -0
  21. package/assets/vendor/mathjax/sre/mathmaps/en.json +158 -0
  22. package/assets/vendor/mathjax/sre/mathmaps/es.json +140 -0
  23. package/assets/vendor/mathjax/sre/mathmaps/euro.json +32 -0
  24. package/assets/vendor/mathjax/sre/mathmaps/fr.json +146 -0
  25. package/assets/vendor/mathjax/sre/mathmaps/hi.json +146 -0
  26. package/assets/vendor/mathjax/sre/mathmaps/it.json +146 -0
  27. package/assets/vendor/mathjax/sre/mathmaps/ko.json +146 -0
  28. package/assets/vendor/mathjax/sre/mathmaps/nb.json +146 -0
  29. package/assets/vendor/mathjax/sre/mathmaps/nemeth.json +125 -0
  30. package/assets/vendor/mathjax/sre/mathmaps/nn.json +146 -0
  31. package/assets/vendor/mathjax/sre/mathmaps/sv.json +146 -0
  32. package/assets/vendor/mathjax/sre/speech-worker.js +1 -0
  33. package/assets/vendor/mathjax/tex-mml-chtml-nofont.js +18 -0
  34. package/assets/vendor/mathjax-fonts/LICENSE +202 -0
  35. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-b.woff2 +0 -0
  36. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-bi.woff2 +0 -0
  37. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-brk.woff2 +0 -0
  38. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-c.woff2 +0 -0
  39. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-cb.woff2 +0 -0
  40. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-f.woff2 +0 -0
  41. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-fb.woff2 +0 -0
  42. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-i.woff2 +0 -0
  43. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-lo.woff2 +0 -0
  44. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-m.woff2 +0 -0
  45. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-mi.woff2 +0 -0
  46. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-n.woff2 +0 -0
  47. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-ob.woff2 +0 -0
  48. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-os.woff2 +0 -0
  49. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-s3.woff2 +0 -0
  50. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-s4.woff2 +0 -0
  51. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-so.woff2 +0 -0
  52. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-ss.woff2 +0 -0
  53. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-ssb.woff2 +0 -0
  54. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-ssi.woff2 +0 -0
  55. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-v.woff2 +0 -0
  56. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-zero.woff2 +0 -0
  57. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml.js +1 -0
  58. package/assets/vendor/mathjax-fonts/mathjax-tex-font/package.json +88 -0
  59. package/assets/vendor/paged.polyfill.js +33251 -0
  60. package/bin/mdpreview.js +8 -0
  61. package/bin/pageproof.js +8 -0
  62. package/package.json +42 -0
  63. package/src/assets.js +246 -0
  64. package/src/cli.js +166 -0
  65. package/src/lifecycle.js +445 -0
  66. package/src/pandoc.js +346 -0
  67. package/src/server.js +228 -0
  68. package/src/util.js +43 -0
package/src/pandoc.js ADDED
@@ -0,0 +1,346 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+ import { escapeHtml, pathExists } from './util.js';
5
+
6
+ const BROWSER_TITLE_SENTINEL = 'MDPREVIEW_BROWSER_TITLE';
7
+ const MATHJAX_URL = '/mathjax/tex-mml-chtml-nofont.js';
8
+
9
+ export function hasYamlBibliography(markdown) {
10
+ const match = String(markdown).match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
11
+ return Boolean(match && /^\s*bibliography\s*:/m.test(match[1]));
12
+ }
13
+
14
+ export async function bibliographyArgs(sourceFile) {
15
+ const markdown = await fs.readFile(sourceFile, 'utf8');
16
+ if (hasYamlBibliography(markdown)) return [];
17
+ const fallback = path.join(path.dirname(sourceFile), 'references.bib');
18
+ return (await pathExists(fallback)) ? ['--bibliography', fallback] : [];
19
+ }
20
+
21
+ export async function buildWithPandoc({
22
+ sourceFile,
23
+ tempDir,
24
+ cssFiles,
25
+ log = console,
26
+ debugBrowser = false,
27
+ buildId = 0
28
+ }) {
29
+ const sourceDir = path.dirname(sourceFile);
30
+ const nextHtml = path.join(tempDir, 'index.next.html');
31
+ const finalHtml = path.join(tempDir, 'index.html');
32
+ const resolvedCssFiles = cssFiles ?? await discoverCssFiles(tempDir);
33
+ const args = [
34
+ sourceFile,
35
+ '--from=markdown',
36
+ '--to=html5',
37
+ '--standalone',
38
+ `--template=${path.join(tempDir, 'template.html')}`,
39
+ ...resolvedCssFiles.map((cssFile) => `--css=${cssFile}`),
40
+ `--mathjax=${MATHJAX_URL}`,
41
+ '--citeproc',
42
+ `--csl=${path.join(tempDir, 'citations.csl')}`,
43
+ // Suppresses Pandoc's empty-title warning; the browser title is replaced after Pandoc renders.
44
+ '--metadata=pagetitle:mdpreview',
45
+ '--metadata=suppress-bibliography:true',
46
+ ...(await bibliographyArgs(sourceFile)),
47
+ `--lua-filter=${path.join(tempDir, 'footnotes-inline.lua')}`,
48
+ `--output=${nextHtml}`
49
+ ];
50
+
51
+ const result = await runPandoc(args, sourceDir);
52
+ if (result.stderr) log?.warn?.(result.stderr.trim());
53
+
54
+ if (result.code === 0) {
55
+ const diagnostics = result.stderr;
56
+ let html = await fs.readFile(nextHtml, 'utf8');
57
+ html = injectBrowserTitle(html, path.basename(sourceFile));
58
+ html = injectBuildId(html, buildId);
59
+ html = injectBrowserDebug(html, debugBrowser);
60
+ html = injectDiagnostics(html, diagnostics);
61
+ await fs.writeFile(nextHtml, html);
62
+ await fs.rename(nextHtml, finalHtml);
63
+ return { ok: true, diagnostics, stderr: result.stderr };
64
+ }
65
+
66
+ const diagnostics = result.stderr;
67
+ await writeErrorPage(finalHtml, diagnostics, {
68
+ debugBrowser,
69
+ buildId,
70
+ cssFiles: resolvedCssFiles,
71
+ browserTitle: path.basename(sourceFile)
72
+ });
73
+ await fs.rm(nextHtml, { force: true });
74
+ return { ok: false, diagnostics, stderr: result.stderr };
75
+ }
76
+
77
+ async function discoverCssFiles(tempDir) {
78
+ const files = await fs.readdir(tempDir).catch(() => []);
79
+ const cascade = files.filter((file) => /^style-\d+-.*\.css$/.test(file)).sort();
80
+ return cascade.length ? cascade : ['style.css'];
81
+ }
82
+
83
+ export function injectBrowserDebug(html, debugBrowser) {
84
+ return String(html).replace(
85
+ 'window.mdpreviewBrowserDebug = false;',
86
+ `window.mdpreviewBrowserDebug = ${debugBrowser ? 'true' : 'false'};`
87
+ );
88
+ }
89
+
90
+ export function injectBuildId(html, buildId) {
91
+ return String(html).replace(
92
+ 'window.mdpreviewBrowserDebug = false;',
93
+ `window.mdpreviewBuildId = ${JSON.stringify(buildId)};\n window.mdpreviewBrowserDebug = false;`
94
+ );
95
+ }
96
+
97
+ export function injectBrowserTitle(html, title) {
98
+ return String(html).replace(
99
+ `<title>${BROWSER_TITLE_SENTINEL}</title>`,
100
+ `<title>${escapeHtml(title || 'Markdown preview')}</title>`
101
+ );
102
+ }
103
+
104
+ function runPandoc(args, cwd) {
105
+ return new Promise((resolve, reject) => {
106
+ const child = spawn('pandoc', args, { cwd, stdio: ['ignore', 'ignore', 'pipe'] });
107
+ let stderr = '';
108
+ child.stderr.on('data', (chunk) => {
109
+ stderr += chunk;
110
+ });
111
+ child.on('error', reject);
112
+ child.on('close', (code) => resolve({ code, stderr }));
113
+ });
114
+ }
115
+
116
+ export function injectDiagnostics(html, diagnostics) {
117
+ const widget = renderDiagnosticsWidget(diagnostics);
118
+ let next = String(html).replace('<!-- MDPREVIEW_DIAGNOSTICS -->', widget);
119
+ if (String(diagnostics || '').trim()) {
120
+ next = next.replace('<body>', '<body class="mdpreview-has-diagnostics">');
121
+ }
122
+ return next;
123
+ }
124
+
125
+ export function renderDiagnosticsWidget(diagnostics, options = {}) {
126
+ const text = String(diagnostics || '').trim();
127
+ if (!text) return '';
128
+ const title = options.title ?? 'Document errors';
129
+ const note = options.note ? `<p>${escapeHtml(options.note)}</p>` : '';
130
+ return `<aside class="mdpreview-diagnostics-widget" data-mdpreview-chrome aria-live="polite">
131
+ <details class="mdpreview-diagnostics-details">
132
+ <summary class="mdpreview-diagnostics-button" title="${escapeHtml(title)}" aria-label="${escapeHtml(title)}">!</summary>
133
+ <section class="mdpreview-diagnostics-panel">
134
+ <h2>${escapeHtml(title)}</h2>
135
+ ${note}
136
+ <pre>${escapeHtml(text)}</pre>
137
+ </section>
138
+ </details>
139
+ </aside>`;
140
+ }
141
+
142
+ export async function writeErrorPage(targetHtml, diagnostics, options = {}) {
143
+ const details = String(diagnostics || '').trim();
144
+ const filename = options.browserTitle || 'document';
145
+ const title = `Error compiling ${filename}`;
146
+ const message = details || 'Pandoc did not produce a preview.';
147
+ const html = `<!doctype html>
148
+ <html>
149
+ <head>
150
+ <meta charset="utf-8">
151
+ <meta name="viewport" content="width=device-width, initial-scale=1">
152
+ <title>${escapeHtml(title)}</title>
153
+ <style data-pagedjs-ignore>
154
+ body.mdpreview-disconnected {
155
+ background: #737373 !important;
156
+ color: #1f1f1f;
157
+ }
158
+
159
+ body.mdpreview-disconnected main {
160
+ opacity: 0.55;
161
+ }
162
+
163
+ .mdpreview-disconnect-widget {
164
+ position: fixed;
165
+ top: 12pt;
166
+ right: 12pt;
167
+ z-index: 10000;
168
+ font-family: Calibri, Arial, sans-serif;
169
+ }
170
+
171
+ .mdpreview-disconnect-details {
172
+ position: relative;
173
+ }
174
+
175
+ .mdpreview-disconnect-button {
176
+ display: block;
177
+ width: 24pt;
178
+ height: 24pt;
179
+ border: 1pt solid #3f3f3f;
180
+ border-radius: 50%;
181
+ background: #5b5b5b;
182
+ color: #f2f2f2;
183
+ box-shadow: 0 1.5pt 5pt rgba(0, 0, 0, 0.18);
184
+ cursor: pointer;
185
+ list-style: none;
186
+ position: relative;
187
+ font-size: 0;
188
+ user-select: none;
189
+ -webkit-user-select: none;
190
+ }
191
+
192
+ .mdpreview-disconnect-button::-webkit-details-marker {
193
+ display: none;
194
+ }
195
+
196
+ .mdpreview-disconnect-button::before,
197
+ .mdpreview-disconnect-button::after {
198
+ content: "";
199
+ position: absolute;
200
+ top: 50%;
201
+ left: 50%;
202
+ transform-origin: center;
203
+ }
204
+
205
+ .mdpreview-disconnect-button::before {
206
+ box-sizing: border-box;
207
+ width: 12pt;
208
+ height: 12pt;
209
+ border: 2pt solid currentColor;
210
+ border-radius: 50%;
211
+ transform: translate(-50%, -50%);
212
+ }
213
+
214
+ .mdpreview-disconnect-button::after {
215
+ width: 14pt;
216
+ height: 2pt;
217
+ border-radius: 1pt;
218
+ background: currentColor;
219
+ transform: translate(-50%, -50%) rotate(-45deg);
220
+ }
221
+
222
+ .mdpreview-disconnect-panel {
223
+ box-sizing: border-box;
224
+ position: absolute;
225
+ top: 30pt;
226
+ right: 0;
227
+ width: min(330pt, calc(100vw - 24pt));
228
+ max-height: calc(100vh - 54pt);
229
+ overflow: auto;
230
+ padding: 12pt 14pt;
231
+ border-top: 4px solid #3f3f3f;
232
+ background: #ededed;
233
+ color: black;
234
+ font-family: Calibri, Arial, sans-serif;
235
+ font-size: 10.5pt;
236
+ line-height: 1.45;
237
+ box-shadow: 0 3pt 12pt rgba(0, 0, 0, 0.22);
238
+ }
239
+ </style>
240
+ </head>
241
+ <body>
242
+ <main>
243
+ <h1>${escapeHtml(title)}</h1>
244
+ <pre>${escapeHtml(message)}</pre>
245
+ </main>
246
+ <script>
247
+ window.mdpreviewBrowserDebug = ${options.debugBrowser ? 'true' : 'false'};
248
+ window.mdpreviewBuildId = ${JSON.stringify(options.buildId ?? 0)};
249
+ window.mdpreviewBrowserLogErrors = [];
250
+ const mdpreviewTextHead = (value) => String(value || '').replace(/\\s+/g, ' ').trim().slice(0, 500);
251
+ const mdpreviewTextTail = (value) => String(value || '').replace(/\\s+/g, ' ').trim().slice(-500);
252
+ const mdpreviewPostBrowserLog = (payload) => {
253
+ const body = JSON.stringify(payload);
254
+ if (!window.mdpreviewBrowserDebug && navigator.sendBeacon) {
255
+ const sent = navigator.sendBeacon('/__browser-log', new Blob([body], { type: 'application/json' }));
256
+ if (sent) return;
257
+ }
258
+ fetch('/__browser-log', {
259
+ method: 'POST',
260
+ headers: { 'content-type': 'application/json' },
261
+ body,
262
+ keepalive: !window.mdpreviewBrowserDebug
263
+ }).catch(() => {});
264
+ };
265
+ const mdpreviewReportErrorPage = () => {
266
+ const text = document.body?.innerText || document.body?.textContent || '';
267
+ const payload = {
268
+ type: 'browser-rendered',
269
+ buildId: window.mdpreviewBuildId,
270
+ phase: 'error-page-rendered',
271
+ ok: false,
272
+ url: location.href,
273
+ pageCount: 0,
274
+ renderedTextLength: text.length,
275
+ sourceTextLength: 0,
276
+ renderedTextTail: mdpreviewTextTail(text),
277
+ errors: []
278
+ };
279
+ if (window.mdpreviewBrowserDebug) {
280
+ payload.renderedTextHead = mdpreviewTextHead(text);
281
+ payload.documentHtml = document.documentElement.outerHTML;
282
+ payload.pagesHtml = '';
283
+ payload.sourceHtml = '';
284
+ }
285
+ mdpreviewPostBrowserLog(payload);
286
+ };
287
+ if (document.readyState === 'loading') {
288
+ document.addEventListener('DOMContentLoaded', mdpreviewReportErrorPage, { once: true });
289
+ } else {
290
+ mdpreviewReportErrorPage();
291
+ }
292
+
293
+ const mdpreviewDisconnectedTitle = 'Preview disconnected';
294
+
295
+ const mdpreviewEnsureDisconnectedWidget = () => {
296
+ let widget = document.querySelector('.mdpreview-disconnect-widget');
297
+ if (widget) return widget;
298
+ widget = document.createElement('aside');
299
+ widget.className = 'mdpreview-disconnect-widget';
300
+ widget.setAttribute('aria-live', 'polite');
301
+ const details = document.createElement('details');
302
+ details.className = 'mdpreview-disconnect-details';
303
+ const button = document.createElement('summary');
304
+ button.className = 'mdpreview-disconnect-button';
305
+ button.setAttribute('title', mdpreviewDisconnectedTitle);
306
+ button.setAttribute('aria-label', mdpreviewDisconnectedTitle);
307
+ const panel = document.createElement('section');
308
+ panel.className = 'mdpreview-disconnect-panel';
309
+ const message = document.createElement('p');
310
+ const strong = document.createElement('strong');
311
+ strong.textContent = mdpreviewDisconnectedTitle;
312
+ message.append(strong);
313
+ panel.append(message);
314
+ details.append(button, panel);
315
+ widget.append(details);
316
+ document.body.prepend(widget);
317
+ return widget;
318
+ };
319
+
320
+ const mdpreviewMarkDisconnected = () => {
321
+ document.body.classList.add('mdpreview-disconnected');
322
+ mdpreviewEnsureDisconnectedWidget();
323
+ };
324
+
325
+ const mdpreviewClearDisconnected = () => {
326
+ document.body.classList.remove('mdpreview-disconnected');
327
+ document.querySelector('.mdpreview-disconnect-widget')?.remove();
328
+ };
329
+
330
+ let mdpreviewDisconnectTimer;
331
+ const events = new EventSource('/events');
332
+ events.addEventListener('open', () => {
333
+ clearTimeout(mdpreviewDisconnectTimer);
334
+ mdpreviewClearDisconnected();
335
+ });
336
+ events.addEventListener('error', () => {
337
+ clearTimeout(mdpreviewDisconnectTimer);
338
+ mdpreviewDisconnectTimer = setTimeout(mdpreviewMarkDisconnected, 2500);
339
+ });
340
+ events.addEventListener('reload', () => location.reload());
341
+ </script>
342
+ </body>
343
+ </html>
344
+ `;
345
+ await fs.writeFile(targetHtml, html);
346
+ }
package/src/server.js ADDED
@@ -0,0 +1,228 @@
1
+ import fs from 'node:fs';
2
+ import fsp from 'node:fs/promises';
3
+ import http from 'node:http';
4
+ import path from 'node:path';
5
+
6
+ const mimeTypes = new Map([
7
+ ['.html', 'text/html; charset=utf-8'],
8
+ ['.css', 'text/css; charset=utf-8'],
9
+ ['.js', 'text/javascript; charset=utf-8'],
10
+ ['.json', 'application/json; charset=utf-8'],
11
+ ['.svg', 'image/svg+xml'],
12
+ ['.png', 'image/png'],
13
+ ['.jpg', 'image/jpeg'],
14
+ ['.jpeg', 'image/jpeg'],
15
+ ['.gif', 'image/gif'],
16
+ ['.webp', 'image/webp'],
17
+ ['.pdf', 'application/pdf'],
18
+ ['.woff', 'font/woff'],
19
+ ['.woff2', 'font/woff2'],
20
+ ['.ttf', 'font/ttf'],
21
+ ['.otf', 'font/otf']
22
+ ]);
23
+
24
+ export function safeResolve(root, requestPath) {
25
+ let pathname;
26
+ try {
27
+ pathname = decodeURIComponent(requestPath.split('?')[0]);
28
+ } catch {
29
+ return null;
30
+ }
31
+ const relative = pathname.replace(/^\/+/, '');
32
+ const resolved = path.resolve(root, relative);
33
+ const normalizedRoot = path.resolve(root);
34
+ if (resolved !== normalizedRoot && !resolved.startsWith(`${normalizedRoot}${path.sep}`)) {
35
+ return null;
36
+ }
37
+ return resolved;
38
+ }
39
+
40
+ export function createPreviewServer(options) {
41
+ const {
42
+ tempDir,
43
+ sourceDir,
44
+ getDiagnostics,
45
+ onFirstClient,
46
+ onClientConnected,
47
+ onAllClientsGone,
48
+ onBrowserLog,
49
+ getStatus,
50
+ onRequest
51
+ } = options;
52
+ const clients = new Set();
53
+ const sockets = new Set();
54
+ let firstClientSeen = false;
55
+ let keepalive;
56
+ let closing = false;
57
+ let closed = false;
58
+
59
+ const server = http.createServer(async (req, res) => {
60
+ onRequest?.(req);
61
+ if (req.method === 'POST' && req.url === '/__browser-log') {
62
+ try {
63
+ const payload = await readJsonBody(req);
64
+ await onBrowserLog?.(payload);
65
+ res.writeHead(204, { 'cache-control': 'no-store' }).end();
66
+ } catch (error) {
67
+ res.writeHead(400, {
68
+ 'content-type': 'text/plain; charset=utf-8',
69
+ 'cache-control': 'no-store'
70
+ }).end(error?.message || 'Bad browser log payload');
71
+ }
72
+ return;
73
+ }
74
+
75
+ if (req.method !== 'GET') {
76
+ res.writeHead(405).end('Method not allowed');
77
+ return;
78
+ }
79
+
80
+ if (req.url === '/' || req.url?.startsWith('/?')) {
81
+ await serveFile(path.join(tempDir, 'index.html'), res, { noCache: true });
82
+ return;
83
+ }
84
+
85
+ if (req.url === '/favicon.ico') {
86
+ res.writeHead(302, {
87
+ location: '/favicon.svg',
88
+ 'cache-control': 'public, max-age=3600'
89
+ }).end();
90
+ return;
91
+ }
92
+
93
+ if (req.url === '/diagnostics.json') {
94
+ res.writeHead(200, {
95
+ 'content-type': 'application/json; charset=utf-8',
96
+ 'cache-control': 'no-store'
97
+ });
98
+ res.end(JSON.stringify(getDiagnostics?.() ?? []));
99
+ return;
100
+ }
101
+
102
+ if (req.url === '/status.json') {
103
+ res.writeHead(200, {
104
+ 'content-type': 'application/json; charset=utf-8',
105
+ 'cache-control': 'no-store'
106
+ });
107
+ res.end(JSON.stringify(getStatus?.() ?? {}));
108
+ return;
109
+ }
110
+
111
+ if (req.url === '/events') {
112
+ onClientConnected?.();
113
+ if (!firstClientSeen) {
114
+ firstClientSeen = true;
115
+ onFirstClient?.();
116
+ }
117
+ res.writeHead(200, {
118
+ 'content-type': 'text/event-stream',
119
+ 'cache-control': 'no-store',
120
+ connection: 'keep-alive'
121
+ });
122
+ res.write(':connected\n\n');
123
+ clients.add(res);
124
+ req.on('close', () => {
125
+ clients.delete(res);
126
+ if (!closing && clients.size === 0) onAllClientsGone?.();
127
+ });
128
+ return;
129
+ }
130
+
131
+ const tempFile = safeResolve(tempDir, req.url ?? '');
132
+ if (tempFile && (await fileIsReadable(tempFile))) {
133
+ await serveFile(tempFile, res, { noCache: true });
134
+ return;
135
+ }
136
+
137
+ const sourceFile = safeResolve(sourceDir, req.url ?? '');
138
+ if (sourceFile && (await fileIsReadable(sourceFile))) {
139
+ await serveFile(sourceFile, res);
140
+ return;
141
+ }
142
+
143
+ res.writeHead(404).end('Not found');
144
+ });
145
+
146
+ server.on('connection', (socket) => {
147
+ sockets.add(socket);
148
+ socket.on('close', () => {
149
+ sockets.delete(socket);
150
+ });
151
+ });
152
+
153
+ keepalive = setInterval(() => {
154
+ for (const client of clients) client.write(':keepalive\n\n');
155
+ }, 15000);
156
+ keepalive.unref();
157
+
158
+ return {
159
+ server,
160
+ clients,
161
+ async listen() {
162
+ await new Promise((resolve, reject) => {
163
+ server.once('error', reject);
164
+ server.listen(0, '127.0.0.1', () => {
165
+ server.off('error', reject);
166
+ resolve();
167
+ });
168
+ });
169
+ return server.address();
170
+ },
171
+ broadcastReload() {
172
+ for (const client of clients) client.write('event: reload\ndata:\n\n');
173
+ },
174
+ async close() {
175
+ if (closed) return;
176
+ closed = true;
177
+ closing = true;
178
+ clearInterval(keepalive);
179
+ const closePromise = new Promise((resolve) => server.close(resolve));
180
+ for (const client of clients) client.end();
181
+ for (const socket of sockets) socket.destroy();
182
+ await closePromise;
183
+ }
184
+ };
185
+ }
186
+
187
+ async function fileIsReadable(filePath) {
188
+ try {
189
+ const stat = await fsp.stat(filePath);
190
+ return stat.isFile();
191
+ } catch {
192
+ return false;
193
+ }
194
+ }
195
+
196
+ async function serveFile(filePath, res, options = {}) {
197
+ const ext = path.extname(filePath).toLowerCase();
198
+ res.writeHead(200, {
199
+ 'content-type': mimeTypes.get(ext) ?? 'application/octet-stream',
200
+ 'cache-control': options.noCache ? 'no-store' : 'public, max-age=3600'
201
+ });
202
+ fs.createReadStream(filePath).pipe(res);
203
+ }
204
+
205
+ function readJsonBody(req, maxBytes = 25 * 1024 * 1024) {
206
+ return new Promise((resolve, reject) => {
207
+ let total = 0;
208
+ const chunks = [];
209
+ req.on('data', (chunk) => {
210
+ total += chunk.length;
211
+ if (total > maxBytes) {
212
+ reject(new Error('Browser log payload is too large'));
213
+ req.destroy();
214
+ return;
215
+ }
216
+ chunks.push(chunk);
217
+ });
218
+ req.on('error', reject);
219
+ req.on('end', () => {
220
+ try {
221
+ const text = Buffer.concat(chunks).toString('utf8');
222
+ resolve(text ? JSON.parse(text) : {});
223
+ } catch {
224
+ reject(new Error('Browser log payload is not valid JSON'));
225
+ }
226
+ });
227
+ });
228
+ }
package/src/util.js ADDED
@@ -0,0 +1,43 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { spawnSync } from 'node:child_process';
5
+
6
+ export const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
7
+
8
+ export async function pathExists(filePath) {
9
+ try {
10
+ await fs.access(filePath);
11
+ return true;
12
+ } catch {
13
+ return false;
14
+ }
15
+ }
16
+
17
+ export function escapeHtml(value) {
18
+ return String(value)
19
+ .replaceAll('&', '&amp;')
20
+ .replaceAll('<', '&lt;')
21
+ .replaceAll('>', '&gt;')
22
+ .replaceAll('"', '&quot;')
23
+ .replaceAll("'", '&#39;');
24
+ }
25
+
26
+ export function isWsl() {
27
+ if (process.platform !== 'linux') return false;
28
+ return Boolean(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP);
29
+ }
30
+
31
+ export function commandExists(command) {
32
+ const result = spawnSync(command, ['--version'], { stdio: 'ignore' });
33
+ if (result.error?.code === 'ENOENT') return false;
34
+ return true;
35
+ }
36
+
37
+ export async function writeJson(filePath, value) {
38
+ await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`);
39
+ }
40
+
41
+ export function delay(ms) {
42
+ return new Promise((resolve) => setTimeout(resolve, ms));
43
+ }