diff --git a/docs/rules/no-unused-selector.md b/docs/rules/no-unused-selector.md index d732e239..5df01d0f 100644 --- a/docs/rules/no-unused-selector.md +++ b/docs/rules/no-unused-selector.md @@ -88,12 +88,14 @@ This is a limitation of this rule. Without this limitation, the root element can ```json { "vue-scoped-css/no-unused-selector": ["error", { - "ignoreBEMModifier": false + "ignoreBEMModifier": false, + "captureClassesFromDoc": [] }] } ``` - `ignoreBEMModifier` ... Set `true` if you want to ignore the `BEM` modifier. Default is false. +- `captureClassesFromDoc` ... Specifies the regexp that extracts the class name from the documentation in the comments. Even if there is no matching element, no error is reported if the document of a class name exists in the comments. ### `"ignoreBEMModifier": true` @@ -111,13 +113,49 @@ This is a limitation of this rule. Without this limitation, the root element can +### `"captureClassesFromDoc": [ "/(\\.[a-z-]+)(?::[a-z-]+)?\\s+-\\s*[^\\r\\n]+/i" ]` + +Example of [KSS] format: + + + +```vue + + +``` + + + ## :books: Further reading - [vue-scoped-css/require-selector-used-inside] - [Vue Loader - Scoped CSS] +- [KSS] [Vue Loader - Scoped CSS]: https://vue-loader.vuejs.org/guide/scoped-css.html [vue-scoped-css/require-selector-used-inside]: ./require-selector-used-inside.md +[KSS]: http://warpspire.com/kss/ ## Implementation diff --git a/docs/rules/require-selector-used-inside.md b/docs/rules/require-selector-used-inside.md index 877cf6b8..fc8dab68 100644 --- a/docs/rules/require-selector-used-inside.md +++ b/docs/rules/require-selector-used-inside.md @@ -53,20 +53,74 @@ div {} ```json { "vue-scoped-css/require-selector-used-inside": ["error", { - "ignoreBEMModifier": false + "ignoreBEMModifier": false, + "captureClassesFromDoc": [] }] } ``` - `ignoreBEMModifier` ... Set `true` if you want to ignore the `BEM` modifier. Default is false. +- `captureClassesFromDoc` ... Specifies the regexp that extracts the class name from the documentation in the comments. Even if there is no matching element, no error is reported if the document of a class name exists in the comments. + +### `"ignoreBEMModifier": true` + + + +```vue + + +``` + + + +### `"captureClassesFromDoc": [ "/(\\.[a-z-]+)(?::[a-z-]+)?\\s+-\\s*[^\\r\\n]+/i" ]` + +Example of [KSS] format: + + + +```vue + + +``` + + ## :books: Further reading - [vue-scoped-css/no-unused-selector] - [Vue Loader - Scoped CSS] +- [KSS] [Vue Loader - Scoped CSS]: https://vue-loader.vuejs.org/guide/scoped-css.html [vue-scoped-css/no-unused-selector]: ./no-unused-selector.md +[KSS]: http://warpspire.com/kss/ ## Implementation diff --git a/lib/options.ts b/lib/options.ts index 93e2fbab..20d27254 100644 --- a/lib/options.ts +++ b/lib/options.ts @@ -1,3 +1,28 @@ +import { toRegExp } from "./utils/regexp" + export interface QueryOptions { ignoreBEMModifier?: boolean + captureClassesFromDoc?: string[] +} + +export interface ParsedQueryOptions { + ignoreBEMModifier: boolean + captureClassesFromDoc: RegExp[] +} + +export namespace ParsedQueryOptions { + /** + * Parse options + */ + export function parse( + options: QueryOptions | undefined, + ): ParsedQueryOptions { + const { ignoreBEMModifier, captureClassesFromDoc } = options || {} + + return { + ignoreBEMModifier: ignoreBEMModifier ?? false, + captureClassesFromDoc: + captureClassesFromDoc?.map(s => toRegExp(s, "g")) ?? [], + } + } } diff --git a/lib/rules/no-parsing-error.ts b/lib/rules/no-parsing-error.ts index d55af1ad..1b79d029 100644 --- a/lib/rules/no-parsing-error.ts +++ b/lib/rules/no-parsing-error.ts @@ -1,10 +1,10 @@ +import { RuleContext } from "../types" +import { VCSSParsingError } from "../styles/ast" import { getStyleContexts, getCommentDirectivesReporter, - StyleContext, -} from "../styles" -import { RuleContext, LineAndColumnData } from "../types" -import { VCSSParsingError } from "../styles/ast" + InvalidStyleContext, +} from "../styles/context" module.exports = { meta: { @@ -48,15 +48,7 @@ module.exports = { * Reports the given style * @param {ASTNode} node node to report */ - function reportInvalidStyle( - style: StyleContext & { - invalid: { - message: string - needReport: boolean - loc: LineAndColumnData - } - }, - ) { + function reportInvalidStyle(style: InvalidStyleContext) { reporter.report({ node: style.styleElement, loc: style.invalid.loc, @@ -72,15 +64,7 @@ module.exports = { for (const style of styles) { if (style.invalid != null) { if (style.invalid.needReport) { - reportInvalidStyle( - style as StyleContext & { - invalid: { - message: string - needReport: boolean - loc: LineAndColumnData - } - }, - ) + reportInvalidStyle(style) } } else { for (const node of style.cssNode?.errors || []) { diff --git a/lib/rules/no-unused-keyframes.ts b/lib/rules/no-unused-keyframes.ts index 0d8dba42..f893121e 100644 --- a/lib/rules/no-unused-keyframes.ts +++ b/lib/rules/no-unused-keyframes.ts @@ -1,11 +1,12 @@ +import { VCSSAtRule, VCSSDeclarationProperty } from "../styles/ast" +import { RuleContext } from "../types" +import { Template } from "../styles/template" import { getStyleContexts, getCommentDirectivesReporter, + ValidStyleContext, StyleContext, -} from "../styles" -import { VCSSAtRule, VCSSDeclarationProperty } from "../styles/ast" -import { RuleContext } from "../types" -import { Template } from "../styles/template" +} from "../styles/context" module.exports = { meta: { @@ -24,9 +25,9 @@ module.exports = { type: "suggestion", // "problem", }, create(context: RuleContext) { - const styles = getStyleContexts(context).filter( - style => !style.invalid && style.scoped, - ) + const styles = getStyleContexts(context) + .filter(StyleContext.isValid) + .filter(style => style.scoped) if (!styles.length) { return {} } @@ -59,7 +60,7 @@ module.exports = { * Extract nodes */ function extract( - style: StyleContext, + style: ValidStyleContext, ): { keyframes: { node: VCSSAtRule; params: Template }[] animationNames: VCSSDeclarationProperty[] @@ -106,7 +107,7 @@ module.exports = { /** * Verify the style */ - function verify(style: StyleContext) { + function verify(style: ValidStyleContext) { const { keyframes, animationNames, animations } = extract(style) for (const decl of animationNames) { diff --git a/lib/rules/no-unused-selector.ts b/lib/rules/no-unused-selector.ts index 15384f1d..19440fac 100644 --- a/lib/rules/no-unused-selector.ts +++ b/lib/rules/no-unused-selector.ts @@ -1,11 +1,4 @@ -import { - getStyleContexts, - getCommentDirectivesReporter, - StyleContext, -} from "../styles" - import { getResolvedSelectors, ResolvedSelector } from "../styles/selectors" - import { VCSSSelectorNode, VCSSSelectorCombinator } from "../styles/ast" import { isTypeSelector, @@ -18,17 +11,23 @@ import { isGeneralSiblingCombinator, isDeepCombinator, } from "../styles/utils/selectors" - import { createQueryContext, QueryContext } from "../styles/selectors/query" import { isRootElement } from "../styles/selectors/query/elements" import { RuleContext } from "../types" +import { ParsedQueryOptions } from "../options" +import { + ValidStyleContext, + getStyleContexts, + StyleContext, + getCommentDirectivesReporter, +} from "../styles/context" /** * Gets scoped selectors. * @param {StyleContext} style The style context * @returns {VCSSSelectorNode[][]} selectors */ -function getScopedSelectors(style: StyleContext): VCSSSelectorNode[][] { +function getScopedSelectors(style: ValidStyleContext): VCSSSelectorNode[][] { const resolvedSelectors = getResolvedSelectors(style) return resolvedSelectors.map(getScopedSelector) } @@ -84,6 +83,16 @@ module.exports = { ignoreBEMModifier: { type: "boolean", }, + captureClassesFromDoc: { + type: "array", + items: [ + { + type: "string", + }, + ], + minItems: 0, + uniqueItems: true, + }, }, additionalProperties: false, }, @@ -91,9 +100,9 @@ module.exports = { type: "suggestion", // "problem", }, create(context: RuleContext) { - const styles = getStyleContexts(context).filter( - style => !style.invalid && style.scoped, - ) + const styles = getStyleContexts(context) + .filter(StyleContext.isValid) + .filter(style => style.scoped) if (!styles.length) { return {} } @@ -215,7 +224,7 @@ module.exports = { "Program:exit"() { const queryContext = createQueryContext( context, - context.options[0] || {}, + ParsedQueryOptions.parse(context.options[0]), ) for (const style of styles) { diff --git a/lib/rules/require-scoped.ts b/lib/rules/require-scoped.ts index b39e6ced..6f0d9867 100644 --- a/lib/rules/require-scoped.ts +++ b/lib/rules/require-scoped.ts @@ -1,5 +1,9 @@ -import { getStyleContexts, getCommentDirectivesReporter } from "../styles" import { RuleContext, AST, TokenStore } from "../types" +import { + getStyleContexts, + StyleContext, + getCommentDirectivesReporter, +} from "../styles/context" module.exports = { meta: { @@ -21,7 +25,7 @@ module.exports = { type: "suggestion", }, create(context: RuleContext) { - const styles = getStyleContexts(context).filter(style => !style.invalid) + const styles = getStyleContexts(context).filter(StyleContext.isValid) if (!styles.length) { return {} } diff --git a/lib/rules/require-selector-used-inside.ts b/lib/rules/require-selector-used-inside.ts index b6422043..f17b34c3 100644 --- a/lib/rules/require-selector-used-inside.ts +++ b/lib/rules/require-selector-used-inside.ts @@ -1,9 +1,3 @@ -import { - getStyleContexts, - getCommentDirectivesReporter, - StyleContext, -} from "../styles" - import { getResolvedSelectors, ResolvedSelector } from "../styles/selectors" import { isTypeSelector, @@ -13,17 +7,23 @@ import { isSelectorCombinator, isDeepCombinator, } from "../styles/utils/selectors" - import { createQueryContext, QueryContext } from "../styles/selectors/query" import { VCSSSelectorNode } from "../styles/ast" import { RuleContext } from "../types" +import { ParsedQueryOptions } from "../options" +import { + ValidStyleContext, + getStyleContexts, + StyleContext, + getCommentDirectivesReporter, +} from "../styles/context" /** * Gets scoped selectors. * @param {StyleContext} style The style context * @returns {VCSSSelectorNode[][]} selectors */ -function getScopedSelectors(style: StyleContext): VCSSSelectorNode[][] { +function getScopedSelectors(style: ValidStyleContext): VCSSSelectorNode[][] { const resolvedSelectors = getResolvedSelectors(style) return resolvedSelectors.map(getScopedSelector) } @@ -62,6 +62,16 @@ module.exports = { ignoreBEMModifier: { type: "boolean", }, + captureClassesFromDoc: { + type: "array", + items: [ + { + type: "string", + }, + ], + minItems: 0, + uniqueItems: true, + }, }, additionalProperties: false, }, @@ -69,9 +79,9 @@ module.exports = { type: "suggestion", }, create(context: RuleContext) { - const styles = getStyleContexts(context).filter( - style => !style.invalid && style.scoped, - ) + const styles = getStyleContexts(context) + .filter(StyleContext.isValid) + .filter(style => style.scoped) if (!styles.length) { return {} } @@ -137,7 +147,7 @@ module.exports = { "Program:exit"() { const queryContext = createQueryContext( context, - context.options[0] || {}, + ParsedQueryOptions.parse(context.options[0]), ) for (const style of styles) { diff --git a/lib/styles/context/index.ts b/lib/styles/context/index.ts index 26095786..9457ee96 100644 --- a/lib/styles/context/index.ts +++ b/lib/styles/context/index.ts @@ -1,4 +1,9 @@ -import { createStyleContexts, StyleContext } from "./style" +import { + createStyleContexts, + StyleContext, + ValidStyleContext, + InvalidStyleContext, +} from "./style" import { CommentDirectivesReporter, createCommentDirectivesReporter, @@ -76,7 +81,13 @@ export function getVueComponentContext( } return (cache.vueComponent = createVueComponentContext(context)) } -export { StyleContext, CommentDirectivesReporter, VueComponentContext } +export { + StyleContext, + ValidStyleContext, + InvalidStyleContext, + CommentDirectivesReporter, + VueComponentContext, +} /** * Gets the comment directive context from given rule context. diff --git a/lib/styles/context/style/index.ts b/lib/styles/context/style/index.ts index 6f4477a9..80c5b385 100644 --- a/lib/styles/context/style/index.ts +++ b/lib/styles/context/style/index.ts @@ -110,10 +110,43 @@ interface Visitor { leaveNode(node: VCSSNode): void } +interface BaseStyleContext { + readonly styleElement: AST.VElement + readonly sourceCode: SourceCode + readonly scoped: boolean + readonly lang: string + traverseNodes(visitor: Visitor): void +} + +export interface ValidStyleContext extends BaseStyleContext { + readonly invalid: null + readonly cssNode: VCSSStyleSheet +} +export interface InvalidStyleContext extends BaseStyleContext { + readonly invalid: { + message: string + needReport: boolean + loc: LineAndColumnData + } + readonly cssNode: null +} + +export type StyleContext = InvalidStyleContext | ValidStyleContext +export namespace StyleContext { + /** + * Checks whether the given context is valid + */ + export function isValid( + context: StyleContext, + ): context is ValidStyleContext { + return !context.invalid + } +} + /** * Style context */ -export class StyleContext { +export class StyleContextImpl { public readonly styleElement: AST.VElement public readonly sourceCode: SourceCode public readonly invalid: { @@ -209,7 +242,9 @@ function traverseNodes(node: VCSSNode, visitor: Visitor): void { export function createStyleContexts(context: RuleContext): StyleContext[] { const styles = getStyleElements(context) - return styles.map(style => new StyleContext(style, context)) + return styles.map( + style => new StyleContextImpl(style, context) as StyleContext, + ) } /** diff --git a/lib/styles/index.ts b/lib/styles/index.ts deleted file mode 100644 index eff808c2..00000000 --- a/lib/styles/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { - getStyleContexts, - getCommentDirectivesReporter, - StyleContext, - CommentDirectivesReporter, -} from "./context" - -export { - StyleContext, - CommentDirectivesReporter, - /** - * Gets the style contexts - * @param {RuleContext} context ESLint rule context - * @returns {StyleContext[]} the style contexts - */ - getStyleContexts, - /** - * Gets the comment directive reporter - * @param {RuleContext} context ESLint rule context - * @returns {CommentDirectivesReporter} the comment directives - */ - getCommentDirectivesReporter, -} diff --git a/lib/styles/selectors/index.ts b/lib/styles/selectors/index.ts index 66e2e1f9..41fcc72d 100644 --- a/lib/styles/selectors/index.ts +++ b/lib/styles/selectors/index.ts @@ -1,5 +1,4 @@ -import { StyleContext } from "../context" - +import { ValidStyleContext } from "../context" import { CSSSelectorResolver, ResolvedSelector, @@ -20,10 +19,9 @@ const RESOLVERS = { * @param {StyleContext} style The style context * @returns {ResolvedSelectors[]} the selector that resolved the nesting. */ -export function getResolvedSelectors(style: StyleContext): ResolvedSelector[] { - if (!style.cssNode) { - return [] - } +export function getResolvedSelectors( + style: ValidStyleContext, +): ResolvedSelector[] { const lang = style.lang const Resolver = isSupportedStyleLang(lang) ? RESOLVERS[lang] diff --git a/lib/styles/selectors/query/index.ts b/lib/styles/selectors/query/index.ts index 40a1601b..f32be3e1 100644 --- a/lib/styles/selectors/query/index.ts +++ b/lib/styles/selectors/query/index.ts @@ -22,13 +22,18 @@ import { } from "./elements" import { VCSSSelectorNode } from "../../ast" import { AST, RuleContext, ASTNode } from "../../../types" -import { QueryOptions } from "../../../options" +import { ParsedQueryOptions } from "../../../options" import { getAttributeValueNodes, getReferenceExpressions, ReferenceExpressions, } from "./attribute-tracker" -import { getVueComponentContext } from "../../context" +import { + getVueComponentContext, + getStyleContexts, + StyleContext, + ValidStyleContext, +} from "../../context" import { getStringFromNode } from "../../utils/nodes" import { Template } from "../../template" @@ -48,6 +53,7 @@ const TRANSITION_GROUP_CLASS_BASES = [...TRANSITION_CLASS_BASES, "move"] export class QueryContext { public elements: AST.VElement[] = [] protected readonly document: VueDocumentQueryContext + protected constructor(document?: VueDocumentQueryContext) { this.document = document || (this as any) } @@ -107,8 +113,9 @@ export class QueryContext { */ class VueDocumentQueryContext extends QueryContext { public context: RuleContext - public options: QueryOptions - public constructor(context: RuleContext, options: QueryOptions) { + public options: ParsedQueryOptions + public docsModifiers: string[] + public constructor(context: RuleContext, options: ParsedQueryOptions) { super() const sourceCode = context.getSourceCode() const { ast } = sourceCode @@ -117,9 +124,48 @@ class VueDocumentQueryContext extends QueryContext { : [] this.context = context this.options = options + + if (options.captureClassesFromDoc.length > 0) { + this.docsModifiers = getStyleContexts(context) + .filter(StyleContext.isValid) + .filter(style => style.scoped) + .map(style => + extractClassesFromDoc(style, options.captureClassesFromDoc), + ) + .reduce((r, a) => r.concat(a), []) + } else { + this.docsModifiers = [] + } } } +/** + * Extract class names documented in the comment. + */ +function extractClassesFromDoc( + style: ValidStyleContext, + captureClassesFromDoc: RegExp[], +): string[] { + const results = new Set() + for (const comment of style.cssNode.comments) { + for (const regexp of captureClassesFromDoc) { + // Get all captures + regexp.lastIndex = 0 + let re + while ((re = regexp.exec(comment.text))) { + if (re.length > 1) { + for (const s of re.slice(1)) { + results.add(s) + } + } else { + results.add(re[0]) + } + } + } + } + return [...results] +} + /** * QueryContext as elements. */ @@ -141,7 +187,7 @@ class ElementsQueryContext extends QueryContext { */ export function createQueryContext( context: RuleContext, - options: QueryOptions = {}, + options: ParsedQueryOptions, ): QueryContext { return new VueDocumentQueryContext(context, options) } @@ -436,6 +482,8 @@ function* genElementsByClassName( document: VueDocumentQueryContext, ): IterableIterator { let removeModifierClassName = null + + // ignoreBEMModifier option if (document.options.ignoreBEMModifier) { if (className.hasString("--")) { const list = className.divide("--") @@ -446,6 +494,21 @@ function* genElementsByClassName( } } + // captureClassesFromDoc option + for (const docMod of document.docsModifiers) { + if (docMod.startsWith(":")) { + continue + } + const modClassName: string = docMod.startsWith(".") + ? docMod.slice(1) + : docMod + if (className.matchString(modClassName)) { + // If the class name is documented, it is considered to match all elements. + yield* elements + return + } + } + for (const element of elements) { if (matchClassName(element, className, document)) { yield element diff --git a/lib/utils/regexp.ts b/lib/utils/regexp.ts new file mode 100644 index 00000000..50ae1515 --- /dev/null +++ b/lib/utils/regexp.ts @@ -0,0 +1,24 @@ +const RE_REGEXP_STR = /^\/(.+)\/(.*)$/u + +/** + * Convert a string to the `RegExp`. + * Normal strings (e.g. `"foo"`) is converted to `/foo/` of `RegExp`. + * Strings like `"/^foo/i"` are converted to `/^foo/i` of `RegExp`. + * + * @param {string} string The string to convert. + * @returns {RegExp} Returns the `RegExp`. + */ +export function toRegExp(string: string, flags?: string): RegExp { + const parts = RE_REGEXP_STR.exec(string) + if (parts) { + let flagArgs: string + if (flags) { + flagArgs = [...new Set(parts[2] + flags)].join("") + } else { + flagArgs = parts[2] + } + + return new RegExp(parts[1], flagArgs) + } + return new RegExp(string, flags) +} diff --git a/tests/lib/rules/no-unused-selector.ts b/tests/lib/rules/no-unused-selector.ts index 9c78971d..8bc40df7 100644 --- a/tests/lib/rules/no-unused-selector.ts +++ b/tests/lib/rules/no-unused-selector.ts @@ -267,6 +267,40 @@ tester.run("no-unused-selector", rule, { `, options: [{ ignoreBEMModifier: true }], }, + // captureClassesFromDoc + { + code: ` + + `, + options: [ + { + captureClassesFromDoc: [ + "/(\\.[a-z-]+)(?::[a-z-]+)?\\s+-\\s*[^\\r\\n]+/i", + ], + }, + ], + }, // ignore nodes `