Compare commits

...

5 Commits

Author SHA1 Message Date
Stephen Zhou
4cde6e31ca Revert "[autofix.ci] apply automated fixes"
This reverts commit 01a05289e9.
2026-01-26 19:29:21 +08:00
Stephen Zhou
2fc02bf1bc Revert "ci: enable ESLint autofix in autofix bot (#31428)"
This reverts commit 41428432cc.
2026-01-26 19:29:05 +08:00
Stephen Zhou
70062ca6f1 check import exist 2026-01-26 19:29:05 +08:00
autofix-ci[bot]
01a05289e9 [autofix.ci] apply automated fixes 2026-01-26 11:24:44 +00:00
Stephen Zhou
14828853b1 refactor: prefer css icon 2026-01-26 19:20:05 +08:00
8 changed files with 3612 additions and 24 deletions

View File

@@ -79,29 +79,6 @@ jobs:
find . -name "*.py" -type f -exec sed -i.bak -E 's/"([^"]+)" \| None/Optional["\1"]/g; s/'"'"'([^'"'"']+)'"'"' \| None/Optional['"'"'\1'"'"']/g' {} \;
find . -name "*.py.bak" -type f -delete
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
package_json_file: web/package.json
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
cache-dependency-path: ./web/pnpm-lock.yaml
- name: Install web dependencies
run: |
cd web
pnpm install --frozen-lockfile
- name: ESLint autofix
run: |
cd web
pnpm lint:fix || true
# mdformat breaks YAML front matter in markdown files. Add --exclude for directories containing YAML front matter.
- name: mdformat
run: |

View File

@@ -3,6 +3,7 @@ import noAsAnyInT from './rules/no-as-any-in-t.js'
import noExtraKeys from './rules/no-extra-keys.js'
import noLegacyNamespacePrefix from './rules/no-legacy-namespace-prefix.js'
import noVersionPrefix from './rules/no-version-prefix.js'
import preferTailwindIcon from './rules/prefer-tailwind-icon.js'
import requireNsOption from './rules/require-ns-option.js'
import validI18nKeys from './rules/valid-i18n-keys.js'
@@ -18,6 +19,7 @@ const plugin = {
'no-extra-keys': noExtraKeys,
'no-legacy-namespace-prefix': noLegacyNamespacePrefix,
'no-version-prefix': noVersionPrefix,
'prefer-tailwind-icon': preferTailwindIcon,
'require-ns-option': requireNsOption,
'valid-i18n-keys': validI18nKeys,
},

View File

