Source RapidContext_Browser.js

1/*
2 * RapidContext <https://www.rapidcontext.com/>
3 * Copyright (c) 2007-2026 Per Cederberg. All rights reserved.
4 *
5 * This program is free software: you can redistribute it and/or
6 * modify it under the terms of the BSD license.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
11 *
12 * See the RapidContext LICENSE for more details.
13 */
14
15/**
16 * Provides a browser compatibility and diagnostics information.
17 * @namespace RapidContext.Browser
18 */
19(function (window) {
20    /* eslint no-var: "off", prefer-template: "off" */
21
22    /**
23     * List of all required browser features (ie. Chrome 86+, Firefox 80+, Safari 14+).
24     *
25     * @name REQUIRED
26     * @memberof RapidContext.Browser
27     * @constant
28     */
29    var REQUIRED = [
30        "AbortController",
31        "AbortSignal",
32        "Array.isArray",
33        "Array.from",
34        "Array.of",
35        "Array.prototype.every",
36        "Array.prototype.filter",
37        "Array.prototype.find",
38        "Array.prototype.findIndex",
39        "Array.prototype.flat",
40        "Array.prototype.flatMap",
41        "Array.prototype.forEach",
42        "Array.prototype.includes",
43        "Array.prototype.map",
44        "Array.prototype.reduce",
45        "Array.prototype.some",
46        "BigInt",
47        "Blob",
48        "CSS.supports",
49        "CustomEvent",
50        "Date.now",
51        "Date.prototype.toISOString",
52        "Date.prototype.toLocaleString",
53        "DOMParser",
54        "DOMTokenList.prototype.add",
55        "DOMTokenList.prototype.contains",
56        "DOMTokenList.prototype.remove",
57        "DOMTokenList.prototype.toggle",
58        "Element.prototype.after",
59        "Element.prototype.append",
60        "Element.prototype.before",
61        "Element.prototype.closest",
62        "Element.prototype.matches",
63        "Element.prototype.prepend",
64        "Element.prototype.remove",
65        "Element.prototype.replaceChildren",
66        "Element.prototype.replaceWith",
67        "FileReader",
68        "FormData",
69        "Function.prototype.bind",
70        "HTMLTemplateElement",
71        "HTMLSlotElement",
72        "JSON.parse",
73        "JSON.stringify",
74        "Math.imul",
75        "Node.prototype.contains",
76        "NodeList.prototype.forEach",
77        "Number.prototype.toLocaleString",
78        "Object.assign",
79        "Object.create",
80        "Object.defineProperty",
81        "Object.entries",
82        "Object.fromEntries",
83        "Object.getOwnPropertyDescriptor",
84        "Object.getOwnPropertyNames",
85        "Object.getPrototypeOf",
86        "Object.is",
87        "Object.keys",
88        "Object.setPrototypeOf",
89        "Object.values",
90        "Promise",
91        "Promise.all",
92        "Promise.allSettled",
93        "Promise.any",
94        "Promise.race",
95        "Promise.reject",
96        "Promise.resolve",
97        "Promise.prototype.finally",
98        "ResizeObserver",
99        "Set",
100        "String.prototype.endsWith",
101        "String.prototype.includes",
102        "String.prototype.matchAll",
103        "String.prototype.padEnd",
104        "String.prototype.padStart",
105        "String.prototype.replaceAll",
106        "String.prototype.startsWith",
107        "String.prototype.trim",
108        "String.raw", // proxy for template literals
109        "TextDecoder",
110        "TextEncoder",
111        "URL",
112        "URL.createObjectURL",
113        "URL.revokeObjectURL",
114        "URLSearchParams",
115        "WeakRef",
116        "console.assert",
117        "console.debug",
118        "console.error",
119        "console.info",
120        "console.log",
121        "console.warn",
122        "document.querySelectorAll",
123        "fetch",
124        "localStorage.getItem",
125        "sessionStorage.setItem",
126        { test: "'head' in document", name: "document.head shortcut" },
127        { test: "'activeElement' in document", name: "document.activeElement" },
128        { test: "'classList' in Element.prototype", name: "Element.prototype.classList" },
129        { test: "'dataset' in HTMLElement.prototype", name: "HTMLElement.prototype.dataset" },
130        { test: "'onload' in HTMLLinkElement.prototype", name: "<link> element onload" },
131        {
132            test: "var el = document.createElement('div');el.style.cssText='width:calc(10px);';!!el.style.length",
133            name: "CSS calc()"
134        },
135        { test: "let a = 2; a === 2", name: "Let statements" },
136        { test: "const a = 3; a === 3", name: "Const statements" },
137        { test: "for (var el of []) el; true", name: "loops with for...of" },
138        { test: "var a = 42; ({ a }).a === 42", name: "shorthand property names" },
139        { test: "({ f(arg) { return arg; } }).f(true)", name: "shorthand method names" },
140        { test: "var k = 'a'; ({ [k]: 42 })['a'] === 42", name: "computed property names" },
141        { test: "((a,  b, ...args) => true)()", name: "rest parameters" },
142        { test: "Math.max(...[1, 2, 3]) === 3", name: "spread parameters" },
143        { test: "[...[1, 2, 3]].length === 3", name: "spread array literals" },
144        { test: "({...{ a: 42}}).a == 42", name: "spread object literals" },
145        { test: "var [a, ...b] = [1, 2, 3]; a == 1 && b.length == 2", name: "array destructuring assignment" },
146        { test: "var {a, b} = { a: 1, b: 2, c: 3 }; a == 1 && b == 2", name: "object destructuring assignment" },
147        { test: "123_456.78_9_12 == 123456.78912", name: "numeric separators (_)" },
148        { test: "class Test {}; true", name: "Class declarations" },
149        { test: "(() => true)()", name: "Arrow functions" },
150        { test: "async () => await Promise.resolve(true)", name: "async/await functions" },
151        { test: "typeof(Symbol.iterator) === 'symbol'", name: "Symbol.iterator" },
152        { test: "globalThis === window", name: "globalThis" },
153        { test: "'key' in KeyboardEvent.prototype", name: "KeyboardEvent.key" },
154        { test: "/^\\p{L}+$/u.test('a\u00E5\u00C4')", name: "regexp Unicode character class escape" },
155        { test: "'noModule' in HTMLScriptElement.prototype", name: "ES modules (script element)" },
156        { test: "var a = null; a?.dummy; true", name: "optional chaining operator (?.)" },
157        { test: "null ?? true", name: "nullish coalescing operator (??)" },
158        { test: "var a = true; a &&= true; a", name: "logical and assignment operator (&&=)" },
159        { test: "var a = false; a ||= true; a", name: "logical or assignment operator (||=)" },
160        { test: "var a = null; a ??= true; a", name: "nullish assignment operator (??=)" },
161        { test: "class Test { a; b = 123; static c = true; }; Test.c", name: "public class fields" },
162        { test: "class Test { #a; #b = 123; static #c = 0; }; true", name: "private class fields" },
163        "--variable-name: #fff",
164        "display: flex",
165        "appearance: none",
166        "inset: 0",
167    ];
168
169    /**
170     * List of all optional (or recommended) browser features. These are
171     * not used in built-in libraries and apps, but will be in the future.
172     *
173     * @name OPTIONAL
174     * @memberof RapidContext.Browser
175     * @constant
176     */
177    var OPTIONAL = [
178        "Array.prototype.at",
179        "Array.prototype.findLast",
180        "Array.prototype.findLastIndex",
181        // "Array.prototype.toReversed",
182        // "Array.prototype.toSorted",
183        // "Array.prototype.toSpliced",
184        // "Array.prototype.with",
185        "Element.prototype.attachShadow",
186        // "Element.prototype.checkVisibility",
187        // "Element.prototype.scrollIntoView",
188        "HTMLDialogElement",
189        // "HTMLScriptElement.supports",
190        // "Map.groupBy",
191        // "Object.groupBy",
192        "Object.hasOwn",
193        // "Promise.withResolvers",
194        // unicodeSets
195        // "Set.prototype.difference",
196        // "Set.prototype.intersection",
197        // "Set.prototype.isDisjointFrom",
198        // "Set.prototype.isSubsetOf",
199        // "Set.prototype.isSupersetOf",
200        // "Set.prototype.union",
201        // "Temporal",
202        "customElements.define",
203        // { test: "HTMLScriptElement.supports('module')", name: "ES module (script element, modern test)", },
204        // { test: "HTMLScriptElement.supports('importmap')", name: "ES module import maps", },
205        { test: "new Error('test', { cause: true }).cause", name: "error cause property" },
206        { test: "class Test { static { 1; } }; true", name: "static class initialization block" },
207        // { test: "'unicodeSets' in RegExp.prototype", name: "regexp unicodeSets" },
208        "selector(:has(*))",
209        // "color: color-mix(in srgb, #000, #fff)",
210        // "line-clamp: 3",
211        // "user-select: none",
212    ];
213
214    /**
215     * Checks if the browser supports all required APIs.
216     *
217     * @return {boolean} true if the browser is supported, or false otherwise
218     *
219     * @memberof RapidContext.Browser
220     */
221    function isSupported() {
222        var hasRequired = supports(REQUIRED);
223        var hasOptional = supports(OPTIONAL);
224        if (!hasRequired) {
225            console.error("browser: required features are missing", info());
226        } else if (!hasOptional) {
227            console.warn("browser: some recommended features are missing", info());
228        }
229        return hasRequired;
230    }
231
232    /**
233     * Checks for browser support for one or more specified APIs. Supports
234     * checking JavaScript APIs, JavaScript syntax and CSS support.
235     *
236     * @param {...(string|Object|Array)} feature one or more features to check
237     * @return {boolean} `true` if supported, or `false` otherwise
238     *
239     * @example
240     * RapidContext.Browser.supports("Array.isArray") ==> true;
241     * RapidContext.Browser.supports({ test: "let a = 2; a === 2", name: "Let statements" }) ==> true;
242     * RapidContext.Browser.supports("display: flex") ==> false;
243     *
244     * @memberof RapidContext.Browser
245     */
246    function supports(feature/*, ...*/) {
247        function checkCSS(code) {
248            try {
249                return CSS.supports("(" + code + ")");
250            } catch (ignore) {
251                return false;
252            }
253        }
254        function checkPath(base, path) {
255            while (base && path.length > 0) {
256                base = base[path.shift()];
257            }
258            return typeof(base) === "function";
259        }
260        function checkEval(code) {
261            try {
262                /* eslint no-eval: "off" */
263                var val = eval(code);
264                return val !== undefined && val !== null;
265            } catch (ignore) {
266                return false;
267            }
268        }
269        function check(test) {
270            var isCSS = /^[a-z-]+:|selector\(/i.test(test);
271            var isPath = /^[a-z]+(\.[a-z]+)*$/i.test(test);
272            var isValid = (
273                (isCSS && checkCSS(test)) ||
274                (isPath && checkPath(window, test.split("."))) ||
275                (!isCSS && !isPath && checkEval(test))
276            );
277            return isValid;
278        }
279        // NOTE: Code below uses old school JavaScript for compatibility
280        var res = true;
281        var features = (feature instanceof Array) ? feature : Array.prototype.slice.call(arguments);
282        for (var i = 0; i < features.length; i++) {
283            var def = features[i].test ? features[i] : { test: features[i] };
284            if (!check(def.test)) {
285                var explain = [def.name, def.test].filter(Boolean).join(": ");
286                console.warn("browser: missing support for " + explain);
287                res = false;
288            }
289        }
290        return res;
291    }
292
293    /**
294     * Returns browser information version and platform information.
295     *
296     * @param {string} [userAgent] the agent string, or undefined for this browser
297     * @return {Object} a browser meta-data object
298     *
299     * @memberof RapidContext.Browser
300     */
301    function info(userAgent) {
302        function firstMatch(patterns, text) {
303            for (var k in patterns) {
304                var m = patterns[k].exec(text);
305                if (m) {
306                    var extra = m[1] ? " " + m[1] : "";
307                    if (/^[ \d_.]+$/.test(extra)) {
308                        extra = extra.replace(/_/g, ".");
309                    }
310                    return k + extra;
311                }
312            }
313            return null;
314        }
315        function clone(obj, keys) {
316            var res = {};
317            for (var i = 0; i < keys.length; i++) {
318                var k = keys[i];
319                res[k] = obj[k];
320            }
321            return res;
322        }
323        var BROWSERS = {
324            "Bot": /Bot|Spider|PhantomJS|Headless|Electron|slimerjs|Python/i,
325            "Edge": /Edg(?:e|A|iOS|)\/([^\s;]+)/,
326            "Chrome": /Chrome\/([^\s;]+)/,
327            "Chrome iOS": /CriOS\/([^\s;]+)/,
328            "Firefox": /Firefox\/([^\s;]+)/,
329            "Firefox iOS": /FxiOS\/([^\s;]+)/,
330            "Safari": /Version\/(\S+).* Safari\/[^\s;]+/,
331            "MSIE": /(?:MSIE |Trident\/.*rv:)([^\s;)]+)/
332        };
333        var PLATFORMS = {
334            "Android": /Android ([^;)]+)/,
335            "Chrome OS": /CrOS [^\s]+ ([^;)]+)/,
336            "iOS": /(?:iPhone|CPU) OS ([\d_]+)/,
337            "macOS": /Mac OS X ([^;)]+)/,
338            "Linux": /Linux ([^;)]+)/,
339            "Windows": /Windows ([^;)]+)/
340        };
341        var DEVICES = {
342            "iPad": /iPad/,
343            "iPhone": /iPhone/,
344            "Tablet": /Tablet/,
345            "Mobile": /Mobile|Android/
346        };
347        var ua = userAgent || window.navigator.userAgent;
348        var browser = firstMatch(BROWSERS, ua);
349        var platform = firstMatch(PLATFORMS, ua);
350        var device = firstMatch(DEVICES, ua);
351        var res = {};
352        if (browser && platform) {
353            res["browser"] = browser;
354            res["platform"] = platform;
355            res["device"] = device || "Desktop/Other";
356        }
357        if (!userAgent) {
358            res["language"] = window.navigator.language;
359            res["screen"] = clone(window.screen, ["width", "height", "colorDepth"]);
360            res["window"] = clone(window, ["innerWidth", "innerHeight", "devicePixelRatio"]);
361            res["cookies"] = _cookies();
362        }
363        res["userAgent"] = String(ua);
364        return res;
365    }
366
367    function _cookies() {
368        var cookies = {};
369        var pairs = window.document.cookie.split(/\s*;\s*/g);
370        pairs.forEach(function (pair) {
371            var name = pair.split("=")[0];
372            var value = pair.substr(name.length + 1);
373            if (name && value) {
374                cookies[decodeURIComponent(name)] = decodeURIComponent(value);
375            }
376        });
377        return cookies;
378    }
379
380    /**
381     * Gets, sets or removes browser cookies.
382     *
383     * @param {string} [name] the cookie name to get/set
384     * @param {string} [value] the cookie value to set, or null to remove
385     * @return {Object|string} all cookie values or a single value
386     *
387      * @memberof RapidContext.Browser
388     */
389    function cookie(name, value) {
390        if (name === undefined || name === null) {
391            return _cookies();
392        } else if (value === undefined) {
393            return _cookies()[name];
394        } else if (value === null) {
395            var prefix = encodeURIComponent(name) + "=";
396            var suffix = ";path=/;expires=" + new Date(0).toUTCString();
397            var domain = window.location.hostname.split(".");
398            while (domain.length > 1) {
399                var domainPart = ";domain=" + domain.join(".");
400                window.document.cookie = prefix + domainPart + suffix;
401                domain.shift();
402            }
403            window.document.cookie = prefix + suffix;
404            return null;
405        } else {
406            window.document.cookie = encodeURIComponent(name) + "=" + encodeURIComponent(value) + ";path=/";
407            return value;
408        }
409    }
410
411    // Create namespaces
412    var RapidContext = window.RapidContext || (window.RapidContext = {});
413    var module = RapidContext.Browser || (RapidContext.Browser = {});
414
415    // Export namespace symbols
416    module.REQUIRED = REQUIRED;
417    module.OPTIONAL = OPTIONAL;
418    module.isSupported = isSupported;
419    module.supports = supports;
420    module.info = info;
421    module.cookie = cookie;
422
423})(globalThis);
424