diff --git a/lib/plugins/paragon-webpack-plugin/utils.js b/lib/plugins/paragon-webpack-plugin/utils.js deleted file mode 100644 index 70a667944..000000000 --- a/lib/plugins/paragon-webpack-plugin/utils.js +++ /dev/null @@ -1,373 +0,0 @@ -const { sources } = require('webpack'); -const parse5 = require('parse5'); - -function getDescendantByTag(node, tag) { - for (let i = 0; i < node.childNodes?.length; i++) { - if (node.childNodes[i].tagName === tag) { - return node.childNodes[i]; - } - const result = getDescendantByTag(node.childNodes[i], tag); - if (result) { - return result; - } - } - return null; -} - -function findScriptInsertionPoint({ document, originalSource }) { - const bodyElement = getDescendantByTag(document, 'body'); - if (!bodyElement) { - throw new Error('Missing body element in index.html.'); - } - - // determine script insertion point - if (bodyElement.sourceCodeLocation?.endTag) { - return bodyElement.sourceCodeLocation.endTag.startOffset; - } - - // less accurate fallback - return originalSource.indexOf('
'); -} - -function findStylesheetInsertionPoint({ document, source }) { - const headElement = getDescendantByTag(document, 'head'); - if (!headElement) { - throw new Error('Missing head element in index.html.'); - } - - // determine script insertion point - if (headElement.sourceCodeLocation?.startTag) { - return headElement.sourceCodeLocation.startTag.endOffset; - } - - // less accurate fallback - const headTagString = '
'; - const headTagIndex = source.indexOf(headTagString); - return headTagIndex + headTagString.length; -} - -function minifyScript(script) { - return script - .replace(/>[\r\n ]+<') - .replace(/(<.*?>)|\s+/g, (m, $1) => { - if ($1) { return $1; } - return ' '; - }) - .trim(); -} - -function insertScriptContentsIntoDocument({ - originalSource, - scriptContents, -}) { - // parse file as html document - const document = parse5.parse(originalSource, { - sourceCodeLocationInfo: true, - }); - - // find the body element - const scriptInsertionPoint = findScriptInsertionPoint({ - document, - originalSource, - }); - - // create Paragon script to inject into the HTML document - const paragonScript = ``; - - // insert the Paragon script into the HTML document - const newSource = new sources.ReplaceSource( - new sources.RawSource(originalSource), - 'index.html', - ); - newSource.insert(scriptInsertionPoint, minifyScript(paragonScript)); - return newSource; -} - -function insertStylesheetsIntoDocument({ - source, - urls, -}) { - // parse file as html document - const document = parse5.parse(source, { - sourceCodeLocationInfo: true, - }); - if (!getDescendantByTag(document, 'head')) { - return undefined; - } - - const newSource = new sources.ReplaceSource( - new sources.RawSource(source), - 'index.html', - ); - - // insert the brand overrides styles into the HTML document - const stylesheetInsertionPoint = findStylesheetInsertionPoint({ - document, - source: newSource, - }); - - function createNewStylesheet(url) { - const baseLink = ``; - return baseLink; - } - - if (urls.default) { - const existingDefaultLink = getDescendantByTag(`link[href='${urls.default}']`); - if (!existingDefaultLink) { - // create link to inject into the HTML document - const stylesheetLink = createNewStylesheet(urls.default); - newSource.insert(stylesheetInsertionPoint, stylesheetLink); - } - } - - if (urls.brandOverride) { - const existingBrandLink = getDescendantByTag(`link[href='${urls.brandOverride}']`); - if (!existingBrandLink) { - // create link to inject into the HTML document - const stylesheetLink = createNewStylesheet(urls.brandOverride); - newSource.insert(stylesheetInsertionPoint, stylesheetLink); - } - } - - return newSource; -} - -function findCoreCssAsset(paragonAssets) { - return paragonAssets?.find((asset) => asset.name.includes('core') && asset.name.endsWith('.css')); -} - -function findThemeVariantCssAssets(paragonAssets, { - isBrandOverride = false, - brandThemeCss, - paragonThemeCss, -}) { - const themeVariantsSource = isBrandOverride ? brandThemeCss?.variants : paragonThemeCss?.variants; - const themeVariantCssAssets = {}; - Object.entries(themeVariantsSource || {}).forEach(([themeVariant, value]) => { - const foundThemeVariantAsset = paragonAssets.find((asset) => asset.name.includes(value.outputChunkName)); - if (!foundThemeVariantAsset) { - return; - } - themeVariantCssAssets[themeVariant] = { - fileName: foundThemeVariantAsset.name, - }; - }); - return themeVariantCssAssets; -} - -function getCssAssetsFromCompilation(compilation, { - isBrandOverride = false, - brandThemeCss, - paragonThemeCss, -}) { - const assetSubstring = isBrandOverride ? 'brand' : 'paragon'; - const paragonAssets = compilation.getAssets().filter(asset => asset.name.includes(assetSubstring) && asset.name.endsWith('.css')); - const coreCssAsset = findCoreCssAsset(paragonAssets); - const themeVariantCssAssets = findThemeVariantCssAssets(paragonAssets, { - isBrandOverride, - paragonThemeCss, - brandThemeCss, - }); - return { - coreCssAsset: { - fileName: coreCssAsset?.name, - }, - themeVariantCssAssets, - }; -} - -function addToScriptContents({ - version, - defaults, - coreCssAsset, - themeVariantCssAssets, -}) { - return { - version, - themeUrls: { - core: coreCssAsset, - variants: themeVariantCssAssets, - defaults, - }, - }; -} - -function generateScriptContents({ - paragonCoreCssAsset, - paragonThemeVariantCssAssets, - brandCoreCssAsset, - brandThemeVariantCssAssets, - paragonThemeCss, - paragonVersion, - brandThemeCss, - brandVersion, -}) { - const scriptContents = {}; - scriptContents.paragon = addToScriptContents({ - version: paragonVersion, - coreCssAsset: paragonCoreCssAsset, - themeVariantCssAssets: paragonThemeVariantCssAssets, - defaults: paragonThemeCss?.defaults, - }); - scriptContents.brand = addToScriptContents({ - version: brandVersion, - coreCssAsset: brandCoreCssAsset, - themeVariantCssAssets: brandThemeVariantCssAssets, - defaults: brandThemeCss?.defaults, - }); - return scriptContents; -} - -function injectMetadataIntoDocument(compilation, { - paragonThemeCss, - paragonVersion, - brandThemeCss, - brandVersion, -}) { - const file = compilation.getAsset('index.html'); - if (!file) { - return undefined; - } - const { - coreCssAsset: paragonCoreCssAsset, - themeVariantCssAssets: paragonThemeVariantCssAssets, - } = getCssAssetsFromCompilation(compilation, { - brandThemeCss, - paragonThemeCss, - }); - const { - coreCssAsset: brandCoreCssAsset, - themeVariantCssAssets: brandThemeVariantCssAssets, - } = getCssAssetsFromCompilation(compilation, { - isBrandOverride: true, - brandThemeCss, - paragonThemeCss, - }); - - const scriptContents = generateScriptContents({ - paragonCoreCssAsset, - paragonThemeVariantCssAssets, - brandCoreCssAsset, - brandThemeVariantCssAssets, - paragonThemeCss, - paragonVersion, - brandThemeCss, - brandVersion, - }); - - const originalSource = file.source.source(); - const newSource = insertScriptContentsIntoDocument({ - originalSource, - coreCssAsset: paragonCoreCssAsset, - themeVariantCssAssets: paragonThemeVariantCssAssets, - scriptContents, - }); - - compilation.updateAsset('index.html', new sources.RawSource(newSource.source())); - - return scriptContents; -} - -function handleVersionSubstitution({ url, wildcardKeyword, localVersion }) { - if (!url || !url.includes(wildcardKeyword) || !localVersion) { - return url; - } - return url.replaceAll(wildcardKeyword, localVersion); -} - -function getParagonStylesheetUrls({ paragonThemeUrls, paragonVersion, brandVersion }) { - const paragonCoreCssUrl = typeof paragonThemeUrls.core.urls === 'object' ? paragonThemeUrls.core.urls.default : paragonThemeUrls.core.url; - const brandCoreCssUrl = typeof paragonThemeUrls.core.urls === 'object' ? paragonThemeUrls.core.urls.brandOverride : undefined; - - const defaultThemeVariants = paragonThemeUrls.defaults || {}; - - const coreCss = { - urls: { - default: handleVersionSubstitution({ url: paragonCoreCssUrl, wildcardKeyword: '$paragonVersion', localVersion: paragonVersion }), - brandOverride: handleVersionSubstitution({ url: brandCoreCssUrl, wildcardKeyword: '$brandVersion', localVersion: brandVersion }), - }, - }; - - const themeVariantsCss = {}; - const themeVariantsEntries = Object.entries(paragonThemeUrls.variants || {}); - themeVariantsEntries.forEach(([themeVariant, { url, urls }]) => { - const themeVariantMetadata = { urls: null }; - if (url) { - themeVariantMetadata.urls = { - default: handleVersionSubstitution({ - url, - wildcardKeyword: '$paragonVersion', - localVersion: paragonVersion, - }), - // If there is no brand override URL, then we don't need to do any version substitution - // but we still need to return the property. - brandOverride: undefined, - }; - } else { - themeVariantMetadata.urls = { - default: handleVersionSubstitution({ - url: urls.default, - wildcardKeyword: '$paragonVersion', - localVersion: paragonVersion, - }), - brandOverride: handleVersionSubstitution({ - url: urls.brandOverride, - wildcardKeyword: '$brandVersion', - localVersion: brandVersion, - }), - }; - } - themeVariantsCss[themeVariant] = themeVariantMetadata; - }); - - return { - core: coreCss, - variants: themeVariantsCss, - defaults: defaultThemeVariants, - }; -} - -function injectParagonCoreStylesheets({ - source, - paragonCoreCss, - paragonThemeCss, - brandThemeCss, -}) { - return insertStylesheetsIntoDocument({ - source, - urls: paragonCoreCss.urls, - paragonThemeCss, - brandThemeCss, - }); -} - -function injectParagonThemeVariantStylesheets({ - source, - paragonThemeVariantCss, - paragonThemeCss, - brandThemeCss, -}) { - let newSource = source; - Object.values(paragonThemeVariantCss).forEach(({ urls }) => { - newSource = insertStylesheetsIntoDocument({ - source: typeof newSource === 'object' ? newSource.source() : newSource, - urls, - paragonThemeCss, - brandThemeCss, - }); - }); - return newSource; -} - -module.exports = { - injectMetadataIntoDocument, - getParagonStylesheetUrls, - injectParagonCoreStylesheets, - injectParagonThemeVariantStylesheets, -}; diff --git a/lib/plugins/paragon-webpack-plugin/utils/assetUtils.js b/lib/plugins/paragon-webpack-plugin/utils/assetUtils.js new file mode 100644 index 000000000..eca27e3a2 --- /dev/null +++ b/lib/plugins/paragon-webpack-plugin/utils/assetUtils.js @@ -0,0 +1,75 @@ +/** + * Finds the core CSS asset from the given array of Paragon assets. + * + * @param {Array} paragonAssets - An array of Paragon assets. + * @return {Object|undefined} The core CSS asset, or undefined if not found. + */ +function findCoreCssAsset(paragonAssets) { + return paragonAssets?.find((asset) => asset.name.includes('core') && asset.name.endsWith('.css')); +} + +/** + * Finds the theme variant CSS assets from the given Paragon assets based on the provided options. + * + * @param {Array} paragonAssets - An array of Paragon assets. + * @param {Object} options - The options for finding the theme variant CSS assets. + * @param {boolean} [options.isBrandOverride=false] - Indicates if the theme variant is a brand override. + * @param {Object} [options.brandThemeCss] - The brand theme CSS object. + * @param {Object} [options.paragonThemeCss] - The Paragon theme CSS object. + * @return {Object} - The theme variant CSS assets. + */ +function findThemeVariantCssAssets(paragonAssets, { + isBrandOverride = false, + brandThemeCss, + paragonThemeCss, +}) { + const themeVariantsSource = isBrandOverride ? brandThemeCss?.variants : paragonThemeCss?.variants; + const themeVariantCssAssets = {}; + Object.entries(themeVariantsSource || {}).forEach(([themeVariant, value]) => { + const foundThemeVariantAsset = paragonAssets.find((asset) => asset.name.includes(value.outputChunkName)); + if (!foundThemeVariantAsset) { + return; + } + themeVariantCssAssets[themeVariant] = { + fileName: foundThemeVariantAsset.name, + }; + }); + return themeVariantCssAssets; +} + +/** + * Retrieves the CSS assets from the compilation based on the provided options. + * + * @param {Object} compilation - The compilation object. + * @param {Object} options - The options for retrieving the CSS assets. + * @param {boolean} [options.isBrandOverride=false] - Indicates if the assets are for a brand override. + * @param {Object} [options.brandThemeCss] - The brand theme CSS object. + * @param {Object} [options.paragonThemeCss] - The Paragon theme CSS object. + * @return {Object} - The CSS assets, including the core CSS asset and theme variant CSS assets. + */ +function getCssAssetsFromCompilation(compilation, { + isBrandOverride = false, + brandThemeCss, + paragonThemeCss, +}) { + const assetSubstring = isBrandOverride ? 'brand' : 'paragon'; + const paragonAssets = compilation.getAssets().filter(asset => asset.name.includes(assetSubstring) && asset.name.endsWith('.css')); + const coreCssAsset = findCoreCssAsset(paragonAssets); + const themeVariantCssAssets = findThemeVariantCssAssets(paragonAssets, { + isBrandOverride, + paragonThemeCss, + brandThemeCss, + }); + return { + coreCssAsset: { + fileName: coreCssAsset?.name, + }, + themeVariantCssAssets, + }; +} + +module.exports = { + findCoreCssAsset, + findThemeVariantCssAssets, + getCssAssetsFromCompilation, +}; diff --git a/lib/plugins/paragon-webpack-plugin/utils/htmlUtils.js b/lib/plugins/paragon-webpack-plugin/utils/htmlUtils.js new file mode 100644 index 000000000..2923951e0 --- /dev/null +++ b/lib/plugins/paragon-webpack-plugin/utils/htmlUtils.js @@ -0,0 +1,69 @@ +const { sources } = require('webpack'); + +const { getCssAssetsFromCompilation } = require('./assetUtils'); +const { generateScriptContents, insertScriptContentsIntoDocument } = require('./scriptUtils'); + +/** + * Injects metadata into the HTML document by modifying the 'index.html' asset in the compilation. + * + * @param {Object} compilation - The Webpack compilation object. + * @param {Object} options - The options object. + * @param {Object} options.paragonThemeCss - The Paragon theme CSS object. + * @param {string} options.paragonVersion - The version of the Paragon theme. + * @param {Object} options.brandThemeCss - The brand theme CSS object. + * @param {string} options.brandVersion - The version of the brand theme. + * @return {Object|undefined} The script contents object if the 'index.html' asset exists, otherwise undefined. + */ +function injectMetadataIntoDocument(compilation, { + paragonThemeCss, + paragonVersion, + brandThemeCss, + brandVersion, +}) { + const file = compilation.getAsset('index.html'); + if (!file) { + return undefined; + } + const { + coreCssAsset: paragonCoreCssAsset, + themeVariantCssAssets: paragonThemeVariantCssAssets, + } = getCssAssetsFromCompilation(compilation, { + brandThemeCss, + paragonThemeCss, + }); + const { + coreCssAsset: brandCoreCssAsset, + themeVariantCssAssets: brandThemeVariantCssAssets, + } = getCssAssetsFromCompilation(compilation, { + isBrandOverride: true, + brandThemeCss, + paragonThemeCss, + }); + + const scriptContents = generateScriptContents({ + paragonCoreCssAsset, + paragonThemeVariantCssAssets, + brandCoreCssAsset, + brandThemeVariantCssAssets, + paragonThemeCss, + paragonVersion, + brandThemeCss, + brandVersion, + }); + + const originalSource = file.source.source(); + const newSource = insertScriptContentsIntoDocument({ + originalSource, + coreCssAsset: paragonCoreCssAsset, + themeVariantCssAssets: paragonThemeVariantCssAssets, + scriptContents, + }); + + compilation.updateAsset('index.html', new sources.RawSource(newSource.source())); + + return scriptContents; +} + +module.exports = { + injectMetadataIntoDocument, +}; diff --git a/lib/plugins/paragon-webpack-plugin/utils/index.js b/lib/plugins/paragon-webpack-plugin/utils/index.js new file mode 100644 index 000000000..439b5bc3a --- /dev/null +++ b/lib/plugins/paragon-webpack-plugin/utils/index.js @@ -0,0 +1,9 @@ +const { getParagonStylesheetUrls, injectParagonCoreStylesheets, injectParagonThemeVariantStylesheets } = require('./paragonStylesheetUtils'); +const { injectMetadataIntoDocument } = require('./htmlUtils'); + +module.exports = { + injectMetadataIntoDocument, + getParagonStylesheetUrls, + injectParagonCoreStylesheets, + injectParagonThemeVariantStylesheets, +}; diff --git a/lib/plugins/paragon-webpack-plugin/utils/paragonStylesheetUtils.js b/lib/plugins/paragon-webpack-plugin/utils/paragonStylesheetUtils.js new file mode 100644 index 000000000..2b8272072 --- /dev/null +++ b/lib/plugins/paragon-webpack-plugin/utils/paragonStylesheetUtils.js @@ -0,0 +1,120 @@ +const { insertStylesheetsIntoDocument } = require('./stylesheetUtils'); +const { handleVersionSubstitution } = require('./tagUtils'); + +/** + * Injects Paragon core stylesheets into the document. + * + * @param {Object} options - The options object. + * @param {string|object} options.source - The source HTML document. + * @param {Object} options.paragonCoreCss - The Paragon core CSS object. + * @param {Object} options.paragonThemeCss - The Paragon theme CSS object. + * @param {Object} options.brandThemeCss - The brand theme CSS object. + * @return {string|object} The modified HTML document with Paragon core stylesheets injected. + */ +function injectParagonCoreStylesheets({ + source, + paragonCoreCss, + paragonThemeCss, + brandThemeCss, +}) { + return insertStylesheetsIntoDocument({ + source, + urls: paragonCoreCss.urls, + paragonThemeCss, + brandThemeCss, + }); +} + +/** + * Injects Paragon theme variant stylesheets into the document. + * + * @param {Object} options - The options object. + * @param {string|object} options.source - The source HTML document. + * @param {Object} options.paragonThemeVariantCss - The Paragon theme variant CSS object. + * @param {Object} options.paragonThemeCss - The Paragon theme CSS object. + * @param {Object} options.brandThemeCss - The brand theme CSS object. + * @return {string|object} The modified HTML document with Paragon theme variant stylesheets injected. + */ +function injectParagonThemeVariantStylesheets({ + source, + paragonThemeVariantCss, + paragonThemeCss, + brandThemeCss, +}) { + let newSource = source; + Object.values(paragonThemeVariantCss).forEach(({ urls }) => { + newSource = insertStylesheetsIntoDocument({ + source: typeof newSource === 'object' ? newSource.source() : newSource, + urls, + paragonThemeCss, + brandThemeCss, + }); + }); + return newSource; +} +/** + * Retrieves the URLs of the Paragon stylesheets based on the provided theme URLs, Paragon version, and brand version. + * + * @param {Object} options - The options object. + * @param {Object} options.paragonThemeUrls - The URLs of the Paragon theme. + * @param {string} options.paragonVersion - The version of the Paragon theme. + * @param {string} options.brandVersion - The version of the brand theme. + * @return {Object} An object containing the URLs of the Paragon stylesheets. + */ +function getParagonStylesheetUrls({ paragonThemeUrls, paragonVersion, brandVersion }) { + const paragonCoreCssUrl = typeof paragonThemeUrls.core.urls === 'object' ? paragonThemeUrls.core.urls.default : paragonThemeUrls.core.url; + const brandCoreCssUrl = typeof paragonThemeUrls.core.urls === 'object' ? paragonThemeUrls.core.urls.brandOverride : undefined; + + const defaultThemeVariants = paragonThemeUrls.defaults || {}; + + const coreCss = { + urls: { + default: handleVersionSubstitution({ url: paragonCoreCssUrl, wildcardKeyword: '$paragonVersion', localVersion: paragonVersion }), + brandOverride: handleVersionSubstitution({ url: brandCoreCssUrl, wildcardKeyword: '$brandVersion', localVersion: brandVersion }), + }, + }; + + const themeVariantsCss = {}; + const themeVariantsEntries = Object.entries(paragonThemeUrls.variants || {}); + themeVariantsEntries.forEach(([themeVariant, { url, urls }]) => { + const themeVariantMetadata = { urls: null }; + if (url) { + themeVariantMetadata.urls = { + default: handleVersionSubstitution({ + url, + wildcardKeyword: '$paragonVersion', + localVersion: paragonVersion, + }), + // If there is no brand override URL, then we don't need to do any version substitution + // but we still need to return the property. + brandOverride: undefined, + }; + } else { + themeVariantMetadata.urls = { + default: handleVersionSubstitution({ + url: urls.default, + wildcardKeyword: '$paragonVersion', + localVersion: paragonVersion, + }), + brandOverride: handleVersionSubstitution({ + url: urls.brandOverride, + wildcardKeyword: '$brandVersion', + localVersion: brandVersion, + }), + }; + } + themeVariantsCss[themeVariant] = themeVariantMetadata; + }); + + return { + core: coreCss, + variants: themeVariantsCss, + defaults: defaultThemeVariants, + }; +} + +module.exports = { + injectParagonCoreStylesheets, + injectParagonThemeVariantStylesheets, + getParagonStylesheetUrls, +}; diff --git a/lib/plugins/paragon-webpack-plugin/utils/scriptUtils.js b/lib/plugins/paragon-webpack-plugin/utils/scriptUtils.js new file mode 100644 index 000000000..11005014a --- /dev/null +++ b/lib/plugins/paragon-webpack-plugin/utils/scriptUtils.js @@ -0,0 +1,144 @@ +const { sources } = require('webpack'); +const parse5 = require('parse5'); + +const { getDescendantByTag, minifyScript } = require('./tagUtils'); + +/** + * Finds the insertion point for a script in an HTML document. + * + * @param {Object} options - The options object. + * @param {Object} options.document - The parsed HTML document. + * @param {string} options.originalSource - The original source code of the HTML document. + * @throws {Error} If the body element is missing in the HTML document. + * @return {number} The insertion point for the script in the HTML document. + */ +function findScriptInsertionPoint({ document, originalSource }) { + const bodyElement = getDescendantByTag(document, 'body'); + if (!bodyElement) { + throw new Error('Missing body element in index.html.'); + } + + // determine script insertion point + if (bodyElement.sourceCodeLocation?.endTag) { + return bodyElement.sourceCodeLocation.endTag.startOffset; + } + + // less accurate fallback + return originalSource.indexOf(''); +} + +/** + * Inserts the given script contents into the HTML document and returns a new source with the modified content. + * + * @param {Object} options - The options object. + * @param {string} options.originalSource - The original HTML source. + * @param {Object} options.scriptContents - The contents of the script to be inserted. + * @return {sources.ReplaceSource} The new source with the modified HTML content. + */ +function insertScriptContentsIntoDocument({ + originalSource, + scriptContents, +}) { + // parse file as html document + const document = parse5.parse(originalSource, { + sourceCodeLocationInfo: true, + }); + + // find the body element + const scriptInsertionPoint = findScriptInsertionPoint({ + document, + originalSource, + }); + + // create Paragon script to inject into the HTML document + const paragonScript = ``; + + // insert the Paragon script into the HTML document + const newSource = new sources.ReplaceSource( + new sources.RawSource(originalSource), + 'index.html', + ); + newSource.insert(scriptInsertionPoint, minifyScript(paragonScript)); + return newSource; +} + +/** + * Creates an object with the provided version, defaults, coreCssAsset, and themeVariantCssAssets + * and returns it. The returned object has the following structure: + * { + * version: The provided version, + * themeUrls: { + * core: The provided coreCssAsset, + * variants: The provided themeVariantCssAssets, + * defaults: The provided defaults + * } + * } + * + * @param {Object} options - The options object. + * @param {string} options.version - The version to be added to the returned object. + * @param {Object} options.defaults - The defaults to be added to the returned object. + * @param {Object} options.coreCssAsset - The coreCssAsset to be added to the returned object. + * @param {Object} options.themeVariantCssAssets - The themeVariantCssAssets to be added to the returned object. + * @return {Object} The object with the provided version, defaults, coreCssAsset, and themeVariantCssAssets. + */ +function addToScriptContents({ + version, + defaults, + coreCssAsset, + themeVariantCssAssets, +}) { + return { + version, + themeUrls: { + core: coreCssAsset, + variants: themeVariantCssAssets, + defaults, + }, + }; +} + +/** + * Generates the script contents object based on the provided assets and versions. + * + * @param {Object} options - The options object. + * @param {Object} options.paragonCoreCssAsset - The asset for the Paragon core CSS. + * @param {Object} options.paragonThemeVariantCssAssets - The assets for the Paragon theme variants. + * @param {Object} options.brandCoreCssAsset - The asset for the brand core CSS. + * @param {Object} options.brandThemeVariantCssAssets - The assets for the brand theme variants. + * @param {Object} options.paragonThemeCss - The Paragon theme CSS. + * @param {string} options.paragonVersion - The version of the Paragon theme. + * @param {Object} options.brandThemeCss - The brand theme CSS. + * @param {string} options.brandVersion - The version of the brand theme. + * @return {Object} The script contents object. + */ +function generateScriptContents({ + paragonCoreCssAsset, + paragonThemeVariantCssAssets, + brandCoreCssAsset, + brandThemeVariantCssAssets, + paragonThemeCss, + paragonVersion, + brandThemeCss, + brandVersion, +}) { + const scriptContents = {}; + scriptContents.paragon = addToScriptContents({ + version: paragonVersion, + coreCssAsset: paragonCoreCssAsset, + themeVariantCssAssets: paragonThemeVariantCssAssets, + defaults: paragonThemeCss?.defaults, + }); + scriptContents.brand = addToScriptContents({ + version: brandVersion, + coreCssAsset: brandCoreCssAsset, + themeVariantCssAssets: brandThemeVariantCssAssets, + defaults: brandThemeCss?.defaults, + }); + return scriptContents; +} + +module.exports = { + addToScriptContents, + insertScriptContentsIntoDocument, + generateScriptContents, +}; diff --git a/lib/plugins/paragon-webpack-plugin/utils/stylesheetUtils.js b/lib/plugins/paragon-webpack-plugin/utils/stylesheetUtils.js new file mode 100644 index 000000000..78ab0c1e2 --- /dev/null +++ b/lib/plugins/paragon-webpack-plugin/utils/stylesheetUtils.js @@ -0,0 +1,106 @@ +const parse5 = require('parse5'); +const { sources } = require('webpack'); + +const { getDescendantByTag } = require('./tagUtils'); + +/** + * Finds the insertion point for a stylesheet in an HTML document. + * + * @param {Object} options - The options object. + * @param {Object} options.document - The parsed HTML document. + * @param {string} options.source - The original source code of the HTML document. + * @throws {Error} If the head element is missing in the HTML document. + * @return {number} The insertion point for the stylesheet in the HTML document. + */ +function findStylesheetInsertionPoint({ document, source }) { + const headElement = getDescendantByTag(document, 'head'); + if (!headElement) { + throw new Error('Missing head element in index.html.'); + } + + // determine script insertion point + if (headElement.sourceCodeLocation?.startTag) { + return headElement.sourceCodeLocation.startTag.endOffset; + } + + // less accurate fallback + const headTagString = '
'; + const headTagIndex = source.indexOf(headTagString); + return headTagIndex + headTagString.length; +} + +/** + * Inserts stylesheets into an HTML document. + * + * @param {object} options - The options for inserting stylesheets. + * @param {string} options.source - The HTML source code. + * @param {object} options.urls - The URLs of the stylesheets to be inserted. + * @param {string} options.urls.default - The URL of the default stylesheet. + * @param {string} options.urls.brandOverride - The URL of the brand override stylesheet. + * @return {object} The new source code with the stylesheets inserted. + */ +function insertStylesheetsIntoDocument({ + source, + urls, +}) { + // parse file as html document + const document = parse5.parse(source, { + sourceCodeLocationInfo: true, + }); + if (!getDescendantByTag(document, 'head')) { + return undefined; + } + + const newSource = new sources.ReplaceSource( + new sources.RawSource(source), + 'index.html', + ); + + // insert the brand overrides styles into the HTML document + const stylesheetInsertionPoint = findStylesheetInsertionPoint({ + document, + source: newSource, + }); + + /** + * Creates a new stylesheet link element. + * + * @param {string} url - The URL of the stylesheet. + * @return {string} The HTML code for the stylesheet link element. + */ + function createNewStylesheet(url) { + const baseLink = ``; + return baseLink; + } + + if (urls.default) { + const existingDefaultLink = getDescendantByTag(`link[href='${urls.default}']`); + if (!existingDefaultLink) { + // create link to inject into the HTML document + const stylesheetLink = createNewStylesheet(urls.default); + newSource.insert(stylesheetInsertionPoint, stylesheetLink); + } + } + + if (urls.brandOverride) { + const existingBrandLink = getDescendantByTag(`link[href='${urls.brandOverride}']`); + if (!existingBrandLink) { + // create link to inject into the HTML document + const stylesheetLink = createNewStylesheet(urls.brandOverride); + newSource.insert(stylesheetInsertionPoint, stylesheetLink); + } + } + + return newSource; +} + +module.exports = { + findStylesheetInsertionPoint, + insertStylesheetsIntoDocument, +}; diff --git a/lib/plugins/paragon-webpack-plugin/utils/tagUtils.js b/lib/plugins/paragon-webpack-plugin/utils/tagUtils.js new file mode 100644 index 000000000..9f4d346d1 --- /dev/null +++ b/lib/plugins/paragon-webpack-plugin/utils/tagUtils.js @@ -0,0 +1,58 @@ +/** + * Recursively searches for a descendant node with the specified tag name. + * + * @param {Object} node - The root node to start the search from. + * @param {string} tag - The tag name to search for. + * @return {Object|null} The first descendant node with the specified tag name, or null if not found. + */ +function getDescendantByTag(node, tag) { + for (let i = 0; i < node.childNodes?.length; i++) { + if (node.childNodes[i].tagName === tag) { + return node.childNodes[i]; + } + const result = getDescendantByTag(node.childNodes[i], tag); + if (result) { + return result; + } + } + return null; +} + +/** + * Replaces a wildcard keyword in a URL with a local version. + * + * @param {Object} options - The options object. + * @param {string} options.url - The URL to substitute the keyword in. + * @param {string} options.wildcardKeyword - The wildcard keyword to replace. + * @param {string} options.localVersion - The local version to substitute the keyword with. + * @return {string} The URL with the wildcard keyword substituted with the local version, + * or the original URL if no substitution is needed. + */ +function handleVersionSubstitution({ url, wildcardKeyword, localVersion }) { + if (!url || !url.includes(wildcardKeyword) || !localVersion) { + return url; + } + return url.replaceAll(wildcardKeyword, localVersion); +} + +/** + * Minifies a script by removing unnecessary whitespace and line breaks. + * + * @param {string} script - The script to be minified. + * @return {string} The minified script. + */ +function minifyScript(script) { + return script + .replace(/>[\r\n ]+<') + .replace(/(<.*?>)|\s+/g, (m, $1) => { + if ($1) { return $1; } + return ' '; + }) + .trim(); +} + +module.exports = { + getDescendantByTag, + handleVersionSubstitution, + minifyScript, +};