Skip to content
231 changes: 230 additions & 1 deletion lib/rules/no-literal-string.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,31 @@ function isValidLiteral(options, { value }) {
//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------
let counter = 0;
const _dict = {};
const fs = require('fs');

function updateDict(str, loc) {
const header = `${loc.fileName
.split('/')
.slice(8)
.join('/')}:${loc.start.line}: `;
console.log(`${header.padEnd(80, ' ')} "${str}"`);
// if (!str.includes('arg')) return;
// if (_dict[str]) {
// _dict[str].push(loc);
// } else {
// _dict[str] = [loc];
// }
_dict[str] = '';
}

process.on('exit', () => {
console.log(`${Object.keys(_dict).length} unique strings found`);
fs.writeFileSync('i18n-dict.json', JSON.stringify(_dict), {
encoding: 'utf-8',
});
});

module.exports = {
meta: {
Expand All @@ -42,9 +67,12 @@ module.exports = {
recommended: true,
},
schema: [require('../options/schema.json')],
fixable: 'code',
},

create(context) {
counter++;
const fileName = context.getFilename();
// variables should be defined here
const { parserServices } = context;
const options = _.defaults(
Expand Down Expand Up @@ -81,10 +109,211 @@ module.exports = {
// Public
//----------------------------------------------------------------------

let fixes = 0;
function report(node) {
context.report({
node,
message: `${message}: ${context.getSourceCode().getText(node.parent)}`,
message: `${message}: ${context
.getSourceCode()
.getText(node.parent)
.replace(/\n/g, '')
.substring(0, 50)}`,
fix: function(fixer) {
const sourceCode = context.getSourceCode();
const ast = sourceCode.ast;

const defaultExport = ast.body.find(
node => node.type === 'ExportDefaultDeclaration'
);
if (!defaultExport) {
console.warn('ERROR: Could not find the default export', fileName);
return;
}

let componentName = defaultExport.declaration.name;

if (!componentName) {
// Couldn't find default export name, see if it's a `export default React.memo`
if (
defaultExport.declaration.type === 'CallExpression' &&
defaultExport.declaration.callee.property.name === 'memo'
) {
componentName = defaultExport.declaration.arguments[0].name;
}
}

if (!componentName) {
console.warn(
'ERROR: Could not find the React component name',
fileName
);
return;
}

// Now find the react component function
const exportedNamedDeclarations = ast.body.filter(
n =>
n.type === 'ExportNamedDeclaration' &&
n.declaration?.id?.name === componentName
);

const functionDeclarations = ast.body.filter(
n => n.type === 'FunctionDeclaration' && n.id.name === componentName
);

const forwardRefs = ast.body.filter(
n =>
(n.declarations?.[0]?.init?.callee?.property?.name ===
'forwardRef' &&
n.declarations[0]?.init?.arguments[0]?.id?.name ===
componentName) ||
(n?.declaration?.declarations?.[0]?.init?.callee?.property
?.name === 'forwardRef' &&
n?.declaration?.declarations[0]?.init?.arguments[0]?.id
?.name === componentName)
);

let functionComponentBody;
if (exportedNamedDeclarations.length === 1) {
const functionComponent = exportedNamedDeclarations[0].declaration;
functionComponentBody = functionComponent.body.body;
} else if (functionDeclarations.length === 1) {
functionComponentBody = functionDeclarations[0].body.body;
} else if (forwardRefs.length === 1) {
functionComponentBody =
forwardRefs[0]?.declarations?.[0]?.init?.arguments?.[0]?.body
?.body ??
forwardRefs[0]?.declaration?.declarations?.[0]?.init
?.arguments?.[0]?.body?.body;
} else {
console.warn(
'ERROR: Could not find the React component body for:',
fileName
);
return;
}

if (!functionComponentBody) {
console.warn(
'ERROR: Could not find the React component body for:',
fileName
);
return;
}

function addImport(importName) {
const importString = `import { useTranslation } from '${importName}';`;
const imports = ast.body.filter(
node => node.type === 'ImportDeclaration'
);
const importNode = ast.body.filter(
node =>
node.type === 'ImportDeclaration' &&
node.source.value === importName
);
if (importNode.length === 0) {
if (imports.length > 0)
return fixer.insertTextAfter(
imports[imports.length - 1],
`\n${importString};\n`
);

return fixer.insertTextBefore(
ast.body[0],
`\n${importString};\n`
);
} else {
// Check if the import has the useTranslation hook
const useTranslation = importNode[0].specifiers.find(
s => s.imported.name === 'useTranslation'
);
if (!useTranslation) {
return fixer.insertTextAfter(
importNode[0].specifiers[importNode[0].specifiers.length - 1],
`, useTranslation`
);
}
}
return;
}

const result = addImport(options['importName'] || 'react-i18next');
if (result) {
return result;
}

// Add useTranslation hook to the React component
const useTranslation = `const { t } = useTranslation();\n`;

// This is the React component's `export default` statement.

// Find all VariableDeclarator nodes in the function component body
const variableDeclarations = functionComponentBody.filter(
n => n.type === 'VariableDeclaration'
);

// Now find one with symbol t
const useTranslationNode = variableDeclarations.find(d =>
d.declarations[0].id?.properties?.some(p => p.value?.name === 't')
);

if (!useTranslationNode || useTranslationNode.length === 0) {
return fixer.insertTextBefore(
functionComponentBody[0],
`\n${useTranslation}\n`
);
}

function summary(str) {
return str.replace(/\n/g, '').substring(0, 50);
}

if (
node.type === 'JSXText' ||
(node.type === 'Literal' && node.parent.type === 'JSXAttribute')
) {
const replacement = node.value
// .split('\n')
// .map(l => l.trim())
// .join(' ')
.trim();
updateDict(replacement, { fileName, ...node.loc });
// console.log(
// `fixing ${fixes++} L${node.loc.start.line} "${summary(node.value)}" to {t(\`${summary(replacement)}\`)}`
// );
return fixer.replaceText(node, `{t(\`${replacement}\`)}`);
}

if (node.type === 'Literal') {
updateDict(node.value, { fileName, ...node.loc });
const replacement = `t(${node.raw})`;
// console.log(
// `fixing ${fixes++} L${node.loc.start.line} ${summary(node.value)} to "${summary(replacement)}"`
// );
return fixer.replaceText(node, replacement);
}

if (node.type === 'TemplateLiteral') {
const formatStr = node.quasis
.map(
(q, i) =>
`${q.value.raw}${
i < node.quasis.length - 1 ? `{{arg${i}}}` : ''
}`
)
.join('');
const replacement = `t(\`${formatStr}\`, {${node.expressions
.map((e, i) => `arg${i}: ${context.getSourceCode().getText(e)}`)
.join(', ')}})`;
// console.log(
// `fixing ${fixes++} L${node.loc.start.line
// } "${summary(context.getSourceCode().getText(node))}" to "${summary(replacement)}"`
// );
updateDict(formatStr, { fileName, ...node.loc });
return fixer.replaceText(node, replacement);
}
throw new Error(`unexpected node type: ${node.type}`);
},
});
}

Expand Down