diff --git a/.changeset/early-badgers-wash.md b/.changeset/early-badgers-wash.md new file mode 100644 index 00000000000..bf188fa12dd --- /dev/null +++ b/.changeset/early-badgers-wash.md @@ -0,0 +1,88 @@ +--- +"@spectrum-css/accordion": minor +"@spectrum-css/actionbar": minor +"@spectrum-css/actionbutton": minor +"@spectrum-css/actiongroup": minor +"@spectrum-css/alertbanner": minor +"@spectrum-css/alertdialog": minor +"@spectrum-css/asset": minor +"@spectrum-css/assetcard": minor +"@spectrum-css/assetlist": minor +"@spectrum-css/avatar": minor +"@spectrum-css/badge": minor +"@spectrum-css/breadcrumb": minor +"@spectrum-css/button": minor +"@spectrum-css/buttongroup": minor +"@spectrum-css/calendar": minor +"@spectrum-css/card": minor +"@spectrum-css/checkbox": minor +"@spectrum-css/clearbutton": minor +"@spectrum-css/closebutton": minor +"@spectrum-css/coachindicator": minor +"@spectrum-css/coachmark": minor +"@spectrum-css/colorarea": minor +"@spectrum-css/colorhandle": minor +"@spectrum-css/colorloupe": minor +"@spectrum-css/colorslider": minor +"@spectrum-css/colorwheel": minor +"@spectrum-css/combobox": minor +"@spectrum-css/contextualhelp": minor +"@spectrum-css/datepicker": minor +"@spectrum-css/dial": minor +"@spectrum-css/dialog": minor +"@spectrum-css/divider": minor +"@spectrum-css/dropindicator": minor +"@spectrum-css/dropzone": minor +"@spectrum-css/fieldgroup": minor +"@spectrum-css/fieldlabel": minor +"@spectrum-css/floatingactionbutton": minor +"@spectrum-css/form": minor +"@spectrum-css/helptext": minor +"@spectrum-css/icon": minor +"@spectrum-css/illustratedmessage": minor +"@spectrum-css/infieldbutton": minor +"@spectrum-css/inlinealert": minor +"@spectrum-css/link": minor +"@spectrum-css/logicbutton": minor +"@spectrum-css/menu": minor +"@spectrum-css/meter": minor +"@spectrum-css/miller": minor +"@spectrum-css/modal": minor +"@spectrum-css/opacitycheckerboard": minor +"@spectrum-css/page": minor +"@spectrum-css/pagination": minor +"@spectrum-css/picker": minor +"@spectrum-css/pickerbutton": minor +"@spectrum-css/popover": minor +"@spectrum-css/progressbar": minor +"@spectrum-css/progresscircle": minor +"@spectrum-css/radio": minor +"@spectrum-css/rating": minor +"@spectrum-css/search": minor +"@spectrum-css/sidenav": minor +"@spectrum-css/slider": minor +"@spectrum-css/splitview": minor +"@spectrum-css/statuslight": minor +"@spectrum-css/steplist": minor +"@spectrum-css/stepper": minor +"@spectrum-css/swatch": minor +"@spectrum-css/swatchgroup": minor +"@spectrum-css/switch": minor +"@spectrum-css/table": minor +"@spectrum-css/tabs": minor +"@spectrum-css/tag": minor +"@spectrum-css/taggroup": minor +"@spectrum-css/textfield": minor +"@spectrum-css/thumbnail": minor +"@spectrum-css/toast": minor +"@spectrum-css/tooltip": minor +"@spectrum-css/tray": minor +"@spectrum-css/treeview": minor +"@spectrum-css/typography": minor +"@spectrum-css/underlay": minor +"@spectrum-css/well": minor +--- + +## New feature + +Minified and gzipped outputs available for all compiled CSS assets. diff --git a/.changeset/slow-waves-eat.md b/.changeset/slow-waves-eat.md new file mode 100644 index 00000000000..61337b5b1ec --- /dev/null +++ b/.changeset/slow-waves-eat.md @@ -0,0 +1,5 @@ +--- +"@spectrum-tools/gh-action-file-diff": minor +--- + +New feature: report on minified and gzipped sizes for existing compiled outputs diff --git a/.github/actions/file-diff/index.js b/.github/actions/file-diff/index.js index a2b3c22b5bb..1bb35b58e37 100644 --- a/.github/actions/file-diff/index.js +++ b/.github/actions/file-diff/index.js @@ -56,28 +56,58 @@ async function run() { // --------------- End evaluation --------------- /** Split the data by component package */ - const { filePath, PACKAGES } = splitDataByPackage(headOutput, headPath, baseOutput); + const { filePath, PACKAGES } = splitDataByPackage(headOutput, baseOutput); const sections = makeTable(PACKAGES, filePath, headPath); + /** + * Calculate the total size of the minified files where applicable, use the regular size + * if the minified file doesn't exist + * @param {Map} contentMap - The map of file names and their sizes + * @returns {number} - The total size of the minified files where applicable + */ + const calculateMinifiedTotal = (contentMap) => [...contentMap.entries()] + .reduce((acc, [filename, size]) => { + // We don't include anything other than css files in the total size + if (!filename.endsWith(".css") || filename.endsWith(".min.css")) return acc; + + // If filename ends with *.css but not *.min.css, add the size of the minified file + if (/\.css$/.test(filename) && !/\.min\.css$/.test(filename)) { + const minified = filename.replace(/\.css$/, ".min.css"); + + // Check if the minified file exists in the headOutput + if (headOutput.has(minified)) { + const minSize = headOutput.get(minified); + if (minSize) return acc + minSize; + } + else { + // If the minified file doesn't exist, add the size of the css file + return acc + size; + } + } + return acc + size; + }, 0); + + /** Calculate the total size of the pull request's assets */ - const overallHeadSize = [...headOutput.values()].reduce( - (acc, size) => acc + size, - 0 - ); + const overallHeadSize = calculateMinifiedTotal(headOutput); - /** Calculate the overall size of the base branch's assets */ - const overallBaseSize = hasBase - ? [...baseOutput.values()].reduce((acc, size) => acc + size, 0) - : undefined; + /** + * Calculate the overall size of the base branch's assets + * if there is a base branch + * @type number + */ + const overallBaseSize = hasBase ? calculateMinifiedTotal(baseOutput) : 0; + /** + * If there is a base branch, check if there is a change in the overall size, + * otherwise, check if the overall size of the head branch is greater than 0 + * @type boolean + */ const hasChange = overallHeadSize !== overallBaseSize; - /** If no diff map data provided, we're going to report on the overall size */ - /** - * If the updated assets are the same as the original, - * report no change - * @todo could likely use this to report the overall change; is that helpful info? + * Report the changes in the compiled assets in a markdown format + * @type string[] **/ const markdown = []; const summary = [ @@ -88,136 +118,188 @@ async function run() { let summaryTable = []; if (sections.length === 0) { - summary.push(...["", " 🎉 No changes detected in any packages"]); - } else { - /** - * Calculate the change in size - * PR - base / base = change - */ - let changeSummary = ""; - if (baseOutput.size > 0 && hasBase && hasChange) { - changeSummary = `**Total change (Δ)**: ${printChange(overallHeadSize, overallBaseSize)} (${printPercentChange(overallHeadSize, overallBaseSize)})`; - } else if (baseOutput.size > 0 && hasBase && !hasChange) { - changeSummary = `No change in file sizes`; - } + summary.push("", " 🎉 No changes detected in any packages"); + } + else { + const tableHead = ["Filename", "Head", "Minified", "Gzipped", ...(hasBase ? ["Compared to base"] : [])]; - if (changeSummary !== "") { - summary.push( - changeSummary, - "", - "Table reports on changes to a package's main file. Other changes can be found in the collapsed Details section below.", - "" - ); - } + /** Next iterate over the components and report on the changes */ + sections.map(({ name, hasChange, mainFile, fileMap }) => { + if (!hasChange) return; - markdown.push( - "", - `
`, - `Details`, - "" - ); + /** + * Iterate over the files in the component and create a markdown table + * @param {Array} table - The markdown table accumulator + * @param {[readableFilename, { headByteSize, baseByteSize }]} - The deconstructed filemap entry + */ + const tableRows = ( + table, // accumulator + [readableFilename, { headByteSize, baseByteSize }] // deconstructed filemap entry; i.e., Map = [key, { ...values }] + ) => { + // @todo readable filename can be linked to html diff of the file? + // https://github.com/adobe/spectrum-css/pull/2093/files#diff-6badd53e481452b5af234953767029ef2e364427dd84cdeed25f5778b6fca2e6 + + if ( + // table is an array containing the printable data for the markdown table + readableFilename.endsWith(".map") || + // If the file is a minified file, don't include it separately in the table + /\.min\.css/.test(readableFilename) + ) { + return table; + } - sections.map(({ name, headMainSize, baseMainSize, hasChange, mainFile, fileMap }) => { - if (!hasChange) return; + // @todo should there be any normalization before comparing the file names? + const isMainFile = readableFilename === mainFile; + + const gzipName = readableFilename.replace(/\.([a-z]+)$/, ".min.$1.gz"); + const gzipFileRef = fileMap.get(gzipName); + + const minName = readableFilename.replace(/\.([a-z]+)$/, ".min.$1"); + const minFileRef = fileMap.get(minName); - /** We only evaluate changes if there is a diff branch being used and this is the main file for the package */ - if (hasBase) { - /** - * If: the component folder exists in the original branch but not the PR - * Or: the pull request file size is 0 or empty but the original branch has a size - * Then: report that it was removed or moved / renamed - * - * Else: report the change - */ - let currentSize; - if (isRemoved(headMainSize, baseMainSize)) { - currentSize = "🚨 deleted/moved"; - } else { - currentSize = bytesToSize(headMainSize); + const removedOnBranch = isRemoved(headByteSize, baseByteSize); + const newOnBranch = isNew(headByteSize, baseByteSize); + + let size, gzipSize, minSize, change, diff; + if (removedOnBranch) { + size = "🚨 deleted/moved" + change = `⬇ ${bytesToSize(baseByteSize)}`; + if (difference(baseByteSize, headByteSize) !== 0 && !newOnBranch) { + diff = ` (${printPercentChange(headByteSize , baseByteSize)})`; + } + } + else { + size = bytesToSize(headByteSize); + + if (gzipFileRef && gzipFileRef?.headByteSize) { + // If the gzip file is new, prefix it's size with a "🆕" emoji + if (isNew(gzipFileRef.headByteSize, gzipFileRef?.baseByteSize)) { + gzipSize = `🆕 ${bytesToSize(gzipFileRef.headByteSize)}`; + } + else if (isRemoved(gzipFileRef.headByteSize, gzipFileRef?.baseByteSize)) { + gzipSize = "🚨 deleted/moved"; + } + else { + gzipSize = bytesToSize(gzipFileRef.headByteSize); + } + } + + if (minFileRef && minFileRef?.headByteSize) { + // If the minSize file is new, prefix it's size with a "🆕" emoji + if (isNew(minFileRef.headByteSize, minFileRef?.baseByteSize)) { + minSize = `🆕 ${bytesToSize(minFileRef.headByteSize)}`; + } + else if (isRemoved(minFileRef.headByteSize, minFileRef?.baseByteSize)) { + minSize = "🚨 deleted/moved"; + } + else { + minSize = bytesToSize(minFileRef.headByteSize); + } + } + + if (newOnBranch) { + change = `🆕 ${bytesToSize(headByteSize)}`; + } + else { + change = printChange(headByteSize, baseByteSize); + } } - /** - * If: the component folder exists in the PR but not the original branch - * Or: the pull request file has size but the original branch does not - * Then: report that it's new - * - * Else: report the change - */ - let comparison; - if (isNew(headMainSize, baseMainSize)) { - comparison = "🎉 new"; - } else { - comparison = printChange(headMainSize, baseMainSize); + if (!minSize) minSize = " - "; + if (!gzipSize) gzipSize = " - "; + + const delta = `${change}${diff ?? ""}`; + + if (isMainFile) { + summaryTable.push([name, size, minSize, gzipSize, delta]); } - summaryTable.push([name, currentSize, comparison]); - } + table.push([ + // Bold the main file to help it stand out + isMainFile ? `**${readableFilename}**` : readableFilename, + // If the file was removed, note it's absense with a dash; otherwise, note it's size + size, + minSize, + gzipSize, + delta, + ]); + return table; + }; - const md = ["", `#### ${name}`, ""]; - md.push( + markdown.push( + "", + `#### ${name}`, + "", ...[ - ["Filename", "Head", ...(hasBase ? ["Compared to base"] : [])], - [" - ", " - ", ...(hasBase ? [" - "] : [])], + tableHead, + tableHead.map(() => "-"), ].map((row) => `| ${row.join(" | ")} |`), - ...[...fileMap.entries()] - .reduce( - ( - table, // accumulator - [readableFilename, { headByteSize = 0, baseByteSize = 0 }] // deconstructed filemap entry; i.e., Map = [key, { ...values }] - ) => { - // @todo readable filename can be linked to html diff of the file? - // https://github.com/adobe/spectrum-css/pull/2093/files#diff-6badd53e481452b5af234953767029ef2e364427dd84cdeed25f5778b6fca2e6 - - // table is an array containing the printable data for the markdown table - if (readableFilename.endsWith(".map")) return table; - - const removedOnBranch = isRemoved(headByteSize, baseByteSize); - // @todo should there be any normalization before comparing the file names? - const isMainFile = readableFilename === mainFile; - const size = removedOnBranch ? "🚨 deleted/moved" : bytesToSize(headByteSize); - const change = !removedOnBranch ? printChange(headByteSize, baseByteSize) : `⬇ ${bytesToSize(baseByteSize)}`; - const diff = difference(baseByteSize, headByteSize) !== 0 ? ` (${printPercentChange(headByteSize , baseByteSize)})` : ""; - const delta = `${change}${removedOnBranch ? "" : diff}`; - - return [ - ...table, - [ - // Bold the main file to help it stand out - isMainFile ? `**${readableFilename}**` : readableFilename, - // If the file was removed, note it's absense with a dash; otherwise, note it's size - size, - ...(hasBase ? [delta] : []), - ] - ]; - }, - [] - ) - .map((row) => `| ${row.join(" | ")} |`), + ...[...fileMap.entries()].reduce(tableRows, []).map((row) => `| ${row.join(" | ")} |`), ); - - markdown.push(...md); }); - markdown.push("", `
`); + /** Calculate the change in size [(head - base) / base = change] */ + if (hasBase) { + if (hasChange) { + summary.push( + `**Total change (Δ)**: ${printChange(overallHeadSize, overallBaseSize)} (${printPercentChange(overallHeadSize, overallBaseSize)})`, + "", + `Table reports on changes to a package's main file.${sections.length > 1 ? ` Other changes can be found in the collapsed Details section below.` : ""}`, + "" + ); + } + else if (sections.length > 1) { + summary.push("✅ **No change in file sizes**", ""); + } + } + else { + summary.push( + "No base branch to compare against.", + "" + ); + } + + // If there is more than 1 component updated, add a details/summary section to the markdown at the start of the array + if (sections.length > 1) { + markdown.unshift( + "", + "
", + "File change details", + "" + ); + + markdown.push("", `
`); + } } if (summaryTable.length > 0) { + const summaryTableHeader = ["Package", "Size", "Minified", "Gzipped"]; + if (hasBase && hasChange) summaryTableHeader.push("Δ"); + // Add the headings to the summary table if it contains data - summaryTable = [ - ["Package", "Size", ...(hasBase ? ["Δ"] : [])], - ["-", "-", ...(hasBase ? ["-"] : [])], - ...summaryTable, - ]; + summaryTable.unshift( + summaryTableHeader, + summaryTableHeader.map(() => "-"), + ); - summary.push(...summaryTable.map((row) => `| ${row.join(" | ")} |`)); + // This removes the delta column if there are no changes to compare + summary.push(...summaryTable.map((row) => { + if (summaryTableHeader.length === row.length) return `| ${row.join(" | ")} |`; + // If the row is not the same length as the header, strip out the extra columns + if (row.length > summaryTableHeader.length) { + return `| ${row.slice(0, summaryTableHeader.length).join(" | ")} |`; + } + + // If the row is shorter than the header, add empty columns to the end with " - " + return `| ${row.concat(Array(summaryTableHeader.length - row.length).fill(" - ")).join(" | ")} |`; + })); } markdown.push( "", "", - "* Size determined by adding together the size of the main file for all packages in the library.
", - "* Results are not gzipped or minified.
", + "* Size is the sum of all main files for packages in the library.
", "* An ASCII character in UTF-8 is 8 bits or 1 byte.", "
" ); @@ -245,14 +327,14 @@ async function run() { // --------------- Set output variables --------------- if (headOutput.size > 0) { const headMainSize = [...headOutput.entries()].reduce( - (acc, [_, size]) => acc + size, + (acc, [, size]) => acc + size, 0 ); core.setOutput("total-size", headMainSize); if (hasBase) { const baseMainSize = [...baseOutput.entries()].reduce( - (acc, [_, size]) => acc + size, + (acc, [, size]) => acc + size, 0 ); @@ -261,7 +343,8 @@ async function run() { hasBase && headMainSize !== baseMainSize ? "true" : "false" ); } - } else { + } + else { core.setOutput("total-size", 0); } } catch (error) { @@ -287,7 +370,7 @@ const printChange = function (v1, v0) { /** Calculate the change in size: v1 - v0 = change */ const d = difference(v1, v0); return d === 0 - ? `-` + ? "-" : `${d > 0 ? "🔴 ⬆" : "🟢 ⬇"} ${bytesToSize(Math.abs(d))}`; }; @@ -300,68 +383,62 @@ const printChange = function (v1, v0) { */ const printPercentChange = function (v1, v0) { const delta = ((v1 - v0) / v0) * 100; - if (delta === 0) return `no change`; + if (delta === 0) return "no change"; return `${delta.toFixed(2)}%`; }; /** - * - * @param {Map>} PACKAGES + * @typedef {string} PackageName - The name of the component package + * @typedef {string} FileName - The name of the file in the component package + * @typedef {{ headByteSize: number, baseByteSize: number }} FileSpecs - The size of the file in the head and base branches + * @typedef {Map} FileDetails - A map of file sizes from the head and base branches keyed by filename (full path, not shorthand) + * @typedef {{ name: PackageName, filePath: string, hasChange: boolean, mainFile: string, fileMap: FileDetails}} PackageDetails - The details of the component package including the main file and the file map as well as other short-hand properties for reporting + */ + +/** + * From the data indexed by filename, create a detailed table of the changes in the compiled assets + * with a full view of all files present in the head and base branches. + * @param {Map} PACKAGES * @param {string} filePath - The path to the component's dist folder from the root of the repo - * @param {string} path - The path from the github workspace to the root of the repo - * @returns {Array<{ name: string, filePath: string, headMainSize: number, baseMainSize: number, hasChange: boolean, fileMap: Map}>} + * @param {string} rootPath - The path from the github workspace to the root of the repo + * @returns {PackageDetails[]} */ -const makeTable = function (PACKAGES, filePath, path) { +const makeTable = function (PACKAGES, filePath, rootPath) { const sections = []; /** Next convert that component data into a detailed object for reporting */ PACKAGES.forEach((fileMap, packageName) => { // Read in the main asset file from the package.json - const packagePath = join(path, filePath, packageName, "package.json"); + const packagePath = join(rootPath, filePath, packageName, "package.json"); + // Default to the index.css file if no main file is provided in the package.json let mainFile = "index.css"; if (existsSync(packagePath)) { + // If the package.json exists, read in the main file const { main } = require(packagePath) ?? {}; - if (main) mainFile = main.replace(/^.*\/dist\//, ""); - } - - let mainFileOnly = [...fileMap.keys()].filter((file) => file === mainFile); - - // If no main file is found, look for the first file matching the filename only - if (mainFileOnly.length === 0) { - mainFileOnly = [...fileMap.keys()].filter((file) => file.endsWith(mainFile)); + // If the main file is a string, use it as the main file + if (typeof main === "string") { + // Strip out the path to the dist folder from the main file + mainFile = main.replace(new RegExp("^.*\/?dist\/"), ""); + } } - const headMainSize = mainFileOnly.reduce( - (acc, filename) => { - const { headByteSize = 0 } = fileMap.get(filename); - return acc + headByteSize; - }, - 0 - ); - - const baseMainSize = mainFileOnly.reduce( - (acc, filename) => { - const { baseByteSize = 0 } = fileMap.get(filename); - return acc + baseByteSize; - }, - 0 - ); - + /** + * Check if any of the files in the component have changed + * @type boolean + */ const hasChange = fileMap.size > 0 && [...fileMap.values()].some(({ headByteSize, baseByteSize }) => headByteSize !== baseByteSize); /** * We don't need to report on components that haven't changed unless they're new or removed */ - if (headMainSize === baseMainSize) return; + if (!hasChange) return; sections.push({ name: packageName, filePath, - headMainSize, - baseMainSize, hasChange, - mainFile: mainFileOnly?.[0], + mainFile, fileMap }); }); @@ -371,69 +448,88 @@ const makeTable = function (PACKAGES, filePath, path) { /** * Split out the data indexed by filename into groups by component - * @param {Map} dataMap - * @param {string} path - * @param {Map} baseMap - * @returns {{ filePath: string, PACKAGES: Map>}} + * @param {Map} dataMap - A map of file names relative to the root of the repo and their sizes + * @param {Map=[new Map()]} baseMap - The map of file sizes from the base branch indexed by filename (optional) + * @returns {{ filePath: string, PACKAGES: Map}} */ -const splitDataByPackage = function (dataMap, path, baseMap = new Map()) { +const splitDataByPackage = function (dataMap, baseMap = new Map()) { + /** + * Path to the component's dist folder relative to the root of the repo + * @type {string|undefined} + */ + let filePath; + const PACKAGES = new Map(); - let filePath; - [...dataMap.entries()].forEach(([file, headByteSize]) => { - // Determine the name of the component - const parts = file.split(sep); - const componentIdx = parts.findIndex((part) => part === "dist") - 1; - const packageName = parts[componentIdx]; - - if (!filePath) { - filePath = `${file.replace(path, "")}/${parts.slice(componentIdx + 1, -1).join(sep)}`; + /** + * Determine the name of the component + * @param {string} file - The full path to the file + * @param {{ part: string|undefined, offset: number|undefined, length: number|undefined }} options - The part of the path to split on and the offset to start from + * @returns {string} + */ + const getPathPart = (file, { part, offset, length, reverse = false } = {}) => { + // If the file is not a string, return it as is + if (!file || typeof file !== "string") return file; + + // Split the file path into parts + const parts = file.split("/"); + + // Default our index to 0 + let idx = 0; + // If a part is provided, find the position of that part + if (typeof part !== "undefined") { + idx = parts.findIndex((p) => p === part); + // index is -1 if the part is not found, return the file as is + if (idx === -1) return file; } - const readableFilename = file.replace(/^.*\/dist\//, ""); + // If an offset is provided, add it to the index + if (typeof offset !== "undefined") idx += offset; - const fileMap = PACKAGES.has(packageName) - ? PACKAGES.get(packageName) - : new Map(); + // If a length is provided, return the parts from the index to the index + length + if (typeof length !== "undefined") { + // If the length is negative, return the parts from the index + length to the index + // this captures the previous n parts before the index + if (length < 0) { + return parts.slice(idx + length, idx).join(sep); + } - if (!fileMap.has(readableFilename)) { - fileMap.set(readableFilename, { - headByteSize, - baseByteSize: baseMap.get(file), - }); + return parts.slice(idx, idx + length).join(sep); } - /** Update the component's table data */ - PACKAGES.set(packageName, fileMap); - }); - - // Look for any base files not present in the head - [...baseMap.entries()].forEach(([file, baseByteSize]) => { - // Determine the name of the component - const parts = file.split(sep); - const componentIdx = parts.findIndex((part) => part === "dist") - 1; - const packageName = parts[componentIdx]; + // Otherwise, return the parts from the index to the end + if (!reverse) return parts.slice(idx).join(sep); + return parts.slice(0, idx).join(sep); + }; - if (!filePath) { - filePath = `${file.replace(path, "")}/${parts.slice(componentIdx + 1, -1).join(sep)}`; - } + const pullDataIntoPackages = (filepath, size, isHead = true) => { + const packageName = getPathPart(filepath, { part: "dist", offset: -1, length: 1 }); + // Capture the path to the component's dist folder, this doesn't include the root path from outside the repo + if (!filePath) filePath = getPathPart(filepath, { part: "dist", reverse: true }); - const readableFilename = file.replace(/^.*\/dist\//, ""); + // Capture the filename without the path to the dist folder + const readableFilename = getPathPart(filepath, { part: "dist", offset: 1 }); - const fileMap = PACKAGES.has(packageName) - ? PACKAGES.get(packageName) - : new Map(); + // If fileMap data already exists for the package, use it; otherwise, create a new map + const fileMap = PACKAGES.has(packageName) ? PACKAGES.get(packageName) : new Map(); + // If the fileMap doesn't have the file, add it if (!fileMap.has(readableFilename)) { fileMap.set(readableFilename, { - headByteSize: dataMap.get(file), - baseByteSize, + headByteSize: isHead ? size : dataMap.get(filepath), + baseByteSize: isHead ? baseMap.get(filepath) : size, }); } /** Update the component's table data */ PACKAGES.set(packageName, fileMap); - }); + }; + + // This sets up the core data structure for the package files + [...dataMap.entries()].forEach(([file, headByteSize]) => pullDataIntoPackages(file, headByteSize, true)); + + // Look for any base files not present in the head to ensure we capture when files are deleted + [...baseMap.entries()].forEach(([file, baseByteSize]) => pullDataIntoPackages(file, baseByteSize, false)); return { filePath, PACKAGES }; }; diff --git a/.github/actions/file-diff/utilities.js b/.github/actions/file-diff/utilities.js index 696a36e7371..5f336e76b99 100644 --- a/.github/actions/file-diff/utilities.js +++ b/.github/actions/file-diff/utilities.js @@ -148,7 +148,7 @@ exports.addComment = async function ({ search, content, token }) { * filesystem and return a Map of the files and their sizes. * @param {string} rootPath * @param {string[]} patterns - * @returns {Promise>} + * @returns {Promise>} - Returns the relative path and size of the files */ exports.fetchFilesAndSizes = async function (rootPath, patterns = [], { core }) { if (!existsSync(rootPath)) return new Map(); diff --git a/tasks/component-builder.js b/tasks/component-builder.js index a0be5681850..93afb637693 100644 --- a/tasks/component-builder.js +++ b/tasks/component-builder.js @@ -17,12 +17,17 @@ const fs = require("fs"); const fsp = fs.promises; const path = require("path"); +const { deflate } = require("zlib"); +const { promisify } = require("util"); + const postcss = require("postcss"); const postcssrc = require("postcss-load-config"); const prettier = require("prettier"); require("colors"); +const gzip = promisify(deflate); + const { dirs, relativePrint, @@ -72,15 +77,17 @@ async function processCSS( } } + // If the output file is a minified file, force the minify flag to true + if (output && path.basename(output, ".css").endsWith(".min")) minify = true; + const ctx = { cwd, env: process.env.NODE_ENV ?? "development", - file: output, + file: output ?? input, from: input, - to: output, + to: output ?? input, verbose: false, minify, - shouldCombine: true, ...postCSSOptions, }; @@ -91,7 +98,7 @@ async function processCSS( const result = await postcss(plugins).process(content, { from: input, - to: output, + to: output ?? input, ...options }); @@ -139,6 +146,12 @@ async function processCSS( writeAndReport(formatted, output, { cwd }), ]; + if (minify) { + promises.push( + gzip(formatted).then(zipped => writeAndReport(zipped, `${output}.gz`, { cwd })) + ); + } + if (result.map) { promises.push( writeAndReport(result.map.toString().trimStart(), `${output}.map`, { cwd }), @@ -155,7 +168,7 @@ async function processCSS( * @param {boolean} config.clean - Should the built assets be cleaned before running the build * @returns Promise */ -async function build({ cwd = process.cwd(), clean = false, componentName } = {}) { +async function build({ cwd = process.cwd(), clean = false, minify = false, componentName } = {}) { // Nothing to do if there's no input file if (!fs.existsSync(path.join(cwd, "index.css"))) return; @@ -163,14 +176,24 @@ async function build({ cwd = process.cwd(), clean = false, componentName } = {}) componentName = getPackageFromPath(cwd); } - return processCSS(undefined, path.join(cwd, "index.css"), path.join(cwd, "dist", "index.css"), { - cwd, - clean, - skipMapping: true, - referencesOnly: false, - preserveVariables: true, - stripLocalSelectors: false, - }); + return Promise.all([ + processCSS(undefined, path.join(cwd, "index.css"), path.join(cwd, "dist", "index.css"), { + cwd, + clean, + skipMapping: true, + referencesOnly: false, + preserveVariables: true, + stripLocalSelectors: false, + }), + minify ? processCSS(undefined, path.join(cwd, "index.css"), path.join(cwd, "dist", "index.min.css"), { + cwd, + clean, + skipMapping: true, + referencesOnly: false, + preserveVariables: true, + stripLocalSelectors: false, + }) : Promise.resolve(), + ]); } /** @@ -180,7 +203,7 @@ async function build({ cwd = process.cwd(), clean = false, componentName } = {}) * @param {boolean} config.clean - Should the built assets be cleaned before running the build * @returns Promise */ -async function buildThemes({ cwd = process.cwd(), clean = false } = {}) { +async function buildThemes({ cwd = process.cwd(), minify = false, clean = false } = {}) { // This fetches the content of the files and returns an array of objects with the content and input paths const contentData = await fetchContent(["themes/*.css"], { cwd, clean }); @@ -190,58 +213,57 @@ async function buildThemes({ cwd = process.cwd(), clean = false } = {}) { const imports = contentData.map(({ input }) => input); const importMap = imports.map((i) => `@import "${i}";`).join("\n"); - const promises = contentData.map(async ({ content, input }) => - processCSS( - content, - path.join(cwd, input), - path.join(cwd, "dist", input), - { - cwd, - clean, - lint: false, - skipMapping: false, - referencesOnly: false, - preserveVariables: true, + const basePostCSSOptions = { + cwd, + clean, + map: false, + env: "production", + lint: false, + }; + + const promises = []; + + contentData.forEach(async ({ content, input }) => { + const theme = path.basename(input, ".css"); + + promises.push( + processCSS(content, path.join(cwd, input), path.join(cwd, "dist", "themes", `${theme}.css`), { + ...basePostCSSOptions, + shouldCombine: true, // Only output the new selectors with the system mappings stripLocalSelectors: true, + }), + minify ? processCSS(content, path.join(cwd, input), path.join(cwd, "dist", "themes", `${theme}.min.css`), { + ...basePostCSSOptions, shouldCombine: true, - theme: path.basename(input, ".css"), - map: false, - env: "production", - }, - ), - ); + // Only output the new selectors with the system mappings + stripLocalSelectors: true, + }) : Promise.resolve(), + ); + }); promises.push( - processCSS( - undefined, - path.join(cwd, "index.css"), - path.join(cwd, "dist", "index-base.css"), - { - cwd, - clean, - skipMapping: false, - referencesOnly: true, - preserveVariables: true, - stripLocalSelectors: true, - }, - ), + processCSS(undefined, path.join(cwd, "index.css"), path.join(cwd, "dist", "index-base.css"), { + ...basePostCSSOptions, + referencesOnly: true, + // Only output the new selectors with the system mappings + stripLocalSelectors: true, + }), + minify ? processCSS(undefined, path.join(cwd, "index.css"), path.join(cwd, "dist", "index-base.min.css"), { + ...basePostCSSOptions, + referencesOnly: true, + // Only output the new selectors with the system mappings + stripLocalSelectors: true, + }) : Promise.resolve(), // Expect this file to have component-specific selectors mapping to the system tokens but not the system tokens themselves - processCSS( - importMap, - path.join(cwd, "index.css"), - path.join(cwd, "dist", "index-theme.css"), - { - cwd, - clean, - resolveImports: true, - skipMapping: false, - stripLocalSelectors: false, - referencesOnly: true, - shouldCombine: false, - map: false, - }, - ), + processCSS(importMap, path.join(cwd, "index.css"), path.join(cwd, "dist", "index-theme.css"), { + ...basePostCSSOptions, + referencesOnly: true, + }), + minify ? processCSS(importMap, path.join(cwd, "index.css"), path.join(cwd, "dist", "index-theme.min.css"), { + ...basePostCSSOptions, + referencesOnly: true, + }) : Promise.resolve(), ); return Promise.all(promises); @@ -259,6 +281,7 @@ async function main({ componentName = process.env.NX_TASK_TARGET_PROJECT, cwd, clean, + minify = false, } = {}) { if (!cwd && componentName) { cwd = path.join(dirs.components, componentName); @@ -274,6 +297,10 @@ async function main({ clean = process.env.NODE_ENV === "production"; } + if (process.env.NODE_ENV === "production") { + minify = true; + } + const key = `[build] ${`@spectrum-css/${componentName}`.cyan}`; console.time(key); @@ -283,8 +310,8 @@ async function main({ } return Promise.all([ - build({ cwd, clean }), - buildThemes({ cwd, clean }), + build({ cwd, clean, minify }), + buildThemes({ cwd, clean, minify }), ]) .then((report) => { const logs = report.flat(Infinity).filter(Boolean);