Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -2629,6 +2629,7 @@ input PlanQuotaIntentInput {
enum Platform {
ASTRO
DRUPAL
HYDROGEN
JAVASCRIPT
LARAVEL
NEXT
Expand Down
4 changes: 4 additions & 0 deletions src/application/model/platform.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export enum Platform {
NEXTJS = 'nextjs',
NUXT = 'nuxt',
HYDROGEN = 'hydrogen',
REACT = 'react',
VUE = 'vue',
JAVASCRIPT = 'javascript',
Expand All @@ -19,6 +20,9 @@ export namespace Platform {
case Platform.NUXT:
return 'Nuxt';

case Platform.HYDROGEN:
return 'Hydrogen';

case Platform.REACT:
return 'React';

Expand Down
Original file line number Diff line number Diff line change
@@ -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 ? '<typeof loader>' : ''}();`)
.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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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':
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand All @@ -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()
Expand All @@ -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));

Expand All @@ -327,7 +327,7 @@ export abstract class ReactExampleGenerator implements SlotExampleGenerator {
.write('<li>', false)
.append(`<strong>${label}:</strong> `);

this.writeRenderingSnippet(writer, definition, `${path}.${attribute.name}`);
ReactExampleGenerator.renderComponentSnippet(writer, definition, `${path}.${attribute.name}`);

writer.append('</li>')
.newLine();
Expand All @@ -341,7 +341,7 @@ export abstract class ReactExampleGenerator implements SlotExampleGenerator {
.indent()
.write(`<strong>${label}</strong>`);

this.writeRenderingSnippet(writer, definition, `${path}.${attribute.name}`);
ReactExampleGenerator.renderComponentSnippet(writer, definition, `${path}.${attribute.name}`);

writer
.outdent()
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <ctx> = createHydrogenContext(...)` and adds a
* `croct: await <factory>(<request>, <ctx>)` property to its returned object, where `<request>`
* 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<t.File, CodemodOptions> {
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<ResultCode<t.File>> {
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);
});
}
}
Loading
Loading