diff --git a/packages/compat/package.json b/packages/compat/package.json index 73b3d6afc..611629c71 100644 --- a/packages/compat/package.json +++ b/packages/compat/package.json @@ -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", diff --git a/packages/compat/src/index.ts b/packages/compat/src/index.ts index 3383f6f93..419572809 100644 --- a/packages/compat/src/index.ts +++ b/packages/compat/src/index.ts @@ -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'; diff --git a/packages/compat/src/template-tag-codemod.ts b/packages/compat/src/template-tag-codemod.ts new file mode 100644 index 000000000..c930a1792 --- /dev/null +++ b/packages/compat/src/template-tag-codemod.ts @@ -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 { + 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\/(?[^\/]+)\/(?.*)/; + 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[] = []; + let transformed_template_value = ''; + transformSync(src, { + plugins: [ + function template_tag_extractor(): unknown { + return { + visitor: { + ImportDeclaration(import_declaration: NodePath) { + 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) { + // 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 = ``; + } + }, + }, + }; + }, + ], + }); + + //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) { + // 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) { + if (import_declaration.node.source.value.indexOf('@ember/component/template-only') !== -1) { + import_declaration.remove(); + } + }, + ExportDefaultDeclaration(path: NodePath) { + 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; pwd?: string } +): Promise<{ + exitCode: number; + stderr: string; + stdout: string; + output: string; +}> { + let env: Record | 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(''); + }, + }); + }); + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f7dc038b8..465b972a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -162,6 +162,9 @@ importers: '@babel/core': specifier: ^7.14.5 version: 7.24.7 + '@babel/plugin-syntax-decorators': + specifier: ^7.24.7 + version: 7.24.7(@babel/core@7.24.7) '@babel/plugin-syntax-dynamic-import': specifier: ^7.8.3 version: 7.8.3(@babel/core@7.24.7) @@ -8896,6 +8899,9 @@ packages: /ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} + peerDependenciesMeta: + ajv: + optional: true dependencies: ajv: 8.16.0 diff --git a/tests/scenarios/template-tag-codemod-test.ts b/tests/scenarios/template-tag-codemod-test.ts new file mode 100644 index 000000000..2d9a08f7f --- /dev/null +++ b/tests/scenarios/template-tag-codemod-test.ts @@ -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': `

this is a gjs file

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