Skip to content

Commit

Permalink
Implement full .hbs -> .gjs codemod
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
chancancode committed Nov 7, 2023
1 parent c9e68e4 commit 0c1ef12
Show file tree
Hide file tree
Showing 10 changed files with 930 additions and 8 deletions.
1 change: 1 addition & 0 deletions bin/ember-codemod-template-tag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const argv = yargs(hideBin(process.argv))

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

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"@codemod-utils/ast-javascript": "^1.2.0",
"@codemod-utils/ast-template": "^1.1.0",
"@codemod-utils/files": "^1.1.0",
"@glimmer/syntax": "^0.85.12",
"change-case": "^5.1.2",
"content-tag": "^1.1.2",
"recast": "^0.23.4",
Expand Down
33 changes: 33 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

93 changes: 87 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,93 @@
import { changeExtension } from './steps/change-extension.js';
import { convertTests, createOptions } from './steps/index.js';
import { removeHbsImport } from './steps/remove-import.js';
import { execSync } from 'node:child_process';

import { findFiles } from '@codemod-utils/files';

import finalize from './steps/finalize.js';
import { createOptions } from './steps/index.js';
import inlineTemplate from './steps/inline-template.js';
import renameJsToGjs from './steps/rename-js-to-gjs.js';
import resolveImports from './steps/resolve-imports.js';
import type { CodemodOptions } from './types/index.js';

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

convertTests(options);
changeExtension(options);
removeHbsImport(options);
const candidates = findFiles('**/*.hbs', {
ignoreList: ['**/templates/**/*.hbs'],
projectRoot: codemodOptions.projectRoot,
});

const converted: string[] = [];
const skipped: [string, string][] = [];

for (const candidate of candidates) {
const goodRef = execSync('git rev-parse HEAD', {
cwd: options.projectRoot,
encoding: 'utf8',
}).trim();

try {
console.log(`Converting ${candidate}`);

execSync(`git reset --hard ${goodRef}`, {
cwd: options.projectRoot,
encoding: 'utf8',
stdio: 'ignore',
});

options.filename = candidate;

renameJsToGjs(options);
resolveImports(options);
inlineTemplate(options);
finalize(options);

console.log(
execSync(`git show --color HEAD~`, {
cwd: options.projectRoot,
encoding: 'utf8',
}),
);

converted.push(candidate);
} catch (error: unknown) {
let reason = String(error);

if (error instanceof Error) {
reason = error.message;
}

reason = reason.trim();

console.warn(`Failed to convert ${candidate}: ${reason}`);

execSync(`git reset --hard ${goodRef}`, {
cwd: options.projectRoot,
stdio: 'ignore',
});

skipped.push([candidate, reason]);
}
}

if (converted.length) {
console.log('Successfully converted %d files:\n', converted.length);

for (const file of converted) {
console.log(`- ${file}`);
}

console.log('\n');
}

if (skipped.length) {
console.log('Skipped %d files:\n', skipped.length);

for (const [file, reason] of skipped) {
console.log(`- ${file}`);
console.log(` ${reason}`);
}

console.log('\n');
}
}
5 changes: 3 additions & 2 deletions src/steps/create-options.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { CodemodOptions, Options } from '../types/index.js';

export function createOptions(codemodOptions: CodemodOptions): Options {
const { appName: appName, projectRoot } = codemodOptions;
const { appName, filename, projectRoot } = codemodOptions;

return {
appName: appName,
appName,
filename,
projectRoot,
};
}
49 changes: 49 additions & 0 deletions src/steps/finalize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { execSync } from 'node:child_process';
import { unlinkSync } from 'node:fs';
import path from 'node:path';

import type { Options } from '../types/index.js';

