mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
feat(core): Add no-template-placeholders ESLint rule for community nodes (#29796)
This commit is contained in:
parent
5af9d0729f
commit
c4056b255e
|
|
@ -61,6 +61,7 @@ export default [
|
|||
| [no-restricted-globals](docs/rules/no-restricted-globals.md) | Disallow usage of restricted global variables in community nodes. | ✅ | | | | |
|
||||
| [no-restricted-imports](docs/rules/no-restricted-imports.md) | Disallow usage of restricted imports in community nodes. | ✅ | | | | |
|
||||
| [no-runtime-dependencies](docs/rules/no-runtime-dependencies.md) | Disallow non-empty "dependencies" in community node package.json | ✅ ☑️ | | | | |
|
||||
| [no-template-placeholders](docs/rules/no-template-placeholders.md) | Disallow unresolved template placeholders in package.json | ✅ ☑️ | | | | |
|
||||
| [node-class-description-icon-missing](docs/rules/node-class-description-icon-missing.md) | Node class description must have an `icon` property defined. Deprecated: use `require-node-description-fields` instead. | | | | 💡 | ❌ |
|
||||
| [node-connection-type-literal](docs/rules/node-connection-type-literal.md) | Disallow string literals in node description `inputs`/`outputs` — use `NodeConnectionTypes` enum instead | ✅ ☑️ | | 🔧 | | |
|
||||
| [node-operation-error-itemindex](docs/rules/node-operation-error-itemindex.md) | Require { itemIndex } in NodeOperationError / NodeApiError options inside item loops | ✅ ☑️ | | | | |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
# Disallow unresolved template placeholders in package.json (`@n8n/community-nodes/no-template-placeholders`)
|
||||
|
||||
💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
|
||||
|
||||
<!-- end auto-generated rule header -->
|
||||
|
||||
## Rule Details
|
||||
|
||||
Community node packages are typically scaffolded from a starter template that contains
|
||||
placeholder values such as `<PACKAGE_NAME>`, `<USERNAME>`, or `{{ authorName }}`. When
|
||||
these placeholders survive into a published `package.json`, the package metadata is
|
||||
broken — the name is invalid, the repository link is dead, etc.
|
||||
|
||||
This rule scans every string value in `package.json` and reports any value containing
|
||||
an unresolved placeholder pattern. It catches:
|
||||
|
||||
- Angle bracket placeholders: `<...>`
|
||||
- Mustache placeholders: `{{...}}`
|
||||
|
||||
The rule applies to **all** string fields, including custom ones — not just the well-known
|
||||
fields like `name`, `description`, `homepage`, or `repository.url`.
|
||||
|
||||
## Examples
|
||||
|
||||
### Incorrect
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "n8n-nodes-<PACKAGE_NAME>",
|
||||
"description": "An n8n community node for {{service}}",
|
||||
"homepage": "https://github.com/<USERNAME>/n8n-nodes-example#readme",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/<USERNAME>/n8n-nodes-example.git"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Correct
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "n8n-nodes-acme",
|
||||
"description": "An n8n community node for the Acme API",
|
||||
"homepage": "https://github.com/acme/n8n-nodes-acme#readme",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/acme/n8n-nodes-acme.git"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -33,6 +33,7 @@ const configs = {
|
|||
'@n8n/community-nodes/no-http-request-with-manual-auth': 'error',
|
||||
'@n8n/community-nodes/no-overrides-field': 'error',
|
||||
'@n8n/community-nodes/no-runtime-dependencies': 'error',
|
||||
'@n8n/community-nodes/no-template-placeholders': 'error',
|
||||
'@n8n/community-nodes/icon-validation': 'error',
|
||||
'@n8n/community-nodes/options-sorted-alphabetically': 'warn',
|
||||
'@n8n/community-nodes/resource-operation-pattern': 'warn',
|
||||
|
|
@ -67,6 +68,7 @@ const configs = {
|
|||
'@n8n/community-nodes/no-http-request-with-manual-auth': 'error',
|
||||
'@n8n/community-nodes/no-overrides-field': 'error',
|
||||
'@n8n/community-nodes/no-runtime-dependencies': 'error',
|
||||
'@n8n/community-nodes/no-template-placeholders': 'error',
|
||||
'@n8n/community-nodes/icon-validation': 'error',
|
||||
'@n8n/community-nodes/options-sorted-alphabetically': 'warn',
|
||||
'@n8n/community-nodes/credential-documentation-url': 'error',
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { NoOverridesFieldRule } from './no-overrides-field.js';
|
|||
import { NoRestrictedGlobalsRule } from './no-restricted-globals.js';
|
||||
import { NoRestrictedImportsRule } from './no-restricted-imports.js';
|
||||
import { NoRuntimeDependenciesRule } from './no-runtime-dependencies.js';
|
||||
import { NoTemplatePlaceholdersRule } from './no-template-placeholders.js';
|
||||
import { NodeClassDescriptionIconMissingRule } from './node-class-description-icon-missing.js';
|
||||
import { NodeConnectionTypeLiteralRule } from './node-connection-type-literal.js';
|
||||
import { NodeOperationErrorItemIndexRule } from './node-operation-error-itemindex.js';
|
||||
|
|
@ -45,6 +46,7 @@ export const rules = {
|
|||
'no-http-request-with-manual-auth': NoHttpRequestWithManualAuthRule,
|
||||
'no-overrides-field': NoOverridesFieldRule,
|
||||
'no-runtime-dependencies': NoRuntimeDependenciesRule,
|
||||
'no-template-placeholders': NoTemplatePlaceholdersRule,
|
||||
'icon-validation': IconValidationRule,
|
||||
'resource-operation-pattern': ResourceOperationPatternRule,
|
||||
'credential-documentation-url': CredentialDocumentationUrlRule,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,135 @@
|
|||
import { RuleTester } from '@typescript-eslint/rule-tester';
|
||||
|
||||
import { NoTemplatePlaceholdersRule } from './no-template-placeholders.js';
|
||||
|
||||
const ruleTester = new RuleTester();
|
||||
|
||||
ruleTester.run('no-template-placeholders', NoTemplatePlaceholdersRule, {
|
||||
valid: [
|
||||
{
|
||||
name: 'package.json with no placeholders',
|
||||
filename: 'package.json',
|
||||
code: `{
|
||||
"name": "n8n-nodes-example",
|
||||
"version": "1.0.0",
|
||||
"description": "An example community node",
|
||||
"homepage": "https://example.com",
|
||||
"repository": { "type": "git", "url": "git+https://github.com/acme/n8n-nodes-example.git" }
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: 'angle brackets that do not look like placeholders are ignored',
|
||||
filename: 'package.json',
|
||||
code: '{ "description": "Compares a < b values" }',
|
||||
},
|
||||
{
|
||||
name: 'single curly braces are ignored',
|
||||
filename: 'package.json',
|
||||
code: '{ "description": "Use { key: value } syntax" }',
|
||||
},
|
||||
{
|
||||
name: 'non-package.json file is ignored even if it has placeholders',
|
||||
filename: 'tsconfig.json',
|
||||
code: '{ "name": "<PACKAGE_NAME>" }',
|
||||
},
|
||||
{
|
||||
name: 'numeric and boolean values are not flagged',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "private": false, "engines": { "node": ">=18" } }',
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
name: 'angle bracket placeholder in name',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-<PACKAGE_NAME>" }',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'unresolvedPlaceholder',
|
||||
data: { pattern: '<PACKAGE_NAME>' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'angle bracket placeholder in description',
|
||||
filename: 'package.json',
|
||||
code: '{ "description": "An n8n community node for <SERVICE>" }',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'unresolvedPlaceholder',
|
||||
data: { pattern: '<SERVICE>' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'angle bracket placeholder in repository url',
|
||||
filename: 'package.json',
|
||||
code: '{ "repository": { "type": "git", "url": "git+https://github.com/<USERNAME>/n8n-nodes-example.git" } }',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'unresolvedPlaceholder',
|
||||
data: { pattern: '<USERNAME>' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'angle bracket placeholder in homepage',
|
||||
filename: 'package.json',
|
||||
code: '{ "homepage": "https://github.com/<USERNAME>/n8n-nodes-example#readme" }',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'unresolvedPlaceholder',
|
||||
data: { pattern: '<USERNAME>' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'mustache placeholder in author',
|
||||
filename: 'package.json',
|
||||
code: '{ "author": "{{ authorName }}" }',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'unresolvedPlaceholder',
|
||||
data: { pattern: '{{ authorName }}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'mustache placeholder inside larger string',
|
||||
filename: 'package.json',
|
||||
code: '{ "description": "Node by {{author}} for service" }',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'unresolvedPlaceholder',
|
||||
data: { pattern: '{{author}}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'placeholder in custom field',
|
||||
filename: 'package.json',
|
||||
code: '{ "n8n": { "n8nNodesApiVersion": 1, "credentials": ["<CREDENTIAL>"] } }',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'unresolvedPlaceholder',
|
||||
data: { pattern: '<CREDENTIAL>' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'multiple placeholders in different fields are all reported',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "<NAME>", "description": "{{description}}" }',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'unresolvedPlaceholder',
|
||||
data: { pattern: '<NAME>' },
|
||||
},
|
||||
{
|
||||
messageId: 'unresolvedPlaceholder',
|
||||
data: { pattern: '{{description}}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import type { TSESTree } from '@typescript-eslint/utils';
|
||||
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
|
||||
|
||||
import { createRule } from '../utils/index.js';
|
||||
|
||||
const ANGLE_PLACEHOLDER = /<[^<>\n]+?>/;
|
||||
const MUSTACHE_PLACEHOLDER = /\{\{[^{}\n]+?\}\}/;
|
||||
|
||||
function findPlaceholder(value: string): { pattern: string; type: 'angle' | 'mustache' } | null {
|
||||
const angleMatch = ANGLE_PLACEHOLDER.exec(value);
|
||||
if (angleMatch) {
|
||||
return { pattern: angleMatch[0], type: 'angle' };
|
||||
}
|
||||
const mustacheMatch = MUSTACHE_PLACEHOLDER.exec(value);
|
||||
if (mustacheMatch) {
|
||||
return { pattern: mustacheMatch[0], type: 'mustache' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const NoTemplatePlaceholdersRule = createRule({
|
||||
name: 'no-template-placeholders',
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Disallow unresolved template placeholders in package.json',
|
||||
},
|
||||
messages: {
|
||||
unresolvedPlaceholder:
|
||||
'String value contains an unresolved template placeholder "{{ pattern }}". Replace it with a real value before publishing.',
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
defaultOptions: [],
|
||||
create(context) {
|
||||
if (!context.filename.endsWith('package.json')) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
Literal(node: TSESTree.Literal) {
|
||||
if (typeof node.value !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip property keys — only flag values.
|
||||
if (
|
||||
node.parent?.type === AST_NODE_TYPES.Property &&
|
||||
node.parent.key === node &&
|
||||
!node.parent.computed
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const placeholder = findPlaceholder(node.value);
|
||||
if (!placeholder) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'unresolvedPlaceholder',
|
||||
data: { pattern: placeholder.pattern },
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user