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..1771befb5c 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); let configFile: string; try { - configFile = await readFile(resolvedPath, false, allowFileNotFound); + configFile = await fs.readFile(configPath, 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(configPath)}`)); throw err; } if (configFile) { - console.log(`Found config file: ${pc.green(resolvedPath)}`); + console.log(`Found config file: ${pc.green(configPath)}`); } return configFile; diff --git a/packages/cli/bin/designsystemet.ts b/packages/cli/bin/designsystemet.ts index d5e236799c..8d40b3c4e5 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,33 @@ 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 configFilePath = opts.config; + + const { configFile, configPath } = await getConfigFile(configFilePath); const config = await parseBuildConfig(configFile, { configPath }); - if (dry) { - console.log(`Performing dry run, no files will be written`); - } + fs.init({ dry, configFilePath }); + + const outDir = fs.getOutdir(opts.outDir); // TODO read output directory from config or CLI options 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 +110,40 @@ function makeTokenCommands() { if (opts.dry) { console.log(`Performing dry run, no files will be written`); } + const themeName = opts.theme; + const configFilePath = opts.config; - const { configFile, configPath } = await getConfigFile(opts.config); + const { configFile, configPath } = await getConfigFile(configFilePath); const config = await parseCreateConfig(configFile, { - theme: opts.theme, + theme: themeName, cmd, configPath, }); + fs.init({ dry: opts.dry, configFilePath }); + + const outDir = fs.getOutdir(config.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 +160,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 }); try { const config = await generateConfigFromTokens({ tokensDir, - outFile: dry ? undefined : outFile, - dry, + outFile: configFilePath, }); if (dry) { @@ -152,6 +176,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)); @@ -205,5 +236,6 @@ 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 }; } 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/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..b0629a0d55 --- /dev/null +++ b/packages/cli/src/utils/filesystem.ts @@ -0,0 +1,155 @@ +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; + /** Resolved write directory */ + workingDir = process.cwd(); + + /** Initialize the file system */ + init({ dry, configFilePath }: { dry?: boolean; configFilePath?: 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; + this.workingDir = configFilePath ? path.dirname(configFilePath) : process.cwd(); + this.isInitialized = true; + } + + getOutdir(outDir?: string) { + if (outDir) { + return path.isAbsolute(outDir) ? outDir : path.join(this.workingDir, outDir); + } + + return this.workingDir; + } + + /** + * 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();