Skip to content
Open
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@ dist/
ecosystem/.cache/
.claude/
bench-results.json

# validate-gts CLI test projects
test/.tmp-*
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<rootDir>`, the folder of `.htmlvalidate.json`:

```json
{
"plugins": ["html-validate-ember", "<rootDir>/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 <strong> instead of <b>');
}
});
}
}

export default { name: 'local', rules: { 'local/no-bold': NoBold } };
```

With Glint, a component invocation is validated as the element the component renders (`<Modal>` as `<dialog>`). `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:
Expand Down
2 changes: 2 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,4 +140,6 @@ const plugin: Plugin = {
},
};

export { isComponentInvocation } from './lib/component-invocation.js';

export default plugin;
32 changes: 32 additions & 0 deletions lib/component-invocation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import fs from 'node:fs';
import type { HtmlElement } from 'html-validate';

const sources = new Map<string, { mtimeMs: number; text: string }>();

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 (`<Modal>` validated as the `<dialog>` 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();
}
32 changes: 23 additions & 9 deletions run.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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}: ${
Expand All @@ -49,15 +49,17 @@ function loadProjectConfig(start: string): ConfigData | null {
return null;
}

function makeValidator(userConfig: ConfigData | null): HtmlValidate {
// Other plugins in the user's config (e.g. `<rootDir>/lint/rules.js`) load
// through html-validate's ESM resolver, with `<rootDir>` 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
// we provide the object-form here.
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',
Expand All @@ -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:
Expand Down Expand Up @@ -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('<rootDir>'))
.map((p) => {
const file = p.replace('<rootDir>', 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
Expand All @@ -246,7 +260,7 @@ function printUsage(): void {
const key = reportCacheKey(
file,
fs.readFileSync(file, 'utf8'),
userConfig,
configForCacheKey,
htmlValidateVersion,
tsconfigPath,
tsconfigPath ? backendKindFor(tsconfigPath) : 'none',
Expand Down
96 changes: 96 additions & 0 deletions test/local-plugins.test.ts
Original file line number Diff line number Diff line change
@@ -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 <rootDir> 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: ['<rootDir>/rules.mjs'],
rules: { 'local/no-bold': 'error' },
}),
);
fs.writeFileSync(path.join(project, 'page.gts'), '<template><b>Bold</b></template>\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']);
});
});
Loading