Source RapidContext_Browser.js

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