diff --git a/packages/common/delete-pngs.ts b/packages/common/delete-pngs.ts index d73dfe973..c5f349517 100644 --- a/packages/common/delete-pngs.ts +++ b/packages/common/delete-pngs.ts @@ -1,5 +1,5 @@ -import fs from "node:fs/promises"; -import path from "node:path"; +import fs from "fs/promises"; +import path from "path"; const directoryPath = path.join(process.cwd(), "src/tests"); diff --git a/packages/common/generate-pngs.ts b/packages/common/generate-pngs.ts index 412bfa462..2b88eab8b 100644 --- a/packages/common/generate-pngs.ts +++ b/packages/common/generate-pngs.ts @@ -1,6 +1,6 @@ -import type { Dirent } from "node:fs"; -import fs from "node:fs/promises"; -import path from "node:path"; +import type { Dirent } from "fs"; +import fs from "fs/promises"; +import path from "path"; const directoryPath = path.join(process.cwd(), "src/tests"); diff --git a/packages/common/src/tests/screenshot/screenshot.ts b/packages/common/src/tests/screenshot/screenshot.ts index 189ec159c..9d3d327ab 100644 --- a/packages/common/src/tests/screenshot/screenshot.ts +++ b/packages/common/src/tests/screenshot/screenshot.ts @@ -10,17 +10,17 @@ import { html } from "lit"; import { afterEach, assert, beforeEach, describe, it } from "vitest"; import { cleanup, render } from "vitest-browser-lit"; -import { bundleToHashMappings } from "../../../public/mercury/css/bundle-to-hash-mappings.ts"; +import { bundleToHashMappings } from "../../../public/mercury/css/bundle-to-hash-mappings.js"; import type { TestAdditionalConfiguration, TestConfiguration -} from "../../typings/test-files.ts"; -import { MERCURY_BASE_CSS_URL } from "../../utils.ts"; +} from "../../typings/test-files.js"; +import { MERCURY_BASE_CSS_URL } from "../../utils.js"; -import "../../components/template-render/template-render.ts"; -import type { ComponentMetadataCodeSnippet } from "../../typings/component-metadata.ts"; -import { waitRender } from "../test-metadata-config.ts"; -import { compareImages } from "./compare-images.ts"; +import "../../components/template-render/template-render.js"; +import type { ComponentMetadataCodeSnippet } from "../../typings/component-metadata.js"; +import { waitRender } from "../test-metadata-config.js"; +import { compareImages } from "./compare-images.js"; const shouldUpdateScreenshotOnMismatch = import.meta.env.VITE_UPDATE_SCREENSHOT_ON_MISMATCH === "true"; diff --git a/packages/mercury-build/.prettierrc.json b/packages/mercury-build/.prettierrc.json new file mode 100644 index 000000000..2297b592c --- /dev/null +++ b/packages/mercury-build/.prettierrc.json @@ -0,0 +1,13 @@ +{ + "arrowParens": "avoid", + "bracketSpacing": true, + "jsxBracketSameLine": false, + "jsxSingleQuote": false, + "quoteProps": "as-needed", + "printWidth": 80, + "semi": true, + "singleQuote": false, + "tabWidth": 2, + "trailingComma": "none", + "useTabs": false +} diff --git a/packages/mercury-build/eslint.config.js b/packages/mercury-build/eslint.config.js new file mode 100644 index 000000000..613d0bf57 --- /dev/null +++ b/packages/mercury-build/eslint.config.js @@ -0,0 +1,118 @@ +import { FlatCompat } from "@eslint/eslintrc"; +import js from "@eslint/js"; +import typescriptEslint from "@typescript-eslint/eslint-plugin"; +import tsParser from "@typescript-eslint/parser"; +import importPlugin from "eslint-plugin-import"; +import { defineConfig } from "eslint/config"; +import globals from "globals"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all +}); + +export default defineConfig([ + importPlugin.flatConfigs.recommended, + { + ignores: ["**/node_modules/*", "**/dist/*"], + + files: ["**/*.ts", "**/*.js", "**.*.js"], + + extends: compat.extends( + "eslint:recommended", + "plugin:@typescript-eslint/eslint-recommended", + "plugin:@typescript-eslint/recommended" + ), + + plugins: { + "@typescript-eslint": typescriptEslint + }, + + languageOptions: { + globals: { + ...globals.browser + }, + + parser: tsParser, + ecmaVersion: "latest", + sourceType: "module" + }, + + settings: { + "import-x/resolver-next": [ + createTypeScriptImportResolver({ + alwaysTryTypes: true, // Always try to resolve types under `@types` directory even if it doesn't contain any source code, like `@types/unist` + + bun: true, // Resolve Bun modules (https://github.com/import-js/eslint-import-resolver-typescript#bun) + + // Choose from one of the "project" configs below or omit to use /tsconfig.json or /jsconfig.json by default + + // Multiple tsconfigs/jsconfigs (Useful for monorepos, but discouraged in favor of `references` supported) + + // Use a glob pattern + project: "packages/mercury-build/tsconfig.json" + }) + ] + }, + + rules: { + "import/extensions": ["error", "always"], + + "import/no-unresolved": [ + "error", + { + ignore: ["\\.js$"] + } + ], + + // - - - - - - - - - - - - + // ESLint + // - - - - - - - - - - - - + camelcase: "warn", // Enforce camelcase naming convention + curly: "error", // Enforce consistent brace style for all control statements + eqeqeq: ["warn", "always", { null: "ignore" }], // Require the use of === and !== "ignore" -------> Do not apply this rule to null + "logical-assignment-operators": [ + "warn", + "always", + { enforceForIfStatements: true } + ], // This rule checks for expressions that can be shortened using logical assignment operator + "dot-notation": "warn", // This rule is aimed at maintaining code consistency and improving code readability by encouraging use of the dot notation style whenever possible. As such, it will warn when it encounters an unnecessary use of square-bracket notation. + "max-depth": ["warn", 3], // Enforce a maximum depth that blocks can be nested. Many developers consider code difficult to read if blocks are nested beyond a certain depth + "no-alert": "error", // Disallow the use of alert, confirm, and prompt + "no-console": "warn", // Warning when using console.log, console.warn or console.error + "no-else-return": ["warn", { allowElseIf: false }], // Disallow else blocks after return statements in if statements + "no-extra-boolean-cast": "error", // Disallow unnecessary boolean casts + "no-debugger": "error", // Error when using debugger; + "no-duplicate-case": "error", // This rule disallows duplicate test expressions in case clauses of switch statements + "no-empty": "error", // Disallow empty block statements + "no-lonely-if": "error", // Disallow if statements as the only statement in else blocks + "no-multi-assign": "error", // Disallow use of chained assignment expressions + "no-nested-ternary": "error", // Errors when using nested ternary expressions + "no-sequences": "error", // Disallow comma operators + "no-undef": "off", // Allows defining undefined variables + "no-unneeded-ternary": "error", // Disallow ternary operators when simpler alternatives exist + "no-useless-return": "error", // Disallow redundant return statements + "prefer-const": "error", + "spaced-comment": ["error", "always", { exceptions: ["-", "+", "/"] }], // Enforce consistent spacing after the // or /* in a comment + + "no-prototype-builtins": "off", + + // - - - - - - - - - - - - + // TypeScript + // - - - - - - - - - - - - + "@typescript-eslint/ban-types": "off", + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/explicit-module-boundary-types": "off", + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/no-empty-function": "off", + "@typescript-eslint/no-non-null-assertion": "off" + + // "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }] + } + } +]); diff --git a/packages/mercury-build/package.json b/packages/mercury-build/package.json new file mode 100644 index 000000000..ca2169893 --- /dev/null +++ b/packages/mercury-build/package.json @@ -0,0 +1,47 @@ +{ + "name": "@genexus/mercury-build", + "version": "0.1.1", + "description": "...", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist/" + ], + "type": "module", + "scripts": { + "build": "tsc", + "watch": "tsc --watch", + "lint": "eslint src/**/*.ts --fix", + "test": "vitest", + "test.ci": "bun run test --watch=false --browser.headless" + }, + "license": "Apache-2.0", + "devDependencies": { + "@eslint/js": "*", + "@genexus/mercury": "^0.32.0", + "@types/node": "*", + "@typescript-eslint/eslint-plugin": "*", + "@typescript-eslint/parser": "*", + "eslint": "*", + "eslint-plugin-import": "~2.32.0", + "eslint-import-resolver-typescript": "~4.4.4", + "globals": "*", + "prettier": "*", + "sass": "~1.86.3", + "typescript": "*", + "typescript-eslint": "*", + "vitest": "*" + }, + "peerDependencies": { + "@genexus/mercury": "^0.32.0" + }, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + } + } +} diff --git a/packages/mercury-build/src/build/build-all-css-bundles.ts b/packages/mercury-build/src/build/build-all-css-bundles.ts new file mode 100644 index 000000000..4c216081f --- /dev/null +++ b/packages/mercury-build/src/build/build-all-css-bundles.ts @@ -0,0 +1,293 @@ +#!/usr/bin/env node +import type { + MercuryBundleBase, + MercuryBundleFull, + MercuryBundleOptimized, + MercuryBundleReset +} from "@genexus/mercury"; +import os from "os"; +import { fileURLToPath } from "url"; +import { isMainThread, parentPort, Worker } from "worker_threads"; + +import { join } from "path"; +import { DEFAULT_OUTPUT_FOLDERS_IN_FINAL_APPLICATION } from "../constants/default-folders-in-final-application.js"; +import { + MERCURY_DIST_RELATIVE_FOLDERS, + MERCURY_SRC_RELATIVE_FOLDERS +} from "../constants/mercury-relative-folders.js"; +import { ensureDirectoryExistsAndItsClear } from "../file-management/ensure-directory-exists-and-its-clear.js"; +import { getFilesMetadataInDir } from "../file-management/get-files-metadata-in-dir.js"; +import { measureTime } from "../other/measure-time.js"; +import { + printRebuilding, + stopRebuildingStdout +} from "../print-to-output/print-building.js"; +import { printBundleWasTranspiled } from "../print-to-output/print-bundle-was-transpiled.js"; +import type { BuildOptions, FileMetadata } from "../typings/cli.js"; +import { createBundlesWithCustomPaths } from "./create-bundles-with-custom-paths.js"; +import { ensureAllDirectoryExists } from "./internal/ensure-all-directory-exists.js"; +import { transpileCssBundleWithPlaceholder } from "./internal/transpile-bundle-and-create-mappings.js"; +import { watchFileSystemChanges } from "./internal/watch-file-system-changes.js"; + +type BundleToAvoidRebuild = + | MercuryBundleBase + | MercuryBundleReset + | MercuryBundleOptimized + | MercuryBundleFull + | "all"; + +const MESSAGE_TYPE = { + SUCCESS: "Success", + ERROR: "Error" +} as const; + +export const buildAllCssBundles = async ( + pathToMercuryRootFolder: string, + buildOptions?: BuildOptions, + extras?: { + watch?: boolean; + ci?: boolean; + } +) => { + // Set default values if not defined + buildOptions ??= {}; + buildOptions.avoidHash ??= new Set(); + buildOptions.fontsPath ??= DEFAULT_OUTPUT_FOLDERS_IN_FINAL_APPLICATION.FONTS; + buildOptions.globant ??= false; + buildOptions.iconsPath ??= DEFAULT_OUTPUT_FOLDERS_IN_FINAL_APPLICATION.ICONS; + buildOptions.outDirPath ??= + DEFAULT_OUTPUT_FOLDERS_IN_FINAL_APPLICATION.OUT_DIR; + + const watchMode = extras?.watch ?? false; + const ciMode = extras?.ci ?? false; + + // This is a WA to have __filename and __dirname in ES modules + const __filename = fileURLToPath(import.meta.url); + + const THREADS = Math.min(8, os.cpus().length / 2); + const workers: Worker[] = []; + + /** + * Files to transpile + */ + let allFilesToProcess: FileMetadata[] = []; + let firstBuild = true; + + // - - - - - - - - - - - - Sets to implement cache - - - - - - - - - - - - + const BUNDLES_TO_AVOID_REBUILD: BundleToAvoidRebuild[] = [ + "all", + "base/icons", + "resets/box-sizing", + "utils/form--full" + ]; + + const CSS_FILES_TO_AVOID_REMOVE = new Set( + BUNDLES_TO_AVOID_REBUILD.map(bundleName => + join( + pathToMercuryRootFolder, + MERCURY_DIST_RELATIVE_FOLDERS.DIST_BUNDLES_CSS, + bundleName + ".css" + ) + ) + ); + + const SCSS_FILES_TO_AVOID_TRANSPILE = new Set( + BUNDLES_TO_AVOID_REBUILD.map(bundleName => + join( + pathToMercuryRootFolder, + MERCURY_SRC_RELATIVE_FOLDERS.SRC_BUNDLES_SCSS, + bundleName + ".scss" + ) + ) + ); + // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + const createWorkers = () => { + for (let index = 0; index < THREADS; index++) { + const worker = new Worker(__filename); + workers.push(worker); + } + }; + + const compileBundles = async (options?: { + scssFilesToAvoidTranspile: Set; + cssFilesToAvoidRemove: Set; + }) => { + let completedProcessingIndex = 0; + + // First build where the directory has to be discovered + if (firstBuild) { + allFilesToProcess = await getFilesMetadataInDir( + join( + pathToMercuryRootFolder, + MERCURY_SRC_RELATIVE_FOLDERS.SRC_BUNDLES_SCSS + ) + ); + createWorkers(); + } + + const ACTUAL_FILES_TO_PROCESS = structuredClone(allFilesToProcess).filter( + fileMetadata => + !options || + !options.scssFilesToAvoidTranspile.has(fileMetadata.filePath) + ); + const TOTAL_FILES = ACTUAL_FILES_TO_PROCESS.length; + + // Clear bundle directories + await ensureDirectoryExistsAndItsClear( + join(pathToMercuryRootFolder, MERCURY_DIST_RELATIVE_FOLDERS.DIST_BUNDLES), + options?.cssFilesToAvoidRemove + ); + + // Ensure all directories exists before transpiling any bundle + await Promise.all(ensureAllDirectoryExists(ACTUAL_FILES_TO_PROCESS)); + + let workersProcessingMetadata = 0; + let thereWasAnError = false; + + const sendFileMetadataToWorker = ( + worker: Worker, + fileMetadata: FileMetadata + ) => { + // Send the data to process the file + worker.postMessage({ fileMetadata, buildOptions }); + workersProcessingMetadata++; + }; + + const updateFileProcessingStatus = () => { + completedProcessingIndex++; + workersProcessingMetadata--; + }; + + return new Promise((resolve, reject) => + workers.forEach(worker => { + let fileMetadata = ACTUAL_FILES_TO_PROCESS.pop()!; + sendFileMetadataToWorker(worker, fileMetadata); + + // When the worker completes the processing, increase the index and + // check if there is work remaining to process in the thread + worker.on("message", message => { + updateFileProcessingStatus(); + + if (message === MESSAGE_TYPE.ERROR) { + thereWasAnError = true; + } + + // Stop all workers when there is an error + if (thereWasAnError) { + worker.removeAllListeners("message"); + + if (workersProcessingMetadata === 0) { + reject(); + } + return; + } + + if (!watchMode && !ciMode) { + printBundleWasTranspiled(fileMetadata.filePath); + } + + // All metadatas were processed. Resolve the Promise + if (completedProcessingIndex === TOTAL_FILES) { + worker.removeAllListeners("message"); + resolve(ACTUAL_FILES_TO_PROCESS); + } + // There is more metadata to be processed, add a new processing in + // the thread + else if (ACTUAL_FILES_TO_PROCESS.length > 0) { + fileMetadata = ACTUAL_FILES_TO_PROCESS.pop()!; + sendFileMetadataToWorker(worker, fileMetadata); + } + // Waiting for the last compilation to end. There is nothing to do, + // so we need to remove the listeners to ensure the following + // processing uses the workers in a "fresh" state + else { + worker.removeAllListeners("message"); + } + }); + }) + ); + }; + + const compileAndCreateBundles = async () => { + printRebuilding(firstBuild); + + try { + // Compile all CSSs into the standard output (dist/bundles/css) + await compileBundles( + firstBuild + ? undefined + : { + cssFilesToAvoidRemove: CSS_FILES_TO_AVOID_REMOVE, + scssFilesToAvoidTranspile: SCSS_FILES_TO_AVOID_TRANSPILE + } + ); + + // Clear the CLI dir output target + await ensureDirectoryExistsAndItsClear(buildOptions!.outDirPath!); + + // Remove "Rebuilding..." message, since the next function will print + // some output in the console + stopRebuildingStdout(); + } catch (err) { + stopRebuildingStdout(); + + if (err) { + console.log(err); + } + } + + try { + // Copy the files from the standard output (dist/bundles/css) to the CLI + // output by applying the transformations for the icons and custom fonts + // paths. + // + // Last true value meaning: Don't hash the bundles in watch mode to avoid + // issues with Angular that caches the bundle mapping file, causing to + // not update the hashes for the fetches + await createBundlesWithCustomPaths( + pathToMercuryRootFolder, + buildOptions as Required, + { avoidAllHashes: true } + ); + // refreshAngularBrowser(); + firstBuild = false; + } catch (err) { + stopRebuildingStdout(); + + if (err) { + console.log(err); + process.exit(1); + } + } + }; + + if (watchMode) { + measureTime(compileAndCreateBundles).finally(() => + watchFileSystemChanges(pathToMercuryRootFolder, compileAndCreateBundles) + ); + } else if (ciMode) { + await compileAndCreateBundles(); + process.exit(); + } + // There is no need to wait for any changes, since the watch mode is not enabled + else { + await measureTime(compileBundles); + process.exit(); + } +}; + +// Worker thread code +if (!isMainThread) { + parentPort!.on( + "message", + (data: { fileMetadata: FileMetadata; buildOptions: BuildOptions }) => + transpileCssBundleWithPlaceholder(data.fileMetadata, data.buildOptions) + .then(() => parentPort!.postMessage(MESSAGE_TYPE.SUCCESS)) + .catch(err => { + console.error(err); + + parentPort!.postMessage(MESSAGE_TYPE.ERROR); + }) + ); +} diff --git a/packages/mercury-build/src/build/create-bundles-with-custom-paths.ts b/packages/mercury-build/src/build/create-bundles-with-custom-paths.ts new file mode 100644 index 000000000..857a77c95 --- /dev/null +++ b/packages/mercury-build/src/build/create-bundles-with-custom-paths.ts @@ -0,0 +1,243 @@ +import fs from "fs"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; + +import { BUNDLE_NAMES } from "../constants/bundle-names.js"; +import { DEFAULT_OUTPUT_FOLDERS_IN_FINAL_APPLICATION } from "../constants/default-folders-in-final-application.js"; +import { + BASE_CSS_FILE, + BASE_GLOBANT_CSS_FILE, + BUNDLE_MAPPING_TO_HASH_FILE +} from "../constants/file-names.js"; +import { HASH_AND_LETTER_LENGTH } from "../constants/hash-length.js"; +import { MERCURY_DIST_RELATIVE_FOLDERS } from "../constants/mercury-relative-folders.js"; +import { getFilesMetadataInDir } from "../file-management/get-files-metadata-in-dir.js"; +import { fromFileMetadataToBundleName } from "../other/from-file-metadata-to-bundle-name.js"; +import { getBundleFolderAndFileBaseDir } from "../other/get-bundle-folder-and-file-base-dir.js"; +import { getFileNameWithHash } from "../other/get-file-name-with-hash.js"; +import { getFileNameWithoutExt } from "../other/get-file-name-without-extension.js"; +import { getFileSize } from "../other/get-file-size.js"; +import { getGzipSize } from "../other/get-gzip-size.js"; +import { getHash } from "../other/get-hash.js"; +import { sanitizeBundleName } from "../other/sanitize-bundle-name.js"; +import { printBundleWasCreatedTableFooter } from "../print-to-output/print-bundle-was-created-table-footer.js"; +import { printBundleWasCreatedTableHeader } from "../print-to-output/print-bundle-was-created-table-header.js"; +import { printBundleWasCreated } from "../print-to-output/print-bundle-was-created.js"; +import type { + // BundleAssociationMetadata, + BuildOptions, + FileMetadata +} from "../typings/cli.js"; + +export const createBundleWithCustomPath = ( + args: Required, + options: { bundleFolder: string; fileBaseDirToWrite: string }, + fileMetadata: FileMetadata, + avoidAllHashes: boolean, + actualFilePath: string +): { + bundleName: string; + bundleNameWithHash: string; + fileSize: string; + fileGzipSize: string; +} => { + const { avoidHash, fontsPath, iconsPath } = args; + const { bundleFolder, fileBaseDirToWrite } = options; + + const fileNameWithoutExt = getFileNameWithoutExt(fileMetadata); + const bundleName = fromFileMetadataToBundleName(fileMetadata); + + // Copy CSS bundle + let css = fs.readFileSync(actualFilePath ?? fileMetadata.filePath, { + encoding: "utf8" + }); + + if (fontsPath !== DEFAULT_OUTPUT_FOLDERS_IN_FINAL_APPLICATION.FONTS) { + css = css.replaceAll( + DEFAULT_OUTPUT_FOLDERS_IN_FINAL_APPLICATION.FONTS, + fontsPath + ); + } + if (iconsPath !== DEFAULT_OUTPUT_FOLDERS_IN_FINAL_APPLICATION.ICONS) { + css = css.replaceAll( + DEFAULT_OUTPUT_FOLDERS_IN_FINAL_APPLICATION.ICONS, + iconsPath + ); + } + + const fileNameWithHash = + avoidAllHashes || avoidHash.has(bundleName) + ? fileNameWithoutExt + : getFileNameWithHash(fileNameWithoutExt, getHash(css)); + const fileNameToWriteCss = `${fileNameWithHash}.css`; + + const filePathWithHash = join(fileBaseDirToWrite, fileNameToWriteCss); + + // Create the CSS bundle with hash + fs.writeFileSync(filePathWithHash, css); + + return { + bundleName, + bundleNameWithHash: sanitizeBundleName( + `${bundleFolder}/${fileNameWithHash}` + ), + fileGzipSize: getFileSize(getGzipSize(css)), + fileSize: getFileSize(css.length) + }; +}; + +export const createBundlesWithCustomPaths = async ( + pathToMercuryRootFolder: string, + args: Required, + extraOptions?: { + avoidAllHashes?: boolean; + silentLog?: boolean; + } +) => { + const hasGlobantVariant = args.globant; + const outDir = join(args.outDirPath); + const CREATED_DIRS = new Set(); + const avoidAllHashes = extraOptions?.avoidAllHashes ?? false; + const silentLog = extraOptions?.silentLog ?? false; + + const cssOutput = join( + pathToMercuryRootFolder, + MERCURY_DIST_RELATIVE_FOLDERS.DIST_BUNDLES_CSS + ); + + /** + * Files to transpile + */ + const allFilesToCopy: FileMetadata[] = await getFilesMetadataInDir(cssOutput); + let largestBundleLength = BUNDLE_MAPPING_TO_HASH_FILE.length; + let largestFileSizeLength = 0; + let largestFileGzipSizeLength = 0; + + allFilesToCopy.forEach(fileMetadata => { + const options = getBundleFolderAndFileBaseDir(fileMetadata, outDir); + + largestBundleLength = Math.max( + largestBundleLength, + options.bundleFolder.length + fileMetadata.fileName.length + ); + }); + + if (!avoidAllHashes) { + largestBundleLength += HASH_AND_LETTER_LENGTH; + } + + const copiedFiles: { + bundleName: string; + bundleNameWithHash: string; + fileGzipSize: string; + fileSize: string; + }[] = []; + + allFilesToCopy.forEach(fileMetadata => { + const bundleName = fromFileMetadataToBundleName(fileMetadata); + + const options = getBundleFolderAndFileBaseDir(fileMetadata, outDir); + const { fileBaseDirToWrite } = options; + let actualFilePath = fileMetadata.filePath; + + if (!CREATED_DIRS.has(fileBaseDirToWrite)) { + CREATED_DIRS.add(fileBaseDirToWrite); + fs.mkdirSync(join(fileBaseDirToWrite), { recursive: true }); + } + + // There is no need to copy the base-globant.css file, since the base.css + // file already contains this file + if (hasGlobantVariant) { + if (bundleName === BUNDLE_NAMES.BASE_GLOBANT) { + return; + } + + // Replace the content of the base bundle with the Globant variant + if (bundleName === BUNDLE_NAMES.BASE) { + actualFilePath = actualFilePath.replace( + BASE_CSS_FILE, + BASE_GLOBANT_CSS_FILE + ); + } + } + + copiedFiles.push( + createBundleWithCustomPath( + args, + options, + fileMetadata, + avoidAllHashes, + actualFilePath + ) + ); + }); + + copiedFiles.sort((a, b) => (a.bundleName <= b.bundleName ? -1 : 0)); + + const bundleMappingFileContent = `export const bundleToHashMappings = {\n${copiedFiles.map(entry => ` "${entry.bundleName}": "${entry.bundleNameWithHash}"`).join(",\n")}\n} as const;\n`; + const bundleMappingFileSize = getFileSize(bundleMappingFileContent.length); + const bundleMappingFileGzipSize = getFileSize( + getGzipSize(bundleMappingFileContent) + ); + const bundleMappingFilePath = join(outDir, BUNDLE_MAPPING_TO_HASH_FILE); + + // Compute the largest fileName length + largestFileSizeLength = copiedFiles.reduce( + (acc, copiedFile) => Math.max(acc, copiedFile.fileSize.length), + bundleMappingFileSize.length + ); + largestFileGzipSizeLength = copiedFiles.reduce( + (acc, copiedFile) => Math.max(acc, copiedFile.fileGzipSize.length), + bundleMappingFileGzipSize.length + ); + + if (!silentLog) { + // Improve process visualization + console.log(""); + + printBundleWasCreatedTableHeader({ + largestBundleLength: avoidAllHashes + ? largestBundleLength + : largestBundleLength + 1, + outDir + }); + + // Print bundle created message + for (let index = 0; index < copiedFiles.length; index++) { + const entry = copiedFiles[index]; + + printBundleWasCreated({ + bundleNameWithHash: `${entry.bundleNameWithHash}.css`, + fileGzipSize: entry.fileGzipSize, + fileSize: entry.fileSize, + largestBundleLength, + largestFileSizeLength, + largestFileGzipSizeLength, + outDir + }); + } + } + + fs.writeFileSync(bundleMappingFilePath, bundleMappingFileContent); + + if (!silentLog) { + printBundleWasCreated({ + bundleNameWithHash: BUNDLE_MAPPING_TO_HASH_FILE, + fileGzipSize: getFileSize(getGzipSize(bundleMappingFileContent)), + fileSize: getFileSize(bundleMappingFileContent.length), + largestBundleLength, + largestFileSizeLength, + largestFileGzipSizeLength, + outDir + }); + + printBundleWasCreatedTableFooter({ + largestBundleLength: avoidAllHashes + ? largestBundleLength + : largestBundleLength + 1, + outDir + }); + } + + return copiedFiles; +}; diff --git a/packages/mercury-build/src/build/internal/ensure-all-directory-exists.ts b/packages/mercury-build/src/build/internal/ensure-all-directory-exists.ts new file mode 100644 index 000000000..a4141551a --- /dev/null +++ b/packages/mercury-build/src/build/internal/ensure-all-directory-exists.ts @@ -0,0 +1,25 @@ +import { mkdir } from "fs/promises"; +import { + MERCURY_DIST_RELATIVE_FOLDERS, + MERCURY_SRC_RELATIVE_FOLDERS +} from "../../constants/mercury-relative-folders.js"; +import type { FileMetadata } from "../../typings/cli"; + +export const ensureAllDirectoryExists = (filesToProcess: FileMetadata[]) => { + const CSS_CREATED_DIRS = new Set(); + + return filesToProcess.map(async fileMetadata => { + const cssOutDir = fileMetadata.dir.replace( + MERCURY_SRC_RELATIVE_FOLDERS.SRC_BUNDLES_SCSS, + MERCURY_DIST_RELATIVE_FOLDERS.DIST_BUNDLES_CSS + ); + + // Create the file directory if it does not exists + if (CSS_CREATED_DIRS.has(cssOutDir)) { + return Promise.resolve(); + } + CSS_CREATED_DIRS.add(cssOutDir); + + return mkdir(cssOutDir, { recursive: true }); + }); +}; diff --git a/packages/mercury-build/src/build/internal/replace-placeholders-in-bundle.ts b/packages/mercury-build/src/build/internal/replace-placeholders-in-bundle.ts new file mode 100644 index 000000000..7497c4166 --- /dev/null +++ b/packages/mercury-build/src/build/internal/replace-placeholders-in-bundle.ts @@ -0,0 +1,10 @@ +import { PLACEHOLDERS } from "../../constants/placeholders.js"; + +export const replacePlaceholdersInBundle = ( + transpiledBundle: string, + fontsValue: string, + iconsValue: string +) => + transpiledBundle + .replaceAll(PLACEHOLDERS.FONTS, fontsValue) + .replaceAll(PLACEHOLDERS.ICONS, iconsValue); diff --git a/packages/mercury-build/src/build/internal/transpile-bundle-and-create-mappings.ts b/packages/mercury-build/src/build/internal/transpile-bundle-and-create-mappings.ts new file mode 100644 index 000000000..6ef97c998 --- /dev/null +++ b/packages/mercury-build/src/build/internal/transpile-bundle-and-create-mappings.ts @@ -0,0 +1,135 @@ +import { writeFile } from "fs/promises"; +import { join } from "path"; +import * as sass from "sass"; + +import { BUNDLE_NAMES } from "../../constants/bundle-names.js"; +import { DEFAULT_OUTPUT_FOLDERS_IN_FINAL_APPLICATION } from "../../constants/default-folders-in-final-application.js"; +import { BASE_GLOBANT_CSS_FILE } from "../../constants/file-names.js"; +import { + MERCURY_DIST_RELATIVE_FOLDERS, + MERCURY_SRC_RELATIVE_FOLDERS +} from "../../constants/mercury-relative-folders.js"; +import { normalizePath } from "../../other/normalize-path.js"; +import { sanitizeBundleName } from "../../other/sanitize-bundle-name.js"; +import type { + BuildOptions, + BundleMetadata, + FileMetadata +} from "../../typings/cli"; +import { replacePlaceholdersInBundle } from "./replace-placeholders-in-bundle.js"; + +// import { transform } from "lightningcss"; + +const transpileBundle = (filePath: string, globant: boolean) => + sass.compile(filePath, { + loadPaths: [globant ? "src/config/globant" : "src/config/default"], + style: "compressed" + }).css; + +// TODO: Feature for tomorrow. Use lightningcss to further reduce the css size +// const transpileBundle = (filePath: string, globant: boolean) => +// transform({ +// code: Buffer.from( +// sass.compile(filePath, { +// loadPaths: [globant ? "src/config/globant" : "src/config/default"], +// style: "compressed" +// }).css +// ), +// filename: "", +// minify: true +// }).code.toString(); + +const BUNDLES: BundleMetadata[] = []; + +const transpileGlobantCssFile = async ( + fileMetadata: FileMetadata, + cssOutDir: string +) => { + const { filePath } = fileMetadata; + + BUNDLES.push({ + fileDir: fileMetadata.dir + .replace(MERCURY_DIST_RELATIVE_FOLDERS.DIST_BUNDLES_SCSS, "") + .replace("\\", "/") + }); + + const transpiledBundle = transpileBundle(filePath, true); + + // Store the CSS file with its default values + await writeFile( + join(cssOutDir, BASE_GLOBANT_CSS_FILE), + replacePlaceholdersInBundle( + transpiledBundle, + DEFAULT_OUTPUT_FOLDERS_IN_FINAL_APPLICATION.FONTS, + DEFAULT_OUTPUT_FOLDERS_IN_FINAL_APPLICATION.ICONS + ) + ); +}; + +export const transpileCssBundleWithPlaceholder = async ( + fileMetadata: FileMetadata, + args?: BuildOptions +) => { + const { fileName } = fileMetadata; + const fileDir = normalizePath(fileMetadata.dir); + let actualFilePath = normalizePath(fileMetadata.filePath); + const hasGlobantVariant = args?.globant ?? false; + + const cssOutDir = fileDir.replace( + MERCURY_SRC_RELATIVE_FOLDERS.SRC_BUNDLES_SCSS, + MERCURY_DIST_RELATIVE_FOLDERS.DIST_BUNDLES_CSS + ); + const fileNameCssExt = fileName.replace(".scss", ".css"); + const bundleName = sanitizeBundleName( + actualFilePath + .replace(MERCURY_SRC_RELATIVE_FOLDERS.SRC_BUNDLES_SCSS, "") + .replace(".scss", "") + ); + + // There is no need to generate the base-globant.css file, since the base.css + // file already contains this file + if (hasGlobantVariant) { + if (bundleName === BUNDLE_NAMES.BASE_GLOBANT) { + return; + } + + // Replace the content of the base bundle with the Globant variant + if (bundleName === BUNDLE_NAMES.BASE) { + actualFilePath = actualFilePath.replace( + BUNDLE_NAMES.BASE, + BUNDLE_NAMES.BASE_GLOBANT + ); + } + } + // The Globant variant is not forced, so we are creating both bundles, the + // base/base and base/base-globant bundles + else if (bundleName === BUNDLE_NAMES.BASE) { + transpileGlobantCssFile( + { + dir: fileDir, + fileName: fileName.replace( + BUNDLE_NAMES.BASE, + BUNDLE_NAMES.BASE_GLOBANT + ), + filePath: actualFilePath + }, + cssOutDir + ); + } + + BUNDLES.push({ + fileDir: fileDir.replace(MERCURY_SRC_RELATIVE_FOLDERS.SRC_BUNDLES_SCSS, "") + }); + + const transpiledBundle = transpileBundle(actualFilePath, hasGlobantVariant); + + // Store the CSS file with its default values + await writeFile( + join(cssOutDir, fileNameCssExt), + replacePlaceholdersInBundle( + transpiledBundle, + DEFAULT_OUTPUT_FOLDERS_IN_FINAL_APPLICATION.FONTS, + DEFAULT_OUTPUT_FOLDERS_IN_FINAL_APPLICATION.ICONS + ) + ); +}; diff --git a/packages/mercury-build/src/build/internal/watch-file-system-changes.ts b/packages/mercury-build/src/build/internal/watch-file-system-changes.ts new file mode 100644 index 000000000..a8f3aad5f --- /dev/null +++ b/packages/mercury-build/src/build/internal/watch-file-system-changes.ts @@ -0,0 +1,72 @@ +import { watch } from "fs"; +import { join } from "path"; +import { styleText } from "util"; + +/** + * This debounce value is useful to batch multiples updates attempt into a + * single compilation. In some cases, multiples files can be changed at one, so + * if we don't debounce the next compilation, we would trigger to compilations. + * + * This case can also happen when performing ESLint on auto-save. + */ +const DEBOUNCE = 100; // 100ms + +export const watchFileSystemChanges = ( + pathToMercuryRootFolder: string, + callbackToCompile: () => Promise +) => { + const MERCURY_SRC_PATH = join(pathToMercuryRootFolder, "src"); + const BASE_PATH = join(MERCURY_SRC_PATH, "base"); + const BUNDLES_PATH = join(MERCURY_SRC_PATH, "bundles"); + const COMPONENTS_PATH = join(MERCURY_SRC_PATH, "components"); + const MERCURY_SCSS_PATH = join(MERCURY_SRC_PATH, "mercury.scss"); + + let compileStatus: + | "idle" + | "debouncing-compiling" + | "compiling" + | "queued-after-compile" = "idle"; + + const performCompilationIfNecessary = () => { + // The current compilation is waiting to be started, so there is no need to + // queue another one after the current has finished + if (compileStatus === "debouncing-compiling") { + return; + } + + // There is no compilation in process. Queue one. + if (compileStatus === "idle") { + compileStatus = "debouncing-compiling"; + } + // There is a compilation in progress. We can set a flag to queue a + // compilation after the current one has finished + else { + if (compileStatus === "compiling") { + compileStatus = "queued-after-compile"; + } + return; + } + + setTimeout(() => { + compileStatus = "compiling"; + + console.time(styleText("green", "Rebuild done in")); + + callbackToCompile().then(() => { + console.timeEnd(styleText("green", "Rebuild done in")); + + const hasToRecompile = compileStatus === "queued-after-compile"; + compileStatus = "idle"; + + if (hasToRecompile) { + performCompilationIfNecessary(); + } + }); + }, DEBOUNCE); + }; + + watch(BASE_PATH, { recursive: true }, performCompilationIfNecessary); + watch(BUNDLES_PATH, { recursive: true }, performCompilationIfNecessary); + watch(COMPONENTS_PATH, { recursive: true }, performCompilationIfNecessary); + watch(MERCURY_SCSS_PATH, { recursive: true }, performCompilationIfNecessary); +}; diff --git a/packages/mercury-build/src/constants.ts b/packages/mercury-build/src/constants.ts new file mode 100644 index 000000000..112df94a4 --- /dev/null +++ b/packages/mercury-build/src/constants.ts @@ -0,0 +1,43 @@ +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; + +import { DEFAULT_OUTPUT_FOLDERS_IN_FINAL_APPLICATION } from "./constants/default-folders-in-final-application.js"; +import type { + MercuryOptions, + MercuryOptionsAssetPreload +} from "./typings/plugin"; + +export const DEFAULT_CSS_PRELOAD_VALUE = { + position: "head", + fetchPriority: "auto" +} satisfies MercuryOptionsAssetPreload; + +export const DEFAULT_CSS_PRELOAD_VALUE_POSITION = "head" satisfies Exclude< + MercuryOptionsAssetPreload, + boolean +>["position"]; + +export const TEMPORAL_CSS_PATH = + "node_modules/.vite/@genexus/mercury/assets/css/"; + +// This is a WA to have the __dirname in ES modules +// Directory name where the script is located (/dist/plugins/) +const __dirname = dirname(fileURLToPath(import.meta.url)).replaceAll("\\", "/"); + +export const FONTS_FOLDER = join(__dirname, "../assets/fonts"); +export const ICONS_FOLDER = join(__dirname, "../assets/icons"); + +export const getCssBaseUrl = (options: MercuryOptions | undefined) => + options?.assetsPaths?.cssPath ?? + DEFAULT_OUTPUT_FOLDERS_IN_FINAL_APPLICATION.CSS; + +export const getFontsBaseUrl = (options: MercuryOptions | undefined) => + options?.assetsPaths?.fontsPath ?? + DEFAULT_OUTPUT_FOLDERS_IN_FINAL_APPLICATION.FONTS; + +export const getIconsBaseUrl = (options: MercuryOptions | undefined) => + options?.assetsPaths?.iconsPath ?? + DEFAULT_OUTPUT_FOLDERS_IN_FINAL_APPLICATION.ICONS; + +export const getDataAttrFromBundleName = (bundleName: T) => + `data-mercury-bundle="${bundleName}"` as const; diff --git a/packages/mercury-build/src/constants/bundle-names.ts b/packages/mercury-build/src/constants/bundle-names.ts new file mode 100644 index 000000000..d145f0f97 --- /dev/null +++ b/packages/mercury-build/src/constants/bundle-names.ts @@ -0,0 +1,6 @@ +import type { MercuryBundleBase } from "@genexus/mercury"; + +export const BUNDLE_NAMES = { + BASE: "base/base" satisfies MercuryBundleBase, + BASE_GLOBANT: "base/base-globant" +}; diff --git a/packages/mercury-build/src/constants/default-folders-in-final-application.ts b/packages/mercury-build/src/constants/default-folders-in-final-application.ts new file mode 100644 index 000000000..019298b93 --- /dev/null +++ b/packages/mercury-build/src/constants/default-folders-in-final-application.ts @@ -0,0 +1,6 @@ +export const DEFAULT_OUTPUT_FOLDERS_IN_FINAL_APPLICATION = { + CSS: "/assets/css/", + FONTS: "/assets/fonts/", + ICONS: "/assets/icons/", + OUT_DIR: "./.mercury" +} as const; diff --git a/packages/mercury-build/src/constants/file-names.ts b/packages/mercury-build/src/constants/file-names.ts new file mode 100644 index 000000000..e8bcaf0c4 --- /dev/null +++ b/packages/mercury-build/src/constants/file-names.ts @@ -0,0 +1,3 @@ +export const BUNDLE_MAPPING_TO_HASH_FILE = "bundle-to-hash-mappings.js"; +export const BASE_CSS_FILE = "base.css"; +export const BASE_GLOBANT_CSS_FILE = "base-globant.css"; diff --git a/packages/mercury-build/src/constants/hash-length.ts b/packages/mercury-build/src/constants/hash-length.ts new file mode 100644 index 000000000..90a83d65e --- /dev/null +++ b/packages/mercury-build/src/constants/hash-length.ts @@ -0,0 +1,2 @@ +export const HASH_LENGTH = 16; +export const HASH_AND_LETTER_LENGTH = HASH_LENGTH + 1; diff --git a/packages/mercury-build/src/constants/kb-unit.ts b/packages/mercury-build/src/constants/kb-unit.ts new file mode 100644 index 000000000..c2af0a0cc --- /dev/null +++ b/packages/mercury-build/src/constants/kb-unit.ts @@ -0,0 +1 @@ +export const KB = 1000; diff --git a/packages/mercury-build/src/constants/mercury-relative-folders.ts b/packages/mercury-build/src/constants/mercury-relative-folders.ts new file mode 100644 index 000000000..01e5c1b0d --- /dev/null +++ b/packages/mercury-build/src/constants/mercury-relative-folders.ts @@ -0,0 +1,11 @@ +const DIST_BUNDLES = "dist/bundles"; + +export const MERCURY_DIST_RELATIVE_FOLDERS = { + DIST_BUNDLES, + DIST_BUNDLES_CSS: `${DIST_BUNDLES}/css`, + DIST_BUNDLES_SCSS: `${DIST_BUNDLES}/scss` +} as const; + +export const MERCURY_SRC_RELATIVE_FOLDERS = { + SRC_BUNDLES_SCSS: "src/bundles/scss" +} as const; diff --git a/packages/mercury-build/src/constants/mime-type-dictionary.ts b/packages/mercury-build/src/constants/mime-type-dictionary.ts new file mode 100644 index 000000000..139366587 --- /dev/null +++ b/packages/mercury-build/src/constants/mime-type-dictionary.ts @@ -0,0 +1,8 @@ +export const mimeTypeDictionaryForMercuryAssets = { + ".css": "text/css", + ".js": "text/javascript", + ".png": "image/png", + ".svg": "image/svg+xml", + ".webp": "image/webp", + ".woff2": "application/font-woff2" +} as const; diff --git a/packages/mercury-build/src/constants/placeholders.ts b/packages/mercury-build/src/constants/placeholders.ts new file mode 100644 index 000000000..1788b7dd3 --- /dev/null +++ b/packages/mercury-build/src/constants/placeholders.ts @@ -0,0 +1,4 @@ +export const PLACEHOLDERS = { + FONTS: "{{FONTS}}", + ICONS: "{{ICONS}}" +} as const; diff --git a/packages/mercury-build/src/constants/tests/mercury-relative-folders.spec.ts b/packages/mercury-build/src/constants/tests/mercury-relative-folders.spec.ts new file mode 100644 index 000000000..eae403467 --- /dev/null +++ b/packages/mercury-build/src/constants/tests/mercury-relative-folders.spec.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from "vitest"; +import { MERCURY_DIST_RELATIVE_FOLDERS } from "../mercury-relative-folders"; + +describe("[mercury-relative-folders.ts]", () => { + test("the distribution folders should match the expected values", () => { + expect(MERCURY_DIST_RELATIVE_FOLDERS).toEqual({ + DIST_BUNDLES: "dist/bundles", + DIST_BUNDLES_CSS: "dist/bundles/css", + DIST_BUNDLES_SCSS: "dist/bundles/scss" + }); + }); +}); diff --git a/packages/mercury-build/src/file-management/copy-directories.ts b/packages/mercury-build/src/file-management/copy-directories.ts new file mode 100644 index 000000000..28ca8946e --- /dev/null +++ b/packages/mercury-build/src/file-management/copy-directories.ts @@ -0,0 +1,4 @@ +import { cpSync } from "fs"; + +export const copyDirectories = (srcDir: string, outDir: string) => + cpSync(srcDir, outDir, { recursive: true }); diff --git a/packages/mercury-build/src/file-management/ensure-directory-exists-and-its-clear.ts b/packages/mercury-build/src/file-management/ensure-directory-exists-and-its-clear.ts new file mode 100644 index 000000000..885622889 --- /dev/null +++ b/packages/mercury-build/src/file-management/ensure-directory-exists-and-its-clear.ts @@ -0,0 +1,33 @@ +import { existsSync, mkdirSync, rmSync } from "fs"; +import { rm } from "fs/promises"; +import { getFilesMetadataInDir } from "./get-files-metadata-in-dir.js"; + +export const ensureDirectoryExistsAndItsClear = async ( + dirPath: string, + filesToNotRemove?: Set +) => { + // First build + if (!existsSync(dirPath)) { + mkdirSync(dirPath, { recursive: true }); + return; + } + + // Incremental rebuild keeps some file without any changes, so we must skip + // the file deletion + if (filesToNotRemove) { + const filesInDir = await getFilesMetadataInDir(dirPath); + + await Promise.all( + filesInDir.map(file => + filesToNotRemove.has(file.filePath) + ? Promise.resolve() + : rm(file.filePath) + ) + ); + } + // Incremental rebuild did not include any file to exclude its removal + else { + rmSync(dirPath, { recursive: true }); + mkdirSync(dirPath, { recursive: true }); + } +}; diff --git a/packages/mercury-build/src/file-management/file-management.ts b/packages/mercury-build/src/file-management/file-management.ts new file mode 100644 index 000000000..b95aaf980 --- /dev/null +++ b/packages/mercury-build/src/file-management/file-management.ts @@ -0,0 +1,23 @@ +import fs from "fs"; +import path from "path"; + +export const refreshAngularBrowser = () => { + const ANGULAR_FILE_TO_TRIGGER_RELOAD = path.join( + process.cwd(), + "../showcase/src/index.html" + ); + + const FILE_CONTENT = fs.readFileSync(ANGULAR_FILE_TO_TRIGGER_RELOAD, { + encoding: "utf8" + }); + + // Trigger a dummy write in the index.html + fs.writeFileSync(ANGULAR_FILE_TO_TRIGGER_RELOAD, FILE_CONTENT + "\n"); + + // Rollback that write to not provoke any changes, but do it after the + // debounce of the Angular CLI has completed the queued HMR + setTimeout( + () => fs.writeFileSync(ANGULAR_FILE_TO_TRIGGER_RELOAD, FILE_CONTENT), + 300 + ); +}; diff --git a/packages/mercury-build/src/file-management/get-all-paths-from-folder.ts b/packages/mercury-build/src/file-management/get-all-paths-from-folder.ts new file mode 100644 index 000000000..5dacb27b4 --- /dev/null +++ b/packages/mercury-build/src/file-management/get-all-paths-from-folder.ts @@ -0,0 +1,41 @@ +import { readdir } from "fs/promises"; +import { extname, join, relative } from "path"; +import type { MercuryPluginMiddlewareAssetMetadata } from "../typings/plugin"; + +/** + * Recursively collects all file paths inside a directory + */ +export async function getAllFiles( + dir: string, + appDestinationPrefix: string, + middlewareMetadata: Map, + root: string = dir +): Promise { + // Read directory entries (files and folders) + const entries = await readdir(dir, { withFileTypes: true }); + + // Process entries: recurse into subdirectories or collect file paths + Promise.all( + entries.map(entry => { + const fullPath = join(dir, entry.name); + + // Recurse into subdirectory + if (entry.isDirectory()) { + return getAllFiles( + fullPath, + appDestinationPrefix, + middlewareMetadata, + root + ); + } + const requestPathMatch = + appDestinationPrefix + relative(root, fullPath).replaceAll("\\", "/"); // Return relative file path + + return middlewareMetadata.set(requestPathMatch, { + fileSystemPath: fullPath, + extension: extname(fullPath), + requestPathMatch + }); + }) + ); +} diff --git a/packages/mercury-build/src/file-management/get-files-metadata-in-dir.ts b/packages/mercury-build/src/file-management/get-files-metadata-in-dir.ts new file mode 100644 index 000000000..464a36193 --- /dev/null +++ b/packages/mercury-build/src/file-management/get-files-metadata-in-dir.ts @@ -0,0 +1,30 @@ +import { readdir } from "fs/promises"; +import { join } from "path"; + +import { normalizePath } from "../other/normalize-path.js"; +import type { FileMetadata } from "../typings/cli"; + +export const getFilesMetadataInDir = async ( + dir: string +): Promise => { + const filesAndDirs = await readdir(dir, { + withFileTypes: true, + recursive: true + }); + + const files: FileMetadata[] = []; + + for (let index = 0; index < filesAndDirs.length; index++) { + const file = filesAndDirs[index]; + + if (file.isFile()) { + files.push({ + dir: normalizePath(file.parentPath), + fileName: normalizePath(file.name), + filePath: normalizePath(join(file.parentPath, file.name)) + }); + } + } + + return files; +}; diff --git a/packages/mercury-build/src/html-transformers/get-html-with-global-initialization.ts b/packages/mercury-build/src/html-transformers/get-html-with-global-initialization.ts new file mode 100644 index 000000000..f6ddfcd45 --- /dev/null +++ b/packages/mercury-build/src/html-transformers/get-html-with-global-initialization.ts @@ -0,0 +1,9 @@ +export const getHtmlWithGlobalInitialization = ( + html: string, + bundleToHashMapping: Record +) => + html.replace( + //, + ` + ` + ); diff --git a/packages/mercury-build/src/html-transformers/get-html-with-inlined-css.ts b/packages/mercury-build/src/html-transformers/get-html-with-inlined-css.ts new file mode 100644 index 000000000..3e3d39591 --- /dev/null +++ b/packages/mercury-build/src/html-transformers/get-html-with-inlined-css.ts @@ -0,0 +1,48 @@ +import { readFile } from "fs/promises"; +import { getCssBaseUrl, getDataAttrFromBundleName } from "../constants.js"; +import type { + MercuryOptions, + MercuryPluginHooksMetadata +} from "../typings/plugin.js"; + +export const getHtmlWithInlinedCss = async ( + html: string, + mercuryOptions: MercuryOptions | undefined, + metadataReusedBetweenHooks: MercuryPluginHooksMetadata +) => { + const { cssInline } = mercuryOptions ?? {}; + const { bundleToHashMapping, middlewareMetadata } = + metadataReusedBetweenHooks.cssBuild; + const CSS_BASE_URL = getCssBaseUrl(mercuryOptions); + + if (cssInline === undefined || bundleToHashMapping === undefined) { + return html; + } + + const filesToInline = Object.keys(cssInline) + .filter(bundleName => cssInline[bundleName as keyof typeof cssInline]) + .map(bundleName => ({ + bundleName, + + fileSystemPath: middlewareMetadata.get( + CSS_BASE_URL + bundleToHashMapping[bundleName] + ".css" + )!.fileSystemPath + })); + + if (filesToInline.length === 0) { + return html; + } + + const fileContentToInclude = await Promise.all( + filesToInline.map(async ({ bundleName, fileSystemPath }) => ({ + bundleName, + css: await readFile(fileSystemPath, { encoding: "utf-8" }) + })) + ); + + return html.replace( + /<\/head>/, + ` ${fileContentToInclude.map(({ bundleName, css }) => ``).join(" ")} + ` + ); +}; diff --git a/packages/mercury-build/src/html-transformers/get-html-with-preloaded-css.ts b/packages/mercury-build/src/html-transformers/get-html-with-preloaded-css.ts new file mode 100644 index 000000000..7522a7b5c --- /dev/null +++ b/packages/mercury-build/src/html-transformers/get-html-with-preloaded-css.ts @@ -0,0 +1,100 @@ +import { + DEFAULT_CSS_PRELOAD_VALUE, + DEFAULT_CSS_PRELOAD_VALUE_POSITION, + getCssBaseUrl, + getDataAttrFromBundleName +} from "../constants.js"; +import type { + MercuryOptions, + MercuryOptionsAssetPreload, + MercuryPluginHooksMetadata +} from "../typings/plugin.js"; + +const renderLinks = ( + links: { + bundleName: string; + metadata: Exclude; + requestPathMatch: string; + }[] +) => + links + .map( + ({ bundleName, metadata, requestPathMatch }) => + `` + ) + .join(" "); + +export const getHtmlWithPreloadedCss = ( + html: string, + mercuryOptions: MercuryOptions | undefined, + metadataReusedBetweenHooks: MercuryPluginHooksMetadata +) => { + const { cssPreload } = mercuryOptions ?? {}; + const { bundleToHashMapping } = metadataReusedBetweenHooks.cssBuild; + const CSS_BASE_URL = getCssBaseUrl(mercuryOptions); + + if (cssPreload === undefined || bundleToHashMapping === undefined) { + return html; + } + + const filesToInline: { + bundleName: string; + metadata: Exclude; + requestPathMatch: string; + }[] = Object.keys(cssPreload) + .filter(bundleName => cssPreload[bundleName as keyof typeof cssPreload]) + .map(bundleName => { + const position = cssPreload[bundleName as keyof typeof cssPreload]!; + + return { + bundleName, + metadata: + typeof position === "boolean" + ? DEFAULT_CSS_PRELOAD_VALUE + : { + position: + // Default position + position.position ?? DEFAULT_CSS_PRELOAD_VALUE_POSITION, + fetchPriority: position.fetchPriority + }, + requestPathMatch: + CSS_BASE_URL + bundleToHashMapping[bundleName] + ".css" + }; + }); + + if (filesToInline.length === 0) { + return html; + } + + let processedHtml = html; + const filesInTheHead = filesToInline.filter( + ({ metadata }) => metadata.position === "head" + ); + const filesInTheBodyStart = filesToInline.filter( + ({ metadata }) => metadata.position === "body-start" + ); + const filesInTheBodyEnd = filesToInline.filter( + ({ metadata }) => metadata.position === "body-end" + ); + + if (filesInTheHead.length !== 0) { + processedHtml = processedHtml.replace( + /<\/head>/, + ` ${renderLinks(filesInTheHead)}\n ` + ); + } + if (filesInTheBodyStart.length !== 0) { + processedHtml = processedHtml.replace( + //, + `\n ${renderLinks(filesInTheBodyStart)}` + ); + } + if (filesInTheBodyEnd.length !== 0) { + processedHtml = processedHtml.replace( + /<\/body>/, + ` ${renderLinks(filesInTheBodyEnd)}\n ` + ); + } + + return processedHtml; +}; diff --git a/packages/mercury-build/src/index.ts b/packages/mercury-build/src/index.ts new file mode 100644 index 000000000..c69862793 --- /dev/null +++ b/packages/mercury-build/src/index.ts @@ -0,0 +1,50 @@ +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +// Build +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +export { buildAllCssBundles } from "./build/build-all-css-bundles.js"; +export { + createBundlesWithCustomPaths, + createBundleWithCustomPath +} from "./build/create-bundles-with-custom-paths.js"; +export { transpileCssBundleWithPlaceholder } from "./build/internal/transpile-bundle-and-create-mappings.js"; + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +// Constants +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +export { DEFAULT_OUTPUT_FOLDERS_IN_FINAL_APPLICATION } from "./constants/default-folders-in-final-application.js"; +export { + MERCURY_DIST_RELATIVE_FOLDERS, + MERCURY_SRC_RELATIVE_FOLDERS +} from "./constants/mercury-relative-folders.js"; + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +// File management +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +export { ensureDirectoryExistsAndItsClear } from "./file-management/ensure-directory-exists-and-its-clear.js"; +export { getFilesMetadataInDir } from "./file-management/get-files-metadata-in-dir.js"; + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +// HTML transformers +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +export { getHtmlWithGlobalInitialization } from "./html-transformers/get-html-with-global-initialization.js"; +export { getHtmlWithInlinedCss } from "./html-transformers/get-html-with-inlined-css.js"; +export { getHtmlWithPreloadedCss } from "./html-transformers/get-html-with-preloaded-css.js"; + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +// Print to output +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +export { printBundleWasCreated } from "./print-to-output/print-bundle-was-created.js"; +export { printBundleWasTranspiled } from "./print-to-output/print-bundle-was-transpiled.js"; + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +// Other +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +export { getGzipSize } from "./other/get-gzip-size.js"; +export { measureTime } from "./other/measure-time.js"; +export { sanitizeBundleName } from "./other/sanitize-bundle-name.js"; + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +// Types +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +export type * from "./typings/cli.js"; +export type * from "./typings/plugin.js"; diff --git a/packages/mercury-build/src/other/from-file-metadata-to-bundle-name.ts b/packages/mercury-build/src/other/from-file-metadata-to-bundle-name.ts new file mode 100644 index 000000000..c15589c36 --- /dev/null +++ b/packages/mercury-build/src/other/from-file-metadata-to-bundle-name.ts @@ -0,0 +1,9 @@ +import type { FileMetadata } from "../typings/cli"; +import { getBundleFolder } from "./get-bundle-folder.js"; +import { getFileNameWithoutExt } from "./get-file-name-without-extension.js"; +import { sanitizeBundleName } from "./sanitize-bundle-name.js"; + +export const fromFileMetadataToBundleName = (fileMetadata: FileMetadata) => + sanitizeBundleName( + `${getBundleFolder(fileMetadata)}/${getFileNameWithoutExt(fileMetadata)}` + ); diff --git a/packages/mercury-build/src/other/get-bundle-folder-and-file-base-dir.ts b/packages/mercury-build/src/other/get-bundle-folder-and-file-base-dir.ts new file mode 100644 index 000000000..6bcc6e904 --- /dev/null +++ b/packages/mercury-build/src/other/get-bundle-folder-and-file-base-dir.ts @@ -0,0 +1,15 @@ +import { join } from "path"; +import type { FileMetadata } from "../typings/cli.js"; +import { getBundleFolder } from "./get-bundle-folder.js"; + +export const getBundleFolderAndFileBaseDir = ( + fileMetadata: FileMetadata, + outDir: string +) => { + const bundleFolder = getBundleFolder(fileMetadata); + + return { + bundleFolder, + fileBaseDirToWrite: join(outDir, bundleFolder) + }; +}; diff --git a/packages/mercury-build/src/other/get-bundle-folder.ts b/packages/mercury-build/src/other/get-bundle-folder.ts new file mode 100644 index 000000000..23a3a511c --- /dev/null +++ b/packages/mercury-build/src/other/get-bundle-folder.ts @@ -0,0 +1,5 @@ +import { MERCURY_DIST_RELATIVE_FOLDERS } from "../constants/mercury-relative-folders.js"; +import type { FileMetadata } from "../typings/cli"; + +export const getBundleFolder = (fileMetadata: FileMetadata) => + fileMetadata.dir.split(MERCURY_DIST_RELATIVE_FOLDERS.DIST_BUNDLES_CSS)[1]; diff --git a/packages/mercury-build/src/other/get-file-name-with-hash.ts b/packages/mercury-build/src/other/get-file-name-with-hash.ts new file mode 100644 index 000000000..273ffca06 --- /dev/null +++ b/packages/mercury-build/src/other/get-file-name-with-hash.ts @@ -0,0 +1,4 @@ +export const getFileNameWithHash = ( + bundleName: B, + hash: H +) => `${bundleName}-${hash}` as const; diff --git a/packages/mercury-build/src/other/get-file-name-without-extension.ts b/packages/mercury-build/src/other/get-file-name-without-extension.ts new file mode 100644 index 000000000..2fee2314b --- /dev/null +++ b/packages/mercury-build/src/other/get-file-name-without-extension.ts @@ -0,0 +1,4 @@ +import type { FileMetadata } from "../typings/cli"; + +export const getFileNameWithoutExt = (fileMetadata: FileMetadata) => + fileMetadata.fileName.replace(/.css$/, ""); diff --git a/packages/mercury-build/src/other/get-file-size.ts b/packages/mercury-build/src/other/get-file-size.ts new file mode 100644 index 000000000..139515861 --- /dev/null +++ b/packages/mercury-build/src/other/get-file-size.ts @@ -0,0 +1,20 @@ +import { KB } from "../constants/kb-unit.js"; + +export const getFileSize = (fileLength: number) => { + if (fileLength < KB) { + return fileLength + " bytes"; + } + + const fileLengthInKB = fileLength / KB; + if (fileLengthInKB < KB) { + return fileLengthInKB.toFixed(2) + " kB"; + } + + const fileLengthInMB = fileLengthInKB / KB; + if (fileLengthInMB < KB) { + return fileLengthInMB.toFixed(2) + " MB"; + } + + const fileLengthInGB = fileLengthInMB / KB; + return fileLengthInGB.toFixed(2) + " GB"; +}; diff --git a/packages/mercury-build/src/other/get-gzip-size.ts b/packages/mercury-build/src/other/get-gzip-size.ts new file mode 100644 index 000000000..611d6d2fb --- /dev/null +++ b/packages/mercury-build/src/other/get-gzip-size.ts @@ -0,0 +1,25 @@ +import { gzipSync, type ZlibOptions } from "zlib"; + +const GZIP_HEADER_CRC_OVERHEAD_ESTIMATION = 311; + +/** + * Estimate the Gzip size of a given string, + * using compression settings similar to browsers/servers. + * @param input - The string to compress and measure. + * @returns The size in bytes after gzip compression. + */ +export function getGzipSize(input: string): number { + const buffer = Buffer.from(input, "utf-8"); + + // Use compression similar to servers (default level ~6) + const options: ZlibOptions = { level: 6 }; + const gzipped = gzipSync(buffer, options); + + // Start with raw gzip size + let size = gzipped.length; + + // Add Gzip header + CRC overhead estimation + size += GZIP_HEADER_CRC_OVERHEAD_ESTIMATION; + + return size; +} diff --git a/packages/mercury-build/src/other/get-hash.ts b/packages/mercury-build/src/other/get-hash.ts new file mode 100644 index 000000000..05c12abb0 --- /dev/null +++ b/packages/mercury-build/src/other/get-hash.ts @@ -0,0 +1,9 @@ +import crypto from "crypto"; +import { HASH_LENGTH } from "../constants/hash-length.js"; + +export const getHash = (fileContent: string) => + crypto + .createHash("md5") + .update(fileContent) + .digest("hex") + .substring(HASH_LENGTH); diff --git a/packages/mercury-build/src/other/measure-time.ts b/packages/mercury-build/src/other/measure-time.ts new file mode 100644 index 000000000..f711614dc --- /dev/null +++ b/packages/mercury-build/src/other/measure-time.ts @@ -0,0 +1,11 @@ +import { styleText } from "util"; + +export const measureTime = async ( + callback: (() => void) | (() => Promise) +) => { + console.time(styleText("green", "Done in")); + + await callback(); + + console.timeEnd(styleText("green", "Done in")); +}; diff --git a/packages/mercury-build/src/other/normalize-path.ts b/packages/mercury-build/src/other/normalize-path.ts new file mode 100644 index 000000000..6d59f8af0 --- /dev/null +++ b/packages/mercury-build/src/other/normalize-path.ts @@ -0,0 +1 @@ +export const normalizePath = (path: string) => path.replaceAll("\\", "/"); diff --git a/packages/mercury-build/src/other/sanitize-bundle-name.ts b/packages/mercury-build/src/other/sanitize-bundle-name.ts new file mode 100644 index 000000000..4aa4b587f --- /dev/null +++ b/packages/mercury-build/src/other/sanitize-bundle-name.ts @@ -0,0 +1,4 @@ +import { normalizePath } from "./normalize-path.js"; + +export const sanitizeBundleName = (bundleName: string) => + normalizePath(bundleName).replace(/^\//, ""); diff --git a/packages/mercury-build/src/plugins/create-middleware-for-dev-server.ts b/packages/mercury-build/src/plugins/create-middleware-for-dev-server.ts new file mode 100644 index 000000000..edfa1e116 --- /dev/null +++ b/packages/mercury-build/src/plugins/create-middleware-for-dev-server.ts @@ -0,0 +1,97 @@ +import { createReadStream } from "fs"; +import { stat } from "fs/promises"; +import { resolve } from "path"; +import type { ViteDevServer } from "vite"; +import { + FONTS_FOLDER, + getFontsBaseUrl, + getIconsBaseUrl, + ICONS_FOLDER +} from "../constants.js"; +import { mimeTypeDictionaryForMercuryAssets } from "../constants/mime-type-dictionary.js"; +import { getAllFiles } from "../file-management/get-all-paths-from-folder.js"; +import type { + MercuryOptions, + MercuryPluginHooksMetadata +} from "../typings/plugin.js"; +import { getMiddlewareMetadataFromCssBuild } from "./internals/build-css.js"; + +/** + * Creates a middleware for Vite dev server to serve static assets like fonts and icons. + * This middleware intercepts requests for these assets and serves them directly from the file system, + * bypassing the need to copy them to the output directory during development. + * @param server - The Vite development server instance. + * @param mercuryOptions - Configuration options for Mercury. + * @param metadataReusedBetweenHooks - Metadata reused between plugin hooks to optimize performance. + * @returns A promise that resolves when the middleware is set up. + */ +export const createViteMiddlewareForDevServer = async ( + server: ViteDevServer, + mercuryOptions: MercuryOptions | undefined, + metadataReusedBetweenHooks: MercuryPluginHooksMetadata +) => { + const FONTS_BASE_URL = getFontsBaseUrl(mercuryOptions); + const ICONS_BASE_URL = getIconsBaseUrl(mercuryOptions); + const { middlewareMetadata } = metadataReusedBetweenHooks.cssBuild; + + // Get all metadata to proxy the assets + await Promise.all([ + getMiddlewareMetadataFromCssBuild({ + applicationRootDir: "", + distributionBuild: false, + mercuryOptions, + metadataReusedBetweenHooks + }), + getAllFiles(FONTS_FOLDER, FONTS_BASE_URL, middlewareMetadata), + getAllFiles(ICONS_FOLDER, ICONS_BASE_URL, middlewareMetadata) + ]); + + server.middlewares.use(async (req, res, next) => { + const match = middlewareMetadata.get(req.url ?? ""); + + if (!match) { + return next(); + } + try { + const absoluteSrc = resolve(match.fileSystemPath); + const stats = await stat(absoluteSrc); + const range = req.headers.range; + + // Detect and set MIME type + const mimeType = + mimeTypeDictionaryForMercuryAssets[ + match.extension as keyof typeof mimeTypeDictionaryForMercuryAssets + ] ?? "application/octet-stream"; + + // Handle range requests (e.g. fonts) + if (range) { + const [startStr, endStr] = range.replace(/bytes=/, "").split("-"); + const start = parseInt(startStr, 10); + const end = endStr ? parseInt(endStr, 10) : stats.size - 1; + const chunkSize = end - start + 1; + + res.writeHead(206, { + "Content-Range": `bytes ${start}-${end}/${stats.size}`, + "Accept-Ranges": "bytes", + "Content-Length": chunkSize, + "Content-Type": mimeType + }); + + createReadStream(absoluteSrc, { start, end }).pipe(res); + } else { + // Normal full file response + res.writeHead(200, { + "Content-Length": stats.size, + "Content-Type": mimeType, + "Accept-Ranges": "bytes", + "Cache-Control": "no-cache" + }); + + createReadStream(absoluteSrc).pipe(res); + } + } catch { + res.statusCode = 404; + res.end("Not found"); + } + }); +}; diff --git a/packages/mercury-build/src/plugins/internals/build-css.ts b/packages/mercury-build/src/plugins/internals/build-css.ts new file mode 100644 index 000000000..43dab69a3 --- /dev/null +++ b/packages/mercury-build/src/plugins/internals/build-css.ts @@ -0,0 +1,104 @@ +import { join } from "path"; + +import { createBundlesWithCustomPaths } from "../../build/create-bundles-with-custom-paths.js"; +import { + getCssBaseUrl, + getFontsBaseUrl, + getIconsBaseUrl, + TEMPORAL_CSS_PATH +} from "../../constants.js"; +import { ensureDirectoryExistsAndItsClear } from "../../file-management/ensure-directory-exists-and-its-clear.js"; +import type { + MercuryOptions, + MercuryPluginHooksMetadata +} from "../../typings/plugin.js"; + +export const buildCss = async (options: { + applicationRootDir: string; + distributionBuild: boolean; + mercuryOptions: MercuryOptions | undefined; + metadataReusedBetweenHooks: MercuryPluginHooksMetadata; +}): Promise => { + const { + applicationRootDir, + distributionBuild, + mercuryOptions, + metadataReusedBetweenHooks + } = options; + const CSS_BASE_URL = getCssBaseUrl(mercuryOptions); + const avoidHash = mercuryOptions?.avoidHash; + const avoidHashSet = new Set(); + + await ensureDirectoryExistsAndItsClear(TEMPORAL_CSS_PATH); + + // Add all components bundles that must not be hashed to the Set + if (avoidHash) { + Object.keys(avoidHash).forEach(componentBundleName => { + if (avoidHash[componentBundleName as keyof typeof avoidHash]) { + avoidHashSet.add(componentBundleName); + } + }); + } + + const bundleHashes = await createBundlesWithCustomPaths( + { + avoidHash: avoidHashSet, + fontsPath: getFontsBaseUrl(mercuryOptions), + globant: mercuryOptions?.theme === "globant", + outDirPath: distributionBuild + ? join(applicationRootDir, CSS_BASE_URL) + : TEMPORAL_CSS_PATH, + iconsPath: getIconsBaseUrl(mercuryOptions) + }, + { silentLog: !distributionBuild } + ); + + // Mapping of the bundle names to their hash + const bundleToHashMapping: Record = {}; + bundleHashes.forEach(bundleHash => { + bundleToHashMapping[bundleHash.bundleName] = bundleHash.bundleNameWithHash; + + // Set mapping from the requestPathMath to the asset metadata that is + // proxied + const requestPathMatch = + CSS_BASE_URL + bundleHash.bundleNameWithHash + ".css"; + metadataReusedBetweenHooks.cssBuild.middlewareMetadata.set( + requestPathMatch, + { + fileSystemPath: join( + process.cwd(), + TEMPORAL_CSS_PATH, + bundleHash.bundleNameWithHash + ".css" + ), + requestPathMatch, + extension: ".css" + } + ); + }); + + metadataReusedBetweenHooks.cssBuild.wasBuilt = true; + metadataReusedBetweenHooks.cssBuild.bundleToHashMapping = bundleToHashMapping; +}; + +export const getBundleToHashMappingFromCssBuild = async (options: { + applicationRootDir: string; + distributionBuild: boolean; + mercuryOptions: MercuryOptions | undefined; + metadataReusedBetweenHooks: MercuryPluginHooksMetadata; +}) => { + if (!options.metadataReusedBetweenHooks.cssBuild.wasBuilt) { + await buildCss(options); + } + return options.metadataReusedBetweenHooks.cssBuild.bundleToHashMapping!; +}; + +export const getMiddlewareMetadataFromCssBuild = async (options: { + applicationRootDir: string; + distributionBuild: boolean; + mercuryOptions: MercuryOptions | undefined; + metadataReusedBetweenHooks: MercuryPluginHooksMetadata; +}) => { + if (!options.metadataReusedBetweenHooks.cssBuild.wasBuilt) { + await buildCss(options); + } +}; diff --git a/packages/mercury-build/src/plugins/perform-build-for-distribution-build.ts b/packages/mercury-build/src/plugins/perform-build-for-distribution-build.ts new file mode 100644 index 000000000..354dea28f --- /dev/null +++ b/packages/mercury-build/src/plugins/perform-build-for-distribution-build.ts @@ -0,0 +1,49 @@ +import { cp, mkdir } from "fs/promises"; +import { join } from "path"; + +import { + FONTS_FOLDER, + getFontsBaseUrl, + getIconsBaseUrl, + ICONS_FOLDER +} from "../constants.js"; +import type { + MercuryOptions, + MercuryPluginHooksMetadata +} from "../typings/plugin.js"; +import { buildCss } from "./internals/build-css.js"; + +export const performBuildForDistributionBuild = async ( + applicationRootDir: string, + mercuryOptions: MercuryOptions | undefined, + metadataReusedBetweenHooks: MercuryPluginHooksMetadata +) => { + const fontsDestination = join( + applicationRootDir, + getFontsBaseUrl(mercuryOptions) + ); + const iconsDestination = join( + applicationRootDir, + getIconsBaseUrl(mercuryOptions) + ); + + await Promise.all([ + mkdir(fontsDestination, { recursive: true }), + mkdir(iconsDestination, { recursive: true }) + ]); + + return Promise.all([ + cp(FONTS_FOLDER, fontsDestination, { recursive: true }), + cp(ICONS_FOLDER, iconsDestination, { recursive: true }), + + // In this case, we directly use buildCss, since we want to update the + // assets in the dist folder and the cached metadata is not useful for this + // case + buildCss({ + applicationRootDir, + distributionBuild: true, + mercuryOptions, + metadataReusedBetweenHooks + }) + ]); +}; diff --git a/packages/mercury-build/src/print-to-output/print-building.ts b/packages/mercury-build/src/print-to-output/print-building.ts new file mode 100644 index 000000000..c16f2a183 --- /dev/null +++ b/packages/mercury-build/src/print-to-output/print-building.ts @@ -0,0 +1,66 @@ +import readline from "readline"; +import { styleText } from "util"; + +const progressDictionary = { + 0: "-", + 1: "\\", + 2: "|", + 3: "/" +} as const; + +let lastRebuildInterval: NodeJS.Timeout; +let updateCounter = 0; + +const { isTTY } = process.stdout; + +const readLineInterface = readline.createInterface({ + input: process.stdin, + output: process.stdout +}); + +export const printRebuilding = (firstBuild: boolean) => { + // process.stdout doesn't work on the CI + if (!isTTY) { + return; + } + + // Hide the cursor to improve the CLI design + readLineInterface.write("\u001B[?25l"); + + const buildingStatus = firstBuild + ? "Building bundles..." + : "Changes detected. Rebuilding bundles..."; + + // First write + process.stdout.write( + `${styleText("yellow", progressDictionary[(updateCounter % 4) as 0 | 1 | 2 | 3])} ${buildingStatus}` + ); + + // Update the left symbol each 100ms + lastRebuildInterval = setInterval(() => { + process.stdout.cursorTo(1); + process.stdout.clearLine(-1); + process.stdout.cursorTo(0); + + process.stdout.write( + `${styleText("yellow", progressDictionary[(updateCounter % 4) as 0 | 1 | 2 | 3])} ${buildingStatus}` + ); + + updateCounter++; + }, 100); +}; + +export const stopRebuildingStdout = () => { + // process.stdout doesn't work on the CI + if (!isTTY) { + return; + } + + clearInterval(lastRebuildInterval); + process.stdout.clearLine(0); + console.log(""); + updateCounter = 0; + + // Show again the cursor + readLineInterface.write("\u001B[?25h"); +}; diff --git a/packages/mercury-build/src/print-to-output/print-bundle-was-created-table-footer.ts b/packages/mercury-build/src/print-to-output/print-bundle-was-created-table-footer.ts new file mode 100644 index 000000000..306dcb131 --- /dev/null +++ b/packages/mercury-build/src/print-to-output/print-bundle-was-created-table-footer.ts @@ -0,0 +1,23 @@ +import { styleText } from "util"; +import { + TABLE_GZIPPED_SIZE_TITLE, + TABLE_RAW_SIZE_TITLE +} from "./table-constants.js"; + +export const printBundleWasCreatedTableFooter = (args: { + outDir: string; + largestBundleLength: number; +}) => { + console.log( + styleText( + ["gray"], + " └" + + "─".repeat(args.outDir.length + args.largestBundleLength + 3) + + "┴" + + "─".repeat(TABLE_RAW_SIZE_TITLE.length + 2) + + "┴" + + "─".repeat(TABLE_GZIPPED_SIZE_TITLE.length + 2) + + "┘" + ) + ); +}; diff --git a/packages/mercury-build/src/print-to-output/print-bundle-was-created-table-header.ts b/packages/mercury-build/src/print-to-output/print-bundle-was-created-table-header.ts new file mode 100644 index 000000000..1dc20e9c7 --- /dev/null +++ b/packages/mercury-build/src/print-to-output/print-bundle-was-created-table-header.ts @@ -0,0 +1,51 @@ +import { styleText } from "util"; +import { separator } from "./print-separator.js"; +import { + TABLE_GZIPPED_SIZE_TITLE, + TABLE_HEADER, + TABLE_RAW_SIZE_TITLE +} from "./table-constants.js"; + +export const printBundleWasCreatedTableHeader = (args: { + outDir: string; + largestBundleLength: number; +}) => { + console.log( + styleText( + ["gray"], + " ┌" + + "─".repeat(args.outDir.length + args.largestBundleLength + 3) + + "┬" + + "─".repeat(TABLE_RAW_SIZE_TITLE.length + 2) + + "┬" + + "─".repeat(TABLE_GZIPPED_SIZE_TITLE.length + 2) + + "┐" + ) + ); + + console.log( + separator() + + styleText(["white", "bold"], TABLE_HEADER) + + " ".repeat( + args.outDir.length + args.largestBundleLength - TABLE_HEADER.length + 1 + ) + + separator() + + styleText(["white", "bold"], TABLE_RAW_SIZE_TITLE) + + separator() + + styleText(["white", "bold"], TABLE_GZIPPED_SIZE_TITLE) + + separator() + ); + + console.log( + separator() + + styleText( + ["gray"], + "-".repeat(args.outDir.length + args.largestBundleLength + 1) + ) + + separator() + + styleText(["gray"], "-".repeat(TABLE_RAW_SIZE_TITLE.length)) + + separator() + + styleText(["gray"], "-".repeat(TABLE_GZIPPED_SIZE_TITLE.length)) + + separator() + ); +}; diff --git a/packages/mercury-build/src/print-to-output/print-bundle-was-created.ts b/packages/mercury-build/src/print-to-output/print-bundle-was-created.ts new file mode 100644 index 000000000..0dff54f24 --- /dev/null +++ b/packages/mercury-build/src/print-to-output/print-bundle-was-created.ts @@ -0,0 +1,39 @@ +import { join } from "path"; +import { styleText } from "util"; + +import { normalizePath } from "../other/normalize-path.js"; +import { separator } from "./print-separator.js"; + +export const printBundleWasCreated = (args: { + outDir: string; + bundleNameWithHash: string; + largestBundleLength: number; + largestFileSizeLength: number; + largestFileGzipSizeLength: number; + fileSize: string; + fileGzipSize: string; +}) => + console.log( + separator() + + styleText("white", normalizePath(args.outDir)) + + styleText( + "greenBright", + normalizePath( + join(args.outDir, args.bundleNameWithHash).replace(args.outDir, "") + ) + ) + + " ".repeat( + Math.max(args.largestBundleLength - args.bundleNameWithHash.length, 0) + ) + + separator() + + " ".repeat( + Math.max(args.largestFileSizeLength - args.fileSize.length, 0) + ) + + styleText("cyanBright", args.fileSize) + + separator() + + " ".repeat( + Math.max(args.largestFileGzipSizeLength - args.fileGzipSize.length, 0) + ) + + styleText("cyanBright", args.fileGzipSize) + + separator() + ); diff --git a/packages/mercury-build/src/print-to-output/print-bundle-was-transpiled.ts b/packages/mercury-build/src/print-to-output/print-bundle-was-transpiled.ts new file mode 100644 index 000000000..1cb19c2e8 --- /dev/null +++ b/packages/mercury-build/src/print-to-output/print-bundle-was-transpiled.ts @@ -0,0 +1,15 @@ +import { styleText } from "util"; +import { + MERCURY_DIST_RELATIVE_FOLDERS, + MERCURY_SRC_RELATIVE_FOLDERS +} from "../constants/mercury-relative-folders.js"; + +export const printBundleWasTranspiled = (filePath: string) => + console.log( + styleText("greenBright", " Transpiled: ") + + styleText("white", MERCURY_DIST_RELATIVE_FOLDERS.DIST_BUNDLES_SCSS) + + styleText( + "whiteBright", + filePath.replace(MERCURY_SRC_RELATIVE_FOLDERS.SRC_BUNDLES_SCSS, "") + ) + ); diff --git a/packages/mercury-build/src/print-to-output/print-separator.ts b/packages/mercury-build/src/print-to-output/print-separator.ts new file mode 100644 index 000000000..e24668a0b --- /dev/null +++ b/packages/mercury-build/src/print-to-output/print-separator.ts @@ -0,0 +1,3 @@ +import { styleText } from "util"; + +export const separator = () => styleText(["gray"], " │ "); diff --git a/packages/mercury-build/src/print-to-output/table-constants.ts b/packages/mercury-build/src/print-to-output/table-constants.ts new file mode 100644 index 000000000..2342832f9 --- /dev/null +++ b/packages/mercury-build/src/print-to-output/table-constants.ts @@ -0,0 +1,3 @@ +export const TABLE_HEADER = "Mercury CSS Files"; +export const TABLE_RAW_SIZE_TITLE = "Raw size "; +export const TABLE_GZIPPED_SIZE_TITLE = "Gzipped "; diff --git a/packages/mercury/src/cli/internal/types.ts b/packages/mercury-build/src/typings/cli.ts similarity index 64% rename from packages/mercury/src/cli/internal/types.ts rename to packages/mercury-build/src/typings/cli.ts index 8c418eee7..ce3cfb32d 100644 --- a/packages/mercury/src/cli/internal/types.ts +++ b/packages/mercury-build/src/typings/cli.ts @@ -14,10 +14,10 @@ export type FileMetadata = { filePath: string; }; -export type CLIArguments = { - avoidHash: Set; - globant: boolean; - iconsPath: string; - fontFacePath: string; - outDirPath: string; +export type BuildOptions = { + avoidHash?: Set; + globant?: boolean; + iconsPath?: string; + fontsPath?: string; + outDirPath?: string; }; diff --git a/packages/mercury-build/src/typings/plugin.ts b/packages/mercury-build/src/typings/plugin.ts new file mode 100644 index 000000000..5ea854d0a --- /dev/null +++ b/packages/mercury-build/src/typings/plugin.ts @@ -0,0 +1,154 @@ +import type { + MercuryBundleBase, + MercuryBundleOptimized, + MercuryBundleReset +} from "@genexus/mercury"; + +export type MercuryOptions = { + /** + * An object to customize the location of the Mercury assets (CSS, font, and + * icon files) in the distribution build. + * + * In dev mode these files are proxied to the real source. + */ + assetsPaths?: MercuryOptionsAssets; + + /** + * Customize which files are not hashed. + * + * **IMPORTANT!**: Use this field if you know what you are doing. Not hashing + * the CSS files could lead to cache issues when changing the version of + * Mercury. + * + * By default, all CSS bundle files are hashed to avoid any cache issue. + */ + avoidHash?: { + [key in + | MercuryBundleBase + | MercuryBundleReset + | MercuryBundleOptimized]?: boolean; + }; + + /** + * Determine which CSS files will be inserted at the end of html `head` as a + * `style` tag. In some cases, this may improve initial load performance; for + * example, inlining the `base/base` bundle. + * + * **Warning**: Abusing of this setting could lead to diminishing returns. + * + * By default, the base/base and resets/box-sizing CSS bundles are inlined. + */ + cssInline?: { + [key in + | Exclude + | MercuryBundleReset + | MercuryBundleOptimized]?: boolean; + }; + + /** + * Determine which CSS files will be preloaded as a `` tag. + * In some cases, this may improve initial load performance. + * + * When using `true` the default config will be `{ position: "head", fetchPriority: "auto" }` + * + * **Warning**: Abusing of this setting could lead to diminishing returns. + * + * By default, the `"base/icons"` CSS bundle is preloaded at the `"body-end"` + * position with `fetchPriority: "low"`. + */ + cssPreload?: { + [key in + | MercuryBundleBase + | MercuryBundleReset + | MercuryBundleOptimized]?: MercuryOptionsAssetPreload; + }; + + /** + * The theme variant in which Mercury is implemented. + * + * @default "mercury" + */ + theme?: "mercury" | "globant"; +}; + +export type MercuryOptionsAssets = { + /** + * Path where the CSS files of Mercury are located in the distribution build. + * + * @default "/assets/css/" + */ + cssPath?: string; + + /** + * Path where the font files of Mercury are located in the distribution build. + * + * @default "/assets/fonts/" + */ + fontsPath?: string; + + /** + * Path where the icon files of Mercury are located in the distribution build. + * + * @default "/assets/icons/" + */ + iconsPath?: string; +}; + +export type MercuryOptionsAssetPreload = + | boolean + | { + /** + * The position where the link is placed. + * - `"head"`: Places the `` at the end of the html `head`. + * - `"body-start"`: Places the `` at the start of the html `body`. + * - `"body-end"`: Places the `` at the end of the html `body`. + * + * @default "head" + */ + position?: "head" | "body-start" | "body-end"; + + /** + * Represents a hint to the browser indicating how it should prioritize + * fetching a particular resource relative to other resources of the same + * type. + * + * Based on [fetchPriority](https://developer.mozilla.org/en-US/docs/Web/API/HTMLLinkElement/fetchPriority). + * + * @default "auto" + */ + fetchPriority?: "auto" | "high" | "low"; + }; + +export type MercuryPluginMiddlewareAssetMetadata = { + /** + * The real path of the asset located in the file system. + * - For the fonts and icons, this path points to `node_modules/@genexus/mercury/dist/assets//` + * - For the CSS, as it has to be build, this path points to `node_modules/.vite/@genexus/mercury/` + */ + fileSystemPath: string; + + /** + * This is the path to match when a request for the middleware is performed. + * + * This path depends on the base url of the asset. For example, this can be: + * - `"/assets/fonts/Inter-latin-ext-700-italic.woff2"` + * - `"/assets/fonts/Inter-latin-ext-400-normal.woff2"` + * - `"/assets/css/components/button-.css"` + * - `"/assets/css/components/chat-.css"` + * - `"/assets/icons/system/dark/accessibility-new.svg"` + */ + requestPathMatch: string; + + /** + * The extension of the asset. For example, `".css"`, `".svg"` or `".woff2"`. + */ + extension: string; +}; + +export type MercuryPluginHooksMetadata = { + cssBuild: { + wasBuilt: boolean; + bundleToHashMapping?: Record; + middlewareMetadata: Map; + }; +}; diff --git a/packages/mercury-build/tsconfig.json b/packages/mercury-build/tsconfig.json new file mode 100644 index 000000000..675278f21 --- /dev/null +++ b/packages/mercury-build/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "lib": ["ESNext"], + "moduleResolution": "node", + "module": "esnext", + "target": "ESNext", + + "declaration": true, + "outDir": "dist/", + "skipLibCheck": true, + + // Linting + "allowUnreachableCode": false, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noImplicitAny": true, + "noImplicitThis": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "strict": true, + "verbatimModuleSyntax": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "src/tests/**"] +} diff --git a/packages/mercury-cli/.prettierrc.json b/packages/mercury-cli/.prettierrc.json new file mode 100644 index 000000000..2297b592c --- /dev/null +++ b/packages/mercury-cli/.prettierrc.json @@ -0,0 +1,13 @@ +{ + "arrowParens": "avoid", + "bracketSpacing": true, + "jsxBracketSameLine": false, + "jsxSingleQuote": false, + "quoteProps": "as-needed", + "printWidth": 80, + "semi": true, + "singleQuote": false, + "tabWidth": 2, + "trailingComma": "none", + "useTabs": false +} diff --git a/packages/mercury-cli/eslint.config.js b/packages/mercury-cli/eslint.config.js new file mode 100644 index 000000000..6f397659d --- /dev/null +++ b/packages/mercury-cli/eslint.config.js @@ -0,0 +1,91 @@ +import { FlatCompat } from "@eslint/eslintrc"; +import js from "@eslint/js"; +import typescriptEslint from "@typescript-eslint/eslint-plugin"; +import tsParser from "@typescript-eslint/parser"; +import { defineConfig } from "eslint/config"; +import globals from "globals"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all +}); + +export default defineConfig([ + { + ignores: ["**/node_modules/*", "**/dist/*"], + + files: ["**/*.ts", "**/*.js", "**.*.js"], + + extends: compat.extends( + "eslint:recommended", + "plugin:@typescript-eslint/eslint-recommended", + "plugin:@typescript-eslint/recommended" + ), + + plugins: { + "@typescript-eslint": typescriptEslint + }, + + languageOptions: { + globals: { + ...globals.browser + }, + + parser: tsParser, + ecmaVersion: "latest", + sourceType: "module" + }, + + rules: { + // - - - - - - - - - - - - + // ESLint + // - - - - - - - - - - - - + camelcase: "warn", // Enforce camelcase naming convention + curly: "error", // Enforce consistent brace style for all control statements + eqeqeq: ["warn", "always", { null: "ignore" }], // Require the use of === and !== "ignore" -------> Do not apply this rule to null + "logical-assignment-operators": [ + "warn", + "always", + { enforceForIfStatements: true } + ], // This rule checks for expressions that can be shortened using logical assignment operator + "dot-notation": "warn", // This rule is aimed at maintaining code consistency and improving code readability by encouraging use of the dot notation style whenever possible. As such, it will warn when it encounters an unnecessary use of square-bracket notation. + "max-depth": ["warn", 3], // Enforce a maximum depth that blocks can be nested. Many developers consider code difficult to read if blocks are nested beyond a certain depth + "no-alert": "error", // Disallow the use of alert, confirm, and prompt + "no-console": "warn", // Warning when using console.log, console.warn or console.error + "no-else-return": ["warn", { allowElseIf: false }], // Disallow else blocks after return statements in if statements + "no-extra-boolean-cast": "error", // Disallow unnecessary boolean casts + "no-debugger": "error", // Error when using debugger; + "no-duplicate-case": "error", // This rule disallows duplicate test expressions in case clauses of switch statements + "no-empty": "error", // Disallow empty block statements + "no-lonely-if": "error", // Disallow if statements as the only statement in else blocks + "no-multi-assign": "error", // Disallow use of chained assignment expressions + "no-nested-ternary": "error", // Errors when using nested ternary expressions + "no-sequences": "error", // Disallow comma operators + "no-undef": "off", // Allows defining undefined variables + "no-unneeded-ternary": "error", // Disallow ternary operators when simpler alternatives exist + "no-useless-return": "error", // Disallow redundant return statements + "prefer-const": "error", + "spaced-comment": ["error", "always", { exceptions: ["-", "+", "/"] }], // Enforce consistent spacing after the // or /* in a comment + + "no-prototype-builtins": "off", + + // - - - - - - - - - - - - + // TypeScript + // - - - - - - - - - - - - + "@typescript-eslint/ban-types": "off", + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/explicit-module-boundary-types": "off", + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/no-empty-function": "off", + "@typescript-eslint/no-non-null-assertion": "off" + + // "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }] + } + } +]); + diff --git a/packages/mercury-cli/package.json b/packages/mercury-cli/package.json new file mode 100644 index 000000000..b4dcc77ff --- /dev/null +++ b/packages/mercury-cli/package.json @@ -0,0 +1,46 @@ +{ + "name": "@genexus/mercury-cli", + "version": "0.1.1", + "description": "...", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist/" + ], + "type": "module", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + } + }, + "scripts": { + "build": "tsc", + "lint": "eslint src/**/*.ts --fix", + "test": "vitest", + "test.ci": "bun run test --watch=false --browser.headless" + }, + "license": "Apache-2.0", + "devDependencies": { + "@genexus/mercury": "^0.32.0", + "@genexus/mercury-build": "workspace:*", + "@eslint/js": "*", + "@types/node": "*", + "@typescript-eslint/eslint-plugin": "*", + "@typescript-eslint/parser": "*", + "eslint": "*", + "globals": "*", + "prettier": "*", + "sass": "~1.86.3", + "typescript": "*", + "typescript-eslint": "*", + "vitest": "*" + }, + "peerDependencies": { + "@genexus/mercury": "^0.32.0" + } +} + diff --git a/packages/mercury/src/cli/bundle.ts b/packages/mercury-cli/src/bundle.ts similarity index 84% rename from packages/mercury/src/cli/bundle.ts rename to packages/mercury-cli/src/bundle.ts index 60b527e5b..bdf9e8e5b 100644 --- a/packages/mercury/src/cli/bundle.ts +++ b/packages/mercury-cli/src/bundle.ts @@ -1,35 +1,28 @@ #!/usr/bin/env node -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { Worker, isMainThread, parentPort } from "worker_threads"; - import type { MercuryBundleBase, MercuryBundleFull, MercuryBundleOptimized, MercuryBundleReset -} from "../types"; - -import { - BASE_BUNDLES_OUT_DIR, - CSS_BUNDLES_OUT_DIR, - SCSS_BUNDLES_INPUT_DIR - // SCSS_BUNDLES_OUT_DIR -} from "./internal/constants.js"; +} from "@genexus/mercury"; import { + createBundlesWithCustomPaths, ensureDirectoryExistsAndItsClear, - getFilesInDir, - refreshAngularBrowser - // copyDirectories -} from "./internal/file-management.js"; -import { transpileCssBundleWithPlaceholder } from "./internal/transpile-bundle-and-create-mappings.js"; -import { measureTime } from "./internal/utils.js"; - -import { createBundlesWithCustomPaths } from "./internal/create-bundles-with-custom-paths.js"; -import { printBundleWasTranspiled } from "./internal/print-utils.js"; -import type { CLIArguments, FileMetadata } from "./internal/types"; + getFilesMetadataInDir, + measureTime, + MERCURY_DIST_RELATIVE_FOLDERS, + MERCURY_SRC_RELATIVE_FOLDERS, + printBundleWasTranspiled, + transpileCssBundleWithPlaceholder, + type CLIArguments, + type FileMetadata +} from "@genexus/mercury-build"; +import fs from "fs/promises"; +import path from "ne:path"; +import os from "os"; +import { fileURLToPath } from "url"; +import { isMainThread, parentPort, Worker } from "worker_threads"; + import { getArguments } from "./internal/validate-args.js"; import { printRebuilding, @@ -86,13 +79,19 @@ if (isMainThread) { const CSS_FILES_TO_AVOID_REMOVE = new Set( BUNDLES_TO_AVOID_REBUILD.map(bundleName => - path.join(CSS_BUNDLES_OUT_DIR, bundleName + ".css") + path.join( + MERCURY_DIST_RELATIVE_FOLDERS.DIST_BUNDLES_CSS, + bundleName + ".css" + ) ) ); const SCSS_FILES_TO_AVOID_TRANSPILE = new Set( BUNDLES_TO_AVOID_REBUILD.map(bundleName => - path.join(SCSS_BUNDLES_INPUT_DIR, bundleName + ".scss") + path.join( + MERCURY_SRC_RELATIVE_FOLDERS.SRC_BUNDLES_SCSS, + bundleName + ".scss" + ) ) ); // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -109,18 +108,17 @@ if (isMainThread) { return filesToProcess.map(async fileMetadata => { const cssOutDir = fileMetadata.dir.replace( - SCSS_BUNDLES_INPUT_DIR, - CSS_BUNDLES_OUT_DIR + MERCURY_SRC_RELATIVE_FOLDERS.SRC_BUNDLES_SCSS, + MERCURY_DIST_RELATIVE_FOLDERS.DIST_BUNDLES_CSS ); // Create the file directory if it does not exists if (CSS_CREATED_DIRS.has(cssOutDir)) { return Promise.resolve(); - } - CSS_CREATED_DIRS.add(cssOutDir); + } + CSS_CREATED_DIRS.add(cssOutDir); - return fs.mkdir(cssOutDir, { recursive: true }); - + return fs.mkdir(cssOutDir, { recursive: true }); }); }; @@ -132,7 +130,9 @@ if (isMainThread) { // First build where the directory has to be discovered if (firstBuild) { - allFilesToProcess = await getFilesInDir(SCSS_BUNDLES_INPUT_DIR); + allFilesToProcess = await getFilesMetadataInDir( + MERCURY_SRC_RELATIVE_FOLDERS.SRC_BUNDLES_SCSS + ); createWorkers(); } @@ -145,7 +145,7 @@ if (isMainThread) { // Clear bundle directories await ensureDirectoryExistsAndItsClear( - BASE_BUNDLES_OUT_DIR, + MERCURY_DIST_RELATIVE_FOLDERS.DIST_BUNDLES, options?.cssFilesToAvoidRemove ); @@ -246,7 +246,7 @@ if (isMainThread) { // Last true value meaning: Don't hash the bundles in watch mode to avoid // issues with Angular that caches the bundle mapping file, causing to // not update the hashes for the fetches - await createBundlesWithCustomPaths(cliArgs!, true); + await createBundlesWithCustomPaths(cliArgs!, { avoidAllHashes: true }); refreshAngularBrowser(); firstBuild = false; } catch (err) { diff --git a/packages/mercury-cli/src/internal/print-utils.ts b/packages/mercury-cli/src/internal/print-utils.ts new file mode 100644 index 000000000..29f2c7833 --- /dev/null +++ b/packages/mercury-cli/src/internal/print-utils.ts @@ -0,0 +1,59 @@ +import { DEFAULT_OUTPUT_FOLDERS_IN_FINAL_APPLICATION } from "@genexus/mercury-build"; +import { styleText } from "util"; + +export const printArgumentDoesNotExistsError = (arg: string) => + console.log( + styleText("red", " error ") + + styleText("gray", "Argument does not exists: ") + + `'${arg}'` + ); + +export const printDuplicatedArgumentError = (arg: string) => + console.log( + styleText("red", " error ") + + styleText("gray", "Duplicated argument type: ") + + `'${arg}'` + ); + +export const printInvalidArgumentError = (arg: string) => + console.log( + styleText("red", " error ") + + styleText("gray", "Invalid argument: ") + + `'${arg}'` + ); + +export const printMissingFontPathArgumentWarning = () => + console.log( + styleText( + "yellow", + " [warning]: Missing --font-face-path argument. The path " + ) + + styleText( + "cyan", + `'${DEFAULT_OUTPUT_FOLDERS_IN_FINAL_APPLICATION.FONTS}'` + ) + + styleText("yellow", " will be used as default.") + ); + +export const printMissingIconsPathArgumentWarning = () => + console.log( + styleText( + "yellow", + " [warning]: Missing --icons-path argument. The path " + ) + + styleText( + "cyan", + `'${DEFAULT_OUTPUT_FOLDERS_IN_FINAL_APPLICATION.ICONS}'` + ) + + styleText("yellow", " will be used as default.") + ); + +export const printMissingOutDirPathArgumentWarning = () => + console.log( + styleText("yellow", " [warning]: Missing --outDir argument. The path ") + + styleText( + "cyan", + `'${DEFAULT_OUTPUT_FOLDERS_IN_FINAL_APPLICATION.OUT_DIR}'` + ) + + styleText("yellow", " will be used as default.") + ); diff --git a/packages/mercury-cli/src/internal/regex.ts b/packages/mercury-cli/src/internal/regex.ts new file mode 100644 index 000000000..740bb5920 --- /dev/null +++ b/packages/mercury-cli/src/internal/regex.ts @@ -0,0 +1,2 @@ +export const SEPARATE_BY_COMMA_REGEX = /\s*,\s*/g; +export const SPECIAL_CHARS_IN_BUNDLE_NAME_REGEX = /[/-]/g; diff --git a/packages/mercury/src/cli/internal/validate-args.ts b/packages/mercury-cli/src/internal/validate-args.ts similarity index 91% rename from packages/mercury/src/cli/internal/validate-args.ts rename to packages/mercury-cli/src/internal/validate-args.ts index b36338817..9fa1b0ce8 100644 --- a/packages/mercury/src/cli/internal/validate-args.ts +++ b/packages/mercury-cli/src/internal/validate-args.ts @@ -1,9 +1,8 @@ import { - DEFAULT_FONT_FACE_PATH, - DEFAULT_ICONS_PATH, - DEFAULT_OUT_DIR_PATH, - SEPARATE_BY_COMMA_REGEX -} from "./constants.js"; + DEFAULT_OUTPUT_FOLDERS_IN_FINAL_APPLICATION, + sanitizeBundleName, + type CLIArguments +} from "@genexus/mercury-build"; import { printArgumentDoesNotExistsError, printDuplicatedArgumentError, @@ -12,8 +11,7 @@ import { printMissingIconsPathArgumentWarning, printMissingOutDirPathArgumentWarning } from "./print-utils.js"; -import type { CLIArguments } from "./types"; -import { sanitizeBundleName } from "./utils.js"; +import { SEPARATE_BY_COMMA_REGEX } from "./regex.js"; const ARGUMENT_VALUE_AND_NAME_SEPARATOR_REGEX = /\s*=\s*/g; const ERROR_IN_CHECK = false; @@ -144,19 +142,19 @@ export const getArguments = (): CLIArguments | undefined => { if (!fontFacePath) { printMissingFontPathArgumentWarning(); - fontFacePath = DEFAULT_FONT_FACE_PATH; + fontFacePath = DEFAULT_OUTPUT_FOLDERS_IN_FINAL_APPLICATION.FONTS; anyWarning = true; } if (!iconsPath) { printMissingIconsPathArgumentWarning(); - iconsPath = DEFAULT_ICONS_PATH; + iconsPath = DEFAULT_OUTPUT_FOLDERS_IN_FINAL_APPLICATION.ICONS; anyWarning = true; } if (!outDirPath) { printMissingOutDirPathArgumentWarning(); - outDirPath = DEFAULT_OUT_DIR_PATH; + outDirPath = DEFAULT_OUTPUT_FOLDERS_IN_FINAL_APPLICATION.OUT_DIR; anyWarning = true; } diff --git a/packages/mercury-cli/src/mercury.ts b/packages/mercury-cli/src/mercury.ts new file mode 100644 index 000000000..ae937a75f --- /dev/null +++ b/packages/mercury-cli/src/mercury.ts @@ -0,0 +1,19 @@ +#!/usr/bin/env node + +import { + createBundlesWithCustomPaths, + ensureDirectoryExistsAndItsClear, + measureTime +} from "@genexus/mercury-build"; +import { getArguments } from "./internal/validate-args.js"; + +measureTime(async () => { + // Side effect: validate arguments from the process and print errors/warnings if necessary + const args = getArguments(); + + if (args) { + ensureDirectoryExistsAndItsClear(args.outDirPath); + + await createBundlesWithCustomPaths(args); + } +}); diff --git a/packages/mercury-cli/tsconfig.json b/packages/mercury-cli/tsconfig.json new file mode 100644 index 000000000..675278f21 --- /dev/null +++ b/packages/mercury-cli/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "lib": ["ESNext"], + "moduleResolution": "node", + "module": "esnext", + "target": "ESNext", + + "declaration": true, + "outDir": "dist/", + "skipLibCheck": true, + + // Linting + "allowUnreachableCode": false, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noImplicitAny": true, + "noImplicitThis": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "strict": true, + "verbatimModuleSyntax": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "src/tests/**"] +} diff --git a/packages/mercury/build-scss.js b/packages/mercury/build-scss.js new file mode 100644 index 000000000..75669c79f --- /dev/null +++ b/packages/mercury/build-scss.js @@ -0,0 +1,6 @@ +import { buildAllCssBundles } from "@genexus/mercury-build"; + +const [, , ...args] = process.argv; +const watch = args.includes("--watch"); + +buildAllCssBundles(process.cwd(), undefined, { watch }); diff --git a/packages/mercury/package.json b/packages/mercury/package.json index ecc5c0334..ee4f84ecc 100644 --- a/packages/mercury/package.json +++ b/packages/mercury/package.json @@ -59,11 +59,10 @@ "build-no-svg": "bun build.js && bun build.scss && bun copy-tasks", "build.scss": "bun build.scss.base && bun build.scss.bundles", "build.scss.base": "scss-bundle -e ./src/icons/_generated/categories.scss -o src/icons/_generated/categories-bundled.scss --logLevel=silent", - "build.scss.bundles": "node dist/cli/bundle.js", + "build.scss.bundles": "node build-scss.js", "build.scss.watch": "tsc && bun build.scss.base && bun copy-tasks && bun build.scss.bundles --watch -i=/assets/icons/ -f=/assets/fonts/ --outDir=../showcase/.mercury/ --avoid-hash=base/base", "build.scss.test-common": "tsc && bun build.scss.base && bun copy-tasks && bun build.scss.bundles --ci -i=/assets/icons/ -f=/assets/fonts/ --outDir=../common/public/mercury/css/", "build.scss.helpers": "scss-bundle -e ./src/assets/scss/_helpers.scss -o ./src/assets/scss/helpers-bundled.scss --logLevel=info", - "start": "vite --port 5200 --open showcase/button.html", "process-icons": "ssg --configPath=./src/config/icons.js", "icons-svg": "ssg-svg --srcDir=src/icons/svg-source --outDir=src/assets/icons/_generated/ --configFilePath=src/icons/svg-source/.config/color-states.json --showcaseDir=showcase/icons/ --showcaseBaseHref=../assets/icons/ --logDir=./log --objectFilePath=src/assets/MERCURY_ASSETS.ts --defaultColorType=on-surface", "icons-sass": "ssg-sass --srcDir=src/assets/icons/ --outDir=src/icons/_generated/ --configFilePath=src/icons/svg-source/.config/color-states.json --vendorPrefix=", @@ -83,6 +82,7 @@ "devDependencies": { "@eslint/js": "*", "@genexus/chameleon-controls-library": "6.22.1", + "@genexus/mercury-build": "workspace:*", "@genexus/svg-sass-generator": "1.1.24", "@jackolope/ts-lit-plugin": "^3.1.4", "@types/node": "*", diff --git a/packages/mercury/src/assets-manager.ts b/packages/mercury/src/assets-manager.ts index f155bcb4d..fc1b6c8a3 100644 --- a/packages/mercury/src/assets-manager.ts +++ b/packages/mercury/src/assets-manager.ts @@ -8,11 +8,11 @@ import type { TreeViewItemModel } from "@genexus/chameleon-controls-library"; -import type { ActionListItemAdditionalBase } from "@genexus/chameleon-controls-library/dist/types/components/action-list/types.d.ts"; -import type { ComboBoxItemModel } from "@genexus/chameleon-controls-library/dist/types/components/combo-box/types.d.ts"; -import type { TreeViewItemImageMultiState } from "@genexus/chameleon-controls-library/dist/types/components/tree-view/types.d.ts"; -import type { RegistryGetImagePathCallback } from "@genexus/chameleon-controls-library/dist/types/index.d.ts"; -import type { Assets, AssetsColorType, AssetsMetadata } from "./types.d.ts"; +import type { ActionListItemAdditionalBase } from "@genexus/chameleon-controls-library/dist/types/components/action-list/types"; +import type { ComboBoxItemModel } from "@genexus/chameleon-controls-library/dist/types/components/combo-box/types"; +import type { TreeViewItemImageMultiState } from "@genexus/chameleon-controls-library/dist/types/components/tree-view/types"; +import type { RegistryGetImagePathCallback } from "@genexus/chameleon-controls-library/dist/types/index"; +import type { Assets, AssetsColorType, AssetsMetadata } from "./types"; const ASSETS_BY_VENDOR: { [key in string]: Assets } = {}; const ALIAS_TO_VENDOR_NAME: { [key in string]: string } = {}; diff --git a/packages/mercury/src/bundles.ts b/packages/mercury/src/bundles.ts index aa5bc647e..9fa92856f 100644 --- a/packages/mercury/src/bundles.ts +++ b/packages/mercury/src/bundles.ts @@ -1,5 +1,5 @@ import type { ThemeModel } from "@genexus/chameleon-controls-library"; -import type { ThemeItemModel } from "@genexus/chameleon-controls-library/dist/types/components/theme/theme-types.d.ts"; +import type { ThemeItemModel } from "@genexus/chameleon-controls-library/dist/types/components/theme/theme-types"; import type { MercuryBundleComponent, MercuryBundleComponentForm, @@ -9,7 +9,7 @@ import type { MercuryBundleReset, MercuryBundleUtil, MercuryBundleUtilFormFull -} from "./types.ts"; +} from "./types.js"; type BundleNames = | MercuryBundleComponent @@ -18,7 +18,13 @@ type BundleNames = | MercuryBundleUtil | MercuryBundleUtilFormFull; -let globalBundleMappings: MercuryBundleMapping | undefined; +// This global setting help us to avoid issues when the Mercury dependency is +// duplicated. Also, allow us to set the mapping inline in the HTML when +// using plugins for building Mercury +globalThis.mercury ??= { + globalBundleMappings: undefined +}; +const { mercury } = globalThis; const getThemeModelItem = ( basePath: string, @@ -36,7 +42,7 @@ const getThemeModelItem = ( ? bundleNamePrefix + bundleName : bundleName; - const actualBundleMapping = bundleMappings ?? globalBundleMappings; + const actualBundleMapping = bundleMappings ?? mercury.globalBundleMappings; const bundleNameWithHash = actualBundleMapping ? actualBundleMapping[bundleName] @@ -299,5 +305,5 @@ export const getBundles = ( * After that, the `getBundles` utility can be used. */ export const setBundleMapping = (mappings: MercuryBundleMapping) => { - globalBundleMappings = mappings; + mercury.globalBundleMappings = mappings; }; diff --git a/packages/mercury/src/cli/internal/constants.ts b/packages/mercury/src/cli/internal/constants.ts deleted file mode 100644 index 99cddb2c6..000000000 --- a/packages/mercury/src/cli/internal/constants.ts +++ /dev/null @@ -1,35 +0,0 @@ -import path from "node:path"; - -import type { MercuryBundleBase } from "../../types"; -// import type { BundleAssociationMetadata } from "./types"; - -export const KB = 1000; - -export const SCSS_BUNDLES_INPUT_DIR = path.join("src", "bundles", "scss"); - -export const BASE_BUNDLES_OUT_DIR = path.join("dist", "bundles"); -export const SCSS_BUNDLES_OUT_DIR = path.join(BASE_BUNDLES_OUT_DIR, "scss"); -export const CSS_BUNDLES_OUT_DIR = path.join(BASE_BUNDLES_OUT_DIR, "css"); - -export const BASE_BUNDLE = "base/base" satisfies MercuryBundleBase; -export const BASE_GLOBANT_BUNDLE = "base/base-globant"; - -// Files -export const BUNDLE_MAPPING_TO_HASH_FILE = "bundle-to-hash-mappings.ts"; -export const BASE_CSS_FILE = "base.css"; -export const BASE_GLOBANT_CSS_FILE = "base-globant.css"; - -// Placeholders -export const ICONS_PATH_PLACEHOLDER = "{{ICONS_PATH}}"; -export const FONT_FACE_PATH_PLACEHOLDER = "{{FONT_FACE_PATH}}"; - -// Defaults -export const DEFAULT_FONT_FACE_PATH = "./assets/fonts/"; -export const DEFAULT_ICONS_PATH = "./assets/icons/"; -export const DEFAULT_OUT_DIR_PATH = "./.mercury"; - -export const SEPARATE_BY_COMMA_REGEX = /\s*,\s*/g; -export const SPECIAL_CHARS_IN_BUNDLE_NAME_REGEX = /[/-]/g; - -export const HASH_LENGTH = 16; -export const HASH_AND_LETTER_LENGTH = HASH_LENGTH + 1; diff --git a/packages/mercury/src/cli/internal/create-bundles-with-custom-paths.ts b/packages/mercury/src/cli/internal/create-bundles-with-custom-paths.ts deleted file mode 100644 index df5e8d1cd..000000000 --- a/packages/mercury/src/cli/internal/create-bundles-with-custom-paths.ts +++ /dev/null @@ -1,206 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -import type { - // BundleAssociationMetadata, - CLIArguments, - FileMetadata -} from "./types"; - -import { - BASE_BUNDLE, - BASE_CSS_FILE, - BASE_GLOBANT_BUNDLE, - BASE_GLOBANT_CSS_FILE, - BUNDLE_MAPPING_TO_HASH_FILE, - DEFAULT_FONT_FACE_PATH, - DEFAULT_ICONS_PATH, - HASH_AND_LETTER_LENGTH -} from "./constants.js"; -import { getFilesInDir } from "./file-management.js"; -import { - getBundleFolderAndFileBaseDir, - getBundleName, - getFileNameWithHash, - getFileNameWithoutExt, - getFileSize, - getHash, - sanitizeBundleName -} from "./utils.js"; -import { - printBundleWasCreated, - printBundleWasCreatedTableHeader -} from "./print-utils.js"; - -export const createBundleWithCustomPath = ( - args: CLIArguments, - options: { bundleFolder: string; fileBaseDirToWrite: string }, - fileMetadata: FileMetadata, - avoidAllHashes: boolean, - actualFilePath: string -): { bundleName: string; bundleNameWithHash: string; fileSize: string } => { - const { avoidHash, fontFacePath, iconsPath } = args; - const { bundleFolder, fileBaseDirToWrite } = options; - - const fileNameWithoutExt = getFileNameWithoutExt(fileMetadata); - const bundleName = getBundleName(fileMetadata); - - // Copy CSS bundle - let css = fs.readFileSync(actualFilePath ?? fileMetadata.filePath, { - encoding: "utf8" - }); - - if (fontFacePath !== DEFAULT_FONT_FACE_PATH) { - css = css.replaceAll(DEFAULT_FONT_FACE_PATH, fontFacePath); - } - if (iconsPath !== DEFAULT_ICONS_PATH) { - css = css.replaceAll(DEFAULT_ICONS_PATH, iconsPath); - } - - const fileNameWithHash = - avoidAllHashes || avoidHash.has(bundleName) - ? fileNameWithoutExt - : getFileNameWithHash(fileNameWithoutExt, getHash(css)); - const fileNameToWriteCss = `${fileNameWithHash}.css`; - - const filePathWithHash = path.join(fileBaseDirToWrite, fileNameToWriteCss); - - // Create the CSS bundle with hash - fs.writeFileSync(filePathWithHash, css); - - return { - bundleName, - bundleNameWithHash: sanitizeBundleName( - `${bundleFolder}/${fileNameWithHash}` - ), - fileSize: getFileSize(css) - }; -}; - -export const createBundlesWithCustomPaths = async ( - args: CLIArguments, - avoidAllHashes: boolean = false -) => { - const hasGlobantVariant = args.globant; - const outDir = path.join(args.outDirPath); - const CREATED_DIRS = new Set(); - - // This is a WA to have __filename and __dirname in ES modules - const __filename = fileURLToPath(import.meta.url); - - // Directory name where the script is located (/dist/cli/) - const __dirname = path.dirname(__filename); - - const cssOutput = path.join(__dirname, "../../bundles/css"); - - /** - * Files to transpile - */ - const allFilesToCopy: FileMetadata[] = await getFilesInDir(cssOutput); - let largestBundleLength = BUNDLE_MAPPING_TO_HASH_FILE.length; - let largestFileSizeLength = 0; - - allFilesToCopy.forEach(fileMetadata => { - const options = getBundleFolderAndFileBaseDir(fileMetadata, outDir); - - largestBundleLength = Math.max( - largestBundleLength, - options.bundleFolder.length + fileMetadata.fileName.length - ); - }); - - if (!avoidAllHashes) { - largestBundleLength += HASH_AND_LETTER_LENGTH; - } - - const copiedFiles: { - bundleName: string; - bundleNameWithHash: string; - fileSize: string; - }[] = []; - - allFilesToCopy.forEach(fileMetadata => { - const bundleName = getBundleName(fileMetadata); - - const options = getBundleFolderAndFileBaseDir(fileMetadata, outDir); - const { fileBaseDirToWrite } = options; - let actualFilePath = fileMetadata.filePath; - - if (!CREATED_DIRS.has(fileBaseDirToWrite)) { - CREATED_DIRS.add(fileBaseDirToWrite); - fs.mkdirSync(path.join(fileBaseDirToWrite), { recursive: true }); - } - - // There is no need to copy the base-globant.css file, since the base.css - // file already contains this file - if (hasGlobantVariant) { - if (bundleName === BASE_GLOBANT_BUNDLE) { - return; - } - - // Replace the content of the base bundle with the Globant variant - if (bundleName === BASE_BUNDLE) { - actualFilePath = actualFilePath.replace( - BASE_CSS_FILE, - BASE_GLOBANT_CSS_FILE - ); - } - } - - copiedFiles.push( - createBundleWithCustomPath( - args, - options, - fileMetadata, - avoidAllHashes, - actualFilePath - ) - ); - }); - - copiedFiles.sort((a, b) => (a.bundleName <= b.bundleName ? -1 : 0)); - - const bundleMappingFileContent = `export const bundleToHashMappings = {\n${copiedFiles.map(entry => ` "${entry.bundleName}": "${entry.bundleNameWithHash}"`).join(",\n")}\n} as const;\n`; - const bundleMappingFileSize = getFileSize(bundleMappingFileContent); - const bundleMappingFilePath = path.join(outDir, BUNDLE_MAPPING_TO_HASH_FILE); - - // Compute the largest fileName length - largestFileSizeLength = copiedFiles.reduce( - (acc, copiedFile) => Math.max(acc, copiedFile.fileSize.length), - bundleMappingFileSize.length - ); - - printBundleWasCreatedTableHeader({ - largestBundleLength: avoidAllHashes - ? largestBundleLength - : largestBundleLength + 1, - outDir - }); - - // Print bundle created message - for (let index = 0; index < copiedFiles.length; index++) { - const entry = copiedFiles[index]; - - printBundleWasCreated({ - bundleNameWithHash: `${entry.bundleNameWithHash}.css`, - fileSize: entry.fileSize, - largestBundleLength, - largestFileSizeLength, - outDir - }); - } - - fs.writeFileSync(bundleMappingFilePath, bundleMappingFileContent); - - // Separate the TS file - console.log(""); - - printBundleWasCreated({ - bundleNameWithHash: BUNDLE_MAPPING_TO_HASH_FILE, - fileSize: getFileSize(bundleMappingFileContent), - largestBundleLength, - largestFileSizeLength, - outDir - }); -}; diff --git a/packages/mercury/src/cli/internal/file-management.ts b/packages/mercury/src/cli/internal/file-management.ts deleted file mode 100644 index 11b4ddef6..000000000 --- a/packages/mercury/src/cli/internal/file-management.ts +++ /dev/null @@ -1,82 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { readdir, rm } from "node:fs/promises"; - -import type { FileMetadata } from "./types"; - -export const ensureDirectoryExistsAndItsClear = async ( - dirPath: string, - filesToNotRemove?: Set -) => { - // First build - if (!fs.existsSync(dirPath)) { - fs.mkdirSync(dirPath, { recursive: true }); - return; - } - - // Incremental rebuild keeps some file without any changes, so we must skip - // the file deletion - if (filesToNotRemove) { - const filesInDir = await getFilesInDir(dirPath); - - await Promise.all( - filesInDir.map(file => - filesToNotRemove.has(file.filePath) - ? Promise.resolve() - : rm(file.filePath) - ) - ); - } - // Incremental rebuild did not include any file to exclude its removal - else { - fs.rmSync(dirPath, { recursive: true }); - fs.mkdirSync(dirPath, { recursive: true }); - } -}; - -export const getFilesInDir = async (dir: string): Promise => { - const filesAndDirs = await readdir(dir, { - withFileTypes: true, - recursive: true - }); - - const files: FileMetadata[] = []; - - for (let index = 0; index < filesAndDirs.length; index++) { - const file = filesAndDirs[index]; - - if (file.isFile()) { - files.push({ - dir: file.parentPath, - fileName: file.name, - filePath: path.join(file.parentPath, file.name) - }); - } - } - - return files; -}; - -export const copyDirectories = (srcDir: string, outDir: string) => - fs.cpSync(srcDir, outDir, { recursive: true }); - -export const refreshAngularBrowser = () => { - const ANGULAR_FILE_TO_TRIGGER_RELOAD = path.join( - process.cwd(), - "../showcase/src/index.html" - ); - - const FILE_CONTENT = fs.readFileSync(ANGULAR_FILE_TO_TRIGGER_RELOAD, { - encoding: "utf8" - }); - - // Trigger a dummy write in the index.html - fs.writeFileSync(ANGULAR_FILE_TO_TRIGGER_RELOAD, FILE_CONTENT + "\n"); - - // Rollback that write to not provoke any changes, but do it after the - // debounce of the Angular CLI has completed the queued HMR - setTimeout( - () => fs.writeFileSync(ANGULAR_FILE_TO_TRIGGER_RELOAD, FILE_CONTENT), - 300 - ); -}; diff --git a/packages/mercury/src/cli/internal/print-utils.ts b/packages/mercury/src/cli/internal/print-utils.ts deleted file mode 100644 index 589811b6c..000000000 --- a/packages/mercury/src/cli/internal/print-utils.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { styleText } from "node:util"; - -import { - DEFAULT_FONT_FACE_PATH, - DEFAULT_ICONS_PATH, - DEFAULT_OUT_DIR_PATH, - SCSS_BUNDLES_INPUT_DIR, - SCSS_BUNDLES_OUT_DIR -} from "./constants.js"; -import path from "node:path"; - -const separator = () => styleText(["gray"], " | "); - -export const printBundleWasCreatedTableHeader = (args: { - outDir: string; - largestBundleLength: number; -}) => - console.log( - styleText(["white", "bold"], "Files") + - " ".repeat( - args.largestBundleLength + args.outDir.length - "Files".length - ) + - separator() + - styleText(["white", "bold"], "Raw size") - ); - -export const printBundleWasCreated = (args: { - outDir: string; - bundleNameWithHash: string; - largestBundleLength: number; - largestFileSizeLength: number; - fileSize: string; -}) => - console.log( - styleText("white", args.outDir) + - styleText( - "greenBright", - path.join(args.outDir, args.bundleNameWithHash).replace(args.outDir, "") - ) + - " ".repeat( - Math.max(args.largestBundleLength - args.bundleNameWithHash.length, 0) - ) + - separator() + - " ".repeat( - Math.max(args.largestFileSizeLength - args.fileSize.length, 0) - ) + - styleText("cyanBright", args.fileSize) - ); - -export const printBundleWasTranspiled = (filePath: string) => - console.log( - styleText("greenBright", " Transpiled: ") + - styleText("white", SCSS_BUNDLES_OUT_DIR) + - styleText("whiteBright", filePath.replace(SCSS_BUNDLES_INPUT_DIR, "")) - ); - -export const printArgumentDoesNotExistsError = (arg: string) => - console.log( - styleText("red", " error ") + - styleText("gray", "Argument does not exists: ") + - `'${arg}'` - ); - -export const printDuplicatedArgumentError = (arg: string) => - console.log( - styleText("red", " error ") + - styleText("gray", "Duplicated argument type: ") + - `'${arg}'` - ); - -export const printInvalidArgumentError = (arg: string) => - console.log( - styleText("red", " error ") + - styleText("gray", "Invalid argument: ") + - `'${arg}'` - ); - -export const printMissingFontPathArgumentWarning = () => - console.log( - styleText( - "yellow", - " [warning]: Missing --font-face-path argument. The path " - ) + - styleText("cyan", `'${DEFAULT_FONT_FACE_PATH}'`) + - styleText("yellow", " will be used as default.") - ); - -export const printMissingIconsPathArgumentWarning = () => - console.log( - styleText( - "yellow", - " [warning]: Missing --icons-path argument. The path " - ) + - styleText("cyan", `'${DEFAULT_ICONS_PATH}'`) + - styleText("yellow", " will be used as default.") - ); - -export const printMissingOutDirPathArgumentWarning = () => - console.log( - styleText("yellow", " [warning]: Missing --outDir argument. The path ") + - styleText("cyan", `'${DEFAULT_OUT_DIR_PATH}'`) + - styleText("yellow", " will be used as default.") - ); diff --git a/packages/mercury/src/cli/internal/transpile-bundle-and-create-mappings.ts b/packages/mercury/src/cli/internal/transpile-bundle-and-create-mappings.ts deleted file mode 100644 index ae2054bb0..000000000 --- a/packages/mercury/src/cli/internal/transpile-bundle-and-create-mappings.ts +++ /dev/null @@ -1,110 +0,0 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import * as sass from "sass"; - -import type { BundleMetadata, CLIArguments, FileMetadata } from "./types"; -import { - BASE_BUNDLE, - BASE_GLOBANT_BUNDLE, - BASE_GLOBANT_CSS_FILE, - CSS_BUNDLES_OUT_DIR, - DEFAULT_FONT_FACE_PATH, - DEFAULT_ICONS_PATH, - SCSS_BUNDLES_INPUT_DIR, - SCSS_BUNDLES_OUT_DIR -} from "./constants.js"; -import { replacePlaceholdersInBundle, sanitizeBundleName } from "./utils.js"; - -const transpileBundle = (filePath: string, globant: boolean) => - sass.compile(filePath, { - loadPaths: [globant ? "src/config/globant" : "src/config/default"], - style: "compressed" - }).css; - -const BUNDLES: BundleMetadata[] = []; - -const transpileGlobantCssFile = async ( - fileMetadata: FileMetadata, - cssOutDir: string -) => { - const { filePath } = fileMetadata; - - BUNDLES.push({ - fileDir: fileMetadata.dir - .replace(SCSS_BUNDLES_OUT_DIR, "") - .replace("\\", "/") - }); - - const transpiledBundle = transpileBundle(filePath, true); - - // Store the CSS file with its default values - await fs.writeFile( - path.join(cssOutDir, BASE_GLOBANT_CSS_FILE), - replacePlaceholdersInBundle( - transpiledBundle, - DEFAULT_FONT_FACE_PATH, - DEFAULT_ICONS_PATH - ) - ); -}; - -export const transpileCssBundleWithPlaceholder = async ( - fileMetadata: FileMetadata, - args?: CLIArguments -) => { - const { fileName } = fileMetadata; - let actualFilePath = fileMetadata.filePath; - const hasGlobantVariant = args?.globant ?? false; - - const cssOutDir = fileMetadata.dir.replace( - SCSS_BUNDLES_INPUT_DIR, - CSS_BUNDLES_OUT_DIR - ); - const fileNameCssExt = fileName.replace(".scss", ".css"); - const bundleName = sanitizeBundleName( - actualFilePath.replace(SCSS_BUNDLES_INPUT_DIR, "").replace(".scss", "") - ); - - // There is no need to generate the base-globant.css file, since the base.css - // file already contains this file - if (hasGlobantVariant) { - if (bundleName === BASE_GLOBANT_BUNDLE) { - return; - } - - // Replace the content of the base bundle with the Globant variant - if (bundleName === BASE_BUNDLE) { - actualFilePath = actualFilePath.replace(BASE_BUNDLE, BASE_GLOBANT_BUNDLE); - } - } - // The Globant variant is not forced, so we are creating both bundles, the - // base/base and base/base-globant bundles - else if (bundleName === BASE_BUNDLE) { - transpileGlobantCssFile( - { - dir: fileMetadata.dir, - fileName: fileName.replace(BASE_BUNDLE, BASE_GLOBANT_BUNDLE), - filePath: actualFilePath - }, - cssOutDir - ); - } - - BUNDLES.push({ - fileDir: fileMetadata.dir - .replace(SCSS_BUNDLES_INPUT_DIR, "") - .replace("\\", "/") - }); - - const transpiledBundle = transpileBundle(actualFilePath, hasGlobantVariant); - - // Store the CSS file with its default values - await fs.writeFile( - path.join(cssOutDir, fileNameCssExt), - replacePlaceholdersInBundle( - transpiledBundle, - DEFAULT_FONT_FACE_PATH, - DEFAULT_ICONS_PATH - ) - ); -}; diff --git a/packages/mercury/src/cli/internal/utils.ts b/packages/mercury/src/cli/internal/utils.ts deleted file mode 100644 index d9ae5384f..000000000 --- a/packages/mercury/src/cli/internal/utils.ts +++ /dev/null @@ -1,90 +0,0 @@ -import crypto from "node:crypto"; -import { styleText } from "node:util"; - -import type { FileMetadata } from "./types"; -import { - CSS_BUNDLES_OUT_DIR, - FONT_FACE_PATH_PLACEHOLDER, - HASH_LENGTH, - ICONS_PATH_PLACEHOLDER, - KB - // SPECIAL_CHARS_IN_BUNDLE_NAME_REGEX -} from "./constants.js"; -import path from "node:path"; - -export const getFileSize = (fileContent: string) => { - const fileLength = fileContent.length; - if (fileLength < KB) { - return fileLength + " bytes"; - } - - const fileLengthInKB = fileLength / KB; - if (fileLengthInKB < KB) { - return fileLengthInKB.toFixed(2) + " kB"; - } - - const fileLengthInMB = fileLengthInKB / KB; - if (fileLengthInMB < KB) { - return fileLengthInMB.toFixed(2) + " MB"; - } - - const fileLengthInGB = fileLengthInMB / KB; - return fileLengthInGB.toFixed(2) + " GB"; -}; - -export const getHash = (fileContent: string) => - crypto - .createHash("md5") - .update(fileContent) - .digest("hex") - .substring(HASH_LENGTH); - -export const getFileNameWithHash = ( - bundleName: B, - hash: H -) => `${bundleName}-${hash}` as const; - -export const replacePlaceholdersInBundle = ( - transpiledBundle: string, - fontFaceValue: string, - iconsValue: string -) => - transpiledBundle - .replaceAll(ICONS_PATH_PLACEHOLDER, iconsValue) - .replaceAll(FONT_FACE_PATH_PLACEHOLDER, fontFaceValue); - -export const measureTime = async ( - callback: (() => void) | (() => Promise) -) => { - console.time(styleText("green", "Done in")); - - await callback(); - - console.timeEnd(styleText("green", "Done in")); -}; - -export const sanitizeBundleName = (bundleName: string) => - bundleName.replaceAll("\\", "/").replace(/^\//, ""); - -export const getBundleFolder = (fileMetadata: FileMetadata) => - fileMetadata.dir.split(CSS_BUNDLES_OUT_DIR)[1]; - -export const getBundleFolderAndFileBaseDir = ( - fileMetadata: FileMetadata, - outDir: string -) => { - const bundleFolder = getBundleFolder(fileMetadata); - - return { - bundleFolder, - fileBaseDirToWrite: path.join(outDir, bundleFolder) - }; -}; - -export const getFileNameWithoutExt = (fileMetadata: FileMetadata) => - fileMetadata.fileName.replace(/.css$/, ""); - -export const getBundleName = (fileMetadata: FileMetadata) => - sanitizeBundleName( - `${getBundleFolder(fileMetadata)}/${getFileNameWithoutExt(fileMetadata)}` - ); diff --git a/packages/mercury/src/cli/internal/watch-fs.ts b/packages/mercury/src/cli/internal/watch-fs.ts deleted file mode 100644 index 9a9fff3c6..000000000 --- a/packages/mercury/src/cli/internal/watch-fs.ts +++ /dev/null @@ -1,140 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { styleText } from "node:util"; -import readline from "readline"; - -/** - * This debounce value is useful to batch multiples updates attempt into a - * single compilation. In some cases, multiples files can be changed at one, so - * if we don't debounce the next compilation, we would trigger to compilations. - * - * This case can also happen when performing ESLint on auto-save. - */ -const DEBOUNCE = 100; // 100ms - -const progressDictionary = { - 0: "-", - 1: "\\", - 2: "|", - 3: "/" -} as const; - -let lastRebuildInterval: NodeJS.Timeout; -let updateCounter = 0; - -const { isTTY } = process.stdout; - -const readLineInterface = readline.createInterface({ - input: process.stdin, - output: process.stdout -}); - -export const printRebuilding = (firstBuild: boolean) => { - // process.stdout doesn't work on the CI - if (!isTTY) { - return; - } - - // Hide the cursor to improve the CLI design - readLineInterface.write("\u001B[?25l"); - - // First write - process.stdout.write( - `${styleText("yellow", progressDictionary[(updateCounter % 4) as 0 | 1 | 2 | 3])} ${firstBuild ? "Building bundles..." : "Changes detected. Rebuilding..."}` - ); - - // Update the left symbol each 100ms - lastRebuildInterval = setInterval(() => { - process.stdout.cursorTo(1); - process.stdout.clearLine(-1); - process.stdout.cursorTo(0); - - process.stdout.write( - `${styleText("yellow", progressDictionary[(updateCounter % 4) as 0 | 1 | 2 | 3])}` - ); - - updateCounter++; - }, 100); -}; - -export const stopRebuildingStdout = () => { - // process.stdout doesn't work on the CI - if (!isTTY) { - return; - } - - clearInterval(lastRebuildInterval); - process.stdout.clearLine(0); - console.log(""); - updateCounter = 0; - - // Show again the cursor - readLineInterface.write("\u001B[?25h"); -}; - -export const watchFileSystemChanges = ( - callbackToCompile: () => Promise -) => { - // This is a WA to have __filename and __dirname in ES modules - const __filename = fileURLToPath(import.meta.url); - - const SRC_PATH = path.join(__filename, "../../../../src"); - const BASE_PATH = path.join(SRC_PATH, "base"); - const BUNDLES_PATH = path.join(SRC_PATH, "bundles"); - const COMPONENTS_PATH = path.join(SRC_PATH, "components"); - const MERCURY_SCSS_PATH = path.join(SRC_PATH, "mercury.scss"); - - let compileStatus: - | "idle" - | "debouncing-compiling" - | "compiling" - | "queued-after-compile" = "idle"; - - const performCompilationIfNecessary = () => { - // The current compilation is waiting to be started, so there is no need to - // queue another one after the current has finished - if (compileStatus === "debouncing-compiling") { - return; - } - - // There is no compilation in process. Queue one. - if (compileStatus === "idle") { - compileStatus = "debouncing-compiling"; - } - // There is a compilation in progress. We can set a flag to queue a - // compilation after the current one has finished - else { - if (compileStatus === "compiling") { - compileStatus = "queued-after-compile"; - } - return; - } - - setTimeout(() => { - compileStatus = "compiling"; - - console.time(styleText("green", "Rebuild done in")); - - callbackToCompile().then(() => { - console.timeEnd(styleText("green", "Rebuild done in")); - - const hasToRecompile = compileStatus === "queued-after-compile"; - compileStatus = "idle"; - - if (hasToRecompile) { - performCompilationIfNecessary(); - } - }); - }, DEBOUNCE); - }; - - fs.watch(BASE_PATH, { recursive: true }, performCompilationIfNecessary); - fs.watch(BUNDLES_PATH, { recursive: true }, performCompilationIfNecessary); - fs.watch(COMPONENTS_PATH, { recursive: true }, performCompilationIfNecessary); - fs.watch( - MERCURY_SCSS_PATH, - { recursive: true }, - performCompilationIfNecessary - ); -}; diff --git a/packages/mercury/src/cli/mercury.ts b/packages/mercury/src/cli/mercury.ts deleted file mode 100644 index 134fef4cb..000000000 --- a/packages/mercury/src/cli/mercury.ts +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env node - -import { createBundlesWithCustomPaths } from "./internal/create-bundles-with-custom-paths.js"; -import { ensureDirectoryExistsAndItsClear } from "./internal/file-management.js"; -import { getArguments } from "./internal/validate-args.js"; -import { measureTime } from "./internal/utils.js"; - -measureTime(async () => { - // Improve process visualization - console.log(""); - - const args = getArguments(); - - if (args) { - ensureDirectoryExistsAndItsClear(args.outDirPath); - - await createBundlesWithCustomPaths(args); - } -}); diff --git a/packages/mercury/src/tests/bundles/bundle-content.e2e.ts b/packages/mercury/src/tests/bundles/bundle-content.e2e.ts index bb138690d..8c5bd3737 100644 --- a/packages/mercury/src/tests/bundles/bundle-content.e2e.ts +++ b/packages/mercury/src/tests/bundles/bundle-content.e2e.ts @@ -1,6 +1,6 @@ import { commands } from "@vitest/browser/context"; import { describe, expect, test } from "vitest"; -import { type AllBundles, OUTPUT_BUNDLES } from "./utils"; +import { type AllBundles, OUTPUT_BUNDLES } from "./constants.js"; const PATH_TO_OUTPUT_BUNDLES = "./dist/bundles/css/"; const PATH_TO_EXPECTED_CSS_RULES = @@ -26,7 +26,7 @@ const getCssRulesFromContent = async (css: string) => const removeIndex = (array: T[], index: number): T => array.splice(index, 1)[0]; -describe("dist/bundles/css", () => { +describe("[dist/bundles/css]", () => { const testCssRulesInCssBundles = (bundleName: AllBundles) => test(`"${bundleName}" should correctly implement the CSS rules for its bundle definition`, async () => { const cssBundleContent = await commands.readFile( @@ -103,7 +103,8 @@ describe("dist/bundles/css", () => { await writeNewBundlesCssRulesFile(); // Make the test fail - throw new Error(error); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + throw new Error(error as any); } }); diff --git a/packages/mercury/src/tests/bundles/bundle-size.spec.ts b/packages/mercury/src/tests/bundles/bundle-size.spec.ts new file mode 100644 index 000000000..bc9058e4c --- /dev/null +++ b/packages/mercury/src/tests/bundles/bundle-size.spec.ts @@ -0,0 +1,51 @@ +import { + getGzipSize, + MERCURY_DIST_RELATIVE_FOLDERS +} from "@genexus/mercury-build"; +import { readFile } from "fs/promises"; +import { join } from "path"; +import { describe, expect, test } from "vitest"; + +import { + type AllBundles, + EXPECTED_BUNDLE_SIZE_GZIPPED, + EXPECTED_BUNDLE_SIZE_RAW, + OUTPUT_BUNDLES +} from "./constants.js"; + +const getBundlePath = (bundleName: T) => + `${MERCURY_DIST_RELATIVE_FOLDERS.DIST_BUNDLES_CSS}${bundleName}` as const; + +describe("[dist/bundles/css]", () => { + describe("[bundle-size-raw]", () => { + const testCssRulesInCssBundles = (bundleName: AllBundles) => + test(`the raw size of "${bundleName}" file should be ${EXPECTED_BUNDLE_SIZE_RAW[bundleName]} bytes`, async () => { + const cssBundleContent = await readFile( + join(process.cwd(), getBundlePath(bundleName)), + { encoding: "utf-8" } + ); + + expect(cssBundleContent.length).toBe( + EXPECTED_BUNDLE_SIZE_RAW[bundleName] + ); + }); + + OUTPUT_BUNDLES.forEach(testCssRulesInCssBundles); + }); + + describe("[bundle-size-gzipped]", () => { + const testCssRulesInCssBundles = (bundleName: AllBundles) => + test(`the gzipped size of "${bundleName}" file should be ${EXPECTED_BUNDLE_SIZE_GZIPPED[bundleName]} bytes`, async () => { + const cssBundleContent = await readFile( + join(process.cwd(), getBundlePath(bundleName)), + { encoding: "utf-8" } + ); + + expect(getGzipSize(cssBundleContent)).toBe( + EXPECTED_BUNDLE_SIZE_GZIPPED[bundleName] + ); + }); + + OUTPUT_BUNDLES.forEach(testCssRulesInCssBundles); + }); +}); diff --git a/packages/mercury/src/tests/bundles/constants.ts b/packages/mercury/src/tests/bundles/constants.ts new file mode 100644 index 000000000..3183a50fe --- /dev/null +++ b/packages/mercury/src/tests/bundles/constants.ts @@ -0,0 +1,133 @@ +type ElementType> = + T extends ReadonlyArray ? ElementType : never; + +export type AllBundles = ElementType; + +export const OUTPUT_BUNDLES = [ + "all.css", + "base/base-globant.css", + "base/base.css", + "base/icons.css", + "chameleon/scrollbar.css", + "components/accordion.css", + "components/button.css", + "components/chat.css", + "components/checkbox.css", + "components/code.css", + "components/combo-box.css", + "components/dialog.css", + "components/dropdown.css", + "components/edit.css", + "components/flexible-layout.css", + "components/icon.css", + "components/layout-splitter.css", + "components/list-box.css", + "components/markdown-viewer.css", + "components/navigation-list.css", + "components/paginator.css", + "components/pills.css", + "components/radio-group.css", + "components/segmented-control.css", + "components/sidebar.css", + "components/slider.css", + "components/switch.css", + "components/tab.css", + "components/tabular-grid.css", + "components/ticket-list.css", + "components/tooltip.css", + "components/tree-view.css", + "components/widget.css", + "resets/box-sizing.css", + "utils/elevation.css", + "utils/form--full.css", + "utils/form.css", + "utils/layout.css", + "utils/spacing.css", + "utils/typography.css" +] as const; + +export const EXPECTED_BUNDLE_SIZE_RAW = { + "all.css": 1878603, + "base/base-globant.css": 39224, + "base/base.css": 39495, + "base/icons.css": 1737280, + "chameleon/scrollbar.css": 1008, + "components/accordion.css": 4004, + "components/button.css": 5331, + "components/chat.css": 18504, + "components/checkbox.css": 1457, + "components/code.css": 2349, + "components/combo-box.css": 4404, + "components/dialog.css": 4868, + "components/dropdown.css": 6073, + "components/edit.css": 1468, + "components/flexible-layout.css": 7543, + "components/icon.css": 765, + "components/layout-splitter.css": 143, + "components/list-box.css": 4578, + "components/markdown-viewer.css": 8025, + "components/navigation-list.css": 1968, + "components/paginator.css": 8785, + "components/pills.css": 5548, + "components/radio-group.css": 1487, + "components/segmented-control.css": 2585, + "components/sidebar.css": 2112, + "components/slider.css": 1594, + "components/switch.css": 2025, + "components/tab.css": 6056, + "components/tabular-grid.css": 14776, + "components/ticket-list.css": 2108, + "components/tooltip.css": 904, + "components/tree-view.css": 4915, + "components/widget.css": 298, + "resets/box-sizing.css": 114, + "utils/elevation.css": 905, + "utils/form--full.css": 16063, + "utils/form.css": 1186, + "utils/layout.css": 1532, + "utils/spacing.css": 950, + "utils/typography.css": 3488 +} satisfies { [key in (typeof OUTPUT_BUNDLES)[number]]: number }; + +export const EXPECTED_BUNDLE_SIZE_GZIPPED = { + "all.css": 127308, + "base/base-globant.css": 5270, + "base/base.css": 5260, + "base/icons.css": 107523, + "chameleon/scrollbar.css": 573, + "components/accordion.css": 1089, + "components/button.css": 1231, + "components/chat.css": 3329, + "components/checkbox.css": 748, + "components/code.css": 867, + "components/combo-box.css": 1196, + "components/dialog.css": 1462, + "components/dropdown.css": 1401, + "components/edit.css": 755, + "components/flexible-layout.css": 1316, + "components/icon.css": 588, + "components/layout-splitter.css": 439, + "components/list-box.css": 1088, + "components/markdown-viewer.css": 2062, + "components/navigation-list.css": 893, + "components/paginator.css": 1871, + "components/pills.css": 1548, + "components/radio-group.css": 788, + "components/segmented-control.css": 938, + "components/sidebar.css": 909, + "components/slider.css": 722, + "components/switch.css": 842, + "components/tab.css": 1160, + "components/tabular-grid.css": 2705, + "components/ticket-list.css": 953, + "components/tooltip.css": 619, + "components/tree-view.css": 1377, + "components/widget.css": 459, + "resets/box-sizing.css": 419, + "utils/elevation.css": 527, + "utils/form--full.css": 2811, + "utils/form.css": 698, + "utils/layout.css": 729, + "utils/spacing.css": 534, + "utils/typography.css": 848 +} satisfies { [key in (typeof OUTPUT_BUNDLES)[number]]: number }; diff --git a/packages/mercury/src/tests/bundles/created-bundles.spec.ts b/packages/mercury/src/tests/bundles/created-bundles.spec.ts index 5486e6031..f43b88087 100644 --- a/packages/mercury/src/tests/bundles/created-bundles.spec.ts +++ b/packages/mercury/src/tests/bundles/created-bundles.spec.ts @@ -1,17 +1,23 @@ -import fs from "fs"; -import { readdir } from "node:fs/promises"; -import path from "path"; +import { + MERCURY_DIST_RELATIVE_FOLDERS, + sanitizeBundleName +} from "@genexus/mercury-build"; +import { existsSync } from "fs"; +import { readdir } from "fs/promises"; +import { join } from "path"; import { describe, expect, test } from "vitest"; -import { sanitizeBundleName } from "../../cli/internal/utils.ts"; -import { OUTPUT_BUNDLES } from "./utils.ts"; +import { OUTPUT_BUNDLES } from "./constants.js"; const BASE_DIR = process.cwd(); -const OUTPUT_PATH = path.join(BASE_DIR, "dist/bundles/css"); +const OUTPUT_PATH = join( + BASE_DIR, + MERCURY_DIST_RELATIVE_FOLDERS.DIST_BUNDLES_CSS +); -describe("dist/bundles/css", () => { +describe(`[${MERCURY_DIST_RELATIVE_FOLDERS.DIST_BUNDLES_CSS}]`, () => { test("The dir should exists", () => { - expect(fs.existsSync(OUTPUT_PATH)).toBe(true); + expect(existsSync(OUTPUT_PATH)).toBe(true); }); test("The dir should only contains the CSS bundles", async () => { @@ -29,9 +35,6 @@ describe("dist/bundles/css", () => { ) .sort((a, b) => (a <= b ? -1 : 0)); - console.log("filesInOutputDir", filesInOutputDir); - console.log("OUTPUT_BUNDLES", OUTPUT_BUNDLES); - expect(filesInOutputDir).toEqual(OUTPUT_BUNDLES); }); }); diff --git a/packages/mercury/src/tests/bundles/utils.ts b/packages/mercury/src/tests/bundles/utils.ts deleted file mode 100644 index 8ffc02ee2..000000000 --- a/packages/mercury/src/tests/bundles/utils.ts +++ /dev/null @@ -1,47 +0,0 @@ -type ElementType> = - T extends ReadonlyArray ? ElementType : never; - -export type AllBundles = ElementType; - -export const OUTPUT_BUNDLES = [ - "all.css", - "base/base-globant.css", - "base/base.css", - "base/icons.css", - "chameleon/scrollbar.css", - "components/accordion.css", - "components/button.css", - "components/chat.css", - "components/checkbox.css", - "components/code.css", - "components/combo-box.css", - "components/dialog.css", - "components/dropdown.css", - "components/edit.css", - "components/flexible-layout.css", - "components/icon.css", - "components/layout-splitter.css", - "components/list-box.css", - "components/markdown-viewer.css", - "components/navigation-list.css", - "components/paginator.css", - "components/pills.css", - "components/radio-group.css", - "components/segmented-control.css", - "components/sidebar.css", - "components/slider.css", - "components/switch.css", - "components/tab.css", - "components/tabular-grid.css", - "components/ticket-list.css", - "components/tooltip.css", - "components/tree-view.css", - "components/widget.css", - "resets/box-sizing.css", - "utils/elevation.css", - "utils/form--full.css", - "utils/form.css", - "utils/layout.css", - "utils/spacing.css", - "utils/typography.css" -] as const; diff --git a/packages/mercury/src/types.ts b/packages/mercury/src/types.ts index 8a3e23f5a..87e9c51bf 100644 --- a/packages/mercury/src/types.ts +++ b/packages/mercury/src/types.ts @@ -119,3 +119,11 @@ export type AssetsColorType = { [key: string]: AssetsIconMetadata }; export interface AssetsIconMetadata { name: string; } + +declare global { + var mercury: + | { + globalBundleMappings: MercuryBundleMapping | undefined; + } + | undefined; +} diff --git a/packages/showcase/src/server.ts b/packages/showcase/src/server.ts index 16f1421fc..34e990744 100644 --- a/packages/showcase/src/server.ts +++ b/packages/showcase/src/server.ts @@ -5,8 +5,8 @@ import { writeResponseToNodeResponse } from "@angular/ssr/node"; import express from "express"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "path"; +import { fileURLToPath } from "url"; // import { renderToString } from "@genexus/chameleon-controls-library/hydrate"; const serverDistFolder = dirname(fileURLToPath(import.meta.url)); diff --git a/packages/unanimo/src/bundles.ts b/packages/unanimo/src/bundles.ts index ca824877d..2b2d7369b 100644 --- a/packages/unanimo/src/bundles.ts +++ b/packages/unanimo/src/bundles.ts @@ -1,5 +1,5 @@ import type { ThemeModel } from "@genexus/chameleon-controls-library"; -import type { ThemeItemModel } from "@genexus/chameleon-controls-library/dist/types/components/theme/theme-types.d.ts"; +import type { ThemeItemModel } from "@genexus/chameleon-controls-library/dist/types/components/theme/theme-types"; import type { UnanimoBundleComponent, UnanimoBundleComponentForm, @@ -8,7 +8,7 @@ import type { UnanimoBundleReset, UnanimoBundleUtil, UnanimoBundleUtilFormFull -} from "./types.ts"; +} from "./types.js"; type BundleNames = | UnanimoBundleComponent diff --git a/packages/vite-mercury-plugin/package.json b/packages/vite-mercury-plugin/package.json new file mode 100644 index 000000000..3fb0eb292 --- /dev/null +++ b/packages/vite-mercury-plugin/package.json @@ -0,0 +1,41 @@ +{ + "name": "@genexus/vite-mercury-plugin", + "version": "0.1.1", + "description": "...", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist/" + ], + "type": "module", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + } + }, + "scripts": { + "build": "tsc", + "lint": "eslint src/**/*.ts --fix", + "test": "vitest", + "test.ci": "bun run test --watch=false --browser.headless" + }, + "license": "Apache-2.0", + "devDependencies": { + "@eslint/js": "*", + "@types/node": "*", + "@typescript-eslint/eslint-plugin": "*", + "@typescript-eslint/parser": "*", + "eslint": "*", + "globals": "*", + "prettier": "*", + "sass": "~1.86.3", + "typescript": "*", + "typescript-eslint": "*", + "vitest": "*" + } +} + diff --git a/packages/vite-mercury-plugin/src/index.ts b/packages/vite-mercury-plugin/src/index.ts new file mode 100644 index 000000000..deeda72e1 --- /dev/null +++ b/packages/vite-mercury-plugin/src/index.ts @@ -0,0 +1,2 @@ +export { mercury } from "./vite.js"; + diff --git a/packages/vite-mercury-plugin/src/vite.ts b/packages/vite-mercury-plugin/src/vite.ts new file mode 100644 index 000000000..6d4b77394 --- /dev/null +++ b/packages/vite-mercury-plugin/src/vite.ts @@ -0,0 +1,94 @@ +import type { PluginOption, ResolvedConfig } from "vite"; + +/** + * A plugin for setting up the Mercury Design System in Vite environment. + * With this plugin there is no need to use the Mercury CLI. + */ +export const mercury = (mercuryOptions?: MercuryOptions): PluginOption => { + let viteConfig: ResolvedConfig; + + /** + * Cache for css builds between the hooks. This avoids re-building the css + * if another hooks already did it. + */ + const metadataReusedBetweenHooks: MercuryPluginHooksMetadata = { + cssBuild: { + wasBuilt: false, + bundleToHashMapping: undefined, + middlewareMetadata: new Map(), + }, + }; + + const actualMercuryOptions: MercuryOptions = mercuryOptions ?? {}; + actualMercuryOptions.cssInline ??= {}; + actualMercuryOptions.cssPreload ??= {}; + + // Default values + if (actualMercuryOptions.cssInline["base/base"] === undefined) { + actualMercuryOptions.cssInline["base/base"] = true; + } + if (actualMercuryOptions.cssInline["resets/box-sizing"] === undefined) { + actualMercuryOptions.cssInline["resets/box-sizing"] = true; + } + if (actualMercuryOptions.cssPreload["base/icons"] === undefined) { + actualMercuryOptions.cssPreload["base/icons"] = { + position: "body-end", + fetchPriority: "low", + }; + } + + return { + name: "genexus-vite-mercury-plugin", + + // Get resolved Vite config + configResolved(resolvedConfig) { + viteConfig = resolvedConfig; + }, + + // Run on dev server startup + async configureServer(server) { + // Create a middleware to proxy the Mercury asset request with the actual + // asset. This way, in dev mode we don't have to copy the asset, but + // use the real asset + await createViteMiddlewareForDevServer( + server, + actualMercuryOptions, + metadataReusedBetweenHooks + ); + }, + + async transformIndexHtml(html) { + const bundleToHashMapping = await getBundleToHashMappingFromCssBuild({ + applicationRootDir: "", + distributionBuild: false, + mercuryOptions: actualMercuryOptions, + metadataReusedBetweenHooks, + }); + + // TODO: Add warning when preloading and inlining duplicated css files + + return getHtmlWithPreloadedCss( + await getHtmlWithInlinedCss( + getHtmlWithGlobalInitialization(html, bundleToHashMapping), + actualMercuryOptions, + metadataReusedBetweenHooks + ), + actualMercuryOptions, + metadataReusedBetweenHooks + ); + }, + + async writeBundle(options) { + // Only perform the CSS build on distribution mode + if (viteConfig.command === "build") { + const outDir = options.dir ?? viteConfig.build.outDir; + await performBuildForDistributionBuild( + outDir, + actualMercuryOptions, + metadataReusedBetweenHooks + ); + } + }, + }; +}; +