react-native-bundle-discovery 1.0.0-rc.8 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ const path = require("path");
2
+
3
+ module.exports = {
4
+ name: "react-native-bundle-discovery",
5
+ data: () => require("./tmp/before_metro-stats.json"),
6
+ setup: path.resolve(__dirname, "setup.js"),
7
+ view: {
8
+ assets: [
9
+ // Global styles
10
+ path.resolve(__dirname, "views/global.css"),
11
+ // Pages
12
+ path.resolve(__dirname, "pages/default.js"),
13
+ path.resolve(__dirname, "pages/module.js"),
14
+ path.resolve(__dirname, "pages/package.js"),
15
+ // Custom views
16
+ path.resolve(__dirname, "views/highcharts.css"),
17
+ path.resolve(__dirname, "views/highcharts.js"),
18
+ path.resolve(__dirname, "views/foamtree.js"),
19
+ ],
20
+ },
21
+ };
package/README.md CHANGED
@@ -4,7 +4,10 @@
4
4
  > Currently, everything is in a very early stage. The project is not yet ready for use.
5
5
 
6
6
 
7
- <img width="800" src="https://github.com/user-attachments/assets/211145d4-8fe7-499b-a372-9d752e878772" />
7
+ A simple package that helps developers visualize and analyze the bundle size of React Native apps.
8
+ With this tool, you can easily explore your app's codebase, identify large or heavy packages, and inspect the structure of modules and code within your project.
9
+
10
+ <img width="800" alt="" src="./assets/img.png" />
8
11
 
9
12
  ### Setup:
10
13
 
@@ -25,7 +28,7 @@ const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config');
25
28
  + projectRoot: __dirname,
26
29
  + //^^^ ⚠️ WARNING: In a monorepo setup, this should point to the monorepo root,
27
30
  + // not the individual package directory.
28
- +})
31
+ +});
29
32
 
30
33
  -const config = {};
