diff --git a/README.md b/README.md index 50b3bf990a..35d92e95fc 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ * [@w5s/core-type](packages/core-type) - Core type modules * [@w5s/database](packages/database) - Database client module * [@w5s/env](packages/env) - Environment variable module +* [@w5s/config](packages/config) - Configuration explorer module * [@w5s/error](packages/error) - Error module * [@w5s/http](packages/http) - HTTP client module * [@w5s/iterable](packages/iterable) - Iterable and AsyncIterable modules diff --git a/packages/config/LICENSE b/packages/config/LICENSE new file mode 100644 index 0000000000..4aa140c405 --- /dev/null +++ b/packages/config/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019-2025 Julien Polo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/config/README.md b/packages/config/README.md new file mode 100644 index 0000000000..a3f9ab8f96 --- /dev/null +++ b/packages/config/README.md @@ -0,0 +1,52 @@ + +# W5S Configuration explorer module _(@w5s/config)_ + + +[![NPM Version][package-version-svg]][package-url] +[![License][license-image]][license-url] + +## Installation + + +```sh +npm install @w5s/config +``` + + +## Usage + + + +```ts +import { Config } from '@w5s/config'; +import { Task } from '@w5s/task'; + +export async function main(): Promise { + const explorer = Config('my-app'); + const result = await Task.run(explorer.search()); + + if (result.ok && result.value != null) { + console.log(result.value.config); + } +} +``` + + +## License + +[MIT][license-url] © Julien Polo [julien.polo@gmail.com](mailto:julien.polo@gmail.com) + + + + +[package-version-svg]: https://img.shields.io/npm/v/@w5s/config.svg?style=flat-square + + +[package-url]: https://www.npmjs.com/package/@w5s/config + + +[license-image]: https://img.shields.io/badge/license-MIT-green.svg?style=flat-square + + +[license-url]: https://www.npmjs.com/package/@w5s/config + diff --git a/packages/config/example/usage.ts b/packages/config/example/usage.ts new file mode 100644 index 0000000000..91d258e4c1 --- /dev/null +++ b/packages/config/example/usage.ts @@ -0,0 +1,11 @@ +import { Config } from '@w5s/config'; +import { Task } from '@w5s/task'; + +export async function main(): Promise { + const explorer = Config('my-app'); + const result = await Task.run(explorer.search()); + + if (result.ok && result.value != null) { + console.log(result.value.config); + } +} diff --git a/packages/config/package.json b/packages/config/package.json new file mode 100644 index 0000000000..d37ae5c2b8 --- /dev/null +++ b/packages/config/package.json @@ -0,0 +1,79 @@ +{ + "name": "@w5s/config", + "version": "1.0.0-alpha.0", + "description": "Configuration explorer module", + "keywords": [ + "fp", + "functional", + "config", + "configuration", + "cosmiconfig" + ], + "homepage": "https://github.com/w5s/std/tree/master/packages/config#readme", + "bugs": { + "url": "https://github.com/w5s/std/issues" + }, + "repository": { + "type": "git", + "url": "git@github.com:w5s/std.git", + "directory": "packages/config" + }, + "license": "MIT", + "author": "Julien Polo ", + "type": "module", + "exports": { + ".": "./dist/index.js", + "./dist/*": "./dist/*" + }, + "typings": "./dist/index.d.ts", + "files": [ + "dist/", + "src/", + "!*.d.ts.map", + "!**/*.spec.*", + "!**/__tests__/**" + ], + "scripts": { + "build": "pnpm run \"/^build:.*/\"", + "build:tsc": "tsc -b tsconfig.build.json", + "clean": "pnpm run \"/^clean:.*/\"", + "clean:tsc": "rm -rf dist", + "docs": "node '../../markdown.mjs'", + "format": "pnpm run \"/^format:.*/\"", + "format:src": "eslint . --fix --ext=mjs,cjs,js,jsx,ts,tsx,json,jsonc,json5,yml,yaml", + "lint": "pnpm run \"/^lint:.*/\"", + "lint:src": "eslint .", + "size-limit": "size-limit", + "spellcheck": "cspell --no-progress '**'", + "test": "pnpm run \"/^test:.*/\"", + "test:src": "vitest run", + "tsc": "tsc", + "validate": "npm run lint && npm run build && npm run test" + }, + "dependencies": { + "@w5s/core": "workspace:^1.0.0-alpha.0", + "@w5s/error": "workspace:^1.0.0-alpha.0", + "@w5s/system": "workspace:^1.0.0-alpha.0", + "@w5s/task": "workspace:^1.0.0-alpha.0" + }, + "devDependencies": { + "typescript": "5.9.3", + "vitest": "latest" + }, + "peerDependencies": { + "typescript": "^3.8.0 || ^4.0.0 || ^5.0.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "publishConfig": { + "access": "public" + }, + "size-limit": [ + { + "path": "dist/index.js", + "limit": "5 kB" + } + ], + "sideEffect": false +} diff --git a/packages/config/src/Config.spec.ts b/packages/config/src/Config.spec.ts new file mode 100644 index 0000000000..1ddc5faa0c --- /dev/null +++ b/packages/config/src/Config.spec.ts @@ -0,0 +1,159 @@ +import { describe, it, expect } from 'vitest'; +import * as fs from 'node:fs/promises'; +import nodePath from 'node:path'; +import os from 'node:os'; +import { Config } from './Config.js'; +import { Task } from '@w5s/task'; +import type { FilePath } from '@w5s/system'; +import { ConfigErrorType } from './ConfigError.js'; + +const asFilePath = (value: string) => value as FilePath; + +async function createTempDir(): Promise { + return fs.mkdtemp(nodePath.join(os.tmpdir(), 'w5s-config-')); +} + +async function writeFile(filePath: string, content: string): Promise { + await fs.mkdir(nodePath.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, content, 'utf8'); +} + +describe('Config', () => { + it('searches the nearest configuration first', async () => { + const root = await createTempDir(); + const child = nodePath.join(root, 'child'); + + await writeFile(nodePath.join(root, '.myapprc.json'), '{"root": true}'); + await writeFile(nodePath.join(child, '.myapprc.json'), '{"child": true}'); + + const explorer = Config<{ child?: boolean }>('myapp'); + const result = await Task.run(explorer.search(asFilePath(child))); + + expect(result.ok).toBe(true); + expect(result.value?.filepath).toBe(nodePath.join(child, '.myapprc.json')); + expect(result.value?.config).toEqual({ child: true }); + }); + + it('respects stopDir when searching', async () => { + const root = await createTempDir(); + const child = nodePath.join(root, 'child'); + const grandChild = nodePath.join(child, 'grand'); + + await writeFile(nodePath.join(root, '.myapprc.json'), '{"root": true}'); + + const explorer = Config('myapp', { stopDir: asFilePath(child) }); + const result = await Task.run(explorer.search(asFilePath(grandChild))); + + expect(result.ok).toBe(true); + expect(result.value).toBeUndefined(); + }); + + it('loads JSON config files', async () => { + const root = await createTempDir(); + const filePath = nodePath.join(root, '.myapprc.json'); + + await writeFile(filePath, '{"value": 123}'); + + const explorer = Config<{ value: number }>('myapp'); + const result = await Task.run(explorer.load(asFilePath(filePath))); + + expect(result.ok).toBe(true); + expect(result.value?.config).toEqual({ value: 123 }); + }); + + it('loads JS module config files', async () => { + const root = await createTempDir(); + const mjsPath = nodePath.join(root, 'myapp.config.mjs'); + const cjsPath = nodePath.join(root, 'myapp.config.cjs'); + + await writeFile(mjsPath, 'export default { value: 42 };'); + await writeFile(cjsPath, 'module.exports = { value: 77 };'); + + const explorer = Config<{ value: number }>('myapp'); + + const mjsResult = await Task.run(explorer.load(asFilePath(mjsPath))); + expect(mjsResult.ok).toBe(true); + expect(mjsResult.value?.config).toEqual({ value: 42 }); + + const cjsResult = await Task.run(explorer.load(asFilePath(cjsPath))); + expect(cjsResult.ok).toBe(true); + expect(cjsResult.value?.config).toEqual({ value: 77 }); + }); + + it('loads config from package.json', async () => { + const root = await createTempDir(); + const packagePath = nodePath.join(root, 'package.json'); + + await writeFile(packagePath, '{"name": "demo", "myapp": {"enabled": true}}'); + + const explorer = Config<{ enabled: boolean }>('myapp'); + const result = await Task.run(explorer.load(asFilePath(packagePath))); + + expect(result.ok).toBe(true); + expect(result.value?.config).toEqual({ enabled: true }); + }); + + it('marks empty JSON files as empty configs', async () => { + const root = await createTempDir(); + const filePath = nodePath.join(root, '.myapprc'); + + await writeFile(filePath, ''); + + const explorer = Config('myapp'); + const result = await Task.run(explorer.search(asFilePath(root))); + + expect(result.ok).toBe(true); + expect(result.value?.filepath).toBe(filePath); + expect(result.value?.isEmpty).toBe(true); + }); + + it('returns ParseError for invalid JSON', async () => { + const root = await createTempDir(); + const filePath = nodePath.join(root, '.myapprc.json'); + + await writeFile(filePath, '{ invalid }'); + + const explorer = Config('myapp'); + const result = await Task.run(explorer.load(asFilePath(filePath))); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.configErrorType).toBe(ConfigErrorType.ParseError); + } + }); + + it('returns NotFound when loading a missing file', async () => { + const root = await createTempDir(); + const filePath = nodePath.join(root, '.missingrc'); + + const explorer = Config('myapp'); + const result = await Task.run(explorer.load(asFilePath(filePath))); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.configErrorType).toBe(ConfigErrorType.NotFound); + } + }); + + it('caches load results when enabled', async () => { + const root = await createTempDir(); + const filePath = nodePath.join(root, '.myapprc.json'); + + await writeFile(filePath, '{"value": 1}'); + + let calls = 0; + const explorer = Config<{ value: number }>('myapp', { + loaders: { + '.json': (filepath, content) => { + calls += 1; + return Config.defaultLoaders<{ value: number }>('myapp')['.json'](filepath, content); + }, + }, + }); + + await Task.run(explorer.load(asFilePath(filePath))); + await Task.run(explorer.load(asFilePath(filePath))); + + expect(calls).toBe(1); + }); +}); diff --git a/packages/config/src/Config.ts b/packages/config/src/Config.ts new file mode 100644 index 0000000000..73fd2eb826 --- /dev/null +++ b/packages/config/src/Config.ts @@ -0,0 +1,494 @@ +import { pathToFileURL } from 'node:url'; +import { createRequire } from 'node:module'; +import type { Option } from '@w5s/core'; +import { Result } from '@w5s/core'; +import { Task } from '@w5s/task'; +import { FileSystem, FilePath, Process } from '@w5s/system'; +import type { FileError } from '@w5s/system'; +import { ConfigError, ConfigErrorType } from './ConfigError.js'; + +export interface ConfigExplorer { + /** + * Search for a configuration file starting from a directory + */ + search(searchFrom?: FilePath): Task>, Config.Error>; + + /** + * Load a configuration file from an exact path + */ + load(filepath: FilePath): Task, Config.Error>; + + /** + * Clears both search and load caches + */ + clearCache(): Task; +} + +function createConfigExplorer( + moduleName: string, + options: Config.Options = {}, +): ConfigExplorer { + const { + searchPlaces = defaultSearchPlaces(moduleName), + packageProp = moduleName, + stopDir, + cache = true, + transform, + loaders, + } = options; + + const resolvedLoaders: Record> = { ...defaultLoaders(packageProp), ...loaders }; + + const loadCache = new Map, Config.Error>>>(); + const searchCache = new Map>, Config.Error>>>(); + + function clearCache(): Task { + return Task.create(() => { + loadCache.clear(); + searchCache.clear(); + return Task.ok(); + }); + } + + function cached(task: Task, cacheOptions: { + key: string; + cache: Map>>; + enabled: boolean; + }): Task { + const { key, cache: cacheMap, enabled } = cacheOptions; + return enabled + ? Task.create(async ({ run }) => { + let returnValue = cacheMap.get(key); + if (returnValue == null) { + returnValue = Promise.resolve(run(task)); + cacheMap.set(key, returnValue); + } + return returnValue; + }) + : task; + } + + function load(filepath: FilePath): Task, Config.Error> { + return cached(loadDefault(filepath), { + key: filepath, + cache: loadCache, + enabled: cache, + }); + } + + function loadDefault(filepath: FilePath): Task, Config.Error> { + return Task.create(async ({ run }) => { + const loaded = await run(loadFromFile(filepath, true)); + if (!loaded.ok) return loaded; + if (loaded.value == null) { + return Task.error( + new ConfigError({ + configErrorType: ConfigErrorType.NotFound, + filepath, + cause: undefined, + message: 'No configuration found in file', + }), + ); + } + return Task.ok(loaded.value); + }); + } + + function search(searchFrom?: FilePath): Task>, Config.Error> { + return Task.andThen(resolveStartDirectory(searchFrom), (startDirectory) => cached(searchDefault(startDirectory), { + key: startDirectory, + cache: searchCache, + enabled: cache, + })); + } + + function searchDefault(startDirectory: FilePath): Task>, Config.Error> { + return Task.create(async ({ run }) => { + const stopDirectory = stopDir == null ? undefined : FilePath.normalize(stopDir); + let currentDirectory = FilePath.normalize(startDirectory); + + while (true) { + for (const searchPlace of searchPlaces) { + const candidatePath = FilePath.concat([currentDirectory, searchPlace]); + const candidateResult = await run(loadIfExists(candidatePath)); + if (Result.isError(candidateResult)) { + return candidateResult; + } + if (candidateResult.value != null) { + return Task.ok(candidateResult.value); + } + } + + if (stopDirectory != null && currentDirectory === stopDirectory) { + break; + } + + const parentDirectory = FilePath.dirname(currentDirectory); + if (parentDirectory === currentDirectory) { + break; + } + currentDirectory = parentDirectory; + } + + return Task.ok(undefined); + }); + } + + function resolveStartDirectory(searchFrom?: FilePath) { + return Task.create(async ({ run }) => { + const cwd = await run(Process.getCurrentDirectory()); + if (!cwd.ok) return cwd; + + const basePath = FilePath.normalize(searchFrom ?? cwd.value); + const statusResult = await run(FileSystem.readFileStatus(basePath)); + if (!statusResult.ok) return Task.ok(FilePath.dirname(basePath)); + + const status = statusResult.value; + return Task.ok(status.isDirectory ? basePath : FilePath.dirname(basePath)); + }); + } + + function loadIfExists(filepath: FilePath): Task>, Config.Error> { + return Task.create(async ({ run }) => { + const statusResult = await run(FileSystem.readFileStatus(filepath)); + if (Result.isError(statusResult)) { + if (isNotFoundError(statusResult.error)) { + return Task.ok(undefined); + } + return Task.error( + new ConfigError({ + configErrorType: ConfigErrorType.ReadError, + filepath, + cause: statusResult.error, + message: 'Failed to read config file status', + }), + ); + } + + const status = statusResult.value; + if (!status.isFile) { + return Task.ok(undefined); + } + + const loaded = await run(loadFromFile(filepath, false)); + if (Result.isError(loaded)) { + if (loaded.error.configErrorType === ConfigErrorType.NotFound) { + return Task.ok(undefined); + } + return loaded; + } + + return Task.ok(loaded.value); + }); + } + + function loadFromFile( + filepath: FilePath, + strict: boolean, + ): Task>, Config.Error> { + return Task.create(async ({ run }) => { + const loaderKey = getLoaderKey(filepath); + if (loaderKey == null) { + return Task.error( + new ConfigError({ + configErrorType: ConfigErrorType.LoadError, + filepath, + cause: undefined, + message: 'No loader available for file', + }), + ); + } + + const loader = resolvedLoaders[loaderKey]; + if (loader == null) { + return Task.error( + new ConfigError({ + configErrorType: ConfigErrorType.LoadError, + filepath, + cause: undefined, + message: 'No loader configured for file', + }), + ); + } + + const contentResult = await run(FileSystem.readFile(filepath, { encoding: 'utf8' })); + if (Result.isError(contentResult)) { + if (isNotFoundError(contentResult.error)) { + return Task.error( + new ConfigError({ + configErrorType: ConfigErrorType.NotFound, + filepath, + cause: contentResult.error, + message: 'Config file not found', + }), + ); + } + return Task.error( + new ConfigError({ + configErrorType: ConfigErrorType.ReadError, + filepath, + cause: contentResult.error, + message: 'Failed to read config file', + }), + ); + } + + const content = contentResult.value as string; + const loaderResult = await run(loader(filepath, content)); + if (Result.isError(loaderResult)) return loaderResult; + + const loaded = loaderResult.value; + if (loaded == null) { + if (strict) { + return Task.error( + new ConfigError({ + configErrorType: ConfigErrorType.NotFound, + filepath, + cause: undefined, + message: 'No configuration found in file', + }), + ); + } + return Task.ok(undefined); + } + + const transformed = transform == null ? loaded : transform(loaded); + if (transformed == null) { + if (strict) { + return Task.error( + new ConfigError({ + configErrorType: ConfigErrorType.NotFound, + filepath, + cause: undefined, + message: 'Config transform returned empty value', + }), + ); + } + return Task.ok(undefined); + } + + return Task.ok(transformed); + }); + } + + return { + search, + load, + clearCache, + }; +} + +function defaultSearchPlaces(moduleName: string): Config.SearchPlaces { + return [ + 'package.json', + `.${moduleName}rc`, + `.${moduleName}rc.json`, + `.${moduleName}rc.js`, + `.${moduleName}rc.cjs`, + `.${moduleName}rc.mjs`, + `${moduleName}.config.js`, + `${moduleName}.config.cjs`, + `${moduleName}.config.mjs`, + ]; +} +function defaultLoaders(packageProp: string): Record> { + return { + 'package.json': createPackageJSONLoader(packageProp), + '.json': loadJSON, + '.js': loadJSModule, + '.cjs': loadCJSModule, + '.mjs': loadMJSModule, + 'noExt': loadJSON, + }; +} + +/** + * Cosmiconfig-like configuration explorer + * + * @namespace + */ +export const Config = Object.assign(createConfigExplorer, { + defaultSearchPlaces, + defaultLoaders, +}); + +export namespace Config { + export type SearchPlaces = ReadonlyArray; + + export type LoaderKey = 'package.json' | 'noExt' | '.json' | '.js' | '.cjs' | '.mjs'; + + export type Loader = ( + filepath: FilePath, + content: string, + ) => Task>, Config.Error>; + + export interface Options { + /** + * List of filenames to search for + */ + readonly searchPlaces?: SearchPlaces; + + /** + * Property to read in package.json + */ + readonly packageProp?: string; + + /** + * Directory to stop searching at + */ + readonly stopDir?: FilePath; + + /** + * Enable or disable caching + */ + readonly cache?: boolean; + + /** + * Transform function applied to loaded config + */ + readonly transform?: (result: Config.Result) => Option>; + + /** + * Custom loaders by file extension + */ + readonly loaders?: Partial>>; + } + + export interface Result { + /** + * Parsed configuration + */ + readonly config: ConfigValue; + + /** + * File path of the configuration file + */ + readonly filepath: FilePath; + + /** + * True if the configuration file is empty + */ + readonly isEmpty?: true; + } + + export type Error = ConfigError; +} + +function getLoaderKey(filepath: FilePath): Config.LoaderKey | undefined { + const basename = FilePath.basename(filepath); + if (basename === 'package.json') { + return 'package.json'; + } + + const extension = FilePath.extname(filepath); + if (extension === '') { + return 'noExt'; + } + + if (extension === '.json' || extension === '.js' || extension === '.cjs' || extension === '.mjs') { + return extension as Config.LoaderKey; + } + + return undefined; +} + +function isNotFoundError(error: FileError): boolean { + return error.code === 'ENOENT'; +} + +function loadJSON(filepath: FilePath, content: string): Task>, Config.Error> { + return Task.create(() => { + const trimmed = content.trim(); + if (trimmed.length === 0) { + return Task.ok({ + config: undefined as ConfigValue, + filepath, + isEmpty: true, + }); + } + + try { + const parsed = JSON.parse(content) as ConfigValue; + return Task.ok({ config: parsed, filepath }); + } catch (error: unknown) { + return Task.error( + new ConfigError({ + configErrorType: ConfigErrorType.ParseError, + filepath, + cause: error, + message: 'Invalid JSON configuration', + }), + ); + } + }); +} + +function createPackageJSONLoader( + packageProp: string, +): (filepath: FilePath, content: string) => Task>, Config.Error> { + return (filepath: FilePath, content: string) => + Task.from(({ resolve, reject }) => { + const trimmed = content.trim(); + if (trimmed.length === 0) { + resolve({ + config: undefined as ConfigValue, + filepath, + isEmpty: true, + }); + return; + } + + try { + const parsed = JSON.parse(content) as Record; + if (!(packageProp in parsed)) { + resolve(undefined); + return; + } + resolve({ config: parsed[packageProp] as ConfigValue, filepath }); + } catch (error: unknown) { + reject( + new ConfigError({ + configErrorType: ConfigErrorType.ParseError, + filepath, + cause: error, + message: 'Invalid package.json', + }), + ); + } + }); +} + +function loadJSModule(filepath: FilePath): Task>, Config.Error> { + return Task.tryCall(async () => { + const mod = await import(pathToFileURL(filepath).href); + const config = (mod as { default?: ConfigValue }).default ?? (mod as ConfigValue); + return ({ config, filepath }); + }, (error) => + new ConfigError({ + configErrorType: ConfigErrorType.LoadError, + filepath, + cause: error, + message: 'Failed to load JS module', + }), + ); +} + +function loadMJSModule(filepath: FilePath): Task>, Config.Error> { + return loadJSModule(filepath); +} + +function loadCJSModule(filepath: FilePath): Task>, Config.Error> { + return Task.tryCall(() => { + const require = createRequire(import.meta.url); + const mod = require(filepath) as ({ default: ConfigValue } | ConfigValue); + const config = typeof mod === 'object' && mod != null && ('default' in mod) ? mod.default : mod; + return ({ config, filepath }); + }, (error) => + new ConfigError({ + configErrorType: ConfigErrorType.LoadError, + filepath, + cause: error, + message: 'Failed to load CJS module', + }), + ); +} diff --git a/packages/config/src/ConfigError.ts b/packages/config/src/ConfigError.ts new file mode 100644 index 0000000000..b24e18bd8a --- /dev/null +++ b/packages/config/src/ConfigError.ts @@ -0,0 +1,24 @@ +import type { Option } from '@w5s/core'; +import { ErrorClass } from '@w5s/error/dist/ErrorClass.js'; +import type { FilePath } from '@w5s/system'; + +export const ConfigErrorType = { + NotFound: 'NotFound', + ParseError: 'ParseError', + LoadError: 'LoadError', + InvalidOptions: 'InvalidOptions', + ReadError: 'ReadError', +} as const; +export type ConfigErrorType = (typeof ConfigErrorType)[keyof typeof ConfigErrorType]; + +/** + * An error when reading or parsing configuration files + */ +export class ConfigError extends ErrorClass({ + errorName: 'ConfigError', +})<{ + configErrorType: ConfigErrorType; + filepath: Option; + // cause: Option; + // message?: string; + }> {} diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts new file mode 100644 index 0000000000..cea85dab4e --- /dev/null +++ b/packages/config/src/index.ts @@ -0,0 +1,2 @@ +export * from './Config.js'; +export * from './ConfigError.js'; diff --git a/packages/config/tsconfig.build.json b/packages/config/tsconfig.build.json new file mode 100644 index 0000000000..f4dafebe10 --- /dev/null +++ b/packages/config/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "rootDir": "./src" + }, + "include": ["src"], + "exclude": ["**/*.spec.ts", "**/*.test.ts", "**/__mocks__/**"] +} diff --git a/packages/config/tsconfig.json b/packages/config/tsconfig.json new file mode 100644 index 0000000000..3b6248b183 --- /dev/null +++ b/packages/config/tsconfig.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.settings.json", + "compilerOptions": {} +} diff --git a/packages/config/typedoc.json b/packages/config/typedoc.json new file mode 100644 index 0000000000..bccf211455 --- /dev/null +++ b/packages/config/typedoc.json @@ -0,0 +1,4 @@ +{ + "tsconfig": "tsconfig.build.json", + "entryPoints": ["./src/index.ts"] +} diff --git a/packages/config/vitest.config.mts b/packages/config/vitest.config.mts new file mode 100644 index 0000000000..de879913ab --- /dev/null +++ b/packages/config/vitest.config.mts @@ -0,0 +1,5 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: {}, +}); diff --git a/packages/system/src/FilePath.spec.ts b/packages/system/src/FilePath.spec.ts index ca97c18caf..59405cb60e 100644 --- a/packages/system/src/FilePath.spec.ts +++ b/packages/system/src/FilePath.spec.ts @@ -4,41 +4,42 @@ import { withTask } from '@w5s/task/dist/Testing.js'; import { FilePath } from './FilePath.js'; describe('FilePath', () => { - const absolutePath = (...parts: string[]) => (FilePath.separator + parts.join(FilePath.separator)) as FilePath; - const relativePath = (...parts: string[]) => parts.join(FilePath.separator) as FilePath; + const { separator, dirname, concat, resolve, parse, format, relative, normalize, basename, extname, isAbsolute, isRelative, isParentOf } = FilePath; + const absolutePath = (...parts: string[]) => (separator + parts.join(separator)) as FilePath; + const relativePath = (...parts: string[]) => parts.join(separator) as FilePath; const expectTask = withTask(expect); - describe('.dirname', () => { + describe(dirname, () => { it('should return the dirname of parameter', () => { const path = absolutePath('one', 'two', 'three'); - expect(FilePath.dirname(path)).toBe(`${FilePath.separator}one${FilePath.separator}two`); + expect(dirname(path)).toBe(`${separator}one${separator}two`); }); }); - describe('.concat', () => { + describe(concat, () => { it('should return joined path using separator', () => { const first = absolutePath('hello', 'world'); const second = relativePath('..', 'earth'); - expect(FilePath.concat([first, second])).toBe(absolutePath('hello', 'earth')); + expect(concat([first, second])).toBe(absolutePath('hello', 'earth')); }); }); - describe('.resolve', () => { + describe(resolve, () => { it('return a resolved path', async () => { expectTask( - FilePath.resolve( + resolve( [absolutePath(''), relativePath('bar', 'baz'), relativePath('..', 'baz2')], relativePath('foo'), ), ).toResolveSync(absolutePath('bar', 'baz2', 'foo')); - expectTask(FilePath.resolve([absolutePath('foo', 'bar')], relativePath('./baz'))).toResolveSync( + expectTask(resolve([absolutePath('foo', 'bar')], relativePath('./baz'))).toResolveSync( absolutePath('foo', 'bar', 'baz'), ); - expectTask(FilePath.resolve([absolutePath('foo', 'bar')], absolutePath('tmp', 'file', ''))).toResolveSync( + expectTask(resolve([absolutePath('foo', 'bar')], absolutePath('tmp', 'file', ''))).toResolveSync( absolutePath('tmp', 'file'), ); expectTask( - FilePath.resolve( + resolve( [relativePath('wwwroot'), relativePath('static_files', 'png')], relativePath('..', 'gif', 'image.gif'), ), @@ -46,10 +47,10 @@ describe('FilePath', () => { }); }); - describe('.parse', () => { + describe(parse, () => { it('should parse empty path', () => { const path = relativePath(''); - expect(FilePath.parse(path)).toStrictEqual({ + expect(parse(path)).toStrictEqual({ root: Option.None, base: Option.None, dir: Option.None, @@ -59,10 +60,10 @@ describe('FilePath', () => { }); }); - describe('.format', () => { + describe(format, () => { it('should format empty path object', () => { expect( - FilePath.format({ + format({ root: Option.None, base: Option.None, dir: Option.None, @@ -73,53 +74,55 @@ describe('FilePath', () => { }); }); - describe('.relative', () => { + describe(relative, () => { it('should return a relative path', () => { const from = absolutePath('home', 'hello', 'world'); const to = absolutePath('home', 'earth'); - expect(FilePath.relative(from, to)).toBe(relativePath('..', '..', 'earth')); + expect(relative(from, to)).toBe(relativePath('..', '..', 'earth')); }); }); - describe('.normalize', () => { + describe(normalize, () => { it('return a normalized path', () => { const path = relativePath('hello', 'world', '..', 'earth'); - expect(FilePath.normalize(path)).toBe(relativePath('hello', 'earth')); + expect(normalize(path)).toBe(relativePath('hello', 'earth')); }); }); - describe('.basename', () => { + describe(basename, () => { it('return the base name of file path', () => { const path = absolutePath('hello', 'world', 'file.txt'); - expect(FilePath.basename(path)).toBe(`file.txt`); - expect(FilePath.basename(path, `.txt`)).toBe(`file`); + expect(basename(path)).toBe(`file.txt`); + expect(basename(path, `.txt`)).toBe(`file`); }); }); - describe('.extname', () => { + describe(extname, () => { it('return the base name of file path', () => { const path = relativePath('world', 'file.log.txt'); - expect(FilePath.extname(path)).toBe(`.txt`); + expect(extname(path)).toBe(`.txt`); + const noExt = relativePath('world', 'file'); + expect(extname(noExt)).toBe(``); }); }); - describe('.isAbsolute', () => { + describe(isAbsolute, () => { it('return absolute path', () => { const absolute = absolutePath('world', 'file.log.txt'); - expect(FilePath.isAbsolute(absolute)).toBe(true); - const relative = relativePath('.', 'world', 'file.log.txt'); - expect(FilePath.isAbsolute(relative)).toBe(false); + expect(isAbsolute(absolute)).toBe(true); + const _relative = relativePath('.', 'world', 'file.log.txt'); + expect(isAbsolute(_relative)).toBe(false); }); }); - describe('.isRelative', () => { + describe(isRelative, () => { it('return absolute path', () => { const absolute = absolutePath('world', 'file.log.txt'); - expect(FilePath.isRelative(absolute)).toBe(false); - const relative = relativePath('.', 'world', 'file.log.txt'); - expect(FilePath.isRelative(relative)).toBe(true); + expect(isRelative(absolute)).toBe(false); + const _relative = relativePath('.', 'world', 'file.log.txt'); + expect(isRelative(_relative)).toBe(true); }); }); - describe('.isParentOf', () => { + describe(isParentOf, () => { it.each([ [{ parent: '', child: '' }, false], [{ parent: '/first/second', child: '/first' }, false], @@ -132,7 +135,7 @@ describe('FilePath', () => { ] as [{ parent: string; child: string }, boolean][])( 'should return correct value for %s', ({ parent, child }, expected) => { - expect(FilePath.isParentOf(FilePath(parent), FilePath(child))).toBe(expected); + expect(isParentOf(FilePath(parent), FilePath(child))).toBe(expected); }, ); }); diff --git a/packages/system/src/FilePath.ts b/packages/system/src/FilePath.ts index b8b24ded32..bb585815d8 100644 --- a/packages/system/src/FilePath.ts +++ b/packages/system/src/FilePath.ts @@ -207,7 +207,7 @@ export const FilePath = Object.assign( ); export namespace FilePath { export type Delimiter = ':' | ';'; - export type Extension = `.${string}`; + export type Extension = `.${string}` | ''; export type Separator = '/' | '\\'; export interface Parsed { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 09fe29d2cc..42f9a39616 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -210,6 +210,28 @@ importers: specifier: 4.1.0 version: 4.1.0(@types/node@20.19.37)(vite@7.3.1(@types/node@20.19.37)(jiti@2.6.1)(terser@5.46.1)(yaml@2.8.2)) + packages/config: + dependencies: + '@w5s/core': + specifier: workspace:^1.0.0-alpha.0 + version: link:../core + '@w5s/error': + specifier: workspace:^1.0.0-alpha.0 + version: link:../error + '@w5s/system': + specifier: workspace:^1.0.0-alpha.0 + version: link:../system + '@w5s/task': + specifier: workspace:^1.0.0-alpha.0 + version: link:../task + devDependencies: + typescript: + specifier: 5.9.3 + version: 5.9.3 + vitest: + specifier: 4.1.0 + version: 4.1.0(@types/node@20.19.37)(vite@7.3.1(@types/node@20.19.37)(jiti@2.6.1)(terser@5.46.1)(yaml@2.8.2)) + packages/console: dependencies: '@w5s/task':