Skip to content

Commit

Permalink
Merge pull request #2133 from embroider-build/bugfix-template-tag-cod…
Browse files Browse the repository at this point in the history
…emod-ts

Bugfix template tag codemod ts
  • Loading branch information
ef4 authored Oct 8, 2024
2 parents 38600e7 + bba6cf5 commit 82923f8
Show file tree
Hide file tree
Showing 6 changed files with 142 additions and 63 deletions.
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@ Edit `ember-cli-build.js`:
```js
return require('@embroider/compat').templateTagCodemod(app, {
shouldTransformPath: (path) => { return true; },
nameHint: (path) => {
// example path: shared/my-button
return path;
},
dryRun: true,
});
```
Expand All @@ -132,6 +136,7 @@ Requires optimized build (static* flags to be turned on)
### Options

* `shouldTransformPath` - allows users to filter the templates that the code mod would run on
* `nameHint` - optional function control the import name and template replacement values - valid JS identifier required or it will be coerced into one
* `dryRun` - option can be used to obtain a summary of the changed the build would perform and which files it would act upon

### Limitations
Expand Down
1 change: 1 addition & 0 deletions packages/compat/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@babel/core": "^7.14.5",
"@babel/plugin-syntax-decorators": "^7.24.7",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-syntax-typescript": "^7.25.4",
"@babel/plugin-transform-runtime": "^7.14.5",
"@babel/preset-env": "^7.14.5",
"@babel/runtime": "^7.18.6",
Expand Down
14 changes: 10 additions & 4 deletions packages/compat/src/resolver-transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,14 @@ export interface CompatResolverOptions extends CoreResolverOptions {
options: UserConfig;
}

export interface ExternalNameHint {
(path: string): string | null;
}

export interface Options {
appRoot: string;
emberVersion: string;
externalNameHint?: ExternalNameHint;
}

