feat(core): Add no-template-placeholders ESLint rule for community nodes (#29796)

This commit is contained in:
Garrit Franke 2026-05-06 08:20:37 +02:00 committed by GitHub
parent 5af9d0729f
commit c4056b255e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 259 additions and 0 deletions

View File

@ -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 | ✅ ☑️ | | | | |

View File

@ -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"
}
}
```

View File

@ -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',

View File

@ -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,

View File

@ -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}}' },
},
],
},
],
});

View File

@ -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 },
});
},
};
},
});