diff --git a/.changeset/thin-foxes-relax.md b/.changeset/thin-foxes-relax.md new file mode 100644 index 0000000..7f55751 --- /dev/null +++ b/.changeset/thin-foxes-relax.md @@ -0,0 +1,5 @@ +--- +"sonda": minor +--- + +Add the `filename` option to allow changing the report output path diff --git a/packages/sonda/README.md b/packages/sonda/README.md index 8676a12..9219507 100644 --- a/packages/sonda/README.md +++ b/packages/sonda/README.md @@ -143,6 +143,7 @@ Each plugin accepts an optional configuration object with the following options. ```javascript SondaRollupPlugin( { format: 'html', + filename: 'sonda-report.html', open: true, detailed: true, gzip: true, @@ -160,6 +161,15 @@ Determines the output format of the report. The following formats are supported: * `'html'` - HTML file with treemap * `'json'` - JSON file +### `filename` + +* **Type:** `string` +* **Default:** `'sonda-report.html'` or `'sonda-report.json'` depending on the `format` option + +Determines the path of the generated report. The values can be either a filename, a relative path, or an absolute path. + +By default, the report is saved in the current working directory. + ### `open` * **Type:** `boolean` diff --git a/packages/sonda/src/report/generate.ts b/packages/sonda/src/report/generate.ts index b09b1f7..29e6911 100644 --- a/packages/sonda/src/report/generate.ts +++ b/packages/sonda/src/report/generate.ts @@ -1,5 +1,5 @@ -import { join } from 'path'; -import { writeFileSync } from 'fs'; +import { dirname } from 'path'; +import { existsSync, mkdirSync, writeFileSync } from 'fs'; import { generateHtmlReport, generateJsonReport } from '../report.js'; import type { Options, JsonReport } from '../types.js'; import { normalizeOptions } from '../utils.js'; @@ -11,9 +11,18 @@ export async function generateReportFromAssets( ): Promise { const options = normalizeOptions( userOptions ); const handler = options.format === 'html' ? saveHtml : saveJson; - const path = handler( assets, inputs, options ); + const report = handler( assets, inputs, options ); + const outputDirectory = dirname( options.filename ); - if ( !options.open || !path ) { + // Ensure the output directory exists + if ( !existsSync( outputDirectory ) ) { + mkdirSync( outputDirectory, { recursive: true } ); + } + + // Write the report to the file system + writeFileSync( options.filename, report ); + + if ( !options.open ) { return; } @@ -23,31 +32,24 @@ export async function generateReportFromAssets( */ const { default: open } = await import( 'open' ); - open( path ); + // Open the report in the default program for the file extension + open( options.filename ); } function saveHtml( assets: string[], inputs: JsonReport[ 'inputs' ], options: Options -): string | null { - const report = generateHtmlReport( assets, inputs, options ); - const path = join( process.cwd(), 'sonda-report.html' ); - - writeFileSync( path, report ); - - return path; +): string { + return generateHtmlReport( assets, inputs, options ); } function saveJson( assets: string[], inputs: JsonReport[ 'inputs' ], options: Options -): string | null { +): string { const report = generateJsonReport( assets, inputs, options ); - const path = join( process.cwd(), 'sonda-report.json' ); - - writeFileSync( path, JSON.stringify( report, null, 2 ) ); - return path; + return JSON.stringify( report, null, 2 ); } diff --git a/packages/sonda/src/types.ts b/packages/sonda/src/types.ts index 021bee8..fc8096e 100644 --- a/packages/sonda/src/types.ts +++ b/packages/sonda/src/types.ts @@ -8,6 +8,14 @@ export interface Options { */ format: 'html' | 'json'; + /** + * Determines the path of the generated report. The values can be either + * a filename, a relative path, or an absolute path. + * + * @default 'sonda-report.html' or 'sonda-report.json' depending on the `format` option + */ + filename: string; + /** * Determines whether to open the report in the default program for given file * extension (`.html` or `.json` depending on the `format` option) after the build. diff --git a/packages/sonda/src/utils.ts b/packages/sonda/src/utils.ts index 2d72f0c..c0f4470 100644 --- a/packages/sonda/src/utils.ts +++ b/packages/sonda/src/utils.ts @@ -1,21 +1,31 @@ -import { relative, win32, posix } from 'path'; +import { join, relative, win32, posix, extname, isAbsolute, format, parse } from 'path'; import type { Options } from './types'; export const esmRegex: RegExp = /\.m[tj]sx?$/; export const cjsRegex: RegExp = /\.c[tj]sx?$/; export const jsRegexp: RegExp = /\.[cm]?[tj]s[x]?$/; -export function normalizeOptions( options?: Partial ) { +export function normalizeOptions( options?: Partial ): Options { + const format = options?.format + || options?.filename?.split( '.' ).at( -1 ) as Options['format'] + || 'html'; + const defaultOptions: Options = { + format, + filename: 'sonda-report.' + format, open: true, - format: 'html', detailed: false, sources: false, gzip: false, brotli: false, }; - return Object.assign( {}, defaultOptions, options ) as Options; + // Merge user options with the defaults + const normalizedOptions = Object.assign( {}, defaultOptions, options ) satisfies Options; + + normalizedOptions.filename = normalizeOutputPath( normalizedOptions ); + + return normalizedOptions; } export function normalizePath( pathToNormalize: string ): string { @@ -28,3 +38,26 @@ export function normalizePath( pathToNormalize: string ): string { // Ensure paths are POSIX-compliant - https://stackoverflow.com/a/63251716/4617687 return relativized.replaceAll( win32.sep, posix.sep ); } + +function normalizeOutputPath( options: Options ): string { + let path = options.filename; + const expectedExtension = '.' + options.format; + + // Ensure the filename is an absolute path + if ( !isAbsolute( path ) ) { + path = join( process.cwd(), path ); + } + + // Ensure that the `filename` extension matches the `format` option + if ( expectedExtension !== extname( path ) ) { + console.warn( + '\x1b[0;33m' + // Make the message yellow + `Sonda: The file extension specified in the 'filename' does not match the 'format' option. ` + + `The extension will be changed to '${ expectedExtension }'.` + ); + + path = format( { ...parse( path ), base: '', ext: expectedExtension } ) + } + + return path; +} diff --git a/packages/sonda/tests/report.generate.test.ts b/packages/sonda/tests/report.generate.test.ts index 8a8da37..2d1ecc6 100644 --- a/packages/sonda/tests/report.generate.test.ts +++ b/packages/sonda/tests/report.generate.test.ts @@ -7,10 +7,14 @@ const mocks = vi.hoisted( () => ( { generateHtmlReport: vi.fn(), generateJsonReport: vi.fn(), writeFileSync: vi.fn(), + existsSync: vi.fn(), + mkdirSync: vi.fn(), } ) ); vi.mock( 'fs', () => ( { writeFileSync: mocks.writeFileSync, + existsSync: mocks.existsSync, + mkdirSync: mocks.mkdirSync, } ) ); vi.mock( 'open', () => ( { @@ -30,6 +34,8 @@ describe( 'generate.ts', () => { it( 'saves HTML report by default', async () => { await generateReportFromAssets( [], {}, normalizeOptions() ); + expect( mocks.existsSync ).toHaveBeenCalled(); + expect( mocks.mkdirSync ).toHaveBeenCalled(); expect( mocks.writeFileSync ).toHaveBeenCalled(); expect( mocks.generateHtmlReport ).toHaveBeenCalled(); expect( mocks.generateJsonReport ).not.toHaveBeenCalled(); @@ -38,6 +44,8 @@ describe( 'generate.ts', () => { it( 'saves JSON report', async () => { await generateReportFromAssets( [], {}, normalizeOptions( { format: 'json' } ) ); + expect( mocks.existsSync ).toHaveBeenCalled(); + expect( mocks.mkdirSync ).toHaveBeenCalled(); expect( mocks.writeFileSync ).toHaveBeenCalled(); expect( mocks.generateJsonReport ).toHaveBeenCalled(); expect( mocks.generateHtmlReport ).not.toHaveBeenCalled(); diff --git a/packages/sonda/tests/utils.test.ts b/packages/sonda/tests/utils.test.ts index 2cb1f34..aaafafc 100644 --- a/packages/sonda/tests/utils.test.ts +++ b/packages/sonda/tests/utils.test.ts @@ -30,8 +30,9 @@ describe('utils.ts', () => { describe( 'normalizeOptions', () => { it( 'should return default options when no options are provided', () => { expect( normalizeOptions() ).toEqual( { - open: true, format: 'html', + filename: process.cwd() + '/sonda-report.html', + open: true, detailed: false, sources: false, gzip: false, @@ -40,9 +41,10 @@ describe('utils.ts', () => { }); it( 'merges defaults with provided values', () => { - expect( normalizeOptions( { format: 'json' } ) ).toEqual( { - open: true, - format: 'json', + expect( normalizeOptions( { open: false } ) ).toEqual( { + format: 'html', + filename: process.cwd() + '/sonda-report.html', + open: false, detailed: false, sources: false, gzip: false, @@ -52,21 +54,80 @@ describe('utils.ts', () => { it( 'allows overriding all options', () => { expect( normalizeOptions( { - open: false, format: 'json', + filename: __dirname + '/sonda-report.json', + open: false, detailed: true, sources: true, gzip: true, brotli: true, } ) ).toEqual( { - open: false, format: 'json', + filename: __dirname + '/sonda-report.json', + open: false, detailed: true, sources: true, gzip: true, brotli: true, } ); - } ) + } ); + + it( 'ensures the `filename` is an absolute path', () => { + expect( normalizeOptions( { filename: './dist/sonda.json' } ) ).toEqual( { + format: 'json', + filename: process.cwd() + '/dist/sonda.json', + open: true, + detailed: false, + sources: false, + gzip: false, + brotli: false, + } ); + } ); + + it( 'matches the `filename` when `format` is provided', () => { + expect( normalizeOptions( { format: 'json' } ) ).toEqual( { + format: 'json', + filename: process.cwd() + '/sonda-report.json', + open: true, + detailed: false, + sources: false, + gzip: false, + brotli: false, + } ); + } ); + + it( 'matches the `format` when `filename` is provided', () => { + expect( normalizeOptions( { filename: 'sonda.json' } ) ).toEqual( { + format: 'json', + filename: process.cwd() + '/sonda.json', + open: true, + detailed: false, + sources: false, + gzip: false, + brotli: false, + } ); + } ); + + it( 'warns and fixes `format` and `filename` mismatch', () => { + const spy = vi.spyOn( console, 'warn' ).mockImplementationOnce( () => {} ); + + const options = normalizeOptions( { + format: 'json', + filename: __dirname + '/sonda-report.html', + } ); + + expect( spy ).toHaveBeenCalledOnce(); + + expect( options ).toEqual( { + format: 'json', + filename: __dirname + '/sonda-report.json', + open: true, + detailed: false, + sources: false, + gzip: false, + brotli: false, + } ); + } ); } ); describe( 'normalizePath', () => {