diff --git a/README.md b/README.md index 96c40cda..b6140275 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,54 @@ If you prefer not to install the package globally you can use `npx`: npx storyblok ``` +## Breaking Changes ⚠️ + +### `.storyblok` directory as default + +All the commands that generate files will now use the `.storyblok` directory as the default directory to interact with those files. This aims to encapsulate all Storyblok CLI operations instead of filling them on the root. Users would be able to customize the directory by using the `--path` flag. + +Example: + +```bash +storyblok pull-languages --space=12345 +``` + +Will generate the languages in the `.storyblok/languages` directory. + +> [!TIP] +> If you prefer to avoid pushing the `.storyblok` directory to your repository you can add it to your `.gitignore` file. + +### Generated filename syntax conventions + +The generated files will now follow a more consistent naming convention. The files will be named using the following syntax: + +``` +.. +``` + +Where: + +- `` is the name of the file. Customizable by the user with the `--filename` flag +- `` is an optional suffix to differentiate the files. By default is going to be the `spaceId` and is customizable by the user with the `--suffix` flag +- `` is the file extension. By default is `json` (Not configurable) + +Example: + +```bash +storyblok pull-languages --space=12345 --filename=my-languages --suffix=dev +``` + +Will generate the languages in the following path `.storyblok/languages/my-languages.dev.json` + +If you would like to use a timestamp as the suffix you can use: + +```bash +storyblok pull-languages --space=12345 --filename=my-languages --suffix="$(date +%s)" +``` + +> [!WARNING] +> The `--filename` will be ignored in the case that `--separate-files` is used on the commands that supports it. + ## Setup First clone the repository and install the dependencies: diff --git a/src/commands/pull-languages/actions.test.ts b/src/commands/pull-languages/actions.test.ts index dbec82bd..49d533df 100644 --- a/src/commands/pull-languages/actions.test.ts +++ b/src/commands/pull-languages/actions.test.ts @@ -84,7 +84,12 @@ describe('pull languages actions', () => { }, ], } - await saveLanguagesToFile('12345', mockResponse, '/temp') + await saveLanguagesToFile('12345', mockResponse, { + filename: 'languages', + path: '/temp', + verbose: false, + space: '12345', + }) const content = vol.readFileSync('/temp/languages.12345.json', 'utf8') expect(content).toBe(JSON.stringify(mockResponse, null, 2)) }) diff --git a/src/commands/pull-languages/actions.ts b/src/commands/pull-languages/actions.ts index b1d963f2..03b57d00 100644 --- a/src/commands/pull-languages/actions.ts +++ b/src/commands/pull-languages/actions.ts @@ -1,9 +1,10 @@ -import { access, constants, mkdir, writeFile } from 'node:fs/promises' -import { join, resolve } from 'node:path' +import { join } from 'node:path' import { handleAPIError, handleFileSystemError } from '../../utils' import { ofetch } from 'ofetch' import { regionsDomain } from '../../constants' +import { resolvePath, saveToFile } from '../../utils/filesystem' +import type { PullLanguagesOptions } from './constants' export interface SpaceInternationalizationOptions { languages: SpaceLanguage[] @@ -31,33 +32,15 @@ export const pullLanguages = async (space: string, token: string, region: string } } -export const saveLanguagesToFile = async (space: string, internationalizationOptions: SpaceInternationalizationOptions, path?: string) => { +export const saveLanguagesToFile = async (space: string, internationalizationOptions: SpaceInternationalizationOptions, options: PullLanguagesOptions) => { try { + const { filename = 'languages', suffix = space, path } = options const data = JSON.stringify(internationalizationOptions, null, 2) - const filename = `languages.${space}.json` - const resolvedPath = path ? resolve(process.cwd(), path) : process.cwd() - const filePath = join(resolvedPath, filename) + const name = `${filename}.${suffix}.json` + const resolvedPath = resolvePath(path, 'languages') + const filePath = join(resolvedPath, name) - // Check if the path exists, and create it if it doesn't - try { - await access(resolvedPath, constants.F_OK) - } - catch { - try { - await mkdir(resolvedPath, { recursive: true }) - } - catch (mkdirError) { - handleFileSystemError('mkdir', mkdirError as Error) - return // Exit early if the directory creation fails - } - } - - try { - await writeFile(filePath, data, { mode: 0o600 }) - } - catch (writeError) { - handleFileSystemError('write', writeError as Error) - } + await saveToFile(filePath, data) } catch (error) { handleFileSystemError('write', error as Error) diff --git a/src/commands/pull-languages/constants.ts b/src/commands/pull-languages/constants.ts new file mode 100644 index 00000000..c25a4c55 --- /dev/null +++ b/src/commands/pull-languages/constants.ts @@ -0,0 +1,30 @@ +import type { CommandOptions } from '../../types' + +/** + * Interface representing the options for the `pull-languages` command. + */ +export interface PullLanguagesOptions extends CommandOptions { + /** + * The path to save the languages file to. + * Defaults to `.storyblok/languages`. + * @default `.storyblok/languages` + */ + path?: string + /** + * The space ID. + * @required true + */ + space: string + /** + * The filename to save the file as. + * Defaults to `languages`. The file will be saved as `..json`. + * @default `languages + */ + filename?: string + /** + * The suffix to add to the filename. + * Defaults to the space ID. + * @default space + */ + suffix?: string +} diff --git a/src/commands/pull-languages/index.test.ts b/src/commands/pull-languages/index.test.ts index 1f938fb9..7c2732f1 100644 --- a/src/commands/pull-languages/index.test.ts +++ b/src/commands/pull-languages/index.test.ts @@ -88,8 +88,10 @@ describe('pullLanguages', () => { vi.mocked(pullLanguages).mockResolvedValue(mockResponse) await pullLanguagesCommand.parseAsync(['node', 'test', '--space', '12345']) expect(pullLanguages).toHaveBeenCalledWith('12345', 'valid-token', 'eu') - expect(saveLanguagesToFile).toHaveBeenCalledWith('12345', mockResponse, undefined) - expect(konsola.ok).toHaveBeenCalledWith(`Languages schema downloaded successfully at ${chalk.hex(colorPalette.PRIMARY)(`languages.12345.json`)}`) + expect(saveLanguagesToFile).toHaveBeenCalledWith('12345', mockResponse, { + space: '12345', + }) + expect(konsola.ok).toHaveBeenCalledWith(`Languages schema downloaded successfully at ${chalk.hex(colorPalette.PRIMARY)(`.storyblok/languages/languages.12345.json`)}`) }) it('should throw an error if the user is not logged in', async () => { @@ -109,8 +111,6 @@ describe('pullLanguages', () => { } const mockError = new CommandError(`Please provide the space as argument --space YOUR_SPACE_ID.`) - - console.log(pullLanguagesCommand) await pullLanguagesCommand.parseAsync(['node', 'test']) expect(konsola.error).toHaveBeenCalledWith(mockError, false) }) @@ -157,8 +157,75 @@ describe('pullLanguages', () => { vi.mocked(pullLanguages).mockResolvedValue(mockResponse) await pullLanguagesCommand.parseAsync(['node', 'test', '--space', '12345', '--path', '/tmp']) expect(pullLanguages).toHaveBeenCalledWith('12345', 'valid-token', 'eu') - expect(saveLanguagesToFile).toHaveBeenCalledWith('12345', mockResponse, '/tmp') + expect(saveLanguagesToFile).toHaveBeenCalledWith('12345', mockResponse, { + path: '/tmp', + space: '12345', + }) expect(konsola.ok).toHaveBeenCalledWith(`Languages schema downloaded successfully at ${chalk.hex(colorPalette.PRIMARY)(`/tmp/languages.12345.json`)}`) }) }) + + describe('--filename option', () => { + it('should save the file with the provided filename', async () => { + const mockResponse = { + default_lang_name: 'en', + languages: [ + { + code: 'ca', + name: 'Catalan', + }, + { + code: 'fr', + name: 'French', + }, + ], + } + session().state = { + isLoggedIn: true, + password: 'valid-token', + region: 'eu', + } + + vi.mocked(pullLanguages).mockResolvedValue(mockResponse) + await pullLanguagesCommand.parseAsync(['node', 'test', '--space', '12345', '--filename', 'custom-languages']) + expect(pullLanguages).toHaveBeenCalledWith('12345', 'valid-token', 'eu') + expect(saveLanguagesToFile).toHaveBeenCalledWith('12345', mockResponse, { + filename: 'custom-languages', + space: '12345', + }) + expect(konsola.ok).toHaveBeenCalledWith(`Languages schema downloaded successfully at ${chalk.hex(colorPalette.PRIMARY)(`.storyblok/languages/custom-languages.12345.json`)}`) + }) + }) + + describe('--suffix option', () => { + it('should save the file with the provided suffix', async () => { + const mockResponse = { + default_lang_name: 'en', + languages: [ + { + code: 'ca', + name: 'Catalan', + }, + { + code: 'fr', + name: 'French', + }, + ], + } + session().state = { + isLoggedIn: true, + password: 'valid-token', + region: 'eu', + } + + vi.mocked(pullLanguages).mockResolvedValue(mockResponse) + await pullLanguagesCommand.parseAsync(['node', 'test', '--space', '12345', '--suffix', 'custom-suffix']) + expect(pullLanguages).toHaveBeenCalledWith('12345', 'valid-token', 'eu') + expect(saveLanguagesToFile).toHaveBeenCalledWith('12345', mockResponse, { + suffix: 'custom-suffix', + space: '12345', + }) + expect(konsola.ok).toHaveBeenCalledWith(`Languages schema downloaded successfully at ${chalk.hex(colorPalette.PRIMARY)(`.storyblok/languages/languages.custom-suffix.json`)}`) + }) + }) }) diff --git a/src/commands/pull-languages/index.ts b/src/commands/pull-languages/index.ts index e567e5c4..59fc3e87 100644 --- a/src/commands/pull-languages/index.ts +++ b/src/commands/pull-languages/index.ts @@ -4,6 +4,7 @@ import { getProgram } from '../../program' import { session } from '../../session' import { pullLanguages, saveLanguagesToFile } from './actions' import chalk from 'chalk' +import type { PullLanguagesOptions } from './constants' const program = getProgram() // Get the shared singleton instance @@ -11,13 +12,15 @@ export const pullLanguagesCommand = program .command('pull-languages') .description(`Download your space's languages schema as json`) .option('-s, --space ', 'space ID') - .option('-p, --path ', 'path to save the file') - .action(async (options) => { + .option('-p, --path ', 'path to save the file. Default is .storyblok/languages') + .option('-f, --filename ', 'filename to save the file as ..json') + .option('--su, --suffix ', 'suffix to add to the file name (e.g. languages..json). By default, the space ID is used.') + .action(async (options: PullLanguagesOptions) => { konsola.title(` ${commands.PULL_LANGUAGES} `, colorPalette.PULL_LANGUAGES, 'Pulling languages...') // Global options const verbose = program.opts().verbose // Command options - const { space, path } = options + const { space, path, filename = 'languages', suffix = options.space } = options const { state, initializeSession } = session() await initializeSession() @@ -38,8 +41,8 @@ export const pullLanguagesCommand = program konsola.warn(`No languages found in the space ${space}`) return } - await saveLanguagesToFile(space, internationalization, path) - konsola.ok(`Languages schema downloaded successfully at ${chalk.hex(colorPalette.PRIMARY)(path ? `${path}/languages.${space}.json` : `languages.${space}.json`)}`) + await saveLanguagesToFile(space, internationalization, options) + konsola.ok(`Languages schema downloaded successfully at ${chalk.hex(colorPalette.PRIMARY)(path ? `${path}/${filename}.${suffix}.json` : `.storyblok/languages/${filename}.${suffix}.json`)}`) } catch (error) { handleError(error as Error, verbose) diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 00000000..05ce6d8c --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,9 @@ +/** + * Interface representing the default options for a CLI command. + */ +export interface CommandOptions { + /** + * Indicates whether verbose output is enabled. + */ + verbose: boolean +} diff --git a/src/utils/filesystem.test.ts b/src/utils/filesystem.test.ts new file mode 100644 index 00000000..5b074e9a --- /dev/null +++ b/src/utils/filesystem.test.ts @@ -0,0 +1,51 @@ +import { vol } from 'memfs' +import { resolvePath, saveToFile } from './filesystem' +import { resolve } from 'node:path' + +// tell vitest to use fs mock from __mocks__ folder +// this can be done in a setup file if fs should always be mocked +vi.mock('node:fs') +vi.mock('node:fs/promises') + +beforeEach(() => { + vi.clearAllMocks() + // reset the state of in-memory fs + vol.reset() +}) + +describe('filesystem utils', async () => { + describe('saveToFile', async () => { + it('should save the data to the file', async () => { + const filePath = '/path/to/file.txt' + const data = 'Hello, World!' + + await saveToFile(filePath, data) + + const content = vol.readFileSync(filePath, 'utf8') + expect(content).toBe(data) + }) + + it('should create the directory if it does not exist', async () => { + const filePath = '/path/to/new/file.txt' + const data = 'Hello, World!' + + await saveToFile(filePath, data) + + const content = vol.readFileSync(filePath, 'utf8') + expect(content).toBe(data) + }) + }) + + describe('resolvePath', async () => { + it('should resolve the path correctly', async () => { + const path = '/path/to/file' + const folder = 'folder' + + const resolvedPath = resolvePath(path, folder) + expect(resolvedPath).toBe(resolve(process.cwd(), path)) + + const resolvedPathWithoutPath = resolvePath(undefined, folder) + expect(resolvedPathWithoutPath).toBe(resolve(process.cwd(), '.storyblok/folder')) + }) + }) +}) diff --git a/src/utils/filesystem.ts b/src/utils/filesystem.ts new file mode 100644 index 00000000..5e9eaa03 --- /dev/null +++ b/src/utils/filesystem.ts @@ -0,0 +1,27 @@ +import { parse, resolve } from 'node:path' +import { mkdir, writeFile } from 'node:fs/promises' +import { handleFileSystemError } from './error/filesystem-error' + +export const saveToFile = async (filePath: string, data: string) => { + // Get the directory path + const resolvedPath = parse(filePath).dir + + // Ensure the directory exists + try { + await mkdir(resolvedPath, { recursive: true }) + } + catch (mkdirError) { + handleFileSystemError('mkdir', mkdirError as Error) + return // Exit early if the directory creation fails + } + + // Write the file + try { + await writeFile(filePath, data) + } + catch (writeError) { + handleFileSystemError('write', writeError as Error) + } +} + +export const resolvePath = (path: string | undefined, folder: string) => path ? resolve(process.cwd(), path) : resolve(resolve(process.cwd(), '.storyblok'), folder)