@@ -0,0 +1,339 @@
/**
* Default prop-to-class mappings
* Maps component props to Tailwind class prefixes
*/
const DEFAULT_PROP_MAPPINGS = {
size: 'size',
width: 'w',
height: 'h',
}
/**
* Default icon library configurations
*/
const DEFAULT_ICON_CONFIGS = [
{
// @remixicon/react
pattern: '@remixicon/react',
prefix: 'i-ri-',
iconFilter: name => name.startsWith('Ri'),
transformName: (iconName) => {
// RiApps2AddLine -> apps-2-add-line
const withoutPrefix = iconName.slice(2) // Remove 'Ri'
return withoutPrefix
.replace(/([a-z])(\d)/g, '$1-$2')
.replace(/(\d)([a-z])/gi, '$1-$2')
.replace(/([a-z])([A-Z])/g, '$1-$2')
.toLowerCase()
},
},
{
// @heroicons/react/20/solid
pattern: '@heroicons/react/20/solid',
prefix: 'i-heroicons-',
suffix: '-20-solid',
iconFilter: name => name.endsWith('Icon'),
transformName: (iconName) => {
// ChevronDownIcon -> chevron-down
const withoutSuffix = iconName.slice(0, -4) // Remove 'Icon'
return withoutSuffix
.replace(/([a-z])(\d)/g, '$1-$2')
.replace(/(\d)([a-z])/gi, '$1-$2')
.replace(/([a-z])([A-Z])/g, '$1-$2')
.toLowerCase()
},
},
{
// @heroicons/react/24/solid
pattern: '@heroicons/react/24/solid',
prefix: 'i-heroicons-',
suffix: '-24-solid',
iconFilter: name => name.endsWith('Icon'),
transformName: (iconName) => {
const withoutSuffix = iconName.slice(0, -4)
return withoutSuffix
.replace(/([a-z])(\d)/g, '$1-$2')
.replace(/(\d)([a-z])/gi, '$1-$2')
.replace(/([a-z])([A-Z])/g, '$1-$2')
.toLowerCase()
},
},
{
// @heroicons/react/24/outline
pattern: '@heroicons/react/24/outline',
prefix: 'i-heroicons-',
suffix: '-24-outline',
iconFilter: name => name.endsWith('Icon'),
transformName: (iconName) => {
const withoutSuffix = iconName.slice(0, -4)
return withoutSuffix
.replace(/([a-z])(\d)/g, '$1-$2')
.replace(/(\d)([a-z])/gi, '$1-$2')
.replace(/([a-z])([A-Z])/g, '$1-$2')
.toLowerCase()
},
},
{
// @heroicons/react/20/outline
pattern: '@heroicons/react/20/outline',
prefix: 'i-heroicons-',
suffix: '-20-outline',
iconFilter: name => name.endsWith('Icon'),
transformName: (iconName) => {
const withoutSuffix = iconName.slice(0, -4)
return withoutSuffix
.replace(/([a-z])(\d)/g, '$1-$2')
.replace(/(\d)([a-z])/gi, '$1-$2')
.replace(/([a-z])([A-Z])/g, '$1-$2')
.toLowerCase()
},
},
]
/**
* Convert pixel value to Tailwind class
* @param {number} pixels
* @param {string} classPrefix - e.g., 'size', 'w', 'h'
* @returns {string}
*/
function pixelToClass(pixels, classPrefix) {
const units = pixels / 4
return `${classPrefix}-${units}`
}
/**
* Get icon class from config
* @param {string} iconName
* @param {object} config
* @returns {string}
*/
function getIconClass(iconName, config) {
const transformed = config.transformName(iconName)
return `${config.prefix}${transformed}${config.suffix || ''}`
}
/** @type {import('eslint').Rule.RuleModule} */
export default {
meta: {
type: 'suggestion',
docs: {
description: 'Prefer Tailwind CSS icon classes over icon library components',
},
fixable: 'code',
schema: [
{
type: 'object',
properties: {
libraries: {
type: 'array',
items: {
type: 'object',
properties: {
pattern: { type: 'string' },
prefix: { type: 'string' },
suffix: { type: 'string' },
},
required: ['pattern', 'prefix'],
},
},
propMappings: {
type: 'object',
additionalProperties: { type: 'string' },
description: 'Maps component props to Tailwind class prefixes, e.g., { size: "size", width: "w", height: "h" }',
},
},
additionalProperties: false,
},
],
messages: {
preferTailwindIcon:
'Prefer using Tailwind CSS icon class "{{iconClass}}" over "{{componentName}}" from "{{source}}"',
preferTailwindIconImport:
'Icon "{{importedName}}" from "{{source}}" can be replaced with Tailwind CSS class "{{iconClass}}"',
},
},
create(context) {
const options = context.options[0] || {}
const iconConfigs = options.libraries || DEFAULT_ICON_CONFIGS
const propMappings = options.propMappings || DEFAULT_PROP_MAPPINGS
// Track imports: localName -> { node, importedName, config, source, used }
const iconImports = new Map()
return {
ImportDeclaration(node) {
const source = node.source.value
// Find matching config
const config = iconConfigs.find(c => source === c.pattern || source.startsWith(`${c.pattern}/`))
if (!config)
return
// Use default filter if not provided (for user-configured libraries)
const iconFilter = config.iconFilter || (() => true)
for (const specifier of node.specifiers) {
if (specifier.type === 'ImportSpecifier') {
const importedName = specifier.imported.name
const localName = specifier.local.name
if (iconFilter(importedName)) {
iconImports.set(localName, {
node: specifier,
importedName,
localName,
config,
source,
used: false,
})
}
}
}
},
JSXOpeningElement(node) {
if (node.name.type !== 'JSXIdentifier')
return
const componentName = node.name.name
const iconInfo = iconImports.get(componentName)
if (!iconInfo)
return
iconInfo.used = true
const iconClass = getIconClass(iconInfo.importedName, iconInfo.config)
// Find className attribute
const classNameAttr = node.attributes.find(
attr => attr.type === 'JSXAttribute' && attr.name.name === 'className',
)
// Process prop mappings (size, width, height, etc.)
const mappedClasses = []
const mappedPropNames = Object.keys(propMappings)
for (const propName of mappedPropNames) {
const attr = node.attributes.find(
a => a.type === 'JSXAttribute' && a.name.name === propName,
)
if (attr && attr.value) {
let pixelValue = null
if (attr.value.type === 'JSXExpressionContainer'
&& attr.value.expression.type === 'Literal'
&& typeof attr.value.expression.value === 'number') {
pixelValue = attr.value.expression.value
}
else if (attr.value.type === 'Literal'
&& typeof attr.value.value === 'number') {
pixelValue = attr.value.value
}
if (pixelValue !== null) {
mappedClasses.push(pixelToClass(pixelValue, propMappings[propName]))
}
}
}
// Build new className
const sourceCode = context.sourceCode
let newClassName
const classesToAdd = [iconClass, ...mappedClasses].filter(Boolean).join(' ')
if (classNameAttr && classNameAttr.value) {
if (classNameAttr.value.type === 'Literal') {
newClassName = `${classesToAdd} ${classNameAttr.value.value}`
}
else if (classNameAttr.value.type === 'JSXExpressionContainer') {
const expression = sourceCode.getText(classNameAttr.value.expression)
newClassName = `\`${classesToAdd} \${${expression}}\``
}
}
else {
newClassName = classesToAdd
}
const parent = node.parent
const isSelfClosing = node.selfClosing
const excludedAttrs = ['className', ...mappedPropNames]
context.report({
node,
messageId: 'preferTailwindIcon',
data: {
iconClass,
componentName,
source: iconInfo.source,
},
fix(fixer) {
const fixes = []
const classValue = newClassName.startsWith('`')
? `{${newClassName}}`
: `"${newClassName}"`
const otherAttrs = node.attributes
.filter(attr => !(attr.type === 'JSXAttribute' && excludedAttrs.includes(attr.name.name)))
.map(attr => sourceCode.getText(attr))
.join(' ')
const attrsStr = otherAttrs
? `className=${classValue} ${otherAttrs}`
: `className=${classValue}`
if (isSelfClosing) {
fixes.push(fixer.replaceText(parent, `<span ${attrsStr} />`))
}
else {
const closingElement = parent.closingElement
fixes.push(fixer.replaceText(node, `<span ${attrsStr}>`))
if (closingElement) {
fixes.push(fixer.replaceText(closingElement, '</span>'))
}
}
return fixes
},
})
},
'Program:exit': function () {
const sourceCode = context.sourceCode
// Report icons that were imported but not found in JSX
for (const [, iconInfo] of iconImports) {
if (!iconInfo.used) {
// Verify the import is still referenced somewhere in the file (besides the import itself)
try {
const variables = sourceCode.getDeclaredVariables(iconInfo.node)
const variable = variables[0]
// Check if there are any references besides the import declaration
const hasReferences = variable && variable.references.some(
ref => ref.identifier !== iconInfo.node.local,
)
if (!hasReferences)
continue
}
catch {
continue
}
const iconClass = getIconClass(iconInfo.importedName, iconInfo.config)
context.report({
node: iconInfo.node,
messageId: 'preferTailwindIconImport',
data: {
importedName: iconInfo.importedName,
source: iconInfo.source,
iconClass,
},
})
}
}
},
}
},
}

