Skip to content

Commit 4a3c8d2

Browse files
committed
Implement full .hbs -> .gjs codemod
Hardcoded a bunch of thins to make it work with Discourse – the chat plugin to be specific. We have a bunch of custom resolver rules that needs to be ported over to make this work. The overall strategry should be generalizable. We have a bunch of custom resolver logic that needs to be ported over, the average app can probably try to share code with the Embroider resolver – or use the Resolver from `@embroider/core` directly with `.resolver.json`. See discourse/discourse#24260 for how this code was used in context.
1 parent c9e68e4 commit 4a3c8d2

File tree

10 files changed

+928
-8
lines changed

10 files changed

+928
-8
lines changed

bin/ember-codemod-template-tag.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const argv = yargs(hideBin(process.argv))
2525

2626
const codemodOptions: CodemodOptions = {
2727
appName: argv['app-name'] ?? 'example-app',
28+
filename: 'nope',
2829
projectRoot: argv['root'] ?? process.cwd(),
2930
};
3031

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"@codemod-utils/ast-javascript": "^1.2.0",
3939
"@codemod-utils/ast-template": "^1.1.0",
4040
"@codemod-utils/files": "^1.1.0",
41+
"@glimmer/syntax": "^0.85.12",
4142
"change-case": "^5.1.2",
4243
"content-tag": "^1.1.2",
4344
"recast": "^0.23.4",

pnpm-lock.yaml

Lines changed: 33 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/index.ts

Lines changed: 87 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,93 @@
1-
import { changeExtension } from './steps/change-extension.js';
2-
import { convertTests, createOptions } from './steps/index.js';
3-
import { removeHbsImport } from './steps/remove-import.js';
1+
import { execSync } from 'node:child_process';
2+
3+
import { findFiles } from '@codemod-utils/files';
4+
5+
import finalize from './steps/finalize.js';
6+
import { createOptions } from './steps/index.js';
7+
import inlineTemplate from './steps/inline-template.js';
8+
import renameJsToGjs from './steps/rename-js-to-gjs.js';
9+
import resolveImports from './steps/resolve-imports.js';
410
import type { CodemodOptions } from './types/index.js';
511

612
export function runCodemod(codemodOptions: CodemodOptions): void {
713
const options = createOptions(codemodOptions);
814

9-
convertTests(options);
10-
changeExtension(options);
11-
removeHbsImport(options);
15+
const candidates = findFiles('**/*.hbs', {
16+
ignoreList: ['**/templates/**/*.hbs'],
17+
projectRoot: codemodOptions.projectRoot,
18+
});
19+
20+
const converted: string[] = [];
21+
const skipped: [string, string][] = [];
22+
23+
for (const candidate of candidates) {
24+
const goodRef = execSync('git rev-parse HEAD', {
25+
cwd: options.projectRoot,
26+
encoding: 'utf8',
27+
}).trim();
28+
29+
try {
30+
console.log(`Converting ${candidate}`);
31+
32+
execSync(`git reset --hard ${goodRef}`, {
33+
cwd: options.projectRoot,
34+
encoding: 'utf8',
35+
stdio: 'ignore',
36+
});
37+
38+
options.filename = candidate;
39+
40+
renameJsToGjs(options);
41+
resolveImports(options);
42+
inlineTemplate(options);
43+
finalize(options);
44+
45+
console.log(
46+
execSync(`git show --color HEAD~`, {
47+
cwd: options.projectRoot,
48+
encoding: 'utf8',
49+
}),
50+
);
51+
52+
converted.push(candidate);
53+
} catch (error: unknown) {
54+
let reason = String(error);
55+
56+
if (error instanceof Error) {
57+
reason = error.message;
58+
}
59+
60+
reason = reason.trim();
61+
62+
console.warn(`Failed to convert ${candidate}: ${reason}`);
63+
64+
execSync(`git reset --hard ${goodRef}`, {
65+
cwd: options.projectRoot,
66+
stdio: 'ignore',
67+
});
68+
69+
skipped.push([candidate, reason]);
70+
}
71+
}
72+
73+
if (converted.length) {
74+
console.log('Successfully converted %d files:\n', converted.length);
75+
76+
for (const file of converted) {
77+
console.log(`- ${file}`);
78+
}
79+
80+
console.log('\n');
81+
}
82+
83+
if (skipped.length) {
84+
console.log('Skipped %d files:\n', skipped.length);
85+
86+
for (const [file, reason] of skipped) {
87+
console.log(`- ${file}`);
88+
console.log(` ${reason}`);
89+
}
90+
91+
console.log('\n');
92+
}
1293
}

src/steps/create-options.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import type { CodemodOptions, Options } from '../types/index.js';
22

33
export function createOptions(codemodOptions: CodemodOptions): Options {
4-
const { appName: appName, projectRoot } = codemodOptions;
4+
const { appName, filename, projectRoot } = codemodOptions;
55

66
return {
7-
appName: appName,
7+
appName,
8+
filename,
89
projectRoot,
910
};
1011
}

