diff --git a/.github/labeler.yml b/.github/labeler.yml
index eeb52e2e2..92ab37f23 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -146,6 +146,11 @@ node:
- any-glob-to-any-file:
- '**/javascript/packages/node/**/*'
+turbo-lint:
+ - changed-files:
+ - any-glob-to-any-file:
+ - '**/javascript/packages/turbo-lint/**/*'
+
stimulus-lint:
- changed-files:
- any-glob-to-any-file:
diff --git a/javascript/packages/turbo-lint/README.md b/javascript/packages/turbo-lint/README.md
new file mode 100644
index 000000000..b120bddbb
--- /dev/null
+++ b/javascript/packages/turbo-lint/README.md
@@ -0,0 +1,39 @@
+# Turbo Lint
+
+**Package**: [`turbo-lint`](https://www.npmjs.com/package/turbo-lint)
+
+---
+
+Linter and static code analyzer for [Turbo](https://turbo.hotwired.dev/), powered by [Herb](https://github.com/marcoroth/herb).
+
+### Installation
+
+:::code-group
+
+```shell [npm]
+npm install turbo-lint
+```
+
+```shell [yarn]
+yarn add turbo-lint
+```
+
+```shell [pnpm]
+pnpm add turbo-lint
+```
+
+```shell [bun]
+bun add turbo-lint
+```
+:::
+
+
+### Run
+
+```bash
+npx turbo-lint
+```
+
+## Rules
+
+Rules are documented [here](./docs/rules/README.md)
diff --git a/javascript/packages/turbo-lint/bin/turbo-lint b/javascript/packages/turbo-lint/bin/turbo-lint
new file mode 100755
index 000000000..631e19e43
--- /dev/null
+++ b/javascript/packages/turbo-lint/bin/turbo-lint
@@ -0,0 +1,3 @@
+#!/usr/bin/env node
+
+import("../dist/turbo-lint.js")
diff --git a/javascript/packages/turbo-lint/docs/rules/README.md b/javascript/packages/turbo-lint/docs/rules/README.md
new file mode 100644
index 000000000..b28b55ce8
--- /dev/null
+++ b/javascript/packages/turbo-lint/docs/rules/README.md
@@ -0,0 +1,7 @@
+# Linter Rules
+
+This page contains documentation for all Turbo Linter rules.
+
+## Available Rules
+
+- [`turbo-permanent`](./html-turbo-permanent.md) - Prevents invalid usage of the boolean attribute
diff --git a/javascript/packages/turbo-lint/docs/rules/html-turbo-permanent.md b/javascript/packages/turbo-lint/docs/rules/html-turbo-permanent.md
new file mode 100644
index 000000000..0cebda8a8
--- /dev/null
+++ b/javascript/packages/turbo-lint/docs/rules/html-turbo-permanent.md
@@ -0,0 +1,32 @@
+# Linter Rule: HTML Turbo permanent attribute usage
+
+**Rule:** `html-turbo-permanent`
+
+## Description
+
+Ensure that turbo permanent attributes are used correctly in HTML elements. The `data-turbo-permanent` attribute is used to mark elements that should persist across page navigations in Turbo Drive.
+
+## Rationale
+
+Data turbo permanent is active if the attribute is present, `data-turbo-permanent="false"` behaves the same as `data-turbo-permanent`. This should be disallowed to avoid confusion.
+
+## Examples
+
+### ✅ Good
+
+```html
+
1 item
+```
+
+### 🚫 Bad
+
+```html
+1 item
+1 item
+1 item
+```
+
+## References
+
+* [Turbo: `data-turbo-permanent` attribute](https://turbo.hotwired.dev/handbook/drive#permanent-elements)
+* [HTML: Boolean Attributes](https://developer.mozilla.org/en-US/docs/Glossary/Boolean/HTML)
diff --git a/javascript/packages/turbo-lint/package.json b/javascript/packages/turbo-lint/package.json
new file mode 100644
index 000000000..76250a1fb
--- /dev/null
+++ b/javascript/packages/turbo-lint/package.json
@@ -0,0 +1,50 @@
+{
+ "name": "turbo-lint",
+ "version": "0.1.0",
+ "description": "Linting rules for Turbo HTML+ERB view templates.",
+ "license": "MIT",
+ "homepage": "https://herb-tools.dev",
+ "bugs": "https://github.com/marcoroth/herb/issues/new?title=Package%20%60turbo-lint%60:%20",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/marcoroth/herb.git",
+ "directory": "javascript/packages/turbo-lint"
+ },
+ "main": "./dist/index.cjs",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "bin": {
+ "turbo-lint": "./bin/turbo-lint"
+ },
+ "exports": {
+ "./package.json": "./package.json",
+ ".": {
+ "types": "./dist/types/index.d.ts",
+ "import": "./dist/index.js",
+ "require": "./dist/index.cjs",
+ "default": "./dist/index.js"
+ }
+ },
+ "scripts": {
+ "clean": "rimraf dist",
+ "build": "yarn clean && tsc -b && rollup -c",
+ "watch": "tsc -b -w",
+ "test": "vitest run",
+ "test:watch": "vitest --watch",
+ "prepublishOnly": "yarn clean && yarn build && yarn test"
+ },
+ "dependencies": {
+ "@herb-tools/core": "0.7.5",
+ "@herb-tools/highlighter": "0.7.5",
+ "@herb-tools/linter": "0.7.5",
+ "@herb-tools/node-wasm": "0.7.5"
+ },
+ "files": [
+ "package.json",
+ "README.md",
+ "docs/",
+ "src/",
+ "bin/",
+ "dist/"
+ ]
+}
diff --git a/javascript/packages/turbo-lint/project.json b/javascript/packages/turbo-lint/project.json
new file mode 100644
index 000000000..897c4e95c
--- /dev/null
+++ b/javascript/packages/turbo-lint/project.json
@@ -0,0 +1,33 @@
+{
+ "name": "turbo-lint",
+ "$schema": "../../../node_modules/nx/schemas/project-schema.json",
+ "sourceRoot": "javascript/packages/turbo-lint/src",
+ "projectType": "library",
+ "targets": {
+ "build": {
+ "executor": "nx:run-script",
+ "options": {
+ "script": "build"
+ },
+ "dependsOn": [
+ "@herb-tools/core:build",
+ "@herb-tools/node-wasm:build",
+ "@herb-tools/linter:build",
+ "@herb-tools/highlighter:build"
+ ]
+ },
+ "test": {
+ "executor": "nx:run-script",
+ "options": {
+ "script": "test"
+ }
+ },
+ "clean": {
+ "executor": "nx:run-script",
+ "options": {
+ "script": "clean"
+ }
+ }
+ },
+ "tags": []
+}
diff --git a/javascript/packages/turbo-lint/rollup.config.mjs b/javascript/packages/turbo-lint/rollup.config.mjs
new file mode 100644
index 000000000..32bbe5254
--- /dev/null
+++ b/javascript/packages/turbo-lint/rollup.config.mjs
@@ -0,0 +1,85 @@
+import typescript from "@rollup/plugin-typescript"
+import { nodeResolve } from "@rollup/plugin-node-resolve"
+import json from "@rollup/plugin-json"
+import commonjs from "@rollup/plugin-commonjs"
+
+// Bundle the CLI entry point into a single CommonJS file.
+// Exclude Node built-in so they remain as externals.
+const external = [
+ "path",
+ "url",
+ "fs",
+ "module",
+]
+
+function isExternal(id) {
+ return (
+ external.includes(id) ||
+ external.some((pkg) => id === pkg || id.startsWith(pkg + "/"))
+ )
+}
+
+export default [
+ // CLI entry point (CommonJS)
+ {
+ input: "src/turbo-lint.ts",
+ output: {
+ file: "dist/turbo-lint.js",
+ format: "cjs",
+ sourcemap: true,
+ },
+ external: isExternal,
+ plugins: [
+ nodeResolve(),
+ commonjs(),
+ json(),
+ typescript({
+ tsconfig: "./tsconfig.json",
+ rootDir: "src/",
+ module: "esnext",
+ }),
+ ],
+ },
+
+ // Library exports (ESM)
+ {
+ input: "src/index.ts",
+ output: {
+ file: "dist/index.js",
+ format: "esm",
+ sourcemap: true,
+ },
+ external: ["@herb-tools/core", "@herb-tools/linter", "@herb-tools/node-wasm", "turbo-parser"],
+ plugins: [
+ nodeResolve(),
+ json(),
+ typescript({
+ tsconfig: "./tsconfig.json",
+ declaration: true,
+ declarationDir: "./dist/types",
+ rootDir: "src/",
+ }),
+ ],
+ },
+
+ // Library exports (CommonJS)
+ {
+ input: "src/index.ts",
+ external: ["@herb-tools/core", "@herb-tools/linter", "@herb-tools/node-wasm", "turbo-parser"],
+ output: {
+ file: "dist/index.cjs",
+ format: "cjs",
+ sourcemap: true,
+ },
+ plugins: [
+ nodeResolve(),
+ commonjs(),
+ json(),
+ typescript({
+ tsconfig: "./tsconfig.json",
+ rootDir: "src/",
+ module: "esnext",
+ }),
+ ],
+ },
+]
diff --git a/javascript/packages/turbo-lint/src/cli.ts b/javascript/packages/turbo-lint/src/cli.ts
new file mode 100644
index 000000000..d072c6287
--- /dev/null
+++ b/javascript/packages/turbo-lint/src/cli.ts
@@ -0,0 +1,38 @@
+import { Herb } from "@herb-tools/node-wasm"
+import { TurboLinter } from "./linter.js"
+import { defaultRules } from "./default-rules.js"
+
+export class CLI {
+ async run() {
+ console.log("Turbo Lint CLI")
+
+ const args = process.argv.slice(2)
+
+ if (args.length === 0) {
+ console.log("Usage: turbo-lint ")
+ process.exit(1)
+ }
+
+ const filePath = args[0]
+
+ // Load the Herb backend
+ await Herb.load()
+
+ const linter = new TurboLinter(Herb, defaultRules)
+
+ try {
+ const result = await linter.lintFile(filePath)
+
+ console.log(`\nFound ${result.errors} errors and ${result.warnings} warnings\n`)
+
+ for (const offense of result.offenses) {
+ console.log(`${filePath} - ${offense.severity}: ${offense.message} (${offense.rule})`)
+ }
+
+ process.exit(result.errors > 0 ? 1 : 0)
+ } catch (error) {
+ console.error("Error:", error)
+ process.exit(1)
+ }
+ }
+}
diff --git a/javascript/packages/turbo-lint/src/default-rules.ts b/javascript/packages/turbo-lint/src/default-rules.ts
new file mode 100644
index 000000000..45f2f0b23
--- /dev/null
+++ b/javascript/packages/turbo-lint/src/default-rules.ts
@@ -0,0 +1,10 @@
+import { HtmlTurboPermanentRule } from "./rules/html-turbo-permanent.js"
+
+import type { RuleClass } from "./types.js"
+
+/**
+ * Default set of Turbo linting rules
+ */
+export const defaultRules: RuleClass[] = [
+ HtmlTurboPermanentRule
+]
\ No newline at end of file
diff --git a/javascript/packages/turbo-lint/src/index.ts b/javascript/packages/turbo-lint/src/index.ts
new file mode 100644
index 000000000..8fcaae0ec
--- /dev/null
+++ b/javascript/packages/turbo-lint/src/index.ts
@@ -0,0 +1,5 @@
+export { TurboLinter } from "./linter.js"
+export { defaultRules } from "./default-rules.js"
+
+export * from "./types.js"
+export * from "./rules/index.js"
\ No newline at end of file
diff --git a/javascript/packages/turbo-lint/src/linter.ts b/javascript/packages/turbo-lint/src/linter.ts
new file mode 100644
index 000000000..8640b0b5b
--- /dev/null
+++ b/javascript/packages/turbo-lint/src/linter.ts
@@ -0,0 +1,68 @@
+import { defaultRules } from "./default-rules.js"
+
+import type { RuleClass, LintResult, LintContext, LintOffense } from "./types.js"
+import type { HerbBackend, ParseResult } from "@herb-tools/core"
+
+export class TurboLinter {
+ private herb: HerbBackend
+ private rules: RuleClass[]
+
+ /**
+ * Creates a new TurboLinter instance.
+ * @param herb - The Herb backend instance for parsing HTML
+ * @param rules - Array of rule classes to use
+ */
+ constructor(herb: HerbBackend, rules: RuleClass[] = defaultRules) {
+ this.herb = herb
+ this.rules = rules
+ }
+
+ /**
+ * Lint source code with Turbo-specific context
+ * @param source - The source code to lint (HTML template)
+ * @param context - Optional context for linting
+ */
+ lint(source: string, context?: Partial): LintResult {
+ const parseResult = this.herb.parse(source) as ParseResult
+
+ const allOffenses: LintOffense[] = []
+
+ // Instantiate and run each rule
+ for (const RuleConstructor of this.rules) {
+ const rule = new RuleConstructor()
+ const offenses = rule.check(parseResult, context)
+ allOffenses.push(...offenses)
+ }
+
+ // Count offense severities
+ const errors = allOffenses.filter(o => o.severity === "error").length
+ const warnings = allOffenses.filter(o => o.severity === "warning").length
+
+ return {
+ offenses: allOffenses,
+ errors,
+ warnings
+ }
+ }
+
+ /**
+ * Get the number of rules loaded in the linter
+ */
+ getRuleCount(): number {
+ return this.rules.length
+ }
+
+ /**
+ * Lint an HTML file
+ */
+ async lintFile(filePath: string): Promise {
+ const fs = await import("fs")
+ const source = fs.readFileSync(filePath, "utf-8")
+
+ const context: Partial = {
+ fileName: filePath
+ }
+
+ return this.lint(source, context)
+ }
+}
diff --git a/javascript/packages/turbo-lint/src/rules/html-turbo-permanent.ts b/javascript/packages/turbo-lint/src/rules/html-turbo-permanent.ts
new file mode 100644
index 000000000..9944e8558
--- /dev/null
+++ b/javascript/packages/turbo-lint/src/rules/html-turbo-permanent.ts
@@ -0,0 +1,47 @@
+import { getTagName, hasAttribute, getAttributeValue, findAttributeByName, getAttributes } from "./rule-utils.js"
+import { ParserRule } from "../types.js"
+import { Visitor } from "@herb-tools/core"
+
+import type { HTMLOpenTagNode, ParseResult } from "@herb-tools/core"
+import type { LintContext, LintOffense } from "../types.js"
+
+class HTMLTurboPermanentVisitor extends Visitor {
+ public readonly offenses: LintOffense[] = []
+ private ruleName: string
+
+ constructor(ruleName: string) {
+ super()
+ this.ruleName = ruleName
+ }
+
+ visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
+ this.checkTurboPermanentAttribute(node)
+ super.visitHTMLOpenTagNode(node)
+ }
+
+ private checkTurboPermanentAttribute(node: HTMLOpenTagNode): void {
+ const attribute = findAttributeByName(getAttributes(node), "data-turbo-permanent")
+ if (!attribute) return
+
+ if (getAttributeValue(attribute) !== null) {
+ this.offenses.push({
+ rule: this.ruleName,
+ code: this.ruleName,
+ source: "Turbo Linter",
+ message: 'Attribute `data-turbo-permanent` should not contain any value',
+ location: node.tag_name!.location,
+ severity: "error"
+ })
+ }
+ }
+}
+
+export class HtmlTurboPermanentRule extends ParserRule {
+ name = "html-turbo-permanent"
+
+ check(result: ParseResult, context?: Partial): LintOffense[] {
+ const visitor = new HTMLTurboPermanentVisitor(this.name)
+ visitor.visit(result.value)
+ return visitor.offenses
+ }
+}
diff --git a/javascript/packages/turbo-lint/src/rules/index.ts b/javascript/packages/turbo-lint/src/rules/index.ts
new file mode 100644
index 000000000..653553a1a
--- /dev/null
+++ b/javascript/packages/turbo-lint/src/rules/index.ts
@@ -0,0 +1 @@
+export { HtmlTurboPermanentRule } from "./html-turbo-permanent.js"
\ No newline at end of file
diff --git a/javascript/packages/turbo-lint/src/rules/rule-utils.ts b/javascript/packages/turbo-lint/src/rules/rule-utils.ts
new file mode 100644
index 000000000..689defc17
--- /dev/null
+++ b/javascript/packages/turbo-lint/src/rules/rule-utils.ts
@@ -0,0 +1,161 @@
+import {
+ getStaticAttributeName,
+ hasDynamicAttributeName as hasNodeDynamicAttributeName,
+ getCombinedAttributeName,
+ hasERBOutput,
+ getStaticContentFromNodes,
+ hasStaticContent,
+ isEffectivelyStatic,
+ getValidatableStaticContent
+} from "@herb-tools/core"
+
+import type {
+ ERBContentNode,
+ HTMLAttributeNameNode,
+ HTMLAttributeNode,
+ HTMLAttributeValueNode,
+ HTMLElementNode,
+ HTMLOpenTagNode,
+ LiteralNode,
+ LexResult,
+ Token,
+ Node
+} from "@herb-tools/core"
+
+import type * as Nodes from "@herb-tools/core"
+
+/**
+ * Gets attributes from an HTMLOpenTagNode
+ */
+export function getAttributes(node: HTMLOpenTagNode): HTMLAttributeNode[] {
+ return node.children.filter(node => node.type === "AST_HTML_ATTRIBUTE_NODE") as HTMLAttributeNode[]
+}
+
+/**
+ * Gets the tag name from an HTML tag node (lowercased)
+ */
+export function getTagName(node: HTMLElementNode | HTMLOpenTagNode | null | undefined): string | null {
+ if (!node) return null
+
+ return node.tag_name?.value.toLowerCase() || null
+}
+
+/**
+ * Gets the attribute name from an HTMLAttributeNode (lowercased)
+ * Returns null if the attribute name contains dynamic content (ERB)
+ */
+export function getAttributeName(attributeNode: HTMLAttributeNode, lowercase = true): string | null {
+ if (attributeNode.name?.type === "AST_HTML_ATTRIBUTE_NAME_NODE") {
+ const nameNode = attributeNode.name as HTMLAttributeNameNode
+ const staticName = getStaticAttributeName(nameNode)
+
+ if (!lowercase) return staticName
+
+ return staticName ? staticName.toLowerCase() : null
+ }
+
+ return null
+}
+
+/**
+ * Checks if an attribute has a dynamic (ERB-containing) name
+ */
+export function hasDynamicAttributeName(attributeNode: HTMLAttributeNode): boolean {
+ if (attributeNode.name?.type === "AST_HTML_ATTRIBUTE_NAME_NODE") {
+ const nameNode = attributeNode.name as HTMLAttributeNameNode
+ return hasNodeDynamicAttributeName(nameNode)
+ }
+
+ return false
+}
+
+/**
+ * Gets the combined string representation of an attribute name (for debugging)
+ * This includes both static content and ERB syntax
+ */
+export function getCombinedAttributeNameString(attributeNode: HTMLAttributeNode): string {
+ if (attributeNode.name?.type === "AST_HTML_ATTRIBUTE_NAME_NODE") {
+ const nameNode = attributeNode.name as HTMLAttributeNameNode
+
+ return getCombinedAttributeName(nameNode)
+ }
+
+ return ""
+}
+
+/**
+ * Gets the attribute value content from an HTMLAttributeValueNode
+ */
+export function getAttributeValue(attributeNode: HTMLAttributeNode): string | null {
+ const valueNode: HTMLAttributeValueNode | null = attributeNode.value as HTMLAttributeValueNode
+
+ if (valueNode === null) return null
+
+ if (valueNode.type !== "AST_HTML_ATTRIBUTE_VALUE_NODE" || !valueNode.children?.length) {
+ return null
+ }
+
+ let result = ""
+
+ for (const child of valueNode.children) {
+ switch (child.type) {
+ case "AST_ERB_CONTENT_NODE": {
+ const erbNode = child as ERBContentNode
+
+ if (erbNode.content) {
+ result += `${erbNode.tag_opening?.value}${erbNode.content.value}${erbNode.tag_closing?.value}`
+ }
+
+ break
+ }
+
+ case "AST_LITERAL_NODE": {
+ result += (child as LiteralNode).content
+ break
+ }
+ }
+ }
+
+ return result
+}
+
+/**
+ * Checks if an attribute has a value
+ */
+export function hasAttributeValue(attributeNode: HTMLAttributeNode): boolean {
+ return attributeNode.value?.type === "AST_HTML_ATTRIBUTE_VALUE_NODE"
+}
+
+/**
+ * Finds an attribute by name in a list of attributes
+ */
+export function findAttributeByName(attributes: Node[], attributeName: string): HTMLAttributeNode | null {
+ for (const child of attributes) {
+ if (child.type === "AST_HTML_ATTRIBUTE_NODE") {
+ const attributeNode = child as HTMLAttributeNode
+ const name = getAttributeName(attributeNode)
+
+ if (name === attributeName.toLowerCase()) {
+ return attributeNode
+ }
+ }
+ }
+
+ return null
+}
+
+/**
+ * Checks if a tag has a specific attribute
+ */
+export function hasAttribute(node: HTMLOpenTagNode, attributeName: string): boolean {
+ return getAttribute(node, attributeName) !== null
+}
+
+/**
+ * Checks if a tag has a specific attribute
+ */
+export function getAttribute(node: HTMLOpenTagNode, attributeName: string): HTMLAttributeNode | null {
+ const attributes = getAttributes(node)
+
+ return findAttributeByName(attributes, attributeName)
+}
\ No newline at end of file
diff --git a/javascript/packages/turbo-lint/src/turbo-lint.ts b/javascript/packages/turbo-lint/src/turbo-lint.ts
new file mode 100644
index 000000000..dd8f1b5c3
--- /dev/null
+++ b/javascript/packages/turbo-lint/src/turbo-lint.ts
@@ -0,0 +1,6 @@
+#!/usr/bin/env node
+
+import { CLI } from "./cli.js"
+
+const cli = new CLI()
+cli.run()
diff --git a/javascript/packages/turbo-lint/src/types.ts b/javascript/packages/turbo-lint/src/types.ts
new file mode 100644
index 000000000..9bc214666
--- /dev/null
+++ b/javascript/packages/turbo-lint/src/types.ts
@@ -0,0 +1,32 @@
+import type { Herb } from "@herb-tools/node-wasm"
+import type { ParseResult, Location, Diagnostic } from "@herb-tools/core"
+
+export type HerbType = typeof Herb
+
+export type LintSeverity = "error" | "warning" | "info" | "hint"
+
+export interface LintContext {
+ fileName?: string
+}
+
+export interface LintOffense extends Diagnostic {
+ rule: string
+ severity: LintSeverity
+}
+
+export interface LintResult {
+ offenses: LintOffense[]
+ errors: number
+ warnings: number
+}
+
+export interface LinterConfig {
+ rules?: Record
+}
+
+export abstract class ParserRule {
+ abstract name: string
+ abstract check(result: ParseResult, context?: Partial): LintOffense[]
+}
+
+export type RuleClass = new () => ParserRule
\ No newline at end of file
diff --git a/javascript/packages/turbo-lint/test/basic.test.ts b/javascript/packages/turbo-lint/test/basic.test.ts
new file mode 100644
index 000000000..82ecc2a90
--- /dev/null
+++ b/javascript/packages/turbo-lint/test/basic.test.ts
@@ -0,0 +1,46 @@
+import { describe, it, expect, beforeAll } from "vitest"
+import { Herb } from "@herb-tools/node-wasm"
+import { TurboLinter, defaultRules } from "../src/index.js"
+
+describe("TurboLinter basic test", () => {
+ beforeAll(async () => {
+ await Herb.load()
+ })
+
+ it("should create linter instance", () => {
+ const linter = new TurboLinter(Herb, defaultRules)
+ expect(linter).toBeDefined()
+ expect(linter.getRuleCount()).toBeGreaterThan(0)
+ })
+
+ it("should lint turbo templates", () => {
+ const linter = new TurboLinter(Herb, defaultRules)
+
+ const source = `
+
+ 1 item
+
+ `
+
+ const result = linter.lint(source)
+ expect(result).toBeDefined()
+ expect(result.offenses).toBeDefined()
+ expect(result.offenses).toHaveLength(0)
+ })
+
+ it("should detect turbo-permanent issues", () => {
+ const linter = new TurboLinter(Herb, defaultRules)
+
+ const source = `
+
+ 1 item
+
+ `
+
+ const result = linter.lint(source)
+ expect(result).toBeDefined()
+ expect(result.offenses).toBeDefined()
+ expect(result.offenses).toHaveLength(1)
+ expect(result.offenses[0].rule).toBe("html-turbo-permanent")
+ })
+})
diff --git a/javascript/packages/turbo-lint/test/helpers/linter-test-helper.ts b/javascript/packages/turbo-lint/test/helpers/linter-test-helper.ts
new file mode 100644
index 000000000..3f2bac811
--- /dev/null
+++ b/javascript/packages/turbo-lint/test/helpers/linter-test-helper.ts
@@ -0,0 +1,271 @@
+import { beforeAll, afterEach, expect } from "vitest"
+
+import { Herb } from "@herb-tools/node-wasm"
+import { TurboLinter } from "../../src/linter.js"
+
+import type { RuleClass } from "../../src/types.js"
+
+interface ExpectedLocation {
+ line?: number
+ column?: number
+}
+
+type LocationInput = ExpectedLocation | [number, number] | [number]
+
+interface ExpectedOffense {
+ message: string
+ location?: ExpectedLocation
+}
+
+interface TestOptions {
+ context?: any
+ allowInvalidSyntax?: boolean
+}
+
+interface LinterTestHelpers {
+ expectNoOffenses: (html: string, options?: any | TestOptions) => void
+ expectWarning: (message: string, location?: LocationInput) => void
+ expectError: (message: string, location?: LocationInput) => void
+ assertOffenses: (html: string, options?: any | TestOptions) => void
+}
+
+/**
+ * Creates a test helper for linter rules that reduces boilerplate in tests.
+ */
+export function createLinterTest(rules: RuleClass | RuleClass[]): LinterTestHelpers {
+ const expectedWarnings: ExpectedOffense[] = []
+ const expectedErrors: ExpectedOffense[] = []
+ let hasAsserted = false
+
+ const ruleClasses = Array.isArray(rules) ? rules : [rules]
+ const primaryRuleClass = ruleClasses[0]
+ const ruleInstance = new primaryRuleClass()
+ const isParserNoErrorsRule = ruleInstance.name === "parser-no-errors"
+
+ beforeAll(async () => {
+ await Herb.load()
+ })
+
+ afterEach(() => {
+ if (!hasAsserted && (expectedWarnings.length > 0 || expectedErrors.length > 0)) {
+ const pendingCount = expectedWarnings.length + expectedErrors.length
+
+ throw new Error(
+ `Test has ${pendingCount} pending expectation(s) that were never asserted. ` +
+ `Did you forget to call assertOffenses() or expectNoOffenses()?`
+ )
+ }
+
+ expectedWarnings.length = 0
+ expectedErrors.length = 0
+ hasAsserted = false
+ })
+
+ const expectNoOffenses = (html: string, options?: any | TestOptions) => {
+ if (expectedWarnings.length > 0 || expectedErrors.length > 0) {
+ throw new Error(
+ "Cannot call expectNoOffenses() after registering expectations with expectWarning() or expectError()"
+ )
+ }
+
+ hasAsserted = true
+
+ const context = options?.context ?? options
+ const allowInvalidSyntax = options?.allowInvalidSyntax ?? false
+
+ if (!isParserNoErrorsRule) {
+ const parseResult = Herb.parse(html, { track_whitespace: true })
+ const parserErrors = parseResult.recursiveErrors()
+
+ if (allowInvalidSyntax && parserErrors.length === 0) {
+ throw new Error(
+ `Test has 'allowInvalidSyntax: true' but the HTML is actually valid.\n` +
+ `Remove the 'allowInvalidSyntax' option since the HTML parses without errors.\n` +
+ `Source:\n${html}`
+ )
+ }
+
+ if (!allowInvalidSyntax && parserErrors.length > 0) {
+ const formattedErrors = parserErrors.map(error => ` - ${error.message} (${error.type}) at ${error.location.start.line}:${error.location.start.column}`).join('\n')
+
+ throw new Error(
+ `Test HTML has parser errors. Fix the HTML before testing the linter rule.\n` +
+ `Source:\n${html}\n\n` +
+ `Parser errors:\n${formattedErrors}`
+ )
+ }
+ }
+
+ const linter = new TurboLinter(Herb, ruleClasses)
+ const lintResult = linter.lint(html, context)
+
+ const ruleName = ruleInstance.name
+ const primaryOffenses = lintResult.offenses.filter(offense => offense.rule === ruleName)
+
+ expect(primaryOffenses).toHaveLength(0)
+ }
+
+ const normalizeLocation = (location?: LocationInput): ExpectedLocation | undefined => {
+ if (!location) return undefined
+
+ if (Array.isArray(location)) {
+ return location.length === 2
+ ? { line: location[0], column: location[1] }
+ : { line: location[0] }
+ }
+ return location
+ }
+
+ const expectWarning = (message: string, location?: LocationInput) => {
+ expectedWarnings.push({ message, location: normalizeLocation(location) })
+ }
+
+ const expectError = (message: string, location?: LocationInput) => {
+ expectedErrors.push({ message, location: normalizeLocation(location) })
+ }
+
+ const assertOffenses = (html: string, options?: any | TestOptions) => {
+ if (expectedWarnings.length === 0 && expectedErrors.length === 0) {
+ throw new Error(
+ "Cannot call assertOffenses() with no expectations. Use expectNoOffenses() instead."
+ )
+ }
+
+ hasAsserted = true
+
+ const context = options?.context ?? options
+ const allowInvalidSyntax = options?.allowInvalidSyntax ?? false
+
+ if (!isParserNoErrorsRule) {
+ const parseResult = Herb.parse(html, { track_whitespace: true })
+ const parserErrors = parseResult.recursiveErrors()
+
+ if (allowInvalidSyntax && parserErrors.length === 0) {
+ throw new Error(
+ `Test has 'allowInvalidSyntax: true' but the HTML is actually valid.\n` +
+ `Remove the 'allowInvalidSyntax' option since the HTML parses without errors.\n` +
+ `Source:\n${html}`
+ )
+ }
+
+ if (!allowInvalidSyntax && parserErrors.length > 0) {
+ const formattedErrors = parserErrors.map(error => ` - ${error.message} (${error.type}) at ${error.location.start.line}:${error.location.start.column}`).join('\n')
+
+ throw new Error(
+ `Test HTML has parser errors. Fix the HTML before testing the linter rule.\n` +
+ `Source:\n${html}\n\n` +
+ `Parser errors:\n${formattedErrors}`
+ )
+ }
+ }
+
+ const linter = new TurboLinter(Herb, ruleClasses)
+ const lintResult = linter.lint(html, context)
+ const ruleName = ruleInstance.name
+
+ const primaryOffenses = lintResult.offenses.filter(o => o.rule === ruleName)
+ const primaryErrors = primaryOffenses.filter(o => o.severity === "error")
+ const primaryWarnings = primaryOffenses.filter(o => o.severity === "warning")
+
+ if (primaryErrors.length !== expectedErrors.length) {
+ throw new Error(
+ `Expected ${expectedErrors.length} error(s) from rule "${ruleName}" but found ${primaryErrors.length}.\n` +
+ `Expected:\n${expectedErrors.map(e => ` - "${e.message}"`).join('\n')}\n` +
+ `Actual:\n${primaryErrors.map(o => ` - "${o.message}" at ${o.location.start.line}:${o.location.start.column}`).join('\n')}`
+ )
+ }
+
+ if (primaryWarnings.length !== expectedWarnings.length) {
+ throw new Error(
+ `Expected ${expectedWarnings.length} warning(s) from rule "${ruleName}" but found ${primaryWarnings.length}.\n` +
+ `Expected:\n${expectedWarnings.map(w => ` - "${w.message}"`).join('\n')}\n` +
+ `Actual:\n${primaryWarnings.map(o => ` - "${o.message}" at ${o.location.start.line}:${o.location.start.column}`).join('\n')}`
+ )
+ }
+
+ primaryOffenses.forEach(offense => {
+ expect(offense.rule).toBe(ruleName)
+ })
+
+ const actualErrors = primaryErrors
+ const actualWarnings = primaryWarnings
+
+ matchOffenses(expectedErrors, actualErrors, "error")
+ matchOffenses(expectedWarnings, actualWarnings, "warning")
+
+ expectedWarnings.length = 0
+ expectedErrors.length = 0
+ }
+
+ return {
+ expectNoOffenses,
+ expectWarning,
+ expectError,
+ assertOffenses
+ }
+}
+
+/**
+ * Matches expected offenses to actual offenses in an order-independent way
+ */
+function matchOffenses(
+ expected: ExpectedOffense[],
+ actual: any[],
+ severity: "error" | "warning"
+) {
+ const unmatched = [...expected]
+ const unmatchedActual = [...actual]
+
+ for (const actualOffense of actual) {
+ const matchIndex = unmatched.findIndex(exp => {
+ if (exp.message !== actualOffense.message) {
+ return false
+ }
+
+ if (exp.location?.line !== undefined && exp.location.line !== actualOffense.location.start.line) {
+ return false
+ }
+
+ if (exp.location?.column !== undefined && exp.location.column !== actualOffense.location.start.column) {
+ return false
+ }
+
+ return true
+ })
+
+ if (matchIndex !== -1) {
+ unmatched.splice(matchIndex, 1)
+ const actualIndex = unmatchedActual.findIndex(o => o === actualOffense)
+ if (actualIndex !== -1) {
+ unmatchedActual.splice(actualIndex, 1)
+ }
+ }
+ }
+
+ if (unmatched.length > 0 || unmatchedActual.length > 0) {
+ const errors: string[] = []
+
+ if (unmatched.length > 0) {
+ errors.push(`Expected ${severity}(s) not found:`)
+ unmatched.forEach(exp => {
+ const location = exp.location?.line !== undefined
+ ? exp.location?.column !== undefined
+ ? ` at ${exp.location.line}:${exp.location.column}`
+ : ` at line ${exp.location.line}`
+ : ""
+ errors.push(` - "${exp.message}"${location}`)
+ })
+ }
+
+ if (unmatchedActual.length > 0) {
+ errors.push(`Unexpected ${severity}(s) found:`)
+ unmatchedActual.forEach(offense => {
+ errors.push(
+ ` - "${offense.message}" at ${offense.location.start.line}:${offense.location.start.column}`
+ )
+ })
+ }
+
+ throw new Error(errors.join("\n"))
+ }
+}
\ No newline at end of file
diff --git a/javascript/packages/turbo-lint/test/rules/html-turbo-permanent.test.ts b/javascript/packages/turbo-lint/test/rules/html-turbo-permanent.test.ts
new file mode 100644
index 000000000..c0d7e306a
--- /dev/null
+++ b/javascript/packages/turbo-lint/test/rules/html-turbo-permanent.test.ts
@@ -0,0 +1,26 @@
+import { describe, test } from "vitest"
+import { HtmlTurboPermanentRule } from "../../src/rules/html-turbo-permanent.js"
+import { createLinterTest } from "../helpers/linter-test-helper.js"
+
+const { expectNoOffenses, expectError, assertOffenses } = createLinterTest(HtmlTurboPermanentRule)
+
+describe("html-turbo-permanent", () => {
+ test("passes when no explicit value is given", () => {
+ expectNoOffenses('1 item
')
+ })
+
+ test("fails when string true value is given", () => {
+ expectError('Attribute `data-turbo-permanent` should not contain any value')
+ assertOffenses('1 item
')
+ })
+
+ test("fails when passing permanent=false", () => {
+ expectError('Attribute `data-turbo-permanent` should not contain any value')
+ assertOffenses('1 item
')
+ })
+
+ test("fails when other value is passed", () => {
+ expectError('Attribute `data-turbo-permanent` should not contain any value')
+ assertOffenses('1 item
')
+ })
+})
diff --git a/javascript/packages/turbo-lint/test/test.html b/javascript/packages/turbo-lint/test/test.html
new file mode 100644
index 000000000..f0cf79b7e
--- /dev/null
+++ b/javascript/packages/turbo-lint/test/test.html
@@ -0,0 +1 @@
+Test
diff --git a/javascript/packages/turbo-lint/test/valid.html b/javascript/packages/turbo-lint/test/valid.html
new file mode 100644
index 000000000..8e30faca1
--- /dev/null
+++ b/javascript/packages/turbo-lint/test/valid.html
@@ -0,0 +1 @@
+Valid
diff --git a/javascript/packages/turbo-lint/tsconfig.json b/javascript/packages/turbo-lint/tsconfig.json
new file mode 100644
index 000000000..b36bf65cd
--- /dev/null
+++ b/javascript/packages/turbo-lint/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "lib": ["ES2022"],
+ "outDir": "./dist",
+ "declaration": true,
+ "declarationDir": "./dist/types",
+ "esModuleInterop": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "moduleDetection": "force",
+ "sourceMap": true
+ },
+ "include": ["src/**/*", "types/**/*"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/workspace.json b/workspace.json
index d828e4849..562cc41e8 100644
--- a/workspace.json
+++ b/workspace.json
@@ -15,6 +15,7 @@
"@herb-tools/rewriter": "javascript/packages/rewriter",
"@herb-tools/tailwind-class-sorter": "javascript/packages/tailwind-class-sorter",
"stimulus-lint": "javascript/packages/stimulus-lint",
+ "turbo-lint": "javascript/packages/turbo-lint",
"herb-language-server": "javascript/packages/herb-language-server",
"vscode": "javascript/packages/vscode",
"docs": "./docs",