From 30de8f25af237f97b5dd49c6bbcd66a0303b025e Mon Sep 17 00:00:00 2001 From: daniexiong Date: Tue, 22 Jun 2021 11:42:03 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=E8=BD=AC=E6=8D=A2=E5=A4=A7=E5=A4=9A?= =?UTF-8?q?=E6=95=B0=E6=83=85=E5=86=B5=E4=B8=AD=E5=A4=96=E9=83=A8css?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E4=B8=AD=E7=9A=84=E7=9B=B8=E5=AF=B9=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E4=B8=BA=E7=BB=9D=E5=AF=B9=E8=B7=AF=E5=BE=84=EF=BC=8C?= =?UTF-8?q?=E9=98=B2=E6=AD=A2404?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/test-process-tpl.js | 20 + src/index.js | 656 +++++++++++++++--------------- src/process-tpl.js | 418 ++++++++++--------- 3 files changed, 574 insertions(+), 520 deletions(-) diff --git a/src/__tests__/test-process-tpl.js b/src/__tests__/test-process-tpl.js index 2e09da7..1b8e56f 100644 --- a/src/__tests__/test-process-tpl.js +++ b/src/__tests__/test-process-tpl.js @@ -4,6 +4,7 @@ import processTpl, { genLinkReplaceSymbol, genModuleScriptReplaceSymbol, genScriptReplaceSymbol, + processCssContent, } from '../process-tpl'; test('test process-tpl', () => { @@ -318,3 +319,22 @@ test('should work with huge html content', () => { const during = Date.now() - start; expect(during < 1000).toBeTruthy(); }); + +test('test process url in external css resources', () => { + const transformedStyleText = processCssContent('//cdntest.com/css/ui.css', ` + @import 'component1.css' + @import url('component2.less') + .test-notice { + background: #ffffff url(../images/bg1.jpg) no-repeat center left; + background-image: url( '../images/bg2.jpg' ); + background-image: url("../images/bg3.jpg"); + }; + /*# sourceMappingURL=test-notice.css.map */ + `); + expect(transformedStyleText.indexOf('//cdntest.com/css/component1.css') !== -1).toBeTruthy(); + expect(transformedStyleText.indexOf('//cdntest.com/css/component2.less') !== -1).toBeTruthy(); + expect(transformedStyleText.indexOf('//cdntest.com/images/bg1.jpg') !== -1).toBeTruthy(); + expect(transformedStyleText.indexOf('//cdntest.com/images/bg2.jpg') !== -1).toBeTruthy(); + expect(transformedStyleText.indexOf('//cdntest.com/images/bg3.jpg') !== -1).toBeTruthy(); + expect(transformedStyleText.indexOf('//cdntest.com/css/test-notice.css.map') !== -1).toBeTruthy(); +}); \ No newline at end of file diff --git a/src/index.js b/src/index.js index 28fd294..3426d3a 100644 --- a/src/index.js +++ b/src/index.js @@ -4,330 +4,332 @@ * @since 2018-08-15 11:37 */ -import processTpl, { genLinkReplaceSymbol, genScriptReplaceSymbol } from './process-tpl'; -import { - defaultGetPublicPath, - getGlobalProp, - getInlineCode, - noteGlobalProps, - readResAsString, - requestIdleCallback, -} from './utils'; - -const styleCache = {}; -const scriptCache = {}; -const embedHTMLCache = {}; -if (!window.fetch) { - throw new Error('[import-html-entry] Here is no "fetch" on the window env, you need to polyfill it'); -} -const defaultFetch = window.fetch.bind(window); - -function defaultGetTemplate(tpl) { - return tpl; -} - -/** - * convert external css link to inline style for performance optimization - * @param template - * @param styles - * @param opts - * @return embedHTML - */ -function getEmbedHTML(template, styles, opts = {}) { - const { fetch = defaultFetch } = opts; - let embedHTML = template; - - return getExternalStyleSheets(styles, fetch) - .then(styleSheets => { - embedHTML = styles.reduce((html, styleSrc, i) => { - html = html.replace(genLinkReplaceSymbol(styleSrc), ``); - return html; - }, embedHTML); - return embedHTML; - }); -} - -const isInlineCode = code => code.startsWith('<'); - -function getExecutableScript(scriptSrc, scriptText, proxy, strictGlobal) { - const sourceUrl = isInlineCode(scriptSrc) ? '' : `//# sourceURL=${scriptSrc}\n`; - - // 通过这种方式获取全局 window,因为 script 也是在全局作用域下运行的,所以我们通过 window.proxy 绑定时也必须确保绑定到全局 window 上 - // 否则在嵌套场景下, window.proxy 设置的是内层应用的 window,而代码其实是在全局作用域运行的,会导致闭包里的 window.proxy 取的是最外层的微应用的 proxy - const globalWindow = (0, eval)('window'); - globalWindow.proxy = proxy; - // TODO 通过 strictGlobal 方式切换 with 闭包,待 with 方式坑趟平后再合并 - return strictGlobal - ? `;(function(window, self, globalThis){with(window){;${scriptText}\n${sourceUrl}}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);` - : `;(function(window, self, globalThis){;${scriptText}\n${sourceUrl}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`; -} - -// for prefetch -export function getExternalStyleSheets(styles, fetch = defaultFetch) { - return Promise.all(styles.map(styleLink => { - if (isInlineCode(styleLink)) { - // if it is inline style - return getInlineCode(styleLink); - } else { - // external styles - return styleCache[styleLink] || - (styleCache[styleLink] = fetch(styleLink).then(response => response.text())); - } - - }, - )); -} - -// for prefetch -export function getExternalScripts(scripts, fetch = defaultFetch, errorCallback = () => { -}) { - - const fetchScript = scriptUrl => scriptCache[scriptUrl] || - (scriptCache[scriptUrl] = fetch(scriptUrl).then(response => { - // usually browser treats 4xx and 5xx response of script loading as an error and will fire a script error event - // https://stackoverflow.com/questions/5625420/what-http-headers-responses-trigger-the-onerror-handler-on-a-script-tag/5625603 - if (response.status >= 400) { - errorCallback(); - throw new Error(`${scriptUrl} load failed with status ${response.status}`); - } - - return response.text(); - }).catch(e => { - errorCallback(); - throw e; - })); - - return Promise.all(scripts.map(script => { - - if (typeof script === 'string') { - if (isInlineCode(script)) { - // if it is inline script - return getInlineCode(script); - } else { - // external script - return fetchScript(script); - } - } else { - // use idle time to load async script - const { src, async } = script; - if (async) { - return { - src, - async: true, - content: new Promise((resolve, reject) => requestIdleCallback(() => fetchScript(src).then(resolve, reject))), - }; - } - - return fetchScript(src); - } - }, - )); -} - -function throwNonBlockingError(error, msg) { - setTimeout(() => { - console.error(msg); - throw error; - }); -} - -const supportsUserTiming = - typeof performance !== 'undefined' && - typeof performance.mark === 'function' && - typeof performance.clearMarks === 'function' && - typeof performance.measure === 'function' && - typeof performance.clearMeasures === 'function'; - -/** - * FIXME to consistent with browser behavior, we should only provide callback way to invoke success and error event - * @param entry - * @param scripts - * @param proxy - * @param opts - * @returns {Promise} - */ -export function execScripts(entry, scripts, proxy = window, opts = {}) { - const { - fetch = defaultFetch, strictGlobal = false, success, error = () => { - }, beforeExec = () => { - }, afterExec = () => { - }, - } = opts; - - return getExternalScripts(scripts, fetch, error) - .then(scriptsText => { - - const geval = (scriptSrc, inlineScript) => { - const rawCode = beforeExec(inlineScript, scriptSrc) || inlineScript; - const code = getExecutableScript(scriptSrc, rawCode, proxy, strictGlobal); - - (0, eval)(code); - - afterExec(inlineScript, scriptSrc); - }; - - function exec(scriptSrc, inlineScript, resolve) { - - const markName = `Evaluating script ${scriptSrc}`; - const measureName = `Evaluating Time Consuming: ${scriptSrc}`; - - if (process.env.NODE_ENV === 'development' && supportsUserTiming) { - performance.mark(markName); - } - - if (scriptSrc === entry) { - noteGlobalProps(strictGlobal ? proxy : window); - - try { - // bind window.proxy to change `this` reference in script - geval(scriptSrc, inlineScript); - const exports = proxy[getGlobalProp(strictGlobal ? proxy : window)] || {}; - resolve(exports); - } catch (e) { - // entry error must be thrown to make the promise settled - console.error(`[import-html-entry]: error occurs while executing entry script ${scriptSrc}`); - throw e; - } - } else { - if (typeof inlineScript === 'string') { - try { - // bind window.proxy to change `this` reference in script - geval(scriptSrc, inlineScript); - } catch (e) { - // consistent with browser behavior, any independent script evaluation error should not block the others - throwNonBlockingError(e, `[import-html-entry]: error occurs while executing normal script ${scriptSrc}`); - } - } else { - // external script marked with async - inlineScript.async && inlineScript?.content - .then(downloadedScriptText => geval(inlineScript.src, downloadedScriptText)) - .catch(e => { - throwNonBlockingError(e, `[import-html-entry]: error occurs while executing async script ${inlineScript.src}`); - }); - } - } - - if (process.env.NODE_ENV === 'development' && supportsUserTiming) { - performance.measure(measureName, markName); - performance.clearMarks(markName); - performance.clearMeasures(measureName); - } - } - - function schedule(i, resolvePromise) { - - if (i < scripts.length) { - const scriptSrc = scripts[i]; - const inlineScript = scriptsText[i]; - - exec(scriptSrc, inlineScript, resolvePromise); - // resolve the promise while the last script executed and entry not provided - if (!entry && i === scripts.length - 1) { - resolvePromise(); - } else { - schedule(i + 1, resolvePromise); - } - } - } - - return new Promise(resolve => schedule(0, success || resolve)); - }); -} - -export default function importHTML(url, opts = {}) { - let fetch = defaultFetch; - let autoDecodeResponse = false; - let getPublicPath = defaultGetPublicPath; - let getTemplate = defaultGetTemplate; - - // compatible with the legacy importHTML api - if (typeof opts === 'function') { - fetch = opts; - } else { - // fetch option is availble - if (opts.fetch) { - // fetch is a funciton - if (typeof opts.fetch === 'function') { - fetch = opts.fetch; - } else { // configuration - fetch = opts.fetch.fn || defaultFetch; - autoDecodeResponse = !!opts.fetch.autoDecodeResponse; - } - } - getPublicPath = opts.getPublicPath || opts.getDomain || defaultGetPublicPath; - getTemplate = opts.getTemplate || defaultGetTemplate; - } - - return embedHTMLCache[url] || (embedHTMLCache[url] = fetch(url) - .then(response => readResAsString(response, autoDecodeResponse)) - .then(html => { - - const assetPublicPath = getPublicPath(url); - const { template, scripts, entry, styles } = processTpl(getTemplate(html), assetPublicPath); - - return getEmbedHTML(template, styles, { fetch }).then(embedHTML => ({ - template: embedHTML, - assetPublicPath, - getExternalScripts: () => getExternalScripts(scripts, fetch), - getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch), - execScripts: (proxy, strictGlobal, execScriptsHooks = {}) => { - if (!scripts.length) { - return Promise.resolve(); - } - return execScripts(entry, scripts, proxy, { - fetch, - strictGlobal, - beforeExec: execScriptsHooks.beforeExec, - afterExec: execScriptsHooks.afterExec, - }); - }, - })); - })); -} - -export function importEntry(entry, opts = {}) { - const { fetch = defaultFetch, getTemplate = defaultGetTemplate } = opts; - const getPublicPath = opts.getPublicPath || opts.getDomain || defaultGetPublicPath; - - if (!entry) { - throw new SyntaxError('entry should not be empty!'); - } - - // html entry - if (typeof entry === 'string') { - return importHTML(entry, { - fetch, - getPublicPath, - getTemplate, - }); - } - - // config entry - if (Array.isArray(entry.scripts) || Array.isArray(entry.styles)) { - - const { scripts = [], styles = [], html = '' } = entry; - const getHTMLWithStylePlaceholder = tpl => styles.reduceRight((html, styleSrc) => `${genLinkReplaceSymbol(styleSrc)}${html}`, tpl); - const getHTMLWithScriptPlaceholder = tpl => scripts.reduce((html, scriptSrc) => `${html}${genScriptReplaceSymbol(scriptSrc)}`, tpl); - - return getEmbedHTML(getTemplate(getHTMLWithScriptPlaceholder(getHTMLWithStylePlaceholder(html))), styles, { fetch }).then(embedHTML => ({ - template: embedHTML, - assetPublicPath: getPublicPath(entry), - getExternalScripts: () => getExternalScripts(scripts, fetch), - getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch), - execScripts: (proxy, strictGlobal, execScriptsHooks = {}) => { - if (!scripts.length) { - return Promise.resolve(); - } - return execScripts(scripts[scripts.length - 1], scripts, proxy, { - fetch, - strictGlobal, - beforeExec: execScriptsHooks.beforeExec, - afterExec: execScriptsHooks.afterExec, - }); - }, - })); - - } else { - throw new SyntaxError('entry scripts or styles should be array!'); - } -} + import processTpl, { genLinkReplaceSymbol, genScriptReplaceSymbol, processCssContent } from './process-tpl'; + import { + defaultGetPublicPath, + getGlobalProp, + getInlineCode, + noteGlobalProps, + readResAsString, + requestIdleCallback, + } from './utils'; + + const styleCache = {}; + const scriptCache = {}; + const embedHTMLCache = {}; + if (!window.fetch) { + throw new Error('[import-html-entry] Here is no "fetch" on the window env, you need to polyfill it'); + } + const defaultFetch = window.fetch.bind(window); + + function defaultGetTemplate(tpl) { + return tpl; + } + + /** + * convert external css link to inline style for performance optimization + * @param template + * @param styles + * @param opts + * @return embedHTML + */ + function getEmbedHTML(template, styles, opts = {}) { + const { fetch = defaultFetch } = opts; + let embedHTML = template; + + return getExternalStyleSheets(styles, fetch) + .then(styleSheets => { + embedHTML = styles.reduce((html, styleSrc, i) => { + const styleText = processCssContent(styleSrc, styleSheets[i]); + html = html.replace(genLinkReplaceSymbol(styleSrc), "")); + return html; + }, embedHTML); + return embedHTML; + }); + } + + const isInlineCode = code => code.startsWith('<'); + + function getExecutableScript(scriptSrc, scriptText, proxy, strictGlobal) { + const sourceUrl = isInlineCode(scriptSrc) ? '' : `//# sourceURL=${scriptSrc}\n`; + + // 通过这种方式获取全局 window,因为 script 也是在全局作用域下运行的,所以我们通过 window.proxy 绑定时也必须确保绑定到全局 window 上 + // 否则在嵌套场景下, window.proxy 设置的是内层应用的 window,而代码其实是在全局作用域运行的,会导致闭包里的 window.proxy 取的是最外层的微应用的 proxy + const globalWindow = (0, eval)('window'); + globalWindow.proxy = proxy; + // TODO 通过 strictGlobal 方式切换 with 闭包,待 with 方式坑趟平后再合并 + return strictGlobal + ? `;(function(window, self, globalThis){with(window){;${scriptText}\n${sourceUrl}}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);` + : `;(function(window, self, globalThis){;${scriptText}\n${sourceUrl}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`; + } + + // for prefetch + export function getExternalStyleSheets(styles, fetch = defaultFetch) { + return Promise.all(styles.map(styleLink => { + if (isInlineCode(styleLink)) { + // if it is inline style + return getInlineCode(styleLink); + } else { + // external styles + return styleCache[styleLink] || + (styleCache[styleLink] = fetch(styleLink).then(response => response.text())); + } + + }, + )); + } + + // for prefetch + export function getExternalScripts(scripts, fetch = defaultFetch, errorCallback = () => { + }) { + + const fetchScript = scriptUrl => scriptCache[scriptUrl] || + (scriptCache[scriptUrl] = fetch(scriptUrl).then(response => { + // usually browser treats 4xx and 5xx response of script loading as an error and will fire a script error event + // https://stackoverflow.com/questions/5625420/what-http-headers-responses-trigger-the-onerror-handler-on-a-script-tag/5625603 + if (response.status >= 400) { + errorCallback(); + throw new Error(`${scriptUrl} load failed with status ${response.status}`); + } + + return response.text(); + }).catch(e => { + errorCallback(); + throw e; + })); + + return Promise.all(scripts.map(script => { + + if (typeof script === 'string') { + if (isInlineCode(script)) { + // if it is inline script + return getInlineCode(script); + } else { + // external script + return fetchScript(script); + } + } else { + // use idle time to load async script + const { src, async } = script; + if (async) { + return { + src, + async: true, + content: new Promise((resolve, reject) => requestIdleCallback(() => fetchScript(src).then(resolve, reject))), + }; + } + + return fetchScript(src); + } + }, + )); + } + + function throwNonBlockingError(error, msg) { + setTimeout(() => { + console.error(msg); + throw error; + }); + } + + const supportsUserTiming = + typeof performance !== 'undefined' && + typeof performance.mark === 'function' && + typeof performance.clearMarks === 'function' && + typeof performance.measure === 'function' && + typeof performance.clearMeasures === 'function'; + + /** + * FIXME to consistent with browser behavior, we should only provide callback way to invoke success and error event + * @param entry + * @param scripts + * @param proxy + * @param opts + * @returns {Promise} + */ + export function execScripts(entry, scripts, proxy = window, opts = {}) { + const { + fetch = defaultFetch, strictGlobal = false, success, error = () => { + }, beforeExec = () => { + }, afterExec = () => { + }, + } = opts; + + return getExternalScripts(scripts, fetch, error) + .then(scriptsText => { + + const geval = (scriptSrc, inlineScript) => { + const rawCode = beforeExec(inlineScript, scriptSrc) || inlineScript; + const code = getExecutableScript(scriptSrc, rawCode, proxy, strictGlobal); + + (0, eval)(code); + + afterExec(inlineScript, scriptSrc); + }; + + function exec(scriptSrc, inlineScript, resolve) { + + const markName = `Evaluating script ${scriptSrc}`; + const measureName = `Evaluating Time Consuming: ${scriptSrc}`; + + if (process.env.NODE_ENV === 'development' && supportsUserTiming) { + performance.mark(markName); + } + + if (scriptSrc === entry) { + noteGlobalProps(strictGlobal ? proxy : window); + + try { + // bind window.proxy to change `this` reference in script + geval(scriptSrc, inlineScript); + const exports = proxy[getGlobalProp(strictGlobal ? proxy : window)] || {}; + resolve(exports); + } catch (e) { + // entry error must be thrown to make the promise settled + console.error(`[import-html-entry]: error occurs while executing entry script ${scriptSrc}`); + throw e; + } + } else { + if (typeof inlineScript === 'string') { + try { + // bind window.proxy to change `this` reference in script + geval(scriptSrc, inlineScript); + } catch (e) { + // consistent with browser behavior, any independent script evaluation error should not block the others + throwNonBlockingError(e, `[import-html-entry]: error occurs while executing normal script ${scriptSrc}`); + } + } else { + // external script marked with async + inlineScript.async && inlineScript?.content + .then(downloadedScriptText => geval(inlineScript.src, downloadedScriptText)) + .catch(e => { + throwNonBlockingError(e, `[import-html-entry]: error occurs while executing async script ${inlineScript.src}`); + }); + } + } + + if (process.env.NODE_ENV === 'development' && supportsUserTiming) { + performance.measure(measureName, markName); + performance.clearMarks(markName); + performance.clearMeasures(measureName); + } + } + + function schedule(i, resolvePromise) { + + if (i < scripts.length) { + const scriptSrc = scripts[i]; + const inlineScript = scriptsText[i]; + + exec(scriptSrc, inlineScript, resolvePromise); + // resolve the promise while the last script executed and entry not provided + if (!entry && i === scripts.length - 1) { + resolvePromise(); + } else { + schedule(i + 1, resolvePromise); + } + } + } + + return new Promise(resolve => schedule(0, success || resolve)); + }); + } + + export default function importHTML(url, opts = {}) { + let fetch = defaultFetch; + let autoDecodeResponse = false; + let getPublicPath = defaultGetPublicPath; + let getTemplate = defaultGetTemplate; + + // compatible with the legacy importHTML api + if (typeof opts === 'function') { + fetch = opts; + } else { + // fetch option is availble + if (opts.fetch) { + // fetch is a funciton + if (typeof opts.fetch === 'function') { + fetch = opts.fetch; + } else { // configuration + fetch = opts.fetch.fn || defaultFetch; + autoDecodeResponse = !!opts.fetch.autoDecodeResponse; + } + } + getPublicPath = opts.getPublicPath || opts.getDomain || defaultGetPublicPath; + getTemplate = opts.getTemplate || defaultGetTemplate; + } + + return embedHTMLCache[url] || (embedHTMLCache[url] = fetch(url) + .then(response => readResAsString(response, autoDecodeResponse)) + .then(html => { + + const assetPublicPath = getPublicPath(url); + const { template, scripts, entry, styles } = processTpl(getTemplate(html), assetPublicPath); + + return getEmbedHTML(template, styles, { fetch }).then(embedHTML => ({ + template: embedHTML, + assetPublicPath, + getExternalScripts: () => getExternalScripts(scripts, fetch), + getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch), + execScripts: (proxy, strictGlobal, execScriptsHooks = {}) => { + if (!scripts.length) { + return Promise.resolve(); + } + return execScripts(entry, scripts, proxy, { + fetch, + strictGlobal, + beforeExec: execScriptsHooks.beforeExec, + afterExec: execScriptsHooks.afterExec, + }); + }, + })); + })); + } + + export function importEntry(entry, opts = {}) { + const { fetch = defaultFetch, getTemplate = defaultGetTemplate } = opts; + const getPublicPath = opts.getPublicPath || opts.getDomain || defaultGetPublicPath; + + if (!entry) { + throw new SyntaxError('entry should not be empty!'); + } + + // html entry + if (typeof entry === 'string') { + return importHTML(entry, { + fetch, + getPublicPath, + getTemplate, + }); + } + + // config entry + if (Array.isArray(entry.scripts) || Array.isArray(entry.styles)) { + + const { scripts = [], styles = [], html = '' } = entry; + const getHTMLWithStylePlaceholder = tpl => styles.reduceRight((html, styleSrc) => `${genLinkReplaceSymbol(styleSrc)}${html}`, tpl); + const getHTMLWithScriptPlaceholder = tpl => scripts.reduce((html, scriptSrc) => `${html}${genScriptReplaceSymbol(scriptSrc)}`, tpl); + + return getEmbedHTML(getTemplate(getHTMLWithScriptPlaceholder(getHTMLWithStylePlaceholder(html))), styles, { fetch }).then(embedHTML => ({ + template: embedHTML, + assetPublicPath: getPublicPath(entry), + getExternalScripts: () => getExternalScripts(scripts, fetch), + getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch), + execScripts: (proxy, strictGlobal, execScriptsHooks = {}) => { + if (!scripts.length) { + return Promise.resolve(); + } + return execScripts(scripts[scripts.length - 1], scripts, proxy, { + fetch, + strictGlobal, + beforeExec: execScriptsHooks.beforeExec, + afterExec: execScriptsHooks.afterExec, + }); + }, + })); + + } else { + throw new SyntaxError('entry scripts or styles should be array!'); + } + } + \ No newline at end of file diff --git a/src/process-tpl.js b/src/process-tpl.js index bdd1b2e..c502fcf 100644 --- a/src/process-tpl.js +++ b/src/process-tpl.js @@ -3,196 +3,228 @@ * @homepage https://github.com/kuitos/ * @since 2018-09-03 15:04 */ -import { getInlineCode, isModuleScriptSupported } from './utils'; - -const ALL_SCRIPT_REGEX = /()[\s\S]*?<\/script>/gi; -const SCRIPT_TAG_REGEX = /<(script)\s+((?!type=('|")text\/ng-template\3).)*?>.*?<\/\1>/is; -const SCRIPT_SRC_REGEX = /.*\ssrc=('|")?([^>'"\s]+)/; -const SCRIPT_TYPE_REGEX = /.*\stype=('|")?([^>'"\s]+)/; -const SCRIPT_ENTRY_REGEX = /.*\sentry\s*.*/; -const SCRIPT_ASYNC_REGEX = /.*\sasync\s*.*/; -const SCRIPT_NO_MODULE_REGEX = /.*\snomodule\s*.*/; -const SCRIPT_MODULE_REGEX = /.*\stype=('|")?module('|")?\s*.*/; -const LINK_TAG_REGEX = /<(link)\s+.*?>/isg; -const LINK_PRELOAD_OR_PREFETCH_REGEX = /\srel=('|")?(preload|prefetch)\1/; -const LINK_HREF_REGEX = /.*\shref=('|")?([^>'"\s]+)/; -const LINK_AS_FONT = /.*\sas=('|")?font\1.*/; -const STYLE_TAG_REGEX = /]*>[\s\S]*?<\/style>/gi; -const STYLE_TYPE_REGEX = /\s+rel=('|")?stylesheet\1.*/; -const STYLE_HREF_REGEX = /.*\shref=('|")?([^>'"\s]+)/; -const HTML_COMMENT_REGEX = //g; -const LINK_IGNORE_REGEX = //is; -const STYLE_IGNORE_REGEX = //is; -const SCRIPT_IGNORE_REGEX = //is; - -function hasProtocol(url) { - return url.startsWith('//') || url.startsWith('http://') || url.startsWith('https://'); -} - -function getEntirePath(path, baseURI) { - return new URL(path, baseURI).toString(); -} - -function isValidJavaScriptType(type) { - const handleTypes = ['text/javascript', 'module', 'application/javascript', 'text/ecmascript', 'application/ecmascript']; - return !type || handleTypes.indexOf(type) !== -1; -} - -export const genLinkReplaceSymbol = (linkHref, preloadOrPrefetch = false) => ``; -export const genScriptReplaceSymbol = (scriptSrc, async = false) => ``; -export const inlineScriptReplaceSymbol = ``; -export const genIgnoreAssetReplaceSymbol = url => ``; -export const genModuleScriptReplaceSymbol = (scriptSrc, moduleSupport) => ``; - -/** - * parse the script link from the template - * 1. collect stylesheets - * 2. use global eval to evaluate the inline scripts - * see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function#Difference_between_Function_constructor_and_function_declaration - * see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/eval#Do_not_ever_use_eval! - * @param tpl - * @param baseURI - * @stripStyles whether to strip the css links - * @returns {{template: void | string | *, scripts: *[], entry: *}} - */ -export default function processTpl(tpl, baseURI) { - - let scripts = []; - const styles = []; - let entry = null; - const moduleSupport = isModuleScriptSupported(); - - const template = tpl - - /* - remove html comment first - */ - .replace(HTML_COMMENT_REGEX, '') - - .replace(LINK_TAG_REGEX, match => { - /* - change the css link - */ - const styleType = !!match.match(STYLE_TYPE_REGEX); - if (styleType) { - - const styleHref = match.match(STYLE_HREF_REGEX); - const styleIgnore = match.match(LINK_IGNORE_REGEX); - - if (styleHref) { - - const href = styleHref && styleHref[2]; - let newHref = href; - - if (href && !hasProtocol(href)) { - newHref = getEntirePath(href, baseURI); - } - if (styleIgnore) { - return genIgnoreAssetReplaceSymbol(newHref); - } - - styles.push(newHref); - return genLinkReplaceSymbol(newHref); - } - } - - const preloadOrPrefetchType = match.match(LINK_PRELOAD_OR_PREFETCH_REGEX) && match.match(LINK_HREF_REGEX) && !match.match(LINK_AS_FONT); - if (preloadOrPrefetchType) { - const [, , linkHref] = match.match(LINK_HREF_REGEX); - return genLinkReplaceSymbol(linkHref, true); - } - - return match; - }) - .replace(STYLE_TAG_REGEX, match => { - if (STYLE_IGNORE_REGEX.test(match)) { - return genIgnoreAssetReplaceSymbol('style file'); - } - return match; - }) - .replace(ALL_SCRIPT_REGEX, (match, scriptTag) => { - const scriptIgnore = scriptTag.match(SCRIPT_IGNORE_REGEX); - const moduleScriptIgnore = - (moduleSupport && !!scriptTag.match(SCRIPT_NO_MODULE_REGEX)) || - (!moduleSupport && !!scriptTag.match(SCRIPT_MODULE_REGEX)); - // in order to keep the exec order of all javascripts - - const matchedScriptTypeMatch = scriptTag.match(SCRIPT_TYPE_REGEX); - const matchedScriptType = matchedScriptTypeMatch && matchedScriptTypeMatch[2]; - if (!isValidJavaScriptType(matchedScriptType)) { - return match; - } - - // if it is a external script - if (SCRIPT_TAG_REGEX.test(match) && scriptTag.match(SCRIPT_SRC_REGEX)) { - /* - collect scripts and replace the ref - */ - - const matchedScriptEntry = scriptTag.match(SCRIPT_ENTRY_REGEX); - const matchedScriptSrcMatch = scriptTag.match(SCRIPT_SRC_REGEX); - let matchedScriptSrc = matchedScriptSrcMatch && matchedScriptSrcMatch[2]; - - if (entry && matchedScriptEntry) { - throw new SyntaxError('You should not set multiply entry script!'); - } else { - - // append the domain while the script not have an protocol prefix - if (matchedScriptSrc && !hasProtocol(matchedScriptSrc)) { - matchedScriptSrc = getEntirePath(matchedScriptSrc, baseURI); - } - - entry = entry || matchedScriptEntry && matchedScriptSrc; - } - - if (scriptIgnore) { - return genIgnoreAssetReplaceSymbol(matchedScriptSrc || 'js file'); - } - - if (moduleScriptIgnore) { - return genModuleScriptReplaceSymbol(matchedScriptSrc || 'js file', moduleSupport); - } - - if (matchedScriptSrc) { - const asyncScript = !!scriptTag.match(SCRIPT_ASYNC_REGEX); - scripts.push(asyncScript ? { async: true, src: matchedScriptSrc } : matchedScriptSrc); - return genScriptReplaceSymbol(matchedScriptSrc, asyncScript); - } - - return match; - } else { - if (scriptIgnore) { - return genIgnoreAssetReplaceSymbol('js file'); - } - - if (moduleScriptIgnore) { - return genModuleScriptReplaceSymbol('js file', moduleSupport); - } - - // if it is an inline script - const code = getInlineCode(match); - - // remove script blocks when all of these lines are comments. - const isPureCommentBlock = code.split(/[\r\n]+/).every(line => !line.trim() || line.trim().startsWith('//')); - - if (!isPureCommentBlock) { - scripts.push(match); - } - - return inlineScriptReplaceSymbol; - } - }); - - scripts = scripts.filter(function (script) { - // filter empty script - return !!script; - }); - - return { - template, - scripts, - styles, - // set the last script as entry if have not set - entry: entry || scripts[scripts.length - 1], - }; -} + import { getInlineCode, isModuleScriptSupported } from './utils'; + + const ALL_SCRIPT_REGEX = /()[\s\S]*?<\/script>/gi; + const SCRIPT_TAG_REGEX = /<(script)\s+((?!type=('|")text\/ng-template\3).)*?>.*?<\/\1>/is; + const SCRIPT_SRC_REGEX = /.*\ssrc=('|")?([^>'"\s]+)/; + const SCRIPT_TYPE_REGEX = /.*\stype=('|")?([^>'"\s]+)/; + const SCRIPT_ENTRY_REGEX = /.*\sentry\s*.*/; + const SCRIPT_ASYNC_REGEX = /.*\sasync\s*.*/; + const SCRIPT_NO_MODULE_REGEX = /.*\snomodule\s*.*/; + const SCRIPT_MODULE_REGEX = /.*\stype=('|")?module('|")?\s*.*/; + const LINK_TAG_REGEX = /<(link)\s+.*?>/isg; + const LINK_PRELOAD_OR_PREFETCH_REGEX = /\srel=('|")?(preload|prefetch)\1/; + const LINK_HREF_REGEX = /.*\shref=('|")?([^>'"\s]+)/; + const LINK_AS_FONT = /.*\sas=('|")?font\1.*/; + const STYLE_TAG_REGEX = /]*>[\s\S]*?<\/style>/gi; + const STYLE_TYPE_REGEX = /\s+rel=('|")?stylesheet\1.*/; + const STYLE_HREF_REGEX = /.*\shref=('|")?([^>'"\s]+)/; + const HTML_COMMENT_REGEX = //g; + const LINK_IGNORE_REGEX = //is; + const STYLE_IGNORE_REGEX = //is; + const SCRIPT_IGNORE_REGEX = //is; + + function hasProtocol(url) { + return url.startsWith('//') || url.startsWith('http://') || url.startsWith('https://'); + } + + function getEntirePath(path, baseURI) { + // 防止new URL(path, '//xxx...')这种形式报错: Invalid base URL + // by daniel@2021-06-22 + let newBaseURI = baseURI; + if (baseURI.indexOf('//') === 0) { + newBaseURI = 'http:' + baseURI; + } + return new URL(path, newBaseURI).toString(); + } + + function isValidJavaScriptType(type) { + const handleTypes = ['text/javascript', 'module', 'application/javascript', 'text/ecmascript', 'application/ecmascript']; + return !type || handleTypes.indexOf(type) !== -1; + } + + export const genLinkReplaceSymbol = (linkHref, preloadOrPrefetch = false) => ``; + export const genScriptReplaceSymbol = (scriptSrc, async = false) => ``; + export const inlineScriptReplaceSymbol = ``; + export const genIgnoreAssetReplaceSymbol = url => ``; + export const genModuleScriptReplaceSymbol = (scriptSrc, moduleSupport) => ``; + /** + * process the css content, transform the relative url in external css to absolute path. + * + * @author Daniel + * @since 2021-06-22 + * @param {string} styleSrc the external css link + * @param {string} styleText the fetched css content + * @returns + */ + export const processCssContent = (styleSrc, styleText) => { + const replacement = function (match, prefix, path, suffix) { + if (hasProtocol(path)) { + return match; + } + + const newHref = getEntirePath(path, styleSrc); + return prefix + newHref + suffix; + }; + + const transformedStyleText = styleText + .replace(/(@import\s*['"])([^'"]+)(['"])/g, replacement) // transform leading `@import 'xxx.css'` + .replace(/(url\(\s*['"]?)([^'"\s\(\)]+)(['"]?\s*\))/g, replacement) // transform `url(xxx.jpg)` + .replace(/(\/[*\/][@#] sourceMappingURL=)(\S+)(( \*\/)?[\r\n]*)$/mg, replacement); // transform trailing `sourceMappingURL=xxx.map` + + return transformedStyleText; + }; + /** + * parse the script link from the template + * 1. collect stylesheets + * 2. use global eval to evaluate the inline scripts + * see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function#Difference_between_Function_constructor_and_function_declaration + * see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/eval#Do_not_ever_use_eval! + * @param tpl + * @param baseURI + * @stripStyles whether to strip the css links + * @returns {{template: void | string | *, scripts: *[], entry: *}} + */ + export default function processTpl(tpl, baseURI) { + + let scripts = []; + const styles = []; + let entry = null; + const moduleSupport = isModuleScriptSupported(); + + const template = tpl + + /* + remove html comment first + */ + .replace(HTML_COMMENT_REGEX, '') + + .replace(LINK_TAG_REGEX, match => { + /* + change the css link + */ + const styleType = !!match.match(STYLE_TYPE_REGEX); + if (styleType) { + + const styleHref = match.match(STYLE_HREF_REGEX); + const styleIgnore = match.match(LINK_IGNORE_REGEX); + + if (styleHref) { + + const href = styleHref && styleHref[2]; + let newHref = href; + + if (href && !hasProtocol(href)) { + newHref = getEntirePath(href, baseURI); + } + if (styleIgnore) { + return genIgnoreAssetReplaceSymbol(newHref); + } + + styles.push(newHref); + return genLinkReplaceSymbol(newHref); + } + } + + const preloadOrPrefetchType = match.match(LINK_PRELOAD_OR_PREFETCH_REGEX) && match.match(LINK_HREF_REGEX) && !match.match(LINK_AS_FONT); + if (preloadOrPrefetchType) { + const [, , linkHref] = match.match(LINK_HREF_REGEX); + return genLinkReplaceSymbol(linkHref, true); + } + + return match; + }) + .replace(STYLE_TAG_REGEX, match => { + if (STYLE_IGNORE_REGEX.test(match)) { + return genIgnoreAssetReplaceSymbol('style file'); + } + return match; + }) + .replace(ALL_SCRIPT_REGEX, (match, scriptTag) => { + const scriptIgnore = scriptTag.match(SCRIPT_IGNORE_REGEX); + const moduleScriptIgnore = + (moduleSupport && !!scriptTag.match(SCRIPT_NO_MODULE_REGEX)) || + (!moduleSupport && !!scriptTag.match(SCRIPT_MODULE_REGEX)); + // in order to keep the exec order of all javascripts + + const matchedScriptTypeMatch = scriptTag.match(SCRIPT_TYPE_REGEX); + const matchedScriptType = matchedScriptTypeMatch && matchedScriptTypeMatch[2]; + if (!isValidJavaScriptType(matchedScriptType)) { + return match; + } + + // if it is a external script + if (SCRIPT_TAG_REGEX.test(match) && scriptTag.match(SCRIPT_SRC_REGEX)) { + /* + collect scripts and replace the ref + */ + + const matchedScriptEntry = scriptTag.match(SCRIPT_ENTRY_REGEX); + const matchedScriptSrcMatch = scriptTag.match(SCRIPT_SRC_REGEX); + let matchedScriptSrc = matchedScriptSrcMatch && matchedScriptSrcMatch[2]; + + if (entry && matchedScriptEntry) { + throw new SyntaxError('You should not set multiply entry script!'); + } else { + + // append the domain while the script not have an protocol prefix + if (matchedScriptSrc && !hasProtocol(matchedScriptSrc)) { + matchedScriptSrc = getEntirePath(matchedScriptSrc, baseURI); + } + + entry = entry || matchedScriptEntry && matchedScriptSrc; + } + + if (scriptIgnore) { + return genIgnoreAssetReplaceSymbol(matchedScriptSrc || 'js file'); + } + + if (moduleScriptIgnore) { + return genModuleScriptReplaceSymbol(matchedScriptSrc || 'js file', moduleSupport); + } + + if (matchedScriptSrc) { + const asyncScript = !!scriptTag.match(SCRIPT_ASYNC_REGEX); + scripts.push(asyncScript ? { async: true, src: matchedScriptSrc } : matchedScriptSrc); + return genScriptReplaceSymbol(matchedScriptSrc, asyncScript); + } + + return match; + } else { + if (scriptIgnore) { + return genIgnoreAssetReplaceSymbol('js file'); + } + + if (moduleScriptIgnore) { + return genModuleScriptReplaceSymbol('js file', moduleSupport); + } + + // if it is an inline script + const code = getInlineCode(match); + + // remove script blocks when all of these lines are comments. + const isPureCommentBlock = code.split(/[\r\n]+/).every(line => !line.trim() || line.trim().startsWith('//')); + + if (!isPureCommentBlock) { + scripts.push(match); + } + + return inlineScriptReplaceSymbol; + } + }); + + scripts = scripts.filter(function (script) { + // filter empty script + return !!script; + }); + + return { + template, + scripts, + styles, + // set the last script as entry if have not set + entry: entry || scripts[scripts.length - 1], + }; + } + \ No newline at end of file From e11e390bf3b08ae70560b0b33e69043f491a8604 Mon Sep 17 00:00:00 2001 From: daniexiong Date: Tue, 22 Jun 2021 11:45:24 +0800 Subject: [PATCH 2/4] =?UTF-8?q?Revert=20"fix:=20=E8=BD=AC=E6=8D=A2?= =?UTF-8?q?=E5=A4=A7=E5=A4=9A=E6=95=B0=E6=83=85=E5=86=B5=E4=B8=AD=E5=A4=96?= =?UTF-8?q?=E9=83=A8css=E8=B5=84=E6=BA=90=E4=B8=AD=E7=9A=84=E7=9B=B8?= =?UTF-8?q?=E5=AF=B9=E8=B7=AF=E5=BE=84=E4=B8=BA=E7=BB=9D=E5=AF=B9=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=EF=BC=8C=E9=98=B2=E6=AD=A2404"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 30de8f25af237f97b5dd49c6bbcd66a0303b025e. --- src/__tests__/test-process-tpl.js | 20 - src/index.js | 656 +++++++++++++++--------------- src/process-tpl.js | 418 +++++++++---------- 3 files changed, 520 insertions(+), 574 deletions(-) diff --git a/src/__tests__/test-process-tpl.js b/src/__tests__/test-process-tpl.js index 1b8e56f..2e09da7 100644 --- a/src/__tests__/test-process-tpl.js +++ b/src/__tests__/test-process-tpl.js @@ -4,7 +4,6 @@ import processTpl, { genLinkReplaceSymbol, genModuleScriptReplaceSymbol, genScriptReplaceSymbol, - processCssContent, } from '../process-tpl'; test('test process-tpl', () => { @@ -319,22 +318,3 @@ test('should work with huge html content', () => { const during = Date.now() - start; expect(during < 1000).toBeTruthy(); }); - -test('test process url in external css resources', () => { - const transformedStyleText = processCssContent('//cdntest.com/css/ui.css', ` - @import 'component1.css' - @import url('component2.less') - .test-notice { - background: #ffffff url(../images/bg1.jpg) no-repeat center left; - background-image: url( '../images/bg2.jpg' ); - background-image: url("../images/bg3.jpg"); - }; - /*# sourceMappingURL=test-notice.css.map */ - `); - expect(transformedStyleText.indexOf('//cdntest.com/css/component1.css') !== -1).toBeTruthy(); - expect(transformedStyleText.indexOf('//cdntest.com/css/component2.less') !== -1).toBeTruthy(); - expect(transformedStyleText.indexOf('//cdntest.com/images/bg1.jpg') !== -1).toBeTruthy(); - expect(transformedStyleText.indexOf('//cdntest.com/images/bg2.jpg') !== -1).toBeTruthy(); - expect(transformedStyleText.indexOf('//cdntest.com/images/bg3.jpg') !== -1).toBeTruthy(); - expect(transformedStyleText.indexOf('//cdntest.com/css/test-notice.css.map') !== -1).toBeTruthy(); -}); \ No newline at end of file diff --git a/src/index.js b/src/index.js index 3426d3a..28fd294 100644 --- a/src/index.js +++ b/src/index.js @@ -4,332 +4,330 @@ * @since 2018-08-15 11:37 */ - import processTpl, { genLinkReplaceSymbol, genScriptReplaceSymbol, processCssContent } from './process-tpl'; - import { - defaultGetPublicPath, - getGlobalProp, - getInlineCode, - noteGlobalProps, - readResAsString, - requestIdleCallback, - } from './utils'; - - const styleCache = {}; - const scriptCache = {}; - const embedHTMLCache = {}; - if (!window.fetch) { - throw new Error('[import-html-entry] Here is no "fetch" on the window env, you need to polyfill it'); - } - const defaultFetch = window.fetch.bind(window); - - function defaultGetTemplate(tpl) { - return tpl; - } - - /** - * convert external css link to inline style for performance optimization - * @param template - * @param styles - * @param opts - * @return embedHTML - */ - function getEmbedHTML(template, styles, opts = {}) { - const { fetch = defaultFetch } = opts; - let embedHTML = template; - - return getExternalStyleSheets(styles, fetch) - .then(styleSheets => { - embedHTML = styles.reduce((html, styleSrc, i) => { - const styleText = processCssContent(styleSrc, styleSheets[i]); - html = html.replace(genLinkReplaceSymbol(styleSrc), "")); - return html; - }, embedHTML); - return embedHTML; - }); - } - - const isInlineCode = code => code.startsWith('<'); - - function getExecutableScript(scriptSrc, scriptText, proxy, strictGlobal) { - const sourceUrl = isInlineCode(scriptSrc) ? '' : `//# sourceURL=${scriptSrc}\n`; - - // 通过这种方式获取全局 window,因为 script 也是在全局作用域下运行的,所以我们通过 window.proxy 绑定时也必须确保绑定到全局 window 上 - // 否则在嵌套场景下, window.proxy 设置的是内层应用的 window,而代码其实是在全局作用域运行的,会导致闭包里的 window.proxy 取的是最外层的微应用的 proxy - const globalWindow = (0, eval)('window'); - globalWindow.proxy = proxy; - // TODO 通过 strictGlobal 方式切换 with 闭包,待 with 方式坑趟平后再合并 - return strictGlobal - ? `;(function(window, self, globalThis){with(window){;${scriptText}\n${sourceUrl}}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);` - : `;(function(window, self, globalThis){;${scriptText}\n${sourceUrl}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`; - } - - // for prefetch - export function getExternalStyleSheets(styles, fetch = defaultFetch) { - return Promise.all(styles.map(styleLink => { - if (isInlineCode(styleLink)) { - // if it is inline style - return getInlineCode(styleLink); - } else { - // external styles - return styleCache[styleLink] || - (styleCache[styleLink] = fetch(styleLink).then(response => response.text())); - } - - }, - )); - } - - // for prefetch - export function getExternalScripts(scripts, fetch = defaultFetch, errorCallback = () => { - }) { - - const fetchScript = scriptUrl => scriptCache[scriptUrl] || - (scriptCache[scriptUrl] = fetch(scriptUrl).then(response => { - // usually browser treats 4xx and 5xx response of script loading as an error and will fire a script error event - // https://stackoverflow.com/questions/5625420/what-http-headers-responses-trigger-the-onerror-handler-on-a-script-tag/5625603 - if (response.status >= 400) { - errorCallback(); - throw new Error(`${scriptUrl} load failed with status ${response.status}`); - } - - return response.text(); - }).catch(e => { - errorCallback(); - throw e; - })); - - return Promise.all(scripts.map(script => { - - if (typeof script === 'string') { - if (isInlineCode(script)) { - // if it is inline script - return getInlineCode(script); - } else { - // external script - return fetchScript(script); - } - } else { - // use idle time to load async script - const { src, async } = script; - if (async) { - return { - src, - async: true, - content: new Promise((resolve, reject) => requestIdleCallback(() => fetchScript(src).then(resolve, reject))), - }; - } - - return fetchScript(src); - } - }, - )); - } - - function throwNonBlockingError(error, msg) { - setTimeout(() => { - console.error(msg); - throw error; - }); - } - - const supportsUserTiming = - typeof performance !== 'undefined' && - typeof performance.mark === 'function' && - typeof performance.clearMarks === 'function' && - typeof performance.measure === 'function' && - typeof performance.clearMeasures === 'function'; - - /** - * FIXME to consistent with browser behavior, we should only provide callback way to invoke success and error event - * @param entry - * @param scripts - * @param proxy - * @param opts - * @returns {Promise} - */ - export function execScripts(entry, scripts, proxy = window, opts = {}) { - const { - fetch = defaultFetch, strictGlobal = false, success, error = () => { - }, beforeExec = () => { - }, afterExec = () => { - }, - } = opts; - - return getExternalScripts(scripts, fetch, error) - .then(scriptsText => { - - const geval = (scriptSrc, inlineScript) => { - const rawCode = beforeExec(inlineScript, scriptSrc) || inlineScript; - const code = getExecutableScript(scriptSrc, rawCode, proxy, strictGlobal); - - (0, eval)(code); - - afterExec(inlineScript, scriptSrc); - }; - - function exec(scriptSrc, inlineScript, resolve) { - - const markName = `Evaluating script ${scriptSrc}`; - const measureName = `Evaluating Time Consuming: ${scriptSrc}`; - - if (process.env.NODE_ENV === 'development' && supportsUserTiming) { - performance.mark(markName); - } - - if (scriptSrc === entry) { - noteGlobalProps(strictGlobal ? proxy : window); - - try { - // bind window.proxy to change `this` reference in script - geval(scriptSrc, inlineScript); - const exports = proxy[getGlobalProp(strictGlobal ? proxy : window)] || {}; - resolve(exports); - } catch (e) { - // entry error must be thrown to make the promise settled - console.error(`[import-html-entry]: error occurs while executing entry script ${scriptSrc}`); - throw e; - } - } else { - if (typeof inlineScript === 'string') { - try { - // bind window.proxy to change `this` reference in script - geval(scriptSrc, inlineScript); - } catch (e) { - // consistent with browser behavior, any independent script evaluation error should not block the others - throwNonBlockingError(e, `[import-html-entry]: error occurs while executing normal script ${scriptSrc}`); - } - } else { - // external script marked with async - inlineScript.async && inlineScript?.content - .then(downloadedScriptText => geval(inlineScript.src, downloadedScriptText)) - .catch(e => { - throwNonBlockingError(e, `[import-html-entry]: error occurs while executing async script ${inlineScript.src}`); - }); - } - } - - if (process.env.NODE_ENV === 'development' && supportsUserTiming) { - performance.measure(measureName, markName); - performance.clearMarks(markName); - performance.clearMeasures(measureName); - } - } - - function schedule(i, resolvePromise) { - - if (i < scripts.length) { - const scriptSrc = scripts[i]; - const inlineScript = scriptsText[i]; - - exec(scriptSrc, inlineScript, resolvePromise); - // resolve the promise while the last script executed and entry not provided - if (!entry && i === scripts.length - 1) { - resolvePromise(); - } else { - schedule(i + 1, resolvePromise); - } - } - } - - return new Promise(resolve => schedule(0, success || resolve)); - }); - } - - export default function importHTML(url, opts = {}) { - let fetch = defaultFetch; - let autoDecodeResponse = false; - let getPublicPath = defaultGetPublicPath; - let getTemplate = defaultGetTemplate; - - // compatible with the legacy importHTML api - if (typeof opts === 'function') { - fetch = opts; - } else { - // fetch option is availble - if (opts.fetch) { - // fetch is a funciton - if (typeof opts.fetch === 'function') { - fetch = opts.fetch; - } else { // configuration - fetch = opts.fetch.fn || defaultFetch; - autoDecodeResponse = !!opts.fetch.autoDecodeResponse; - } - } - getPublicPath = opts.getPublicPath || opts.getDomain || defaultGetPublicPath; - getTemplate = opts.getTemplate || defaultGetTemplate; - } - - return embedHTMLCache[url] || (embedHTMLCache[url] = fetch(url) - .then(response => readResAsString(response, autoDecodeResponse)) - .then(html => { - - const assetPublicPath = getPublicPath(url); - const { template, scripts, entry, styles } = processTpl(getTemplate(html), assetPublicPath); - - return getEmbedHTML(template, styles, { fetch }).then(embedHTML => ({ - template: embedHTML, - assetPublicPath, - getExternalScripts: () => getExternalScripts(scripts, fetch), - getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch), - execScripts: (proxy, strictGlobal, execScriptsHooks = {}) => { - if (!scripts.length) { - return Promise.resolve(); - } - return execScripts(entry, scripts, proxy, { - fetch, - strictGlobal, - beforeExec: execScriptsHooks.beforeExec, - afterExec: execScriptsHooks.afterExec, - }); - }, - })); - })); - } - - export function importEntry(entry, opts = {}) { - const { fetch = defaultFetch, getTemplate = defaultGetTemplate } = opts; - const getPublicPath = opts.getPublicPath || opts.getDomain || defaultGetPublicPath; - - if (!entry) { - throw new SyntaxError('entry should not be empty!'); - } - - // html entry - if (typeof entry === 'string') { - return importHTML(entry, { - fetch, - getPublicPath, - getTemplate, - }); - } - - // config entry - if (Array.isArray(entry.scripts) || Array.isArray(entry.styles)) { - - const { scripts = [], styles = [], html = '' } = entry; - const getHTMLWithStylePlaceholder = tpl => styles.reduceRight((html, styleSrc) => `${genLinkReplaceSymbol(styleSrc)}${html}`, tpl); - const getHTMLWithScriptPlaceholder = tpl => scripts.reduce((html, scriptSrc) => `${html}${genScriptReplaceSymbol(scriptSrc)}`, tpl); - - return getEmbedHTML(getTemplate(getHTMLWithScriptPlaceholder(getHTMLWithStylePlaceholder(html))), styles, { fetch }).then(embedHTML => ({ - template: embedHTML, - assetPublicPath: getPublicPath(entry), - getExternalScripts: () => getExternalScripts(scripts, fetch), - getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch), - execScripts: (proxy, strictGlobal, execScriptsHooks = {}) => { - if (!scripts.length) { - return Promise.resolve(); - } - return execScripts(scripts[scripts.length - 1], scripts, proxy, { - fetch, - strictGlobal, - beforeExec: execScriptsHooks.beforeExec, - afterExec: execScriptsHooks.afterExec, - }); - }, - })); - - } else { - throw new SyntaxError('entry scripts or styles should be array!'); - } - } - \ No newline at end of file +import processTpl, { genLinkReplaceSymbol, genScriptReplaceSymbol } from './process-tpl'; +import { + defaultGetPublicPath, + getGlobalProp, + getInlineCode, + noteGlobalProps, + readResAsString, + requestIdleCallback, +} from './utils'; + +const styleCache = {}; +const scriptCache = {}; +const embedHTMLCache = {}; +if (!window.fetch) { + throw new Error('[import-html-entry] Here is no "fetch" on the window env, you need to polyfill it'); +} +const defaultFetch = window.fetch.bind(window); + +function defaultGetTemplate(tpl) { + return tpl; +} + +/** + * convert external css link to inline style for performance optimization + * @param template + * @param styles + * @param opts + * @return embedHTML + */ +function getEmbedHTML(template, styles, opts = {}) { + const { fetch = defaultFetch } = opts; + let embedHTML = template; + + return getExternalStyleSheets(styles, fetch) + .then(styleSheets => { + embedHTML = styles.reduce((html, styleSrc, i) => { + html = html.replace(genLinkReplaceSymbol(styleSrc), ``); + return html; + }, embedHTML); + return embedHTML; + }); +} + +const isInlineCode = code => code.startsWith('<'); + +function getExecutableScript(scriptSrc, scriptText, proxy, strictGlobal) { + const sourceUrl = isInlineCode(scriptSrc) ? '' : `//# sourceURL=${scriptSrc}\n`; + + // 通过这种方式获取全局 window,因为 script 也是在全局作用域下运行的,所以我们通过 window.proxy 绑定时也必须确保绑定到全局 window 上 + // 否则在嵌套场景下, window.proxy 设置的是内层应用的 window,而代码其实是在全局作用域运行的,会导致闭包里的 window.proxy 取的是最外层的微应用的 proxy + const globalWindow = (0, eval)('window'); + globalWindow.proxy = proxy; + // TODO 通过 strictGlobal 方式切换 with 闭包,待 with 方式坑趟平后再合并 + return strictGlobal + ? `;(function(window, self, globalThis){with(window){;${scriptText}\n${sourceUrl}}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);` + : `;(function(window, self, globalThis){;${scriptText}\n${sourceUrl}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`; +} + +// for prefetch +export function getExternalStyleSheets(styles, fetch = defaultFetch) { + return Promise.all(styles.map(styleLink => { + if (isInlineCode(styleLink)) { + // if it is inline style + return getInlineCode(styleLink); + } else { + // external styles + return styleCache[styleLink] || + (styleCache[styleLink] = fetch(styleLink).then(response => response.text())); + } + + }, + )); +} + +// for prefetch +export function getExternalScripts(scripts, fetch = defaultFetch, errorCallback = () => { +}) { + + const fetchScript = scriptUrl => scriptCache[scriptUrl] || + (scriptCache[scriptUrl] = fetch(scriptUrl).then(response => { + // usually browser treats 4xx and 5xx response of script loading as an error and will fire a script error event + // https://stackoverflow.com/questions/5625420/what-http-headers-responses-trigger-the-onerror-handler-on-a-script-tag/5625603 + if (response.status >= 400) { + errorCallback(); + throw new Error(`${scriptUrl} load failed with status ${response.status}`); + } + + return response.text(); + }).catch(e => { + errorCallback(); + throw e; + })); + + return Promise.all(scripts.map(script => { + + if (typeof script === 'string') { + if (isInlineCode(script)) { + // if it is inline script + return getInlineCode(script); + } else { + // external script + return fetchScript(script); + } + } else { + // use idle time to load async script + const { src, async } = script; + if (async) { + return { + src, + async: true, + content: new Promise((resolve, reject) => requestIdleCallback(() => fetchScript(src).then(resolve, reject))), + }; + } + + return fetchScript(src); + } + }, + )); +} + +function throwNonBlockingError(error, msg) { + setTimeout(() => { + console.error(msg); + throw error; + }); +} + +const supportsUserTiming = + typeof performance !== 'undefined' && + typeof performance.mark === 'function' && + typeof performance.clearMarks === 'function' && + typeof performance.measure === 'function' && + typeof performance.clearMeasures === 'function'; + +/** + * FIXME to consistent with browser behavior, we should only provide callback way to invoke success and error event + * @param entry + * @param scripts + * @param proxy + * @param opts + * @returns {Promise} + */ +export function execScripts(entry, scripts, proxy = window, opts = {}) { + const { + fetch = defaultFetch, strictGlobal = false, success, error = () => { + }, beforeExec = () => { + }, afterExec = () => { + }, + } = opts; + + return getExternalScripts(scripts, fetch, error) + .then(scriptsText => { + + const geval = (scriptSrc, inlineScript) => { + const rawCode = beforeExec(inlineScript, scriptSrc) || inlineScript; + const code = getExecutableScript(scriptSrc, rawCode, proxy, strictGlobal); + + (0, eval)(code); + + afterExec(inlineScript, scriptSrc); + }; + + function exec(scriptSrc, inlineScript, resolve) { + + const markName = `Evaluating script ${scriptSrc}`; + const measureName = `Evaluating Time Consuming: ${scriptSrc}`; + + if (process.env.NODE_ENV === 'development' && supportsUserTiming) { + performance.mark(markName); + } + + if (scriptSrc === entry) { + noteGlobalProps(strictGlobal ? proxy : window); + + try { + // bind window.proxy to change `this` reference in script + geval(scriptSrc, inlineScript); + const exports = proxy[getGlobalProp(strictGlobal ? proxy : window)] || {}; + resolve(exports); + } catch (e) { + // entry error must be thrown to make the promise settled + console.error(`[import-html-entry]: error occurs while executing entry script ${scriptSrc}`); + throw e; + } + } else { + if (typeof inlineScript === 'string') { + try { + // bind window.proxy to change `this` reference in script + geval(scriptSrc, inlineScript); + } catch (e) { + // consistent with browser behavior, any independent script evaluation error should not block the others + throwNonBlockingError(e, `[import-html-entry]: error occurs while executing normal script ${scriptSrc}`); + } + } else { + // external script marked with async + inlineScript.async && inlineScript?.content + .then(downloadedScriptText => geval(inlineScript.src, downloadedScriptText)) + .catch(e => { + throwNonBlockingError(e, `[import-html-entry]: error occurs while executing async script ${inlineScript.src}`); + }); + } + } + + if (process.env.NODE_ENV === 'development' && supportsUserTiming) { + performance.measure(measureName, markName); + performance.clearMarks(markName); + performance.clearMeasures(measureName); + } + } + + function schedule(i, resolvePromise) { + + if (i < scripts.length) { + const scriptSrc = scripts[i]; + const inlineScript = scriptsText[i]; + + exec(scriptSrc, inlineScript, resolvePromise); + // resolve the promise while the last script executed and entry not provided + if (!entry && i === scripts.length - 1) { + resolvePromise(); + } else { + schedule(i + 1, resolvePromise); + } + } + } + + return new Promise(resolve => schedule(0, success || resolve)); + }); +} + +export default function importHTML(url, opts = {}) { + let fetch = defaultFetch; + let autoDecodeResponse = false; + let getPublicPath = defaultGetPublicPath; + let getTemplate = defaultGetTemplate; + + // compatible with the legacy importHTML api + if (typeof opts === 'function') { + fetch = opts; + } else { + // fetch option is availble + if (opts.fetch) { + // fetch is a funciton + if (typeof opts.fetch === 'function') { + fetch = opts.fetch; + } else { // configuration + fetch = opts.fetch.fn || defaultFetch; + autoDecodeResponse = !!opts.fetch.autoDecodeResponse; + } + } + getPublicPath = opts.getPublicPath || opts.getDomain || defaultGetPublicPath; + getTemplate = opts.getTemplate || defaultGetTemplate; + } + + return embedHTMLCache[url] || (embedHTMLCache[url] = fetch(url) + .then(response => readResAsString(response, autoDecodeResponse)) + .then(html => { + + const assetPublicPath = getPublicPath(url); + const { template, scripts, entry, styles } = processTpl(getTemplate(html), assetPublicPath); + + return getEmbedHTML(template, styles, { fetch }).then(embedHTML => ({ + template: embedHTML, + assetPublicPath, + getExternalScripts: () => getExternalScripts(scripts, fetch), + getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch), + execScripts: (proxy, strictGlobal, execScriptsHooks = {}) => { + if (!scripts.length) { + return Promise.resolve(); + } + return execScripts(entry, scripts, proxy, { + fetch, + strictGlobal, + beforeExec: execScriptsHooks.beforeExec, + afterExec: execScriptsHooks.afterExec, + }); + }, + })); + })); +} + +export function importEntry(entry, opts = {}) { + const { fetch = defaultFetch, getTemplate = defaultGetTemplate } = opts; + const getPublicPath = opts.getPublicPath || opts.getDomain || defaultGetPublicPath; + + if (!entry) { + throw new SyntaxError('entry should not be empty!'); + } + + // html entry + if (typeof entry === 'string') { + return importHTML(entry, { + fetch, + getPublicPath, + getTemplate, + }); + } + + // config entry + if (Array.isArray(entry.scripts) || Array.isArray(entry.styles)) { + + const { scripts = [], styles = [], html = '' } = entry; + const getHTMLWithStylePlaceholder = tpl => styles.reduceRight((html, styleSrc) => `${genLinkReplaceSymbol(styleSrc)}${html}`, tpl); + const getHTMLWithScriptPlaceholder = tpl => scripts.reduce((html, scriptSrc) => `${html}${genScriptReplaceSymbol(scriptSrc)}`, tpl); + + return getEmbedHTML(getTemplate(getHTMLWithScriptPlaceholder(getHTMLWithStylePlaceholder(html))), styles, { fetch }).then(embedHTML => ({ + template: embedHTML, + assetPublicPath: getPublicPath(entry), + getExternalScripts: () => getExternalScripts(scripts, fetch), + getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch), + execScripts: (proxy, strictGlobal, execScriptsHooks = {}) => { + if (!scripts.length) { + return Promise.resolve(); + } + return execScripts(scripts[scripts.length - 1], scripts, proxy, { + fetch, + strictGlobal, + beforeExec: execScriptsHooks.beforeExec, + afterExec: execScriptsHooks.afterExec, + }); + }, + })); + + } else { + throw new SyntaxError('entry scripts or styles should be array!'); + } +} diff --git a/src/process-tpl.js b/src/process-tpl.js index c502fcf..bdd1b2e 100644 --- a/src/process-tpl.js +++ b/src/process-tpl.js @@ -3,228 +3,196 @@ * @homepage https://github.com/kuitos/ * @since 2018-09-03 15:04 */ - import { getInlineCode, isModuleScriptSupported } from './utils'; - - const ALL_SCRIPT_REGEX = /()[\s\S]*?<\/script>/gi; - const SCRIPT_TAG_REGEX = /<(script)\s+((?!type=('|")text\/ng-template\3).)*?>.*?<\/\1>/is; - const SCRIPT_SRC_REGEX = /.*\ssrc=('|")?([^>'"\s]+)/; - const SCRIPT_TYPE_REGEX = /.*\stype=('|")?([^>'"\s]+)/; - const SCRIPT_ENTRY_REGEX = /.*\sentry\s*.*/; - const SCRIPT_ASYNC_REGEX = /.*\sasync\s*.*/; - const SCRIPT_NO_MODULE_REGEX = /.*\snomodule\s*.*/; - const SCRIPT_MODULE_REGEX = /.*\stype=('|")?module('|")?\s*.*/; - const LINK_TAG_REGEX = /<(link)\s+.*?>/isg; - const LINK_PRELOAD_OR_PREFETCH_REGEX = /\srel=('|")?(preload|prefetch)\1/; - const LINK_HREF_REGEX = /.*\shref=('|")?([^>'"\s]+)/; - const LINK_AS_FONT = /.*\sas=('|")?font\1.*/; - const STYLE_TAG_REGEX = /]*>[\s\S]*?<\/style>/gi; - const STYLE_TYPE_REGEX = /\s+rel=('|")?stylesheet\1.*/; - const STYLE_HREF_REGEX = /.*\shref=('|")?([^>'"\s]+)/; - const HTML_COMMENT_REGEX = //g; - const LINK_IGNORE_REGEX = //is; - const STYLE_IGNORE_REGEX = //is; - const SCRIPT_IGNORE_REGEX = //is; - - function hasProtocol(url) { - return url.startsWith('//') || url.startsWith('http://') || url.startsWith('https://'); - } - - function getEntirePath(path, baseURI) { - // 防止new URL(path, '//xxx...')这种形式报错: Invalid base URL - // by daniel@2021-06-22 - let newBaseURI = baseURI; - if (baseURI.indexOf('//') === 0) { - newBaseURI = 'http:' + baseURI; - } - return new URL(path, newBaseURI).toString(); - } - - function isValidJavaScriptType(type) { - const handleTypes = ['text/javascript', 'module', 'application/javascript', 'text/ecmascript', 'application/ecmascript']; - return !type || handleTypes.indexOf(type) !== -1; - } - - export const genLinkReplaceSymbol = (linkHref, preloadOrPrefetch = false) => ``; - export const genScriptReplaceSymbol = (scriptSrc, async = false) => ``; - export const inlineScriptReplaceSymbol = ``; - export const genIgnoreAssetReplaceSymbol = url => ``; - export const genModuleScriptReplaceSymbol = (scriptSrc, moduleSupport) => ``; - /** - * process the css content, transform the relative url in external css to absolute path. - * - * @author Daniel - * @since 2021-06-22 - * @param {string} styleSrc the external css link - * @param {string} styleText the fetched css content - * @returns - */ - export const processCssContent = (styleSrc, styleText) => { - const replacement = function (match, prefix, path, suffix) { - if (hasProtocol(path)) { - return match; - } - - const newHref = getEntirePath(path, styleSrc); - return prefix + newHref + suffix; - }; - - const transformedStyleText = styleText - .replace(/(@import\s*['"])([^'"]+)(['"])/g, replacement) // transform leading `@import 'xxx.css'` - .replace(/(url\(\s*['"]?)([^'"\s\(\)]+)(['"]?\s*\))/g, replacement) // transform `url(xxx.jpg)` - .replace(/(\/[*\/][@#] sourceMappingURL=)(\S+)(( \*\/)?[\r\n]*)$/mg, replacement); // transform trailing `sourceMappingURL=xxx.map` - - return transformedStyleText; - }; - /** - * parse the script link from the template - * 1. collect stylesheets - * 2. use global eval to evaluate the inline scripts - * see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function#Difference_between_Function_constructor_and_function_declaration - * see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/eval#Do_not_ever_use_eval! - * @param tpl - * @param baseURI - * @stripStyles whether to strip the css links - * @returns {{template: void | string | *, scripts: *[], entry: *}} - */ - export default function processTpl(tpl, baseURI) { - - let scripts = []; - const styles = []; - let entry = null; - const moduleSupport = isModuleScriptSupported(); - - const template = tpl - - /* - remove html comment first - */ - .replace(HTML_COMMENT_REGEX, '') - - .replace(LINK_TAG_REGEX, match => { - /* - change the css link - */ - const styleType = !!match.match(STYLE_TYPE_REGEX); - if (styleType) { - - const styleHref = match.match(STYLE_HREF_REGEX); - const styleIgnore = match.match(LINK_IGNORE_REGEX); - - if (styleHref) { - - const href = styleHref && styleHref[2]; - let newHref = href; - - if (href && !hasProtocol(href)) { - newHref = getEntirePath(href, baseURI); - } - if (styleIgnore) { - return genIgnoreAssetReplaceSymbol(newHref); - } - - styles.push(newHref); - return genLinkReplaceSymbol(newHref); - } - } - - const preloadOrPrefetchType = match.match(LINK_PRELOAD_OR_PREFETCH_REGEX) && match.match(LINK_HREF_REGEX) && !match.match(LINK_AS_FONT); - if (preloadOrPrefetchType) { - const [, , linkHref] = match.match(LINK_HREF_REGEX); - return genLinkReplaceSymbol(linkHref, true); - } - - return match; - }) - .replace(STYLE_TAG_REGEX, match => { - if (STYLE_IGNORE_REGEX.test(match)) { - return genIgnoreAssetReplaceSymbol('style file'); - } - return match; - }) - .replace(ALL_SCRIPT_REGEX, (match, scriptTag) => { - const scriptIgnore = scriptTag.match(SCRIPT_IGNORE_REGEX); - const moduleScriptIgnore = - (moduleSupport && !!scriptTag.match(SCRIPT_NO_MODULE_REGEX)) || - (!moduleSupport && !!scriptTag.match(SCRIPT_MODULE_REGEX)); - // in order to keep the exec order of all javascripts - - const matchedScriptTypeMatch = scriptTag.match(SCRIPT_TYPE_REGEX); - const matchedScriptType = matchedScriptTypeMatch && matchedScriptTypeMatch[2]; - if (!isValidJavaScriptType(matchedScriptType)) { - return match; - } - - // if it is a external script - if (SCRIPT_TAG_REGEX.test(match) && scriptTag.match(SCRIPT_SRC_REGEX)) { - /* - collect scripts and replace the ref - */ - - const matchedScriptEntry = scriptTag.match(SCRIPT_ENTRY_REGEX); - const matchedScriptSrcMatch = scriptTag.match(SCRIPT_SRC_REGEX); - let matchedScriptSrc = matchedScriptSrcMatch && matchedScriptSrcMatch[2]; - - if (entry && matchedScriptEntry) { - throw new SyntaxError('You should not set multiply entry script!'); - } else { - - // append the domain while the script not have an protocol prefix - if (matchedScriptSrc && !hasProtocol(matchedScriptSrc)) { - matchedScriptSrc = getEntirePath(matchedScriptSrc, baseURI); - } - - entry = entry || matchedScriptEntry && matchedScriptSrc; - } - - if (scriptIgnore) { - return genIgnoreAssetReplaceSymbol(matchedScriptSrc || 'js file'); - } - - if (moduleScriptIgnore) { - return genModuleScriptReplaceSymbol(matchedScriptSrc || 'js file', moduleSupport); - } - - if (matchedScriptSrc) { - const asyncScript = !!scriptTag.match(SCRIPT_ASYNC_REGEX); - scripts.push(asyncScript ? { async: true, src: matchedScriptSrc } : matchedScriptSrc); - return genScriptReplaceSymbol(matchedScriptSrc, asyncScript); - } - - return match; - } else { - if (scriptIgnore) { - return genIgnoreAssetReplaceSymbol('js file'); - } - - if (moduleScriptIgnore) { - return genModuleScriptReplaceSymbol('js file', moduleSupport); - } - - // if it is an inline script - const code = getInlineCode(match); - - // remove script blocks when all of these lines are comments. - const isPureCommentBlock = code.split(/[\r\n]+/).every(line => !line.trim() || line.trim().startsWith('//')); - - if (!isPureCommentBlock) { - scripts.push(match); - } - - return inlineScriptReplaceSymbol; - } - }); - - scripts = scripts.filter(function (script) { - // filter empty script - return !!script; - }); - - return { - template, - scripts, - styles, - // set the last script as entry if have not set - entry: entry || scripts[scripts.length - 1], - }; - } - \ No newline at end of file +import { getInlineCode, isModuleScriptSupported } from './utils'; + +const ALL_SCRIPT_REGEX = /()[\s\S]*?<\/script>/gi; +const SCRIPT_TAG_REGEX = /<(script)\s+((?!type=('|")text\/ng-template\3).)*?>.*?<\/\1>/is; +const SCRIPT_SRC_REGEX = /.*\ssrc=('|")?([^>'"\s]+)/; +const SCRIPT_TYPE_REGEX = /.*\stype=('|")?([^>'"\s]+)/; +const SCRIPT_ENTRY_REGEX = /.*\sentry\s*.*/; +const SCRIPT_ASYNC_REGEX = /.*\sasync\s*.*/; +const SCRIPT_NO_MODULE_REGEX = /.*\snomodule\s*.*/; +const SCRIPT_MODULE_REGEX = /.*\stype=('|")?module('|")?\s*.*/; +const LINK_TAG_REGEX = /<(link)\s+.*?>/isg; +const LINK_PRELOAD_OR_PREFETCH_REGEX = /\srel=('|")?(preload|prefetch)\1/; +const LINK_HREF_REGEX = /.*\shref=('|")?([^>'"\s]+)/; +const LINK_AS_FONT = /.*\sas=('|")?font\1.*/; +const STYLE_TAG_REGEX = /]*>[\s\S]*?<\/style>/gi; +const STYLE_TYPE_REGEX = /\s+rel=('|")?stylesheet\1.*/; +const STYLE_HREF_REGEX = /.*\shref=('|")?([^>'"\s]+)/; +const HTML_COMMENT_REGEX = //g; +const LINK_IGNORE_REGEX = //is; +const STYLE_IGNORE_REGEX = //is; +const SCRIPT_IGNORE_REGEX = //is; + +function hasProtocol(url) { + return url.startsWith('//') || url.startsWith('http://') || url.startsWith('https://'); +} + +function getEntirePath(path, baseURI) { + return new URL(path, baseURI).toString(); +} + +function isValidJavaScriptType(type) { + const handleTypes = ['text/javascript', 'module', 'application/javascript', 'text/ecmascript', 'application/ecmascript']; + return !type || handleTypes.indexOf(type) !== -1; +} + +export const genLinkReplaceSymbol = (linkHref, preloadOrPrefetch = false) => ``; +export const genScriptReplaceSymbol = (scriptSrc, async = false) => ``; +export const inlineScriptReplaceSymbol = ``; +export const genIgnoreAssetReplaceSymbol = url => ``; +export const genModuleScriptReplaceSymbol = (scriptSrc, moduleSupport) => ``; + +/** + * parse the script link from the template + * 1. collect stylesheets + * 2. use global eval to evaluate the inline scripts + * see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function#Difference_between_Function_constructor_and_function_declaration + * see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/eval#Do_not_ever_use_eval! + * @param tpl + * @param baseURI + * @stripStyles whether to strip the css links + * @returns {{template: void | string | *, scripts: *[], entry: *}} + */ +export default function processTpl(tpl, baseURI) { + + let scripts = []; + const styles = []; + let entry = null; + const moduleSupport = isModuleScriptSupported(); + + const template = tpl + + /* + remove html comment first + */ + .replace(HTML_COMMENT_REGEX, '') + + .replace(LINK_TAG_REGEX, match => { + /* + change the css link + */ + const styleType = !!match.match(STYLE_TYPE_REGEX); + if (styleType) { + + const styleHref = match.match(STYLE_HREF_REGEX); + const styleIgnore = match.match(LINK_IGNORE_REGEX); + + if (styleHref) { + + const href = styleHref && styleHref[2]; + let newHref = href; + + if (href && !hasProtocol(href)) { + newHref = getEntirePath(href, baseURI); + } + if (styleIgnore) { + return genIgnoreAssetReplaceSymbol(newHref); + } + + styles.push(newHref); + return genLinkReplaceSymbol(newHref); + } + } + + const preloadOrPrefetchType = match.match(LINK_PRELOAD_OR_PREFETCH_REGEX) && match.match(LINK_HREF_REGEX) && !match.match(LINK_AS_FONT); + if (preloadOrPrefetchType) { + const [, , linkHref] = match.match(LINK_HREF_REGEX); + return genLinkReplaceSymbol(linkHref, true); + } + + return match; + }) + .replace(STYLE_TAG_REGEX, match => { + if (STYLE_IGNORE_REGEX.test(match)) { + return genIgnoreAssetReplaceSymbol('style file'); + } + return match; + }) + .replace(ALL_SCRIPT_REGEX, (match, scriptTag) => { + const scriptIgnore = scriptTag.match(SCRIPT_IGNORE_REGEX); + const moduleScriptIgnore = + (moduleSupport && !!scriptTag.match(SCRIPT_NO_MODULE_REGEX)) || + (!moduleSupport && !!scriptTag.match(SCRIPT_MODULE_REGEX)); + // in order to keep the exec order of all javascripts + + const matchedScriptTypeMatch = scriptTag.match(SCRIPT_TYPE_REGEX); + const matchedScriptType = matchedScriptTypeMatch && matchedScriptTypeMatch[2]; + if (!isValidJavaScriptType(matchedScriptType)) { + return match; + } + + // if it is a external script + if (SCRIPT_TAG_REGEX.test(match) && scriptTag.match(SCRIPT_SRC_REGEX)) { + /* + collect scripts and replace the ref + */ + + const matchedScriptEntry = scriptTag.match(SCRIPT_ENTRY_REGEX); + const matchedScriptSrcMatch = scriptTag.match(SCRIPT_SRC_REGEX); + let matchedScriptSrc = matchedScriptSrcMatch && matchedScriptSrcMatch[2]; + + if (entry && matchedScriptEntry) { + throw new SyntaxError('You should not set multiply entry script!'); + } else { + + // append the domain while the script not have an protocol prefix + if (matchedScriptSrc && !hasProtocol(matchedScriptSrc)) { + matchedScriptSrc = getEntirePath(matchedScriptSrc, baseURI); + } + + entry = entry || matchedScriptEntry && matchedScriptSrc; + } + + if (scriptIgnore) { + return genIgnoreAssetReplaceSymbol(matchedScriptSrc || 'js file'); + } + + if (moduleScriptIgnore) { + return genModuleScriptReplaceSymbol(matchedScriptSrc || 'js file', moduleSupport); + } + + if (matchedScriptSrc) { + const asyncScript = !!scriptTag.match(SCRIPT_ASYNC_REGEX); + scripts.push(asyncScript ? { async: true, src: matchedScriptSrc } : matchedScriptSrc); + return genScriptReplaceSymbol(matchedScriptSrc, asyncScript); + } + + return match; + } else { + if (scriptIgnore) { + return genIgnoreAssetReplaceSymbol('js file'); + } + + if (moduleScriptIgnore) { + return genModuleScriptReplaceSymbol('js file', moduleSupport); + } + + // if it is an inline script + const code = getInlineCode(match); + + // remove script blocks when all of these lines are comments. + const isPureCommentBlock = code.split(/[\r\n]+/).every(line => !line.trim() || line.trim().startsWith('//')); + + if (!isPureCommentBlock) { + scripts.push(match); + } + + return inlineScriptReplaceSymbol; + } + }); + + scripts = scripts.filter(function (script) { + // filter empty script + return !!script; + }); + + return { + template, + scripts, + styles, + // set the last script as entry if have not set + entry: entry || scripts[scripts.length - 1], + }; +} From 7301261c04f2e62558d754461d36aab651052e49 Mon Sep 17 00:00:00 2001 From: daniexiong Date: Tue, 22 Jun 2021 12:25:19 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=E8=BD=AC=E6=8D=A2=E5=A4=A7=E5=A4=9A?= =?UTF-8?q?=E6=95=B0=E6=83=85=E5=86=B5=E4=B8=AD=E5=A4=96=E9=83=A8css?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E4=B8=AD=E7=9A=84=E7=9B=B8=E5=AF=B9=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E4=B8=BA=E7=BB=9D=E5=AF=B9=E8=B7=AF=E5=BE=84=EF=BC=8C?= =?UTF-8?q?=E9=98=B2=E6=AD=A2404?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/test-process-tpl.js | 20 +++++++++++++++++++ src/index.js | 5 +++-- src/process-tpl.js | 33 ++++++++++++++++++++++++++++++- 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/src/__tests__/test-process-tpl.js b/src/__tests__/test-process-tpl.js index 2e09da7..1b8e56f 100644 --- a/src/__tests__/test-process-tpl.js +++ b/src/__tests__/test-process-tpl.js @@ -4,6 +4,7 @@ import processTpl, { genLinkReplaceSymbol, genModuleScriptReplaceSymbol, genScriptReplaceSymbol, + processCssContent, } from '../process-tpl'; test('test process-tpl', () => { @@ -318,3 +319,22 @@ test('should work with huge html content', () => { const during = Date.now() - start; expect(during < 1000).toBeTruthy(); }); + +test('test process url in external css resources', () => { + const transformedStyleText = processCssContent('//cdntest.com/css/ui.css', ` + @import 'component1.css' + @import url('component2.less') + .test-notice { + background: #ffffff url(../images/bg1.jpg) no-repeat center left; + background-image: url( '../images/bg2.jpg' ); + background-image: url("../images/bg3.jpg"); + }; + /*# sourceMappingURL=test-notice.css.map */ + `); + expect(transformedStyleText.indexOf('//cdntest.com/css/component1.css') !== -1).toBeTruthy(); + expect(transformedStyleText.indexOf('//cdntest.com/css/component2.less') !== -1).toBeTruthy(); + expect(transformedStyleText.indexOf('//cdntest.com/images/bg1.jpg') !== -1).toBeTruthy(); + expect(transformedStyleText.indexOf('//cdntest.com/images/bg2.jpg') !== -1).toBeTruthy(); + expect(transformedStyleText.indexOf('//cdntest.com/images/bg3.jpg') !== -1).toBeTruthy(); + expect(transformedStyleText.indexOf('//cdntest.com/css/test-notice.css.map') !== -1).toBeTruthy(); +}); \ No newline at end of file diff --git a/src/index.js b/src/index.js index 28fd294..a096a68 100644 --- a/src/index.js +++ b/src/index.js @@ -4,7 +4,7 @@ * @since 2018-08-15 11:37 */ -import processTpl, { genLinkReplaceSymbol, genScriptReplaceSymbol } from './process-tpl'; +import processTpl, { genLinkReplaceSymbol, genScriptReplaceSymbol, processCssContent } from './process-tpl'; import { defaultGetPublicPath, getGlobalProp, @@ -40,7 +40,8 @@ function getEmbedHTML(template, styles, opts = {}) { return getExternalStyleSheets(styles, fetch) .then(styleSheets => { embedHTML = styles.reduce((html, styleSrc, i) => { - html = html.replace(genLinkReplaceSymbol(styleSrc), ``); + const styleText = processCssContent(styleSrc, styleSheets[i]); + html = html.replace(genLinkReplaceSymbol(styleSrc), ``); return html; }, embedHTML); return embedHTML; diff --git a/src/process-tpl.js b/src/process-tpl.js index bdd1b2e..19c55d3 100644 --- a/src/process-tpl.js +++ b/src/process-tpl.js @@ -30,7 +30,13 @@ function hasProtocol(url) { } function getEntirePath(path, baseURI) { - return new URL(path, baseURI).toString(); + // 防止new URL(path, '//xxx...')这种形式报错: Invalid base URL + // by daniel@2021-06-22 + let newBaseURI = baseURI; + if (baseURI.indexOf('//') === 0) { + newBaseURI = 'http:' + baseURI; + } + return new URL(path, newBaseURI).toString(); } function isValidJavaScriptType(type) { @@ -43,7 +49,32 @@ export const genScriptReplaceSymbol = (scriptSrc, async = false) => ``; export const genIgnoreAssetReplaceSymbol = url => ``; export const genModuleScriptReplaceSymbol = (scriptSrc, moduleSupport) => ``; +/** + * process the css content, transform the relative url in external css to absolute path. + * + * @author Daniel + * @since 2021-06-22 + * @param {string} styleSrc the external css link + * @param {string} styleText the fetched css content + * @returns + */ + export const processCssContent = (styleSrc, styleText) => { + const replacement = function (match, prefix, path, suffix) { + if (hasProtocol(path)) { + return match; + } + const newHref = getEntirePath(path, styleSrc); + return prefix + newHref + suffix; + }; + + const transformedStyleText = styleText + .replace(/(@import\s*['"])([^'"]+)(['"])/g, replacement) // transform leading `@import 'xxx.css'` + .replace(/(url\(\s*['"]?)([^'"\s\(\)]+)(['"]?\s*\))/g, replacement) // transform `url(xxx.jpg)` + .replace(/(\/[*\/][@#] sourceMappingURL=)(\S+)(( \*\/)?[\r\n]*)$/mg, replacement); // transform trailing `sourceMappingURL=xxx.map` + + return transformedStyleText; +}; /** * parse the script link from the template * 1. collect stylesheets From 2ce721d0e9fdc4980b34bdb241b4578519931f66 Mon Sep 17 00:00:00 2001 From: daniexiong Date: Tue, 22 Jun 2021 12:34:19 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=E5=A2=9E=E5=8A=A0=E4=BA=86=E5=8D=95?= =?UTF-8?q?=E5=85=83=E6=B5=8B=E8=AF=95=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/test-process-tpl.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/__tests__/test-process-tpl.js b/src/__tests__/test-process-tpl.js index 1b8e56f..8fa26f8 100644 --- a/src/__tests__/test-process-tpl.js +++ b/src/__tests__/test-process-tpl.js @@ -328,6 +328,8 @@ test('test process url in external css resources', () => { background: #ffffff url(../images/bg1.jpg) no-repeat center left; background-image: url( '../images/bg2.jpg' ); background-image: url("../images/bg3.jpg"); + background-image: url("//test.com/images/bg4.jpg"); + background-image: url("http://test.com/images/bg5.jpg"); }; /*# sourceMappingURL=test-notice.css.map */ `); @@ -336,5 +338,7 @@ test('test process url in external css resources', () => { expect(transformedStyleText.indexOf('//cdntest.com/images/bg1.jpg') !== -1).toBeTruthy(); expect(transformedStyleText.indexOf('//cdntest.com/images/bg2.jpg') !== -1).toBeTruthy(); expect(transformedStyleText.indexOf('//cdntest.com/images/bg3.jpg') !== -1).toBeTruthy(); + expect(transformedStyleText.indexOf('//test.com/images/bg4.jpg') !== -1).toBeTruthy(); + expect(transformedStyleText.indexOf('http://test.com/images/bg5.jpg') !== -1).toBeTruthy(); expect(transformedStyleText.indexOf('//cdntest.com/css/test-notice.css.map') !== -1).toBeTruthy(); }); \ No newline at end of file