html-validate-vue
Version:
vue transform for html-validate
528 lines (506 loc) • 13.1 kB
JavaScript
// src/index.ts
import { compatibilityCheck } from "html-validate";
// package.json
var name = "html-validate-vue";
var peerDependencies = {
"html-validate": "^8.10.0 || ^9.0.0"
};
// elements.json
var elements_default = {
component: {
flow: true,
phrasing: true,
transparent: true,
component: "is",
requiredAttributes: ["is"]
},
"keep-alive": {
flow: true,
phrasing: true,
transparent: true
},
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
},
"router-view": {
flow: true,
slots: ["default"]
},
slot: {
flow: true,
phrasing: true,
transparent: true,
scriptSupporting: true
},
suspense: {
flow: true,
phrasing: true,
transparent: true,
slots: ["default", "fallback"]
},
teleport: {
flow: true,
phrasing: true
},
transition: {
flow: true,
phrasing: true,
transparent: true
},
"transition-group": {
flow: true,
phrasing: true,
transparent: true,
component: "tag"
}
};
// 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-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 } from "html-validate";
// src/utils/strip-templating.ts
function stripTemplating(str) {
return str.replace(/{{(.*?)}}/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;
return [slot, attr.keyLocation];
}
if (attr.key === "slot" && typeof attr.value === "string") {
return [attr.value.toString(), 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;
doc.visitDepthFirst((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/prefer-slot-shorthand.ts
import { Rule as Rule2, DynamicValue } 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 (/^\[.*\]$/.exec(slot)) {
return null;
}
return slot;
}
function isDeprecatedSlot(attr, value) {
if (attr !== "slot") {
return null;
}
if (value instanceof DynamicValue) {
return null;
}
if (value === null || value === "") {
return null;
}
return value.toString();
}
var PreferSlotShorthand = class extends Rule2 {
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 Rule3 } 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 Rule3 {
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;
doc.visitDepthFirst((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/prefer-slot-shorthand": PreferSlotShorthand,
"vue/required-slots": RequiredSlots
};
var rules_default = rules;
// src/hooks/attribute.ts
import { DynamicValue as DynamicValue2 } from "html-validate";
function* processAttribute(attr) {
yield attr;
const bind = /^(?:v-bind)?:(.*)$/.exec(attr.key);
if (bind) {
yield {
...attr,
key: bind[1],
value: new DynamicValue2(attr.value ? String(attr.value) : ""),
originalAttribute: attr.key
};
}
}
// src/hooks/element.ts
import {
DynamicValue as DynamicValue3
} 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 (isBound(node) || isSlot(node)) {
node.appendText(new DynamicValue3(""), 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* transformSFC(source) {
const sfc = /^(<template([^>]*)>)([^]*?)^<\/template>/gm;
let match;
while ((match = sfc.exec(source.data)) !== null) {
const [, preamble, attr, data] = match;
if (!/lang=".*?"/.exec(attr)) {
const [line, column] = findLocation(source.data, match.index, preamble.length);
yield {
data: stripTemplating(data),
filename: source.filename,
line: line + (source.line - 1),
column: column + (source.column - 1),
offset: match.index + (source.offset || 0) + preamble.length,
originalData: source.originalData ?? source.data,
hooks: {
processAttribute,
processElement
}
};
}
}
}
transformSFC.api = 1;
// src/transform/auto.transform.ts
function isVue(source) {
return !!/\.vue$/i.exec(source.filename);
}
function isJavascript(source) {
return !!/\.(jsx?|tsx?)$/i.exec(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/schema.json
var schema_default = {
properties: {
component: {
type: "string",
copyable: true
},
requiredSlots: {
type: "array",
items: {
type: "string"
}
},
slots: {
type: "array",
items: {
type: "string"
}
}
}
};
// 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;
// src/entry-esm.ts
var entry_esm_default = src_default;
export {
entry_esm_default as default
};
//# sourceMappingURL=index.mjs.map