Skip to content

Commit ba3f5fa

Browse files
committed
Add support for addons
Before template colocation, addons had to explicitly import a template and set it via the `layout` property or using the `layout` decorator. With template colocation, those can all be dropped: the build pipeline automatically sets it up correctly (though it can also still be set up manually if users so desire, using `setComponentTemplate`, which is what colocation transforms to in the end). Accordingly, running the codemod against an addon does more than it does in the (default) app case. As with apps, it *does* colocate the template with the backing class. However, it also looks up the imports for the template in question and removes those along with their binding to the `layout` property of a component class or via the `layout` decorator from `ember-decorators`. Notes on the change: - Templates shared by multiple components are excluded. In this case, it is up to end users how to list those; they are reported in the same way other skipped templates (like partials) are. - App components using the `layout` property or decorator are now updated as well. This is very rare, but the codemod can handle it trivially, so it does. Additional improvements made along the way: - Add basic JSDoc type annotations for the `Migrator` class to make working with its properties easier (autocomplete etc.). - Replace `find` methods with getters for repeated lookups where possible (some have to be rerun after other transforms). - Reuse the values looked up via the utility types.
1 parent 828cdb0 commit ba3f5fa

File tree

18 files changed

+1498
-145
lines changed

18 files changed

+1498
-145
lines changed

lib/migrator.js

Lines changed: 236 additions & 49 deletions
Large diffs are not rendered by default.

lib/utils/file.js

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
const fse = require("fs-extra");
2-
const path = require('path');
3-
const removeDirectories = require('remove-empty-directories');
2+
const path = require("path");
3+
const removeDirectories = require("remove-empty-directories");
44

