diff --git a/lib/rules/no-literal-string.js b/lib/rules/no-literal-string.js index be3b6bc..9f227f6 100644 --- a/lib/rules/no-literal-string.js +++ b/lib/rules/no-literal-string.js @@ -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: { @@ -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( @@ -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}`); + }, }); }