diff --git a/README.md b/README.md
index 89313f5df0..bd54ca9e24 100644
--- a/README.md
+++ b/README.md
@@ -240,6 +240,7 @@ rules in templates can be disabled with eslint directives with mustache or html
| [no-ember-super-in-es-classes](docs/rules/no-ember-super-in-es-classes.md) | disallow use of `this._super` in ES class methods | ✅ | 🔧 | |
| [no-empty-glimmer-component-classes](docs/rules/no-empty-glimmer-component-classes.md) | disallow empty backing classes for Glimmer components | ✅ | | |
| [no-tracked-properties-from-args](docs/rules/no-tracked-properties-from-args.md) | disallow creating @tracked properties from this.args | ✅ | | |
+| [template-indent](docs/rules/template-indent.md) | enforce consistent indentation | | 🔧 | |
### jQuery
diff --git a/docs/rules/template-indent.md b/docs/rules/template-indent.md
new file mode 100644
index 0000000000..8413b154e9
--- /dev/null
+++ b/docs/rules/template-indent.md
@@ -0,0 +1,66 @@
+# ember/template-indent
+
+🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
+
+
+
+This rule extends the base [eslint indent](https://eslint.org/docs/latest/rules/indent) rule, but only applies the indents to Glimmer Nodes.
+
+Otherwise, it receives the same options as the original and can run together with the base rule.
+
+## Configuration
+
+
+
+| Name | Type | Default |
+| :--------------- | :------- | :------ |
+| `ignoreComments` | Boolean | `false` |
+| `ignoredNodes` | String[] | |
+
+
+
+## Rule Details
+
+Enforce consistent indentation for fcct templates
+
+```js
+const rules = {
+ 'ember/template-indent': [
+ 'error',
+ 2, // or 'tab'
+ {
+ ignoreComments: false,
+ ignoredNodes: []
+ }
+ ]
+};
+```
+
+## Examples
+
+Examples of **incorrect** code for this rule:
+
+```gjs
+// my-octane-component.gjs
+
+
+
+
+
+}
+```
+
+Examples of **correct** code for this rule:
+
+```gjs
+// my-component.gjs
+
+
+
+
+
+```
+
+## References
+
+- [eslint indent](https://eslint.org/docs/latest/rules/indent)
diff --git a/lib/rules/template-indent.js b/lib/rules/template-indent.js
new file mode 100644
index 0000000000..0c5aa542e7
--- /dev/null
+++ b/lib/rules/template-indent.js
@@ -0,0 +1,200 @@
+const { builtinRules } = require('eslint/use-at-your-own-risk');
+
+const baseRule = builtinRules.get('indent');
+const IGNORED_ELEMENTS = new Set(['pre', 'script', 'style', 'textarea']);
+
+const schema = baseRule.meta.schema.map((s) => ({ ...s }));
+schema[1].properties = {
+ ignoredNodes: schema[1].properties.ignoredNodes,
+ ignoreComments: schema[1].properties.ignoreComments,
+};
+
+/** @type {import('eslint').Rule.RuleModule} */
+module.exports = {
+ ERROR_MESSAGE: baseRule.meta.messages.wrongIndentation,
+ name: 'indent',
+ meta: {
+ type: 'layout',
+ docs: {
+ description: 'enforce consistent indentation',
+ extendsBaseRule: true,
+ // too opinionated to be recommended
+ recommended: false,
+ category: 'Ember Octane',
+ url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-indent.md',
+ },
+ fixable: 'whitespace',
+ hasSuggestions: baseRule.meta.hasSuggestions,
+ schema,
+ messages: baseRule.meta.messages,
+ },
+
+ create: (context) => {
+ const ctx = Object.create(context, {
+ report: {
+ writable: false,
+ configurable: false,
+ value: (info) => {
+ const node = context.sourceCode.getNodeByRangeIndex(info.node.range[0]);
+ if (!node.type.startsWith('Glimmer')) {
+ return;
+ }
+ context.report(info);
+ },
+ },
+ });
+ const rules = baseRule.create(ctx);
+ const sourceCode = context.sourceCode;
+
+ function JSXElement(node) {
+ let closingElement;
+ let openingElement;
+ if (node.type === 'GlimmerElementNode') {
+ const tokens = sourceCode.getTokens(node);
+ const openEnd = tokens.find((t) => t.value === '>');
+ const closeStart = tokens.findLast((t) => t.value === '<');
+ if (!node.selfClosing) {
+ closingElement = {
+ type: 'JSXClosingElement',
+ parent: node,
+ range: [closeStart.range[0], node.range[1]],
+ loc: {
+ start: Object.assign({}, node.loc.start),
+ end: Object.assign({}, node.loc.end),
+ },
+ };
+ closingElement.loc.start = sourceCode.getLocFromIndex(closeStart.range[0]);
+ closingElement.name = { ...closingElement, type: 'JSXIdentifier' };
+ closingElement.name.range = [
+ closingElement.name.range[0] + 1,
+ closingElement.name.range[1] - 1,
+ ];
+ }
+
+ openingElement = {
+ type: 'JSXOpeningElement',
+ selfClosing: node.selfClosing,
+ attributes: node.attributes,
+ parent: node,
+ range: [node.range[0], openEnd.range[1]],
+ loc: {
+ start: Object.assign({}, node.loc.start),
+ end: Object.assign({}, node.loc.end),
+ },
+ };
+ openingElement.loc.end = sourceCode.getLocFromIndex(openEnd.range[1]);
+ openingElement.name = { ...openingElement, type: 'JSXIdentifier' };
+ openingElement.name.range = [
+ openingElement.name.range[0] + 1,
+ openingElement.name.range[1] - 1,
+ ];
+ }
+ if (node.type === 'GlimmerBlockStatement') {
+ const tokens = sourceCode.getTokens(node);
+ let openEndIdx = tokens.findIndex((t) => t.value === '}');
+ while (tokens[openEndIdx + 1].value === '}') {
+ openEndIdx += 1;
+ }
+ const openEnd = tokens[openEndIdx];
+ let closeStartIdx = tokens.findLastIndex((t) => t.value === '{');
+ while (tokens[closeStartIdx - 1].value === '{') {
+ closeStartIdx -= 1;
+ }
+ const closeStart = tokens[closeStartIdx];
+ closingElement = {
+ type: 'JSXClosingElement',
+ parent: node,
+ range: [closeStart.range[0], node.range[1]],
+ loc: {
+ start: Object.assign({}, node.loc.start),
+ end: Object.assign({}, node.loc.end),
+ },
+ };
+ closingElement.loc.start = sourceCode.getLocFromIndex(closeStart.range[0]);
+
+ openingElement = {
+ type: 'JSXOpeningElement',
+ attributes: node.params,
+ parent: node,
+ range: [node.range[0], openEnd.range[1]],
+ loc: {
+ start: Object.assign({}, node.loc.start),
+ end: Object.assign({}, node.loc.end),
+ },
+ };
+ openingElement.loc.end = sourceCode.getLocFromIndex(openEnd.range[1]);
+ }
+ return {
+ type: 'JSXElement',
+ openingElement,
+ closingElement,
+ children: node.children || node.body,
+ parent: node.parent,
+ range: node.range,
+ loc: node.loc,
+ };
+ }
+
+ const ignoredStack = new Set();
+
+ return Object.assign({}, rules, {
+ // overwrite the base rule here so we can use our KNOWN_NODES list instead
+ '*:exit'(node) {
+ // For nodes we care about, skip the default handling, because it just marks the node as ignored...
+ if (
+ !node.type.startsWith('Glimmer') ||
+ (ignoredStack.size > 0 && !ignoredStack.has(node))
+ ) {
+ rules['*:exit'](node);
+ }
+ if (ignoredStack.has(node)) {
+ ignoredStack.delete(node);
+ }
+ },
+ 'GlimmerTemplate:exit'(node) {
+ if (!node.parent) {
+ rules['Program:exit'](node);
+ }
+ },
+ GlimmerElementNode(node) {
+ if (ignoredStack.size > 0) {
+ return;
+ }
+ if (IGNORED_ELEMENTS.has(node.tag)) {
+ ignoredStack.add(node);
+ }
+ const jsx = JSXElement(node);
+ rules['JSXElement'](jsx);
+ rules['JSXOpeningElement'](jsx.openingElement);
+ if (jsx.closingElement) {
+ rules['JSXClosingElement'](jsx.closingElement);
+ }
+ },
+ GlimmerAttrNode(node) {
+ if (ignoredStack.size > 0 || !node.value) {
+ return;
+ }
+ rules['JSXAttribute[value]']({
+ ...node,
+ type: 'JSXAttribute',
+ name: {
+ type: 'JSXIdentifier',
+ name: node.name,
+ range: [node.range[0], node.range[0] + node.name.length - 1],
+ },
+ });
+ },
+ GlimmerTemplate(node) {
+ if (!node.parent) {
+ return;
+ }
+ const jsx = JSXElement({ ...node, tag: 'template', type: 'GlimmerElementNode' });
+ rules['JSXElement'](jsx);
+ },
+ GlimmerBlockStatement(node) {
+ const body = [...node.program.body, ...(node.inverse?.body || [])];
+ rules['JSXElement'](JSXElement({ ...node, body }));
+ },
+ });
+ },
+};
diff --git a/tests/lib/rules/template-indent.js b/tests/lib/rules/template-indent.js
new file mode 100644
index 0000000000..622515e3c0
--- /dev/null
+++ b/tests/lib/rules/template-indent.js
@@ -0,0 +1,349 @@
+//------------------------------------------------------------------------------
+// Requirements
+//------------------------------------------------------------------------------
+
+const rule = require('../../../lib/rules/template-indent');
+const RuleTester = require('eslint').RuleTester;
+
+//------------------------------------------------------------------------------
+// Tests
+//------------------------------------------------------------------------------
+
+const ruleTester = new RuleTester({
+ parser: require.resolve('../../../lib/parsers/gjs-gts-parser.js'),
+ parserOptions: { ecmaVersion: 2020, sourceType: 'module' },
+});
+
+const ruleTesterWithBaseIntent = new RuleTester({
+ parser: require.resolve('../../../lib/parsers/gjs-gts-parser.js'),
+ parserOptions: { ecmaVersion: 2020, sourceType: 'module' },
+ rules: {
+ indent: 'warn',
+ },
+});
+
+ruleTester.run('template-indent', rule, {
+ valid: [
+ `
+
+
+
+
+
+ doesnt matter
+ where we
+ write
+
+
+
+
+ `,
+ `
+class MyClass {
+ sad=2;
+
+
+ {{test}}
+
+}
+ `,
+ `
+const tpl =
+
+ {{test}}
+
+ `,
+ ],
+
+ invalid: [
+ {
+ code: `
+
+
+ {{#if x}}
+ {{test}}
+ {{/if}}
+
+
+
+ doesnt matter
+ where we
+write
+
+
+
+
+
+
+ doesnt matter
+ where we
+ write
+
+
+
+
+
+ `,
+ output: `
+
+
+ {{#if x}}
+ {{test}}
+ {{/if}}
+
+
+
+ doesnt matter
+ where we
+write
+
+
+
+
+
+
+ doesnt matter
+ where we
+ write
+
+
+
+
+
+ `,
+ errors: [
+ {
+ message: 'Expected indentation of 4 spaces but found 0.',
+ line: 2,
+ column: 1,
+ endLine: 2,
+ endColumn: 1,
+ },
+ {
+ message: 'Expected indentation of 8 spaces but found 4.',
+ line: 3,
+ column: 1,
+ endLine: 3,
+ endColumn: 5,
+ },
+ {
+ message: 'Expected indentation of 4 spaces but found 0.',
+ line: 4,
+ column: 1,
+ endLine: 4,
+ endColumn: 1,
+ },
+ {
+ message: 'Expected indentation of 4 spaces but found 0.',
+ line: 5,
+ column: 1,
+ endLine: 5,
+ endColumn: 1,
+ },
+ {
+ message: 'Expected indentation of 8 spaces but found 4.',
+ line: 6,
+ column: 1,
+ endLine: 6,
+ endColumn: 5,
+ },
+ {
+ message: 'Expected indentation of 12 spaces but found 2.',
+ line: 7,
+ column: 1,
+ endLine: 7,
+ endColumn: 3,
+ },
+ {
+ message: 'Expected indentation of 4 spaces but found 0.',
+ line: 9,
+ column: 1,
+ endLine: 9,
+ endColumn: 1,
+ },
+ {
+ message: 'Expected indentation of 4 spaces but found 0.',
+ line: 10,
+ column: 1,
+ endLine: 10,
+ endColumn: 1,
+ },
+ {
+ message: 'Expected indentation of 4 spaces but found 0.',
+ line: 15,
+ column: 1,
+ endLine: 15,
+ endColumn: 1,
+ },
+ {
+ message: 'Expected indentation of 4 spaces but found 0.',
+ line: 16,
+ column: 1,
+ endLine: 16,
+ endColumn: 1,
+ },
+ {
+ message: 'Expected indentation of 8 spaces but found 4.',
+ line: 17,
+ column: 1,
+ endLine: 17,
+ endColumn: 5,
+ },
+ {
+ message: 'Expected indentation of 8 spaces but found 0.',
+ line: 18,
+ column: 1,
+ endLine: 18,
+ endColumn: 1,
+ },
+ {
+ message: 'Expected indentation of 8 spaces but found 2.',
+ line: 19,
+ column: 1,
+ endLine: 19,
+ endColumn: 3,
+ },
+ {
+ message: 'Expected indentation of 4 spaces but found 0.',
+ line: 20,
+ column: 1,
+ endLine: 20,
+ endColumn: 1,
+ },
+ {
+ message: 'Expected indentation of 4 spaces but found 0.',
+ line: 21,
+ column: 1,
+ endLine: 21,
+ endColumn: 1,
+ },
+ {
+ message: 'Expected indentation of 8 spaces but found 4.',
+ line: 22,
+ column: 1,
+ endLine: 22,
+ endColumn: 5,
+ },
+ {
+ message: 'Expected indentation of 8 spaces but found 6.',
+ line: 23,
+ column: 1,
+ endLine: 23,
+ endColumn: 7,
+ },
+ {
+ message: 'Expected indentation of 4 spaces but found 0.',
+ line: 24,
+ column: 1,
+ endLine: 24,
+ endColumn: 1,
+ },
+ {
+ message: 'Expected indentation of 4 spaces but found 0.',
+ line: 33,
+ column: 1,
+ endLine: 33,
+ endColumn: 1,
+ },
+ {
+ message: 'Expected indentation of 8 spaces but found 1.',
+ line: 34,
+ column: 1,
+ endLine: 34,
+ endColumn: 2,
+ },
+ {
+ message: 'Expected indentation of 4 spaces but found 1.',
+ line: 35,
+ column: 1,
+ endLine: 35,
+ endColumn: 2,
+ },
+ ],
+ },
+ ],
+});
+
+// make sure this works together with the base indent rule
+ruleTesterWithBaseIntent.run('template-indent-with-base.indent', rule, {
+ valid: [
+ `
+class MyClass {
+ sad=2;
+
+
+ {{test}}
+
+}
+ `,
+ ],
+ invalid: [
+ {
+ code: `
+class MyClass {
+ sad=2;
+
+
+ {{test}}
+
+}
+ `,
+ output: `
+class MyClass {
+ sad=2;
+
+
+ {{test}}
+
+}
+ `,
+ errors: [
+ {
+ type: 'Identifier',
+ message: 'Expected indentation of 4 spaces but found 6.',
+ line: 3,
+ column: 1,
+ endLine: 3,
+ endColumn: 7,
+ },
+ {
+ type: 'Punctuator',
+ message: 'Expected indentation of 8 spaces but found 10.',
+ line: 6,
+ column: 1,
+ endLine: 6,
+ endColumn: 11,
+ },
+ ],
+ },
+ ],
+});