buninu-browser 0.1.0 → 0.2.1

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 (64) hide show
  1. package/AGENTS.md +17 -0
  2. package/LICENSES/brotli.txt +19 -0
  3. package/README.md +41 -7
  4. package/THIRD_PARTY_NOTICES.md +26 -4
  5. package/buninu-browser.js +10 -2
  6. package/lib/audio/pcm.js +16 -0
  7. package/lib/audio/piano.js +109 -0
  8. package/lib/audio/pulse-client.js +413 -0
  9. package/lib/cdp-server.js +28 -7
  10. package/lib/document-types.js +308 -0
  11. package/lib/download-directory.js +28 -0
  12. package/lib/download.js +63 -0
  13. package/lib/happy-dom/selector-matcher.js +83 -0
  14. package/lib/headless-shell.js +165 -26
  15. package/lib/input/hit-test.js +57 -8
  16. package/lib/layout/text-layout.js +290 -41
  17. package/lib/network/network-service.js +177 -40
  18. package/lib/network/resolver.js +16 -0
  19. package/lib/paint/display-list.js +25 -10
  20. package/lib/render-tree/index.js +58 -0
  21. package/lib/renderer/bindings.js +543 -55
  22. package/lib/renderer/flags.js +8 -5
  23. package/lib/renderer/host.js +103 -7
  24. package/lib/renderer/main.js +35 -3
  25. package/lib/renderer/module-transform.js +30 -23
  26. package/lib/renderer/page-realm.js +228 -24
  27. package/lib/renderer/page-renderer.js +1264 -221
  28. package/lib/screenshot-cli.js +61 -0
  29. package/lib/style/computed-style.js +558 -229
  30. package/lib/style/selector-keys.js +302 -0
  31. package/lib/style/selector-traits.js +240 -0
  32. package/lib/user-agent.js +12 -0
  33. package/package.json +1 -1
  34. package/patches/happy-dom/0003-defer-script-events-to-renderer.patch +40 -0
  35. package/patches/happy-dom/0004-insert-adjacent-html-in-order.patch +19 -0
  36. package/patches/happy-dom/0005-invalidate-matches-on-structure-changes.patch +43 -0
  37. package/patches/happy-dom/README.md +12 -0
  38. package/patches/ses/0001-allow-regexp-own-tostring.patch +13 -0
  39. package/patches/ses/README.md +16 -0
  40. package/test/computed-style.test.js +113 -1
  41. package/test/document-types.test.js +111 -0
  42. package/test/download.test.js +192 -0
  43. package/test/fixtures/tone.mp3 +0 -0
  44. package/test/flex.test.js +11 -0
  45. package/test/happy-dom-matches.test.js +25 -0
  46. package/test/headless-shell.test.js +56 -0
  47. package/test/hit-test.test.js +37 -0
  48. package/test/log.test.js +4 -1
  49. package/test/network-service.test.js +67 -0
  50. package/test/page-scripts.test.js +437 -29
  51. package/test/piano.test.js +65 -0
  52. package/test/positioning.test.js +11 -0
  53. package/test/pulse-client.test.js +130 -0
  54. package/test/render-tree.test.js +36 -1
  55. package/test/resolver.test.js +12 -1
  56. package/test/screenshot-cli.test.js +19 -0
  57. package/test/selector-keys.test.js +39 -0
  58. package/test/selector-traits.test.js +19 -0
  59. package/test/text-layout.test.js +91 -1
  60. package/test/user-agent.test.js +42 -0
  61. package/vendor/happy-dom/packages/happy-dom/src/nodes/element/Element.ts +3 -6
  62. package/vendor/happy-dom/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts +2 -20
  63. package/vendor/happy-dom/packages/happy-dom/src/query-selector/QuerySelector.ts +15 -3
  64. package/vendor/ses/dist/ses.mjs +2 -0
