Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add option to log dynamic translation lookups (in HBS files) #514

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions __snapshots__/test.js.snap
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Test Fixtures concat-expression 1`] = `
"[1/4] 🔍 Finding JS and HBS files...
[2/4] 🔍 Searching for translations keys in JS and HBS files...

⭐ Found 2 dynamic translations. This might cause this tool to report more unused translations than there actually are!

- prefix.{{this.dynamicKey}}.not-missing (used in app/templates/application.hbs)
- prefix.{{this.dynamicKey}}.value (used in app/templates/application.hbs)
[3/4] ⚙️ Checking for unused translations...
[4/4] ⚙️ Checking for missing translations...

👏 No unused translations were found!

👏 No missing translations were found!
"
`;

exports[`Test Fixtures concat-expression 2`] = `Map {}`;

exports[`Test Fixtures decorators 1`] = `
"[1/4] 🔍 Finding JS and HBS files...
[2/4] 🔍 Searching for translations keys in JS and HBS files...
Expand Down
5 changes: 4 additions & 1 deletion bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ const { run } = require('../index');

let rootDir = pkgDir.sync();

run(rootDir, { fix: process.argv.includes('--fix') })
run(rootDir, {
fix: process.argv.includes('--fix'),
logDynamic: process.argv.includes('--log-dynamic'),
})
.then(exitCode => {
process.exitCode = exitCode;
})
Expand Down
8 changes: 8 additions & 0 deletions fixtures/concat-expression/app/templates/application.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{{t (concat "prefix" "." "simple")}}
{{t (concat "prefix." (if true "with-if-first" "with-if-second"))}}
{{t (concat "prefix.some-action" (if this.isCompact "-short"))}}
{{t (concat "prefix." (if this.isEditing "edit" "new") ".label")}}
{{t (if true "foo" (concat "prefix." (if this.isEditing "edit" "new") ".nested"))}}
{{t (concat "prefix." this.dynamicKey ".not-missing")}}
{{t (concat "prefix." (if true "a1" (if false "b1" (concat "c" (if false "1" "2")))) ".value")}}
{{t (concat "prefix." (if true this.dynamicKey "key-that-should-exist") ".value")}}
23 changes: 23 additions & 0 deletions fixtures/concat-expression/translations/en.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
prefix:
simple: Simple concatenation
with-if-first: First condition
with-if-second: Second condition
some-action: Action that can be long
some-action-short: Action
edit:
label: Label on edit branch
nested: Nested concat on edit branch
new:
label: Label on new branch
nested: Nested concat on new branch
a1:
value: Value
b1:
value: Value
c1:
value: Value
c2:
value: Value
key-that-should-exist:
value: value
foo: Foo
180 changes: 145 additions & 35 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ async function run(rootDir, options = {}) {
let log = options.log || console.log;
let writeToFile = options.writeToFile || fs.writeFileSync;
let shouldFix = options.fix || false;
let logDynamic = options.logDynamic || false;

let chalkOptions = {};
if (options.color === false) {
Expand All @@ -33,7 +34,26 @@ async function run(rootDir, options = {}) {
let files = [...appFiles, ...inRepoFiles];

log(`${step(2)} 🔍 Searching for translations keys in JS and HBS files...`);
let usedTranslationKeys = await analyzeFiles(rootDir, files);
let [usedTranslationKeys, usedDynamicTranslations] = await analyzeFiles(rootDir, files);

if (logDynamic) {
if (usedDynamicTranslations.size === 0) {
log();
log(' ⭐ No dynamic translations were found.');
log();
} else {
log();
log(
` ⭐ Found ${chalk.bold.yellow(
usedDynamicTranslations.size
)} dynamic translations. This might cause this tool to report more unused translations than there actually are!`
);
log();
for (let [key, files] of usedDynamicTranslations) {
log(` - ${key} ${chalk.dim(`(used in ${generateFileList(files)})`)}`);
}
}
}

log(`${step(3)} ⚙️ Checking for unused translations...`);

Expand Down Expand Up @@ -163,9 +183,10 @@ function joinPaths(inputPathOrPaths, outputPaths) {

async function analyzeFiles(cwd, files) {
let allTranslationKeys = new Map();
let allDynamicTranslations = new Map();

for (let file of files) {
let translationKeys = await analyzeFile(cwd, file);
let [translationKeys, dynamicTranslations] = await analyzeFile(cwd, file);

for (let key of translationKeys) {
if (allTranslationKeys.has(key)) {
Expand All @@ -174,9 +195,17 @@ async function analyzeFiles(cwd, files) {
allTranslationKeys.set(key, new Set([file]));
}
}

for (let dynamicTranslation of dynamicTranslations) {
if (allDynamicTranslations.has(dynamicTranslation)) {
allDynamicTranslations.get(dynamicTranslation).add(file);
} else {
allDynamicTranslations.set(dynamicTranslation, new Set([file]));
}
}
}

return allTranslationKeys;
return [allTranslationKeys, allDynamicTranslations];
}

async function analyzeFile(cwd, file) {
Expand Down Expand Up @@ -229,57 +258,138 @@ async function analyzeJsFile(content) {
},
});

return translationKeys;
return [translationKeys, new Set()];
}

async function analyzeHbsFile(content) {
let translationKeys = new Set();
let dynamicTranslations = new Set();

// parse the HBS file
let ast = Glimmer.preprocess(content);

class StringKey {
constructor(value) {
this.value = value;
}

join(otherKey) {
if (otherKey instanceof StringKey) {
return new StringKey(this.value + otherKey.value);
} else {
return new CompositeKey(this, otherKey);
}
}

toString() {
return this.value;
}
}

class DynamicKey {
constructor(node) {
this.node = node;
}

join(otherKey) {
return new CompositeKey(this, otherKey);
}

toString() {
if (this.node.type === 'PathExpression') {
return `{{${this.node.original}}}`;
} else if (this.node.type === 'SubExpression') {
return `{{${this.node.path.original} helper}}`;
}

return '{{dynamic key}}';
}
}

class CompositeKey {
constructor(...values) {
this.values = values;
}

join(otherKey) {
return new CompositeKey(...this.values, otherKey);
}

toString() {
return this.values.reduce((string, value) => string + value.toString(), '');
}
}

function findKeysInIfExpression(node) {
let keysInFirstParam = findKeysInNode(node.params[1]);
let keysInSecondParam =
node.params.length > 2 ? findKeysInNode(node.params[2]) : [new StringKey('')];

return [...keysInFirstParam, ...keysInSecondParam];
}

function findKeysInConcatExpression(node) {
let potentialKeys = [new StringKey('')];

for (let param of node.params) {
let keysInParam = findKeysInNode(param);

if (keysInParam.length === 0) return [];

potentialKeys = potentialKeys.reduce((newPotentialKeys, potentialKey) => {
for (let key of keysInParam) {
newPotentialKeys.push(potentialKey.join(key));
}

return newPotentialKeys;
}, []);
}

return potentialKeys;
}

function findKeysInNode(node) {
if (!node) return [];

if (node.type === 'StringLiteral') {
return [new StringKey(node.value)];
} else if (node.type === 'SubExpression' && node.path.original === 'if') {
return findKeysInIfExpression(node);
} else if (node.type === 'SubExpression' && node.path.original === 'concat') {
return findKeysInConcatExpression(node);
}

return [new DynamicKey(node)];
}

function processNode(node) {
if (node.path.type !== 'PathExpression') return;
if (node.path.original !== 't') return;
if (node.params.length === 0) return;

for (let key of findKeysInNode(node.params[0])) {
if (key instanceof StringKey) {
translationKeys.add(key.value);
} else {
dynamicTranslations.add(key.toString());
}
}
}

// find translation keys in the syntax tree
Glimmer.traverse(ast, {
// handle {{t "foo"}} case
MustacheStatement(node) {
if (node.path.type !== 'PathExpression') return;
if (node.path.original !== 't') return;
if (node.params.length === 0) return;

let firstParam = node.params[0];
if (firstParam.type === 'StringLiteral') {
translationKeys.add(firstParam.value);
} else if (firstParam.type === 'SubExpression' && firstParam.path.original === 'if') {
if (firstParam.params[1].type === 'StringLiteral') {
translationKeys.add(firstParam.params[1].value);
}
if (firstParam.params[2].type === 'StringLiteral') {
translationKeys.add(firstParam.params[2].value);
}
}
processNode(node);
},

// handle {{some-component foo=(t "bar")}} case
SubExpression(node) {
if (node.path.type !== 'PathExpression') return;
if (node.path.original !== 't') return;
if (node.params.length === 0) return;

let firstParam = node.params[0];
if (firstParam.type === 'StringLiteral') {
translationKeys.add(firstParam.value);
} else if (firstParam.type === 'SubExpression' && firstParam.path.original === 'if') {
if (firstParam.params[1].type === 'StringLiteral') {
translationKeys.add(firstParam.params[1].value);
}
if (firstParam.params[2].type === 'StringLiteral') {
translationKeys.add(firstParam.params[2].value);
}
}
processNode(node);
},
});

return translationKeys;
return [translationKeys, dynamicTranslations];
}

async function analyzeTranslationFiles(cwd, files) {
Expand Down
2 changes: 2 additions & 0 deletions test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ describe('Test Fixtures', () => {
'external-addon-translations',
];
let fixturesWithFix = ['remove-unused-translations', 'remove-unused-translations-nested'];
let fixturesWithDynamicKeys = ['concat-expression'];
let fixturesWithConfig = {
'external-addon-translations': {
externalPaths: ['@*/*', 'external-addon'],
Expand Down Expand Up @@ -41,6 +42,7 @@ describe('Test Fixtures', () => {
color: false,
writeToFile,
config: fixturesWithConfig[fixture],
logDynamic: fixturesWithDynamicKeys.includes(fixture),
});

let expectedReturnValue = fixturesWithErrors.includes(fixture) ? 1 : 0;
Expand Down