-
Notifications
You must be signed in to change notification settings - Fork 142
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
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 d1fc685
added built in components
void-mAlex 87b8604
adding coverage for direct invocation of built-ins
ef4 95d3284
refactoring into combined list
ef4 562e744
fix the ambiguous cases
ef4 ded054d
if it's a pure builtin without an import do nothing
void-mAlex 213f90f
correct typo for uniqueId import path
void-mAlex f2cb191
WIP compatBuild replacement for running an app code through embroider…
void-mAlex fb168b2
Merge remote-tracking branch 'origin/stable' into template-tag-codemod
void-mAlex 4e03cad
Merge remote-tracking branch 'origin/stable' into template-tag-codemod
void-mAlex 5770075
export templateTagCodemod entry point from compat package
void-mAlex 8d8fc32
revert resolver transform changes
void-mAlex 8fe8e8d
handle templateOnly components
void-mAlex 5cb2df7
add basic test for template-tag-codemod
mansona 2a95c0f
remove un-needed template only import
void-mAlex 53eab6c
check template only tests passes
void-mAlex bbcab0a
revert lock file changes
void-mAlex 6518cb6
pattern match against windows separators as well
void-mAlex 4c211ea
resolve babel plugins relative to embroider compat
void-mAlex 61e75fd
Merge branch 'template-tag-codemod' of github.com:embroider-build/emb…
void-mAlex 962bab8
declare dependency on @babel/plugin-syntax-decorators
void-mAlex 1498c47
duplicate execute function to avoid an export from audit system
void-mAlex File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(''); | ||
}, | ||
}); | ||
}); | ||
}); | ||
} |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'); | ||
}); | ||
}); | ||
}); |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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 🤔
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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