From 2de003154d52b2e2dc7256fee3dc67cc573c588e Mon Sep 17 00:00:00 2001 From: gziolo Date: Wed, 31 Jan 2024 08:31:29 +0000 Subject: [PATCH] Script Modules API: Add import map polyfill for older browsers Syncs the changes from https://github.com/WordPress/gutenberg/pull/58263. Adds a polyfill to make import maps compatible with unsported browsers (https://caniuse.com/import-maps). Fixes #60348. Props cbravobernal, jorbin, luisherranz, jonsurrell. Built from https://develop.svn.wordpress.org/trunk@57492 git-svn-id: http://core.svn.wordpress.org/trunk@56993 1a063a9b-81f0-0310-95a4-ce76da25c4cd --- wp-includes/class-wp-script-modules.php | 16 + .../js/dist/vendor/wp-polyfill-importmap.js | 970 ++++++++++++++++++ .../dist/vendor/wp-polyfill-importmap.min.js | 1 + wp-includes/script-loader.php | 2 + wp-includes/version.php | 2 +- 5 files changed, 990 insertions(+), 1 deletion(-) create mode 100644 wp-includes/js/dist/vendor/wp-polyfill-importmap.js create mode 100644 wp-includes/js/dist/vendor/wp-polyfill-importmap.min.js diff --git a/wp-includes/class-wp-script-modules.php b/wp-includes/class-wp-script-modules.php index 7eebb79da1..b2413d0f24 100644 --- a/wp-includes/class-wp-script-modules.php +++ b/wp-includes/class-wp-script-modules.php @@ -211,10 +211,26 @@ class WP_Script_Modules { * Prints the import map using a script tag with a type="importmap" attribute. * * @since 6.5.0 + * + * @global WP_Scripts $wp_scripts The WP_Scripts object for printing the polyfill. */ public function print_import_map() { $import_map = $this->get_import_map(); if ( ! empty( $import_map['imports'] ) ) { + global $wp_scripts; + if ( isset( $wp_scripts ) ) { + wp_print_inline_script_tag( + wp_get_script_polyfill( + $wp_scripts, + array( + 'HTMLScriptElement.supports && HTMLScriptElement.supports("importmap")' => 'wp-polyfill-importmap', + ) + ), + array( + 'id' => 'wp-load-polyfill-importmap', + ) + ); + } wp_print_inline_script_tag( wp_json_encode( $import_map, JSON_HEX_TAG | JSON_HEX_AMP ), array( diff --git a/wp-includes/js/dist/vendor/wp-polyfill-importmap.js b/wp-includes/js/dist/vendor/wp-polyfill-importmap.js new file mode 100644 index 0000000000..7ec4661ca0 --- /dev/null +++ b/wp-includes/js/dist/vendor/wp-polyfill-importmap.js @@ -0,0 +1,970 @@ +/* ES Module Shims Wasm 1.8.2 */ +(function () { + + const hasWindow = typeof window !== 'undefined'; + const hasDocument = typeof document !== 'undefined'; + + const noop = () => {}; + + const optionsScript = hasDocument ? document.querySelector('script[type=esms-options]') : undefined; + + const esmsInitOptions = optionsScript ? JSON.parse(optionsScript.innerHTML) : {}; + Object.assign(esmsInitOptions, self.esmsInitOptions || {}); + + let shimMode = hasDocument ? !!esmsInitOptions.shimMode : true; + + const importHook = globalHook(shimMode && esmsInitOptions.onimport); + const resolveHook = globalHook(shimMode && esmsInitOptions.resolve); + let fetchHook = esmsInitOptions.fetch ? globalHook(esmsInitOptions.fetch) : fetch; + const metaHook = esmsInitOptions.meta ? globalHook(shimMode && esmsInitOptions.meta) : noop; + + const mapOverrides = esmsInitOptions.mapOverrides; + + let nonce = esmsInitOptions.nonce; + if (!nonce && hasDocument) { + const nonceElement = document.querySelector('script[nonce]'); + if (nonceElement) + nonce = nonceElement.nonce || nonceElement.getAttribute('nonce'); + } + + const onerror = globalHook(esmsInitOptions.onerror || noop); + const onpolyfill = esmsInitOptions.onpolyfill ? globalHook(esmsInitOptions.onpolyfill) : () => { + console.log('%c^^ Module TypeError above is polyfilled and can be ignored ^^', 'font-weight:900;color:#391'); + }; + + const { revokeBlobURLs, noLoadEventRetriggers, enforceIntegrity } = esmsInitOptions; + + function globalHook (name) { + return typeof name === 'string' ? self[name] : name; + } + + const enable = Array.isArray(esmsInitOptions.polyfillEnable) ? esmsInitOptions.polyfillEnable : []; + const cssModulesEnabled = enable.includes('css-modules'); + const jsonModulesEnabled = enable.includes('json-modules'); + + const edge = !navigator.userAgentData && !!navigator.userAgent.match(/Edge\/\d+\.\d+/); + + const baseUrl = hasDocument + ? document.baseURI + : `${location.protocol}//${location.host}${location.pathname.includes('/') + ? location.pathname.slice(0, location.pathname.lastIndexOf('/') + 1) + : location.pathname}`; + + const createBlob = (source, type = 'text/javascript') => URL.createObjectURL(new Blob([source], { type })); + let { skip } = esmsInitOptions; + if (Array.isArray(skip)) { + const l = skip.map(s => new URL(s, baseUrl).href); + skip = s => l.some(i => i[i.length - 1] === '/' && s.startsWith(i) || s === i); + } + else if (typeof skip === 'string') { + const r = new RegExp(skip); + skip = s => r.test(s); + } else if (skip instanceof RegExp) { + skip = s => skip.test(s); + } + + const eoop = err => setTimeout(() => { throw err }); + + const throwError = err => { (self.reportError || hasWindow && window.safari && console.error || eoop)(err), void onerror(err); }; + + function fromParent (parent) { + return parent ? ` imported from ${parent}` : ''; + } + + let importMapSrcOrLazy = false; + + function setImportMapSrcOrLazy () { + importMapSrcOrLazy = true; + } + + // shim mode is determined on initialization, no late shim mode + if (!shimMode) { + if (document.querySelectorAll('script[type=module-shim],script[type=importmap-shim],link[rel=modulepreload-shim]').length) { + shimMode = true; + } + else { + let seenScript = false; + for (const script of document.querySelectorAll('script[type=module],script[type=importmap]')) { + if (!seenScript) { + if (script.type === 'module' && !script.ep) + seenScript = true; + } + else if (script.type === 'importmap' && seenScript) { + importMapSrcOrLazy = true; + break; + } + } + } + } + + const backslashRegEx = /\\/g; + + function asURL (url) { + try { + if (url.indexOf(':') !== -1) + return new URL(url).href; + } + catch (_) {} + } + + function resolveUrl (relUrl, parentUrl) { + return resolveIfNotPlainOrUrl(relUrl, parentUrl) || (asURL(relUrl) || resolveIfNotPlainOrUrl('./' + relUrl, parentUrl)); + } + + function resolveIfNotPlainOrUrl (relUrl, parentUrl) { + const hIdx = parentUrl.indexOf('#'), qIdx = parentUrl.indexOf('?'); + if (hIdx + qIdx > -2) + parentUrl = parentUrl.slice(0, hIdx === -1 ? qIdx : qIdx === -1 || qIdx > hIdx ? hIdx : qIdx); + if (relUrl.indexOf('\\') !== -1) + relUrl = relUrl.replace(backslashRegEx, '/'); + // protocol-relative + if (relUrl[0] === '/' && relUrl[1] === '/') { + return parentUrl.slice(0, parentUrl.indexOf(':') + 1) + relUrl; + } + // relative-url + else if (relUrl[0] === '.' && (relUrl[1] === '/' || relUrl[1] === '.' && (relUrl[2] === '/' || relUrl.length === 2 && (relUrl += '/')) || + relUrl.length === 1 && (relUrl += '/')) || + relUrl[0] === '/') { + const parentProtocol = parentUrl.slice(0, parentUrl.indexOf(':') + 1); + if (parentProtocol === 'blob:') { + throw new TypeError(`Failed to resolve module specifier "${relUrl}". Invalid relative url or base scheme isn't hierarchical.`); + } + // Disabled, but these cases will give inconsistent results for deep backtracking + //if (parentUrl[parentProtocol.length] !== '/') + // throw new Error('Cannot resolve'); + // read pathname from parent URL + // pathname taken to be part after leading "/" + let pathname; + if (parentUrl[parentProtocol.length + 1] === '/') { + // resolving to a :// so we need to read out the auth and host + if (parentProtocol !== 'file:') { + pathname = parentUrl.slice(parentProtocol.length + 2); + pathname = pathname.slice(pathname.indexOf('/') + 1); + } + else { + pathname = parentUrl.slice(8); + } + } + else { + // resolving to :/ so pathname is the /... part + pathname = parentUrl.slice(parentProtocol.length + (parentUrl[parentProtocol.length] === '/')); + } + + if (relUrl[0] === '/') + return parentUrl.slice(0, parentUrl.length - pathname.length - 1) + relUrl; + + // join together and split for removal of .. and . segments + // looping the string instead of anything fancy for perf reasons + // '../../../../../z' resolved to 'x/y' is just 'z' + const segmented = pathname.slice(0, pathname.lastIndexOf('/') + 1) + relUrl; + + const output = []; + let segmentIndex = -1; + for (let i = 0; i < segmented.length; i++) { + // busy reading a segment - only terminate on '/' + if (segmentIndex !== -1) { + if (segmented[i] === '/') { + output.push(segmented.slice(segmentIndex, i + 1)); + segmentIndex = -1; + } + continue; + } + // new segment - check if it is relative + else if (segmented[i] === '.') { + // ../ segment + if (segmented[i + 1] === '.' && (segmented[i + 2] === '/' || i + 2 === segmented.length)) { + output.pop(); + i += 2; + continue; + } + // ./ segment + else if (segmented[i + 1] === '/' || i + 1 === segmented.length) { + i += 1; + continue; + } + } + // it is the start of a new segment + while (segmented[i] === '/') i++; + segmentIndex = i; + } + // finish reading out the last segment + if (segmentIndex !== -1) + output.push(segmented.slice(segmentIndex)); + return parentUrl.slice(0, parentUrl.length - pathname.length) + output.join(''); + } + } + + function resolveAndComposeImportMap (json, baseUrl, parentMap) { + const outMap = { imports: Object.assign({}, parentMap.imports), scopes: Object.assign({}, parentMap.scopes) }; + + if (json.imports) + resolveAndComposePackages(json.imports, outMap.imports, baseUrl, parentMap); + + if (json.scopes) + for (let s in json.scopes) { + const resolvedScope = resolveUrl(s, baseUrl); + resolveAndComposePackages(json.scopes[s], outMap.scopes[resolvedScope] || (outMap.scopes[resolvedScope] = {}), baseUrl, parentMap); + } + + return outMap; + } + + function getMatch (path, matchObj) { + if (matchObj[path]) + return path; + let sepIndex = path.length; + do { + const segment = path.slice(0, sepIndex + 1); + if (segment in matchObj) + return segment; + } while ((sepIndex = path.lastIndexOf('/', sepIndex - 1)) !== -1) + } + + function applyPackages (id, packages) { + const pkgName = getMatch(id, packages); + if (pkgName) { + const pkg = packages[pkgName]; + if (pkg === null) return; + return pkg + id.slice(pkgName.length); + } + } + + + function resolveImportMap (importMap, resolvedOrPlain, parentUrl) { + let scopeUrl = parentUrl && getMatch(parentUrl, importMap.scopes); + while (scopeUrl) { + const packageResolution = applyPackages(resolvedOrPlain, importMap.scopes[scopeUrl]); + if (packageResolution) + return packageResolution; + scopeUrl = getMatch(scopeUrl.slice(0, scopeUrl.lastIndexOf('/')), importMap.scopes); + } + return applyPackages(resolvedOrPlain, importMap.imports) || resolvedOrPlain.indexOf(':') !== -1 && resolvedOrPlain; + } + + function resolveAndComposePackages (packages, outPackages, baseUrl, parentMap) { + for (let p in packages) { + const resolvedLhs = resolveIfNotPlainOrUrl(p, baseUrl) || p; + if ((!shimMode || !mapOverrides) && outPackages[resolvedLhs] && (outPackages[resolvedLhs] !== packages[resolvedLhs])) { + throw Error(`Rejected map override "${resolvedLhs}" from ${outPackages[resolvedLhs]} to ${packages[resolvedLhs]}.`); + } + let target = packages[p]; + if (typeof target !== 'string') + continue; + const mapped = resolveImportMap(parentMap, resolveIfNotPlainOrUrl(target, baseUrl) || target, baseUrl); + if (mapped) { + outPackages[resolvedLhs] = mapped; + continue; + } + console.warn(`Mapping "${p}" -> "${packages[p]}" does not resolve`); + } + } + + let dynamicImport = !hasDocument && (0, eval)('u=>import(u)'); + + let supportsDynamicImport; + + const dynamicImportCheck = hasDocument && new Promise(resolve => { + const s = Object.assign(document.createElement('script'), { + src: createBlob('self._d=u=>import(u)'), + ep: true + }); + s.setAttribute('nonce', nonce); + s.addEventListener('load', () => { + if (!(supportsDynamicImport = !!(dynamicImport = self._d))) { + let err; + window.addEventListener('error', _err => err = _err); + dynamicImport = (url, opts) => new Promise((resolve, reject) => { + const s = Object.assign(document.createElement('script'), { + type: 'module', + src: createBlob(`import*as m from'${url}';self._esmsi=m`) + }); + err = undefined; + s.ep = true; + if (nonce) + s.setAttribute('nonce', nonce); + // Safari is unique in supporting module script error events + s.addEventListener('error', cb); + s.addEventListener('load', cb); + function cb (_err) { + document.head.removeChild(s); + if (self._esmsi) { + resolve(self._esmsi, baseUrl); + self._esmsi = undefined; + } + else { + reject(!(_err instanceof Event) && _err || err && err.error || new Error(`Error loading ${opts && opts.errUrl || url} (${s.src}).`)); + err = undefined; + } + } + document.head.appendChild(s); + }); + } + document.head.removeChild(s); + delete self._d; + resolve(); + }); + document.head.appendChild(s); + }); + + // support browsers without dynamic import support (eg Firefox 6x) + let supportsJsonAssertions = false; + let supportsCssAssertions = false; + + const supports = hasDocument && HTMLScriptElement.supports; + + let supportsImportMaps = supports && supports.name === 'supports' && supports('importmap'); + let supportsImportMeta = supportsDynamicImport; + + const importMetaCheck = 'import.meta'; + const cssModulesCheck = `import"x"assert{type:"css"}`; + const jsonModulesCheck = `import"x"assert{type:"json"}`; + + let featureDetectionPromise = Promise.resolve(dynamicImportCheck).then(() => { + if (!supportsDynamicImport) + return; + + if (!hasDocument) + return Promise.all([ + supportsImportMaps || dynamicImport(createBlob(importMetaCheck)).then(() => supportsImportMeta = true, noop), + cssModulesEnabled && dynamicImport(createBlob(cssModulesCheck.replace('x', createBlob('', 'text/css')))).then(() => supportsCssAssertions = true, noop), + jsonModulesEnabled && dynamicImport(createBlob(jsonModulescheck.replace('x', createBlob('{}', 'text/json')))).then(() => supportsJsonAssertions = true, noop), + ]); + + return new Promise(resolve => { + const iframe = document.createElement('iframe'); + iframe.style.display = 'none'; + iframe.setAttribute('nonce', nonce); + function cb ({ data }) { + const isFeatureDetectionMessage = Array.isArray(data) && data[0] === 'esms'; + if (!isFeatureDetectionMessage) { + return; + } + supportsImportMaps = data[1]; + supportsImportMeta = data[2]; + supportsCssAssertions = data[3]; + supportsJsonAssertions = data[4]; + resolve(); + document.head.removeChild(iframe); + window.removeEventListener('message', cb, false); + } + window.addEventListener('message', cb, false); + + const importMapTest = `