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

feat: auto-fix #119

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
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