From 0baa36f35cb1109e8cb1bd1dc20f0c1fa9682ef4 Mon Sep 17 00:00:00 2001 From: BCsabaEngine Date: Thu, 4 Dec 2025 20:57:16 +0100 Subject: [PATCH 1/2] feat: npm variable interpolation --- .svelteesp32rc.example.json | 5 +- CHANGELOG.md | 55 ++++++ CLAUDE.md | 89 +++++++++ README.md | 85 +++++++++ src/commandLine.ts | 107 ++++++++++- test/unit/commandLine.test.ts | 344 ++++++++++++++++++++++++++++++++++ 6 files changed, 682 insertions(+), 3 deletions(-) diff --git a/.svelteesp32rc.example.json b/.svelteesp32rc.example.json index ff8bdea..c2a2880 100644 --- a/.svelteesp32rc.example.json +++ b/.svelteesp32rc.example.json @@ -1,4 +1,5 @@ { + "_comment": "This RC file demonstrates npm variable interpolation. Variables like $npm_package_version are replaced with values from package.json in the same directory.", "engine": "psychic", "sourcepath": "./demo/svelte/dist", "outputfile": "./output.h", @@ -6,8 +7,8 @@ "gzip": "true", "cachetime": 0, "created": false, - "version": "", + "version": "v$npm_package_version", "espmethod": "initSvelteStaticFiles", - "define": "SVELTEESP32", + "define": "$npm_package_name", "exclude": ["*.map", "*.md", "test/**/*"] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ba038a..876df0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,60 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.13.0] - 2025-12-04 + +### Added + +- **NPM Package Variable Interpolation**: RC files now support automatic variable substitution from `package.json` + - Syntax: `$npm_package_` (e.g., `$npm_package_version`, `$npm_package_name`) + - Supported in all string fields: `version`, `define`, `sourcepath`, `outputfile`, `espmethod`, and `exclude` patterns + - Nested field support: `$npm_package_repository_type` accesses `packageJson.repository.type` + - Multiple variables in one field: `"$npm_package_name-v$npm_package_version"` → `"myapp-v1.2.3"` + - Smart regex pattern stops at underscore + uppercase (e.g., `$npm_package_name_STATIC` → interpolates `name`, keeps `_STATIC`) + - `package.json` must exist in same directory as RC file + - Clear error messages when variables are used but `package.json` not found, listing affected fields + - Unknown variables left unchanged (not an error) for flexibility +- 5 new core functions in `src/commandLine.ts` (lines 125-220): + - `findPackageJson()` - Locates package.json in RC file directory + - `parsePackageJson()` - Reads and parses package.json with error handling + - `getNpmPackageVariable()` - Extracts values using underscore-separated path traversal + - `checkStringForNpmVariable()` - Helper for variable detection + - `hasNpmVariables()` - Optimization to skip interpolation when not needed + - `interpolateNpmVariables()` - Main function processing all string fields +- 20+ comprehensive unit tests in `test/unit/commandLine.test.ts` (lines 604-952): + - Simple field extraction (`$npm_package_version`) + - Nested field extraction (`$npm_package_repository_type`) + - Multiple variables in one string + - Exclude array pattern interpolation + - Error handling (missing package.json, invalid JSON) + - Integration tests with RC file loading +- Updated `.svelteesp32rc.example.json` with variable interpolation examples + +### Changed + +- Enhanced RC file loading flow to interpolate npm variables before validation +- Interpolation integrated seamlessly: defaults → RC file (with interpolation) → CLI arguments +- Updated README.md with comprehensive "NPM Package Variable Interpolation" section: + - Syntax explanation and examples + - Nested field access documentation + - Multiple variable usage examples + - Requirements and error behavior + - Common use cases (version sync, dynamic naming, CI/CD) +- Updated CLAUDE.md with detailed implementation documentation: + - Core functions and processing flow + - Regex pattern design rationale + - Error handling strategy + - Testing coverage details + - Example usage and benefits +- All 118 tests passing (69 commandLine tests including 20 new npm interpolation tests) + +### Use Cases + +- **Version Synchronization**: `"version": "v$npm_package_version"` keeps header version in sync with package.json +- **Dynamic C++ Defines**: `"define": "$npm_package_name"` uses actual package name for defines +- **CI/CD Integration**: Reusable RC files across different projects with variable package names +- **Single Source of Truth**: Project metadata maintained only in package.json + ## [1.12.1] - 2025-12-04 ### Changed @@ -320,6 +374,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.13.0]: https://github.com/BCsabaEngine/svelteesp32/compare/v1.12.1...v1.13.0 [1.12.1]: https://github.com/BCsabaEngine/svelteesp32/compare/v1.12.0...v1.12.1 [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 diff --git a/CLAUDE.md b/CLAUDE.md index ec05268..dd6b2c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -310,3 +310,92 @@ The generated header file includes a `//config:` comment at the top that display - Shows all configuration parameters: `engine`, `sourcepath`, `outputfile`, `etag`, `gzip`, `cachetime`, `espmethod`, `define`, and `exclude` patterns - Works consistently whether configuration comes from RC file, CLI arguments, or both - Provides complete traceability of the configuration used for code generation + +## NPM Package Variable Interpolation + +RC files support automatic variable interpolation from `package.json`, allowing dynamic configuration based on project metadata. + +**Feature:** Variables like `$npm_package_version` and `$npm_package_name` in RC files are automatically replaced with values from `package.json` located in the same directory as the RC file. + +**Supported Fields:** All string fields in RC configuration: + +- `version` - e.g., `"v$npm_package_version"` +- `define` - e.g., `"$npm_package_name_STATIC"` +- `sourcepath` - e.g., `"./$npm_package_name/dist"` +- `outputfile` - e.g., `"./output_$npm_package_version.h"` +- `espmethod` - e.g., `"init$npm_package_name"` +- `exclude` patterns - e.g., `["$npm_package_name.map"]` + +**Variable Syntax:** + +- Simple fields: `$npm_package_version` → `packageJson.version` +- Nested fields: `$npm_package_repository_type` → `packageJson.repository.type` +- Multiple variables: `"$npm_package_name-v$npm_package_version"` → `"myapp-v1.2.3"` + +**Implementation** (`src/commandLine.ts` lines 125-220): + +**Core Functions:** + +1. **`findPackageJson(rcFilePath: string)`** - Locates package.json in the same directory as RC file +2. **`parsePackageJson(packageJsonPath: string)`** - Reads and parses package.json with error handling +3. **`getNpmPackageVariable(packageJson, variableName)`** - Extracts values from package.json using underscore-separated path segments (e.g., `$npm_package_repository_type` traverses `packageJson.repository.type`) +4. **`checkStringForNpmVariable(value)`** - Helper to check if a string contains npm package variables +5. **`hasNpmVariables(config)`** - Quick check if RC config contains any npm variables (optimization to skip interpolation when not needed) +6. **`interpolateNpmVariables(config, rcFilePath)`** - Main interpolation function that processes all string fields + +**Processing Flow:** + +1. Load RC file JSON +2. **Check for npm variables** - If present, find and parse package.json +3. **Interpolate variables** - Replace `$npm_package_*` patterns using regex: `/\$npm_package_[\dA-Za-z]+(?:_[a-z][\dA-Za-z]*)*/g` +4. Validate interpolated configuration +5. Merge with CLI arguments + +**Error Handling:** + +- If variables are used but package.json not found: Throws error listing affected fields +- If variable doesn't exist in package.json: Left unchanged (not an error) +- If package.json is invalid JSON: Clear error message with file path + +**Regex Pattern Design:** + +- Matches: `$npm_package_version`, `$npm_package_repository_type` +- Stops at: `_STATIC`, `_CONSTANT` (underscore followed by uppercase) +- Example: `"$npm_package_name_STATIC"` → interpolates `name`, keeps `_STATIC` suffix + +**Testing** (`test/unit/commandLine.test.ts` lines 604-952): + +- 20+ comprehensive tests covering all edge cases +- Tests for simple fields, nested fields, multiple variables, error scenarios +- 100% coverage of new interpolation functions + +**Example Usage:** + +```json +// .svelteesp32rc.json +{ + "engine": "psychic", + "version": "v$npm_package_version", + "define": "$npm_package_name", + "sourcepath": "./dist" +} + +// package.json +{ + "name": "esp32-webui", + "version": "2.1.0" +} + +// Result after interpolation: +{ + "version": "v2.1.0", + "define": "esp32-webui" +} +``` + +**Benefits:** + +- **Version synchronization**: Header version automatically matches package version +- **Dynamic naming**: C++ defines use actual package name +- **CI/CD friendly**: Reusable RC files across projects +- **Consistency**: Single source of truth for project metadata diff --git a/README.md b/README.md index d0798c7..7452d48 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.13.0, RC files support npm package variable interpolation + > 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 @@ -540,6 +542,89 @@ To keep defaults, explicitly list them in your RC file: } ``` +#### NPM Package Variable Interpolation + +RC files support automatic variable interpolation from your `package.json`. This allows you to reference package.json fields in your RC configuration using npm-style variable syntax. + +**Syntax:** `$npm_package_` + +**Supported in:** All string fields (`version`, `define`, `sourcepath`, `outputfile`, `espmethod`, `exclude` patterns) + +**Example:** + +```json +// .svelteesp32rc.json +{ + "engine": "psychic", + "version": "v$npm_package_version", + "define": "$npm_package_name", + "sourcepath": "./dist", + "outputfile": "./output.h" +} +``` + +With `package.json` containing: + +```json +{ + "name": "my-esp32-app", + "version": "2.1.0" +} +``` + +The variables are automatically interpolated to: + +```json +{ + "version": "v2.1.0", + "define": "my_esp32_app" +} +``` + +**Nested Fields:** + +You can access nested package.json fields using underscores: + +```json +// package.json +{ + "name": "myapp", + "repository": { + "type": "git", + "url": "https://github.com/user/repo.git" + } +} + +// .svelteesp32rc.json +{ + "version": "$npm_package_repository_type" +} +// Results in: "version": "git" +``` + +**Multiple Variables:** + +Combine multiple variables in a single field: + +```json +{ + "version": "$npm_package_name-v$npm_package_version-release" +} +// Results in: "my-esp32-app-v2.1.0-release" +``` + +**Requirements:** + +- `package.json` must exist in the same directory as the RC file +- If variables are used but `package.json` is not found, an error is thrown with details about which fields contain variables +- Unknown variables are left unchanged (e.g., `$npm_package_nonexistent` stays as-is) + +**Use Cases:** + +- **Version Synchronization:** Keep header version in sync with npm package version +- **Dynamic Naming:** Use package name for C++ defines automatically +- **CI/CD Integration:** Reusable RC files across projects with different package names + ### 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 c5212db..9583766 100644 --- a/src/commandLine.ts +++ b/src/commandLine.ts @@ -122,11 +122,113 @@ function findRcFile(customConfigPath?: string): string | undefined { return undefined; } +function findPackageJson(rcFilePath: string): string | undefined { + const rcDirectory = path.dirname(rcFilePath); + const packageJsonPath = path.join(rcDirectory, 'package.json'); + return existsSync(packageJsonPath) ? packageJsonPath : undefined; +} + +function parsePackageJson(packageJsonPath: string): Record { + try { + const content = readFileSync(packageJsonPath, 'utf8'); + return JSON.parse(content); + } catch (error) { + throw new Error(`Failed to parse package.json at ${packageJsonPath}: ${(error as Error).message}`); + } +} + +function getNpmPackageVariable(packageJson: Record, variableName: string): string | undefined { + const prefix = '$npm_package_'; + if (!variableName.startsWith(prefix)) return undefined; + + const pathString = variableName.slice(prefix.length); + const pathSegments = pathString.split('_'); + + let current: unknown = packageJson; + for (const segment of pathSegments) { + if (current === null || current === undefined) return undefined; + if (typeof current !== 'object') return undefined; + current = (current as Record)[segment]; + } + + if (current === null || current === undefined) return undefined; + return String(current); +} + +function checkStringForNpmVariable(value: string | undefined): boolean { + return value?.includes('$npm_package_') ?? false; +} + +function hasNpmVariables(config: IRcFileConfig): boolean { + return ( + checkStringForNpmVariable(config.sourcepath) || + checkStringForNpmVariable(config.outputfile) || + checkStringForNpmVariable(config.espmethod) || + checkStringForNpmVariable(config.define) || + checkStringForNpmVariable(config.version) || + (Array.isArray(config.exclude) && config.exclude.some((pattern) => checkStringForNpmVariable(pattern))) || + false + ); +} + +function interpolateNpmVariables(config: IRcFileConfig, rcFilePath: string): IRcFileConfig { + // Quick check - return unchanged if no variables + if (!hasNpmVariables(config)) return config; + + // Find package.json + const packageJsonPath = findPackageJson(rcFilePath); + if (!packageJsonPath) { + const affectedFields: string[] = []; + if (config.sourcepath?.includes('$npm_package_')) affectedFields.push('sourcepath'); + if (config.outputfile?.includes('$npm_package_')) affectedFields.push('outputfile'); + if (config.version?.includes('$npm_package_')) affectedFields.push('version'); + if (config.espmethod?.includes('$npm_package_')) affectedFields.push('espmethod'); + if (config.define?.includes('$npm_package_')) affectedFields.push('define'); + if (config.exclude) + for (const [index, pattern] of config.exclude.entries()) + if (pattern.includes('$npm_package_')) affectedFields.push(`exclude[${index}]`); + + throw new Error( + `RC file uses npm package variables but package.json not found in ${path.dirname(rcFilePath)}\n` + + `Variables found in fields: ${affectedFields.join(', ')}\n` + + `Please ensure package.json exists in the same directory as your RC file.` + ); + } + + // Parse package.json + const packageJson = parsePackageJson(packageJsonPath); + + // Interpolation function + const interpolateString = (value: string): string => { + // Match $npm_package_ followed by field name (stops at _ + uppercase) + // This allows: version, repository_type, but stops at _STATIC + const regex = /\$npm_package_[\dA-Za-z]+(?:_[a-z][\dA-Za-z]*)*/g; + // eslint-disable-next-line unicorn/prefer-string-replace-all -- replaceAll not available in ES2020 + return value.replace(regex, (match: string) => getNpmPackageVariable(packageJson, match) ?? match); + }; + + // Create new config with interpolated values + const result: IRcFileConfig = { ...config }; + + if (result.sourcepath) result.sourcepath = interpolateString(result.sourcepath); + if (result.outputfile) result.outputfile = interpolateString(result.outputfile); + if (result.espmethod) result.espmethod = interpolateString(result.espmethod); + if (result.define) result.define = interpolateString(result.define); + if (result.version) result.version = interpolateString(result.version); + if (result.exclude) result.exclude = result.exclude.map((pattern) => interpolateString(pattern)); + + return result; +} + function loadRcFile(rcPath: string): IRcFileConfig { try { const content = readFileSync(rcPath, 'utf8'); const config = JSON.parse(content); - return validateRcConfig(config, rcPath); + + // Interpolate npm package variables before validation + const interpolatedConfig = interpolateNpmVariables(config, rcPath); + + return validateRcConfig(interpolatedConfig, rcPath); } catch (error) { if (error instanceof SyntaxError) throw new Error(`Invalid JSON in RC file ${rcPath}: ${error.message}`); @@ -418,6 +520,9 @@ function parseArguments(): ICopyFilesArguments { return result as ICopyFilesArguments; } +// Export functions for testing +export { getNpmPackageVariable, hasNpmVariables, interpolateNpmVariables }; + export function formatConfiguration(cmdLine: ICopyFilesArguments): string { const parts: string[] = [ `engine=${cmdLine.engine}`, diff --git a/test/unit/commandLine.test.ts b/test/unit/commandLine.test.ts index 362c3b0..59cdc31 100644 --- a/test/unit/commandLine.test.ts +++ b/test/unit/commandLine.test.ts @@ -600,5 +600,349 @@ describe('commandLine', () => { expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("Unknown property 'anotherUnknown'")); }); }); + + describe('npm variable interpolation', () => { + describe('getNpmPackageVariable', () => { + const mockPackageJson = { + name: 'svelteesp32', + version: '1.12.1', + author: 'BCsabaEngine', + repository: { + type: 'git', + url: 'https://github.com/BCsabaEngine/svelteesp32.git' + }, + keywords: ['svelte', 'esp32'], + engines: { + node: '>=20' + } + }; + + it('should extract simple field (version)', async () => { + const { getNpmPackageVariable } = await import('../../src/commandLine'); + const result = getNpmPackageVariable(mockPackageJson, '$npm_package_version'); + expect(result).toBe('1.12.1'); + }); + + it('should extract simple field (name)', async () => { + const { getNpmPackageVariable } = await import('../../src/commandLine'); + const result = getNpmPackageVariable(mockPackageJson, '$npm_package_name'); + expect(result).toBe('svelteesp32'); + }); + + it('should extract nested field (repository.type)', async () => { + const { getNpmPackageVariable } = await import('../../src/commandLine'); + const result = getNpmPackageVariable(mockPackageJson, '$npm_package_repository_type'); + expect(result).toBe('git'); + }); + + it('should extract deep nested field (repository.url)', async () => { + const { getNpmPackageVariable } = await import('../../src/commandLine'); + const result = getNpmPackageVariable(mockPackageJson, '$npm_package_repository_url'); + expect(result).toBe('https://github.com/BCsabaEngine/svelteesp32.git'); + }); + + it('should return undefined for non-existent field', async () => { + const { getNpmPackageVariable } = await import('../../src/commandLine'); + const result = getNpmPackageVariable(mockPackageJson, '$npm_package_nonexistent'); + expect(result).toBeUndefined(); + }); + + it('should return undefined for variable without prefix', async () => { + const { getNpmPackageVariable } = await import('../../src/commandLine'); + const result = getNpmPackageVariable(mockPackageJson, 'version'); + expect(result).toBeUndefined(); + }); + + it('should convert non-string values to strings', async () => { + const { getNpmPackageVariable } = await import('../../src/commandLine'); + const packageJsonWithNumber = { count: 42 }; + const result = getNpmPackageVariable(packageJsonWithNumber, '$npm_package_count'); + expect(result).toBe('42'); + }); + + it('should handle null values', async () => { + const { getNpmPackageVariable } = await import('../../src/commandLine'); + const packageJsonWithNull = { field: undefined }; + const result = getNpmPackageVariable(packageJsonWithNull, '$npm_package_field'); + expect(result).toBeUndefined(); + }); + + it('should handle undefined values', async () => { + const { getNpmPackageVariable } = await import('../../src/commandLine'); + const result = getNpmPackageVariable(mockPackageJson, '$npm_package_undefined_field'); + expect(result).toBeUndefined(); + }); + }); + + describe('hasNpmVariables', () => { + it('should return false when no variables present', async () => { + const { hasNpmVariables } = await import('../../src/commandLine'); + const config = { + engine: 'psychic' as const, + sourcepath: './dist', + outputfile: './output.h' + }; + expect(hasNpmVariables(config)).toBe(false); + }); + + it('should return true when variable in version', async () => { + const { hasNpmVariables } = await import('../../src/commandLine'); + const config = { + version: 'v$npm_package_version' + }; + expect(hasNpmVariables(config)).toBe(true); + }); + + it('should return true when variable in sourcepath', async () => { + const { hasNpmVariables } = await import('../../src/commandLine'); + const config = { + sourcepath: './$npm_package_name/dist' + }; + expect(hasNpmVariables(config)).toBe(true); + }); + + it('should return true when variable in define', async () => { + const { hasNpmVariables } = await import('../../src/commandLine'); + const config = { + define: '$npm_package_name_STATIC' + }; + expect(hasNpmVariables(config)).toBe(true); + }); + + it('should return true when variable in exclude array', async () => { + const { hasNpmVariables } = await import('../../src/commandLine'); + const config = { + exclude: ['*.map', '$npm_package_name.test.js'] + }; + expect(hasNpmVariables(config)).toBe(true); + }); + + it('should return true for multiple fields with variables', async () => { + const { hasNpmVariables } = await import('../../src/commandLine'); + const config = { + version: 'v$npm_package_version', + define: '$npm_package_name', + exclude: ['$npm_package_name/**/*'] + }; + expect(hasNpmVariables(config)).toBe(true); + }); + }); + + describe('interpolateNpmVariables', () => { + it('should return config unchanged when no variables present', async () => { + const fsModule = await import('node:fs'); + vi.mocked(fsModule.existsSync).mockReturnValue(true); + + const { interpolateNpmVariables } = await import('../../src/commandLine'); + const config = { + engine: 'psychic' as const, + sourcepath: './dist', + outputfile: './output.h' + }; + const result = interpolateNpmVariables(config, '/test/.svelteesp32rc.json'); + expect(result).toEqual(config); + }); + + it('should interpolate version field', async () => { + const fsModule = await import('node:fs'); + vi.mocked(fsModule.existsSync).mockReturnValue(true); + vi.mocked(fsModule.readFileSync).mockImplementation((path: string) => { + if (path.includes('package.json')) return JSON.stringify({ name: 'testapp', version: '1.2.3' }); + return '{}'; + }); + + const { interpolateNpmVariables } = await import('../../src/commandLine'); + const config = { + version: 'v$npm_package_version' + }; + const result = interpolateNpmVariables(config, '/test/.svelteesp32rc.json'); + expect(result.version).toBe('v1.2.3'); + }); + + it('should interpolate multiple fields', async () => { + const fsModule = await import('node:fs'); + vi.mocked(fsModule.existsSync).mockReturnValue(true); + vi.mocked(fsModule.readFileSync).mockImplementation((path: string) => { + if (path.includes('package.json')) return JSON.stringify({ name: 'testapp', version: '1.2.3' }); + + return '{}'; + }); + + const { interpolateNpmVariables } = await import('../../src/commandLine'); + const config = { + version: 'v$npm_package_version', + define: '$npm_package_name_STATIC', + outputfile: './$npm_package_name.h' + }; + const result = interpolateNpmVariables(config, '/test/.svelteesp32rc.json'); + expect(result.version).toBe('v1.2.3'); + expect(result.define).toBe('testapp_STATIC'); + expect(result.outputfile).toBe('./testapp.h'); + }); + + it('should interpolate exclude array patterns', async () => { + const fsModule = await import('node:fs'); + vi.mocked(fsModule.existsSync).mockReturnValue(true); + vi.mocked(fsModule.readFileSync).mockImplementation((path: string) => { + if (path.includes('package.json')) return JSON.stringify({ name: 'testapp', version: '1.2.3' }); + + return '{}'; + }); + + const { interpolateNpmVariables } = await import('../../src/commandLine'); + const config = { + exclude: ['*.map', '$npm_package_name/**/*.test.js'] + }; + const result = interpolateNpmVariables(config, '/test/.svelteesp32rc.json'); + expect(result.exclude).toEqual(['*.map', 'testapp/**/*.test.js']); + }); + + it('should handle mixed static and variable content', async () => { + const fsModule = await import('node:fs'); + vi.mocked(fsModule.existsSync).mockReturnValue(true); + vi.mocked(fsModule.readFileSync).mockImplementation((path: string) => { + if (path.includes('package.json')) return JSON.stringify({ name: 'testapp', version: '1.2.3' }); + + return '{}'; + }); + + const { interpolateNpmVariables } = await import('../../src/commandLine'); + const config = { + version: '$npm_package_name-v$npm_package_version-release' + }; + const result = interpolateNpmVariables(config, '/test/.svelteesp32rc.json'); + expect(result.version).toBe('testapp-v1.2.3-release'); + }); + + it('should leave unknown variables unchanged', async () => { + const fsModule = await import('node:fs'); + vi.mocked(fsModule.existsSync).mockReturnValue(true); + vi.mocked(fsModule.readFileSync).mockImplementation((path: string) => { + if (path.includes('package.json')) return JSON.stringify({ name: 'testapp', version: '1.2.3' }); + + return '{}'; + }); + + const { interpolateNpmVariables } = await import('../../src/commandLine'); + const config = { + version: 'v$npm_package_unknown_field' + }; + const result = interpolateNpmVariables(config, '/test/.svelteesp32rc.json'); + expect(result.version).toBe('v$npm_package_unknown_field'); + }); + + it('should throw error when variables present but package.json not found', async () => { + const fsModule = await import('node:fs'); + vi.mocked(fsModule.existsSync).mockReturnValue(false); + + const { interpolateNpmVariables } = await import('../../src/commandLine'); + const config = { + version: 'v$npm_package_version' + }; + + expect(() => interpolateNpmVariables(config, '/test/.svelteesp32rc.json')).toThrow( + 'RC file uses npm package variables but package.json not found' + ); + }); + + it('should list affected fields in error message', async () => { + const fsModule = await import('node:fs'); + vi.mocked(fsModule.existsSync).mockReturnValue(false); + + const { interpolateNpmVariables } = await import('../../src/commandLine'); + const config = { + version: 'v$npm_package_version', + define: '$npm_package_name' + }; + + expect(() => interpolateNpmVariables(config, '/test/.svelteesp32rc.json')).toThrow('version, define'); + }); + + it('should handle nested package.json fields', async () => { + const fsModule = await import('node:fs'); + vi.mocked(fsModule.existsSync).mockReturnValue(true); + vi.mocked(fsModule.readFileSync).mockImplementation((path: string) => { + if (path.includes('package.json')) + return JSON.stringify({ + name: 'testapp', + repository: { type: 'git', url: 'https://github.com/test/repo.git' } + }); + + return '{}'; + }); + + const { interpolateNpmVariables } = await import('../../src/commandLine'); + const config = { + version: '$npm_package_repository_type' + }; + const result = interpolateNpmVariables(config, '/test/.svelteesp32rc.json'); + expect(result.version).toBe('git'); + }); + }); + + describe('RC file with npm variables integration', () => { + it('should load RC file with interpolated variables', async () => { + const mockRcContent = JSON.stringify({ + engine: 'psychic', + sourcepath: '/test/dist', + version: 'v$npm_package_version', + define: '$npm_package_name' + }); + + const mockPackageJson = JSON.stringify({ + name: 'testapp', + version: '2.0.0' + }); + + const fsModule = await import('node:fs'); + vi.mocked(fsModule.existsSync).mockImplementation((path: string) => { + if (path.includes('package.json')) return true; + if (path.includes('.svelteesp32rc')) return true; + if (path === '/test/dist') return true; + return false; + }); + + vi.mocked(fsModule.readFileSync).mockImplementation((path: string) => { + if (path.includes('package.json')) return mockPackageJson; + if (path.includes('.svelteesp32rc')) return mockRcContent; + return '{}'; + }); + + vi.mocked(fsModule.statSync).mockReturnValue({ isDirectory: () => true } as fs.Stats); + + process.argv = ['node', 'script.js']; + + const module = await import('../../src/commandLine'); + + // The module should execute successfully with interpolated values + expect(module).toBeDefined(); + }); + + it('should fail when using variables without package.json', async () => { + const mockRcContent = JSON.stringify({ + sourcepath: '/test/dist', + version: 'v$npm_package_version' + }); + + const fsModule = await import('node:fs'); + vi.mocked(fsModule.existsSync).mockImplementation((path: string) => { + if (path.includes('package.json')) return false; // package.json doesn't exist + if (path.includes('.svelteesp32rc')) return true; + return false; + }); + + vi.mocked(fsModule.readFileSync).mockImplementation((path: string) => { + if (path.includes('.svelteesp32rc')) return mockRcContent; + return '{}'; + }); + + process.argv = ['node', 'script.js']; + + await expect(import('../../src/commandLine')).rejects.toThrow( + 'RC file uses npm package variables but package.json not found' + ); + }); + }); + }); }); }); From 76363e4d14bbd076670be4efad3a5a5f537ad10a Mon Sep 17 00:00:00 2001 From: BCsabaEngine Date: Thu, 4 Dec 2025 20:57:21 +0100 Subject: [PATCH 2/2] release: 1.13.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8b5356c..af25c46 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "svelteesp32", - "version": "1.12.1", + "version": "1.13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "svelteesp32", - "version": "1.12.1", + "version": "1.13.0", "license": "ISC", "dependencies": { "handlebars": "^4.7.8", diff --git a/package.json b/package.json index 0a31ba0..6015d18 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "svelteesp32", - "version": "1.12.1", + "version": "1.13.0", "description": "Convert Svelte (or any frontend) JS application to serve it from ESP32 webserver (PsychicHttp)", "author": "BCsabaEngine", "license": "ISC",