diff --git a/.gitignore b/.gitignore index ece49f6..c835559 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ dist/ ecosystem/.cache/ .claude/ bench-results.json + +# validate-gts CLI test projects +test/.tmp-* diff --git a/README.md b/README.md index 3efe086..2b0eb2b 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,39 @@ Glint adds per-file overhead (TS program build, module rewrite, TypeChecker call Glint results, the transform output and (for `validate-gts`) the report of each file are cached on disk under `node_modules/.cache/html-validate-ember/`. An entry is keyed on the file's content plus the content of every project file it imports, transitively (through relative paths and tsconfig `paths`), the project's ambient `.d.ts` files, the lockfile, the tsconfig and the plugin — so editing an imported component invalidates its consumers. Set `HVE_NO_CACHE=1` to bypass the caches. +## Custom rules + +Put project-specific rules in a local [html-validate plugin](https://html-validate.org/dev/writing-plugins.html) and reference it with ``, the folder of `.htmlvalidate.json`: + +```json +{ + "plugins": ["html-validate-ember", "/lint/html-rules.mjs"], + "rules": { "local/no-bold": "error" } +} +``` + +```js +// lint/html-rules.mjs +import { Rule } from 'html-validate'; +import { isComponentInvocation } from 'html-validate-ember'; + +class NoBold extends Rule { + setup() { + this.on('element:ready', ({ target }) => { + if (target.tagName === 'b' && !isComponentInvocation(target)) { + this.report(target, 'Use instead of '); + } + }); + } +} + +export default { name: 'local', rules: { 'local/no-bold': NoBold } }; +``` + +With Glint, a component invocation is validated as the element the component renders (`` as ``). `isComponentInvocation(element)` is true for those, so a rule can report on the component's own template only, not again at every call site. + +`validate-gts` includes the plugin file in its cache key, so editing a rule re-lints every file. + ## Silencing rules Three layers, broadest to narrowest: diff --git a/index.ts b/index.ts index 09b806f..55126f3 100644 --- a/index.ts +++ b/index.ts @@ -140,4 +140,6 @@ const plugin: Plugin = { }, }; +export { isComponentInvocation } from './lib/component-invocation.js'; + export default plugin; diff --git a/lib/component-invocation.ts b/lib/component-invocation.ts new file mode 100644 index 0000000..1c22228 --- /dev/null +++ b/lib/component-invocation.ts @@ -0,0 +1,32 @@ +import fs from 'node:fs'; +import type { HtmlElement } from 'html-validate'; + +const sources = new Map(); + +function readSource(filename: string): string | null { + let stat: fs.Stats; + try { + stat = fs.statSync(filename); + } catch { + return null; + } + const cached = sources.get(filename); + if (cached?.mtimeMs === stat.mtimeMs) return cached.text; + const text = fs.readFileSync(filename, 'utf8'); + sources.set(filename, { mtimeMs: stat.mtimeMs, text }); + return text; +} + +/** + * True when html-validate sees the element in place of a component + * invocation (`` validated as the `` it renders), not an + * element written in the template. Custom rules can use it to report on + * the component's own template only. + */ +export function isComponentInvocation(element: HtmlElement): boolean { + const { filename, offset } = element.location; + const source = readSource(filename); + if (source === null) return false; + const written = /^[^\s/>]+/.exec(source.slice(offset, offset + 200))?.[0]; + return written !== undefined && written.toLowerCase() !== element.tagName.toLowerCase(); +} diff --git a/run.ts b/run.ts index 2942352..91b6ee1 100644 --- a/run.ts +++ b/run.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import fs from 'node:fs'; import path from 'node:path'; -import { HtmlValidate, formatterFactory } from 'html-validate'; +import { HtmlValidate, StaticConfigLoader, esmResolver, formatterFactory } from 'html-validate'; import type { ConfigData, Report, Result } from 'html-validate'; import { createRequire } from 'node:module'; @@ -28,13 +28,13 @@ import { assumeStaticFileSystem } from './lib/deps.js'; // (comments, trailing commas) aren't supported here. If you need them, // either run html-validate's CLI directly (`npx html-validate '...'`) // or file an issue and we'll wire in html-validate's loader. -function loadProjectConfig(start: string): ConfigData | null { +function loadProjectConfig(start: string): { config: ConfigData; dir: string } | null { let dir = fs.statSync(start).isDirectory() ? start : path.dirname(start); while (dir !== path.dirname(dir)) { const candidate = path.join(dir, '.htmlvalidate.json'); if (fs.existsSync(candidate)) { try { - return JSON.parse(fs.readFileSync(candidate, 'utf8')) as ConfigData; + return { config: JSON.parse(fs.readFileSync(candidate, 'utf8')) as ConfigData, dir }; } catch (err) { process.stderr.write( `[validate-gts] failed to parse ${candidate}: ${ @@ -49,7 +49,9 @@ function loadProjectConfig(start: string): ConfigData | null { return null; } -function makeValidator(userConfig: ConfigData | null): HtmlValidate { +// Other plugins in the user's config (e.g. `/lint/rules.js`) load +// through html-validate's ESM resolver, with `` as the config's folder. +function makeValidator(userConfig: ConfigData | null, configDir: string): HtmlValidate { // Always pass the plugin object directly (bypasses CJS-require of our // ESM-only package). Strip any string reference to "html-validate-ember" // from the user's plugins array — they're a no-op via require() and @@ -57,7 +59,7 @@ function makeValidator(userConfig: ConfigData | null): HtmlValidate { const userPlugins = (userConfig?.plugins ?? []).filter( (p) => !(typeof p === 'string' && p === 'html-validate-ember'), ); - return new HtmlValidate({ + const config: ConfigData = { extends: userConfig?.extends ?? [ 'html-validate:recommended', 'html-validate-ember:gts-recommended', @@ -72,7 +74,8 @@ function makeValidator(userConfig: ConfigData | null): HtmlValidate { }, ...(userConfig?.elements ? { elements: userConfig.elements } : {}), ...(userConfig?.root ? { root: userConfig.root } : {}), - }); + }; + return new HtmlValidate(new StaticConfigLoader([esmResolver({ rootDir: configDir })], config)); } // Recognized template file extensions: @@ -223,8 +226,19 @@ function printUsage(): void { // programmatic config (we can't rely on html-validate's own discovery // because its plugin loader uses CJS require() which doesn't work for // our ESM-only package). - const userConfig = loadProjectConfig(files[0]!); - const htmlvalidate = makeValidator(userConfig); + const project = loadProjectConfig(files[0]!); + const configDir = project?.dir ?? process.cwd(); + const htmlvalidate = makeValidator(project?.config ?? null, configDir); + // Local plugin sources are part of the cache key, so editing a rule re-lints. + const configForCacheKey = { + config: project?.config ?? null, + localPlugins: (project?.config.plugins ?? []) + .filter((p): p is string => typeof p === 'string' && p.includes('')) + .map((p) => { + const file = p.replace('', configDir); + return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null; + }), + }; // Pre-load all .gts/.gjs files into the Glint TS program in one shot, // so the per-file extractAttrTypeMap calls reuse a single program @@ -246,7 +260,7 @@ function printUsage(): void { const key = reportCacheKey( file, fs.readFileSync(file, 'utf8'), - userConfig, + configForCacheKey, htmlValidateVersion, tsconfigPath, tsconfigPath ? backendKindFor(tsconfigPath) : 'none', diff --git a/test/local-plugins.test.ts b/test/local-plugins.test.ts new file mode 100644 index 0000000..28753d3 --- /dev/null +++ b/test/local-plugins.test.ts @@ -0,0 +1,96 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, it, expect } from 'vitest'; +import { HtmlValidate, Rule } from 'html-validate'; + +import plugin, { isComponentInvocation } from '../index.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.join(__dirname, '..'); + +function rulesSource(message: string): string { + return `import { Rule } from 'html-validate'; + +class NoBold extends Rule { + setup() { + this.on('element:ready', ({ target }) => { + if (target.tagName === 'b') this.report(target, '${message}'); + }); + } +} + +export default { name: 'local', rules: { 'local/no-bold': NoBold } }; +`; +} + +describe('validate-gts: plugins from the project config', () => { + const project = path.join(__dirname, `.tmp-local-plugin-${process.pid}`); + + afterEach(() => fs.rmSync(project, { recursive: true, force: true })); + + it('loads plugins and re-lints when a plugin file changes', () => { + fs.mkdirSync(project, { recursive: true }); + fs.writeFileSync( + path.join(project, '.htmlvalidate.json'), + JSON.stringify({ + extends: ['html-validate-ember:recommended'], + plugins: ['/rules.mjs'], + rules: { 'local/no-bold': 'error' }, + }), + ); + fs.writeFileSync(path.join(project, 'page.gts'), '\n'); + + const run = (): string => { + try { + return execFileSync( + path.join(root, 'node_modules', '.bin', 'tsx'), + [path.join(root, 'run.ts'), path.join(project, 'page.gts')], + { encoding: 'utf8', stdio: 'pipe' }, + ); + } catch (error) { + return String((error as { stdout?: string }).stdout ?? ''); + } + }; + + fs.writeFileSync(path.join(project, 'rules.mjs'), rulesSource('first message')); + expect(run()).toContain('first message'); + + fs.writeFileSync(path.join(project, 'rules.mjs'), rulesSource('second message')); + expect(run()).toContain('second message'); + }); +}); + +describe('isComponentInvocation', () => { + it('is true for a component rendered as its root element, false for a written element', async () => { + const seen: string[] = []; + class Record extends Rule { + override setup(): void { + this.on('element:ready', ({ target }) => { + seen.push(`${target.tagName} ${isComponentInvocation(target)}`); + }); + } + } + + const prevGlint = process.env['HVE_GLINT']; + process.env['HVE_GLINT'] = '1'; + try { + const htmlvalidate = new HtmlValidate({ + root: true, + plugins: [plugin, { name: 'record', rules: { 'record/all': Record } }], + rules: { 'record/all': 'error' }, + transform: { '^.*\\.(gts|gjs|hbs)$': 'html-validate-ember' }, + }); + await htmlvalidate.validateFile(path.join(root, 'examples', 'anchor-target-href-consumer.gts')); + await htmlvalidate.validateFile( + path.join(root, 'test', 'glint-fixtures', 'anchor-target-href-leaf.gts'), + ); + } finally { + if (prevGlint === undefined) delete process.env['HVE_GLINT']; + else process.env['HVE_GLINT'] = prevGlint; + } + + expect(seen).toEqual(['a true', 'a false']); + }); +});