Skip to content

Commit

Permalink
Merge pull request #2226 from embroider-build/codemod
Browse files Browse the repository at this point in the history
template-tag-codemod
  • Loading branch information
ef4 authored Jan 8, 2025
2 parents c3af848 + b5ef225 commit e73d3a7
Show file tree
Hide file tree
Showing 17 changed files with 975 additions and 117 deletions.
2 changes: 2 additions & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -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

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 @@ -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';
12 changes: 6 additions & 6 deletions packages/compat/src/resolver-transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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),
};
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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':
Expand Down Expand Up @@ -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;
});
Expand Down Expand Up @@ -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) => {
Expand Down
9 changes: 5 additions & 4 deletions packages/core/src/module-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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 {
Expand Down
41 changes: 31 additions & 10 deletions packages/core/src/node-resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,20 @@ import type { Resolver } from './module-resolver';

export class NodeRequestAdapter implements RequestAdapter<Resolution<NodeResolution, Error>> {
static create: RequestAdapterCreate<
{ resolver: Resolver; specifier: string; fromFile: string },
{ resolver: Resolver; specifier: string; fromFile: string; extensions: string[] },
Resolution<NodeResolution, Error>
> = ({ 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';
Expand Down Expand Up @@ -75,7 +75,7 @@ export class NodeRequestAdapter implements RequestAdapter<Resolution<NodeResolut

let initialError;

for (let candidate of candidates(specifier)) {
for (let candidate of candidates(specifier, this.extensions)) {
let filename;
try {
filename = require.resolve(candidate, {
Expand All @@ -92,17 +92,28 @@ export class NodeRequestAdapter implements RequestAdapter<Resolution<NodeResolut

continue;
}
if (filename.endsWith('.hbs') && !candidate.endsWith('.hbs')) {
// Evaluating the `handlebars` NPM package installs a Node extension
// that puts `*.hbs` in the automatic search path. But we can't control
// its priority, and it's really important to us that `.hbs` cannot
// shadow other extensions with higher priority. For example, when both
// `.ts` and `.hbs` exist, resolving is supposed to find the `.ts`.
//
// This covers the case where we found an hbs "by accident", when we
// weren't actually expecting it.
continue;
}
return { type: 'found', filename, result: { type: 'real' as 'real', filename }, virtual: false };
}

return { type: 'not_found', err: initialError };
}
}

function* candidates(specifier: string) {
yield specifier;
const defaultExtensions = ['.hbs.js', '.hbs'];

const extensions = ['.hbs.js', '.hbs'];
function* candidates(specifier: string, extensions: string[]) {
yield specifier;

for (let ext of extensions) {
yield `${specifier}${ext}`;
Expand All @@ -113,12 +124,22 @@ type NodeResolution = { type: 'virtual'; filename: string; content: string } | {

type NodeResolutionError = { type: 'not_found'; err: Error };

export interface NodeResolveOpts {
extensions?: string[];
}

export async function nodeResolve(
resolver: Resolver,
specifier: string,
fromFile: string
fromFile: string,
opts?: NodeResolveOpts
): Promise<NodeResolution | NodeResolutionError> {
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':
Expand Down
7 changes: 7 additions & 0 deletions packages/template-tag-codemod/.eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# compiled output
/src/**/*.js
/src/**/*.d.ts

# dependencies
/node_modules/

8 changes: 8 additions & 0 deletions packages/template-tag-codemod/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/node_modules
/src/**/*.js
/src/**/*.d.ts
/src/**/*.map
/*/tests/**/*.js
/*/tests/**/*.d.ts
/*/tests/**/*.map
*.tsbuildinfo
51 changes: 51 additions & 0 deletions packages/template-tag-codemod/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
{
"name": "@embroider/template-tag-codemod",
"version": "0.4.0",
"private": false,
"type": "module",
"description": "Convert to HBS to GJS with <template></template>",
"repository": {
"type": "git",
"url": "https://github.com/embroider-build/embroider.git",
"directory": "packages/template-tag-codemod"
},
"license": "MIT",
"author": "Edward Faulkner <[email protected]>",
"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"
}
}
65 changes: 65 additions & 0 deletions packages/template-tag-codemod/src/cli.ts
Original file line number Diff line number Diff line change
@@ -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 <template> be inserted inside existing class bodies? Say "beginning" or "end".`,
}),

async argv => {
await run(argv as Options);
}
)
.parse();
Loading

0 comments on commit e73d3a7

Please sign in to comment.