Skip to content

Commit

Permalink
Merge pull request #1842 from embroider-build/template-tag-codemod
Browse files Browse the repository at this point in the history
[beta] template-tag code mod
  • Loading branch information
void-mAlex authored Jul 18, 2024
2 parents ae1f07e + 1498c47 commit 40eb106
Show file tree
Hide file tree
Showing 5 changed files with 348 additions and 0 deletions.
1 change: 1 addition & 0 deletions packages/compat/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"dependencies": {
"@babel/code-frame": "^7.14.5",
"@babel/core": "^7.14.5",
"@babel/plugin-syntax-decorators": "^7.24.7",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-transform-runtime": "^7.14.5",
"@babel/preset-env": "^7.14.5",
Expand Down
1 change: 1 addition & 0 deletions packages/compat/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ export { default as Addons } from './compat-addons';
export { default as Options, recommendedOptions } from './options';
export { default as V1Addon } from './v1-addon';
export { default as compatBuild, PipelineOptions } from './default-pipeline';
export { default as templateTagCodemod } from './template-tag-codemod';
export { PackageRules, ModuleRules } from './dependency-rules';
292 changes: 292 additions & 0 deletions packages/compat/src/template-tag-codemod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,292 @@
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 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 { spawn } from 'child_process';
import { locateEmbroiderWorkingDir } from '@embroider/core';

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

export default function templateTagCodemod(
emberApp: EmberAppInstance,
{ shouldTransformPath = (() => true) as TemplateTagCodemodOptions['shouldTransformPath'], dryRun = false } = {}
): Node {
return new TemplateTagCodemodPlugin(
[
compatBuild(emberApp, undefined, {
staticAddonTrees: true,
staticAddonTestSupportTrees: true,
staticComponents: true,
staticHelpers: true,
staticModifiers: true,
staticEmberSource: true,
amdCompatibility: {
es: [],
},
}),
],
{ shouldTransformPath, dryRun }
);
}
class TemplateTagCodemodPlugin extends Plugin {
constructor(inputNodes: InputNode[], readonly options: TemplateTagCodemodOptions) {
super(inputNodes, {
name: 'TemplateTagCodemodPlugin',
});
}
async build() {
function* walkSync(dir: string): Generator<string> {
const files = readdirSync(dir);

for (const file of files) {
const pathToFile = join(dir, file);
const isDirectory = statSync(pathToFile).isDirectory();
if (isDirectory) {
yield* walkSync(pathToFile);
} else {
yield pathToFile;
}
}
}
this.inputPaths[0];
const tmp_path = readFileSync(this.inputPaths[0] + '/.stage2-output').toLocaleString();
const compatPattern = /#embroider_compat\/(?<type>[^\/]+)\/(?<rest>.*)/;
const resolver = new ResolverLoader(process.cwd()).resolver;
const hbs_file_test = /[\\/]rewritten-app[\\/]components[\\/].*\.hbs$/;
// locate ember-source for the host app so we know which version to insert builtIns for
const emberSourceEntrypoint = require.resolve('ember-source', { paths: [process.cwd()] });
const emberVersion = JSON.parse(readFileSync(join(emberSourceEntrypoint, '../../package.json')).toString()).version;

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();
const ember_template_compiler = resolver.nodeResolve(
'ember-source/vendor/ember/ember-template-compiler',
resolve(locateEmbroiderWorkingDir(process.cwd()), 'rewritten-app', 'package.json')
);
if (ember_template_compiler.type === 'not_found') {
throw 'This will not ever be true';
}

const embroider_compat_path = require.resolve('@embroider/compat', { paths: [process.cwd()] });
const babel_plugin_ember_template_compilation = require.resolve('babel-plugin-ember-template-compilation', {
paths: [embroider_compat_path],
});
const babel_plugin_syntax_decorators = require.resolve('@babel/plugin-syntax-decorators', {
paths: [embroider_compat_path],
});
let src =
transformSync(hbsToJS(template_file_src), {
plugins: [
[
babel_plugin_ember_template_compilation,
{
compilerPath: ember_template_compiler.filename,
transforms: [ResolverTransform({ appRoot: process.cwd(), emberVersion: emberVersion })],
targetFormat: 'hbs',
},
],
],
})?.code ?? '';
const import_bucket: NodePath<t.ImportDeclaration>[] = [];
let transformed_template_value = '';
transformSync(src, {
plugins: [
function template_tag_extractor(): unknown {
return {
visitor: {
ImportDeclaration(import_declaration: NodePath<t.ImportDeclaration>) {
const extractor = import_declaration.node.source.value.match(compatPattern);
if (extractor) {
const result = resolver.nodeResolve(extractor[0], current_file);
if (result.type === 'real') {
// find package
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];
import_bucket.push(import_declaration);
}
} else if (import_declaration.node.source.value.indexOf('@ember/template-compilation') === -1) {
import_bucket.push(import_declaration);
}
},
CallExpression(path: NodePath<t.CallExpression>) {
// reverse of hbs to js
// extract the template string to put into template tag in backing class
if (
'name' in path.node.callee &&
path.node.callee.name === 'precompileTemplate' &&
path.node.arguments &&
'value' in path.node.arguments[0]
) {
transformed_template_value = `<template>\n\t${path.node.arguments[0].value}\n</template>`;
}
},
},
};
},
],
});

//find backing class
const backing_class_resolution = resolver.nodeResolve(
'#embroider_compat/' + relative(tmp_path, current_file).replace(/[\\]/g, '/').slice(0, -4),
tmp_path
);

const backing_class_filename = 'filename' in backing_class_resolution ? backing_class_resolution.filename : '';
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, {
plugins: [
[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();
}
},
ExportDefaultDeclaration(path: NodePath<t.ExportDefaultDeclaration>) {
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);
},
});
},
},
};
},
],
})!.code!.replace(`/*${magic_string}*/`, transformed_template_value);
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);
}

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';

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

