feat: Improve community node icon lint rules (themed variants, allow PNG) (no-changelog) (#32207)

Co-authored-by: Garrit Franke <garritfra@users.noreply.github.com>
This commit is contained in:
Garrit Franke
2026-06-12 15:26:36 +02:00
committed by GitHub
parent 8a44b52b45
commit 9d5405a70e
10 changed files with 314 additions and 43 deletions
@@ -54,7 +54,8 @@ export default [
| [credential-documentation-url](docs/rules/credential-documentation-url.md) | Enforce valid credential documentationUrl format (URL or lowercase alphanumeric slug) | ✅ ☑️ | | 🔧 | | |
| [credential-password-field](docs/rules/credential-password-field.md) | Ensure credential fields with sensitive names have typeOptions.password = true | ✅ ☑️ | | 🔧 | | |
| [credential-test-required](docs/rules/credential-test-required.md) | Ensure credentials have a credential test | ✅ ☑️ | | | 💡 | |
| [icon-validation](docs/rules/icon-validation.md) | Validate node and credential icon files exist, are SVG format, and light/dark icons are different | ✅ ☑️ | | | 💡 | |
| [icon-prefer-themed-variants](docs/rules/icon-prefer-themed-variants.md) | Encourage node and credential icons to provide light/dark variants instead of a single icon file | | ✅ ☑️ | | | |
| [icon-validation](docs/rules/icon-validation.md) | Validate node and credential icon files exist, use the file: protocol, and that light/dark icons are different | ✅ ☑️ | | | 💡 | |
| [missing-paired-item](docs/rules/missing-paired-item.md) | Require pairedItem on INodeExecutionData objects in execute() methods to preserve item linking. | ✅ ☑️ | | | | |
| [n8n-object-validation](docs/rules/n8n-object-validation.md) | Validate the structure of the "n8n" object in community node package.json (required keys, types, and dist/ paths) | ✅ ☑️ | | | | |
| [no-builder-hint-leakage](docs/rules/no-builder-hint-leakage.md) | Disallow wire-format expression syntax (={{...}}) and NodeConnectionType string literals in builderHint texts and AI-builder prompts. Use expr() and SDK-canonical references instead. | ✅ ☑️ | | | | |
@@ -0,0 +1,71 @@
# Encourage node and credential icons to provide light/dark variants instead of a single icon file (`@n8n/community-nodes/icon-prefer-themed-variants`)
⚠️ This rule _warns_ in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
<!-- end auto-generated rule header -->
## Rule Details
n8n supports themed icons via the `{ light, dark }` object form, and the
marketplace/preview UI renders both variants for nodes that aren't installed yet.
A single icon file often renders poorly on one of the two themes (for example, a
dark glyph on a dark background).
This rule warns when a node or credential defines its icon as a single file
string instead of the themed `{ light, dark }` object. It is a **warning**, not
an error: most existing nodes ship a single icon, so this nudges authors toward
themed variants without failing verification.
The companion `icon-validation` rule still validates that the referenced files
exist, use the `file:` protocol, and that the light and dark variants point to
different files.
## Examples
### ❌ Incorrect
```typescript
export class MyNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'My Node',
name: 'myNode',
icon: 'file:icons/my-icon.svg', // Single file — renders poorly on one theme
// ...
};
}
```
```typescript
export class MyApi implements ICredentialType {
name = 'myApi';
displayName = 'My API';
icon = 'file:icons/my-icon.svg'; // Single file
}
```
### ✅ Correct
```typescript
export class MyNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'My Node',
name: 'myNode',
icon: {
light: 'file:icons/my-icon.light.svg',
dark: 'file:icons/my-icon.dark.svg',
},
// ...
};
}
```
```typescript
export class MyApi implements ICredentialType {
name = 'myApi';
displayName = 'My API';
icon = {
light: 'file:icons/my-icon.light.svg',
dark: 'file:icons/my-icon.dark.svg',
};
}
```
@@ -1,4 +1,4 @@
# Validate node and credential icon files exist, are SVG format, and light/dark icons are different (`@n8n/community-nodes/icon-validation`)
# Validate node and credential icon files exist, use the file: protocol, and that light/dark icons are different (`@n8n/community-nodes/icon-validation`)
💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
@@ -8,7 +8,9 @@
## Rule Details
Validates that your node and credential icon files exist, are in SVG format, and use the correct `file:` protocol. Icons must be different files when providing light/dark theme variants.
Validates that your node and credential icon files exist and use the correct `file:` protocol. Icons must be different files when providing light/dark theme variants.
Both SVG and PNG icons are accepted. SVG is recommended (it scales cleanly and supports theming), but PNG is allowed — see the `icon-prefer-themed-variants` rule for the related light/dark nudge.
## Examples
@@ -19,7 +21,7 @@ export class MyNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'My Node',
name: 'myNode',
icon: 'icons/my-icon.png', // Missing 'file:' prefix, wrong format
icon: 'icons/my-icon.svg', // Missing 'file:' prefix
// ...
};
}
@@ -36,6 +36,7 @@ const configs = {
'@n8n/community-nodes/no-runtime-dependencies': 'error',
'@n8n/community-nodes/no-template-placeholders': 'error',
'@n8n/community-nodes/icon-validation': 'error',
'@n8n/community-nodes/icon-prefer-themed-variants': 'warn',
'@n8n/community-nodes/options-sorted-alphabetically': 'warn',
'@n8n/community-nodes/resource-operation-pattern': 'warn',
'@n8n/community-nodes/credential-documentation-url': 'error',
@@ -78,6 +79,7 @@ const configs = {
'@n8n/community-nodes/no-runtime-dependencies': 'error',
'@n8n/community-nodes/no-template-placeholders': 'error',
'@n8n/community-nodes/icon-validation': 'error',
'@n8n/community-nodes/icon-prefer-themed-variants': 'warn',
'@n8n/community-nodes/options-sorted-alphabetically': 'warn',
'@n8n/community-nodes/credential-documentation-url': 'error',
'@n8n/community-nodes/resource-operation-pattern': 'warn',
@@ -0,0 +1,128 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { IconPreferThemedVariantsRule } from './icon-prefer-themed-variants.js';
const ruleTester = new RuleTester();
const nodeFilePath = '/tmp/TestNode.node.ts';
const credentialFilePath = '/tmp/TestCredential.credentials.ts';
function createNodeCode(icon?: string | { light: string; dark: string }): string {
let iconProperty = '';
if (icon) {
if (typeof icon === 'string') {
iconProperty = `icon: '${icon}',`;
} else {
iconProperty = `icon: {
light: '${icon.light}',
dark: '${icon.dark}'
},`;
}
}
return `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
${iconProperty}
group: ['input'],
version: 1,
description: 'A test node',
defaults: {
name: 'Test Node',
},
inputs: ['main'],
outputs: ['main'],
properties: [],
};
}`;
}
function createCredentialCode(icon?: string | { light: string; dark: string }): string {
let iconProperty = '';
if (icon) {
if (typeof icon === 'string') {
iconProperty = `icon = '${icon}';`;
} else {
iconProperty = `icon = {
light: '${icon.light}',
dark: '${icon.dark}'
};`;
}
}
return `
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
export class TestCredential implements ICredentialType {
name = 'testApi';
displayName = 'Test API';
${iconProperty}
properties: INodeProperties[] = [];
}`;
}
function createNonNodeClass(icon: string): string {
return `
export class NotANode {
icon = '${icon}';
}`;
}
ruleTester.run('icon-prefer-themed-variants', IconPreferThemedVariantsRule, {
valid: [
{
name: 'non-node class ignored',
filename: nodeFilePath,
code: createNonNodeClass('file:icon.svg'),
},
{
name: 'non-node file ignored',
filename: '/tmp/regular-file.ts',
code: createNodeCode('file:icon.svg'),
},
{
name: 'node with no icon property ignored',
filename: nodeFilePath,
code: createNodeCode(),
},
{
name: 'node with themed light/dark icons',
filename: nodeFilePath,
code: createNodeCode({
light: 'file:icons/icon.light.svg',
dark: 'file:icons/icon.dark.svg',
}),
},
{
name: 'credential with no icon property ignored',
filename: credentialFilePath,
code: createCredentialCode(),
},
{
name: 'credential with themed light/dark icons',
filename: credentialFilePath,
code: createCredentialCode({
light: 'file:icons/icon.light.svg',
dark: 'file:icons/icon.dark.svg',
}),
},
],
invalid: [
{
name: 'node with single string icon',
filename: nodeFilePath,
code: createNodeCode('file:icons/icon.svg'),
errors: [{ messageId: 'missingThemedVariants' }],
},
{
name: 'credential with single string icon',
filename: credentialFilePath,
code: createCredentialCode('file:icons/icon.svg'),
errors: [{ messageId: 'missingThemedVariants' }],
},
],
});
@@ -0,0 +1,70 @@
import { TSESTree } from '@typescript-eslint/utils';
import {
isNodeTypeClass,
isCredentialTypeClass,
findClassProperty,
findObjectProperty,
isFileType,
createRule,
} from '../utils/index.js';
const messages = {
missingThemedVariants:
'Icon is defined as a single file. Provide both light and dark variants using the `{ light, dark }` form so the icon renders well on both themes.',
} as const;
export const IconPreferThemedVariantsRule = createRule({
name: 'icon-prefer-themed-variants',
meta: {
type: 'suggestion',
docs: {
description:
'Encourage node and credential icons to provide light/dark variants instead of a single icon file',
},
messages,
schema: [],
},
defaultOptions: [],
create(context) {
if (
!isFileType(context.filename, '.node.ts') &&
!isFileType(context.filename, '.credentials.ts')
) {
return {};
}
const checkIconValue = (iconValue: TSESTree.Node) => {
if (
iconValue.type === TSESTree.AST_NODE_TYPES.Literal &&
typeof iconValue.value === 'string'
) {
context.report({
node: iconValue,
messageId: 'missingThemedVariants',
});
}
};
return {
ClassDeclaration(node) {
if (isNodeTypeClass(node)) {
const descriptionProperty = findClassProperty(node, 'description');
if (descriptionProperty?.value?.type !== TSESTree.AST_NODE_TYPES.ObjectExpression) {
return;
}
const iconProperty = findObjectProperty(descriptionProperty.value, 'icon');
if (iconProperty) {
checkIconValue(iconProperty.value);
}
} else if (isCredentialTypeClass(node)) {
const iconProperty = findClassProperty(node, 'icon');
if (iconProperty?.value) {
checkIconValue(iconProperty.value);
}
}
},
};
},
});
@@ -146,6 +146,11 @@ ruleTester.run('icon-validation', IconValidationRule, {
filename: nodeFilePath,
code: createNodeCode('file:icons/TestNode.svg', true),
},
{
name: 'node with valid PNG string icon in description',
filename: nodeFilePath,
code: createNodeCode('file:icons/NotSvg.png', true),
},
{
name: 'node with valid light/dark icons in description',
filename: nodeFilePath,
@@ -162,6 +167,11 @@ ruleTester.run('icon-validation', IconValidationRule, {
filename: credentialFilePath,
code: createCredentialCode('file:icons/TestNode.svg'),
},
{
name: 'credential with valid PNG string icon',
filename: credentialFilePath,
code: createCredentialCode('file:icons/NotSvg.png'),
},
{
name: 'credential with valid light/dark icons',
filename: credentialFilePath,
@@ -9,20 +9,18 @@ import {
findObjectProperty,
getStringLiteralValue,
validateIconPath,
findSimilarSvgFiles,
findSimilarIconFiles,
isFileType,
createRule,
} from '../utils/index.js';
const messages = {
iconFileNotFound: 'Icon file "{{ iconPath }}" does not exist',
iconNotSvg: 'Icon file "{{ iconPath }}" must be an SVG file (end with .svg)',
lightDarkSame: 'Light and dark icons cannot be the same file. Both point to "{{ iconPath }}"',
invalidIconPath: 'Icon path "{{ iconPath }}" must use file: protocol and be a string',
missingIcon: 'Node/Credential class must have an icon property defined',
addPlaceholder: 'Add icon property with placeholder',
addFileProtocol: "Add 'file:' protocol to icon path",
changeExtension: "Change icon extension to '.svg'",
similarIcon: "Use existing icon '{{ suggestedName }}'",
} as const;
@@ -32,7 +30,7 @@ export const IconValidationRule = createRule({
type: 'problem',
docs: {
description:
'Validate node and credential icon files exist, are SVG format, and light/dark icons are different',
'Validate node and credential icon files exist, use the file: protocol, and that light/dark icons are different',
},
messages,
schema: [],
@@ -80,34 +78,12 @@ export const IconValidationRule = createRule({
return false;
}
if (!validation.isSvg) {
const relativePath = iconPath.replace(/^file:/, '');
const suggestions: ReportSuggestionArray<keyof typeof messages> = [];
const pathWithoutExt = relativePath.replace(/\.[^/.]+$/, '');
const svgPath = `${pathWithoutExt}.svg`;
suggestions.push({
messageId: 'changeExtension',
fix(fixer) {
return fixer.replaceText(node, `"file:${svgPath}"`);
},
});
context.report({
node,
messageId: 'iconNotSvg',
data: { iconPath: relativePath },
suggest: suggestions,
});
return false;
}
if (!validation.exists) {
const relativePath = iconPath.replace(/^file:/, '');
const suggestions: ReportSuggestionArray<keyof typeof messages> = [];
// Find similar SVG files in the same directory
const similarFiles = findSimilarSvgFiles(relativePath, currentDir);
// Find similar icon files in the same directory
const similarFiles = findSimilarIconFiles(relativePath, currentDir);
for (const similarFile of similarFiles) {
suggestions.push({
messageId: 'similarIcon',
@@ -8,6 +8,7 @@ import { CredClassOAuth2NamingRule } from './cred-class-oauth2-naming.js';
import { CredentialDocumentationUrlRule } from './credential-documentation-url.js';
import { CredentialPasswordFieldRule } from './credential-password-field.js';
import { CredentialTestRequiredRule } from './credential-test-required.js';
import { IconPreferThemedVariantsRule } from './icon-prefer-themed-variants.js';
import { IconValidationRule } from './icon-validation.js';
import { MissingPairedItemRule } from './missing-paired-item.js';
import { N8nObjectValidationRule } from './n8n-object-validation.js';
@@ -55,6 +56,7 @@ export const rules = {
'no-runtime-dependencies': NoRuntimeDependenciesRule,
'no-template-placeholders': NoTemplatePlaceholdersRule,
'icon-validation': IconValidationRule,
'icon-prefer-themed-variants': IconPreferThemedVariantsRule,
'resource-operation-pattern': ResourceOperationPatternRule,
'credential-documentation-url': CredentialDocumentationUrlRule,
'node-class-description-icon-missing': NodeClassDescriptionIconMissingRule,
@@ -158,20 +158,17 @@ export function validateIconPath(
): {
isValid: boolean;
isFile: boolean;
isSvg: boolean;
exists: boolean;
} {
const isFile = iconPath.startsWith('file:');
const relativePath = iconPath.replace(/^file:/, '');
const isSvg = relativePath.endsWith('.svg');
// Should not use safeJoinPath here because iconPath can be outside of the node class folder
const fullPath = path.join(baseDir, relativePath);
const exists = fileExistsWithCaseSync(fullPath);
return {
isValid: isFile && isSvg && exists,
isValid: isFile && exists,
isFile,
isSvg,
exists,
};
}
@@ -268,7 +265,9 @@ function fileExistsWithCaseSync(filePath: string): boolean {
}
}
export function findSimilarSvgFiles(targetPath: string, baseDir: string): string[] {
const ICON_EXTENSIONS = ['.svg', '.png'];
export function findSimilarIconFiles(targetPath: string, baseDir: string): string[] {
try {
const targetFileName = path.basename(targetPath, path.extname(targetPath));
const targetDir = path.dirname(targetPath);
@@ -279,15 +278,25 @@ export function findSimilarSvgFiles(targetPath: string, baseDir: string): string
return [];
}
const files = readdirSync(searchDir);
const svgFileNames = files
.filter((file) => file.endsWith('.svg'))
.map((file) => path.basename(file, '.svg'));
const files = readdirSync(searchDir).filter((file) =>
ICON_EXTENSIONS.includes(path.extname(file).toLowerCase()),
);
const candidateNames = new Set(svgFileNames);
// Map icon base names to their actual filenames so suggestions keep their extension.
const baseNameToFiles = new Map<string, string[]>();
for (const file of files) {
const baseName = path.basename(file, path.extname(file));
const existing = baseNameToFiles.get(baseName) ?? [];
existing.push(file);
baseNameToFiles.set(baseName, existing);
}
const candidateNames = new Set(baseNameToFiles.keys());
const similarNames = findSimilarStrings(targetFileName, candidateNames);
return similarNames.map((name) => path.join(targetDir, `${name}.svg`));
return similarNames.flatMap((name) =>
(baseNameToFiles.get(name) ?? []).map((file) => path.join(targetDir, file)),
);
} catch {
return [];
}