mirror of https://github.com/grafana/grafana
FEMT: Add `no-restricted-img-srcs` rule (#105006)
parent
56cfeb8616
commit
8f17f607fa
@ -0,0 +1,96 @@ |
||||
// @ts-check |
||||
const { AST_NODE_TYPES } = require('@typescript-eslint/utils'); |
||||
const { upperFirst } = require('lodash'); |
||||
|
||||
/** @typedef {import('@typescript-eslint/utils/ts-eslint').RuleContext<'publicImg' | 'importImage' | 'useBuildFolder', []>} RuleContextWithOptions */ |
||||
|
||||
/** |
||||
* @param {string} str |
||||
*/ |
||||
const camelCase = (str) => { |
||||
return str |
||||
.replace(/[-_]/g, ' ') |
||||
.split(' ') |
||||
.map((word, index) => (index === 0 ? word : upperFirst(word))) |
||||
.join(''); |
||||
}; |
||||
|
||||
/** |
||||
* @param {string} value |
||||
* @returns {string} |
||||
*/ |
||||
const convertPathToImportName = (value) => { |
||||
const fullFileName = value.split('/').pop() || ''; |
||||
const fileType = fullFileName.split('.').pop(); |
||||
const fileName = fullFileName.replace(`.${fileType}`, ''); |
||||
return camelCase(fileName) + upperFirst(fileType); |
||||
}; |
||||
|
||||
/** |
||||
* @param {import('@typescript-eslint/utils/ts-eslint').RuleFixer} fixer |
||||
* @param {import('@typescript-eslint/utils').TSESTree.StringLiteral} node |
||||
* @param {RuleContextWithOptions} context |
||||
*/ |
||||
function getImageImportFixers(fixer, node, context) { |
||||
const { value: importPath } = node; |
||||
const pathWithoutPublic = importPath.replace('public/', ''); |
||||
|
||||
/** e.g. public/img/checkbox.png -> checkboxPng */ |
||||
const imageImportName = convertPathToImportName(importPath); |
||||
|
||||
const body = context.sourceCode.ast.body; |
||||
|
||||
const existingImport = body.find( |
||||
(node) => node.type === AST_NODE_TYPES.ImportDeclaration && node.source.value === pathWithoutPublic |
||||
); |
||||
|
||||
const fixers = []; |
||||
|
||||
// If there's no existing import at all, add a fixer for this |
||||
if (!existingImport) { |
||||
const importStatementFixer = fixer.insertTextBefore( |
||||
body[0], |
||||
`import ${imageImportName} from '${pathWithoutPublic}';\n` |
||||
); |
||||
fixers.push(importStatementFixer); |
||||
} |
||||
|
||||
const isInAttribute = node.parent.type === AST_NODE_TYPES.JSXAttribute; |
||||
const variableReplacement = isInAttribute ? `{${imageImportName}}` : imageImportName; |
||||
const variableFixer = fixer.replaceText(node, variableReplacement); |
||||
fixers.push(variableFixer); |
||||
|
||||
return fixers; |
||||
} |
||||
|
||||
/** |
||||
* @param {import('@typescript-eslint/utils/ts-eslint').RuleFixer} fixer |
||||
* @param {import('@typescript-eslint/utils').TSESTree.StringLiteral} node |
||||
*/ |
||||
const replaceWithPublicBuild = (fixer, node) => { |
||||
const { value } = node; |
||||
|
||||
const startingQuote = node.raw.startsWith('"') ? '"' : "'"; |
||||
return fixer.replaceText( |
||||
node, |
||||
`${startingQuote}${value.replace('public/img/', 'public/build/img/')}${startingQuote}` |
||||
); |
||||
}; |
||||
|
||||
/** |
||||
* @param {string} value |
||||
*/ |
||||
const isInvalidImageLocation = (value) => { |
||||
return ( |
||||
value.startsWith('public/img/') || |
||||
(!value.startsWith('public/build/') && |
||||
!value.startsWith('public/plugins/') && |
||||
/public.*(\.svg|\.png|\.jpg|\.jpeg|\.gif)$/.test(value)) |
||||
); |
||||
}; |
||||
|
||||
module.exports = { |
||||
getImageImportFixers, |
||||
replaceWithPublicBuild, |
||||
isInvalidImageLocation, |
||||
}; |
@ -0,0 +1,78 @@ |
||||
// @ts-check |
||||
/** @typedef {import('@typescript-eslint/utils').TSESTree.Literal} Literal */ |
||||
/** @typedef {import('@typescript-eslint/utils').TSESTree.TemplateLiteral} TemplateLiteral */ |
||||
const { getImageImportFixers, replaceWithPublicBuild, isInvalidImageLocation } = require('./import-utils.cjs'); |
||||
|
||||
const { ESLintUtils, AST_NODE_TYPES } = require('@typescript-eslint/utils'); |
||||
|
||||
const createRule = ESLintUtils.RuleCreator( |
||||
(name) => `https://github.com/grafana/grafana/blob/main/packages/grafana-eslint-rules/README.md#${name}` |
||||
); |
||||
|
||||
const imgSrcRule = createRule({ |
||||
create(context) { |
||||
return { |
||||
/** |
||||
* @param {Literal|TemplateLiteral} node |
||||
*/ |
||||
'Literal, TemplateLiteral'(node) { |
||||
if (node.type === AST_NODE_TYPES.TemplateLiteral) { |
||||
if (node.quasis.some((quasi) => isInvalidImageLocation(quasi.value.raw))) { |
||||
return context.report({ |
||||
node, |
||||
messageId: 'publicImg', |
||||
}); |
||||
} |
||||
return; |
||||
} |
||||
|
||||
const { value } = node; |
||||
|
||||
if (value && typeof value === 'string' && isInvalidImageLocation(value)) { |
||||
const canUseBuildFolder = value.startsWith('public/img/'); |
||||
/** |
||||
* @type {import('@typescript-eslint/utils/ts-eslint').SuggestionReportDescriptor<"publicImg" | "importImage" | "useBuildFolder">[]} |
||||
*/ |
||||
const suggestions = [ |
||||
{ |
||||
messageId: 'importImage', |
||||
fix: (fixer) => getImageImportFixers(fixer, node, context), |
||||
}, |
||||
]; |
||||
|
||||
if (canUseBuildFolder) { |
||||
suggestions.push({ |
||||
messageId: 'useBuildFolder', |
||||
fix: (fixer) => replaceWithPublicBuild(fixer, node), |
||||
}); |
||||
} |
||||
|
||||
return context.report({ |
||||
node, |
||||
messageId: 'publicImg', |
||||
suggest: suggestions, |
||||
}); |
||||
} |
||||
}, |
||||
}; |
||||
}, |
||||
name: 'no-restricted-img-srcs', |
||||
meta: { |
||||
fixable: 'code', |
||||
hasSuggestions: true, |
||||
type: 'problem', |
||||
docs: { |
||||
description: 'Disallow references to images in the public folder', |
||||
}, |
||||
messages: { |
||||
publicImg: |
||||
"Don't reference image sources from the public folder. Either use the build folder or import the image", |
||||
importImage: 'Import image instead', |
||||
useBuildFolder: 'Use public/build path instead', |
||||
}, |
||||
schema: [], |
||||
}, |
||||
defaultOptions: [], |
||||
}); |
||||
|
||||
module.exports = imgSrcRule; |
@ -0,0 +1,143 @@ |
||||
/* eslint-disable @grafana/no-restricted-img-srcs */ |
||||
import { RuleTester } from 'eslint'; |
||||
|
||||
import noRestrictedImgSrcs from '../rules/no-restricted-img-srcs.cjs'; |
||||
|
||||
RuleTester.setDefaultConfig({ |
||||
languageOptions: { |
||||
ecmaVersion: 2018, |
||||
sourceType: 'module', |
||||
parserOptions: { |
||||
ecmaFeatures: { |
||||
jsx: true, |
||||
}, |
||||
}, |
||||
}, |
||||
}); |
||||
|
||||
const ruleTester = new RuleTester(); |
||||
|
||||
ruleTester.run('eslint no-restricted-img-srcs', noRestrictedImgSrcs, { |
||||
valid: [ |
||||
{ |
||||
name: 'uses build folder', |
||||
code: `const foo = 'public/build/img/checkbox.png';`, |
||||
}, |
||||
{ |
||||
name: 'uses import', |
||||
code: ` |
||||
import foo from 'img/checkbox.png'; |
||||
const bar = foo; |
||||
const baz = <img src={foo} />; |
||||
`,
|
||||
}, |
||||
{ |
||||
name: 'plugin folder', |
||||
code: `const foo = 'public/plugins/foo/checkbox.png';`, |
||||
}, |
||||
{ |
||||
name: 'template literal', |
||||
code: `const foo = \`something else\``, |
||||
}, |
||||
], |
||||
invalid: [ |
||||
{ |
||||
name: 'references public folder', |
||||
code: ` |
||||
const foo = 'public/img/checkbox-128-icon.png';`,
|
||||
errors: [ |
||||
{ |
||||
messageId: 'publicImg', |
||||
suggestions: [ |
||||
{ |
||||
messageId: 'importImage', |
||||
output: ` |
||||
import checkbox128IconPng from 'img/checkbox-128-icon.png'; |
||||
const foo = checkbox128IconPng;`,
|
||||
}, |
||||
{ |
||||
messageId: 'useBuildFolder', |
||||
output: ` |
||||
const foo = 'public/build/img/checkbox-128-icon.png';`,
|
||||
}, |
||||
], |
||||
}, |
||||
], |
||||
}, |
||||
{ |
||||
name: 'template literal', |
||||
code: ` |
||||
const isDark = true ? 'dark' : 'light'; |
||||
const foo = \`public/img/checkbox-128-icon-\${isDark}.png\`;`, |
||||
errors: [ |
||||
{ |
||||
messageId: 'publicImg', |
||||
}, |
||||
], |
||||
}, |
||||
{ |
||||
name: 'fixes jsx attribute', |
||||
code: `<img src="public/img/checkbox.png" />`, |
||||
errors: [ |
||||
{ |
||||
messageId: 'publicImg', |
||||
suggestions: [ |
||||
{ |
||||
messageId: 'importImage', |
||||
output: `import checkboxPng from 'img/checkbox.png';
|
||||
<img src={checkboxPng} />`,
|
||||
}, |
||||
{ |
||||
messageId: 'useBuildFolder', |
||||
output: `<img src="public/build/img/checkbox.png" />`, |
||||
}, |
||||
], |
||||
}, |
||||
], |
||||
}, |
||||
{ |
||||
name: 'fixes with existing import', |
||||
code: ` |
||||
import checkboxPng from 'img/checkbox.png'; |
||||
const foo = checkboxPng; |
||||
const bar = 'public/img/checkbox.png';`,
|
||||
errors: [ |
||||
{ |
||||
messageId: 'publicImg', |
||||
suggestions: [ |
||||
{ |
||||
messageId: 'importImage', |
||||
output: ` |
||||
import checkboxPng from 'img/checkbox.png'; |
||||
const foo = checkboxPng; |
||||
const bar = checkboxPng;`,
|
||||
}, |
||||
{ |
||||
messageId: 'useBuildFolder', |
||||
output: ` |
||||
import checkboxPng from 'img/checkbox.png'; |
||||
const foo = checkboxPng; |
||||
const bar = 'public/build/img/checkbox.png';`,
|
||||
}, |
||||
], |
||||
}, |
||||
], |
||||
}, |
||||
{ |
||||
name: 'image elsewhere in public folder', |
||||
code: `const foo = 'public/app/plugins/datasource/alertmanager/img/logo.svg';`, |
||||
errors: [ |
||||
{ |
||||
messageId: 'publicImg', |
||||
suggestions: [ |
||||
{ |
||||
messageId: 'importImage', |
||||
output: `import logoSvg from 'app/plugins/datasource/alertmanager/img/logo.svg';
|
||||
const foo = logoSvg;`,
|
||||
}, |
||||
], |
||||
}, |
||||
], |
||||
}, |
||||
], |
||||
}); |
@ -0,0 +1 @@ |
||||
export default '__DEFAULT_MOCK_IMAGE_CONTENT__'; |
@ -1 +0,0 @@ |
||||
export const svg = 'svg'; |
Loading…
Reference in new issue