Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions javascript/packages/linter/docs/rules/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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
<div id="player" data-turbo-permanent>
<!-- This element will persist across page navigations -->
</div>

<audio id="background-music" data-turbo-permanent>
<source src="/music.mp3" type="audio/mpeg">
</audio>
```

### 🚫 Bad

```erb
<div data-turbo-permanent>
<!-- Missing id: Turbo can't track this element -->
</div>

<audio data-turbo-permanent>
<source src="/music.mp3" type="audio/mpeg">
</audio>
```

## References

- [Turbo Handbook: Persisting Elements Across Page Loads](https://turbo.hotwired.dev/handbook/building#persisting-elements-across-page-loads)
2 changes: 2 additions & 0 deletions javascript/packages/linter/src/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import { HTMLNoTitleAttributeRule } from "./rules/html-no-title-attribute.js"
import { HTMLNoUnderscoresInAttributeNamesRule } from "./rules/html-no-underscores-in-attribute-names.js"
import { HTMLRequireClosingTagsRule } from "./rules/html-require-closing-tags.js"
import { HTMLTagNameLowercaseRule } from "./rules/html-tag-name-lowercase.js"
import { TurboPermanentRequireIdRule } from "./rules/turbo-permanent-require-id.js"

import { SVGTagNameCapitalizationRule } from "./rules/svg-tag-name-capitalization.js"

Expand Down Expand Up @@ -124,6 +125,7 @@ export const rules: RuleClass[] = [
HTMLNoUnderscoresInAttributeNamesRule,
HTMLRequireClosingTagsRule,
HTMLTagNameLowercaseRule,
TurboPermanentRequireIdRule,

SVGTagNameCapitalizationRule,

Expand Down
48 changes: 48 additions & 0 deletions javascript/packages/linter/src/rules/turbo-permanent-require-id.ts
Original file line number Diff line number Diff line change
@@ -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<LintContext>): UnboundLintOffense[] {
const visitor = new TurboPermanentRequireIdVisitor(this.name, context)

visitor.visit(result.value)

return visitor.offenses
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -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('<div id="flash-messages" data-turbo-permanent>Flash</div>')
})

test("fails for element with data-turbo-permanent but no id", () => {
expectError(MESSAGE)

assertOffenses('<div data-turbo-permanent>Flash</div>')
})

test("passes for element without data-turbo-permanent", () => {
expectNoOffenses('<div class="container">Content</div>')
})

test("passes for element with only id", () => {
expectNoOffenses('<div id="my-element">Content</div>')
})

test("fails for self-closing element with data-turbo-permanent but no id", () => {
expectError(MESSAGE)

assertOffenses('<input data-turbo-permanent>')
})

test("passes for self-closing element with data-turbo-permanent and id", () => {
expectNoOffenses('<input id="my-input" data-turbo-permanent>')
})

test("fails for multiple elements with data-turbo-permanent but no id", () => {
expectError(MESSAGE)
expectError(MESSAGE)

assertOffenses('<div data-turbo-permanent>Flash</div>\n<span data-turbo-permanent>Notice</span>')
})

test("passes for element with data-turbo-permanent and ERB id", () => {
expectNoOffenses('<div id="<%= dom_id(record) %>" data-turbo-permanent>Content</div>')
})

test("passes for element with data-turbo-permanent value and id", () => {
expectNoOffenses('<div id="cart" data-turbo-permanent="">Cart</div>')
})

test("fails for element with other data attributes but no id", () => {
expectError(MESSAGE)

assertOffenses('<div class="flash" data-turbo-permanent data-controller="flash">Flash</div>')
})
})
Loading