File diff suppressed because it is too large Load Diff

View File

@@ -118,6 +118,17 @@ export default antfu(
// // 'dify-i18n/require-ns-option': 'error',
// },
// },
// Prefer Tailwind CSS icons over @remixicon/react
{
files: ['**/*.tsx'],
ignores: ['eslint-rules/**'],
plugins: {
'dify-i18n': difyI18n,
},
rules: {
'dify-i18n/prefer-tailwind-icon': 'error',
},
},
// i18n JSON validation rules
{
files: ['i18n/**/*.json'],

View File

@@ -162,7 +162,10 @@
"devDependencies": {
"@antfu/eslint-config": "7.0.1",
"@chromatic-com/storybook": "5.0.0",
"@egoist/tailwindcss-icons": "1.9.0",
"@eslint-react/eslint-plugin": "2.7.0",
"@iconify-json/heroicons": "1.2.3",
"@iconify-json/ri": "1.2.7",
"@mdx-js/loader": "3.1.1",
"@mdx-js/react": "3.1.1",
"@next/bundle-analyzer": "16.1.4",

56
web/pnpm-lock.yaml generated
View File

@@ -372,9 +372,18 @@ importers:
'@chromatic-com/storybook':
specifier: 5.0.0
version: 5.0.0(storybook@10.2.0(@testing-library/dom@10.4.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))
'@egoist/tailwindcss-icons':
specifier: 1.9.0
version: 1.9.0(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.2))
'@eslint-react/eslint-plugin':
specifier: 2.7.0
version: 2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
'@iconify-json/heroicons':
specifier: 1.2.3
version: 1.2.3
'@iconify-json/ri':
specifier: 1.2.7
version: 1.2.7
'@mdx-js/loader':
specifier: 3.1.1
version: 3.1.1(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3))
@@ -730,6 +739,9 @@ packages:
'@antfu/install-pkg@1.1.0':
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
'@antfu/utils@8.1.1':
resolution: {integrity: sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==}
'@asamuzakjp/css-color@4.1.1':
resolution: {integrity: sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==}
@@ -919,6 +931,11 @@ packages:
resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==}
engines: {node: '>=10.0.0'}
'@egoist/tailwindcss-icons@1.9.0':
resolution: {integrity: sha512-xWA9cUy6hzlK7Y6TaoRIcwmilSXiTJ8rbXcEdf9uht7yzDgw/yIgF4rThIQMrpD2Y2v4od51+r2y6Z7GStanDQ==}
peerDependencies:
tailwindcss: '*'
'@emnapi/core@1.8.1':
resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
@@ -1292,9 +1309,18 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'}
'@iconify-json/heroicons@1.2.3':
resolution: {integrity: sha512-n+vmCEgTesRsOpp5AB5ILB6srsgsYK+bieoQBNlafvoEhjVXLq8nIGN4B0v/s4DUfa0dOrjwE/cKJgIKdJXOEg==}
'@iconify-json/ri@1.2.7':
resolution: {integrity: sha512-j/Fkb8GlWY5y/zLj1BGxWRtDzuJFrI7562zLw+iQVEykieBgew43+r8qAvtSajvb75MfUIHjsNOYQPRD8FfLfw==}
'@iconify/types@2.0.0':
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
'@iconify/utils@2.3.0':
resolution: {integrity: sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==}
'@iconify/utils@3.1.0':
resolution: {integrity: sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==}
@@ -7712,6 +7738,8 @@ snapshots:
package-manager-detector: 1.6.0
tinyexec: 1.0.2
'@antfu/utils@8.1.1': {}
'@asamuzakjp/css-color@4.1.1':
dependencies:
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
@@ -7965,6 +7993,13 @@ snapshots:
'@discoveryjs/json-ext@0.5.7': {}
'@egoist/tailwindcss-icons@1.9.0(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@iconify/utils': 2.3.0
tailwindcss: 3.4.18(tsx@4.21.0)(yaml@2.8.2)
transitivePeerDependencies:
- supports-color
'@emnapi/core@1.8.1':
dependencies:
'@emnapi/wasi-threads': 1.1.0
@@ -8328,8 +8363,29 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {}
'@iconify-json/heroicons@1.2.3':
dependencies:
'@iconify/types': 2.0.0
'@iconify-json/ri@1.2.7':
dependencies:
'@iconify/types': 2.0.0
'@iconify/types@2.0.0': {}
'@iconify/utils@2.3.0':
dependencies:
'@antfu/install-pkg': 1.1.0
'@antfu/utils': 8.1.1
'@iconify/types': 2.0.0
debug: 4.4.3
globals: 15.15.0
kolorist: 1.8.0
local-pkg: 1.1.2
mlly: 1.8.0
transitivePeerDependencies:
- supports-color
'@iconify/utils@3.1.0':
dependencies:
'@antfu/install-pkg': 1.1.0

View File

@@ -1,3 +1,4 @@
import { iconsPlugin } from '@egoist/tailwindcss-icons'
import tailwindTypography from '@tailwindcss/typography'
// @ts-expect-error workaround for turbopack issue
import tailwindThemeVarDefine from './themes/tailwind-theme-var-define.ts'
@@ -146,7 +147,7 @@ const config = {
},
},
},
plugins: [tailwindTypography],
plugins: [tailwindTypography, iconsPlugin()],
// https://github.com/tailwindlabs/tailwindcss/discussions/5969
corePlugins: {
preflight: false,