html-validate
Version:
Offline HTML5 validator and linter
859 lines (846 loc) • 27.4 kB
JavaScript
import fs, { existsSync } from 'node:fs';
import { f as StaticConfigLoader, K as normalizeSource, L as transformSource, O as Engine, P as Parser, Q as transformSourceSync, X as transformFilename, Y as transformFilenameSync, B as Reporter, Z as configurationSchema, _ as isThenable, U as UserError, b as ConfigLoader, a as ConfigError, C as Config, $ as compatibilityCheckImpl, v as version } from './core.js';
import path from 'node:path';
import fs$1 from 'node:fs/promises';
import { pathToFileURL } from 'node:url';
import { createRequire } from 'node:module';
import kleur from 'kleur';
function requireUncached(require, moduleId) {
const filename = require.resolve(moduleId);
const m = require.cache[filename];
if (m?.parent) {
const { parent } = m;
for (let i = parent.children.length - 1; i >= 0; i--) {
if (parent.children[i].id === filename) {
parent.children.splice(i, 1);
}
}
}
delete require.cache[filename];
return require(filename);
}
const defaultFS = {
readFileSync: fs.readFileSync
};
function isSourceHooks(value) {
if (!value || typeof value === "string") {
return false;
}
return Boolean(value.processAttribute ?? value.processElement);
}
function isConfigData(value) {
if (!value || typeof value === "string") {
return false;
}
return !(value.processAttribute ?? value.processElement);
}
class HtmlValidate {
configLoader;
constructor(arg) {
const [loader, config] = arg instanceof ConfigLoader ? [arg, void 0] : [void 0, arg];
this.configLoader = loader ?? new StaticConfigLoader(config);
}
/* eslint-enable @typescript-eslint/unified-signatures */
validateString(str, arg1, arg2, arg3) {
const filename = typeof arg1 === "string" ? arg1 : "inline";
const options = isConfigData(arg1) ? arg1 : isConfigData(arg2) ? arg2 : void 0;
const hooks = isSourceHooks(arg1) ? arg1 : isSourceHooks(arg2) ? arg2 : arg3;
const source = {
data: str,
filename,
line: 1,
column: 1,
offset: 0,
hooks
};
return this.validateSource(source, options);
}
/* eslint-enable @typescript-eslint/unified-signatures */
validateStringSync(str, arg1, arg2, arg3) {
const filename = typeof arg1 === "string" ? arg1 : "inline";
const options = isConfigData(arg1) ? arg1 : isConfigData(arg2) ? arg2 : void 0;
const hooks = isSourceHooks(arg1) ? arg1 : isSourceHooks(arg2) ? arg2 : arg3;
const source = {
data: str,
filename,
line: 1,
column: 1,
offset: 0,
hooks
};
return this.validateSourceSync(source, options);
}
/**
* Parse and validate HTML from [[Source]].
*
* @public
* @param input - Source to parse.
* @returns Report output.
*/
async validateSource(input, configOverride) {
const source = normalizeSource(input);
const config = await this.getConfigFor(source.filename, configOverride);
const resolvers = this.configLoader.getResolvers();
const transformedSource = await transformSource(resolvers, config, source);
const engine = new Engine(config, Parser);
return engine.lint(transformedSource);
}
/**
* Parse and validate HTML from [[Source]].
*
* @public
* @param input - Source to parse.
* @returns Report output.
*/
validateSourceSync(input, configOverride) {
const source = normalizeSource(input);
const config = this.getConfigForSync(source.filename, configOverride);
const resolvers = this.configLoader.getResolvers();
const transformedSource = transformSourceSync(resolvers, config, source);
const engine = new Engine(config, Parser);
return engine.lint(transformedSource);
}
/**
* Parse and validate HTML from file.
*
* @public
* @param filename - Filename to read and parse.
* @returns Report output.
*/
async validateFile(filename, fs2 = defaultFS) {
const config = await this.getConfigFor(filename);
const resolvers = this.configLoader.getResolvers();
const source = await transformFilename(resolvers, config, filename, fs2);
const engine = new Engine(config, Parser);
return Promise.resolve(engine.lint(source));
}
/**
* Parse and validate HTML from file.
*
* @public
* @param filename - Filename to read and parse.
* @returns Report output.
*/
validateFileSync(filename, fs2 = defaultFS) {
const config = this.getConfigForSync(filename);
const resolvers = this.configLoader.getResolvers();
const source = transformFilenameSync(resolvers, config, filename, fs2);
const engine = new Engine(config, Parser);
return engine.lint(source);
}
/**
* Parse and validate HTML from multiple files. Result is merged together to a
* single report.
*
* @param filenames - Filenames to read and parse.
* @returns Report output.
*/
async validateMultipleFiles(filenames, fs2 = defaultFS) {
return Reporter.merge(filenames.map((filename) => this.validateFile(filename, fs2)));
}
/**
* Parse and validate HTML from multiple files. Result is merged together to a
* single report.
*
* @param filenames - Filenames to read and parse.
* @returns Report output.
*/
validateMultipleFilesSync(filenames, fs2 = defaultFS) {
return Reporter.merge(filenames.map((filename) => this.validateFileSync(filename, fs2)));
}
/**
* Returns true if the given filename can be validated.
*
* A file is considered to be validatable if the extension is `.html` or if a
* transformer matches the filename.
*
* This is mostly useful for tooling to determine whenever to validate the
* file or not. CLI tools will run on all the given files anyway.
*/
async canValidate(filename) {
if (filename.toLowerCase().endsWith(".html")) {
return true;
}
const config = await this.getConfigFor(filename);
return config.canTransform(filename);
}
/**
* Returns true if the given filename can be validated.
*
* A file is considered to be validatable if the extension is `.html` or if a
* transformer matches the filename.
*
* This is mostly useful for tooling to determine whenever to validate the
* file or not. CLI tools will run on all the given files anyway.
*/
canValidateSync(filename) {
if (filename.toLowerCase().endsWith(".html")) {
return true;
}
const config = this.getConfigForSync(filename);
return config.canTransform(filename);
}
/**
* Tokenize filename and output all tokens.
*
* Using CLI this is enabled with `--dump-tokens`. Mostly useful for
* debugging.
*
* @internal
* @param filename - Filename to tokenize.
*/
async dumpTokens(filename, fs2 = defaultFS) {
const config = await this.getConfigFor(filename);
const resolvers = this.configLoader.getResolvers();
const source = await transformFilename(resolvers, config, filename, fs2);
const engine = new Engine(config, Parser);
return engine.dumpTokens(source);
}
/**
* Parse filename and output all events.
*
* Using CLI this is enabled with `--dump-events`. Mostly useful for
* debugging.
*
* @internal
* @param filename - Filename to dump events from.
*/
async dumpEvents(filename, fs2 = defaultFS) {
const config = await this.getConfigFor(filename);
const resolvers = this.configLoader.getResolvers();
const source = await transformFilename(resolvers, config, filename, fs2);
const engine = new Engine(config, Parser);
return engine.dumpEvents(source);
}
/**
* Parse filename and output DOM tree.
*
* Using CLI this is enabled with `--dump-tree`. Mostly useful for
* debugging.
*
* @internal
* @param filename - Filename to dump DOM tree from.
*/
async dumpTree(filename, fs2 = defaultFS) {
const config = await this.getConfigFor(filename);
const resolvers = this.configLoader.getResolvers();
const source = await transformFilename(resolvers, config, filename, fs2);
const engine = new Engine(config, Parser);
return engine.dumpTree(source);
}
/**
* Transform filename and output source data.
*
* Using CLI this is enabled with `--dump-source`. Mostly useful for
* debugging.
*
* @internal
* @param filename - Filename to dump source from.
*/
async dumpSource(filename, fs2 = defaultFS) {
const config = await this.getConfigFor(filename);
const resolvers = this.configLoader.getResolvers();
const sources = await transformFilename(resolvers, config, filename, fs2);
return sources.reduce((result, source) => {
const line = String(source.line);
const column = String(source.column);
const offset = String(source.offset);
result.push(`Source ${source.filename}@${line}:${column} (offset: ${offset})`);
if (source.transformedBy) {
result.push("Transformed by:");
result = result.concat(source.transformedBy.reverse().map((name) => ` - ${name}`));
}
if (source.hooks && Object.keys(source.hooks).length > 0) {
result.push("Hooks");
for (const [key, present] of Object.entries(source.hooks)) {
if (present) {
result.push(` - ${key}`);
}
}
}
result.push("---");
result = result.concat(source.data.split("\n"));
result.push("---");
return result;
}, []);
}
/**
* Get effective configuration schema.
*/
getConfigurationSchema() {
return Promise.resolve(configurationSchema);
}
/**
* Get effective metadata element schema.
*
* If a filename is given the configured plugins can extend the
* schema. Filename must not be an existing file or a filetype normally
* handled by html-validate but the path will be used when resolving
* configuration. As a rule-of-thumb, set it to the elements json file.
*/
async getElementsSchema(filename) {
const config = await this.getConfigFor(filename ?? "inline");
const metaTable = config.getMetaTable();
return metaTable.getJSONSchema();
}
/**
* Get effective metadata element schema.
*
* If a filename is given the configured plugins can extend the
* schema. Filename must not be an existing file or a filetype normally
* handled by html-validate but the path will be used when resolving
* configuration. As a rule-of-thumb, set it to the elements json file.
*/
getElementsSchemaSync(filename) {
const config = this.getConfigForSync(filename ?? "inline");
const metaTable = config.getMetaTable();
return metaTable.getJSONSchema();
}
async getContextualDocumentation(message, filenameOrConfig = "inline") {
const config = typeof filenameOrConfig === "string" ? await this.getConfigFor(filenameOrConfig) : await filenameOrConfig;
const engine = new Engine(config, Parser);
return engine.getRuleDocumentation(message);
}
getContextualDocumentationSync(message, filenameOrConfig = "inline") {
const config = typeof filenameOrConfig === "string" ? this.getConfigForSync(filenameOrConfig) : filenameOrConfig;
const engine = new Engine(config, Parser);
return engine.getRuleDocumentation(message);
}
/**
* Get contextual documentation for the given rule.
*
* Typical usage:
*
* ```js
* const report = await htmlvalidate.validateFile("my-file.html");
* for (const result of report.results){
* const config = await htmlvalidate.getConfigFor(result.filePath);
* for (const message of result.messages){
* const documentation = await htmlvalidate.getRuleDocumentation(message.ruleId, config, message.context);
* // do something with documentation
* }
* }
* ```
*
* @public
* @deprecated Deprecated since 8.0.0, use [[getContextualDocumentation]] instead.
* @param ruleId - Rule to get documentation for.
* @param config - If set it provides more accurate description by using the
* correct configuration for the file.
* @param context - If set to `Message.context` some rules can provide
* contextual details and suggestions.
*/
async getRuleDocumentation(ruleId, config = null, context = null) {
const c = config ?? this.getConfigFor("inline");
const engine = new Engine(await c, Parser);
return engine.getRuleDocumentation({ ruleId, context });
}
/**
* Get contextual documentation for the given rule.
*
* Typical usage:
*
* ```js
* const report = htmlvalidate.validateFileSync("my-file.html");
* for (const result of report.results){
* const config = htmlvalidate.getConfigForSync(result.filePath);
* for (const message of result.messages){
* const documentation = htmlvalidate.getRuleDocumentationSync(message.ruleId, config, message.context);
* // do something with documentation
* }
* }
* ```
*
* @public
* @deprecated Deprecated since 8.0.0, use [[getContextualDocumentationSync]] instead.
* @param ruleId - Rule to get documentation for.
* @param config - If set it provides more accurate description by using the
* correct configuration for the file.
* @param context - If set to `Message.context` some rules can provide
* contextual details and suggestions.
*/
getRuleDocumentationSync(ruleId, config = null, context = null) {
const c = config ?? this.getConfigForSync("inline");
const engine = new Engine(c, Parser);
return engine.getRuleDocumentation({ ruleId, context });
}
/**
* Create a parser configured for given filename.
*
* @internal
* @param source - Source to use.
*/
async getParserFor(source) {
const config = await this.getConfigFor(source.filename);
return new Parser(config);
}
/**
* Get configuration for given filename.
*
* See [[FileSystemConfigLoader]] for details.
*
* @public
* @param filename - Filename to get configuration for.
* @param configOverride - Configuration to apply last.
*/
getConfigFor(filename, configOverride) {
const config = this.configLoader.getConfigFor(filename, configOverride);
return Promise.resolve(config);
}
/**
* Get configuration for given filename.
*
* See [[FileSystemConfigLoader]] for details.
*
* @public
* @param filename - Filename to get configuration for.
* @param configOverride - Configuration to apply last.
*/
getConfigForSync(filename, configOverride) {
const config = this.configLoader.getConfigFor(filename, configOverride);
if (isThenable(config)) {
throw new UserError("Cannot use asynchronous config loader with synchronous api");
}
return config;
}
/**
* Get current configuration loader.
*
* @public
* @since %version%
* @returns Current configuration loader.
*/
/* istanbul ignore next -- not testing setters/getters */
getConfigLoader() {
return this.configLoader;
}
/**
* Set configuration loader.
*
* @public
* @since %version%
* @param loader - New configuration loader to use.
*/
/* istanbul ignore next -- not testing setters/getters */
setConfigLoader(loader) {
this.configLoader = loader;
}
/**
* Flush configuration cache. Clears full cache unless a filename is given.
*
* See [[FileSystemConfigLoader]] for details.
*
* @public
* @param filename - If set, only flush cache for given filename.
*/
flushConfigCache(filename) {
this.configLoader.flushCache(filename);
}
}
const legacyRequire = createRequire(import.meta.url);
const importResolve = (specifier) => {
return new URL(import.meta.resolve(specifier));
};
let cachedRootDir = null;
function determineRootDirImpl(intial, fs2) {
let current = intial;
while (true) {
const search = path.join(current, "package.json");
if (fs2.existsSync(search)) {
return current;
}
const child = current;
current = path.dirname(current);
if (current === child) {
break;
}
}
return intial;
}
function determineRootDir() {
cachedRootDir ??= determineRootDirImpl(process.cwd(), fs);
return cachedRootDir;
}
function expandRelativePath(value, { cwd }) {
if (typeof value === "string" && value.startsWith(".")) {
return path.normalize(path.join(cwd, value));
} else {
return value;
}
}
function isRequireError(error) {
return Boolean(error && typeof error === "object" && "code" in error);
}
function isTransformer$1(value) {
return typeof value === "function";
}
function cjsResolver(options = {}) {
const rootDir = options.rootDir ?? determineRootDir();
function internalRequire(id, { cache }) {
const moduleName = id.replace("<rootDir>", rootDir);
try {
if (cache) {
return legacyRequire(moduleName);
} else {
return requireUncached(legacyRequire, moduleName);
}
} catch (err) {
if (isRequireError(err) && err.code === "MODULE_NOT_FOUND") {
return null;
}
throw err;
}
}
return {
name: "nodejs-resolver",
resolveElements(id, options2) {
return internalRequire(id, options2);
},
resolveConfig(id, options2) {
const configData = internalRequire(id, options2);
if (!configData) {
return null;
}
const cwd = path.dirname(id);
const expand = (value) => expandRelativePath(value, { cwd });
if (Array.isArray(configData.elements)) {
configData.elements = configData.elements.map(expand);
}
if (Array.isArray(configData.extends)) {
configData.extends = configData.extends.map(expand);
}
if (Array.isArray(configData.plugins)) {
configData.plugins = configData.plugins.map(expand);
}
return configData;
},
resolvePlugin(id, options2) {
return internalRequire(id, options2);
},
resolveTransformer(id, options2) {
const mod = internalRequire(id, options2);
if (!mod) {
return null;
}
if (isTransformer$1(mod)) {
return mod;
}
if (mod.transformer) {
throw new ConfigError(
`Module "${id}" is not a valid transformer. This looks like a plugin, did you forget to load the plugin first?`
);
}
throw new ConfigError(`Module "${id}" is not a valid transformer.`);
}
};
}
function nodejsResolver(options = {}) {
return cjsResolver(options);
}
function importFunction(id) {
return import(id);
}
async function getModuleName(id, { cache, rootDir }) {
const moduleName = id.replace("<rootDir>", rootDir);
const url = existsSync(id) ? pathToFileURL(id) : importResolve(moduleName);
if (url.protocol !== "file:") {
return url;
}
if (cache) {
return url;
} else {
const stat = await fs$1.stat(url);
url.searchParams.append("mtime", String(stat.mtime.getTime()));
return url;
}
}
function isImportError(error) {
return Boolean(error && typeof error === "object" && "code" in error);
}
async function internalImport(id, rootDir, { cache }) {
if (id.endsWith(".json")) {
const content = await fs$1.readFile(id, "utf-8");
return JSON.parse(content);
}
try {
const url = await getModuleName(id, { cache, rootDir });
if (url.protocol !== "file:") {
return null;
}
const moduleName = url.toString();
const { default: defaultImport } = await importFunction(moduleName);
if (!defaultImport) {
throw new UserError(`"${id}" does not have a default export`);
}
return defaultImport;
} catch (err) {
if (isImportError(err) && err.code === "MODULE_NOT_FOUND" && !err.requireStack) {
return null;
}
throw err;
}
}
function isTransformer(value) {
return typeof value === "function";
}
function esmResolver(options = {}) {
const rootDir = options.rootDir ?? determineRootDir();
return {
name: "esm-resolver",
resolveElements(id, options2) {
return internalImport(id, rootDir, options2);
},
async resolveConfig(id, options2) {
const configData = await internalImport(id, rootDir, options2);
if (!configData) {
return null;
}
const cwd = path.dirname(id);
const expand = (value) => expandRelativePath(value, { cwd });
if (Array.isArray(configData.elements)) {
configData.elements = configData.elements.map(expand);
}
if (Array.isArray(configData.extends)) {
configData.extends = configData.extends.map(expand);
}
if (Array.isArray(configData.plugins)) {
configData.plugins = configData.plugins.map(expand);
}
return configData;
},
resolvePlugin(id, options2) {
return internalImport(id, rootDir, options2);
},
async resolveTransformer(id, options2) {
const mod = await internalImport(id, rootDir, options2);
if (!mod) {
return null;
}
if (isTransformer(mod)) {
return mod;
}
if (mod.transformer) {
throw new ConfigError(
`Module "${id}" is not a valid transformer. This looks like a plugin, did you forget to load the plugin first?`
);
}
throw new ConfigError(`Module "${id}" is not a valid transformer.`);
}
};
}
function findConfigurationFiles(fs2, directory) {
return ["json", "mjs", "cjs", "js"].map((extension) => path.join(directory, `.htmlvalidate.${extension}`)).filter((filePath) => fs2.existsSync(filePath));
}
const defaultResolvers = [esmResolver()];
function hasResolver(value) {
return Array.isArray(value[0]);
}
class FileSystemConfigLoader extends ConfigLoader {
cache;
fs;
constructor(...args) {
if (hasResolver(args)) {
const [resolvers, config, options = {}] = args;
super(resolvers, config);
this.fs = /* istanbul ignore next */
options.fs ?? fs;
} else {
const [config, options = {}] = args;
super(defaultResolvers, config);
this.fs = /* istanbul ignore next */
options.fs ?? fs;
}
this.cache = /* @__PURE__ */ new Map();
}
/**
* Get configuration for given filename.
*
* @param filename - Filename to get configuration for.
* @param configOverride - Configuration to merge final result with.
*/
getConfigFor(filename, configOverride) {
const override = this.loadFromObject(configOverride ?? {});
if (isThenable(override)) {
return override.then((override2) => {
return this._resolveAsync(filename, override2);
});
} else {
return this._resolveSync1(filename, override);
}
}
/**
* Flush configuration cache.
*
* @param filename - If given only the cache for that file is flushed.
*/
flushCache(filename) {
if (filename) {
this.cache.delete(filename);
} else {
this.cache.clear();
}
}
/**
* Load raw configuration from directory traversal.
*
* This configuration is not merged with global configuration and may return
* `null` if no configuration files are found.
*/
fromFilename(filename) {
if (filename === "inline") {
return null;
}
const cache = this.cache.get(filename);
if (cache) {
return cache;
}
let found = false;
let current = path.resolve(path.dirname(filename));
let config = this.empty();
while (true) {
for (const configFile of findConfigurationFiles(this.fs, current)) {
const local = this.loadFromFile(configFile);
if (isThenable(local)) {
return this.fromFilenameAsync(filename);
}
found = true;
const merged = local.merge(this.resolvers, config);
if (isThenable(merged)) {
throw new Error("internal error: async result ended up in sync path");
}
config = merged;
}
if (config.isRootFound()) {
break;
}
const child = current;
current = path.dirname(current);
if (current === child) {
break;
}
}
if (!found) {
this.cache.set(filename, null);
return null;
}
this.cache.set(filename, config);
return config;
}
/**
* Async version of [[fromFilename]].
*
* @internal
*/
async fromFilenameAsync(filename) {
if (filename === "inline") {
return null;
}
const cache = this.cache.get(filename);
if (cache) {
return cache;
}
let found = false;
let current = path.resolve(path.dirname(filename));
let config = this.empty();
while (true) {
for (const configFile of findConfigurationFiles(this.fs, current)) {
const local = await this.loadFromFile(configFile);
found = true;
config = await local.merge(this.resolvers, config);
}
if (config.isRootFound()) {
break;
}
const child = current;
current = path.dirname(current);
if (current === child) {
break;
}
}
if (!found) {
this.cache.set(filename, null);
return null;
}
this.cache.set(filename, config);
return config;
}
_merge(globalConfig, override, config) {
const merged = config ? config.merge(this.resolvers, override) : globalConfig.merge(this.resolvers, override);
if (isThenable(merged)) {
return merged.then((merged2) => {
return merged2.resolve();
});
} else {
return merged.resolve();
}
}
_resolveSync1(filename, override) {
if (override.isRootFound()) {
return override.resolve();
}
const globalConfig = this.getGlobalConfig();
if (isThenable(globalConfig)) {
return globalConfig.then((globalConfig2) => {
return this._resolveSync2(filename, override, globalConfig2);
});
} else {
return this._resolveSync2(filename, override, globalConfig);
}
}
_resolveSync2(filename, override, globalConfig) {
if (globalConfig.isRootFound()) {
const merged = globalConfig.merge(this.resolvers, override);
if (isThenable(merged)) {
return merged.then((merged2) => {
return merged2.resolve();
});
} else {
return merged.resolve();
}
}
const config = this.fromFilename(filename);
if (isThenable(config)) {
return config.then((config2) => {
return this._merge(globalConfig, override, config2);
});
} else {
return this._merge(globalConfig, override, config);
}
}
async _resolveAsync(filename, override) {
if (override.isRootFound()) {
return override.resolve();
}
const globalConfig = await this.getGlobalConfig();
if (globalConfig.isRootFound()) {
const merged = await globalConfig.merge(this.resolvers, override);
return merged.resolve();
}
const config = await this.fromFilenameAsync(filename);
return this._merge(globalConfig, override, config);
}
/**
* @internal For testing only
*/
_getInternalCache() {
return this.cache;
}
defaultConfig() {
return Config.defaultConfig();
}
}
const defaults = {
silent: false,
version,
logger(text) {
console.error(kleur.red(text));
}
};
function compatibilityCheck(name, declared, options) {
return compatibilityCheckImpl(name, declared, {
...defaults,
...options
});
}
export { FileSystemConfigLoader as F, HtmlValidate as H, compatibilityCheck as a, cjsResolver as c, esmResolver as e, legacyRequire as l, nodejsResolver as n };
//# sourceMappingURL=core-nodejs.js.map