From 93989c95f978754a20bfc12d428e824f122a7b22 Mon Sep 17 00:00:00 2001 From: BCsabaEngine Date: Wed, 3 Dec 2025 19:04:48 +0100 Subject: [PATCH 1/6] fix: excluded file list --- src/file.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/file.ts b/src/file.ts index d7e9162..63483ab 100644 --- a/src/file.ts +++ b/src/file.ts @@ -92,13 +92,13 @@ export const getFiles = (): Map => { // Report excluded files if (excludedFiles.length > 0) { - console.log(cyanLog(`\n Excluded ${excludedFiles.length} file(s):`)); + console.log(`\nExcluded ${excludedFiles.length} file(s):`); // Show first 10 excluded files, then summarize if more const displayLimit = 10; - for (const file of excludedFiles.slice(0, displayLimit)) console.log(cyanLog(` - ${file}`)); + for (const file of excludedFiles.slice(0, displayLimit)) console.log(cyanLog(`- ${file}`)); if (excludedFiles.length > displayLimit) - console.log(cyanLog(` ... and ${excludedFiles.length - displayLimit} more`)); + console.log(cyanLog(`... and ${excludedFiles.length - displayLimit} more`)); console.log(); // Blank line for readability } From ee75eaf41cb0be9cd6fa1b6743c9e6fdd2f9ea83 Mon Sep 17 00:00:00 2001 From: BCsabaEngine Date: Wed, 3 Dec 2025 19:04:58 +0100 Subject: [PATCH 2/6] fix: readme --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index d8b20a2..750f4b0 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ In order to be able to easily update OTA, it is important - from the users' poin This npm package provides a solution for **inserting any JS client application into the ESP web server** (PsychicHttp and also ESPAsyncWebServer (https://github.com/ESP32Async/ESPAsyncWebServer) and ESP-IDF available, PsychicHttp is the default). For this, JS, html, css, font, assets, etc. files must be converted to binary byte array. Npm mode is easy to use and easy to **integrate into your CI/CD pipeline**. +> Starting with version v1.11.0, you can exclude files by pattern + > Starting with version v1.10.0, we reduced npm dependencies > Starting with version v1.9.0, code generator for esp-idf is available From e2e1d21de8e6ab81f530878b968d966fafcc1d22 Mon Sep 17 00:00:00 2001 From: BCsabaEngine Date: Wed, 3 Dec 2025 19:31:58 +0100 Subject: [PATCH 3/6] feat: rc file --- .svelteesp32rc.example.json | 13 ++ README.md | 111 +++++++++++++ src/commandLine.ts | 180 ++++++++++++++++++++- test/unit/commandLine.test.ts | 294 +++++++++++++++++++++++++++++++++- 4 files changed, 589 insertions(+), 9 deletions(-) create mode 100644 .svelteesp32rc.example.json diff --git a/.svelteesp32rc.example.json b/.svelteesp32rc.example.json new file mode 100644 index 0000000..ff8bdea --- /dev/null +++ b/.svelteesp32rc.example.json @@ -0,0 +1,13 @@ +{ + "engine": "psychic", + "sourcepath": "./demo/svelte/dist", + "outputfile": "./output.h", + "etag": "true", + "gzip": "true", + "cachetime": 0, + "created": false, + "version": "", + "espmethod": "initSvelteStaticFiles", + "define": "SVELTEESP32", + "exclude": ["*.map", "*.md", "test/**/*"] +} diff --git a/README.md b/README.md index 750f4b0..8d9d25c 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ In order to be able to easily update OTA, it is important - from the users' poin This npm package provides a solution for **inserting any JS client application into the ESP web server** (PsychicHttp and also ESPAsyncWebServer (https://github.com/ESP32Async/ESPAsyncWebServer) and ESP-IDF available, PsychicHttp is the default). For this, JS, html, css, font, assets, etc. files must be converted to binary byte array. Npm mode is easy to use and easy to **integrate into your CI/CD pipeline**. +> Starting with version v1.12.0, you can use .rc file to config + > Starting with version v1.11.0, you can exclude files by pattern > Starting with version v1.10.0, we reduced npm dependencies @@ -427,8 +429,117 @@ You can use the following c++ directives at the project level if you want to con | `--version` | Include a version string in generated header, e.g. `--version=v$npm_package_version` | '' | | `--espmethod` | Name of generated initialization method | `initSvelteStaticFiles` | | `--define` | Prefix of c++ defines (e.g., SVELTEESP32_COUNT) | `SVELTEESP32` | +| `--config` | Use custom RC file path | `.svelteesp32rc.json` | | `-h` | Show help | | +### Configuration File + +You can store frequently-used options in a configuration file to avoid repeating command line arguments. This is especially useful for CI/CD pipelines and team collaboration. + +#### Quick Start + +Create `.svelteesp32rc.json` in your project directory: + +```json +{ + "engine": "psychic", + "sourcepath": "./dist", + "outputfile": "./esp32/include/svelteesp32.h", + "etag": "true", + "gzip": "true", + "cachetime": 86400, + "exclude": ["*.map", "*.md"] +} +``` + +Then simply run: + +```bash +npx svelteesp32 +``` + +No command line arguments needed! + +#### Search Locations + +The tool automatically searches for `.svelteesp32rc.json` in: + +1. Current working directory +2. User home directory + +Or specify a custom location: + +```bash +npx svelteesp32 --config=.svelteesp32rc.prod.json +``` + +#### Configuration Reference + +All CLI options can be specified in the RC file using long-form property names: + +| RC Property | CLI Flag | Type | Example | +| ------------ | ------------- | ------- | ------------------------------------------------ | +| `engine` | `-e` | string | `"psychic"`, `"psychic2"`, `"async"`, `"espidf"` | +| `sourcepath` | `-s` | string | `"./dist"` | +| `outputfile` | `-o` | string | `"./output.h"` | +| `etag` | `--etag` | string | `"true"`, `"false"`, `"compiler"` | +| `gzip` | `--gzip` | string | `"true"`, `"false"`, `"compiler"` | +| `cachetime` | `--cachetime` | number | `86400` | +| `created` | `--created` | boolean | `true`, `false` | +| `version` | `--version` | string | `"v1.0.0"` | +| `espmethod` | `--espmethod` | string | `"initSvelteStaticFiles"` | +| `define` | `--define` | string | `"SVELTEESP32"` | +| `exclude` | `--exclude` | array | `["*.map", "*.md"]` | + +#### CLI Override + +Command line arguments always take precedence over RC file values: + +```bash +# Use RC settings but override etag +npx svelteesp32 --etag=false + +# Use RC settings but add different exclude pattern +npx svelteesp32 --exclude="*.txt" +``` + +#### Multiple Environments + +Create different config files for different environments: + +```bash +# Development build +npx svelteesp32 --config=.svelteesp32rc.dev.json + +# Production build +npx svelteesp32 --config=.svelteesp32rc.prod.json +``` + +#### Exclude Pattern Behavior + +**Replace mode**: When you specify `exclude` patterns in RC file or CLI, they completely replace the defaults. + +- **No exclude specified**: Uses default system exclusions (`.DS_Store`, `Thumbs.db`, `.git`, etc.) +- **RC file has exclude**: Replaces defaults with RC patterns +- **CLI has --exclude**: Replaces RC patterns (or defaults if no RC) + +Example: + +```json +// .svelteesp32rc.json +{ "exclude": ["*.map"] } +``` + +Result: Only `*.map` is excluded (default patterns are replaced) + +To keep defaults, explicitly list them in your RC file: + +```json +{ + "exclude": [".DS_Store", "Thumbs.db", ".git", "*.map", "*.md"] +} +``` + ### Q&A - **How big a frontend application can be placed?** If you compress the content with gzip, even a 3-4Mb assets directory can be placed. This is a serious enough amount to serve a complete application. diff --git a/src/commandLine.ts b/src/commandLine.ts index ad2b0f7..d80e86a 100644 --- a/src/commandLine.ts +++ b/src/commandLine.ts @@ -1,4 +1,8 @@ -import { existsSync, statSync } from 'node:fs'; +import { existsSync, readFileSync, statSync } from 'node:fs'; +import { homedir } from 'node:os'; +import path from 'node:path'; + +import { cyanLog, yellowLog } from './consoleColor'; interface ICopyFilesArguments { engine: 'psychic' | 'psychic2' | 'async' | 'espidf'; @@ -15,10 +19,27 @@ interface ICopyFilesArguments { help?: boolean; } +interface IRcFileConfig { + engine?: 'psychic' | 'psychic2' | 'async' | 'espidf'; + sourcepath?: string; + outputfile?: string; + espmethod?: string; + define?: string; + gzip?: 'true' | 'false' | 'compiler'; + etag?: 'true' | 'false' | 'compiler'; + cachetime?: number; + created?: boolean; + version?: string; + exclude?: string[]; +} + function showHelp(): never { console.log(` svelteesp32 - Svelte JS to ESP32 converter +Configuration: + --config Use custom RC file (default: search for .svelteesp32rc.json) + Options: -e, --engine The engine for which the include file is created (psychic|psychic2|async|espidf) (default: "psychic") @@ -34,6 +55,23 @@ Options: --exclude Exclude files matching glob pattern (repeatable or comma-separated) Examples: --exclude="*.map" --exclude="test/**/*.ts" -h, --help Shows this help + +RC File: + The tool searches for .svelteesp32rc.json in: + 1. Current directory (./.svelteesp32rc.json) + 2. User home directory (~/.svelteesp32rc.json) + + Example RC file (all fields optional): + { + "engine": "psychic", + "sourcepath": "./dist", + "outputfile": "./output.h", + "etag": "true", + "gzip": "true", + "exclude": ["*.map", "*.md"] + } + + CLI arguments override RC file values. `); process.exit(0); } @@ -61,8 +99,110 @@ const DEFAULT_EXCLUDE_PATTERNS = [ '.gitattributes' // Git attributes file ]; +function findRcFile(customConfigPath?: string): string | undefined { + // If --config specified, use that exclusively + if (customConfigPath) { + if (existsSync(customConfigPath)) return customConfigPath; + throw new Error(`Config file not found: ${customConfigPath}`); + } + + // Check current directory + for (const filename of ['.svelteesp32rc.json', '.svelteesp32rc']) { + const cwdPath = path.join(process.cwd(), filename); + if (existsSync(cwdPath)) return cwdPath; + } + + // Check home directory + const homeDirectory = homedir(); + for (const filename of ['.svelteesp32rc.json', '.svelteesp32rc']) { + const homePath = path.join(homeDirectory, filename); + if (existsSync(homePath)) return homePath; + } + + return undefined; +} + +function loadRcFile(rcPath: string): IRcFileConfig { + try { + const content = readFileSync(rcPath, 'utf8'); + const config = JSON.parse(content); + return validateRcConfig(config, rcPath); + } catch (error) { + if (error instanceof SyntaxError) throw new Error(`Invalid JSON in RC file ${rcPath}: ${error.message}`); + + throw error; + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function validateRcConfig(config: any, rcPath: string): IRcFileConfig { + if (typeof config !== 'object' || config === null) throw new Error(`RC file ${rcPath} must contain a JSON object`); + + const validKeys = new Set([ + 'engine', + 'sourcepath', + 'outputfile', + 'espmethod', + 'define', + 'gzip', + 'etag', + 'cachetime', + 'created', + 'version', + 'exclude' + ]); + + // Warn about unknown keys + for (const key of Object.keys(config)) + if (!validKeys.has(key)) console.warn(yellowLog(`Warning: Unknown property '${key}' in RC file ${rcPath}`)); + + // Validate individual properties + if (config.engine !== undefined) config.engine = validateEngine(config.engine); + + if (config.etag !== undefined) config.etag = validateTriState(config.etag, 'etag'); + + if (config.gzip !== undefined) config.gzip = validateTriState(config.gzip, 'gzip'); + + if (config.cachetime !== undefined && (typeof config.cachetime !== 'number' || Number.isNaN(config.cachetime))) + throw new TypeError(`Invalid cachetime in RC file: ${config.cachetime}`); + + if (config.exclude !== undefined) { + if (!Array.isArray(config.exclude)) throw new TypeError("'exclude' in RC file must be an array"); + + // Validate each exclude pattern is a string + for (const pattern of config.exclude) + if (typeof pattern !== 'string') throw new TypeError('All exclude patterns must be strings'); + } + + return config; +} + function parseArguments(): ICopyFilesArguments { const arguments_ = process.argv.slice(2); + + // STEP 1: Check for --config flag first + let customConfigPath: string | undefined; + for (let index = 0; index < arguments_.length; index++) { + const argument = arguments_[index]; + if (!argument) continue; + + if (argument === '--config' && arguments_[index + 1]) { + customConfigPath = arguments_[index + 1]; + break; + } + if (argument.startsWith('--config=')) { + customConfigPath = argument.slice('--config='.length); + break; + } + } + + // STEP 2: Find and load RC file + const rcPath = findRcFile(customConfigPath); + const rcConfig = rcPath ? loadRcFile(rcPath) : {}; + + if (rcPath) console.log(cyanLog(`[SvelteESP32] Using config from: ${rcPath}`)); + + // STEP 3: Initialize with defaults const result: Partial = { engine: 'psychic', outputfile: 'svelteesp32.h', @@ -76,6 +216,24 @@ function parseArguments(): ICopyFilesArguments { exclude: [...DEFAULT_EXCLUDE_PATTERNS] }; + // STEP 4: Merge RC file values + if (rcConfig.engine) result.engine = rcConfig.engine; + if (rcConfig.sourcepath) result.sourcepath = rcConfig.sourcepath; + if (rcConfig.outputfile) result.outputfile = rcConfig.outputfile; + if (rcConfig.etag) result.etag = rcConfig.etag; + if (rcConfig.gzip) result.gzip = rcConfig.gzip; + if (rcConfig.cachetime !== undefined) result.cachetime = rcConfig.cachetime; + if (rcConfig.created !== undefined) result.created = rcConfig.created; + if (rcConfig.version) result.version = rcConfig.version; + if (rcConfig.espmethod) result.espmethod = rcConfig.espmethod; + if (rcConfig.define) result.define = rcConfig.define; + + // Replace defaults with RC exclude if provided + if (rcConfig.exclude && rcConfig.exclude.length > 0) result.exclude = [...rcConfig.exclude]; + + // STEP 5: Parse CLI arguments + const cliExclude: string[] = []; + for (let index = 0; index < arguments_.length; index++) { const argument = arguments_[index]; @@ -95,6 +253,9 @@ function parseArguments(): ICopyFilesArguments { const flagName = flag.slice(2); switch (flagName) { + case 'config': + // Already processed, skip + break; case 'engine': result.engine = validateEngine(value); break; @@ -129,8 +290,7 @@ function parseArguments(): ICopyFilesArguments { .split(',') .map((p) => p.trim()) .filter(Boolean); - result.exclude = result.exclude || [...DEFAULT_EXCLUDE_PATTERNS]; - result.exclude.push(...patterns); + cliExclude.push(...patterns); break; } default: @@ -179,6 +339,10 @@ function parseArguments(): ICopyFilesArguments { if (!nextArgument || nextArgument.startsWith('-')) throw new Error(`Missing value for flag: ${argument}`); switch (flag) { + case 'config': + // Already processed, skip + index++; + break; case 'engine': result.engine = validateEngine(nextArgument); index++; @@ -222,8 +386,7 @@ function parseArguments(): ICopyFilesArguments { .split(',') .map((p) => p.trim()) .filter(Boolean); - result.exclude = result.exclude || [...DEFAULT_EXCLUDE_PATTERNS]; - result.exclude.push(...patterns); + cliExclude.push(...patterns); index++; break; } @@ -236,9 +399,12 @@ function parseArguments(): ICopyFilesArguments { throw new Error(`Unknown argument: ${argument}`); } - // Validate required arguments + // STEP 6: Apply CLI exclude (replaces RC/defaults) + if (cliExclude.length > 0) result.exclude = [...cliExclude]; + + // STEP 7: Validate required arguments if (!result.sourcepath) { - console.error('Error: --sourcepath is required'); + console.error('Error: --sourcepath is required (can be specified in RC file or CLI)'); showHelp(); } diff --git a/test/unit/commandLine.test.ts b/test/unit/commandLine.test.ts index 517c45a..362c3b0 100644 --- a/test/unit/commandLine.test.ts +++ b/test/unit/commandLine.test.ts @@ -4,20 +4,27 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('node:fs', () => ({ existsSync: vi.fn(() => true), + readFileSync: vi.fn(() => '{}'), statSync: vi.fn(() => ({ isDirectory: () => true })) })); +vi.mock('node:os', () => ({ + homedir: vi.fn(() => '/home/user') +})); + describe('commandLine', () => { const originalArgv = process.argv; const originalExit = process.exit; const originalConsoleLog = console.log; const originalConsoleError = console.error; + const originalConsoleWarn = console.warn; beforeEach(() => { vi.clearAllMocks(); process.exit = vi.fn() as never; console.log = vi.fn(); console.error = vi.fn(); + console.warn = vi.fn(); }); afterEach(() => { @@ -25,6 +32,7 @@ describe('commandLine', () => { process.exit = originalExit; console.log = originalConsoleLog; console.error = originalConsoleError; + console.warn = originalConsoleWarn; vi.resetModules(); }); @@ -137,7 +145,7 @@ describe('commandLine', () => { const { cmdLine } = await import('../../src/commandLine'); expect(cmdLine.exclude).toContain('*.map'); - expect(cmdLine.exclude).toContain('.DS_Store'); // Default patterns still present + expect(cmdLine.exclude).not.toContain('.DS_Store'); // Replace mode: defaults replaced by CLI }); it('should parse multiple exclude patterns with repeated flag', async () => { @@ -280,7 +288,9 @@ describe('commandLine', () => { await import('../../src/commandLine').catch(() => {}); - expect(console.error).toHaveBeenCalledWith('Error: --sourcepath is required'); + expect(console.error).toHaveBeenCalledWith( + 'Error: --sourcepath is required (can be specified in RC file or CLI)' + ); expect(process.exit).toHaveBeenCalledWith(0); }); }); @@ -311,4 +321,284 @@ describe('commandLine', () => { expect(process.exit).toHaveBeenCalledWith(1); }); }); + + describe('RC file support', () => { + describe('RC file discovery', () => { + it('should load RC file from current directory', async () => { + const mockRcContent = JSON.stringify({ + engine: 'async', + sourcepath: '/test/dist', + etag: 'true' + }); + + const fsModule = await import('node:fs'); + vi.mocked(fsModule.existsSync).mockImplementation((path) => { + if (path === '/test/dist') return true; + if (path.toString().includes('.svelteesp32rc.json')) return true; + return false; + }); + vi.mocked(fsModule.readFileSync).mockReturnValue(mockRcContent); + vi.mocked(fsModule.statSync).mockReturnValue({ isDirectory: () => true } as fs.Stats); + + process.argv = ['node', 'script.js']; + + const { cmdLine } = await import('../../src/commandLine'); + + expect(cmdLine.engine).toBe('async'); + expect(cmdLine.etag).toBe('true'); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Using config from:')); + }); + + it('should prefer .svelteesp32rc.json over .svelteesp32rc', async () => { + const mockRcContent = JSON.stringify({ engine: 'psychic2', sourcepath: '/test/dist' }); + + const fsModule = await import('node:fs'); + vi.mocked(fsModule.existsSync).mockImplementation((path) => { + const pathString = path.toString(); + if (pathString.includes('.svelteesp32rc.json')) return true; + if (pathString.includes('.svelteesp32rc')) return true; + return pathString === '/test/dist'; + }); + vi.mocked(fsModule.readFileSync).mockReturnValue(mockRcContent); + vi.mocked(fsModule.statSync).mockReturnValue({ isDirectory: () => true } as fs.Stats); + + process.argv = ['node', 'script.js']; + + const { cmdLine } = await import('../../src/commandLine'); + + expect(cmdLine.engine).toBe('psychic2'); + }); + + it('should load RC file from home directory when not in current directory', async () => { + const mockRcContent = JSON.stringify({ engine: 'espidf', sourcepath: '/test/dist' }); + + const fsModule = await import('node:fs'); + const osModule = await import('node:os'); + vi.mocked(osModule.homedir).mockReturnValue('/home/user'); + vi.mocked(fsModule.existsSync).mockImplementation((path) => { + const pathString = path.toString(); + if (pathString.includes('/home/user/.svelteesp32rc.json')) return true; + return pathString === '/test/dist'; + }); + vi.mocked(fsModule.readFileSync).mockReturnValue(mockRcContent); + vi.mocked(fsModule.statSync).mockReturnValue({ isDirectory: () => true } as fs.Stats); + + process.argv = ['node', 'script.js']; + + const { cmdLine } = await import('../../src/commandLine'); + + expect(cmdLine.engine).toBe('espidf'); + }); + + it('should use custom config path when --config specified', async () => { + const mockRcContent = JSON.stringify({ engine: 'async', sourcepath: '/test/dist' }); + + const fsModule = await import('node:fs'); + vi.mocked(fsModule.existsSync).mockImplementation((path) => { + return path === '/custom/config.json' || path === '/test/dist'; + }); + vi.mocked(fsModule.readFileSync).mockReturnValue(mockRcContent); + vi.mocked(fsModule.statSync).mockReturnValue({ isDirectory: () => true } as fs.Stats); + + process.argv = ['node', 'script.js', '--config=/custom/config.json']; + + const { cmdLine } = await import('../../src/commandLine'); + + expect(cmdLine.engine).toBe('async'); + }); + + it('should throw error when custom config file not found', async () => { + const fsModule = await import('node:fs'); + vi.mocked(fsModule.existsSync).mockReturnValue(false); + + process.argv = ['node', 'script.js', '--config=/missing/config.json']; + + await expect(import('../../src/commandLine')).rejects.toThrow('Config file not found: /missing/config.json'); + }); + }); + + describe('CLI override', () => { + it('should override RC file values with CLI arguments', async () => { + const mockRcContent = JSON.stringify({ + engine: 'async', + sourcepath: '/test/dist', + etag: 'false', + outputfile: 'rc-output.h' + }); + + const fsModule = await import('node:fs'); + vi.mocked(fsModule.existsSync).mockReturnValue(true); + vi.mocked(fsModule.readFileSync).mockReturnValue(mockRcContent); + vi.mocked(fsModule.statSync).mockReturnValue({ isDirectory: () => true } as fs.Stats); + + process.argv = ['node', 'script.js', '--etag=true', '--outputfile=cli-output.h']; + + const { cmdLine } = await import('../../src/commandLine'); + + expect(cmdLine.engine).toBe('async'); // From RC + expect(cmdLine.sourcepath).toBe('/test/dist'); // From RC + expect(cmdLine.etag).toBe('true'); // Overridden by CLI + expect(cmdLine.outputfile).toBe('cli-output.h'); // Overridden by CLI + }); + + it('should allow sourcepath from RC file only', async () => { + const mockRcContent = JSON.stringify({ sourcepath: '/test/dist' }); + + const fsModule = await import('node:fs'); + vi.mocked(fsModule.existsSync).mockReturnValue(true); + vi.mocked(fsModule.readFileSync).mockReturnValue(mockRcContent); + vi.mocked(fsModule.statSync).mockReturnValue({ isDirectory: () => true } as fs.Stats); + + process.argv = ['node', 'script.js']; + + const { cmdLine } = await import('../../src/commandLine'); + + expect(cmdLine.sourcepath).toBe('/test/dist'); + }); + }); + + describe('Exclude pattern replace mode', () => { + it('should use default exclude patterns when no RC or CLI exclude', async () => { + process.argv = ['node', 'script.js', '--sourcepath=/test/dist']; + + const { cmdLine } = await import('../../src/commandLine'); + + expect(cmdLine.exclude).toContain('.DS_Store'); + expect(cmdLine.exclude).toContain('Thumbs.db'); + expect(cmdLine.exclude).toContain('.git'); + }); + + it('should replace defaults with RC exclude patterns', async () => { + const mockRcContent = JSON.stringify({ + sourcepath: '/test/dist', + exclude: ['*.map', '*.md'] + }); + + const fsModule = await import('node:fs'); + vi.mocked(fsModule.existsSync).mockReturnValue(true); + vi.mocked(fsModule.readFileSync).mockReturnValue(mockRcContent); + vi.mocked(fsModule.statSync).mockReturnValue({ isDirectory: () => true } as fs.Stats); + + process.argv = ['node', 'script.js']; + + const { cmdLine } = await import('../../src/commandLine'); + + expect(cmdLine.exclude).toContain('*.map'); + expect(cmdLine.exclude).toContain('*.md'); + expect(cmdLine.exclude).not.toContain('.DS_Store'); // Defaults replaced + }); + + it('should replace RC exclude patterns with CLI exclude', async () => { + const mockRcContent = JSON.stringify({ + sourcepath: '/test/dist', + exclude: ['*.map', '*.md'] + }); + + const fsModule = await import('node:fs'); + vi.mocked(fsModule.existsSync).mockReturnValue(true); + vi.mocked(fsModule.readFileSync).mockReturnValue(mockRcContent); + vi.mocked(fsModule.statSync).mockReturnValue({ isDirectory: () => true } as fs.Stats); + + process.argv = ['node', 'script.js', '--exclude=*.txt']; + + const { cmdLine } = await import('../../src/commandLine'); + + expect(cmdLine.exclude).toContain('*.txt'); // From CLI + expect(cmdLine.exclude).not.toContain('*.map'); // RC replaced + expect(cmdLine.exclude).not.toContain('.DS_Store'); // Defaults replaced + }); + }); + + describe('RC file validation', () => { + it('should throw error for invalid JSON', async () => { + const fsModule = await import('node:fs'); + vi.mocked(fsModule.existsSync).mockReturnValue(true); + vi.mocked(fsModule.readFileSync).mockReturnValue('{ invalid json }'); + + process.argv = ['node', 'script.js']; + + await expect(import('../../src/commandLine')).rejects.toThrow('Invalid JSON in RC file'); + }); + + it('should validate engine values from RC file', async () => { + const mockRcContent = JSON.stringify({ + sourcepath: '/test/dist', + engine: 'invalid' + }); + + const fsModule = await import('node:fs'); + vi.mocked(fsModule.existsSync).mockReturnValue(true); + vi.mocked(fsModule.readFileSync).mockReturnValue(mockRcContent); + + process.argv = ['node', 'script.js']; + + await expect(import('../../src/commandLine')).rejects.toThrow('Invalid engine: invalid'); + }); + + it('should validate etag tri-state values from RC file', async () => { + const mockRcContent = JSON.stringify({ + sourcepath: '/test/dist', + etag: 'invalid' + }); + + const fsModule = await import('node:fs'); + vi.mocked(fsModule.existsSync).mockReturnValue(true); + vi.mocked(fsModule.readFileSync).mockReturnValue(mockRcContent); + + process.argv = ['node', 'script.js']; + + await expect(import('../../src/commandLine')).rejects.toThrow('Invalid etag: invalid'); + }); + + it('should validate cachetime is a number in RC file', async () => { + const mockRcContent = JSON.stringify({ + sourcepath: '/test/dist', + cachetime: 'notanumber' + }); + + const fsModule = await import('node:fs'); + vi.mocked(fsModule.existsSync).mockReturnValue(true); + vi.mocked(fsModule.readFileSync).mockReturnValue(mockRcContent); + + process.argv = ['node', 'script.js']; + + await expect(import('../../src/commandLine')).rejects.toThrow('Invalid cachetime in RC file'); + }); + + it('should validate exclude is an array in RC file', async () => { + const mockRcContent = JSON.stringify({ + sourcepath: '/test/dist', + exclude: '*.map' + }); + + const fsModule = await import('node:fs'); + vi.mocked(fsModule.existsSync).mockReturnValue(true); + vi.mocked(fsModule.readFileSync).mockReturnValue(mockRcContent); + + process.argv = ['node', 'script.js']; + + await expect(import('../../src/commandLine')).rejects.toThrow("'exclude' in RC file must be an array"); + }); + + it('should warn about unknown properties in RC file', async () => { + const mockRcContent = JSON.stringify({ + sourcepath: '/test/dist', + unknownProp: 'value', + anotherUnknown: 'test' + }); + + const fsModule = await import('node:fs'); + vi.mocked(fsModule.existsSync).mockReturnValue(true); + vi.mocked(fsModule.readFileSync).mockReturnValue(mockRcContent); + vi.mocked(fsModule.statSync).mockReturnValue({ isDirectory: () => true } as fs.Stats); + + process.argv = ['node', 'script.js']; + + await import('../../src/commandLine'); + + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("Unknown property 'unknownProp'")); + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("Unknown property 'anotherUnknown'")); + }); + }); + }); }); From 20f6de7f2ebd493304c4cb7c3ea356fcf40df61c Mon Sep 17 00:00:00 2001 From: BCsabaEngine Date: Thu, 4 Dec 2025 07:43:12 +0100 Subject: [PATCH 4/6] fix: lint --- src/commandLine.ts | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/src/commandLine.ts b/src/commandLine.ts index d80e86a..2dd5e95 100644 --- a/src/commandLine.ts +++ b/src/commandLine.ts @@ -134,10 +134,12 @@ function loadRcFile(rcPath: string): IRcFileConfig { } } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function validateRcConfig(config: any, rcPath: string): IRcFileConfig { +function validateRcConfig(config: unknown, rcPath: string): IRcFileConfig { if (typeof config !== 'object' || config === null) throw new Error(`RC file ${rcPath} must contain a JSON object`); + // Type assertion after runtime check + const configObject = config as Record; + const validKeys = new Set([ 'engine', 'sourcepath', @@ -153,28 +155,33 @@ function validateRcConfig(config: any, rcPath: string): IRcFileConfig { ]); // Warn about unknown keys - for (const key of Object.keys(config)) + for (const key of Object.keys(configObject)) if (!validKeys.has(key)) console.warn(yellowLog(`Warning: Unknown property '${key}' in RC file ${rcPath}`)); // Validate individual properties - if (config.engine !== undefined) config.engine = validateEngine(config.engine); + if (configObject['engine'] !== undefined) configObject['engine'] = validateEngine(configObject['engine'] as string); - if (config.etag !== undefined) config.etag = validateTriState(config.etag, 'etag'); + if (configObject['etag'] !== undefined) + configObject['etag'] = validateTriState(configObject['etag'] as string, 'etag'); - if (config.gzip !== undefined) config.gzip = validateTriState(config.gzip, 'gzip'); + if (configObject['gzip'] !== undefined) + configObject['gzip'] = validateTriState(configObject['gzip'] as string, 'gzip'); - if (config.cachetime !== undefined && (typeof config.cachetime !== 'number' || Number.isNaN(config.cachetime))) - throw new TypeError(`Invalid cachetime in RC file: ${config.cachetime}`); + if ( + configObject['cachetime'] !== undefined && + (typeof configObject['cachetime'] !== 'number' || Number.isNaN(configObject['cachetime'])) + ) + throw new TypeError(`Invalid cachetime in RC file: ${configObject['cachetime']}`); - if (config.exclude !== undefined) { - if (!Array.isArray(config.exclude)) throw new TypeError("'exclude' in RC file must be an array"); + if (configObject['exclude'] !== undefined) { + if (!Array.isArray(configObject['exclude'])) throw new TypeError("'exclude' in RC file must be an array"); // Validate each exclude pattern is a string - for (const pattern of config.exclude) + for (const pattern of configObject['exclude']) if (typeof pattern !== 'string') throw new TypeError('All exclude patterns must be strings'); } - return config; + return configObject as IRcFileConfig; } function parseArguments(): ICopyFilesArguments { From 1aae0672022c6b1d94298dc2edf273d403689cff Mon Sep 17 00:00:00 2001 From: BCsabaEngine Date: Thu, 4 Dec 2025 07:43:19 +0100 Subject: [PATCH 5/6] release: 1.12.0 --- CHANGELOG.md | 41 +++++++++++++++++++++++++++++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23b60ea..32b9a20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.12.0] - 2025-12-04 + +### Added + +- **Configuration File Support**: New RC file feature (`.svelteesp32rc.json`) for storing frequently-used options + - Automatic search in current directory and user home directory + - `--config` flag for specifying custom RC file path + - All CLI options can be configured in RC file using long-form property names + - CLI arguments always override RC file values (3-stage merge: defaults → RC → CLI) + - **Replace mode** for exclude patterns: RC or CLI exclude completely replaces defaults + - Cyan-colored console output showing which RC file was loaded + - Comprehensive validation with unknown property warnings to catch typos + - Example RC file (`.svelteesp32rc.example.json`) included in repository +- 16 new unit tests for RC file functionality: + - RC file discovery (current directory, home directory, custom path) + - RC file parsing and validation (invalid JSON, invalid values, unknown properties) + - CLI override behavior + - Exclude pattern replace mode + - Backward compatibility +- Updated test coverage to 84.32% for `commandLine.ts` (up from 84.56%) +- TypeScript type safety improvements: replaced `any` with `unknown` in `validateRcConfig()` + +### Changed + +- Enhanced `commandLine.ts` with RC file loading, validation, and merging logic +- Updated help text with RC file documentation and examples +- Enhanced README.md with comprehensive "Configuration File" section: + - Quick start guide with example RC file + - Configuration reference table mapping RC properties to CLI flags + - CLI override examples + - Multiple environment setup guide (dev/prod configs) + - Exclude pattern behavior documentation +- Updated command line options table with `--config` flag +- Error message for missing `--sourcepath` now mentions RC file option +- All 92 tests passing with new RC file test suite + +### Fixed + +- ESLint error: replaced `any` type with `unknown` in configuration validation + ## [1.11.0] - 2025-12-03 ### Added @@ -266,6 +306,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - CLI interface with `-s`, `-e`, `-o` options - `index.html` automatic default route handling +[1.12.0]: https://github.com/BCsabaEngine/svelteesp32/compare/v1.11.0...v1.12.0 [1.11.0]: https://github.com/BCsabaEngine/svelteesp32/compare/v1.10.0...v1.11.0 [1.10.0]: https://github.com/BCsabaEngine/svelteesp32/compare/v1.9.4...v1.10.0 [1.9.4]: https://github.com/BCsabaEngine/svelteesp32/compare/v1.9.3...v1.9.4 diff --git a/package-lock.json b/package-lock.json index 0974eae..34ea2ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "svelteesp32", - "version": "1.11.0", + "version": "1.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "svelteesp32", - "version": "1.11.0", + "version": "1.12.0", "license": "ISC", "dependencies": { "handlebars": "^4.7.8", diff --git a/package.json b/package.json index 3291079..cdae9a0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "svelteesp32", - "version": "1.11.0", + "version": "1.12.0", "description": "Convert Svelte (or any frontend) JS application to serve it from ESP32 webserver (PsychicHttp)", "author": "BCsabaEngine", "license": "ISC", From 509ff633d741396626d5c986cbf62ca8f32392ba Mon Sep 17 00:00:00 2001 From: BCsabaEngine Date: Thu, 4 Dec 2025 07:47:32 +0100 Subject: [PATCH 6/6] fix: typo --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8d9d25c..0e2633f 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ In order to be able to easily update OTA, it is important - from the users' poin This npm package provides a solution for **inserting any JS client application into the ESP web server** (PsychicHttp and also ESPAsyncWebServer (https://github.com/ESP32Async/ESPAsyncWebServer) and ESP-IDF available, PsychicHttp is the default). For this, JS, html, css, font, assets, etc. files must be converted to binary byte array. Npm mode is easy to use and easy to **integrate into your CI/CD pipeline**. -> Starting with version v1.12.0, you can use .rc file to config +> Starting with version v1.12.0, you can use RC file for configuration > Starting with version v1.11.0, you can exclude files by pattern @@ -88,13 +88,13 @@ npm run fix ### Usage -**Install package** as devDependency (it is practical if the package is part of the project so that you always receive updates) +**Install package** as dev dependency (it is practical if the package is part of the project so that you always receive updates) ```bash npm install -D svelteesp32 ``` -After a successful Svelte build (rollup/webpack/vite) **create an includeable c++ header** file +After a successful Svelte build (rollup/webpack/vite) **create an includable c++ header** file ```bash // for PsychicHttpServer