Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
f9db871
Add template-lint-disable pragma for template regions
NullVoxPopuli Mar 22, 2026
cfc13ae
Remove HTML comment support for template-lint-disable
NullVoxPopuli Mar 22, 2026
176831f
Add hbs file support for template-lint-disable and tests
NullVoxPopuli Mar 22, 2026
ed81226
Fix polynomial regex (ReDoS) flagged by CodeQL
NullVoxPopuli Mar 22, 2026
51ff450
Fix self-lint errors
NullVoxPopuli Mar 22, 2026
83cbfa3
Address PR review feedback
NullVoxPopuli Mar 22, 2026
f48d3e6
Revert config-legacy/base.js to original
NullVoxPopuli Mar 22, 2026
c981dfa
Revert config/base.js to original
NullVoxPopuli Mar 22, 2026
2df802f
Fix prettier formatting
NullVoxPopuli Mar 22, 2026
e5bece8
Address review feedback from johanrd
NullVoxPopuli Apr 11, 2026
b89188e
Fix noop.postprocess corrupting hbs error messages and add test coverage
NullVoxPopuli Apr 11, 2026
6434dbd
Clean up: hoist regex, remove duplicate test, clarify docs
NullVoxPopuli Apr 11, 2026
464076c
Add inline comments explaining regex spacing asymmetry
NullVoxPopuli Apr 11, 2026
bab29f6
Fix silent no-ops and rule name matching gaps
NullVoxPopuli Apr 11, 2026
f9a0a28
Add README section for template-lint-disable processor
NullVoxPopuli Apr 11, 2026
a429d2e
Add hbs parser to README config and test eslint-disable ranges
NullVoxPopuli Apr 11, 2026
a077ffe
Document eslint-disable-next-line in templates and add test
NullVoxPopuli Apr 11, 2026
5c433af
Add test for eslint-disable/eslint-enable range in gjs templates
NullVoxPopuli Apr 11, 2026
d3b8cb6
Fix markdownlint MD032: add blank line before list
NullVoxPopuli Apr 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,80 @@ rules in templates can be disabled with eslint directives with mustache or html
</template>
```

### template-lint-disable (experimental)

> [!WARNING]
> This processor is experimental and not yet covered by semver guarantees. The API may change in future releases.

For users migrating from `ember-template-lint`, this plugin provides an opt-in `template-lint-disable` processor that recognizes `{{! template-lint-disable }}` comments. This can be useful for suppressing lint errors in `.hbs` files or in gjs/gts templates using familiar ember-template-lint syntax.

To enable it, set the processor and parser in your ESLint config:

```js
// eslint.config.js (flat config)
const ember = require('eslint-plugin-ember');

module.exports = [
{
files: ['**/*.hbs'],
parser: 'ember-eslint-parser/hbs',
plugins: { ember },
processor: 'ember/template-lint-disable',
// ...
},
];
```

```js
// .eslintrc.js (legacy config)
module.exports = {
overrides: [
{
files: ['**/*.hbs'],
parser: 'ember-eslint-parser/hbs',
plugins: ['ember'],
processor: 'ember/template-lint-disable',
// ...
},
],
};
```

Usage:

```hbs
{{! suppress all rules on the next line }}
{{! template-lint-disable }}
Hello world

{{! suppress a specific rule }}
{{!-- template-lint-disable no-bare-strings --}}
Hello world
```

Rule names can be specified as:

- Template-lint names: `no-bare-strings` (maps to `ember/template-no-bare-strings`)
- Plugin-qualified names: `template-no-bare-strings` (maps to `ember/template-no-bare-strings`)
- Full ESLint rule IDs: `ember/template-no-bare-strings`, `no-undef`

> [!NOTE]
> Unlike `ember-template-lint`, this directive only suppresses the **next line** (and the comment line itself). It does not disable rules for the rest of the scope. `template-lint-enable`, `template-lint-disable-next-line`, and `template-lint-disable-tree` are not supported.

Standard ESLint directives also work natively in mustache comments and can be used alongside `template-lint-disable`:

```hbs
{{! suppress a single line }}
{{! eslint-disable-next-line ember/template-no-bare-strings }}
<span>Hello world</span>

