From d845c1716d3d55f609ced5a44688daf0d5957f0c Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Tue, 1 Apr 2025 09:06:15 +0200 Subject: [PATCH 01/16] feat: add utility functions for string formatting and object manipulation - Introduced new utility functions in `format.ts` for converting strings to PascalCase, CamelCase, and SnakeCase. - Added functions for masking tokens, slugifying text, and recursively removing properties from objects. - Implemented a method to convert objects to string parameters for URLSearchParams. - Created a function to generate regex patterns from glob patterns. - Updated `index.ts` to export the new formatting utilities. --- src/utils/format.test.ts | 157 +++++++++++++++++++++++++++++++++++++++ src/utils/format.ts | 86 +++++++++++++++++++++ src/utils/index.ts | 77 +------------------ 3 files changed, 245 insertions(+), 75 deletions(-) create mode 100644 src/utils/format.test.ts create mode 100644 src/utils/format.ts diff --git a/src/utils/format.test.ts b/src/utils/format.test.ts new file mode 100644 index 0000000..35b3c5f --- /dev/null +++ b/src/utils/format.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest'; +import { + createRegexFromGlob, + maskToken, + objectToStringParams, + removePropertyRecursively, + slugify, + toCamelCase, + toPascalCase, + toSnakeCase, +} from './format'; + +describe('format utils', () => { + describe('toPascalCase', () => { + it('should convert snake_case to PascalCase', () => { + expect(toPascalCase('hello_world')).toBe('HelloWorld'); + }); + + it('should handle single word', () => { + expect(toPascalCase('hello')).toBe('Hello'); + }); + }); + + describe('toCamelCase', () => { + it('should convert snake_case to camelCase', () => { + expect(toCamelCase('hello_world')).toBe('helloWorld'); + }); + + it('should handle single word', () => { + expect(toCamelCase('hello')).toBe('hello'); + }); + }); + + describe('toSnakeCase', () => { + it('should convert PascalCase to snake_case', () => { + expect(toSnakeCase('HelloWorld')).toBe('hello_world'); + }); + + it('should convert camelCase to snake_case', () => { + expect(toSnakeCase('helloWorld')).toBe('hello_world'); + }); + }); + + describe('maskToken', () => { + it('should mask token longer than 4 characters', () => { + expect(maskToken('1234567890')).toBe('1234******'); + }); + + it('should not mask token with 4 or fewer characters', () => { + expect(maskToken('1234')).toBe('1234'); + expect(maskToken('123')).toBe('123'); + }); + }); + + describe('slugify', () => { + it('should convert text to URL-friendly slug', () => { + expect(slugify('Hello World!')).toBe('hello-world'); + }); + + it('should handle special characters and multiple spaces', () => { + expect(slugify('Hello World!!! Test')).toBe('hello-world-test'); + }); + + it('should remove non-word characters', () => { + expect(slugify('Hello@World#123')).toBe('helloworld123'); + }); + }); + + describe('removePropertyRecursively', () => { + it('should remove specified property from nested object', () => { + const input = { + name: 'test', + id: 1, + nested: { + name: 'nested', + id: 2, + deep: { + name: 'deep', + id: 3, + }, + }, + }; + const expected = { + name: 'test', + nested: { + name: 'nested', + deep: { + name: 'deep', + }, + }, + }; + expect(removePropertyRecursively(input, 'id')).toEqual(expected); + }); + + it('should handle arrays', () => { + const input = { + items: [ + { id: 1, name: 'item1' }, + { id: 2, name: 'item2' }, + ], + }; + const expected = { + items: [ + { name: 'item1' }, + { name: 'item2' }, + ], + }; + expect(removePropertyRecursively(input, 'id')).toEqual(expected); + }); + }); + + describe('objectToStringParams', () => { + it('should convert object values to strings', () => { + const input = { + number: 123, + boolean: true, + string: 'test', + object: { key: 'value' }, + array: [1, 2, 3], + undefined, + }; + const expected = { + number: '123', + boolean: 'true', + string: 'test', + object: '{"key":"value"}', + array: '[1,2,3]', + }; + expect(objectToStringParams(input)).toEqual(expected); + }); + + it('should skip undefined values', () => { + const input = { + defined: 'value', + undef: undefined, + }; + expect(objectToStringParams(input)).toEqual({ + defined: 'value', + }); + }); + }); + + describe('createRegexFromGlob', () => { + it('should create regex from glob pattern', () => { + const regex = createRegexFromGlob('test*.js'); + expect(regex.test('test.js')).toBe(true); + expect(regex.test('test123.js')).toBe(true); + expect(regex.test('other.js')).toBe(false); + }); + + it('should escape special characters', () => { + const regex = createRegexFromGlob('test.js'); + expect(regex.test('test.js')).toBe(true); + expect(regex.test('testxjs')).toBe(false); + }); + }); +}); diff --git a/src/utils/format.ts b/src/utils/format.ts new file mode 100644 index 0000000..ae6ee16 --- /dev/null +++ b/src/utils/format.ts @@ -0,0 +1,86 @@ +export const toPascalCase = (str: string) => { + return str.replace(/(?:^|_)(\w)/g, (_, char) => char.toUpperCase()); +}; + +export const toCamelCase = (str: string) => { + return str.replace(/(?:^|_)(\w)/g, (_, char) => char.toUpperCase()).replace(/_/g, ''); +}; + +export const toSnakeCase = (str: string) => { + return str.replace(/([A-Z])/g, '_$1').toLowerCase(); +}; + +export function maskToken(token: string): string { + // Show only the first 4 characters and replace the rest with asterisks + if (token.length <= 4) { + // If the token is too short, just return it as is + return token; + } + const visiblePart = token.slice(0, 4); + const maskedPart = '*'.repeat(token.length - 4); + return `${visiblePart}${maskedPart}`; +} + +export const slugify = (text: string): string => + text + .toString() + .toLowerCase() + .replace(/\s+/g, '-') // Replace spaces with - + .replace(/[^\w-]+/g, '') // Remove all non-word chars + .replace(/-{2,}/g, '-') // Replace multiple - with single - + .replace(/^-+/, '') // Trim - from start of text + .replace(/-+$/, ''); + +export const removePropertyRecursively = (obj: Record, property: string): Record => { + if (typeof obj !== 'object' || obj === null) { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map(item => removePropertyRecursively(item, property)); + } + + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + if (key !== property) { + result[key] = removePropertyRecursively(value, property); + } + } + return result; +}; + +/** + * Converts an object with potential non-string values to an object with string values + * for use with URLSearchParams + * + * @param obj - The object to convert + * @returns An object with all values converted to strings + */ +export const objectToStringParams = (obj: Record): Record => { + return Object.entries(obj).reduce((acc, [key, value]) => { + // Skip undefined values + if (value === undefined) { + return acc; + } + + // Convert objects/arrays to JSON strings + if (typeof value === 'object' && value !== null) { + acc[key] = JSON.stringify(value); + } + else { + // Convert other types to strings + acc[key] = String(value); + } + return acc; + }, {} as Record); +}; + +/** + * Creates a regex pattern from a glob pattern + * @param pattern - The glob pattern to convert + * @returns A regex that matches the glob pattern + */ +export function createRegexFromGlob(pattern: string): RegExp { + // Add ^ and $ to ensure exact match, escape the pattern to handle special characters + return new RegExp(`^${pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\\\*/g, '.*')}$`); +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 3186215..0dc77d6 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -4,7 +4,9 @@ import type { RegionCode } from '../constants'; import { regions } from '../constants'; export * from './error/'; +export * from './format'; export * from './konsola'; + export const __filename = fileURLToPath(import.meta.url); export const __dirname = dirname(__filename); @@ -12,79 +14,4 @@ export function isRegion(value: RegionCode): value is RegionCode { return Object.values(regions).includes(value); } -export function maskToken(token: string): string { - // Show only the first 4 characters and replace the rest with asterisks - if (token.length <= 4) { - // If the token is too short, just return it as is - return token; - } - const visiblePart = token.slice(0, 4); - const maskedPart = '*'.repeat(token.length - 4); - return `${visiblePart}${maskedPart}`; -} - -export const slugify = (text: string): string => - text - .toString() - .toLowerCase() - .replace(/\s+/g, '-') // Replace spaces with - - .replace(/[^\w-]+/g, '') // Remove all non-word chars - .replace(/-{2,}/g, '-') // Replace multiple - with single - - .replace(/^-+/, '') // Trim - from start of text - .replace(/-+$/, ''); - -export const removePropertyRecursively = (obj: Record, property: string): Record => { - if (typeof obj !== 'object' || obj === null) { - return obj; - } - - if (Array.isArray(obj)) { - return obj.map(item => removePropertyRecursively(item, property)); - } - - const result: Record = {}; - for (const [key, value] of Object.entries(obj)) { - if (key !== property) { - result[key] = removePropertyRecursively(value, property); - } - } - return result; -}; - -/** - * Converts an object with potential non-string values to an object with string values - * for use with URLSearchParams - * - * @param obj - The object to convert - * @returns An object with all values converted to strings - */ -export const objectToStringParams = (obj: Record): Record => { - return Object.entries(obj).reduce((acc, [key, value]) => { - // Skip undefined values - if (value === undefined) { - return acc; - } - - // Convert objects/arrays to JSON strings - if (typeof value === 'object' && value !== null) { - acc[key] = JSON.stringify(value); - } - else { - // Convert other types to strings - acc[key] = String(value); - } - return acc; - }, {} as Record); -}; - export const isVitest = process.env.VITEST === 'true'; - -/** - * Creates a regex pattern from a glob pattern - * @param pattern - The glob pattern to convert - * @returns A regex that matches the glob pattern - */ -export function createRegexFromGlob(pattern: string): RegExp { - // Add ^ and $ to ensure exact match, escape the pattern to handle special characters - return new RegExp(`^${pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\\\*/g, '.*')}$`); -} From 4c7dcacd74c332729c6c64ee99003be6349f0f2e Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Mon, 7 Apr 2025 09:50:50 +0200 Subject: [PATCH 02/16] feat: base types generation cmd --- .vscode/launch.json | 16 + package.json | 1 + pnpm-lock.yaml | 61 +++- src/commands/components/constants.ts | 2 +- src/commands/components/pull/index.ts | 2 +- src/commands/types/command.ts | 11 + src/commands/types/generate/actions.ts | 176 ++++++++++ src/commands/types/generate/constants.ts | 4 + src/commands/types/generate/index.ts | 65 ++++ src/commands/types/index.ts | 5 + src/constants.ts | 2 + src/index.ts | 1 + src/types/storyblok.ts | 39 +++ src/utils/format.ts | 9 +- src/utils/storyblok-schemas.ts | 429 +++++++++++++++++++++++ 15 files changed, 811 insertions(+), 12 deletions(-) create mode 100644 src/commands/types/command.ts create mode 100644 src/commands/types/generate/actions.ts create mode 100644 src/commands/types/generate/constants.ts create mode 100644 src/commands/types/generate/index.ts create mode 100644 src/commands/types/index.ts create mode 100644 src/types/storyblok.ts create mode 100644 src/utils/storyblok-schemas.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index ee20c49..e8b0d7c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -280,10 +280,26 @@ } }, { + "type": "node", + "request": "launch", "name": "Debug Migrations generate non-existing component", "program": "${workspaceFolder}/dist/index.mjs", "args": ["migrations", "generate", "non-existing", "--space", "295017"], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "sourceMaps": true, + "outFiles": ["${workspaceFolder}/dist/**/*.js"], + "env": { + "STUB": "true" + } + }, + { + "type": "node", + "request": "launch", + "name": "Debug Generate types", + "program": "${workspaceFolder}/dist/index.mjs", + "args": ["types", "generate", "--space", "295017", "--filter", "component-inside-*"], "cwd": "${workspaceFolder}", "console": "integratedTerminal", "sourceMaps": true, diff --git a/package.json b/package.json index c08b8f2..b2432eb 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "chalk": "^5.4.1", "commander": "^13.1.0", "dotenv": "^16.4.7", + "json-schema-to-typescript": "^15.0.4", "ohash": "^2.0.5", "storyblok-js-client": "^6.10.7" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0968091..85254fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: dotenv: specifier: ^16.4.7 version: 16.4.7 + json-schema-to-typescript: + specifier: ^15.0.4 + version: 15.0.4 ohash: specifier: ^2.0.5 version: 2.0.5 @@ -32,7 +35,7 @@ importers: devDependencies: '@storyblok/eslint-config': specifier: ^0.3.0 - version: 0.3.0(@typescript-eslint/utils@8.22.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3))(@vue/compiler-sfc@3.5.5)(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3)(vitest@3.0.4) + version: 0.3.0(@typescript-eslint/utils@8.22.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3))(@vue/compiler-sfc@3.5.5)(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3)(vitest@3.0.4(@types/debug@4.1.12)(@types/node@22.12.0)(@vitest/ui@3.0.4)(jiti@2.4.2)(msw@2.7.0(@types/node@22.12.0)(typescript@5.7.3))(yaml@2.7.0)) '@types/inquirer': specifier: ^9.0.7 version: 9.0.7 @@ -41,7 +44,7 @@ importers: version: 22.12.0 '@vitest/coverage-v8': specifier: ^3.0.4 - version: 3.0.4(vitest@3.0.4) + version: 3.0.4(vitest@3.0.4(@types/debug@4.1.12)(@types/node@22.12.0)(@vitest/ui@3.0.4)(jiti@2.4.2)(msw@2.7.0(@types/node@22.12.0)(typescript@5.7.3))(yaml@2.7.0)) '@vitest/ui': specifier: ^3.0.4 version: 3.0.4(vitest@3.0.4) @@ -128,6 +131,10 @@ packages: '@antfu/utils@0.7.10': resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==} + '@apidevtools/json-schema-ref-parser@11.9.3': + resolution: {integrity: sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==} + engines: {node: '>= 16'} + '@babel/code-frame@7.26.2': resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} engines: {node: '>=6.9.0'} @@ -558,6 +565,9 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@jsdevtools/ono@7.1.3': + resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} + '@jsonjoy.com/base64@1.1.2': resolution: {integrity: sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==} engines: {node: '>=10.0'} @@ -802,6 +812,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/lodash@4.17.16': + resolution: {integrity: sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -1776,6 +1789,11 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema-to-typescript@15.0.4: + resolution: {integrity: sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==} + engines: {node: '>=16.0.0'} + hasBin: true + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -2011,6 +2029,9 @@ packages: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.2: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} @@ -2913,7 +2934,7 @@ snapshots: '@jridgewell/gen-mapping': 0.3.8 '@jridgewell/trace-mapping': 0.3.25 - '@antfu/eslint-config@3.6.2(@typescript-eslint/utils@8.22.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3))(@vue/compiler-sfc@3.5.5)(eslint-plugin-format@0.1.3(eslint@9.19.0(jiti@2.4.2)))(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3)(vitest@3.0.4)': + '@antfu/eslint-config@3.6.2(@typescript-eslint/utils@8.22.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3))(@vue/compiler-sfc@3.5.5)(eslint-plugin-format@0.1.3(eslint@9.19.0(jiti@2.4.2)))(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3)(vitest@3.0.4(@types/debug@4.1.12)(@types/node@22.12.0)(@vitest/ui@3.0.4)(jiti@2.4.2)(msw@2.7.0(@types/node@22.12.0)(typescript@5.7.3))(yaml@2.7.0))': dependencies: '@antfu/install-pkg': 0.4.1 '@clack/prompts': 0.7.0 @@ -2922,7 +2943,7 @@ snapshots: '@stylistic/eslint-plugin': 2.13.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3) '@typescript-eslint/eslint-plugin': 8.22.0(@typescript-eslint/parser@8.22.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3))(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3) '@typescript-eslint/parser': 8.22.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3) - '@vitest/eslint-plugin': 1.1.25(@typescript-eslint/utils@8.22.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3))(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3)(vitest@3.0.4) + '@vitest/eslint-plugin': 1.1.25(@typescript-eslint/utils@8.22.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3))(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3)(vitest@3.0.4(@types/debug@4.1.12)(@types/node@22.12.0)(@vitest/ui@3.0.4)(jiti@2.4.2)(msw@2.7.0(@types/node@22.12.0)(typescript@5.7.3))(yaml@2.7.0)) eslint: 9.19.0(jiti@2.4.2) eslint-config-flat-gitignore: 0.3.0(eslint@9.19.0(jiti@2.4.2)) eslint-flat-config-utils: 0.4.0 @@ -2969,6 +2990,12 @@ snapshots: '@antfu/utils@0.7.10': {} + '@apidevtools/json-schema-ref-parser@11.9.3': + dependencies: + '@jsdevtools/ono': 7.1.3 + '@types/json-schema': 7.0.15 + js-yaml: 4.1.0 + '@babel/code-frame@7.26.2': dependencies: '@babel/helper-validator-identifier': 7.25.9 @@ -3390,6 +3417,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + '@jsdevtools/ono@7.1.3': {} + '@jsonjoy.com/base64@1.1.2(tslib@2.8.1)': dependencies: tslib: 2.8.1 @@ -3547,9 +3576,9 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.32.1': optional: true - '@storyblok/eslint-config@0.3.0(@typescript-eslint/utils@8.22.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3))(@vue/compiler-sfc@3.5.5)(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3)(vitest@3.0.4)': + '@storyblok/eslint-config@0.3.0(@typescript-eslint/utils@8.22.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3))(@vue/compiler-sfc@3.5.5)(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3)(vitest@3.0.4(@types/debug@4.1.12)(@types/node@22.12.0)(@vitest/ui@3.0.4)(jiti@2.4.2)(msw@2.7.0(@types/node@22.12.0)(typescript@5.7.3))(yaml@2.7.0))': dependencies: - '@antfu/eslint-config': 3.6.2(@typescript-eslint/utils@8.22.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3))(@vue/compiler-sfc@3.5.5)(eslint-plugin-format@0.1.3(eslint@9.19.0(jiti@2.4.2)))(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3)(vitest@3.0.4) + '@antfu/eslint-config': 3.6.2(@typescript-eslint/utils@8.22.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3))(@vue/compiler-sfc@3.5.5)(eslint-plugin-format@0.1.3(eslint@9.19.0(jiti@2.4.2)))(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3)(vitest@3.0.4(@types/debug@4.1.12)(@types/node@22.12.0)(@vitest/ui@3.0.4)(jiti@2.4.2)(msw@2.7.0(@types/node@22.12.0)(typescript@5.7.3))(yaml@2.7.0)) eslint: 9.19.0(jiti@2.4.2) eslint-plugin-format: 0.1.3(eslint@9.19.0(jiti@2.4.2)) transitivePeerDependencies: @@ -3615,6 +3644,8 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/lodash@4.17.16': {} + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -3716,7 +3747,7 @@ snapshots: '@typescript-eslint/types': 8.22.0 eslint-visitor-keys: 4.2.0 - '@vitest/coverage-v8@3.0.4(vitest@3.0.4)': + '@vitest/coverage-v8@3.0.4(vitest@3.0.4(@types/debug@4.1.12)(@types/node@22.12.0)(@vitest/ui@3.0.4)(jiti@2.4.2)(msw@2.7.0(@types/node@22.12.0)(typescript@5.7.3))(yaml@2.7.0))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -3734,7 +3765,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/eslint-plugin@1.1.25(@typescript-eslint/utils@8.22.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3))(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3)(vitest@3.0.4)': + '@vitest/eslint-plugin@1.1.25(@typescript-eslint/utils@8.22.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3))(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3)(vitest@3.0.4(@types/debug@4.1.12)(@types/node@22.12.0)(@vitest/ui@3.0.4)(jiti@2.4.2)(msw@2.7.0(@types/node@22.12.0)(typescript@5.7.3))(yaml@2.7.0))': dependencies: '@typescript-eslint/utils': 8.22.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3) eslint: 9.19.0(jiti@2.4.2) @@ -4707,6 +4738,18 @@ snapshots: json-parse-even-better-errors@2.3.1: {} + json-schema-to-typescript@15.0.4: + dependencies: + '@apidevtools/json-schema-ref-parser': 11.9.3 + '@types/json-schema': 7.0.15 + '@types/lodash': 4.17.16 + is-glob: 4.0.3 + js-yaml: 4.1.0 + lodash: 4.17.21 + minimist: 1.2.8 + prettier: 3.4.2 + tinyglobby: 0.2.10 + json-schema-traverse@0.4.1: {} json-stable-stringify-without-jsonify@1.0.1: {} @@ -5107,6 +5150,8 @@ snapshots: dependencies: brace-expansion: 2.0.1 + minimist@1.2.8: {} + minipass@7.1.2: {} mkdist@2.2.0(typescript@5.7.3): diff --git a/src/commands/components/constants.ts b/src/commands/components/constants.ts index b567c62..371f3e1 100644 --- a/src/commands/components/constants.ts +++ b/src/commands/components/constants.ts @@ -4,7 +4,7 @@ export interface SpaceComponent { created_at: string; updated_at: string; id: number; - schema: Record; + schema: Record; image?: string; preview_field?: string; is_root?: boolean; diff --git a/src/commands/components/pull/index.ts b/src/commands/components/pull/index.ts index cd12c64..65a43bf 100644 --- a/src/commands/components/pull/index.ts +++ b/src/commands/components/pull/index.ts @@ -15,7 +15,7 @@ componentsCommand .command('pull [componentName]') .option('-f, --filename ', 'custom name to be used in file(s) name instead of space id') .option('--sf, --separate-files [value]', 'Argument to create a single file for each component') - .option('--su, --suffix ', 'suffix to add to the file name (e.g. components..json). By default, the space ID is used.') + .option('--su, --suffix ', 'suffix to add to the file name (e.g. components..json)') .description(`Download your space's components schema as json. Optionally specify a component name to pull a single component.`) .action(async (componentName: string | undefined, options: PullComponentsOptions) => { konsola.title(` ${commands.COMPONENTS} `, colorPalette.COMPONENTS, componentName ? `Pulling component ${componentName}...` : 'Pulling components...'); diff --git a/src/commands/types/command.ts b/src/commands/types/command.ts new file mode 100644 index 0000000..d2599fe --- /dev/null +++ b/src/commands/types/command.ts @@ -0,0 +1,11 @@ +import { getProgram } from '../../program'; + +const program = getProgram(); // Get the shared singleton instance + +// Components root command +export const typesCommand = program + .command('types') + .alias('ts') + .description(`Generate types d.ts for your component schemas`) + .option('-s, --space ', 'space ID') + .option('-p, --path ', 'path to save the file. Default is .storyblok/types'); diff --git a/src/commands/types/generate/actions.ts b/src/commands/types/generate/actions.ts new file mode 100644 index 0000000..c42d2b8 --- /dev/null +++ b/src/commands/types/generate/actions.ts @@ -0,0 +1,176 @@ +import { compile, type JSONSchema } from 'json-schema-to-typescript'; +import type { SpaceComponent } from '../../../commands/components/constants'; +import { handleFileSystemError, toCamelCase, toPascalCase } from '../../../utils'; +import type { GenerateTypesOptions } from './constants'; +import type { ComponentPropertySchema, StoryblokPropertyType } from '../../../types/storyblok'; +import { storyblokSchemas } from '../../../utils/storyblok-schemas'; +import { join, resolve } from 'node:path'; +import { resolvePath, saveToFile } from '../../../utils/filesystem'; + +export interface ComponentGroupsAndNamesObject { + componentGroups: Map>; + componentNames: Set; +} + +// Constants +const STORY_TYPE = 'ISbStoryData'; +const DEFAULT_TYPEDEFS_HEADER = [ + '// This file was generated by the storyblok CLI.', + '// DO NOT MODIFY THIS FILE BY HAND.', + `import type { ${STORY_TYPE} } from "storyblok";`, +]; + +const getPropertyTypeAnnotation = (property: ComponentPropertySchema) => { + // If a property type is one of the ones provided by Storyblok, return that type + // Casting as string[] to avoid TS error on using Array.includes on different narrowed types + if (Array.from(storyblokSchemas.keys()).includes(property.type as StoryblokPropertyType)) { + return { type: property.type }; + } + + // Initialize property type as any (fallback type) + // const type: string | string[] = 'any'; + + const options = property.options && property.options.length > 0 ? property.options.map(item => item.value) : []; + + // Add empty option to options array + if (options.length > 0 && property.exclude_empty_option !== true) { + options.unshift(''); + } + + switch (property.type) { + case 'bloks': + return { type: 'array' }; + case 'boolean': + return { type: 'boolean' }; + case 'datetime': + case 'image': + case 'markdown': + case 'number': + case 'text': + case 'textarea': + return { type: 'string' }; + default: + return { type: 'any' }; + } +}; +const getComponentType = ( + componentName: string, + options: GenerateTypesOptions, +): string => { + const prefix = options.typeNamesPrefix ?? ''; + const suffix = options.typeNamesSuffix ?? ''; + const componentType = toPascalCase(toCamelCase(`${prefix}_${componentName}_${suffix}`)); + const isFirstCharacterNumber = !Number.isNaN(Number.parseInt(componentType.charAt(0))); + return isFirstCharacterNumber ? `_${componentType}` : componentType; +}; + +const getComponentPropertiesTypeAnnotations = async ( + component: SpaceComponent, + _options: GenerateTypesOptions, +) => { + // const typeAnnotations: JSONSchema['properties'] = {}; + + const componentPropertiesTypeAnnotations = await Promise.all( + Object.entries>(component.schema).map(async ([key, value]) => { + if (key.startsWith('tab-')) { + return; + } + + const typeAnnotation: JSONSchema = { + [key]: getPropertyTypeAnnotation(value), + }; + + return { [key]: typeAnnotation }; + }), + ); + + return componentPropertiesTypeAnnotations; +}; + +export const generateComponentGroupsAndComponentNames = ( + components: SpaceComponent[], +): ComponentGroupsAndNamesObject => { + return components.reduce( + (acc, currentComponent) => { + if (currentComponent.component_group_uuid) { + acc.componentGroups.set( + currentComponent.component_group_uuid, + acc.componentGroups.has(currentComponent.component_group_uuid) + ? acc.componentGroups.get(currentComponent.component_group_uuid)!.add(currentComponent.name) + : new Set([currentComponent.name]), + ); + } + acc.componentNames.add(currentComponent.name); + return acc; + }, + { componentGroups: new Map(), componentNames: new Set() }, + ); +}; + +export const generateTypes = async ( + components: SpaceComponent[], + options: GenerateTypesOptions, +) => { + /* const { componentGroups, componentNames } = generateComponentGroupsAndComponentNames(components); + const typedefs = [...DEFAULT_TYPEDEFS_HEADER]; */ + + const schemas = await Promise.all(components.map(async (component) => { + const type = getComponentType(component.name, options); + const componentPropertiesTypeAnnotations = await getComponentPropertiesTypeAnnotations(component, options); + const requiredFields = Object.entries>(component.schema).reduce( + (acc, [key, value]) => { + if (value.required) { + return [...acc, key]; + } + return acc; + }, + ['component', '_uid'], + ); + + const componentSchema: JSONSchema = { + $id: `#/${component.name}`, + title: type, + type: 'object', + required: requiredFields, + properties: { + ...componentPropertiesTypeAnnotations, + component: { + type: 'string', + enum: [component.name], + }, + _uid: { + type: 'string', + }, + }, + }; + + return componentSchema; + })); + + const typedefString = await Promise.all(schemas.map(async (schema) => { + return await compile(schema, schema.$id.replace('#/', ''), { + additionalProperties: false, + bannerComment: '', + }); + })); + + return [ + ...DEFAULT_TYPEDEFS_HEADER, + ...typedefString, + ].join('\n'); +}; + +export const saveTypesToFile = async (space: string, typedefString: string, options: SaveTypesOptions) => { + const { filename = 'storyblok', path } = options; + // Ensure we always include the components/space folder structure regardless of custom path + const resolvedPath = path + ? resolve(process.cwd(), path, 'types', space) + : resolvePath(path, `types/${space}`); + + try { + await saveToFile(join(resolvedPath, `${filename}.d.ts`), typedefString); + } + catch (error) { + handleFileSystemError('write', error as Error); + } +}; diff --git a/src/commands/types/generate/constants.ts b/src/commands/types/generate/constants.ts new file mode 100644 index 0000000..8e1f0fa --- /dev/null +++ b/src/commands/types/generate/constants.ts @@ -0,0 +1,4 @@ +export interface GenerateTypesOptions { + filter?: string; + separateFiles?: boolean; +} diff --git a/src/commands/types/generate/index.ts b/src/commands/types/generate/index.ts new file mode 100644 index 0000000..a31e22f --- /dev/null +++ b/src/commands/types/generate/index.ts @@ -0,0 +1,65 @@ +import { colorPalette, commands } from '../../../constants'; +import { CommandError, handleError, isVitest, konsola } from '../../../utils'; +import { getProgram } from '../../../program'; +import { session } from '../../../session'; +import { Spinner } from '@topcli/spinner'; +import { readComponentsFiles } from '../../components/push/actions'; +import type { GenerateTypesOptions } from './constants'; +import type { ReadComponentsOptions } from '../../components/push/constants'; +import { typesCommand } from '../command'; +import { generateTypes, saveTypesToFile } from './actions'; + +const program = getProgram(); + +typesCommand + .command('generate [componentName]') + .description('Generate types d.ts for your component schemas') + .option('--fi, --filter ', 'glob filter to apply to the components before generating types') + .option('--sf, --separate-files', '') + .action(async (componentName: string | undefined, options: GenerateTypesOptions) => { + konsola.title(` ${commands.TYPES} `, colorPalette.TYPES, componentName ? `Generating types for component ${componentName}...` : 'Generating types...'); + // Global options + const verbose = program.opts().verbose; + + // Command options + const { space, path } = typesCommand.opts(); + + console.log(typesCommand.opts()); + + // const { filter, separateFiles } = options; + + const { state, initializeSession } = session(); + await initializeSession(); + + if (!state.isLoggedIn || !state.password || !state.region) { + handleError(new CommandError(`You are currently not logged in. Please login first to get your user info.`), verbose); + return; + } + if (!space) { + handleError(new CommandError(`Please provide the space as argument --space YOUR_SPACE_ID.`), verbose); + return; + } + + const spinnerText = componentName ? `Generating types for component ${componentName}...` : 'Generating types...'; + const spinner = new Spinner({ + verbose: !isVitest, + }).start(spinnerText); + + try { + const spaceData = await readComponentsFiles({ + ...options as ReadComponentsOptions, + from: space, + path, + }); + + const typedefString = await generateTypes(spaceData.components, options); + + await saveTypesToFile(space, typedefString, options); + + spinner.succeed(`Successfully generated types for component ${componentName}`); + } + catch (error) { + spinner.failed(`Failed to generate types for component ${componentName}`); + handleError(error as Error, verbose); + } + }); diff --git a/src/commands/types/index.ts b/src/commands/types/index.ts new file mode 100644 index 0000000..80ba50d --- /dev/null +++ b/src/commands/types/index.ts @@ -0,0 +1,5 @@ +import './command'; +import './generate'; + +/* export * from './generate/actions'; +export * from './generate/constants'; */ diff --git a/src/constants.ts b/src/constants.ts index b1e6cf5..09d7d7d 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -5,6 +5,7 @@ export const commands = { COMPONENTS: 'Components', LANGUAGES: 'languages', MIGRATIONS: 'Migrations', + TYPES: 'Types', } as const; export const colorPalette = { @@ -14,6 +15,7 @@ export const colorPalette = { COMPONENTS: '#a185ff', LANGUAGES: '#FFC107', MIGRATIONS: '#8CE2FF', + TYPES: '#3178C6', } as const; export interface ReadonlyArray { diff --git a/src/index.ts b/src/index.ts index 2c38b23..87717f8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ import './commands/user'; import './commands/components'; import './commands/languages'; import './commands/migrations'; +import './commands/types'; import { session } from './session'; diff --git a/src/types/storyblok.ts b/src/types/storyblok.ts new file mode 100644 index 0000000..d3c5a50 --- /dev/null +++ b/src/types/storyblok.ts @@ -0,0 +1,39 @@ +export type StoryblokPropertyType = 'asset' | 'multiasset' | 'multilink' | 'table' | 'richtext'; + +export type ComponentPropertySchemaType = + | StoryblokPropertyType + | 'array' + | 'bloks' + | 'boolean' + | 'custom' + | 'datetime' + | 'image' + | 'markdown' + | 'number' + | 'option' + | 'options' + | 'text' + | 'textarea'; + +export interface ComponentPropertySchemaOption { + _uid: string; + name: string; + value: string; +} + +export interface ComponentPropertySchema { + asset_link_type?: boolean; + component_group_whitelist?: string[]; + component_whitelist?: string[]; + email_link_type?: boolean; + exclude_empty_option?: boolean; + filter_content_type?: string | string[]; + key: string; + options?: ComponentPropertySchemaOption[]; + pos: number; + restrict_components?: boolean; + restrict_type?: 'groups' | ''; + source?: 'internal' | 'external' | 'internal_stories' | 'internal_languages'; + type: ComponentPropertySchemaType; + use_uuid?: boolean; +}; diff --git a/src/utils/format.ts b/src/utils/format.ts index ae6ee16..e9b2577 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -3,11 +3,16 @@ export const toPascalCase = (str: string) => { }; export const toCamelCase = (str: string) => { - return str.replace(/(?:^|_)(\w)/g, (_, char) => char.toUpperCase()).replace(/_/g, ''); + return str + .replace(/(?:^|_)(\w)/g, (_, char) => char.toUpperCase()) + .replace(/_/g, '') + .replace(/^[A-Z]/, char => char.toLowerCase()); }; export const toSnakeCase = (str: string) => { - return str.replace(/([A-Z])/g, '_$1').toLowerCase(); + return str + .replace(/([A-Z])/g, (_, char) => `_${char.toLowerCase()}`) + .replace(/^_/, ''); }; export function maskToken(token: string): string { diff --git a/src/utils/storyblok-schemas.ts b/src/utils/storyblok-schemas.ts new file mode 100644 index 0000000..a3896f7 --- /dev/null +++ b/src/utils/storyblok-schemas.ts @@ -0,0 +1,429 @@ +import type { JSONSchema } from 'json-schema-to-typescript'; +import type { StoryblokPropertyType } from '../types/storyblok'; + +export const getAssetJSONSchema = (title: string): JSONSchema => ({ + $id: '#/asset', + title, + type: 'object', + required: ['id', 'fieldtype', 'filename', 'name', 'title', 'focus', 'alt'], + properties: { + alt: { + type: ['string', 'null'], + }, + copyright: { + type: ['string', 'null'], + }, + fieldtype: { + type: 'string', + enum: ['asset'], + }, + id: { + type: 'number', + }, + filename: { + type: ['string', 'null'], + }, + name: { + type: 'string', + }, + title: { + type: ['string', 'null'], + }, + focus: { + type: ['string', 'null'], + }, + meta_data: { + type: 'object', + }, + source: { + type: ['string', 'null'], + }, + is_external_url: { + type: 'boolean', + }, + is_private: { + type: 'boolean', + }, + src: { + type: 'string', + }, + updated_at: { + type: 'string', + }, + // Cloudinary integration keys + width: { + type: ['number', 'null'], + }, + height: { + type: ['number', 'null'], + }, + aspect_ratio: { + type: ['number', 'null'], + }, + public_id: { + type: ['string', 'null'], + }, + content_type: { + type: 'string', + }, + }, +}); + +export const getMultiassetJSONSchema = (title: string): JSONSchema => ({ + $id: '#/multiasset', + title, + type: 'array', + items: { + type: 'object', + required: ['id', 'fieldtype', 'filename', 'name', 'title', 'focus', 'alt'], + properties: { + alt: { + type: ['string', 'null'], + }, + copyright: { + type: ['string', 'null'], + }, + fieldtype: { + type: 'string', + enum: ['asset'], + }, + id: { + type: 'number', + }, + filename: { + type: ['string', 'null'], + }, + name: { + type: 'string', + }, + title: { + type: ['string', 'null'], + }, + focus: { + type: ['string', 'null'], + }, + meta_data: { + type: 'object', + }, + source: { + type: ['string', 'null'], + }, + is_external_url: { + type: 'boolean', + }, + is_private: { + type: 'boolean', + }, + src: { + type: 'string', + }, + updated_at: { + type: 'string', + }, + // Cloudinary integration keys + width: { + type: ['number', 'null'], + }, + height: { + type: ['number', 'null'], + }, + aspect_ratio: { + type: ['number', 'null'], + }, + public_id: { + type: ['string', 'null'], + }, + content_type: { + type: 'string', + }, + }, + }, +}); + +// TODO: find a reliable way to share props among different Link Types to increase maintainability +// Currently not possible because of JSONSchema4 complaining +const multilinkSharedRequiredProps = ['fieldtype', 'id', 'url', 'cached_url', 'linktype']; + +export const getMultilinkJSONSchema = (title: string): JSONSchema => ({ + $id: '#/multilink', + title, + oneOf: [ + { + type: 'object', + required: multilinkSharedRequiredProps, + properties: { + // Shared props + fieldtype: { + type: 'string', + enum: ['multilink'], + }, + id: { type: 'string' }, + url: { type: 'string' }, + cached_url: { type: 'string' }, + target: { type: 'string', enum: ['_blank', '_self'] }, + // Custom props + anchor: { + type: 'string', + }, + rel: { + type: 'string', + }, + title: { + type: 'string', + }, + prep: { + type: 'string', + }, + linktype: { + type: 'string', + enum: ['story'], + }, + story: { + type: 'object', + required: ['name', 'id', 'uuid', 'slug', 'full_slug'], + properties: { + name: { + type: 'string', + }, + created_at: { + type: 'string', + format: 'date-time', + }, + published_at: { + type: 'string', + format: 'date-time', + }, + id: { + type: 'integer', + }, + uuid: { + type: 'string', + format: 'uuid', + }, + content: { + type: 'object', + }, + slug: { + type: 'string', + }, + full_slug: { + type: 'string', + }, + sort_by_date: { + type: ['null', 'string'], + format: 'date-time', + }, + position: { + type: 'integer', + }, + tag_list: { + type: 'array', + items: { + type: 'string', + }, + }, + is_startpage: { + type: 'boolean', + }, + parent_id: { + type: ['null', 'integer'], + }, + meta_data: { + type: ['null', 'object'], + }, + group_id: { + type: 'string', + format: 'uuid', + }, + first_published_at: { + type: 'string', + format: 'date-time', + }, + release_id: { + type: ['null', 'integer'], + }, + lang: { + type: 'string', + }, + path: { + type: ['null', 'string'], + }, + alternates: { + type: 'array', + }, + default_full_slug: { + type: ['null', 'string'], + }, + translated_slugs: { + type: ['null', 'array'], + }, + }, + }, + }, + }, + { + type: 'object', + required: multilinkSharedRequiredProps, + properties: { + // Shared props + fieldtype: { + type: 'string', + enum: ['multilink'], + }, + id: { type: 'string' }, + url: { type: 'string' }, + cached_url: { type: 'string' }, + target: { type: 'string', enum: ['_blank', '_self'] }, + // Custom props + linktype: { + type: 'string', + enum: ['url'], + }, + rel: { + type: 'string', + }, + title: { + type: 'string', + }, + }, + }, + { + type: 'object', + required: multilinkSharedRequiredProps, + properties: { + // Shared props + fieldtype: { + type: 'string', + enum: ['multilink'], + }, + id: { type: 'string' }, + url: { type: 'string' }, + cached_url: { type: 'string' }, + target: { type: 'string', enum: ['_blank', '_self'] }, + // Custom props + email: { + type: 'string', + }, + linktype: { + type: 'string', + enum: ['email'], + }, + }, + }, + { + type: 'object', + required: multilinkSharedRequiredProps, + properties: { + // Shared props + fieldtype: { + type: 'string', + enum: ['multilink'], + }, + id: { type: 'string' }, + url: { type: 'string' }, + cached_url: { type: 'string' }, + target: { type: 'string', enum: ['_blank', '_self'] }, + // Custom props + linktype: { + type: 'string', + enum: ['asset'], + }, + }, + }, + ], +}); + +export const getRichtextJSONSchema = (title: string): JSONSchema => ({ + $id: '#/richtext', + title, + type: 'object', + required: ['type'], + properties: { + type: { + type: 'string', + }, + content: { + type: 'array', + items: { + $ref: '#', + }, + }, + marks: { + type: 'array', + items: { + $ref: '#', + }, + }, + attrs: {}, + text: { + type: 'string', + }, + }, +}); + +export const getTableJSONSchema = (title: string): JSONSchema => ({ + $id: '#/table', + title, + type: 'object', + required: ['tbody', 'thead'], + properties: { + thead: { + type: 'array', + items: { + type: 'object', + required: ['_uid', 'component'], + properties: { + _uid: { + type: 'string', + }, + value: { + type: 'string', + }, + component: { + type: 'number', + }, + }, + }, + }, + tbody: { + type: 'array', + items: { + type: 'object', + required: ['_uid', 'component', 'body'], + properties: { + _uid: { + type: 'string', + }, + body: { + type: 'array', + items: { + type: 'object', + properties: { + _uid: { + type: 'string', + }, + value: { + type: 'string', + }, + component: { + type: 'number', + }, + }, + }, + }, + component: { + type: 'number', + }, + }, + }, + }, + }, +}); + +export const storyblokSchemas = new Map([ + ['asset', getAssetJSONSchema], + ['multiasset', getMultiassetJSONSchema], + ['multilink', getMultilinkJSONSchema], + ['table', getTableJSONSchema], + ['richtext', getRichtextJSONSchema], +]); From 4cf1e3bd945a23b9997ade91ff92a2f722d8810b Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Mon, 7 Apr 2025 09:59:21 +0200 Subject: [PATCH 03/16] feat: strict mode to disable compiler.additionalProperties loose typing --- .vscode/launch.json | 14 ++++++++++++++ src/commands/types/generate/actions.ts | 8 ++++++-- src/commands/types/generate/constants.ts | 1 + src/commands/types/generate/index.ts | 1 + 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index e8b0d7c..7272ae9 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -307,6 +307,20 @@ "env": { "STUB": "true" } + }, + { + "type": "node", + "request": "launch", + "name": "Debug Generate types strict", + "program": "${workspaceFolder}/dist/index.mjs", + "args": ["types", "generate", "--space", "295017", "--strict"], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "sourceMaps": true, + "outFiles": ["${workspaceFolder}/dist/**/*.js"], + "env": { + "STUB": "true" + } } ] } diff --git a/src/commands/types/generate/actions.ts b/src/commands/types/generate/actions.ts index c42d2b8..1e97cd6 100644 --- a/src/commands/types/generate/actions.ts +++ b/src/commands/types/generate/actions.ts @@ -109,7 +109,9 @@ export const generateComponentGroupsAndComponentNames = ( export const generateTypes = async ( components: SpaceComponent[], - options: GenerateTypesOptions, + options: GenerateTypesOptions = { + strict: false, + }, ) => { /* const { componentGroups, componentNames } = generateComponentGroupsAndComponentNames(components); const typedefs = [...DEFAULT_TYPEDEFS_HEADER]; */ @@ -147,9 +149,11 @@ export const generateTypes = async ( return componentSchema; })); + console.log(options); + const typedefString = await Promise.all(schemas.map(async (schema) => { return await compile(schema, schema.$id.replace('#/', ''), { - additionalProperties: false, + additionalProperties: !options.strict, bannerComment: '', }); })); diff --git a/src/commands/types/generate/constants.ts b/src/commands/types/generate/constants.ts index 8e1f0fa..9c2ac08 100644 --- a/src/commands/types/generate/constants.ts +++ b/src/commands/types/generate/constants.ts @@ -1,4 +1,5 @@ export interface GenerateTypesOptions { filter?: string; separateFiles?: boolean; + strict?: boolean; } diff --git a/src/commands/types/generate/index.ts b/src/commands/types/generate/index.ts index a31e22f..dba68f3 100644 --- a/src/commands/types/generate/index.ts +++ b/src/commands/types/generate/index.ts @@ -16,6 +16,7 @@ typesCommand .description('Generate types d.ts for your component schemas') .option('--fi, --filter ', 'glob filter to apply to the components before generating types') .option('--sf, --separate-files', '') + .option('--strict', 'strict mode, no loose typing') .action(async (componentName: string | undefined, options: GenerateTypesOptions) => { konsola.title(` ${commands.TYPES} `, colorPalette.TYPES, componentName ? `Generating types for component ${componentName}...` : 'Generating types...'); // Global options From 1b5f03fe54af9a4a5b5676681ab033a162699c04 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Mon, 7 Apr 2025 11:48:21 +0200 Subject: [PATCH 04/16] feat: generate shared storyblok property types d.ts --- src/commands/types/generate/actions.ts | 209 ++++++++++++++++++++--- src/commands/types/generate/constants.ts | 5 + src/commands/types/generate/index.ts | 16 +- src/index.ts | 2 + src/types/storyblok.ts | 88 ++++++++++ 5 files changed, 292 insertions(+), 28 deletions(-) diff --git a/src/commands/types/generate/actions.ts b/src/commands/types/generate/actions.ts index 1e97cd6..3e8c7f5 100644 --- a/src/commands/types/generate/actions.ts +++ b/src/commands/types/generate/actions.ts @@ -13,11 +13,9 @@ export interface ComponentGroupsAndNamesObject { } // Constants -const STORY_TYPE = 'ISbStoryData'; const DEFAULT_TYPEDEFS_HEADER = [ '// This file was generated by the storyblok CLI.', '// DO NOT MODIFY THIS FILE BY HAND.', - `import type { ${STORY_TYPE} } from "storyblok";`, ]; const getPropertyTypeAnnotation = (property: ComponentPropertySchema) => { @@ -66,27 +64,46 @@ const getComponentType = ( const getComponentPropertiesTypeAnnotations = async ( component: SpaceComponent, - _options: GenerateTypesOptions, -) => { - // const typeAnnotations: JSONSchema['properties'] = {}; + options: GenerateTypesOptions, +): Promise => { + return Object.entries>(component.schema).reduce(async (accPromise, [key, value]) => { + const acc = await accPromise; - const componentPropertiesTypeAnnotations = await Promise.all( - Object.entries>(component.schema).map(async ([key, value]) => { - if (key.startsWith('tab-')) { - return; - } + // Skip tabbed properties + if (key.startsWith('tab-')) { + return acc; + } - const typeAnnotation: JSONSchema = { - [key]: getPropertyTypeAnnotation(value), + const propertyType = value.type; + const propertyTypeAnnotation: JSONSchema = { + [key]: getPropertyTypeAnnotation(value as ComponentPropertySchema), + }; + + if (propertyType === 'custom') { + return { + ...acc, + // TODO: Add custom type annotation }; + } - return { [key]: typeAnnotation }; - }), - ); + if (Array.from(storyblokSchemas.keys()).includes(propertyType as StoryblokPropertyType)) { + const componentType = getComponentType(propertyType, options); + propertyTypeAnnotation[key].tsType = `Storyblok${componentType}`; - return componentPropertiesTypeAnnotations; -}; + /* const typedefForStoryblokPropertyType = storyblokSchemas.get(propertyType as StoryblokPropertyType); + if (typedefForStoryblokPropertyType) { + typeDef.push( + await compile(typedefForStoryblokPropertyType, typedefForStoryblokPropertyType.$id.replace('#/', ''), { + additionalProperties: !options.strict, + bannerComment: '', + }), + ); + } */ + } + return { ...acc, ...propertyTypeAnnotation }; + }, Promise.resolve({} as JSONSchema)); +}; export const generateComponentGroupsAndComponentNames = ( components: SpaceComponent[], ): ComponentGroupsAndNamesObject => { @@ -115,6 +132,8 @@ export const generateTypes = async ( ) => { /* const { componentGroups, componentNames } = generateComponentGroupsAndComponentNames(components); const typedefs = [...DEFAULT_TYPEDEFS_HEADER]; */ + const typeDefs = [...DEFAULT_TYPEDEFS_HEADER]; + const storyblokPropertyTypes = new Set(); const schemas = await Promise.all(components.map(async (component) => { const type = getComponentType(component.name, options); @@ -129,6 +148,15 @@ export const generateTypes = async ( ['component', '_uid'], ); + // Check if any property has a type that's in storyblokSchemas.keys() + if (componentPropertiesTypeAnnotations) { + Object.entries(componentPropertiesTypeAnnotations).forEach(([_, property]) => { + if (property.type && Array.from(storyblokSchemas.keys()).includes(property.type as StoryblokPropertyType)) { + storyblokPropertyTypes.add(property.type as StoryblokPropertyType); + } + }); + } + const componentSchema: JSONSchema = { $id: `#/${component.name}`, title: type, @@ -149,23 +177,33 @@ export const generateTypes = async ( return componentSchema; })); - console.log(options); - - const typedefString = await Promise.all(schemas.map(async (schema) => { + const result = await Promise.all(schemas.map(async (schema) => { return await compile(schema, schema.$id.replace('#/', ''), { additionalProperties: !options.strict, bannerComment: '', }); })); + // Add imports for Storyblok types if needed + const imports: string[] = []; + if (storyblokPropertyTypes.size > 0) { + const typeImports = Array.from(storyblokPropertyTypes).map((type) => { + const pascalType = toPascalCase(type); + return `Storyblok${pascalType}`; + }); + + imports.push(`import type { ${typeImports.join(', ')} } from '../storyblok.d.ts';`); + } + + const finalTypeDef = [...typeDefs, ...imports, ...result]; + return [ - ...DEFAULT_TYPEDEFS_HEADER, - ...typedefString, + ...finalTypeDef, ].join('\n'); }; export const saveTypesToFile = async (space: string, typedefString: string, options: SaveTypesOptions) => { - const { filename = 'storyblok', path } = options; + const { filename = 'storyblok-components', path } = options; // Ensure we always include the components/space folder structure regardless of custom path const resolvedPath = path ? resolve(process.cwd(), path, 'types', space) @@ -178,3 +216,128 @@ export const saveTypesToFile = async (space: string, typedefString: string, opti handleFileSystemError('write', error as Error); } }; + +// Add SaveTypesOptions interface +export interface SaveTypesOptions { + filename?: string; + path?: string; +} + +/** + * Generates a d.ts file with the Storyblok type definitions + * @param options - Options for generating the types + * @returns Promise that resolves when the file is saved + */ +export const generateStoryblokTypes = async (options: SaveTypesOptions = {}) => { + const { filename = 'storyblok', path } = options; + + // Define the content of the d.ts file + const typeDefs = [ + '// This file was generated by the storyblok CLI.', + '// DO NOT MODIFY THIS FILE BY HAND.', + '', + 'export type StoryblokPropertyType = \'asset\' | \'multiasset\' | \'multilink\' | \'table\' | \'richtext\';', + '', + 'export interface StoryblokAsset {', + ' alt: string | null;', + ' copyright: string | null;', + ' fieldtype: \'asset\';', + ' id: number;', + ' filename: string | null;', + ' name: string;', + ' title: string | null;', + ' focus: string | null;', + ' meta_data: Record;', + ' source: string | null;', + ' is_external_url: boolean;', + ' is_private: boolean;', + ' src: string;', + ' updated_at: string;', + ' // Cloudinary integration keys', + ' width: number | null;', + ' height: number | null;', + ' aspect_ratio: number | null;', + ' public_id: string | null;', + ' content_type: string;', + '}', + '', + 'export interface StoryblokMultiasset extends Array {}', + '', + 'export interface StoryblokMultilink {', + ' fieldtype: \'multilink\';', + ' id: string;', + ' url: string;', + ' cached_url: string;', + ' target?: \'_blank\' | \'_self\';', + ' anchor?: string;', + ' rel?: string;', + ' title?: string;', + ' prep?: string;', + ' linktype: \'story\' | \'url\' | \'email\' | \'asset\';', + ' story?: {', + ' name: string;', + ' created_at: string;', + ' published_at: string;', + ' id: number;', + ' uuid: string;', + ' content: Record;', + ' slug: string;', + ' full_slug: string;', + ' sort_by_date?: string;', + ' position?: number;', + ' tag_list?: string[];', + ' is_startpage?: boolean;', + ' parent_id?: number | null;', + ' meta_data?: Record | null;', + ' group_id?: string;', + ' first_published_at?: string;', + ' release_id?: number | null;', + ' lang?: string;', + ' path?: string | null;', + ' alternates?: any[];', + ' default_full_slug?: string | null;', + ' translated_slugs?: any[] | null;', + ' };', + ' email?: string;', + '}', + '', + 'export interface StoryblokTable {', + ' thead: Array<{', + ' _uid: string;', + ' value: string;', + ' component: number;', + ' }>;', + ' tbody: Array<{', + ' _uid: string;', + ' component: number;', + ' body: Array<{', + ' _uid: string;', + ' value: string;', + ' component: number;', + ' }>;', + ' }>;', + '}', + '', + 'export interface StoryblokRichtext {', + ' type: string;', + ' content?: StoryblokRichtext[];', + ' marks?: StoryblokRichtext[];', + ' attrs?: Record;', + ' text?: string;', + '}', + ].join('\n'); + + // Determine the path to save the file + const resolvedPath = path + ? resolve(process.cwd(), path, 'types') + : resolvePath(path, 'types'); + + try { + await saveToFile(join(resolvedPath, `${filename}.d.ts`), typeDefs); + return true; + } + catch (error) { + handleFileSystemError('write', error as Error); + return false; + } +}; diff --git a/src/commands/types/generate/constants.ts b/src/commands/types/generate/constants.ts index 9c2ac08..7b91c33 100644 --- a/src/commands/types/generate/constants.ts +++ b/src/commands/types/generate/constants.ts @@ -2,4 +2,9 @@ export interface GenerateTypesOptions { filter?: string; separateFiles?: boolean; strict?: boolean; + typeNamesPrefix?: string; + typeNamesSuffix?: string; + storyblokTypes?: boolean; + filename?: string; + path?: string; } diff --git a/src/commands/types/generate/index.ts b/src/commands/types/generate/index.ts index dba68f3..3f70880 100644 --- a/src/commands/types/generate/index.ts +++ b/src/commands/types/generate/index.ts @@ -7,7 +7,8 @@ import { readComponentsFiles } from '../../components/push/actions'; import type { GenerateTypesOptions } from './constants'; import type { ReadComponentsOptions } from '../../components/push/constants'; import { typesCommand } from '../command'; -import { generateTypes, saveTypesToFile } from './actions'; +import type { SaveTypesOptions } from './actions'; +import { generateStoryblokTypes, generateTypes, saveTypesToFile } from './actions'; const program = getProgram(); @@ -41,10 +42,10 @@ typesCommand return; } - const spinnerText = componentName ? `Generating types for component ${componentName}...` : 'Generating types...'; + /* const spinnerText = componentName ? `Generating types for component ${componentName}...` : 'Generating types...'; const spinner = new Spinner({ verbose: !isVitest, - }).start(spinnerText); + }).start(spinnerText); */ try { const spaceData = await readComponentsFiles({ @@ -53,14 +54,19 @@ typesCommand path, }); + await generateStoryblokTypes({ + filename: options.filename, + path: options.path, + }); + const typedefString = await generateTypes(spaceData.components, options); await saveTypesToFile(space, typedefString, options); - spinner.succeed(`Successfully generated types for component ${componentName}`); + // spinner.succeed(`Successfully generated types for component ${componentName}`); } catch (error) { - spinner.failed(`Failed to generate types for component ${componentName}`); + // spinner.failed(`Failed to generate types for component ${componentName}`); handleError(error as Error, verbose); } }); diff --git a/src/index.ts b/src/index.ts index 87717f8..b386709 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,8 @@ import './commands/types'; import { session } from './session'; +export * from './types/storyblok'; + dotenv.config(); // This will load variables from .env into process.env const program = getProgram(); console.clear(); diff --git a/src/types/storyblok.ts b/src/types/storyblok.ts index d3c5a50..1a33619 100644 --- a/src/types/storyblok.ts +++ b/src/types/storyblok.ts @@ -37,3 +37,91 @@ export interface ComponentPropertySchema { type: ComponentPropertySchemaType; use_uuid?: boolean; }; + +export interface StoryblokAsset { + alt: string | null; + copyright: string | null; + fieldtype: 'asset'; + id: number; + filename: string | null; + name: string; + title: string | null; + focus: string | null; + meta_data: Record; + source: string | null; + is_external_url: boolean; + is_private: boolean; + src: string; + updated_at: string; + // Cloudinary integration keys + width: number | null; + height: number | null; + aspect_ratio: number | null; + public_id: string | null; + content_type: string; +} + +export interface StoryblokMultiasset extends Array {} + +export interface StoryblokMultilink { + fieldtype: 'multilink'; + id: string; + url: string; + cached_url: string; + target?: '_blank' | '_self'; + anchor?: string; + rel?: string; + title?: string; + prep?: string; + linktype: 'story' | 'url' | 'email' | 'asset'; + story?: { + name: string; + created_at: string; + published_at: string; + id: number; + uuid: string; + content: Record; + slug: string; + full_slug: string; + sort_by_date?: string; + position?: number; + tag_list?: string[]; + is_startpage?: boolean; + parent_id?: number | null; + meta_data?: Record | null; + group_id?: string; + first_published_at?: string; + release_id?: number | null; + lang?: string; + path?: string | null; + alternates?: any[]; + default_full_slug?: string | null; + translated_slugs?: any[] | null; + }; + email?: string; +} + +export interface StoryblokTable { + thead: Array<{ + _uid: string; + value: string; + component: number; + }>; + tbody: Array<{ + _uid: string; + component: number; + body: Array<{ + _uid: string; + value: string; + component: number; + }>; + }>; +} + +export interface StoryblokRichtext { + type: string; + content?: StoryblokRichtext[]; + marks?: StoryblokRichtext[]; + attrs?: Record; + text?: string; +} From 2648fa3f5eadb70717665469a40c348db42dd492 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Tue, 8 Apr 2025 09:33:25 +0200 Subject: [PATCH 05/16] feat: generate d.ts with storyblok special field types - Refactored the type generation logic to improve handling of component types, including sanitization of component names. - Introduced a new `ComponentPropertySchema` interface and related types in `schemas.ts` for better type safety and organization. - Updated the `generateStoryblokTypes` function to read type definitions from an external file, ensuring a more robust extraction process. - Removed redundant type definitions from `storyblok.ts` to streamline the codebase and avoid duplication. --- src/commands/types/generate/actions.ts | 216 ++++++++++++------------- src/types/schemas.ts | 39 +++++ src/types/storyblok.ts | 48 +----- 3 files changed, 146 insertions(+), 157 deletions(-) create mode 100644 src/types/schemas.ts diff --git a/src/commands/types/generate/actions.ts b/src/commands/types/generate/actions.ts index 3e8c7f5..2f05b8f 100644 --- a/src/commands/types/generate/actions.ts +++ b/src/commands/types/generate/actions.ts @@ -2,10 +2,12 @@ import { compile, type JSONSchema } from 'json-schema-to-typescript'; import type { SpaceComponent } from '../../../commands/components/constants'; import { handleFileSystemError, toCamelCase, toPascalCase } from '../../../utils'; import type { GenerateTypesOptions } from './constants'; -import type { ComponentPropertySchema, StoryblokPropertyType } from '../../../types/storyblok'; +import type { StoryblokPropertyType } from '../../../types/storyblok'; import { storyblokSchemas } from '../../../utils/storyblok-schemas'; import { join, resolve } from 'node:path'; import { resolvePath, saveToFile } from '../../../utils/filesystem'; +import { readFileSync } from 'node:fs'; +import type { ComponentPropertySchema } from '../../../types/schemas'; export interface ComponentGroupsAndNamesObject { componentGroups: Map>; @@ -28,7 +30,7 @@ const getPropertyTypeAnnotation = (property: ComponentPropertySchema) => { // Initialize property type as any (fallback type) // const type: string | string[] = 'any'; - const options = property.options && property.options.length > 0 ? property.options.map(item => item.value) : []; + const options = property.options && property.options.length > 0 ? property.options.map((item: { value: string }) => item.value) : []; // Add empty option to options array if (options.length > 0 && property.exclude_empty_option !== true) { @@ -51,13 +53,27 @@ const getPropertyTypeAnnotation = (property: ComponentPropertySchema) => { return { type: 'any' }; } }; -const getComponentType = ( + +export const getComponentType = ( componentName: string, options: GenerateTypesOptions, ): string => { const prefix = options.typeNamesPrefix ?? ''; const suffix = options.typeNamesSuffix ?? ''; - const componentType = toPascalCase(toCamelCase(`${prefix}_${componentName}_${suffix}`)); + + // Sanitize the component name to handle special characters and emojis + const sanitizedName = componentName + // Replace any character that's not a letter or number with an underscore + .replace(/[^a-z0-9]/gi, '_') + // Replace multiple consecutive underscores with a single underscore + .replace(/_+/g, '_') + // Trim underscores from the beginning and end + .replace(/^_+|_+$/g, ''); + + // Convert to PascalCase + const componentType = toPascalCase(toCamelCase(`${prefix}_${sanitizedName}_${suffix}`)); + + // If the component type starts with a number, prefix it with an underscore const isFirstCharacterNumber = !Number.isNaN(Number.parseInt(componentType.charAt(0))); return isFirstCharacterNumber ? `_${componentType}` : componentType; }; @@ -89,16 +105,16 @@ const getComponentPropertiesTypeAnnotations = async ( if (Array.from(storyblokSchemas.keys()).includes(propertyType as StoryblokPropertyType)) { const componentType = getComponentType(propertyType, options); propertyTypeAnnotation[key].tsType = `Storyblok${componentType}`; + } - /* const typedefForStoryblokPropertyType = storyblokSchemas.get(propertyType as StoryblokPropertyType); - if (typedefForStoryblokPropertyType) { - typeDef.push( - await compile(typedefForStoryblokPropertyType, typedefForStoryblokPropertyType.$id.replace('#/', ''), { - additionalProperties: !options.strict, - bannerComment: '', - }), - ); - } */ + if (propertyType === 'multilink') { + const excludedLinktypes: string[] = [ + ...(!value.email_link_type ? ['{ linktype?: "email" }'] : []), + ...(!value.asset_link_type ? ['{ linktype?: "asset" }'] : []), + ]; + const componentType = getComponentType(propertyType, options); + propertyTypeAnnotation[key].tsType + = excludedLinktypes.length > 0 ? `Exclude` : componentType; } return { ...acc, ...propertyTypeAnnotation }; @@ -136,6 +152,7 @@ export const generateTypes = async ( const storyblokPropertyTypes = new Set(); const schemas = await Promise.all(components.map(async (component) => { + // Get the component type name with proper handling of numbers at the start const type = getComponentType(component.name, options); const componentPropertiesTypeAnnotations = await getComponentPropertiesTypeAnnotations(component, options); const requiredFields = Object.entries>(component.schema).reduce( @@ -159,7 +176,7 @@ export const generateTypes = async ( const componentSchema: JSONSchema = { $id: `#/${component.name}`, - title: type, + title: type, // This is the key - we're using the properly formatted type name type: 'object', required: requiredFields, properties: { @@ -178,7 +195,8 @@ export const generateTypes = async ( })); const result = await Promise.all(schemas.map(async (schema) => { - return await compile(schema, schema.$id.replace('#/', ''), { + // Use the title as the interface name + return await compile(schema, schema.title || schema.$id.replace('#/', ''), { additionalProperties: !options.strict, bannerComment: '', }); @@ -231,113 +249,79 @@ export interface SaveTypesOptions { export const generateStoryblokTypes = async (options: SaveTypesOptions = {}) => { const { filename = 'storyblok', path } = options; - // Define the content of the d.ts file - const typeDefs = [ - '// This file was generated by the storyblok CLI.', - '// DO NOT MODIFY THIS FILE BY HAND.', - '', - 'export type StoryblokPropertyType = \'asset\' | \'multiasset\' | \'multilink\' | \'table\' | \'richtext\';', - '', - 'export interface StoryblokAsset {', - ' alt: string | null;', - ' copyright: string | null;', - ' fieldtype: \'asset\';', - ' id: number;', - ' filename: string | null;', - ' name: string;', - ' title: string | null;', - ' focus: string | null;', - ' meta_data: Record;', - ' source: string | null;', - ' is_external_url: boolean;', - ' is_private: boolean;', - ' src: string;', - ' updated_at: string;', - ' // Cloudinary integration keys', - ' width: number | null;', - ' height: number | null;', - ' aspect_ratio: number | null;', - ' public_id: string | null;', - ' content_type: string;', - '}', - '', - 'export interface StoryblokMultiasset extends Array {}', - '', - 'export interface StoryblokMultilink {', - ' fieldtype: \'multilink\';', - ' id: string;', - ' url: string;', - ' cached_url: string;', - ' target?: \'_blank\' | \'_self\';', - ' anchor?: string;', - ' rel?: string;', - ' title?: string;', - ' prep?: string;', - ' linktype: \'story\' | \'url\' | \'email\' | \'asset\';', - ' story?: {', - ' name: string;', - ' created_at: string;', - ' published_at: string;', - ' id: number;', - ' uuid: string;', - ' content: Record;', - ' slug: string;', - ' full_slug: string;', - ' sort_by_date?: string;', - ' position?: number;', - ' tag_list?: string[];', - ' is_startpage?: boolean;', - ' parent_id?: number | null;', - ' meta_data?: Record | null;', - ' group_id?: string;', - ' first_published_at?: string;', - ' release_id?: number | null;', - ' lang?: string;', - ' path?: string | null;', - ' alternates?: any[];', - ' default_full_slug?: string | null;', - ' translated_slugs?: any[] | null;', - ' };', - ' email?: string;', - '}', - '', - 'export interface StoryblokTable {', - ' thead: Array<{', - ' _uid: string;', - ' value: string;', - ' component: number;', - ' }>;', - ' tbody: Array<{', - ' _uid: string;', - ' component: number;', - ' body: Array<{', - ' _uid: string;', - ' value: string;', - ' component: number;', - ' }>;', - ' }>;', - '}', - '', - 'export interface StoryblokRichtext {', - ' type: string;', - ' content?: StoryblokRichtext[];', - ' marks?: StoryblokRichtext[];', - ' attrs?: Record;', - ' text?: string;', - '}', - ].join('\n'); + try { + // Get the path to the storyblok.ts file + const storyblokTypesPath = resolve(process.cwd(), 'src', 'types', 'storyblok.ts'); - // Determine the path to save the file - const resolvedPath = path - ? resolve(process.cwd(), path, 'types') - : resolvePath(path, 'types'); + // Read the content of the storyblok.ts file + const storyblokTypesContent = readFileSync(storyblokTypesPath, 'utf-8'); + + // Extract the type definitions using a more robust approach + const lines = storyblokTypesContent.split('\n'); + const typeDefinitions: string[] = []; + let isCollecting = false; + let bracketCount = 0; + let currentType = ''; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Check if this line starts a type definition + if (line.includes('export type StoryblokPropertyType') + || line.includes('export interface Storyblok')) { + // If we were already collecting a type, add it to our results + if (isCollecting) { + typeDefinitions.push(''); + } + + isCollecting = true; + typeDefinitions.push(line); + currentType = line.includes('type') ? 'type' : 'interface'; + + // Count opening and closing braces to handle nested structures + bracketCount += (line.match(/\{/g) || []).length; + bracketCount -= (line.match(/\}/g) || []).length; + + // For types, we don't need to collect more lines + if (currentType === 'type') { + isCollecting = false; + continue; + } + + // For interfaces, continue collecting lines until we've matched all braces + let j = i + 1; + while (j < lines.length && bracketCount > 0) { + const nextLine = lines[j]; + bracketCount += (nextLine.match(/\{/g) || []).length; + bracketCount -= (nextLine.match(/\}/g) || []).length; + typeDefinitions.push(nextLine); + j++; + } + + // Skip the lines we've already processed + i = j - 1; + isCollecting = false; + } + } + + // Define the content of the d.ts file + const typeDefs = [ + '// This file was generated by the storyblok CLI.', + '// DO NOT MODIFY THIS FILE BY HAND.', + '', + ...typeDefinitions, + ].join('\n'); + + // Determine the path to save the file + const resolvedPath = path + ? resolve(process.cwd(), path, 'types') + : resolvePath(path, 'types'); - try { await saveToFile(join(resolvedPath, `${filename}.d.ts`), typeDefs); return true; } catch (error) { - handleFileSystemError('write', error as Error); + handleFileSystemError('read', error as Error); return false; } }; diff --git a/src/types/schemas.ts b/src/types/schemas.ts new file mode 100644 index 0000000..3bad1e8 --- /dev/null +++ b/src/types/schemas.ts @@ -0,0 +1,39 @@ +import type { StoryblokPropertyType } from './storyblok'; + +export type ComponentPropertySchemaType = + | StoryblokPropertyType + | 'array' + | 'bloks' + | 'boolean' + | 'custom' + | 'datetime' + | 'image' + | 'markdown' + | 'number' + | 'option' + | 'options' + | 'text' + | 'textarea'; + +export interface ComponentPropertySchemaOption { + _uid: string; + name: string; + value: string; +} + +export interface ComponentPropertySchema { + asset_link_type?: boolean; + component_group_whitelist?: string[]; + component_whitelist?: string[]; + email_link_type?: boolean; + exclude_empty_option?: boolean; + filter_content_type?: string | string[]; + key: string; + options?: ComponentPropertySchemaOption[]; + pos: number; + restrict_components?: boolean; + restrict_type?: 'groups' | ''; + source?: 'internal' | 'external' | 'internal_stories' | 'internal_languages'; + type: ComponentPropertySchemaType; + use_uuid?: boolean; +}; diff --git a/src/types/storyblok.ts b/src/types/storyblok.ts index 1a33619..ab38f19 100644 --- a/src/types/storyblok.ts +++ b/src/types/storyblok.ts @@ -1,43 +1,5 @@ export type StoryblokPropertyType = 'asset' | 'multiasset' | 'multilink' | 'table' | 'richtext'; -export type ComponentPropertySchemaType = - | StoryblokPropertyType - | 'array' - | 'bloks' - | 'boolean' - | 'custom' - | 'datetime' - | 'image' - | 'markdown' - | 'number' - | 'option' - | 'options' - | 'text' - | 'textarea'; - -export interface ComponentPropertySchemaOption { - _uid: string; - name: string; - value: string; -} - -export interface ComponentPropertySchema { - asset_link_type?: boolean; - component_group_whitelist?: string[]; - component_whitelist?: string[]; - email_link_type?: boolean; - exclude_empty_option?: boolean; - filter_content_type?: string | string[]; - key: string; - options?: ComponentPropertySchemaOption[]; - pos: number; - restrict_components?: boolean; - restrict_type?: 'groups' | ''; - source?: 'internal' | 'external' | 'internal_stories' | 'internal_languages'; - type: ComponentPropertySchemaType; - use_uuid?: boolean; -}; - export interface StoryblokAsset { alt: string | null; copyright: string | null; @@ -102,18 +64,22 @@ export interface StoryblokMultilink { } export interface StoryblokTable { + fieldtype: 'table'; thead: Array<{ _uid: string; value: string; - component: number; + component: '_table_head'; + _editable?: string; }>; tbody: Array<{ _uid: string; - component: number; + component: '_table_row'; + _editable?: string; body: Array<{ _uid: string; value: string; - component: number; + component: '_table_col'; + _editable?: string; }>; }>; } From 08d27be1f03f5c7d67c2d003cc83b9e0498c3bf2 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Tue, 8 Apr 2025 09:59:18 +0200 Subject: [PATCH 06/16] feat: blok fields type generation - Added support for component restrictions based on groups and individual whitelists in `getComponentPropertiesTypeAnnotations`. - Introduced a new parameter `componentsMaps` to facilitate the mapping of component groups and names. - Updated the `generateTypes` function to pass the new `componentsMaps` parameter, improving type safety and flexibility in handling component properties. --- src/commands/types/generate/actions.ts | 47 +++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/src/commands/types/generate/actions.ts b/src/commands/types/generate/actions.ts index 2f05b8f..739db3d 100644 --- a/src/commands/types/generate/actions.ts +++ b/src/commands/types/generate/actions.ts @@ -81,6 +81,7 @@ export const getComponentType = ( const getComponentPropertiesTypeAnnotations = async ( component: SpaceComponent, options: GenerateTypesOptions, + componentsMaps: ComponentGroupsAndNamesObject, ): Promise => { return Object.entries>(component.schema).reduce(async (accPromise, [key, value]) => { const acc = await accPromise; @@ -117,6 +118,49 @@ const getComponentPropertiesTypeAnnotations = async ( = excludedLinktypes.length > 0 ? `Exclude` : componentType; } + if (propertyType === 'bloks') { + if (value.restrict_components) { + // Components restricted by groups + if (value.restrict_type === 'groups') { + if ( + Array.isArray(value.component_group_whitelist) + && value.component_group_whitelist.length > 0 + ) { + const componentsInGroupWhitelist = value.component_group_whitelist.reduce( + (components: string[], groupUUID: string) => { + const componentsInGroup = componentsMaps.componentGroups.get(groupUUID); + + return componentsInGroup + ? [ + ...components, + ...Array.from(componentsInGroup).map(componentName => getComponentType(componentName, options)), + ] + : components; + }, + [], + ); + + propertyTypeAnnotation[key].tsType + = componentsInGroupWhitelist.length > 0 ? `(${componentsInGroupWhitelist.join(' | ')})[]` : `never[]`; + } + } + else { + // Components restricted by 1-by-1 list + if (Array.isArray(value.component_whitelist) && value.component_whitelist.length > 0) { + propertyTypeAnnotation[key].tsType = `(${value.component_whitelist + .map((name: string) => getComponentType(name, options)) + .join(' | ')})[]`; + } + } + } + else { + // All components can be slotted in this property (AKA no restrictions) + propertyTypeAnnotation[key].tsType = `(${Array.from(componentsMaps.componentNames) + .map(componentName => getComponentType(componentName, options)) + .join(' | ')})[]`; + } + } + return { ...acc, ...propertyTypeAnnotation }; }, Promise.resolve({} as JSONSchema)); }; @@ -146,6 +190,7 @@ export const generateTypes = async ( strict: false, }, ) => { + const componentsMaps = generateComponentGroupsAndComponentNames(components); /* const { componentGroups, componentNames } = generateComponentGroupsAndComponentNames(components); const typedefs = [...DEFAULT_TYPEDEFS_HEADER]; */ const typeDefs = [...DEFAULT_TYPEDEFS_HEADER]; @@ -154,7 +199,7 @@ export const generateTypes = async ( const schemas = await Promise.all(components.map(async (component) => { // Get the component type name with proper handling of numbers at the start const type = getComponentType(component.name, options); - const componentPropertiesTypeAnnotations = await getComponentPropertiesTypeAnnotations(component, options); + const componentPropertiesTypeAnnotations = await getComponentPropertiesTypeAnnotations(component, options, componentsMaps); const requiredFields = Object.entries>(component.schema).reduce( (acc, [key, value]) => { if (value.required) { From 0d5b1c32c88dca2a42599ca3334b0b2d3dcb92a5 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Tue, 8 Apr 2025 10:30:12 +0200 Subject: [PATCH 07/16] feat: enhance type generation with space data integration - Updated `generateTypes` and `getComponentPropertiesTypeAnnotations` functions to utilize `spaceData` instead of `componentsMaps`, improving type safety and flexibility. - Added null checks for `spaceData.components` to prevent runtime errors. - Removed the redundant `generateComponentGroupsAndComponentNames` function to streamline the codebase. - Improved error handling in the `generateTypes` function by implementing a try/catch block to manage asynchronous errors effectively. --- src/commands/types/generate/actions.ts | 180 ++++++++++++------------- src/commands/types/generate/index.ts | 17 ++- 2 files changed, 96 insertions(+), 101 deletions(-) diff --git a/src/commands/types/generate/actions.ts b/src/commands/types/generate/actions.ts index 739db3d..4c96e29 100644 --- a/src/commands/types/generate/actions.ts +++ b/src/commands/types/generate/actions.ts @@ -1,6 +1,6 @@ import { compile, type JSONSchema } from 'json-schema-to-typescript'; -import type { SpaceComponent } from '../../../commands/components/constants'; -import { handleFileSystemError, toCamelCase, toPascalCase } from '../../../utils'; +import type { SpaceComponent, SpaceData } from '../../../commands/components/constants'; +import { handleError, handleFileSystemError, toCamelCase, toPascalCase } from '../../../utils'; import type { GenerateTypesOptions } from './constants'; import type { StoryblokPropertyType } from '../../../types/storyblok'; import { storyblokSchemas } from '../../../utils/storyblok-schemas'; @@ -81,7 +81,7 @@ export const getComponentType = ( const getComponentPropertiesTypeAnnotations = async ( component: SpaceComponent, options: GenerateTypesOptions, - componentsMaps: ComponentGroupsAndNamesObject, + spaceData: SpaceData, ): Promise => { return Object.entries>(component.schema).reduce(async (accPromise, [key, value]) => { const acc = await accPromise; @@ -126,14 +126,18 @@ const getComponentPropertiesTypeAnnotations = async ( Array.isArray(value.component_group_whitelist) && value.component_group_whitelist.length > 0 ) { + // Find components that belong to the whitelisted groups const componentsInGroupWhitelist = value.component_group_whitelist.reduce( (components: string[], groupUUID: string) => { - const componentsInGroup = componentsMaps.componentGroups.get(groupUUID); + // Find components that have this group UUID + const componentsInGroup = spaceData.components.filter( + component => component.component_group_uuid === groupUUID, + ); - return componentsInGroup + return componentsInGroup.length > 0 ? [ ...components, - ...Array.from(componentsInGroup).map(componentName => getComponentType(componentName, options)), + ...componentsInGroup.map(component => getComponentType(component.name, options)), ] : components; }, @@ -155,114 +159,106 @@ const getComponentPropertiesTypeAnnotations = async ( } else { // All components can be slotted in this property (AKA no restrictions) - propertyTypeAnnotation[key].tsType = `(${Array.from(componentsMaps.componentNames) - .map(componentName => getComponentType(componentName, options)) - .join(' | ')})[]`; + // Add null check to ensure spaceData.components is defined + if (spaceData && Array.isArray(spaceData.components)) { + propertyTypeAnnotation[key].tsType = `(${spaceData.components + .map(component => getComponentType(component.name, options)) + .join(' | ')})[]`; + } + else { + // Fallback to never[] if components is undefined + propertyTypeAnnotation[key].tsType = `never[]`; + } } } return { ...acc, ...propertyTypeAnnotation }; }, Promise.resolve({} as JSONSchema)); }; -export const generateComponentGroupsAndComponentNames = ( - components: SpaceComponent[], -): ComponentGroupsAndNamesObject => { - return components.reduce( - (acc, currentComponent) => { - if (currentComponent.component_group_uuid) { - acc.componentGroups.set( - currentComponent.component_group_uuid, - acc.componentGroups.has(currentComponent.component_group_uuid) - ? acc.componentGroups.get(currentComponent.component_group_uuid)!.add(currentComponent.name) - : new Set([currentComponent.name]), - ); - } - acc.componentNames.add(currentComponent.name); - return acc; - }, - { componentGroups: new Map(), componentNames: new Set() }, - ); -}; export const generateTypes = async ( - components: SpaceComponent[], + spaceData: SpaceData, options: GenerateTypesOptions = { strict: false, }, ) => { - const componentsMaps = generateComponentGroupsAndComponentNames(components); - /* const { componentGroups, componentNames } = generateComponentGroupsAndComponentNames(components); + try { + /* const { componentGroups, componentNames } = generateComponentGroupsAndComponentNames(components); const typedefs = [...DEFAULT_TYPEDEFS_HEADER]; */ - const typeDefs = [...DEFAULT_TYPEDEFS_HEADER]; - const storyblokPropertyTypes = new Set(); + const typeDefs = [...DEFAULT_TYPEDEFS_HEADER]; + const storyblokPropertyTypes = new Set(); - const schemas = await Promise.all(components.map(async (component) => { + const schemas = await Promise.all(spaceData.components.map(async (component) => { // Get the component type name with proper handling of numbers at the start - const type = getComponentType(component.name, options); - const componentPropertiesTypeAnnotations = await getComponentPropertiesTypeAnnotations(component, options, componentsMaps); - const requiredFields = Object.entries>(component.schema).reduce( - (acc, [key, value]) => { - if (value.required) { - return [...acc, key]; - } - return acc; - }, - ['component', '_uid'], - ); - - // Check if any property has a type that's in storyblokSchemas.keys() - if (componentPropertiesTypeAnnotations) { - Object.entries(componentPropertiesTypeAnnotations).forEach(([_, property]) => { - if (property.type && Array.from(storyblokSchemas.keys()).includes(property.type as StoryblokPropertyType)) { - storyblokPropertyTypes.add(property.type as StoryblokPropertyType); - } - }); - } - - const componentSchema: JSONSchema = { - $id: `#/${component.name}`, - title: type, // This is the key - we're using the properly formatted type name - type: 'object', - required: requiredFields, - properties: { - ...componentPropertiesTypeAnnotations, - component: { - type: 'string', - enum: [component.name], + const type = getComponentType(component.name, options); + const componentPropertiesTypeAnnotations = await getComponentPropertiesTypeAnnotations(component, options, spaceData); + const requiredFields = Object.entries>(component.schema).reduce( + (acc, [key, value]) => { + if (value.required) { + return [...acc, key]; + } + return acc; }, - _uid: { - type: 'string', + ['component', '_uid'], + ); + + // Check if any property has a type that's in storyblokSchemas.keys() + if (componentPropertiesTypeAnnotations) { + Object.entries(componentPropertiesTypeAnnotations).forEach(([_, property]) => { + if (property.type && Array.from(storyblokSchemas.keys()).includes(property.type as StoryblokPropertyType)) { + storyblokPropertyTypes.add(property.type as StoryblokPropertyType); + } + }); + } + + const componentSchema: JSONSchema = { + $id: `#/${component.name}`, + title: type, // This is the key - we're using the properly formatted type name + type: 'object', + required: requiredFields, + properties: { + ...componentPropertiesTypeAnnotations, + component: { + type: 'string', + enum: [component.name], + }, + _uid: { + type: 'string', + }, }, - }, - }; + }; - return componentSchema; - })); + return componentSchema; + })); - const result = await Promise.all(schemas.map(async (schema) => { + const result = await Promise.all(schemas.map(async (schema) => { // Use the title as the interface name - return await compile(schema, schema.title || schema.$id.replace('#/', ''), { - additionalProperties: !options.strict, - bannerComment: '', - }); - })); - - // Add imports for Storyblok types if needed - const imports: string[] = []; - if (storyblokPropertyTypes.size > 0) { - const typeImports = Array.from(storyblokPropertyTypes).map((type) => { - const pascalType = toPascalCase(type); - return `Storyblok${pascalType}`; - }); - - imports.push(`import type { ${typeImports.join(', ')} } from '../storyblok.d.ts';`); - } + return await compile(schema, schema.title || schema.$id.replace('#/', ''), { + additionalProperties: !options.strict, + bannerComment: '', + }); + })); + + // Add imports for Storyblok types if needed + const imports: string[] = []; + if (storyblokPropertyTypes.size > 0) { + const typeImports = Array.from(storyblokPropertyTypes).map((type) => { + const pascalType = toPascalCase(type); + return `Storyblok${pascalType}`; + }); - const finalTypeDef = [...typeDefs, ...imports, ...result]; + imports.push(`import type { ${typeImports.join(', ')} } from '../storyblok.d.ts';`); + } + + const finalTypeDef = [...typeDefs, ...imports, ...result]; - return [ - ...finalTypeDef, - ].join('\n'); + return [ + ...finalTypeDef, + ].join('\n'); + } + catch (error) { + handleError(error as Error); + } }; export const saveTypesToFile = async (space: string, typedefString: string, options: SaveTypesOptions) => { diff --git a/src/commands/types/generate/index.ts b/src/commands/types/generate/index.ts index 3f70880..d63f975 100644 --- a/src/commands/types/generate/index.ts +++ b/src/commands/types/generate/index.ts @@ -7,7 +7,6 @@ import { readComponentsFiles } from '../../components/push/actions'; import type { GenerateTypesOptions } from './constants'; import type { ReadComponentsOptions } from '../../components/push/constants'; import { typesCommand } from '../command'; -import type { SaveTypesOptions } from './actions'; import { generateStoryblokTypes, generateTypes, saveTypesToFile } from './actions'; const program = getProgram(); @@ -26,8 +25,6 @@ typesCommand // Command options const { space, path } = typesCommand.opts(); - console.log(typesCommand.opts()); - // const { filter, separateFiles } = options; const { state, initializeSession } = session(); @@ -42,10 +39,10 @@ typesCommand return; } - /* const spinnerText = componentName ? `Generating types for component ${componentName}...` : 'Generating types...'; + const spinnerText = componentName ? `Generating types for component ${componentName}...` : 'Generating types...'; const spinner = new Spinner({ verbose: !isVitest, - }).start(spinnerText); */ + }).start(spinnerText); try { const spaceData = await readComponentsFiles({ @@ -59,14 +56,16 @@ typesCommand path: options.path, }); - const typedefString = await generateTypes(spaceData.components, options); + const typedefString = await generateTypes(spaceData, options); - await saveTypesToFile(space, typedefString, options); + if (typedefString) { + await saveTypesToFile(space, typedefString, options); + } - // spinner.succeed(`Successfully generated types for component ${componentName}`); + spinner.succeed(`Successfully generated types for component ${componentName}`); } catch (error) { - // spinner.failed(`Failed to generate types for component ${componentName}`); + spinner.failed(`Failed to generate types for component ${componentName}`); handleError(error as Error, verbose); } }); From 2c7444be83c3dbccccd9f60284116540e07ccaa9 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Tue, 8 Apr 2025 10:54:02 +0200 Subject: [PATCH 08/16] feat: added type prefix flag - Refactored `GenerateTypesOptions` to replace `typeNamesPrefix` and `typeNamesSuffix` with a single `typePrefix` option for improved clarity and usability. - Enhanced `getComponentType` function to utilize the new `typePrefix` option, simplifying the type name generation process. - Updated command options in `index.ts` to include `--type-prefix` for better user experience when generating component types. - Adjusted handling of Storyblok property types to ensure consistent type generation without unnecessary prefixes. --- src/commands/types/generate/actions.ts | 19 ++++++++++++++----- src/commands/types/generate/constants.ts | 3 +-- src/commands/types/generate/index.ts | 1 + 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/commands/types/generate/actions.ts b/src/commands/types/generate/actions.ts index 4c96e29..a65e82e 100644 --- a/src/commands/types/generate/actions.ts +++ b/src/commands/types/generate/actions.ts @@ -54,12 +54,20 @@ const getPropertyTypeAnnotation = (property: ComponentPropertySchema) => { } }; +/** + * Generates a TypeScript type name for a component + * @param componentName - The name of the component + * @param options - Options for generating the type name + * @returns The generated type name + * + * The type name can be customized with the following options: + * - typePrefix: Prefix to be prepended to all generated component type names (can be set via --type-prefix flag) + */ export const getComponentType = ( componentName: string, options: GenerateTypesOptions, ): string => { - const prefix = options.typeNamesPrefix ?? ''; - const suffix = options.typeNamesSuffix ?? ''; + const prefix = options.typePrefix ?? ''; // Sanitize the component name to handle special characters and emojis const sanitizedName = componentName @@ -71,7 +79,7 @@ export const getComponentType = ( .replace(/^_+|_+$/g, ''); // Convert to PascalCase - const componentType = toPascalCase(toCamelCase(`${prefix}_${sanitizedName}_${suffix}`)); + const componentType = toPascalCase(toCamelCase(`${prefix}_${sanitizedName}`)); // If the component type starts with a number, prefix it with an underscore const isFirstCharacterNumber = !Number.isNaN(Number.parseInt(componentType.charAt(0))); @@ -104,7 +112,8 @@ const getComponentPropertiesTypeAnnotations = async ( } if (Array.from(storyblokSchemas.keys()).includes(propertyType as StoryblokPropertyType)) { - const componentType = getComponentType(propertyType, options); + // For Storyblok property types, don't apply the prefix + const componentType = toPascalCase(toCamelCase(propertyType)); propertyTypeAnnotation[key].tsType = `Storyblok${componentType}`; } @@ -113,7 +122,7 @@ const getComponentPropertiesTypeAnnotations = async ( ...(!value.email_link_type ? ['{ linktype?: "email" }'] : []), ...(!value.asset_link_type ? ['{ linktype?: "asset" }'] : []), ]; - const componentType = getComponentType(propertyType, options); + const componentType = toPascalCase(toCamelCase(propertyType)); propertyTypeAnnotation[key].tsType = excludedLinktypes.length > 0 ? `Exclude` : componentType; } diff --git a/src/commands/types/generate/constants.ts b/src/commands/types/generate/constants.ts index 7b91c33..43c3d0a 100644 --- a/src/commands/types/generate/constants.ts +++ b/src/commands/types/generate/constants.ts @@ -2,8 +2,7 @@ export interface GenerateTypesOptions { filter?: string; separateFiles?: boolean; strict?: boolean; - typeNamesPrefix?: string; - typeNamesSuffix?: string; + typePrefix?: string; storyblokTypes?: boolean; filename?: string; path?: string; diff --git a/src/commands/types/generate/index.ts b/src/commands/types/generate/index.ts index d63f975..81fec01 100644 --- a/src/commands/types/generate/index.ts +++ b/src/commands/types/generate/index.ts @@ -17,6 +17,7 @@ typesCommand .option('--fi, --filter ', 'glob filter to apply to the components before generating types') .option('--sf, --separate-files', '') .option('--strict', 'strict mode, no loose typing') + .option('--type-prefix ', 'prefix to be prepended to all generated component type names') .action(async (componentName: string | undefined, options: GenerateTypesOptions) => { konsola.title(` ${commands.TYPES} `, colorPalette.TYPES, componentName ? `Generating types for component ${componentName}...` : 'Generating types...'); // Global options From 1e85c1a1d39a168bb9aaa9e9be846c5030e195b5 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Tue, 8 Apr 2025 11:05:01 +0200 Subject: [PATCH 09/16] chore: add debug configuration for generating types from separate files - Introduced a new launch configuration in `.vscode/launch.json` for debugging the type generation process with separate files. - This configuration allows for easier debugging of the type generation command, enhancing the development experience. --- .vscode/launch.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.vscode/launch.json b/.vscode/launch.json index 7272ae9..8f27ed9 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -321,6 +321,20 @@ "env": { "STUB": "true" } + }, + { + "type": "node", + "request": "launch", + "name": "Debug Generate types from separate files", + "program": "${workspaceFolder}/dist/index.mjs", + "args": ["types", "generate", "--space", "295017", "--separate-files"], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "sourceMaps": true, + "outFiles": ["${workspaceFolder}/dist/**/*.js"], + "env": { + "STUB": "true" + } } ] } From a88d059f4f9d656593dc96e95d8c833542b7ac1c Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Wed, 9 Apr 2025 11:46:44 +0200 Subject: [PATCH 10/16] refactor: simplify generate command options - Removed the optional `[componentName]` parameter from the `generate` command to streamline the command usage. - Eliminated the `--fi, --filter ` option to reduce complexity in type generation, focusing on essential flags for better clarity and usability. --- src/commands/types/generate/index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/commands/types/generate/index.ts b/src/commands/types/generate/index.ts index 81fec01..093b766 100644 --- a/src/commands/types/generate/index.ts +++ b/src/commands/types/generate/index.ts @@ -12,9 +12,8 @@ import { generateStoryblokTypes, generateTypes, saveTypesToFile } from './action const program = getProgram(); typesCommand - .command('generate [componentName]') + .command('generate') .description('Generate types d.ts for your component schemas') - .option('--fi, --filter ', 'glob filter to apply to the components before generating types') .option('--sf, --separate-files', '') .option('--strict', 'strict mode, no loose typing') .option('--type-prefix ', 'prefix to be prepended to all generated component type names') From 5e99e284fd56710de1b8d866a25b9851271447c3 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Thu, 10 Apr 2025 09:19:01 +0200 Subject: [PATCH 11/16] feat: enhance type generation command with suffix support - Added a new `suffix` option to the `GenerateTypesOptions` interface to allow for suffix customization during type generation. - Updated the `generate` command in `index.ts` to utilize the new `suffix` option, improving flexibility in type generation. - Modified the VSCode launch configuration to include a new debug option for generating types with a specified suffix, enhancing the development experience. --- .vscode/launch.json | 14 ++++++++++++++ src/commands/types/generate/constants.ts | 1 + src/commands/types/generate/index.ts | 13 ++++++------- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 1aea244..095de5b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -363,6 +363,20 @@ "env": { "STUB": "true" } + }, + { + "type": "node", + "request": "launch", + "name": "Debug Generate types with suffix", + "program": "${workspaceFolder}/dist/index.mjs", + "args": ["types", "generate", "--space", "295017", "--suffix", "prod"], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "sourceMaps": true, + "outFiles": ["${workspaceFolder}/dist/**/*.js"], + "env": { + "STUB": "true" + } } ] } diff --git a/src/commands/types/generate/constants.ts b/src/commands/types/generate/constants.ts index 43c3d0a..c9a4207 100644 --- a/src/commands/types/generate/constants.ts +++ b/src/commands/types/generate/constants.ts @@ -6,4 +6,5 @@ export interface GenerateTypesOptions { storyblokTypes?: boolean; filename?: string; path?: string; + suffix?: string; } diff --git a/src/commands/types/generate/index.ts b/src/commands/types/generate/index.ts index 093b766..e810198 100644 --- a/src/commands/types/generate/index.ts +++ b/src/commands/types/generate/index.ts @@ -17,16 +17,15 @@ typesCommand .option('--sf, --separate-files', '') .option('--strict', 'strict mode, no loose typing') .option('--type-prefix ', 'prefix to be prepended to all generated component type names') - .action(async (componentName: string | undefined, options: GenerateTypesOptions) => { - konsola.title(` ${commands.TYPES} `, colorPalette.TYPES, componentName ? `Generating types for component ${componentName}...` : 'Generating types...'); + .option('--suffix ', 'Components suffix') + .action(async (options: GenerateTypesOptions) => { + konsola.title(` ${commands.TYPES} `, colorPalette.TYPES, 'Generating types...'); // Global options const verbose = program.opts().verbose; // Command options const { space, path } = typesCommand.opts(); - // const { filter, separateFiles } = options; - const { state, initializeSession } = session(); await initializeSession(); @@ -39,7 +38,7 @@ typesCommand return; } - const spinnerText = componentName ? `Generating types for component ${componentName}...` : 'Generating types...'; + const spinnerText = 'Generating types...'; const spinner = new Spinner({ verbose: !isVitest, }).start(spinnerText); @@ -62,10 +61,10 @@ typesCommand await saveTypesToFile(space, typedefString, options); } - spinner.succeed(`Successfully generated types for component ${componentName}`); + spinner.succeed(`Successfully generated types for space ${space}`); } catch (error) { - spinner.failed(`Failed to generate types for component ${componentName}`); + spinner.failed(`Failed to generate types for space ${space}`); handleError(error as Error, verbose); } }); From 291576d7af0cbce32960a04ba09614686a25c9d2 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Thu, 10 Apr 2025 10:47:54 +0200 Subject: [PATCH 12/16] feat: implement custom fields parser for type generation - Added a new `customFieldsParser` option to the `GenerateTypesOptions` interface, allowing for custom field type handling during type generation. - Introduced a `custom-fields-parser.ts` file to define the structure for the 'native-color-picker' field type. - Updated the `getComponentPropertiesTypeAnnotations` function to utilize the custom fields parser when available, enhancing flexibility in type generation. - Enhanced the `generate` command in `index.ts` to support the new `--custom-fields-parser` option for specifying the parser file path. - Modified the VSCode launch configuration to include a debug option for generating types with a custom fields parser, improving the development experience. --- .vscode/launch.json | 14 ++++++++++++ custom-fields-parser.ts | 16 ++++++++++++++ src/commands/types/generate/actions.ts | 27 ++++++++++++++++++------ src/commands/types/generate/constants.ts | 1 + src/commands/types/generate/index.ts | 1 + 5 files changed, 53 insertions(+), 6 deletions(-) create mode 100644 custom-fields-parser.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 095de5b..1392ad8 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -377,6 +377,20 @@ "env": { "STUB": "true" } + }, + { + "type": "node", + "request": "launch", + "name": "Debug Generate types with custom fields parser", + "program": "${workspaceFolder}/dist/index.mjs", + "args": ["types", "generate", "--space", "295017", "--custom-fields-parser", "./custom-fields-parser.ts"], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "sourceMaps": true, + "outFiles": ["${workspaceFolder}/dist/**/*.js"], + "env": { + "STUB": "true" + } } ] } diff --git a/custom-fields-parser.ts b/custom-fields-parser.ts new file mode 100644 index 0000000..cf77fa9 --- /dev/null +++ b/custom-fields-parser.ts @@ -0,0 +1,16 @@ +export default (key: string, field: any) => { + switch (field.field_type) { + case 'native-color-picker': + return { + [key]: { + properties: { + color: { type: 'string' }, + }, + required: ['color'], + type: 'object', + }, + }; + default: + return {}; + } +}; diff --git a/src/commands/types/generate/actions.ts b/src/commands/types/generate/actions.ts index a65e82e..7c6ba96 100644 --- a/src/commands/types/generate/actions.ts +++ b/src/commands/types/generate/actions.ts @@ -90,6 +90,7 @@ const getComponentPropertiesTypeAnnotations = async ( component: SpaceComponent, options: GenerateTypesOptions, spaceData: SpaceData, + customFieldsParser?: Record, ): Promise => { return Object.entries>(component.schema).reduce(async (accPromise, [key, value]) => { const acc = await accPromise; @@ -104,10 +105,11 @@ const getComponentPropertiesTypeAnnotations = async ( [key]: getPropertyTypeAnnotation(value as ComponentPropertySchema), }; - if (propertyType === 'custom') { + if (propertyType === 'custom' && customFieldsParser?.default) { + const customField = typeof customFieldsParser.default === 'function' ? customFieldsParser.default(key, value) : {}; return { ...acc, - // TODO: Add custom type annotation + ...customField, }; } @@ -185,6 +187,17 @@ const getComponentPropertiesTypeAnnotations = async ( }, Promise.resolve({} as JSONSchema)); }; +export const loadCustomFieldsParser = async (path: string) => { + try { + const customFieldsParser = await import(resolve(path)); + return customFieldsParser; + } + catch (error) { + handleError(error as Error); + return null; + } +}; + export const generateTypes = async ( spaceData: SpaceData, options: GenerateTypesOptions = { @@ -192,15 +205,17 @@ export const generateTypes = async ( }, ) => { try { - /* const { componentGroups, componentNames } = generateComponentGroupsAndComponentNames(components); - const typedefs = [...DEFAULT_TYPEDEFS_HEADER]; */ const typeDefs = [...DEFAULT_TYPEDEFS_HEADER]; const storyblokPropertyTypes = new Set(); - + let customFieldsParser: any; + // Custom fields parser + if (options.customFieldsParser) { + customFieldsParser = await loadCustomFieldsParser(options.customFieldsParser); + } const schemas = await Promise.all(spaceData.components.map(async (component) => { // Get the component type name with proper handling of numbers at the start const type = getComponentType(component.name, options); - const componentPropertiesTypeAnnotations = await getComponentPropertiesTypeAnnotations(component, options, spaceData); + const componentPropertiesTypeAnnotations = await getComponentPropertiesTypeAnnotations(component, options, spaceData, customFieldsParser); const requiredFields = Object.entries>(component.schema).reduce( (acc, [key, value]) => { if (value.required) { diff --git a/src/commands/types/generate/constants.ts b/src/commands/types/generate/constants.ts index c9a4207..c8590f3 100644 --- a/src/commands/types/generate/constants.ts +++ b/src/commands/types/generate/constants.ts @@ -7,4 +7,5 @@ export interface GenerateTypesOptions { filename?: string; path?: string; suffix?: string; + customFieldsParser?: string; } diff --git a/src/commands/types/generate/index.ts b/src/commands/types/generate/index.ts index e810198..f5f79a6 100644 --- a/src/commands/types/generate/index.ts +++ b/src/commands/types/generate/index.ts @@ -18,6 +18,7 @@ typesCommand .option('--strict', 'strict mode, no loose typing') .option('--type-prefix ', 'prefix to be prepended to all generated component type names') .option('--suffix ', 'Components suffix') + .option('--custom-fields-parser ', 'Path to the parser file for Custom Field Types') .action(async (options: GenerateTypesOptions) => { konsola.title(` ${commands.TYPES} `, colorPalette.TYPES, 'Generating types...'); // Global options From 4ac34e14ed170a17f6c7f28135119c75b17362a5 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Thu, 10 Apr 2025 11:09:05 +0200 Subject: [PATCH 13/16] feat: add compiler options support for type generation - Introduced a new `compilerOptions` option in the `GenerateTypesOptions` interface to allow for custom compiler configurations during type generation. - Implemented a `compiler-options.ts` file to define default compiler settings. - Updated the `generateTypes` function to load and apply compiler options when generating types, enhancing flexibility and customization. - Enhanced the `getComponentPropertiesTypeAnnotations` function to support the new `customFieldsParser` type definition. - Modified the VSCode launch configuration to include a debug option for generating types with specified compiler options, improving the development experience. --- .vscode/launch.json | 14 ++++++++++++ compiler-options.ts | 5 +++++ src/commands/types/generate/actions.ts | 28 +++++++++++++++++++----- src/commands/types/generate/constants.ts | 1 + src/commands/types/generate/index.ts | 1 + 5 files changed, 43 insertions(+), 6 deletions(-) create mode 100644 compiler-options.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 1392ad8..350a81f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -391,6 +391,20 @@ "env": { "STUB": "true" } + }, + { + "type": "node", + "request": "launch", + "name": "Debug Generate types with compiler options", + "program": "${workspaceFolder}/dist/index.mjs", + "args": ["types", "generate", "--space", "295017", "--compiler-options", "./compiler-options.ts"], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "sourceMaps": true, + "outFiles": ["${workspaceFolder}/dist/**/*.js"], + "env": { + "STUB": "true" + } } ] } diff --git a/compiler-options.ts b/compiler-options.ts new file mode 100644 index 0000000..4c28e8a --- /dev/null +++ b/compiler-options.ts @@ -0,0 +1,5 @@ +export default { + unknownAny: false, + additionalProperties: false, + bannerComment: 'Awiwi', +}; diff --git a/src/commands/types/generate/actions.ts b/src/commands/types/generate/actions.ts index 7c6ba96..90cbf1e 100644 --- a/src/commands/types/generate/actions.ts +++ b/src/commands/types/generate/actions.ts @@ -90,7 +90,7 @@ const getComponentPropertiesTypeAnnotations = async ( component: SpaceComponent, options: GenerateTypesOptions, spaceData: SpaceData, - customFieldsParser?: Record, + customFieldsParser?: (key: string, value: Record) => Record, ): Promise => { return Object.entries>(component.schema).reduce(async (accPromise, [key, value]) => { const acc = await accPromise; @@ -105,8 +105,8 @@ const getComponentPropertiesTypeAnnotations = async ( [key]: getPropertyTypeAnnotation(value as ComponentPropertySchema), }; - if (propertyType === 'custom' && customFieldsParser?.default) { - const customField = typeof customFieldsParser.default === 'function' ? customFieldsParser.default(key, value) : {}; + if (propertyType === 'custom' && customFieldsParser) { + const customField = typeof customFieldsParser === 'function' ? customFieldsParser(key, value) : {}; return { ...acc, ...customField, @@ -187,10 +187,10 @@ const getComponentPropertiesTypeAnnotations = async ( }, Promise.resolve({} as JSONSchema)); }; -export const loadCustomFieldsParser = async (path: string) => { +const loadCustomFieldsParser = async (path: string) => { try { const customFieldsParser = await import(resolve(path)); - return customFieldsParser; + return customFieldsParser.default; } catch (error) { handleError(error as Error); @@ -198,6 +198,14 @@ export const loadCustomFieldsParser = async (path: string) => { } }; +async function loadCompilerOptions(path: string) { + if (path) { + const compilerOptions = await import(resolve(path)); + return compilerOptions.default; + } + return {}; +} + export const generateTypes = async ( spaceData: SpaceData, options: GenerateTypesOptions = { @@ -207,11 +215,18 @@ export const generateTypes = async ( try { const typeDefs = [...DEFAULT_TYPEDEFS_HEADER]; const storyblokPropertyTypes = new Set(); - let customFieldsParser: any; + let customFieldsParser: Record | undefined; + let compilerOptions: Record | undefined; // Custom fields parser if (options.customFieldsParser) { customFieldsParser = await loadCustomFieldsParser(options.customFieldsParser); } + + // Compiler options + if (options.compilerOptions) { + compilerOptions = await loadCompilerOptions(options.compilerOptions); + } + const schemas = await Promise.all(spaceData.components.map(async (component) => { // Get the component type name with proper handling of numbers at the start const type = getComponentType(component.name, options); @@ -260,6 +275,7 @@ export const generateTypes = async ( return await compile(schema, schema.title || schema.$id.replace('#/', ''), { additionalProperties: !options.strict, bannerComment: '', + ...compilerOptions, }); })); diff --git a/src/commands/types/generate/constants.ts b/src/commands/types/generate/constants.ts index c8590f3..35f10ff 100644 --- a/src/commands/types/generate/constants.ts +++ b/src/commands/types/generate/constants.ts @@ -8,4 +8,5 @@ export interface GenerateTypesOptions { path?: string; suffix?: string; customFieldsParser?: string; + compilerOptions?: string; } diff --git a/src/commands/types/generate/index.ts b/src/commands/types/generate/index.ts index f5f79a6..0b66b9c 100644 --- a/src/commands/types/generate/index.ts +++ b/src/commands/types/generate/index.ts @@ -19,6 +19,7 @@ typesCommand .option('--type-prefix ', 'prefix to be prepended to all generated component type names') .option('--suffix ', 'Components suffix') .option('--custom-fields-parser ', 'Path to the parser file for Custom Field Types') + .option('--compiler-options ', 'path to the compiler options from json-schema-to-typescript') .action(async (options: GenerateTypesOptions) => { konsola.title(` ${commands.TYPES} `, colorPalette.TYPES, 'Generating types...'); // Global options From b0ba4d1f24d8c0df2da63eb06a85cc78754b4c85 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Mon, 14 Apr 2025 09:44:42 +0200 Subject: [PATCH 14/16] test: add comprehensive tests for type generation actions - Introduced a new test file `actions.test.ts` to validate the functionality of type generation actions. - Implemented tests for `generateTypes` and `getComponentType` functions, covering various scenarios including strict mode, custom fields parser, and component property type annotations. - Mocked necessary modules and created a virtual file system to simulate file operations, ensuring isolated and reliable tests. - Enhanced test coverage for handling different property types such as text, textarea, number, boolean, multilink, and custom types. - Verified that generated types align with expected outputs, improving the robustness of the type generation feature. --- src/commands/types/generate/actions.test.ts | 659 ++++++++++++++++++++ 1 file changed, 659 insertions(+) create mode 100644 src/commands/types/generate/actions.test.ts diff --git a/src/commands/types/generate/actions.test.ts b/src/commands/types/generate/actions.test.ts new file mode 100644 index 0000000..8c5985b --- /dev/null +++ b/src/commands/types/generate/actions.test.ts @@ -0,0 +1,659 @@ +import { generateStoryblokTypes, generateTypes, getComponentType } from './actions'; +import type { SpaceComponent, SpaceData } from '../../../commands/components/constants'; +import type { GenerateTypesOptions } from './constants'; +import { join, resolve } from 'node:path'; +import { vol } from 'memfs'; +import { readFileSync } from 'node:fs'; + +// 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'), +})); + +// Mock the fs module +vi.mock('node:fs', () => ({ + readFileSync: vi.fn().mockReturnValue(''), +})); + +vi.mock('node:fs/promises'); +vi.mock('node:path', () => ({ + resolve: vi.fn().mockReturnValue('/mocked/path'), + join: vi.fn().mockReturnValue('/mocked/joined/path'), +})); + +// Create a mock for the custom fields parser +const mockCustomFieldsParser = vi.fn().mockImplementation((key, field) => { + if (field.field_type === 'native-color-picker') { + return { + [key]: { + properties: { + color: { type: 'string' }, + }, + required: ['color'], + type: 'object', + }, + }; + } + return {}; +}); + +// Mock the dynamic import +vi.mock('/mocked/path', () => ({ + default: mockCustomFieldsParser, +})); + +// Mock the import function +vi.mock('node:module', () => ({ + import: vi.fn().mockResolvedValue({ + default: mockCustomFieldsParser, + }), +})); + +// Set up the virtual file system with our custom fields parser +vol.fromJSON({ + '/path/to/custom/parser.ts': ` +export default (key: string, field: any) => { + switch (field.field_type) { + case 'native-color-picker': + return { + [key]: { + properties: { + color: { type: 'string' }, + }, + required: ['color'], + type: 'object', + }, + }; + default: + return {}; + } +}; +`, + // Add a mock storyblok.ts file for testing generateStoryblokTypes + '/mocked/path': ` +// Storyblok types +export type StoryblokPropertyType = 'text' | 'textarea' | 'number' | 'boolean' | 'multilink' | 'bloks' | 'custom'; + +export interface StoryblokText { + type: 'text'; + required?: boolean; +} + +export interface StoryblokTextarea { + type: 'textarea'; + required?: boolean; +} + +export interface StoryblokNumber { + type: 'number'; + required?: boolean; +} + +export interface StoryblokBoolean { + type: 'boolean'; + required?: boolean; +} + +export interface StoryblokMultilink { + type: 'multilink'; + required?: boolean; + email_link_type?: boolean; + asset_link_type?: boolean; +} + +export interface StoryblokBloks { + type: 'bloks'; + required?: boolean; + restrict_components?: boolean; + component_whitelist?: string[]; + component_group_whitelist?: string[]; + restrict_type?: 'groups' | 'components'; +} + +export interface StoryblokCustom { + type: 'custom'; + required?: boolean; + field_type?: string; +} +`, +}); + +// Set up the mock content for readFileSync +const mockStoryblokContent = vol.readFileSync('/mocked/path', 'utf-8') as string; +vi.mocked(readFileSync).mockImplementation((path) => { + if (path === '/mocked/path') { + return mockStoryblokContent; + } + return ''; +}); + +const mockSpaceData: SpaceData = { + components: [ + { + name: 'test_component', + display_name: 'Test Component', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z', + id: 1, + schema: { + title: { + type: 'text', + required: true, + }, + description: { + type: 'textarea', + required: false, + }, + }, + color: null, + internal_tags_list: [], + internal_tag_ids: [], + }, + ], + groups: [], + presets: [], + internalTags: [], +}; + +describe('generate types actions', () => { + it('should generate types successfully', async () => { + // Create mock options + const mockOptions: GenerateTypesOptions = { + strict: false, + }; + + // Call the function with the correct parameters + const result = await generateTypes(mockSpaceData, mockOptions); + + // Verify the result contains expected content + expect(result).toContain('// This file was generated by the storyblok CLI.'); + expect(result).toContain('// DO NOT MODIFY THIS FILE BY HAND.'); + expect(result).toContain('export interface TestComponent'); + expect(result).toContain('title: string'); + expect(result).toContain('description?: string'); + expect(result).toContain('component: "test_component"'); + expect(result).toContain('_uid: string'); + expect(result).toContain('[k: string]: unknown'); + }); + + it('should generate types successfully with strict mode', async () => { + const mockOptions: GenerateTypesOptions = { + strict: true, + }; + + const result = await generateTypes(mockSpaceData, mockOptions); + + expect(result).not.toContain('[k: string]: unknown'); + }); + + it('should handle customFieldsParser option', async () => { + // Create mock options with customFieldsParser + const mockOptions: GenerateTypesOptions = { + strict: false, + customFieldsParser: '/path/to/custom/parser.ts', + }; + + // Call the function with the customFieldsParser option + const result = await generateTypes(mockSpaceData, mockOptions); + + // Verify that the result is generated successfully + expect(result).toBeDefined(); + if (result) { + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + } + + // Verify that resolve was called with the customFieldsParser path + expect(resolve).toHaveBeenCalledWith('/path/to/custom/parser.ts'); + }); + + it('should handle compilerOptions option', async () => { + // Create mock options with compilerOptions + const mockOptions: GenerateTypesOptions = { + strict: false, + compilerOptions: '/path/to/compiler/options', + }; + + // Call the function with the compilerOptions option + const result = await generateTypes(mockSpaceData, mockOptions); + + // Verify that the result is generated successfully + expect(result).toBeDefined(); + if (result) { + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + } + + // Verify that resolve was called with the compilerOptions path + expect(resolve).toHaveBeenCalledWith('/path/to/compiler/options'); + }); + + it('should apply typePrefix to component type names', async () => { + // Create mock options with typePrefix + const mockOptions: GenerateTypesOptions = { + strict: false, + typePrefix: 'Custom', + }; + + // Call the function with the typePrefix option + const result = await generateTypes(mockSpaceData, mockOptions); + + // Verify that the result contains the expected prefixed type name + expect(result).toContain('export interface CustomTestComponent'); + expect(result).toContain('title: string'); + expect(result).toContain('description?: string'); + expect(result).toContain('component: "test_component"'); + expect(result).toContain('_uid: string'); + expect(result).toContain('[k: string]: unknown'); + }); +}); + +describe('getComponentType', () => { + it('should convert component name to PascalCase', () => { + const options: GenerateTypesOptions = {}; + expect(getComponentType('test_component', options)).toBe('TestComponent'); + }); + + it('should handle special characters in component name', () => { + const options: GenerateTypesOptions = {}; + expect(getComponentType('test-component!', options)).toBe('TestComponent'); + }); + + it('should handle emojis in component name', () => { + const options: GenerateTypesOptions = {}; + expect(getComponentType('testšŸ˜€component', options)).toBe('TestComponent'); + }); + + it('should handle multiple consecutive special characters', () => { + const options: GenerateTypesOptions = {}; + expect(getComponentType('test___component', options)).toBe('TestComponent'); + }); + + it('should handle component names starting with numbers', () => { + const options: GenerateTypesOptions = {}; + expect(getComponentType('123component', options)).toBe('_123component'); + }); + + it('should apply typePrefix when provided', () => { + const options: GenerateTypesOptions = { + typePrefix: 'Custom', + }; + expect(getComponentType('test_component', options)).toBe('CustomTestComponent'); + }); + + it('should handle empty typePrefix', () => { + const options: GenerateTypesOptions = { + typePrefix: '', + }; + expect(getComponentType('test_component', options)).toBe('TestComponent'); + }); + + it('should handle component names with spaces', () => { + const options: GenerateTypesOptions = {}; + expect(getComponentType('test component', options)).toBe('TestComponent'); + }); +}); + +describe('component property type annotations', () => { + it('should handle text property type', async () => { + // Create a component with text property type + const componentWithTextType: SpaceComponent = { + name: 'test_component', + display_name: 'Test Component', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z', + id: 1, + schema: { + title: { + type: 'text', + required: true, + }, + }, + color: null, + internal_tags_list: [], + internal_tag_ids: [], + }; + + // Create a space data with this component + const spaceData: SpaceData = { + components: [componentWithTextType], + groups: [], + presets: [], + internalTags: [], + }; + + // Generate types + const result = await generateTypes(spaceData, { strict: false }); + + // Verify that the result contains the expected property type + expect(result).toContain('title: string'); + }); + + it('should handle textarea property type', async () => { + // Create a component with textarea property type + const componentWithTextareaType: SpaceComponent = { + name: 'test_component', + display_name: 'Test Component', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z', + id: 1, + schema: { + description: { + type: 'textarea', + required: false, + }, + }, + color: null, + internal_tags_list: [], + internal_tag_ids: [], + }; + + // Create a space data with this component + const spaceData: SpaceData = { + components: [componentWithTextareaType], + groups: [], + presets: [], + internalTags: [], + }; + + // Generate types + const result = await generateTypes(spaceData, { strict: false }); + + // Verify that the result contains the expected property type + expect(result).toContain('description?: string'); + }); + + it('should handle number property type', async () => { + // Create a component with number property type + const componentWithNumberType: SpaceComponent = { + name: 'test_component', + display_name: 'Test Component', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z', + id: 1, + schema: { + count: { + type: 'number', + required: false, + }, + }, + color: null, + internal_tags_list: [], + internal_tag_ids: [], + }; + + // Create a space data with this component + const spaceData: SpaceData = { + components: [componentWithNumberType], + groups: [], + presets: [], + internalTags: [], + }; + + // Generate types + const result = await generateTypes(spaceData, { strict: false }); + + // Verify that the result contains the expected property type + expect(result).toContain('count?: string'); + }); + + it('should handle boolean property type', async () => { + // Create a component with boolean property type + const componentWithBooleanType: SpaceComponent = { + name: 'test_component', + display_name: 'Test Component', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z', + id: 1, + schema: { + isActive: { + type: 'boolean', + required: false, + }, + }, + color: null, + internal_tags_list: [], + internal_tag_ids: [], + }; + + // Create a space data with this component + const spaceData: SpaceData = { + components: [componentWithBooleanType], + groups: [], + presets: [], + internalTags: [], + }; + + // Generate types + const result = await generateTypes(spaceData, { strict: false }); + + // Verify that the result contains the expected property type + expect(result).toContain('isActive?: boolean'); + }); + + it('should handle multilink property type', async () => { + // Create a component with multilink property type + const componentWithMultilinkType: SpaceComponent = { + name: 'test_component', + display_name: 'Test Component', + 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: false, + asset_link_type: true, + }, + }, + color: null, + internal_tags_list: [], + internal_tag_ids: [], + }; + + // Create a space data with this component + const spaceData: SpaceData = { + components: [componentWithMultilinkType], + groups: [], + presets: [], + internalTags: [], + }; + + // Generate types + const result = await generateTypes(spaceData, { strict: false }); + + // Verify that the result contains the expected property type + expect(result).toContain('link?:'); + }); + + it('should handle bloks property type with component restrictions', async () => { + // Create a component with bloks property type and component restrictions + const componentWithBloksType: SpaceComponent = { + name: 'test_component', + display_name: 'Test Component', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z', + id: 1, + schema: { + content: { + type: 'bloks', + required: false, + restrict_components: true, + component_whitelist: ['button', 'image'], + }, + }, + color: null, + internal_tags_list: [], + internal_tag_ids: [], + }; + + // Create a space data with this component + const spaceData: SpaceData = { + components: [componentWithBloksType], + groups: [], + presets: [], + internalTags: [], + }; + + // Generate types + const result = await generateTypes(spaceData, { strict: false }); + + // Verify that the result contains the expected property type + expect(result).toContain('content?:'); + }); + + it('should handle tabbed properties correctly', async () => { + // Create a component with tabbed properties + const componentWithTabbedProperties: SpaceComponent = { + name: 'test_component', + display_name: 'Test Component', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z', + id: 1, + schema: { + 'tab-content': { + type: 'tab', + display_name: 'Content', + }, + 'title': { + type: 'text', + required: true, + }, + }, + color: null, + internal_tags_list: [], + internal_tag_ids: [], + }; + + // Create a space data with this component + const spaceData: SpaceData = { + components: [componentWithTabbedProperties], + groups: [], + presets: [], + internalTags: [], + }; + + // Generate types + const result = await generateTypes(spaceData, { strict: false }); + + // Verify that the result contains the expected property type but not the tab + expect(result).toContain('title: string'); + expect(result).not.toContain('tab-content'); + }); + + it('should handle custom property type with customFieldsParser', async () => { + // Create a component with custom property type + const componentWithCustomType: SpaceComponent = { + name: 'test_component', + display_name: 'Test Component', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z', + id: 1, + schema: { + colorPicker: { + type: 'custom', + field_type: 'native-color-picker', + required: false, + }, + }, + color: null, + internal_tags_list: [], + internal_tag_ids: [], + }; + + // Create a space data with this component + const spaceData: SpaceData = { + components: [componentWithCustomType], + groups: [], + presets: [], + internalTags: [], + }; + + // Create mock options with customFieldsParser + const mockOptions: GenerateTypesOptions = { + strict: false, + customFieldsParser: '/path/to/custom/parser.ts', + }; + + // Reset the mock to ensure it's called with the right parameters + mockCustomFieldsParser.mockClear(); + + // Generate types + const result = await generateTypes(spaceData, mockOptions); + + // Verify that the custom fields parser was called + expect(mockCustomFieldsParser).toHaveBeenCalled(); + + // Verify that the result contains the expected property type + expect(result).toContain('colorPicker?:'); + expect(result).toContain('color: string'); + }); +}); + +describe('generateStoryblokTypes', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should generate Storyblok types successfully with default options', async () => { + // Call the function with default options + const result = await generateStoryblokTypes(); + + // Verify that the function returns true + expect(result).toBe(true); + + // Verify that readFileSync was called with the correct path + expect(readFileSync).toHaveBeenCalledWith('/mocked/path', 'utf-8'); + + // Verify that saveToFile was called with the correct parameters + expect(saveToFile).toHaveBeenCalledWith('/mocked/joined/path', expect.stringContaining('// This file was generated by the storyblok CLI.')); + expect(saveToFile).toHaveBeenCalledWith('/mocked/joined/path', expect.stringContaining('export type StoryblokPropertyType')); + expect(saveToFile).toHaveBeenCalledWith('/mocked/joined/path', expect.stringContaining('export interface StoryblokText')); + }); + + it('should generate Storyblok types with custom filename', async () => { + // Call the function with custom filename + const result = await generateStoryblokTypes({ filename: 'custom-storyblok' }); + + // Verify that the function returns true + expect(result).toBe(true); + + // Verify that saveToFile was called with the correct filename + expect(saveToFile).toHaveBeenCalledWith('/mocked/joined/path', expect.any(String)); + expect(join).toHaveBeenCalledWith(expect.any(String), 'custom-storyblok.d.ts'); + }); + + it('should generate Storyblok types with custom path', async () => { + // Call the function with custom path + const result = await generateStoryblokTypes({ path: '/custom/path' }); + + // Verify that the function returns true + expect(result).toBe(true); + + // Verify that resolve was called with the correct path + expect(resolve).toHaveBeenCalledWith(expect.any(String), '/custom/path', 'types'); + }); + + it('should extract all Storyblok type definitions', async () => { + // Call the function + await generateStoryblokTypes(); + + // Get the content passed to saveToFile + const savedContent = vi.mocked(saveToFile).mock.calls[0][1]; + + // Verify that all expected type definitions are included + expect(savedContent).toContain('export type StoryblokPropertyType'); + expect(savedContent).toContain('export interface StoryblokText'); + expect(savedContent).toContain('export interface StoryblokTextarea'); + expect(savedContent).toContain('export interface StoryblokNumber'); + expect(savedContent).toContain('export interface StoryblokBoolean'); + expect(savedContent).toContain('export interface StoryblokMultilink'); + expect(savedContent).toContain('export interface StoryblokBloks'); + expect(savedContent).toContain('export interface StoryblokCustom'); + }); +}); From e26edbd5c21d40b559ce264bc9d60937db78c32e Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Mon, 14 Apr 2025 10:27:49 +0200 Subject: [PATCH 15/16] test: add unit tests for type generation command - Introduced a new test file `index.test.ts` to validate the functionality of the type generation command. - Implemented tests for various scenarios including successful type generation, strict mode, type prefix, suffix, separate files, custom fields parser, and compiler options. - Mocked necessary modules and created a controlled environment to ensure isolated and reliable tests. - Enhanced test coverage for different command options, improving the robustness of the type generation feature. --- src/commands/types/generate/index.test.ts | 354 ++++++++++++++++++++++ src/commands/types/generate/index.ts | 3 +- 2 files changed, 356 insertions(+), 1 deletion(-) create mode 100644 src/commands/types/generate/index.test.ts diff --git a/src/commands/types/generate/index.test.ts b/src/commands/types/generate/index.test.ts new file mode 100644 index 0000000..7de2ee2 --- /dev/null +++ b/src/commands/types/generate/index.test.ts @@ -0,0 +1,354 @@ +import { session } from '../../../session'; +import { CommandError, konsola } from '../../../utils'; +import { generateStoryblokTypes, generateTypes } from './actions'; +import chalk from 'chalk'; +import { colorPalette } from '../../../constants'; +// Import the main components module first to ensure proper initialization +import '../index'; +import { typesCommand } from '../command'; +import { readComponentsFiles } from '../../components/push/actions'; + +vi.mock('./actions', () => ({ + generateStoryblokTypes: vi.fn(), + generateTypes: vi.fn(), + getComponentType: vi.fn(), +})); + +vi.mock('../../components/push/actions', () => ({ + readComponentsFiles: vi.fn(), +})); + +// Mocking the session module +vi.mock('../../../session', () => { + let _cache: Record | null = null; + const session = () => { + if (!_cache) { + _cache = { + state: { + isLoggedIn: false, + }, + updateSession: vi.fn(), + persistCredentials: vi.fn(), + initializeSession: vi.fn(), + }; + } + return _cache; + }; + + return { + session, + }; +}); + +vi.mock('../../../utils', async () => { + const actualUtils = await vi.importActual('../../../utils'); + return { + ...actualUtils, + isVitestRunning: true, + konsola: { + ok: vi.fn(), + title: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + br: vi.fn(), + }, + handleError: (error: unknown, header = false) => { + konsola.error(error as string, header); + // Optionally, prevent process.exit during tests + }, + }; +}); + +describe('types generate', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.clearAllMocks(); + // Fix the linter errors by using a type assertion + (typesCommand as any)._optionValues = {}; + (typesCommand as any)._optionValueSources = {}; + for (const command of typesCommand.commands) { + (command as any)._optionValues = {}; + (command as any)._optionValueSources = {}; + } + }); + + describe('default mode', () => { + it('should prompt the user if the operation was sucessfull', async () => { + const mockResponse = [{ + name: 'component-name', + display_name: 'Component Name', + created_at: '2021-08-09T12:00:00Z', + updated_at: '2021-08-09T12:00:00Z', + id: 12345, + schema: { type: 'object' }, + color: null, + internal_tags_list: [], + internal_tag_ids: [], + }]; + + const mockSpaceData = { + components: mockResponse, + groups: [], + presets: [], + internalTags: [], + }; + + session().state = { + isLoggedIn: true, + password: 'valid-token', + region: 'eu', + }; + + vi.mocked(readComponentsFiles).mockResolvedValue(mockSpaceData); + + vi.mocked(generateStoryblokTypes).mockResolvedValue(true); + + await typesCommand.parseAsync(['node', 'test', 'generate', '--space', '12345']); + + expect(generateStoryblokTypes).toHaveBeenCalledWith({ + filename: undefined, + path: undefined, + }); + + expect(generateTypes).toHaveBeenCalledWith(mockSpaceData, { + + }); + + expect(konsola.ok).toHaveBeenCalledWith(`Successfully generated types for space ${chalk.hex(colorPalette.PRIMARY)('12345')}`); + }); + + it('should pass strict mode option to generateTypes when --strict flag is used', async () => { + const mockResponse = [{ + name: 'component-name', + display_name: 'Component Name', + created_at: '2021-08-09T12:00:00Z', + updated_at: '2021-08-09T12:00:00Z', + id: 12345, + schema: { type: 'object' }, + color: null, + internal_tags_list: [], + internal_tag_ids: [], + }]; + + const mockSpaceData = { + components: mockResponse, + groups: [], + presets: [], + internalTags: [], + }; + + session().state = { + isLoggedIn: true, + password: 'valid-token', + region: 'eu', + }; + + vi.mocked(readComponentsFiles).mockResolvedValue(mockSpaceData); + vi.mocked(generateStoryblokTypes).mockResolvedValue(true); + vi.mocked(generateTypes).mockResolvedValue('// Generated types'); + + // Run the command with the --strict flag + await typesCommand.parseAsync(['node', 'test', 'generate', '--space', '12345', '--strict']); + + // Verify that generateTypes was called with the strict option set to true + expect(generateTypes).toHaveBeenCalledWith(mockSpaceData, { + strict: true, + }); + }); + + it('should pass typePrefix option to generateTypes when --type-prefix flag is used', async () => { + const mockResponse = [{ + name: 'component-name', + display_name: 'Component Name', + created_at: '2021-08-09T12:00:00Z', + updated_at: '2021-08-09T12:00:00Z', + id: 12345, + schema: { type: 'object' }, + color: null, + internal_tags_list: [], + internal_tag_ids: [], + }]; + + const mockSpaceData = { + components: mockResponse, + groups: [], + presets: [], + internalTags: [], + }; + + session().state = { + isLoggedIn: true, + password: 'valid-token', + region: 'eu', + }; + + vi.mocked(readComponentsFiles).mockResolvedValue(mockSpaceData); + vi.mocked(generateStoryblokTypes).mockResolvedValue(true); + vi.mocked(generateTypes).mockResolvedValue('// Generated types'); + + // Run the command with the --type-prefix flag + await typesCommand.parseAsync(['node', 'test', 'generate', '--space', '12345', '--type-prefix', 'Custom']); + + // Verify that generateTypes was called with the typePrefix option set to 'Custom' + expect(generateTypes).toHaveBeenCalledWith(mockSpaceData, { + typePrefix: 'Custom', + }); + }); + + it('should pass suffix option to generateTypes when --suffix flag is used', async () => { + const mockResponse = [{ + name: 'component-name', + display_name: 'Component Name', + created_at: '2021-08-09T12:00:00Z', + updated_at: '2021-08-09T12:00:00Z', + id: 12345, + schema: { type: 'object' }, + color: null, + internal_tags_list: [], + internal_tag_ids: [], + }]; + + const mockSpaceData = { + components: mockResponse, + groups: [], + presets: [], + internalTags: [], + }; + + session().state = { + isLoggedIn: true, + password: 'valid-token', + region: 'eu', + }; + + vi.mocked(readComponentsFiles).mockResolvedValue(mockSpaceData); + vi.mocked(generateStoryblokTypes).mockResolvedValue(true); + vi.mocked(generateTypes).mockResolvedValue('// Generated types'); + + // Run the command with the --suffix flag + await typesCommand.parseAsync(['node', 'test', 'generate', '--space', '12345', '--suffix', 'Component']); + + // Verify that generateTypes was called with the suffix option set to 'Component' + expect(generateTypes).toHaveBeenCalledWith(mockSpaceData, { + suffix: 'Component', + }); + }); + + it('should pass separateFiles option to generateTypes when --separate-files flag is used', async () => { + const mockResponse = [{ + name: 'component-name', + display_name: 'Component Name', + created_at: '2021-08-09T12:00:00Z', + updated_at: '2021-08-09T12:00:00Z', + id: 12345, + schema: { type: 'object' }, + color: null, + internal_tags_list: [], + internal_tag_ids: [], + }]; + + const mockSpaceData = { + components: mockResponse, + groups: [], + presets: [], + internalTags: [], + }; + + session().state = { + isLoggedIn: true, + password: 'valid-token', + region: 'eu', + }; + + vi.mocked(readComponentsFiles).mockResolvedValue(mockSpaceData); + vi.mocked(generateStoryblokTypes).mockResolvedValue(true); + vi.mocked(generateTypes).mockResolvedValue('// Generated types'); + + // Run the command with the --separate-files flag + await typesCommand.parseAsync(['node', 'test', 'generate', '--space', '12345', '--separate-files']); + + // Verify that generateTypes was called with the separateFiles option set to true + expect(generateTypes).toHaveBeenCalledWith(mockSpaceData, { + separateFiles: true, + }); + }); + + it('should pass customFieldsParser option to generateTypes when --custom-fields-parser flag is used', async () => { + const mockResponse = [{ + name: 'component-name', + display_name: 'Component Name', + created_at: '2021-08-09T12:00:00Z', + updated_at: '2021-08-09T12:00:00Z', + id: 12345, + schema: { type: 'object' }, + color: null, + internal_tags_list: [], + internal_tag_ids: [], + }]; + + const mockSpaceData = { + components: mockResponse, + groups: [], + presets: [], + internalTags: [], + }; + + session().state = { + isLoggedIn: true, + password: 'valid-token', + region: 'eu', + }; + + vi.mocked(readComponentsFiles).mockResolvedValue(mockSpaceData); + vi.mocked(generateStoryblokTypes).mockResolvedValue(true); + vi.mocked(generateTypes).mockResolvedValue('// Generated types'); + + // Run the command with the --custom-fields-parser flag + await typesCommand.parseAsync(['node', 'test', 'generate', '--space', '12345', '--custom-fields-parser', '/path/to/parser.ts']); + + // Verify that generateTypes was called with the customFieldsParser option set to '/path/to/parser.ts' + expect(generateTypes).toHaveBeenCalledWith(mockSpaceData, { + customFieldsParser: '/path/to/parser.ts', + }); + }); + + it('should pass compilerOptions option to generateTypes when --compiler-options flag is used', async () => { + const mockResponse = [{ + name: 'component-name', + display_name: 'Component Name', + created_at: '2021-08-09T12:00:00Z', + updated_at: '2021-08-09T12:00:00Z', + id: 12345, + schema: { type: 'object' }, + color: null, + internal_tags_list: [], + internal_tag_ids: [], + }]; + + const mockSpaceData = { + components: mockResponse, + groups: [], + presets: [], + internalTags: [], + }; + + session().state = { + isLoggedIn: true, + password: 'valid-token', + region: 'eu', + }; + + vi.mocked(readComponentsFiles).mockResolvedValue(mockSpaceData); + vi.mocked(generateStoryblokTypes).mockResolvedValue(true); + vi.mocked(generateTypes).mockResolvedValue('// Generated types'); + + // Run the command with the --compiler-options flag + await typesCommand.parseAsync(['node', 'test', 'generate', '--space', '12345', '--compiler-options', '/path/to/options.json']); + + // Verify that generateTypes was called with the compilerOptions option set to '/path/to/options.json' + expect(generateTypes).toHaveBeenCalledWith(mockSpaceData, { + compilerOptions: '/path/to/options.json', + }); + }); + }); +}); diff --git a/src/commands/types/generate/index.ts b/src/commands/types/generate/index.ts index 0b66b9c..faa5ff2 100644 --- a/src/commands/types/generate/index.ts +++ b/src/commands/types/generate/index.ts @@ -63,7 +63,8 @@ typesCommand await saveTypesToFile(space, typedefString, options); } - spinner.succeed(`Successfully generated types for space ${space}`); + spinner.succeed(); + konsola.ok(`Successfully generated types for space ${space}`); } catch (error) { spinner.failed(`Failed to generate types for space ${space}`); From d0aab03e0cc0da4bd307f17f78a8f7c93abde9ed Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Mon, 14 Apr 2025 10:31:09 +0200 Subject: [PATCH 16/16] fix: remove unused import in type generation test - Removed the unused `CommandError` import from `index.test.ts` to clean up the code and improve readability. - This change helps maintain a tidy codebase by eliminating unnecessary dependencies. --- src/commands/types/generate/index.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/types/generate/index.test.ts b/src/commands/types/generate/index.test.ts index 7de2ee2..982ca92 100644 --- a/src/commands/types/generate/index.test.ts +++ b/src/commands/types/generate/index.test.ts @@ -1,5 +1,5 @@ import { session } from '../../../session'; -import { CommandError, konsola } from '../../../utils'; +import { konsola } from '../../../utils'; import { generateStoryblokTypes, generateTypes } from './actions'; import chalk from 'chalk'; import { colorPalette } from '../../../constants';