@@ -0,0 +1,308 @@
1
+ /**
2
+ * What a top-level response looks like in the viewport. HTML is shown as
3
+ * itself; anything else gets a small generated HTML document, as browsers do
4
+ * for "standalone" resources (HTML §7.4.4-§7.4.7):
5
+ *
6
+ * - images: the image alone on a dark background, centered and fitted to the
7
+ * width (SVG inline, other formats as a data: URL so they are not fetched
8
+ * twice);
9
+ * - video and audio: a <video> or <audio> element for the URL on the same
10
+ * background, with the file's name and type under it;
11
+ * - the formats bunmsh's `serve` previews, handled the way it does: Markdown
12
+ * rendered with Bun.markdown.html, and JSON, JSON5, JSONC, JSON Lines, YAML,
13
+ * TOML and XML parsed with Bun's parsers and printed as highlighted JSON,
14
+ * under a "Pretty-print" checkbox that is on by default and, unticked,
15
+ * shows the text as sent;
16
+ * - everything else, whatever its bytes: the text in a <pre>. There is no
17
+ * "cannot be shown" page (see AGENTS.md).
18
+ *
19
+ * Large files would stall layout (on a phone, 1 MB of text takes seconds and
20
+ * 4 MB never finished): past DISPLAY_LIMIT only the start of the text is
21
+ * shown, and past PRETTY_LIMIT the preview is skipped for the text, each
22
+ * with a bar at the top that says so and points to casty's `download`.
23
+ *
24
+ * The bytes kept for downloads are the response's own, not this HTML. Text is
25
+ * decoded with the Content-Type charset, UTF-8 when there is none.
26
+ */
27
+
28
+ const HTML_TYPES = new Set(["text/html", "application/xhtml+xml"]);
29
+ // Measured on a phone: 256 KiB of text lays out in about 2 s; a 32 KiB
30
+ // preview (highlighted JSON, many spans) in about 2.5 s.
31
+ export const DISPLAY_LIMIT = 256 * 1024;
32
+ export const PRETTY_LIMIT = 32 * 1024;
33
+
34
+ // From bunmsh serve.js (MIT, same author): the formats it previews, by file
35
+ // extension, with its parser names, pretty() and highlightJson().
36
+ const PARSERS = new Map([
37
+ ["json", { name: "JSON.parse", parse: JSON.parse }],
38
+ ["json5", { name: "Bun.JSON5.parse", parse: Bun.JSON5.parse }],
39
+ ["jsonc", { name: "Bun.JSONC.parse", parse: Bun.JSONC.parse }],
40
+ ["jsonl", { name: "Bun.JSONL.parse", parse: Bun.JSONL.parse }],
41
+ ["ndjson", { name: "Bun.JSONL.parse", parse: Bun.JSONL.parse }],
42
+ ["yaml", { name: "Bun.YAML.parse", parse: Bun.YAML.parse }],
43
+ ["yml", { name: "Bun.YAML.parse", parse: Bun.YAML.parse }],
44
+ ["toml", { name: "Bun.TOML.parse", parse: Bun.TOML.parse }],
45
+ ]);
46
+ if (typeof Bun.XML?.parse === "function") {
47
+ PARSERS.set("xml", { name: "Bun.XML.parse", parse: Bun.XML.parse });
48
+ }
49
+ const MARKDOWN = "Bun.markdown.html";
50
+
51
+ // A URL without one of those extensions: its Content-Type names the format.
52
+ const EXTENSION_BY_TYPE = new Map([
53
+ ["text/markdown", "md"], ["text/x-markdown", "md"],
54
+ ["application/json", "json"], ["text/json", "json"], ["application/json5", "json5"], ["application/jsonc", "jsonc"],
55
+ ["application/jsonl", "jsonl"], ["application/x-ndjson", "ndjson"], ["application/jsonlines", "jsonl"],
56
+ ["text/yaml", "yaml"], ["application/yaml", "yaml"], ["application/x-yaml", "yaml"], ["text/x-yaml", "yaml"],
57
+ ["application/toml", "toml"], ["text/x-toml", "toml"],
58
+ ["application/xml", "xml"], ["text/xml", "xml"],
59
+ ]);
60
+
61
+ /** "md", a PARSERS key, or null: what bunmsh serve would preview this as. */
62
+ function previewKind(name, type) {
63
+ const byName = extension(name);
64
+ if (byName === "md" || PARSERS.has(byName)) return byName;
65
+ const byType = EXTENSION_BY_TYPE.get(type) ?? (type.endsWith("+json") ? "json" : type.endsWith("+xml") ? "xml" : null);
66
+ return byType === "md" || PARSERS.has(byType) ? byType : null;
67
+ }
68
+
69
+ /**
70
+ * @param {{ url: string, contentType?: string, body: Uint8Array }} response
71
+ * @returns {{ source: string, contentType: string }}
72
+ */
73
+ export function displayDocument({ url, contentType = "", body }) {
74
+ const { type, charset } = parseContentType(contentType);
75
+ const name = fileName(url);
76
+ if (HTML_TYPES.has(type) || (type === "" && looksLikeHtml(body))) {
77
+ return { source: decode(body, charset), contentType: contentType || "text/html" };
78
+ }
79
+ if (type === "image/svg+xml") return html(name, IMAGE_STYLE, `<div class="image">${stripProlog(decode(body, charset))}</div>`);
80
+ if (type.startsWith("image/")) {
81
+ const src = `data:${type};base64,${Buffer.from(body).toString("base64")}`;
82
+ return html(name, IMAGE_STYLE, `<div class="image"><img src="${src}" alt="${Bun.escapeHTML(name)}"></div>`);
83
+ }
84
+ const media = mediaKind(type, name);
85
+ if (media) return mediaDocument(url, name, type || media, media, body.byteLength);
86
+ const kind = previewKind(name, type);
87
+ if (kind && body.byteLength <= PRETTY_LIMIT) return previewDocument(url, name, kind, decode(body, charset));
88
+ // Everything else is shown as text, whatever the bytes are: no file is unviewable.
89
+ const note = kind
90
+ ? `Too large to pretty-print quickly (${formatSize(body.byteLength)})${body.byteLength > DISPLAY_LIMIT ? `; showing the first ${formatSize(DISPLAY_LIMIT)}` : ""}.`
91
+ : body.byteLength > DISPLAY_LIMIT ? `Large file (${formatSize(body.byteLength)}); showing the first ${formatSize(DISPLAY_LIMIT)}.` : null;
92
+ const shown = body.byteLength > DISPLAY_LIMIT ? body.subarray(0, DISPLAY_LIMIT) : body;
93
+ const bar = `<div class="bar"><span>${note ? `${note} The whole file: casty's <code>download</code> (Ctrl+E).` : ""}</span>${downloadButton(url)}</div>`;
94
+ return html(name, TEXT_STYLE, `${bar}<pre>${Bun.escapeHTML(decode(shown, charset))}</pre>`);
95
+ }
96
+
97
+ /** Built-in audio diagnostics page; CSS keys work without SVG or page scripts. */
98
+ export function audioDemoDocument() {
99
+ const whites = [["A4", 69], ["B4", 71], ["C5", 72], ["D5", 74],
100
+ ["E5", 76], ["F5", 77], ["G5", 79], ["A5", 81]]
101
+ .map(([name, note]) => `<button type="button" class="white-key" data-piano-note="${note}"><span>${name}</span></button>`).join("");
102
+ const blacks = [["A♯4", 70, "8.75"], ["C♯5", 73, "33.75"], ["D♯5", 75, "46.25"],
103
+ ["F♯5", 78, "71.25"], ["G♯5", 80, "83.75"]]
104
+ .map(([name, note, left]) => `<button type="button" class="black-key" data-piano-note="${note}" style="left:${left}%"><span>${name}</span></button>`).join("");
105
+ const style = `:root { color-scheme: dark }
106
+ body { margin: 0; min-height: 100vh; background: #151820; color: #f4f5f7; font: 15px sans-serif }
107
+ .demo { max-width: 560px; margin: 0 auto; padding: 42px 18px 24px; text-align: center }
108
+ h1 { margin: 0 0 8px; font-size: 24px }
109
+ .note { color: #abb1c0; font-size: 13px; line-height: 1.5 }
110
+ .keyboard { display: flex; position: relative; width: 100%; max-width: 420px; height: 150px; margin: 32px auto 20px; box-shadow: 0 8px 22px #0008 }
111
+ .white-key { flex: 1; position: relative; box-sizing: border-box; padding: 0; border: 1px solid #6f7683; border-radius: 0 0 6px 6px; background: #f5f3ed; color: #2c3039; cursor: pointer; font: inherit }
112
+ .white-key span { position: absolute; bottom: 9px; left: 0; right: 0; font-size: 12px }
113
+ .black-key { position: absolute; z-index: 1; top: 0; width: 8.6%; height: 92px; padding: 0; border: 0; border-radius: 0 0 5px 5px; background: #262b35; box-shadow: 0 4px 5px #0008; color: #e8e8e8; cursor: pointer; font: inherit }
114
+ .black-key span { position: absolute; bottom: 8px; left: 0; right: 0; font-size: 10px }
115
+ input { box-sizing: border-box; width: 100%; max-width: 420px; padding: 10px 12px; border: 1px solid #858da0; border-radius: 7px; background: #222735; color: white; font: 15px sans-serif }
116
+ input:focus { outline: 2px solid #90b4ff; outline-offset: 2px }`;
117
+ return html("Audio demo", style, `<main class="demo"><h1>Audio demo</h1>`
118
+ + `<p class="note">Tap a key, or click the field and play with Space, J, K and the LazyI keyboard bindings.</p>`
119
+ + `<div class="keyboard" aria-label="Piano keyboard">${whites}${blacks}</div>`
120
+ + `<input id="buninu-piano-input" type="text" autocomplete="off" placeholder="Play Piano · 彈鋼琴 · ピアノを弾く" aria-label="Play Piano">`
121
+ + `<p class="note">Space = A4 · J = B4 · K = C♯5</p></main>`);
122
+ }
123
+
124
+ function formatSize(bytes) {
125
+ return bytes >= 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`;
126
+ }
127
+
128
+ // Media the renderer's decoder (wasmpeg's FFmpeg) handles: by type, or by
129
+ // extension when the server's type says nothing.
130
+ const VIDEO_TYPES = new Set(["application/ogg", "application/mp4", "application/x-matroska", "application/mxf"]);
131
+ const VIDEO_EXTENSIONS = new Set(["mp4", "m4v", "mov", "webm", "mkv", "avi", "ogv", "ogg", "ts", "mts", "m2ts",
132
+ "mpg", "mpeg", "3gp", "3g2", "wmv", "flv", "mxf", "y4m"]);
133
+ const AUDIO_EXTENSIONS = new Set(["mp3", "wav", "flac", "oga", "opus", "m4a", "aac", "wma", "aiff", "aif", "amr", "ac3", "mka"]);
134
+
135
+ /** "video", "audio" or null. */
136
+ function mediaKind(type, name) {
137
+ if (type.startsWith("video/") || VIDEO_TYPES.has(type)) return "video";
138
+ if (type.startsWith("audio/")) return "audio";
139
+ if (!["", "application/octet-stream", "binary/octet-stream"].includes(type)) return null;
140
+ const ext = extension(name);
141
+ return VIDEO_EXTENSIONS.has(ext) ? "video" : AUDIO_EXTENSIONS.has(ext) ? "audio" : null;
142
+ }
143
+
144
+ /** A media document (HTML §7.4.6): the element alone, playing the URL itself. */
145
+ function mediaDocument(url, name, type, kind, size) {
146
+ const src = Bun.escapeHTML(url);
147
+ const about = `${Bun.escapeHTML(name)} (${Bun.escapeHTML(type)}, ${size} bytes)`;
148
+ const element = kind === "video"
149
+ ? `<video controls autoplay src="${src}"></video><p class="note">${about}. Click the video to play.</p>`
150
+ : `<audio controls src="${src}"></audio><p class="note">${about}. Click to play; `
151
+ + "sound goes to a PulseAudio server on 127.0.0.1:4713, such as jspulse.</p>";
152
+ const piano = kind === "audio" && (type === "audio/mpeg" || extension(name) === "mp3")
153
+ ? `<label class="piano">鋼琴 <input id="buninu-piano-input" type="text" autocomplete="off" placeholder="Space J K · F1–F12" aria-label="鋼琴鍵盤"></label>`
154
+ : "";
155
+ return html(name, IMAGE_STYLE, `${piano}<div class="image">${element}</div>`);
156
+ }
157
+
158
+ /** bunmsh serve's preview (its previewResponse), under a Pretty-print checkbox over the text as sent. */
159
+ function previewDocument(url, name, kind, source) {
160
+ const raw = (hidden) => `<pre id="raw"${hidden ? " hidden" : ""}>${Bun.escapeHTML(source)}</pre>`;
161
+ const parser = PARSERS.get(kind);
162
+ const previewer = kind === "md" ? MARKDOWN : parser.name;
163
+ let formatted;
164
+ try {
165
+ formatted = kind === "md"
166
+ ? `<div id="formatted">${Bun.markdown.html(source, { headings: true })}</div>`
167
+ : `<pre id="formatted"><code>${highlightJson(pretty(parser.parse(source)))}</code></pre>`;
168
+ } catch (error) {
169
+ return html(`Cannot preview ${name}`, TEXT_STYLE, `<div class="bar"><span></span>${downloadButton(url)}</div><h1>Cannot preview ${Bun.escapeHTML(name)}</h1>`
170
+ + `<pre>${Bun.escapeHTML(error?.stack ?? String(error))}</pre>${raw(false)}`);
171
+ }
172
+ return html(`${name} — ${previewer}`, TEXT_STYLE, `<div class="bar"><span><label><input type="checkbox" id="pretty-print" checked> Pretty-print</label>`
173
+ + ` <span class="note">(${previewer})</span></span>${downloadButton(url)}</div>${formatted}${raw(true)}`
174
+ + `<script>(() => {
175
+ const box = document.getElementById("pretty-print");
176
+ const show = () => {
177
+ document.getElementById("formatted").hidden = !box.checked;
178
+ document.getElementById("raw").hidden = box.checked;
179
+ };
180
+ box.addEventListener("change", show);
181
+ })();</script>`);
182
+ }
183
+
184
+ function downloadButton(url) {
185
+ return `<a class="download" href="${Bun.escapeHTML(url)}" download>Download</a>`;
186
+ }
187
+
188
+ // Centered in the viewport both ways, as browsers show a standalone image.
189
+ const IMAGE_STYLE = `body { margin: 0; background: #0e0e0e }
190
+ .image { min-height: 100vh; display: flex; align-items: center; justify-content: center }
191
+ .image { flex-direction: column }
192
+ .image img, .image svg { max-width: 100%; height: auto; background: white }
193
+ .image video { max-width: 100% }
194
+ .image .note { color: #bbb; font-family: sans-serif; font-size: 13px }
195
+ .piano { position: fixed; z-index: 2; top: 8px; left: 8px; display: flex; align-items: center; gap: 5px; color: #eee; font: 13px sans-serif }
196
+ .piano input { width: 128px; padding: 3px 5px; font: 13px sans-serif }`;
197
+ // The link style and highlight colors are bunmsh serve's.
198
+ const TEXT_STYLE = `:root { color-scheme: light dark }
199
+ body { margin: 8px }
200
+ a { color: inherit; text-decoration: none }
201
+ pre { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; font-family: monospace }
202
+ code { font: inherit }
203
+ .bar { display: flex; justify-content: space-between; align-items: center; gap: 16px; font-family: sans-serif; margin-bottom: 8px }
204
+ .download { margin-left: auto; padding: 4px 9px; border: 1px solid currentColor; border-radius: 4px; white-space: nowrap }
205
+ .note { font-family: sans-serif; color: #666 }
206
+ .json-statement { color: #b0005a }
207
+ .json-string { color: #6b5d00 }
208
+ .json-special { color: #087f5b }
209
+ .json-number, .json-constant { color: #5f3dc4 }
210
+ @media (prefers-color-scheme: dark) {
211
+ .json-statement { color: #f92672 }
212
+ .json-string { color: #e6db74 }
213
+ .json-special { color: #a6e22e }
214
+ .json-number, .json-constant { color: #ae81ff }
215
+ }`;
216
+
217
+ function html(title, style, body) {
218
+ return {
219
+ source: `<!doctype html><html><head><meta charset="utf-8"><title>${Bun.escapeHTML(title)}</title>`
220
+ + `<style>${style}</style></head><body>${body}</body></html>`,
221
+ contentType: "text/html",
222
+ };
223
+ }
224
+
225
+ // bunmsh serve.js pretty(): BigInts (TOML integers) keep their digits as "123n".
226
+ const pretty = (value) => {
227
+ try {
228
+ return JSON.stringify(value, (_key, item) =>
229
+ typeof item === "bigint" ? `${item}n` : item, 2) ?? String(value);
230
+ } catch {
231
+ return Bun.inspect(value, { colors: false, depth: Infinity });
232
+ }
233
+ };
234
+
235
+ // bunmsh serve.js highlightJson(): statement keys, strings and escapes,
236
+ // numbers, and true/false/null constants of the JSON pretty() emits.
237
+ export const highlightJson = (source) => {
238
+ const input = String(source);
239
+ const token = /"(?:\\(?:u[\da-fA-F]{4}|.)|[^"\\])*"|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?|\b(?:true|false|null)\b/g;
240
+ let output = "", cursor = 0, match;
241
+ const stringHtml = (value) => {
242
+ let html = "", offset = 0;
243
+ for (const escape of value.matchAll(/\\(?:u[\da-fA-F]{4}|.)/g)) {
244
+ html += Bun.escapeHTML(value.slice(offset, escape.index));
245
+ html += `<span class="json-special">${Bun.escapeHTML(escape[0])}</span>`;
246
+ offset = escape.index + escape[0].length;
247
+ }
248
+ return html + Bun.escapeHTML(value.slice(offset));
249
+ };
250
+ while ((match = token.exec(input))) {
251
+ output += Bun.escapeHTML(input.slice(cursor, match.index));
252
+ const value = match[0];
253
+ let end = token.lastIndex;
254
+ if (value.startsWith('"')) {
255
+ const propertyEnd = /^\s*:/.exec(input.slice(end));
256
+ if (propertyEnd) {
257
+ end += propertyEnd[0].length;
258
+ output += `<span class="json-statement">${Bun.escapeHTML(input.slice(match.index, end))}</span>`;
259
+ } else {
260
+ output += `<span class="json-string">${stringHtml(value)}</span>`;
261
+ }
262
+ } else if (value === "true" || value === "false" || value === "null") {
263
+ output += `<span class="json-constant">${value}</span>`;
264
+ } else {
265
+ output += `<span class="json-number">${value}</span>`;
266
+ }
267
+ cursor = end;
268
+ token.lastIndex = end;
269
+ }
270
+ return output + Bun.escapeHTML(input.slice(cursor));
271
+ };
272
+
273
+ export function parseContentType(value) {
274
+ const [type = "", ...parameters] = String(value).split(";");
275
+ const charset = parameters.map((part) => part.trim().match(/^charset=["']?([^"';]+)/i)?.[1]).find(Boolean) ?? null;
276
+ return { type: type.trim().toLowerCase(), charset };
277
+ }
278
+
279
+ function decode(body, charset) {
280
+ try {
281
+ return new TextDecoder(charset ?? "utf-8").decode(body);
282
+ } catch {
283
+ return new TextDecoder().decode(body);
284
+ }
285
+ }
286
+
287
+ function fileName(url) {
288
+ try {
289
+ const segment = new URL(url).pathname.split("/").filter(Boolean).pop() ?? "";
290
+ return decodeURIComponent(segment) || new URL(url).hostname || url;
291
+ } catch {
292
+ return String(url);
293
+ }
294
+ }
295
+
296
+ function extension(name) {
297
+ const dot = name.lastIndexOf(".");
298
+ return dot > 0 ? name.slice(dot + 1).toLowerCase() : "";
299
+ }
300
+
301
+ function stripProlog(svg) {
302
+ return svg.replace(/^\s*<\?xml[^>]*>\s*/i, "").replace(/<!DOCTYPE[^>]*>\s*/i, "");
303
+ }
304
+
305
+ function looksLikeHtml(body) {
306
+ const head = new TextDecoder().decode(body.subarray(0, 512)).trimStart().toLowerCase();
307
+ return head.startsWith("<!doctype html") || head.startsWith("<html") || head.startsWith("<head") || head.startsWith("<body") || head === "";
308
+ }
@@ -0,0 +1,28 @@
1
+ import { homedir } from "node:os";
2
+ import { join, win32 } from "node:path";
3
+
4
+ // Windows can move the Downloads known folder away from %USERPROFILE%. Query
5
+ // its registered location through PowerShell so non-ASCII paths stay UTF-8.
6
+ const WINDOWS_DOWNLOADS = [
7
+ "$OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new($false)",
8
+ "$key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\User Shell Folders')",
9
+ "if ($key) { $path = $key.GetValue('{374DE290-123F-4565-9164-39C4925E467B}'); if ($path) { [Console]::Write([Environment]::ExpandEnvironmentVariables($path)) } }",
10
+ ].join("; ");
11
+
12
+ function windowsDownloads() {
13
+ try {
14
+ const result = Bun.spawnSync({ cmd: ["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", WINDOWS_DOWNLOADS],
15
+ stdout: "pipe", stderr: "ignore" });
16
+ return result.exitCode === 0 ? new TextDecoder().decode(result.stdout).trim() : null;
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ export function downloadsDirectory({ platform = process.platform, home = homedir(), queryWindows = windowsDownloads } = {}) {
23
+ if (platform === "win32") {
24
+ const known = queryWindows();
25
+ return known && win32.isAbsolute(known) ? known : win32.join(home, "Downloads");
26
+ }
27
+ return join(home, "Downloads");
28
+ }
@@ -0,0 +1,63 @@
1
+ import { mkdir, open, unlink } from "node:fs/promises";
2
+ import { extname, join } from "node:path";
3
+ import { Readable, Transform } from "node:stream";
4
+ import { pipeline } from "node:stream/promises";
5
+ import { downloadsDirectory } from "./download-directory.js";
6
+
7
+ export const DEFAULT_DOWNLOAD_DIR = downloadsDirectory();
8
+
9
+ export function isAttachment(value) {
10
+ return /^\s*attachment\s*(?:;|$)/i.test(value ?? "");
11
+ }
12
+
13
+ export function suggestedFilename(url, disposition = "", attributeName = "") {
14
+ const encoded = /(?:^|;)\s*filename\*\s*=\s*(?:UTF-8''|utf-8'[^']*')([^;]+)/i.exec(disposition)?.[1];
15
+ const plain = /(?:^|;)\s*filename\s*=\s*(?:"([^"]*)"|([^;]*))/i.exec(disposition);
16
+ let name = "";
17
+ if (encoded) { try { name = decodeURIComponent(encoded.trim().replace(/^"|"$/g, "")); } catch {} }
18
+ if (!name) name = plain?.[1] ?? plain?.[2]?.trim() ?? attributeName;
19
+ if (!name) {
20
+ const target = new URL(url);
21
+ const segment = target.pathname.split("/").filter(Boolean).at(-1);
22
+ try { name = decodeURIComponent(segment ?? ""); } catch { name = segment ?? ""; }
23
+ if (!name) name = target.hostname || "download";
24
+ }
25
+ name = name.split(/[\\/]/).at(-1).replace(/[<>:"|?*\x00-\x1f\x7f]/g, "_").replace(/^\.+/, "_").replace(/[. ]+$/, "").slice(0, 200);
26
+ return name || "download";
27
+ }
28
+
29
+ /** Save a response stream or already held bytes without replacing an existing file. */
30
+ export async function saveDownload({ url, disposition = "", attributeName = "", filename = null, body, dir = DEFAULT_DOWNLOAD_DIR }) {
31
+ await mkdir(dir, { recursive: true });
32
+ const name = filename ?? suggestedFilename(url, disposition, attributeName);
33
+ const extension = extname(name);
34
+ const stem = name.slice(0, name.length - extension.length);
35
+ let file;
36
+ let path;
37
+ for (let number = 1; ; number++) {
38
+ path = join(dir, number === 1 ? name : `${stem} (${number})${extension}`);
39
+ try { file = await open(path, "wx"); break; }
40
+ catch (error) { if (error.code !== "EEXIST") throw error; }
41
+ }
42
+ try {
43
+ let bytes = 0;
44
+ if (body instanceof Uint8Array) {
45
+ bytes = body.byteLength;
46
+ await file.writeFile(body);
47
+ } else if (body) {
48
+ const count = new Transform({ transform(chunk, _encoding, done) {
49
+ bytes += chunk.byteLength;
50
+ done(null, chunk);
51
+ } });
52
+ await pipeline(Readable.fromWeb(body), count, file.createWriteStream());
53
+ }
54
+ else await file.close();
55
+ return { path, name: path.slice(dir.length + 1), bytes };
56
+ } catch (error) {
57
+ await file.close().catch(() => {});
58
+ await unlink(path).catch(() => {});
59
+ throw error;
60
+ } finally {
61
+ await file.close().catch(() => {});
62
+ }
63
+ }
@@ -0,0 +1,83 @@
1
+ import * as PropertySymbol from "../../vendor/happy-dom/packages/happy-dom/src/PropertySymbol.ts";
2
+ import QuerySelector from "../../vendor/happy-dom/packages/happy-dom/src/query-selector/QuerySelector.ts";
3
+ import SelectorCombinatorEnum from "../../vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorCombinatorEnum.ts";
4
+ import SelectorParser from "../../vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts";
5
+
6
+ /**
7
+ * Selector matching for the style engine, which matches tens of thousands of
8
+ * stylesheet selectors against every element. It gives the answers of Happy
9
+ * DOM's Element.matches() (same parser, same per-compound matching), without
10
+ * what that pays on every call: re-validating and looking up the selector
11
+ * string, and recording a cached result on the element and the elements it
12
+ * visited. The style engine keeps its own results.
13
+ */
14
+
15
+ // Element.matches() rejects these before parsing (Happy DOM's QuerySelector).
16
+ const INVALID_SELECTOR = /^[.#\[]?\d|[.#]$/;
17
+
18
+ /**
19
+ * A selector compiled for matchesCompiled(), or null when it is invalid.
20
+ * `element` is any element of the document (it provides the window). The
21
+ * result does not depend on the document and may be kept with the selector.
22
+ * Returns undefined for objects that are not Happy DOM elements.
23
+ */
24
+ export function compileSelector(element, selector) {
25
+ const window = element?.[PropertySymbol.window];
26
+ if (!window) return undefined;
27
+ selector = String(selector);
28
+ if (INVALID_SELECTOR.test(selector)) return null;
29
+ try {
30
+ const groups = new SelectorParser({ window, scope: element, globalMatchFunction: QuerySelector.globalMatchFunction })
31
+ .getSelectorGroups(selector);
32
+ // Matching walks from the subject (the last compound) leftwards.
33
+ return groups.map((items) => items.slice().reverse());
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ /** Whether `element` matches a compiled selector, as element.matches(selector) would. */
40
+ export function matchesCompiled(element, compiled) {
41
+ for (const items of compiled) {
42
+ if (matchItems(element, element, items, 0, null)) return true;
43
+ }
44
+ return false;
45
+ }
46
+
47
+ /** Happy DOM's QuerySelector.matchSelector, reduced to a yes or no. */
48
+ function matchItems(scope, element, items, index, previous) {
49
+ const item = items[index];
50
+ if (!item) return false;
51
+ if (item.match(scope, element)) {
52
+ if (index === items.length - 1) return true;
53
+ switch (item.combinator) {
54
+ case SelectorCombinatorEnum.adjacentSibling: {
55
+ const sibling = element.previousElementSibling;
56
+ if (sibling && matchItems(scope, sibling, items, index + 1, item)) return true;
57
+ break;
58
+ }
59
+ case SelectorCombinatorEnum.none:
60
+ case SelectorCombinatorEnum.child:
61
+ case SelectorCombinatorEnum.descendant: {
62
+ const parent = element.parentNode;
63
+ if (parent && parent !== element[PropertySymbol.ownerDocument] && matchItems(scope, parent, items, index + 1, item)) return true;
64
+ break;
65
+ }
66
+ case SelectorCombinatorEnum.subsequentSibling: {
67
+ const parent = element.parentNode;
68
+ if (parent && parent !== element[PropertySymbol.ownerDocument]) {
69
+ const siblings = parent[PropertySymbol.elementArray];
70
+ for (let i = siblings.indexOf(element) - 1; i >= 0; i--) {
71
+ if (matchItems(scope, siblings[i], items, index + 1, item)) return true;
72
+ }
73
+ }
74
+ break;
75
+ }
76
+ }
77
+ }
78
+ if (previous?.combinator === SelectorCombinatorEnum.none || previous?.combinator === SelectorCombinatorEnum.descendant) {
79
+ const parent = element.parentNode;
80
+ if (parent && parent !== element[PropertySymbol.ownerDocument]) return matchItems(scope, parent, items, index, previous);
81
+ }
82
+ return false;
83
+ }