test(arch): guard panel UI conventions
This commit is contained in:
parent
3f08b80b5e
commit
cde9cd4610
323
lint/plugin.js
323
lint/plugin.js
|
|
@ -1,4 +1,5 @@
|
|||
import { existsSync } from 'node:fs'
|
||||
import { parse as parseVueSfc } from 'vue/compiler-sfc'
|
||||
|
||||
function normalizedFilename(context) {
|
||||
return (context.filename ?? context.getFilename?.() ?? '').replace(/\\/g, '/')
|
||||
|
|
@ -172,6 +173,97 @@ const noStructuredCloneSceneArrays = {
|
|||
}
|
||||
}
|
||||
|
||||
function vueSfcDescriptor(source, filename) {
|
||||
return parseVueSfc(source, { filename }).descriptor
|
||||
}
|
||||
|
||||
function vueTemplateAst(source, filename) {
|
||||
return vueSfcDescriptor(source, filename).template?.ast ?? null
|
||||
}
|
||||
|
||||
function isVueSourceFile(file) {
|
||||
return file.endsWith('.vue') && (file.includes('/src/') || file.includes('/packages/vue/src/'))
|
||||
}
|
||||
|
||||
function sourceLineCount(source) {
|
||||
const normalized = source.endsWith('\n') ? source.slice(0, -1) : source
|
||||
return normalized.split('\n').length
|
||||
}
|
||||
|
||||
const VUE_ELEMENT_NODE = 1
|
||||
const VUE_SIMPLE_EXPRESSION_NODE = 4
|
||||
const VUE_INTERPOLATION_NODE = 5
|
||||
const VUE_ATTRIBUTE_NODE = 6
|
||||
const VUE_DIRECTIVE_NODE = 7
|
||||
|
||||
function walkVueTemplateAst(node, visitor) {
|
||||
visitor(node)
|
||||
for (const prop of node.props ?? []) walkVueTemplateAst(prop, visitor)
|
||||
for (const child of node.children ?? []) walkVueTemplateAst(child, visitor)
|
||||
if (node.type === VUE_INTERPOLATION_NODE && node.content) {
|
||||
walkVueTemplateAst(node.content, visitor)
|
||||
}
|
||||
if (node.type === VUE_DIRECTIVE_NODE) {
|
||||
if (node.arg) walkVueTemplateAst(node.arg, visitor)
|
||||
if (node.exp) walkVueTemplateAst(node.exp, visitor)
|
||||
}
|
||||
}
|
||||
|
||||
function walkExpressionAst(node, visitor) {
|
||||
if (!node || typeof node !== 'object') return
|
||||
visitor(node)
|
||||
for (const value of Object.values(node)) {
|
||||
if (!value || value === node.loc) continue
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) walkExpressionAst(item, visitor)
|
||||
continue
|
||||
}
|
||||
walkExpressionAst(value, visitor)
|
||||
}
|
||||
}
|
||||
|
||||
function isUiHelperName(name) {
|
||||
const prefix = 'use'
|
||||
const suffix = 'UI'
|
||||
if (!name.startsWith(prefix) || !name.endsWith(suffix)) return false
|
||||
const firstDomainChar = name.at(prefix.length)
|
||||
return firstDomainChar !== undefined && firstDomainChar === firstDomainChar.toUpperCase()
|
||||
}
|
||||
|
||||
function hasExpressionCall(expression, predicate) {
|
||||
if (expression?.type !== VUE_SIMPLE_EXPRESSION_NODE || !expression.ast) return false
|
||||
let found = false
|
||||
walkExpressionAst(expression.ast, (node) => {
|
||||
if (found || node.type !== 'CallExpression') return
|
||||
if (node.callee?.type === 'Identifier' && predicate(node.callee.name)) found = true
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
function hasUiHelperCall(expression) {
|
||||
return hasExpressionCall(expression, isUiHelperName)
|
||||
}
|
||||
|
||||
function isStaticVueAttribute(node, name) {
|
||||
return node.type === VUE_ATTRIBUTE_NODE && node.name === name
|
||||
}
|
||||
|
||||
function isVueBindDirective(node, name) {
|
||||
return node.type === VUE_DIRECTIVE_NODE && node.name === 'bind' && node.arg?.content === name
|
||||
}
|
||||
|
||||
function isBoundStringLiteral(node, name) {
|
||||
if (!isVueBindDirective(node, name)) return false
|
||||
const expression = node.exp
|
||||
if (expression?.type !== VUE_SIMPLE_EXPRESSION_NODE) return false
|
||||
const ast = expression.ast
|
||||
if (!ast) return false
|
||||
if (ast.type === 'StringLiteral' || (ast.type === 'Literal' && typeof ast.value === 'string')) {
|
||||
return true
|
||||
}
|
||||
return ast.type === 'TemplateLiteral' && ast.expressions?.length === 0
|
||||
}
|
||||
|
||||
const noVueStyleBlocks = {
|
||||
meta: {
|
||||
docs: {
|
||||
|
|
@ -185,8 +277,8 @@ const noVueStyleBlocks = {
|
|||
|
||||
return {
|
||||
Program(node) {
|
||||
const source = context.sourceCode.getText()
|
||||
if (/<style\b/i.test(source)) {
|
||||
const descriptor = vueSfcDescriptor(context.sourceCode.getText(), file)
|
||||
if (descriptor.styles.length === 0) return
|
||||
context.report({
|
||||
node,
|
||||
message:
|
||||
|
|
@ -196,7 +288,6 @@ const noVueStyleBlocks = {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const noNativeTitleAttributesInVue = {
|
||||
meta: {
|
||||
|
|
@ -211,8 +302,16 @@ const noNativeTitleAttributesInVue = {
|
|||
|
||||
return {
|
||||
Program(node) {
|
||||
const source = context.sourceCode.getText()
|
||||
if (/\s:?title=/.test(source)) {
|
||||
const template = vueTemplateAst(context.sourceCode.getText(), file)
|
||||
if (!template) return
|
||||
let hasTitleAttribute = false
|
||||
walkVueTemplateAst(template, (templateNode) => {
|
||||
if (hasTitleAttribute) return
|
||||
if (isStaticVueAttribute(templateNode, 'title') || isVueBindDirective(templateNode, 'title')) {
|
||||
hasTitleAttribute = true
|
||||
}
|
||||
})
|
||||
if (!hasTitleAttribute) return
|
||||
context.report({
|
||||
node,
|
||||
message: 'Use the shared tooltip UI instead of native title attributes.'
|
||||
|
|
@ -221,7 +320,6 @@ const noNativeTitleAttributesInVue = {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const noHardcodedTipLabelsInVue = {
|
||||
meta: {
|
||||
|
|
@ -236,8 +334,17 @@ const noHardcodedTipLabelsInVue = {
|
|||
|
||||
return {
|
||||
Program(node) {
|
||||
const source = context.sourceCode.getText()
|
||||
if (/<Tip\b[^>]*\slabel=["']/.test(source)) {
|
||||
const template = vueTemplateAst(context.sourceCode.getText(), file)
|
||||
if (!template) return
|
||||
let hasHardcodedTipLabel = false
|
||||
walkVueTemplateAst(template, (templateNode) => {
|
||||
if (hasHardcodedTipLabel) return
|
||||
if (templateNode.type !== VUE_ELEMENT_NODE || templateNode.tag !== 'Tip') return
|
||||
hasHardcodedTipLabel = templateNode.props?.some(
|
||||
(prop) => isStaticVueAttribute(prop, 'label') || isBoundStringLiteral(prop, 'label')
|
||||
)
|
||||
})
|
||||
if (!hasHardcodedTipLabel) return
|
||||
context.report({
|
||||
node,
|
||||
message: 'Use a localized binding for Tip labels, not a hardcoded string.'
|
||||
|
|
@ -246,6 +353,94 @@ const noHardcodedTipLabelsInVue = {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
const noRawSvgInAppVueTemplates = {
|
||||
meta: {
|
||||
docs: {
|
||||
description: 'Disallow raw SVG in app Vue templates — use Iconify/unplugin icons'
|
||||
}
|
||||
},
|
||||
create(context) {
|
||||
const file = normalizedFilename(context)
|
||||
if (!isVueSourceFile(file)) return {}
|
||||
|
||||
return {
|
||||
Program(node) {
|
||||
const template = vueTemplateAst(context.sourceCode.getText(), file)
|
||||
if (!template) return
|
||||
let hasRawSvg = false
|
||||
walkVueTemplateAst(template, (templateNode) => {
|
||||
if (templateNode.type === VUE_ELEMENT_NODE && templateNode.tag === 'svg') {
|
||||
hasRawSvg = true
|
||||
}
|
||||
})
|
||||
if (!hasRawSvg) return
|
||||
context.report({
|
||||
node,
|
||||
message: 'Use Iconify/unplugin icon components instead of raw <svg> in app templates.'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const noUiHelperCallsInVueTemplates = {
|
||||
meta: {
|
||||
docs: {
|
||||
description: 'Disallow use*UI() helper calls inside Vue templates'
|
||||
}
|
||||
},
|
||||
create(context) {
|
||||
const file = normalizedFilename(context)
|
||||
if (!file.endsWith('.vue') || !file.includes('/src/components/')) return {}
|
||||
|
||||
return {
|
||||
Program(node) {
|
||||
const template = vueTemplateAst(context.sourceCode.getText(), file)
|
||||
if (!template) return
|
||||
let hasTemplateUiHelperCall = false
|
||||
walkVueTemplateAst(template, (templateNode) => {
|
||||
if (!hasTemplateUiHelperCall && hasUiHelperCall(templateNode)) {
|
||||
hasTemplateUiHelperCall = true
|
||||
}
|
||||
})
|
||||
if (!hasTemplateUiHelperCall) return
|
||||
context.report({
|
||||
node,
|
||||
message: 'Hoist use*UI() calls out of templates or hide them inside shared UI components.'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const PROPERTY_SECTION_LINE_ALLOWLIST = new Set([
|
||||
'/src/components/properties/LayoutSection/SizeControls.vue'
|
||||
])
|
||||
|
||||
const noLargePropertySectionComponents = {
|
||||
meta: {
|
||||
docs: {
|
||||
description: 'Disallow oversized property-section Vue components'
|
||||
}
|
||||
},
|
||||
create(context) {
|
||||
const file = normalizedFilename(context)
|
||||
if (!file.endsWith('.vue') || !file.includes('/src/components/properties/')) return {}
|
||||
if ([...PROPERTY_SECTION_LINE_ALLOWLIST].some((suffix) => file.endsWith(suffix))) return {}
|
||||
|
||||
return {
|
||||
Program(node) {
|
||||
const lineCount = sourceLineCount(context.sourceCode.getText())
|
||||
if (lineCount <= 250) return
|
||||
context.report({
|
||||
node,
|
||||
message:
|
||||
'Split property-section components over 250 lines into focused controls or document an explicit allowlist.'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const TEST_ID_FORMAT = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/
|
||||
|
|
@ -298,8 +493,14 @@ const noDynamicDataTestIdInVue = {
|
|||
|
||||
return {
|
||||
Program(node) {
|
||||
const source = context.sourceCode.getText()
|
||||
if (/\B:data-test-id\s*=|\bv-bind:data-test-id\s*=/.test(source)) {
|
||||
const template = vueTemplateAst(context.sourceCode.getText(), file)
|
||||
if (!template) return
|
||||
let hasDynamicDataTestId = false
|
||||
walkVueTemplateAst(template, (templateNode) => {
|
||||
if (hasDynamicDataTestId) return
|
||||
if (isVueBindDirective(templateNode, 'data-test-id')) hasDynamicDataTestId = true
|
||||
})
|
||||
if (!hasDynamicDataTestId) return
|
||||
context.report({
|
||||
node,
|
||||
message: 'Use v-test-id for dynamic/configurable test ids instead of :data-test-id.'
|
||||
|
|
@ -308,7 +509,6 @@ const noDynamicDataTestIdInVue = {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const noTestIdHelperBindInVue = {
|
||||
meta: {
|
||||
|
|
@ -322,8 +522,19 @@ const noTestIdHelperBindInVue = {
|
|||
|
||||
return {
|
||||
Program(node) {
|
||||
const source = context.sourceCode.getText()
|
||||
if (/\bv-bind\s*=\s*"testId(?:Attr)?\(/.test(source)) {
|
||||
const template = vueTemplateAst(context.sourceCode.getText(), file)
|
||||
if (!template) return
|
||||
let hasTestIdHelperBind = false
|
||||
walkVueTemplateAst(template, (templateNode) => {
|
||||
if (hasTestIdHelperBind) return
|
||||
if (templateNode.type !== VUE_DIRECTIVE_NODE || templateNode.name !== 'bind') return
|
||||
if (templateNode.arg) return
|
||||
hasTestIdHelperBind = hasExpressionCall(
|
||||
templateNode.exp,
|
||||
(name) => name === 'testId' || name === 'testIdAttr'
|
||||
)
|
||||
})
|
||||
if (!hasTestIdHelperBind) return
|
||||
context.report({
|
||||
node,
|
||||
message: 'Use v-test-id instead of v-bind="testId(...)" in Vue templates.'
|
||||
|
|
@ -332,7 +543,6 @@ const noTestIdHelperBindInVue = {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const noInvalidTestIdAttributes = {
|
||||
meta: {
|
||||
|
|
@ -346,26 +556,35 @@ const noInvalidTestIdAttributes = {
|
|||
|
||||
return {
|
||||
Program(node) {
|
||||
const source = context.sourceCode.getText()
|
||||
const invalidAttr = source.match(/\bdata-testid\s*=/)
|
||||
if (invalidAttr) {
|
||||
const template = vueTemplateAst(context.sourceCode.getText(), file)
|
||||
if (!template) return
|
||||
let invalidId = null
|
||||
let hasInvalidSpelling = false
|
||||
walkVueTemplateAst(template, (templateNode) => {
|
||||
if (hasInvalidSpelling || invalidId !== null) return
|
||||
if (
|
||||
isStaticVueAttribute(templateNode, 'data-testid') ||
|
||||
isVueBindDirective(templateNode, 'data-testid')
|
||||
) {
|
||||
hasInvalidSpelling = true
|
||||
return
|
||||
}
|
||||
if (!isStaticVueAttribute(templateNode, 'data-test-id')) return
|
||||
const id = templateNode.value?.content ?? ''
|
||||
if (!TEST_ID_FORMAT.test(id)) invalidId = id
|
||||
})
|
||||
if (hasInvalidSpelling) {
|
||||
context.report({
|
||||
node,
|
||||
message: 'Use data-test-id instead of data-testid.'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const attrPattern = /\bdata-test-id\s*=\s*"([^"]+)"/g
|
||||
for (const match of source.matchAll(attrPattern)) {
|
||||
const id = match[1]
|
||||
if (TEST_ID_FORMAT.test(id)) continue
|
||||
if (invalidId === null) return
|
||||
context.report({
|
||||
node,
|
||||
message: `Static data-test-id values must be kebab-case. Invalid id: "${id}".`
|
||||
message: `Static data-test-id values must be kebab-case. Invalid id: "${invalidId}".`
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -406,6 +625,22 @@ const noRawTestIdSelectorsInTests = {
|
|||
}
|
||||
}
|
||||
|
||||
function isGeneratedTestIdLiteral(value) {
|
||||
if (typeof value !== 'string') return false
|
||||
const toolbarValue = value.startsWith('mobile-toolbar-')
|
||||
? value.slice('mobile-'.length)
|
||||
: value
|
||||
return (
|
||||
toolbarValue.startsWith('toolbar-tool-') ||
|
||||
toolbarValue.startsWith('toolbar-flyout-') ||
|
||||
toolbarValue.startsWith('toolbar-flyout-item-') ||
|
||||
value === 'variables-add-float' ||
|
||||
value === 'variables-add-string' ||
|
||||
value === 'variables-add-boolean' ||
|
||||
value.startsWith('acp-permission-option-')
|
||||
)
|
||||
}
|
||||
|
||||
const noGeneratedTestIdLiterals = {
|
||||
meta: {
|
||||
docs: {
|
||||
|
|
@ -418,19 +653,32 @@ const noGeneratedTestIdLiterals = {
|
|||
if (file.endsWith('/tests/helpers/test-ids.ts')) return {}
|
||||
if (!file.includes('/src/') && !file.includes('/tests/')) return {}
|
||||
|
||||
return {
|
||||
Program(node) {
|
||||
const source = context.sourceCode.getText()
|
||||
const match = source.match(
|
||||
/["'`](?:mobile-)?toolbar-(?:tool|flyout|flyout-item)-|["'`]variables-add-(?:float|string|boolean)["'`]|["'`]acp-permission-option-/
|
||||
)
|
||||
if (!match) return
|
||||
function report(node) {
|
||||
context.report({
|
||||
node,
|
||||
message:
|
||||
'Use shared generated test-id helpers instead of hand-written toolbar/variable/permission id literals.'
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
Program(node) {
|
||||
if (!file.endsWith('.vue')) return
|
||||
const template = vueTemplateAst(context.sourceCode.getText(), file)
|
||||
if (!template) return
|
||||
let hasGeneratedTemplateId = false
|
||||
walkVueTemplateAst(template, (templateNode) => {
|
||||
if (hasGeneratedTemplateId || !isStaticVueAttribute(templateNode, 'data-test-id')) return
|
||||
hasGeneratedTemplateId = isGeneratedTestIdLiteral(templateNode.value?.content)
|
||||
})
|
||||
if (hasGeneratedTemplateId) report(node)
|
||||
},
|
||||
Literal(node) {
|
||||
if (isGeneratedTestIdLiteral(node.value)) report(node)
|
||||
},
|
||||
TemplateElement(node) {
|
||||
if (isGeneratedTestIdLiteral(node.value?.raw)) report(node)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -631,8 +879,11 @@ const noHandRolledColor = {
|
|||
|
||||
return {
|
||||
TemplateLiteral(node) {
|
||||
const raw = context.sourceCode.getText(node)
|
||||
if (/rgba?\s*\(/.test(raw)) {
|
||||
const hasHandRolledRgb = node.quasis?.some((quasi) => {
|
||||
const raw = quasi.value?.raw
|
||||
return typeof raw === 'string' && (raw.includes('rgb(') || raw.includes('rgba('))
|
||||
})
|
||||
if (!hasHandRolledRgb) return
|
||||
context.report({
|
||||
node,
|
||||
message:
|
||||
|
|
@ -642,7 +893,6 @@ const noHandRolledColor = {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const noRawConsoleFormat = {
|
||||
meta: {
|
||||
|
|
@ -1837,6 +2087,9 @@ const plugin = {
|
|||
'no-vue-style-blocks': noVueStyleBlocks,
|
||||
'no-native-title-attributes-in-vue': noNativeTitleAttributesInVue,
|
||||
'no-hardcoded-tip-labels-in-vue': noHardcodedTipLabelsInVue,
|
||||
'no-raw-svg-in-app-vue-templates': noRawSvgInAppVueTemplates,
|
||||
'no-ui-helper-calls-in-vue-templates': noUiHelperCallsInVueTemplates,
|
||||
'no-large-property-section-components': noLargePropertySectionComponents,
|
||||
'no-raw-test-id-string-props': noRawTestIdStringProps,
|
||||
'no-dynamic-data-test-id-in-vue': noDynamicDataTestIdInVue,
|
||||
'no-test-id-helper-bind-in-vue': noTestIdHelperBindInVue,
|
||||
|
|
|
|||
|
|
@ -151,6 +151,9 @@
|
|||
"open-pencil/no-vue-style-blocks": "error",
|
||||
"open-pencil/no-native-title-attributes-in-vue": "error",
|
||||
"open-pencil/no-hardcoded-tip-labels-in-vue": "error",
|
||||
"open-pencil/no-raw-svg-in-app-vue-templates": "error",
|
||||
"open-pencil/no-ui-helper-calls-in-vue-templates": "error",
|
||||
"open-pencil/no-large-property-section-components": "error",
|
||||
"open-pencil/no-raw-test-id-string-props": "error",
|
||||
"open-pencil/no-dynamic-data-test-id-in-vue": "error",
|
||||
"open-pencil/no-test-id-helper-bind-in-vue": "error",
|
||||
|
|
|
|||
Loading…
Reference in a new issue