diff --git a/README.md b/README.md index 7a04aa1..b60021c 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,9 @@ Need more examples? 1. Multiline tokens like **Selectors, Values, etc.** are rendered on a single line 1. Unknown syntax is rendered as-is, with multi-line formatting kept intact -## Minify CSS +## Options + +### Minify CSS This package also exposes a minifier function since minifying CSS follows many of the same rules as formatting. @@ -79,7 +81,7 @@ let minified = minify('a {}') let formatted_mini = format('a {}', { minify: true }) ``` -## Tab size +### Tab size For cases where you cannot control the tab size with CSS there is an option to override the default tabbed indentation with N spaces. @@ -91,8 +93,36 @@ let formatted = format('a { color: red; }', { }) ``` +## CLI + +This library also ships a CLI tools that's a small wrapper around the library. + +``` +USAGE + format-css [options] [file...] + cat styles.css | format-css [options] + +OPTIONS + --minify Minify the CSS output + --tab-size= Use N spaces for indentation instead of tabs + --help, -h Show this help + +EXAMPLES + # Format a file + format-css styles.css + + # Format with 2-space indentation + format-css styles.css --tab-size=2 + + # Minify + format-css styles.css --minify + + # Via pipe + cat styles.css | format-css +``` + ## Related projects -- [Format CSS online](https://www.projectwallace.com/prettify-css?utm_source=github&utm_medium=wallace_format_css_related_projects) - See this formatter in action online! -- [Minify CSS online](https://www.projectwallace.com/minify-css?utm_source=github&utm_medium=wallace_format_css_related_projects) - See this minifier in action online! -- [CSS Analyzer](https://github.com/projectwallace/css-analyzer) - The best CSS analyzer that powers all analysis on [projectwallace.com](https://www.projectwallace.com?utm_source=github&utm_medium=wallace_format_css_related_projects) +- [Format CSS online](https://www.projectwallace.com/prettify-css) - See this formatter in action online! +- [Minify CSS online](https://www.projectwallace.com/minify-css) - See this minifier in action online! +- [CSS Analyzer](https://github.com/projectwallace/css-analyzer) - The best CSS analyzer that powers all analysis on [projectwallace.com](https://www.projectwallace.com) diff --git a/package-lock.json b/package-lock.json index 51b814e..ff75528 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,8 +11,12 @@ "dependencies": { "@projectwallace/css-parser": "^0.13.5" }, + "bin": { + "format-css": "dist/cli.mjs" + }, "devDependencies": { "@codecov/rollup-plugin": "^1.9.1", + "@types/node": "^25.5.0", "@vitest/coverage-v8": "^4.0.3", "oxfmt": "^0.36.0", "oxlint": "^1.24.0", @@ -22,7 +26,7 @@ "vitest": "^4.0.3" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.12.0" } }, "node_modules/@actions/core": { @@ -1917,6 +1921,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, "node_modules/@vitest/coverage-v8": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.0.tgz", @@ -2343,16 +2357,6 @@ "@types/estree": "^1.0.0" } }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -3484,6 +3488,13 @@ "node": ">=14.0" } }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, "node_modules/universal-user-agent": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.1.tgz", @@ -3611,302 +3622,6 @@ } } }, - "node_modules/vite/node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz", - "integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz", - "integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz", - "integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz", - "integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz", - "integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz", - "integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz", - "integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz", - "integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz", - "integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz", - "integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz", - "integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz", - "integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==", - "dev": true, - "license": "MIT" - }, - "node_modules/vite/node_modules/rolldown": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz", - "integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.115.0", - "@rolldown/pluginutils": "1.0.0-rc.9" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.9", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", - "@rolldown/binding-darwin-x64": "1.0.0-rc.9", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" - } - }, "node_modules/vitest": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", diff --git a/package.json b/package.json index bd959bf..acfe3ef 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,9 @@ "type": "git", "url": "git+https://github.com/projectwallace/format-css.git" }, + "bin": { + "format-css": "./dist/cli.mjs" + }, "files": [ "dist" ], @@ -42,6 +45,7 @@ }, "devDependencies": { "@codecov/rollup-plugin": "^1.9.1", + "@types/node": "^25.5.0", "@vitest/coverage-v8": "^4.0.3", "oxfmt": "^0.36.0", "oxlint": "^1.24.0", @@ -51,7 +55,7 @@ "vitest": "^4.0.3" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.12.0" }, "issues": "https://github.com/projectwallace/format-css/issues" } diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts new file mode 100644 index 0000000..dcd96b4 --- /dev/null +++ b/src/cli/cli.test.ts @@ -0,0 +1,141 @@ +import { test, expect, describe, vi } from 'vitest' +import { resolve } from 'node:path' +import { parse_arguments, run } from './cli.js' + +describe('parse_arguments', () => { + describe('files', () => { + test('no files returns empty array', () => { + let result = parse_arguments([]) + expect(result.files).toEqual([]) + }) + + test('valid file path is resolved to absolute path', () => { + let result = parse_arguments(['styles.css']) + expect(result.files).toEqual([resolve('styles.css')]) + }) + + test('path traversal ../../etc throws', () => { + expect(() => parse_arguments(['../../etc'])).toThrowError() + }) + + test('path traversal ../sibling throws', () => { + expect(() => parse_arguments(['../sibling/file.css'])).toThrowError() + }) + + test('multiple valid files are all resolved', () => { + let result = parse_arguments(['a.css', 'b.css']) + expect(result.files).toEqual([resolve('a.css'), resolve('b.css')]) + }) + }) + + describe('--minify', () => { + test('defaults to false', () => { + expect(parse_arguments([]).minify).toBe(false) + }) + + test('--minify sets minify to true', () => { + expect(parse_arguments(['--minify']).minify).toBe(true) + }) + }) + + describe('--tab-size', () => { + test('defaults to undefined', () => { + expect(parse_arguments([]).tab_size).toBeUndefined() + }) + + test('--tab-size=2 sets tab_size to 2', () => { + expect(parse_arguments(['--tab-size=2']).tab_size).toBe(2) + }) + + test('--tab-size=4 sets tab_size to 4', () => { + expect(parse_arguments(['--tab-size=4']).tab_size).toBe(4) + }) + + test('--tab-size=0 throws', () => { + expect(() => parse_arguments(['--tab-size=0'])).toThrowError() + }) + + test('--tab-size=-1 throws', () => { + expect(() => parse_arguments(['--tab-size=-1'])).toThrowError() + }) + + test('--tab-size=abc throws', () => { + expect(() => parse_arguments(['--tab-size=abc'])).toThrowError() + }) + }) + + test('unknown flag throws', () => { + expect(() => parse_arguments(['--unknown'])).toThrowError() + }) +}) + +describe('run', () => { + function make_io(overrides: Partial[1]> = {}) { + return { + readFile: vi.fn(() => 'a{color:red}'), + readStdin: vi.fn(async () => 'a{color:red}'), + write: vi.fn(), + isTTY: false, + ...overrides, + } + } + + test('--help shows help text', async () => { + let io = make_io({ isTTY: true }) + await run(['--help'], io) + expect(io.write).toHaveBeenCalledOnce() + expect(io.write.mock.calls[0][0]).toContain('USAGE') + }) + + test('-h shows help text', async () => { + let io = make_io({ isTTY: true }) + await run(['-h'], io) + expect(io.write).toHaveBeenCalledOnce() + expect(io.write.mock.calls[0][0]).toContain('USAGE') + }) + + test('no files and isTTY shows help text', async () => { + let io = make_io({ isTTY: true }) + await run([], io) + expect(io.write.mock.calls[0][0]).toContain('USAGE') + }) + + test('reads from stdin when no files and not a TTY', async () => { + let io = make_io({ isTTY: false }) + await run([], io) + expect(io.readStdin).toHaveBeenCalledOnce() + expect(io.write).toHaveBeenCalledOnce() + }) + + test('formats file and writes output', async () => { + let io = make_io({ readFile: vi.fn(() => 'a{color:red}') }) + await run(['styles.css'], io) + expect(io.readFile).toHaveBeenCalledOnce() + expect(io.write).toHaveBeenCalledOnce() + expect(io.write.mock.calls[0][0]).toContain('color: red') + }) + + test('formats multiple files', async () => { + let io = make_io({ readFile: vi.fn(() => 'a{color:red}') }) + await run(['a.css', 'b.css'], io) + expect(io.readFile).toHaveBeenCalledTimes(2) + expect(io.write).toHaveBeenCalledTimes(2) + }) + + test('--minify minifies the output', async () => { + let io = make_io({ readFile: vi.fn(() => 'a { color: red; }') }) + await run(['styles.css', '--minify'], io) + expect(io.write.mock.calls[0][0]).toBe('a{color:red}') + }) + + test('--tab-size=2 uses 2-space indentation', async () => { + let io = make_io({ readFile: vi.fn(() => 'a{color:red}') }) + await run(['styles.css', '--tab-size=2'], io) + expect(io.write.mock.calls[0][0]).toContain(' color') + }) + + test('path traversal throws', async () => { + let io = make_io() + await expect(run(['../../etc/passwd'], io)).rejects.toThrow() + }) +}) diff --git a/src/cli/cli.ts b/src/cli/cli.ts new file mode 100644 index 0000000..ba977e2 --- /dev/null +++ b/src/cli/cli.ts @@ -0,0 +1,130 @@ +#!/usr/bin/env node + +import { parseArgs, styleText } from 'node:util' +import { readFileSync } from 'node:fs' +import { resolve, sep } from 'node:path' +import { fileURLToPath } from 'node:url' + +// import from absolute package name instead of relative import (like ../lib/index.ts) +// to prevent lib/index.js being bundled into cli.js, which would mean that indexjs +// ends up in our /dist twice, which is wasteful +import { format } from '@projectwallace/format-css' + +function help(): string { + return ` +${styleText('bold', 'USAGE')} + format-css [options] [file...] + cat styles.css | format-css [options] + +${styleText('bold', 'OPTIONS')} + --minify Minify the CSS output + --tab-size= Use N spaces for indentation instead of tabs + --help, -h Show this help + +${styleText('bold', 'EXAMPLES')} + ${styleText('dim', '# Format a file')} + format-css styles.css + + ${styleText('dim', '# Format with 2-space indentation')} + format-css styles.css --tab-size=2 + + ${styleText('dim', '# Minify')} + format-css styles.css --minify + + ${styleText('dim', '# Via pipe')} + cat styles.css | format-css + `.trim() +} + +export type CliArguments = { + files: string[] + minify: boolean + tab_size: number | undefined +} + +export function parse_arguments(args: string[]): CliArguments { + const { values, positionals } = parseArgs({ + args, + allowPositionals: true, + options: { + minify: { type: 'boolean', default: false }, + 'tab-size': { type: 'string' }, + }, + }) + + const issues: string[] = [] + + let tab_size: number | undefined + if (values['tab-size'] !== undefined) { + tab_size = Number(values['tab-size']) + if (isNaN(tab_size) || tab_size < 1) { + issues.push('--tab-size must be a positive integer') + } + } + + const cwd = process.cwd() + const files: string[] = [] + for (const file of positionals) { + const resolved = resolve(file) + if (resolved !== cwd && !resolved.startsWith(cwd + sep)) { + issues.push(`Invalid path: ${file}`) + } else { + files.push(resolved) + } + } + + if (issues.length > 0) { + throw new Error(issues.join('\n')) + } + + return { files, minify: values.minify ?? false, tab_size } +} + +export type CliIO = { + readFile: (path: string) => string + readStdin: () => Promise + write: (output: string) => void + isTTY: boolean +} + +export async function run(args: string[], io: CliIO): Promise { + if (args.includes('--help') || args.includes('-h')) { + io.write(help() + '\n') + return + } + + const { files, minify, tab_size } = parse_arguments(args) + const options = { minify, tab_size } + + if (files.length > 0) { + for (const file of files) { + io.write(format(io.readFile(file), options)) + } + } else if (!io.isTTY) { + io.write(format(await io.readStdin(), options)) + } else { + io.write(help() + '\n') + } +} + +async function read_stdin(): Promise { + const chunks: Buffer[] = [] + for await (const chunk of process.stdin) { + chunks.push(chunk) + } + return Buffer.concat(chunks).toString('utf-8') +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + try { + await run(process.argv.slice(2), { + readFile: (path) => readFileSync(path, 'utf-8'), + readStdin: read_stdin, + write: (output) => process.stdout.write(output), + isTTY: process.stdin.isTTY === true, + }) + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + } +} diff --git a/index.ts b/src/lib/index.ts similarity index 100% rename from index.ts rename to src/lib/index.ts diff --git a/test/api.test.ts b/test/api.test.ts index f1fe063..b6629e0 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -1,5 +1,5 @@ import { test, expect } from 'vitest' -import { format } from '../index.js' +import { format } from '../src/lib/index.js' test('empty input', () => { let actual = format(``) diff --git a/test/atrules.test.ts b/test/atrules.test.ts index e53d49e..ed7379f 100644 --- a/test/atrules.test.ts +++ b/test/atrules.test.ts @@ -1,5 +1,5 @@ import { test, expect } from 'vitest' -import { format, minify } from '../index.js' +import { format, minify } from '../src/lib/index.js' test('AtRules start on a new line', () => { let actual = format(` diff --git a/test/comments.test.ts b/test/comments.test.ts index 9d80460..0240784 100644 --- a/test/comments.test.ts +++ b/test/comments.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from 'vitest' -import { format } from '../index.js' +import { format } from '../src/lib/index.js' describe('comments', () => { test('only comment', () => { diff --git a/test/declarations.test.ts b/test/declarations.test.ts index 5f26df7..d915342 100644 --- a/test/declarations.test.ts +++ b/test/declarations.test.ts @@ -1,5 +1,5 @@ import { test, expect } from 'vitest' -import { format } from '../index.js' +import { format } from '../src/lib/index.js' test('Declarations end with a semicolon (;)', () => { let actual = format(` diff --git a/test/minify.test.ts b/test/minify.test.ts index 6496922..8d9416c 100644 --- a/test/minify.test.ts +++ b/test/minify.test.ts @@ -1,5 +1,5 @@ import { test, expect } from 'vitest' -import { minify } from '../index.js' +import { minify } from '../src/lib/index.js' test('empty rule', () => { let actual = minify(`a {}`) diff --git a/test/rules.test.ts b/test/rules.test.ts index e911862..32ba894 100644 --- a/test/rules.test.ts +++ b/test/rules.test.ts @@ -1,5 +1,5 @@ import { test, expect } from 'vitest' -import { format } from '../index.js' +import { format } from '../src/lib/index.js' test('AtRules and Rules start on a new line', () => { let actual = format(` diff --git a/test/selectors.test.ts b/test/selectors.test.ts index 32cfc60..05d9c86 100644 --- a/test/selectors.test.ts +++ b/test/selectors.test.ts @@ -1,5 +1,5 @@ import { test, expect } from 'vitest' -import { format } from '../index.js' +import { format } from '../src/lib/index.js' test('A single selector is rendered without a trailing comma', () => { let actual = format('a {}') diff --git a/test/tab-size.test.ts b/test/tab-size.test.ts index 6532ce6..6790a76 100644 --- a/test/tab-size.test.ts +++ b/test/tab-size.test.ts @@ -1,5 +1,5 @@ import { test, expect } from 'vitest' -import { format } from '../index.js' +import { format } from '../src/lib/index.js' let fixture = ` selector { diff --git a/test/values.test.ts b/test/values.test.ts index 82b0c28..b03965b 100644 --- a/test/values.test.ts +++ b/test/values.test.ts @@ -1,5 +1,5 @@ import { test, expect } from 'vitest' -import { format } from '../index.js' +import { format } from '../src/lib/index.js' test('collapses abundant whitespace', () => { let actual = format(`a { diff --git a/tsconfig.json b/tsconfig.json index 315e0d2..0ed86e1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,25 +2,25 @@ "compilerOptions": { // Base options: "skipLibCheck": true, - "target": "es2022", + "target": "es2024", "verbatimModuleSyntax": true, - "allowJs": true, - "checkJs": true, + "allowJs": false, "moduleDetection": "force", - // Strictness "strict": true, "noUncheckedIndexedAccess": true, - // Type checking, not transpiling "module": "ESNext", "moduleResolution": "bundler", - "allowSyntheticDefaultImports": true, - "noEmit": true, - - // Code runs in the DOM - "lib": ["ES2022", "DOM", "DOM.Iterable"] + "lib": ["es2024", "DOM"], + "declaration": true, + "rootDirs": ["src/lib", "src/cli"], + "outDir": "dist", + "paths": { + // So we can import like an external in the CLI + "@projectwallace/format-css": ["./src/lib/index.ts"] + } }, - "include": ["index.ts"], + "include": ["src/lib/index.ts", "src/cli/cli.ts"], "exclude": ["node_modules"] } diff --git a/tsdown.config.ts b/tsdown.config.ts index 7a9ed47..799fa3a 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,15 +1,33 @@ import { defineConfig } from 'tsdown' import { codecovRollupPlugin } from '@codecov/rollup-plugin' -export default defineConfig({ - entry: 'index.ts', - platform: 'neutral', - publint: true, - plugins: [ - codecovRollupPlugin({ - enableBundleAnalysis: process.env.CODECOV_TOKEN !== undefined, - bundleName: 'formatCss', - uploadToken: process.env.CODECOV_TOKEN, - }), - ], -}) +export default defineConfig([ + { + entry: 'src/lib/index.ts', + platform: 'neutral', + publint: true, + plugins: [ + codecovRollupPlugin({ + enableBundleAnalysis: process.env.CODECOV_TOKEN !== undefined, + bundleName: 'formatCss', + uploadToken: process.env.CODECOV_TOKEN, + }), + ], + }, + { + entry: 'src/cli/cli.ts', + platform: 'node', + dts: false, + // Reference the lib via its package name to avoid bundling it twice + deps: { + neverBundle: ['@projectwallace/format-css'], + }, + plugins: [ + codecovRollupPlugin({ + enableBundleAnalysis: process.env.CODECOV_TOKEN !== undefined, + bundleName: 'formatCssCli', + uploadToken: process.env.CODECOV_TOKEN, + }), + ], + }, +]) diff --git a/vitest.config.ts b/vitest.config.ts index c2eecb0..781a89b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,12 @@ import { defineConfig } from 'vitest/config' +import { resolve } from 'node:path' export default defineConfig({ + resolve: { + alias: { + '@projectwallace/format-css': resolve('./src/lib/index.ts'), + }, + }, test: { coverage: { provider: 'v8',