type BuiltIn = {
Expand Down Expand Up @@ -178,7 +183,8 @@ class TemplateResolver implements ASTPlugin {
constructor(
private env: Env,
private config: CompatResolverOptions,
private builtInsForEmberVersion: ReturnType<typeof builtInKeywords>
private builtInsForEmberVersion: ReturnType<typeof builtInKeywords>,
private externalNameHint?: ExternalNameHint
) {
this.moduleResolver = new Resolver(config);
if ((globalThis as any).embroider_audit) {
Expand Down Expand Up @@ -728,7 +734,7 @@ class TemplateResolver implements ASTPlugin {

// the extra underscore here guarantees that we will never collide with an
// HTML element.
return parts[parts.length - 1] + '_';
return this.externalNameHint?.(path) ?? parts[parts.length - 1] + '_';
}

private handleDynamicModifier(param: ASTv1.Expression): ModifierResolution | ResolutionFail | null {
Expand Down Expand Up @@ -971,7 +977,7 @@ class TemplateResolver implements ASTPlugin {
}

// This is the AST transform that resolves components, helpers and modifiers at build time
export default function makeResolverTransform({ appRoot, emberVersion }: Options) {
export default function makeResolverTransform({ appRoot, emberVersion, externalNameHint }: Options) {
let config: CompatResolverOptions = readJSONSync(join(locateEmbroiderWorkingDir(appRoot), 'resolver.json'));
const resolverTransform: ASTPluginBuilder<Env> = env => {
if (env.strictMode) {
Expand All @@ -980,7 +986,7 @@ export default function makeResolverTransform({ appRoot, emberVersion }: Options
visitor: {},
};
}
return new TemplateResolver(env, config, builtInKeywords(emberVersion));
return new TemplateResolver(env, config, builtInKeywords(emberVersion), externalNameHint);
};
(resolverTransform as any).parallelBabel = {
requireFile: __filename,
Expand Down
175 changes: 117 additions & 58 deletions packages/compat/src/template-tag-codemod.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,41 @@
import { default as compatBuild } from './default-pipeline';
import type { EmberAppInstance } from '@embroider/core';
import type { Node, InputNode } from 'broccoli-node-api';
import { join, relative, resolve } from 'path';
import { join, relative, resolve, extname } from 'path';
import type { types as t } from '@babel/core';
import type { NodePath } from '@babel/traverse';
import { statSync, readdirSync, readFileSync, writeFileSync } from 'fs';
import Plugin from 'broccoli-plugin';
import { transformSync } from '@babel/core';
import { hbsToJS, ResolverLoader } from '@embroider/core';
import { ImportUtil } from 'babel-import-util';
import ResolverTransform from './resolver-transform';
import ResolverTransform, { type ExternalNameHint } from './resolver-transform';
import { spawn } from 'child_process';
import { locateEmbroiderWorkingDir } from '@embroider/core';

export interface TemplateTagCodemodOptions {
shouldTransformPath: (outputPath: string) => boolean;
nameHint: ExternalNameHint;
dryRun: boolean;
}

export default function templateTagCodemod(
emberApp: EmberAppInstance,
{ shouldTransformPath = (() => true) as TemplateTagCodemodOptions['shouldTransformPath'], dryRun = false } = {}
{
shouldTransformPath = (() => true) as TemplateTagCodemodOptions['shouldTransformPath'],
nameHint = (path => {
return path
.split('/')
.map(part =>
part
.split('-')
// capitalize first letter
.map(inner_part => inner_part.charAt(0).toUpperCase() + inner_part.slice(1))
.join('')
)
.join('_');
}) as TemplateTagCodemodOptions['nameHint'],
dryRun = false,
} = {}
): Node {
return new TemplateTagCodemodPlugin(
[
Expand All @@ -36,9 +51,13 @@ export default function templateTagCodemod(
},
}),
],
{ shouldTransformPath, dryRun }
{ shouldTransformPath, nameHint, dryRun }
);
}

const TEMPLATE_ONLY_MARKER = `import templateOnlyComponent from '@ember/component/template-only';`;
const TEMPLATE_COLOCATION_MARKER = /\/\* import __COLOCATED_TEMPLATE__ from (.*) \*\//;

class TemplateTagCodemodPlugin extends Plugin {
constructor(inputNodes: InputNode[], readonly options: TemplateTagCodemodOptions) {
super(inputNodes, {
Expand Down Expand Up @@ -83,28 +102,42 @@ class TemplateTagCodemodPlugin extends Plugin {
const babel_plugin_syntax_decorators = require.resolve('@babel/plugin-syntax-decorators', {
paths: [embroider_compat_path],
});
const babel_plugin_syntax_typescript = require.resolve('@babel/plugin-syntax-typescript', {
paths: [embroider_compat_path],
});
const resolver_transform = ResolverTransform({
appRoot: process.cwd(),
emberVersion: emberVersion,
externalNameHint: this.options.nameHint,
});

for await (const current_file of walkSync(tmp_path)) {
if (hbs_file_test.test(current_file) && this.options.shouldTransformPath(current_file)) {
const template_file_src = readFileSync(current_file).toLocaleString();

let src =
// run the template transformations using embroider resolver information
// to replace template values with js import syntax used in g(j/t)s
let transformed_source =
transformSync(hbsToJS(template_file_src), {
plugins: [
[
babel_plugin_ember_template_compilation,
{
compilerPath: ember_template_compiler.filename,
transforms: [ResolverTransform({ appRoot: process.cwd(), emberVersion: emberVersion })],
transforms: [resolver_transform],
targetFormat: 'hbs',
},
],
],
filename: current_file,
})?.code ?? '';

// using transformSync to parse and traverse in one go
// we're only extracting the transformed template information from previous step
// and preserving it for later assembly in the backing class
const import_bucket: NodePath<t.ImportDeclaration>[] = [];
let transformed_template_value = '';
transformSync(src, {
let template_tag_value = '';
transformSync(transformed_source, {
plugins: [
function template_tag_extractor(): unknown {
return {
Expand All @@ -114,11 +147,19 @@ class TemplateTagCodemodPlugin extends Plugin {
if (extractor) {
const result = resolver.nodeResolve(extractor[0], current_file);
if (result.type === 'real') {
// find package
// find package there the resolver is pointing
const owner_package = resolver.packageCache.ownerOfFile(result.filename);
// change import to real one
import_declaration.node.source.value =
owner_package!.name + '/' + extractor[1] + '/' + extractor[2];
let relative_import_path = relative(owner_package!.root, result.filename);
// for addons strip off appPublicationDir from relative path
// we do this on app files as well as they don't contain the
// path that we strip off
// this makes sure that ambiguous imports get properly attributed
relative_import_path = relative_import_path.replace('_app_/', '');
// remove the extension to match what a developer would normally write
relative_import_path = relative_import_path.slice(0, -extname(relative_import_path).length);

// change import path to real one
import_declaration.node.source.value = owner_package!.name + '/' + relative_import_path;
import_bucket.push(import_declaration);
}
} else if (import_declaration.node.source.value.indexOf('@ember/template-compilation') === -1) {
Expand All @@ -134,7 +175,7 @@ class TemplateTagCodemodPlugin extends Plugin {
path.node.arguments &&
'value' in path.node.arguments[0]
) {
transformed_template_value = `<template>\n\t${path.node.arguments[0].value}\n</template>`;
template_tag_value = `<template>\n\t${path.node.arguments[0].value}\n</template>`;
}
},
},
Expand All @@ -150,79 +191,97 @@ class TemplateTagCodemodPlugin extends Plugin {
);

const backing_class_filename = 'filename' in backing_class_resolution ? backing_class_resolution.filename : '';
// this can be either a generated js file in case of template only components
// the js or ts file depending on what the app is configured
const backing_class_src = readFileSync(backing_class_filename).toString();
const magic_string = '__MAGIC_STRING_FOR_TEMPLATE_TAG_REPLACE__';
const is_template_only =
backing_class_src.indexOf("import templateOnlyComponent from '@ember/component/template-only';") !== -1;

src = transformSync(backing_class_src, {
const is_typescript = extname(backing_class_filename) === '.ts';

let insert_imports_byte_count = null;
let insert_template_byte_count = null;

const is_template_only = backing_class_src.indexOf(TEMPLATE_ONLY_MARKER) !== -1;

// we parse the backing class to find the insert points for imports and template
transformSync(backing_class_src, {
plugins: [
[babel_plugin_syntax_decorators, { decoratorsBeforeExport: true }],
function glimmer_syntax_creator(babel): unknown {
[
is_typescript ? babel_plugin_syntax_typescript : babel_plugin_syntax_decorators,
{ decoratorsBeforeExport: true },
],
function glimmer_syntax_creator(/* babel */): unknown {
return {
name: 'test',
visitor: {
Program: {
enter(path: NodePath<t.Program>) {
// Always instantiate the ImportUtil instance at the Program scope
const importUtil = new ImportUtil(babel.types, path);
const first_node = path.get('body')[0];
if (
first_node &&
first_node.node &&
first_node.node.leadingComments &&
first_node.node.leadingComments[0]?.value.includes('__COLOCATED_TEMPLATE__')
) {
//remove magic comment
first_node.node.leadingComments.splice(0, 1);
}
for (const template_import of import_bucket) {
for (let i = 0, len = template_import.node.specifiers.length; i < len; ++i) {
const specifier = template_import.node.specifiers[i];
if (specifier.type === 'ImportDefaultSpecifier') {
importUtil.import(path, template_import.node.source.value, 'default', specifier.local.name);
} else if (specifier.type === 'ImportSpecifier') {
importUtil.import(path, template_import.node.source.value, specifier.local.name);
}
}
}
},
},
ImportDeclaration(import_declaration: NodePath<t.ImportDeclaration>) {
if (import_declaration.node.source.value.indexOf('@ember/component/template-only') !== -1) {
import_declaration.remove();
}
insert_imports_byte_count = import_declaration.node.end;
},
ExportDefaultDeclaration(path: NodePath<t.ExportDefaultDeclaration>) {
// convention is that we have a default export for each component
// we look for the closing bracket of the class body
path.traverse({
ClassBody(path) {
const classbody_nodes = path.get('body');
//add magic string to be replaces with the contents of the template tag
classbody_nodes[classbody_nodes.length - 1].addComment('trailing', magic_string, false);
// we substract 1 to find the byte right before the final closing bracket `}`
// this is the default insert point for template tag though it could live anywhere inside the class body
// possible future point to add option for putting template first thing in class
insert_template_byte_count = path.node.end ? path.node.end - 1 : 0;
},
});
},
},
};
},
],
})!.code!.replace(`/*${magic_string}*/`, transformed_template_value);
});

// list of imports needed by the previous hbs template extracted in second step
const hbs_template_required_imports = import_bucket.join('\n');

// we extracted all we needed from transformed_source so we switch to the second phase
// transforming the backing class into what will be our final output
transformed_source = backing_class_src;
if (is_template_only) {
// because we can't inject a comment as the default export
// we replace the known exported string
src = src.replace('templateOnlyComponent()', transformed_template_value);
transformed_source = transformed_source.replace('templateOnlyComponent()', template_tag_value);
// we clean known markers from generated files
transformed_source = transformed_source.replace(TEMPLATE_ONLY_MARKER, hbs_template_required_imports);
transformed_source = transformed_source.replace(TEMPLATE_COLOCATION_MARKER, '');
} else {
// we modify the source from end to start in order to keep our byte counts valid through the transforms
if (insert_template_byte_count) {
// first we split the backing class at the byte count we found during backing class parsing
// then concat the string back together adding the transformed template in the middle
transformed_source =
transformed_source.substring(0, insert_template_byte_count) +
'\n' +
template_tag_value +
'\n' +
transformed_source.substring(insert_template_byte_count, transformed_source.length);
}
if (insert_imports_byte_count) {
// first we split the backing class at the byte count we found during backing class parsing
// then concat the string back together adding the transformed template in the middle
transformed_source =
transformed_source.substring(0, insert_imports_byte_count) +
'\n' +
hbs_template_required_imports +
'\n' +
transformed_source.substring(insert_imports_byte_count, transformed_source.length);
}
transformed_source = transformed_source.replace(TEMPLATE_COLOCATION_MARKER, '');
}

const dryRun = this.options.dryRun ? '--dry-run' : '';
// work out original file path in app tree
const app_relative_path = join('app', relative(tmp_path, current_file));
const new_file_path = app_relative_path.slice(0, -4) + '.gjs';
const new_file_path = app_relative_path.slice(0, -4) + (is_typescript ? '.gts' : '.gjs');

// write glimmer file out
if (this.options.dryRun) {
console.log('Write new file', new_file_path, src);
console.log('Write new file', new_file_path, transformed_source);
} else {
writeFileSync(join(process.cwd(), new_file_path), src, { flag: 'wx+' });
writeFileSync(join(process.cwd(), new_file_path), transformed_source, { flag: 'wx+' });
}

// git rm old files (js/ts if exists + hbs)
Expand All @@ -233,7 +292,7 @@ class TemplateTagCodemodPlugin extends Plugin {

if (!is_template_only) {
// remove backing class only if it's not a template only component
// resolve repative path to rewritten-app
// resolve relative path to rewritten-app
const app_relative_path = join('app', relative(tmp_path, backing_class_filename));
let rm_js = await execute(`git rm ${app_relative_path} ${dryRun}`, {
pwd: process.cwd(),
Expand Down
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit 82923f8

Please sign in to comment.