// git rm old files (js/ts if exists + hbs)
let rm_hbs = await execute(`git rm ${app_relative_path} ${dryRun}`, {
pwd: process.cwd(),
});
console.log(rm_hbs.output);

if (!is_template_only) {
// remove backing class only if it's not a template only component
// resolve repative 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(),
});

console.log(rm_js.output);
}
}
}
}
}

async function execute(
shellCommand: string,
opts?: { env?: Record<string, string>; pwd?: string }
): Promise<{
exitCode: number;
stderr: string;
stdout: string;
output: string;
}> {
let env: Record<string, string | undefined> | undefined;
if (opts?.env) {
env = { ...process.env, ...opts.env };
}
let child = spawn(shellCommand, {
stdio: ['inherit', 'pipe', 'pipe'],
cwd: opts?.pwd,
shell: true,
env,
});
let stderrBuffer: string[] = [];
let stdoutBuffer: string[] = [];
let combinedBuffer: string[] = [];
child.stderr.on('data', data => {
stderrBuffer.push(data);
combinedBuffer.push(data);
});
child.stdout.on('data', data => {
stdoutBuffer.push(data);
combinedBuffer.push(data);
});
return new Promise(resolve => {
child.on('close', (exitCode: number) => {
resolve({
exitCode,
get stdout() {
return stdoutBuffer.join('');
},
get stderr() {
return stderrBuffer.join('');
},
get output() {
return combinedBuffer.join('');
},
});
});
});
}
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.

48 changes: 48 additions & 0 deletions tests/scenarios/template-tag-codemod-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { readFileSync } from 'fs-extra';
import { appScenarios } from './scenarios';
import QUnit from 'qunit';
import { join } from 'path';

const { module: Qmodule, test } = QUnit;

appScenarios
.only('release')
.map('template-tag-codemod', project => {
project.mergeFiles({
app: {
components: {
'face.hbs': `<h1> this is a gjs file</h1>`,
},
},
'ember-cli-build.js': `'use strict';
const EmberApp = require('ember-cli/lib/broccoli/ember-app');
module.exports = function (defaults) {
const app = new EmberApp(defaults, {
// Add options here
});
return require('@embroider/compat').templateTagCodemod(app, {});
};`,
});
})
.forEachScenario(async scenario => {
Qmodule(`${scenario.name}`, function (/* hooks */) {
test('running the codemod works', async function (assert) {
let app = await scenario.prepare();
await app.execute('node ./node_modules/ember-cli/bin/ember b');

// TODO figure out how to get assert.codeContains to understand template tag
const fileContents = readFileSync(join(app.dir, 'app/components/face.gjs'), 'utf-8');
assert.equal(
fileContents,
`export default <template>
<h1> this is a gjs file</h1>
</template>;`
);
// TODO figure out how to get around the protection in place to not delete unversioned files
// we do git rm for the very reason we avoid possible destructive operations
// assert.ok(!existsSync(join(app.dir, 'app/components/face.hbs')), 'template only component gets deleted');
});
});
});

0 comments on commit 40eb106

Please sign in to comment.