html-validate-vue
Version:
vue transform for html-validate
813 lines (790 loc) • 19.6 kB
JavaScript
// 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