Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .github/labeler.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
39 changes: 39 additions & 0 deletions javascript/packages/turbo-lint/README.md
Original file line number Diff line number Diff line change
@@ -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)
3 changes: 3 additions & 0 deletions javascript/packages/turbo-lint/bin/turbo-lint
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/usr/bin/env node

import("../dist/turbo-lint.js")
7 changes: 7 additions & 0 deletions javascript/packages/turbo-lint/docs/rules/README.md
Original file line number Diff line number Diff line change
@@ -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
32 changes: 32 additions & 0 deletions javascript/packages/turbo-lint/docs/rules/html-turbo-permanent.md
Original file line number Diff line number Diff line change
@@ -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
<div id="cart-counter" data-turbo-permanent>1 item</div>
```

### 🚫 Bad

```html
<div id="cart-counter" data-turbo-permanent="true">1 item</div>
<div id="cart-counter" data-turbo-permanent="false">1 item</div>
<div id="cart-counter" data-turbo-permanent="foo">1 item</div>
```

## 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)
50 changes: 50 additions & 0 deletions javascript/packages/turbo-lint/package.json
Original file line number Diff line number Diff line change
@@ -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/"
]
}
33 changes: 33 additions & 0 deletions javascript/packages/turbo-lint/project.json
Original file line number Diff line number Diff line change
@@ -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": []
}
85 changes: 85 additions & 0 deletions javascript/packages/turbo-lint/rollup.config.mjs
Original file line number Diff line number Diff line change
@@ -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",
}),
],
},
]
38 changes: 38 additions & 0 deletions javascript/packages/turbo-lint/src/cli.ts
Original file line number Diff line number Diff line change
@@ -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 <file>")
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)
}
}
}
10 changes: 10 additions & 0 deletions javascript/packages/turbo-lint/src/default-rules.ts
Original file line number Diff line number Diff line change
@@ -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
]
5 changes: 5 additions & 0 deletions javascript/packages/turbo-lint/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export { TurboLinter } from "./linter.js"
export { defaultRules } from "./default-rules.js"

export * from "./types.js"
export * from "./rules/index.js"
68 changes: 68 additions & 0 deletions javascript/packages/turbo-lint/src/linter.ts
Original file line number Diff line number Diff line change
@@ -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<LintContext>): 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<LintResult> {
const fs = await import("fs")
const source = fs.readFileSync(filePath, "utf-8")

const context: Partial<LintContext> = {
fileName: filePath
}

return this.lint(source, context)
}
}
Loading
Loading