From 0d20d15a5723fa2a14bd8dced936a7c2cacca75a Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Mon, 26 Jan 2026 18:38:33 -0300 Subject: [PATCH 1/5] wip --- package.json | 3 +- .../javascript/utils/getImportLocalName.ts | 8 +++++ src/application/project/sdk/javasScriptSdk.ts | 31 +++++++++++++++- .../project/sdk/storyblookPlugin.ts | 36 +++++++++++++++++++ .../utils/getImportLocalName.test.ts | 36 +++++++++++++++++++ 5 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 src/application/project/sdk/storyblookPlugin.ts diff --git a/package.json b/package.json index ccccc523..ce2f47f6 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "test": "jest -c jest.config.js --coverage", "validate": "tsc --noEmit", "build": "tsup", - "graphql-codegen": "graphql-codegen --config codegen.ts" + "graphql-codegen": "graphql-codegen --config codegen.ts", + "postinstall": "graphql-codegen" }, "dependencies": { "@babel/core": "^7.28.5", diff --git a/src/application/project/code/transformation/javascript/utils/getImportLocalName.ts b/src/application/project/code/transformation/javascript/utils/getImportLocalName.ts index f28f36d7..efe50180 100644 --- a/src/application/project/code/transformation/javascript/utils/getImportLocalName.ts +++ b/src/application/project/code/transformation/javascript/utils/getImportLocalName.ts @@ -33,6 +33,14 @@ export function getImportLocalName(source: string | t.File, matcher: ImportMatch continue; } + if (t.isImportNamespaceSpecifier(specifier)) { + if (matches('*', matcher.importName)) { + localName = specifier.local.name; + } + + continue; + } + if (t.isImportSpecifier(specifier) && matches(specifier.imported, matcher.importName)) { localName = specifier.local.name; diff --git a/src/application/project/sdk/javasScriptSdk.ts b/src/application/project/sdk/javasScriptSdk.ts index 404f2712..449f037b 100644 --- a/src/application/project/sdk/javasScriptSdk.ts +++ b/src/application/project/sdk/javasScriptSdk.ts @@ -36,6 +36,7 @@ export type Configuration = { formatter: CodeFormatter, fileSystem: FileSystem, tsConfigLoader: TsConfigLoader, + hooks?: JavaScriptSdkPlugin[], }; type VersionedContent = { @@ -49,6 +50,10 @@ type ContentOptions = BaseContentOptions & { notifier?: TaskNotifier, }; +export type JavaScriptSdkPlugin = { + getInstallationPlan(installation: Installation): Promise>, +}; + export abstract class JavaScriptSdk implements Sdk { protected static readonly CONTENT_PACKAGE = '@croct/content'; @@ -64,6 +69,8 @@ export abstract class JavaScriptSdk implements Sdk { private readonly importConfigLoader: TsConfigLoader; + private readonly plugins: JavaScriptSdkPlugin[]; + protected constructor(configuration: Configuration) { this.projectDirectory = configuration.projectDirectory; this.packageManager = configuration.packageManager; @@ -71,6 +78,7 @@ export abstract class JavaScriptSdk implements Sdk { this.formatter = configuration.formatter; this.fileSystem = configuration.fileSystem; this.importConfigLoader = configuration.tsConfigLoader; + this.plugins = configuration.hooks ?? []; } public async generateSlotExample(slot: Slot, installation: Installation): Promise { @@ -99,7 +107,7 @@ export abstract class JavaScriptSdk implements Sdk { public async setup(installation: Installation): Promise { const {input, output} = installation; - const plan = await this.getInstallationPlan(installation); + const plan = await this.resolveInstallationPlan(installation); const configuration: ProjectConfiguration = { ...plan.configuration, @@ -279,6 +287,27 @@ export abstract class JavaScriptSdk implements Sdk { return defaultPath; } + private resolveInstallationPlan(installation: Installation): Promise { + let promise = this.getInstallationPlan(installation); + + for (const plugin of this.plugins) { + promise = promise.then(async plan => { + const hookPlan = await plugin(installation); + + return { + tasks: [...plan.tasks, ...(hookPlan.tasks ?? [])], + dependencies: [...plan.dependencies, ...(hookPlan.dependencies ?? [])], + configuration: { + ...plan.configuration, + ...hookPlan.configuration, + }, + }; + }); + } + + return promise; + } + protected abstract getInstallationPlan(installation: Installation): Promise; public async update(installation: Installation, options: UpdateOptions = {}): Promise { diff --git a/src/application/project/sdk/storyblookPlugin.ts b/src/application/project/sdk/storyblookPlugin.ts new file mode 100644 index 00000000..3c6f9edb --- /dev/null +++ b/src/application/project/sdk/storyblookPlugin.ts @@ -0,0 +1,36 @@ +import {InstallationPlan, JavaScriptSdkPlugin} from '@/application/project/sdk/javasScriptSdk'; +import {Installation} from '@/application/project/sdk/sdk'; +import {PackageManager} from '@/application/project/packageManager/packageManager'; +import {Task} from '@/application/cli/io/output'; +import {HelpfulError} from '@/application/error'; + +export class StoryblookPlugin implements JavaScriptSdkPlugin { + private packageManager: PackageManager; + + public async getInstallationPlan(_: Installation): Promise> { + if (!await this.packageManager.hasDependency('@storyblok/js')) { + return {}; + } + + const tasks: Task[] = []; + + tasks.push({ + title: 'Configuring Storyblok integration', + task: async notifier => { + notifier.update('Configuring middleware'); + + try { + await this.updateCode(this.codemod.middleware, installation.project.middleware.file); + + notifier.confirm('Storyblok configured'); + } catch (error) { + notifier.alert('Failed to configure Storyblok', HelpfulError.formatMessage(error)); + } + }, + }); + + return { + dependencies: ['@croct/plug-storyblok'], + }; + } +} diff --git a/test/application/project/code/transformation/javascript/utils/getImportLocalName.test.ts b/test/application/project/code/transformation/javascript/utils/getImportLocalName.test.ts index 2b82454c..e90f010f 100644 --- a/test/application/project/code/transformation/javascript/utils/getImportLocalName.test.ts +++ b/test/application/project/code/transformation/javascript/utils/getImportLocalName.test.ts @@ -93,6 +93,42 @@ describe('getImportLocalName', () => { }, expected: 'alias', }, + { + description: 'return the local name of the namespace import', + code: 'import * as sdk from \'croct\';', + matcher: { + moduleName: 'croct', + importName: '*', + }, + expected: 'sdk', + }, + { + description: 'return null if namespace import does not match the module name', + code: 'import * as sdk from \'croct\';', + matcher: { + moduleName: 'something', + importName: '*', + }, + expected: null, + }, + { + description: 'return null if looking for namespace but import is named', + code: 'import {sdk} from \'croct\';', + matcher: { + moduleName: 'croct', + importName: '*', + }, + expected: null, + }, + { + description: 'return the local name of the namespace import when module matches regex', + code: 'import * as croct from \'@croct/sdk\';', + matcher: { + moduleName: /@croct/, + importName: '*', + }, + expected: 'croct', + }, ])('should $description', ({code, matcher, expected}) => { expect(getImportLocalName(code, matcher)).toBe(expected); }); From 10b016b91bc6269bba624baf046b150661afccb1 Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Tue, 27 Jan 2026 12:00:31 -0300 Subject: [PATCH 2/5] Add support for Storyblok SDK --- .../javascript/storyblokInitCodemod.ts | 97 ++++++++ src/application/project/sdk/javasScriptSdk.ts | 22 +- .../project/sdk/storyblookPlugin.ts | 67 +++++- src/infrastructure/application/cli/cli.ts | 33 ++- .../javascript/storyblokInitCodemod.test.ts | 221 ++++++++++++++++++ 5 files changed, 428 insertions(+), 12 deletions(-) create mode 100644 src/application/project/code/transformation/javascript/storyblokInitCodemod.ts create mode 100644 test/application/project/code/transformation/javascript/storyblokInitCodemod.test.ts diff --git a/src/application/project/code/transformation/javascript/storyblokInitCodemod.ts b/src/application/project/code/transformation/javascript/storyblokInitCodemod.ts new file mode 100644 index 00000000..251f0608 --- /dev/null +++ b/src/application/project/code/transformation/javascript/storyblokInitCodemod.ts @@ -0,0 +1,97 @@ +/* eslint-disable no-param-reassign -- False positives */ +import * as t from '@babel/types'; +import {traverse} from '@babel/core'; +import {Codemod, CodemodOptions, ResultCode} from '@/application/project/code/transformation/codemod'; +import {addImport} from '@/application/project/code/transformation/javascript/utils/addImport'; +import {getImportLocalName} from '@/application/project/code/transformation/javascript/utils/getImportLocalName'; + +export type StoryblokInitCodemodOptions = CodemodOptions & { + name: string, + module: string, +}; + +export class StoryblokInitCodemod implements Codemod { + public apply(input: t.File, options?: StoryblokInitCodemodOptions): Promise> { + if (options === undefined) { + return Promise.resolve({ + modified: false, + result: input, + }); + } + + const localName = getImportLocalName(input, { + moduleName: /@storyblok\/(js|react)/, + importName: /storyblokInit|\*/, + }); + + if (localName === null) { + return Promise.resolve({ + modified: false, + result: input, + }); + } + + const importLocalName = options.module !== undefined + ? getImportLocalName(input, { + importName: options.name, + moduleName: options.module, + }) + : null; + + const wrapperName = importLocalName ?? options.name; + let modified = false; + + traverse(input, { + CallExpression: path => { + const {callee} = path.node; + + // Match direct function calls: targetFunction(...) + if (t.isIdentifier(callee) && callee.name === 'storyblokInit') { + path.node.arguments = [ + t.callExpression( + t.identifier(wrapperName), + path.node.arguments, + ), + ]; + + modified = true; + } + + // Match member expression calls: obj.targetFunction(...) + if ( + t.isMemberExpression(callee) + && t.isIdentifier(callee.property) + && callee.property.name === 'storyblokInit' + ) { + path.node.arguments = [ + t.callExpression( + t.identifier(wrapperName), + path.node.arguments, + ), + ]; + + modified = true; + } + }, + }); + + if (modified && importLocalName === null) { + const {body} = input.program; + + if (!t.isImportDeclaration(body[0])) { + body.unshift(t.emptyStatement()); + } + + addImport(input, { + type: 'value', + moduleName: options.module, + importName: options.name, + }); + } + + return Promise.resolve({ + modified: modified, + result: input, + }); + } +} diff --git a/src/application/project/sdk/javasScriptSdk.ts b/src/application/project/sdk/javasScriptSdk.ts index 449f037b..7727f646 100644 --- a/src/application/project/sdk/javasScriptSdk.ts +++ b/src/application/project/sdk/javasScriptSdk.ts @@ -36,7 +36,7 @@ export type Configuration = { formatter: CodeFormatter, fileSystem: FileSystem, tsConfigLoader: TsConfigLoader, - hooks?: JavaScriptSdkPlugin[], + plugins?: JavaScriptSdkPlugin[], }; type VersionedContent = { @@ -50,8 +50,17 @@ type ContentOptions = BaseContentOptions & { notifier?: TaskNotifier, }; +export type JavaScriptPluginContext = { + packageManager: PackageManager, + projectDirectory: WorkingDirectory, + fileSystem: FileSystem, +}; + export type JavaScriptSdkPlugin = { - getInstallationPlan(installation: Installation): Promise>, + getInstallationPlan( + installation: Installation, + context: JavaScriptPluginContext + ): Promise>, }; export abstract class JavaScriptSdk implements Sdk { @@ -78,7 +87,7 @@ export abstract class JavaScriptSdk implements Sdk { this.formatter = configuration.formatter; this.fileSystem = configuration.fileSystem; this.importConfigLoader = configuration.tsConfigLoader; - this.plugins = configuration.hooks ?? []; + this.plugins = configuration.plugins ?? []; } public async generateSlotExample(slot: Slot, installation: Installation): Promise { @@ -289,10 +298,15 @@ export abstract class JavaScriptSdk implements Sdk { private resolveInstallationPlan(installation: Installation): Promise { let promise = this.getInstallationPlan(installation); + const context: JavaScriptPluginContext = { + packageManager: this.packageManager, + projectDirectory: this.projectDirectory, + fileSystem: this.fileSystem, + }; for (const plugin of this.plugins) { promise = promise.then(async plan => { - const hookPlan = await plugin(installation); + const hookPlan = await plugin.getInstallationPlan(installation, context); return { tasks: [...plan.tasks, ...(hookPlan.tasks ?? [])], diff --git a/src/application/project/sdk/storyblookPlugin.ts b/src/application/project/sdk/storyblookPlugin.ts index 3c6f9edb..2afa9ee1 100644 --- a/src/application/project/sdk/storyblookPlugin.ts +++ b/src/application/project/sdk/storyblookPlugin.ts @@ -1,14 +1,22 @@ -import {InstallationPlan, JavaScriptSdkPlugin} from '@/application/project/sdk/javasScriptSdk'; -import {Installation} from '@/application/project/sdk/sdk'; -import {PackageManager} from '@/application/project/packageManager/packageManager'; +import {extname} from 'path'; +import {InstallationPlan, JavaScriptSdkPlugin, JavaScriptPluginContext} from '@/application/project/sdk/javasScriptSdk'; import {Task} from '@/application/cli/io/output'; import {HelpfulError} from '@/application/error'; +import {Codemod} from '@/application/project/code/transformation/codemod'; +import {Installation} from '@/application/project/sdk/sdk'; export class StoryblookPlugin implements JavaScriptSdkPlugin { - private packageManager: PackageManager; + private readonly codemod: Codemod; + + public constructor(codemod: Codemod) { + this.codemod = codemod; + } - public async getInstallationPlan(_: Installation): Promise> { - if (!await this.packageManager.hasDependency('@storyblok/js')) { + public async getInstallationPlan( + _: Installation, + context: JavaScriptPluginContext, + ): Promise> { + if (!await context.packageManager.hasDependency('@storyblok/js')) { return {}; } @@ -20,7 +28,7 @@ export class StoryblookPlugin implements JavaScriptSdkPlugin { notifier.update('Configuring middleware'); try { - await this.updateCode(this.codemod.middleware, installation.project.middleware.file); + await this.configureStoryblok(context); notifier.confirm('Storyblok configured'); } catch (error) { @@ -30,7 +38,52 @@ export class StoryblookPlugin implements JavaScriptSdkPlugin { }); return { + tasks: tasks, dependencies: ['@croct/plug-storyblok'], }; } + + private async configureStoryblok(scope: JavaScriptPluginContext): Promise { + const initializationFile = await this.findStoryblokInitializationFile(scope); + + if (initializationFile === null) { + throw new HelpfulError('Could not find any file containing Storyblok initialization.'); + } + + const result = await this.codemod.apply(initializationFile); + + if (!result.modified) { + throw new HelpfulError('Unable to automatically configure Storyblok integration.'); + } + } + + private async findStoryblokInitializationFile(scope: JavaScriptPluginContext): Promise { + const {fileSystem, projectDirectory} = scope; + + const directory = projectDirectory.get(); + const iterator = fileSystem.list(directory, (path, depth) => { + if (depth > 20) { + return false; + } + + const extension = extname(path).toLowerCase(); + + return extension === '.js' || extension === '.ts' || extension === '.jsx' || extension === '.tsx'; + }); + + for await (const entry of iterator) { + if (entry.type !== 'file') { + continue; + } + + const path = fileSystem.joinPaths(directory, entry.name); + const content = await fileSystem.readTextFile(path); + + if (content.includes('storyblokInit')) { + return path; + } + } + + return null; + } } diff --git a/src/infrastructure/application/cli/cli.ts b/src/infrastructure/application/cli/cli.ts index 33618783..4922582b 100644 --- a/src/infrastructure/application/cli/cli.ts +++ b/src/infrastructure/application/cli/cli.ts @@ -7,6 +7,7 @@ import XDGAppPaths from 'xdg-app-paths'; import ci from 'ci-info'; import {FilteredLogger, Logger, LogLevel} from '@croct/logging'; import {Token} from '@croct/sdk/token'; +import {File} from '@babel/types'; import {ConsoleInput} from '@/infrastructure/application/cli/io/consoleInput'; import {ConsoleOutput, LinkOpener} from '@/infrastructure/application/cli/io/consoleOutput'; import {Sdk} from '@/application/project/sdk/sdk'; @@ -312,7 +313,7 @@ import {WriteFileOptionsValidator} from '@/infrastructure/application/validation import {AutoUpdater} from '@/application/cli/autoUpdater'; import {DeletePathAction} from '@/application/template/action/deletePathAction'; import {DeletePathOptionsValidator} from '@/infrastructure/application/validation/actions/deletePathOptionsValidator'; -import {Codemod} from '@/application/project/code/transformation/codemod'; +import {Codemod, ResultCode} from '@/application/project/code/transformation/codemod'; import {ResolveImportAction} from '@/application/template/action/resolveImportAction'; import { ResolveImportOptionsValidator, @@ -321,6 +322,8 @@ import {CreateApiKeyAction} from '@/application/template/action/createApiKeyActi import { CreateApiKeyOptionsValidator, } from '@/infrastructure/application/validation/actions/createApiKeyOptionsValidator'; +import {StoryblokInitCodemod} from '@/application/project/code/transformation/javascript/storyblokInitCodemod'; +import {StoryblookPlugin} from '@/application/project/sdk/storyblookPlugin'; export type Configuration = { program: Program, @@ -1727,10 +1730,12 @@ export class Cli { mapping: { [Platform.JAVASCRIPT]: (): Sdk => new PlugJsSdk({ ...config, + plugins: [new StoryblookPlugin(this.createStoryblokCodemod(Platform.JAVASCRIPT))], bundlers: ['vite', 'parcel', 'tsup', 'rollup'], }), [Platform.REACT]: (): Sdk => new PlugReactSdk({ ...config, + plugins: [new StoryblookPlugin(this.createStoryblokCodemod(Platform.REACT))], importResolver: importResolver, codemod: { provider: new FormatCodemod( @@ -1790,6 +1795,7 @@ export class Cli { return new PlugNextSdk({ ...config, + plugins: [new StoryblookPlugin(this.createStoryblokCodemod(Platform.NEXTJS))], userApi: this.getUserApi(), applicationApi: this.getApplicationApi(), importResolver: importResolver, @@ -1923,6 +1929,31 @@ export class Cli { }); } + private createStoryblokCodemod(platform: Platform.JAVASCRIPT | Platform.REACT | Platform.NEXTJS): Codemod { + const codemod = new StoryblokInitCodemod(); + const modules = { + [Platform.JAVASCRIPT]: 'js', + [Platform.REACT]: 'react', + [Platform.NEXTJS]: 'next', + }; + + return new FormatCodemod( + this.getJavaScriptFormatter(), + new FileCodemod({ + fileSystem: this.getFileSystem(), + codemod: new JavaScriptCodemod({ + languages: ['typescript', 'jsx'], + codemod: { + apply: (input: File): Promise> => codemod.apply(input, { + name: 'withCroct', + module: `@croct/plug-storyblok/${modules[platform]}`, + }), + }, + }), + }), + ); + } + private share any)>(method: M, factory: () => ReturnType): ReturnType { const instance = this.instances.get(method); diff --git a/test/application/project/code/transformation/javascript/storyblokInitCodemod.test.ts b/test/application/project/code/transformation/javascript/storyblokInitCodemod.test.ts new file mode 100644 index 00000000..7ebd38aa --- /dev/null +++ b/test/application/project/code/transformation/javascript/storyblokInitCodemod.test.ts @@ -0,0 +1,221 @@ +import { + StoryblokInitCodemod, + StoryblokInitCodemodOptions, +} from '@/application/project/code/transformation/javascript/storyblokInitCodemod'; +import {JavaScriptCodemod} from '@/application/project/code/transformation/javascript/javaScriptCodemod'; + +describe('StoryblokInitCodemod', () => { + function createTransformer(): JavaScriptCodemod { + return new JavaScriptCodemod({ + languages: ['typescript', 'jsx'], + codemod: new StoryblokInitCodemod(), + }); + } + + it('should wrap storyblokInit arguments when imported from @storyblok/js', async () => { + const transformer = createTransformer(); + + const input = [ + "import { storyblokInit } from '@storyblok/js';", + '', + 'storyblokInit({ accessToken: "token" });', + ].join('\n'); + + const {result, modified} = await transformer.apply(input, { + name: 'withCroct', + module: '@croct/storyblok', + }); + + expect(modified).toBe(true); + expect(result).toEqual([ + 'import { withCroct } from "@croct/storyblok";', + "import { storyblokInit } from '@storyblok/js';", + 'storyblokInit(withCroct({ accessToken: "token" }));', + ].join('\n')); + }); + + it('should wrap storyblokInit arguments when imported from @storyblok/react', async () => { + const transformer = createTransformer(); + + const input = [ + "import { storyblokInit } from '@storyblok/react';", + '', + 'storyblokInit({ accessToken: "token" });', + ].join('\n'); + + const {result, modified} = await transformer.apply(input, { + name: 'withCroct', + module: '@croct/storyblok', + }); + + expect(modified).toBe(true); + expect(result).toEqual([ + 'import { withCroct } from "@croct/storyblok";', + "import { storyblokInit } from '@storyblok/react';", + 'storyblokInit(withCroct({ accessToken: "token" }));', + ].join('\n')); + }); + + it('should wrap member expression calls', async () => { + const transformer = createTransformer(); + + const input = [ + "import * as sb from '@storyblok/js';", + '', + 'sb.storyblokInit({ accessToken: "token" });', + ].join('\n'); + + const {result, modified} = await transformer.apply(input, { + name: 'withCroct', + module: '@croct/storyblok', + }); + + expect(modified).toBe(true); + expect(result).toEqual([ + 'import { withCroct } from "@croct/storyblok";', + "import * as sb from '@storyblok/js';", + 'sb.storyblokInit(withCroct({ accessToken: "token" }));', + ].join('\n')); + }); + + it('should use existing import alias for wrapper function', async () => { + const transformer = createTransformer(); + + const input = [ + "import { withCroct as croctWrapper } from '@croct/storyblok';", + "import { storyblokInit } from '@storyblok/js';", + '', + 'storyblokInit({ accessToken: "token" });', + ].join('\n'); + + const {result, modified} = await transformer.apply(input, { + name: 'withCroct', + module: '@croct/storyblok', + }); + + expect(modified).toBe(true); + expect(result).toEqual([ + "import { withCroct as croctWrapper } from '@croct/storyblok';", + "import { storyblokInit } from '@storyblok/js';", + '', + 'storyblokInit(croctWrapper({ accessToken: "token" }));', + ].join('\n')); + }); + + it('should not modify code when storyblokInit import is not found', async () => { + const transformer = createTransformer(); + + const input = [ + "import { someOtherFunction } from '@storyblok/js';", + '', + 'someOtherFunction({ accessToken: "token" });', + ].join('\n'); + + const {result, modified} = await transformer.apply(input, { + name: 'withCroct', + module: '@croct/storyblok', + }); + + expect(modified).toBe(false); + expect(result).toBe(input); + }); + + it('should not modify code when no storyblok import exists', async () => { + const transformer = createTransformer(); + + const input = [ + "import { something } from 'other-module';", + '', + 'storyblokInit({ accessToken: "token" });', + ].join('\n'); + + const {result, modified} = await transformer.apply(input, { + name: 'withCroct', + module: '@croct/storyblok', + }); + + expect(modified).toBe(false); + expect(result).toBe(input); + }); + + it('should return unmodified when options are not provided', async () => { + const transformer = createTransformer(); + + const input = [ + "import { storyblokInit } from '@storyblok/js';", + '', + 'storyblokInit({ accessToken: "token" });', + ].join('\n'); + + const {result, modified} = await transformer.apply(input); + + expect(modified).toBe(false); + expect(result).toBe(input); + }); + + it('should wrap multiple storyblokInit calls', async () => { + const transformer = createTransformer(); + + const input = [ + "import { storyblokInit } from '@storyblok/js';", + '', + 'storyblokInit({ accessToken: "token1" });', + 'storyblokInit({ accessToken: "token2" });', + ].join('\n'); + + const {result, modified} = await transformer.apply(input, { + name: 'withCroct', + module: '@croct/storyblok', + }); + + expect(modified).toBe(true); + expect(result).toEqual([ + 'import { withCroct } from "@croct/storyblok";', + "import { storyblokInit } from '@storyblok/js';", + 'storyblokInit(withCroct({ accessToken: "token1" }));', + 'storyblokInit(withCroct({ accessToken: "token2" }));', + ].join('\n')); + }); + + it('should handle storyblokInit with multiple arguments', async () => { + const transformer = createTransformer(); + + const input = [ + "import { storyblokInit } from '@storyblok/js';", + '', + 'storyblokInit(config, options);', + ].join('\n'); + + const {result, modified} = await transformer.apply(input, { + name: 'withCroct', + module: '@croct/storyblok', + }); + + expect(modified).toBe(true); + expect(result).toEqual([ + 'import { withCroct } from "@croct/storyblok";', + "import { storyblokInit } from '@storyblok/js';", + 'storyblokInit(withCroct(config, options));', + ].join('\n')); + }); + + it('should add empty statement before import when first statement is not an import', async () => { + const transformer = createTransformer(); + + const input = [ + 'const x = 1;', + "import { storyblokInit } from '@storyblok/js';", + '', + 'storyblokInit({ accessToken: "token" });', + ].join('\n'); + + const {result, modified} = await transformer.apply(input, { + name: 'withCroct', + module: '@croct/storyblok', + }); + + expect(modified).toBe(true); + expect(result).toContain('import { withCroct } from "@croct/storyblok";'); + expect(result).toContain('storyblokInit(withCroct({ accessToken: "token" }));'); + }); +}); From aed38e0db90aac3690a6e4e171867eeec3406773 Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Tue, 27 Jan 2026 12:02:48 -0300 Subject: [PATCH 3/5] Fix vulnerabilities --- package-lock.json | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index 07726eed..790b93b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "croct", "version": "0.0.0-dev", + "hasInstallScript": true, "license": "MIT", "dependencies": { "@babel/core": "^7.28.5", @@ -2531,10 +2532,11 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, + "license": "MIT", "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" @@ -10474,10 +10476,11 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, + "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, @@ -10778,10 +10781,11 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "dev": true, + "license": "MIT" }, "node_modules/lodash.merge": { "version": "4.6.2", @@ -13286,10 +13290,11 @@ } }, "node_modules/sucrase/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "dev": true, + "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", From b17a7075bef2034d3b0a6eb4f667920693954bf3 Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Tue, 27 Jan 2026 12:21:23 -0300 Subject: [PATCH 4/5] Fix issues --- .../project/sdk/storyblookPlugin.ts | 50 +++++++++++++------ src/infrastructure/application/cli/cli.ts | 46 ++++++++++------- 2 files changed, 62 insertions(+), 34 deletions(-) diff --git a/src/application/project/sdk/storyblookPlugin.ts b/src/application/project/sdk/storyblookPlugin.ts index 2afa9ee1..ad64c7d7 100644 --- a/src/application/project/sdk/storyblookPlugin.ts +++ b/src/application/project/sdk/storyblookPlugin.ts @@ -4,12 +4,21 @@ import {Task} from '@/application/cli/io/output'; import {HelpfulError} from '@/application/error'; import {Codemod} from '@/application/project/code/transformation/codemod'; import {Installation} from '@/application/project/sdk/sdk'; +import {ScanFilter} from '@/application/fs/fileSystem'; + +export type Configuration = { + scanFilter: ScanFilter, + codemod: Codemod, +}; export class StoryblookPlugin implements JavaScriptSdkPlugin { private readonly codemod: Codemod; - public constructor(codemod: Codemod) { - this.codemod = codemod; + private readonly scanFilter: ScanFilter; + + public constructor(configuration: Configuration) { + this.codemod = configuration.codemod; + this.scanFilter = configuration.scanFilter; } public async getInstallationPlan( @@ -23,9 +32,9 @@ export class StoryblookPlugin implements JavaScriptSdkPlugin { const tasks: Task[] = []; tasks.push({ - title: 'Configuring Storyblok integration', + title: 'Configure Storyblok integration', task: async notifier => { - notifier.update('Configuring middleware'); + notifier.update('Configuring Storyblok integration'); try { await this.configureStoryblok(context); @@ -44,33 +53,44 @@ export class StoryblookPlugin implements JavaScriptSdkPlugin { } private async configureStoryblok(scope: JavaScriptPluginContext): Promise { - const initializationFile = await this.findStoryblokInitializationFile(scope); + const initializationFiles = await this.findStoryblokInitializationFiles(scope); - if (initializationFile === null) { + if (initializationFiles.length === 0) { throw new HelpfulError('Could not find any file containing Storyblok initialization.'); } - const result = await this.codemod.apply(initializationFile); + const results = initializationFiles.map( + file => this.codemod + .apply(file) + .then(result => result.modified), + ); - if (!result.modified) { - throw new HelpfulError('Unable to automatically configure Storyblok integration.'); + if (!(await Promise.all(results)).some(modified => modified)) { + throw new HelpfulError('Could not find any Storyblok initialization to configure.'); } } - private async findStoryblokInitializationFile(scope: JavaScriptPluginContext): Promise { + private async findStoryblokInitializationFiles(scope: JavaScriptPluginContext): Promise { const {fileSystem, projectDirectory} = scope; const directory = projectDirectory.get(); - const iterator = fileSystem.list(directory, (path, depth) => { - if (depth > 20) { + const iterator = fileSystem.list(directory, async (path, depth) => { + if (!await this.scanFilter(path, depth) || depth > 20) { return false; } const extension = extname(path).toLowerCase(); - return extension === '.js' || extension === '.ts' || extension === '.jsx' || extension === '.tsx'; + // Allow directories (no extension) and JS/TS files + return extension === '' + || extension === '.js' + || extension === '.ts' + || extension === '.jsx' + || extension === '.tsx'; }); + const files: string[] = []; + for await (const entry of iterator) { if (entry.type !== 'file') { continue; @@ -80,10 +100,10 @@ export class StoryblookPlugin implements JavaScriptSdkPlugin { const content = await fileSystem.readTextFile(path); if (content.includes('storyblokInit')) { - return path; + files.push(path); } } - return null; + return files; } } diff --git a/src/infrastructure/application/cli/cli.ts b/src/infrastructure/application/cli/cli.ts index 4922582b..92884cf7 100644 --- a/src/infrastructure/application/cli/cli.ts +++ b/src/infrastructure/application/cli/cli.ts @@ -11,7 +11,10 @@ import {File} from '@babel/types'; import {ConsoleInput} from '@/infrastructure/application/cli/io/consoleInput'; import {ConsoleOutput, LinkOpener} from '@/infrastructure/application/cli/io/consoleOutput'; import {Sdk} from '@/application/project/sdk/sdk'; -import {Configuration as JavaScriptSdkConfiguration} from '@/application/project/sdk/javasScriptSdk'; +import { + Configuration as JavaScriptSdkConfiguration, + JavaScriptSdkPlugin, +} from '@/application/project/sdk/javasScriptSdk'; import {PlugJsSdk} from '@/application/project/sdk/plugJsSdk'; import {PlugReactSdk} from '@/application/project/sdk/plugReactSdk'; import {PlugNextSdk} from '@/application/project/sdk/plugNextSdk'; @@ -1730,12 +1733,12 @@ export class Cli { mapping: { [Platform.JAVASCRIPT]: (): Sdk => new PlugJsSdk({ ...config, - plugins: [new StoryblookPlugin(this.createStoryblokCodemod(Platform.JAVASCRIPT))], + plugins: [this.createStoryblokPlugin(Platform.JAVASCRIPT)], bundlers: ['vite', 'parcel', 'tsup', 'rollup'], }), [Platform.REACT]: (): Sdk => new PlugReactSdk({ ...config, - plugins: [new StoryblookPlugin(this.createStoryblokCodemod(Platform.REACT))], + plugins: [this.createStoryblokPlugin(Platform.REACT)], importResolver: importResolver, codemod: { provider: new FormatCodemod( @@ -1795,7 +1798,7 @@ export class Cli { return new PlugNextSdk({ ...config, - plugins: [new StoryblookPlugin(this.createStoryblokCodemod(Platform.NEXTJS))], + plugins: [this.createStoryblokPlugin(Platform.NEXTJS)], userApi: this.getUserApi(), applicationApi: this.getApplicationApi(), importResolver: importResolver, @@ -1929,7 +1932,9 @@ export class Cli { }); } - private createStoryblokCodemod(platform: Platform.JAVASCRIPT | Platform.REACT | Platform.NEXTJS): Codemod { + private createStoryblokPlugin( + platform: Platform.JAVASCRIPT | Platform.REACT | Platform.NEXTJS, + ): JavaScriptSdkPlugin { const codemod = new StoryblokInitCodemod(); const modules = { [Platform.JAVASCRIPT]: 'js', @@ -1937,21 +1942,24 @@ export class Cli { [Platform.NEXTJS]: 'next', }; - return new FormatCodemod( - this.getJavaScriptFormatter(), - new FileCodemod({ - fileSystem: this.getFileSystem(), - codemod: new JavaScriptCodemod({ - languages: ['typescript', 'jsx'], - codemod: { - apply: (input: File): Promise> => codemod.apply(input, { - name: 'withCroct', - module: `@croct/plug-storyblok/${modules[platform]}`, - }), - }, + return new StoryblookPlugin({ + scanFilter: this.getScanFilter(), + codemod: new FormatCodemod( + this.getJavaScriptFormatter(), + new FileCodemod({ + fileSystem: this.getFileSystem(), + codemod: new JavaScriptCodemod({ + languages: ['typescript', 'jsx'], + codemod: { + apply: (input: File): Promise> => codemod.apply(input, { + name: 'withCroct', + module: `@croct/plug-storyblok/${modules[platform]}`, + }), + }, + }), }), - }), - ); + ), + }); } private share any)>(method: M, factory: () => ReturnType): ReturnType { From badc849be5f4d94e624d690f68f8f472986ce509 Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Tue, 27 Jan 2026 13:15:01 -0300 Subject: [PATCH 5/5] Apply review suggestions --- .../project/sdk/{storyblookPlugin.ts => storyblokPlugin.ts} | 2 +- src/infrastructure/application/cli/cli.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) rename src/application/project/sdk/{storyblookPlugin.ts => storyblokPlugin.ts} (98%) diff --git a/src/application/project/sdk/storyblookPlugin.ts b/src/application/project/sdk/storyblokPlugin.ts similarity index 98% rename from src/application/project/sdk/storyblookPlugin.ts rename to src/application/project/sdk/storyblokPlugin.ts index ad64c7d7..51ff12c3 100644 --- a/src/application/project/sdk/storyblookPlugin.ts +++ b/src/application/project/sdk/storyblokPlugin.ts @@ -11,7 +11,7 @@ export type Configuration = { codemod: Codemod, }; -export class StoryblookPlugin implements JavaScriptSdkPlugin { +export class StoryblokPlugin implements JavaScriptSdkPlugin { private readonly codemod: Codemod; private readonly scanFilter: ScanFilter; diff --git a/src/infrastructure/application/cli/cli.ts b/src/infrastructure/application/cli/cli.ts index 92884cf7..00d7e034 100644 --- a/src/infrastructure/application/cli/cli.ts +++ b/src/infrastructure/application/cli/cli.ts @@ -326,7 +326,7 @@ import { CreateApiKeyOptionsValidator, } from '@/infrastructure/application/validation/actions/createApiKeyOptionsValidator'; import {StoryblokInitCodemod} from '@/application/project/code/transformation/javascript/storyblokInitCodemod'; -import {StoryblookPlugin} from '@/application/project/sdk/storyblookPlugin'; +import {StoryblokPlugin} from '@/application/project/sdk/storyblokPlugin'; export type Configuration = { program: Program, @@ -1942,7 +1942,7 @@ export class Cli { [Platform.NEXTJS]: 'next', }; - return new StoryblookPlugin({ + return new StoryblokPlugin({ scanFilter: this.getScanFilter(), codemod: new FormatCodemod( this.getJavaScriptFormatter(),