{{! suppress a range of lines }}
{{! eslint-disable ember/template-no-bare-strings }}
Hello world
Another bare string
{{! eslint-enable ember/template-no-bare-strings }}
```

## 🧰 Configurations

<!-- begin auto-generated configs list -->
Expand Down
2 changes: 2 additions & 0 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

const requireIndex = require('requireindex');
const noop = require('ember-eslint-parser/noop');
const templateLintDisableProcessor = require('./processors/template-lint-disable');
const pkg = require('../package.json'); // eslint-disable-line import/extensions

module.exports = {
Expand All @@ -17,5 +18,6 @@ module.exports = {
processors: {
// https://eslint.org/docs/developer-guide/working-with-plugins#file-extension-named-processor
noop,
'template-lint-disable': templateLintDisableProcessor,
},
};
153 changes: 153 additions & 0 deletions lib/processors/template-lint-disable.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
'use strict';

const noop = require('ember-eslint-parser/noop');

/**
* Regex patterns for template-lint-disable mustache comments.
* Two separate patterns to avoid polynomial backtracking (ReDoS):
* {{! template-lint-disable rule1 rule2 }}
* {{!-- template-lint-disable rule1 rule2 --}}
*/
// Lookahead (?=\s|…) ensures we match exactly "template-lint-disable"
// and not "template-lint-disable-next-line" or "template-lint-disable-tree".
// \s+ after ! — required because {{!text}} is valid Handlebars (comment with no space)
const MUSTACHE_COMMENT_REGEX = /{{!\s+template-lint-disable(?=\s|}})([\s\w,/@-]*)}}/g;
// \s* after -- — the dashes already delimit, so {{!--template-lint-disable--}} is fine
const MUSTACHE_BLOCK_COMMENT_REGEX = /{{!--\s*template-lint-disable(?=\s|--)([\s\w,/@-]*)--}}/g;

const GJS_GTS_EXT = /\.(gjs|gts)$/;

// Store disable directives per file
const fileDisableDirectives = new Map();

/**
* Parse template-lint-disable comments from source text and store
* which lines/rules should be suppressed.
*
* template-lint-disable means "disable next line" (like eslint-disable-next-line).
*
* NOTE: In ember-template-lint, `template-lint-disable` disables from that point
* for the rest of the scope (until `template-lint-enable`). This implementation
* intentionally uses "next line only" semantics to match ESLint conventions.
* `template-lint-enable` is not supported and will be silently ignored.
* `template-lint-disable-next-line` and `template-lint-disable-tree` are also
* not matched — only the exact `template-lint-disable` directive is recognized.
*
* Errors on the comment line itself are also suppressed (similar to
* eslint-disable-line), and errors on the next line are suppressed
* (similar to eslint-disable-next-line). This dual behavior exists because
* template AST TextNodes can start on the same line as the comment.
*
* CAVEAT: The processor runs on the entire file text, not just template regions.
* A JS string literal containing `{{! template-lint-disable }}` in a gjs file
* would match and suppress errors on the same/next line. This is unlikely in
* practice but impossible to fix without region-aware parsing.
*/
function collectMatches(line, lineIndex, directives) {
for (const regex of [MUSTACHE_COMMENT_REGEX, MUSTACHE_BLOCK_COMMENT_REGEX]) {
regex.lastIndex = 0;
let match;

while ((match = regex.exec(line)) !== null) {
const rulesPart = (match[1] || '').trim();
const rules = rulesPart ? rulesPart.split(/[\s,]+/).filter(Boolean) : []; // empty = disable all

// Comment is on line lineIndex+1 (1-indexed), next line is lineIndex+2.
// In template ASTs, TextNodes can start on the same line as the comment
// (e.g. the newline after {{! template-lint-disable }} is part of the
// following TextNode), so we suppress both the comment line and the next.
directives.push({
commentLine: lineIndex + 1,
nextLine: lineIndex + 2,
rules,
});
}
}
}

function parseDisableDirectives(text) {
const directives = [];
const lines = text.split('\n');

for (const [i, line] of lines.entries()) {
collectMatches(line, i, directives);
}

return directives;
}

/**
* Map a rule name from template-lint format to eslint-plugin-ember format.
* Accepts three forms:
* "no-bare-strings" -> "ember/template-no-bare-strings"
* "template-no-bare-strings" -> "ember/template-no-bare-strings"
* "ember/template-no-bare-strings" -> exact match
*/
function matchesRule(ruleId, disableRuleName) {
if (ruleId === disableRuleName) {
return true;
}
// e.g. "template-no-bare-strings" -> "ember/template-no-bare-strings"
if (ruleId === `ember/${disableRuleName}`) {
return true;
}
// e.g. "no-bare-strings" -> "ember/template-no-bare-strings"
if (ruleId === `ember/template-${disableRuleName}`) {
return true;
}
return false;
}

function shouldSuppressMessage(message, directives) {
for (const directive of directives) {
if (message.line !== directive.commentLine && message.line !== directive.nextLine) {
continue;
}
// No rules specified = suppress all
if (directive.rules.length === 0) {
return true;
}
// Check if any specified rule matches this message's rule
if (directive.rules.some((rule) => matchesRule(message.ruleId, rule))) {
return true;
}
}
return false;
}

module.exports = {
preprocess(text, fileName) {
if (!text.includes('template-lint-disable')) {
fileDisableDirectives.delete(fileName);
return [text];
}

const directives = parseDisableDirectives(text);
if (directives.length > 0) {
fileDisableDirectives.set(fileName, directives);
} else {
fileDisableDirectives.delete(fileName);
}
// Return text as-is (single code block)
return [text];
},

postprocess(messages, fileName) {
// Only run noop's postprocess for gjs/gts files — it appends gjs/gts setup
// instructions on parse failures, which corrupts hbs error messages since
// the hbs parser never calls registerParsedFile.
const msgs = GJS_GTS_EXT.test(fileName)
? noop.postprocess(messages, fileName)
: messages.flat();

const directives = fileDisableDirectives.get(fileName);
if (!directives) {
return msgs;
}

fileDisableDirectives.delete(fileName);
return msgs.filter((message) => !shouldSuppressMessage(message, directives));
},

supportsAutofix: true,
};
Loading
Loading