mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-19 07:36:52 +00:00
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:
@@ -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',
|
||||
|
||||
+128
@@ -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 [];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user