diff --git a/schema.graphql b/schema.graphql index 86bf209d..17a72274 100644 --- a/schema.graphql +++ b/schema.graphql @@ -2629,6 +2629,7 @@ input PlanQuotaIntentInput { enum Platform { ASTRO DRUPAL + HYDROGEN JAVASCRIPT LARAVEL NEXT diff --git a/src/application/model/platform.ts b/src/application/model/platform.ts index 4ae72c89..5c8d879c 100644 --- a/src/application/model/platform.ts +++ b/src/application/model/platform.ts @@ -1,6 +1,7 @@ export enum Platform { NEXTJS = 'nextjs', NUXT = 'nuxt', + HYDROGEN = 'hydrogen', REACT = 'react', VUE = 'vue', JAVASCRIPT = 'javascript', @@ -19,6 +20,9 @@ export namespace Platform { case Platform.NUXT: return 'Nuxt'; + case Platform.HYDROGEN: + return 'Hydrogen'; + case Platform.REACT: return 'React'; diff --git a/src/application/project/code/generation/slot/hydrogenExampleGenerator.ts b/src/application/project/code/generation/slot/hydrogenExampleGenerator.ts new file mode 100644 index 00000000..6a751515 --- /dev/null +++ b/src/application/project/code/generation/slot/hydrogenExampleGenerator.ts @@ -0,0 +1,105 @@ +import type {SlotDefinition, SlotExampleGenerator} from './slotExampleGenerator'; +import {ReactExampleGenerator} from './reactExampleGenerator'; +import type {CodeExample} from '@/application/project/code/generation/example'; +import {CodeLanguage} from '@/application/project/code/generation/example'; +import {CodeWriter} from '@/application/project/code/generation/codeWritter'; +import {formatSlug} from '@/application/project/code/generation/utils'; + +/** + * The Hydrogen era, which selects the routing imports. + * + * - `react-router`: React Router 7 (`react-router`, route `+types`). + * - `remix`: Remix v2 (`@remix-run/react`, `@shopify/remix-oxygen`). + */ +export type HydrogenFramework = 'react-router' | 'remix'; + +export type Configuration = { + typescript: boolean, + framework: HydrogenFramework, + routeFilePath: string, + routeComponentName: string, + indentationSize?: number, +}; + +/** + * Generates a Hydrogen route that renders a slot. + */ +export class HydrogenExampleGenerator implements SlotExampleGenerator { + private readonly configuration: Configuration; + + public constructor(configuration: Configuration) { + this.configuration = configuration; + } + + public generate(definition: SlotDefinition): CodeExample { + const { + typescript, + framework, + routeFilePath, + routeComponentName, + indentationSize, + } = this.configuration; + + const slug = formatSlug(definition.id); + const isReactRouter = framework === 'react-router'; + const name = HydrogenExampleGenerator.replaceVariables(routeComponentName, definition.id); + const writer = new CodeWriter(indentationSize); + + writer.write("import {fetchContent} from '@croct/plug-hydrogen/server';"); + writer.write(`import {useLoaderData} from '${isReactRouter ? 'react-router' : '@remix-run/react'}';`); + + if (typescript) { + writer.write( + isReactRouter + ? `import type {Route} from './+types/${slug}';` + : "import type {LoaderFunctionArgs} from '@shopify/remix-oxygen';", + ); + } + + writer.newLine(); + + const argsType = typescript + ? `: ${isReactRouter ? 'Route.LoaderArgs' : 'LoaderFunctionArgs'}` + : ''; + + writer.write(`export async function loader({context}${argsType}) {`) + .indent() + .write(`const {content} = await fetchContent('${slug}', {scope: context});`) + .newLine() + .write('return {content};') + .outdent() + .write('}'); + + writer.newLine(); + + writer.write(`export default function ${name}() {`) + .indent() + .write(`const {content} = useLoaderData${typescript ? '' : ''}();`) + .newLine() + .write('return (') + .indent(); + + ReactExampleGenerator.renderComponentSnippet(writer, definition.definition, 'content'); + + writer + .outdent() + .write(');') + .outdent() + .write('}'); + + return { + files: [ + { + path: HydrogenExampleGenerator.replaceVariables(routeFilePath, definition.id), + language: typescript ? CodeLanguage.TYPESCRIPT_XML : CodeLanguage.JAVASCRIPT_XML, + code: writer.toString(), + }, + ], + }; + } + + private static replaceVariables(value: string, id: string): string { + return value.replace(/%name%/g, CodeWriter.formatName(id, true)) + .replace(/%slug%/g, formatSlug(id)); + } +} diff --git a/src/application/project/code/generation/slot/reactExampleGenerator.ts b/src/application/project/code/generation/slot/reactExampleGenerator.ts index 09a64197..96887470 100644 --- a/src/application/project/code/generation/slot/reactExampleGenerator.ts +++ b/src/application/project/code/generation/slot/reactExampleGenerator.ts @@ -161,7 +161,7 @@ export abstract class ReactExampleGenerator implements SlotExampleGenerator { writer.write('return (') .indent(); - this.writeRenderingSnippet(writer, definition.definition, this.options.contentVariable); + ReactExampleGenerator.renderComponentSnippet(writer, definition.definition, this.options.contentVariable); writer .outdent() @@ -197,7 +197,7 @@ export abstract class ReactExampleGenerator implements SlotExampleGenerator { protected abstract writeSlotHeader(writer: CodeWriter, definition: SlotDefinition): void; - private writeRenderingSnippet(writer: CodeWriter, definition: ContentDefinition, path: string): void { + public static renderComponentSnippet(writer: CodeWriter, definition: ContentDefinition, path: string): void { switch (definition.type) { case 'number': case 'text': @@ -238,7 +238,7 @@ export abstract class ReactExampleGenerator implements SlotExampleGenerator { writer.appendIndentation(); } - this.writeRenderingSnippet(writer, definition.items, variable); + ReactExampleGenerator.renderComponentSnippet(writer, definition.items, variable); if (inline) { writer.newLine(); @@ -270,7 +270,7 @@ export abstract class ReactExampleGenerator implements SlotExampleGenerator { writer.indent(); } - this.writeAttributeSnippet(writer, {name: name, ...attribute}, path); + ReactExampleGenerator.writeAttributeSnippet(writer, {name: name, ...attribute}, path); if (attribute.optional === true) { writer.outdent(); @@ -297,7 +297,7 @@ export abstract class ReactExampleGenerator implements SlotExampleGenerator { .write(`{${path}._type === '${id}' && (`) .indent(); - this.writeRenderingSnippet(writer, variant, path); + ReactExampleGenerator.renderComponentSnippet(writer, variant, path); writer .outdent() @@ -315,7 +315,7 @@ export abstract class ReactExampleGenerator implements SlotExampleGenerator { } } - private writeAttributeSnippet(writer: CodeWriter, attribute: Attribute, path: string): void { + private static writeAttributeSnippet(writer: CodeWriter, attribute: Attribute, path: string): void { const definition = attribute.type; const label = ReactExampleGenerator.escapeEntities(attribute.label ?? formatLabel(attribute.name)); @@ -327,7 +327,7 @@ export abstract class ReactExampleGenerator implements SlotExampleGenerator { .write('
  • ', false) .append(`${label}: `); - this.writeRenderingSnippet(writer, definition, `${path}.${attribute.name}`); + ReactExampleGenerator.renderComponentSnippet(writer, definition, `${path}.${attribute.name}`); writer.append('
  • ') .newLine(); @@ -341,7 +341,7 @@ export abstract class ReactExampleGenerator implements SlotExampleGenerator { .indent() .write(`${label}`); - this.writeRenderingSnippet(writer, definition, `${path}.${attribute.name}`); + ReactExampleGenerator.renderComponentSnippet(writer, definition, `${path}.${attribute.name}`); writer .outdent() diff --git a/src/application/project/code/transformation/javascript/hydrogenContextCodemod.ts b/src/application/project/code/transformation/javascript/hydrogenContextCodemod.ts new file mode 100644 index 00000000..bd4566d8 --- /dev/null +++ b/src/application/project/code/transformation/javascript/hydrogenContextCodemod.ts @@ -0,0 +1,193 @@ +import * as t from '@babel/types'; +import {traverse} from '@babel/core'; +import {traverseFast} from '@babel/types'; +import type {Codemod, CodemodOptions, ResultCode} from '@/application/project/code/transformation/codemod'; +import {CodemodError} from '@/application/project/code/transformation/codemod'; +import {getImportLocalName} from '@/application/project/code/transformation/javascript/utils/getImportLocalName'; +import {addImport} from '@/application/project/code/transformation/javascript/utils/addImport'; + +export type HydrogenContextConfiguration = { + /** + * The context factory import to call, e.g. `createCroctContext`. + */ + factory: { + moduleName: string, + importName: string, + localName?: string, + }, + required?: boolean, +}; + +type Anchor = { + request: string, + context: string, + returnStatement: t.ReturnStatement, + returnArgument: t.Expression, + functionNode: t.Function, +}; + +/** + * Exposes the Croct visitor context on the Hydrogen (Remix) load context. + * + * Finds the function that builds `const = createHydrogenContext(...)` and adds a + * `croct: await (, )` property to its returned object, where `` + * is the function's first parameter. Adds the import. Returns unmodified when the anchor is + * absent or `croct` is already wired. + */ +export class HydrogenContextCodemod implements Codemod { + private static readonly FACTORY_NAME = 'createHydrogenContext'; + + private static readonly FACTORY_MODULE = '@shopify/hydrogen'; + + private static readonly PROPERTY = 'croct'; + + private readonly configuration: HydrogenContextConfiguration; + + public constructor(configuration: HydrogenContextConfiguration) { + this.configuration = configuration; + } + + public apply(input: t.File): Promise> { + const anchor = HydrogenContextCodemod.findAnchor(input); + + if (anchor === null) { + if (this.configuration.required === true) { + throw new CodemodError('No Hydrogen load context found to expose the Croct context on.'); + } + + return Promise.resolve({modified: false, result: input}); + } + + const {factory} = this.configuration; + const importedName = getImportLocalName(input, { + moduleName: factory.moduleName, + importName: factory.importName, + }); + + if (importedName !== null && HydrogenContextCodemod.callsFactory(anchor.functionNode, importedName)) { + return Promise.resolve({modified: false, result: input}); + } + + const {returnArgument} = anchor; + + if (t.isObjectExpression(returnArgument) && HydrogenContextCodemod.hasProperty(returnArgument)) { + return Promise.resolve({modified: false, result: input}); + } + + const {localName} = addImport(input, { + type: 'value', + moduleName: factory.moduleName, + importName: factory.importName, + localName: factory.localName, + }); + + const property = t.objectProperty( + t.identifier(HydrogenContextCodemod.PROPERTY), + t.awaitExpression( + t.callExpression(t.identifier(localName), [t.identifier(anchor.request), t.identifier(anchor.context)]), + ), + ); + + if (t.isObjectExpression(returnArgument)) { + returnArgument.properties.push(property); + } else { + anchor.returnStatement.argument = t.objectExpression([t.spreadElement(returnArgument), property]); + } + + return Promise.resolve({modified: true, result: input}); + } + + private static findAnchor(ast: t.File): Anchor | null { + const factoryName = getImportLocalName(ast, { + moduleName: HydrogenContextCodemod.FACTORY_MODULE, + importName: HydrogenContextCodemod.FACTORY_NAME, + }) ?? HydrogenContextCodemod.FACTORY_NAME; + + let anchor: Anchor | null = null; + + traverse(ast, { + VariableDeclarator: path => { + const {init} = path.node; + const call = init !== null && t.isAwaitExpression(init) ? init.argument : init; + + if ( + call === null + || !t.isCallExpression(call) + || !t.isIdentifier(call.callee) + || call.callee.name !== factoryName + || !t.isIdentifier(path.node.id) + ) { + return; + } + + const fn = path.getFunctionParent(); + + if (fn === null) { + return; + } + + const [param] = fn.node.params; + + if (param === undefined || !t.isIdentifier(param)) { + return; + } + + const request = param.name; + const context = path.node.id.name; + + fn.traverse({ + ReturnStatement: returnPath => { + const {argument} = returnPath.node; + + if (anchor !== null || returnPath.getFunctionParent()?.node !== fn.node || argument === null) { + return; + } + + anchor = { + request: request, + context: context, + returnStatement: returnPath.node, + returnArgument: argument as t.Expression, + functionNode: fn.node, + }; + + returnPath.stop(); + }, + }); + + if (anchor !== null) { + path.stop(); + } + }, + }); + + return anchor; + } + + private static callsFactory(node: t.Node, localName: string): boolean { + let called = false; + + traverseFast(node, current => { + if (called || !t.isCallExpression(current) || !t.isIdentifier(current.callee)) { + return; + } + + called = current.callee.name === localName; + }); + + return called; + } + + private static hasProperty(object: t.ObjectExpression): boolean { + return object.properties.some(property => { + if (!t.isObjectProperty(property) || property.computed) { + return false; + } + + const {key} = property; + + return (t.isIdentifier(key) && key.name === HydrogenContextCodemod.PROPERTY) + || (t.isStringLiteral(key) && key.value === HydrogenContextCodemod.PROPERTY); + }); + } +} diff --git a/src/application/project/code/transformation/javascript/hydrogenCookiesCodemod.ts b/src/application/project/code/transformation/javascript/hydrogenCookiesCodemod.ts new file mode 100644 index 00000000..c5ff5ba0 --- /dev/null +++ b/src/application/project/code/transformation/javascript/hydrogenCookiesCodemod.ts @@ -0,0 +1,221 @@ +import * as t from '@babel/types'; +import {traverse} from '@babel/core'; +import {traverseFast} from '@babel/types'; +import type {Codemod, CodemodOptions, ResultCode} from '@/application/project/code/transformation/codemod'; +import {CodemodError} from '@/application/project/code/transformation/codemod'; +import {getImportLocalName} from '@/application/project/code/transformation/javascript/utils/getImportLocalName'; +import {addImport} from '@/application/project/code/transformation/javascript/utils/addImport'; + +export type HydrogenCookiesConfiguration = { + /** + * The cookie-writer import to call, e.g. `writeCroctCookies`. + */ + writer: { + moduleName: string, + importName: string, + localName?: string, + }, + required?: boolean, +}; + +type Anchor = { + response: t.Expression, + context: t.Expression, + statements: t.Statement[], + call: t.CallExpression, + scope: t.Statement[], +}; + +/** + * Writes the Croct visitor cookies after Hydrogen commits its session. + */ +export class HydrogenCookiesCodemod implements Codemod { + private readonly configuration: HydrogenCookiesConfiguration; + + public constructor(configuration: HydrogenCookiesConfiguration) { + this.configuration = configuration; + } + + public apply(input: t.File): Promise> { + const anchor = HydrogenCookiesCodemod.findAnchor(input); + + if (anchor === null) { + if (this.configuration.required === true) { + throw new CodemodError('No session Set-Cookie statement found to write the Croct cookies after.'); + } + + return Promise.resolve({modified: false, result: input}); + } + + const {writer} = this.configuration; + const importedName = getImportLocalName(input, { + moduleName: writer.moduleName, + importName: writer.importName, + }); + + if (importedName !== null && HydrogenCookiesCodemod.hasCall(anchor.scope, importedName)) { + return Promise.resolve({modified: false, result: input}); + } + + const {localName} = addImport(input, { + type: 'value', + moduleName: writer.moduleName, + importName: writer.importName, + localName: writer.localName, + }); + + const index = anchor.statements.findIndex( + statement => HydrogenCookiesCodemod.contains(statement, anchor.call), + ); + + anchor.statements.splice( + index + 1, + 0, + t.expressionStatement( + t.callExpression(t.identifier(localName), [ + t.cloneNode(anchor.response), + t.cloneNode(anchor.context), + ]), + ), + ); + + return Promise.resolve({modified: true, result: input}); + } + + private static findAnchor(ast: t.File): Anchor | null { + let anchor: Anchor | null = null; + + traverse(ast, { + CallExpression: path => { + const response = HydrogenCookiesCodemod.matchSetCookie(path.node); + + if (response === null) { + return; + } + + const fn = path.getFunctionParent(); + + if (fn === null || !t.isBlockStatement(fn.node.body)) { + return; + } + + const context = HydrogenCookiesCodemod.findSessionContext(path.node); + + if (context === null) { + return; + } + + // Insert in the block that holds the session cookie — the wrapping `try`, if any, + // so the writer runs before `return response`, not after the try/catch. A guarding + // `if (session.isPending) { … }` is handled by `findIndex`/`contains` below, which + // resolves to the whole guard statement rather than the nested set call. + const tryStatement = fn.node + .body + .body + .find( + (statement): statement is t.TryStatement => t.isTryStatement(statement) + && HydrogenCookiesCodemod.contains(statement.block, path.node), + ); + + anchor = { + response: response, + context: context, + statements: tryStatement !== undefined ? tryStatement.block.body : fn.node.body.body, + call: path.node, + scope: fn.node.body.body, + }; + + path.stop(); + }, + }); + + return anchor; + } + + /** + * Whether the node contains the target node anywhere within it. + */ + private static contains(node: t.Node, target: t.Node): boolean { + let found = false; + + traverseFast(node, current => { + if (current === target) { + found = true; + } + }); + + return found; + } + + /** + * Returns the response expression of a `.headers.set('Set-Cookie', …)` call. + */ + private static matchSetCookie(node: t.CallExpression): t.Expression | null { + const {callee} = node; + + if ( + !t.isMemberExpression(callee) + || callee.computed + || !t.isIdentifier(callee.property) + || callee.property.name !== 'set' + ) { + return null; + } + + const headers = callee.object; + + if ( + !t.isMemberExpression(headers) + || headers.computed + || !t.isIdentifier(headers.property) + || headers.property.name !== 'headers' + ) { + return null; + } + + const [first] = node.arguments; + + if (first === undefined || !t.isStringLiteral(first) || first.value !== 'Set-Cookie') { + return null; + } + + return headers.object; + } + + /** + * Returns the object of the first `.session` member access within the node. + */ + private static findSessionContext(node: t.Node): t.Expression | null { + let context: t.Expression | null = null; + + traverseFast(node, current => { + if ( + context === null + && t.isMemberExpression(current) + && !current.computed + && t.isIdentifier(current.property) + && current.property.name === 'session' + ) { + context = current.object; + } + }); + + return context; + } + + private static hasCall(statements: t.Statement[], localName: string): boolean { + return statements.some(statement => { + let called = false; + + traverseFast(statement, node => { + if (called || !t.isCallExpression(node) || !t.isIdentifier(node.callee)) { + return; + } + + called = node.callee.name === localName; + }); + + return called; + }); + } +} diff --git a/src/application/project/code/transformation/javascript/hydrogenCspCodemod.ts b/src/application/project/code/transformation/javascript/hydrogenCspCodemod.ts new file mode 100644 index 00000000..3401643e --- /dev/null +++ b/src/application/project/code/transformation/javascript/hydrogenCspCodemod.ts @@ -0,0 +1,137 @@ +import * as t from '@babel/types'; +import {traverse} from '@babel/core'; +import type {Codemod, CodemodOptions, ResultCode} from '@/application/project/code/transformation/codemod'; +import {CodemodError} from '@/application/project/code/transformation/codemod'; +import {getImportLocalName} from '@/application/project/code/transformation/javascript/utils/getImportLocalName'; +import {spreadAsArray} from '@/application/project/code/transformation/javascript/utils/spreadAsArray'; + +export type HydrogenCspConfiguration = { + /** + * The origin the browser SDK must be allowed to reach, e.g. `https://api.croct.io`. + */ + origin: string, + required?: boolean, +}; + +/** + * Allows the Croct origin in Hydrogen's Content Security Policy. + */ +export class HydrogenCspCodemod implements Codemod { + private static readonly FUNCTION_NAME = 'createContentSecurityPolicy'; + + private static readonly FUNCTION_MODULE = '@shopify/hydrogen'; + + private static readonly DIRECTIVE = 'connectSrc'; + + private readonly configuration: HydrogenCspConfiguration; + + public constructor(configuration: HydrogenCspConfiguration) { + this.configuration = configuration; + } + + public apply(input: t.File): Promise> { + const options = HydrogenCspCodemod.resolveOptionsObject(input); + + if (options === null) { + if (this.configuration.required === true) { + throw new CodemodError('No content security policy configuration found to allow the Croct origin.'); + } + + return Promise.resolve({modified: false, result: input}); + } + + const {origin} = this.configuration; + const directive = HydrogenCspCodemod.findProperty(options, HydrogenCspCodemod.DIRECTIVE); + + if (directive === null) { + options.properties.push( + t.objectProperty( + t.identifier(HydrogenCspCodemod.DIRECTIVE), + t.arrayExpression([t.stringLiteral(origin)]), + ), + ); + + return Promise.resolve({modified: true, result: input}); + } + + if (!t.isArrayExpression(directive.value)) { + // Normalize a non-array directive (a variable, a call, etc.) into an array with the + // origin, preserving the existing value. The cast is forced by `ObjectProperty.value`'s + // `Expression | PatternLike` type; an object-literal value is always an expression. + directive.value = t.arrayExpression([ + spreadAsArray(directive.value as t.Expression), + t.stringLiteral(origin), + ]); + + return Promise.resolve({modified: true, result: input}); + } + + const array = directive.value; + + if (HydrogenCspCodemod.hasValue(array, origin)) { + return Promise.resolve({modified: false, result: input}); + } + + array.elements.push(t.stringLiteral(origin)); + + return Promise.resolve({modified: true, result: input}); + } + + private static resolveOptionsObject(ast: t.File): t.ObjectExpression | null { + const functionName = getImportLocalName(ast, { + moduleName: HydrogenCspCodemod.FUNCTION_MODULE, + importName: HydrogenCspCodemod.FUNCTION_NAME, + }) ?? HydrogenCspCodemod.FUNCTION_NAME; + + let options: t.ObjectExpression | null = null; + + traverse(ast, { + CallExpression: path => { + if (!t.isIdentifier(path.node.callee) || path.node.callee.name !== functionName) { + return; + } + + const [argument] = path.node.arguments; + + if (argument === undefined) { + // The policy is created without options; add an empty object to extend. + options = t.objectExpression([]); + + path.node + .arguments + .push(options); + } else if (t.isObjectExpression(argument)) { + options = argument; + } + + if (options !== null) { + path.stop(); + } + }, + }); + + return options; + } + + private static hasValue(array: t.ArrayExpression, value: string): boolean { + return array.elements.some( + element => element !== null && t.isStringLiteral(element) && element.value === value, + ); + } + + private static findProperty(object: t.ObjectExpression, name: string): t.ObjectProperty | null { + for (const property of object.properties) { + if (!t.isObjectProperty(property) || property.computed) { + continue; + } + + const {key} = property; + + if ((t.isIdentifier(key) && key.name === name) || (t.isStringLiteral(key) && key.value === name)) { + return property; + } + } + + return null; + } +} diff --git a/src/application/project/code/transformation/javascript/hydrogenMiddlewareCodemod.ts b/src/application/project/code/transformation/javascript/hydrogenMiddlewareCodemod.ts new file mode 100644 index 00000000..18549881 --- /dev/null +++ b/src/application/project/code/transformation/javascript/hydrogenMiddlewareCodemod.ts @@ -0,0 +1,147 @@ +import * as t from '@babel/types'; +import type {Codemod, CodemodOptions, ResultCode} from '@/application/project/code/transformation/codemod'; +import {getImportLocalName} from '@/application/project/code/transformation/javascript/utils/getImportLocalName'; +import {addImport} from '@/application/project/code/transformation/javascript/utils/addImport'; +import {spreadAsArray} from '@/application/project/code/transformation/javascript/utils/spreadAsArray'; + +export type HydrogenMiddlewareConfiguration = { + /** + * The middleware factory import to register, e.g. `createCroctMiddleware`. + */ + middleware: { + moduleName: string, + importName: string, + localName?: string, + }, +}; + +type Match = { + declarator: t.VariableDeclarator, + index: number, +}; + +/** + * Registers the Croct middleware in the Hydrogen (React Router 7) root route. + */ +export class HydrogenMiddlewareCodemod implements Codemod { + private static readonly EXPORT_NAME = 'middleware'; + + private static readonly EXISTING_NAME = 'existingMiddleware'; + + private readonly configuration: HydrogenMiddlewareConfiguration; + + public constructor(configuration: HydrogenMiddlewareConfiguration) { + this.configuration = configuration; + } + + public apply(input: t.File): Promise> { + const {middleware} = this.configuration; + const match = HydrogenMiddlewareCodemod.findDeclarator(input); + const array = match === null + ? HydrogenMiddlewareCodemod.createExport(input) + : HydrogenMiddlewareCodemod.resolveArray(input, match); + + const importedName = getImportLocalName(input, { + moduleName: middleware.moduleName, + importName: middleware.importName, + }); + + if (importedName !== null && HydrogenMiddlewareCodemod.hasCall(array, importedName)) { + return Promise.resolve({modified: false, result: input}); + } + + const {localName} = addImport(input, { + type: 'value', + moduleName: middleware.moduleName, + importName: middleware.importName, + localName: middleware.localName, + }); + + array.elements.push(t.callExpression(t.identifier(localName), [])); + + return Promise.resolve({modified: true, result: input}); + } + + /** + * Creates `export const middleware = []` at the end of the program and returns the array. + */ + private static createExport(input: t.File): t.ArrayExpression { + const array = t.arrayExpression([]); + const {body} = input.program; + + body.push( + t.exportNamedDeclaration( + t.variableDeclaration('const', [ + t.variableDeclarator(t.identifier(HydrogenMiddlewareCodemod.EXPORT_NAME), array), + ]), + ), + ); + + return array; + } + + /** + * Returns the array to append to, normalizing a non-array export value into one. + */ + private static resolveArray(input: t.File, match: Match): t.ArrayExpression { + const {declarator, index} = match; + const {init} = declarator; + + if (t.isArrayExpression(init)) { + return init; + } + + if (init === null) { + declarator.init = t.arrayExpression([]); + + return declarator.init; + } + + // Bind the existing value to a constant and normalize it to an array, preserving it whether + // it is a single middleware or already an array. + const {EXISTING_NAME} = HydrogenMiddlewareCodemod; + const {body} = input.program; + + body.splice( + index, + 0, + t.variableDeclaration('const', [t.variableDeclarator(t.identifier(EXISTING_NAME), init)]), + ); + + const array = t.arrayExpression([spreadAsArray(t.identifier(EXISTING_NAME))]); + + declarator.init = array; + + return array; + } + + private static hasCall(array: t.ArrayExpression, localName: string): boolean { + return array.elements.some( + element => element !== null + && t.isCallExpression(element) + && t.isIdentifier(element.callee) + && element.callee.name === localName, + ); + } + + private static findDeclarator(ast: t.File): Match | null { + const {body} = ast.program; + + for (let index = 0; index < body.length; index++) { + const statement = body[index]; + const declaration = t.isExportNamedDeclaration(statement) ? statement.declaration : statement; + + if (!t.isVariableDeclaration(declaration)) { + continue; + } + + for (const declarator of declaration.declarations) { + if (t.isIdentifier(declarator.id) && declarator.id.name === HydrogenMiddlewareCodemod.EXPORT_NAME) { + return {declarator: declarator, index: index}; + } + } + } + + return null; + } +} diff --git a/src/application/project/code/transformation/javascript/jsxWrapperCodemod.ts b/src/application/project/code/transformation/javascript/jsxWrapperCodemod.ts index 1641084a..00022d5f 100644 --- a/src/application/project/code/transformation/javascript/jsxWrapperCodemod.ts +++ b/src/application/project/code/transformation/javascript/jsxWrapperCodemod.ts @@ -3,6 +3,7 @@ import * as t from '@babel/types'; import {traverse} from '@babel/core'; import {traverseFast} from '@babel/types'; import type {ResultCode, Codemod, CodemodOptions} from '@/application/project/code/transformation/codemod'; +import {CodemodError} 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'; import type {AttributeType} from '@/application/project/code/transformation/javascript/utils/createJsxProps'; @@ -18,18 +19,48 @@ type TargetChildren = { index: number, }; +/** + * Wraps the `{name}` expression child (e.g. `children`). + */ +export type VariableTarget = { + variable: string, + component?: never, + container?: never, +}; + +/** + * Wraps the matched `` element itself. `Name` may be dotted (e.g. `Foo.Bar`) or namespaced. + */ +export type ComponentTarget = { + component: string, + variable?: never, + container?: never, +}; + +/** + * Wraps its children, nesting the former children inside the wrapper. + */ +export type ContainerTarget = { + container: string, + variable?: never, + component?: never, +}; + +/** + * Selects what the wrapper wraps. + */ +export type WrapperTarget = VariableTarget | ComponentTarget | ContainerTarget; + export type WrapperConfiguration = { wrapper: { component: string, module: string, props?: Record, }, - targets?: { - variable?: string, - component?: string, - }, + targets?: WrapperTarget, fallbackToNamedExports?: boolean, fallbackCodemod?: Codemod, + required?: boolean, }; export type WrapperOptions = CodemodOptions & { @@ -127,7 +158,7 @@ export class JsxWrapperCodemod implem } // export {Component as SomeComponent}; - for (const specifier of namedExport.specifiers ?? []) { + for (const specifier of namedExport.specifiers) { if (t.isExportSpecifier(specifier)) { const declaration = this.findComponentDeclaration(input, specifier.local.name); @@ -172,6 +203,10 @@ export class JsxWrapperCodemod implem return fallbackCodemod.apply(input, options); } + if (result === Transformation.NOT_APPLIED && this.configuration.required === true) { + throw new CodemodError(`No component found to wrap with <${this.configuration.wrapper.component}>.`); + } + return Promise.resolve({ modified: result === Transformation.APPLIED, result: input, @@ -241,7 +276,13 @@ export class JsxWrapperCodemod implem * @return the result of the transformation. */ private wrapBlockStatement(node: t.BlockStatement, component: string, ast: t.File, options?: O): Transformation { - const returnStatement = JsxWrapperCodemod.findReturnStatement(node); + const container = this.configuration.targets?.container; + + const returnStatement = container !== undefined + ? this.findReturnWithElement(node, JsxWrapperCodemod.resolveElementName(ast, container)) + ?? JsxWrapperCodemod.findReturnStatement(node) + : JsxWrapperCodemod.findReturnStatement(node); + const argument = returnStatement?.argument ?? null; if (returnStatement !== null && argument !== null) { @@ -280,12 +321,23 @@ export class JsxWrapperCodemod implem }; } + const container = this.configuration.targets?.container; + + if (container !== undefined) { + return this.wrapElementChildren( + node, + JsxWrapperCodemod.resolveElementName(ast, container), + component, + options, + ); + } + const target = this.findTargetChildren(ast, node); if (target !== null) { const {parent, index} = target; - const children = [...parent.children ?? []]; + const children = [...parent.children]; const child = children.splice(index, 1)[0]; target.parent.children = children.length === 0 @@ -336,18 +388,18 @@ export class JsxWrapperCodemod implem * @param options The wrapper options. * @return The wrapped JSX element. */ - private wrapElement(node: JsxKind, name: string | undefined, options?: O): t.JSXElement { + private wrapElement(node: JsxKind, name: string, options?: O): t.JSXElement { if (node.extra?.parenthesized === true) { node.extra.parenthesized = false; } return t.jsxElement( t.jsxOpeningElement( - t.jsxIdentifier(name ?? this.configuration.wrapper.component), + t.jsxIdentifier(name), this.getProviderProps(options), ), t.jsxClosingElement( - t.jsxIdentifier(name ?? this.configuration.wrapper.component), + t.jsxIdentifier(name), ), [ t.jsxText('\n'), @@ -366,6 +418,90 @@ export class JsxWrapperCodemod implem return createJsxAttributes({...this.configuration.wrapper.props, ...options?.props}); } + /** + * Wraps the children of the named element with the wrapper component, in place. + * + * The element stays where it is and the wrapper becomes its single child, nesting the + * former children inside. Returns NOT_APPLIED when the element is absent or has no content + * to wrap, so the caller can fall back to other exports. + */ + private wrapElementChildren(node: ExpressionKind, name: string, component: string, options?: O): WrapperInsertion { + const element = this.findElement(node, name); + + if (element === null) { + return {result: Transformation.NOT_APPLIED, node: node}; + } + + const hasContent = element.children.some(child => !t.isJSXText(child) || child.value.trim() !== ''); + + if (!hasContent) { + return {result: Transformation.NOT_APPLIED, node: node}; + } + + element.children = [ + t.jsxText('\n'), + t.jsxElement( + t.jsxOpeningElement(t.jsxIdentifier(component), this.getProviderProps(options)), + t.jsxClosingElement(t.jsxIdentifier(component)), + [t.jsxText('\n'), ...element.children, t.jsxText('\n')], + ), + t.jsxText('\n'), + ]; + + return {result: Transformation.APPLIED, node: node}; + } + + /** + * Finds the first JSX element whose opening name matches the given (dotted) name. + */ + private findElement(node: t.Node, name: string): t.JSXElement | null { + let element: t.JSXElement | null = null; + + traverseFast(node, current => { + if ( + element === null + && t.isJSXElement(current) + && JsxWrapperCodemod.getJsxName(current.openingElement.name) === name + ) { + element = current; + } + }); + + return element; + } + + /** + * Returns the dotted name of a JSX element name (identifier, member expression, or namespace). + */ + private static getJsxName(name: t.JSXIdentifier | t.JSXMemberExpression | t.JSXNamespacedName): string { + if (t.isJSXMemberExpression(name)) { + return `${JsxWrapperCodemod.getJsxName(name.object)}.${JsxWrapperCodemod.getJsxName(name.property)}`; + } + + if (t.isJSXNamespacedName(name)) { + return `${name.namespace.name}:${name.name.name}`; + } + + return name.name; + } + + /** + * Resolves the import alias of a target element name's root identifier, so an aliased import + * (e.g. `import {Analytics as Shopify}`) still matches `` for the configured + * `Analytics.Provider`. The root is matched against any module, leaving it untouched when it is + * not an imported binding. + */ + private static resolveElementName(ast: t.File, name: string): string { + const segments = name.split('.'); + const local = getImportLocalName(ast, {moduleName: /.*/, importName: segments[0]}); + + if (local !== null) { + segments[0] = local; + } + + return segments.join('.'); + } + /** * Determines if the element contains the specified component. * @@ -456,6 +592,10 @@ export class JsxWrapperCodemod implem return null; } + const componentName = configuration.targets?.component === undefined + ? undefined + : JsxWrapperCodemod.resolveElementName(ast, configuration.targets.component); + traverse(ast, { enter: function enter(path) { const {node} = path; @@ -487,10 +627,8 @@ export class JsxWrapperCodemod implem const {openingElement} = nestedPath.node; if ( - configuration.targets?.component !== undefined - && t.isJSXOpeningElement(openingElement) - && t.isJSXIdentifier(openingElement.name) - && openingElement.name.name === configuration.targets.component + componentName !== undefined + && JsxWrapperCodemod.getJsxName(openingElement.name) === componentName ) { if (nestedPath.parent !== null) { const parent = nestedPath.parent as t.JSXElement; @@ -528,4 +666,24 @@ export class JsxWrapperCodemod implem return returnStatement; } + + /** + * Finds the first return statement whose argument renders an element with the given (dotted) name. + */ + private findReturnWithElement(body: t.BlockStatement, name: string): t.ReturnStatement | null { + let match: t.ReturnStatement | null = null; + + traverseFast(body, node => { + if ( + match === null + && t.isReturnStatement(node) + && node.argument != null + && this.findElement(node.argument, name) !== null + ) { + match = node; + } + }); + + return match; + } } diff --git a/src/application/project/code/transformation/javascript/nuxtConfigModuleCodemod.ts b/src/application/project/code/transformation/javascript/nuxtConfigModuleCodemod.ts index 7053153d..636046db 100644 --- a/src/application/project/code/transformation/javascript/nuxtConfigModuleCodemod.ts +++ b/src/application/project/code/transformation/javascript/nuxtConfigModuleCodemod.ts @@ -1,10 +1,13 @@ import * as t from '@babel/types'; import {traverse} from '@babel/core'; import type {Codemod, CodemodOptions, ResultCode} from '@/application/project/code/transformation/codemod'; +import {CodemodError} from '@/application/project/code/transformation/codemod'; import {getImportLocalName} from '@/application/project/code/transformation/javascript/utils/getImportLocalName'; +import {spreadAsArray} from '@/application/project/code/transformation/javascript/utils/spreadAsArray'; export type NuxtConfigModuleConfiguration = { moduleName: string, + required?: boolean, }; /** @@ -26,6 +29,10 @@ export class NuxtConfigModuleCodemod implements Codemod const config = NuxtConfigModuleCodemod.findConfig(input); if (config === null) { + if (this.configuration.required === true) { + throw new CodemodError('No Nuxt configuration found to register the Croct module.'); + } + return Promise.resolve({modified: false, result: input}); } @@ -43,7 +50,15 @@ export class NuxtConfigModuleCodemod implements Codemod } if (!t.isArrayExpression(modulesProperty.value)) { - return Promise.resolve({modified: false, result: input}); + // Normalize a non-array `modules` value (a variable, a call, etc.) into an array with + // the module, preserving the existing value. The cast is forced by `ObjectProperty.value`'s + // `Expression | PatternLike` type; an object-literal value is always an expression. + modulesProperty.value = t.arrayExpression([ + spreadAsArray(modulesProperty.value as t.Expression), + t.stringLiteral(this.configuration.moduleName), + ]); + + return Promise.resolve({modified: true, result: input}); } if (this.hasModule(modulesProperty.value)) { diff --git a/src/application/project/code/transformation/javascript/storyblokInitCodemod.ts b/src/application/project/code/transformation/javascript/storyblokInitCodemod.ts index 4de5fe87..8d3ca4d9 100644 --- a/src/application/project/code/transformation/javascript/storyblokInitCodemod.ts +++ b/src/application/project/code/transformation/javascript/storyblokInitCodemod.ts @@ -2,12 +2,14 @@ import * as t from '@babel/types'; import {traverse} from '@babel/core'; import type {Codemod, CodemodOptions, ResultCode} from '@/application/project/code/transformation/codemod'; +import {CodemodError} 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, + required?: boolean, }; export class StoryblokInitCodemod implements Codemod { @@ -25,18 +27,20 @@ export class StoryblokInitCodemod implements Codemod { + for (const specifier of path.node.specifiers) { + if (matches(getImportedName(specifier), importName)) { + moduleName = path.node.source.value; + + return path.stop(); + } + } + + return path.skip(); + }, + }); + + return moduleName; +} + +function getImportedName(specifier: t.ImportDeclaration['specifiers'][number]): string { + if (t.isImportDefaultSpecifier(specifier)) { + return 'default'; + } + + if (t.isImportNamespaceSpecifier(specifier)) { + return '*'; + } + + return t.isIdentifier(specifier.imported) ? specifier.imported.name : specifier.imported.value; +} + +function matches(value: string, matcher: string | RegExp): boolean { + return typeof matcher === 'string' ? value === matcher : matcher.test(value); +} diff --git a/src/application/project/code/transformation/javascript/utils/spreadAsArray.ts b/src/application/project/code/transformation/javascript/utils/spreadAsArray.ts new file mode 100644 index 00000000..857c9d90 --- /dev/null +++ b/src/application/project/code/transformation/javascript/utils/spreadAsArray.ts @@ -0,0 +1,21 @@ +import * as t from '@babel/types'; + +/** + * Builds a spread that flattens a value as an array: `...(Array.isArray(value) ? value : [value])`. + * + * Lets a codemod append to a list whose current value may not be an array literal (a variable, a + * call, etc.) while preserving it, whether it is a single item or already an array. Pass an + * identifier (e.g. a hoisted constant) when the value has side effects to avoid evaluating it twice. + */ +export function spreadAsArray(value: t.Expression): t.SpreadElement { + return t.spreadElement( + t.conditionalExpression( + t.callExpression( + t.memberExpression(t.identifier('Array'), t.identifier('isArray')), + [t.cloneNode(value)], + ), + t.cloneNode(value), + t.arrayExpression([t.cloneNode(value)]), + ), + ); +} diff --git a/src/application/project/code/transformation/javascript/viteConfigPluginCodemod.ts b/src/application/project/code/transformation/javascript/viteConfigPluginCodemod.ts new file mode 100644 index 00000000..ed7719c9 --- /dev/null +++ b/src/application/project/code/transformation/javascript/viteConfigPluginCodemod.ts @@ -0,0 +1,232 @@ +import * as t from '@babel/types'; +import {traverse} from '@babel/core'; +import type {Codemod, CodemodOptions, ResultCode} from '@/application/project/code/transformation/codemod'; +import {CodemodError} from '@/application/project/code/transformation/codemod'; +import {getImportLocalName} from '@/application/project/code/transformation/javascript/utils/getImportLocalName'; +import {addImport} from '@/application/project/code/transformation/javascript/utils/addImport'; +import {spreadAsArray} from '@/application/project/code/transformation/javascript/utils/spreadAsArray'; + +export type ViteConfigPluginConfiguration = { + /** + * The plugin import to add and register. + */ + plugin: { + moduleName: string, + importName: string, + localName?: string, + }, + + /** + * Where to insert the plugin in the `plugins` array. Defaults to the end. + */ + position?: 'start' | 'end', + required?: boolean, +}; + +/** + * Registers a Vite plugin in the `plugins` array of the Vite configuration. + * + * Supports the `defineConfig` call form and bare object exports, including the + * indirect variable forms of each, with type-assertion wrappers unwrapped. The + * plugin import is added and a `()` call is inserted into the array, + * creating it when missing and normalizing a non-array value into one. If the + * plugin is already registered, the codemod returns unmodified. + */ +export class ViteConfigPluginCodemod implements Codemod { + private readonly configuration: ViteConfigPluginConfiguration; + + public constructor(configuration: ViteConfigPluginConfiguration) { + this.configuration = configuration; + } + + public apply(input: t.File): Promise> { + const config = ViteConfigPluginCodemod.findConfig(input); + + if (config === null) { + if (this.configuration.required === true) { + throw new CodemodError('No Vite configuration found to register the plugin.'); + } + + return Promise.resolve({modified: false, result: input}); + } + + const plugins = ViteConfigPluginCodemod.resolvePluginsArray(config); + const {plugin, position = 'end'} = this.configuration; + const importedName = getImportLocalName(input, { + moduleName: plugin.moduleName, + importName: plugin.importName, + }); + + if (importedName !== null && ViteConfigPluginCodemod.hasPluginCall(plugins, importedName)) { + return Promise.resolve({modified: false, result: input}); + } + + const {localName} = addImport(input, { + type: 'value', + moduleName: plugin.moduleName, + importName: plugin.importName, + localName: plugin.localName, + }); + + const call = t.callExpression(t.identifier(localName), []); + + if (position === 'start') { + plugins.elements.unshift(call); + } else { + plugins.elements.push(call); + } + + return Promise.resolve({modified: true, result: input}); + } + + private static hasPluginCall(array: t.ArrayExpression, localName: string): boolean { + return array.elements.some( + element => element !== null + && t.isCallExpression(element) + && t.isIdentifier(element.callee) + && element.callee.name === localName, + ); + } + + /** + * Returns the `plugins` array of the config, creating an empty one if it is missing. When + * `plugins` is a non-array value (a variable, a call, etc.), it is normalized into an array, + * preserving the existing value, so the plugin can still be appended. + */ + private static resolvePluginsArray(config: t.ObjectExpression): t.ArrayExpression { + for (const property of config.properties) { + if (!t.isObjectProperty(property) || property.computed) { + continue; + } + + const {key} = property; + const isPlugins = (t.isIdentifier(key) && key.name === 'plugins') + || (t.isStringLiteral(key) && key.value === 'plugins'); + + if (isPlugins) { + if (t.isArrayExpression(property.value)) { + return property.value; + } + + // Normalize a non-array plugins value, preserving it. The cast is forced by + // `ObjectProperty.value`'s `Expression | PatternLike` type; an object-literal + // value is always an expression. + const normalized = t.arrayExpression([spreadAsArray(property.value as t.Expression)]); + + property.value = normalized; + + return normalized; + } + } + + const plugins = t.arrayExpression([]); + + config.properties.push(t.objectProperty(t.identifier('plugins'), plugins)); + + return plugins; + } + + private static findConfig(ast: t.File): t.ObjectExpression | null { + const defineName = getImportLocalName(ast, { + moduleName: 'vite', + importName: 'defineConfig', + }) ?? 'defineConfig'; + + let config: t.ObjectExpression | null = null; + + traverse(ast, { + ExportDefaultDeclaration: path => { + const declaration = ViteConfigPluginCodemod.unwrapTypeWrapper(path.node.declaration); + + if (t.isObjectExpression(declaration)) { + config = declaration; + + return path.stop(); + } + + if (t.isCallExpression(declaration)) { + config = ViteConfigPluginCodemod.unwrapDefineCall(declaration, defineName); + + return path.stop(); + } + + if (t.isIdentifier(declaration)) { + config = ViteConfigPluginCodemod.resolveBinding(ast, declaration.name, defineName); + + return path.stop(); + } + + return path.skip(); + }, + }); + + return config; + } + + private static unwrapDefineCall(call: t.CallExpression, defineName: string): t.ObjectExpression | null { + if (!t.isIdentifier(call.callee) || call.callee.name !== defineName) { + return null; + } + + const argument = call.arguments[0]; + + if (argument === undefined) { + return null; + } + + const unwrapped = ViteConfigPluginCodemod.unwrapTypeWrapper(argument); + + return t.isObjectExpression(unwrapped) ? unwrapped : null; + } + + private static unwrapTypeWrapper(node: t.Node): t.Node { + let current = node; + + while ( + t.isTSAsExpression(current) + || t.isTSSatisfiesExpression(current) + || t.isTSTypeAssertion(current) + || t.isTSNonNullExpression(current) + ) { + current = current.expression; + } + + return current; + } + + private static resolveBinding(ast: t.File, name: string, defineName: string): t.ObjectExpression | null { + let resolved: t.ObjectExpression | null = null; + + traverse(ast, { + VariableDeclarator: path => { + const {node} = path; + + if (!t.isIdentifier(node.id) || node.id.name !== name) { + return; + } + + if (node.init === null || node.init === undefined) { + return; + } + + const init = ViteConfigPluginCodemod.unwrapTypeWrapper(node.init); + + if (t.isObjectExpression(init)) { + resolved = init; + + return path.stop(); + } + + if (t.isCallExpression(init)) { + resolved = ViteConfigPluginCodemod.unwrapDefineCall(init, defineName); + + if (resolved !== null) { + return path.stop(); + } + } + }, + }); + + return resolved; + } +} diff --git a/src/application/project/code/transformation/javascript/vuePluginCodemod.ts b/src/application/project/code/transformation/javascript/vuePluginCodemod.ts index 438657c4..63556fa0 100644 --- a/src/application/project/code/transformation/javascript/vuePluginCodemod.ts +++ b/src/application/project/code/transformation/javascript/vuePluginCodemod.ts @@ -1,6 +1,7 @@ import * as t from '@babel/types'; import {traverse} from '@babel/core'; import type {Codemod, CodemodOptions, ResultCode} from '@/application/project/code/transformation/codemod'; +import {CodemodError} 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'; import type {AttributeType} from '@/application/project/code/transformation/javascript/utils/createObjectProps'; @@ -34,6 +35,7 @@ export type VuePluginConfiguration = { factory: string, }, args?: Record, + required?: boolean, }; export type VuePluginOptions = CodemodOptions & { @@ -58,6 +60,10 @@ export class VuePluginCodemod implements Codemod { const anchor = VuePluginCodemod.findMountAnchor(input); if (anchor === null) { + if (this.configuration.required === true) { + throw new CodemodError('No Vue app initialization found to register the Croct plugin.'); + } + return Promise.resolve({modified: false, result: input}); } diff --git a/src/application/project/code/transformation/javascript/vueStoryblokCodemod.ts b/src/application/project/code/transformation/javascript/vueStoryblokCodemod.ts index ca0b5df0..e969eeaf 100644 --- a/src/application/project/code/transformation/javascript/vueStoryblokCodemod.ts +++ b/src/application/project/code/transformation/javascript/vueStoryblokCodemod.ts @@ -1,6 +1,7 @@ import * as t from '@babel/types'; import {traverse} from '@babel/core'; import type {Codemod, CodemodOptions, ResultCode} from '@/application/project/code/transformation/codemod'; +import {CodemodError} 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'; import {VuePluginCodemod} from '@/application/project/code/transformation/javascript/vuePluginCodemod'; @@ -14,6 +15,7 @@ export type VueStoryblokConfiguration = { module: string, identifier: string, }, + required?: boolean, }; /** @@ -35,6 +37,10 @@ export class VueStoryblokCodemod implements Codemod { const anchor = VuePluginCodemod.findMountAnchor(input); if (anchor === null) { + if (this.configuration.required === true) { + throw new CodemodError('No Vue app initialization found to wire the Storyblok integration.'); + } + return Promise.resolve({modified: false, result: input}); } @@ -55,12 +61,20 @@ export class VueStoryblokCodemod implements Codemod { }); if (storyblokLocal === null) { + if (this.configuration.required === true) { + throw new CodemodError('No Storyblok Vue plugin import found.'); + } + return Promise.resolve({modified: false, result: input}); } const useCall = VuePluginCodemod.findUseCall(anchor, storyblokLocal); if (useCall === null) { + if (this.configuration.required === true) { + throw new CodemodError('No app.use(StoryblokVue) call found to wrap.'); + } + return Promise.resolve({modified: false, result: input}); } diff --git a/src/application/project/code/transformation/php/drupalLocalSettingsCodemod.ts b/src/application/project/code/transformation/php/drupalLocalSettingsCodemod.ts index 720041b5..d70cafef 100644 --- a/src/application/project/code/transformation/php/drupalLocalSettingsCodemod.ts +++ b/src/application/project/code/transformation/php/drupalLocalSettingsCodemod.ts @@ -1,10 +1,12 @@ import type {Codemod, ResultCode} from '@/application/project/code/transformation/codemod'; +import {CodemodError} from '@/application/project/code/transformation/codemod'; export type Configuration = { /** * The local settings filename that `settings.php` should include. */ file: string, + required?: boolean, }; type Scan = { @@ -29,12 +31,19 @@ export class DrupalLocalSettingsCodemod implements Codemod { private readonly file: string; - public constructor({file}: Configuration) { + private readonly required: boolean; + + public constructor({file, required = false}: Configuration) { this.file = file; + this.required = required; } public apply(input: string): Promise> { if (input.trim() === '') { + if (this.required) { + throw new CodemodError('settings.php is empty; cannot add the settings.local.php include.'); + } + return Promise.resolve({modified: false, result: input}); } diff --git a/src/application/project/code/transformation/php/symfonyBundleCodemod.ts b/src/application/project/code/transformation/php/symfonyBundleCodemod.ts index fda514c5..3d1c16e0 100644 --- a/src/application/project/code/transformation/php/symfonyBundleCodemod.ts +++ b/src/application/project/code/transformation/php/symfonyBundleCodemod.ts @@ -1,10 +1,12 @@ import type {Codemod, ResultCode} from '@/application/project/code/transformation/codemod'; +import {CodemodError} from '@/application/project/code/transformation/codemod'; export type Configuration = { /** * The fully-qualified bundle class to register (without a leading backslash). */ bundle: string, + required?: boolean, }; /** @@ -13,8 +15,11 @@ export type Configuration = { export class SymfonyBundleCodemod implements Codemod { private readonly bundle: string; - public constructor({bundle}: Configuration) { + private readonly required: boolean; + + public constructor({bundle, required = false}: Configuration) { this.bundle = bundle; + this.required = required; } public apply(input: string): Promise> { @@ -27,6 +32,10 @@ export class SymfonyBundleCodemod implements Codemod { const closing = input.lastIndexOf('];'); if (closing === -1) { + if (this.required) { + throw new CodemodError('No bundle array found in config/bundles.php to register the Croct bundle.'); + } + return Promise.resolve({modified: false, result: input}); } diff --git a/src/application/project/import/importResolver.ts b/src/application/project/import/importResolver.ts index dc0d8f85..fddef7a1 100644 --- a/src/application/project/import/importResolver.ts +++ b/src/application/project/import/importResolver.ts @@ -10,5 +10,13 @@ export class ImportResolverError extends HelpfulError { } export interface ImportResolver { + /** + * Returns the import specifier to use for `filePath` from `importPath` (file → specifier). + */ getImportPath(filePath: string, importPath?: string): Promise; + + /** + * Resolves an import specifier from `sourcePath` to the file it points to, or null (specifier → file). + */ + resolveImport(importPath: string, sourcePath: string): Promise; } diff --git a/src/application/project/import/lazyImportResolver.ts b/src/application/project/import/lazyImportResolver.ts index 98965e2b..b6ff5606 100644 --- a/src/application/project/import/lazyImportResolver.ts +++ b/src/application/project/import/lazyImportResolver.ts @@ -23,4 +23,8 @@ export class LazyImportResolver implements ImportResolver { public async getImportPath(filePath: string, importPath?: string): Promise { return (await this.resolver).getImportPath(filePath, importPath); } + + public async resolveImport(importPath: string, sourcePath: string): Promise { + return (await this.resolver).resolveImport(importPath, sourcePath); + } } diff --git a/src/application/project/import/nodeImportResolver.ts b/src/application/project/import/nodeImportResolver.ts index af97d31f..1f446ddf 100644 --- a/src/application/project/import/nodeImportResolver.ts +++ b/src/application/project/import/nodeImportResolver.ts @@ -1,7 +1,7 @@ import type {ImportResolver} from '@/application/project/import/importResolver'; import type {WorkingDirectory} from '@/application/fs/workingDirectory/workingDirectory'; import type {FileSystem} from '@/application/fs/fileSystem'; -import type {TsConfigLoader} from '@/application/project/import/tsConfigLoader'; +import type {NodeImportConfig, TsConfigLoader} from '@/application/project/import/tsConfigLoader'; export type Configuration = { projectDirectory: WorkingDirectory, @@ -10,6 +10,8 @@ export type Configuration = { }; export class NodeImportResolver implements ImportResolver { + private static readonly EXTENSIONS = ['ts', 'tsx', 'js', 'jsx', 'mts', 'mjs', 'cts', 'cjs']; + private readonly projectDirectory: WorkingDirectory; private readonly fileSystem: FileSystem; @@ -92,4 +94,107 @@ export class NodeImportResolver implements ImportResolver { : resolvedRelativePath, ); } + + public async resolveImport(importPath: string, sourcePath: string): Promise { + const projectDirectory = this.projectDirectory.get(); + const absoluteSourcePath = this.fileSystem.isAbsolutePath(sourcePath) + ? sourcePath + : this.fileSystem.joinPaths(projectDirectory, sourcePath); + + let candidates: string[]; + + if (importPath.startsWith('.')) { + // Relative specifier resolved against the importing file's directory. + candidates = [this.fileSystem.joinPaths(this.fileSystem.getDirectoryName(absoluteSourcePath), importPath)]; + } else { + const config = await this.tsConfigLoader.load(projectDirectory, {sourcePaths: [absoluteSourcePath]}); + + // A bare specifier (a package, not a project alias) is not resolvable to a project file. + candidates = config !== null ? this.resolveAliases(config, importPath) : []; + } + + for (const candidate of candidates) { + const file = await this.findFile(candidate); + + if (file !== null) { + return this.fileSystem.getRelativePath(projectDirectory, file); + } + } + + return null; + } + + /** + * Maps an import specifier to the candidate base paths of every matching tsconfig `paths` alias, + * most specific (longest literal prefix) first. + */ + private resolveAliases(config: NodeImportConfig, importPath: string): string[] { + const specifier = this.fileSystem.normalizeSeparators(importPath); + const matches: Array<{prefixLength: number, bases: string[]}> = []; + + for (const [pattern, targets] of Object.entries(config.paths)) { + const wildcard = pattern.indexOf('*'); + let substitution: string | null = null; + + if (wildcard === -1) { + substitution = specifier === this.fileSystem.normalizeSeparators(pattern) ? '' : null; + } else { + const prefix = this.fileSystem.normalizeSeparators(pattern.slice(0, wildcard)); + const suffix = pattern.slice(wildcard + 1); + + if ( + specifier.startsWith(prefix) && specifier.endsWith(suffix) + && specifier.length >= prefix.length + suffix.length + ) { + substitution = specifier.slice(prefix.length, specifier.length - suffix.length); + } + } + + if (substitution === null) { + continue; + } + + matches.push({ + prefixLength: wildcard === -1 ? pattern.length : wildcard, + bases: targets.map( + target => this.fileSystem.joinPaths( + config.baseUrl, + target.includes('*') ? target.replace('*', substitution ?? '') : target, + ), + ), + }); + } + + return matches + .sort((first, second) => second.prefixLength - first.prefixLength) + .flatMap(match => match.bases); + } + + /** + * Probes a base path for the actual module file, mirroring TypeScript/Node extension and + * directory-index resolution. + */ + private async findFile(base: string): Promise { + if (/\.[mc]?[jt]sx?$/.test(base) && await this.fileSystem.exists(base)) { + return base; + } + + for (const extension of NodeImportResolver.EXTENSIONS) { + const file = `${base}.${extension}`; + + if (await this.fileSystem.exists(file)) { + return file; + } + } + + for (const extension of NodeImportResolver.EXTENSIONS) { + const file = this.fileSystem.joinPaths(base, `index.${extension}`); + + if (await this.fileSystem.exists(file)) { + return file; + } + } + + return null; + } } diff --git a/src/application/project/sdk/plugDrupalSdk.ts b/src/application/project/sdk/plugDrupalSdk.ts index 3c5824a3..965751ed 100644 --- a/src/application/project/sdk/plugDrupalSdk.ts +++ b/src/application/project/sdk/plugDrupalSdk.ts @@ -19,6 +19,12 @@ export type Configuration = PhpSdkConfiguration & { localSettingsFileCodemod: Codemod, }; +/** + * The outcome of including `settings.local.php`: added, already present, file not found, or the + * codemod could not modify it. + */ +type LocalSettingsResult = 'included' | 'unchanged' | 'missing' | 'failed'; + export class PlugDrupalSdk extends PhpSdk { private static readonly MODULE_NAME = 'croct_example'; @@ -80,6 +86,9 @@ export class PlugDrupalSdk extends PhpSdk { case 'unchanged': return notifier.confirm('`settings.php` already includes `settings.local.php`'); + case 'failed': + return notifier.alert('Could not include the local settings', instruction); + default: return notifier.warn('Could not include the local settings', instruction); } @@ -465,7 +474,7 @@ export class PlugDrupalSdk extends PhpSdk { ); } - private async includeLocalSettings(): Promise<'included' | 'unchanged' | 'missing'> { + private async includeLocalSettings(): Promise { const path = await this.resolveSettingsFile(); if (path === null) { @@ -475,9 +484,13 @@ export class PlugDrupalSdk extends PhpSdk { // The injected codemod reads/writes settings.php and style-fixes it by // decoration; its `modified` flag tells whether the include was added. Drupal leaves // settings.php read-only after install, so apply it under a temporary unlock. - const {modified} = await this.runWritablePaths([path], () => this.localSettingsFileCodemod.apply(path)); + try { + const {modified} = await this.runWritablePaths([path], () => this.localSettingsFileCodemod.apply(path)); - return modified ? 'included' : 'unchanged'; + return modified ? 'included' : 'unchanged'; + } catch { + return 'failed'; + } } private async resolveSettingsFile(): Promise { diff --git a/src/application/project/sdk/plugHydrogenSdk.ts b/src/application/project/sdk/plugHydrogenSdk.ts new file mode 100644 index 00000000..23135f6a --- /dev/null +++ b/src/application/project/sdk/plugHydrogenSdk.ts @@ -0,0 +1,411 @@ +import type {Installation, InstallationPlan} from '@/application/project/sdk/sdk'; +import {SdkError} from '@/application/project/sdk/sdk'; +import type {Configuration as JavaScriptSdkConfiguration} from '@/application/project/sdk/javasScriptSdk'; +import {JavaScriptSdk} from '@/application/project/sdk/javasScriptSdk'; +import type {ProjectConfiguration, ProjectPaths} from '@/application/project/configuration/projectConfiguration'; +import type {Example} from '@/application/project/example/example'; +import {UrlExample} from '@/application/project/example/example'; +import type {Codemod, CodemodOptions} from '@/application/project/code/transformation/codemod'; +import type {WrapperOptions} from '@/application/project/code/transformation/javascript/jsxWrapperCodemod'; +import type {Task} from '@/application/cli/io/output'; +import {EnvFile} from '@/application/project/code/envFile'; +import type {ExampleFile} from '@/application/project/code/generation/example'; +import {HydrogenExampleGenerator} from '@/application/project/code/generation/slot/hydrogenExampleGenerator'; +import type {Slot} from '@/application/model/slot'; +import {ErrorReason, HelpfulError} from '@/application/error'; +import type {UserApi} from '@/application/api/user'; +import type {ApplicationApi, GeneratedApiKey} from '@/application/api/application'; +import {ApiKeyPermission} from '@/application/model/application'; +import {ApiError} from '@/application/api/error'; +import {getImportSource} from '@/application/project/code/transformation/javascript/utils/getImportSource'; +import type {ImportResolver} from '@/application/project/import/importResolver'; + +/** + * The Hydrogen's underlying framework, which determines the codemod transformations. + * + * The boundary is `@shopify/hydrogen@2025.5.0`, where the skeleton migrated from + * Remix to React Router 7. + */ +type Framework = 'react-router' | 'remix'; + +type CodemodConfiguration = { + /** + * Registers the `croct()` Vite plugin in `vite.config.ts`. + */ + vite: Codemod, + + /** + * Wraps the app with `` inside `` in `app/root.tsx`. + */ + provider: Codemod, + + /** + * Registers the Croct middleware in `app/root.tsx` (React Router 7 only). + */ + middleware: Codemod, + + /** + * Exposes the Croct context on the load context in `app/lib/context.ts` (Remix only). + */ + context: Codemod, + + /** + * Writes the Croct cookies after the session commit in `server.ts`. + */ + cookies: Codemod, + + /** + * Allows the Croct origin in the CSP in `app/entry.server.tsx`. + */ + csp: Codemod, +}; + +export type Configuration = JavaScriptSdkConfiguration & { + codemod: CodemodConfiguration, + userApi: UserApi, + applicationApi: ApplicationApi, + importResolver: ImportResolver, +}; + +enum HydrogenEnvVar { + API_KEY = 'CROCT_API_KEY', + APP_ID = 'PUBLIC_CROCT_APP_ID', +} + +type HydrogenProjectInfo = { + framework: Framework, + viteConfig: string | null, + server: string | null, + root: string | null, + context: string | null, + entryServer: string | null, + envFile: EnvFile, +}; + +type HydrogenInstallation = Installation & { + project: HydrogenProjectInfo, +}; + +type CodemodTaskOptions = { + /** + * The task title, shown while it runs. + */ + title: string, + + /** + * The success message confirmed once the codemod is applied. + */ + confirmation: string, + + /** + * The codemod to apply. + */ + codemod: keyof CodemodConfiguration, + + /** + * The target file, or null when it could not be located. + */ + file: string | null, + + /** + * The manual step shown when the codemod cannot wire the file, so the user can do it by hand. + */ + instructions: string, +}; + +export class PlugHydrogenSdk extends JavaScriptSdk { + private readonly codemod: CodemodConfiguration; + + private readonly userApi: UserApi; + + private readonly applicationApi: ApplicationApi; + + private readonly importResolver: ImportResolver; + + public constructor(configuration: Configuration) { + super(configuration); + + this.codemod = configuration.codemod; + this.userApi = configuration.userApi; + this.applicationApi = configuration.applicationApi; + this.importResolver = configuration.importResolver; + } + + public getPaths(configuration: ProjectConfiguration): Promise { + return Promise.resolve({ + ...configuration.paths, + source: configuration.paths?.source ?? 'app', + utilities: configuration.paths?.utilities ?? 'app/lib', + components: configuration.paths?.components ?? 'app/components', + examples: configuration.paths?.examples ?? 'app/routes', + }); + } + + protected createExample(slot: Slot): Promise { + // Hydrogen file-based routing serves `app/routes/.tsx` at `/`. + return Promise.resolve(new UrlExample(slot.name, `/${slot.slug}`)); + } + + protected async generateSlotExampleFiles(slot: Slot, installation: Installation): Promise { + const [isTypeScript, framework] = await Promise.all([ + this.isTypeScriptProject(), + this.detectFramework(), + ]); + + const paths = await this.getPaths(installation.configuration); + + const generator = new HydrogenExampleGenerator({ + typescript: isTypeScript, + framework: framework, + routeFilePath: this.fileSystem.joinPaths(paths.examples, `%slug%${isTypeScript ? '.tsx' : '.jsx'}`), + routeComponentName: '%name%Route', + }); + + const example = generator.generate({ + id: slot.slug, + version: slot.version.major, + definition: slot.resolvedDefinition, + }); + + return example.files; + } + + protected async getInstallationPlan(installation: Installation): Promise { + const {configuration} = installation; + const project = await this.getProjectInfo(); + + return { + dependencies: ['@croct/plug-hydrogen'], + tasks: this.getInstallationTasks({...installation, project: project}), + configuration: configuration, + }; + } + + private async getProjectInfo(): Promise { + const projectDirectory = this.projectDirectory.get(); + + const [framework, viteConfig, server, root, entryServer] = await Promise.all([ + this.detectFramework(), + this.locateFile( + 'vite.config.ts', + 'vite.config.mts', + 'vite.config.cts', + 'vite.config.js', + 'vite.config.mjs', + 'vite.config.cjs', + ), + this.locateFile('server.ts', 'server.js'), + this.locateFile('app/root.tsx', 'app/root.jsx'), + this.locateFile('app/entry.server.tsx', 'app/entry.server.jsx'), + ]); + + // The load-context module is app code, not a fixed framework path, so follow its import + // from the server entry before assuming the skeleton's conventional location. + const context = await this.locateContext(server); + + return { + framework: framework, + viteConfig: viteConfig, + server: server, + root: root, + context: context, + entryServer: entryServer, + envFile: new EnvFile(this.fileSystem, this.fileSystem.joinPaths(projectDirectory, '.env')), + }; + } + + /** + * Detects the era from the `@shopify/hydrogen` version, falling back to the routing dependency. + */ + private async detectFramework(): Promise { + const [hydrogenVersionUsesReactRouter, hasReactRouterDependency] = await Promise.all([ + this.packageManager.hasDirectDependency('@shopify/hydrogen', '>=2025.5.0'), + this.packageManager.hasDirectDependency('react-router'), + ]); + + return hydrogenVersionUsesReactRouter || hasReactRouterDependency ? 'react-router' : 'remix'; + } + + /** + * Locates the load-context module. + * + * The skeleton puts it at `app/lib/context.*`, but it is ordinary app code referenced from the + * server entry (e.g. `import {createAppLoadContext} from '~/lib/context'`), so it is resolved by + * following that import first, falling back to the conventional path. + */ + private async locateContext(server: string | null): Promise { + const imported = server !== null ? await this.followContextImport(server) : null; + + if (imported !== null) { + return imported; + } + + return this.locateFile( + 'app/lib/context.ts', + 'app/lib/context.tsx', + 'app/lib/context.js', + 'app/lib/context.jsx', + ); + } + + /** + * Resolves the load-context module by following its import in the server entry. + * + * The factory is imported from a local module (e.g. `import {createAppLoadContext} from + * '~/lib/context'`); its name varies by era (`createAppLoadContext`, `createHydrogenRouterContext`). + * The specifier is resolved through the project's tsconfig aliases. + */ + private async followContextImport(server: string): Promise { + const source = await this.readFile(server); + + if (source === null) { + return null; + } + + const specifier = getImportSource(source, /^create[A-Za-z]*Context$/); + + return specifier !== null ? this.importResolver.resolveImport(specifier, server) : null; + } + + private getInstallationTasks(installation: HydrogenInstallation): Task[] { + const {project} = installation; + + return [ + { + title: 'Set up environment variables', + task: async notifier => { + notifier.update('Setting up environment variables'); + + try { + await this.updateEnvVariables(installation); + + notifier.confirm('Environment variables updated'); + } catch (error) { + notifier.alert('Failed to update environment variables', HelpfulError.formatMessage(error)); + } + }, + }, + this.getCodemodTask({ + title: 'Register Vite plugin', + confirmation: 'Vite plugin registered', + codemod: 'vite', + file: project.viteConfig, + instructions: 'Add the `croct()` plugin to the `plugins` array in your Vite config.', + }), + project.framework === 'react-router' + ? this.getCodemodTask({ + title: 'Register middleware', + confirmation: 'Middleware registered', + codemod: 'middleware', + file: project.root, + instructions: 'Add `createCroctMiddleware()` to the `middleware` array exported from app/root.', + }) + : this.getCodemodTask({ + title: 'Expose Croct context', + confirmation: 'Croct context exposed', + codemod: 'context', + file: project.context, + instructions: 'Add `croct: await createCroctContext(request, context)` to the load context.', + }), + this.getCodemodTask({ + title: 'Configure Croct cookies', + confirmation: 'Croct cookies configured', + codemod: 'cookies', + file: project.server, + instructions: 'Call `writeCroctCookies(response, context)` after the session commits in server.ts.', + }), + this.getCodemodTask({ + title: 'Configure provider', + confirmation: 'Provider configured', + codemod: 'provider', + file: project.root, + instructions: 'Wrap your app with `` in app/root.', + }), + this.getCodemodTask({ + title: 'Configure content security policy', + confirmation: 'Content security policy configured', + codemod: 'csp', + file: project.entryServer, + instructions: 'Add `https://api.croct.io` to `connectSrc` in your content security policy.', + }), + ]; + } + + private getCodemodTask(options: CodemodTaskOptions): Task { + const {title, confirmation, codemod, file, instructions} = options; + const action = `${title.charAt(0).toLowerCase()}${title.slice(1)}`; + + return { + title: title, + task: async notifier => { + notifier.update(title); + + if (file === null) { + notifier.alert(`Failed to ${action}`, instructions); + + return; + } + + try { + await this.applyCodemod(this.codemod[codemod], file); + + notifier.confirm(confirmation); + } catch { + notifier.alert(`Failed to ${action}`, instructions); + } + }, + }; + } + + private async applyCodemod(codemod: Codemod, file: string): Promise { + await codemod.apply(this.fileSystem.joinPaths(this.projectDirectory.get(), file)); + } + + private async updateEnvVariables(installation: HydrogenInstallation): Promise { + const {project: {envFile}, configuration} = installation; + + const application = await this.workspaceApi.getApplication({ + organizationSlug: configuration.organization, + workspaceSlug: configuration.workspace, + applicationSlug: configuration.applications.development, + }); + + if (application === null) { + throw new SdkError( + `Development application \`${configuration.applications.development}\` not found.`, + {reason: ErrorReason.NOT_FOUND}, + ); + } + + if (!await envFile.hasVariable(HydrogenEnvVar.API_KEY) && installation.skipApiKeySetup !== true) { + const user = await this.userApi.getUser(); + + let apiKey: GeneratedApiKey; + + try { + apiKey = await this.applicationApi.createApiKey({ + organizationSlug: configuration.organization, + workspaceSlug: configuration.workspace, + applicationSlug: application.slug, + name: `${user.username} CLI`, + permissions: [ApiKeyPermission.ISSUE_TOKEN], + }); + } catch (error) { + if (error instanceof HelpfulError) { + throw new SdkError( + error instanceof ApiError && error.isAccessDenied() + ? 'Your user does not have permission to create an API key' + : error.message, + error.help, + ); + } + + throw error; + } + + await envFile.setVariables({[HydrogenEnvVar.API_KEY]: apiKey.secret}); + } + + await envFile.setVariables({[HydrogenEnvVar.APP_ID]: application.publicId}); + } +} diff --git a/src/application/project/sdk/plugNuxtSdk.ts b/src/application/project/sdk/plugNuxtSdk.ts index d21e189e..4e109e6d 100644 --- a/src/application/project/sdk/plugNuxtSdk.ts +++ b/src/application/project/sdk/plugNuxtSdk.ts @@ -154,8 +154,11 @@ export class PlugNuxtSdk extends JavaScriptSdk { await this.applyConfigCodemod(installation.project.config.file); notifier.confirm('Module registered'); - } catch (error) { - notifier.alert('Failed to register module', HelpfulError.formatMessage(error)); + } catch { + notifier.alert( + 'Failed to register module', + 'Add \'@croct/plug-nuxt\' to the modules array in your nuxt.config.', + ); } }, }, diff --git a/src/application/project/sdk/plugReactSdk.ts b/src/application/project/sdk/plugReactSdk.ts index 10b24f89..eab0cd86 100644 --- a/src/application/project/sdk/plugReactSdk.ts +++ b/src/application/project/sdk/plugReactSdk.ts @@ -216,14 +216,12 @@ export class PlugReactSdk extends JavaScriptSdk { notifier.update('Configuring provider'); const providerFile = installation.project.provider.file; + const instructions = 'Wrap your app\'s root component with from @croct/plug-react.'; try { if (providerFile === null) { - // @todo add help link to documentation - notifier.alert('No root component found'); + notifier.alert('No root component found', instructions); } else { - notifier.update('Configuring provider'); - await this.installProvider(providerFile, { props: { appId: PlugReactSdk.getAppIdProperty(await getPublicIds(), projectEnv?.property), @@ -232,8 +230,8 @@ export class PlugReactSdk extends JavaScriptSdk { notifier.confirm('Provider configured'); } - } catch (error) { - notifier.alert('Failed to install provider', HelpfulError.formatMessage(error)); + } catch { + notifier.alert('Failed to install provider', instructions); } }, }); diff --git a/src/application/project/sdk/plugSymfonySdk.ts b/src/application/project/sdk/plugSymfonySdk.ts index f6ee5545..8b4d25ff 100644 --- a/src/application/project/sdk/plugSymfonySdk.ts +++ b/src/application/project/sdk/plugSymfonySdk.ts @@ -55,8 +55,12 @@ export class PlugSymfonySdk extends PhpSdk { ? 'Bundle registered' : 'Bundle already registered', ); - } catch (error) { - notifier.alert('Failed to register bundle', HelpfulError.formatMessage(error)); + } catch { + notifier.alert( + 'Failed to register bundle', + 'Add Croct\\Plug\\Symfony\\CroctBundle::class => [\'all\' => true] ' + + 'to the array in config/bundles.php.', + ); } }, }, diff --git a/src/application/project/sdk/plugVueSdk.ts b/src/application/project/sdk/plugVueSdk.ts index 759f7cc8..e5df4e5f 100644 --- a/src/application/project/sdk/plugVueSdk.ts +++ b/src/application/project/sdk/plugVueSdk.ts @@ -223,8 +223,12 @@ export class PlugVueSdk extends JavaScriptSdk { notifier.confirm('Plugin registered'); } - } catch (error) { - notifier.alert('Failed to register plugin', HelpfulError.formatMessage(error)); + } catch { + notifier.alert( + 'Failed to register plugin', + 'Register the Croct plugin in your Vue entry: ' + + 'app.use(createCroct({appId: \'\'})) before app.mount().', + ); } }, }); diff --git a/src/application/project/server/provider/parser/hydrogenCommandParser.ts b/src/application/project/server/provider/parser/hydrogenCommandParser.ts new file mode 100644 index 00000000..4e48dcfd --- /dev/null +++ b/src/application/project/server/provider/parser/hydrogenCommandParser.ts @@ -0,0 +1,22 @@ +import type {ServerCommandParser} from '@/application/project/server/provider/projectServerProvider'; +import type {ServerInfo} from '@/application/project/server/factory/serverFactory'; + +export class HydrogenCommandParser implements ServerCommandParser { + public parse(command: string): ServerInfo | null { + if (!command.includes('hydrogen dev') && !command.includes('h2 dev')) { + return null; + } + + const portMatch = command.match(/--port\s*(\d+)/); + const hostMatch = command.match(/--host\s*(\S+)/); + const port = portMatch !== null ? Number.parseInt(portMatch[1], 10) : null; + const host = hostMatch !== null ? hostMatch[1] : 'localhost'; + + return { + protocol: 'http', + host: host, + ...(port !== null ? {port: port} : {}), + defaultPort: 3000, + }; + } +} diff --git a/src/infrastructure/application/api/graphql/workspace.ts b/src/infrastructure/application/api/graphql/workspace.ts index e96d8dd3..d0d49139 100644 --- a/src/infrastructure/application/api/graphql/workspace.ts +++ b/src/infrastructure/application/api/graphql/workspace.ts @@ -123,6 +123,7 @@ function createNormalizationMap(map: Record< const platformMap = createNormalizationMap({ [Platform.JAVASCRIPT]: GraphqlPlatform.Javascript, + [Platform.HYDROGEN]: GraphqlPlatform.Hydrogen, [Platform.REACT]: GraphqlPlatform.React, [Platform.NEXTJS]: GraphqlPlatform.Next, [Platform.VUE]: GraphqlPlatform.Vue, diff --git a/src/infrastructure/application/cli/cli.ts b/src/infrastructure/application/cli/cli.ts index 7ca8cf78..d4725c46 100644 --- a/src/infrastructure/application/cli/cli.ts +++ b/src/infrastructure/application/cli/cli.ts @@ -24,6 +24,7 @@ import {PlugReactSdk} from '@/application/project/sdk/plugReactSdk'; import {PlugNextSdk} from '@/application/project/sdk/plugNextSdk'; import {PlugVueSdk} from '@/application/project/sdk/plugVueSdk'; import {PlugNuxtSdk} from '@/application/project/sdk/plugNuxtSdk'; +import {PlugHydrogenSdk} from '@/application/project/sdk/plugHydrogenSdk'; import {PlugPhpSdk} from '@/application/project/sdk/plugPhpSdk'; import {PlugLaravelSdk} from '@/application/project/sdk/plugLaravelSdk'; import {PlugSymfonySdk} from '@/application/project/sdk/plugSymfonySdk'; @@ -205,6 +206,7 @@ import {ExampleLauncher} from '@/application/project/example/exampleLauncher'; import {ProjectServerProvider} from '@/application/project/server/provider/projectServerProvider'; import {NextCommandParser} from '@/application/project/server/provider/parser/nextCommandParser'; import {NuxtCommandParser} from '@/application/project/server/provider/parser/nuxtCommandParser'; +import {HydrogenCommandParser} from '@/application/project/server/provider/parser/hydrogenCommandParser'; import {ViteCommandParser} from '@/application/project/server/provider/parser/viteCommandParser'; import {ParcelCommandParser} from '@/application/project/server/provider/parser/parcelCommandParser'; import {ReactScriptCommandParser} from '@/application/project/server/provider/parser/reactScriptCommandParser'; @@ -377,6 +379,13 @@ import {NuxtStoryblokPlugin} from '@/application/project/sdk/nuxtStoryblokPlugin import {VuePluginCodemod} from '@/application/project/code/transformation/javascript/vuePluginCodemod'; import {VueStoryblokCodemod} from '@/application/project/code/transformation/javascript/vueStoryblokCodemod'; import {NuxtConfigModuleCodemod} from '@/application/project/code/transformation/javascript/nuxtConfigModuleCodemod'; +import {ViteConfigPluginCodemod} from '@/application/project/code/transformation/javascript/viteConfigPluginCodemod'; +import { + HydrogenMiddlewareCodemod, +} from '@/application/project/code/transformation/javascript/hydrogenMiddlewareCodemod'; +import {HydrogenContextCodemod} from '@/application/project/code/transformation/javascript/hydrogenContextCodemod'; +import {HydrogenCookiesCodemod} from '@/application/project/code/transformation/javascript/hydrogenCookiesCodemod'; +import {HydrogenCspCodemod} from '@/application/project/code/transformation/javascript/hydrogenCspCodemod'; import { NuxtStoryblokPluginCodemod, @@ -1828,6 +1837,7 @@ export class Cli { languages: ['typescript', 'jsx'], codemod: new JsxWrapperCodemod({ fallbackToNamedExports: true, + required: true, wrapper: { module: '@croct/plug-react', component: 'CroctProvider', @@ -1871,6 +1881,7 @@ export class Cli { module: '@croct/plug-vue', factory: 'createCroct', }, + required: true, }), }), }), @@ -1902,6 +1913,111 @@ export class Cli { languages: ['typescript'], codemod: new NuxtConfigModuleCodemod({ moduleName: '@croct/plug-nuxt', + required: true, + }), + }), + }), + ), + }, + }), + [Platform.HYDROGEN]: (): Sdk => new PlugHydrogenSdk({ + ...config, + userApi: this.getUserApi(), + applicationApi: this.getApplicationApi(), + importResolver: importResolver, + codemod: { + vite: new FormatCodemod( + formatter, + new FileCodemod({ + fileSystem: this.getFileSystem(), + codemod: new JavaScriptCodemod({ + languages: ['typescript'], + codemod: new ViteConfigPluginCodemod({ + plugin: { + moduleName: '@croct/plug-hydrogen/vite', + importName: 'croct', + }, + required: true, + }), + }), + }), + ), + provider: new FormatCodemod( + formatter, + new FileCodemod({ + fileSystem: this.getFileSystem(), + codemod: new JavaScriptCodemod({ + languages: ['typescript', 'jsx'], + codemod: new JsxWrapperCodemod({ + wrapper: { + component: 'CroctProvider', + module: '@croct/plug-hydrogen', + }, + targets: { + container: 'Analytics.Provider', + }, + fallbackToNamedExports: true, + required: true, + }), + }), + }), + ), + middleware: new FormatCodemod( + formatter, + new FileCodemod({ + fileSystem: this.getFileSystem(), + codemod: new JavaScriptCodemod({ + languages: ['typescript', 'jsx'], + codemod: new HydrogenMiddlewareCodemod({ + middleware: { + moduleName: '@croct/plug-hydrogen/server', + importName: 'createCroctMiddleware', + }, + }), + }), + }), + ), + context: new FormatCodemod( + formatter, + new FileCodemod({ + fileSystem: this.getFileSystem(), + codemod: new JavaScriptCodemod({ + languages: ['typescript'], + codemod: new HydrogenContextCodemod({ + factory: { + moduleName: '@croct/plug-hydrogen/server', + importName: 'createCroctContext', + }, + required: true, + }), + }), + }), + ), + cookies: new FormatCodemod( + formatter, + new FileCodemod({ + fileSystem: this.getFileSystem(), + codemod: new JavaScriptCodemod({ + languages: ['typescript'], + codemod: new HydrogenCookiesCodemod({ + writer: { + moduleName: '@croct/plug-hydrogen/server', + importName: 'writeCroctCookies', + }, + required: true, + }), + }), + }), + ), + csp: new FormatCodemod( + formatter, + new FileCodemod({ + fileSystem: this.getFileSystem(), + codemod: new JavaScriptCodemod({ + languages: ['typescript', 'jsx'], + codemod: new HydrogenCspCodemod({ + origin: 'https://api.croct.io', + required: true, }), }), }), @@ -2050,7 +2166,10 @@ export class Cli { phpConfig.formatter, new FileCodemod({ fileSystem: fileSystem, - codemod: new SymfonyBundleCodemod({bundle: 'Croct\\Plug\\Symfony\\CroctBundle'}), + codemod: new SymfonyBundleCodemod({ + bundle: 'Croct\\Plug\\Symfony\\CroctBundle', + required: true, + }), }), ), // YAML has no formatter, so it is not wrapped in FormatCodemod. @@ -2065,7 +2184,10 @@ export class Cli { phpConfig.formatter, new FileCodemod({ fileSystem: fileSystem, - codemod: new DrupalLocalSettingsCodemod({file: PlugDrupalSdk.LOCAL_SETTINGS_FILE}), + codemod: new DrupalLocalSettingsCodemod({ + file: PlugDrupalSdk.LOCAL_SETTINGS_FILE, + required: true, + }), }), ), }), @@ -2088,6 +2210,7 @@ export class Cli { [Platform.NEXTJS]: () => this.getJavaScriptFormatter(), [Platform.VUE]: () => this.getJavaScriptFormatter(), [Platform.NUXT]: () => this.getJavaScriptFormatter(), + [Platform.HYDROGEN]: () => this.getJavaScriptFormatter(), [Platform.LARAVEL]: () => this.getPhpFormatter(), [Platform.SYMFONY]: () => this.getPhpFormatter(), [Platform.DRUPAL]: () => this.getPhpFormatter(), @@ -2384,6 +2507,7 @@ export class Cli { [Platform.NEXTJS]: () => this.getNodeServerProvider().get(), [Platform.VUE]: () => this.getNodeServerProvider().get(), [Platform.NUXT]: () => this.getNodeServerProvider().get(), + [Platform.HYDROGEN]: () => this.getNodeServerProvider().get(), [Platform.LARAVEL]: () => this.createDevServer( {name: 'php', arguments: ['artisan', 'serve']}, 8000, @@ -2428,6 +2552,7 @@ export class Cli { parsers: [ new NextCommandParser(), new NuxtCommandParser(), + new HydrogenCommandParser(), new ViteCommandParser(), new ParcelCommandParser(), new ReactScriptCommandParser(), @@ -2617,6 +2742,14 @@ export class Cli { dependencies: ['nuxt'], }), }, + // Hydrogen ships React, so it must be matched before the generic React rule. + { + value: Platform.HYDROGEN, + condition: new HasDependency({ + packageManager: nodePackageManager, + dependencies: ['@shopify/hydrogen'], + }), + }, { value: Platform.REACT, condition: new HasDependency({ diff --git a/test/application/project/code/generation/slot/__snapshots__/hydrogenExampleGenerator.test.ts.snap b/test/application/project/code/generation/slot/__snapshots__/hydrogenExampleGenerator.test.ts.snap new file mode 100644 index 00000000..c9de4f13 --- /dev/null +++ b/test/application/project/code/generation/slot/__snapshots__/hydrogenExampleGenerator.test.ts.snap @@ -0,0 +1,498 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`HydrogenExampleGenerator should generate a route for booleanWithLabel: booleanWithLabel 1`] = ` +{ + "files": [ + { + "code": "import {fetchContent} from '@croct/plug-hydrogen/server'; +import {useLoaderData} from 'react-router'; +import type {Route} from './+types/feature-toggle'; + +export async function loader({context}: Route.LoaderArgs) { + const {content} = await fetchContent('feature-toggle', {scope: context}); + + return {content}; +} + +export default function FeatureToggleRoute() { + const {content} = useLoaderData(); + + return ( +
      +
    • Enabled: {content.enabled ? 'Active' : 'Inactive'}
    • +
    • Visible: {content.visible ? 'Yes' : 'No'}
    • +
    + ); +} +", + "language": "tsx", + "path": "app/routes/feature-toggle.tsx", + }, + ], +} +`; + +exports[`HydrogenExampleGenerator should generate a route for labeledAttributes: labeledAttributes 1`] = ` +{ + "files": [ + { + "code": "import {fetchContent} from '@croct/plug-hydrogen/server'; +import {useLoaderData} from 'react-router'; +import type {Route} from './+types/stylized'; + +export async function loader({context}: Route.LoaderArgs) { + const {content} = await fetchContent('stylized', {scope: context}); + + return {content}; +} + +export default function StylizedRoute() { + const {content} = useLoaderData(); + + return ( +
      +
    • Tom & Jerry <special>: {content.title}
    • +
    + ); +} +", + "language": "tsx", + "path": "app/routes/stylized.tsx", + }, + ], +} +`; + +exports[`HydrogenExampleGenerator should generate a route for listOfScalars: listOfScalars 1`] = ` +{ + "files": [ + { + "code": "import {fetchContent} from '@croct/plug-hydrogen/server'; +import {useLoaderData} from 'react-router'; +import type {Route} from './+types/tag-cloud'; + +export async function loader({context}: Route.LoaderArgs) { + const {content} = await fetchContent('tag-cloud', {scope: context}); + + return {content}; +} + +export default function TagCloudRoute() { + const {content} = useLoaderData(); + + return ( +
      +
    • + Tags +
        + {content.tags.map((item, index) => ( +
      1. + {item} +
      2. + ))} +
      +
    • +
    + ); +} +", + "language": "tsx", + "path": "app/routes/tag-cloud.tsx", + }, + ], +} +`; + +exports[`HydrogenExampleGenerator should generate a route for listOfStructures: listOfStructures 1`] = ` +{ + "files": [ + { + "code": "import {fetchContent} from '@croct/plug-hydrogen/server'; +import {useLoaderData} from 'react-router'; +import type {Route} from './+types/product-grid'; + +export async function loader({context}: Route.LoaderArgs) { + const {content} = await fetchContent('product-grid', {scope: context}); + + return {content}; +} + +export default function ProductGridRoute() { + const {content} = useLoaderData(); + + return ( +
      +
    • + Products +
        + {content.products.map((product, index) => ( +
      1. +
          +
        • Name: {product.name}
        • +
        • Price: {product.price}
        • +
        +
      2. + ))} +
      +
    • +
    + ); +} +", + "language": "tsx", + "path": "app/routes/product-grid.tsx", + }, + ], +} +`; + +exports[`HydrogenExampleGenerator should generate a route for nestedStructure: nestedStructure 1`] = ` +{ + "files": [ + { + "code": "import {fetchContent} from '@croct/plug-hydrogen/server'; +import {useLoaderData} from 'react-router'; +import type {Route} from './+types/author-card'; + +export async function loader({context}: Route.LoaderArgs) { + const {content} = await fetchContent('author-card', {scope: context}); + + return {content}; +} + +export default function AuthorCardRoute() { + const {content} = useLoaderData(); + + return ( +
      +
    • + Author +
        +
      • Name: {content.author.name}
      • + {content.author.bio && ( +
      • Bio: {content.author.bio}
      • + )} +
      +
    • +
    + ); +} +", + "language": "tsx", + "path": "app/routes/author-card.tsx", + }, + ], +} +`; + +exports[`HydrogenExampleGenerator should generate a route for optionalAttributes: optionalAttributes 1`] = ` +{ + "files": [ + { + "code": "import {fetchContent} from '@croct/plug-hydrogen/server'; +import {useLoaderData} from 'react-router'; +import type {Route} from './+types/promo-card'; + +export async function loader({context}: Route.LoaderArgs) { + const {content} = await fetchContent('promo-card', {scope: context}); + + return {content}; +} + +export default function PromoCardRoute() { + const {content} = useLoaderData(); + + return ( +
      +
    • Title: {content.title}
    • + {content.subtitle && ( +
    • Subtitle: {content.subtitle}
    • + )} +
    + ); +} +", + "language": "tsx", + "path": "app/routes/promo-card.tsx", + }, + ], +} +`; + +exports[`HydrogenExampleGenerator should generate a route for privateAttributes: privateAttributes 1`] = ` +{ + "files": [ + { + "code": "import {fetchContent} from '@croct/plug-hydrogen/server'; +import {useLoaderData} from 'react-router'; +import type {Route} from './+types/tracked-banner'; + +export async function loader({context}: Route.LoaderArgs) { + const {content} = await fetchContent('tracked-banner', {scope: context}); + + return {content}; +} + +export default function TrackedBannerRoute() { + const {content} = useLoaderData(); + + return ( +
      +
    • Headline: {content.headline}
    • +
    + ); +} +", + "language": "tsx", + "path": "app/routes/tracked-banner.tsx", + }, + ], +} +`; + +exports[`HydrogenExampleGenerator should generate a route for simpleStructure: simpleStructure 1`] = ` +{ + "files": [ + { + "code": "import {fetchContent} from '@croct/plug-hydrogen/server'; +import {useLoaderData} from 'react-router'; +import type {Route} from './+types/home-hero'; + +export async function loader({context}: Route.LoaderArgs) { + const {content} = await fetchContent('home-hero', {scope: context}); + + return {content}; +} + +export default function HomeHeroRoute() { + const {content} = useLoaderData(); + + return ( +
      +
    • Headline: {content.headline}
    • +
    • View count: {content.viewCount}
    • +
    • Featured: {content.featured ? 'Yes' : 'No'}
    • +
    + ); +} +", + "language": "tsx", + "path": "app/routes/home-hero.tsx", + }, + ], +} +`; + +exports[`HydrogenExampleGenerator should generate a route for unionInStructure: unionInStructure 1`] = ` +{ + "files": [ + { + "code": "import {fetchContent} from '@croct/plug-hydrogen/server'; +import {useLoaderData} from 'react-router'; +import type {Route} from './+types/block'; + +export async function loader({context}: Route.LoaderArgs) { + const {content} = await fetchContent('block', {scope: context}); + + return {content}; +} + +export default function BlockRoute() { + const {content} = useLoaderData(); + + return ( +
      +
    • + Media + {content.media._type === 'image' && ( +
        +
      • Url: {content.media.url}
      • +
      + )} + {content.media._type === 'video' && ( +
        +
      • Source: {content.media.source}
      • +
      + )} +
    • +
    + ); +} +", + "language": "tsx", + "path": "app/routes/block.tsx", + }, + ], +} +`; + +exports[`HydrogenExampleGenerator should generate a route for unionRoot: unionRoot 1`] = ` +{ + "files": [ + { + "code": "import {fetchContent} from '@croct/plug-hydrogen/server'; +import {useLoaderData} from 'react-router'; +import type {Route} from './+types/cta-block'; + +export async function loader({context}: Route.LoaderArgs) { + const {content} = await fetchContent('cta-block', {scope: context}); + + return {content}; +} + +export default function CtaBlockRoute() { + const {content} = useLoaderData(); + + return ( + <> + {content._type === 'button' && ( +
      +
    • Label: {content.label}
    • +
    + )} + {content._type === 'link' && ( +
      +
    • Url: {content.url}
    • +
    • Text: {content.text}
    • +
    + )} + + ); +} +", + "language": "tsx", + "path": "app/routes/cta-block.tsx", + }, + ], +} +`; + +exports[`HydrogenExampleGenerator should generate the react-router-js route: react-router-js 1`] = ` +{ + "files": [ + { + "code": "import {fetchContent} from '@croct/plug-hydrogen/server'; +import {useLoaderData} from 'react-router'; + +export async function loader({context}) { + const {content} = await fetchContent('home-hero', {scope: context}); + + return {content}; +} + +export default function HomeHeroRoute() { + const {content} = useLoaderData(); + + return ( +
      +
    • Headline: {content.headline}
    • +
    • View count: {content.viewCount}
    • +
    • Featured: {content.featured ? 'Yes' : 'No'}
    • +
    + ); +} +", + "language": "jsx", + "path": "app/routes/home-hero.jsx", + }, + ], +} +`; + +exports[`HydrogenExampleGenerator should generate the react-router-ts route: react-router-ts 1`] = ` +{ + "files": [ + { + "code": "import {fetchContent} from '@croct/plug-hydrogen/server'; +import {useLoaderData} from 'react-router'; +import type {Route} from './+types/home-hero'; + +export async function loader({context}: Route.LoaderArgs) { + const {content} = await fetchContent('home-hero', {scope: context}); + + return {content}; +} + +export default function HomeHeroRoute() { + const {content} = useLoaderData(); + + return ( +
      +
    • Headline: {content.headline}
    • +
    • View count: {content.viewCount}
    • +
    • Featured: {content.featured ? 'Yes' : 'No'}
    • +
    + ); +} +", + "language": "tsx", + "path": "app/routes/home-hero.tsx", + }, + ], +} +`; + +exports[`HydrogenExampleGenerator should generate the remix-js route: remix-js 1`] = ` +{ + "files": [ + { + "code": "import {fetchContent} from '@croct/plug-hydrogen/server'; +import {useLoaderData} from '@remix-run/react'; + +export async function loader({context}) { + const {content} = await fetchContent('home-hero', {scope: context}); + + return {content}; +} + +export default function HomeHeroRoute() { + const {content} = useLoaderData(); + + return ( +
      +
    • Headline: {content.headline}
    • +
    • View count: {content.viewCount}
    • +
    • Featured: {content.featured ? 'Yes' : 'No'}
    • +
    + ); +} +", + "language": "jsx", + "path": "app/routes/home-hero.jsx", + }, + ], +} +`; + +exports[`HydrogenExampleGenerator should generate the remix-ts route: remix-ts 1`] = ` +{ + "files": [ + { + "code": "import {fetchContent} from '@croct/plug-hydrogen/server'; +import {useLoaderData} from '@remix-run/react'; +import type {LoaderFunctionArgs} from '@shopify/remix-oxygen'; + +export async function loader({context}: LoaderFunctionArgs) { + const {content} = await fetchContent('home-hero', {scope: context}); + + return {content}; +} + +export default function HomeHeroRoute() { + const {content} = useLoaderData(); + + return ( +
      +
    • Headline: {content.headline}
    • +
    • View count: {content.viewCount}
    • +
    • Featured: {content.featured ? 'Yes' : 'No'}
    • +
    + ); +} +", + "language": "tsx", + "path": "app/routes/home-hero.tsx", + }, + ], +} +`; diff --git a/test/application/project/code/generation/slot/hydrogenExampleGenerator.test.ts b/test/application/project/code/generation/slot/hydrogenExampleGenerator.test.ts new file mode 100644 index 00000000..997c2eb4 --- /dev/null +++ b/test/application/project/code/generation/slot/hydrogenExampleGenerator.test.ts @@ -0,0 +1,87 @@ +import {readFileSync, readdirSync} from 'fs'; +import {basename, resolve} from 'path'; +import type {SlotDefinition} from '@/application/project/code/generation/slot/slotExampleGenerator'; +import type {Configuration} from '@/application/project/code/generation/slot/hydrogenExampleGenerator'; +import {HydrogenExampleGenerator} from '@/application/project/code/generation/slot/hydrogenExampleGenerator'; + +describe('HydrogenExampleGenerator', () => { + const fixturesPath = resolve(__dirname, 'fixtures'); + + function loadFixture(file: string): SlotDefinition { + return JSON.parse(readFileSync(resolve(fixturesPath, file), 'utf-8')); + } + + const fixtures = readdirSync(fixturesPath).filter(file => file.endsWith('.json')) + .map( + file => ({ + name: basename(file, '.json'), + definition: loadFixture(file), + }), + ); + + const baseOptions: Configuration = { + typescript: true, + framework: 'react-router', + routeFilePath: 'app/routes/%slug%.tsx', + routeComponentName: '%name%Route', + }; + + const variants: Array<{label: string, options: Partial}> = [ + { + label: 'react-router-ts', + options: { + framework: 'react-router', + typescript: true, + }, + }, + { + label: 'react-router-js', + options: { + framework: 'react-router', + typescript: false, + routeFilePath: 'app/routes/%slug%.jsx', + }, + }, + { + label: 'remix-ts', + options: { + framework: 'remix', + typescript: true, + }, + }, + { + label: 'remix-js', + options: { + framework: 'remix', + typescript: false, + routeFilePath: 'app/routes/%slug%.jsx', + }, + }, + ]; + + it.each(variants)('should generate the $label route', ({label, options}) => { + // A single fixture exercises every era/language variant; the content-shape variations are + // covered by the per-fixture cases below. + const example = new HydrogenExampleGenerator({...baseOptions, ...options}) + .generate(loadFixture('simpleStructure.json')); + + expect(example).toMatchSnapshot(label); + }); + + it.each(fixtures)('should generate a route for $name', ({name, definition}) => { + const example = new HydrogenExampleGenerator(baseOptions).generate(definition); + + expect(example).toMatchSnapshot(name); + }); + + it('fetches the slot server-side and renders the content directly', () => { + const [route] = new HydrogenExampleGenerator(baseOptions) + .generate(loadFixture('simpleStructure.json')) + .files; + + expect(route.code).toContain("const {content} = await fetchContent('home-hero', {scope: context});"); + expect(route.code).toContain('const {content} = useLoaderData();'); + expect(route.code).not.toContain(' { + return request.headers.get('Cookie'); + }, + }); + + return hydrogenContext; +} diff --git a/test/application/project/code/transformation/fixtures/hydrogen-context/noFactory.ts b/test/application/project/code/transformation/fixtures/hydrogen-context/noFactory.ts new file mode 100644 index 00000000..56247f07 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-context/noFactory.ts @@ -0,0 +1,8 @@ +export async function createAppLoadContext(request, env, executionContext) { + let pending; + const count = 1; + const built = factory.create(); + const handler = createRequestHandler(request); + + return handler; +} diff --git a/test/application/project/code/transformation/fixtures/hydrogen-context/noFunction.ts b/test/application/project/code/transformation/fixtures/hydrogen-context/noFunction.ts new file mode 100644 index 00000000..e0aaeec1 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-context/noFunction.ts @@ -0,0 +1,5 @@ +import {createHydrogenContext} from '@shopify/hydrogen'; + +const hydrogenContext = createHydrogenContext({env, request}); + +export {hydrogenContext}; diff --git a/test/application/project/code/transformation/fixtures/hydrogen-context/noParams.ts b/test/application/project/code/transformation/fixtures/hydrogen-context/noParams.ts new file mode 100644 index 00000000..d1311185 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-context/noParams.ts @@ -0,0 +1,7 @@ +import {createHydrogenContext} from '@shopify/hydrogen'; + +export async function createAppLoadContext() { + const hydrogenContext = createHydrogenContext({}); + + return hydrogenContext; +} diff --git a/test/application/project/code/transformation/fixtures/hydrogen-context/noReturn.ts b/test/application/project/code/transformation/fixtures/hydrogen-context/noReturn.ts new file mode 100644 index 00000000..050a12bf --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-context/noReturn.ts @@ -0,0 +1,11 @@ +import {createHydrogenContext} from '@shopify/hydrogen'; + +export async function createAppLoadContext(request, env) { + const hydrogenContext = createHydrogenContext({env, request}); + + if (!request) { + return; + } + + doSomething(hydrogenContext); +} diff --git a/test/application/project/code/transformation/fixtures/hydrogen-context/returnIdentifier.ts b/test/application/project/code/transformation/fixtures/hydrogen-context/returnIdentifier.ts new file mode 100644 index 00000000..6f34c1b1 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-context/returnIdentifier.ts @@ -0,0 +1,11 @@ +import {createHydrogenContext} from '@shopify/hydrogen'; + +export async function createAppLoadContext(request, env, executionContext) { + const hydrogenContext = createHydrogenContext({ + env, + request, + cache: await caches.open('hydrogen'), + }); + + return hydrogenContext; +} diff --git a/test/application/project/code/transformation/fixtures/hydrogen-context/returnObject.ts b/test/application/project/code/transformation/fixtures/hydrogen-context/returnObject.ts new file mode 100644 index 00000000..64e8f13a --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-context/returnObject.ts @@ -0,0 +1,10 @@ +import {createHydrogenContext} from '@shopify/hydrogen'; + +export async function createAppLoadContext(request, env, executionContext) { + const hydrogenContext = createHydrogenContext({env, request}); + + return { + ...hydrogenContext, + extra: true, + }; +} diff --git a/test/application/project/code/transformation/fixtures/hydrogen-cookies/alreadyPresent.ts b/test/application/project/code/transformation/fixtures/hydrogen-cookies/alreadyPresent.ts new file mode 100644 index 00000000..8bace5f2 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-cookies/alreadyPresent.ts @@ -0,0 +1,13 @@ +import {writeCroctCookies} from '@croct/plug-hydrogen/server'; + +export async function handleFetch(request, hydrogenContext) { + const response = await handleRequest(request); + + if (hydrogenContext.session.isPending) { + response.headers.set('Set-Cookie', await hydrogenContext.session.commit()); + } + + writeCroctCookies(response, hydrogenContext); + + return response; +} diff --git a/test/application/project/code/transformation/fixtures/hydrogen-cookies/alreadyPresentOutsideBlock.ts b/test/application/project/code/transformation/fixtures/hydrogen-cookies/alreadyPresentOutsideBlock.ts new file mode 100644 index 00000000..a69fa922 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-cookies/alreadyPresentOutsideBlock.ts @@ -0,0 +1,20 @@ +import {writeCroctCookies} from '@croct/plug-hydrogen/server'; + +export async function handleFetch(request, appLoadContext) { + let response; + + try { + response = await handleRequest(request); + + if (appLoadContext.session.isPending) { + response.headers.set('Set-Cookie', await appLoadContext.session.commit()); + } + } catch (error) { + response = new Response('error', {status: 500}); + } + + // Already called, but in the function body — not in the `try` block that holds the Set-Cookie. + writeCroctCookies(response, appLoadContext); + + return response; +} diff --git a/test/application/project/code/transformation/fixtures/hydrogen-cookies/arrowExpressionBody.ts b/test/application/project/code/transformation/fixtures/hydrogen-cookies/arrowExpressionBody.ts new file mode 100644 index 00000000..406b4e09 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-cookies/arrowExpressionBody.ts @@ -0,0 +1,4 @@ +export const handle = (request, hydrogenContext) => response.headers.set( + 'Set-Cookie', + hydrogenContext.session.commit(), +); diff --git a/test/application/project/code/transformation/fixtures/hydrogen-cookies/destructuredHeaders.ts b/test/application/project/code/transformation/fixtures/hydrogen-cookies/destructuredHeaders.ts new file mode 100644 index 00000000..79d65f98 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-cookies/destructuredHeaders.ts @@ -0,0 +1,8 @@ +export async function handleFetch(request, hydrogenContext) { + const response = await handleRequest(request); + const {headers} = response; + + headers.set('Set-Cookie', await hydrogenContext.session.commit()); + + return response; +} diff --git a/test/application/project/code/transformation/fixtures/hydrogen-cookies/differentVars.ts b/test/application/project/code/transformation/fixtures/hydrogen-cookies/differentVars.ts new file mode 100644 index 00000000..15676ed1 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-cookies/differentVars.ts @@ -0,0 +1,9 @@ +export async function handleFetch(request, appLoadContext) { + const res = await handleRequest(request); + + if (appLoadContext.session.isPending) { + res.headers.set('Set-Cookie', await appLoadContext.session.commit()); + } + + return res; +} diff --git a/test/application/project/code/transformation/fixtures/hydrogen-cookies/directSet.ts b/test/application/project/code/transformation/fixtures/hydrogen-cookies/directSet.ts new file mode 100644 index 00000000..5e9d0517 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-cookies/directSet.ts @@ -0,0 +1,7 @@ +export async function handleFetch(request, appLoadContext) { + const response = await handleRequest(request); + + response.headers.set('Set-Cookie', await appLoadContext.session.commit()); + + return response; +} diff --git a/test/application/project/code/transformation/fixtures/hydrogen-cookies/ifWrapped.ts b/test/application/project/code/transformation/fixtures/hydrogen-cookies/ifWrapped.ts new file mode 100644 index 00000000..0eb32eb9 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-cookies/ifWrapped.ts @@ -0,0 +1,15 @@ +import {createRequestHandler} from '@shopify/hydrogen'; + +export default { + async fetch(request, env, executionContext) { + const hydrogenContext = await createHydrogenRouterContext(request, env, executionContext); + const handleRequest = createRequestHandler({build: serverBuild, getContext: () => hydrogenContext}); + const response = await handleRequest(request); + + if (hydrogenContext.session.isPending) { + response.headers.set('Set-Cookie', await hydrogenContext.session.commit()); + } + + return response; + }, +}; diff --git a/test/application/project/code/transformation/fixtures/hydrogen-cookies/noFunction.ts b/test/application/project/code/transformation/fixtures/hydrogen-cookies/noFunction.ts new file mode 100644 index 00000000..6a1b322f --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-cookies/noFunction.ts @@ -0,0 +1,3 @@ +const response = new Response(); + +response.headers.set('Set-Cookie', await hydrogenContext.session.commit()); diff --git a/test/application/project/code/transformation/fixtures/hydrogen-cookies/noSession.ts b/test/application/project/code/transformation/fixtures/hydrogen-cookies/noSession.ts new file mode 100644 index 00000000..0b0d9fbd --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-cookies/noSession.ts @@ -0,0 +1,7 @@ +export async function handleFetch(request) { + const response = await handleRequest(request); + + response.headers.set('Set-Cookie', buildCookie()); + + return response; +} diff --git a/test/application/project/code/transformation/fixtures/hydrogen-cookies/nonHeadersObject.ts b/test/application/project/code/transformation/fixtures/hydrogen-cookies/nonHeadersObject.ts new file mode 100644 index 00000000..7ca51226 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-cookies/nonHeadersObject.ts @@ -0,0 +1,7 @@ +export async function handleFetch(request, hydrogenContext) { + const response = await handleRequest(request); + + response.cookies.set('Set-Cookie', await hydrogenContext.session.commit()); + + return response; +} diff --git a/test/application/project/code/transformation/fixtures/hydrogen-cookies/notSetCookieHeader.ts b/test/application/project/code/transformation/fixtures/hydrogen-cookies/notSetCookieHeader.ts new file mode 100644 index 00000000..a96888af --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-cookies/notSetCookieHeader.ts @@ -0,0 +1,7 @@ +export async function handleFetch(request, hydrogenContext) { + const response = await handleRequest(request); + + response.headers.set('Content-Type', 'text/html'); + + return response; +} diff --git a/test/application/project/code/transformation/fixtures/hydrogen-cookies/tryCatchWrapped.ts b/test/application/project/code/transformation/fixtures/hydrogen-cookies/tryCatchWrapped.ts new file mode 100644 index 00000000..7b1eeb3b --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-cookies/tryCatchWrapped.ts @@ -0,0 +1,30 @@ +import {storefrontRedirect, createRequestHandler} from '@shopify/hydrogen'; +import {createAppLoadContext} from '~/lib/context'; + +export default { + async fetch(request, env, executionContext) { + try { + const appLoadContext = await createAppLoadContext(request, env, executionContext); + const handleRequest = createRequestHandler({ + build: remixBuild, + mode: process.env.NODE_ENV, + getLoadContext: () => appLoadContext, + }); + + const response = await handleRequest(request); + + if (appLoadContext.session.isPending) { + response.headers.set('Set-Cookie', await appLoadContext.session.commit()); + } + + if (response.status === 404) { + return storefrontRedirect({request, response, storefront: appLoadContext.storefront}); + } + + return response; + } catch (error) { + console.error(error); + return new Response('An unexpected error occurred', {status: 500}); + } + }, +}; diff --git a/test/application/project/code/transformation/fixtures/hydrogen-csp/aliasedImport.ts b/test/application/project/code/transformation/fixtures/hydrogen-csp/aliasedImport.ts new file mode 100644 index 00000000..42c9f8c2 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-csp/aliasedImport.ts @@ -0,0 +1,5 @@ +import {createContentSecurityPolicy as createCsp} from '@shopify/hydrogen'; + +const csp = createCsp({ + connectSrc: ['https://example.com'], +}); diff --git a/test/application/project/code/transformation/fixtures/hydrogen-csp/alreadyPresent.ts b/test/application/project/code/transformation/fixtures/hydrogen-csp/alreadyPresent.ts new file mode 100644 index 00000000..f88b9447 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-csp/alreadyPresent.ts @@ -0,0 +1,5 @@ +import {createContentSecurityPolicy} from '@shopify/hydrogen'; + +const csp = createContentSecurityPolicy({ + connectSrc: ['https://api.croct.io'], +}); diff --git a/test/application/project/code/transformation/fixtures/hydrogen-csp/emptyCall.ts b/test/application/project/code/transformation/fixtures/hydrogen-csp/emptyCall.ts new file mode 100644 index 00000000..c2f00b90 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-csp/emptyCall.ts @@ -0,0 +1,3 @@ +import {createContentSecurityPolicy} from '@shopify/hydrogen'; + +const csp = createContentSecurityPolicy(); diff --git a/test/application/project/code/transformation/fixtures/hydrogen-csp/emptyConnectSrc.ts b/test/application/project/code/transformation/fixtures/hydrogen-csp/emptyConnectSrc.ts new file mode 100644 index 00000000..ebc0c830 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-csp/emptyConnectSrc.ts @@ -0,0 +1,5 @@ +import {createContentSecurityPolicy} from '@shopify/hydrogen'; + +const csp = createContentSecurityPolicy({ + connectSrc: [], +}); diff --git a/test/application/project/code/transformation/fixtures/hydrogen-csp/existingConnectSrc.ts b/test/application/project/code/transformation/fixtures/hydrogen-csp/existingConnectSrc.ts new file mode 100644 index 00000000..49bd7c00 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-csp/existingConnectSrc.ts @@ -0,0 +1,5 @@ +import {createContentSecurityPolicy} from '@shopify/hydrogen'; + +const csp = createContentSecurityPolicy({ + connectSrc: ['https://example.com'], +}); diff --git a/test/application/project/code/transformation/fixtures/hydrogen-csp/memberCalleeCall.ts b/test/application/project/code/transformation/fixtures/hydrogen-csp/memberCalleeCall.ts new file mode 100644 index 00000000..428dd563 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-csp/memberCalleeCall.ts @@ -0,0 +1,3 @@ +const csp = security.createContentSecurityPolicy({ + connectSrc: ['https://example.com'], +}); diff --git a/test/application/project/code/transformation/fixtures/hydrogen-csp/noCall.ts b/test/application/project/code/transformation/fixtures/hydrogen-csp/noCall.ts new file mode 100644 index 00000000..382a7b76 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-csp/noCall.ts @@ -0,0 +1,3 @@ +const csp = buildPolicy({ + connectSrc: ['https://example.com'], +}); diff --git a/test/application/project/code/transformation/fixtures/hydrogen-csp/nonArrayConnectSrc.ts b/test/application/project/code/transformation/fixtures/hydrogen-csp/nonArrayConnectSrc.ts new file mode 100644 index 00000000..4e5b2a65 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-csp/nonArrayConnectSrc.ts @@ -0,0 +1,5 @@ +import {createContentSecurityPolicy} from '@shopify/hydrogen'; + +const csp = createContentSecurityPolicy({ + connectSrc: defaultSources, +}); diff --git a/test/application/project/code/transformation/fixtures/hydrogen-csp/nonObjectArg.ts b/test/application/project/code/transformation/fixtures/hydrogen-csp/nonObjectArg.ts new file mode 100644 index 00000000..52c31170 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-csp/nonObjectArg.ts @@ -0,0 +1,3 @@ +import {createContentSecurityPolicy} from '@shopify/hydrogen'; + +const csp = createContentSecurityPolicy(policyOptions); diff --git a/test/application/project/code/transformation/fixtures/hydrogen-csp/spreadOption.ts b/test/application/project/code/transformation/fixtures/hydrogen-csp/spreadOption.ts new file mode 100644 index 00000000..84861956 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-csp/spreadOption.ts @@ -0,0 +1,6 @@ +import {createContentSecurityPolicy} from '@shopify/hydrogen'; + +const csp = createContentSecurityPolicy({ + ...base, + connectSrc: ['https://example.com'], +}); diff --git a/test/application/project/code/transformation/fixtures/hydrogen-csp/standard.ts b/test/application/project/code/transformation/fixtures/hydrogen-csp/standard.ts new file mode 100644 index 00000000..a4fa3c0a --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-csp/standard.ts @@ -0,0 +1,12 @@ +import {createContentSecurityPolicy} from '@shopify/hydrogen'; + +export default function handleRequest(request, context) { + const {header} = createContentSecurityPolicy({ + shop: { + checkoutDomain: context.env.PUBLIC_CHECKOUT_DOMAIN, + storeDomain: context.env.PUBLIC_STORE_DOMAIN, + }, + }); + + return header; +} diff --git a/test/application/project/code/transformation/fixtures/hydrogen-csp/stringLiteralKey.ts b/test/application/project/code/transformation/fixtures/hydrogen-csp/stringLiteralKey.ts new file mode 100644 index 00000000..6697154a --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-csp/stringLiteralKey.ts @@ -0,0 +1,5 @@ +import {createContentSecurityPolicy} from '@shopify/hydrogen'; + +const csp = createContentSecurityPolicy({ + 'connectSrc': ['https://example.com'], +}); diff --git a/test/application/project/code/transformation/fixtures/hydrogen-middleware/absent.ts b/test/application/project/code/transformation/fixtures/hydrogen-middleware/absent.ts new file mode 100644 index 00000000..e636fdef --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-middleware/absent.ts @@ -0,0 +1,3 @@ +export default function App() { + return null; +} diff --git a/test/application/project/code/transformation/fixtures/hydrogen-middleware/alreadyRegistered.ts b/test/application/project/code/transformation/fixtures/hydrogen-middleware/alreadyRegistered.ts new file mode 100644 index 00000000..a2ff36cc --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-middleware/alreadyRegistered.ts @@ -0,0 +1,3 @@ +import {createCroctMiddleware} from '@croct/plug-hydrogen/server'; + +export const middleware = [createCroctMiddleware()]; diff --git a/test/application/project/code/transformation/fixtures/hydrogen-middleware/decoyConst.ts b/test/application/project/code/transformation/fixtures/hydrogen-middleware/decoyConst.ts new file mode 100644 index 00000000..5b5685c3 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-middleware/decoyConst.ts @@ -0,0 +1,5 @@ +import {logger} from './middleware/logger'; + +const VERSION = '1'; + +export const middleware = [logger()]; diff --git a/test/application/project/code/transformation/fixtures/hydrogen-middleware/emptyArray.ts b/test/application/project/code/transformation/fixtures/hydrogen-middleware/emptyArray.ts new file mode 100644 index 00000000..29b860af --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-middleware/emptyArray.ts @@ -0,0 +1 @@ +export const middleware = []; diff --git a/test/application/project/code/transformation/fixtures/hydrogen-middleware/existingArray.ts b/test/application/project/code/transformation/fixtures/hydrogen-middleware/existingArray.ts new file mode 100644 index 00000000..3a909f91 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-middleware/existingArray.ts @@ -0,0 +1,3 @@ +import {logger} from './middleware/logger'; + +export const middleware = [logger()]; diff --git a/test/application/project/code/transformation/fixtures/hydrogen-middleware/identifierInit.ts b/test/application/project/code/transformation/fixtures/hydrogen-middleware/identifierInit.ts new file mode 100644 index 00000000..dce3a402 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-middleware/identifierInit.ts @@ -0,0 +1,3 @@ +import {baseMiddleware} from './middleware/base'; + +export const middleware = baseMiddleware; diff --git a/test/application/project/code/transformation/fixtures/hydrogen-middleware/importPresentNoCall.ts b/test/application/project/code/transformation/fixtures/hydrogen-middleware/importPresentNoCall.ts new file mode 100644 index 00000000..1e882fe5 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-middleware/importPresentNoCall.ts @@ -0,0 +1,4 @@ +import {createCroctMiddleware} from '@croct/plug-hydrogen/server'; +import {logger} from './middleware/logger'; + +export const middleware = [logger()]; diff --git a/test/application/project/code/transformation/fixtures/hydrogen-middleware/multipleDeclarators.ts b/test/application/project/code/transformation/fixtures/hydrogen-middleware/multipleDeclarators.ts new file mode 100644 index 00000000..67e8ce96 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-middleware/multipleDeclarators.ts @@ -0,0 +1,3 @@ +import {logger} from './middleware/logger'; + +export const version = '1', middleware = [logger()]; diff --git a/test/application/project/code/transformation/fixtures/hydrogen-middleware/nonArrayInit.ts b/test/application/project/code/transformation/fixtures/hydrogen-middleware/nonArrayInit.ts new file mode 100644 index 00000000..2acf2ae8 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-middleware/nonArrayInit.ts @@ -0,0 +1,3 @@ +import {buildMiddleware} from './middleware'; + +export const middleware = buildMiddleware(); diff --git a/test/application/project/code/transformation/fixtures/hydrogen-middleware/typedArray.ts b/test/application/project/code/transformation/fixtures/hydrogen-middleware/typedArray.ts new file mode 100644 index 00000000..cd8e3e43 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-middleware/typedArray.ts @@ -0,0 +1,4 @@ +import type {MiddlewareFunction} from 'react-router'; +import {logger} from './middleware/logger'; + +export const middleware: MiddlewareFunction[] = [logger()]; diff --git a/test/application/project/code/transformation/fixtures/hydrogen-middleware/uninitializedLet.ts b/test/application/project/code/transformation/fixtures/hydrogen-middleware/uninitializedLet.ts new file mode 100644 index 00000000..d9f16d77 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/hydrogen-middleware/uninitializedLet.ts @@ -0,0 +1 @@ +export let middleware; diff --git a/test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfAlreadyWrapped.tsx b/test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfAlreadyWrapped.tsx new file mode 100644 index 00000000..4d74f2ae --- /dev/null +++ b/test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfAlreadyWrapped.tsx @@ -0,0 +1,12 @@ +import {Analytics} from '@shopify/hydrogen'; +import {CroctProvider} from '@croct/plug-react'; + +export default function App({data}) { + return + + + + + + ; +} diff --git a/test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfIdentifierElement.tsx b/test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfIdentifierElement.tsx new file mode 100644 index 00000000..5dca326b --- /dev/null +++ b/test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfIdentifierElement.tsx @@ -0,0 +1,5 @@ +export default function App({data}) { + return + + ; +} diff --git a/test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfMemberExpression.tsx b/test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfMemberExpression.tsx new file mode 100644 index 00000000..775acb9e --- /dev/null +++ b/test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfMemberExpression.tsx @@ -0,0 +1,9 @@ +import {Analytics} from '@shopify/hydrogen'; + +export default function App({data}) { + return + + + + ; +} diff --git a/test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfNamespaced.tsx b/test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfNamespaced.tsx new file mode 100644 index 00000000..a1c66bfb --- /dev/null +++ b/test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfNamespaced.tsx @@ -0,0 +1,7 @@ +export default function App() { + return + + + + ; +} diff --git a/test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfNotFound.tsx b/test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfNotFound.tsx new file mode 100644 index 00000000..a63adbd3 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfNotFound.tsx @@ -0,0 +1,7 @@ +export default function App({data}) { + return
    + + + +
    ; +} diff --git a/test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfRemixTernary.tsx b/test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfRemixTernary.tsx new file mode 100644 index 00000000..a7756031 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfRemixTernary.tsx @@ -0,0 +1,21 @@ +import {Analytics} from '@shopify/hydrogen'; + +export function Layout({children}) { + const data = useRouteLoaderData('root'); + + return + + {data ? ( + + {children} + + ) : ( + children + )} + + ; +} + +export default function App() { + return ; +} diff --git a/test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfSelfClosing.tsx b/test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfSelfClosing.tsx new file mode 100644 index 00000000..64a4d196 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/jsx-wrapper/childrenOfSelfClosing.tsx @@ -0,0 +1,5 @@ +import {Analytics} from '@shopify/hydrogen'; + +export default function App() { + return ; +} diff --git a/test/application/project/code/transformation/fixtures/jsx-wrapper/containerAliasedImport.tsx b/test/application/project/code/transformation/fixtures/jsx-wrapper/containerAliasedImport.tsx new file mode 100644 index 00000000..357ee84e --- /dev/null +++ b/test/application/project/code/transformation/fixtures/jsx-wrapper/containerAliasedImport.tsx @@ -0,0 +1,9 @@ +import {Analytics as Shopify} from '@shopify/hydrogen'; + +export default function App({data}) { + return + + + + ; +} diff --git a/test/application/project/code/transformation/fixtures/jsx-wrapper/containerAlreadyWrapped.tsx b/test/application/project/code/transformation/fixtures/jsx-wrapper/containerAlreadyWrapped.tsx new file mode 100644 index 00000000..4d74f2ae --- /dev/null +++ b/test/application/project/code/transformation/fixtures/jsx-wrapper/containerAlreadyWrapped.tsx @@ -0,0 +1,12 @@ +import {Analytics} from '@shopify/hydrogen'; +import {CroctProvider} from '@croct/plug-react'; + +export default function App({data}) { + return + + + + + + ; +} diff --git a/test/application/project/code/transformation/fixtures/jsx-wrapper/containerEarlyReturn.tsx b/test/application/project/code/transformation/fixtures/jsx-wrapper/containerEarlyReturn.tsx new file mode 100644 index 00000000..41c2ea9e --- /dev/null +++ b/test/application/project/code/transformation/fixtures/jsx-wrapper/containerEarlyReturn.tsx @@ -0,0 +1,17 @@ +import {Analytics} from '@shopify/hydrogen'; + +export default function App() { + const data = useRouteLoaderData('root'); + + if (!data) { + return ; + } + + return ( + + + + + + ); +} diff --git a/test/application/project/code/transformation/fixtures/jsx-wrapper/containerIdentifierElement.tsx b/test/application/project/code/transformation/fixtures/jsx-wrapper/containerIdentifierElement.tsx new file mode 100644 index 00000000..5dca326b --- /dev/null +++ b/test/application/project/code/transformation/fixtures/jsx-wrapper/containerIdentifierElement.tsx @@ -0,0 +1,5 @@ +export default function App({data}) { + return + + ; +} diff --git a/test/application/project/code/transformation/fixtures/jsx-wrapper/containerMemberExpression.tsx b/test/application/project/code/transformation/fixtures/jsx-wrapper/containerMemberExpression.tsx new file mode 100644 index 00000000..775acb9e --- /dev/null +++ b/test/application/project/code/transformation/fixtures/jsx-wrapper/containerMemberExpression.tsx @@ -0,0 +1,9 @@ +import {Analytics} from '@shopify/hydrogen'; + +export default function App({data}) { + return + + + + ; +} diff --git a/test/application/project/code/transformation/fixtures/jsx-wrapper/containerNamespaced.tsx b/test/application/project/code/transformation/fixtures/jsx-wrapper/containerNamespaced.tsx new file mode 100644 index 00000000..a1c66bfb --- /dev/null +++ b/test/application/project/code/transformation/fixtures/jsx-wrapper/containerNamespaced.tsx @@ -0,0 +1,7 @@ +export default function App() { + return + + + + ; +} diff --git a/test/application/project/code/transformation/fixtures/jsx-wrapper/containerNotFound.tsx b/test/application/project/code/transformation/fixtures/jsx-wrapper/containerNotFound.tsx new file mode 100644 index 00000000..a63adbd3 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/jsx-wrapper/containerNotFound.tsx @@ -0,0 +1,7 @@ +export default function App({data}) { + return
    + + + +
    ; +} diff --git a/test/application/project/code/transformation/fixtures/jsx-wrapper/containerRemixTernary.tsx b/test/application/project/code/transformation/fixtures/jsx-wrapper/containerRemixTernary.tsx new file mode 100644 index 00000000..a7756031 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/jsx-wrapper/containerRemixTernary.tsx @@ -0,0 +1,21 @@ +import {Analytics} from '@shopify/hydrogen'; + +export function Layout({children}) { + const data = useRouteLoaderData('root'); + + return + + {data ? ( + + {children} + + ) : ( + children + )} + + ; +} + +export default function App() { + return ; +} diff --git a/test/application/project/code/transformation/fixtures/jsx-wrapper/containerSelfClosing.tsx b/test/application/project/code/transformation/fixtures/jsx-wrapper/containerSelfClosing.tsx new file mode 100644 index 00000000..64a4d196 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/jsx-wrapper/containerSelfClosing.tsx @@ -0,0 +1,5 @@ +import {Analytics} from '@shopify/hydrogen'; + +export default function App() { + return ; +} diff --git a/test/application/project/code/transformation/fixtures/jsx-wrapper/defaultExportUninitializedReference.tsx b/test/application/project/code/transformation/fixtures/jsx-wrapper/defaultExportUninitializedReference.tsx new file mode 100644 index 00000000..1f5529e2 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/jsx-wrapper/defaultExportUninitializedReference.tsx @@ -0,0 +1,3 @@ +let Component; + +export default Component; diff --git a/test/application/project/code/transformation/fixtures/jsx-wrapper/targetComponentMemberExpression.tsx b/test/application/project/code/transformation/fixtures/jsx-wrapper/targetComponentMemberExpression.tsx new file mode 100644 index 00000000..7b77ce65 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/jsx-wrapper/targetComponentMemberExpression.tsx @@ -0,0 +1,9 @@ +import {Theme} from 'ui'; + +export default function App({Component, pageProps}) { + return
    + + + +
    ; +} diff --git a/test/application/project/code/transformation/fixtures/jsx-wrapper/targetSingleChild.tsx b/test/application/project/code/transformation/fixtures/jsx-wrapper/targetSingleChild.tsx new file mode 100644 index 00000000..36ecab86 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/jsx-wrapper/targetSingleChild.tsx @@ -0,0 +1,3 @@ +export default function App({Component, pageProps}) { + return
    ; +} diff --git a/test/application/project/code/transformation/fixtures/nextjs-proxy/existingProxyReexportWithConfig.ts b/test/application/project/code/transformation/fixtures/nextjs-proxy/existingProxyReexportWithConfig.ts new file mode 100644 index 00000000..72745167 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/nextjs-proxy/existingProxyReexportWithConfig.ts @@ -0,0 +1,5 @@ +export { proxy } from "@croct/plug-next/proxy"; + +export const config = { + matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'], +} diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/aliasedDefineConfig.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/aliasedDefineConfig.ts new file mode 100644 index 00000000..91037a6e --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/aliasedDefineConfig.ts @@ -0,0 +1,6 @@ +import {defineConfig as define} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; + +export default define({ + plugins: [hydrogen()], +}); diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/alreadyRegistered.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/alreadyRegistered.ts new file mode 100644 index 00000000..350e59a6 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/alreadyRegistered.ts @@ -0,0 +1,7 @@ +import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; +import {croct} from '@croct/plug-hydrogen/vite'; + +export default defineConfig({ + plugins: [hydrogen(), croct()], +}); diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/asConstConfig.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/asConstConfig.ts new file mode 100644 index 00000000..29cb5ee0 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/asConstConfig.ts @@ -0,0 +1,7 @@ +import {defineConfig} from 'vite'; +import type {UserConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; + +export default defineConfig({ + plugins: [hydrogen()], +}) as UserConfig; diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/bareObjectExport.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/bareObjectExport.ts new file mode 100644 index 00000000..7e270761 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/bareObjectExport.ts @@ -0,0 +1,5 @@ +import {hydrogen} from '@shopify/hydrogen/vite'; + +export default { + plugins: [hydrogen()], +}; diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/bareObjectIndirectVariable.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/bareObjectIndirectVariable.ts new file mode 100644 index 00000000..41124fc2 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/bareObjectIndirectVariable.ts @@ -0,0 +1,7 @@ +import {hydrogen} from '@shopify/hydrogen/vite'; + +const config = { + plugins: [hydrogen()], +}; + +export default config; diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/emptyDefineCall.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/emptyDefineCall.ts new file mode 100644 index 00000000..ed8b1878 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/emptyDefineCall.ts @@ -0,0 +1,3 @@ +import {defineConfig} from 'vite'; + +export default defineConfig(); diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/emptyPlugins.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/emptyPlugins.ts new file mode 100644 index 00000000..228faab6 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/emptyPlugins.ts @@ -0,0 +1,5 @@ +import {defineConfig} from 'vite'; + +export default defineConfig({ + plugins: [], +}); diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/functionConfig.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/functionConfig.ts new file mode 100644 index 00000000..176cb1eb --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/functionConfig.ts @@ -0,0 +1,6 @@ +import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; + +export default defineConfig(() => ({ + plugins: [hydrogen()], +})); diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/functionDefaultExport.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/functionDefaultExport.ts new file mode 100644 index 00000000..ad8de35c --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/functionDefaultExport.ts @@ -0,0 +1,3 @@ +export default function config() { + return {plugins: []}; +} diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/importPresentNoCall.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/importPresentNoCall.ts new file mode 100644 index 00000000..b843f069 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/importPresentNoCall.ts @@ -0,0 +1,7 @@ +import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; +import {croct} from '@croct/plug-hydrogen/vite'; + +export default defineConfig({ + plugins: [hydrogen()], +}); diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/indirectConfigVariable.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/indirectConfigVariable.ts new file mode 100644 index 00000000..84d2c40a --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/indirectConfigVariable.ts @@ -0,0 +1,8 @@ +import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; + +const config = defineConfig({ + plugins: [hydrogen()], +}); + +export default config; diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/indirectConfigWithExtraDeclarator.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/indirectConfigWithExtraDeclarator.ts new file mode 100644 index 00000000..23a01583 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/indirectConfigWithExtraDeclarator.ts @@ -0,0 +1,9 @@ +import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; + +const version = '2024'; +const config = defineConfig({ + plugins: [hydrogen()], +}); + +export default config; diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/indirectNonDefineCall.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/indirectNonDefineCall.ts new file mode 100644 index 00000000..9d39fc1b --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/indirectNonDefineCall.ts @@ -0,0 +1,9 @@ +import {hydrogen} from '@shopify/hydrogen/vite'; + +function build() { + return {plugins: [hydrogen()]}; +} + +const config = build(); + +export default config; diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/memberCalleeExport.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/memberCalleeExport.ts new file mode 100644 index 00000000..57c25a06 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/memberCalleeExport.ts @@ -0,0 +1,6 @@ +import * as vite from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; + +export default vite.defineConfig({ + plugins: [hydrogen()], +}); diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/noPluginsKey.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/noPluginsKey.ts new file mode 100644 index 00000000..949a630a --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/noPluginsKey.ts @@ -0,0 +1,7 @@ +import {defineConfig} from 'vite'; + +export default defineConfig({ + server: { + port: 3000, + }, +}); diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/nonArrayPlugins.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/nonArrayPlugins.ts new file mode 100644 index 00000000..dece227e --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/nonArrayPlugins.ts @@ -0,0 +1,8 @@ +import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; + +const basePlugins = [hydrogen()]; + +export default defineConfig({ + plugins: basePlugins, +}); diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/nonDefineConfigCall.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/nonDefineConfigCall.ts new file mode 100644 index 00000000..206fa255 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/nonDefineConfigCall.ts @@ -0,0 +1,9 @@ +import {hydrogen} from '@shopify/hydrogen/vite'; + +function wrapConfig(config) { + return config; +} + +export default wrapConfig({ + plugins: [hydrogen()], +}); diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/positionStart.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/positionStart.ts new file mode 100644 index 00000000..6175c888 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/positionStart.ts @@ -0,0 +1,6 @@ +import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; + +export default defineConfig({ + plugins: [hydrogen()], +}); diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/remixPlugins.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/remixPlugins.ts new file mode 100644 index 00000000..70ded5b6 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/remixPlugins.ts @@ -0,0 +1,16 @@ +import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; +import {oxygen} from '@shopify/mini-oxygen/vite'; +import {vitePlugin as remix} from '@remix-run/dev'; +import tsconfigPaths from 'vite-tsconfig-paths'; + +export default defineConfig({ + plugins: [ + hydrogen(), + oxygen(), + remix({ + presets: [hydrogen.v3preset()], + }), + tsconfigPaths(), + ], +}); diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/satisfiesConfig.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/satisfiesConfig.ts new file mode 100644 index 00000000..cac15a9a --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/satisfiesConfig.ts @@ -0,0 +1,7 @@ +import {defineConfig} from 'vite'; +import type {UserConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; + +export default defineConfig({ + plugins: [hydrogen()], +}) satisfies UserConfig; diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/sparsePluginsArray.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/sparsePluginsArray.ts new file mode 100644 index 00000000..c686a8fb --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/sparsePluginsArray.ts @@ -0,0 +1,7 @@ +import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; +import {croct} from '@croct/plug-hydrogen/vite'; + +export default defineConfig({ + plugins: [hydrogen(), , croct()], +}); diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/spreadConfigProperties.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/spreadConfigProperties.ts new file mode 100644 index 00000000..6568eb48 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/spreadConfigProperties.ts @@ -0,0 +1,9 @@ +import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; + +const base = {server: {port: 3000}}; + +export default defineConfig({ + ...base, + plugins: [hydrogen()], +}); diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/standard.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/standard.ts new file mode 100644 index 00000000..60276dac --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/standard.ts @@ -0,0 +1,8 @@ +import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; +import {oxygen} from '@shopify/mini-oxygen/vite'; +import {reactRouter} from '@react-router/dev/vite'; + +export default defineConfig({ + plugins: [hydrogen(), oxygen(), reactRouter()], +}); diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/stringLiteralPluginsKey.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/stringLiteralPluginsKey.ts new file mode 100644 index 00000000..d89bd99d --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/stringLiteralPluginsKey.ts @@ -0,0 +1,6 @@ +import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; + +export default defineConfig({ + 'plugins': [hydrogen()], +}); diff --git a/test/application/project/code/transformation/fixtures/vite-config-plugin/uninitializedBinding.ts b/test/application/project/code/transformation/fixtures/vite-config-plugin/uninitializedBinding.ts new file mode 100644 index 00000000..a69c3c75 --- /dev/null +++ b/test/application/project/code/transformation/fixtures/vite-config-plugin/uninitializedBinding.ts @@ -0,0 +1,5 @@ +import {defineConfig} from 'vite'; + +let config; + +export default config; diff --git a/test/application/project/code/transformation/javascript/__snapshots__/hydrogenContextCodemod.test.ts.snap b/test/application/project/code/transformation/javascript/__snapshots__/hydrogenContextCodemod.test.ts.snap new file mode 100644 index 00000000..2d781387 --- /dev/null +++ b/test/application/project/code/transformation/javascript/__snapshots__/hydrogenContextCodemod.test.ts.snap @@ -0,0 +1,200 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`HydrogenContextCodemod should correctly transform aliasedFactory.ts: aliasedFactory.ts 1`] = ` +"import { createCroctContext } from "@croct/plug-hydrogen/server"; +import {createHydrogenContext as createContext} from '@shopify/hydrogen'; + +export async function createAppLoadContext(request, env, executionContext) { + const hydrogenContext = createContext({env, request}); + + return { + ...hydrogenContext, + extra: true, + croct: await createCroctContext(request, hydrogenContext) + }; +} +" +`; + +exports[`HydrogenContextCodemod should correctly transform alreadyWired.ts: alreadyWired.ts 1`] = ` +"import {createHydrogenContext} from '@shopify/hydrogen'; +import {createCroctContext} from '@croct/plug-hydrogen/server'; + +export async function createAppLoadContext(request, env, executionContext) { + const hydrogenContext = createHydrogenContext({env, request}); + + return { + ...hydrogenContext, + croct: await createCroctContext(request, hydrogenContext), + }; +} +" +`; + +exports[`HydrogenContextCodemod should correctly transform awaitedContext.ts: awaitedContext.ts 1`] = ` +"import { createCroctContext } from "@croct/plug-hydrogen/server"; +import {createHydrogenContext} from '@shopify/hydrogen'; + +export async function createAppLoadContext(request, env, executionContext) { + const hydrogenContext = await createHydrogenContext({env, request}); + + return { + ...hydrogenContext, + croct: await createCroctContext(request, hydrogenContext) + }; +} +" +`; + +exports[`HydrogenContextCodemod should correctly transform croctAlreadyOnObject.ts: croctAlreadyOnObject.ts 1`] = ` +"import {createHydrogenContext} from '@shopify/hydrogen'; + +export async function createAppLoadContext(request, env, executionContext) { + const hydrogenContext = createHydrogenContext({env, request}); + + return { + ...hydrogenContext, + croct: customCroct, + }; +} +" +`; + +exports[`HydrogenContextCodemod should correctly transform croctStringLiteralKey.ts: croctStringLiteralKey.ts 1`] = ` +"import {createHydrogenContext} from '@shopify/hydrogen'; + +export async function createAppLoadContext(request, env) { + const hydrogenContext = createHydrogenContext({env, request}); + + return { + ...hydrogenContext, + 'croct': customCroct, + }; +} +" +`; + +exports[`HydrogenContextCodemod should correctly transform destructuredContextId.ts: destructuredContextId.ts 1`] = ` +"import {createHydrogenContext} from '@shopify/hydrogen'; + +export async function createAppLoadContext(request, env) { + const {storefront} = createHydrogenContext({env, request}); + + return {storefront}; +} +" +`; + +exports[`HydrogenContextCodemod should correctly transform destructuredParam.ts: destructuredParam.ts 1`] = ` +"import {createHydrogenContext} from '@shopify/hydrogen'; + +export async function createAppLoadContext({request, env}) { + const hydrogenContext = createHydrogenContext({env, request}); + + return hydrogenContext; +} +" +`; + +exports[`HydrogenContextCodemod should correctly transform nestedReturn.ts: nestedReturn.ts 1`] = ` +"import { createCroctContext } from "@croct/plug-hydrogen/server"; +import {createHydrogenContext} from '@shopify/hydrogen'; + +export async function createAppLoadContext(request, env) { + const hydrogenContext = createHydrogenContext({env, request}); + + hydrogenContext.cart = createCartHandler({ + getCartId: () => { + return request.headers.get('Cookie'); + }, + }); + + return { + ...hydrogenContext, + croct: await createCroctContext(request, hydrogenContext) + }; +} +" +`; + +exports[`HydrogenContextCodemod should correctly transform noFactory.ts: noFactory.ts 1`] = ` +"export async function createAppLoadContext(request, env, executionContext) { + let pending; + const count = 1; + const built = factory.create(); + const handler = createRequestHandler(request); + + return handler; +} +" +`; + +exports[`HydrogenContextCodemod should correctly transform noFunction.ts: noFunction.ts 1`] = ` +"import {createHydrogenContext} from '@shopify/hydrogen'; + +const hydrogenContext = createHydrogenContext({env, request}); + +export {hydrogenContext}; +" +`; + +exports[`HydrogenContextCodemod should correctly transform noParams.ts: noParams.ts 1`] = ` +"import {createHydrogenContext} from '@shopify/hydrogen'; + +export async function createAppLoadContext() { + const hydrogenContext = createHydrogenContext({}); + + return hydrogenContext; +} +" +`; + +exports[`HydrogenContextCodemod should correctly transform noReturn.ts: noReturn.ts 1`] = ` +"import {createHydrogenContext} from '@shopify/hydrogen'; + +export async function createAppLoadContext(request, env) { + const hydrogenContext = createHydrogenContext({env, request}); + + if (!request) { + return; + } + + doSomething(hydrogenContext); +} +" +`; + +exports[`HydrogenContextCodemod should correctly transform returnIdentifier.ts: returnIdentifier.ts 1`] = ` +"import { createCroctContext } from "@croct/plug-hydrogen/server"; +import {createHydrogenContext} from '@shopify/hydrogen'; + +export async function createAppLoadContext(request, env, executionContext) { + const hydrogenContext = createHydrogenContext({ + env, + request, + cache: await caches.open('hydrogen'), + }); + + return { + ...hydrogenContext, + croct: await createCroctContext(request, hydrogenContext) + }; +} +" +`; + +exports[`HydrogenContextCodemod should correctly transform returnObject.ts: returnObject.ts 1`] = ` +"import { createCroctContext } from "@croct/plug-hydrogen/server"; +import {createHydrogenContext} from '@shopify/hydrogen'; + +export async function createAppLoadContext(request, env, executionContext) { + const hydrogenContext = createHydrogenContext({env, request}); + + return { + ...hydrogenContext, + extra: true, + croct: await createCroctContext(request, hydrogenContext) + }; +} +" +`; diff --git a/test/application/project/code/transformation/javascript/__snapshots__/hydrogenCookiesCodemod.test.ts.snap b/test/application/project/code/transformation/javascript/__snapshots__/hydrogenCookiesCodemod.test.ts.snap new file mode 100644 index 00000000..addb2ed9 --- /dev/null +++ b/test/application/project/code/transformation/javascript/__snapshots__/hydrogenCookiesCodemod.test.ts.snap @@ -0,0 +1,189 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`HydrogenCookiesCodemod should correctly transform alreadyPresent.ts: alreadyPresent.ts 1`] = ` +"import {writeCroctCookies} from '@croct/plug-hydrogen/server'; + +export async function handleFetch(request, hydrogenContext) { + const response = await handleRequest(request); + + if (hydrogenContext.session.isPending) { + response.headers.set('Set-Cookie', await hydrogenContext.session.commit()); + } + + writeCroctCookies(response, hydrogenContext); + + return response; +} +" +`; + +exports[`HydrogenCookiesCodemod should correctly transform alreadyPresentOutsideBlock.ts: alreadyPresentOutsideBlock.ts 1`] = ` +"import {writeCroctCookies} from '@croct/plug-hydrogen/server'; + +export async function handleFetch(request, appLoadContext) { + let response; + + try { + response = await handleRequest(request); + + if (appLoadContext.session.isPending) { + response.headers.set('Set-Cookie', await appLoadContext.session.commit()); + } + } catch (error) { + response = new Response('error', {status: 500}); + } + + // Already called, but in the function body — not in the \`try\` block that holds the Set-Cookie. + writeCroctCookies(response, appLoadContext); + + return response; +} +" +`; + +exports[`HydrogenCookiesCodemod should correctly transform arrowExpressionBody.ts: arrowExpressionBody.ts 1`] = ` +"export const handle = (request, hydrogenContext) => response.headers.set( + 'Set-Cookie', + hydrogenContext.session.commit(), +); +" +`; + +exports[`HydrogenCookiesCodemod should correctly transform destructuredHeaders.ts: destructuredHeaders.ts 1`] = ` +"export async function handleFetch(request, hydrogenContext) { + const response = await handleRequest(request); + const {headers} = response; + + headers.set('Set-Cookie', await hydrogenContext.session.commit()); + + return response; +} +" +`; + +exports[`HydrogenCookiesCodemod should correctly transform differentVars.ts: differentVars.ts 1`] = ` +"import { writeCroctCookies } from "@croct/plug-hydrogen/server"; + +export async function handleFetch(request, appLoadContext) { + const res = await handleRequest(request); + + if (appLoadContext.session.isPending) { + res.headers.set('Set-Cookie', await appLoadContext.session.commit()); + } + + writeCroctCookies(res, appLoadContext); + return res; +} +" +`; + +exports[`HydrogenCookiesCodemod should correctly transform directSet.ts: directSet.ts 1`] = ` +"import { writeCroctCookies } from "@croct/plug-hydrogen/server"; + +export async function handleFetch(request, appLoadContext) { + const response = await handleRequest(request); + response.headers.set('Set-Cookie', await appLoadContext.session.commit()); + writeCroctCookies(response, appLoadContext); + return response; +} +" +`; + +exports[`HydrogenCookiesCodemod should correctly transform ifWrapped.ts: ifWrapped.ts 1`] = ` +"import { writeCroctCookies } from "@croct/plug-hydrogen/server"; +import {createRequestHandler} from '@shopify/hydrogen'; + +export default { + async fetch(request, env, executionContext) { + const hydrogenContext = await createHydrogenRouterContext(request, env, executionContext); + const handleRequest = createRequestHandler({build: serverBuild, getContext: () => hydrogenContext}); + const response = await handleRequest(request); + + if (hydrogenContext.session.isPending) { + response.headers.set('Set-Cookie', await hydrogenContext.session.commit()); + } + + writeCroctCookies(response, hydrogenContext); + return response; + }, +}; +" +`; + +exports[`HydrogenCookiesCodemod should correctly transform noFunction.ts: noFunction.ts 1`] = ` +"const response = new Response(); + +response.headers.set('Set-Cookie', await hydrogenContext.session.commit()); +" +`; + +exports[`HydrogenCookiesCodemod should correctly transform noSession.ts: noSession.ts 1`] = ` +"export async function handleFetch(request) { + const response = await handleRequest(request); + + response.headers.set('Set-Cookie', buildCookie()); + + return response; +} +" +`; + +exports[`HydrogenCookiesCodemod should correctly transform nonHeadersObject.ts: nonHeadersObject.ts 1`] = ` +"export async function handleFetch(request, hydrogenContext) { + const response = await handleRequest(request); + + response.cookies.set('Set-Cookie', await hydrogenContext.session.commit()); + + return response; +} +" +`; + +exports[`HydrogenCookiesCodemod should correctly transform notSetCookieHeader.ts: notSetCookieHeader.ts 1`] = ` +"export async function handleFetch(request, hydrogenContext) { + const response = await handleRequest(request); + + response.headers.set('Content-Type', 'text/html'); + + return response; +} +" +`; + +exports[`HydrogenCookiesCodemod should correctly transform tryCatchWrapped.ts: tryCatchWrapped.ts 1`] = ` +"import { writeCroctCookies } from "@croct/plug-hydrogen/server"; +import {storefrontRedirect, createRequestHandler} from '@shopify/hydrogen'; +import {createAppLoadContext} from '~/lib/context'; + +export default { + async fetch(request, env, executionContext) { + try { + const appLoadContext = await createAppLoadContext(request, env, executionContext); + + const handleRequest = createRequestHandler({ + build: remixBuild, + mode: process.env.NODE_ENV, + getLoadContext: () => appLoadContext, + }); + + const response = await handleRequest(request); + + if (appLoadContext.session.isPending) { + response.headers.set('Set-Cookie', await appLoadContext.session.commit()); + } + + writeCroctCookies(response, appLoadContext); + + if (response.status === 404) { + return storefrontRedirect({request, response, storefront: appLoadContext.storefront}); + } + + return response; + } catch (error) { + console.error(error); + return new Response('An unexpected error occurred', {status: 500}); + } + }, +}; +" +`; diff --git a/test/application/project/code/transformation/javascript/__snapshots__/hydrogenCspCodemod.test.ts.snap b/test/application/project/code/transformation/javascript/__snapshots__/hydrogenCspCodemod.test.ts.snap new file mode 100644 index 00000000..8f9b38f4 --- /dev/null +++ b/test/application/project/code/transformation/javascript/__snapshots__/hydrogenCspCodemod.test.ts.snap @@ -0,0 +1,116 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`HydrogenCspCodemod should correctly transform aliasedImport.ts: aliasedImport.ts 1`] = ` +"import {createContentSecurityPolicy as createCsp} from '@shopify/hydrogen'; + +const csp = createCsp({ + connectSrc: ['https://example.com', "https://api.croct.io"], +}); +" +`; + +exports[`HydrogenCspCodemod should correctly transform alreadyPresent.ts: alreadyPresent.ts 1`] = ` +"import {createContentSecurityPolicy} from '@shopify/hydrogen'; + +const csp = createContentSecurityPolicy({ + connectSrc: ['https://api.croct.io'], +}); +" +`; + +exports[`HydrogenCspCodemod should correctly transform emptyCall.ts: emptyCall.ts 1`] = ` +"import {createContentSecurityPolicy} from '@shopify/hydrogen'; + +const csp = createContentSecurityPolicy({ + connectSrc: ["https://api.croct.io"] +}); +" +`; + +exports[`HydrogenCspCodemod should correctly transform emptyConnectSrc.ts: emptyConnectSrc.ts 1`] = ` +"import {createContentSecurityPolicy} from '@shopify/hydrogen'; + +const csp = createContentSecurityPolicy({ + connectSrc: ["https://api.croct.io"], +}); +" +`; + +exports[`HydrogenCspCodemod should correctly transform existingConnectSrc.ts: existingConnectSrc.ts 1`] = ` +"import {createContentSecurityPolicy} from '@shopify/hydrogen'; + +const csp = createContentSecurityPolicy({ + connectSrc: ['https://example.com', "https://api.croct.io"], +}); +" +`; + +exports[`HydrogenCspCodemod should correctly transform memberCalleeCall.ts: memberCalleeCall.ts 1`] = ` +"const csp = security.createContentSecurityPolicy({ + connectSrc: ['https://example.com'], +}); +" +`; + +exports[`HydrogenCspCodemod should correctly transform noCall.ts: noCall.ts 1`] = ` +"const csp = buildPolicy({ + connectSrc: ['https://example.com'], +}); +" +`; + +exports[`HydrogenCspCodemod should correctly transform nonArrayConnectSrc.ts: nonArrayConnectSrc.ts 1`] = ` +"import {createContentSecurityPolicy} from '@shopify/hydrogen'; + +const csp = createContentSecurityPolicy({ + connectSrc: [ + ...(Array.isArray(defaultSources) ? defaultSources : [defaultSources]), + "https://api.croct.io" + ], +}); +" +`; + +exports[`HydrogenCspCodemod should correctly transform nonObjectArg.ts: nonObjectArg.ts 1`] = ` +"import {createContentSecurityPolicy} from '@shopify/hydrogen'; + +const csp = createContentSecurityPolicy(policyOptions); +" +`; + +exports[`HydrogenCspCodemod should correctly transform spreadOption.ts: spreadOption.ts 1`] = ` +"import {createContentSecurityPolicy} from '@shopify/hydrogen'; + +const csp = createContentSecurityPolicy({ + ...base, + connectSrc: ['https://example.com', "https://api.croct.io"], +}); +" +`; + +exports[`HydrogenCspCodemod should correctly transform standard.ts: standard.ts 1`] = ` +"import {createContentSecurityPolicy} from '@shopify/hydrogen'; + +export default function handleRequest(request, context) { + const {header} = createContentSecurityPolicy({ + shop: { + checkoutDomain: context.env.PUBLIC_CHECKOUT_DOMAIN, + storeDomain: context.env.PUBLIC_STORE_DOMAIN, + }, + + connectSrc: ["https://api.croct.io"] + }); + + return header; +} +" +`; + +exports[`HydrogenCspCodemod should correctly transform stringLiteralKey.ts: stringLiteralKey.ts 1`] = ` +"import {createContentSecurityPolicy} from '@shopify/hydrogen'; + +const csp = createContentSecurityPolicy({ + 'connectSrc': ['https://example.com', "https://api.croct.io"], +}); +" +`; diff --git a/test/application/project/code/transformation/javascript/__snapshots__/hydrogenMiddlewareCodemod.test.ts.snap b/test/application/project/code/transformation/javascript/__snapshots__/hydrogenMiddlewareCodemod.test.ts.snap new file mode 100644 index 00000000..38bc11f4 --- /dev/null +++ b/test/application/project/code/transformation/javascript/__snapshots__/hydrogenMiddlewareCodemod.test.ts.snap @@ -0,0 +1,93 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`HydrogenMiddlewareCodemod should correctly transform absent.ts: absent.ts 1`] = ` +"import { createCroctMiddleware } from "@croct/plug-hydrogen/server"; + +export default function App() { + return null; +} + +export const middleware = [createCroctMiddleware()]; +" +`; + +exports[`HydrogenMiddlewareCodemod should correctly transform alreadyRegistered.ts: alreadyRegistered.ts 1`] = ` +"import {createCroctMiddleware} from '@croct/plug-hydrogen/server'; + +export const middleware = [createCroctMiddleware()]; +" +`; + +exports[`HydrogenMiddlewareCodemod should correctly transform decoyConst.ts: decoyConst.ts 1`] = ` +"import { createCroctMiddleware } from "@croct/plug-hydrogen/server"; +import {logger} from './middleware/logger'; +const VERSION = '1'; +export const middleware = [logger(), createCroctMiddleware()]; +" +`; + +exports[`HydrogenMiddlewareCodemod should correctly transform emptyArray.ts: emptyArray.ts 1`] = ` +"import { createCroctMiddleware } from "@croct/plug-hydrogen/server"; +export const middleware = [createCroctMiddleware()]; +" +`; + +exports[`HydrogenMiddlewareCodemod should correctly transform existingArray.ts: existingArray.ts 1`] = ` +"import { createCroctMiddleware } from "@croct/plug-hydrogen/server"; +import {logger} from './middleware/logger'; +export const middleware = [logger(), createCroctMiddleware()]; +" +`; + +exports[`HydrogenMiddlewareCodemod should correctly transform identifierInit.ts: identifierInit.ts 1`] = ` +"import { createCroctMiddleware } from "@croct/plug-hydrogen/server"; +import {baseMiddleware} from './middleware/base'; +const existingMiddleware = baseMiddleware; + +export const middleware = [ + ...(Array.isArray(existingMiddleware) ? existingMiddleware : [existingMiddleware]), + createCroctMiddleware() +]; +" +`; + +exports[`HydrogenMiddlewareCodemod should correctly transform importPresentNoCall.ts: importPresentNoCall.ts 1`] = ` +"import {createCroctMiddleware} from '@croct/plug-hydrogen/server'; +import {logger} from './middleware/logger'; + +export const middleware = [logger(), createCroctMiddleware()]; +" +`; + +exports[`HydrogenMiddlewareCodemod should correctly transform multipleDeclarators.ts: multipleDeclarators.ts 1`] = ` +"import { createCroctMiddleware } from "@croct/plug-hydrogen/server"; +import {logger} from './middleware/logger'; +export const version = '1', middleware = [logger(), createCroctMiddleware()]; +" +`; + +exports[`HydrogenMiddlewareCodemod should correctly transform nonArrayInit.ts: nonArrayInit.ts 1`] = ` +"import { createCroctMiddleware } from "@croct/plug-hydrogen/server"; +import {buildMiddleware} from './middleware'; +const existingMiddleware = buildMiddleware(); + +export const middleware = [ + ...(Array.isArray(existingMiddleware) ? existingMiddleware : [existingMiddleware]), + createCroctMiddleware() +]; +" +`; + +exports[`HydrogenMiddlewareCodemod should correctly transform typedArray.ts: typedArray.ts 1`] = ` +"import { createCroctMiddleware } from "@croct/plug-hydrogen/server"; +import type {MiddlewareFunction} from 'react-router'; +import {logger} from './middleware/logger'; +export const middleware: MiddlewareFunction[] = [logger(), createCroctMiddleware()]; +" +`; + +exports[`HydrogenMiddlewareCodemod should correctly transform uninitializedLet.ts: uninitializedLet.ts 1`] = ` +"import { createCroctMiddleware } from "@croct/plug-hydrogen/server"; +export let middleware = [createCroctMiddleware()]; +" +`; diff --git a/test/application/project/code/transformation/javascript/__snapshots__/jsxWrapperCodemod.test.ts.snap b/test/application/project/code/transformation/javascript/__snapshots__/jsxWrapperCodemod.test.ts.snap index 2a1c787c..936a6d6e 100644 --- a/test/application/project/code/transformation/javascript/__snapshots__/jsxWrapperCodemod.test.ts.snap +++ b/test/application/project/code/transformation/javascript/__snapshots__/jsxWrapperCodemod.test.ts.snap @@ -13,6 +13,279 @@ export default function App({Component, pageProps}: AppProps): ReactElement { " `; +exports[`JsxWrapperCodemod should correctly transform childrenOfAlreadyWrapped.tsx: childrenOfAlreadyWrapped.tsx 1`] = ` +"import {Analytics} from '@shopify/hydrogen'; +import {CroctProvider} from '@croct/plug-react'; + +export default function App({data}) { + return + + + + + + ; +} +" +`; + +exports[`JsxWrapperCodemod should correctly transform childrenOfIdentifierElement.tsx: childrenOfIdentifierElement.tsx 1`] = ` +"import { CroctProvider } from "@croct/plug-react"; + +export default function App({data}) { + return ( + + + + ); +} +" +`; + +exports[`JsxWrapperCodemod should correctly transform childrenOfMemberExpression.tsx: childrenOfMemberExpression.tsx 1`] = ` +"import { CroctProvider } from "@croct/plug-react"; +import {Analytics} from '@shopify/hydrogen'; + +export default function App({data}) { + return ( + + + + + + ); +} +" +`; + +exports[`JsxWrapperCodemod should correctly transform childrenOfNamespaced.tsx: childrenOfNamespaced.tsx 1`] = ` +"import { CroctProvider } from "@croct/plug-react"; + +export default function App() { + return ( + + + + + + ); +} +" +`; + +exports[`JsxWrapperCodemod should correctly transform childrenOfNotFound.tsx: childrenOfNotFound.tsx 1`] = ` +"import { CroctProvider } from "@croct/plug-react"; + +export default function App({data}) { + return ( +
    + + + +
    +
    ); +} +" +`; + +exports[`JsxWrapperCodemod should correctly transform childrenOfRemixTernary.tsx: childrenOfRemixTernary.tsx 1`] = ` +"import { CroctProvider } from "@croct/plug-react"; +import {Analytics} from '@shopify/hydrogen'; + +export function Layout({children}) { + const data = useRouteLoaderData('root'); + + return + + {data ? ( + + {children} + + ) : ( + children + )} + + ; +} + +export default function App() { + return ( + + ); +} +" +`; + +exports[`JsxWrapperCodemod should correctly transform childrenOfSelfClosing.tsx: childrenOfSelfClosing.tsx 1`] = ` +"import { CroctProvider } from "@croct/plug-react"; +import {Analytics} from '@shopify/hydrogen'; + +export default function App() { + return ( + + ); +} +" +`; + +exports[`JsxWrapperCodemod should correctly transform containerAliasedImport.tsx: containerAliasedImport.tsx 1`] = ` +"import { CroctProvider } from "@croct/plug-react"; +import {Analytics as Shopify} from '@shopify/hydrogen'; + +export default function App({data}) { + return ( + + + + + + + +); +} +" +`; + +exports[`JsxWrapperCodemod should correctly transform containerAlreadyWrapped.tsx: containerAlreadyWrapped.tsx 1`] = ` +"import {Analytics} from '@shopify/hydrogen'; +import {CroctProvider} from '@croct/plug-react'; + +export default function App({data}) { + return + + + + + + ; +} +" +`; + +exports[`JsxWrapperCodemod should correctly transform containerEarlyReturn.tsx: containerEarlyReturn.tsx 1`] = ` +"import { CroctProvider } from "@croct/plug-react"; +import {Analytics} from '@shopify/hydrogen'; + +export default function App() { + const data = useRouteLoaderData('root'); + + if (!data) { + return ; + } + + return ( + + + + + + + +); +} +" +`; + +exports[`JsxWrapperCodemod should correctly transform containerIdentifierElement.tsx: containerIdentifierElement.tsx 1`] = ` +"import { CroctProvider } from "@croct/plug-react"; + +export default function App({data}) { + return ( + + + + + +); +} +" +`; + +exports[`JsxWrapperCodemod should correctly transform containerMemberExpression.tsx: containerMemberExpression.tsx 1`] = ` +"import { CroctProvider } from "@croct/plug-react"; +import {Analytics} from '@shopify/hydrogen'; + +export default function App({data}) { + return ( + + + + + + + +); +} +" +`; + +exports[`JsxWrapperCodemod should correctly transform containerNamespaced.tsx: containerNamespaced.tsx 1`] = ` +"import { CroctProvider } from "@croct/plug-react"; + +export default function App() { + return ( + + + + + + + + ); +} +" +`; + +exports[`JsxWrapperCodemod should correctly transform containerNotFound.tsx: containerNotFound.tsx 1`] = ` +"export default function App({data}) { + return
    + + + +
    ; +} +" +`; + +exports[`JsxWrapperCodemod should correctly transform containerRemixTernary.tsx: containerRemixTernary.tsx 1`] = ` +"import { CroctProvider } from "@croct/plug-react"; +import {Analytics} from '@shopify/hydrogen'; + +export function Layout({children}) { + const data = useRouteLoaderData('root'); + + return ( + + {data ? ( + + + + {children} + + + + ) : ( + children + )} + + ); +} + +export default function App() { + return ; +} +" +`; + +exports[`JsxWrapperCodemod should correctly transform containerSelfClosing.tsx: containerSelfClosing.tsx 1`] = ` +"import {Analytics} from '@shopify/hydrogen'; + +export default function App() { + return ; +} +" +`; + exports[`JsxWrapperCodemod should correctly transform defaultExport.tsx: defaultExport.tsx 1`] = ` "import { CroctProvider } from "@croct/plug-react"; import type {AppProps} from "next/app"; @@ -121,6 +394,13 @@ export default Reference; " `; +exports[`JsxWrapperCodemod should correctly transform defaultExportUninitializedReference.tsx: defaultExportUninitializedReference.tsx 1`] = ` +"let Component; + +export default Component; +" +`; + exports[`JsxWrapperCodemod should correctly transform defaultExportUnrelated.tsx: defaultExportUnrelated.tsx 1`] = ` "export default 1; " @@ -380,3 +660,32 @@ export default function App({Component, pageProps}) { } " `; + +exports[`JsxWrapperCodemod should correctly transform targetComponentMemberExpression.tsx: targetComponentMemberExpression.tsx 1`] = ` +"import { CroctProvider } from "@croct/plug-react"; +import {Theme} from 'ui'; + +export default function App({Component, pageProps}) { + return (
    + + + + + +
    ); +} +" +`; + +exports[`JsxWrapperCodemod should correctly transform targetSingleChild.tsx: targetSingleChild.tsx 1`] = ` +"import { CroctProvider } from "@croct/plug-react"; + +export default function App({Component, pageProps}) { + return (
    + + + +
    ); +} +" +`; diff --git a/test/application/project/code/transformation/javascript/__snapshots__/nextJsProxyCodemod.test.ts.snap b/test/application/project/code/transformation/javascript/__snapshots__/nextJsProxyCodemod.test.ts.snap index ccb35262..0aaa9108 100644 --- a/test/application/project/code/transformation/javascript/__snapshots__/nextJsProxyCodemod.test.ts.snap +++ b/test/application/project/code/transformation/javascript/__snapshots__/nextJsProxyCodemod.test.ts.snap @@ -458,6 +458,15 @@ export const config = { " `; +exports[`NextJsProxyCodemod should correctly transform existingProxyReexportWithConfig.ts: existingProxyReexportWithConfig.ts 1`] = ` +"export { proxy } from "@croct/plug-next/proxy"; + +export const config = { + matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'], +} +" +`; + exports[`NextJsProxyCodemod should correctly transform matcherAlias.ts: matcherAlias.ts 1`] = ` "import { withCroct } from "@croct/plug-next/proxy"; diff --git a/test/application/project/code/transformation/javascript/__snapshots__/nuxtConfigModuleCodemod.test.ts.snap b/test/application/project/code/transformation/javascript/__snapshots__/nuxtConfigModuleCodemod.test.ts.snap index abf2d589..b03d5854 100644 --- a/test/application/project/code/transformation/javascript/__snapshots__/nuxtConfigModuleCodemod.test.ts.snap +++ b/test/application/project/code/transformation/javascript/__snapshots__/nuxtConfigModuleCodemod.test.ts.snap @@ -132,7 +132,7 @@ exports[`NuxtConfigModuleCodemod should correctly transform nonArrayModules.ts: "const modules = ['@nuxtjs/tailwindcss']; export default defineNuxtConfig({ - modules: modules, + modules: [...(Array.isArray(modules) ? modules : [modules]), "@croct/plug-nuxt"], }); " `; diff --git a/test/application/project/code/transformation/javascript/__snapshots__/viteConfigPluginCodemod.test.ts.snap b/test/application/project/code/transformation/javascript/__snapshots__/viteConfigPluginCodemod.test.ts.snap new file mode 100644 index 00000000..62c2e03d --- /dev/null +++ b/test/application/project/code/transformation/javascript/__snapshots__/viteConfigPluginCodemod.test.ts.snap @@ -0,0 +1,287 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ViteConfigPluginCodemod should correctly transform aliasedDefineConfig.ts: aliasedDefineConfig.ts 1`] = ` +"import { croct } from "@croct/plug-hydrogen/vite"; +import {defineConfig as define} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; + +export default define({ + plugins: [hydrogen(), croct()], +}); +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform alreadyRegistered.ts: alreadyRegistered.ts 1`] = ` +"import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; +import {croct} from '@croct/plug-hydrogen/vite'; + +export default defineConfig({ + plugins: [hydrogen(), croct()], +}); +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform asConstConfig.ts: asConstConfig.ts 1`] = ` +"import { croct } from "@croct/plug-hydrogen/vite"; +import {defineConfig} from 'vite'; +import type {UserConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; + +export default defineConfig({ + plugins: [hydrogen(), croct()], +}) as UserConfig; +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform bareObjectExport.ts: bareObjectExport.ts 1`] = ` +"import { croct } from "@croct/plug-hydrogen/vite"; +import {hydrogen} from '@shopify/hydrogen/vite'; + +export default { + plugins: [hydrogen(), croct()], +}; +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform bareObjectIndirectVariable.ts: bareObjectIndirectVariable.ts 1`] = ` +"import { croct } from "@croct/plug-hydrogen/vite"; +import {hydrogen} from '@shopify/hydrogen/vite'; + +const config = { + plugins: [hydrogen(), croct()], +}; + +export default config; +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform emptyDefineCall.ts: emptyDefineCall.ts 1`] = ` +"import {defineConfig} from 'vite'; + +export default defineConfig(); +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform emptyPlugins.ts: emptyPlugins.ts 1`] = ` +"import { croct } from "@croct/plug-hydrogen/vite"; +import {defineConfig} from 'vite'; + +export default defineConfig({ + plugins: [croct()], +}); +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform functionConfig.ts: functionConfig.ts 1`] = ` +"import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; + +export default defineConfig(() => ({ + plugins: [hydrogen()], +})); +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform functionDefaultExport.ts: functionDefaultExport.ts 1`] = ` +"export default function config() { + return {plugins: []}; +} +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform importPresentNoCall.ts: importPresentNoCall.ts 1`] = ` +"import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; +import {croct} from '@croct/plug-hydrogen/vite'; + +export default defineConfig({ + plugins: [hydrogen(), croct()], +}); +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform indirectConfigVariable.ts: indirectConfigVariable.ts 1`] = ` +"import { croct } from "@croct/plug-hydrogen/vite"; +import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; + +const config = defineConfig({ + plugins: [hydrogen(), croct()], +}); + +export default config; +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform indirectConfigWithExtraDeclarator.ts: indirectConfigWithExtraDeclarator.ts 1`] = ` +"import { croct } from "@croct/plug-hydrogen/vite"; +import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; +const version = '2024'; + +const config = defineConfig({ + plugins: [hydrogen(), croct()], +}); + +export default config; +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform indirectNonDefineCall.ts: indirectNonDefineCall.ts 1`] = ` +"import {hydrogen} from '@shopify/hydrogen/vite'; + +function build() { + return {plugins: [hydrogen()]}; +} + +const config = build(); + +export default config; +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform memberCalleeExport.ts: memberCalleeExport.ts 1`] = ` +"import * as vite from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; + +export default vite.defineConfig({ + plugins: [hydrogen()], +}); +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform noPluginsKey.ts: noPluginsKey.ts 1`] = ` +"import { croct } from "@croct/plug-hydrogen/vite"; +import {defineConfig} from 'vite'; + +export default defineConfig({ + server: { + port: 3000, + }, + + plugins: [croct()] +}); +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform nonArrayPlugins.ts: nonArrayPlugins.ts 1`] = ` +"import { croct } from "@croct/plug-hydrogen/vite"; +import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; +const basePlugins = [hydrogen()]; + +export default defineConfig({ + plugins: [...(Array.isArray(basePlugins) ? basePlugins : [basePlugins]), croct()], +}); +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform nonDefineConfigCall.ts: nonDefineConfigCall.ts 1`] = ` +"import {hydrogen} from '@shopify/hydrogen/vite'; + +function wrapConfig(config) { + return config; +} + +export default wrapConfig({ + plugins: [hydrogen()], +}); +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform positionStart.ts: positionStart.ts 1`] = ` +"import { croct } from "@croct/plug-hydrogen/vite"; +import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; + +export default defineConfig({ + plugins: [croct(), hydrogen()], +}); +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform remixPlugins.ts: remixPlugins.ts 1`] = ` +"import { croct } from "@croct/plug-hydrogen/vite"; +import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; +import {oxygen} from '@shopify/mini-oxygen/vite'; +import {vitePlugin as remix} from '@remix-run/dev'; +import tsconfigPaths from 'vite-tsconfig-paths'; + +export default defineConfig({ + plugins: [hydrogen(), oxygen(), remix({ + presets: [hydrogen.v3preset()], + }), tsconfigPaths(), croct()], +}); +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform satisfiesConfig.ts: satisfiesConfig.ts 1`] = ` +"import { croct } from "@croct/plug-hydrogen/vite"; +import {defineConfig} from 'vite'; +import type {UserConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; + +export default defineConfig({ + plugins: [hydrogen(), croct()], +}) satisfies UserConfig; +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform sparsePluginsArray.ts: sparsePluginsArray.ts 1`] = ` +"import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; +import {croct} from '@croct/plug-hydrogen/vite'; + +export default defineConfig({ + plugins: [hydrogen(), , croct()], +}); +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform spreadConfigProperties.ts: spreadConfigProperties.ts 1`] = ` +"import { croct } from "@croct/plug-hydrogen/vite"; +import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; +const base = {server: {port: 3000}}; + +export default defineConfig({ + ...base, + plugins: [hydrogen(), croct()], +}); +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform standard.ts: standard.ts 1`] = ` +"import { croct } from "@croct/plug-hydrogen/vite"; +import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; +import {oxygen} from '@shopify/mini-oxygen/vite'; +import {reactRouter} from '@react-router/dev/vite'; + +export default defineConfig({ + plugins: [hydrogen(), oxygen(), reactRouter(), croct()], +}); +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform stringLiteralPluginsKey.ts: stringLiteralPluginsKey.ts 1`] = ` +"import { croct } from "@croct/plug-hydrogen/vite"; +import {defineConfig} from 'vite'; +import {hydrogen} from '@shopify/hydrogen/vite'; + +export default defineConfig({ + 'plugins': [hydrogen(), croct()], +}); +" +`; + +exports[`ViteConfigPluginCodemod should correctly transform uninitializedBinding.ts: uninitializedBinding.ts 1`] = ` +"import {defineConfig} from 'vite'; + +let config; + +export default config; +" +`; diff --git a/test/application/project/code/transformation/javascript/hydrogenContextCodemod.test.ts b/test/application/project/code/transformation/javascript/hydrogenContextCodemod.test.ts new file mode 100644 index 00000000..db5c16e4 --- /dev/null +++ b/test/application/project/code/transformation/javascript/hydrogenContextCodemod.test.ts @@ -0,0 +1,43 @@ +import {resolve} from 'path'; +import type { + HydrogenContextConfiguration, +} from '@/application/project/code/transformation/javascript/hydrogenContextCodemod'; +import {HydrogenContextCodemod} from '@/application/project/code/transformation/javascript/hydrogenContextCodemod'; +import {JavaScriptCodemod} from '@/application/project/code/transformation/javascript/javaScriptCodemod'; +import {loadFixtures} from '../fixtures'; + +describe('HydrogenContextCodemod', () => { + const defaultOptions: HydrogenContextConfiguration = { + factory: { + moduleName: '@croct/plug-hydrogen/server', + importName: 'createCroctContext', + }, + }; + + const scenarios = loadFixtures( + resolve(__dirname, '../fixtures/hydrogen-context'), + defaultOptions, + {}, + ); + + it.each(scenarios)('should correctly transform $name', async ({name, fixture, options}) => { + const transformer = new JavaScriptCodemod({ + languages: ['typescript'], + codemod: new HydrogenContextCodemod(options), + }); + + const output = await transformer.apply(fixture); + + expect(output.result).toMatchSnapshot(name); + }); + + it('throws when required and there is no Hydrogen load context', async () => { + const transformer = new JavaScriptCodemod({ + languages: ['typescript'], + codemod: new HydrogenContextCodemod({...defaultOptions, required: true}), + }); + + await expect(transformer.apply('export function noop() {\n return 1;\n}\n')).rejects + .toThrow('No Hydrogen load context found'); + }); +}); diff --git a/test/application/project/code/transformation/javascript/hydrogenCookiesCodemod.test.ts b/test/application/project/code/transformation/javascript/hydrogenCookiesCodemod.test.ts new file mode 100644 index 00000000..36efdb4b --- /dev/null +++ b/test/application/project/code/transformation/javascript/hydrogenCookiesCodemod.test.ts @@ -0,0 +1,80 @@ +import {resolve} from 'path'; +import type { + HydrogenCookiesConfiguration, +} from '@/application/project/code/transformation/javascript/hydrogenCookiesCodemod'; +import {HydrogenCookiesCodemod} from '@/application/project/code/transformation/javascript/hydrogenCookiesCodemod'; +import {JavaScriptCodemod} from '@/application/project/code/transformation/javascript/javaScriptCodemod'; +import {loadFixtures} from '../fixtures'; + +describe('HydrogenCookiesCodemod', () => { + const defaultOptions: HydrogenCookiesConfiguration = { + writer: { + moduleName: '@croct/plug-hydrogen/server', + importName: 'writeCroctCookies', + }, + }; + + const scenarios = loadFixtures( + resolve(__dirname, '../fixtures/hydrogen-cookies'), + defaultOptions, + {}, + ); + + it.each(scenarios)('should correctly transform $name', async ({name, fixture, options}) => { + const transformer = new JavaScriptCodemod({ + languages: ['typescript'], + codemod: new HydrogenCookiesCodemod(options), + }); + + const output = await transformer.apply(fixture); + + expect(output.result).toMatchSnapshot(name); + }); + + it('inserts the writer before the return when the handler is wrapped in try/catch', async () => { + const {fixture} = scenarios.find(scenario => scenario.name === 'tryCatchWrapped.ts')!; + + const transformer = new JavaScriptCodemod({ + languages: ['typescript'], + codemod: new HydrogenCookiesCodemod(defaultOptions), + }); + + const {result} = await transformer.apply(fixture); + + const writerIndex = result.indexOf('writeCroctCookies(response, appLoadContext)'); + const returnIndex = result.indexOf('return response;'); + + // The writer must run on the response before it is returned, not as unreachable + // code after the try/catch block. + expect(writerIndex).toBeGreaterThanOrEqual(0); + expect(returnIndex).toBeGreaterThanOrEqual(0); + expect(writerIndex).toBeLessThan(returnIndex); + }); + + it('does not add a second writer when one is already called elsewhere in the handler', async () => { + const {fixture} = scenarios.find(scenario => scenario.name === 'alreadyPresentOutsideBlock.ts')!; + + const transformer = new JavaScriptCodemod({ + languages: ['typescript'], + codemod: new HydrogenCookiesCodemod(defaultOptions), + }); + + const {result} = await transformer.apply(fixture); + + // The writer is already called in the handler (outside the `try` block that holds the + // Set-Cookie), so the codemod must be a no-op — idempotence is scoped to the whole handler, + // not just the insertion block. + expect((result.match(/writeCroctCookies\(/g) ?? []).length).toBe(1); + }); + + it('throws when required and there is no session Set-Cookie statement', async () => { + const transformer = new JavaScriptCodemod({ + languages: ['typescript'], + codemod: new HydrogenCookiesCodemod({...defaultOptions, required: true}), + }); + + await expect(transformer.apply('export function handleFetch() {\n return new Response();\n}\n')) + .rejects + .toThrow('No session Set-Cookie statement found'); + }); +}); diff --git a/test/application/project/code/transformation/javascript/hydrogenCspCodemod.test.ts b/test/application/project/code/transformation/javascript/hydrogenCspCodemod.test.ts new file mode 100644 index 00000000..baa0b60d --- /dev/null +++ b/test/application/project/code/transformation/javascript/hydrogenCspCodemod.test.ts @@ -0,0 +1,38 @@ +import {resolve} from 'path'; +import type {HydrogenCspConfiguration} from '@/application/project/code/transformation/javascript/hydrogenCspCodemod'; +import {HydrogenCspCodemod} from '@/application/project/code/transformation/javascript/hydrogenCspCodemod'; +import {JavaScriptCodemod} from '@/application/project/code/transformation/javascript/javaScriptCodemod'; +import {loadFixtures} from '../fixtures'; + +describe('HydrogenCspCodemod', () => { + const defaultOptions: HydrogenCspConfiguration = { + origin: 'https://api.croct.io', + }; + + const scenarios = loadFixtures( + resolve(__dirname, '../fixtures/hydrogen-csp'), + defaultOptions, + {}, + ); + + it.each(scenarios)('should correctly transform $name', async ({name, fixture, options}) => { + const transformer = new JavaScriptCodemod({ + languages: ['typescript'], + codemod: new HydrogenCspCodemod(options), + }); + + const output = await transformer.apply(fixture); + + expect(output.result).toMatchSnapshot(name); + }); + + it('throws when required and there is no content security policy', async () => { + const transformer = new JavaScriptCodemod({ + languages: ['typescript'], + codemod: new HydrogenCspCodemod({...defaultOptions, required: true}), + }); + + await expect(transformer.apply('export const value = 1;\n')).rejects + .toThrow('No content security policy configuration found'); + }); +}); diff --git a/test/application/project/code/transformation/javascript/hydrogenMiddlewareCodemod.test.ts b/test/application/project/code/transformation/javascript/hydrogenMiddlewareCodemod.test.ts new file mode 100644 index 00000000..da0cdb62 --- /dev/null +++ b/test/application/project/code/transformation/javascript/hydrogenMiddlewareCodemod.test.ts @@ -0,0 +1,35 @@ +import {resolve} from 'path'; +import type { + HydrogenMiddlewareConfiguration, +} from '@/application/project/code/transformation/javascript/hydrogenMiddlewareCodemod'; +import { + HydrogenMiddlewareCodemod, +} from '@/application/project/code/transformation/javascript/hydrogenMiddlewareCodemod'; +import {JavaScriptCodemod} from '@/application/project/code/transformation/javascript/javaScriptCodemod'; +import {loadFixtures} from '../fixtures'; + +describe('HydrogenMiddlewareCodemod', () => { + const defaultOptions: HydrogenMiddlewareConfiguration = { + middleware: { + moduleName: '@croct/plug-hydrogen/server', + importName: 'createCroctMiddleware', + }, + }; + + const scenarios = loadFixtures( + resolve(__dirname, '../fixtures/hydrogen-middleware'), + defaultOptions, + {}, + ); + + it.each(scenarios)('should correctly transform $name', async ({name, fixture, options}) => { + const transformer = new JavaScriptCodemod({ + languages: ['typescript'], + codemod: new HydrogenMiddlewareCodemod(options), + }); + + const output = await transformer.apply(fixture); + + expect(output.result).toMatchSnapshot(name); + }); +}); diff --git a/test/application/project/code/transformation/javascript/jsxWrapperCodemod.test.ts b/test/application/project/code/transformation/javascript/jsxWrapperCodemod.test.ts index 6a5c75d1..f3b28e0b 100644 --- a/test/application/project/code/transformation/javascript/jsxWrapperCodemod.test.ts +++ b/test/application/project/code/transformation/javascript/jsxWrapperCodemod.test.ts @@ -25,6 +25,62 @@ describe('JsxWrapperCodemod', () => { component: 'Component', }, }, + 'targetSingleChild.tsx': { + targets: { + component: 'Component', + }, + }, + 'targetComponentMemberExpression.tsx': { + targets: { + component: 'Theme.Provider', + }, + }, + 'containerMemberExpression.tsx': { + targets: { + container: 'Analytics.Provider', + }, + }, + 'containerAliasedImport.tsx': { + targets: { + container: 'Analytics.Provider', + }, + }, + 'containerRemixTernary.tsx': { + targets: { + container: 'Analytics.Provider', + }, + fallbackToNamedExports: true, + }, + 'containerEarlyReturn.tsx': { + targets: { + container: 'Analytics.Provider', + }, + }, + 'containerAlreadyWrapped.tsx': { + targets: { + container: 'Analytics.Provider', + }, + }, + 'containerNotFound.tsx': { + targets: { + container: 'Analytics.Provider', + }, + }, + 'containerSelfClosing.tsx': { + targets: { + container: 'Analytics.Provider', + }, + }, + 'containerNamespaced.tsx': { + targets: { + container: 'svg:g', + }, + }, + 'containerIdentifierElement.tsx': { + targets: { + container: 'Providers', + }, + }, 'targetChildren.tsx': { targets: { variable: 'children', @@ -217,4 +273,38 @@ describe('JsxWrapperCodemod', () => { expect(result).toEqual(input); }); + + it('should throw when required and no component can be wrapped', async () => { + const transformer = new JavaScriptCodemod({ + languages: ['typescript', 'jsx'], + codemod: new JsxWrapperCodemod({ + ...defaultOptions, + targets: {container: 'Analytics.Provider'}, + required: true, + }), + }); + + await expect(transformer.apply('export default function App() {\n return
    ;\n}\n')) + .rejects + .toThrow('No component found to wrap with .'); + }); + + it('should not throw when required but the wrapper is already present', async () => { + const transformer = new JavaScriptCodemod({ + languages: ['typescript', 'jsx'], + codemod: new JsxWrapperCodemod({...defaultOptions, required: true}), + }); + + const input = [ + "import {CroctProvider} from '@croct/plug-react';", + 'export default function App() {', + ' return ;', + '}', + '', + ].join('\n'); + + const {result} = await transformer.apply(input); + + expect(result).toEqual(input); + }); }); diff --git a/test/application/project/code/transformation/javascript/nuxtConfigModuleCodemod.test.ts b/test/application/project/code/transformation/javascript/nuxtConfigModuleCodemod.test.ts index 3a1f63e2..80e785a1 100644 --- a/test/application/project/code/transformation/javascript/nuxtConfigModuleCodemod.test.ts +++ b/test/application/project/code/transformation/javascript/nuxtConfigModuleCodemod.test.ts @@ -27,4 +27,14 @@ describe('NuxtConfigModuleCodemod', () => { expect(output.result).toMatchSnapshot(name); }); + + it('throws when required and there is no Nuxt configuration', async () => { + const transformer = new JavaScriptCodemod({ + languages: ['typescript'], + codemod: new NuxtConfigModuleCodemod({...defaultOptions, required: true}), + }); + + await expect(transformer.apply('export default makeConfig();\n')).rejects + .toThrow('No Nuxt configuration found to register the Croct module.'); + }); }); diff --git a/test/application/project/code/transformation/javascript/storyblokInitCodemod.test.ts b/test/application/project/code/transformation/javascript/storyblokInitCodemod.test.ts index a4b21c10..566bc03a 100644 --- a/test/application/project/code/transformation/javascript/storyblokInitCodemod.test.ts +++ b/test/application/project/code/transformation/javascript/storyblokInitCodemod.test.ts @@ -138,6 +138,24 @@ describe('StoryblokInitCodemod', () => { expect(result).toBe(input); }); + it('should throw when required and no storyblok import is found', async () => { + const transformer = createTransformer(); + + const input = [ + "import { someOtherFunction } from '@storyblok/js';", + '', + 'someOtherFunction({ accessToken: "token" });', + ].join('\n'); + + await expect( + transformer.apply(input, { + name: 'withCroct', + module: '@croct/storyblok', + required: true, + }), + ).rejects.toThrow('No Storyblok import found to wire the Croct integration.'); + }); + it('should return unmodified when options are not provided', async () => { const transformer = createTransformer(); diff --git a/test/application/project/code/transformation/javascript/utils/getImportSource.test.ts b/test/application/project/code/transformation/javascript/utils/getImportSource.test.ts new file mode 100644 index 00000000..a7e82526 --- /dev/null +++ b/test/application/project/code/transformation/javascript/utils/getImportSource.test.ts @@ -0,0 +1,77 @@ +import {getImportSource} from '@/application/project/code/transformation/javascript/utils/getImportSource'; +import {parse} from '@/application/project/code/transformation/javascript/utils/parse'; + +describe('getImportSource', () => { + type Scenario = { + description: string, + code: string, + importName: string | RegExp, + expected: string | null, + }; + + it.each([ + { + description: 'return null when there is no import', + code: '', + importName: 'sdk', + expected: null, + }, + { + description: 'return null when no import matches the name', + code: "import {sdk} from 'croct';", + importName: 'something', + expected: null, + }, + { + description: 'return the source of a named import', + code: "import {createAppLoadContext} from '~/lib/context';", + importName: 'createAppLoadContext', + expected: '~/lib/context', + }, + { + description: 'match the imported name, not the local alias', + code: "import {createAppLoadContext as build} from '~/lib/context';", + importName: 'createAppLoadContext', + expected: '~/lib/context', + }, + { + description: 'match a literal named import', + code: "import {'createContext' as build} from '~/lib/context';", + importName: 'createContext', + expected: '~/lib/context', + }, + { + description: 'match a default import against `default`', + code: "import sdk from 'croct';", + importName: 'default', + expected: 'croct', + }, + { + description: 'match a namespace import against `*`', + code: "import * as sdk from 'croct';", + importName: '*', + expected: 'croct', + }, + { + description: 'match the imported name by regex', + code: "import {createHydrogenRouterContext} from '~/lib/context';", + importName: /^create[A-Za-z]*Context$/, + expected: '~/lib/context', + }, + { + description: 'return the first matching import source', + code: "import type {AppLoadContext} from '@shopify/remix-oxygen';\n" + + "import {createAppLoadContext} from '~/lib/context';", + importName: /^create[A-Za-z]*Context$/, + expected: '~/lib/context', + }, + ])('should $description', ({code, importName, expected}) => { + expect(getImportSource(code, importName)).toBe(expected); + }); + + it('should accept an already-parsed AST', () => { + const ast = parse("import {sdk} from 'croct';", ['typescript']); + + expect(getImportSource(ast, 'sdk')).toBe('croct'); + }); +}); diff --git a/test/application/project/code/transformation/javascript/viteConfigPluginCodemod.test.ts b/test/application/project/code/transformation/javascript/viteConfigPluginCodemod.test.ts new file mode 100644 index 00000000..5a9f1454 --- /dev/null +++ b/test/application/project/code/transformation/javascript/viteConfigPluginCodemod.test.ts @@ -0,0 +1,48 @@ +import {resolve} from 'path'; +import type { + ViteConfigPluginConfiguration, +} from '@/application/project/code/transformation/javascript/viteConfigPluginCodemod'; +import {ViteConfigPluginCodemod} from '@/application/project/code/transformation/javascript/viteConfigPluginCodemod'; +import {JavaScriptCodemod} from '@/application/project/code/transformation/javascript/javaScriptCodemod'; +import {loadFixtures} from '../fixtures'; + +describe('ViteConfigPluginCodemod', () => { + const defaultOptions: ViteConfigPluginConfiguration = { + plugin: { + moduleName: '@croct/plug-hydrogen/vite', + importName: 'croct', + }, + }; + + const scenarios = loadFixtures( + resolve(__dirname, '../fixtures/vite-config-plugin'), + defaultOptions, + { + 'positionStart.ts': { + ...defaultOptions, + position: 'start', + }, + }, + ); + + it.each(scenarios)('should correctly transform $name', async ({name, fixture, options}) => { + const transformer = new JavaScriptCodemod({ + languages: ['typescript'], + codemod: new ViteConfigPluginCodemod(options), + }); + + const output = await transformer.apply(fixture); + + expect(output.result).toMatchSnapshot(name); + }); + + it('throws when required and there is no Vite config', async () => { + const transformer = new JavaScriptCodemod({ + languages: ['typescript'], + codemod: new ViteConfigPluginCodemod({...defaultOptions, required: true}), + }); + + await expect(transformer.apply('export const value = 1;\n')).rejects + .toThrow('No Vite configuration found'); + }); +}); diff --git a/test/application/project/code/transformation/javascript/vuePluginCodemod.test.ts b/test/application/project/code/transformation/javascript/vuePluginCodemod.test.ts index f2285439..13074adb 100644 --- a/test/application/project/code/transformation/javascript/vuePluginCodemod.test.ts +++ b/test/application/project/code/transformation/javascript/vuePluginCodemod.test.ts @@ -210,4 +210,15 @@ describe('VuePluginCodemod', () => { expect(useCall).toEqual(expected); }); + + it('throws when required and there is no Vue app initialization', async () => { + const transformer = new JavaScriptCodemod({ + languages: ['typescript'], + codemod: new VuePluginCodemod({...defaultOptions, required: true}), + }); + + await expect(transformer.apply("import { something } from 'somewhere';\n\nsomething({ foo: 'bar' });\n")) + .rejects + .toThrow('No Vue app initialization found to register the Croct plugin.'); + }); }); diff --git a/test/application/project/code/transformation/javascript/vueStoryblokCodemod.test.ts b/test/application/project/code/transformation/javascript/vueStoryblokCodemod.test.ts index 2e97491d..7e966874 100644 --- a/test/application/project/code/transformation/javascript/vueStoryblokCodemod.test.ts +++ b/test/application/project/code/transformation/javascript/vueStoryblokCodemod.test.ts @@ -32,4 +32,66 @@ describe('VueStoryblokCodemod', () => { expect(output.result).toMatchSnapshot(name); }); + + it('throws when required and there is no Vue app initialization', async () => { + const transformer = new JavaScriptCodemod({ + languages: ['typescript'], + codemod: new VueStoryblokCodemod({...defaultOptions, required: true}), + }); + + const source = [ + "import { StoryblokVue } from '@storyblok/vue';", + '', + 'const app = { use: () => app, mount: () => {} };', + '', + "app.use(StoryblokVue, { accessToken: 'YOUR_ACCESS_TOKEN' });", + "app.mount('#app');", + '', + ].join('\n'); + + await expect(transformer.apply(source)).rejects + .toThrow('No Vue app initialization found to wire the Storyblok integration.'); + }); + + it('throws when required and there is no Storyblok Vue plugin import', async () => { + const transformer = new JavaScriptCodemod({ + languages: ['typescript'], + codemod: new VueStoryblokCodemod({...defaultOptions, required: true}), + }); + + const source = [ + "import { createApp } from 'vue';", + "import { apiPlugin } from '@storyblok/vue';", + "import App from './App.vue';", + '', + 'const app = createApp(App);', + "app.mount('#app');", + '', + ].join('\n'); + + await expect(transformer.apply(source)).rejects + .toThrow('No Storyblok Vue plugin import found.'); + }); + + it('throws when required and there is no app.use(StoryblokVue) call', async () => { + const transformer = new JavaScriptCodemod({ + languages: ['typescript'], + codemod: new VueStoryblokCodemod({...defaultOptions, required: true}), + }); + + const source = [ + "import { createApp } from 'vue';", + "import { StoryblokVue } from '@storyblok/vue';", + "import App from './App.vue';", + '', + 'console.log(StoryblokVue);', + '', + 'const app = createApp(App);', + "app.mount('#app');", + '', + ].join('\n'); + + await expect(transformer.apply(source)).rejects + .toThrow('No app.use(StoryblokVue) call found to wrap.'); + }); }); diff --git a/test/application/project/code/transformation/php/drupalLocalSettingsCodemod.test.ts b/test/application/project/code/transformation/php/drupalLocalSettingsCodemod.test.ts index 36bbb5e0..228efb99 100644 --- a/test/application/project/code/transformation/php/drupalLocalSettingsCodemod.test.ts +++ b/test/application/project/code/transformation/php/drupalLocalSettingsCodemod.test.ts @@ -29,4 +29,14 @@ describe('DrupalLocalSettingsCodemod', () => { expect(modified).toBe(false); expect(result).toBe(''); }); + + it('throws when required and the content is empty', async () => { + const requiredCodemod = new DrupalLocalSettingsCodemod({ + file: 'settings.local.php', + required: true, + }); + + await expect(async () => requiredCodemod.apply(' \n\t')).rejects + .toThrow('settings.php is empty; cannot add the settings.local.php include.'); + }); }); diff --git a/test/application/project/code/transformation/php/symfonyBundleCodemod.test.ts b/test/application/project/code/transformation/php/symfonyBundleCodemod.test.ts index 8350bc14..5f88ce6d 100644 --- a/test/application/project/code/transformation/php/symfonyBundleCodemod.test.ts +++ b/test/application/project/code/transformation/php/symfonyBundleCodemod.test.ts @@ -22,4 +22,14 @@ describe('SymfonyBundleCodemod', () => { expect(reapplied.modified).toBe(false); expect(reapplied.result).toBe(result); }); + + it('throws when required and the bundle array is missing', async () => { + const requiredCodemod = new SymfonyBundleCodemod({ + bundle: 'Croct\\Plug\\Symfony\\CroctBundle', + required: true, + }); + + await expect(async () => requiredCodemod.apply('