diff --git a/javascript/packages/formatter/README.md b/javascript/packages/formatter/README.md
index 8bcda4789..25ba384ee 100644
--- a/javascript/packages/formatter/README.md
+++ b/javascript/packages/formatter/README.md
@@ -244,6 +244,34 @@ herb-format --force app/views/excluded-file.html.erb
When using `--force` on an excluded file, the formatter will show a warning but proceed with formatting.
+### Disabling Formatting for Entire Files
+
+You can disable formatting for an entire file by adding the `ignore` directive anywhere in your template:
+
+```erb
+<%# herb:formatter ignore %>
+```
+
+**Example:**
+
+:::code-group
+```erb [ignored.html.erb]
+<%# herb:formatter ignore %>
+
+
This entire file will not be formatted
+```
+
+```erb [formatted.html.erb]
+
+
This file will be formatted
+
+```
+:::
+
+::: warning Important
+The `<%# herb:formatter ignore %>` directive must be an exact match. Extra text or spacing will prevent it from working.
+:::
+
## Rewriters
The formatter supports **rewriters** that allow you to transform templates before and after formatting.
diff --git a/javascript/packages/formatter/src/format-ignore.ts b/javascript/packages/formatter/src/format-ignore.ts
new file mode 100644
index 000000000..9f117d9ed
--- /dev/null
+++ b/javascript/packages/formatter/src/format-ignore.ts
@@ -0,0 +1,51 @@
+import { isERBCommentNode } from "@herb-tools/core"
+import { Visitor } from "@herb-tools/core"
+
+import type { Node, ERBContentNode } from "@herb-tools/core"
+
+const HERB_FORMATTER_PREFIX = "herb:formatter"
+const HERB_FORMATTER_IGNORE_PREFIX = `${HERB_FORMATTER_PREFIX} ignore`
+
+/**
+ * Check if an ERB content node is a herb:formatter ignore comment
+ */
+export function isHerbFormatterIgnoreComment(node: Node): boolean {
+ if (!isERBCommentNode(node)) return false
+
+ const content = node?.content?.value || ""
+
+ return content.trim() === HERB_FORMATTER_IGNORE_PREFIX
+}
+
+/**
+ * Check if the document contains a herb:formatter ignore directive anywhere.
+ */
+export function hasFormatterIgnoreDirective(node: Node): boolean {
+ const detector = new FormatterIgnoreDetector()
+ detector.visit(node)
+ return detector.hasIgnoreDirective
+}
+
+/**
+* Visitor that detects if the AST contains a herb:formatter ignore directive.
+*/
+class FormatterIgnoreDetector extends Visitor {
+ public hasIgnoreDirective = false
+
+ visitNode(node: Node): void {
+ if (this.hasIgnoreDirective) return
+
+ this.visitChildNodes(node)
+ }
+
+ visitERBContentNode(node: ERBContentNode): void {
+ if (isHerbFormatterIgnoreComment(node)) {
+ this.hasIgnoreDirective = true
+ return
+ }
+
+ if (this.hasIgnoreDirective) return
+
+ this.visitChildNodes(node)
+ }
+}
diff --git a/javascript/packages/formatter/src/formatter.ts b/javascript/packages/formatter/src/formatter.ts
index 7230765f4..261c32491 100644
--- a/javascript/packages/formatter/src/formatter.ts
+++ b/javascript/packages/formatter/src/formatter.ts
@@ -2,13 +2,13 @@ import { FormatPrinter } from "./format-printer.js"
import { isScaffoldTemplate } from "./scaffold-template-detector.js"
import { resolveFormatOptions } from "./options.js"
+import { hasFormatterIgnoreDirective } from "./format-ignore.js"
import type { Config } from "@herb-tools/config"
import type { RewriteContext } from "@herb-tools/rewriter"
import type { HerbBackend, ParseResult } from "@herb-tools/core"
import type { FormatOptions } from "./options.js"
-
/**
* Formatter uses a Herb Backend to parse the source and then
* formats the resulting AST into a well-indented, wrapped string.
@@ -61,6 +61,7 @@ export class Formatter {
if (result.failed) return source
if (isScaffoldTemplate(result)) return source
+ if (hasFormatterIgnoreDirective(result.value)) return source
const resolvedOptions = resolveFormatOptions({ ...this.options, ...options })
diff --git a/javascript/packages/formatter/test/herb-formatter-ignore.test.ts b/javascript/packages/formatter/test/herb-formatter-ignore.test.ts
new file mode 100644
index 000000000..8224eb47b
--- /dev/null
+++ b/javascript/packages/formatter/test/herb-formatter-ignore.test.ts
@@ -0,0 +1,120 @@
+import { describe, test, expect, beforeAll } from "vitest"
+import { Herb } from "@herb-tools/node-wasm"
+import { Formatter } from "../src"
+import dedent from "dedent"
+
+let formatter: Formatter
+
+describe("herb:formatter ignore directive", () => {
+ beforeAll(async () => {
+ await Herb.load()
+ formatter = new Formatter(Herb, {
+ indentWidth: 2,
+ maxLineLength: 80,
+ })
+ })
+
+ test("should ignore formatting when directive is at top of file", () => {
+ const source = dedent`
+ <%# herb:formatter ignore %>
+
+ Badly formatted content
+
+ `
+
+ const result = formatter.format(source)
+ expect(result).toBe(source)
+ })
+
+ test("should ignore formatting when directive is in middle of file", () => {
+ const source = dedent`
+
+ <%# herb:formatter ignore %>
+ Badly formatted content
+
+ `
+
+ const result = formatter.format(source)
+ expect(result).toBe(source)
+ })
+
+ test("should work with frontmatter before directive", () => {
+ const source = dedent`
+ ---
+ title: Test
+ ---
+ <%# herb:formatter ignore %>
+
+ content
+
+ `
+
+ const result = formatter.format(source)
+ expect(result).toBe(source)
+ })
+
+ test("should work with whitespace before directive", () => {
+ const source = `
+
+ <%# herb:formatter ignore %>
+
+ content
+
+ `
+
+ const result = formatter.format(source)
+ expect(result).toBe(source)
+ })
+
+ test("should not match herb:formatter ignore with extra text", () => {
+ const source = dedent`
+ <%# herb:formatter ignore some-rule %>
+
+ content
+
+ `
+
+ const result = formatter.format(source)
+ expect(result).not.toBe(source)
+ })
+
+ test("should not match herb:disable all", () => {
+ const source = dedent`
+ <%# herb:disable all %>
+
+ content
+
+ `
+
+ const result = formatter.format(source)
+ expect(result).not.toBe(source)
+ })
+
+ test("should ignore formatting when directive is at end of file", () => {
+ const source = dedent`
+
+ content
+
+ <%# herb:formatter ignore %>
+ `
+
+ const result = formatter.format(source)
+ expect(result).toBe(source)
+ })
+
+ test("should ignore formatting when directive is nested deep in document", () => {
+ const source = dedent`
+
+
+
+ <%# herb:formatter ignore %>
+ Badly formatted
+
+
+
+ `
+
+ const result = formatter.format(source)
+ expect(result).toBe(source)
+ })
+})
diff --git a/javascript/packages/linter/README.md b/javascript/packages/linter/README.md
index f17240074..dc19d6a41 100644
--- a/javascript/packages/linter/README.md
+++ b/javascript/packages/linter/README.md
@@ -349,6 +349,36 @@ To disable all linting rules for a specific line, use `all`:
Inline disable comments only affect the line they appear on. Each line that needs linting disabled must have its own disable comment.
:::
+### Disabling Linting for Entire Files
+
+You can disable linting for an entire file by adding the `ignore` directive anywhere in your template:
+
+```erb
+<%# herb:linter ignore %>
+```
+
+**Example:**
+
+:::code-group
+```erb [ignored.html.erb]
+<%# herb:linter ignore %>
+
+
+ This entire file will not be linted
+
+```
+
+```erb [regular.html.erb]
+
+ This entire file will be linted
+
+```
+:::
+
+::: warning Important
+The `<%# herb:linter ignore %>` directive must be an exact match. Extra text or spacing will prevent it from working.
+:::
+
## Configuration
Create a `.herb.yml` file in your project root to configure the linter:
diff --git a/javascript/packages/linter/src/herb-disable-comment-utils.ts b/javascript/packages/linter/src/herb-disable-comment-utils.ts
index 89375af69..a6daa27fe 100644
--- a/javascript/packages/linter/src/herb-disable-comment-utils.ts
+++ b/javascript/packages/linter/src/herb-disable-comment-utils.ts
@@ -2,6 +2,9 @@
* Utilities for parsing herb:disable comments
*/
+import { isERBCommentNode } from "@herb-tools/core"
+import type { Node } from "@herb-tools/core"
+
/**
* Information about a single rule name in a herb:disable comment
*/
diff --git a/javascript/packages/linter/src/linter-ignore.ts b/javascript/packages/linter/src/linter-ignore.ts
new file mode 100644
index 000000000..587583621
--- /dev/null
+++ b/javascript/packages/linter/src/linter-ignore.ts
@@ -0,0 +1,56 @@
+import { isERBCommentNode } from "@herb-tools/core"
+import { Visitor, ERBContentNode } from "@herb-tools/core"
+
+import type { Node, ParseResult } from "@herb-tools/core"
+
+const HERB_LINTER_PREFIX = "herb:linter"
+const HERB_LINTER_IGNORE_PREFIX = `${HERB_LINTER_PREFIX} ignore`
+
+/**
+ * Check if an ERB content node is a herb:linter ignore comment.
+ *
+ * @param node - The ERB content node to check
+ * @returns true if this is a linter ignore directive
+ */
+export function isHerbLinterIgnoreComment(node: Node): boolean {
+ if (!isERBCommentNode(node)) return false
+
+ const content = node?.content?.value || ""
+
+ return content.trim() === HERB_LINTER_IGNORE_PREFIX
+}
+
+/**
+ * Check if the document contains a herb:linter ignore directive anywhere.
+ */
+export function hasLinterIgnoreDirective(parseResult: ParseResult): boolean {
+ if (parseResult.failed) return false
+
+ const detector = new LinterIgnoreDetector()
+ detector.visit(parseResult.value)
+ return detector.hasIgnoreDirective
+}
+
+/**
+ * Visitor that detects if the AST contains a herb:linter ignore directive.
+ */
+class LinterIgnoreDetector extends Visitor {
+ public hasIgnoreDirective = false
+
+ visitNode(node: Node): void {
+ if (this.hasIgnoreDirective) return
+
+ this.visitChildNodes(node)
+ }
+
+ visitERBContentNode(node: ERBContentNode): void {
+ if (isHerbLinterIgnoreComment(node)) {
+ this.hasIgnoreDirective = true
+ return
+ }
+
+ if (this.hasIgnoreDirective) return
+
+ this.visitChildNodes(node)
+ }
+}
diff --git a/javascript/packages/linter/src/linter.ts b/javascript/packages/linter/src/linter.ts
index 1ef5833c9..ccc126e42 100644
--- a/javascript/packages/linter/src/linter.ts
+++ b/javascript/packages/linter/src/linter.ts
@@ -5,6 +5,7 @@ import { minimatch } from "minimatch"
import { rules } from "./rules.js"
import { findNodeByLocation } from "./rules/rule-utils.js"
import { parseHerbDisableLine } from "./herb-disable-comment-utils.js"
+import { hasLinterIgnoreDirective } from "./linter-ignore.js"
import { ParserNoErrorsRule } from "./rules/parser-no-errors.js"
import { DEFAULT_RULE_CONFIG } from "./types.js"
@@ -303,6 +304,18 @@ export class Linter {
let wouldBeIgnoredCount = 0
const parseResult = this.herb.parse(source, { track_whitespace: true })
+
+ // Check for file-level ignore directive using visitor
+ if (hasLinterIgnoreDirective(parseResult)) {
+ return {
+ offenses: [],
+ errors: 0,
+ warnings: 0,
+ info: 0,
+ hints: 0,
+ ignored: 0
+ }
+ }
const lexResult = this.herb.lex(source)
const hasParserErrors = parseResult.recursiveErrors().length > 0
const sourceLines = source.split("\n")
@@ -573,3 +586,4 @@ export class Linter {
}
}
}
+
diff --git a/javascript/packages/linter/test/herb-linter-ignore.test.ts b/javascript/packages/linter/test/herb-linter-ignore.test.ts
new file mode 100644
index 000000000..58994f3f7
--- /dev/null
+++ b/javascript/packages/linter/test/herb-linter-ignore.test.ts
@@ -0,0 +1,96 @@
+import dedent from "dedent"
+import { describe, test } from "vitest"
+import { HTMLTagNameLowercaseRule } from "../src/rules/html-tag-name-lowercase.js"
+import { createLinterTest } from "./helpers/linter-test-helper.js"
+
+const { expectNoOffenses, expectError, assertOffenses } = createLinterTest(HTMLTagNameLowercaseRule)
+
+describe("herb:linter ignore directive", () => {
+ test("should ignore all linting when directive is at top of file", () => {
+ expectNoOffenses(dedent`
+ <%# herb:linter ignore %>
+
+ content
+
+ `)
+ })
+
+ test("should ignore linting when directive is in middle of file", () => {
+ expectNoOffenses(dedent`
+
+ <%# herb:linter ignore %>
+ content
+
+ `)
+ })
+
+ test("should work with empty lines before directive", () => {
+ expectNoOffenses(`
+
+ <%# herb:linter ignore %>
+
+ content
+
+ `)
+ })
+
+ test("should not match herb:linter ignore with extra text", () => {
+ expectError('Opening tag name `` should be lowercase. Use `
` instead.')
+ expectError('Opening tag name `` should be lowercase. Use `` instead.')
+ expectError('Closing tag name `` should be lowercase. Use `` instead.')
+ expectError('Closing tag name `
` should be lowercase. Use `
` instead.')
+
+ assertOffenses(dedent`
+ <%# herb:linter ignore some-rule %>
+
+ content
+
+ `)
+ })
+
+ test("should not match herb:disable all", () => {
+ expectError('Opening tag name `` should be lowercase. Use `
` instead.')
+ expectError('Opening tag name `` should be lowercase. Use `` instead.')
+ expectError('Closing tag name `` should be lowercase. Use `` instead.')
+ expectError('Closing tag name `
` should be lowercase. Use `
` instead.')
+
+ assertOffenses(dedent`
+ <%# herb:disable all %>
+
+ content
+
+ `)
+ })
+
+ test("directive must be exact match", () => {
+ expectError('Opening tag name `` should be lowercase. Use `
` instead.')
+ expectError('Closing tag name `
` should be lowercase. Use `
` instead.')
+
+ assertOffenses(dedent`
+ <%# herb:linter ignore %>
+ content
+ `)
+ })
+
+ test("should ignore linting when directive is at end of file", () => {
+ expectNoOffenses(dedent`
+
+ content
+
+ <%# herb:linter ignore %>
+ `)
+ })
+
+ test("should ignore linting when directive is nested deep in document", () => {
+ expectNoOffenses(dedent`
+
+
+
+ <%# herb:linter ignore %>
+ content
+
+
+
+ `)
+ })
+})