Skip to content
Open
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
5 changes: 5 additions & 0 deletions packages/cli/src/commands/types/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import { getProgram } from '../../program';

const program = getProgram(); // Get the shared singleton instance

export interface TypesCommandOptions {
space: string;
path?: string;
}

// Components root command
export const typesCommand = program
.command(commands.TYPES)
Expand Down
18 changes: 14 additions & 4 deletions packages/cli/src/commands/types/generate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,17 @@ The `types generate` command generates TypeScript type definitions (`.d.ts` file
storyblok types generate --space <spaceId>
```

## Options

| Option | Description | Default |
|--------|-------------|---------|
| `--sf, --separate-files` | Generate separate type definition files for each component | `false` |
| `--sf, --separate-files` | Generate separate `.d.ts` files for each component (requires components pulled with `--separate-files`) | `false` |
| `--strict` | Enable strict mode with no loose typing | `false` |
| `--filename <name>` | File name for the generated type files | `storyblok` |
| `--type-prefix <prefix>` | Prefix to be prepended to all generated component type names | - |
| `--suffix <suffix>` | Suffix for component names | - |
| `--custom-fields-parser <path>` | Path to the parser file for Custom Field Types | - |
| `--compiler-options <options>` | Path to the compiler options from json-schema-to-typescript | - |
| `--space <spaceId>` | (Required) The ID of your Storyblok space | - |
| `--path <path>` | Path to the directory containing your component files | `.storyblok/components` |
| `--path <path>` | Path to the directory containing your component files | `.storyblok/types/{spaceId}/storyblok-components.d.ts` |

## Examples

Expand Down Expand Up @@ -70,6 +68,17 @@ The following structure will be created:
└── storyblok-components.d.ts # Your component types
```

If you use `--separate-files`, then each component will have its own `.d.ts` file:
```
.storyblok/
└── types/
├── storyblok.d.ts
└── 295018/
├── Hero.d.ts
├── ProductCard.d.ts
└── Footer.d.ts
```

> **Note:**
> The `{spaceId}` folder corresponds to the ID of your Storyblok space.
> The generated files are always placed under `.storyblok/types/` and `.storyblok/types/{spaceId}/`.
Expand All @@ -81,3 +90,4 @@ The following structure will be created:
- The generated types are based on your component schemas in Storyblok
- When using `--strict`, the generated types will be more precise but may require more explicit type handling in your code
- Custom field types can be handled by providing a parser file with `--custom-fields-parser`
- If you use `--separate-files`, make sure your component files were pulled with the same flag (`components pull --separate-files`)
149 changes: 132 additions & 17 deletions packages/cli/src/commands/types/generate/actions.test.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
import { generateStoryblokTypes, generateTypes, getComponentType, getStoryType, saveTypesToComponentsFile } from './actions';
import { generateStoryblokTypes, generateTypes, getComponentType, getStoryType, saveConsolidatedTypeFile, saveSeparateTypeFiles } from './actions';
import { vol } from 'memfs';
import { readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { SpaceComponent, SpaceComponentsData } from '../../../commands/components/constants';
import type { GenerateTypesOptions } from './constants';

// Import the mocked functions
import { saveToFile } from '../../../utils/filesystem';

// Mock the filesystem module
vi.mock('../../../utils/filesystem', () => ({
saveToFile: vi.fn().mockResolvedValue(undefined),
resolvePath: vi.fn().mockReturnValue('/mocked/resolved/path'),
}));
vi.mock('../../../utils/filesystem', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../utils/filesystem')>();
return {
sanitizeFilename: actual.sanitizeFilename,
saveToFile: vi.fn().mockResolvedValue(undefined),
resolvePath: vi.fn().mockReturnValue('/mocked/resolved/path'),
};
});

// Mock the fs module
vi.mock('node:fs', () => ({
Expand Down Expand Up @@ -805,29 +808,141 @@ describe('generateStoryblokTypes', () => {
});
});

describe('saveTypesToComponentsFile', () => {
describe('separate files mode', () => {
it('should return an array of separate type files when options.separateFiles is true', async () => {
const spaceData: SpaceComponentsData = {
components: [
{
name: 'Hero Section X',
display_name: 'Hero',
created_at: '2023-01-01T00:00:00Z',
updated_at: '2023-01-01T00:00:00Z',
id: 1,
schema: {
title: { type: 'text', required: true },
},
internal_tags_list: [],
internal_tag_ids: [],
},
{
name: 'cta-button',
display_name: 'CTA Button',
created_at: '2023-01-01T00:00:00Z',
updated_at: '2023-01-01T00:00:00Z',
id: 2,
schema: {
href: { type: 'multilink', required: false, email_link_type: true, asset_link_type: true },
},
internal_tags_list: [],
internal_tag_ids: [],
},
],
datasources: [],
groups: [],
presets: [],
internalTags: [],
};

const result = await generateTypes(spaceData, { separateFiles: true, strict: false });
expect(Array.isArray(result)).toBe(true);
const files = result as { name: string; content: string }[];
expect(files).toHaveLength(2);

expect(files[0].content).toContain('// This file was generated by the storyblok CLI.');
expect(files[0].content).toContain('// DO NOT MODIFY THIS FILE BY HAND.');
expect(files[0].name).toBe('Hero Section X');
Copy link

Copilot AI Oct 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The expected filename 'Hero Section X' contains spaces and special characters which will not match the sanitized output from sanitizeFilename. The test should expect a sanitized version like 'Hero_Section_X' or similar based on the sanitization logic.

Suggested change
expect(files[0].name).toBe('Hero Section X');
expect(files[0].name).toBe('Hero_Section_X');

Copilot uses AI. Check for mistakes.
expect(files[0].content.includes('export interface HeroSectionX')).toBe(true);
expect(files[1].name).toBe('cta-button');
expect(files[1].content.includes('export interface CtaButton')).toBe(true);
});

it('should scope Storyblok imports per file (only files that need Multilink import should have it)', async () => {
const withMultilink: SpaceComponent = {
name: 'link-card',
created_at: '2023-01-01T00:00:00Z',
updated_at: '2023-01-01T00:00:00Z',
id: 1,
schema: {
link: { type: 'multilink', required: false, email_link_type: true, asset_link_type: true },
},
};

const simpleText: SpaceComponent = {
name: 'plain-text',
created_at: '2023-01-01T00:00:00Z',
updated_at: '2023-01-01T00:00:00Z',
id: 2,
schema: {
text: { type: 'text', required: false },
},
};

const spaceData: SpaceComponentsData = {
components: [withMultilink, simpleText],
datasources: [],
groups: [],
presets: [],
internalTags: [],
};

const files = await generateTypes(spaceData, { separateFiles: true, strict: false });
if (!Array.isArray(files)) {
throw new TypeError(`generateTypes didn't return an array`);
}

const fileWith = files.find(f => f.name.includes('link-card') || f.content.includes('export interface LinkCard'));
const fileWithout = files.find(f => f.name.includes('plain-text') || f.content.includes('export interface PlainText'));

expect(fileWith).toBeDefined();
expect(fileWithout).toBeDefined();

// Multilink import only in the file that needs it
expect(fileWith!.content).toMatch(/import type \{ StoryblokMultilink \} from '\.\.\/storyblok\.d\.ts';/);
expect(fileWithout!.content).not.toMatch(/import type \{ StoryblokMultilink \} from '\.\.\/storyblok\.d\.ts';/);
});
});

describe('saveConsolidatedTypeFile', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('should call saveTypesToComponentsFile with the expected path and default filename', async () => {
it('should write a single file with default filename', async () => {
const dummyTypes = '// types content';
await saveTypesToComponentsFile('12345', dummyTypes, {});
await saveConsolidatedTypeFile('12345', dummyTypes, '/some/path');

// We expect join to be called with the filename ending in -components.d.ts
expect(join).toHaveBeenCalledWith(expect.any(String), `storyblok-components.d.ts`);
// We expect saveToFile to be called with the mocked joined path and the dummy types
expect(join).toHaveBeenCalledWith(expect.any(String), 'storyblok-components.d.ts');
expect(saveToFile).toHaveBeenCalledWith('/mocked/joined/path', dummyTypes);
});

it('should call saveTypesToComponentsFile with the expected path and custom filename', async () => {
const customFilename = 'my-custom-types';
it('should write a single file with custom filename', async () => {
const dummyTypes = '// types content';
await saveTypesToComponentsFile('12345', dummyTypes, { filename: customFilename });
const customFilename = 'my-custom-types';

await saveConsolidatedTypeFile('12345', dummyTypes, '/some/path', customFilename);

// We expect join to be called with the filename ending in -components.d.ts
expect(join).toHaveBeenCalledWith(expect.any(String), `${customFilename}.d.ts`);
// We expect saveToFile to be called with the mocked joined path and the dummy types
expect(saveToFile).toHaveBeenCalledWith('/mocked/joined/path', dummyTypes);
});
});

describe('saveSeparateTypeFiles', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('should write one file per component', async () => {
const files = [
{ name: 'Hero', content: '// hero' },
{ name: 'Button', content: '// button' },
];

await saveSeparateTypeFiles('12345', files, '/some/path');

expect(saveToFile).toHaveBeenCalledTimes(2);
expect(join).toHaveBeenCalledWith(expect.any(String), 'Hero.d.ts');
expect(join).toHaveBeenCalledWith(expect.any(String), 'Button.d.ts');
expect(saveToFile).toHaveBeenCalledWith('/mocked/joined/path', '// hero');
expect(saveToFile).toHaveBeenCalledWith('/mocked/joined/path', '// button');
});
});
Loading
Loading