html-validate
Version:
Offline HTML5 validator and linter
607 lines (588 loc) • 16.5 kB
JavaScript
import { l as legacyRequire, F as FileSystemConfigLoader, e as esmResolver, H as HtmlValidate } from './core-nodejs.js';
import { g as getFormatter$1, U as UserError, e as ensureError, i as ignore, d as deepmerge, J as engines, B as Reporter } from './core.js';
import path$1 from 'node:path/posix';
import fs from 'fs';
import path from 'node:path';
import { globSync } from 'glob';
import prompts from 'prompts';
import './meta-helper.js';
import fs$1 from 'node:fs';
import betterAjvErrors from '@sidvind/better-ajv-errors';
import kleur from 'kleur';
const DEFAULT_EXTENSIONS = ["html"];
function isDirectory(filename) {
const st = fs.statSync(filename);
return st.isDirectory();
}
function join(stem, filename) {
if (path.isAbsolute(filename)) {
return path.normalize(filename);
} else {
return path.normalize(path.join(stem, filename));
}
}
function directoryPattern(extensions) {
switch (extensions.length) {
case 0:
return "**/*";
case 1:
return `**/*.${extensions[0]}`;
default:
return `**/*.{${extensions.join(",")}}`;
}
}
function expandFiles(patterns, options) {
const cwd = options.cwd ?? process.cwd();
const extensions = options.extensions ?? DEFAULT_EXTENSIONS;
const files = patterns.reduce((result, pattern) => {
if (pattern === "-") {
result.push("/dev/stdin");
return result;
}
for (const filename of globSync(pattern, { cwd })) {
const fullpath = join(cwd, filename);
if (isDirectory(fullpath)) {
const dir = expandFiles([directoryPattern(extensions)], { ...options, cwd: fullpath });
result = result.concat(dir.map((cur) => join(filename, cur)));
continue;
}
result.push(fullpath);
}
return result.sort((a, b) => {
const pa = a.split("/").length;
const pb = b.split("/").length;
if (pa !== pb) {
return pa - pb;
} else {
return a > b ? 1 : -1;
}
});
}, []);
return Array.from(new Set(files));
}
function wrap(formatter, dst) {
return (results) => {
const output = formatter(results);
if (dst) {
const dir = path.dirname(dst);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(dst, output, "utf-8");
return "";
} else {
return output;
}
};
}
function loadFormatter(name) {
const fn = getFormatter$1(name);
if (fn) {
return fn;
}
try {
return legacyRequire(name);
} catch (error) {
throw new UserError(`No formatter named "${name}"`, ensureError(error));
}
}
function getFormatter(formatters) {
const fn = formatters.split(",").map((cur) => {
const [name, dst] = cur.split("=", 2);
const fn2 = loadFormatter(name);
return wrap(fn2, dst);
});
return (report) => {
return fn.map((formatter) => formatter(report.results)).filter(Boolean).join("\n");
};
}
class IsIgnored {
/** Cache for parsed .htmlvalidateignore files */
cacheIgnore;
constructor() {
this.cacheIgnore = /* @__PURE__ */ new Map();
}
/**
* Searches ".htmlvalidateignore" files from filesystem and returns `true` if
* one of them contains a pattern matching given filename.
*/
isIgnored(filename) {
return this.match(filename);
}
/**
* Clear cache
*/
clearCache() {
this.cacheIgnore.clear();
}
match(target) {
let current = path.dirname(target);
while (true) {
const relative = path.relative(current, target);
const filename = path.join(current, ".htmlvalidateignore");
const ig = this.parseFile(filename);
if (ig?.ignores(relative)) {
return true;
}
const child = current;
current = path.dirname(current);
if (current === child) {
break;
}
}
return false;
}
parseFile(filename) {
if (this.cacheIgnore.has(filename)) {
return this.cacheIgnore.get(filename);
}
if (!fs.existsSync(filename)) {
this.cacheIgnore.set(filename, void 0);
return void 0;
}
const content = fs.readFileSync(filename, "utf-8");
const ig = ignore().add(content);
this.cacheIgnore.set(filename, ig);
return ig;
}
}
const frameworkConfig = {
["AngularJS" /* angularjs */]: {
transform: {
"^.*\\.js$": "html-validate-angular/js",
"^.*\\.html$": "html-validate-angular/html"
}
},
["Vue.js" /* vuejs */]: {
plugins: ["html-validate-vue"],
extends: ["html-validate-vue:recommended"],
transform: {
"^.*\\.vue$": "html-validate-vue"
}
},
["Markdown" /* markdown */]: {
transform: {
"^.*\\.md$": "html-validate-markdown"
}
}
};
function addFrameworks(src, frameworks) {
let config = src;
for (const framework of frameworks) {
config = deepmerge(config, frameworkConfig[framework]);
}
return config;
}
function writeConfig(dst, config) {
return new Promise((resolve, reject) => {
fs.writeFile(dst, JSON.stringify(config, null, 2), (err) => {
if (err) reject(err);
resolve();
});
});
}
async function init$1(cwd) {
const filename = `${cwd}/.htmlvalidate.json`;
const exists = fs.existsSync(filename);
const initialConfig = {
elements: ["html5"],
extends: ["html-validate:recommended"]
};
if (exists) {
const result = await prompts({
name: "overwrite",
type: "confirm",
message: "A .htmlvalidate.json file already exists, do you want to overwrite it?"
});
if (!result.overwrite) {
return Promise.reject();
}
}
const questions = [
{
name: "frameworks",
type: "multiselect",
choices: [
{ title: "AngularJS" /* angularjs */, value: "AngularJS" /* angularjs */ },
{ title: "Vue.js" /* vuejs */, value: "Vue.js" /* vuejs */ },
{ title: "Markdown" /* markdown */, value: "Markdown" /* markdown */ }
],
message: "Support additional frameworks?"
}
];
const answers = await prompts(questions);
let config = initialConfig;
config = addFrameworks(config, answers.frameworks);
await writeConfig(filename, config);
return {
filename
};
}
function parseSeverity(ruleId, severity) {
switch (severity) {
case "off":
case "0":
return "off";
case "warn":
case "1":
return "warn";
case "error":
case "2":
return "error";
default:
throw new Error(`Invalid severity "${severity}" for rule "${ruleId}"`);
}
}
function parseItem(value) {
const [ruleId, severity = "error"] = value.split(":", 2);
return { ruleId, severity: parseSeverity(ruleId, severity) };
}
function getRuleConfig(values) {
if (typeof values === "string") {
return getRuleConfig([values]);
}
return values.reduce((parsedRules, value) => {
const { ruleId, severity } = parseItem(value.trim());
return { [ruleId]: severity, ...parsedRules };
}, {});
}
const resolver = esmResolver();
function defaultConfig(preset) {
const presets = preset.split(",").map((it) => `html-validate:${it}`);
return {
extends: presets
};
}
async function getBaseConfig(preset, filename) {
if (filename) {
const configData = await resolver.resolveConfig(path$1.resolve(filename), { cache: false });
if (!configData) {
throw new UserError(`Failed to read configuration from "${filename}"`);
}
return configData;
} else {
return defaultConfig(preset ?? "recommended");
}
}
class CLI {
options;
config;
loader;
ignored;
/**
* Create new CLI helper.
*
* Can be used to create tooling with similar properties to bundled CLI
* script.
*/
constructor(options) {
this.options = options ?? {};
this.config = null;
this.loader = null;
this.ignored = new IsIgnored();
}
/**
* Returns list of files matching patterns and are not ignored. Filenames will
* have absolute paths.
*
* @public
*/
async expandFiles(patterns, options = {}) {
const files = expandFiles(patterns, options).filter((filename) => !this.isIgnored(filename));
return Promise.resolve(files);
}
getFormatter(formatters) {
return Promise.resolve(getFormatter(formatters));
}
/**
* Initialize project with a new configuration.
*
* A new `.htmlvalidate.json` file will be placed in the path provided by
* `cwd`.
*/
init(cwd) {
return init$1(cwd);
}
/**
* Clear cache.
*
* Previously fetched [[HtmlValidate]] instances must either be fetched again
* or call [[HtmlValidate.flushConfigCache]].
*/
/* istanbul ignore next: each method is tested separately */
clearCache() {
if (this.loader) {
this.loader.flushCache();
}
this.ignored.clearCache();
return Promise.resolve();
}
/**
* Get HtmlValidate instance with configuration based on options passed to the
* constructor.
*
* @internal
*/
async getLoader() {
if (!this.loader) {
const config = await this.getConfig();
this.loader = new FileSystemConfigLoader([resolver], config);
}
return this.loader;
}
/**
* Get HtmlValidate instance with configuration based on options passed to the
* constructor.
*
* @public
*/
async getValidator() {
const loader = await this.getLoader();
return new HtmlValidate(loader);
}
/**
* @internal
*/
async getConfig() {
this.config ??= await this.resolveConfig();
return this.config;
}
/**
* Searches ".htmlvalidateignore" files from filesystem and returns `true` if
* one of them contains a pattern matching given filename.
*/
isIgnored(filename) {
return this.ignored.isIgnored(filename);
}
async resolveConfig() {
const { options } = this;
const havePreset = Boolean(options.preset);
const haveConfig = Boolean(options.configFile);
const config = await getBaseConfig(options.preset, options.configFile);
if (options.rules) {
if (havePreset || haveConfig) {
config.rules = { ...config.rules, ...getRuleConfig(options.rules) };
} else {
config.extends = [];
config.rules = getRuleConfig(options.rules);
}
}
return config;
}
}
function prettyError(err) {
let json;
if (err.filename && fs$1.existsSync(err.filename)) {
json = fs$1.readFileSync(err.filename, "utf-8");
}
return betterAjvErrors(err.schema, err.obj, err.errors, {
format: "cli",
indent: 2,
json
});
}
function handleSchemaValidationError(console, err) {
if (err.filename) {
const filename = path.relative(process.cwd(), err.filename);
console.error(kleur.red(`A configuration error was found in "${filename}":`));
} else {
console.error(kleur.red(`A configuration error was found:`));
}
console.group();
{
console.error(prettyError(err));
}
console.groupEnd();
}
class ImportResolveMissingError extends UserError {
constructor() {
const message = `import.meta.resolve(..) is not available on this system`;
super(message);
Error.captureStackTrace(this, ImportResolveMissingError);
this.name = ImportResolveMissingError.name;
}
prettyFormat() {
const { message } = this;
const currentVersion = process.version;
const requiredVersion = engines.node.split("||").map((it) => `v${it.replace(/^[^\d]+/, "").trim()}`);
return [
kleur.red(`Error: ${message}.`),
"",
`Either ensure you are running a supported NodeJS version:`,
` Current: ${currentVersion}`,
` Required: ${requiredVersion.join(", ")} or later`,
`Or set NODE_OPTIONS="--experimental-import-meta-resolve"`
].join("\n");
}
}
var Mode = /* @__PURE__ */ ((Mode2) => {
Mode2[Mode2["LINT"] = 0] = "LINT";
Mode2[Mode2["INIT"] = 1] = "INIT";
Mode2[Mode2["DUMP_EVENTS"] = 2] = "DUMP_EVENTS";
Mode2[Mode2["DUMP_TOKENS"] = 3] = "DUMP_TOKENS";
Mode2[Mode2["DUMP_TREE"] = 4] = "DUMP_TREE";
Mode2[Mode2["DUMP_SOURCE"] = 5] = "DUMP_SOURCE";
Mode2[Mode2["PRINT_CONFIG"] = 6] = "PRINT_CONFIG";
return Mode2;
})(Mode || {});
function modeToFlag(mode) {
switch (mode) {
case 0 /* LINT */:
return null;
case 1 /* INIT */:
return "--init";
case 2 /* DUMP_EVENTS */:
return "--dump-events";
case 3 /* DUMP_TOKENS */:
return "--dump-tokens";
case 4 /* DUMP_TREE */:
return "--dump-tree";
case 5 /* DUMP_SOURCE */:
return "--dump-source";
case 6 /* PRINT_CONFIG */:
return "--print-config";
}
}
function renameStdin(report, filename) {
const stdin = report.results.find((cur) => cur.filePath === "/dev/stdin");
if (stdin) {
stdin.filePath = filename;
}
}
async function lint(htmlvalidate, output, files, options) {
const reports = [];
for (const filename of files) {
try {
reports.push(await htmlvalidate.validateFile(filename));
} catch (err) {
const message = kleur.red(`Validator crashed when parsing "${filename}"`);
output.write(`${message}
`);
throw err;
}
}
const merged = Reporter.merge(reports);
if (options.stdinFilename) {
renameStdin(merged, options.stdinFilename);
}
output.write(options.formatter(merged));
if (options.maxWarnings >= 0 && merged.warningCount > options.maxWarnings) {
output.write(
`
html-validate found too many warnings (maximum: ${String(options.maxWarnings)}).
`
);
return false;
}
return merged.valid;
}
async function init(cli, output, options) {
const result = await cli.init(options.cwd);
output.write(`Configuration written to "${result.filename}"
`);
return true;
}
async function printConfig(htmlvalidate, output, files) {
if (files.length > 1) {
output.write(`\`--print-config\` expected a single filename but got multiple:
`);
for (const filename of files) {
output.write(` - ${filename}
`);
}
output.write("\n");
return false;
}
const config = await htmlvalidate.getConfigFor(files[0]);
const json = JSON.stringify(config.getConfigData(), null, 2);
output.write(`${json}
`);
return true;
}
const jsonIgnored = [
"annotation",
"blockedRules",
"cache",
"closed",
"depth",
"disabledRules",
"nodeType",
"unique",
"voidElement"
];
const jsonFiltered = [
"childNodes",
"children",
"data",
"meta",
"metaElement",
"originalData",
"parent"
];
function isLocation(key, value) {
return Boolean(value && (key === "location" || key.endsWith("Location")));
}
function isIgnored(key) {
return key.startsWith("_") || jsonIgnored.includes(key);
}
function isFiltered(key, value) {
return Boolean(value && jsonFiltered.includes(key));
}
function eventReplacer(key, value) {
if (isLocation(key, value)) {
const filename = value.filename;
const line = String(value.line);
const column = String(value.column);
return `${filename}:${line}:${column}`;
}
if (isIgnored(key)) {
return void 0;
}
if (isFiltered(key, value)) {
return "[truncated]";
}
return value;
}
function eventFormatter(entry) {
const strdata = JSON.stringify(entry.data, eventReplacer, 2);
return `${entry.event}: ${strdata}`;
}
async function dump(htmlvalidate, output, files, mode) {
let lines;
switch (mode) {
case Mode.DUMP_EVENTS:
lines = files.map(async (filename) => {
const lines2 = await htmlvalidate.dumpEvents(filename);
return lines2.map(eventFormatter);
});
break;
case Mode.DUMP_TOKENS:
lines = files.map(async (filename) => {
const lines2 = await htmlvalidate.dumpTokens(filename);
return lines2.map((entry) => {
const data = JSON.stringify(entry.data);
return `TOKEN: ${entry.token}
Data: ${data}
Location: ${entry.location}`;
});
});
break;
case Mode.DUMP_TREE:
lines = files.map((filename) => htmlvalidate.dumpTree(filename));
break;
case Mode.DUMP_SOURCE:
lines = files.map((filename) => htmlvalidate.dumpSource(filename));
break;
default:
throw new Error(`Unknown mode "${String(mode)}"`);
}
const flat = (await Promise.all(lines)).reduce((s, c) => s.concat(c), []);
output.write(flat.join("\n"));
output.write("\n");
return Promise.resolve(true);
}
function haveImportMetaResolve() {
return "resolve" in import.meta;
}
export { CLI as C, ImportResolveMissingError as I, Mode as M, handleSchemaValidationError as a, dump as d, haveImportMetaResolve as h, init as i, lint as l, modeToFlag as m, printConfig as p };
//# sourceMappingURL=cli.js.map