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

[beta] template-tag code mod #1842

Merged
merged 22 commits into from
Jul 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
7fe1c21
resolver transform to emit imports for helper and modifiers that need…
void-mAlex Feb 17, 2024
d1fc685
added built in components
void-mAlex Feb 17, 2024
87b8604
adding coverage for direct invocation of built-ins
ef4 Feb 20, 2024
95d3284
refactoring into combined list
ef4 Feb 20, 2024
562e744
fix the ambiguous cases
ef4 Feb 20, 2024
ded054d
if it's a pure builtin without an import do nothing
void-mAlex Mar 9, 2024
213f90f
correct typo for uniqueId import path
void-mAlex Mar 14, 2024
f2cb191
WIP compatBuild replacement for running an app code through embroider…
void-mAlex Mar 14, 2024
fb168b2
Merge remote-tracking branch 'origin/stable' into template-tag-codemod
void-mAlex Apr 23, 2024
4e03cad
Merge remote-tracking branch 'origin/stable' into template-tag-codemod
void-mAlex Jul 7, 2024
5770075
export templateTagCodemod entry point from compat package
void-mAlex Jul 16, 2024
8d8fc32
revert resolver transform changes
void-mAlex Jul 16, 2024
8fe8e8d
handle templateOnly components
void-mAlex Jul 16, 2024
5cb2df7
add basic test for template-tag-codemod
mansona Jul 16, 2024
2a95c0f
remove un-needed template only import
void-mAlex Jul 16, 2024
53eab6c
check template only tests passes
void-mAlex Jul 16, 2024
bbcab0a
revert lock file changes
void-mAlex Jul 16, 2024
6518cb6
pattern match against windows separators as well
void-mAlex Jul 17, 2024
4c211ea
resolve babel plugins relative to embroider compat
void-mAlex Jul 18, 2024
61e75fd
Merge branch 'template-tag-codemod' of github.com:embroider-build/emb…
void-mAlex Jul 18, 2024
962bab8
declare dependency on @babel/plugin-syntax-decorators
void-mAlex Jul 18, 2024
1498c47
duplicate execute function to avoid an export from audit system
void-mAlex Jul 18, 2024
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 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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this brings me back to what I said during the call. I imagine having a remove function that verifies that the file is in the repo (maybe after following links) before it tries to delete anything would be good enough for most people 🤔

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

anything can happen between the time the function verifies that, and actual actioning of the remove opration, git rm handles all of that very nicely for us which is perfectly.

even if we had a function to test it's within the repo, what the current implementation does is not remove anything that isn't backed up (read source controlled) which is WAY safer than removing things that happen to be in the repo. and only creates things that don't already exist.

I always error on the side of safer when it comes to removing things on behalf of users and in this case it's the test that can't cope with the solution, so we shouldn't implement a lesser solution so the test is happier

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how about inverting it? make it have an optional config that you can pass a remove function to and we can add it to the docs that if you want it to do a git remove for reasons then you just pass a function that does git remove

thoughts?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

configuration is to be avoided if possible.
convention is that code mod does the right thing by default

if anything we need to make scenario tester source control aware/capable

// 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');
});
});
});