diff --git a/javascript/packages/formatter/src/cli.ts b/javascript/packages/formatter/src/cli.ts
index 37d8508c7..e8f163dbc 100644
--- a/javascript/packages/formatter/src/cli.ts
+++ b/javascript/packages/formatter/src/cli.ts
@@ -5,6 +5,7 @@ import { resolve, relative } from "path"
import { Herb } from "@herb-tools/node-wasm"
import { Config, addHerbExtensionRecommendation, getExtensionsJsonRelativePath } from "@herb-tools/config"
+import { colorize } from "@herb-tools/highlighter"
import { Formatter } from "./formatter.js"
import { ASTRewriter, StringRewriter, CustomRewriterLoader, builtinRewriters, isASTRewriterClass, isStringRewriterClass } from "@herb-tools/rewriter/loader"
@@ -208,7 +209,19 @@ export class CLI {
allRewriterClasses.push(...builtinRewriters)
const loader = new CustomRewriterLoader({ baseDir })
- const { rewriters: customRewriters, duplicateWarnings } = await loader.loadRewritersWithInfo()
+ const { rewriters: customRewriters, rewriterInfo, duplicateWarnings } = await loader.loadRewritersWithInfo()
+
+ if (customRewriters.length > 0) {
+ console.error(colorize(`\nLoaded ${customRewriters.length} custom ${pluralize(customRewriters.length, 'rewriter')}:`, "green"))
+
+ for (const { name, path } of rewriterInfo) {
+ const relativePath = config.projectPath ? path.replace(config.projectPath + '/', '') : path
+
+ console.error(colorize(` • ${name}`, "cyan") + colorize(` (${relativePath})`, "dim"))
+ }
+
+ console.error()
+ }
allRewriterClasses.push(...customRewriters)
warnings.push(...duplicateWarnings)
@@ -274,18 +287,41 @@ export class CLI {
}
if (preRewriters.length > 0 || postRewriters.length > 0) {
- const parts: string[] = []
+ const customRewriterPaths = new Map(rewriterInfo.map(r => [r.name, r.path]))
if (preRewriters.length > 0) {
- parts.push(`${preRewriters.length} pre-format ${pluralize(preRewriters.length, 'rewriter')}: ${rewriterNames.pre.join(', ')}`)
+ console.error(colorize(`\nUsing ${preRewriters.length} pre-format ${pluralize(preRewriters.length, 'rewriter')}:`, "green"))
+
+ for (const rewriter of preRewriters) {
+ const customPath = customRewriterPaths.get(rewriter.name)
+
+ if (customPath) {
+ const relativePath = config.projectPath ? customPath.replace(config.projectPath + '/', '') : customPath
+ console.error(colorize(` • ${rewriter.name}`, "cyan") + colorize(` (${relativePath})`, "dim"))
+ } else {
+ console.error(colorize(` • ${rewriter.name}`, "cyan") + colorize(` (built-in)`, "dim"))
+ }
+ }
+
+ console.error()
}
if (postRewriters.length > 0) {
- parts.push(`${postRewriters.length} post-format ${pluralize(postRewriters.length, 'rewriter')}: ${rewriterNames.post.join(', ')}`)
- }
+ console.error(colorize(`\nUsing ${postRewriters.length} post-format ${pluralize(postRewriters.length, 'rewriter')}:`, "green"))
- console.error(`Using ${parts.join(', ')}`)
- console.error()
+ for (const rewriter of postRewriters) {
+ const customPath = customRewriterPaths.get(rewriter.name)
+
+ if (customPath) {
+ const relativePath = config.projectPath ? customPath.replace(config.projectPath + '/', '') : customPath
+ console.error(colorize(` • ${rewriter.name}`, "cyan") + colorize(` (${relativePath})`, "dim"))
+ } else {
+ console.error(colorize(` • ${rewriter.name}`, "cyan") + colorize(` (built-in)`, "dim"))
+ }
+ }
+
+ console.error()
+ }
}
if (warnings.length > 0) {
diff --git a/javascript/packages/formatter/test/cli/__snapshots__/rewriters.test.ts.snap b/javascript/packages/formatter/test/cli/__snapshots__/rewriters.test.ts.snap
new file mode 100644
index 000000000..ccf53a111
--- /dev/null
+++ b/javascript/packages/formatter/test/cli/__snapshots__/rewriters.test.ts.snap
@@ -0,0 +1,77 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`CLI > Rewriters > should actually apply Tailwind class sorting 1`] = `
+"✓ Using Herb config file at test-rewriters/.herb.yml
+⚠️ Experimental Preview: The formatter is in early development. Please report any unexpected behavior or bugs to https://github.com/marcoroth/herb/issues/new?template=formatting-issue.md
+
+[32m
+Using 1 pre-format rewriter:[0m
+[36m • tailwind-class-sorter[0m[2m (built-in)[0m
+
+"
+`;
+
+exports[`CLI > Rewriters > should format from stdin with rewriters configured 1`] = `
+"✓ Using Herb config file at test-rewriters/.herb.yml
+⚠️ Experimental Preview: The formatter is in early development. Please report any unexpected behavior or bugs to https://github.com/marcoroth/herb/issues/new?template=formatting-issue.md
+
+[32m
+Using 1 pre-format rewriter:[0m
+[36m • tailwind-class-sorter[0m[2m (built-in)[0m
+
+"
+`;
+
+exports[`CLI > Rewriters > should format from stdin with rewriters configured 2`] = `
+"
+"
+`;
+
+exports[`CLI > Rewriters > should handle both pre and post rewriters 1`] = `
+"✓ Using Herb config file at test-rewriters/.herb.yml
+⚠️ Experimental Preview: The formatter is in early development. Please report any unexpected behavior or bugs to https://github.com/marcoroth/herb/issues/new?template=formatting-issue.md
+
+[32m
+Using 1 pre-format rewriter:[0m
+[36m • tailwind-class-sorter[0m[2m (built-in)[0m
+
+"
+`;
+
+exports[`CLI > Rewriters > should not show rewriter info when no rewriters are configured 1`] = `
+"✓ Using Herb config file at test-rewriters/.herb.yml
+⚠️ Experimental Preview: The formatter is in early development. Please report any unexpected behavior or bugs to https://github.com/marcoroth/herb/issues/new?template=formatting-issue.md
+
+"
+`;
+
+exports[`CLI > Rewriters > should show multiple rewriters on stderr 1`] = `
+"✓ Using Herb config file at test-rewriters/.herb.yml
+⚠️ Experimental Preview: The formatter is in early development. Please report any unexpected behavior or bugs to https://github.com/marcoroth/herb/issues/new?template=formatting-issue.md
+
+[32m
+Using 1 pre-format rewriter:[0m
+[36m • tailwind-class-sorter[0m[2m (built-in)[0m
+
+"
+`;
+
+exports[`CLI > Rewriters > should show rewriter info on stderr when pre-format rewriters are configured 1`] = `
+"✓ Using Herb config file at test-rewriters/.herb.yml
+⚠️ Experimental Preview: The formatter is in early development. Please report any unexpected behavior or bugs to https://github.com/marcoroth/herb/issues/new?template=formatting-issue.md
+
+[32m
+Using 1 pre-format rewriter:[0m
+[36m • tailwind-class-sorter[0m[2m (built-in)[0m
+
+"
+`;
+
+exports[`CLI > Rewriters > should show warning for unknown rewriters on stderr 1`] = `
+"✓ Using Herb config file at test-rewriters/.herb.yml
+⚠️ Experimental Preview: The formatter is in early development. Please report any unexpected behavior or bugs to https://github.com/marcoroth/herb/issues/new?template=formatting-issue.md
+
+⚠️ Pre-format rewriter "non-existent-rewriter" not found. Skipping.
+
+"
+`;
diff --git a/javascript/packages/formatter/test/cli/rewriters.test.ts b/javascript/packages/formatter/test/cli/rewriters.test.ts
index aa6992921..1ec0c9d97 100644
--- a/javascript/packages/formatter/test/cli/rewriters.test.ts
+++ b/javascript/packages/formatter/test/cli/rewriters.test.ts
@@ -5,6 +5,15 @@ import { join } from "path"
import { execBinary, expectExitCode, } from "./cli-helpers"
+/**
+ * Normalizes stderr output by replacing absolute paths with relative paths
+ * This makes snapshots consistent across different environments (local vs CI)
+ */
+function normalizeStderr(stderr: string): string {
+ const cwd = process.cwd()
+ return stderr.replace(new RegExp(cwd + '/', 'g'), '')
+}
+
describe("CLI", () => {
describe("Rewriters", () => {
const testDir = "test-rewriters"
@@ -35,7 +44,7 @@ describe("CLI", () => {
const result = await execBinary([testFile])
expectExitCode(result, 0)
- expect(result.stderr).toContain("Using 1 pre-format rewriter: tailwind-class-sorter")
+ expect(normalizeStderr(result.stderr)).toMatchSnapshot()
})
it("should show multiple rewriters on stderr", async () => {
@@ -55,7 +64,7 @@ describe("CLI", () => {
const result = await execBinary([testFile])
expectExitCode(result, 0)
- expect(result.stderr).toContain("Using 1 pre-format rewriter: tailwind-class-sorter")
+ expect(normalizeStderr(result.stderr)).toMatchSnapshot()
})
it("should actually apply Tailwind class sorting", async () => {
@@ -77,7 +86,7 @@ describe("CLI", () => {
const result = await execBinary([testFile])
expectExitCode(result, 0)
- expect(result.stdout).toContain("Formatted: test-rewriters/test.html.erb")
+ expect(normalizeStderr(result.stderr)).toMatchSnapshot()
const formattedContent = await readFile(testFile, 'utf-8')
expect(formattedContent).toBe(`
\n`)
@@ -98,8 +107,8 @@ describe("CLI", () => {
const result = await execBinary(["--config-file", configPath], input)
expectExitCode(result, 0)
- expect(result.stderr).toContain(`Using 1 pre-format rewriter: tailwind-class-sorter`)
- expect(result.stdout).toContain(`class="bg-blue-500 px-4 text-white"`)
+ expect(normalizeStderr(result.stderr)).toMatchSnapshot()
+ expect(result.stdout).toMatchSnapshot()
})
it("should show warning for unknown rewriters on stderr", async () => {
@@ -119,9 +128,7 @@ describe("CLI", () => {
const result = await execBinary([testFile])
expectExitCode(result, 0)
- expect(result.stderr).toContain("⚠️")
- expect(result.stderr).toContain("non-existent-rewriter")
- expect(result.stderr).toContain("not found")
+ expect(normalizeStderr(result.stderr)).toMatchSnapshot()
})
it("should not show rewriter info when no rewriters are configured", async () => {
@@ -138,9 +145,7 @@ describe("CLI", () => {
const result = await execBinary([testFile])
expectExitCode(result, 0)
- expect(result.stderr).not.toContain("pre-format rewriter")
- expect(result.stderr).not.toContain("post-format rewriter")
- expect(result.stderr).toContain("⚠️ Experimental Preview")
+ expect(normalizeStderr(result.stderr)).toMatchSnapshot()
})
it("should handle both pre and post rewriters", async () => {
@@ -160,8 +165,7 @@ describe("CLI", () => {
const result = await execBinary([testFile])
expectExitCode(result, 0)
- expect(result.stderr).toContain("Using 1 pre-format rewriter: tailwind-class-sorter")
- expect(result.stderr).not.toContain("post-format")
+ expect(normalizeStderr(result.stderr)).toMatchSnapshot()
})
})
})
diff --git a/javascript/packages/formatter/test/rewriters/fixtures/uppercase-tags.js b/javascript/packages/formatter/test/rewriters/fixtures/uppercase-tags.js
index 357c8557c..cc9e0b89f 100644
--- a/javascript/packages/formatter/test/rewriters/fixtures/uppercase-tags.js
+++ b/javascript/packages/formatter/test/rewriters/fixtures/uppercase-tags.js
@@ -30,7 +30,7 @@ class UppercaseTagsVisitor extends Visitor {
}
}
-export class UppercaseTagsRewriter extends ASTRewriter {
+export default class UppercaseTagsRewriter extends ASTRewriter {
get name() {
return "uppercase-tags"
}
diff --git a/javascript/packages/language-server/src/linter_service.ts b/javascript/packages/language-server/src/linter_service.ts
index 0b19b4efa..775e3428f 100644
--- a/javascript/packages/language-server/src/linter_service.ts
+++ b/javascript/packages/language-server/src/linter_service.ts
@@ -1,24 +1,37 @@
-import { Diagnostic, Range, Position, CodeDescription } from "vscode-languageserver/node"
+import { Diagnostic, Range, Position, CodeDescription, Connection } from "vscode-languageserver/node"
import { TextDocument } from "vscode-languageserver-textdocument"
-import { Linter } from "@herb-tools/linter"
+import { Linter, rules, type RuleClass } from "@herb-tools/linter"
+import { loadCustomRules as loadCustomRulesFromFs } from "@herb-tools/linter/loader"
import { Herb } from "@herb-tools/node-wasm"
import { Config } from "@herb-tools/config"
import { Settings } from "./settings"
+import { Project } from "./project"
import { lintToDignosticSeverity } from "./utils"
+const OPEN_CONFIG_ACTION = 'Open .herb.yml'
+
export interface LintServiceResult {
diagnostics: Diagnostic[]
}
export class LinterService {
+ private readonly connection: Connection
private readonly settings: Settings
+ private readonly project: Project
private readonly source = "Herb Linter "
private linter?: Linter
-
- constructor(settings: Settings) {
+ private allRules: RuleClass[] = rules
+ private customRulesLoaded = false
+ private failedCustomRules: Map = new Map()
+ private hasShownCustomRuleWarning = false
+ private customRulePaths: Map = new Map()
+
+ constructor(connection: Connection, settings: Settings, project: Project) {
+ this.connection = connection
this.settings = settings
+ this.project = project
}
/**
@@ -27,6 +40,77 @@ export class LinterService {
*/
rebuildLinter(): void {
this.linter = undefined
+ this.allRules = rules
+ this.customRulesLoaded = false
+ this.hasShownCustomRuleWarning = false
+ this.failedCustomRules.clear()
+ this.customRulePaths.clear()
+ }
+
+ /**
+ * Load custom rules from the project and merge with built-in rules
+ */
+ private async loadCustomRules(): Promise {
+ if (this.customRulesLoaded) {
+ return
+ }
+
+ const baseDir = this.project.projectPath
+
+ try {
+ const { rules: customRules, ruleInfo, warnings: duplicateWarnings } = await loadCustomRulesFromFs({ baseDir, silent: true })
+
+ if (customRules.length > 0) {
+ this.connection.console.log(`[Linter] Loaded ${customRules.length} custom rules: ${ruleInfo.map(r => r.name).join(', ')}`)
+
+ ruleInfo.forEach(({ name, path }) => {
+ this.customRulePaths.set(name, path)
+ })
+
+ this.allRules = [...rules, ...customRules]
+
+ if (duplicateWarnings.length > 0) {
+ duplicateWarnings.forEach(warning => {
+ this.connection.console.warn(`[Linter] ${warning}`)
+ })
+ }
+ }
+
+ this.customRulesLoaded = true
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error)
+
+ this.connection.console.error(`[Linter] Failed to load custom rules: ${errorMessage}`)
+ this.failedCustomRules.set('custom-rules', errorMessage)
+ this.customRulesLoaded = true
+ }
+ }
+
+ /**
+ * Show warning message to user about failed custom rules
+ */
+ private showCustomRuleWarnings(): void {
+ if (this.failedCustomRules.size === 0 || this.hasShownCustomRuleWarning) {
+ return
+ }
+
+ this.hasShownCustomRuleWarning = true
+
+ const failures = Array.from(this.failedCustomRules.entries())
+ const message = failures.length === 1
+ ? `Failed to load custom linter rules: ${failures[0][1]}`
+ : `Failed to load custom linter rules:\n${failures.map(([_, error], i) => `${i + 1}. ${error}`).join('\n')}`
+
+ if (this.settings.hasShowDocumentCapability) {
+ this.connection.window.showWarningMessage(message, { title: OPEN_CONFIG_ACTION }).then(action => {
+ if (action?.title === OPEN_CONFIG_ACTION) {
+ const configPath = `${this.project.projectPath}/.herb.yml`
+ this.connection.window.showDocument({ uri: `file://${configPath}`, takeFocus: true })
+ }
+ })
+ } else {
+ this.connection.window.showWarningMessage(message)
+ }
}
async lintDocument(textDocument: TextDocument): Promise {
@@ -40,6 +124,10 @@ export class LinterService {
const projectConfig = this.settings.projectConfig
if (!this.linter) {
+ await this.loadCustomRules()
+
+ this.showCustomRuleWarnings()
+
const linterConfig = projectConfig?.config?.linter || { enabled: true, rules: {} }
const config = Config.fromObject({
@@ -52,7 +140,9 @@ export class LinterService {
}
}, { projectPath: projectConfig?.projectPath || process.cwd() })
- this.linter = Linter.from(Herb, config)
+ const filteredRules = Linter.filterRulesByConfig(this.allRules, config.linter?.rules)
+
+ this.linter = new Linter(Herb, filteredRules, config, this.allRules)
}
const content = textDocument.getText()
@@ -64,8 +154,11 @@ export class LinterService {
Position.create(offense.location.end.line - 1, offense.location.end.column),
)
+ const customRulePath = this.customRulePaths.get(offense.rule)
const codeDescription: CodeDescription = {
- href: `https://herb-tools.dev/linter/rules/${offense.rule}`
+ href: customRulePath
+ ? `file://${customRulePath}`
+ : `https://herb-tools.dev/linter/rules/${offense.rule}`
}
return {
diff --git a/javascript/packages/language-server/src/server.ts b/javascript/packages/language-server/src/server.ts
index b5e93fc8a..0404dcf12 100644
--- a/javascript/packages/language-server/src/server.ts
+++ b/javascript/packages/language-server/src/server.ts
@@ -90,6 +90,8 @@ export class Server {
...patterns,
{ globPattern: `**/.herb.yml` },
{ globPattern: `**/**/.herb-lsp/config.json` },
+ { globPattern: `**/.herb/rules/**/*.mjs` },
+ { globPattern: `**/.herb/rewriters/**/*.mjs` },
],
})
})
@@ -117,9 +119,28 @@ export class Server {
this.connection.onDidChangeWatchedFiles(async (params) => {
for (const event of params.changes) {
- if (event.uri.endsWith("/.herb.yml") || event.uri.endsWith("/.herb-lsp/config.json")) {
+ const isConfigChange = event.uri.endsWith("/.herb.yml") || event.uri.endsWith("/.herb-lsp/config.json")
+ const isCustomRuleChange = event.uri.includes("/.herb/rules/")
+ const isCustomRewriterChange = event.uri.includes("/.herb/rewriters/")
+
+ if (isConfigChange) {
await this.service.refreshConfig()
+ const documents = this.service.documentService.getAll()
+ await Promise.all(documents.map(document =>
+ this.service.diagnostics.refreshDocument(document)
+ ))
+ } else if (isCustomRuleChange || isCustomRewriterChange) {
+ if (isCustomRuleChange) {
+ this.connection.console.log(`[Linter] Custom rule changed: ${event.uri}`)
+ this.service.linterService.rebuildLinter()
+ }
+
+ if (isCustomRewriterChange) {
+ this.connection.console.log(`[Rewriter] Custom rewriter changed: ${event.uri}`)
+ await this.service.formattingService.refreshConfig(this.service.config)
+ }
+
const documents = this.service.documentService.getAll()
await Promise.all(documents.map(document =>
this.service.diagnostics.refreshDocument(document)
diff --git a/javascript/packages/language-server/src/service.ts b/javascript/packages/language-server/src/service.ts
index 0ee567081..27679058a 100644
--- a/javascript/packages/language-server/src/service.ts
+++ b/javascript/packages/language-server/src/service.ts
@@ -37,7 +37,7 @@ export class Service {
this.documentService = new DocumentService(this.connection)
this.project = new Project(connection, this.settings.projectPath.replace("file://", ""))
this.parserService = new ParserService()
- this.linterService = new LinterService(this.settings)
+ this.linterService = new LinterService(this.connection, this.settings, this.project)
this.formattingService = new FormattingService(this.connection, this.documentService.documents, this.project, this.settings)
this.autofixService = new AutofixService(this.connection, this.config)
this.configService = new ConfigService(this.project.projectPath)
diff --git a/javascript/packages/language-server/test/linter_service.test.ts b/javascript/packages/language-server/test/linter_service.test.ts
index 4a32db687..8cb4a152b 100644
--- a/javascript/packages/language-server/test/linter_service.test.ts
+++ b/javascript/packages/language-server/test/linter_service.test.ts
@@ -4,6 +4,7 @@ import { TextDocument } from "vscode-languageserver-textdocument"
import { LinterService } from "../src/linter_service"
import { Settings } from "../src/settings"
+import { Project } from "../src/project"
import { Herb } from "@herb-tools/node-wasm"
import type { Connection, InitializeParams } from "vscode-languageserver/node"
@@ -16,6 +17,11 @@ describe("LinterService", () => {
const mockConnection = {
workspace: {
getConfiguration: vi.fn()
+ },
+ console: {
+ log: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn()
}
} as unknown as Connection
@@ -26,6 +32,10 @@ describe("LinterService", () => {
workspaceFolders: null
}
+ const mockProject = {
+ projectPath: process.cwd()
+ } as Project
+
const createTestDocument = (content: string) => {
return TextDocument.create("file:///test.html.erb", "erb", 1, content)
}
@@ -35,7 +45,7 @@ describe("LinterService", () => {
const settings = new Settings(mockParams, mockConnection)
settings.getDocumentSettings = vi.fn().mockResolvedValue(null)
- const linterService = new LinterService(settings)
+ const linterService = new LinterService(mockConnection, settings, mockProject)
const textDocument = createTestDocument("Test
\n")
const result = await linterService.lintDocument(textDocument)
@@ -51,7 +61,7 @@ describe("LinterService", () => {
// linter is undefined
})
- const linterService = new LinterService(settings)
+ const linterService = new LinterService(mockConnection, settings, mockProject)
const textDocument = createTestDocument("Test
\n")
const result = await linterService.lintDocument(textDocument)
@@ -66,7 +76,7 @@ describe("LinterService", () => {
linter: { enabled: false }
})
- const linterService = new LinterService(settings)
+ const linterService = new LinterService(mockConnection, settings, mockProject)
const textDocument = createTestDocument("Test
\n")
const result = await linterService.lintDocument(textDocument)
@@ -80,7 +90,7 @@ describe("LinterService", () => {
linter: { enabled: true }
})
- const linterService = new LinterService(settings)
+ const linterService = new LinterService(mockConnection, settings, mockProject)
const textDocument = createTestDocument("Hello
")
const result = await linterService.lintDocument(textDocument)
@@ -92,7 +102,7 @@ describe("LinterService", () => {
const settings = new Settings(mockParams, mockConnection)
settings.hasConfigurationCapability = false
- const linterService = new LinterService(settings)
+ const linterService = new LinterService(mockConnection, settings, mockProject)
const textDocument = createTestDocument("Test
")
const result = await linterService.lintDocument(textDocument)
@@ -106,7 +116,7 @@ describe("LinterService", () => {
linter: { enabled: true }
})
- const linterService = new LinterService(settings)
+ const linterService = new LinterService(mockConnection, settings, mockProject)
const textDocument = createTestDocument("Content")
const result = await linterService.lintDocument(textDocument)
@@ -142,7 +152,7 @@ describe("LinterService", () => {
applySeverityOverrides: (offenses) => offenses
} as any
- const linterService = new LinterService(settings)
+ const linterService = new LinterService(mockConnection, settings, mockProject)
const textDocument = createTestDocument(" Content
")
const result = await linterService.lintDocument(textDocument)
diff --git a/javascript/packages/linter/README.md b/javascript/packages/linter/README.md
index 3b51ff3ae..08838fcd4 100644
--- a/javascript/packages/linter/README.md
+++ b/javascript/packages/linter/README.md
@@ -413,6 +413,115 @@ herb-lint --force app/views/excluded-file.html.erb
When using `--force` on an excluded file, the linter will show a warning but proceed with linting.
+### Custom Rules
+
+Create custom linter rules for project-specific requirements by placing ES module files (`.mjs`) in `.herb/rules/`:
+
+::: info File Extension
+Custom rules must use the `.mjs` extension to avoid Node.js module type warnings. The `.mjs` extension explicitly marks files as ES modules.
+:::
+
+::: code-group
+
+
+```js [.herb/rules/no-inline-styles.mjs]
+import { BaseRuleVisitor, ParserRule } from "@herb-tools/linter"
+
+class NoDivTagsVisitor extends BaseRuleVisitor {
+ visitHTMLOpenTagNode(node) {
+ if (!node.tag_name) return
+ if (node.tag_name.value !== "div") return
+
+ this.addOffense(
+ `Avoid using \`\` tags. Consider using semantic HTML elements like \`
\`, \`\`, \`\`, \`\`, \`\`, \`\`, or \`\` instead.`,
+ node.tag_name.location
+ )
+ }
+}
+
+export default class NoDivTagsRule extends ParserRule {
+ name = "no-div-tags"
+
+ check(result, context) {
+ const visitor = new NoDivTagsVisitor(this.name, context)
+ visitor.visit(result.value)
+ return visitor.offenses
+ }
+}
+```
+
+```js [.herb/rules/no-inline-styles.mjs]
+import { BaseRuleVisitor, getAttributes, getAttributeName } from "@herb-tools/linter"
+
+class NoInlineStylesVisitor extends BaseRuleVisitor {
+ visitHTMLOpenTagNode(node) {
+ const attributes = getAttributes(node)
+
+ for (const attribute of attributes) {
+ const attributeName = getAttributeName(attribute)
+
+ if (attributeName === "style") {
+ this.addOffense(
+ `Avoid using inline \`style\` attributes. Use CSS classes instead.`,
+ attribute.location,
+ "warning"
+ )
+ }
+ }
+
+ super.visitHTMLOpenTagNode(node)
+ }
+}
+
+export default class NoInlineStylesRule {
+ name = "no-inline-styles"
+
+ check(parseResult, context) {
+ const visitor = new NoInlineStylesVisitor(this.name, context)
+ visitor.visit(parseResult.value)
+ return visitor.offenses
+ }
+}
+```
+
+:::
+
+**Rule Configuration:**
+
+The `defaultConfig` getter is optional. If not specified, custom rules default to:
+- `enabled: true` - Rule is enabled by default
+- `severity: "error"` - Offenses are reported as errors
+- `exclude: []` - No files are excluded
+
+You can override the `defaultConfig` getter to customize these defaults, as shown in the example above where severity is set to `"warning"`.
+
+**Rule Properties:**
+
+- `static type` - Optional, defaults to `"parser"`. Can be `"parser"`, `"lexer"`, or `"source"`
+- `name` - Required, the rule identifier used in configuration and output
+- `check()` - Required, the method that checks for offenses
+- `defaultConfig` - Optional, returns the default configuration for the rule
+- `isEnabled()` - Optional, dynamically determines if the rule should run
+- `autofix()` - Optional, implements automatic fixing for the rule
+
+Custom rules are loaded automatically by default. Use `--no-custom-rules` to disable them.
+
+When custom rules are loaded, the linter will display them:
+
+```
+Loaded 2 custom rules:
+ • no-inline-styles (.herb/rules/no-inline-styles.mjs)
+ • require-aria-labels (.herb/rules/require-aria-labels.mjs)
+```
+
+::: warning Rule Name Clashes
+If a custom rule has the same name as a built-in rule or another custom rule, you'll see a warning. The custom rule will override the built-in rule.
+:::
+
+::: tip Hot Reload
+Custom rules are automatically reloaded when changed in editors with the Herb Language Server. No need to restart your editor!
+:::
+
### Language Server Integration
The linter is automatically integrated into the [Herb Language Server](https://herb-tools.dev/projects/language-server), providing real-time validation in supported editors like VS Code, Zed, and Neovim.
diff --git a/javascript/packages/linter/package.json b/javascript/packages/linter/package.json
index d50f79d10..9ad5baf04 100644
--- a/javascript/packages/linter/package.json
+++ b/javascript/packages/linter/package.json
@@ -36,6 +36,12 @@
"types": "./dist/types/src/cli.d.ts",
"require": "./dist/src/cli.js",
"default": "./dist/src/cli.js"
+ },
+ "./loader": {
+ "types": "./dist/types/loader.d.ts",
+ "import": "./dist/loader.js",
+ "require": "./dist/loader.cjs",
+ "default": "./dist/loader.js"
}
},
"dependencies": {
diff --git a/javascript/packages/linter/rollup.config.mjs b/javascript/packages/linter/rollup.config.mjs
index 4a21c39c8..05246e288 100644
--- a/javascript/packages/linter/rollup.config.mjs
+++ b/javascript/packages/linter/rollup.config.mjs
@@ -82,4 +82,44 @@ export default [
}),
],
},
+
+ // Loader entry point (includes custom rule loader)
+ {
+ input: "src/loader.ts",
+ output: {
+ file: "dist/loader.js",
+ format: "esm",
+ sourcemap: true,
+ },
+ external,
+ plugins: [
+ nodeResolve({ preferBuiltins: true }),
+ commonjs(),
+ json(),
+ typescript({
+ tsconfig: "./tsconfig.json",
+ declaration: true,
+ declarationDir: "./dist/types",
+ rootDir: "src/",
+ }),
+ ],
+ },
+ {
+ input: "src/loader.ts",
+ output: {
+ file: "dist/loader.cjs",
+ format: "cjs",
+ sourcemap: true,
+ },
+ external,
+ plugins: [
+ nodeResolve({ preferBuiltins: true }),
+ commonjs(),
+ json(),
+ typescript({
+ tsconfig: "./tsconfig.json",
+ rootDir: "src/",
+ }),
+ ],
+ },
]
diff --git a/javascript/packages/linter/src/cli.ts b/javascript/packages/linter/src/cli.ts
index b0af673ba..2a985759a 100644
--- a/javascript/packages/linter/src/cli.ts
+++ b/javascript/packages/linter/src/cli.ts
@@ -141,7 +141,7 @@ export class CLI {
const startTime = Date.now()
const startDate = new Date()
- let { patterns, configFile, formatOption, showTiming, theme, wrapLines, truncateLines, useGitHubActions, fix, ignoreDisableComments, force, init } = this.argumentParser.parse(process.argv)
+ let { patterns, configFile, formatOption, showTiming, theme, wrapLines, truncateLines, useGitHubActions, fix, ignoreDisableComments, force, init, loadCustomRules } = this.argumentParser.parse(process.argv)
this.determineProjectPath(patterns)
@@ -247,7 +247,8 @@ export class CLI {
fix,
ignoreDisableComments,
linterConfig,
- config: processingConfig
+ config: processingConfig,
+ loadCustomRules
}
const results = await this.fileProcessor.processFiles(files, formatOption, context)
diff --git a/javascript/packages/linter/src/cli/argument-parser.ts b/javascript/packages/linter/src/cli/argument-parser.ts
index dd703bcdb..bac1c6529 100644
--- a/javascript/packages/linter/src/cli/argument-parser.ts
+++ b/javascript/packages/linter/src/cli/argument-parser.ts
@@ -23,6 +23,7 @@ export interface ParsedArguments {
ignoreDisableComments: boolean
force: boolean
init: boolean
+ loadCustomRules: boolean
}
export class ArgumentParser {
@@ -46,6 +47,7 @@ export class ArgumentParser {
--json use JSON output format (shortcut for --format json)
--github enable GitHub Actions annotations (combines with --format)
--no-github disable GitHub Actions annotations (even in GitHub Actions environment)
+ --no-custom-rules disable loading custom rules from project (custom rules are loaded by default from .herb/rules/**/*.{mjs,js})
--theme syntax highlighting theme (${THEME_NAMES.join("|")}) or path to custom theme file [default: ${DEFAULT_THEME}]
--no-color disable colored output
--no-timing hide timing information
@@ -73,7 +75,8 @@ export class ArgumentParser {
"no-color": { type: "boolean" },
"no-timing": { type: "boolean" },
"no-wrap-lines": { type: "boolean" },
- "truncate-lines": { type: "boolean" }
+ "truncate-lines": { type: "boolean" },
+ "no-custom-rules": { type: "boolean" }
},
allowPositionals: true
})
@@ -139,8 +142,9 @@ export class ArgumentParser {
const ignoreDisableComments = values["ignore-disable-comments"] || false
const configFile = values["config-file"]
const init = values.init || false
+ const loadCustomRules = !values["no-custom-rules"]
- return { patterns, configFile, formatOption, showTiming, theme, wrapLines, truncateLines, useGitHubActions, fix, ignoreDisableComments, force, init }
+ return { patterns, configFile, formatOption, showTiming, theme, wrapLines, truncateLines, useGitHubActions, fix, ignoreDisableComments, force, init, loadCustomRules }
}
private getFilePatterns(positionals: string[]): string[] {
diff --git a/javascript/packages/linter/src/cli/file-processor.ts b/javascript/packages/linter/src/cli/file-processor.ts
index 82d13ffa8..f675930d7 100644
--- a/javascript/packages/linter/src/cli/file-processor.ts
+++ b/javascript/packages/linter/src/cli/file-processor.ts
@@ -1,5 +1,6 @@
import { Herb } from "@herb-tools/node-wasm"
import { Linter } from "../linter.js"
+import { loadCustomRules } from "../loader.js"
import { Config } from "@herb-tools/config"
import { readFileSync, writeFileSync } from "fs"
@@ -24,6 +25,7 @@ export interface ProcessingContext {
ignoreDisableComments?: boolean
linterConfig?: HerbConfigOptions['linter']
config?: Config
+ loadCustomRules?: boolean
}
export interface ProcessingResult {
@@ -43,6 +45,7 @@ export interface ProcessingResult {
export class FileProcessor {
private linter: Linter | null = null
+ private customRulesLoaded: boolean = false
private isRuleAutocorrectable(ruleName: string): boolean {
if (!this.linter) return false
@@ -68,17 +71,60 @@ export class FileProcessor {
let filesWithOffenses = 0
let filesFixed = 0
let ruleCount = 0
+
const allOffenses: ProcessedFile[] = []
const ruleOffenses = new Map }>()
+ if (!this.linter) {
+ let customRules = undefined
+ let customRuleInfo: Array<{ name: string, path: string }> = []
+ let customRuleWarnings: string[] = []
+
+ if (context?.loadCustomRules && !this.customRulesLoaded) {
+ try {
+ const result = await loadCustomRules({
+ baseDir: context.projectPath,
+ silent: formatOption === 'json'
+ })
+
+ customRules = result.rules
+ customRuleInfo = result.ruleInfo
+ customRuleWarnings = result.warnings
+
+ this.customRulesLoaded = true
+
+ if (customRules.length > 0 && formatOption !== 'json') {
+ const ruleText = customRules.length === 1 ? 'rule' : 'rules'
+ console.log(colorize(`\nLoaded ${customRules.length} custom ${ruleText}:`, "green"))
+
+ for (const { name, path } of customRuleInfo) {
+ const relativePath = context.projectPath ? path.replace(context.projectPath + '/', '') : path
+ console.log(colorize(` • ${name}`, "cyan") + colorize(` (${relativePath})`, "dim"))
+ }
+
+ if (customRuleWarnings.length > 0) {
+ console.log()
+ for (const warning of customRuleWarnings) {
+ console.warn(colorize(` ⚠ ${warning}`, "yellow"))
+ }
+ }
+
+ console.log()
+ }
+ } catch (error) {
+ if (formatOption !== 'json') {
+ console.warn(colorize(`Warning: Failed to load custom rules: ${error}`, "yellow"))
+ }
+ }
+ }
+
+ this.linter = Linter.from(Herb, context?.config, customRules)
+ }
+
for (const filename of files) {
const filePath = context?.projectPath ? resolve(context.projectPath, filename) : resolve(filename)
let content = readFileSync(filePath, "utf-8")
- if (!this.linter) {
- this.linter = Linter.from(Herb, context?.config)
- }
-
const lintResult = this.linter.lint(content, {
fileName: filename,
ignoreDisableComments: context?.ignoreDisableComments
diff --git a/javascript/packages/linter/src/custom-rule-loader.ts b/javascript/packages/linter/src/custom-rule-loader.ts
new file mode 100644
index 000000000..182da84e9
--- /dev/null
+++ b/javascript/packages/linter/src/custom-rule-loader.ts
@@ -0,0 +1,189 @@
+import { pathToFileURL } from "url"
+import { glob } from "glob"
+
+import type { RuleClass } from "./types.js"
+
+export interface CustomRuleLoaderOptions {
+ /**
+ * Base directory to search for custom rules
+ * Defaults to current working directory
+ */
+ baseDir?: string
+
+ /**
+ * Glob patterns to search for custom rule files
+ * Defaults to looking in common locations
+ */
+ patterns?: string[]
+
+ /**
+ * Whether to suppress errors when loading custom rules
+ * Defaults to false
+ */
+ silent?: boolean
+}
+
+const DEFAULT_PATTERNS = [
+ ".herb/rules/**/*.mjs",
+]
+
+/**
+ * Loads custom linter rules from the user's project
+ */
+export class CustomRuleLoader {
+ private baseDir: string
+ private patterns: string[]
+ private silent: boolean
+
+ constructor(options: CustomRuleLoaderOptions = {}) {
+ this.baseDir = options.baseDir || process.cwd()
+ this.patterns = options.patterns || DEFAULT_PATTERNS
+ this.silent = options.silent || false
+ }
+
+ /**
+ * Discovers custom rule files in the project
+ */
+ async discoverRuleFiles(): Promise {
+ const allFiles: string[] = []
+
+ for (const pattern of this.patterns) {
+ try {
+ const files = await glob(pattern, {
+ cwd: this.baseDir,
+ absolute: true,
+ nodir: true
+ })
+
+ allFiles.push(...files)
+ } catch (error) {
+ if (!this.silent) {
+ console.warn(`Warning: Failed to search pattern "${pattern}": ${error}`)
+ }
+ }
+ }
+
+ return [...new Set(allFiles)]
+ }
+
+ /**
+ * Loads a single rule file
+ */
+ async loadRuleFile(filePath: string): Promise {
+ try {
+ const fileUrl = pathToFileURL(filePath).href
+ const cacheBustedUrl = `${fileUrl}?t=${Date.now()}`
+ const module = await import(cacheBustedUrl)
+
+ if (module.default && this.isValidRuleClass(module.default)) {
+ return [module.default]
+ }
+
+ if (!this.silent) {
+ console.warn(`Warning: No valid default export found in "${filePath}". Custom rules must use default export.`)
+ }
+
+ return []
+ } catch (error) {
+ if (!this.silent) {
+ console.error(`Error loading rule file "${filePath}": ${error}`)
+ }
+ return []
+ }
+ }
+
+ /**
+ * Type guard to check if an export is a valid rule class
+ */
+ private isValidRuleClass(value: any): value is RuleClass {
+ if (typeof value !== 'function') return false
+ if (!value.prototype) return false
+
+ const instance = new value()
+
+ return typeof instance.check === 'function' && typeof instance.name === 'string'
+ }
+
+ /**
+ * Loads all custom rules from the project
+ */
+ async loadRules(): Promise {
+ const ruleFiles = await this.discoverRuleFiles()
+
+ if (ruleFiles.length === 0) {
+ return []
+ }
+
+ const allRules: RuleClass[] = []
+
+ for (const filePath of ruleFiles) {
+ const rules = await this.loadRuleFile(filePath)
+ allRules.push(...rules)
+ }
+
+ return allRules
+ }
+
+ /**
+ * Loads all custom rules and returns detailed information about each rule
+ */
+ async loadRulesWithInfo(): Promise<{ rules: RuleClass[], ruleInfo: Array<{ name: string, path: string }>, duplicateWarnings: string[] }> {
+ const ruleFiles = await this.discoverRuleFiles()
+
+ if (ruleFiles.length === 0) {
+ return { rules: [], ruleInfo: [], duplicateWarnings: [] }
+ }
+
+ const allRules: RuleClass[] = []
+ const ruleInfo: Array<{ name: string, path: string }> = []
+ const duplicateWarnings: string[] = []
+ const seenNames = new Map()
+
+ for (const filePath of ruleFiles) {
+ const rules = await this.loadRuleFile(filePath)
+ for (const RuleClass of rules) {
+ const instance = new RuleClass()
+ const ruleName = instance.name
+
+ if (seenNames.has(ruleName)) {
+ const firstPath = seenNames.get(ruleName)!
+ duplicateWarnings.push(
+ `Custom rule "${ruleName}" is defined in multiple files: "${firstPath}" and "${filePath}". The later one will be used.`
+ )
+ } else {
+ seenNames.set(ruleName, filePath)
+ }
+
+ allRules.push(RuleClass)
+ ruleInfo.push({
+ name: ruleName,
+ path: filePath
+ })
+ }
+ }
+
+ return { rules: allRules, ruleInfo, duplicateWarnings }
+ }
+
+ /**
+ * Static helper to check if custom rules exist in a project
+ */
+ static async hasCustomRules(baseDir: string = process.cwd()): Promise {
+ const loader = new CustomRuleLoader({ baseDir, silent: true })
+ const files = await loader.discoverRuleFiles()
+ return files.length > 0
+ }
+
+ /**
+ * Static helper to load custom rules and merge with default rules
+ */
+ static async loadAndMergeRules(
+ defaultRules: RuleClass[],
+ options: CustomRuleLoaderOptions = {}
+ ): Promise {
+ const loader = new CustomRuleLoader(options)
+ const customRules = await loader.loadRules()
+
+ return [...defaultRules, ...customRules]
+ }
+}
diff --git a/javascript/packages/linter/src/linter.ts b/javascript/packages/linter/src/linter.ts
index ddd418c82..1ef5833c9 100644
--- a/javascript/packages/linter/src/linter.ts
+++ b/javascript/packages/linter/src/linter.ts
@@ -7,13 +7,45 @@ import { findNodeByLocation } from "./rules/rule-utils.js"
import { parseHerbDisableLine } from "./herb-disable-comment-utils.js"
import { ParserNoErrorsRule } from "./rules/parser-no-errors.js"
+import { DEFAULT_RULE_CONFIG } from "./types.js"
import type { RuleClass, Rule, ParserRule, LexerRule, SourceRule, LintResult, LintOffense, UnboundLintOffense, LintContext, AutofixResult } from "./types.js"
import type { ParseResult, LexResult, HerbBackend } from "@herb-tools/core"
import type { RuleConfig, Config } from "@herb-tools/config"
+export interface LinterOptions {
+ /**
+ * Array of rule classes to use. If not provided, uses default rules.
+ */
+ rules?: RuleClass[]
+
+ /**
+ * Whether to load custom rules from the project.
+ * Defaults to false for backward compatibility.
+ */
+ loadCustomRules?: boolean
+
+ /**
+ * Base directory to search for custom rules.
+ * Defaults to current working directory.
+ */
+ customRulesBaseDir?: string
+
+ /**
+ * Custom glob patterns to search for rule files.
+ */
+ customRulesPatterns?: string[]
+
+ /**
+ * Whether to suppress custom rule loading errors.
+ * Defaults to false.
+ */
+ silentCustomRules?: boolean
+}
+
export class Linter {
protected rules: RuleClass[]
+ protected allAvailableRules: RuleClass[]
protected herb: HerbBackend
protected offenses: LintOffense[]
protected config?: Config
@@ -23,14 +55,16 @@ export class Linter {
*
* @param herb - The Herb backend instance for parsing and lexing
* @param config - Optional full Config instance for rule filtering, severity overrides, and path-based filtering
+ * @param customRules - Optional array of custom rules to include alongside built-in rules
* @returns A configured Linter instance
*/
- static from(herb: HerbBackend, config?: Config): Linter {
+ static from(herb: HerbBackend, config?: Config, customRules?: RuleClass[]): Linter {
+ const allRules = customRules ? [...rules, ...customRules] : rules
const filteredRules = config?.linter?.rules
- ? Linter.filterRulesByConfig(rules, config.linter.rules)
+ ? Linter.filterRulesByConfig(allRules, config.linter.rules)
: undefined
- return new Linter(herb, filteredRules, config)
+ return new Linter(herb, filteredRules, config, allRules)
}
/**
@@ -42,11 +76,13 @@ export class Linter {
* @param herb - The Herb backend instance for parsing and lexing
* @param rules - Array of rule classes (Parser/AST or Lexer) to use. If not provided, uses default rules.
* @param config - Optional full Config instance for severity overrides and path-based rule filtering
+ * @param allAvailableRules - Optional array of ALL available rules (including disabled) for herb:disable validation
*/
- constructor(herb: HerbBackend, rules?: RuleClass[], config?: Config) {
+ constructor(herb: HerbBackend, rules?: RuleClass[], config?: Config, allAvailableRules?: RuleClass[]) {
this.herb = herb
this.config = config
this.rules = rules !== undefined ? rules : this.getDefaultRules()
+ this.allAvailableRules = allAvailableRules !== undefined ? allAvailableRules : this.rules
this.offenses = []
}
@@ -69,7 +105,7 @@ export class Linter {
const instance = new ruleClass()
const ruleName = instance.name
- const defaultEnabled = instance.defaultConfig.enabled
+ const defaultEnabled = instance.defaultConfig?.enabled ?? DEFAULT_RULE_CONFIG.enabled
const userRuleConfig = userRulesConfig?.[ruleName]
if (userRuleConfig !== undefined) {
@@ -93,10 +129,11 @@ export class Linter {
/**
* Returns all available rule classes that can be referenced in herb:disable comments.
* This includes all rules that exist, regardless of whether they're currently enabled.
+ * Includes both built-in rules and any loaded custom rules.
* @returns Array of all available rule classes
*/
protected getAvailableRules(): RuleClass[] {
- return rules
+ return this.allAvailableRules
}
/**
@@ -150,7 +187,7 @@ export class Linter {
}
if (context?.fileName && !this.config?.linter?.rules?.[rule.name]?.exclude) {
- const defaultExclude = rule.defaultConfig.exclude
+ const defaultExclude = rule.defaultConfig?.exclude ?? DEFAULT_RULE_CONFIG.exclude
if (defaultExclude && defaultExclude.length > 0) {
const isExcluded = defaultExclude.some((pattern: string) => minimatch(context.fileName!, pattern))
@@ -392,7 +429,7 @@ export class Linter {
}
const ruleInstance = new RuleClass()
- const defaultSeverity = ruleInstance.defaultConfig.severity
+ const defaultSeverity = ruleInstance.defaultConfig?.severity ?? DEFAULT_RULE_CONFIG.severity
const userRuleConfig = this.config?.linter?.rules?.[ruleName]
const severity = userRuleConfig?.severity ?? defaultSeverity
diff --git a/javascript/packages/linter/src/loader.ts b/javascript/packages/linter/src/loader.ts
new file mode 100644
index 000000000..d9d222770
--- /dev/null
+++ b/javascript/packages/linter/src/loader.ts
@@ -0,0 +1,30 @@
+export * from "./index.js"
+
+export { CustomRuleLoader } from "./custom-rule-loader.js"
+export type { CustomRuleLoaderOptions } from "./custom-rule-loader.js"
+
+import { CustomRuleLoader } from "./custom-rule-loader.js"
+import type { RuleClass } from "./types.js"
+
+/**
+ * Loads custom rules from the filesystem.
+ * Only available in Node.js environments.
+ */
+export async function loadCustomRules(options?: {
+ baseDir?: string
+ patterns?: string[]
+ silent?: boolean
+}): Promise<{
+ rules: RuleClass[]
+ ruleInfo: Array<{ name: string, path: string }>
+ warnings: string[]
+}> {
+ const loader = new CustomRuleLoader(options)
+ const { rules: customRules, ruleInfo, duplicateWarnings } = await loader.loadRulesWithInfo()
+
+ return {
+ rules: customRules,
+ ruleInfo,
+ warnings: duplicateWarnings
+ }
+}
diff --git a/javascript/packages/linter/src/types.ts b/javascript/packages/linter/src/types.ts
index 8f24469c1..3ac7fe4bc 100644
--- a/javascript/packages/linter/src/types.ts
+++ b/javascript/packages/linter/src/types.ts
@@ -68,6 +68,16 @@ export interface AutofixResult[]
}
+/**
+ * Default configuration for rules when defaultConfig is not specified.
+ * Custom rules can omit defaultConfig and will use these defaults.
+ */
+export const DEFAULT_RULE_CONFIG: FullRuleConfig = {
+ enabled: true,
+ severity: "error",
+ exclude: []
+}
+
/**
* Base class for parser rules.
*/
@@ -77,7 +87,9 @@ export abstract class ParserRule): UnboundLintOffense[]
/**
@@ -109,7 +121,9 @@ export abstract class LexerRule): UnboundLintOffense[]
/**
@@ -164,7 +178,9 @@ export abstract class SourceRule): UnboundLintOffense[]
/**
diff --git a/javascript/packages/linter/test/rules/herb-disable-comment-unnecessary.test.ts b/javascript/packages/linter/test/rules/herb-disable-comment-unnecessary.test.ts
index a43c9919b..d0737addb 100644
--- a/javascript/packages/linter/test/rules/herb-disable-comment-unnecessary.test.ts
+++ b/javascript/packages/linter/test/rules/herb-disable-comment-unnecessary.test.ts
@@ -6,11 +6,13 @@ import { createLinterTest } from "../helpers/linter-test-helper.js"
import { HerbDisableCommentUnnecessaryRule } from "../../src/rules/herb-disable-comment-unnecessary.js"
import { HTMLTagNameLowercaseRule } from "../../src/rules/html-tag-name-lowercase.js"
import { HTMLAttributeDoubleQuotesRule } from "../../src/rules/html-attribute-double-quotes.js"
+import { ERBCommentSyntax } from "../../src/rules/erb-comment-syntax.js"
const { expectNoOffenses, expectWarning, assertOffenses } = createLinterTest([
HerbDisableCommentUnnecessaryRule,
HTMLTagNameLowercaseRule,
HTMLAttributeDoubleQuotesRule,
+ ERBCommentSyntax,
])
describe("HerbDisableCommentUnnecessaryRule", () => {
diff --git a/javascript/packages/linter/test/rules/herb-disable-comment-valid-rule-name.test.ts b/javascript/packages/linter/test/rules/herb-disable-comment-valid-rule-name.test.ts
index 29c750423..77069a9ce 100644
--- a/javascript/packages/linter/test/rules/herb-disable-comment-valid-rule-name.test.ts
+++ b/javascript/packages/linter/test/rules/herb-disable-comment-valid-rule-name.test.ts
@@ -1,9 +1,13 @@
import dedent from "dedent"
import { describe, test } from "vitest"
import { HerbDisableCommentValidRuleNameRule } from "../../src/rules/herb-disable-comment-valid-rule-name.js"
+import { ERBCommentSyntax } from "../../src/rules/erb-comment-syntax.js"
import { createLinterTest } from "../helpers/linter-test-helper.js"
-const { expectNoOffenses, expectWarning, assertOffenses } = createLinterTest(HerbDisableCommentValidRuleNameRule)
+const { expectNoOffenses, expectWarning, assertOffenses } = createLinterTest([
+ HerbDisableCommentValidRuleNameRule,
+ ERBCommentSyntax,
+])
describe("HerbDisableCommentValidRuleNameRule", () => {
test("allows valid single rule name", () => {
@@ -25,7 +29,7 @@ describe("HerbDisableCommentValidRuleNameRule", () => {
})
test("warns on unknown single rule name", () => {
- expectWarning("Unknown rule `this-rule-doesnt-exist`. Did you mean `erb-no-empty-tags`?")
+ expectWarning("Unknown rule `this-rule-doesnt-exist`. Did you mean `erb-comment-syntax`?")
assertOffenses(dedent`
test
<%# herb:disable this-rule-doesnt-exist %>
diff --git a/javascript/packages/linter/test/rules/herb-disable-precise-locations.test.ts b/javascript/packages/linter/test/rules/herb-disable-precise-locations.test.ts
index e7c6ae2b7..643edfa08 100644
--- a/javascript/packages/linter/test/rules/herb-disable-precise-locations.test.ts
+++ b/javascript/packages/linter/test/rules/herb-disable-precise-locations.test.ts
@@ -6,6 +6,8 @@ import { Linter } from "../../src/linter.js"
import { HerbDisableCommentValidRuleNameRule } from "../../src/rules/herb-disable-comment-valid-rule-name.js"
import { HerbDisableCommentNoRedundantAllRule } from "../../src/rules/herb-disable-comment-no-redundant-all.js"
+import { HTMLTagNameLowercaseRule } from "../../src/rules/html-tag-name-lowercase.js"
+import { HTMLAttributeDoubleQuotesRule } from "../../src/rules/html-attribute-double-quotes.js"
describe("Herb Disable Comment Precise Locations", () => {
beforeAll(async () => {
@@ -37,7 +39,11 @@ describe("Herb Disable Comment Precise Locations", () => {
test
<%# herb:disable html-tag-name-lowercase, invalid-rule, html-attribute-double-quotes %>
`
- const linter = new Linter(Herb, [HerbDisableCommentValidRuleNameRule])
+ const linter = new Linter(Herb, [
+ HerbDisableCommentValidRuleNameRule,
+ HTMLTagNameLowercaseRule,
+ HTMLAttributeDoubleQuotesRule,
+ ])
const result = linter.lint(html)
expect(result.offenses).toHaveLength(1)
diff --git a/javascript/packages/rewriter/README.md b/javascript/packages/rewriter/README.md
index a61e7ebc7..135a43e44 100644
--- a/javascript/packages/rewriter/README.md
+++ b/javascript/packages/rewriter/README.md
@@ -201,9 +201,49 @@ export default class AddTrailingNewline extends StringRewriter {
### Using Custom Rewriters
-By default, rewriters are auto-discovered from: `.herb/rewriters/**/*.{js,mjs,cjs}`
+By default, rewriters are auto-discovered from: `.herb/rewriters/**/*.mjs`
-Which means you can just reference and configure them in `.herb.yml` using their filename.
+::: info File Extension
+Custom rewriters must use the `.mjs` extension to avoid Node.js module type warnings. The `.mjs` extension explicitly marks files as ES modules.
+:::
+
+#### Configuring in `.herb.yml`
+
+Reference custom rewriters by their name in your configuration:
+
+```yaml [.herb.yml]
+formatter:
+ enabled: true
+ rewriter:
+ pre:
+ - tailwind-class-sorter # Built-in rewriter
+ - my-ast-rewriter # Custom rewriter
+ post:
+ - add-trailing-newline # Custom rewriter
+```
+
+When custom rewriters are loaded, the formatter will display them:
+
+```
+Loaded 2 custom rewriters:
+ • my-ast-rewriter (.herb/rewriters/my-ast-rewriter.mjs)
+ • add-trailing-newline (.herb/rewriters/add-newline.mjs)
+
+Using 2 pre-format rewriters:
+ • tailwind-class-sorter (built-in)
+ • my-ast-rewriter (.herb/rewriters/my-ast-rewriter.mjs)
+
+Using 1 post-format rewriter:
+ • add-trailing-newline (.herb/rewriters/add-newline.mjs)
+```
+
+::: warning Rewriter Name Clashes
+If a custom rewriter has the same name as a built-in rewriter or another custom rewriter, you'll see a warning. The custom rewriter will override the built-in one.
+:::
+
+::: tip Hot Reload
+Custom rewriters are automatically reloaded when changed in editors with the Herb Language Server. No need to restart your editor!
+:::
## API Reference
diff --git a/javascript/packages/rewriter/src/custom-rewriter-loader.ts b/javascript/packages/rewriter/src/custom-rewriter-loader.ts
index 7ad74aa50..982e0be76 100644
--- a/javascript/packages/rewriter/src/custom-rewriter-loader.ts
+++ b/javascript/packages/rewriter/src/custom-rewriter-loader.ts
@@ -25,7 +25,7 @@ export interface CustomRewriterLoaderOptions {
}
const DEFAULT_PATTERNS = [
- ".herb/rewriters/**/*.{js,mjs,cjs}",
+ ".herb/rewriters/**/*.mjs",
]
/**
@@ -82,25 +82,18 @@ export class CustomRewriterLoader {
async loadRewriterFile(filePath: string): Promise {
try {
const fileUrl = pathToFileURL(filePath).href
- const module = await import(fileUrl)
-
- const rewriters: RewriterClass[] = []
+ const cacheBustedUrl = `${fileUrl}?t=${Date.now()}`
+ const module = await import(cacheBustedUrl)
if (module.default && isRewriterClass(module.default)) {
- rewriters.push(module.default)
- }
-
- for (const [exportName, exportValue] of Object.entries(module)) {
- if (exportName !== 'default' && isRewriterClass(exportValue as any)) {
- rewriters.push(exportValue as RewriterClass)
- }
+ return [module.default]
}
- if (rewriters.length === 0 && !this.silent) {
- console.warn(`Warning: No valid rewriter classes found in "${filePath}"`)
+ if (!this.silent) {
+ console.warn(`Warning: No valid default export found in "${filePath}". Custom rewriters must use default export.`)
}
- return rewriters
+ return []
} catch (error) {
if (!this.silent) {
console.error(`Error loading rewriter file "${filePath}": ${error}`)
diff --git a/javascript/packages/vscode/src/analysis-service.ts b/javascript/packages/vscode/src/analysis-service.ts
index 03e73af7f..5c988c09d 100644
--- a/javascript/packages/vscode/src/analysis-service.ts
+++ b/javascript/packages/vscode/src/analysis-service.ts
@@ -24,9 +24,9 @@ export class AnalysisService {
let formatterIndentWidth = 2
let formatterMaxLineLength = 80
let linterRules = {}
+ const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || process.cwd()
try {
- const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
if (workspaceRoot) {
const projectConfig = await Config.loadForEditor(workspaceRoot)
linterEnabled = projectConfig.linter?.enabled ?? true
@@ -50,7 +50,8 @@ export class AnalysisService {
formatterEnabled.toString(),
formatterIndentWidth.toString(),
formatterMaxLineLength.toString(),
- JSON.stringify(linterRules)
+ JSON.stringify(linterRules),
+ workspaceRoot
], { timeout: 1000 })
const result = JSON.parse(stdout.trim())
const failed = result.errors > 0 || result.lintErrors > 0
diff --git a/javascript/packages/vscode/src/parse-worker.js b/javascript/packages/vscode/src/parse-worker.js
index 905075934..a3e3b3ff2 100644
--- a/javascript/packages/vscode/src/parse-worker.js
+++ b/javascript/packages/vscode/src/parse-worker.js
@@ -1,6 +1,6 @@
const fs = require('fs');
const { Herb } = require('@herb-tools/node-wasm');
-const { Linter } = require('@herb-tools/linter');
+const { Linter, loadCustomRules } = require('@herb-tools/linter/loader');
const { Formatter } = require('@herb-tools/formatter');
const { Config } = require('@herb-tools/config');
@@ -11,6 +11,7 @@ const { Config } = require('@herb-tools/config');
const formatterIndentWidth = parseInt(process.argv[5]) || 2;
const formatterMaxLineLength = parseInt(process.argv[6]) || 80;
const linterRulesJson = process.argv[7] || '{}';
+ const workspaceRoot = process.argv[8] || process.cwd();
let linterRules = {};
try {
@@ -57,8 +58,21 @@ const { Config } = require('@herb-tools/config');
enabled: true,
rules: linterRules
}
- }, { projectPath: process.cwd() });
- const linter = Linter.from(Herb, config);
+ }, { projectPath: workspaceRoot });
+
+ let customRules = undefined;
+
+ try {
+ const result = await loadCustomRules({
+ baseDir: workspaceRoot,
+ silent: true
+ });
+ customRules = result.rules;
+ } catch (customRuleError) {
+ // Ignore custom rule loading errors in worker
+ }
+
+ const linter = Linter.from(Herb, config, customRules);
const lintResult = linter.lint(content, { fileName: file });
lintOffenses = lintResult.offenses || [];