Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support templateOnlyComponent() usages in template tag codemod #2408

Merged
merged 1 commit into from
Apr 4, 2025
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
123 changes: 90 additions & 33 deletions packages/template-tag-codemod/src/extract-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,53 +118,110 @@ export async function extractTemplates(ast: types.File, filename: string) {
return meta.result;
}

interface ComponentClassLocation {
loc: { start: number; end: number };
bodyNode: types.ClassBody;
}

interface TemplateOnlyComponentLocation {
loc: { start: number; end: number };
tocNode: types.CallExpression;
}

interface LocatePluginOpts {
templates: { start: number; end: number }[];
componentBody: { loc: { start: number; end: number }; node: types.ClassBody } | { problem: string } | undefined;
component: ComponentClassLocation | TemplateOnlyComponentLocation | { problem: string } | undefined;
}

function locatePlugin(_babel: typeof Babel): Babel.PluginObj<{ opts: LocatePluginOpts }> {
return {
visitor: {
ExportDefaultDeclaration(path, state) {
let dec: types.Node = path.node.declaration;

if (dec.type === 'Identifier') {
// We need to resolve the path to the exported value, so start with the
// export declaration, which might be the value in simple cases like
//
// ```js
// export default class Foo {};
// ```
//
// or
//
// ```ts
// export default templateOnlyComponent<FooSignature>();
// ```
let valuePath: Babel.NodePath<unknown> = path.get('declaration');

if (valuePath.isIdentifier()) {
// This is an export of an identifier, not the value, e.g.
// `class Foo {}; export default Foo;`. So find the
// underlying declaration
let binding = path.scope.getBinding(dec.name);
//
// ```js
// class Foo {};
// export default Foo;
// ```
//
// or
//
// ```ts
// const Foo = templateOnlyComponent<FooSignature>();
// export default Foo;
// ```
let binding = path.scope.getBinding(valuePath.node.name);
if (!binding) {
throw new Error(`bug: unable to get binding for identifier: ${dec.name}`);
throw new Error(`bug: unable to get binding for identifier: ${valuePath.node.name}`);
}

if (binding.path.isVariableDeclarator()) {
// It's a variable declarator, e.g.
//
// ```ts
// const Foo = templateOnlyComponent<FooSignature>();
// export default Foo;
// ```
valuePath = binding.path.get('init');
if (!valuePath) {
throw new Error(`bug: unable to get init for variable declarator: ${binding.path}`);
}
} else {
// It's something else, e.g.
//
// ```js
// class Foo {};
// export default Foo;
// ```
//
// (we'll handle the possible cases below)
valuePath = binding.path;
}
dec = binding.path.node;
}

switch (dec.type) {
case 'ClassDeclaration':
case 'ClassExpression':
state.opts.componentBody = { loc: extractLoc(dec.body), node: dec.body };
if (valuePath.isClassDeclaration() || valuePath.isClassExpression()) {
state.opts.component = { loc: extractLoc(valuePath.node.body), bodyNode: valuePath.node.body };
return;
} else if (valuePath.isCallExpression()) {
let callee = valuePath.get('callee');
if (
callee.node.type === 'MemberExpression' &&
callee.node.property.type === 'Identifier' &&
callee.node.property.name === 'extend'
) {
state.opts.component = {
problem: `This codemod does not support old styles Component.extend() syntax. Convert to a native class first.`,
};
return;
case 'CallExpression':
let callee = dec.callee;
if (
callee.type === 'MemberExpression' &&
callee.property.type === 'Identifier' &&
callee.property.name === 'extend'
) {
state.opts.componentBody = {
problem: `This codemod does not support old styles Component.extend() syntax. Convert to a native class first.`,
};
return;
}
case 'TSInterfaceDeclaration':
// ignoring type-only export
}

if (callee.referencesImport('@ember/component/template-only', 'default')) {
state.opts.component = { loc: extractLoc(valuePath.node), tocNode: valuePath.node };
return;
default:
state.opts.componentBody = {
problem: `The default export from this JS file is not something we understand. Found ${dec.type}`,
};
}
} else if (valuePath.isTSInterfaceDeclaration()) {
// ignoring type-only export
return;
}

state.opts.component = {
problem: `The default export from this JS file is not something we understand. Found ${valuePath.type}`,
};
},
CallExpression(path, state) {
if (path.get('callee').referencesImport('@ember/template-compilation', 'precompileTemplate')) {
Expand All @@ -176,13 +233,13 @@ function locatePlugin(_babel: typeof Babel): Babel.PluginObj<{ opts: LocatePlugi
}

export async function locateTemplates(ast: types.File, filename: string): Promise<LocatePluginOpts> {
const meta: LocatePluginOpts = { componentBody: undefined, templates: [] };
const meta: LocatePluginOpts = { component: undefined, templates: [] };
await transformFromAstAsync(ast, undefined, {
configFile: false,
filename,
plugins: [[locatePlugin, meta]],
});
if (!meta.componentBody) {
if (!meta.component) {
throw new Error(`failed to locate component template insertion point in ${filename}`);
}
return meta;
Expand Down
94 changes: 85 additions & 9 deletions packages/template-tag-codemod/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,37 +367,71 @@ export async function processComponent(
let jsSource = readFileSync(jsPath, 'utf8');
let ast = await parseJS(jsPath, jsSource);
let edits = deleteImports(ast);
let { componentBody, templates } = await locateTemplates(ast, jsPath);
let { component, templates } = await locateTemplates(ast, jsPath);
if (templates.length > 0) {
return {
context: `component: ${hbsPath}`,
status: 'failure',
messages: [`unimplemented: component JS that already has some other templates in it`],
};
}
if (!componentBody) {
if (!component) {
return {
context: `component: ${hbsPath}`,
status: 'failure',
messages: [`could not locate where to insert template into ${jsPath}`],
};
}
if ('problem' in componentBody) {
return { context: `component: ${hbsPath}`, status: 'failure', messages: [componentBody.problem] };
if ('problem' in component) {
return { context: `component: ${hbsPath}`, status: 'failure', messages: [component.problem] };
}
ast = await insertComponentTemplate(ast, componentBody.loc, hbsSource);

if ('bodyNode' in component) {
ast = await insertTemplateIntoComponentClass(ast, component.loc, hbsSource);
} else {
ast = await replaceTemplateOnlyCallWithTemplate(ast, component.loc, hbsSource);
}

let invokables = await locateInvokables(jsPath, ast, opts);
ast = await runResolverTransform(ast, jsPath, invokables, undefined, opts);
let finalTemplates = await extractTemplates(ast, jsPath);
if (finalTemplates.length !== 1) {
throw new Error(`bug: should see one templates, not ${finalTemplates.length}`);
}
let index = opts.templateInsertion === 'beginning' ? componentBody.loc.start + 1 : componentBody.loc.end - 1;
edits.push({ start: index, end: index, replacement: `<template>${finalTemplates[0].templateSource}</template>` });

const templateReplacement = `<template>${finalTemplates[0].templateSource}</template>`;
if ('bodyNode' in component) {
// Inserting into a component class body
let index = opts.templateInsertion === 'beginning' ? component.loc.start + 1 : component.loc.end - 1;
edits.push({ start: index, end: index, replacement: templateReplacement });
} else {
// Replacing a templateOnlyComponent() call, so replace the call itself
// with the template replacement
let replacement = templateReplacement;

let signatureType = component.tocNode.typeParameters?.params[0];
if (signatureType) {
// The templateOnlyComponent() call has a signature type argument, so we
// must be in Typescript-land. So, add an import of
// `TemplateOnlyComponent` and include a satisfies expression in the
// template replacement
edits.unshift({
start: 0,
end: 0,
replacement: `import type { TemplateOnlyComponent } from '@ember/component/template-only';`,
});
replacement = [replacement, 'satisfies', `TemplateOnlyComponent<${generate(signatureType).code}>`].join(' ');
}
edits.push({ start: component.loc.start, end: component.loc.end, replacement });
}

edits.unshift({
start: 0,
end: 0,
replacement: extractImports(ast, path => path !== '@ember/template-compilation'),
replacement: extractImports(
ast,
path => path !== '@ember/template-compilation' && path !== '@ember/component/template-only'
),
});
let newSrc = applyEdits(jsSource, edits);
writeFileSync(jsPath.replace(/\.js$/, '.gjs').replace(/\.ts$/, '.gts'), newSrc);
Expand Down Expand Up @@ -445,7 +479,7 @@ async function parseJS(filename: string, src: string): Promise<types.File> {
// syntax. This is not how we produce our final output. Rather, we're setting
// things up so embroider's resolver transform can see the template within the
// correct scope.
async function insertComponentTemplate(
async function insertTemplateIntoComponentClass(
ast: types.File,
loc: { start: number },
hbsSource: string
Expand Down Expand Up @@ -487,6 +521,48 @@ async function insertComponentTemplate(
return result!.ast!;
}

// replace the `templateOnlyComponent()` expression with the template using
// `precompileTemplate` syntax. This is not how we produce our final output.
// Rather, we're setting things up so embroider's resolver transform can see the
// template within the correct scope.
async function replaceTemplateOnlyCallWithTemplate(
ast: types.File,
loc: { start: number },
hbsSource: string
): Promise<types.File> {
let importUtil: ImportUtil;
let didInsert = false;

function inserter({ types: t }: typeof babel): babel.PluginObj {
return {
visitor: {
Program(path) {
importUtil = new ImportUtil(babel, path);
},
CallExpression(path) {
if (path.node.loc?.start.index === loc.start) {
let block = t.callExpression(importUtil.import(path, '@ember/template-compilation', 'precompileTemplate'), [
t.stringLiteral(hbsSource),
]);
path.replaceWith(block);
didInsert = true;
}
},
},
};
}
let result = await transformFromAstAsync(ast, undefined, {
code: false,
ast: true,
configFile: false,
plugins: [inserter],
});
if (!didInsert) {
throw new Error(`bug: failed to insert component template`);
}
return result!.ast!;
}

function unusedNameLike(desiredName: string, isUsed: (name: string) => boolean): string {
let candidate = desiredName;
let counter = 0;
Expand Down
78 changes: 78 additions & 0 deletions tests/scenarios/template-tag-codemod-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,84 @@ tsAppScenarios
});
});

test('template-only ts component', async function (assert) {
await assert.codeMod({
from: {
'app/components/example.hbs': `Hello world`,
'app/components/example.ts': `
import templateOnlyComponent from '@ember/component/template-only';
interface FooSignature {
Args: {
value: string;
};
}
export default templateOnlyComponent<FooSignature>();
`,
},
to: {
'app/components/example.gts': `
import type { TemplateOnlyComponent } from '@ember/component/template-only';
interface FooSignature {
Args: {
value: string;
};
}
export default <template>Hello world</template> satisfies TemplateOnlyComponent<FooSignature>;
`,
},
via: 'npx template-tag-codemod --reusePrebuild --renderTests false --routeTemplates false --components ./app/components/example.hbs',
});
});

test('template-only ts component with separate export statement', async function (assert) {
await assert.codeMod({
from: {
'app/components/example.hbs': `Hello world`,
'app/components/example.ts': `
import templateOnlyComponent from '@ember/component/template-only';
interface FooSignature {
Args: {
value: string;
};
}
const Foo = templateOnlyComponent<FooSignature>();
export default Foo;
`,
},
to: {
'app/components/example.gts': `
import type { TemplateOnlyComponent } from '@ember/component/template-only';
interface FooSignature {
Args: {
value: string;
};
}
const Foo = <template>Hello world</template> satisfies TemplateOnlyComponent<FooSignature>;
export default Foo;
`,
},
via: 'npx template-tag-codemod --reusePrebuild --renderTests false --routeTemplates false --components ./app/components/example.hbs',
});
});

test('template-only ts component without signature', async function (assert) {
await assert.codeMod({
from: {
'app/components/example.hbs': `Hello world`,
'app/components/example.ts': `
import templateOnlyComponent from '@ember/component/template-only';
export default templateOnlyComponent();
`,
},
to: {
'app/components/example.gts': `
export default <template>Hello world</template>;
`,
},
via: 'npx template-tag-codemod --reusePrebuild --renderTests false --routeTemplates false --components ./app/components/example.hbs',
});
});

test('name collision between original js and added import', async function (assert) {
await assert.codeMod({
from: {
Expand Down