export default function finalize(options: Options): void {
const hbs = path.join(options.projectRoot, options.filename);

const gjs = path.join(
options.projectRoot,
options.filename.replace('.hbs', '.gjs'),
);

execSync(`yarn eslint --fix ${JSON.stringify(gjs)}`, {
cwd: options.projectRoot,
});

execSync(`yarn prettier --write ${JSON.stringify(gjs)}`, {
cwd: options.projectRoot,
});

execSync(`yarn ember-template-lint --fix ${JSON.stringify(hbs)}`, {
cwd: options.projectRoot,
});

execSync(`yarn prettier --write ${JSON.stringify(hbs)}`, {
cwd: options.projectRoot,
});

const label = path.basename(options.filename).replace('.hbs', '');
const convert = JSON.stringify(`DEV: convert chat/${label} -> gjs`);

execSync('git add .', { cwd: options.projectRoot });
execSync(`git commit -m ${convert}`, {
cwd: options.projectRoot,
stdio: 'ignore',
});

unlinkSync(hbs);

const cleanup = JSON.stringify(`DEV: cleanup chat/${label}`);

execSync('git add .', { cwd: options.projectRoot });
execSync(`git commit -m ${cleanup}`, {
cwd: options.projectRoot,
stdio: 'ignore',
});
}
73 changes: 73 additions & 0 deletions src/steps/inline-template.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';

import { AST } from '@codemod-utils/ast-javascript';

import type { Options } from '../types/index.js';

export default function inlineTemplate(options: Options): void {
const hbs = path.join(options.projectRoot, options.filename);

const hbsSource = readFileSync(hbs, { encoding: 'utf8' });

const gjs = path.join(
options.projectRoot,
options.filename.replace('.hbs', '.gjs'),
);

const jsSource = readFileSync(gjs, { encoding: 'utf8' });

const traverse = AST.traverse(false);

let found = 0;

const ast = traverse(jsSource, {
visitClassDeclaration(path) {
if (
path.node.superClass?.type === 'Identifier' &&
path.node.superClass.name === 'Component'
) {
found++;

path.node.body.body.push(
AST.builders.classProperty(
AST.builders.identifier('__template__'),
null,
),
);
}

return false;
},
});

if (found === 0) {
throw new Error('cannot find class');
} else if (found > 1) {
throw new Error('too many classes?');
}

const jsPlaceholder = AST.print(ast);

const matches = [...jsPlaceholder.matchAll(/^\s*__template__;$/gm)];

if (matches.length === 0) {
throw new Error('cannot find placeholder');
} else if (matches.length > 1) {
throw new Error('too many placeholders?');
}

let templateTag = `\n`;

templateTag += ` <template>\n`;

for (const line of hbsSource.split('\n')) {
templateTag += ` ${line}\n`;
}

templateTag += ` </template>`;

const gjsSource = jsPlaceholder.replace(/^\s*__template__;$/m, templateTag);

writeFileSync(gjs, gjsSource, { encoding: 'utf8' });
}
49 changes: 49 additions & 0 deletions src/steps/rename-js-to-gjs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { execSync } from 'node:child_process';
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
import path from 'node:path';

import type { Options } from '../types/index.js';

export default function renameJsToGjs(options: Options): void {
const src = path.join(
options.projectRoot,
options.filename.replace('.hbs', '.js'),
);

const dest = path.join(
options.projectRoot,
options.filename.replace('.hbs', '.gjs'),
);

if (existsSync(src)) {
if (readFileSync(src, { encoding: 'utf8' }).includes(`.extend(`)) {
throw new Error(
`It appears to be a classic class, convert it to native class first!`,
);
}

renameSync(src, dest);
} else {
if (options.filename.includes('/components/')) {
writeFileSync(
dest,
`import Component from "@glimmer/component";\n` +
`\n` +
`export default class extends Component {\n` +
`}\n`,
{ encoding: 'utf8' },
);
} else {
throw new Error(`It does not appear to be a component!`);
}
}

const label = path.basename(options.filename).replace('.hbs', '');
const message = JSON.stringify(`DEV: mv chat/${label} -> gjs`);

execSync('git add .', { cwd: options.projectRoot });
execSync(`git commit -m ${message}`, {
cwd: options.projectRoot,
stdio: 'ignore',
});
}
Loading

0 comments on commit 0c1ef12

Please sign in to comment.