webview-preview 0.0.0-stage → 1.0.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.
@@ -0,0 +1,318 @@
1
+ import argparse
2
+ import json
3
+ import os
4
+ import sys
5
+
6
+ os.environ.setdefault("GDK_BACKEND", "x11")
7
+
8
+ import gi
9
+
10
+ gi.require_version("Gdk", "3.0")
11
+ gi.require_version("Gtk", "3.0")
12
+ gi.require_version("WebKit2", "4.1")
13
+
14
+ from gi.repository import Gdk, GLib, Gtk, WebKit2
15
+
16
+ INPUT_SETTLE_MS = 50
17
+ GDK_KEY_NAMES = {
18
+ "Tab": "Tab", "Enter": "Return", "Escape": "Escape", "Backspace": "BackSpace", "Delete": "Delete",
19
+ "Insert": "Insert", "Space": "space", "Home": "Home", "End": "End", "PageUp": "Page_Up", "PageDown": "Page_Down",
20
+ "ArrowUp": "Up", "ArrowDown": "Down", "ArrowLeft": "Left", "ArrowRight": "Right",
21
+ **{f"F{number}": f"F{number}" for number in range(1, 13)}
22
+ }
23
+ MODIFIER_KEYS = {"Shift": "Shift_L", "Control": "Control_L", "Alt": "Alt_L", "Meta": "Super_L"}
24
+ MODIFIER_MASKS = {
25
+ "Shift": Gdk.ModifierType.SHIFT_MASK,
26
+ "Control": Gdk.ModifierType.CONTROL_MASK,
27
+ "Alt": Gdk.ModifierType.MOD1_MASK,
28
+ "Meta": Gdk.ModifierType.SUPER_MASK
29
+ }
30
+
31
+
32
+ def emit(kind, value):
33
+ sys.stdout.write(f"{kind}\t{json.dumps(value)}\n")
34
+ sys.stdout.flush()
35
+
36
+
37
+ def parse_args():
38
+ parser = argparse.ArgumentParser()
39
+ parser.add_argument("url")
40
+ parser.add_argument("--steps", required=True)
41
+ parser.add_argument("--init", required=True)
42
+ parser.add_argument("--init-errors", required=True)
43
+ parser.add_argument("--out", required=True)
44
+ parser.add_argument("--prefix", required=True)
45
+ parser.add_argument("--width", type=int, required=True)
46
+ parser.add_argument("--height", type=int, required=True)
47
+ parser.add_argument("--snap-margin", type=int, required=True)
48
+ parser.add_argument("--headed", action="store_true")
49
+ parser.add_argument("--keep-storage", action="store_true")
50
+ parser.add_argument("--wait-for-timeout-ms", type=int, required=True)
51
+ parser.add_argument("--wait-for-poll-ms", type=int, required=True)
52
+ parser.add_argument("--load-settle-ms", type=int, required=True)
53
+ parser.add_argument("--js-settle-ms", type=int, required=True)
54
+ parser.add_argument("--timeout-ms", type=int, required=True)
55
+ return parser.parse_args()
56
+
57
+
58
+ def describe(value):
59
+ if value.is_string():
60
+ return value.to_string()
61
+ if value.is_undefined():
62
+ return "undefined"
63
+ return value.to_json(0) or value.to_string()
64
+
65
+
66
+ def keyval_for(key):
67
+ if key in GDK_KEY_NAMES:
68
+ return Gdk.keyval_from_name(GDK_KEY_NAMES[key])
69
+ return Gdk.unicode_to_keyval(ord(key))
70
+
71
+
72
+ def main():
73
+ args = parse_args()
74
+ steps = json.loads(args.steps)
75
+ init_scripts = json.loads(args.init)
76
+ os.makedirs(args.out, exist_ok=True)
77
+
78
+ window = Gtk.Window(title="webview-preview") if args.headed else Gtk.OffscreenWindow()
79
+ window.set_default_size(args.width, args.height)
80
+ window.set_size_request(args.width, args.height)
81
+ manager = WebKit2.UserContentManager()
82
+ for source in init_scripts:
83
+ manager.add_script(WebKit2.UserScript.new(
84
+ source,
85
+ WebKit2.UserContentInjectedFrames.TOP_FRAME,
86
+ WebKit2.UserScriptInjectionTime.START,
87
+ None,
88
+ None
89
+ ))
90
+ view = WebKit2.WebView(user_content_manager=manager, is_ephemeral=not args.keep_storage)
91
+ window.add(view)
92
+ window.show_all()
93
+
94
+ display = Gdk.Display.get_default()
95
+ seat = display.get_default_seat()
96
+ keymap = Gdk.Keymap.get_for_display(display)
97
+ state = {"index": 0, "failed": False, "started": False}
98
+
99
+ def finish(failed=False):
100
+ state["failed"] = state["failed"] or failed
101
+ Gtk.main_quit()
102
+ return False
103
+
104
+ def step_failed(message):
105
+ state["failed"] = True
106
+ emit("error", message)
107
+
108
+ def deliver(event):
109
+ if args.headed:
110
+ Gtk.main_do_event(event)
111
+ else:
112
+ window.event(event)
113
+
114
+ def focus_window():
115
+ event = Gdk.Event.new(Gdk.EventType.FOCUS_CHANGE)
116
+ event.focus_change.window = window.get_window()
117
+ event.focus_change.send_event = 1
118
+ event.focus_change.in_ = 1
119
+ event.set_device(seat.get_keyboard())
120
+ deliver(event)
121
+ view.grab_focus()
122
+
123
+ def key_event(kind, keyval, mask):
124
+ found, entries = keymap.get_entries_for_keyval(keyval)
125
+ event = Gdk.Event.new(kind)
126
+ event.key.window = window.get_window()
127
+ event.key.send_event = 1
128
+ event.key.time = Gtk.get_current_event_time()
129
+ event.key.keyval = keyval
130
+ event.key.state = mask
131
+ if found and entries:
132
+ event.key.hardware_keycode = entries[0].keycode
133
+ event.set_device(seat.get_keyboard())
134
+ deliver(event)
135
+
136
+ def press(keyval, modifiers=()):
137
+ mask = Gdk.ModifierType(0)
138
+ for modifier in modifiers:
139
+ key_event(Gdk.EventType.KEY_PRESS, Gdk.keyval_from_name(MODIFIER_KEYS[modifier]), mask)
140
+ mask |= MODIFIER_MASKS[modifier]
141
+ key_event(Gdk.EventType.KEY_PRESS, keyval, mask)
142
+ key_event(Gdk.EventType.KEY_RELEASE, keyval, mask)
143
+ for modifier in reversed(modifiers):
144
+ key_event(Gdk.EventType.KEY_RELEASE, Gdk.keyval_from_name(MODIFIER_KEYS[modifier]), mask)
145
+ mask &= ~MODIFIER_MASKS[modifier]
146
+
147
+ def mouse(kind, x, y):
148
+ event = Gdk.Event.new(kind)
149
+ fields = event.motion if kind == Gdk.EventType.MOTION_NOTIFY else event.button
150
+ if kind != Gdk.EventType.MOTION_NOTIFY:
151
+ fields.button = 1
152
+ fields.window = view.get_window()
153
+ fields.send_event = 1
154
+ fields.x = x
155
+ fields.y = y
156
+ fields.time = Gtk.get_current_event_time()
157
+ event.set_device(seat.get_pointer())
158
+ view.event(event)
159
+
160
+ def full_pixbuf():
161
+ if args.headed:
162
+ return Gdk.pixbuf_get_from_window(
163
+ view.get_window(), 0, 0, view.get_allocated_width(), view.get_allocated_height()
164
+ )
165
+ return window.get_pixbuf()
166
+
167
+ def save(name, pixbuf):
168
+ path = os.path.join(args.out, f"{args.prefix}{name}.png")
169
+ pixbuf.savev(path, "png", [], [])
170
+ emit("saved", path)
171
+
172
+ def evaluate(code, on_value, on_error):
173
+ def on_result(webview, result):
174
+ try:
175
+ on_value(describe(webview.evaluate_javascript_finish(result)))
176
+ except Exception as error:
177
+ on_error(str(error))
178
+
179
+ view.evaluate_javascript(code, -1, None, None, None, on_result)
180
+
181
+ def with_rect(step, use):
182
+ selector = step.get("selector") or step["value"]
183
+
184
+ def on_value(text):
185
+ rect = json.loads(text) if text and text != "null" else None
186
+ if not rect:
187
+ step_failed(f"{step['kind']}:{step['value']} failed: no element matches {selector}")
188
+ return next_step()
189
+ if rect["width"] <= 0 or rect["height"] <= 0:
190
+ step_failed(f"{step['kind']}:{step['value']} failed: {selector} has no size (hidden?)")
191
+ return next_step()
192
+ use(rect)
193
+
194
+ evaluate(step["rect"], on_value, lambda error: (step_failed(f"{step['kind']}:{step['value']} failed: {error}"), next_step()))
195
+
196
+ def snap_element(step, rect):
197
+ pixbuf = full_pixbuf()
198
+ width, height = view.get_allocated_width(), view.get_allocated_height()
199
+ scale = pixbuf.get_width() / width
200
+ margin = args.snap_margin
201
+ left = max(0, int(rect["x"] - margin))
202
+ top = max(0, int(rect["y"] - margin))
203
+ right = min(width, int(rect["x"] + rect["width"] + margin + 0.999))
204
+ bottom = min(height, int(rect["y"] + rect["height"] + margin + 0.999))
205
+ if right <= left or bottom <= top:
206
+ step_failed(f"snap:{step['value']} failed: element is outside the window")
207
+ return next_step()
208
+ cropped = pixbuf.new_subpixbuf(
209
+ int(left * scale), int(top * scale), int((right - left) * scale), int((bottom - top) * scale)
210
+ ).copy()
211
+ save(step["value"], cropped)
212
+ next_step()
213
+
214
+ def point_at(step, rect):
215
+ x = rect["x"] + rect["width"] / 2
216
+ y = rect["y"] + rect["height"] / 2
217
+ mouse(Gdk.EventType.MOTION_NOTIFY, x, y)
218
+ if step["kind"] == "click":
219
+ mouse(Gdk.EventType.BUTTON_PRESS, x, y)
220
+ mouse(Gdk.EventType.BUTTON_RELEASE, x, y)
221
+ GLib.timeout_add(INPUT_SETTLE_MS, run_step)
222
+
223
+ def wait_for(step, started):
224
+ def on_value(text):
225
+ if text == "true":
226
+ return next_step()
227
+ if (GLib.get_monotonic_time() - started) / 1000 >= args.wait_for_timeout_ms:
228
+ step_failed(f"waitfor:{step['value']} timed out after {args.wait_for_timeout_ms} ms")
229
+ return next_step()
230
+ GLib.timeout_add(args.wait_for_poll_ms, lambda: (wait_for(step, started), False)[1])
231
+
232
+ evaluate(step["check"], on_value, lambda error: (step_failed(f"waitfor:{step['value']} failed: {error}"), next_step()))
233
+
234
+ def next_step():
235
+ GLib.idle_add(run_step)
236
+
237
+ def run_step():
238
+ if state["index"] >= len(steps):
239
+ return finish()
240
+ step = steps[state["index"]]
241
+ state["index"] += 1
242
+ kind, value = step["kind"], step["value"]
243
+
244
+ if kind == "snap":
245
+ if step.get("selector"):
246
+ with_rect(step, lambda rect: snap_element(step, rect))
247
+ else:
248
+ save(value, full_pixbuf())
249
+ next_step()
250
+ elif kind in ("js", "jsnow"):
251
+ evaluate(value, lambda _: None, lambda error: emit("error", f"js failed: {error}"))
252
+ if kind == "js":
253
+ GLib.timeout_add(args.js_settle_ms, run_step)
254
+ else:
255
+ next_step()
256
+ elif kind == "wait":
257
+ GLib.timeout_add(int(value), run_step)
258
+ elif kind == "waitfor":
259
+ wait_for(step, GLib.get_monotonic_time())
260
+ elif kind == "resize":
261
+ width, height = (int(part) for part in value.split("x"))
262
+ window.set_size_request(width, height)
263
+ window.resize(width, height)
264
+ GLib.timeout_add(INPUT_SETTLE_MS, run_step)
265
+ elif kind == "print":
266
+ evaluate(
267
+ value,
268
+ lambda text: (emit("print", text), next_step()),
269
+ lambda error: (emit("error", f"print failed: {error}"), next_step())
270
+ )
271
+ elif kind in ("hover", "click"):
272
+ with_rect(step, lambda rect: point_at(step, rect))
273
+ elif kind == "key":
274
+ press(keyval_for(step["key"]), step["modifiers"])
275
+ GLib.timeout_add(INPUT_SETTLE_MS, run_step)
276
+ elif kind == "type":
277
+ for character in value:
278
+ press(Gdk.unicode_to_keyval(ord(character)))
279
+ GLib.timeout_add(INPUT_SETTLE_MS, run_step)
280
+ return False
281
+
282
+ def start_steps():
283
+ focus_window()
284
+ if not init_scripts:
285
+ return run_step()
286
+ evaluate(
287
+ args.init_errors,
288
+ lambda text: (emit("init-errors", text), next_step()),
289
+ lambda error: (emit("error", f"init check failed: {error}"), next_step())
290
+ )
291
+ return False
292
+
293
+ def on_load_changed(_view, event):
294
+ if event == WebKit2.LoadEvent.FINISHED and not state["started"]:
295
+ state["started"] = True
296
+ GLib.timeout_add(args.load_settle_ms, start_steps)
297
+
298
+ def on_load_failed(_view, _event, uri, error):
299
+ emit("error", f"failed to load {uri}: {error.message}")
300
+ return finish(failed=True)
301
+
302
+ def on_timeout():
303
+ emit("error", f"timed out after {args.timeout_ms} ms")
304
+ return finish(failed=True)
305
+
306
+ view.connect("load-changed", on_load_changed)
307
+ view.connect("load-failed", on_load_failed)
308
+ view.load_uri(args.url)
309
+ GLib.timeout_add(args.timeout_ms, on_timeout)
310
+ Gtk.main()
311
+
312
+ sys.stdout.flush()
313
+ sys.stderr.flush()
314
+ os._exit(1 if state["failed"] else 0)
315
+
316
+
317
+ if __name__ == "__main__":
318
+ main()
package/src/index.d.ts ADDED
@@ -0,0 +1,101 @@
1
+ export type Engine = "auto" | "webkitgtk" | "msedge" | "chrome" | "chromium" | "webkit";
2
+
3
+ export type Step =
4
+ | `js:${string}`
5
+ | `jsnow:${string}`
6
+ | `wait:${number}`
7
+ | `waitfor:${string}`
8
+ | `measure:${string}`
9
+ | `snap:${string}`
10
+ | `print:${string}`
11
+ | `resize:${number}x${number}`
12
+ | `key:${string}`
13
+ | `type:${string}`
14
+ | `hover:${string}`
15
+ | `click:${string}`
16
+ | `macro:${string}`;
17
+
18
+ export interface ServerOptions {
19
+ command: string;
20
+ port: number;
21
+ host?: string;
22
+ cwd?: string;
23
+ readyTimeoutMs?: number;
24
+ }
25
+
26
+ export interface PreviewConfig {
27
+ baseUrl?: string;
28
+ server?: ServerOptions;
29
+ engine?: Engine;
30
+ size?: `${number}x${number}`;
31
+ out?: string;
32
+ prefix?: string;
33
+ scale?: number;
34
+ init?: InitEntry | InitEntry[];
35
+ mock?: Record<string, MockResponse>;
36
+ logRequests?: boolean;
37
+ headed?: boolean;
38
+ keepStorage?: boolean;
39
+ waitTimeout?: number;
40
+ snapMargin?: number;
41
+ compare?: boolean;
42
+ updateBaseline?: boolean;
43
+ baseline?: string;
44
+ maxDiffPixels?: number;
45
+ compareTolerance?: number;
46
+ tauri?: boolean | TauriStub;
47
+ globals?: Record<string, unknown>;
48
+ locale?: string;
49
+ macros?: Record<string, Step[] | string[]>;
50
+ }
51
+
52
+ export interface TauriStub {
53
+ commands?: Record<string, unknown | ((args: Record<string, unknown>) => unknown)>;
54
+ label?: string;
55
+ }
56
+
57
+ export interface AnyApi {
58
+ readonly members: Record<string, unknown>;
59
+ }
60
+
61
+ export interface MockResponse {
62
+ status?: number;
63
+ json?: unknown;
64
+ body?: string;
65
+ headers?: Record<string, string>;
66
+ delayMs?: number;
67
+ }
68
+
69
+ export type InitEntry = string | { file: string }
70
+
71
+ export interface PreviewOptions extends PreviewConfig {
72
+ target?: string;
73
+ steps?: Step[] | string[];
74
+ initFile?: string | string[];
75
+ mockFile?: string | string[];
76
+ config?: string | false;
77
+ log?: { info: (message: string) => void; error: (message: string) => void }
78
+ }
79
+
80
+ export interface PreviewResult {
81
+ engine: Exclude<Engine, "auto">;
82
+ url: string;
83
+ saved: string[];
84
+ failed: boolean;
85
+ }
86
+
87
+ export declare const ENGINES: Engine[];
88
+ export declare const REQUESTS_KEY: "__webviewPreviewRequests";
89
+ export declare function resolveUrl(target: string, baseUrl?: string): string;
90
+ export declare function preview(options?: PreviewOptions): Promise<PreviewResult>;
91
+ export declare function defineConfig(config: PreviewConfig): PreviewConfig;
92
+ export declare function anyApi(members?: Record<string, unknown>): AnyApi;
93
+ export interface CleanOptions {
94
+ config?: string | false;
95
+ out?: string;
96
+ baseline?: string;
97
+ olderThan?: string;
98
+ dryRun?: boolean;
99
+ }
100
+
101
+ export declare function cleanSnapshots(options?: CleanOptions): Promise<{ removed: string[]; out: string }>;
package/src/index.mjs ADDED
@@ -0,0 +1,101 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { compareSnapshots } from "#src/snapshots/compare.mjs";
4
+ import { loadConfig } from "#src/core/config.mjs";
5
+ import { resolveEngine, runEngine } from "#src/engines/index.mjs";
6
+ import { checkLocale, globalsScript, localeScript } from "#src/page/globals.mjs";
7
+ import { resolveInit, wrapInit } from "#src/page/init.mjs";
8
+ import { compileMock, mockScript, resolveMockFiles } from "#src/page/mock.mjs";
9
+ import { startServer } from "#src/core/server.mjs";
10
+ import { expandMacros, parseSize, parseSteps } from "#src/core/steps.mjs";
11
+ import { resolveTauri, tauriScript } from "#src/page/tauri.mjs";
12
+
13
+ export { ENGINES } from "#src/engines/index.mjs";
14
+ export { REQUESTS_KEY } from "#src/page/mock.mjs";
15
+ export { cleanSnapshots } from "#src/snapshots/clean.mjs";
16
+ export { anyApi } from "#src/page/source.mjs";
17
+
18
+ const DEFAULTS = { engine: "auto", size: "1200x650", out: ".webview-preview", steps: ["snap:page"] }
19
+ const ABSOLUTE_URL = /^[a-z][a-z\d+.-]*:/i;
20
+
21
+ const consoleLog = {
22
+ info: message => console.log(message),
23
+ error: message => console.error(message)
24
+ }
25
+
26
+ export const resolveUrl = (target, baseUrl) => {
27
+ if (ABSOLUTE_URL.test(target)) return target;
28
+ if (!baseUrl) throw new Error(`"${target}" is not a full URL; set baseUrl to open routes`);
29
+ return `${baseUrl}${target.startsWith("/") ? target : `/${target}`}`;
30
+ }
31
+
32
+ export const preview = async (options = {}) => {
33
+ const config = options.config === false ? {} : await loadConfig(options.config);
34
+ const merged = { ...DEFAULTS, ...config, ...Object.fromEntries(Object.entries(options).filter(([, value]) => value !== undefined)) }
35
+ const baseDir = config.configDir ?? process.cwd();
36
+ const engine = resolveEngine(merged.engine);
37
+ const macros = { ...config.macros, ...options.macros }
38
+ const steps = parseSteps(expandMacros(merged.steps.length ? merged.steps : DEFAULTS.steps, macros));
39
+ const out = path.resolve(baseDir, merged.out);
40
+ const url = resolveUrl(merged.target ?? merged.url ?? "/", merged.baseUrl);
41
+ parseSize(merged.size);
42
+ const mockFiles = await resolveMockFiles(options.mockFile, process.cwd());
43
+ const hasMock = config.mock !== undefined || options.mock !== undefined || mockFiles.length > 0
44
+ || !!(options.logRequests ?? config.logRequests);
45
+ const mockRules = compileMock([config.mock, ...mockFiles, options.mock]);
46
+ const locale = merged.locale === undefined ? undefined : checkLocale(String(merged.locale));
47
+ const tauri = resolveTauri(config.tauri, options.tauri);
48
+ const globals = { ...config.globals, ...options.globals }
49
+ const stubs = [
50
+ locale && { label: "locale", source: localeScript(locale) },
51
+ tauri && { label: "tauri", source: tauriScript(tauri) },
52
+ Object.keys(globals).length && { label: "globals", source: globalsScript(globals) }
53
+ ].filter(Boolean).map(stub => {
54
+ try {
55
+ new Function(stub.source);
56
+ } catch (error) {
57
+ throw new Error(`${stub.label}: the generated script does not parse (${error.message}); check the functions in your config`);
58
+ }
59
+ return wrapInit(stub);
60
+ });
61
+ const init = stubs.concat(hasMock ? [mockScript(mockRules)] : [], resolveInit([
62
+ { entries: config.init, baseDir },
63
+ { entries: options.init, baseDir: process.cwd() },
64
+ { entries: options.initFile && [].concat(options.initFile).map(file => ({ file })), baseDir: process.cwd() }
65
+ ], baseDir));
66
+ mkdirSync(out, { recursive: true });
67
+
68
+ const server = merged.server && { cwd: baseDir, ...merged.server }
69
+ const stopServer = await startServer(server);
70
+ try {
71
+ const result = await runEngine(engine, {
72
+ url,
73
+ steps,
74
+ init,
75
+ size: merged.size,
76
+ locale,
77
+ out,
78
+ prefix: merged.prefix ?? `${engine}-`,
79
+ scale: merged.scale === undefined ? undefined : Number(merged.scale),
80
+ snapMargin: Number(merged.snapMargin ?? 8),
81
+ isHeaded: !!merged.headed,
82
+ isStorageKept: !!merged.keepStorage,
83
+ waitForTimeoutMs: Number(merged.waitTimeout ?? 10000),
84
+ log: merged.log ?? consoleLog
85
+ });
86
+ const shouldCompare = !!(merged.compare || merged.updateBaseline);
87
+ const compareFailed = shouldCompare && compareSnapshots({
88
+ saved: result.saved,
89
+ baselineDir: path.resolve(baseDir, merged.baseline ?? "webview-preview-baseline"),
90
+ updateBaseline: !!merged.updateBaseline,
91
+ tolerance: Number(merged.compareTolerance ?? 2),
92
+ maxDiffPixels: Number(merged.maxDiffPixels ?? 0),
93
+ log: merged.log ?? consoleLog
94
+ });
95
+ return { engine, url, ...result, failed: result.failed || compareFailed }
96
+ } finally {
97
+ stopServer();
98
+ }
99
+ }
100
+
101
+ export const defineConfig = config => config;
@@ -0,0 +1,37 @@
1
+ import { ANY_FUNCTION, toSource } from "#src/page/source.mjs";
2
+
3
+ const GLOBAL_PATH = /^(window\.)?[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*$/;
4
+ const LOCALE = /^[a-z]{2,3}(-[A-Za-z\d]{2,8})*$/i;
5
+
6
+ export const globalsScript = globals => {
7
+ const entries = Object.entries(globals).map(([path, value]) => {
8
+ if (!GLOBAL_PATH.test(path)) throw new Error(`globals: "${path}" must be a dotted path like window.api or window.go.main`);
9
+ const name = path.startsWith("window.") ? path : `window.${path}`;
10
+ return `assign(${JSON.stringify(name)}, ${toSource(value, name)});`;
11
+ });
12
+ return `(() => {
13
+ const __webviewPreviewAny = ${ANY_FUNCTION};
14
+ const assign = (path, value) => {
15
+ const parts = path.split(".").slice(1);
16
+ const last = parts.pop();
17
+ let target = window;
18
+ for (const part of parts) {
19
+ if (target[part] === undefined || target[part] === null) target[part] = {};
20
+ target = target[part];
21
+ }
22
+ target[last] = value;
23
+ }
24
+ ${entries.join("\n\t")}
25
+ })();`;
26
+ }
27
+
28
+ export const checkLocale = locale => {
29
+ if (!LOCALE.test(locale)) throw new Error(`locale must look like en-US or ru, got "${locale}"`);
30
+ return locale;
31
+ }
32
+
33
+ export const localeScript = locale => `(() => {
34
+ const language = ${JSON.stringify(checkLocale(locale))};
35
+ Object.defineProperty(Navigator.prototype, "language", { get: () => language, configurable: true });
36
+ Object.defineProperty(Navigator.prototype, "languages", { get: () => [language], configurable: true });
37
+ })();`;
@@ -0,0 +1,32 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ const ERRORS_KEY = "__webviewPreviewInitErrors";
5
+ export const INIT_ERRORS_EXPRESSION = `JSON.stringify(window.${ERRORS_KEY} || [])`;
6
+ const toList = value => value === undefined || value === null ? [] : Array.isArray(value) ? value : [value];
7
+
8
+ const readEntry = (entry, baseDir, index) => {
9
+ if (typeof entry === "string") return { label: `init[${index}]`, source: entry }
10
+ if (entry && typeof entry.file === "string") {
11
+ const file = path.resolve(baseDir, entry.file);
12
+ if (!existsSync(file)) throw new Error(`init file not found: ${file}`);
13
+ return { label: path.basename(file), source: readFileSync(file, "utf8") }
14
+ }
15
+ throw new Error(`init entries must be code strings or { file: "path" }, got ${JSON.stringify(entry)}`);
16
+ }
17
+
18
+ export const wrapInit = ({ label, source }) => `{
19
+ try {
20
+ ${source}
21
+ } catch (error) {
22
+ (window.${ERRORS_KEY} = window.${ERRORS_KEY} || []).push(${JSON.stringify(label)} + ": " + (error && error.message || error));
23
+ }
24
+ }`;
25
+
26
+ export const resolveInit = (groups, baseDir) => groups
27
+ .flatMap(group => toList(group.entries).map(entry => ({ entry, baseDir: group.baseDir })))
28
+ .map(({ entry, baseDir: dir }, index) => wrapInit(readEntry(entry, dir ?? baseDir, index)));
29
+
30
+ export const reportInitErrors = (json, log) => {
31
+ for (const message of JSON.parse(json || "[]")) log.error(`init failed: ${message}`);
32
+ }