diff --git a/lib/rules/template-indent.js b/lib/rules/template-indent.js
new file mode 100644
index 0000000000..c47acbd9fa
--- /dev/null
+++ b/lib/rules/template-indent.js
@@ -0,0 +1,178 @@
+const { builtinRules } = require('eslint/use-at-your-own-risk');
+
+const baseRule = builtinRules.get('indent');
+const IGNORED_ELEMENTS = new Set(['pre', 'script', 'style', 'textarea']);
+
+/** @type {import('eslint').Rule.RuleModule} */
+module.exports = {
+ ERROR_MESSAGE: baseRule.meta.messages.wrongIndentation,
+ name: 'indent',
+ meta: {
+ type: 'layout',
+ docs: {
+ description: 'enforce consistent indentation',
+ // too opinionated to be recommended
+ extendsBaseRule: true,
+ recommended: true,
+ 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: baseRule.meta.schema,
+ messages: baseRule.meta.messages,
+ },
+
+ create: (context) => {
+ const rules = baseRule.create(context);
+ 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/package.json b/package.json
index dfab669402..e176c7cd3a 100644
--- a/package.json
+++ b/package.json
@@ -86,6 +86,7 @@
"snake-case": "^3.0.3"
},
"devDependencies": {
+ "@types/eslint": "^8.44.2",
"@babel/plugin-proposal-class-properties": "^7.13.0",
"@babel/plugin-proposal-decorators": "^7.15.8",
"@types/eslint": "^8.44.2",
@@ -99,7 +100,7 @@
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jest": "^27.0.1",
"eslint-plugin-markdown": "^3.0.0",
- "eslint-plugin-node": "^11.1.0",
+ "eslint-plugin-n": "^16.0.2",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-unicorn": "^46.0.1",
"eslint-remote-tester": "^3.0.0",
diff --git a/tests/lib/rules/template-indent.js b/tests/lib/rules/template-indent.js
new file mode 100644
index 0000000000..cb06fb2a96
--- /dev/null
+++ b/tests/lib/rules/template-indent.js
@@ -0,0 +1,256 @@
+//------------------------------------------------------------------------------
+// Requirements
+//------------------------------------------------------------------------------
+
+const rule = require('../../../lib/rules/template-indent');
+const RuleTester = require('eslint').RuleTester;
+
+const { ERROR_MESSAGE } = rule;
+
+//------------------------------------------------------------------------------
+// Tests
+//------------------------------------------------------------------------------
+
+const ruleTester = new RuleTester({
+ parser: require.resolve('../../../lib/parsers/gjs-parser.js'),
+ parserOptions: { ecmaVersion: 2020, sourceType: 'module', ecmaFeatures: { jsx: true } },
+});
+ruleTester.run('template-indent', rule, {
+ valid: [
+ `
+
+
+
+
+
+ doesnt matter
+ where we
+ write
+
+
+
+
+ `,
+ ],
+
+ invalid: [
+ {
+ code: `
+
+
+ {{#if x}}
+ {{test}}
+ {{/if}}
+
+
+
+ doesnt matter
+ where we
+write
+
+
+
+
+
+
+ `,
+ output: `
+
+
+ {{#if x}}
+ {{test}}
+ {{/if}}
+
+
+
+ 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: 25,
+ column: 1,
+ endLine: 25,
+ endColumn: 1,
+ },
+ {
+ message: 'Expected indentation of 8 spaces but found 1.',
+ line: 26,
+ column: 1,
+ endLine: 26,
+ endColumn: 2,
+ },
+ {
+ message: 'Expected indentation of 4 spaces but found 1.',
+ line: 27,
+ column: 1,
+ endLine: 27,
+ endColumn: 2,
+ }
+ ],
+ },
+ ]
+});