From 1caa115529b099f8b64a37deb7b94aa0c5b5a2ac Mon Sep 17 00:00:00 2001 From: Jonas Pardeyke Date: Sun, 22 Feb 2026 13:23:30 +0100 Subject: [PATCH 1/4] added TurboPermanentRequireIdRule --- javascript/packages/linter/src/rules.ts | 2 + .../rules/html-turbo-permanent-require-id.ts | 48 +++++++++++++++ ...ml-turbo-permanent-require-id-rule.test.ts | 58 +++++++++++++++++++ 3 files changed, 108 insertions(+) create mode 100644 javascript/packages/linter/src/rules/html-turbo-permanent-require-id.ts create mode 100644 javascript/packages/linter/test/rules/html-turbo-permanent-require-id-rule.test.ts diff --git a/javascript/packages/linter/src/rules.ts b/javascript/packages/linter/src/rules.ts index 9962e28e3..03da277c8 100644 --- a/javascript/packages/linter/src/rules.ts +++ b/javascript/packages/linter/src/rules.ts @@ -58,6 +58,7 @@ import { HTMLNoSpaceInTagRule } from "./rules/html-no-space-in-tag.js" import { HTMLNoTitleAttributeRule } from "./rules/html-no-title-attribute.js" import { HTMLNoUnderscoresInAttributeNamesRule } from "./rules/html-no-underscores-in-attribute-names.js" import { HTMLTagNameLowercaseRule } from "./rules/html-tag-name-lowercase.js" +import { HTMLTurboPermanentRequireIdRule } from "./rules/html-turbo-permanent-require-id.js" import { SVGTagNameCapitalizationRule } from "./rules/svg-tag-name-capitalization.js" @@ -122,6 +123,7 @@ export const rules: RuleClass[] = [ HTMLNoTitleAttributeRule, HTMLNoUnderscoresInAttributeNamesRule, HTMLTagNameLowercaseRule, + HTMLTurboPermanentRequireIdRule, SVGTagNameCapitalizationRule, diff --git a/javascript/packages/linter/src/rules/html-turbo-permanent-require-id.ts b/javascript/packages/linter/src/rules/html-turbo-permanent-require-id.ts new file mode 100644 index 000000000..aeb752934 --- /dev/null +++ b/javascript/packages/linter/src/rules/html-turbo-permanent-require-id.ts @@ -0,0 +1,48 @@ +import { BaseRuleVisitor, getAttribute } from "./rule-utils.js" + +import { ParserRule } from "../types.js" +import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js" +import type { HTMLOpenTagNode, ParseResult } from "@herb-tools/core" + +class TurboPermanentRequireIdVisitor extends BaseRuleVisitor { + visitHTMLOpenTagNode(node: HTMLOpenTagNode): void { + this.checkTurboPermanent(node) + super.visitHTMLOpenTagNode(node) + } + + private checkTurboPermanent(node: HTMLOpenTagNode): void { + const turboPermanentAttribute = getAttribute(node, "data-turbo-permanent") + + if (!turboPermanentAttribute) { + return + } + + const idAttribute = getAttribute(node, "id") + + if (!idAttribute) { + this.addOffense( + "Elements with `data-turbo-permanent` must have an `id` attribute. Without an `id`, Turbo can't track the element across page changes and the permanent behavior won't work as expected.", + turboPermanentAttribute.location, + ) + } + } +} + +export class HTMLTurboPermanentRequireIdRule extends ParserRule { + name = "html-turbo-permanent-require-id" + + get defaultConfig(): FullRuleConfig { + return { + enabled: true, + severity: "error" + } + } + + check(result: ParseResult, context?: Partial): UnboundLintOffense[] { + const visitor = new TurboPermanentRequireIdVisitor(this.name, context) + + visitor.visit(result.value) + + return visitor.offenses + } +} diff --git a/javascript/packages/linter/test/rules/html-turbo-permanent-require-id-rule.test.ts b/javascript/packages/linter/test/rules/html-turbo-permanent-require-id-rule.test.ts new file mode 100644 index 000000000..316e26d0a --- /dev/null +++ b/javascript/packages/linter/test/rules/html-turbo-permanent-require-id-rule.test.ts @@ -0,0 +1,58 @@ +import { describe, test } from "vitest" +import { HTMLTurboPermanentRequireIdRule } from "../../src/rules/html-turbo-permanent-require-id.js" +import { createLinterTest } from "../helpers/linter-test-helper.js" + +const { expectNoOffenses, expectError, assertOffenses } = createLinterTest(HTMLTurboPermanentRequireIdRule) + +const MESSAGE = "Elements with `data-turbo-permanent` must have an `id` attribute. Without an `id`, Turbo can't track the element across page changes and the permanent behavior won't work as expected." + +describe("html-turbo-permanent-require-id", () => { + test("passes for element with data-turbo-permanent and id", () => { + expectNoOffenses('
Flash
') + }) + + test("fails for element with data-turbo-permanent but no id", () => { + expectError(MESSAGE) + + assertOffenses('
Flash
') + }) + + test("passes for element without data-turbo-permanent", () => { + expectNoOffenses('
Content
') + }) + + test("passes for element with only id", () => { + expectNoOffenses('
Content
') + }) + + test("fails for self-closing element with data-turbo-permanent but no id", () => { + expectError(MESSAGE) + + assertOffenses('') + }) + + test("passes for self-closing element with data-turbo-permanent and id", () => { + expectNoOffenses('') + }) + + test("fails for multiple elements with data-turbo-permanent but no id", () => { + expectError(MESSAGE) + expectError(MESSAGE) + + assertOffenses('
Flash
\nNotice') + }) + + test("passes for element with data-turbo-permanent and ERB id", () => { + expectNoOffenses('
Content
') + }) + + test("passes for element with data-turbo-permanent value and id", () => { + expectNoOffenses('
Cart
') + }) + + test("fails for element with other data attributes but no id", () => { + expectError(MESSAGE) + + assertOffenses('
Flash
') + }) +}) From 628b4d704db918c8b8ec91ff886914d7b0a208c9 Mon Sep 17 00:00:00 2001 From: Jonas Pardeyke Date: Wed, 25 Feb 2026 20:28:54 +0100 Subject: [PATCH 2/4] renamed to TurboPermanentRequireId --- javascript/packages/linter/src/rules.ts | 4 +- .../src/rules/turbo-permanent-require-id.ts | 48 +++++++++++++++ .../turbo-permanent-require-id-rule.test.ts | 58 +++++++++++++++++++ 3 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 javascript/packages/linter/src/rules/turbo-permanent-require-id.ts create mode 100644 javascript/packages/linter/test/rules/turbo-permanent-require-id-rule.test.ts diff --git a/javascript/packages/linter/src/rules.ts b/javascript/packages/linter/src/rules.ts index 03da277c8..6f73e8f88 100644 --- a/javascript/packages/linter/src/rules.ts +++ b/javascript/packages/linter/src/rules.ts @@ -58,7 +58,7 @@ import { HTMLNoSpaceInTagRule } from "./rules/html-no-space-in-tag.js" import { HTMLNoTitleAttributeRule } from "./rules/html-no-title-attribute.js" import { HTMLNoUnderscoresInAttributeNamesRule } from "./rules/html-no-underscores-in-attribute-names.js" import { HTMLTagNameLowercaseRule } from "./rules/html-tag-name-lowercase.js" -import { HTMLTurboPermanentRequireIdRule } from "./rules/html-turbo-permanent-require-id.js" +import { TurboPermanentRequireIdRule } from "./rules/turbo-permanent-require-id.js" import { SVGTagNameCapitalizationRule } from "./rules/svg-tag-name-capitalization.js" @@ -123,7 +123,7 @@ export const rules: RuleClass[] = [ HTMLNoTitleAttributeRule, HTMLNoUnderscoresInAttributeNamesRule, HTMLTagNameLowercaseRule, - HTMLTurboPermanentRequireIdRule, + TurboPermanentRequireIdRule, SVGTagNameCapitalizationRule, diff --git a/javascript/packages/linter/src/rules/turbo-permanent-require-id.ts b/javascript/packages/linter/src/rules/turbo-permanent-require-id.ts new file mode 100644 index 000000000..dba4fd264 --- /dev/null +++ b/javascript/packages/linter/src/rules/turbo-permanent-require-id.ts @@ -0,0 +1,48 @@ +import { BaseRuleVisitor, getAttribute } from "./rule-utils.js" + +import { ParserRule } from "../types.js" +import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js" +import type { HTMLOpenTagNode, ParseResult } from "@herb-tools/core" + +class TurboPermanentRequireIdVisitor extends BaseRuleVisitor { + visitHTMLOpenTagNode(node: HTMLOpenTagNode): void { + this.checkTurboPermanent(node) + super.visitHTMLOpenTagNode(node) + } + + private checkTurboPermanent(node: HTMLOpenTagNode): void { + const turboPermanentAttribute = getAttribute(node, "data-turbo-permanent") + + if (!turboPermanentAttribute) { + return + } + + const idAttribute = getAttribute(node, "id") + + if (!idAttribute) { + this.addOffense( + "Elements with `data-turbo-permanent` must have an `id` attribute. Without an `id`, Turbo can't track the element across page changes and the permanent behavior won't work as expected.", + turboPermanentAttribute.location, + ) + } + } +} + +export class TurboPermanentRequireIdRule extends ParserRule { + name = "turbo-permanent-require-id" + + get defaultConfig(): FullRuleConfig { + return { + enabled: true, + severity: "error" + } + } + + check(result: ParseResult, context?: Partial): UnboundLintOffense[] { + const visitor = new TurboPermanentRequireIdVisitor(this.name, context) + + visitor.visit(result.value) + + return visitor.offenses + } +} diff --git a/javascript/packages/linter/test/rules/turbo-permanent-require-id-rule.test.ts b/javascript/packages/linter/test/rules/turbo-permanent-require-id-rule.test.ts new file mode 100644 index 000000000..09c17c24f --- /dev/null +++ b/javascript/packages/linter/test/rules/turbo-permanent-require-id-rule.test.ts @@ -0,0 +1,58 @@ +import { describe, test } from "vitest" +import { TurboPermanentRequireIdRule } from "../../src/rules/turbo-permanent-require-id.js" +import { createLinterTest } from "../helpers/linter-test-helper.js" + +const { expectNoOffenses, expectError, assertOffenses } = createLinterTest(TurboPermanentRequireIdRule) + +const MESSAGE = "Elements with `data-turbo-permanent` must have an `id` attribute. Without an `id`, Turbo can't track the element across page changes and the permanent behavior won't work as expected." + +describe("turbo-permanent-require-id", () => { + test("passes for element with data-turbo-permanent and id", () => { + expectNoOffenses('
Flash
') + }) + + test("fails for element with data-turbo-permanent but no id", () => { + expectError(MESSAGE) + + assertOffenses('
Flash
') + }) + + test("passes for element without data-turbo-permanent", () => { + expectNoOffenses('
Content
') + }) + + test("passes for element with only id", () => { + expectNoOffenses('
Content
') + }) + + test("fails for self-closing element with data-turbo-permanent but no id", () => { + expectError(MESSAGE) + + assertOffenses('') + }) + + test("passes for self-closing element with data-turbo-permanent and id", () => { + expectNoOffenses('') + }) + + test("fails for multiple elements with data-turbo-permanent but no id", () => { + expectError(MESSAGE) + expectError(MESSAGE) + + assertOffenses('
Flash
\nNotice') + }) + + test("passes for element with data-turbo-permanent and ERB id", () => { + expectNoOffenses('
Content
') + }) + + test("passes for element with data-turbo-permanent value and id", () => { + expectNoOffenses('
Cart
') + }) + + test("fails for element with other data attributes but no id", () => { + expectError(MESSAGE) + + assertOffenses('
Flash
') + }) +}) From ae6b0ecd1160cd75cde99e1287094f3c6bcad2f4 Mon Sep 17 00:00:00 2001 From: Jonas Pardeyke Date: Wed, 25 Feb 2026 21:42:16 +0100 Subject: [PATCH 3/4] remove renamed rules --- .../rules/html-turbo-permanent-require-id.ts | 48 --------------- ...ml-turbo-permanent-require-id-rule.test.ts | 58 ------------------- 2 files changed, 106 deletions(-) delete mode 100644 javascript/packages/linter/src/rules/html-turbo-permanent-require-id.ts delete mode 100644 javascript/packages/linter/test/rules/html-turbo-permanent-require-id-rule.test.ts diff --git a/javascript/packages/linter/src/rules/html-turbo-permanent-require-id.ts b/javascript/packages/linter/src/rules/html-turbo-permanent-require-id.ts deleted file mode 100644 index aeb752934..000000000 --- a/javascript/packages/linter/src/rules/html-turbo-permanent-require-id.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { BaseRuleVisitor, getAttribute } from "./rule-utils.js" - -import { ParserRule } from "../types.js" -import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js" -import type { HTMLOpenTagNode, ParseResult } from "@herb-tools/core" - -class TurboPermanentRequireIdVisitor extends BaseRuleVisitor { - visitHTMLOpenTagNode(node: HTMLOpenTagNode): void { - this.checkTurboPermanent(node) - super.visitHTMLOpenTagNode(node) - } - - private checkTurboPermanent(node: HTMLOpenTagNode): void { - const turboPermanentAttribute = getAttribute(node, "data-turbo-permanent") - - if (!turboPermanentAttribute) { - return - } - - const idAttribute = getAttribute(node, "id") - - if (!idAttribute) { - this.addOffense( - "Elements with `data-turbo-permanent` must have an `id` attribute. Without an `id`, Turbo can't track the element across page changes and the permanent behavior won't work as expected.", - turboPermanentAttribute.location, - ) - } - } -} - -export class HTMLTurboPermanentRequireIdRule extends ParserRule { - name = "html-turbo-permanent-require-id" - - get defaultConfig(): FullRuleConfig { - return { - enabled: true, - severity: "error" - } - } - - check(result: ParseResult, context?: Partial): UnboundLintOffense[] { - const visitor = new TurboPermanentRequireIdVisitor(this.name, context) - - visitor.visit(result.value) - - return visitor.offenses - } -} diff --git a/javascript/packages/linter/test/rules/html-turbo-permanent-require-id-rule.test.ts b/javascript/packages/linter/test/rules/html-turbo-permanent-require-id-rule.test.ts deleted file mode 100644 index 316e26d0a..000000000 --- a/javascript/packages/linter/test/rules/html-turbo-permanent-require-id-rule.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, test } from "vitest" -import { HTMLTurboPermanentRequireIdRule } from "../../src/rules/html-turbo-permanent-require-id.js" -import { createLinterTest } from "../helpers/linter-test-helper.js" - -const { expectNoOffenses, expectError, assertOffenses } = createLinterTest(HTMLTurboPermanentRequireIdRule) - -const MESSAGE = "Elements with `data-turbo-permanent` must have an `id` attribute. Without an `id`, Turbo can't track the element across page changes and the permanent behavior won't work as expected." - -describe("html-turbo-permanent-require-id", () => { - test("passes for element with data-turbo-permanent and id", () => { - expectNoOffenses('
Flash
') - }) - - test("fails for element with data-turbo-permanent but no id", () => { - expectError(MESSAGE) - - assertOffenses('
Flash
') - }) - - test("passes for element without data-turbo-permanent", () => { - expectNoOffenses('
Content
') - }) - - test("passes for element with only id", () => { - expectNoOffenses('
Content
') - }) - - test("fails for self-closing element with data-turbo-permanent but no id", () => { - expectError(MESSAGE) - - assertOffenses('') - }) - - test("passes for self-closing element with data-turbo-permanent and id", () => { - expectNoOffenses('') - }) - - test("fails for multiple elements with data-turbo-permanent but no id", () => { - expectError(MESSAGE) - expectError(MESSAGE) - - assertOffenses('
Flash
\nNotice') - }) - - test("passes for element with data-turbo-permanent and ERB id", () => { - expectNoOffenses('
Content
') - }) - - test("passes for element with data-turbo-permanent value and id", () => { - expectNoOffenses('
Cart
') - }) - - test("fails for element with other data attributes but no id", () => { - expectError(MESSAGE) - - assertOffenses('
Flash
') - }) -}) From 897795de41264cae0509106f24614ae1de42720b Mon Sep 17 00:00:00 2001 From: Jonas Pardeyke Date: Wed, 25 Feb 2026 21:46:51 +0100 Subject: [PATCH 4/4] added docs --- .../packages/linter/docs/rules/README.md | 1 + .../docs/rules/turbo-permanent-require-id.md | 41 +++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 javascript/packages/linter/docs/rules/turbo-permanent-require-id.md diff --git a/javascript/packages/linter/docs/rules/README.md b/javascript/packages/linter/docs/rules/README.md index 88c44edf4..a72f548b9 100644 --- a/javascript/packages/linter/docs/rules/README.md +++ b/javascript/packages/linter/docs/rules/README.md @@ -59,6 +59,7 @@ This page contains documentation for all Herb Linter rules. - [`html-tag-name-lowercase`](./html-tag-name-lowercase.md) - Enforces lowercase tag names in HTML - [`parser-no-errors`](./parser-no-errors.md) - Disallow parser errors in HTML+ERB documents - [`svg-tag-name-capitalization`](./svg-tag-name-capitalization.md) - Enforces proper camelCase capitalization for SVG elements +- [`turbo-permanent-require-id`](./turbo-permanent-require-id.md) - Require `id` attribute on elements with `data-turbo-permanent` ## Contributing diff --git a/javascript/packages/linter/docs/rules/turbo-permanent-require-id.md b/javascript/packages/linter/docs/rules/turbo-permanent-require-id.md new file mode 100644 index 000000000..04801d4cb --- /dev/null +++ b/javascript/packages/linter/docs/rules/turbo-permanent-require-id.md @@ -0,0 +1,41 @@ +# Linter Rule: Require `id` attribute on elements with `data-turbo-permanent` + +**Rule:** `turbo-permanent-require-id` + +## Description + +Ensure that all HTML elements with the `data-turbo-permanent` attribute also have an `id` attribute. Without an `id`, Turbo can't track the element across page changes and the permanent behavior won't work as expected. + +## Rationale + +Turbo's `data-turbo-permanent` attribute marks an element to be persisted across page navigations. Turbo uses the element's `id` to match it between the current page and the new page. If no `id` is present, Turbo has no way to identify and preserve the element, so the `data-turbo-permanent` attribute has no effect. + +## Examples + +### ✅ Good + +```erb +
+ +
+ + +``` + +### 🚫 Bad + +```erb +
+ +
+ + +``` + +## References + +- [Turbo Handbook: Persisting Elements Across Page Loads](https://turbo.hotwired.dev/handbook/building#persisting-elements-across-page-loads)