UNPKG

html-validate

Version:

Offline HTML5 validator and linter

859 lines (846 loc) 27.4 kB
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