UNPKG

html-validate-vue

Version:

vue transform for html-validate

813 lines (790 loc) 19.6 kB
// src/index.ts import { compatibilityCheck } from "html-validate"; // package.json var name = "html-validate-vue"; var peerDependencies = { "html-validate": "^8.21.0 || ^9.0.0 || ^10.0.0 || ^11.0.0" }; // src/elements.ts import { defineMetadata } from "html-validate"; var elements_default = defineMetadata({ "*": { attributes: { /* https://vuejs.org/api/built-in-special-attributes.html#is */ is: {}, /* https://vuejs.org/api/built-in-special-attributes.html#key */ key: {}, /* https://vuejs.org/api/built-in-special-attributes.html#ref */ ref: {}, /* directives */ "v-*": {}, /* props */ ":*": {}, /* event listeners */ "@*": {} } }, component: { flow: true, phrasing: true, transparent: true, component: "is", attributes: { is: { required: true }, /* <component> can handle arbitrary props */ "*": {} } }, "keep-alive": { flow: true, phrasing: true, transparent: true, attributes: { include: {}, exclude: {}, max: { enum: [/^\d+$/] } } }, portal: { deprecated: { source: "vue", message: "the <portal> element has been renamed to <teleport>", documentation: "The `<portal>` element has been renamed to `<teleport>`" } }, "router-link": { flow: true, phrasing: true, transparent: true, attributes: { "active-class": { enum: ["/.+/"] }, "aria-current-value": { enum: ["location", "time", "true", "false", "page", "step", "date"] }, custom: { boolean: true }, "exact-active-class": { enum: ["/.+/"] }, replace: { boolean: true }, to: { enum: ["/.+/"], required: true } } }, "router-view": { flow: true, slots: ["default"], attributes: { name: { enum: ["/.+/"] }, route: {} } }, slot: { flow: true, phrasing: true, transparent: true, scriptSupporting: true, attributes: { name: {}, /* scoped slot can take arbitrary bindings */ "*": {} } }, suspense: { flow: true, phrasing: true, transparent: true, slots: ["default", "fallback"], attributes: { timeout: { enum: [/^\d+$/] }, suspensible: { boolean: true } } }, teleport: { flow: true, phrasing: true, attributes: { defer: { boolean: true }, disabled: { boolean: true }, to: { enum: ["/.+/"] } } }, template: { attributes: { "#*": {} } }, transition: { flow: true, phrasing: true, transparent: true, attributes: { name: { enum: ["/.+/"] }, "enter-from-class": { enum: ["/.+/"] }, "enter-active-class": { enum: ["/.+/"] }, "enter-to-class": { enum: ["/.+/"] }, "leave-from-class": { enum: ["/.+/"] }, "leave-active-class": { enum: ["/.+/"] }, "leave-to-class": { enum: ["/.+/"] }, type: { enum: ["transition", "animation"] }, duration: { enum: [/^\d+$/] }, css: { boolean: true }, appear: { boolean: true }, mode: { enum: ["in-out", "out-in", "default"] } } }, "transition-group": { flow: true, phrasing: true, transparent: true, component: "tag", attributes: { name: { enum: ["/.+/"] }, "enter-from-class": { enum: ["/.+/"] }, "enter-active-class": { enum: ["/.+/"] }, "enter-to-class": { enum: ["/.+/"] }, "leave-from-class": { enum: ["/.+/"] }, "leave-active-class": { enum: ["/.+/"] }, "leave-to-class": { enum: ["/.+/"] }, type: { enum: ["transition", "animation"] }, duration: { enum: [/^\d+$/] }, css: { boolean: true }, appear: { boolean: true }, "move-class": { enum: ["/.+/"] }, tag: { enum: ["/.+/"] } } } }); // src/configs/recommended.ts function createConfig() { const config = { elements: [elements_default], rules: { /* vue modifiers often use camelcase so allow by default */ "attr-case": ["error", { style: "camelcase" }], /* self closing tags often used in vue code so allow by default*/ "void-style": ["error", { style: "selfclose" }], "no-self-closing": "off", /* enable rules from this plugin */ "vue/available-slots": "error", "vue/prefer-boolean-shorthand": "error", "vue/prefer-slot-shorthand": "error", "vue/required-slots": "error" } }; return config; } var recommended_default = createConfig(); // src/configs/index.ts var configs_default = { recommended: recommended_default }; // src/rules/available-slots.ts import { Rule, walk } from "html-validate"; // src/utils/strip-templating.ts function stripTemplating(str) { return str.replaceAll(/{{(.*?)}}/g, (_, m) => { const filler = " ".repeat(m.length); return `{{${filler}}}`; }); } // src/utils/slots.ts var slotAttr = /(?:v-slot:|#)([^[]+)/; function haveSlot(value) { return value !== null; } function findUsedSlots(node) { const template = node.querySelectorAll(">template, >[slot]"); const slots = template.map((child) => findSlotAttribute(child)).filter(haveSlot); const initial = /* @__PURE__ */ new Map(); return slots.reduce((result, [key, val]) => { result.set(key, val); return result; }, initial); } function findSlotAttribute(node) { for (const attr of node.attributes) { const match = slotAttr.exec(attr.key); if (match) { const slot = match[1]; return [slot, attr.keyLocation]; } if (attr.key === "slot" && typeof attr.value === "string") { return [attr.value, attr.valueLocation]; } } return null; } // src/rules/available-slots.ts function difference(a, b) { const result = new Set(a); for (const elem of b) { result.delete(elem); } return result; } var AvailableSlots = class extends Rule { documentation(context) { if (context) { return { description: [ `The <${context.element}> component does not have a slot named "${context.slot}". Only known slots can be specified.`, "", "The following slots are available" ].concat(context.available.map((slot) => `- ${slot}`)).join("\n") }; } else { return { description: "Only known slots can be specified." }; } } setup() { this.on("dom:ready", (event) => { const doc = event.document; walk.depthFirst(doc, (node) => { const meta = node.meta; if (!meta?.slots) { return; } this.validateSlots(node, meta.slots); }); }); } validateSlots(node, slots) { const usedSlots = findUsedSlots(node); const available = new Set(slots); const used = new Set(usedSlots.keys()); const diff = difference(used, available); for (const missing of diff) { const context = { element: node.tagName, slot: missing, available: slots }; this.report( node, `<${node.tagName}> component has no slot "${missing}"`, usedSlots.get(missing), context ); } } }; // src/rules/deprecated-slot.ts import { Rule as Rule2, walk as walk2 } from "html-validate"; var DeprecatedSlot = class extends Rule2 { documentation(context) { if (context) { const preamble = `The slot \`${context.slot}\` is deprecated and must not be used`; return { description: context.info ? `${preamble}: ${context.info}.` : `${preamble}.` }; } else { return { description: "This slot is deprecated and must not be used." }; } } setup() { this.on("dom:ready", (event) => { const doc = event.document; walk2.depthFirst(doc, (node) => { const deprecatedSlots = node.meta?.deprecatedSlots; if (!deprecatedSlots) { return; } this.validateSlots(node, deprecatedSlots); }); }); } validateSlots(node, deprecatedSlots) { const slots = findUsedSlots(node); for (const [slot, location] of slots.entries()) { const deprecated = deprecatedSlots[slot]; if (!deprecated) { continue; } const context = { element: node.tagName, slot, info: typeof deprecated === "string" ? deprecated : null }; const preamble = `slot "${slot}" is deprecated`; const message = typeof deprecated === "string" ? `${preamble}: ${deprecated}` : preamble; this.report({ node, message, location, context }); } } }; // src/rules/prefer-boolean-shorthand.ts import { DynamicValue, Rule as Rule3 } from "html-validate"; var PreferBooleanShorthand = class extends Rule3 { documentation(context) { const { prop, originalAttribute } = context; return { description: [ `Use \`${prop}\` instead of \`${originalAttribute}="true"\`.`, "", `The \`${prop}\` prop is declared as a boolean. Vue.js allows omitting the value for boolean props, so \`${prop}\` is equivalent to \`${originalAttribute}="true"\`.` ].join("\n") }; } setup() { this.on("attr", (event) => { const { key, value, meta, originalAttribute, target, location } = event; if (!originalAttribute) { return; } if (!meta?.boolean) { return; } if (!(value instanceof DynamicValue) || value.expr !== "true") { return; } const context = { prop: key, originalAttribute }; const message = `Use "${key}" instead of "${originalAttribute}="true""`; this.report(target, message, location, context); }); } }; // src/rules/prefer-slot-shorthand.ts import { DynamicValue as DynamicValue2, Rule as Rule4 } from "html-validate"; var prefix = "v-slot:"; function isVSlot(attr) { if (!attr.startsWith(prefix)) { return null; } if (attr.length <= prefix.length) { return null; } const slot = attr.slice(prefix.length); if (/^\[.*]$/.test(slot)) { return null; } return slot; } function isDeprecatedSlot(attr, value) { if (attr !== "slot") { return null; } if (value instanceof DynamicValue2) { return null; } if (value === null || value === "") { return null; } return value; } var PreferSlotShorthand = class extends Rule4 { documentation(context) { const slot = context?.slot ?? "my-slot"; return { description: [ `Prefer to use \`#${slot}\` over \`v-slot:${slot}\`.`, "", "Vue.js supports a shorthand syntax for slots using `#` instead of `v-slot:` or the deprecated `slot` attribute, e.g. using `#header` instead of `v-slot:header`." ].join("\n") }; } setup() { this.on("attr", (event) => { const { key: attr, value, target, keyLocation: location } = event; let slot; slot = isVSlot(attr); if (slot) { const context = { slot }; const message = `Prefer to use #${slot} over ${attr}`; this.report(target, message, location, context); } slot = isDeprecatedSlot(attr, value); if (slot) { const context = { slot }; const message = `Prefer to use #${slot} over ${attr}="${value.toString()}"`; this.report(target, message, location, context); } }); } }; // src/rules/required-slots.ts import { Rule as Rule5, walk as walk3 } from "html-validate"; function difference2(a, b) { const result = new Set(a); for (const elem of b) { result.delete(elem); } return result; } var RequiredSlots = class extends Rule5 { documentation(context) { if (context) { return { description: [ `The <${context.element}> component requires slot "${context.slot}" to be implemented. Add \`<template v-slot:${context.slot}>\``, "", "The following slots are required" ].concat(context.required.map((slot) => `- ${slot}`)).join("\n") }; } else { return { description: "All required slots must be implemented." }; } } setup() { this.on("dom:ready", (event) => { const doc = event.document; walk3.depthFirst(doc, (node) => { const meta = node.meta; if (!meta?.requiredSlots) { return; } this.validateSlots(node, meta.requiredSlots); }); }); } validateSlots(node, requiredSlots) { const usedSlots = findUsedSlots(node); const required = new Set(requiredSlots); const used = new Set(usedSlots.keys()); const diff = difference2(required, used); for (const missing of diff) { const context = { element: node.tagName, slot: missing, required: requiredSlots }; this.report( node, `<${node.tagName}> component requires slot "${missing}" to be implemented`, null, context ); } } }; // src/rules/index.ts var rules = { "vue/available-slots": AvailableSlots, "vue/deprecated-slot": DeprecatedSlot, "vue/prefer-boolean-shorthand": PreferBooleanShorthand, "vue/prefer-slot-shorthand": PreferSlotShorthand, "vue/required-slots": RequiredSlots }; var rules_default = rules; // src/schema.json var schema_default = { properties: { component: { type: "string", copyable: true }, deprecatedSlots: { type: "object", patternProperties: { ".*": { anyOf: [{ type: "boolean" }, { type: "string" }] } } }, requiredSlots: { type: "array", items: { type: "string" } }, slots: { type: "array", items: { type: "string" } } } }; // src/hooks/attribute.ts import { DynamicValue as DynamicValue3 } from "html-validate"; function* processAttribute(attr) { yield attr; const bind = /^(?:v-bind)?:(.*)$/.exec(attr.key); if (bind) { const key = bind[1]; yield { ...attr, key, value: new DynamicValue3(attr.value ? String(attr.value) : ""), originalAttribute: attr.key }; } } // src/hooks/element.ts import { DynamicValue as DynamicValue4 } from "html-validate"; function isBound(node) { return node.hasAttribute("v-html"); } function isSlot(node) { return node.is("slot"); } function loadSlotMeta(context, slotname, node) { const parent = node.parent; if (!parent) { return; } const getMetaFor = (tagName) => { const meta2 = context.getMetaFor(tagName); return meta2 && meta2.tagName !== "*" ? meta2 : null; }; const key = `${parent.tagName}#${slotname}`; const fallbackKey = `${parent.tagName}:${slotname}`; const meta = getMetaFor(key) ?? getMetaFor(fallbackKey); if (!meta) { return; } node.loadMeta(meta); node.setAnnotation(`slot "${slotname}" (<${parent.tagName}>)`); } function loadComponentMeta(context, node, component) { let src = node; if (node.tagName === "template" && src.parent) { src = src.parent; } const attr = src.getAttribute(component); if (!attr || attr.isDynamic) { return; } const tagName = attr.value; const meta = context.getMetaFor(tagName); if (!meta || meta.tagName === "*") { return; } node.loadMeta(meta); if (node.isSameNode(src)) { node.setAnnotation(`<${meta.tagName}> (<${node.tagName}>)`); } else { const name2 = node.annotatedName.replace("(<", `(<${meta.tagName}> via <`); node.setAnnotation(name2); } } function processElement(node) { const slot = findSlotAttribute(node); if (slot) { const [slotname] = slot; loadSlotMeta(this, slotname, node); } if (node.meta?.component) { loadComponentMeta(this, node, node.meta.component); } if (node.is("template") && node.meta) { node.meta.templateRoot = false; } if (isBound(node) || isSlot(node)) { node.appendText(new DynamicValue4(""), node.location); } } // src/transform/html.transform.ts function* transformHTML(source) { yield { ...source, hooks: { processAttribute, processElement } }; } transformHTML.api = 1; // src/transform/js.transform.ts import { TemplateExtractor } from "@html-validate/plugin-utils"; function transformJS(source) { const te = TemplateExtractor.fromString(source.data, source.filename); return Array.from(te.extractObjectProperty("template"), (cur) => { cur.originalData = source.originalData ?? source.data; cur.hooks = { processAttribute, processElement }; return cur; }); } transformJS.api = 1; // src/transform/sfc.transform.ts function findLocation(source, index, preamble) { let line = 1; let prev = 0; let pos = source.indexOf("\n"); while (pos !== -1) { if (pos > index) { return [line, index - prev + preamble + 1]; } line++; prev = pos; pos = source.indexOf("\n", pos + 1); } return [line, 1]; } function computeIndent(text) { const lines = text.split("\n"); const initial = null; const common = lines.reduce((common2, line) => { const match = /^(\s*)\S/.exec(line); if (match) { const indent = match[1]; return common2 === null || indent.length < common2.length ? indent : common2; } else { return common2; } }, initial); return common ?? ""; } function* transformSFC(source) { const indent = computeIndent(source.data); const sfc = new RegExp(`^${indent}(<template([^>]*)>)([^]*?)^${indent}(</template>)`, "gm"); let match; while ((match = sfc.exec(source.data)) !== null) { const attr = match[2] ?? ""; const data = `${match[1]}${match[3]}${match[4]}`; if (!/lang=".*?"/.test(attr)) { const [line, column] = findLocation(source.data, match.index, 0); yield { data: stripTemplating(data), filename: source.filename, line: line + (source.line - 1), column: column + (source.column - 1), offset: match.index + source.offset, originalData: source.originalData ?? source.data, hooks: { processAttribute, processElement } }; } } } transformSFC.api = 1; // src/transform/auto.transform.ts function isVue(source) { return /\.vue$/i.test(source.filename); } function isJavascript(source) { return /\.(jsx?|tsx?)$/i.test(source.filename); } function* autodetect(source) { if (isVue(source)) { yield* transformSFC(source); } else if (isJavascript(source)) { yield* transformJS(source); } else { yield* transformHTML(source); } } autodetect.api = 1; // src/transform/index.ts var transformer = { default: autodetect, auto: autodetect, js: transformJS, sfc: transformSFC, html: transformHTML }; var transform_default = transformer; // src/index.ts var range = peerDependencies["html-validate"]; compatibilityCheck(name, range); var plugin = { name, configs: configs_default, rules: rules_default, transformer: transform_default, elementSchema: schema_default }; var src_default = plugin; export { src_default as default }; //# sourceMappingURL=index.mjs.map