Skip to content
Merged
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 a path relative to `.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 <strong> instead of <b>');
}
});
}
}

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

A component invocation is validated as the element the component renders (`<MyDialog>` 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 files and the modules they import in its cache key, so editing a rule re-lints every file.

## Silencing rules

Three layers, broadest to narrowest:
Expand Down
13 changes: 13 additions & 0 deletions blank.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,7 @@ interface Context {
renames: Array<[number, number, string]>;
fullyBlankedRanges: Range[];
dynamicContentOffsets: number[];
componentInvocationOffsets: number[];
// Per-offset attribute injections, applied at parse-time by
// transform.ts's `processElement` hook via `setAttribute`. Each
// entry pairs an attribute name with either a literal value or
Expand Down Expand Up @@ -1251,6 +1252,12 @@ function emitAttribute(attr: AST.AttrNode, ctx: Context, effectiveTag: string):
}
}

function lookupResolvedAttrs(node: AST.ElementNode, ctx: Context): ComponentAttrs | undefined {
if (!node.loc.start) return undefined;
const key = `${node.loc.start.line}:${node.loc.start.column}`;
return ctx.effectiveComponentAttrMap?.get(key) ?? ctx.glintComponentAttrMap?.get(key);
}

// Handle an `<element>` AST node: drive Glint substitution / neutralization
// and process attributes / modifiers / inline comments.
function handleElementNode(node: AST.ElementNode, ctx: Context): void {
Expand All @@ -1277,6 +1284,7 @@ function handleElementNode(node: AST.ElementNode, ctx: Context): void {
}
if (resolved !== null) {
effectiveTag = resolved;
if (!lookupResolvedAttrs(node, ctx)?.ownElement) ctx.componentInvocationOffsets.push(start);
if (node.selfClosing && !VOID_ELEMENTS.has(resolved)) {
// Non-void self-closing: the whole span is in fullyBlankedRanges;
// attribute children are guarded out via inFullyBlankedRange.
Expand Down Expand Up @@ -1488,6 +1496,8 @@ export interface BlankResult {
content: string;
error: Error | null;
dynamicContentOffsets: number[];
// Element starts where a component invocation was substituted by the tag it renders.
componentInvocationOffsets: number[];
// Per-offset attribute injections — applied at parse-time by
// transform.ts's `processElement` hook via `setAttribute`. See
// `Context.attrInjections` for rationale.
Expand Down Expand Up @@ -1518,6 +1528,7 @@ export interface BlankErrorResult {
content: string;
error: Error;
dynamicContentOffsets?: undefined;
componentInvocationOffsets?: undefined;
attrInjections?: undefined;
disablePerElement?: undefined;
}
Expand Down Expand Up @@ -1551,6 +1562,7 @@ function blankTemplateContent(
renames: [],
fullyBlankedRanges: [],
dynamicContentOffsets: [],
componentInvocationOffsets: [],
attrInjections: new Map(),
branchSelections,
inFullyBlankedRange(offset: number): boolean {
Expand Down Expand Up @@ -1614,6 +1626,7 @@ function blankTemplateContent(
content: buf.join(''),
error: null,
dynamicContentOffsets: ctx.dynamicContentOffsets,
componentInvocationOffsets: ctx.componentInvocationOffsets,
attrInjections: ctx.attrInjections,
disablePerElement: detectSuppressions(
ast,
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;
2 changes: 2 additions & 0 deletions lib/builtin-components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export interface ComponentAttrs {
// directly in it, so their consumer content has a different parent at
// runtime than it has in the blanked output.
nestedYieldBlocks?: string[];
// The element is written in this template (`(element this.tag)`), not a component call.
ownElement?: boolean;
// Source byte offsets of the `<template>` block this root was
// extracted from. Matches the `.gts` original-source coordinate
// system that content-tag's preprocessor reports AND that
Expand Down
1 change: 1 addition & 0 deletions lib/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ export interface CachedPass {
content: string;
error: string | null;
dynamicContentOffsets: number[];
componentInvocationOffsets: number[];
attrInjections: Array<[number, Array<{ attr: string; value: string | null }>]>;
disablePerElement: Array<[number, string[]]>;
}
Expand Down
16 changes: 16 additions & 0 deletions lib/component-invocation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { HtmlElement } from 'html-validate';

// A registered symbol, so the mark survives two copies of this module (src and dist).
const COMPONENT_INVOCATION = Symbol.for('html-validate-ember.componentInvocation');

export function markComponentInvocation(element: unknown): void {
(element as Record<symbol, boolean>)[COMPONENT_INVOCATION] = true;
}

/**
* True when html-validate sees the element in place of a component invocation
* (`<MyDialog>` validated as the `<dialog>` it renders), not an element written in the template.
*/
export function isComponentInvocation(element: HtmlElement): boolean {
return (element as unknown as Record<symbol, boolean>)[COMPONENT_INVOCATION] === true;
}
1 change: 1 addition & 0 deletions lib/glint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -967,6 +967,7 @@ export function extractAttrTypeMap(filename: string, contents: string): Extracti
// attribute injection / aria-strip can operate on the splatted
// tag like any other resolved component.
hasSplat: true,
ownElement: true,
});
}
}
Expand Down
132 changes: 94 additions & 38 deletions run.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { HtmlValidate, formatterFactory } from 'html-validate';
import type { ConfigData, Report, Result } from 'html-validate';
import { HtmlValidate, StaticConfigLoader, esmResolver, formatterFactory } from 'html-validate';
import type { ConfigData, Report, Resolver, Result } from 'html-validate';
import { createRequire } from 'node:module';

import plugin from './index.js';
Expand All @@ -13,51 +13,86 @@ import { __glintUnavailable } from './transform.js';
import { backendKindFor, findTsconfig } from './lib/backend/index.js';
import { readReportCache, reportCacheKey, writeReportCache } from './lib/cache.js';
import type { CachedReport } from './lib/cache.js';
import { assumeStaticFileSystem } from './lib/deps.js';
import { assumeStaticFileSystem, dependencyClosure, sha256 } from './lib/deps.js';

// Walk up from `start` looking for a `.htmlvalidate.json` config file
// and return its parsed contents (or null if none found / unreadable).
// We have to load the user's config ourselves rather than relying on
// html-validate's discovery: html-validate's plugin loader uses CJS
// `require()` to resolve plugins by string name, which fails silently
// for our ESM-only package. To work around that, we pass the plugin
// object directly via `plugins: [plugin]` and merge the user's rules /
// extends / transform into the same programmatic config.
//
// Limitation: parses with `JSON.parse`, so JSON5 / JSONC features
// (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 {
// The nearest `.htmlvalidate.json`, loaded like html-validate's CLI loads it:
// './' paths are relative to its folder, and so is `<rootDir>`.
async function loadProjectConfig(start: string): Promise<{ 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)) {
let config: unknown;
try {
return JSON.parse(fs.readFileSync(candidate, 'utf8')) as ConfigData;
config = await esmResolver({ rootDir: dir }).resolveConfig!(candidate, { cache: false });
} catch (err) {
process.stderr.write(
`[validate-gts] failed to parse ${candidate}: ${
err instanceof Error ? err.message : String(err)
}\n`,
`[validate-gts] failed to parse ${candidate}: ${err instanceof Error ? err.message : String(err)}\n`,
);
return null;
}
if (!config || typeof config !== 'object' || Array.isArray(config)) {
process.stderr.write(`[validate-gts] ${candidate} is not a configuration object\n`);
return null;
}
return { config: substituteRootDir(config as ConfigData, dir), dir };
}
dir = path.dirname(dir);
}
return null;
}

function makeValidator(userConfig: ConfigData | null): 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.
// html-validate resolves a raw native path only when it exists as written,
// so `<rootDir>` is substituted here (a function replacer keeps `$` literal).
function substituteRootDir(config: ConfigData, dir: string): ConfigData {
const expand = <T>(value: T): T =>
(typeof value === 'string' ? value.replace('<rootDir>', () => dir) : value) as T;
return {
...config,
...(config.plugins ? { plugins: config.plugins.map(expand) } : {}),
...(config.extends ? { extends: config.extends.map(expand) } : {}),
...(config.elements ? { elements: config.elements.map(expand) } : {}),
...(config.transform
? { transform: Object.fromEntries(Object.entries(config.transform).map(([k, v]) => [k, expand(v)])) }
: {}),
};
}

// html-validate's ESM resolver, recording every local file it loads. A missing
// local file resolves to nothing, rather than being looked up as a URL.
function recordingResolver(rootDir: string, files: Set<string>): Resolver {
const inner = esmResolver({ rootDir });
const found = (id: string): boolean => {
if (!path.isAbsolute(id)) return true;
if (!fs.existsSync(id)) return false;
files.add(id);
return true;
};
return {
name: 'validate-gts',
resolveConfig: (id, options) => (found(id) ? inner.resolveConfig!(id, options) : null),
resolveElements: (id, options) => (found(id) ? inner.resolveElements!(id, options) : null),
resolvePlugin: (id, options) => (found(id) ? inner.resolvePlugin!(id, options) : null),
resolveTransformer: (id, options) => (found(id) ? inner.resolveTransformer!(id, options) : null),
};
}

/** Content of the local files the configuration loaded, with everything they import. */
function localFilesSha(files: ReadonlySet<string>): string {
const all = new Set<string>();
for (const file of files) {
all.add(file);
for (const dep of dependencyClosure(file, fs.readFileSync(file, 'utf8'), null)) all.add(dep);
}
return sha256([...all].sort().map((file) => `${file}\0${sha256(fs.readFileSync(file))}`).join('\0'));
}

// The plugin is passed as an object, so a by-name "html-validate-ember" entry is dropped.
function makeValidator(userConfig: ConfigData | null, resolver: Resolver): 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',
Expand All @@ -72,7 +107,8 @@ function makeValidator(userConfig: ConfigData | null): HtmlValidate {
},
...(userConfig?.elements ? { elements: userConfig.elements } : {}),
...(userConfig?.root ? { root: userConfig.root } : {}),
});
};
return new HtmlValidate(new StaticConfigLoader([resolver], config));
}

// Recognized template file extensions:
Expand Down Expand Up @@ -145,7 +181,7 @@ function printUsage(): void {
' N=0 disables multipass and falls back to the single-branch heuristic.\n' +
'\n' +
' Pass any mix of .gts/.gjs/.hbs files and directories. Directories are walked recursively.\n' +
' Exits non-zero when any file has errors.\n',
' Exits non-zero when any file has errors or fails to validate.\n',
);
}

Expand Down Expand Up @@ -218,13 +254,28 @@ function printUsage(): void {
process.exit(2);
}

// Look for `.htmlvalidate.json` in the first target's ancestors. If
// present, merge the user's rules / extends / transform into our
// 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 = await loadProjectConfig(files[0]!);
// Transformers resolve lazily, on first use, so local ones are added here.
const localFiles = new Set<string>(
Object.values(project?.config.transform ?? {}).filter(
(t): t is string => typeof t === 'string' && path.isAbsolute(t) && fs.existsSync(t),
),
);
const htmlvalidate = makeValidator(
project?.config ?? null,
recordingResolver(project?.dir ?? process.cwd(), localFiles),
);
// Resolve once up front: a broken plugin fails here with one message, and
// the local files it loads are known before the cache keys are computed.
try {
await htmlvalidate.getConfigFor(files[0]!);
} catch (err) {
process.stderr.write(
`[validate-gts] failed to load the configuration: ${err instanceof Error ? err.message : String(err)}\n`,
);
process.exit(2);
}
const configForCacheKey = { config: project?.config ?? null, localFiles: localFilesSha(localFiles) };

// 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 +297,7 @@ function printUsage(): void {
const key = reportCacheKey(
file,
fs.readFileSync(file, 'utf8'),
userConfig,
configForCacheKey,
htmlValidateVersion,
tsconfigPath,
tsconfigPath ? backendKindFor(tsconfigPath) : 'none',
Expand Down Expand Up @@ -338,6 +389,7 @@ function printUsage(): void {
let totalWarnings = 0;
let valid = 0;
let invalid = 0;
let fatal = 0;
const ruleCounts = new Map<string, number>();

// Validation progress: only shown in --quiet TTY mode (otherwise per-file
Expand Down Expand Up @@ -393,6 +445,7 @@ function printUsage(): void {
err instanceof Error ? err.message : String(err)
}\n`,
);
fatal++;
tickValidation();
continue;
}
Expand Down Expand Up @@ -446,6 +499,9 @@ function printUsage(): void {
process.stdout.write(`files: ${files.length}\n`);
process.stdout.write(`clean: ${valid}\n`);
process.stdout.write(`with errors: ${invalid}\n`);
if (fatal > 0) {
process.stdout.write(`failed to validate: ${fatal}\n`);
}
process.stdout.write(`total errors: ${totalErrors}\n`);
if (totalWarnings > 0) {
process.stdout.write(`total warnings: ${totalWarnings}\n`);
Expand All @@ -458,7 +514,7 @@ function printUsage(): void {
}
}

if (totalErrors > 0) {
if (totalErrors > 0 || fatal > 0) {
process.exit(1);
}
})().catch((err: unknown) => {
Expand Down
3 changes: 2 additions & 1 deletion test/cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,10 +162,11 @@ describe('transform cache', () => {
content: '<div> </div>',
error: null,
dynamicContentOffsets: [5],
componentInvocationOffsets: [3],
attrInjections: [[3, [{ attr: 'type', value: 'button' }, { attr: 'hidden', value: null }]]],
disablePerElement: [[0, ['no-inline-style']]],
},
{ content: '<div> </div>', error: 'unclosed', dynamicContentOffsets: [], attrInjections: [], disablePerElement: [] },
{ content: '<div> </div>', error: 'unclosed', dynamicContentOffsets: [], componentInvocationOffsets: [], attrInjections: [], disablePerElement: [] },
],
},
];
Expand Down
Loading
Loading