src/steps/finalize.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { execSync } from 'node:child_process';
2+
import { unlinkSync } from 'node:fs';
3+
import path from 'node:path';
4+
5+
import type { Options } from '../types/index.js';
6+
7+
export default function finalize(options: Options): void {
8+
const hbs = path.join(options.projectRoot, options.filename);
9+
10+
const gjs = path.join(
11+
options.projectRoot,
12+
options.filename.replace('.hbs', '.gjs'),
13+
);
14+
15+
execSync(`yarn eslint --fix ${JSON.stringify(gjs)}`, {
16+
cwd: options.projectRoot,
17+
});
18+
19+
execSync(`yarn prettier --write ${JSON.stringify(gjs)}`, {
20+
cwd: options.projectRoot,
21+
});
22+
23+
execSync(`yarn ember-template-lint --fix ${JSON.stringify(hbs)}`, {
24+
cwd: options.projectRoot,
25+
});
26+
27+
execSync(`yarn prettier --write ${JSON.stringify(hbs)}`, {
28+
cwd: options.projectRoot,
29+
});
30+
31+
const label = path.basename(options.filename).replace('.hbs', '');
32+
const convert = JSON.stringify(`DEV: convert chat/${label} -> gjs`);
33+
34+
execSync('git add .', { cwd: options.projectRoot });
35+
execSync(`git commit -m ${convert}`, {
36+
cwd: options.projectRoot,
37+
stdio: 'ignore',
38+
});
39+
40+
unlinkSync(hbs);
41+
42+
const cleanup = JSON.stringify(`DEV: cleanup chat/${label}`);
43+
44+
execSync('git add .', { cwd: options.projectRoot });
45+
execSync(`git commit -m ${cleanup}`, {
46+
cwd: options.projectRoot,
47+
stdio: 'ignore',
48+
});
49+
}

src/steps/inline-template.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { readFileSync, writeFileSync } from 'node:fs';
2+
import path from 'node:path';
3+
4+
import { AST } from '@codemod-utils/ast-javascript';
5+
6+
import type { Options } from '../types/index.js';
7+
8+
export default function inlineTemplate(options: Options): void {
9+
const hbs = path.join(options.projectRoot, options.filename);
10+
11+
const hbsSource = readFileSync(hbs, { encoding: 'utf8' });
12+
13+
const gjs = path.join(
14+
options.projectRoot,
15+
options.filename.replace('.hbs', '.gjs'),
16+
);
17+
18+
const jsSource = readFileSync(gjs, { encoding: 'utf8' });
19+
20+
const traverse = AST.traverse(false);
21+
22+
let found = 0;
23+
24+
const ast = traverse(jsSource, {
25+
visitClassDeclaration(path) {
26+
if (
27+
path.node.superClass?.type === 'Identifier' &&
28+
path.node.superClass.name === 'Component'
29+
) {
30+
found++;
31+
32+
path.node.body.body.push(
33+
AST.builders.classProperty(
34+
AST.builders.identifier('__template__'),
35+
null,
36+
),
37+
);
38+
}
39+
40+
return false;
41+
},
42+
});
43+
44+
if (found === 0) {
45+
throw new Error('cannot find class');
46+
} else if (found > 1) {
47+
throw new Error('too many classes?');
48+
}
49+
50+
const jsPlaceholder = AST.print(ast);
51+
52+
const matches = [...jsPlaceholder.matchAll(/^\s*__template__;$/gm)];
53+
54+
if (matches.length === 0) {
55+
throw new Error('cannot find placeholder');
56+
} else if (matches.length > 1) {
57+
throw new Error('too many placeholders?');
58+
}
59+
60+
let templateTag = `\n`;
61+
62+
templateTag += ` <template>\n`;
63+
64+
for (const line of hbsSource.split('\n')) {
65+
templateTag += ` ${line}\n`;
66+
}
67+
68+
templateTag += ` </template>`;
69+
70+
const gjsSource = jsPlaceholder.replace(/^\s*__template__;$/m, templateTag);
71+
72+
writeFileSync(gjs, gjsSource, { encoding: 'utf8' });
73+
}

src/steps/rename-js-to-gjs.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { execSync } from 'node:child_process';
2+
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
3+
import path from 'node:path';
4+
5+
import type { Options } from '../types/index.js';
6+
7+
export default function renameJsToGjs(options: Options): void {
8+
const src = path.join(
9+
options.projectRoot,
10+
options.filename.replace('.hbs', '.js'),
11+
);
12+
13+
const dest = path.join(
14+
options.projectRoot,
15+
options.filename.replace('.hbs', '.gjs'),
16+
);
17+
18+
if (existsSync(src)) {
19+
if (readFileSync(src, { encoding: 'utf8' }).includes(`.extend(`)) {
20+
throw new Error(
21+
`It appears to be a classic class, convert it to native class first!`,
22+
);
23+
}
24+
25+
renameSync(src, dest);
26+
} else {
27+
if (options.filename.includes('/components/')) {
28+
writeFileSync(
29+
dest,
30+
`import Component from "@glimmer/component";\n` +
31+
`\n` +
32+
`export default class extends Component {\n` +
33+
`}\n`,
34+
{ encoding: 'utf8' },
35+
);
36+
} else {
37+
throw new Error(`It does not appear to be a component!`);
38+
}
39+
}
40+
41+
const label = path.basename(options.filename).replace('.hbs', '');
42+
const message = JSON.stringify(`DEV: mv chat/${label} -> gjs`);
43+
44+
execSync('git add .', { cwd: options.projectRoot });
45+
execSync(`git commit -m ${message}`, {
46+
cwd: options.projectRoot,
47+
stdio: 'ignore',
48+
});
49+
}

0 commit comments

Comments
 (0)