55
function moveFile(sourceFilePath, targetFilePath) {
66
let targetFileDirectory = path.dirname(targetFilePath);
77
if (!fse.existsSync(targetFileDirectory)) {
88
console.info(`📁 Creating ${targetFileDirectory}`);
9-
fse.mkdirSync(targetFileDirectory, { recursive: true })
9+
fse.mkdirSync(targetFileDirectory, { recursive: true });
1010
}
1111

1212
console.info(`👍 Moving ${sourceFilePath} -> ${targetFilePath}`);
@@ -15,13 +15,17 @@ function moveFile(sourceFilePath, targetFilePath) {
1515

1616
async function removeDirs(dirPath, removeOnlyEmptyDirectories = false) {
1717
if (removeOnlyEmptyDirectories) {
18-
removeDirectories(dirPath);
18+
if (fse.existsSync(dirPath)) {
19+
removeDirectories(dirPath);
20+
}
1921
} else {
20-
await fse.remove(dirPath)
22+
if (fse.existsSync(dirPath)) {
23+
await fse.remove(dirPath);
24+
}
2125
}
2226
}
2327

2428
module.exports = {
2529
moveFile,
26-
removeDirs
27-
}
30+
removeDirs,
31+
};

lib/utils/rewrite-imports.js

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
/** Rewrite the imports for modules we moved. */
2+
3+
let parser = require('recast/parsers/typescript');
4+
const j = require("jscodeshift").withParser(parser);
5+
6+
function transform(source) {
7+
const root = j(source);
8+
9+
// Start by eliminating the classic class `layout` property and corresponding
10+
// import of the template.
11+
let layoutProperty = findClassicLayout(root);
12+
if (layoutProperty) {
13+
let importDeclaration = findMatchingImport(
14+
root,
15+
layoutProperty.node.value.name
16+
);
17+
if (importDeclaration) {
18+
importDeclaration.parent.prune();
19+
}
20+
layoutProperty.prune();
21+
}
22+
23+
// Then do the same for `layout` property set on an ES class via assignment
24+
// and corresponding import of the template.
25+
let layoutClassProperties = findClassPropertyLayout(root);
26+
layoutClassProperties.forEach((lcp) => {
27+
let importDeclaration = findMatchingImport(root, lcp.node.value.name);
28+
if (importDeclaration) {
29+
importDeclaration.parent.prune();
30+
}
31+
lcp.prune();
32+
});
33+
34+
// Finally, remove the `layout` decorator import, its usage, and the import
35+
// for whatever template was used in the decorator invocation.
36+
let layoutDecoratorSpecifier = findLayoutDecorator(root);
37+
if (layoutDecoratorSpecifier) {
38+
let decoratorUsages = findLayoutDecoratorUsage(root, layoutDecoratorSpecifier);
39+
decoratorUsages.forEach((decorator) => {
40+
let layoutName = decorator.node.expression.arguments[0].name;
41+
let importDeclaration = findMatchingImport(root, layoutName);
42+
if (importDeclaration) {
43+
importDeclaration.parent.prune();
44+
}
45+
decorator.prune();
46+
});
47+
48+
if (layoutDecoratorSpecifier.parentPath.node.specifiers.length > 1) {
49+
layoutDecoratorSpecifier.prune();
50+
} else {
51+
layoutDecoratorSpecifier.parentPath.parentPath.prune();
52+
}
53+
}
54+
55+
return root.toSource();
56+
}
57+
58+
/**
59+
ID `layout` properties on classic objects like this:
60+
61+
```js
62+
export default Component.extend({
63+
layout
64+
});
65+
```
66+
*/
67+
function findClassicLayout(root) {
68+
let layoutProperty;
69+
70+
root
71+
.find(j.CallExpression, {
72+
callee: {
73+
property: {
74+
name: "extend",
75+
},
76+
},
77+
})
78+
.forEach((path) => {
79+
path.get("arguments").filter((argumentPath) => {
80+
if (argumentPath.node.type === "ObjectExpression") {
81+
let properties = argumentPath.get("properties");
82+
let matches = properties.filter((p) => p.node.key.name === "layout");
83+
84+
layoutProperty = matches[0];
85+
}
86+
});
87+
});
88+
89+
return layoutProperty;
90+
}
91+
92+
/**
93+
ID `layout` properties on modern classes like this:
94+
95+
```js
96+
export default class MyThing extends Component {
97+
layout
98+
}
99+
```
100+
*/
101+
function findClassPropertyLayout(root) {
102+
return root.find(j.ClassDeclaration).map((path) => {
103+
let properties = path.get("body").get("body");
104+
return properties.filter((p) =>
105+
j.match(p, { type: "ClassProperty", key: { name: "layout" } })
106+
);
107+
});
108+
}
109+
110+
/**
111+
ID imports with a given name -- useful for mapping back to the import for
112+
whatever is bound to a given class property (whether classic or ES classes).
113+
*/
114+
function findMatchingImport(root, name) {
115+
let importPath;
116+
117+
root
118+
.find(j.ImportDeclaration)
119+
.forEach((importDeclaration) => {
120+
let specifiers = importDeclaration.get("specifiers");
121+
let matches = specifiers.filter((specifierPath) =>
122+
j.match(specifierPath, {
123+
type: "ImportDefaultSpecifier",
124+
local: { name },
125+
})
126+
);
127+
128+
if (matches[0]) {
129+
importPath = matches[0];
130+
}
131+
});
132+
133+
return importPath;
134+
}
135+
136+
/**
137+
ID `layout` decorator imports, since it can be renamed after importing and if
138+
so we need the local name. For example:
139+
140+
```js
141+
import { layout as templateLayout } from '@ember-decorators/component';
142+
```
143+
*/
144+
function findLayoutDecorator(root) {
145+
let importSpecifier;
146+
147+
root
148+
.find(j.ImportDeclaration, {
149+
source: { value: "@ember-decorators/component" },
150+
})
151+
.forEach((importPath) => {
152+
let specifiers = importPath.get("specifiers");
153+
let matches = specifiers.filter((specifierPath) =>
154+
j.match(specifierPath, { imported: { name: "layout" } })
155+
);
156+
157+
importSpecifier = matches[0];
158+
});
159+
160+
return importSpecifier;
161+
}
162+
163+
/**
164+
ID `layout` decorator imports, since it can be renamed after importing and ifn
165+
so we need the local name
166+
167+
```js
168+
@layout(someTemplateValue)
169+
export default class MyThing extends Component {
170+
// ...
171+
}
172+
```
173+
*/
174+
function findLayoutDecoratorUsage(root, layoutDecorator) {
175+
return root.find(j.ClassDeclaration).map((classPath) => {
176+
if (classPath.node.decorators === undefined) {
177+
return;
178+
}
179+
180+
let decorators = classPath.get("decorators");
181+
let matches = decorators.filter((decoratorPath) =>
182+
j.match(decoratorPath, {
183+
expression: {
184+
callee: { name: layoutDecorator.node.local.name },
185+
},
186+
})
187+
);
188+
189+
return matches;
190+
});
191+
}
192+
193+
module.exports = { transform };

lib/utils/templates.js

Lines changed: 77 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,30 @@
1-
const { readFileSync } = require('fs');
2-
const JSParser = require('./js-parser');
3-
const jsTraverse = require('@babel/traverse').default;
4-
const { parse, traverse } = require('ember-template-recast');
1+
const { readFileSync } = require("fs");
2+
const JSParser = require("./js-parser");
3+
const jsTraverse = require("@babel/traverse").default;
4+
const { parse, traverse } = require("ember-template-recast");
55

66
function getLayoutNameTemplates(files) {
7-
let names = files.map(file => {
8-
let content = readFileSync(file, 'utf8');
9-
return fileInLayoutName(content);
10-
}).filter(Boolean);
7+
let names = files
8+
.map((file) => {
9+
let content = readFileSync(file, "utf8");
10+
return fileInLayoutName(content);
11+
})
12+
.filter(Boolean);
1113
return Array.from(new Set(names));
1214
}
1315

1416
function fileInLayoutName(content) {
1517
let ast = JSParser.parse(content);
1618
let layoutName;
1719
jsTraverse(ast, {
18-
ClassProperty: function(path) {
19-
if (path.node.key.name === 'layoutName') {
20+
ClassProperty: function (path) {
21+
if (path.node.key.name === "layoutName") {
2022
layoutName = path.node.key.value.value;
2123
path.stop();
2224
}
2325
},
24-
Property: function(path) {
25-
if (path.node.key.name === 'layoutName') {
26+
Property: function (path) {
27+
if (path.node.key.name === "layoutName") {
2628
layoutName = path.node.value.value;
2729
path.stop();
2830
}
@@ -32,13 +34,14 @@ function fileInLayoutName(content) {
3234
}
3335

3436
function getPartialTemplates(files) {
35-
let names = files.reduce((acc, file) => {
36-
let content = readFileSync(file, 'utf8');
37-
let partials = filesInPartials(content);
38-
return partials.length ? acc.concat(partials) : acc;
39-
}, [])
40-
.filter(Boolean)
41-
.filter(path => path.startsWith('components/'))
37+
let names = files
38+
.reduce((acc, file) => {
39+
let content = readFileSync(file, "utf8");
40+
let partials = filesInPartials(content);
41+
return partials.length ? acc.concat(partials) : acc;
42+
}, [])
43+
.filter(Boolean)
44+
.filter((path) => path.startsWith("components/"));
4245
return Array.from(new Set(names));
4346
}
4447

@@ -47,15 +50,67 @@ function filesInPartials(content) {
4750
const ast = parse(content);
4851
traverse(ast, {
4952
MustacheStatement(node) {
50-
if (node.path.original === 'partial') {
53+
if (node.path.original === "partial") {
5154
partials.push(node.params[0].value);
5255
}
5356
},
5457
});
5558
return partials;
5659
}
5760

61+
/**
62+
* Get a
63+
* @param {string[]} componentPaths
64+
* @param {string[]} templatePaths
65+
* @return {Array<{ backingClassPath: string, importedTemplates: string[]]>}
66+
*/
67+
function getImportedTemplates(componentPaths, templatePaths) {
68+
let pathsForComparison = templatePaths.map(path =>
69+
path.replace(/.*\/templates/gi, "templates").replace('.hbs', '')
70+
);
71+
72+
return (
73+
componentPaths
74+
// load the contents of each backing class, preserving its original file
75+
// path so it can be used in later steps.
76+
.map((path) => [path, readFileSync(path, { encoding: "utf8" })])
77+
// Then parse each file's contents into an AST to search through.
78+
.map(([path, contents]) => [path, JSParser.parse(contents)])
79+
// Then traverse the backing class's AST for imports matching any of the
80+
// known template paths.
81+
.map(([path, ast]) => ({
82+
backingClassPath: path,
83+
importedTemplates: importedTemplates(ast, pathsForComparison),
84+
}))
85+
// Finally, get rid of any component paths which didn't have any matches,
86+
// since we only care about components which *do* import the templates.
87+
.filter(({ importedTemplates }) => importedTemplates.length > 0)
88+
);
89+
}
90+
91+
/**
92+
* @param {File} ast
93+
* @param {string[]} templatePaths
94+
* @returns {string[]}
95+
*/
96+
function importedTemplates(ast, templatePaths) {
97+
/** @type {string[]} */
98+
let templatesImportedInAst = [];
99+
100+
jsTraverse(ast, {
101+
ImportDeclaration(path) {
102+
const { value } = path.node.source;
103+
if (templatePaths.find(path => value.includes(path))) {
104+
templatesImportedInAst.push(value);
105+
}
106+
},
107+
});
108+
109+
return templatesImportedInAst;
110+
}
111+
58112
module.exports = {
59113
getLayoutNameTemplates,
60-
getPartialTemplates
61-
}
114+
getPartialTemplates,
115+
getImportedTemplates,
116+
};

0 commit comments

Comments
 (0)