31
34
  +const config = {
@@ -37,7 +40,31 @@ const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config');
37
40
  module.exports = mergeConfig(getDefaultConfig(__dirname), config);
38
41
  ```
39
42
 
43
+ #### 3. Build the app
44
+
45
+ As example, for iOS you can run the following command, and it will generate the `metro-stats.json` file in the root of your project:
46
+
47
+ ```bash
48
+ npx react-native bundle \
49
+ --entry-file index.js \
50
+ --platform ios \
51
+ --dev false \
52
+ --bundle-output ios/main.jsbundle \
53
+ --assets-dest ios/assets
54
+ ```
55
+
56
+ #### 4. View the report
57
+
58
+ Run webserver to view the report:
59
+
60
+ ```bash
61
+ npx react-native-bundle-discovery metro-stats.json
62
+ ```
63
+
64
+ ---
65
+
40
66
  **Similar projects:**
67
+
41
68
  - https://github.com/expo/atlas
42
69
  - https://github.com/v3ron/expo-atlas-without-expo
43
70
  - https://github.com/callstack/react-native-bundle-visualizer
@@ -45,7 +72,8 @@ module.exports = mergeConfig(getDefaultConfig(__dirname), config);
45
72
  - https://github.com/statoscope/statoscope
46
73
  - https://github.com/relative-ci/bundle-stats/tree/master/packages/cli
47
74
 
75
+ ---
48
76
 
49
- **Links:**
77
+ **Built using Discovery.js:**
50
78
  - Build blocks for pages: https://discoveryjs.github.io/discovery/#views-showcase
51
79
  - Jora syntax: https://discoveryjs.github.io/jora/#article:jora-syntax-operators
package/lib/bin.js CHANGED
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ const fs = require("fs");
3
+ const path = require("path");
4
+ const chalk = require("chalk");
5
+ const { createServer } = require("@discoveryjs/cli");
6
+ const config = require("../.discoveryrc.js");
7
+
8
+ const filePath = process.argv[2];
9
+
10
+ if (!filePath) {
11
+ console.error(
12
+ `Usage: '${chalk.green("npx react-native-bundle-discovery <path-to-file>")}', Please provide a path to a JSON file.`,
13
+ );
14
+ process.exit(1);
15
+ }
16
+
17
+ const jsonFilePath = path.resolve(process.cwd(), filePath);
18
+
19
+ let fullJsonPath;
20
+
21
+ try {
22
+ fullJsonPath = require.resolve(jsonFilePath);
23
+ } catch (err) {
24
+ console.error(`Error loading file: ${chalk.red(jsonFilePath)}`);
25
+ console.error(err.message);
26
+ process.exit(1);
27
+ }
28
+
29
+ const PORT = process.env.PORT || 8079;
30
+
31
+ const configFile = path.resolve(__dirname, "./.tmp.js");
32
+
33
+ fs.writeFileSync(
34
+ configFile,
35
+ `module.exports = ${JSON.stringify(
36
+ { ...config, data: "<tmp>" },
37
+ null,
38
+ 1,
39
+ ).replace(`"<tmp>"`, `() => require("${fullJsonPath}")`)};`,
40
+ );
41
+
42
+ createServer({
43
+ cache: false,
44
+ minify: true,
45
+ dev: false,
46
+ config: configFile,
47
+ configFile,
48
+ }).then((server) =>
49
+ server.listen(PORT, () =>
50
+ console.log(`Server listen on ${chalk.green(`http://localhost:${PORT}`)}`),
51
+ ),
52
+ );
@@ -1,6 +1,7 @@
1
- const { writeFileSync, existsSync } = require("node:fs");
2
- const { resolve } = require("node:path");
3
- const { Buffer } = require("node:buffer");
1
+ const { writeFileSync, existsSync } = require("fs");
2
+ const { resolve } = require("path");
3
+ const { Buffer } = require("buffer");
4
+ const chalk = require("chalk");
4
5
 
5
6
  const NAME = require("../package.json").name;
6
7
 
@@ -113,7 +114,9 @@ function createJsonReport({
113
114
 
114
115
  writeFileSync(outputJsonPath, JSON.stringify(stats));
115
116
 
116
- console.log(`[${NAME}]: Saved stats to`, outputJsonPath);
117
+ console.log(
118
+ `${chalk.yellow(`[${NAME}]`)}: Saved stats to ${chalk.green(outputJsonPath)}`,
119
+ );
117
120
  }
118
121
 
119
122
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-bundle-discovery",
3
- "version": "1.0.0-rc.8",
3
+ "version": "1.0.0",
4
4
  "main": "index.js",
5
5
  "bin": "lib/bin.js",
6
6
  "repository": "git@github.com:retyui/react-native-bundle-discovery.git",
@@ -9,10 +9,13 @@
9
9
  "scripts": {
10
10
  "format": "prettier --write .",
11
11
  "start": "NODE_ENV=development npx discovery --config .discoveryrc.js",
12
- "build": "npx discovery-build --config .discoveryrc.js --output build --serve-only-assets --single-file",
13
- "postbuild": "yarn print-size",
14
- "print-size": "npx @delucis/filesize-cli build/index.html",
15
- "postinstall": "yarn patch-package"
12
+ "build": "npx discovery-build --config .discoveryrc.js --output build --serve-only-assets --single-file"
13
+ },
14
+ "dependencies": {
15
+ "@discoveryjs/cli": "2.14.2",
16
+ "@discoveryjs/discovery": "1.0.0-beta.93",
17
+ "chalk": "^4.1.2",
18
+ "highcharts": "12.2.0"
16
19
  },
17
20
  "peerDependencies": {
18
21
  "metro": "*"
@@ -23,17 +26,17 @@
23
26
  }
24
27
  },
25
28
  "files": [
29
+ "vendors",
26
30
  "lib",
27
- "index.js"
31
+ "views",
32
+ "pages",
33
+ "index.js",
34
+ "setup.js",
35
+ "prepare.js",
36
+ "queryHelpers.js",
37
+ ".discoveryrc.js"
28
38
  ],
29
39
  "devDependencies": {
30
- "@carrotsearch/foamtree": "3.5.1",
31
- "@discoveryjs/cli": "2.14.2",
32
- "@discoveryjs/discovery": "1.0.0-beta.93",
33
- "highcharts": "12.2.0",
34
- "lodash": "4.17.21",
35
- "patch-package": "8.0.0",
36
40
  "prettier": "3.5.3"
37
- },
38
- "packageManager": "yarn@4.6.0"
41
+ }
39
42
  }
@@ -0,0 +1,266 @@
1
+ const platformColor = `transformOptions.platform = 'android' ? 'rgba(194, 239, 116, .4)' : 'rgba(119, 31, 218, .4)'`;
2
+
3
+ function getPackage(entry) {
4
+ return `
5
+ $packages: $.packages;
6
+ $totalSize: $.modules.sum(=>output.sizeInBytes);
7
+ $toModule: => {
8
+ ext: $.path.getFileExtension(),
9
+ name: $.path,
10
+ size: $.output.sizeInBytes.formatBytes(),
11
+ percent: ($.output.sizeInBytes / $totalSize).percent(3),
12
+ };
13
+
14
+ ${entry}.group(=> path.getModulesName())
15
+ .map(=> ({
16
+ $pkgName: $.key;
17
+ pkgName: $pkgName, // example: lodash
18
+ size: $.value.sum(=>output.sizeInBytes),
19
+ pkgInstances: $.value
20
+ .group(=> path.split($pkgName).pick(0) + $pkgName)
21
+ .map(=> {
22
+ $pkgNameWithPath: $.key;
23
+ pkgName: $pkgNameWithPath, // example: node_modules/lodash
24
+ version: $packages.[path = $pkgNameWithPath][0].version,
25
+ size: $.value.sum(=> output.sizeInBytes),
26
+ modules: $.value.map(=> $.$toModule()),
27
+ }),
28
+ }))`;
29
+ }
30
+
31
+ function getPackageList({
32
+ data,
33
+ itemPkgName,
34
+ showCopiesBadge,
35
+ expanded,
36
+ limit,
37
+ subLimit,
38
+ }) {
39
+ return {
40
+ view: "list",
41
+ data,
42
+ emptyText: "⚠️ No packages found",
43
+ limit,
44
+ item: {
45
+ view: "tree",
46
+ expanded,
47
+ itemConfig: {
48
+ content: [
49
+ itemPkgName,
50
+ "text: ' '",
51
+ "pill-badge:{ text: size.formatBytes(), color: 'rgba(120, 177, 9, 0.35)' }",
52
+ showCopiesBadge
53
+ ? {
54
+ view: "pill-badge",
55
+ when: "pkgInstances.size() > 1",
56
+ data: "(pkgInstances.size() - 1).pluralBadge(['copy','copies'], '+')",
57
+ color: "rgba(255, 0, 0, 0.35)",
58
+ }
59
+ : null,
60
+ {
61
+ view: "pill-badge",
62
+ data: "pkgInstances.modules.size().pluralBadge(['file','files'])",
63
+ },
64
+ ].filter(Boolean),
65
+ children: `$.pkgInstances`,
66
+ itemConfig: {
67
+ view: "tree-leaf",
68
+ limit: subLimit,
69
+ content: [
70
+ //
71
+ "text:pkgName",
72
+ "text:' '",
73
+ "pill-badge:{ text: 'v' + version, color: '#0af' }",
74
+ "pill-badge:{ text: size.formatBytes(), color: 'rgba(120, 177, 9, 0.35)' }",
75
+ {
76
+ view: "pill-badge",
77
+ data: "modules.size().pluralBadge(['file','files'])",
78
+ },
79
+ ],
80
+ children: `$.modules`,
81
+ itemConfig: {
82
+ view: "tree-leaf",
83
+ content: getTreeModule({ hasPercent: true }),
84
+ },
85
+ },
86
+ },
87
+ },
88
+ };
89
+ }
90
+
91
+ function getTreeModule({ hasTextMatch = false, hasPercent = false } = {}) {
92
+ return [
93
+ "pill-badge:{ text: ext, color: ext.getExtColor() }",
94
+ hasTextMatch
95
+ ? {
96
+ view: "link",
97
+ content: hasTextMatch ? "text-match" : "text",
98
+ data: `{
99
+ href: name.pageLink("module", {}),
100
+ text: name,
101
+ match: #.filterByPathStr
102
+ }`,
103
+ }
104
+ : {
105
+ view: "link",
106
+ data: `{ href: $.name.pageLink("module", {}), text: $.name }`,
107
+ },
108
+ "text:' '",
109
+ {
110
+ view: "badge",
111
+ when: "isEntry",
112
+ text: "Entrypoint",
113
+ color: "gold",
114
+ textColor: "black",
115
+ },
116
+ "pill-badge:{ text: size, color: 'rgba(120, 177, 9, 0.35)' }",
117
+ hasPercent
118
+ ? "pill-badge:{ text: percent, color: 'rgba(120, 177, 9, 0.35)' }"
119
+ : null,
120
+ ].filter(Boolean);
121
+ }
122
+ function getModulesTree({ data, limit }) {
123
+ if (!data) {
124
+ throw new Error("[getModulesTree]: data is required");
125
+ }
126
+ return {
127
+ view: "content-filter",
128
+ data,
129
+ name: "filterByPathStr",
130
+ content: {
131
+ view: "list",
132
+ limit,
133
+ data: ".[name ~= #.filterByPathStr]",
134
+ emptyText: "⚠️ No modules found",
135
+ item: {
136
+ view: "tree",
137
+ expanded: false,
138
+ itemConfig: {
139
+ content: getTreeModule({ hasTextMatch: true }),
140
+ children: `
141
+ [
142
+ {
143
+ title:'Imported by modules',
144
+ data: $.reasons,
145
+ type: 'reasons',
146
+ },
147
+ {
148
+ title:'Similar copies',
149
+ data: $.duplicates,
150
+ type: 'duplicates',
151
+ }
152
+ ].filter(=> $.data.size() > 0)
153
+ `,
154
+ itemConfig: {
155
+ view: "switch",
156
+ content: [
157
+ {
158
+ when: 'type="reasons"',
159
+ content: {
160
+ view: "tree-leaf",
161
+ content: [
162
+ "text:title",
163
+ "text:' '",
164
+ "badge:{ text: $.data.size() }",
165
+ ],
166
+ children: `$.data`,
167
+ itemConfig: {
168
+ view: "tree-leaf",
169
+ content: getTreeModule(),
170
+ },
171
+ },
172
+ },
173
+ {
174
+ when: 'type="duplicates"',
175
+ content: {
176
+ view: "tree-leaf",
177
+ content: [
178
+ "text:title",
179
+ "text:' '",
180
+ "badge:{ text: $.data.size() }",
181
+ ],
182
+ children: `$.data`,
183
+ itemConfig: {
184
+ view: "tree-leaf",
185
+ content: getTreeModule(),
186
+ },
187
+ },
188
+ },
189
+ ],
190
+ },
191
+ },
192
+ },
193
+ },
194
+ };
195
+ }
196
+
197
+ const metadata = {
198
+ platform: {
199
+ when: "transformOptions.platform",
200
+ view: "badge",
201
+ data: `{ prefix: 'Platform: ', text: transformOptions.platform, color: ${platformColor} }`,
202
+ },
203
+ size: {
204
+ when: "modules.filter(=> $.path has 'node_modules').size()",
205
+ view: "badge",
206
+ data: `{ prefix: 'Size: ', text: modules.sum(=>output.sizeInBytes).formatBytes(), color: ${platformColor} }`,
207
+ },
208
+ node_modules_size: {
209
+ when: "modules.filter(=> $.path has 'node_modules').size()",
210
+ view: "badge",
211
+ data: "{ prefix: 'node_modules: ', text: modules.filter(=> $.path has 'node_modules').sum(=>output.sizeInBytes).formatBytes(), color: 'rgba(255, 0, 0, 0.35)' }",
212
+ },
213
+ source_code_size: {
214
+ when: "modules.filter(=> $.path has 'node_modules').size()",
215
+ view: "badge",
216
+ data: `
217
+ // vars
218
+ $totalSize: modules.sum(=>output.sizeInBytes);
219
+ $thirdPartySize: modules.filter(=> $.path has 'node_modules').sum(=>output.sizeInBytes);
220
+ // return data
221
+ { prefix: 'Source code: ', text: ($totalSize - $thirdPartySize).formatBytes(), color: 'rgba(148, 111, 234, 0.5)' }`,
222
+ },
223
+ is_dev: {
224
+ when: "transformOptions.dev != null",
225
+ view: "badge",
226
+ data: "{ prefix: '__DEV__: ', text: transformOptions.dev }",
227
+ },
228
+ is_minified: {
229
+ when: "transformOptions.minify != null",
230
+ view: "badge",
231
+ data: "{ prefix: 'Minify: ', text: transformOptions.minify }",
232
+ },
233
+ };
234
+
235
+ const externalLinkHtml = `<svg class="my-icon my-icon-link" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" ><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14 21 3"></path></svg>`;
236
+
237
+ function getCopyToClipboardButton({ textToCopy, className }) {
238
+ return {
239
+ view: "button",
240
+ className: `${className ?? ""} copy-to-clipboard`,
241
+ data: `{ textToCopy: ${textToCopy} }`,
242
+ onClick(elm, data) {
243
+ clearTimeout(elm.__timer);
244
+ navigator.clipboard
245
+ .writeText(data.textToCopy)
246
+ .then(() => {
247
+ elm.classList.add("done");
248
+ elm.__timer = setTimeout(() => elm.classList.remove("done"), 2000);
249
+ })
250
+ .catch(() => {
251
+ elm.classList.add("err");
252
+ elm.__timer = setTimeout(() => elm.classList.remove("err"), 2000);
253
+ });
254
+ },
255
+ };
256
+ }
257
+
258
+ module.exports = {
259
+ getCopyToClipboardButton,
260
+ getPackage,
261
+ getPackageList,
262
+ externalLinkHtml,
263
+ getTreeModule,
264
+ getModulesTree,
265
+ metadata,
266
+ };
@@ -0,0 +1,194 @@
1
+ const {
2
+ metadata,
3
+ getModulesTree,
4
+ getPackage,
5
+ getPackageList,
6
+ } = require("./_common");
7
+
8
+ const topMetaData = [
9
+ metadata.platform,
10
+ metadata.size,
11
+ metadata.source_code_size,
12
+ metadata.node_modules_size,
13
+ metadata.is_dev,
14
+ metadata.is_minified,
15
+ ];
16
+
17
+ const TABS = {
18
+ TREEMAP_FOAMTREE: "treemap-foamtree",
19
+ MODULES: "modules",
20
+ PACKAGES: "packages",
21
+ DUPLICATES: "duplicates",
22
+ };
23
+
24
+ function parseHashRef(url = window.location.href) {
25
+ return new URL(url).hash.split(":")?.[1];
26
+ }
27
+
28
+ discovery.page.define("default", [
29
+ ...topMetaData,
30
+
31
+ {
32
+ view: "tabs",
33
+ name: "mainTabs",
34
+ className: "main-tabs",
35
+ value: parseHashRef() ?? TABS.TREEMAP_FOAMTREE,
36
+ tabs: [
37
+ {
38
+ value: TABS.TREEMAP_FOAMTREE,
39
+ text: "Treemap chart",
40
+ className: `main-tabs-${TABS.TREEMAP_FOAMTREE}`,
41
+ },
42
+ {
43
+ value: TABS.PACKAGES,
44
+ className: `main-tabs-${TABS.PACKAGES}`,
45
+ content: [
46
+ "text:'Packages '",
47
+ `pill-badge: modules.filter(=> path has "node_modules").group(=> path.getModulesName(), => 0).size()`,
48
+ ],
49
+ },
50
+ {
51
+ value: TABS.MODULES,
52
+ className: `main-tabs-${TABS.MODULES}`,
53
+ content: ["text:'Modules '", "pill-badge: modules.size()"],
54
+ },
55
+ {
56
+ value: TABS.DUPLICATES,
57
+ className: `main-tabs-${TABS.DUPLICATES}`,
58
+ content: [
59
+ "text:'Duplicate modules '",
60
+ "pill-badge: modules.filter(=> duplicates).size()",
61
+ ],
62
+ },
63
+ ],
64
+ content: {
65
+ view: "switch",
66
+ context(data, context) {
67
+ // FIXME - common nobody handle navigation in such way :(
68
+ const isHashChangeEvent = new Error("").stack.includes("Promise.all");
69
+ discovery.overridePageHashStateWithAnchor({
70
+ id: "default",
71
+ ref: isHashChangeEvent ? discovery.pageRef : context.mainTabs,
72
+ });
73
+ discovery.cancelScheduledRender();
74
+
75
+ const id = discovery.pageRef ?? TABS.TREEMAP_FOAMTREE;
76
+
77
+ setTimeout(() => {
78
+ discovery.dom.root
79
+ .querySelectorAll(".main-tabs>div>.onclick")
80
+ .forEach((e) => e.classList.remove("active"));
81
+ discovery.dom.root
82
+ .querySelectorAll(`.main-tabs .main-tabs-${id}`)
83
+ .forEach((e) => e.classList.add("active"));
84
+ }, 50);
85
+
86
+ return {
87
+ ...context,
88
+ id,
89
+ };
90
+ },
91
+ content: [
92
+ {
93
+ when: `#.id="${TABS.TREEMAP_FOAMTREE}"`,
94
+ content: {
95
+ view: "content-filter",
96
+ name: "filterByPathStr",
97
+ debounce: 300,
98
+ className: "foamtree-filter",
99
+ content: {
100
+ view: "foamtree",
101
+ data: `
102
+ $root: $.rootFolder;
103
+ $applyFilter: => #.filterByPathStr ? $.modules.filter(=> $.path ~= #.filterByPathStr) : $.modules;
104
+ $dataObject: $.$applyFilter()
105
+ .map(=> {path, size: $.output.sizeInBytes})
106
+ .transformFilesList($root, "foamtree");
107
+
108
+ {
109
+ options: {
110
+ dataObject: $dataObject,
111
+ descriptionGroupSize: 0.05,
112
+ descriptionGroupMinHeight: 30,
113
+ }
114
+ }
115
+ `,
116
+ },
117
+ },
118
+ },
119
+ {
120
+ when: `#.id="${TABS.MODULES}"`,
121
+ content: getModulesTree({
122
+ limit: 200,
123
+ data: `
124
+ $entryPoint: $.entryPointPath;
125
+ $totalSize: modules.sum(=>output.sizeInBytes);
126
+ $toModule: => {
127
+ ext: $.path.getFileExtension(),
128
+ name: $.path,
129
+ size: $.output.sizeInBytes.formatBytes(),
130
+ percent: ($.output.sizeInBytes / $totalSize).percent(3),
131
+ };
132
+ modules.map(=> {
133
+ ...$.$toModule(),
134
+ isEntry: $.isEntry,
135
+ reasons: $.dependents.map(=> $.$toModule()),
136
+ duplicates: $.duplicates.map(=> $.$toModule()),
137
+ })
138
+ `,
139
+ }),
140
+ },
141
+ {
142
+ when: `#.id="${TABS.PACKAGES}"`,
143
+ content: [
144
+ {
145
+ view: "content-filter",
146
+ data: `${getPackage(`modules.filter(=> path has "node_modules")`)}.sort(pkgInstances desc, size desc)`,
147
+ name: "filterByPathStr",
148
+ content: getPackageList({
149
+ showCopiesBadge: true,
150
+ expanded: false,
151
+ limit: 200,
152
+ subLimit: 50,
153
+ data: ".[pkgName ~= #.filterByPathStr]",
154
+ itemPkgName: {
155
+ view: "link",
156
+ content: "text-match",
157
+ data: `{
158
+ href: pkgName.pageLink("package", {}),
159
+ text: pkgName,
160
+ match: #.filterByPathStr
161
+ }`,
162
+ },
163
+ }),
164
+ },
165
+ ],
166
+ },
167
+ {
168
+ when: `#.id="${TABS.DUPLICATES}"`,
169
+ content: getModulesTree({
170
+ data: `
171
+ // values
172
+ $duplicatesOnly: modules.filter(=> duplicates);
173
+ $totalSize: $duplicatesOnly.sum(=>output.sizeInBytes);
174
+ $toModule: => {
175
+ ext: $.path.getFileExtension(),
176
+ name: $.path,
177
+ size: $.output.sizeInBytes.formatBytes(),
178
+ percent: ($.output.sizeInBytes / $totalSize).percent(3),
179
+ };
180
+ // return
181
+ $duplicatesOnly.map(=> {
182
+ ...$.$toModule(),
183
+ reasons: $.dependents.map(=> $.$toModule()),
184
+ duplicates: $.duplicates.map(=> $.$toModule()),
185
+ })
186
+ `,
187
+ }),
188
+ },
189
+ ],
190
+ },
191
+ },
192
+
193
+ // END
194
+ ]);