diff --git a/README.md b/README.md
index c62c1626ce..412f91abcc 100644
--- a/README.md
+++ b/README.md
@@ -150,6 +150,80 @@ rules in templates can be disabled with eslint directives with mustache or html
```
+### 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 }}
+Hello world
+
+{{! 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
diff --git a/lib/index.js b/lib/index.js
index bfb49a4471..638e4d840b 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -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 = {
@@ -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,
},
};
diff --git a/lib/processors/template-lint-disable.js b/lib/processors/template-lint-disable.js
new file mode 100644
index 0000000000..af18ce5a64
--- /dev/null
+++ b/lib/processors/template-lint-disable.js
@@ -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,
+};
diff --git a/tests/lib/rules-preprocessor/gjs-gts-parser-test.js b/tests/lib/rules-preprocessor/gjs-gts-parser-test.js
index cda7c36a34..c11edc76b0 100644
--- a/tests/lib/rules-preprocessor/gjs-gts-parser-test.js
+++ b/tests/lib/rules-preprocessor/gjs-gts-parser-test.js
@@ -886,3 +886,167 @@ describe('multiple tokens in same file', () => {
expect(resultErrors[2].message).toBe("Use 'String#startsWith' method instead.");
});
});
+
+function initESLintWithTemplateLintDisable() {
+ return new ESLint({
+ ignore: false,
+ useEslintrc: false,
+ plugins: { ember: plugin },
+ overrideConfig: {
+ root: true,
+ env: {
+ browser: true,
+ },
+ parserOptions: {
+ ecmaVersion: 2022,
+ sourceType: 'module',
+ },
+ parser: gjsGtsParser,
+ plugins: ['ember'],
+ processor: 'ember/template-lint-disable',
+ rules: {
+ 'no-undef': 'error',
+ 'no-unused-vars': 'error',
+ quotes: ['error', 'single'],
+ },
+ },
+ });
+}
+
+describe('supports template-lint-disable directive', () => {
+ it('disables all rules on the next line with mustache comment', async () => {
+ const eslint = initESLintWithTemplateLintDisable();
+ const code = `
+
+
+ {{! template-lint-disable }}
+ {{test}}
+
+
+ `;
+ const results = await eslint.lintText(code, { filePath: 'my-component.gjs' });
+ const resultErrors = results.flatMap((result) => result.messages);
+ // {{test}} would normally trigger no-undef, but should be suppressed
+ expect(resultErrors).toHaveLength(0);
+ });
+
+ it('disables all rules on the next line with mustache block comment', async () => {
+ const eslint = initESLintWithTemplateLintDisable();
+ const code = `
+
+
+ {{!-- template-lint-disable --}}
+ {{test}}
+
+
+ `;
+ const results = await eslint.lintText(code, { filePath: 'my-component.gjs' });
+ const resultErrors = results.flatMap((result) => result.messages);
+ expect(resultErrors).toHaveLength(0);
+ });
+
+ it('disables a specific rule by eslint rule name', async () => {
+ const eslint = initESLintWithTemplateLintDisable();
+ const code = `
+
+
+ {{! template-lint-disable no-undef }}
+ {{test}}
+ {{other}}
+
+
+ `;
+ const results = await eslint.lintText(code, { filePath: 'my-component.gjs' });
+ const resultErrors = results.flatMap((result) => result.messages);
+ // {{test}} on line after disable should be suppressed
+ // {{other}} on the line after that should NOT be suppressed
+ expect(resultErrors).toHaveLength(1);
+ expect(resultErrors[0].message).toBe("'other' is not defined.");
+ });
+
+ it('only disables the next line, not subsequent lines', async () => {
+ const eslint = initESLintWithTemplateLintDisable();
+ const code = `
+
+
+ {{! template-lint-disable }}
+ {{test}}
+ {{other}}
+
+
+ `;
+ const results = await eslint.lintText(code, { filePath: 'my-component.gjs' });
+ const resultErrors = results.flatMap((result) => result.messages);
+ // {{test}} suppressed, {{other}} NOT suppressed
+ expect(resultErrors).toHaveLength(1);
+ expect(resultErrors[0].message).toBe("'other' is not defined.");
+ });
+
+ it('does not suppress unrelated rules when a specific rule is named', async () => {
+ const eslint = initESLintWithTemplateLintDisable();
+ const code = `
+
+
+ {{! template-lint-disable ember/template-no-bare-strings }}
+ {{test}}
+
+
+ `;
+ const results = await eslint.lintText(code, { filePath: 'my-component.gjs' });
+ const resultErrors = results.flatMap((result) => result.messages);
+ // no-undef should still fire since we only disabled template-no-bare-strings
+ expect(resultErrors).toHaveLength(1);
+ expect(resultErrors[0].ruleId).toBe('no-undef');
+ });
+
+ it('supports multiple rule names', async () => {
+ const eslint = initESLintWithTemplateLintDisable();
+ const code = `
+
+
+ {{! template-lint-disable no-undef quotes }}
+ {{test}}
+
+
+ `;
+ const results = await eslint.lintText(code, { filePath: 'my-component.gjs' });
+ const resultErrors = results.flatMap((result) => result.messages);
+ expect(resultErrors).toHaveLength(0);
+ });
+
+ it('works with multiple disable comments in the same file', async () => {
+ const eslint = initESLintWithTemplateLintDisable();
+ const code = `
+
+
+ {{! template-lint-disable }}
+ {{test}}
+ {{! template-lint-disable }}
+ {{other}}
+
+
+ `;
+ const results = await eslint.lintText(code, { filePath: 'my-component.gjs' });
+ const resultErrors = results.flatMap((result) => result.messages);
+ expect(resultErrors).toHaveLength(0);
+ });
+
+ it('eslint-disable/eslint-enable range works in gjs templates', async () => {
+ const eslint = initESLintWithTemplateLintDisable();
+ const code = `
+
+
+ {{! eslint-disable no-undef }}
+ {{test}}
+ {{other}}
+ {{! eslint-enable no-undef }}
+ {{shouldError}}
+
+
+ `;
+ const results = await eslint.lintText(code, { filePath: 'my-component.gjs' });
+ const resultErrors = results.flatMap((result) => result.messages);
+ expect(resultErrors).toHaveLength(1);
+ expect(resultErrors[0].message).toBe("'shouldError' is not defined.");
+ });
+});
diff --git a/tests/lib/rules-preprocessor/hbs-parser-test.js b/tests/lib/rules-preprocessor/hbs-parser-test.js
new file mode 100644
index 0000000000..b5ee7b71c7
--- /dev/null
+++ b/tests/lib/rules-preprocessor/hbs-parser-test.js
@@ -0,0 +1,207 @@
+'use strict';
+
+const { ESLint } = require('eslint');
+const plugin = require('../../../lib');
+
+const hbsParser = require.resolve('ember-eslint-parser/hbs');
+
+function initHbsESLint() {
+ return new ESLint({
+ ignore: false,
+ useEslintrc: false,
+ plugins: { ember: plugin },
+ overrideConfig: {
+ root: true,
+ parserOptions: {
+ ecmaVersion: 2022,
+ sourceType: 'module',
+ },
+ plugins: ['ember'],
+ overrides: [
+ {
+ files: ['**/*.hbs'],
+ parser: hbsParser,
+ processor: 'ember/template-lint-disable',
+ rules: {
+ 'ember/template-no-bare-strings': 'error',
+ },
+ },
+ ],
+ },
+ });
+}
+
+describe('supports template-lint-disable directive in hbs files', () => {
+ it('disables all rules on the next line with mustache comment', async () => {
+ const eslint = initHbsESLint();
+ const code = `
+ {{! template-lint-disable }}
+ Hello world
+
`;
+ const results = await eslint.lintText(code, { filePath: 'my-template.hbs' });
+ const resultErrors = results.flatMap((result) => result.messages);
+ expect(resultErrors).toHaveLength(0);
+ });
+
+ it('disables all rules on the next line with mustache block comment', async () => {
+ const eslint = initHbsESLint();
+ const code = `
+ {{!-- template-lint-disable --}}
+ Hello world
+
`;
+ const results = await eslint.lintText(code, { filePath: 'my-template.hbs' });
+ const resultErrors = results.flatMap((result) => result.messages);
+ expect(resultErrors).toHaveLength(0);
+ });
+
+ it('only disables the next line, not subsequent lines', async () => {
+ const eslint = initHbsESLint();
+ const code = `{{! template-lint-disable }}
+Hello world
+Bare string here too
`;
+ const results = await eslint.lintText(code, { filePath: 'my-template.hbs' });
+ const resultErrors = results.flatMap((result) => result.messages);
+ // Line 2 "Hello world" suppressed, but line 3 "Bare string here too" should still error
+ expect(resultErrors).toHaveLength(1);
+ expect(resultErrors[0].line).toBe(3);
+ });
+
+ it('disables a specific rule by name', async () => {
+ const eslint = initHbsESLint();
+ const code = `
+ {{! template-lint-disable ember/template-no-bare-strings }}
+ Hello world
+
`;
+ const results = await eslint.lintText(code, { filePath: 'my-template.hbs' });
+ const resultErrors = results.flatMap((result) => result.messages);
+ expect(resultErrors).toHaveLength(0);
+ });
+
+ it('supports template-lint rule name format (maps to ember/ prefix)', async () => {
+ const eslint = initHbsESLint();
+ const code = `
+ {{! template-lint-disable no-bare-strings }}
+ Hello world
+
`;
+ const results = await eslint.lintText(code, { filePath: 'my-template.hbs' });
+ const resultErrors = results.flatMap((result) => result.messages);
+ expect(resultErrors).toHaveLength(0);
+ });
+
+ it('does not suppress unrelated rules when a specific rule is named', async () => {
+ const eslint = initHbsESLint();
+ const code = `
+ {{! template-lint-disable ember/template-no-html-comments }}
+ Hello world
+
`;
+ const results = await eslint.lintText(code, { filePath: 'my-template.hbs' });
+ const resultErrors = results.flatMap((result) => result.messages);
+ // no-bare-strings should still fire since we only disabled no-html-comments
+ expect(resultErrors).toHaveLength(1);
+ expect(resultErrors[0].ruleId).toBe('ember/template-no-bare-strings');
+ });
+
+ it('works with multiple disable comments in the same file', async () => {
+ const eslint = initHbsESLint();
+ const code = `
+ {{! template-lint-disable }}
+ Hello world
+ {{! template-lint-disable }}
+ Another bare string
+
`;
+ const results = await eslint.lintText(code, { filePath: 'my-template.hbs' });
+ const resultErrors = results.flatMap((result) => result.messages);
+ expect(resultErrors).toHaveLength(0);
+ });
+
+ it('bare strings without disable comment still trigger errors', async () => {
+ const eslint = initHbsESLint();
+ const code = `
+ Hello world
+
`;
+ const results = await eslint.lintText(code, { filePath: 'my-template.hbs' });
+ const resultErrors = results.flatMap((result) => result.messages);
+ expect(resultErrors).toHaveLength(1);
+ expect(resultErrors[0].ruleId).toBe('ember/template-no-bare-strings');
+ // Verify error message is not corrupted with gjs/gts setup instructions
+ expect(resultErrors[0].message).not.toContain('To lint Gjs/Gts files');
+ });
+
+ it('suppresses errors on the comment line itself', async () => {
+ const eslint = initHbsESLint();
+ const code = 'Hello {{! template-lint-disable }}';
+ const results = await eslint.lintText(code, { filePath: 'my-template.hbs' });
+ const resultErrors = results.flatMap((result) => result.messages);
+ // "Hello" is on the same line as the disable comment — suppressed
+ expect(resultErrors).toHaveLength(0);
+ });
+
+ it('does not match template-lint-disable-next-line (different directive)', async () => {
+ const eslint = initHbsESLint();
+ const code = `
+ {{! template-lint-disable-next-line }}
+ Hello world
+
`;
+ const results = await eslint.lintText(code, { filePath: 'my-template.hbs' });
+ const resultErrors = results.flatMap((result) => result.messages);
+ // template-lint-disable-next-line is NOT a recognized directive — error should fire
+ expect(resultErrors).toHaveLength(1);
+ expect(resultErrors[0].ruleId).toBe('ember/template-no-bare-strings');
+ });
+
+ it('matches template-no-bare-strings middle form (ember/ prefix mapping)', async () => {
+ const eslint = initHbsESLint();
+ const code = `
+ {{! template-lint-disable template-no-bare-strings }}
+ Hello world
+
`;
+ const results = await eslint.lintText(code, { filePath: 'my-template.hbs' });
+ const resultErrors = results.flatMap((result) => result.messages);
+ expect(resultErrors).toHaveLength(0);
+ });
+
+ it('eslint-disable-next-line works in hbs templates', async () => {
+ const eslint = initHbsESLint();
+ const code = `
+ {{! eslint-disable-next-line ember/template-no-bare-strings }}
+ Hello world
+ Still bare
+
`;
+ const results = await eslint.lintText(code, { filePath: 'my-template.hbs' });
+ const resultErrors = results.flatMap((result) => result.messages);
+ // "Hello world" on the next line is suppressed, "Still bare" still errors
+ expect(resultErrors).toHaveLength(1);
+ expect(resultErrors[0].ruleId).toBe('ember/template-no-bare-strings');
+ expect(resultErrors[0].line).toBe(4);
+ });
+
+ it('eslint-disable/eslint-enable ranges work in hbs templates', async () => {
+ const eslint = initHbsESLint();
+ const code = `{{! eslint-disable ember/template-no-bare-strings }}
+Hello world
+Another bare string
+{{! eslint-enable ember/template-no-bare-strings }}
+This should error`;
+ const results = await eslint.lintText(code, { filePath: 'my-template.hbs' });
+ const resultErrors = results.flatMap((result) => result.messages);
+ // Lines 2-3 are inside the eslint-disable range — suppressed
+ // "This should error" after eslint-enable triggers the rule
+ expect(resultErrors).toHaveLength(1);
+ expect(resultErrors[0].ruleId).toBe('ember/template-no-bare-strings');
+ });
+
+ it('parses @-scoped rule names without breaking the regex', async () => {
+ const eslint = initHbsESLint();
+ const code = `
+ {{! template-lint-disable @ember/template-no-bare-strings }}
+ Hello world
+
`;
+ const results = await eslint.lintText(code, { filePath: 'my-template.hbs' });
+ const resultErrors = results.flatMap((result) => result.messages);
+ // @ember/template-no-bare-strings won't match ruleId ember/template-no-bare-strings
+ // (different string), so the error still fires — this tests that @ is accepted
+ // by the regex without breaking parsing
+ expect(resultErrors).toHaveLength(1);
+ expect(resultErrors[0].ruleId).toBe('ember/template-no-bare-strings');
+ });
+});