Repository: ai/size-limit (js)

Issue:
Can the config option be supported like eslint or stylelint to specify the path of the configuration file?
eslint or stylelint
```
stylelint --config src/config/stylelint.config.mjs ....
```
It would be great if size limit could also support this usage
```
size-limit --config src/config/size-limit.mjs ...
```

Pull request description:
feat: implement new --config arg to specify a custom path to configuration file when running CLI
This PR introduces a new `--config` argument to specify a custom path to the `size-limit` configuration file when running CLI.

Resolves #367

Further enhancement:
- add validation for config extension to throw a custom size-limit error. Currently, it will be `Error: No loader specified for extension ".extension"`;
- catch an error if there is no such file or directory from the parameter to throw a custom size-limit error. Currently, it will be `Error: no such file or directory...`.

Patch:
diff --git a/README.md b/README.md
index 898ff28..8cc8530 100644
--- a/README.md
+++ b/README.md
@@ -591,6 +591,12 @@ npm install --save-dev @size-limit/file
 npx size-limit --limit "10 kB" dist/bundle.js
 ```
 
+Additionally, you can specify a custom path to your configuration file when running the CLI:
+
+```sh
+npx size-limit --config src/configs/your-config.{m,c}?{js,ts,json}
+```
+
 [Statoscope docs]: https://github.com/statoscope/statoscope/tree/master/packages/webpack-plugin#optionsreports-report
 [pattern]: https://github.com/SuperchupuDev/tinyglobby
 
diff --git a/fixtures/config-file-from-arg/index.js b/fixtures/config-file-from-arg/index.js
new file mode 100644
index 0000000..d1bdfc5
--- /dev/null
+++ b/fixtures/config-file-from-arg/index.js
@@ -0,0 +1,5 @@
+const first = 'first'
+
+const second = 'second'
+
+export { first, second }
diff --git a/fixtures/config-file-from-arg/package.json b/fixtures/config-file-from-arg/package.json
new file mode 100644
index 0000000..c8a12af
--- /dev/null
+++ b/fixtures/config-file-from-arg/package.json
@@ -0,0 +1,17 @@
+{
+  "private": true,
+  "name": "config-file-from-arg",
+  "devDependencies": {
+    "@size-limit/file": ">= 0.0.0",
+    "size-limit": ">= 0.0.0"
+  },
+  "size-limit": [
+    {
+      "path": [
+        "a.js",
+        "b.js"
+      ],
+      "import": "a"
+    }
+  ]
+}
diff --git a/fixtures/config-file-from-arg/src/configs/my-size-limit.config.js b/fixtures/config-file-from-arg/src/configs/my-size-limit.config.js
new file mode 100644
index 0000000..737046b
--- /dev/null
+++ b/fixtures/config-file-from-arg/src/configs/my-size-limit.config.js
@@ -0,0 +1,12 @@
+export default [
+  {
+    path: '../../index.js',
+    limit: 10,
+    name: 'index'
+  },
+  {
+    path: '../main.js',
+    limit: 20,
+    name: 'main'
+  }
+]
diff --git a/fixtures/config-file-from-arg/src/main.js b/fixtures/config-file-from-arg/src/main.js
new file mode 100644
index 0000000..371fdfb
--- /dev/null
+++ b/fixtures/config-file-from-arg/src/main.js
@@ -0,0 +1,1 @@
+console.log('hello')
diff --git a/packages/size-limit/create-reporter.js b/packages/size-limit/create-reporter.js
index 6e9f996..acd7124 100644
--- a/packages/size-limit/create-reporter.js
+++ b/packages/size-limit/create-reporter.js
@@ -1,5 +1,6 @@
 import bytes from 'bytes-iec'
 import { join } from 'node:path'
+import readline from 'node:readline'
 import pc from 'picocolors'
 
 const { bgGreen, bgRed, black, bold, gray, green, red, yellow } = pc
@@ -42,10 +43,13 @@ function getFixText(prefix, config) {
 
   return prefix
 }
-
 function createHumanReporter(process, isSilentMode = false) {
+  let output = ''
+
   function print(...lines) {
-    process.stdout.write('  ' + lines.join('\n  ') + '\n')
+    let value = '  ' + lines.join('\n  ') + '\n'
+    output += value
+    process.stdout.write(value)
   }
 
   function formatBytes(size) {
@@ -190,6 +194,12 @@ function createHumanReporter(process, isSilentMode = false) {
         print(`Webpack Stats file was saved to ${statsFilepath}`)
         print('You can review it using https://webpack.github.io/analyse')
       }
+
+      // clean the blank line in silent mode if the output is empty
+      if (isSilentMode && !output.trim()) {
+        readline.moveCursor(process.stdout, 0, -1)
+        readline.clearLine(process.stdout, 0)
+      }
     }
   }
 }
diff --git a/packages/size-limit/get-config.js b/packages/size-limit/get-config.js
index 872eccf..22c8415 100644
--- a/packages/size-limit/get-config.js
+++ b/packages/size-limit/get-config.js
@@ -1,7 +1,7 @@
 import bytes from 'bytes-iec'
 import { lilconfig } from 'lilconfig'
 import { createRequire } from 'node:module'
-import { dirname, isAbsolute, join, relative } from 'node:path'
+import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
 import { fileURLToPath, pathToFileURL } from 'node:url'
 import { glob } from 'tinyglobby'
 
@@ -147,7 +147,9 @@ export default async function getConfig(plugins, process, args, pkg) {
         '.size-limit.cts'
       ]
     })
-    let result = await explorer.search(process.cwd())
+    let result = args.config?.trim()
+      ? await explorer.load(resolve(args.config.trim()))
+      : await explorer.search(process.cwd())
 
     if (result === null) throw new SizeLimitError('noConfig')
     checkChecks(plugins, result.config)
diff --git a/packages/size-limit/parse-args.js b/packages/size-limit/parse-args.js
index 3848e69..b02d4b4 100644
--- a/packages/size-limit/parse-args.js
+++ b/packages/size-limit/parse-args.js
@@ -63,6 +63,12 @@ export default function parseArgs(plugins, argv) {
         throw new SizeLimitError('argWithoutParameter', 'compare-with', 'FILE')
       }
       args.compareWith = nextArg
+    } else if (arg === '--config') {
+      let nextArg = argv[++i]
+      if (!nextArg || nextArg.startsWith('--')) {
+        throw new SizeLimitError('argWithoutParameter', 'config', 'FILE')
+      }
+      args.config = nextArg
     } else if (arg === '--watch') {
       /* c8 ignore next */
       args.watch = true


Test patch:
diff --git a/packages/size-limit/test/__snapshots__/create-reporter.test.js.snap b/packages/size-limit/test/__snapshots__/create-reporter.test.js.snap
index 7aba6c4..958d7f0 100644
--- a/packages/size-limit/test/__snapshots__/create-reporter.test.js.snap
+++ b/packages/size-limit/test/__snapshots__/create-reporter.test.js.snap
@@ -134,7 +134,7 @@ exports[`renders list of failed checks in silent mode 1`] = `
 
 exports[`renders list of success checks in silent mode 1`] = `
 "  
-"
+[1A[2K"
 `;
 
 exports[`renders result for file with gzip 1`] = `
diff --git a/packages/size-limit/test/__snapshots__/run.test.js.snap b/packages/size-limit/test/__snapshots__/run.test.js.snap
index 9bb7e6b..08cc728 100644
--- a/packages/size-limit/test/__snapshots__/run.test.js.snap
+++ b/packages/size-limit/test/__snapshots__/run.test.js.snap
@@ -305,7 +305,7 @@ Check out docs for more complicated cases
 
 exports[`shows nothing in silent mode and success check 1`] = `
 "  
