Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat!: generated file naming syntax #135

Merged
merged 6 commits into from
Dec 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,54 @@ If you prefer not to install the package globally you can use `npx`:
npx storyblok <command>
```

## 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:

```
<filename>.<suffix>.<extension>
```

Where:

- `<filename>` is the name of the file. Customizable by the user with the `--filename` flag
- `<suffix>` 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
- `<extension>` 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:
Expand Down
7 changes: 6 additions & 1 deletion src/commands/pull-languages/actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
})
Expand Down
35 changes: 9 additions & 26 deletions src/commands/pull-languages/actions.ts
Original file line number Diff line number Diff line change
@@ -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[]
Expand Down Expand Up @@ -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)
Expand Down
30 changes: 30 additions & 0 deletions src/commands/pull-languages/constants.ts
Original file line number Diff line number Diff line change
@@ -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 `<filename>.<space>.json`.
* @default `languages
*/
filename?: string
/**
* The suffix to add to the filename.
* Defaults to the space ID.
* @default space
*/
suffix?: string
}
77 changes: 72 additions & 5 deletions src/commands/pull-languages/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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)
})
Expand Down Expand Up @@ -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`)}`)
})
})
})
13 changes: 8 additions & 5 deletions src/commands/pull-languages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,23 @@ 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

export const pullLanguagesCommand = program
.command('pull-languages')
.description(`Download your space's languages schema as json`)
.option('-s, --space <space>', 'space ID')
.option('-p, --path <path>', 'path to save the file')
.action(async (options) => {
.option('-p, --path <path>', 'path to save the file. Default is .storyblok/languages')
.option('-f, --filename <filename>', 'filename to save the file as <filename>.<suffix>.json')
.option('--su, --suffix <suffix>', 'suffix to add to the file name (e.g. languages.<suffix>.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()
Expand All @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* Interface representing the default options for a CLI command.
*/
export interface CommandOptions {
/**
* Indicates whether verbose output is enabled.
*/
verbose: boolean
}
51 changes: 51 additions & 0 deletions src/utils/filesystem.test.ts
Original file line number Diff line number Diff line change
@@ -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'))
})
})
})
27 changes: 27 additions & 0 deletions src/utils/filesystem.ts
Original file line number Diff line number Diff line change
@@ -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)