diff --git a/.changeset/two-experts-punch.md b/.changeset/two-experts-punch.md new file mode 100644 index 0000000000..baf56cb5fb --- /dev/null +++ b/.changeset/two-experts-punch.md @@ -0,0 +1,5 @@ +--- +"@digdir/designsystemet": patch +--- + +fix resolving of `outDir` in config not being relative to config file location diff --git a/packages/cli/bin/config.ts b/packages/cli/bin/config.ts index 1f43c54caa..59da80f3ec 100644 --- a/packages/cli/bin/config.ts +++ b/packages/cli/bin/config.ts @@ -1,4 +1,3 @@ -import path from 'node:path'; import type { Command, OptionValues } from '@commander-js/extra-typings'; import pc from 'picocolors'; import * as R from 'ramda'; @@ -10,25 +9,24 @@ import { parseConfig, validateConfig, } from '../src/config.js'; -import { readFile } from '../src/utils.js'; +import fs from '../src/utils/filesystem.js'; import { getCliOption, getDefaultCliOption, getSuppliedCliOption, type OptionGetter } from './options.js'; -export async function readConfigFile(configPath: string, allowFileNotFound = true): Promise { - const resolvedPath = path.resolve(process.cwd(), configPath); +export async function readConfigFile(configFilePath: string, allowFileNotFound = true): Promise { let configFile: string; try { - configFile = await readFile(resolvedPath, false, allowFileNotFound); + configFile = await fs.readFile(configFilePath, allowFileNotFound); } catch (err) { if (allowFileNotFound) { return ''; } - console.error(pc.redBright(`Could not read config file at ${pc.blue(resolvedPath)}`)); + console.error(pc.redBright(`Could not read config file at ${pc.blue(configFilePath)}`)); throw err; } if (configFile) { - console.log(`Found config file: ${pc.green(resolvedPath)}`); + console.log(`Found config file: ${pc.green(configFilePath)}`); } return configFile; @@ -36,11 +34,11 @@ export async function readConfigFile(configPath: string, allowFileNotFound = tru export async function parseCreateConfig( configFile: string, - options: { theme: string; cmd: Command; configPath: string }, + options: { theme: string; cmd: Command; configFilePath: string }, ): Promise { - const { cmd, theme = 'theme', configPath } = options; + const { cmd, theme = 'theme', configFilePath } = options; - const configParsed: CreateConfigSchema = parseConfig(configFile, configPath); + const configParsed: CreateConfigSchema = parseConfig(configFile, configFilePath); /* * Check that we're not creating multiple themes with different color names. @@ -101,14 +99,14 @@ export async function parseCreateConfig( }, }); - return validateConfig(configFileCreateSchema, unvalidatedConfig, configPath); + return validateConfig(configFileCreateSchema, unvalidatedConfig, configFilePath); } export async function parseBuildConfig( configFile: string, - { configPath }: { configPath: string }, + { configFilePath }: { configFilePath: string }, ): Promise { - const configParsed: BuildConfigSchema = parseConfig(configFile, configPath); + const configParsed: BuildConfigSchema = parseConfig(configFile, configFilePath); - return validateConfig(commonConfig, configParsed, configPath); + return validateConfig(commonConfig, configParsed, configFilePath); } diff --git a/packages/cli/bin/designsystemet.ts b/packages/cli/bin/designsystemet.ts index d5e236799c..8246ddc265 100644 --- a/packages/cli/bin/designsystemet.ts +++ b/packages/cli/bin/designsystemet.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node +import path from 'node:path'; import { Argument, createCommand, program } from '@commander-js/extra-typings'; import pc from 'picocolors'; import * as R from 'ramda'; @@ -6,11 +7,11 @@ import { convertToHex } from '../src/colors/index.js'; import type { CssColor } from '../src/colors/types.js'; import migrations from '../src/migrations/index.js'; import { buildTokens } from '../src/tokens/build.js'; -import { writeTokens } from '../src/tokens/create/write.js'; +import { createTokenFiles } from '../src/tokens/create/files.js'; import { cliOptions, createTokens } from '../src/tokens/create.js'; import { generateConfigFromTokens } from '../src/tokens/generate-config.js'; -import type { Theme } from '../src/tokens/types.js'; -import { cleanDir } from '../src/utils.js'; +import type { OutputFile, Theme } from '../src/tokens/types.js'; +import fs from '../src/utils/filesystem.js'; import { parseBuildConfig, parseCreateConfig, readConfigFile } from './config.js'; export const figletAscii = ` @@ -51,22 +52,31 @@ function makeTokenCommands() { .option('--experimental-tailwind', 'Generate Tailwind CSS classes for tokens', false) .action(async (opts) => { console.log(figletAscii); - const { verbose, clean, dry, experimentalTailwind } = opts; - const tokensDir = typeof opts.tokens === 'string' ? opts.tokens : DEFAULT_TOKENS_CREATE_DIR; - const outDir = typeof opts.outDir === 'string' ? opts.outDir : './dist/tokens'; + const { verbose, clean, dry, experimentalTailwind, tokens } = opts; - const { configFile, configPath } = await getConfigFile(opts.config); - const config = await parseBuildConfig(configFile, { configPath }); + const { configFile, configFilePath } = await getConfigFile(opts.config); + const config = await parseBuildConfig(configFile, { configFilePath }); - if (dry) { - console.log(`Performing dry run, no files will be written`); - } + fs.init({ dry, configFilePath, outdir: opts.outDir }); + + const outDir = fs.outDir; if (clean) { - await cleanDir(outDir, dry); + await fs.cleanDir(outDir); } - await buildTokens({ tokensDir, outDir, verbose, dry, tailwind: experimentalTailwind, ...config }); + const files = await buildTokens({ + tokensDir: tokens, + verbose, + tailwind: experimentalTailwind, + ...config, + }); + + console.log(`\n💾 Writing build to ${pc.green(outDir)}`); + + await fs.writeFiles(files, outDir, true); + + console.log(`\n✅ Finished building tokens in ${pc.green(outDir)}`); return Promise.resolve(); }); @@ -98,29 +108,45 @@ function makeTokenCommands() { if (opts.dry) { console.log(`Performing dry run, no files will be written`); } + const themeName = opts.theme; - const { configFile, configPath } = await getConfigFile(opts.config); + const { configFile, configFilePath } = await getConfigFile(opts.config); const config = await parseCreateConfig(configFile, { - theme: opts.theme, + theme: themeName, cmd, - configPath, + configFilePath, }); + fs.init({ + dry: opts.dry, + configFilePath, + outdir: opts.outDir !== DEFAULT_TOKENS_CREATE_DIR ? opts.outDir : config.outDir, + }); + + console.log('initialized file system with config:', { workingDir: fs.workingDir, outDir: fs.outDir }); + + const outDir = fs.outDir; + if (config.clean) { - await cleanDir(config.outDir, opts.dry); + await fs.cleanDir(outDir); } - /* - * Create and write tokens for each theme - */ + + let files: OutputFile[] = []; if (config.themes) { for (const [name, themeWithoutName] of Object.entries(config.themes)) { // Casting as missing properties should be validated by `getDefaultOrExplicitOption` to default values const theme = { name, ...themeWithoutName } as Theme; const { tokenSets } = await createTokens(theme); - await writeTokens({ outDir: config.outDir, theme, dry: opts.dry, tokenSets }); + files = files.concat(await createTokenFiles({ outDir, theme, tokenSets })); } } + + await fs.writeFiles(files, outDir); + + console.log(`\n✅ Finished creating tokens in ${pc.green(outDir)} for theme: ${pc.blue(themeName)}`); + + return Promise.resolve(); }); return tokenCmd; @@ -137,14 +163,15 @@ program .action(async (opts) => { console.log(figletAscii); const { dry } = opts; - const tokensDir = typeof opts.dir === 'string' ? opts.dir : DEFAULT_TOKENS_CREATE_DIR; - const outFile = typeof opts.out === 'string' ? opts.out : DEFAULT_CONFIG_FILE; + const tokensDir = path.resolve(opts.dir); + const configFilePath = path.resolve(opts.out); + + fs.init({ dry, configFilePath, outdir: path.dirname(configFilePath) }); try { const config = await generateConfigFromTokens({ tokensDir, - outFile: dry ? undefined : outFile, - dry, + outFile: configFilePath, }); if (dry) { @@ -152,6 +179,13 @@ program console.log('Generated config (dry run):'); console.log(JSON.stringify(config, null, 2)); } + + if (configFilePath) { + const configJson = JSON.stringify(config, null, 2); + await fs.writeFile(configFilePath, configJson); + console.log(); + console.log(`\n✅ Config file written to ${pc.blue(configFilePath)}`); + } } catch (error) { console.error(pc.redBright('Error generating config:')); console.error(error instanceof Error ? error.message : String(error)); @@ -201,9 +235,10 @@ function parseBoolean(value: string | boolean): boolean { return value === 'true' || value === true; } -async function getConfigFile(config: string | undefined) { - const allowFileNotFound = R.isNil(config) || config === DEFAULT_CONFIG_FILE; - const configPath = config ?? DEFAULT_CONFIG_FILE; - const configFile = await readConfigFile(configPath, allowFileNotFound); - return { configFile, configPath }; +async function getConfigFile(userConfigFilePath: string | undefined) { + const allowFileNotFound = R.isNil(userConfigFilePath) || userConfigFilePath === DEFAULT_CONFIG_FILE; + const configFilePath = userConfigFilePath ?? DEFAULT_CONFIG_FILE; + const configFile = await readConfigFile(configFilePath, allowFileNotFound); + + return { configFile, configFilePath }; } diff --git a/packages/cli/configs/test-tokens.config.json b/packages/cli/configs/test-tokens.config.json index 5d1b5f9201..b04c9ebbec 100644 --- a/packages/cli/configs/test-tokens.config.json +++ b/packages/cli/configs/test-tokens.config.json @@ -1,6 +1,6 @@ { "$schema": "../dist/config.schema.json", - "outDir": "./temp/config/design-tokens", + "outDir": "../temp/config/design-tokens", "clean": true, "themes": { "some-org": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 78bd7ed7fe..970fd79481 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -4,7 +4,7 @@ "description": "CLI for Designsystemet", "author": "Designsystemet team", "engines": { - "node": ">=20 <25" + "node": ">=20" }, "repository": { "type": "git", diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index 632dec546b..05ceaf28eb 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -51,12 +51,12 @@ function makeFriendlyError(err: unknown) { export function validateConfig( schema: z.ZodType, unvalidatedConfig: Record, - configPath: string, + configFilePath: string, ): T { try { return schema.parse(unvalidatedConfig) as T; } catch (err) { - console.error(pc.redBright(`Invalid config file at ${pc.red(configPath)}`)); + console.error(pc.redBright(`Invalid config file at ${pc.red(configFilePath)}`)); const validationError = makeFriendlyError(err); console.error(validationError?.toString()); @@ -64,7 +64,7 @@ export function validateConfig( } } -export function parseConfig(configFile: string, configPath: string): T { +export function parseConfig(configFile: string, configFilePath: string): T { if (!configFile) { return {} as T; } @@ -72,7 +72,7 @@ export function parseConfig(configFile: string, configPath: string): T { try { return JSON.parse(configFile) as T; } catch (err) { - console.error(pc.redBright(`Failed parsing config file at ${pc.red(configPath)}`)); + console.error(pc.redBright(`Failed parsing config file at ${pc.red(configFilePath)}`)); const validationError = makeFriendlyError(err); console.error(validationError?.toString()); diff --git a/packages/cli/src/migrations/codemods/css/run.ts b/packages/cli/src/migrations/codemods/css/run.ts index 482893cf1f..73b36210c0 100644 --- a/packages/cli/src/migrations/codemods/css/run.ts +++ b/packages/cli/src/migrations/codemods/css/run.ts @@ -1,9 +1,7 @@ -import fs from 'node:fs'; - import glob from 'fast-glob'; import type { AcceptedPlugin } from 'postcss'; import postcss from 'postcss'; -import { readFile } from '../../../utils.js'; +import fs from '../../../../src/utils/filesystem.js'; type CssCodemodProps = { plugins: AcceptedPlugin[]; @@ -25,10 +23,10 @@ export const runCssCodemod = async ({ plugins = [], globPattern = './**/*.css' } // console.log(`Skipping ${file}`); return; } - const contents = readFile(file).toString(); - const result = await processor.process(contents, { from: file }); + const contents = await fs.readFile(file); + const result = await processor.process(contents.toString(), { from: file }); - fs.writeFileSync(file, result.css); + await fs.writeFile(file, result.css); }); await Promise.all(filePromises); diff --git a/packages/cli/src/scripts/update-preview-tokens.ts b/packages/cli/src/scripts/update-preview-tokens.ts index 24615c5e6a..69a7b65f58 100644 --- a/packages/cli/src/scripts/update-preview-tokens.ts +++ b/packages/cli/src/scripts/update-preview-tokens.ts @@ -1,4 +1,3 @@ -import path from 'node:path'; import pc from 'picocolors'; import type { TransformedToken } from 'style-dictionary/types'; import config from './../../../../designsystemet.config.json' with { type: 'json' }; @@ -6,25 +5,11 @@ import { generate$Themes } from '../tokens/create/generators/$themes.js'; import { createTokens } from '../tokens/create.js'; import { buildOptions, processPlatform } from '../tokens/process/platform.js'; import { processThemeObject } from '../tokens/process/utils/getMultidimensionalThemes.js'; -import type { OutputFile, SizeModes, Theme } from '../tokens/types.js'; -import { cleanDir, mkdir, writeFile } from '../utils.js'; +import type { SizeModes, Theme } from '../tokens/types.js'; +import fs from '../utils/filesystem.js'; const OUTDIR = '../../internal/components/src/tokens/design-tokens'; -async function write(files: OutputFile[], outDir: string, dry?: boolean) { - for (const { destination, output } of files) { - if (destination) { - const filePath = path.join(outDir, destination); - const fileDir = path.dirname(filePath); - - console.log(`Writing file: ${pc.green(filePath)}`); - - await mkdir(fileDir, dry); - await writeFile(filePath, output, dry); - } - } -} - const toPreviewToken = (tokens: { token: TransformedToken; formatted: string }[]): PreviewToken[] => tokens.map(({ token, formatted }) => { const [variable, value] = formatted.split(':'); @@ -55,7 +40,7 @@ export const formatTheme = async (themeConfig: Theme) => { buildTokenFormats: {}, }); - await cleanDir(OUTDIR, false); + await fs.cleanDir(OUTDIR); console.log( buildOptions?.buildTokenFormats @@ -95,7 +80,7 @@ export const formatTheme = async (themeConfig: Theme) => { console.log(`\n💾 Writing preview tokens`); for (const [type, tokens] of Object.entries(tokensGroupedByType)) { - write( + fs.writeFiles( [ { destination: `${type}.json`, @@ -103,7 +88,6 @@ export const formatTheme = async (themeConfig: Theme) => { }, ], OUTDIR, - false, ); } console.log(`\n✅ Finished building preview tokens for ${pc.blue('Designsystemet')}`); diff --git a/packages/cli/src/tokens/build.ts b/packages/cli/src/tokens/build.ts index 29c258e5e7..48019cd7f9 100644 --- a/packages/cli/src/tokens/build.ts +++ b/packages/cli/src/tokens/build.ts @@ -1,8 +1,7 @@ -import path from 'node:path'; import type { ThemeObject } from '@tokens-studio/types'; import pc from 'picocolors'; import * as R from 'ramda'; -import { mkdir, readFile, writeFile } from '../utils.js'; +import fs from '../utils/filesystem.js'; import { createTypeDeclarationFiles } from './process/output/declarations.js'; import { createTailwindCSSFiles } from './process/output/tailwind.js'; import { createThemeCSSFiles, defaultFileHeader } from './process/output/theme.js'; @@ -10,29 +9,14 @@ import { type BuildOptions, processPlatform } from './process/platform.js'; import { processThemeObject } from './process/utils/getMultidimensionalThemes.js'; import type { DesignsystemetObject, OutputFile } from './types.js'; -async function write(files: OutputFile[], outDir: string, dry?: boolean) { - for (const { destination, output } of files) { - if (destination) { - const filePath = path.join(outDir, destination); - const fileDir = path.dirname(filePath); - - console.log(destination); - - await mkdir(fileDir, dry); - await writeFile(filePath, output, dry); - } - } -} - export const buildTokens = async (options: Omit) => { - const outDir = path.resolve(options.outDir); - const tokensDir = path.resolve(options.tokensDir); - const $themes = JSON.parse(await readFile(`${tokensDir}/$themes.json`)) as ThemeObject[]; + const tokensDir = options.tokensDir; + const $themes = JSON.parse(await fs.readFile(`${tokensDir}/$themes.json`)) as ThemeObject[]; const processed$themes = $themes.map(processThemeObject); let $designsystemet: DesignsystemetObject | undefined; try { - const $designsystemetContent = await readFile(`${tokensDir}/$designsystemet.jsonc`); + const $designsystemetContent = await fs.readFile(`${tokensDir}/$designsystemet.jsonc`); $designsystemet = JSON.parse($designsystemetContent) as DesignsystemetObject; } catch (_error) {} @@ -40,21 +24,12 @@ export const buildTokens = async (options: Omit JSON.stringify(data, null, 2); -type WriteTokensOptions = { +type CreateTokenFilesOptions = { outDir: string; theme: Theme; - /** Dry run, no files will be written */ - dry?: boolean; tokenSets: TokenSets; }; -export const writeTokens = async (options: WriteTokensOptions) => { +export const createTokenFiles = async (options: CreateTokenFilesOptions) => { const { outDir, tokenSets, theme: { name: themeName, colors }, - dry, } = options; - const targetDir = path.resolve(process.cwd(), String(outDir)); - const $themesPath = path.join(targetDir, '$themes.json'); - const $metadataPath = path.join(targetDir, '$metadata.json'); - const $designsystemetPath = path.join(targetDir, '$designsystemet.jsonc'); + + const $themesPath = '$themes.json'; + const $metadataPath = '$metadata.json'; + const $designsystemetPath = '$designsystemet.jsonc'; let themeObjects: ThemeObject[] = []; const sizeModes: SizeModes[] = ['small', 'medium', 'large']; - await mkdir(targetDir, dry); + await fs.mkdir(outDir); try { // Fetch existing themes - const $themes = await readFile($themesPath); + const $themes = await fs.readFile(path.join(outDir, $themesPath)); if ($themes) { themeObjects = JSON.parse($themes) as ThemeObject[]; } @@ -59,18 +56,16 @@ export const writeTokens = async (options: WriteTokensOptions) => { const $metadata = generate$Metadata(['dark', 'light'], themes, colors, sizeModes); const $designsystemet = generate$Designsystemet(); - await writeFile($themesPath, stringify($themes), dry); - await writeFile($metadataPath, stringify($metadata), dry); - await writeFile($designsystemetPath, stringify($designsystemet), dry); + const files: OutputFile[] = []; - for (const [set, tokens] of tokenSets) { - // Remove last part of the path to get the directory - const fileDir = path.join(targetDir, path.dirname(set)); - await mkdir(fileDir, dry); + files.push({ destination: $themesPath, output: stringify($themes) }); + files.push({ destination: $metadataPath, output: stringify($metadata) }); + files.push({ destination: $designsystemetPath, output: stringify($designsystemet) }); - const filePath = path.join(targetDir, `${set}.json`); - await writeFile(filePath, stringify(tokens), dry); + for (const [set, tokens] of tokenSets) { + const filePath = `${set}.json`; + files.push({ destination: filePath, output: stringify(tokens) }); } - console.log(`Finished creating Designsystem design tokens in ${pc.green(outDir)} for theme ${pc.blue(themeName)}`); + return files; }; diff --git a/packages/cli/src/tokens/generate-config.ts b/packages/cli/src/tokens/generate-config.ts index f8d244d46a..d7c3c84f81 100644 --- a/packages/cli/src/tokens/generate-config.ts +++ b/packages/cli/src/tokens/generate-config.ts @@ -1,8 +1,8 @@ -import fs from 'node:fs/promises'; import path from 'node:path'; import pc from 'picocolors'; import type { CssColor } from '../colors/types.js'; import type { CreateConfigSchema } from '../config.js'; +import fs from '../utils/filesystem.js'; type TokenValue = { $type: string; @@ -18,7 +18,7 @@ type TokenObject = { */ async function readJsonFile(filePath: string): Promise { try { - const content = await fs.readFile(filePath, 'utf-8'); + const content = await fs.readFile(filePath); return JSON.parse(content) as TokenObject; } catch (err) { throw new Error(`Failed to read token file at ${filePath}: ${err instanceof Error ? err.message : String(err)}`); @@ -209,14 +209,13 @@ function categorizeColors( export type GenerateConfigOptions = { tokensDir: string; outFile?: string; - dry?: boolean; }; /** * Generates a config file from existing design tokens */ export async function generateConfigFromTokens(options: GenerateConfigOptions): Promise { - const { tokensDir, dry = false } = options; + const { tokensDir } = options; console.log(`\nReading tokens from ${pc.blue(tokensDir)}`); @@ -291,12 +290,5 @@ export async function generateConfigFromTokens(options: GenerateConfigOptions): } } - if (!dry && options.outFile) { - const configJson = JSON.stringify(config, null, 2); - await fs.writeFile(options.outFile, configJson, 'utf-8'); - console.log(); - console.log(`\n✅ Config file written to ${pc.blue(options.outFile)}`); - } - return config; } diff --git a/packages/cli/src/tokens/process/platform.ts b/packages/cli/src/tokens/process/platform.ts index 22ea78ace4..0872ef1a49 100644 --- a/packages/cli/src/tokens/process/platform.ts +++ b/packages/cli/src/tokens/process/platform.ts @@ -16,8 +16,6 @@ type SharedOptions = { defaultSize?: string; /** Set the available size modes */ sizeModes?: string[]; - /** Dry run, no files will be written */ - dry?: boolean; /** Token Studio `$themes.json` content */ processed$themes: ProcessedThemeObject[]; /** Color groups */ @@ -30,8 +28,6 @@ export type BuildOptions = { type: 'build'; /** Design tokens path */ tokensDir: string; - /** Output directory for built tokens */ - outDir: string; /** Tailwind CSS configuration */ tailwind?: boolean; } & SharedOptions; diff --git a/packages/cli/src/tokens/types.ts b/packages/cli/src/tokens/types.ts index 67a5db9919..731f818cc7 100644 --- a/packages/cli/src/tokens/types.ts +++ b/packages/cli/src/tokens/types.ts @@ -70,6 +70,8 @@ export type BuildConfig = { export type SDConfigForThemePermutation = { permutation: ThemePermutation; config: SDConfig }; +/** This type is taken from Style Dictionary `formatPlatform`. + * Metadata for a file output */ export type OutputFile = { output: string; destination: string; diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts deleted file mode 100644 index 0e93f35ae8..0000000000 --- a/packages/cli/src/utils.ts +++ /dev/null @@ -1,89 +0,0 @@ -import type { CopyOptions } from 'node:fs'; -import fs from 'node:fs/promises'; -import pc from 'picocolors'; - -/** - * Creates a directory if it does not already exist. - * - * @param dir - The path of the directory to create. - * @param dry - Optional. If `true`, the function will log the operation - * without actually creating the directory. - * - * @returns A promise that resolves when the operation is complete. - * If the directory already exists or `dry` is `true`, the promise resolves immediately. - */ -export const mkdir = async (dir: string, dry?: boolean) => { - if (dry) { - console.log(`${pc.blue('mkdir')} ${dir}`); - return Promise.resolve(); - } - - const exists = await fs - .access(dir, fs.constants.F_OK) - .then(() => true) - .catch(() => false); - - if (exists) { - return Promise.resolve(); - } - - return fs.mkdir(dir, { recursive: true }); -}; - -export const writeFile = async (path: string, data: string, dry?: boolean) => { - if (dry) { - console.log(`${pc.blue('writeFile')} ${path}`); - return Promise.resolve(); - } - - return fs.writeFile(path, data, { encoding: 'utf-8' }).catch((error) => { - console.error(pc.red(`Error writing file: ${path}`)); - console.error(pc.red(error)); - throw error; - }); -}; - -export const cp = async (src: string, dest: string, dry?: boolean, filter?: CopyOptions['filter']) => { - if (dry) { - console.log(`${pc.blue('cp')} ${src} ${dest}`); - return Promise.resolve(); - } - - return fs.cp(src, dest, { recursive: true, filter }); -}; - -export const copyFile = async (src: string, dest: string, dry?: boolean) => { - if (dry) { - console.log(`${pc.blue('copyFile')} ${src} to ${dest}`); - return Promise.resolve(); - } - - return fs.copyFile(src, dest); -}; - -export const cleanDir = async (dir: string, dry?: boolean) => { - if (dry) { - console.log(`${pc.blue('cleanDir')} ${dir}`); - return Promise.resolve(); - } - - console.log(`\n🔥 Cleaning dir ${pc.red(`${dir.trim()}`)} `); - - return fs.rm(dir, { recursive: true, force: true }); -}; - -export const readFile = async (path: string, dry?: boolean, allowFileNotFound?: boolean) => { - if (dry) { - console.log(`${pc.blue('readFile')} ${path}`); - return Promise.resolve(''); - } - - try { - return await fs.readFile(path, 'utf-8'); - } catch (error) { - if (allowFileNotFound && (error as NodeJS.ErrnoException).code === 'ENOENT') { - return ''; - } - throw error; - } -}; diff --git a/packages/cli/src/utils/filesystem.ts b/packages/cli/src/utils/filesystem.ts new file mode 100644 index 0000000000..1c04e6fafc --- /dev/null +++ b/packages/cli/src/utils/filesystem.ts @@ -0,0 +1,151 @@ +import type { CopyOptions, PathLike } from 'node:fs'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import pc from 'picocolors'; +import type { OutputFile } from '../tokens/types.js'; + +class FileSystem { + private isInitialized = false; + private dry = false; + /** Default working directory is where the process was started */ + workingDir = process.cwd(); + outDir = this.workingDir; + + /** Initialize the file system */ + init({ dry, configFilePath, outdir }: { dry?: boolean; configFilePath?: string; outdir?: string }) { + if (this.isInitialized) { + console.warn(pc.yellow('FileSystem is already initialized. Ignoring subsequent init call.')); + return; + } + + if (dry) { + console.log(pc.blue('Initializing FileSystem in dry-run mode. No files will be written.')); + } + + this.dry = dry ?? false; + // If a config file path is provided, set the working directory to the config file's directory. This allows relative paths in the config file to be resolved correctly. If no config file path is provided, use the current working directory. + this.workingDir = configFilePath ? path.dirname(configFilePath) : this.workingDir; + // If an output directory is provided, resolve it relative to the working directory. Otherwise, use the working directory as the output directory. + this.outDir = outdir ? (path.isAbsolute(outdir) ? outdir : path.join(this.workingDir, outdir)) : this.workingDir; + this.isInitialized = true; + } + + /** + * Creates a directory if it does not already exist. + * + * @param dir - The path of the directory to create. + * + * @returns A promise that resolves when the operation is complete. + * If the directory already exists or `dry` is `true`, the promise resolves immediately. + */ + mkdir = async (dir: PathLike) => { + if (this.dry) { + console.log(`${pc.blue('mkdir')} ${dir}`); + return Promise.resolve(); + } + + const exists = await fs + .access(dir, fs.constants.F_OK) + .then(() => true) + .catch(() => false); + + if (exists) { + return Promise.resolve(); + } + + return fs.mkdir(dir, { recursive: true }); + }; + + writeFile = async (path: PathLike, data: string) => { + if (this.dry) { + console.log(`${pc.blue('writeFile')} ${path}`); + return Promise.resolve(); + } + + return fs.writeFile(path, data, { encoding: 'utf-8' }).catch((error) => { + console.error(pc.red(`Error writing file: ${path}`)); + console.error(pc.red(error)); + throw error; + }); + }; + + cp = async (src: string, dest: string, filter?: CopyOptions['filter']) => { + if (this.dry) { + console.log(`${pc.blue('cp')} ${src} ${dest}`); + return Promise.resolve(); + } + + return fs.cp(src, dest, { recursive: true, filter }); + }; + + copyFile = async (src: PathLike, dest: PathLike) => { + if (this.dry) { + console.log(`${pc.blue('copyFile')} ${src} to ${dest}`); + return Promise.resolve(); + } + + return fs.copyFile(src, dest); + }; + + cleanDir = async (dir: string) => { + if (this.dry) { + console.log(`${pc.blue('cleanDir')} ${dir}`); + return Promise.resolve(); + } + + console.log(`\n🔥 Cleaning dir ${pc.red(`${dir.trim()}`)} `); + + return fs.rm(dir, { recursive: true, force: true }); + }; + + readFile = async (path: PathLike, allowFileNotFound?: boolean) => { + if (this.dry) { + console.log(`${pc.blue('readFile')} ${path}`); + } + + try { + return await fs.readFile(path, 'utf-8'); + } catch (error) { + if (allowFileNotFound && (error as NodeJS.ErrnoException).code === 'ENOENT') { + return ''; + } + throw error; + } + }; + readdir = async (path: PathLike) => { + if (this.dry) { + console.log(`${pc.blue('readdir')} ${path}`); + } + + try { + return await fs.readdir(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return []; + } + throw error; + } + }; + writeFiles = async (files: OutputFile[], outDir: string, log?: boolean) => { + for (const { destination: filename, output } of files) { + if (filename) { + const filePath = path.join(outDir, filename); + const fileDir = path.dirname(filePath); + + if (log) { + console.log(filename); + } + + await this.mkdir(fileDir); + await this.writeFile(filePath, output); + } + } + }; +} + +/** + * An abstraction of Node's file system API and helper functions for CLI interaction with the file system. + * + * Allows dry-running destructive operations, logging and store relevant file system state. + */ +export default new FileSystem();