diff --git a/.eslintignore b/.eslintignore index 03fdf3c63..9ec0e7c1f 100644 --- a/.eslintignore +++ b/.eslintignore @@ -34,6 +34,8 @@ /packages/vite/index.d.ts /packages/vite/**/*.js /packages/vite/**/*.d.ts +/packages/template-tag-codemod/**/*.js +/packages/template-tag-codemod/**/*.d.ts /packages/reverse-exports/**/*.js /packages/reverse-exports/**/*.d.ts diff --git a/.prettierignore b/.prettierignore index 7db31a954..b6bd7152d 100644 --- a/.prettierignore +++ b/.prettierignore @@ -43,6 +43,8 @@ /packages/vite/index.mjs /packages/vite/**/*.js /packages/vite/**/*.d.ts +/packages/template-tag-codemod/**/*.js +/packages/template-tag-codemod/**/*.d.ts /packages/reverse-exports/**/*.js /packages/reverse-exports/**/*.d.ts diff --git a/packages/compat/src/index.ts b/packages/compat/src/index.ts index ff459ffff..de84f3eb4 100644 --- a/packages/compat/src/index.ts +++ b/packages/compat/src/index.ts @@ -4,3 +4,4 @@ export { default as Options, recommendedOptions } from './options'; export { default as V1Addon } from './v1-addon'; export { prebuild, PipelineOptions } from './default-pipeline'; export { PackageRules, ModuleRules } from './dependency-rules'; +export type { Options as ResolverTransformOptions } from './resolver-transform'; diff --git a/packages/compat/src/resolver-transform.ts b/packages/compat/src/resolver-transform.ts index e7ed0ff2b..ea5528d97 100644 --- a/packages/compat/src/resolver-transform.ts +++ b/packages/compat/src/resolver-transform.ts @@ -415,7 +415,7 @@ class TemplateResolver implements ASTPlugin { return name; } - private targetComponent(name: string): ComponentResolution | null { + private targetComponent(name: string, nameHint: string): ComponentResolution | null { if (!this.staticComponentsEnabled) { return null; } @@ -450,7 +450,7 @@ class TemplateResolver implements ASTPlugin { yieldsComponents: componentRules ? componentRules.yieldsSafeComponents : [], yieldsArguments: componentRules ? componentRules.yieldsArguments : [], argumentsAreComponents: componentRules ? componentRules.argumentsAreComponents : [], - nameHint: this.nameHint(name), + nameHint: this.nameHint(nameHint), }; } @@ -490,7 +490,7 @@ class TemplateResolver implements ASTPlugin { }; } - return this.targetComponent(component.path); + return this.targetComponent(component.path, component.path); } private targetHelper(path: string): HelperResolution | null { @@ -620,7 +620,7 @@ class TemplateResolver implements ASTPlugin { if (ownComponentRules?.disambiguate[path]) { switch (ownComponentRules.disambiguate[path]) { case 'component': - return this.targetComponent(path); + return this.targetComponent(path, path); case 'helper': return this.targetHelper(path); case 'data': @@ -838,7 +838,7 @@ class TemplateResolver implements ASTPlugin { }); return; } - let resolution = this.targetComponent(node.path.original); + let resolution = this.targetComponent(node.path.original, node.path.original); this.emit(path, resolution, (node, newId) => { node.path = newId; }); @@ -989,7 +989,7 @@ class TemplateResolver implements ASTPlugin { // if it starts with lower case, it can't be a component we need to // globally resolve if (node.tag[0] !== node.tag[0].toLowerCase()) { - resolution = this.targetComponent(dasherize(node.tag)); + resolution = this.targetComponent(dasherize(node.tag), node.tag); } this.emit(path, resolution, (node, newId) => { diff --git a/packages/core/src/module-resolver.ts b/packages/core/src/module-resolver.ts index b0e7609a1..2cf5f8afa 100644 --- a/packages/core/src/module-resolver.ts +++ b/packages/core/src/module-resolver.ts @@ -18,7 +18,7 @@ import { exports as resolveExports } from 'resolve.exports'; import { Memoize } from 'typescript-memoize'; import { describeExports } from './describe-exports'; import { readFileSync } from 'fs'; -import { nodeResolve } from './node-resolve'; +import { nodeResolve, type NodeResolveOpts } from './node-resolve'; import type { Options, EngineConfig } from './module-resolver-options'; import { satisfies } from 'semver'; import { extractResolution, type ModuleRequest, type Resolution } from './module-request'; @@ -190,13 +190,14 @@ export class Resolver { // defaultResolve already configured to be "do the normal node thing". async nodeResolve( specifier: string, - fromFile: string + fromFile: string, + opts?: NodeResolveOpts ): Promise< | { type: 'virtual'; filename: string; content: string } | { type: 'real'; filename: string } | { type: 'not_found'; err: Error } > { - return nodeResolve(this, specifier, fromFile); + return nodeResolve(this, specifier, fromFile, opts); } get packageCache() { @@ -1362,7 +1363,7 @@ export class Resolver { // check whether the given file with the given owningPackage is an addon's // appTree, and if so return the notional location within the app (or owning // engine) that it "logically" lives at. - private reverseSearchAppTree( + reverseSearchAppTree( owningPackage: Package, fromFile: string ): { owningEngine: EngineConfig; inAppName: string } | undefined { diff --git a/packages/core/src/node-resolve.ts b/packages/core/src/node-resolve.ts index 7b40efa2b..db08bff7c 100644 --- a/packages/core/src/node-resolve.ts +++ b/packages/core/src/node-resolve.ts @@ -9,20 +9,20 @@ import type { Resolver } from './module-resolver'; export class NodeRequestAdapter implements RequestAdapter> { static create: RequestAdapterCreate< - { resolver: Resolver; specifier: string; fromFile: string }, + { resolver: Resolver; specifier: string; fromFile: string; extensions: string[] }, Resolution - > = ({ resolver, specifier, fromFile }) => { + > = ({ resolver, specifier, fromFile, extensions }) => { return { initialState: { specifier, fromFile, meta: undefined, }, - adapter: new NodeRequestAdapter(resolver), + adapter: new NodeRequestAdapter(resolver, extensions), }; }; - private constructor(private resolver: Resolver) {} + private constructor(private resolver: Resolver, private extensions: string[]) {} get debugType() { return 'node'; @@ -75,7 +75,7 @@ export class NodeRequestAdapter implements RequestAdapter { - let request = ModuleRequest.create(NodeRequestAdapter.create, { resolver, fromFile, specifier }); + let request = ModuleRequest.create(NodeRequestAdapter.create, { + resolver, + fromFile, + specifier, + extensions: opts?.extensions ?? defaultExtensions, + }); let resolution = await resolver.resolve(request!); switch (resolution.type) { case 'not_found': diff --git a/packages/template-tag-codemod/.eslintignore b/packages/template-tag-codemod/.eslintignore new file mode 100644 index 000000000..4336ad549 --- /dev/null +++ b/packages/template-tag-codemod/.eslintignore @@ -0,0 +1,7 @@ +# compiled output +/src/**/*.js +/src/**/*.d.ts + +# dependencies +/node_modules/ + diff --git a/packages/template-tag-codemod/.gitignore b/packages/template-tag-codemod/.gitignore new file mode 100644 index 000000000..575705668 --- /dev/null +++ b/packages/template-tag-codemod/.gitignore @@ -0,0 +1,8 @@ +/node_modules +/src/**/*.js +/src/**/*.d.ts +/src/**/*.map +/*/tests/**/*.js +/*/tests/**/*.d.ts +/*/tests/**/*.map +*.tsbuildinfo \ No newline at end of file diff --git a/packages/template-tag-codemod/package.json b/packages/template-tag-codemod/package.json new file mode 100644 index 000000000..7d3e2b035 --- /dev/null +++ b/packages/template-tag-codemod/package.json @@ -0,0 +1,51 @@ +{ + "name": "@embroider/template-tag-codemod", + "version": "0.4.0", + "private": false, + "type": "module", + "description": "Convert to HBS to GJS with ", + "repository": { + "type": "git", + "url": "https://github.com/embroider-build/embroider.git", + "directory": "packages/template-tag-codemod" + }, + "license": "MIT", + "author": "Edward Faulkner ", + "exports": { + ".": "./src/index.js" + }, + "bin": { + "template-tag-codemod": "./src/cli.js" + }, + "files": [ + "src/**/*.js", + "src/**/*.d.ts", + "src/**/*.js.map" + ], + "scripts": {}, + "dependencies": { + "@babel/core": "^7.26.0", + "@babel/plugin-syntax-decorators": "^7.25.9", + "@babel/plugin-syntax-typescript": "^7.25.9", + "@embroider/compat": "workspace:^*", + "@embroider/core": "workspace:^*", + "@embroider/reverse-exports": "workspace:^*", + "@types/babel__core": "^7.20.5", + "@types/yargs": "^17.0.3", + "babel-plugin-ember-template-compilation": "^2.3.0", + "broccoli": "^3.5.2", + "console-ui": "^3.1.2", + "ember-cli": "^6.0.1", + "glob": "^11.0.0", + "yargs": "^17.0.1" + }, + "devDependencies": { + "@glimmer/syntax": "^0.84.3", + "@types/glob": "^8.1.0", + "@types/node": "^22.9.3", + "typescript": "^5.4.5" + }, + "engines": { + "node": ">= 20" + } +} diff --git a/packages/template-tag-codemod/src/cli.ts b/packages/template-tag-codemod/src/cli.ts new file mode 100644 index 000000000..b91585e83 --- /dev/null +++ b/packages/template-tag-codemod/src/cli.ts @@ -0,0 +1,65 @@ +#!/usr/bin/env node +import yargs from 'yargs/yargs'; +import { type Options, optionsWithDefaults, run } from './index.js'; + +yargs(process.argv.slice(2)) + .scriptName('template-tag-codemod') + .command( + '$0', + "Converts Ember's .hbs format to .gjs or .gts format.", + y => + y + .option('relativeLocalPaths', { + default: optionsWithDefaults().relativeLocalPaths, + type: 'boolean', + describe: `When true, imports for other files in the same project will use relative paths with file extensions. This is the most compatible with modern Node ESM convensions, but it's not supported by Ember's classic build.`, + }) + .option('extensions', { + array: true, + type: 'string', + default: optionsWithDefaults().extensions, + describe: `File extensions to search when resolving components, helpers, and modifiers inside your hbs files`, + }) + .option('nativeRouteTemplates', { + default: optionsWithDefaults().nativeRouteTemplates, + type: 'boolean', + describe: `When true, assume we can use template-tag directly in route files (requires ember-source >= 6.3.0-beta.3). When false, assume we can use the ember-route-template addon instead.`, + }) + .option('routeTemplates', { + array: true, + type: 'string', + default: optionsWithDefaults().routeTemplates, + describe: `Controls which route template files we will convert to template tag. Provide a list of globs.`, + }) + .option('components', { + array: true, + type: 'string', + default: optionsWithDefaults().components, + describe: `Controls which component files we will convert to template tag. Provide a list of globs.`, + }) + .option('defaultFormat', { + type: 'string', + default: optionsWithDefaults().defaultFormat, + describe: `When a .js or .ts file already exists, we necessarily convert to .gjs or .gts respectively. But when only an .hbs file exists, we have a choice of default.`, + }) + .option('templateOnlyComponentSignature', { + type: 'string', + default: optionsWithDefaults().templateOnlyComponentSignature, + describe: `Snippet of typescript to use as the type signature of newly-converted template-only components.`, + }) + .option('routeTemplateSignature', { + type: 'string', + default: optionsWithDefaults().routeTemplateSignature, + describe: `Snippet of typescript to use as the type signature of route templates.`, + }) + .option('templateInsertion', { + type: 'string', + default: optionsWithDefaults().templateInsertion, + describe: `Where should