-"
+[1A[2K"
 `;
 
 exports[`shows size-limit dependency warning 1`] = `""`;
@@ -397,6 +397,16 @@ exports[`throws on --compare-with argument without webpack 1`] = `
 "
 `;
 
+exports[`throws on --config argument without FILE parameter 1`] = `
+"[41m[30m ERROR [39m[49m [31mMissing parameter [33mFILE[31m for [33m--config[31m argument[39m
+"
+`;
+
+exports[`throws on --config argument without FILE parameter 2`] = `
+"[41m[30m ERROR [39m[49m [31mMissing parameter [33mFILE[31m for [33m--config[31m argument[39m
+"
+`;
+
 exports[`throws on --save-bundle argument without DIR parameter 1`] = `
 "[41m[30m ERROR [39m[49m [31mMissing parameter [33mDIR[31m for [33m--save-bundle[31m argument[39m
 "
diff --git a/packages/size-limit/test/get-config.test.js b/packages/size-limit/test/get-config.test.js
index 0b287cb..43a9182 100644
--- a/packages/size-limit/test/get-config.test.js
+++ b/packages/size-limit/test/get-config.test.js
@@ -1,4 +1,4 @@
-import { join } from 'node:path'
+import { dirname, join } from 'node:path'
 import { beforeEach, describe, expect, it, vi } from 'vitest'
 
 import calc from '../calc'
@@ -429,6 +429,31 @@ it('normalizes import', async () => {
   })
 })
 
+it('takes config from CLI config argument', async () => {
+  let cwd = 'config-file-from-arg'
+  let configPath = 'src/configs/my-size-limit.config.js'
+  expect(await check(cwd, ['--config', fixture(cwd, configPath)])).toEqual({
+    checks: [
+      {
+        files: [fixture(cwd, 'index.js')],
+        limit: 10,
+        name: 'index',
+        path: '../../index.js',
+        sizeLimit: 10
+      },
+      {
+        files: [fixture(cwd, 'src/main.js')],
+        limit: 20,
+        name: 'main',
+        path: '../main.js',
+        sizeLimit: 20
+      }
+    ],
+    configPath,
+    cwd: fixture(cwd, dirname(configPath))
+  })
+})
+
 const allConfigFileExtensions = ['mjs', 'js', 'cjs', 'ts', 'mts', 'cts']
 const exportTypes = [
   { exportSyntax: 'export default', moduleType: 'esm' },
diff --git a/packages/size-limit/test/run.test.js b/packages/size-limit/test/run.test.js
index ce5b6b2..5f9d449 100644
--- a/packages/size-limit/test/run.test.js
+++ b/packages/size-limit/test/run.test.js
@@ -200,6 +200,23 @@ it('throws on --compare-with argument without value', async () => {
   expect(await error('webpack', ['--why', '--compare-with'])).toMatchSnapshot()
 })
 
+it('throws on --config argument without FILE parameter', async () => {
+  expect(await error('file', ['--config'])).toMatchSnapshot()
+  expect(await error('file', ['--config', '--why'])).toMatchSnapshot()
+})
+
+it('throws on --config argument with invalid FILE parameter', async () => {
+  expect(await error('file', ['--config', 'invalid/config/path'])).toContain(
+    'no such file or directory'
+  )
+})
+
+it('throws on --config argument with invalid FILE extension', async () => {
+  expect(
+    await error('file', ['--config', 'invalid/config/path.extension'])
+  ).toContain('No loader specified for extension')
+})
+
 it('throws on no config', async () => {
   expect(await error('file')).toMatchSnapshot()
 })