Skip to content

Commit 38bd3b6

Browse files
authored
Fix false positives for non component template in no-raw-text rule. (#230)
1 parent 41b8294 commit 38bd3b6

File tree

3 files changed

+377
-2
lines changed

3 files changed

+377
-2
lines changed

lib/rules/no-raw-text.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* @author kazuya kawaguchi (a.k.a. kazupon)
33
*/
44
import { parse, AST as VAST } from 'vue-eslint-parser'
5-
import { defineTemplateBodyVisitor } from '../utils/index'
5+
import { defineTemplateBodyVisitor, getVueObjectType } from '../utils/index'
66
import type {
77
JSXText,
88
RuleContext,
@@ -21,8 +21,10 @@ const config: {
2121
} = { ignorePattern: /^[^\S\s]$/, ignoreNodes: [], ignoreText: [] }
2222
const hasOnlyWhitespace = (value: string) => /^[\r\n\s\t\f\v]+$/.test(value)
2323
const hasTemplateElementValue = (
24-
value: any // eslint-disable-line @typescript-eslint/no-explicit-any
24+
value: AnyValue
2525
): value is { raw: string; cooked: string } =>
26+
value != null &&
27+
typeof value === 'object' &&
2628
'raw' in value &&
2729
typeof value.raw === 'string' &&
2830
'cooked' in value &&
@@ -294,6 +296,12 @@ function create(context: RuleContext): RuleListener {
294296
if (!valueNode) {
295297
return
296298
}
299+
if (
300+
getVueObjectType(context, node) == null ||
301+
valueNode.value == null
302+
) {
303+
return
304+
}
297305

298306
const templateNode = getComponentTemplateNode(valueNode.value)
299307
VAST.traverseNodes(templateNode, {

lib/utils/index.ts

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,156 @@ export function defineCustomBlocksVisitor(
339339
return compositingVisitors(jsonVisitor, yamlVisitor)
340340
}
341341

342+
export type VueObjectType =
343+
| 'mark'
344+
| 'export'
345+
| 'definition'
346+
| 'instance'
347+
| 'variable'
348+
| 'components-option'
349+
/**
350+
* If the given object is a Vue component or instance, returns the Vue definition type.
351+
* @param context The ESLint rule context object.
352+
* @param node Node to check
353+
* @returns The Vue definition type.
354+
*/
355+
export function getVueObjectType(
356+
context: RuleContext,
357+
node: VAST.ESLintObjectExpression
358+
): VueObjectType | null {
359+
if (node.type !== 'ObjectExpression' || !node.parent) {
360+
return null
361+
}
362+
const parent = node.parent
363+
if (parent.type === 'ExportDefaultDeclaration') {
364+
// export default {} in .vue || .jsx
365+
const ext = extname(context.getFilename()).toLowerCase()
366+
if (
367+
(ext === '.vue' || ext === '.jsx' || !ext) &&
368+
skipTSAsExpression(parent.declaration) === node
369+
) {
370+
const scriptSetup = getScriptSetupElement(context)
371+
if (
372+
scriptSetup &&
373+
scriptSetup.range[0] <= parent.range[0] &&
374+
parent.range[1] <= scriptSetup.range[1]
375+
) {
376+
// `export default` in `<script setup>`
377+
return null
378+
}
379+
return 'export'
380+
}
381+
} else if (parent.type === 'CallExpression') {
382+
// Vue.component('xxx', {}) || component('xxx', {})
383+
if (
384+
getVueComponentDefinitionType(node) != null &&
385+
skipTSAsExpression(parent.arguments.slice(-1)[0]) === node
386+
) {
387+
return 'definition'
388+
}
389+
} else if (parent.type === 'NewExpression') {
390+
// new Vue({})
391+
if (
392+
isVueInstance(parent) &&
393+
skipTSAsExpression(parent.arguments[0]) === node
394+
) {
395+
return 'instance'
396+
}
397+
} else if (parent.type === 'VariableDeclarator') {
398+
// This is a judgment method that eslint-plugin-vue does not have.
399+
// If the variable name is PascalCase, it is considered to be a Vue component. e.g. MyComponent = {}
400+
if (
401+
parent.init === node &&
402+
parent.id.type === 'Identifier' &&
403+
/^[A-Z][a-zA-Z\d]+/u.test(parent.id.name) &&
404+
parent.id.name.toUpperCase() !== parent.id.name
405+
) {
406+
return 'variable'
407+
}
408+
} else if (parent.type === 'Property') {
409+
// This is a judgment method that eslint-plugin-vue does not have.
410+
// If set to components, it is considered to be a Vue component.
411+
const componentsCandidate = parent.parent as VAST.ESLintObjectExpression
412+
const pp = componentsCandidate.parent
413+
if (
414+
pp &&
415+
pp.type === 'Property' &&
416+
pp.value === componentsCandidate &&
417+
!pp.computed &&
418+
(pp.key.type === 'Identifier'
419+
? pp.key.name
420+
: pp.key.type === 'Literal'
421+
? pp.key.value + ''
422+
: '') === 'components'
423+
) {
424+
return 'components-option'
425+
}
426+
}
427+
if (
428+
getComponentComments(context).some(
429+
el => el.loc.end.line === node.loc.start.line - 1
430+
)
431+
) {
432+
return 'mark'
433+
}
434+
return null
435+
}
436+
437+
/**
438+
* Gets the element of `<script setup>`
439+
* @param context The ESLint rule context object.
440+
* @returns the element of `<script setup>`
441+
*/
442+
export function getScriptSetupElement(
443+
context: RuleContext
444+
): VAST.VElement | null {
445+
const df =
446+
context.parserServices.getDocumentFragment &&
447+
context.parserServices.getDocumentFragment()
448+
if (!df) {
449+
return null
450+
}
451+
const scripts = df.children
452+
.filter(isVElement)
453+
.filter(e => e.name === 'script')
454+
if (scripts.length === 2) {
455+
return scripts.find(e => getAttribute(e, 'setup')) || null
456+
} else {
457+
const script = scripts[0]
458+
if (script && getAttribute(script, 'setup')) {
459+
return script
460+
}
461+
}
462+
return null
463+
}
464+
/**
465+
* Checks whether the given node is VElement.
466+
* @param node
467+
*/
468+
export function isVElement(
469+
node: VAST.VElement | VAST.VExpressionContainer | VAST.VText
470+
): node is VAST.VElement {
471+
return node.type === 'VElement'
472+
}
473+
474+
/**
475+
* Retrieve `TSAsExpression#expression` value if the given node a `TSAsExpression` node. Otherwise, pass through it.
476+
* @template T Node type
477+
* @param node The node to address.
478+
* @returns The `TSAsExpression#expression` value if the node is a `TSAsExpression` node. Otherwise, the node.
479+
*/
480+
export function skipTSAsExpression<T extends VAST.Node>(node: T): T {
481+
if (!node) {
482+
return node
483+
}
484+
// @ts-expect-error -- ignore
485+
if (node.type === 'TSAsExpression') {
486+
// @ts-expect-error -- ignore
487+
return skipTSAsExpression(node.expression)
488+
}
489+
return node
490+
}
491+
342492
function compositingVisitors(
343493
visitor: RuleListener,
344494
...visitors: RuleListener[]
@@ -361,3 +511,115 @@ function compositingVisitors(
361511
}
362512
return visitor
363513
}
514+
515+
/**
516+
* Get the Vue component definition type from given node
517+
* Vue.component('xxx', {}) || component('xxx', {})
518+
* @param node Node to check
519+
* @returns {'component' | 'mixin' | 'extend' | 'createApp' | 'defineComponent' | null}
520+
*/
521+
function getVueComponentDefinitionType(node: VAST.ESLintObjectExpression) {
522+
const parent = node.parent
523+
if (parent && parent.type === 'CallExpression') {
524+
const callee = parent.callee
525+
526+
if (callee.type === 'MemberExpression') {
527+
const calleeObject = skipTSAsExpression(callee.object)
528+
529+
if (calleeObject.type === 'Identifier') {
530+
const propName =
531+
!callee.computed &&
532+
callee.property.type === 'Identifier' &&
533+
callee.property.name
534+
if (calleeObject.name === 'Vue') {
535+
// for Vue.js 2.x
536+
// Vue.component('xxx', {}) || Vue.mixin({}) || Vue.extend('xxx', {})
537+
const maybeFullVueComponentForVue2 =
538+
propName && isObjectArgument(parent)
539+
540+
return maybeFullVueComponentForVue2 &&
541+
(propName === 'component' ||
542+
propName === 'mixin' ||
543+
propName === 'extend')
544+
? propName
545+
: null
546+
}
547+
548+
// for Vue.js 3.x
549+
// app.component('xxx', {}) || app.mixin({})
550+
const maybeFullVueComponent = propName && isObjectArgument(parent)
551+
552+
return maybeFullVueComponent &&
553+
(propName === 'component' || propName === 'mixin')
554+
? propName
555+
: null
556+
}
557+
}
558+
559+
if (callee.type === 'Identifier') {
560+
if (callee.name === 'component') {
561+
// for Vue.js 2.x
562+
// component('xxx', {})
563+
const isDestructedVueComponent = isObjectArgument(parent)
564+
return isDestructedVueComponent ? 'component' : null
565+
}
566+
if (callee.name === 'createApp') {
567+
// for Vue.js 3.x
568+
// createApp({})
569+
const isAppVueComponent = isObjectArgument(parent)
570+
return isAppVueComponent ? 'createApp' : null
571+
}
572+
if (callee.name === 'defineComponent') {
573+
// for Vue.js 3.x
574+
// defineComponent({})
575+
const isDestructedVueComponent = isObjectArgument(parent)
576+
return isDestructedVueComponent ? 'defineComponent' : null
577+
}
578+
}
579+
}
580+
581+
return null
582+
583+
function isObjectArgument(node: VAST.ESLintCallExpression) {
584+
return (
585+
node.arguments.length > 0 &&
586+
skipTSAsExpression(node.arguments.slice(-1)[0]).type ===
587+
'ObjectExpression'
588+
)
589+
}
590+
}
591+
592+
/**
593+
* Check whether given node is new Vue instance
594+
* new Vue({})
595+
* @param node Node to check
596+
*/
597+
function isVueInstance(node: VAST.ESLintNewExpression) {
598+
const callee = node.callee
599+
return Boolean(
600+
node.type === 'NewExpression' &&
601+
callee.type === 'Identifier' &&
602+
callee.name === 'Vue' &&
603+
node.arguments.length &&
604+
skipTSAsExpression(node.arguments[0]).type === 'ObjectExpression'
605+
)
606+
}
607+
608+
const componentComments = new WeakMap<RuleContext, VAST.Token[]>()
609+
/**
610+
* Gets the component comments of a given context.
611+
* @param context The ESLint rule context object.
612+
* @return The the component comments.
613+
*/
614+
function getComponentComments(context: RuleContext) {
615+
let tokens = componentComments.get(context)
616+
if (tokens) {
617+
return tokens
618+
}
619+
const sourceCode = context.getSourceCode()
620+
tokens = sourceCode
621+
.getAllComments()
622+
.filter(comment => /@vue\/component/g.test(comment.value))
623+
componentComments.set(context, tokens)
624+
return tokens
625+
}

0 commit comments

Comments
 (0)