From 390a650c7177c02b46b01d9e45e5aa7eb3b7bcf9 Mon Sep 17 00:00:00 2001 From: D N <4661784+retyui@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:57:54 +0200 Subject: [PATCH 1/8] Move rozenite --- {rozenite => _rozenite}/.stats/.dir | 0 {rozenite => _rozenite}/dist/index.html | 0 {rozenite => _rozenite}/dist/react-native.js | 0 {rozenite => _rozenite}/dist/rozenite.json | 0 {rozenite => _rozenite}/dist/style.css | 0 {rozenite => _rozenite}/package.json | 0 _serializer/index.js | 196 +++++++++++++++++++ 7 files changed, 196 insertions(+) rename {rozenite => _rozenite}/.stats/.dir (100%) rename {rozenite => _rozenite}/dist/index.html (100%) rename {rozenite => _rozenite}/dist/react-native.js (100%) rename {rozenite => _rozenite}/dist/rozenite.json (100%) rename {rozenite => _rozenite}/dist/style.css (100%) rename {rozenite => _rozenite}/package.json (100%) create mode 100644 _serializer/index.js diff --git a/rozenite/.stats/.dir b/_rozenite/.stats/.dir similarity index 100% rename from rozenite/.stats/.dir rename to _rozenite/.stats/.dir diff --git a/rozenite/dist/index.html b/_rozenite/dist/index.html similarity index 100% rename from rozenite/dist/index.html rename to _rozenite/dist/index.html diff --git a/rozenite/dist/react-native.js b/_rozenite/dist/react-native.js similarity index 100% rename from rozenite/dist/react-native.js rename to _rozenite/dist/react-native.js diff --git a/rozenite/dist/rozenite.json b/_rozenite/dist/rozenite.json similarity index 100% rename from rozenite/dist/rozenite.json rename to _rozenite/dist/rozenite.json diff --git a/rozenite/dist/style.css b/_rozenite/dist/style.css similarity index 100% rename from rozenite/dist/style.css rename to _rozenite/dist/style.css diff --git a/rozenite/package.json b/_rozenite/package.json similarity index 100% rename from rozenite/package.json rename to _rozenite/package.json diff --git a/_serializer/index.js b/_serializer/index.js new file mode 100644 index 0000000..5d465a5 --- /dev/null +++ b/_serializer/index.js @@ -0,0 +1,196 @@ +const { parse } = require("path"); +const { writeFileSync, existsSync } = require("fs"); +const { resolve } = require("path"); +const { Buffer } = require("buffer"); +const chalk = require("chalk"); + +const NAME = require("../package.json").name; + +function getDefault(module) { + return module.__esModule ? module.default : module; +} + +function getDefaultSerializer() { + const metroPath = parse(require.resolve("metro/package.json")).dir; + const bundleToString = getDefault( + require(`${metroPath}/src/lib/bundleToString.js`), + ); + const baseJSBundle = getDefault( + require(`${metroPath}/src/DeltaBundler/Serializers/baseJSBundle.js`), + ); + + return function defaultSerializer(entryPoint, preModules, graph, options) { + let bundle = baseJSBundle(entryPoint, preModules, graph, options); + + // Sentry support + // https://docs.sentry.io/platforms/react-native/manual-setup/metro/#wrap-your-custom-serializer + if (typeof options?.sentryBundleCallback === "function") { + bundle = options.sentryBundleCallback(bundle); + } + + return bundleToString(bundle).code; + }; +} + +function getStringSizeInBytes(str) { + return Buffer.byteLength(str, "utf8"); +} + +/** + * `/Users/i/app/node_modules/metro/node_modules/@babel/runtime/helpers/createClass.js` -> `@babel/runtime` + * `/Users/i/app/node_modules/react/jsx-runtime.js` -> `react` + */ +function getPackageNameFromPath(path) { + const parts = path.split("node_modules/"); + const lastPart = parts[parts.length - 1]; + if (lastPart.startsWith("@")) { + return lastPart.split("/").slice(0, 2).join("/"); + } + return lastPart.split("/")[0]; +} + +/** + * `/Users/i/app/node_modules/metro/node_modules/@babel/runtime/helpers/createClass.js` -> `/Users/i/app/node_modules/metro/node_modules/@babel/runtime` + * `/Users/i/app/node_modules/react/jsx-runtime.js` -> `/Users/i/app/node_modules/react` + */ +function getPackageAbsolutePath(path, pkgName) { + const parts = path.split("node_modules/"); + parts[parts.length - 1] = pkgName; + return parts.join("node_modules/"); +} + +function toPackages(modules) { + const packages = new Map(); + modules.forEach((module) => { + if (!module.path.includes("node_modules/")) { + return; + } + + const pkgName = getPackageNameFromPath(module.path); + const absolutePkgPath = getPackageAbsolutePath(module.path, pkgName); + + if (!packages.has(absolutePkgPath)) { + packages.set(absolutePkgPath, { + name: pkgName, + absolutePath: absolutePkgPath, + version: require(resolve(absolutePkgPath, "package.json")).version, + }); + } + }); + + return Array.from(packages.values()).sort((a, b) => + a.name.localeCompare(b.name), + ); +} + +function toModuleStruct(m, includeCode) { + const sourceCode = m.getSource().toString("utf8"); + const outputCode = m.output[0].data.code; + return { + path: m.path, + source: { + code: includeCode ? sourceCode : "", + lineCount: sourceCode.split("\n").length, + sizeInBytes: getStringSizeInBytes(sourceCode), + }, + output: { + code: includeCode ? outputCode : "", + lineCount: m.output[0].data.lineCount, + sizeInBytes: getStringSizeInBytes(outputCode), + }, + dependencies: Array.from(m?.dependencies?.values?.() ?? []) + .filter((e) => e.absolutePath) + .map((e) => ({ + absolutePath: e.absolutePath, + name: e.data.name, + })), + }; +} + +function createJsonReport({ + graph, + entryPoint, + includeEnvs, + preModules, + includeCode, + outputJsonPath, + rootFolder, + silent, +}) { + const dependencies = Array.from(graph.dependencies.values()); + + const stats = { + date: Date.now(), + entryPoint, + transformOptions: graph.transformOptions, + envs: includeEnvs.reduce((acc, envName) => { + acc[envName] = process.env[envName]; + return acc; + }, {}), + rootFolder, + packages: toPackages(preModules).concat(toPackages(dependencies)), + modules: preModules + .map((m) => toModuleStruct(m, includeCode)) + .concat(dependencies.map((m) => toModuleStruct(m, includeCode))), + }; + + writeFileSync(outputJsonPath, JSON.stringify(stats)); + + if (!silent) { + console.log( + `${chalk.yellow(`[${NAME}]`)}: Saved stats to ${chalk.green(outputJsonPath)}`, + ); + } +} + +/** + * Creates a custom serializer function for Metro bundler, which generates a JSON report + * and optionally modifies the serialization process. + * + * @param {Object} options - Configuration options for the serializer. + * @param {Function} [options.serializer] - A custom serializer function. If not provided, a default serializer is used. + * @param {string} options.projectRoot - The root directory of the project. Must exist. + * @param {string} [options.outputJsonPath] - The path where the JSON report will be saved. Defaults to "metro-stats.json" in the project root. + * @param {boolean} [options.includeCode=true] - Whether to include the source and output code in the JSON report. + * @param {string[]} [options.includeEnvs=[]] - A list of environment variable names to include in the JSON report. + * @returns {Function} - A custom serializer function to be used by Metro. + * @throws {Error} - Throws an error if the project root does not exist. + */ +function createSerializer({ + serializer, + projectRoot, + outputJsonPath, + includeCode = true, + silent = false, + includeEnvs = [], +} = {}) { + const mySerializer = serializer || getDefaultSerializer(); + + if (!existsSync(projectRoot)) { + throw new Error(`[${NAME}]: Project root does not exist: ${projectRoot}`); + } + + const myOutputJsonPath = + outputJsonPath ?? resolve(projectRoot, "metro-stats.json"); + + function customSerializer(entryPoint, preModules, graph, options) { + const code = mySerializer(entryPoint, preModules, graph, options); + + createJsonReport({ + graph, + entryPoint, + includeEnvs, + preModules, + includeCode, + outputJsonPath: myOutputJsonPath, + rootFolder: projectRoot, + silent, + }); + + return code; + } + + return customSerializer; +} + +module.exports = { createSerializer }; From 806f7404aea2f1d00f85f81ee0df37c56c6ffe12 Mon Sep 17 00:00:00 2001 From: D N <4661784+retyui@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:58:11 +0200 Subject: [PATCH 2/8] Move react-native-bundle-discovery pkg --- .gitignore | 2 + _serializer/index.js | 3 +- _serializer/package.json | 25 +++++ index.js | 2 +- lib/customSerializer.js | 196 --------------------------------------- 5 files changed, 29 insertions(+), 199 deletions(-) create mode 100644 _serializer/package.json delete mode 100644 lib/customSerializer.js diff --git a/.gitignore b/.gitignore index 858a3b8..d810470 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,5 @@ tmp/ .bundle-discovery/ /lib/.tmp.js + +_serializer/README.md diff --git a/_serializer/index.js b/_serializer/index.js index 5d465a5..46b205a 100644 --- a/_serializer/index.js +++ b/_serializer/index.js @@ -1,6 +1,5 @@ -const { parse } = require("path"); +const { parse, resolve } = require("path"); const { writeFileSync, existsSync } = require("fs"); -const { resolve } = require("path"); const { Buffer } = require("buffer"); const chalk = require("chalk"); diff --git a/_serializer/package.json b/_serializer/package.json new file mode 100644 index 0000000..bc90447 --- /dev/null +++ b/_serializer/package.json @@ -0,0 +1,25 @@ +{ + "name": "react-native-bundle-discovery", + "version": "2.0.0", + "main": "index.js", + "repository": "git@github.com:retyui/react-native-bundle-discovery.git", + "author": "David <4661784+retyui@users.noreply.github.com>", + "license": "MIT", + "scripts": { + "prepublishOnly": "cp ../README.md README.md" + }, + "dependencies": { + "chalk": "^4.1.2" + }, + "peerDependencies": { + "metro": "*" + }, + "peerDependenciesMeta": { + "metro": { + "optional": true + } + }, + "files": [ + "index.js" + ] +} diff --git a/index.js b/index.js index 045b31c..7128709 100644 --- a/index.js +++ b/index.js @@ -1 +1 @@ -module.exports = require("./lib/customSerializer"); +module.exports = require("./_serializer/customSerializer"); diff --git a/lib/customSerializer.js b/lib/customSerializer.js deleted file mode 100644 index 5d465a5..0000000 --- a/lib/customSerializer.js +++ /dev/null @@ -1,196 +0,0 @@ -const { parse } = require("path"); -const { writeFileSync, existsSync } = require("fs"); -const { resolve } = require("path"); -const { Buffer } = require("buffer"); -const chalk = require("chalk"); - -const NAME = require("../package.json").name; - -function getDefault(module) { - return module.__esModule ? module.default : module; -} - -function getDefaultSerializer() { - const metroPath = parse(require.resolve("metro/package.json")).dir; - const bundleToString = getDefault( - require(`${metroPath}/src/lib/bundleToString.js`), - ); - const baseJSBundle = getDefault( - require(`${metroPath}/src/DeltaBundler/Serializers/baseJSBundle.js`), - ); - - return function defaultSerializer(entryPoint, preModules, graph, options) { - let bundle = baseJSBundle(entryPoint, preModules, graph, options); - - // Sentry support - // https://docs.sentry.io/platforms/react-native/manual-setup/metro/#wrap-your-custom-serializer - if (typeof options?.sentryBundleCallback === "function") { - bundle = options.sentryBundleCallback(bundle); - } - - return bundleToString(bundle).code; - }; -} - -function getStringSizeInBytes(str) { - return Buffer.byteLength(str, "utf8"); -} - -/** - * `/Users/i/app/node_modules/metro/node_modules/@babel/runtime/helpers/createClass.js` -> `@babel/runtime` - * `/Users/i/app/node_modules/react/jsx-runtime.js` -> `react` - */ -function getPackageNameFromPath(path) { - const parts = path.split("node_modules/"); - const lastPart = parts[parts.length - 1]; - if (lastPart.startsWith("@")) { - return lastPart.split("/").slice(0, 2).join("/"); - } - return lastPart.split("/")[0]; -} - -/** - * `/Users/i/app/node_modules/metro/node_modules/@babel/runtime/helpers/createClass.js` -> `/Users/i/app/node_modules/metro/node_modules/@babel/runtime` - * `/Users/i/app/node_modules/react/jsx-runtime.js` -> `/Users/i/app/node_modules/react` - */ -function getPackageAbsolutePath(path, pkgName) { - const parts = path.split("node_modules/"); - parts[parts.length - 1] = pkgName; - return parts.join("node_modules/"); -} - -function toPackages(modules) { - const packages = new Map(); - modules.forEach((module) => { - if (!module.path.includes("node_modules/")) { - return; - } - - const pkgName = getPackageNameFromPath(module.path); - const absolutePkgPath = getPackageAbsolutePath(module.path, pkgName); - - if (!packages.has(absolutePkgPath)) { - packages.set(absolutePkgPath, { - name: pkgName, - absolutePath: absolutePkgPath, - version: require(resolve(absolutePkgPath, "package.json")).version, - }); - } - }); - - return Array.from(packages.values()).sort((a, b) => - a.name.localeCompare(b.name), - ); -} - -function toModuleStruct(m, includeCode) { - const sourceCode = m.getSource().toString("utf8"); - const outputCode = m.output[0].data.code; - return { - path: m.path, - source: { - code: includeCode ? sourceCode : "", - lineCount: sourceCode.split("\n").length, - sizeInBytes: getStringSizeInBytes(sourceCode), - }, - output: { - code: includeCode ? outputCode : "", - lineCount: m.output[0].data.lineCount, - sizeInBytes: getStringSizeInBytes(outputCode), - }, - dependencies: Array.from(m?.dependencies?.values?.() ?? []) - .filter((e) => e.absolutePath) - .map((e) => ({ - absolutePath: e.absolutePath, - name: e.data.name, - })), - }; -} - -function createJsonReport({ - graph, - entryPoint, - includeEnvs, - preModules, - includeCode, - outputJsonPath, - rootFolder, - silent, -}) { - const dependencies = Array.from(graph.dependencies.values()); - - const stats = { - date: Date.now(), - entryPoint, - transformOptions: graph.transformOptions, - envs: includeEnvs.reduce((acc, envName) => { - acc[envName] = process.env[envName]; - return acc; - }, {}), - rootFolder, - packages: toPackages(preModules).concat(toPackages(dependencies)), - modules: preModules - .map((m) => toModuleStruct(m, includeCode)) - .concat(dependencies.map((m) => toModuleStruct(m, includeCode))), - }; - - writeFileSync(outputJsonPath, JSON.stringify(stats)); - - if (!silent) { - console.log( - `${chalk.yellow(`[${NAME}]`)}: Saved stats to ${chalk.green(outputJsonPath)}`, - ); - } -} - -/** - * Creates a custom serializer function for Metro bundler, which generates a JSON report - * and optionally modifies the serialization process. - * - * @param {Object} options - Configuration options for the serializer. - * @param {Function} [options.serializer] - A custom serializer function. If not provided, a default serializer is used. - * @param {string} options.projectRoot - The root directory of the project. Must exist. - * @param {string} [options.outputJsonPath] - The path where the JSON report will be saved. Defaults to "metro-stats.json" in the project root. - * @param {boolean} [options.includeCode=true] - Whether to include the source and output code in the JSON report. - * @param {string[]} [options.includeEnvs=[]] - A list of environment variable names to include in the JSON report. - * @returns {Function} - A custom serializer function to be used by Metro. - * @throws {Error} - Throws an error if the project root does not exist. - */ -function createSerializer({ - serializer, - projectRoot, - outputJsonPath, - includeCode = true, - silent = false, - includeEnvs = [], -} = {}) { - const mySerializer = serializer || getDefaultSerializer(); - - if (!existsSync(projectRoot)) { - throw new Error(`[${NAME}]: Project root does not exist: ${projectRoot}`); - } - - const myOutputJsonPath = - outputJsonPath ?? resolve(projectRoot, "metro-stats.json"); - - function customSerializer(entryPoint, preModules, graph, options) { - const code = mySerializer(entryPoint, preModules, graph, options); - - createJsonReport({ - graph, - entryPoint, - includeEnvs, - preModules, - includeCode, - outputJsonPath: myOutputJsonPath, - rootFolder: projectRoot, - silent, - }); - - return code; - } - - return customSerializer; -} - -module.exports = { createSerializer }; From f1b3276d4a1ca17ec1ddffc25035e3f4739edf4a Mon Sep 17 00:00:00 2001 From: D N <4661784+retyui@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:39:36 +0200 Subject: [PATCH 3/8] Change structure of packages and CLI --- README.md | 6 +- _cli/bin.js | 66 ++++++++++ _cli/index.js | 56 +++++++++ _cli/package.json | 18 +++ _cli/packages.js | 217 +++++++++++++++++++++++++++++++++ _cli/prepare.js | 26 ++++ _cli/utils.js | 9 ++ _rozenite/dist/react-native.js | 2 +- _rozenite/package.json | 3 +- _serializer/package.json | 2 +- package.json | 4 +- 11 files changed, 401 insertions(+), 8 deletions(-) create mode 100644 _cli/bin.js create mode 100644 _cli/index.js create mode 100644 _cli/package.json create mode 100644 _cli/packages.js create mode 100644 _cli/prepare.js create mode 100644 _cli/utils.js diff --git a/README.md b/README.md index 50e8bd4..27b29a2 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ There are two ways to install the package: #### 1. Install (independent tool) ```bash -yarn add -D react-native-bundle-discovery +yarn add -D react-native-bundle-discovery react-native-bundle-discovery-ui ``` Add to your `metro.config.js`: @@ -117,7 +117,7 @@ npx react-native bundle \ Run webserver to view the report: ```bash -npx react-native-bundle-discovery server metro-stats.json [--port ] +npx react-native-bundle-discovery-ui server metro-stats.json [--port ] ``` ##### 4.2 Build the HTML report @@ -125,7 +125,7 @@ npx react-native-bundle-discovery server metro-stats.json [--port ] Run the following command to generate an HTML report from the JSON file: ```bash -npx react-native-bundle-discovery build metro-stats.json +npx react-native-bundle-discovery-ui build metro-stats.json ``` diff --git a/_cli/bin.js b/_cli/bin.js new file mode 100644 index 0000000..a8ec7bc --- /dev/null +++ b/_cli/bin.js @@ -0,0 +1,66 @@ +#!/usr/bin/env node +const minimist = require("minimist"); + +function printHelp() { + console.log(`react-native-bundle-discovery-cli + +Usage: + react-native-bundle-discovery-cli packages [--sort size|name] [--format json|table|default] + +Commands: + packages Print package list from a Metro bundler stat report + +Options: + -h, --help Show help + --sort Sort by size (default, desc) or name (asc) + --format Output format: json, table, or default (default: default) +`); +} + +function fail(message) { + console.error(message); + console.error("Use --help to see usage."); + process.exit(1); +} + +const argv = minimist(process.argv.slice(2), { + alias: { + h: "help", + }, + boolean: ["help"], + default: { + sort: "size", + format: "default", + }, +}); + +const command = argv._[0]; + +if (argv.help || !command) { + printHelp(); + process.exit(0); +} + +if (command === "packages") { + const file = argv._[1]; + const sort = argv.sort; + const format = argv.format; + if (!file) { + fail("Missing required argument: "); + } + if (sort !== "size" && sort !== "name") { + fail(`Invalid value for --sort: ${sort}. Expected one of: size, name.`); + } + if (format !== "json" && format !== "table" && format !== "default") { + fail(`Invalid value for --format: ${format}. Expected one of: json, table, default.`); + } + + try { + const { printPackagesList } = require("./packages.js"); + return printPackagesList(file, { sort, format }); + } catch (error) { + fail(error.message); + } +} + +fail(`Unknown command: ${command}`); diff --git a/_cli/index.js b/_cli/index.js new file mode 100644 index 0000000..8e56230 --- /dev/null +++ b/_cli/index.js @@ -0,0 +1,56 @@ +/** + * @typedef {Object} TransformOptions + * @property {Record} customTransformOptions - Кастомные опции трансформации + * @property {boolean} dev - Флаг режима разработки + * @property {boolean} minify - Флаг минификации кода + * @property {string} platform - Целевая платформа (например, "ios", "android") + * @property {string} type - Тип модуля + * @property {string} unstable_transformProfile - Профиль трансформации + */ + +/** + * @typedef {Object} PackageInfo + * @property {string} name - Название пакета + * @property {string} absolutePath - Абсолютный путь к пакету + * @property {string} version - Версия пакета + * @property {string} path - Относительный путь к пакету + */ + +/** + * @typedef {Object} SourceInfo + * @property {string} code - Исходный код + * @property {number} lineCount - Количество строк в исходном коде + * @property {number} sizeInBytes - Размер исходного кода в байтах + */ + +/** + * @typedef {Object} OutputInfo + * @property {string} code - Скомпилированный/сминифицированный код + * @property {number} lineCount - Количество строк в итоговом коде + * @property {number} sizeInBytes - Размер итогового кода в байтах + */ + +/** + * @typedef {Object} ModuleInfo + * @property {string} path - Относительный путь к модулю + * @property {SourceInfo} source - Информация об исходном коде + * @property {OutputInfo} output - Информация о собранном коде + * @property {Array<*>} dependencies - Список зависимостей модуля + * @property {string} absolutePath - Абсолютный путь к файлу модуля + * @property {Array<*>} duplicates - Дубликаты модуля + * @property {ModuleInfo[]} dependents - Модули, зависящие от данного (рекурсивный тип) + */ + +/** + * @typedef {Object} BuildReport + * @property {number} date - Timestamp даты сборки + * @property {string} entryPoint - Абсолютный путь к точке входа + * @property {TransformOptions} transformOptions - Параметры трансформации + * @property {Record} envs - Переменные окружения + * @property {string} rootFolder - Абсолютный путь к корневой папке проекта + * @property {PackageInfo[]} packages - Список используемых пакетов + * @property {ModuleInfo[]} modules - Список обработанных модулей + */ + +/** @type {BuildReport} */ +const report = require("./tmp/metro-stats.json"); diff --git a/_cli/package.json b/_cli/package.json new file mode 100644 index 0000000..9668fb5 --- /dev/null +++ b/_cli/package.json @@ -0,0 +1,18 @@ +{ + "name": "react-native-bundle-discovery-cli", + "version": "2.0.0-rc.1", + "main": "index.js", + "bin": "bin.js", + "repository": "git@github.com:retyui/react-native-bundle-discovery.git", + "author": "David <4661784+retyui@users.noreply.github.com>", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.8" + }, + "files": [ + "index.js", + "bin.js", + "packages.js", + "prepare.js" + ] +} diff --git a/_cli/packages.js b/_cli/packages.js new file mode 100644 index 0000000..796359c --- /dev/null +++ b/_cli/packages.js @@ -0,0 +1,217 @@ +const path = require("path"); +const { prepareReport } = require("./prepare.js"); +const { formatBytes } = require("./utils.js"); + +function readBuildReport(filePath) { + try { + // Resolve from current working directory to support relative CLI paths. + const resolvedPath = path.resolve(filePath); + const report = require(resolvedPath); + + return prepareReport(report); + } catch (error) { + throw new Error(`Failed to read report file: ${filePath}\n${error.message}`); + } +} + +function getPackageGroups(report, sort = "size") { + const groupedPackages = report.packages.reduce((map, pkg) => { + const name = pkg?.name ?? ""; + const version = pkg?.version ?? ""; + const packagePath = pkg?.path ?? pkg?.absolutePath ?? ""; + const sizeInBytes = pkg?.sizeInBytes ?? 0; + + if (!map.has(name)) { + map.set(name, []); + } + + map.get(name).push({ version, path: packagePath, sizeInBytes }); + return map; + }, new Map()); + + const packageGroups = Array.from(groupedPackages.entries()).map(([name, entries]) => { + const totalSizeInBytes = entries.reduce((sum, entry) => sum + entry.sizeInBytes, 0); + return { name, entries, totalSizeInBytes }; + }); + + if (sort === "name") { + packageGroups.sort((a, b) => a.name.localeCompare(b.name)); + } else { + // Default behavior: show heavier packages first. + packageGroups.sort((a, b) => b.totalSizeInBytes - a.totalSizeInBytes); + } + + return packageGroups; +} + +function printDefaultFormat(packageGroups, report) { + const duplicateCount = packageGroups.filter(({ entries }) => entries.length > 1).length; + + console.log( + `Found ${report.packages.length} package entries (${packageGroups.length} unique names)`, + ); + console.log(`Duplicate package names: ${duplicateCount}`); + + packageGroups.forEach(({ name, entries }, index) => { + if (entries.length === 1) { + const entry = entries[0]; + console.log( + `${index + 1}. ${name}@${entry.version} (${entry.path}) - ${formatBytes(entry.sizeInBytes)}`, + ); + return; + } + + console.log(`${index + 1}. ${name} [DUPLICATE x${entries.length}]`); + entries.forEach((entry, entryIndex) => { + console.log( + ` - ${entryIndex + 1}) ${entry.version} (${entry.path}) - ${formatBytes(entry.sizeInBytes)}`, + ); + }); + }); +} + +function printTableFormat(packageGroups, report) { + const duplicateCount = packageGroups.filter(({ entries }) => entries.length > 1).length; + + console.log( + `Found ${report.packages.length} package entries (${packageGroups.length} unique names)`, + ); + console.log(`Duplicate package names: ${duplicateCount}`); + console.log(""); + + const rows = []; + packageGroups.forEach(({ name, entries }, index) => { + if (entries.length === 1) { + const entry = entries[0]; + rows.push({ + "#": index + 1, + "Package": `${name}@${entry.version}`, + "Path": entry.path, + "Size": formatBytes(entry.sizeInBytes), + }); + } else { + rows.push({ + "#": index + 1, + "Package": `${name} [DUPLICATE x${entries.length}]`, + "Path": "", + "Size": "", + }); + entries.forEach((entry, entryIndex) => { + rows.push({ + "#": "", + "Package": ` ${entryIndex + 1}) ${entry.version}`, + "Path": entry.path, + "Size": formatBytes(entry.sizeInBytes), + }); + }); + } + }); + + // Print table + if (rows.length > 0) { + const keys = Object.keys(rows[0]); + const colWidths = {}; + keys.forEach(key => { + colWidths[key] = Math.max( + key.length, + ...rows.map(row => String(row[key]).length), + ); + }); + + // Print header + console.log( + keys.map(key => key.padEnd(colWidths[key])).join(" | "), + ); + console.log( + keys.map(key => "-".repeat(colWidths[key])).join("-+-"), + ); + + // Print rows + rows.forEach(row => { + console.log( + keys.map(key => String(row[key]).padEnd(colWidths[key])).join(" | "), + ); + }); + } +} + +function printJsonFormat(packageGroups, report) { + const duplicateCount = packageGroups.filter(({ entries }) => entries.length > 1).length; + + const output = { + summary: { + totalPackageEntries: report.packages.length, + uniquePackageNames: packageGroups.length, + duplicatePackages: duplicateCount, + }, + packages: packageGroups.map(({ name, entries, totalSizeInBytes }, index) => { + if (entries.length === 1) { + const entry = entries[0]; + return { + index: index + 1, + name, + isDuplicate: false, + entries: [ + { + version: entry.version, + path: entry.path, + sizeInBytes: entry.sizeInBytes, + size: formatBytes(entry.sizeInBytes), + }, + ], + totalSizeInBytes, + totalSize: formatBytes(totalSizeInBytes), + }; + } else { + return { + index: index + 1, + name, + isDuplicate: true, + duplicateCount: entries.length, + entries: entries.map((entry, entryIndex) => ({ + index: entryIndex + 1, + version: entry.version, + path: entry.path, + sizeInBytes: entry.sizeInBytes, + size: formatBytes(entry.sizeInBytes), + })), + totalSizeInBytes, + totalSize: formatBytes(totalSizeInBytes), + }; + } + }), + }; + + console.log(JSON.stringify(output, null, 2)); +} + +function printPackagesList(filePath, options = {}) { + const { sort = "size", format = "default" } = options; + const report = readBuildReport(filePath); + + if (report.packages.length === 0) { + if (format === "json") { + console.log(JSON.stringify({ message: "No packages found in report" })); + } else { + console.log("No packages found in report"); + } + return; + } + + const packageGroups = getPackageGroups(report, sort); + + switch (format) { + case "json": + printJsonFormat(packageGroups, report); + break; + case "table": + printTableFormat(packageGroups, report); + break; + default: + printDefaultFormat(packageGroups, report); + } +} + +module.exports = { + printPackagesList, +}; diff --git a/_cli/prepare.js b/_cli/prepare.js new file mode 100644 index 0000000..3bfe351 --- /dev/null +++ b/_cli/prepare.js @@ -0,0 +1,26 @@ +function getSize(report, pkg) { + let size = 0; + report.modules.forEach(module => { + if(module.path.startsWith(pkg.absolutePath + '/')) { + size += module.output?.sizeInBytes ?? 0; + } + }); + return size; +} + +function prepareReport(report) { + report.packages = report.packages.map((pkg) => { + const sizeInBytes = getSize(report, pkg); + return { + ...pkg, + path: pkg.absolutePath.replace(report.rootFolder + '/', ''), + sizeInBytes, + }; + }); + + return report; +} + +module.exports = { + prepareReport, +}; diff --git a/_cli/utils.js b/_cli/utils.js new file mode 100644 index 0000000..325d141 --- /dev/null +++ b/_cli/utils.js @@ -0,0 +1,9 @@ +function formatBytes(bytes, decimals = 2) { + if (bytes === 0) return "0 Bytes"; + const k = 1024, + sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"], + i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + " " + sizes[i]; +} + +module.exports = {formatBytes}; diff --git a/_rozenite/dist/react-native.js b/_rozenite/dist/react-native.js index 3614bf3..cebee61 100644 --- a/_rozenite/dist/react-native.js +++ b/_rozenite/dist/react-native.js @@ -2,7 +2,7 @@ import fs from "fs"; import path from "path"; import { createSerializer } from "react-native-bundle-discovery"; import { createServer } from "@discoveryjs/cli"; -import discoveryrc from "react-native-bundle-discovery/.discoveryrc.js"; +import discoveryrc from "react-native-bundle-discovery-ui/.discoveryrc.js"; const id = Math.floor(Math.random() * 10); const fileName = `rozenite-metro-stats-${id}.json`; // Random name in case if multiple instances of Metro are running on different ports diff --git a/_rozenite/package.json b/_rozenite/package.json index b10c256..eba9ccd 100644 --- a/_rozenite/package.json +++ b/_rozenite/package.json @@ -1,6 +1,6 @@ { "name": "react-native-bundle-discovery-rozenite-plugin", - "version": "1.0.0", + "version": "2.0.0-rc.1", "license": "MIT", "main": "dist/react-native.js", "files": [ @@ -9,6 +9,7 @@ ], "dependencies": { "react-native-bundle-discovery": "*", + "react-native-bundle-discovery-ui": "*", "@discoveryjs/cli": "2.14.2" } } diff --git a/_serializer/package.json b/_serializer/package.json index bc90447..e00c7f3 100644 --- a/_serializer/package.json +++ b/_serializer/package.json @@ -1,6 +1,6 @@ { "name": "react-native-bundle-discovery", - "version": "2.0.0", + "version": "2.0.0-rc.1", "main": "index.js", "repository": "git@github.com:retyui/react-native-bundle-discovery.git", "author": "David <4661784+retyui@users.noreply.github.com>", diff --git a/package.json b/package.json index 5d2b938..f4735b0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "react-native-bundle-discovery", - "version": "1.3.1", + "name": "react-native-bundle-discovery-ui", + "version": "2.0.0-rc.1", "main": "index.js", "bin": "lib/bin.js", "repository": "git@github.com:retyui/react-native-bundle-discovery.git", From 36fb35b7fff1cb0ad89120804117335a058eb9d8 Mon Sep 17 00:00:00 2001 From: D N <4661784+retyui@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:05:29 +0200 Subject: [PATCH 4/8] Add lodash case --- _cli/packages.js | 40 ++++++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/_cli/packages.js b/_cli/packages.js index 796359c..4b01580 100644 --- a/_cli/packages.js +++ b/_cli/packages.js @@ -2,6 +2,22 @@ const path = require("path"); const { prepareReport } = require("./prepare.js"); const { formatBytes } = require("./utils.js"); +const LODASH_FAMILY_GROUP = "lodash (please use only one)"; + +function getDuplicateGroupName(packageName) { + if ( + packageName === "lodash" || + packageName === "lodash-es" || + packageName === "underscore" || + packageName === "ramda" || + packageName.startsWith("lodash.") + ) { + return LODASH_FAMILY_GROUP; + } + + return packageName; +} + function readBuildReport(filePath) { try { // Resolve from current working directory to support relative CLI paths. @@ -17,15 +33,21 @@ function readBuildReport(filePath) { function getPackageGroups(report, sort = "size") { const groupedPackages = report.packages.reduce((map, pkg) => { const name = pkg?.name ?? ""; + const duplicateGroupName = getDuplicateGroupName(name); const version = pkg?.version ?? ""; const packagePath = pkg?.path ?? pkg?.absolutePath ?? ""; const sizeInBytes = pkg?.sizeInBytes ?? 0; - if (!map.has(name)) { - map.set(name, []); + if (!map.has(duplicateGroupName)) { + map.set(duplicateGroupName, []); } - map.get(name).push({ version, path: packagePath, sizeInBytes }); + map.get(duplicateGroupName).push({ + name, + version, + path: packagePath, + sizeInBytes, + }); return map; }, new Map()); @@ -56,7 +78,7 @@ function printDefaultFormat(packageGroups, report) { if (entries.length === 1) { const entry = entries[0]; console.log( - `${index + 1}. ${name}@${entry.version} (${entry.path}) - ${formatBytes(entry.sizeInBytes)}`, + `${index + 1}. ${entry.name}@${entry.version} (${entry.path}) - ${formatBytes(entry.sizeInBytes)}`, ); return; } @@ -64,7 +86,7 @@ function printDefaultFormat(packageGroups, report) { console.log(`${index + 1}. ${name} [DUPLICATE x${entries.length}]`); entries.forEach((entry, entryIndex) => { console.log( - ` - ${entryIndex + 1}) ${entry.version} (${entry.path}) - ${formatBytes(entry.sizeInBytes)}`, + ` - ${entryIndex + 1}) ${entry.name}@${entry.version} (${entry.path}) - ${formatBytes(entry.sizeInBytes)}`, ); }); }); @@ -85,7 +107,7 @@ function printTableFormat(packageGroups, report) { const entry = entries[0]; rows.push({ "#": index + 1, - "Package": `${name}@${entry.version}`, + "Package": `${entry.name}@${entry.version}`, "Path": entry.path, "Size": formatBytes(entry.sizeInBytes), }); @@ -99,7 +121,7 @@ function printTableFormat(packageGroups, report) { entries.forEach((entry, entryIndex) => { rows.push({ "#": "", - "Package": ` ${entryIndex + 1}) ${entry.version}`, + "Package": ` ${entryIndex + 1}) ${entry.name}@${entry.version}`, "Path": entry.path, "Size": formatBytes(entry.sizeInBytes), }); @@ -149,10 +171,11 @@ function printJsonFormat(packageGroups, report) { const entry = entries[0]; return { index: index + 1, - name, + name: entry.name, isDuplicate: false, entries: [ { + name: entry.name, version: entry.version, path: entry.path, sizeInBytes: entry.sizeInBytes, @@ -170,6 +193,7 @@ function printJsonFormat(packageGroups, report) { duplicateCount: entries.length, entries: entries.map((entry, entryIndex) => ({ index: entryIndex + 1, + name: entry.name, version: entry.version, path: entry.path, sizeInBytes: entry.sizeInBytes, From 417c3e14c2b6539934de2c8abb6ffb25b40ecbe0 Mon Sep 17 00:00:00 2001 From: D N <4661784+retyui@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:37:28 +0200 Subject: [PATCH 5/8] Add basic analize cmd --- _cli/analyze.js | 85 +++++++++++++++++++++++++ _cli/bin.js | 22 +++++++ _cli/package.json | 4 +- _cli/recommendations/index.js | 4 ++ _cli/recommendations/linear-gradient.js | 36 +++++++++++ _cli/recommendations/radial-gradient.js | 36 +++++++++++ _cli/utils.js | 38 ++++++++++- 7 files changed, 223 insertions(+), 2 deletions(-) create mode 100644 _cli/analyze.js create mode 100644 _cli/recommendations/index.js create mode 100644 _cli/recommendations/linear-gradient.js create mode 100644 _cli/recommendations/radial-gradient.js diff --git a/_cli/analyze.js b/_cli/analyze.js new file mode 100644 index 0000000..c62a143 --- /dev/null +++ b/_cli/analyze.js @@ -0,0 +1,85 @@ +const path = require("path"); +const { prepareReport } = require("./prepare.js"); +const recommendations = require("./recommendations/index.js"); + +function readBuildReport(filePath) { + try { + // Resolve from current working directory to support relative CLI paths. + const resolvedPath = path.resolve(filePath); + const report = require(resolvedPath); + return prepareReport(report); + } catch (error) { + throw new Error(`Failed to read report file: ${filePath}\n${error.message}`); + } +} + +function collectRecommendations(report) { + return recommendations + .map((recommendation) => { + const finding = recommendation.check(report); + if (!finding) { + return null; + } + + return { + id: recommendation.id, + title: recommendation.title, + ...finding, + }; + }) + .filter(Boolean); +} + +function printDefaultFormat(filePath, findings) { + if (findings.length === 0) { + console.log(`No optimization recommendations found for ${filePath}.`); + return; + } + + console.log(`Found ${findings.length} optimization recommendation(s):`); + + findings.forEach((finding, index) => { + console.log(`${index + 1}. ${finding.title}`); + if (finding.message) { + console.log(` Why: ${finding.message}`); + } + if (finding.packages && finding.packages.length > 0) { + console.log(` Packages: ${finding.packages.join(", ")}`); + } + if (finding.docsUrl) { + console.log(` Docs: ${finding.docsUrl}`); + } + }); +} + +function printJsonFormat(filePath, findings) { + console.log( + JSON.stringify( + { + file: filePath, + recommendations: findings, + }, + null, + 2, + ), + ); +} + +function printAnalyzeReport(filePath, options = {}) { + const { format = "default" } = options; + const report = readBuildReport(filePath); + const findings = collectRecommendations(report); + + switch (format) { + case "json": + printJsonFormat(filePath, findings); + break; + default: + printDefaultFormat(filePath, findings); + } +} + +module.exports = { + printAnalyzeReport, +}; + diff --git a/_cli/bin.js b/_cli/bin.js index a8ec7bc..c67378f 100644 --- a/_cli/bin.js +++ b/_cli/bin.js @@ -6,9 +6,13 @@ function printHelp() { Usage: react-native-bundle-discovery-cli packages [--sort size|name] [--format json|table|default] + react-native-bundle-discovery-cli analyze [--format json|default] + react-native-bundle-discovery-cli analize [--format json|default] Commands: packages Print package list from a Metro bundler stat report + analyze Analyze bundle and print optimization recommendations + analize Alias for analyze Options: -h, --help Show help @@ -63,4 +67,22 @@ if (command === "packages") { } } +if (command === "analyze" || command === "analize") { + const file = argv._[1]; + const format = argv.format; + if (!file) { + fail("Missing required argument: "); + } + if (format !== "json" && format !== "default") { + fail(`Invalid value for --format: ${format}. Expected one of: json, default.`); + } + + try { + const { printAnalyzeReport } = require("./analyze.js"); + return printAnalyzeReport(file, { format }); + } catch (error) { + fail(error.message); + } +} + fail(`Unknown command: ${command}`); diff --git a/_cli/package.json b/_cli/package.json index 9668fb5..e144785 100644 --- a/_cli/package.json +++ b/_cli/package.json @@ -12,7 +12,9 @@ "files": [ "index.js", "bin.js", + "analyze.js", "packages.js", - "prepare.js" + "prepare.js", + "recommendations" ] } diff --git a/_cli/recommendations/index.js b/_cli/recommendations/index.js new file mode 100644 index 0000000..ceb57f0 --- /dev/null +++ b/_cli/recommendations/index.js @@ -0,0 +1,4 @@ +const reactNativeLinearGradient = require("./linear-gradient.js"); +const reactNativeRadialGradient = require("./radial-gradient.js"); + +module.exports = [reactNativeLinearGradient, reactNativeRadialGradient]; diff --git a/_cli/recommendations/linear-gradient.js b/_cli/recommendations/linear-gradient.js new file mode 100644 index 0000000..d1fac5b --- /dev/null +++ b/_cli/recommendations/linear-gradient.js @@ -0,0 +1,36 @@ +const { isVersionGte } = require("../utils.js"); + +module.exports = { + id: "linear-gradient-vs-background-image", + title: "Replace third-party linear gradient libraries with built-in backgroundImage", + check(report) { + const packages = report?.packages ?? []; + const gradientPkgs = packages.filter( + (pkg) => pkg?.name === "react-native-linear-gradient" || pkg?.name === "expo-linear-gradient", + ); + + if (gradientPkgs.length === 0) { + return null; + } + + const reactNativeVersion = packages.find((pkg) => pkg?.name === "react-native")?.version; + + if (!isVersionGte(reactNativeVersion, "0.76.0")) { + return null; + } + + const isStableBackgroundImage = isVersionGte(reactNativeVersion, "0.87.0"); + const backgroundImageProp = isStableBackgroundImage + ? "backgroundImage" + : "experimental_backgroundImage"; + const docsAnchor = isStableBackgroundImage + ? "backgroundimage" + : "experimental_backgroundimage"; + + return { + message: `React Native ships built-in \`linear-gradient()\` support (starting React Native 0.76.x+). You can remove ${gradientPkgs.map(e => e.name).join(", ")} and migrate to a simple View with a \`${backgroundImageProp}\` style prop.`, + packages: [...gradientPkgs.map(e => `${e.name}@${e.version}`), `react-native@${reactNativeVersion}`], + docsUrl: `https://reactnative.dev/docs/${reactNativeVersion}/view-style-props#${docsAnchor}`, + }; + }, +}; diff --git a/_cli/recommendations/radial-gradient.js b/_cli/recommendations/radial-gradient.js new file mode 100644 index 0000000..97ca7c0 --- /dev/null +++ b/_cli/recommendations/radial-gradient.js @@ -0,0 +1,36 @@ +const { isVersionGte } = require("../utils.js"); + +module.exports = { + id: "radial-gradient-vs-background-image", + title: "Replace third-party radial gradient libraries with built-in backgroundImage", + check(report) { + const packages = report?.packages ?? []; + const gradientPkgs = packages.filter( + (pkg) => pkg?.name === "react-native-radial-gradient" || pkg?.name === "expo-radial-gradient", + ); + + if (gradientPkgs.length === 0) { + return null; + } + + const reactNativeVersion = packages.find((pkg) => pkg?.name === "react-native")?.version; + + if (!isVersionGte(reactNativeVersion, "0.80.0")) { + return null; + } + + const isStableBackgroundImage = isVersionGte(reactNativeVersion, "0.87.0"); + const backgroundImageProp = isStableBackgroundImage + ? "backgroundImage" + : "experimental_backgroundImage"; + const docsAnchor = isStableBackgroundImage + ? "backgroundimage" + : "experimental_backgroundimage"; + + return { + message: `React Native ships built-in \`radial-gradient()\` support (starting React Native 0.80.x+). You can remove ${gradientPkgs.map(e => e.name).join(", ")} and migrate to a simple View with a \`${backgroundImageProp}\` style prop.`, + packages: [...gradientPkgs.map(e => `${e.name}@${e.version}`), `react-native@${reactNativeVersion}`], + docsUrl: `https://reactnative.dev/docs/${reactNativeVersion}/view-style-props#${docsAnchor}`, + }; + }, +}; diff --git a/_cli/utils.js b/_cli/utils.js index 325d141..c5a1211 100644 --- a/_cli/utils.js +++ b/_cli/utils.js @@ -6,4 +6,40 @@ function formatBytes(bytes, decimals = 2) { return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + " " + sizes[i]; } -module.exports = {formatBytes}; +function parseVersion(version) { + if (typeof version !== "string") { + return null; + } + + const match = version.match(/^(\d+)\.(\d+)\.(\d+)/); + if (!match) { + return null; + } + + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + }; +} + +function isVersionGte(version, targetVersion) { + const current = parseVersion(version); + const target = parseVersion(targetVersion); + + if (!current || !target) { + return false; + } + + if (current.major !== target.major) { + return current.major > target.major; + } + + if (current.minor !== target.minor) { + return current.minor > target.minor; + } + + return current.patch >= target.patch; +} + +module.exports = {formatBytes, isVersionGte}; From 3dd76b75a0b8aba4647dbc026b05b1849185c6ab Mon Sep 17 00:00:00 2001 From: D N <4661784+retyui@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:45:09 +0200 Subject: [PATCH 6/8] check deduplicate --- _cli/analyze.js | 2 +- _cli/package.json | 3 ++- _cli/packages.js | 2 ++ _cli/recommendations/duplicate-packages.js | 27 ++++++++++++++++++++++ _cli/recommendations/index.js | 3 ++- 5 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 _cli/recommendations/duplicate-packages.js diff --git a/_cli/analyze.js b/_cli/analyze.js index c62a143..6aec208 100644 --- a/_cli/analyze.js +++ b/_cli/analyze.js @@ -47,7 +47,7 @@ function printDefaultFormat(filePath, findings) { console.log(` Packages: ${finding.packages.join(", ")}`); } if (finding.docsUrl) { - console.log(` Docs: ${finding.docsUrl}`); + console.log(` Docs: ${Array.isArray(finding.docsUrl) ? finding.docsUrl.join(", ") : finding.docsUrl}`); } }); } diff --git a/_cli/package.json b/_cli/package.json index e144785..8224bed 100644 --- a/_cli/package.json +++ b/_cli/package.json @@ -7,7 +7,8 @@ "author": "David <4661784+retyui@users.noreply.github.com>", "license": "MIT", "dependencies": { - "minimist": "^1.2.8" + "minimist": "^1.2.8", + "chalk": "^4.1.2" }, "files": [ "index.js", diff --git a/_cli/packages.js b/_cli/packages.js index 4b01580..c8d386d 100644 --- a/_cli/packages.js +++ b/_cli/packages.js @@ -238,4 +238,6 @@ function printPackagesList(filePath, options = {}) { module.exports = { printPackagesList, + getDuplicateGroupName, + getPackageGroups, }; diff --git a/_cli/recommendations/duplicate-packages.js b/_cli/recommendations/duplicate-packages.js new file mode 100644 index 0000000..be50917 --- /dev/null +++ b/_cli/recommendations/duplicate-packages.js @@ -0,0 +1,27 @@ +const { getPackageGroups } = require("../packages.js"); + +module.exports = { + id: "duplicate-packages", + title: "Deduplicate repeated packages in the bundle", + check(report) { + const packageGroups = getPackageGroups(report, "size"); + const duplicateGroups = packageGroups.filter((group) => group.entries.length > 1); + + if (duplicateGroups.length === 0) { + return null; + } + + const packages = duplicateGroups.map(({ name, entries }) => { + const versions = Array.from(new Set(entries.map((entry) => `${entry.name}@${entry.version}`))); + return `${name} x${entries.length}: ${versions.join(", ")}`; + }); + + + return { + packages, + message: `Found ${duplicateGroups.length} duplicate package group(s). Align versions or use dependency overrides (npm) or resolutions (yarn) to keep a single copy per package group. Also you can use a "packages" command to see all packages in the bundle.`, + docsUrl: ["https://docs.npmjs.com/cli/v10/configuring-npm/package-json#overrides", "https://classic.yarnpkg.com/lang/en/docs/selective-version-resolutions/"], + }; + }, +}; + diff --git a/_cli/recommendations/index.js b/_cli/recommendations/index.js index ceb57f0..98ef9b0 100644 --- a/_cli/recommendations/index.js +++ b/_cli/recommendations/index.js @@ -1,4 +1,5 @@ const reactNativeLinearGradient = require("./linear-gradient.js"); const reactNativeRadialGradient = require("./radial-gradient.js"); +const duplicatePackages = require("./duplicate-packages.js"); -module.exports = [reactNativeLinearGradient, reactNativeRadialGradient]; +module.exports = [reactNativeLinearGradient, reactNativeRadialGradient, duplicatePackages]; From 19840c8e139fe0a9ba9cefbccab4be8c9a69c506 Mon Sep 17 00:00:00 2001 From: D N <4661784+retyui@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:55:13 +0200 Subject: [PATCH 7/8] Add chalk --- _cli/analyze.js | 29 ++++++++++++++++------ _cli/recommendations/duplicate-packages.js | 2 +- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/_cli/analyze.js b/_cli/analyze.js index 6aec208..0617207 100644 --- a/_cli/analyze.js +++ b/_cli/analyze.js @@ -1,4 +1,5 @@ const path = require("path"); +const chalk = require("chalk"); const { prepareReport } = require("./prepare.js"); const recommendations = require("./recommendations/index.js"); @@ -31,23 +32,38 @@ function collectRecommendations(report) { } function printDefaultFormat(filePath, findings) { + const prettyPath = chalk.cyan(filePath); + if (findings.length === 0) { - console.log(`No optimization recommendations found for ${filePath}.`); + console.log(`${chalk.green("No optimization recommendations found for")} ${prettyPath}.`); return; } - console.log(`Found ${findings.length} optimization recommendation(s):`); + const recommendationLabel = findings.length === 1 ? "recommendation" : "recommendations"; + console.log( + `${chalk.bold.green("Found")} ${chalk.bold(findings.length)} ${chalk.green(`${recommendationLabel}:`)}`, + ); + console.log(`${chalk.dim("Report:")} ${prettyPath}`); + console.log(); findings.forEach((finding, index) => { - console.log(`${index + 1}. ${finding.title}`); + console.log(`${chalk.bold.yellow(`${index + 1}.`)} ${chalk.bold(finding.title)}`); + if (finding.message) { - console.log(` Why: ${finding.message}`); + console.log(` ${chalk.blue("Why:")} ${finding.message}`); } + if (finding.packages && finding.packages.length > 0) { - console.log(` Packages: ${finding.packages.join(", ")}`); + console.log(` ${chalk.magenta("Packages:")} ${finding.packages.join(", ")}`); } + if (finding.docsUrl) { - console.log(` Docs: ${Array.isArray(finding.docsUrl) ? finding.docsUrl.join(", ") : finding.docsUrl}`); + const docs = Array.isArray(finding.docsUrl) ? finding.docsUrl.join(", ") : finding.docsUrl; + console.log(` ${chalk.cyan("Links:")} ${chalk.underline(docs)}`); + } + + if (index < findings.length - 1) { + console.log(chalk.dim(" ----------------------------------------")); } }); } @@ -82,4 +98,3 @@ function printAnalyzeReport(filePath, options = {}) { module.exports = { printAnalyzeReport, }; - diff --git a/_cli/recommendations/duplicate-packages.js b/_cli/recommendations/duplicate-packages.js index be50917..a937e6b 100644 --- a/_cli/recommendations/duplicate-packages.js +++ b/_cli/recommendations/duplicate-packages.js @@ -19,7 +19,7 @@ module.exports = { return { packages, - message: `Found ${duplicateGroups.length} duplicate package group(s). Align versions or use dependency overrides (npm) or resolutions (yarn) to keep a single copy per package group. Also you can use a "packages" command to see all packages in the bundle.`, + message: `Found ${duplicateGroups.length} duplicate package group(s). Align versions or use dependency overrides (npm) or resolutions (yarn) to keep a single copy per package group. Use a "${require('../package.json').name} packages " command to see more...`, docsUrl: ["https://docs.npmjs.com/cli/v10/configuring-npm/package-json#overrides", "https://classic.yarnpkg.com/lang/en/docs/selective-version-resolutions/"], }; }, From 37dcbe3c0ec2666da58d1fcffe724b600d9dfb41 Mon Sep 17 00:00:00 2001 From: D N <4661784+retyui@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:20:14 +0200 Subject: [PATCH 8/8] Better lodash msg --- _cli/analyze.js | 41 ++++++---------------- _cli/packages.js | 23 +++++++++--- _cli/recommendations/duplicate-packages.js | 41 ++++++++++++++++------ 3 files changed, 59 insertions(+), 46 deletions(-) diff --git a/_cli/analyze.js b/_cli/analyze.js index 0617207..8e32cf8 100644 --- a/_cli/analyze.js +++ b/_cli/analyze.js @@ -16,17 +16,19 @@ function readBuildReport(filePath) { function collectRecommendations(report) { return recommendations - .map((recommendation) => { + .flatMap((recommendation) => { const finding = recommendation.check(report); if (!finding) { return null; } - return { - id: recommendation.id, - title: recommendation.title, - ...finding, - }; + return (Array.isArray(finding) ? finding : [finding]).map(f => { + return { + id: recommendation.id, + title: recommendation.title, + ...f, + }; + }); }) .filter(Boolean); } @@ -54,7 +56,7 @@ function printDefaultFormat(filePath, findings) { } if (finding.packages && finding.packages.length > 0) { - console.log(` ${chalk.magenta("Packages:")} ${finding.packages.join(", ")}`); + console.log(` ${chalk.magenta("Packages:")} ${finding.packages}`); } if (finding.docsUrl) { @@ -68,31 +70,10 @@ function printDefaultFormat(filePath, findings) { }); } -function printJsonFormat(filePath, findings) { - console.log( - JSON.stringify( - { - file: filePath, - recommendations: findings, - }, - null, - 2, - ), - ); -} - -function printAnalyzeReport(filePath, options = {}) { - const { format = "default" } = options; +function printAnalyzeReport(filePath) { const report = readBuildReport(filePath); const findings = collectRecommendations(report); - - switch (format) { - case "json": - printJsonFormat(filePath, findings); - break; - default: - printDefaultFormat(filePath, findings); - } + printDefaultFormat(filePath, findings); } module.exports = { diff --git a/_cli/packages.js b/_cli/packages.js index c8d386d..bd340fa 100644 --- a/_cli/packages.js +++ b/_cli/packages.js @@ -1,6 +1,7 @@ const path = require("path"); const { prepareReport } = require("./prepare.js"); const { formatBytes } = require("./utils.js"); +const chalk = require("chalk"); const LODASH_FAMILY_GROUP = "lodash (please use only one)"; @@ -70,23 +71,34 @@ function printDefaultFormat(packageGroups, report) { const duplicateCount = packageGroups.filter(({ entries }) => entries.length > 1).length; console.log( - `Found ${report.packages.length} package entries (${packageGroups.length} unique names)`, + chalk.bold.cyan( + `Found ${report.packages.length} package entries (${packageGroups.length} unique names)`, + ), ); - console.log(`Duplicate package names: ${duplicateCount}`); + + if (duplicateCount > 0) { + console.log(chalk.bold.yellow(`Duplicate package names: ${duplicateCount}`)); + } else { + console.log(chalk.bold.green("Duplicate package names: 0")); + } packageGroups.forEach(({ name, entries }, index) => { + const listIndex = chalk.dim(`${index + 1}.`); + if (entries.length === 1) { const entry = entries[0]; console.log( - `${index + 1}. ${entry.name}@${entry.version} (${entry.path}) - ${formatBytes(entry.sizeInBytes)}`, + `${listIndex} ${chalk.whiteBright(`${entry.name}@${entry.version}`)} ${chalk.gray(`(${entry.path})`)} - ${chalk.magenta(formatBytes(entry.sizeInBytes))}`, ); return; } - console.log(`${index + 1}. ${name} [DUPLICATE x${entries.length}]`); + console.log( + `${listIndex} ${chalk.yellowBright(name)} ${chalk.black.bgYellow(` DUPLICATE x${entries.length} `)}`, + ); entries.forEach((entry, entryIndex) => { console.log( - ` - ${entryIndex + 1}) ${entry.name}@${entry.version} (${entry.path}) - ${formatBytes(entry.sizeInBytes)}`, + ` ${chalk.gray(`- ${entryIndex + 1})`)} ${chalk.white(`${entry.name}@${entry.version}`)} ${chalk.gray(`(${entry.path})`)} - ${chalk.magenta(formatBytes(entry.sizeInBytes))}`, ); }); }); @@ -240,4 +252,5 @@ module.exports = { printPackagesList, getDuplicateGroupName, getPackageGroups, + LODASH_FAMILY_GROUP, }; diff --git a/_cli/recommendations/duplicate-packages.js b/_cli/recommendations/duplicate-packages.js index a937e6b..a6b7db7 100644 --- a/_cli/recommendations/duplicate-packages.js +++ b/_cli/recommendations/duplicate-packages.js @@ -1,4 +1,4 @@ -const { getPackageGroups } = require("../packages.js"); +const { getPackageGroups, LODASH_FAMILY_GROUP } = require("../packages.js"); module.exports = { id: "duplicate-packages", @@ -11,17 +11,36 @@ module.exports = { return null; } - const packages = duplicateGroups.map(({ name, entries }) => { - const versions = Array.from(new Set(entries.map((entry) => `${entry.name}@${entry.version}`))); - return `${name} x${entries.length}: ${versions.join(", ")}`; - }); + return duplicateGroups.map((group) => { + const {name, entries} = group; + const versions = Array.from(new Set(entries.map((entry) => `${entry.name}@${entry.version} (${entry.path})`))); + const packages = `${name === LODASH_FAMILY_GROUP ? 'lodash' : name} x${entries.length}:\n - ${versions.join("\n - ")}`; + const hasDotLodash = entries.some(e =>e.name.startsWith("lodash.")); + const hasESLodash = entries.some(e =>e.name === 'lodash-es'); + const hasSimpleLodash = entries.some(e => e.name === "lodash"); + const hasUnderscore = entries.some(e => e.name === 'underscore'); + const hasRamda = entries.some(e => e.name === 'ramda'); + const message = [ + hasDotLodash && hasSimpleLodash && "You can simply use `lodash/*` imports and remove `lodash.*` packages", + hasESLodash && hasSimpleLodash && "You don't need two copy of `lodash` and `lodash-es`! Just use one", + hasUnderscore && "You have both `underscore` and `lodash` in your project. Consider using only one library to reduce bundle size.", + hasRamda && "You have both `ramda` and `lodash` in your project. Consider using only one library to reduce bundle size.", + ].filter(Boolean).join("; "); - return { - packages, - message: `Found ${duplicateGroups.length} duplicate package group(s). Align versions or use dependency overrides (npm) or resolutions (yarn) to keep a single copy per package group. Use a "${require('../package.json').name} packages " command to see more...`, - docsUrl: ["https://docs.npmjs.com/cli/v10/configuring-npm/package-json#overrides", "https://classic.yarnpkg.com/lang/en/docs/selective-version-resolutions/"], - }; + if(name === LODASH_FAMILY_GROUP){ + return { + packages, + message, + docsUrl: null, + } + } + + return { + packages, + message: `Found ${duplicateGroups.length} duplicate packages. Align versions or use dependency overrides (npm) / resolutions (yarn) to keep a single copy per package. Use a "${require('../package.json').name} packages " command to see more...`, + docsUrl: ["https://docs.npmjs.com/cli/v10/configuring-npm/package-json#overrides", "https://classic.yarnpkg.com/lang/en/docs/selective-version-resolutions/"], + }; + }) }, }; -