Port design linter from figma-use
This commit is contained in:
parent
b072c69a2f
commit
c57e636dcb
11
README.md
11
README.md
|
|
@ -88,6 +88,17 @@ open-pencil convert design.pen output.fig # Convert between docume
|
|||
</div>
|
||||
```
|
||||
|
||||
### Lint design files
|
||||
|
||||
Catch naming, layout, structure, and accessibility issues from the terminal:
|
||||
|
||||
```sh
|
||||
open-pencil lint design.fig
|
||||
open-pencil lint design.pen --preset strict
|
||||
open-pencil lint design.fig --rule color-contrast
|
||||
open-pencil lint design.fig --list-rules
|
||||
```
|
||||
|
||||
### Analyze design tokens
|
||||
|
||||
Audit an entire design system from the terminal — find inconsistencies, extract the real palette, spot components waiting to be extracted:
|
||||
|
|
|
|||
86
packages/cli/src/commands/lint.ts
Normal file
86
packages/cli/src/commands/lint.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { defineCommand } from 'citty'
|
||||
|
||||
import { allRules, createLinter, presets, type LintMessage } from '@open-pencil/core'
|
||||
|
||||
import { bold, dim, fail, fmtList, ok } from '../format'
|
||||
import { loadDocument } from '../headless'
|
||||
|
||||
function formatSeverity(severity: LintMessage['severity']) {
|
||||
if (severity === 'error') return fail('error')
|
||||
if (severity === 'warning') return fail('warn')
|
||||
return ok('info')
|
||||
}
|
||||
|
||||
function formatMessage(message: LintMessage) {
|
||||
return {
|
||||
header: `${formatSeverity(message.severity)} ${bold(message.ruleId)} ${dim(message.nodePath.join(' / '))}`,
|
||||
details: {
|
||||
message: message.message,
|
||||
node: `${message.nodeName} (${message.nodeId})`,
|
||||
suggest: message.suggest
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
meta: {
|
||||
name: 'lint',
|
||||
description: 'Lint design documents for consistency, structure, and accessibility issues'
|
||||
},
|
||||
args: {
|
||||
file: {
|
||||
type: 'positional',
|
||||
required: true,
|
||||
description: 'Design document to lint (.fig, .pen)'
|
||||
},
|
||||
preset: {
|
||||
type: 'string',
|
||||
default: 'recommended',
|
||||
description: 'Preset: recommended, strict, accessibility'
|
||||
},
|
||||
rule: { type: 'string', description: 'Run specific rule(s) only (repeatable)' },
|
||||
json: { type: 'boolean', default: false, description: 'Output as JSON' },
|
||||
'list-rules': { type: 'boolean', default: false, description: 'List rules and exit' }
|
||||
},
|
||||
async run({ args }) {
|
||||
if (args['list-rules']) {
|
||||
console.log('')
|
||||
console.log(bold('Available rules'))
|
||||
console.log('')
|
||||
console.log(
|
||||
fmtList(
|
||||
Object.entries(allRules).map(([id, rule]) => ({
|
||||
header: bold(id),
|
||||
details: { category: rule.meta.category, description: rule.meta.description }
|
||||
}))
|
||||
)
|
||||
)
|
||||
console.log('')
|
||||
console.log(bold(`Presets: ${Object.keys(presets).join(', ')}`))
|
||||
console.log('')
|
||||
return
|
||||
}
|
||||
|
||||
const graph = await loadDocument(args.file)
|
||||
const rules = args.rule ? (Array.isArray(args.rule) ? args.rule : [args.rule]) : undefined
|
||||
const result = createLinter({ preset: args.preset, rules }).lintGraph(graph)
|
||||
|
||||
if (args.json) {
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
} else if (result.messages.length === 0) {
|
||||
console.log(ok('No lint issues found.'))
|
||||
} else {
|
||||
console.log('')
|
||||
console.log(
|
||||
bold(
|
||||
`Lint issues: ${result.errorCount} errors, ${result.warningCount} warnings, ${result.infoCount} info`
|
||||
)
|
||||
)
|
||||
console.log('')
|
||||
console.log(fmtList(result.messages.map(formatMessage)))
|
||||
console.log('')
|
||||
}
|
||||
|
||||
if (result.errorCount > 0) process.exit(1)
|
||||
}
|
||||
})
|
||||
|
|
@ -8,6 +8,7 @@ import exportCmd from './commands/export'
|
|||
import find from './commands/find'
|
||||
import formats from './commands/formats'
|
||||
import info from './commands/info'
|
||||
import lint from './commands/lint'
|
||||
import node from './commands/node'
|
||||
import pages from './commands/pages'
|
||||
import query from './commands/query'
|
||||
|
|
@ -30,6 +31,7 @@ const main = defineCommand({
|
|||
find,
|
||||
formats,
|
||||
info,
|
||||
lint,
|
||||
query,
|
||||
node,
|
||||
pages,
|
||||
|
|
|
|||
|
|
@ -347,6 +347,7 @@ export {
|
|||
} from './kiwi'
|
||||
|
||||
export * from './io'
|
||||
export * from './lint'
|
||||
|
||||
export { CODEGEN_PROMPT } from './tools/prompts/codegen-prompt'
|
||||
export {
|
||||
|
|
|
|||
15
packages/core/src/lint/index.ts
Normal file
15
packages/core/src/lint/index.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
export { Linter, createLinter } from './linter'
|
||||
export { defineRule } from './rule'
|
||||
export { allRules } from './rules'
|
||||
export { presets, recommended, strict, accessibility } from './presets'
|
||||
export type {
|
||||
Rule,
|
||||
RuleMeta,
|
||||
RuleContext,
|
||||
LintNode,
|
||||
LintMessage,
|
||||
LintResult,
|
||||
LintConfig,
|
||||
Severity,
|
||||
Category
|
||||
} from './types'
|
||||
147
packages/core/src/lint/linter.ts
Normal file
147
packages/core/src/lint/linter.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { presets } from './presets'
|
||||
import { allRules } from './rules'
|
||||
import { getNodePath } from './utils'
|
||||
|
||||
import type { SceneGraph, SceneNode } from '../scene-graph'
|
||||
import type {
|
||||
LintConfig,
|
||||
LintMessage,
|
||||
LintNode,
|
||||
LintResult,
|
||||
Rule,
|
||||
RuleContext,
|
||||
Severity
|
||||
} from './types'
|
||||
|
||||
export class Linter {
|
||||
private rules = new Map<string, Rule>()
|
||||
private ruleConfigs = new Map<string, { severity: Severity; options?: Record<string, unknown> }>()
|
||||
private messages: LintMessage[] = []
|
||||
private nodes = new Map<string, LintNode & { parent?: LintNode }>()
|
||||
|
||||
constructor(options: { config?: LintConfig; preset?: string; rules?: string[] } = {}) {
|
||||
let baseConfig: Record<
|
||||
string,
|
||||
Severity | { severity: Severity; options?: Record<string, unknown> }
|
||||
> = {}
|
||||
if (options.preset) baseConfig = { ...presets[options.preset].rules }
|
||||
if (options.config?.extends) {
|
||||
const ids = Array.isArray(options.config.extends)
|
||||
? options.config.extends
|
||||
: [options.config.extends]
|
||||
for (const id of ids) baseConfig = { ...baseConfig, ...presets[id].rules }
|
||||
}
|
||||
if (options.config?.rules) baseConfig = { ...baseConfig, ...options.config.rules }
|
||||
const rulesToLoad = options.rules ?? Object.keys(baseConfig)
|
||||
for (const ruleId of rulesToLoad) {
|
||||
const rule = allRules[ruleId]
|
||||
const config = baseConfig[ruleId]
|
||||
if (config === 'off') continue
|
||||
this.rules.set(ruleId, rule)
|
||||
if (typeof config === 'string') this.ruleConfigs.set(ruleId, { severity: config })
|
||||
else this.ruleConfigs.set(ruleId, config)
|
||||
}
|
||||
}
|
||||
|
||||
lintGraph(graph: SceneGraph, rootIds?: string[]): LintResult {
|
||||
this.messages = []
|
||||
this.nodes.clear()
|
||||
const roots = rootIds && rootIds.length > 0 ? rootIds : graph.getPages().map((p) => p.id)
|
||||
for (const id of roots) this.capture(graph, id, undefined)
|
||||
for (const id of roots) this.lintNode(id)
|
||||
return {
|
||||
messages: this.messages,
|
||||
errorCount: this.messages.filter((m) => m.severity === 'error').length,
|
||||
warningCount: this.messages.filter((m) => m.severity === 'warning').length,
|
||||
infoCount: this.messages.filter((m) => m.severity === 'info').length
|
||||
}
|
||||
}
|
||||
|
||||
private capture(graph: SceneGraph, id: string, parent?: LintNode) {
|
||||
const raw = graph.getNode(id)
|
||||
if (!raw) return
|
||||
const node = this.toLintNode(raw)
|
||||
;(node as LintNode & { parent?: LintNode }).parent = parent
|
||||
this.nodes.set(id, node as LintNode & { parent?: LintNode })
|
||||
for (const childId of raw.childIds) this.capture(graph, childId, node)
|
||||
}
|
||||
|
||||
private toLintNode(raw: SceneNode): LintNode {
|
||||
return {
|
||||
id: raw.id,
|
||||
name: raw.name,
|
||||
type: raw.type,
|
||||
width: raw.width,
|
||||
height: raw.height,
|
||||
x: raw.x,
|
||||
y: raw.y,
|
||||
rotation: raw.rotation,
|
||||
visible: raw.visible,
|
||||
locked: raw.locked,
|
||||
layoutMode: raw.layoutMode,
|
||||
itemSpacing: raw.itemSpacing,
|
||||
paddingTop: raw.paddingTop,
|
||||
paddingRight: raw.paddingRight,
|
||||
paddingBottom: raw.paddingBottom,
|
||||
paddingLeft: raw.paddingLeft,
|
||||
cornerRadius: raw.cornerRadius,
|
||||
childIds: raw.childIds.slice(),
|
||||
componentId: raw.componentId || undefined,
|
||||
text: raw.text,
|
||||
fontSize: raw.fontSize,
|
||||
styleRunCount: raw.styleRuns.length,
|
||||
boundVariables: raw.boundVariables,
|
||||
fills: raw.fills.map((f) => ({
|
||||
type: f.type,
|
||||
visible: f.visible,
|
||||
opacity: f.opacity,
|
||||
color: f.type === 'SOLID' ? f.color : undefined
|
||||
})),
|
||||
strokes: raw.strokes.map((stroke) => ({
|
||||
visible: stroke.visible,
|
||||
opacity: stroke.opacity,
|
||||
color: stroke.color
|
||||
})),
|
||||
effects: raw.effects.map((effect) => ({
|
||||
type: effect.type,
|
||||
visible: effect.visible,
|
||||
radius: effect.radius
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
private lintNode(id: string) {
|
||||
const node = this.nodes.get(id)
|
||||
if (!node) return
|
||||
for (const [ruleId, rule] of this.rules) {
|
||||
if (rule.match && !rule.match.includes(node.type)) continue
|
||||
const config = this.ruleConfigs.get(ruleId)
|
||||
if (!config || config.severity === 'off') continue
|
||||
const context: RuleContext = {
|
||||
report: ({ node, message, suggest }) => {
|
||||
this.messages.push({
|
||||
ruleId,
|
||||
severity: config.severity as Exclude<Severity, 'off'>,
|
||||
message,
|
||||
nodeId: node.id,
|
||||
nodeName: node.name,
|
||||
nodePath: getNodePath(this.nodes.get(node.id) as LintNode & { parent?: LintNode }),
|
||||
suggest
|
||||
})
|
||||
},
|
||||
getConfig: () => config.options,
|
||||
getParent: (node) => this.nodes.get(node.id)?.parent ?? null,
|
||||
getChildren: (node) =>
|
||||
node.childIds
|
||||
.map((childId) => this.nodes.get(childId))
|
||||
.filter((child): child is LintNode => !!child)
|
||||
}
|
||||
rule.check(node, context)
|
||||
}
|
||||
for (const childId of node.childIds) this.lintNode(childId)
|
||||
}
|
||||
}
|
||||
|
||||
export function createLinter(options?: { config?: LintConfig; preset?: string; rules?: string[] }) {
|
||||
return new Linter(options)
|
||||
}
|
||||
56
packages/core/src/lint/presets.ts
Normal file
56
packages/core/src/lint/presets.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import type { Severity } from './types'
|
||||
|
||||
type RuleConfig = Severity | { severity: Severity; options?: Record<string, unknown> }
|
||||
export interface Preset {
|
||||
rules: Record<string, RuleConfig>
|
||||
}
|
||||
|
||||
export const recommended: Preset = {
|
||||
rules: {
|
||||
'no-hardcoded-colors': 'warning',
|
||||
'no-default-names': 'info',
|
||||
'prefer-auto-layout': 'info',
|
||||
'consistent-spacing': 'warning',
|
||||
'consistent-radius': 'info',
|
||||
'color-contrast': 'error',
|
||||
'touch-target-size': 'warning',
|
||||
'text-style-required': 'info',
|
||||
'min-text-size': 'warning',
|
||||
'no-hidden-layers': 'info',
|
||||
'no-deeply-nested': 'warning',
|
||||
'no-empty-frames': 'info',
|
||||
'pixel-perfect': 'info',
|
||||
'no-groups': 'info',
|
||||
'effect-style-required': 'info',
|
||||
'no-mixed-styles': 'warning',
|
||||
'no-detached-instances': 'off'
|
||||
}
|
||||
}
|
||||
|
||||
export const strict: Preset = {
|
||||
rules: Object.fromEntries(
|
||||
Object.keys(recommended.rules).map((id) => [id, id === 'color-contrast' ? 'error' : 'warning'])
|
||||
)
|
||||
}
|
||||
export const accessibility: Preset = {
|
||||
rules: {
|
||||
'color-contrast': 'error',
|
||||
'touch-target-size': 'error',
|
||||
'min-text-size': 'error',
|
||||
'no-hardcoded-colors': 'off',
|
||||
'no-default-names': 'off',
|
||||
'prefer-auto-layout': 'off',
|
||||
'consistent-spacing': 'off',
|
||||
'consistent-radius': 'off',
|
||||
'text-style-required': 'off',
|
||||
'no-hidden-layers': 'off',
|
||||
'no-deeply-nested': 'off',
|
||||
'no-empty-frames': 'off',
|
||||
'pixel-perfect': 'off',
|
||||
'no-groups': 'off',
|
||||
'effect-style-required': 'off',
|
||||
'no-mixed-styles': 'off',
|
||||
'no-detached-instances': 'off'
|
||||
}
|
||||
}
|
||||
export const presets: Record<string, Preset> = { recommended, strict, accessibility }
|
||||
13
packages/core/src/lint/rule.ts
Normal file
13
packages/core/src/lint/rule.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import type { Rule, RuleContext, RuleMeta, LintNode } from './types'
|
||||
|
||||
export function defineRule(definition: {
|
||||
meta: Omit<RuleMeta, 'severity'> & { severity?: RuleMeta['severity'] }
|
||||
match?: string[]
|
||||
check: (node: LintNode, context: RuleContext) => void
|
||||
}): Rule {
|
||||
return {
|
||||
meta: { severity: 'warning', ...definition.meta },
|
||||
match: definition.match,
|
||||
check: definition.check
|
||||
}
|
||||
}
|
||||
32
packages/core/src/lint/rules/color-contrast.ts
Normal file
32
packages/core/src/lint/rules/color-contrast.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { defineRule } from '../rule'
|
||||
import { contrastRatio } from '../utils'
|
||||
|
||||
export default defineRule({
|
||||
meta: {
|
||||
id: 'color-contrast',
|
||||
category: 'accessibility',
|
||||
severity: 'error',
|
||||
description: 'Text must have sufficient contrast against its background'
|
||||
},
|
||||
match: ['TEXT'],
|
||||
check(node, context) {
|
||||
const textFill = node.fills.find((f) => f.type === 'SOLID' && f.visible && f.color)
|
||||
const textColor = textFill?.color
|
||||
if (!textColor) return
|
||||
let parent = context.getParent(node)
|
||||
while (parent) {
|
||||
const bg = parent.fills.find((f) => f.type === 'SOLID' && f.visible && f.color)?.color
|
||||
if (bg) {
|
||||
const ratio = contrastRatio(textColor, bg)
|
||||
if (ratio < 4.5)
|
||||
context.report({
|
||||
node,
|
||||
message: `Contrast ratio ${ratio.toFixed(2)}:1 is below WCAG AA`,
|
||||
suggest: 'Increase contrast between text and background'
|
||||
})
|
||||
return
|
||||
}
|
||||
parent = context.getParent(parent)
|
||||
}
|
||||
}
|
||||
})
|
||||
18
packages/core/src/lint/rules/consistent-radius.ts
Normal file
18
packages/core/src/lint/rules/consistent-radius.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { defineRule } from '../rule'
|
||||
const SCALE = [0, 2, 4, 6, 8, 12, 16, 20, 24, 32, 9999]
|
||||
export default defineRule({
|
||||
meta: {
|
||||
id: 'consistent-radius',
|
||||
category: 'layout',
|
||||
description: 'Corner radius should follow the radius scale'
|
||||
},
|
||||
match: ['RECTANGLE', 'FRAME', 'COMPONENT', 'INSTANCE'],
|
||||
check(node, context) {
|
||||
if (node.cornerRadius > 0 && !SCALE.includes(node.cornerRadius))
|
||||
context.report({
|
||||
node,
|
||||
message: `Corner radius ${node.cornerRadius}px is not in scale`,
|
||||
suggest: 'Use a radius token or a scale value'
|
||||
})
|
||||
}
|
||||
})
|
||||
33
packages/core/src/lint/rules/consistent-spacing.ts
Normal file
33
packages/core/src/lint/rules/consistent-spacing.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { defineRule } from '../rule'
|
||||
import { isMultipleOf, SPACING_SCALE } from '../utils'
|
||||
|
||||
export default defineRule({
|
||||
meta: {
|
||||
id: 'consistent-spacing',
|
||||
category: 'layout',
|
||||
description: 'Spacing should follow the spacing scale'
|
||||
},
|
||||
match: ['FRAME', 'COMPONENT'],
|
||||
check(node, context) {
|
||||
if (node.layoutMode === 'NONE') return
|
||||
const config = context.getConfig() as { base?: number } | undefined
|
||||
const base = config?.base ?? 8
|
||||
const valid = (value: number) => SPACING_SCALE.includes(value) || isMultipleOf(value, base)
|
||||
const values = [
|
||||
['gap', node.itemSpacing],
|
||||
['paddingTop', node.paddingTop],
|
||||
['paddingRight', node.paddingRight],
|
||||
['paddingBottom', node.paddingBottom],
|
||||
['paddingLeft', node.paddingLeft]
|
||||
] as const
|
||||
for (const [name, value] of values) {
|
||||
if (value > 0 && !valid(value)) {
|
||||
context.report({
|
||||
node,
|
||||
message: `${name} ${value}px is not in spacing scale`,
|
||||
suggest: 'Use a spacing token or 8pt-grid multiple'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
10
packages/core/src/lint/rules/effect-style-required.ts
Normal file
10
packages/core/src/lint/rules/effect-style-required.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { defineRule } from '../rule'
|
||||
|
||||
export default defineRule({
|
||||
meta: { id: 'effect-style-required', category: 'design-tokens', description: 'Effects should use shared effect presets or tokens' },
|
||||
check(node, context) {
|
||||
const visibleEffects = node.effects.filter((effect) => effect.visible)
|
||||
if (visibleEffects.length === 0) return
|
||||
context.report({ node, message: `Effect without shared style: ${visibleEffects.map((effect) => `${effect.type} ${effect.radius}px`).join(', ')}`, suggest: 'Extract reusable shadows and blurs into shared presets or variables' })
|
||||
}
|
||||
})
|
||||
57
packages/core/src/lint/rules/index.ts
Normal file
57
packages/core/src/lint/rules/index.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
export { default as noHardcodedColors } from './no-hardcoded-colors'
|
||||
export { default as noDefaultNames } from './no-default-names'
|
||||
export { default as preferAutoLayout } from './prefer-auto-layout'
|
||||
export { default as consistentSpacing } from './consistent-spacing'
|
||||
export { default as consistentRadius } from './consistent-radius'
|
||||
export { default as colorContrast } from './color-contrast'
|
||||
export { default as touchTargetSize } from './touch-target-size'
|
||||
export { default as textStyleRequired } from './text-style-required'
|
||||
export { default as minTextSize } from './min-text-size'
|
||||
export { default as noHiddenLayers } from './no-hidden-layers'
|
||||
export { default as noDeeplyNested } from './no-deeply-nested'
|
||||
export { default as noEmptyFrames } from './no-empty-frames'
|
||||
export { default as pixelPerfect } from './pixel-perfect'
|
||||
export { default as noGroups } from './no-groups'
|
||||
export { default as effectStyleRequired } from './effect-style-required'
|
||||
export { default as noMixedStyles } from './no-mixed-styles'
|
||||
export { default as noDetachedInstances } from './no-detached-instances'
|
||||
|
||||
import colorContrast from './color-contrast'
|
||||
import consistentRadius from './consistent-radius'
|
||||
import consistentSpacing from './consistent-spacing'
|
||||
import effectStyleRequired from './effect-style-required'
|
||||
import minTextSize from './min-text-size'
|
||||
import noDeeplyNested from './no-deeply-nested'
|
||||
import noDefaultNames from './no-default-names'
|
||||
import noDetachedInstances from './no-detached-instances'
|
||||
import noEmptyFrames from './no-empty-frames'
|
||||
import noGroups from './no-groups'
|
||||
import noHardcodedColors from './no-hardcoded-colors'
|
||||
import noHiddenLayers from './no-hidden-layers'
|
||||
import noMixedStyles from './no-mixed-styles'
|
||||
import pixelPerfect from './pixel-perfect'
|
||||
import preferAutoLayout from './prefer-auto-layout'
|
||||
import textStyleRequired from './text-style-required'
|
||||
import touchTargetSize from './touch-target-size'
|
||||
|
||||
import type { Rule } from '../types'
|
||||
|
||||
export const allRules: Record<string, Rule> = {
|
||||
'no-hardcoded-colors': noHardcodedColors,
|
||||
'no-default-names': noDefaultNames,
|
||||
'prefer-auto-layout': preferAutoLayout,
|
||||
'consistent-spacing': consistentSpacing,
|
||||
'consistent-radius': consistentRadius,
|
||||
'color-contrast': colorContrast,
|
||||
'touch-target-size': touchTargetSize,
|
||||
'text-style-required': textStyleRequired,
|
||||
'min-text-size': minTextSize,
|
||||
'no-hidden-layers': noHiddenLayers,
|
||||
'no-deeply-nested': noDeeplyNested,
|
||||
'no-empty-frames': noEmptyFrames,
|
||||
'pixel-perfect': pixelPerfect,
|
||||
'no-groups': noGroups,
|
||||
'effect-style-required': effectStyleRequired,
|
||||
'no-mixed-styles': noMixedStyles,
|
||||
'no-detached-instances': noDetachedInstances
|
||||
}
|
||||
11
packages/core/src/lint/rules/min-text-size.ts
Normal file
11
packages/core/src/lint/rules/min-text-size.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { defineRule } from '../rule'
|
||||
|
||||
export default defineRule({
|
||||
meta: { id: 'min-text-size', category: 'accessibility', description: 'Text should be large enough to be readable (minimum 12px)' },
|
||||
match: ['TEXT'],
|
||||
check(node, context) {
|
||||
const config = context.getConfig() as { minSize?: number } | undefined
|
||||
const minSize = config?.minSize ?? 12
|
||||
if (node.fontSize < minSize) context.report({ node, message: `Text size ${node.fontSize}px is below minimum ${minSize}px`, suggest: `Increase to at least ${minSize}px for readability` })
|
||||
}
|
||||
})
|
||||
26
packages/core/src/lint/rules/no-deeply-nested.ts
Normal file
26
packages/core/src/lint/rules/no-deeply-nested.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { defineRule } from '../rule'
|
||||
|
||||
export default defineRule({
|
||||
meta: {
|
||||
id: 'no-deeply-nested',
|
||||
category: 'structure',
|
||||
description: 'Avoid deeply nested layers'
|
||||
},
|
||||
check(node, context) {
|
||||
const config = context.getConfig() as { maxDepth?: number } | undefined
|
||||
const maxDepth = config?.maxDepth ?? 6
|
||||
let depth = 0
|
||||
let current = context.getParent(node)
|
||||
while (current) {
|
||||
depth++
|
||||
current = context.getParent(current)
|
||||
}
|
||||
if (depth > maxDepth) {
|
||||
context.report({
|
||||
node,
|
||||
message: `Layer nested ${depth} levels deep (max ${maxDepth})`,
|
||||
suggest: 'Flatten structure or extract a component'
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
21
packages/core/src/lint/rules/no-default-names.ts
Normal file
21
packages/core/src/lint/rules/no-default-names.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { defineRule } from '../rule'
|
||||
import { isDefaultName } from '../utils'
|
||||
|
||||
export default defineRule({
|
||||
meta: {
|
||||
id: 'no-default-names',
|
||||
category: 'naming',
|
||||
description: 'Layers should have descriptive names'
|
||||
},
|
||||
check(node, context) {
|
||||
if (!isDefaultName(node.name)) return
|
||||
const isSmallDecorative =
|
||||
['RECTANGLE', 'ELLIPSE', 'LINE'].includes(node.type) && node.width < 24 && node.height < 24
|
||||
if (isSmallDecorative) return
|
||||
context.report({
|
||||
node,
|
||||
message: `Default layer name "${node.name}" is not descriptive`,
|
||||
suggest: 'Rename to describe the layer purpose'
|
||||
})
|
||||
}
|
||||
})
|
||||
34
packages/core/src/lint/rules/no-detached-instances.ts
Normal file
34
packages/core/src/lint/rules/no-detached-instances.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { defineRule } from '../rule'
|
||||
const PATTERNS = [
|
||||
/^(button|btn)/i,
|
||||
/^(input|field|text-?field)/i,
|
||||
/^(card|modal|dialog)/i,
|
||||
/^(icon|avatar|badge)/i,
|
||||
/^(nav|menu|tab)/i,
|
||||
/^(header|footer|sidebar)/i,
|
||||
/^(list|item|row)/i,
|
||||
/^(chip|tag|label)/i,
|
||||
/^(tooltip|popover|dropdown)/i
|
||||
]
|
||||
export default defineRule({
|
||||
meta: {
|
||||
id: 'no-detached-instances',
|
||||
category: 'components',
|
||||
description: 'Frames that look like components should be instances, not detached copies'
|
||||
},
|
||||
match: ['FRAME'],
|
||||
check(node, context) {
|
||||
if (
|
||||
node.componentId ||
|
||||
!PATTERNS.some((p) => p.test(node.name)) ||
|
||||
context.getChildren(node).length === 0 ||
|
||||
node.layoutMode === 'NONE'
|
||||
)
|
||||
return
|
||||
context.report({
|
||||
node,
|
||||
message: `Frame "${node.name}" looks like a component but isn't an instance`,
|
||||
suggest: 'Use a component instance instead of a detached frame'
|
||||
})
|
||||
}
|
||||
})
|
||||
21
packages/core/src/lint/rules/no-empty-frames.ts
Normal file
21
packages/core/src/lint/rules/no-empty-frames.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { defineRule } from '../rule'
|
||||
export default defineRule({
|
||||
meta: {
|
||||
id: 'no-empty-frames',
|
||||
category: 'structure',
|
||||
description: 'Frames should not be empty unless used as spacers'
|
||||
},
|
||||
match: ['FRAME'],
|
||||
check(node, context) {
|
||||
if (context.getChildren(node).length > 0) return
|
||||
const isSpacer =
|
||||
node.name.toLowerCase().includes('spacer') || node.width <= 1 || node.height <= 1
|
||||
const hasFill = node.fills.some((f) => f.visible && f.type === 'SOLID')
|
||||
if (!isSpacer && !hasFill)
|
||||
context.report({
|
||||
node,
|
||||
message: 'Empty frame with no fill',
|
||||
suggest: 'Delete if unused, or add content/fill'
|
||||
})
|
||||
}
|
||||
})
|
||||
16
packages/core/src/lint/rules/no-groups.ts
Normal file
16
packages/core/src/lint/rules/no-groups.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { defineRule } from '../rule'
|
||||
export default defineRule({
|
||||
meta: {
|
||||
id: 'no-groups',
|
||||
category: 'structure',
|
||||
description: 'Use frames instead of groups for better layout control'
|
||||
},
|
||||
match: ['GROUP'],
|
||||
check(node, context) {
|
||||
context.report({
|
||||
node,
|
||||
message: 'Group should be converted to Frame',
|
||||
suggest: 'Groups cannot use auto layout. Convert to Frame for better control.'
|
||||
})
|
||||
}
|
||||
})
|
||||
16
packages/core/src/lint/rules/no-hardcoded-colors.ts
Normal file
16
packages/core/src/lint/rules/no-hardcoded-colors.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { defineRule } from '../rule'
|
||||
|
||||
export default defineRule({
|
||||
meta: { id: 'no-hardcoded-colors', category: 'design-tokens', description: 'Colors should use variables instead of hardcoded values' },
|
||||
match: ['RECTANGLE','ELLIPSE','FRAME','TEXT','VECTOR','LINE','POLYGON','STAR','COMPONENT','INSTANCE'],
|
||||
check(node, context) {
|
||||
const checkPaints = (paints: typeof node.fills, field: 'fills' | 'strokes') => {
|
||||
for (const paint of paints) {
|
||||
if (paint.type !== 'SOLID' || !paint.visible || !paint.color || node.boundVariables[field]) continue
|
||||
context.report({ node, message: `Hardcoded ${field === 'fills' ? 'fill' : 'stroke'} color detected`, suggest: 'Bind this color to a design variable for consistency' })
|
||||
}
|
||||
}
|
||||
checkPaints(node.fills, 'fills')
|
||||
checkPaints(node.strokes as typeof node.fills, 'strokes')
|
||||
}
|
||||
})
|
||||
16
packages/core/src/lint/rules/no-hidden-layers.ts
Normal file
16
packages/core/src/lint/rules/no-hidden-layers.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { defineRule } from '../rule'
|
||||
export default defineRule({
|
||||
meta: {
|
||||
id: 'no-hidden-layers',
|
||||
category: 'structure',
|
||||
description: 'Hidden layers may indicate unused elements'
|
||||
},
|
||||
check(node, context) {
|
||||
if (!node.visible)
|
||||
context.report({
|
||||
node,
|
||||
message: 'Hidden layer detected',
|
||||
suggest: 'Delete if unused or keep only if required for component states'
|
||||
})
|
||||
}
|
||||
})
|
||||
11
packages/core/src/lint/rules/no-mixed-styles.ts
Normal file
11
packages/core/src/lint/rules/no-mixed-styles.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { defineRule } from '../rule'
|
||||
|
||||
export default defineRule({
|
||||
meta: { id: 'no-mixed-styles', category: 'typography', description: 'Text layers should not mix multiple styles in one node' },
|
||||
match: ['TEXT'],
|
||||
check(node, context) {
|
||||
if (node.text.length > 1 && node.styleRunCount > 0) {
|
||||
context.report({ node, message: 'Text layer has mixed font styles', suggest: 'Split into separate text layers or unify the text style' })
|
||||
}
|
||||
}
|
||||
})
|
||||
24
packages/core/src/lint/rules/pixel-perfect.ts
Normal file
24
packages/core/src/lint/rules/pixel-perfect.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { defineRule } from '../rule'
|
||||
|
||||
export default defineRule({
|
||||
meta: {
|
||||
id: 'pixel-perfect',
|
||||
category: 'layout',
|
||||
description: 'Elements should align to whole pixels'
|
||||
},
|
||||
check(node, context) {
|
||||
const values: Array<[string, number]> = [
|
||||
['x', node.x],
|
||||
['y', node.y],
|
||||
['width', node.width],
|
||||
['height', node.height]
|
||||
]
|
||||
const subpixel = values.filter(([, value]) => Math.abs(value - Math.round(value)) >= 0.01)
|
||||
if (subpixel.length === 0) return
|
||||
context.report({
|
||||
node,
|
||||
message: `Subpixel values: ${subpixel.map(([k, v]) => `${k}: ${v}`).join(', ')}`,
|
||||
suggest: 'Round to whole pixels for crisp rendering'
|
||||
})
|
||||
}
|
||||
})
|
||||
20
packages/core/src/lint/rules/prefer-auto-layout.ts
Normal file
20
packages/core/src/lint/rules/prefer-auto-layout.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { defineRule } from '../rule'
|
||||
|
||||
export default defineRule({
|
||||
meta: {
|
||||
id: 'prefer-auto-layout',
|
||||
category: 'layout',
|
||||
description: 'Frames with multiple children should use auto layout'
|
||||
},
|
||||
match: ['FRAME', 'COMPONENT'],
|
||||
check(node, context) {
|
||||
const config = context.getConfig() as { minChildren?: number } | undefined
|
||||
const minChildren = config?.minChildren ?? 2
|
||||
if (node.layoutMode !== 'NONE' || context.getChildren(node).length < minChildren) return
|
||||
context.report({
|
||||
node,
|
||||
message: `Frame with ${context.getChildren(node).length} children doesn't use auto layout`,
|
||||
suggest: 'Add horizontal or vertical auto layout'
|
||||
})
|
||||
}
|
||||
})
|
||||
11
packages/core/src/lint/rules/text-style-required.ts
Normal file
11
packages/core/src/lint/rules/text-style-required.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { defineRule } from '../rule'
|
||||
|
||||
export default defineRule({
|
||||
meta: { id: 'text-style-required', category: 'typography', description: 'Text layers should use shared typography tokens or styles' },
|
||||
match: ['TEXT'],
|
||||
check(node, context) {
|
||||
if (node.text.length <= 2) return
|
||||
if (node.boundVariables.fontSize || node.boundVariables.fontFamily) return
|
||||
context.report({ node, message: 'Text layer without typography variable bindings', suggest: 'Bind font size or font family to a shared text token when possible' })
|
||||
}
|
||||
})
|
||||
39
packages/core/src/lint/rules/touch-target-size.ts
Normal file
39
packages/core/src/lint/rules/touch-target-size.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { defineRule } from '../rule'
|
||||
const PATTERNS = [
|
||||
/button/i,
|
||||
/btn/i,
|
||||
/link/i,
|
||||
/cta/i,
|
||||
/icon/i,
|
||||
/checkbox/i,
|
||||
/radio/i,
|
||||
/switch/i,
|
||||
/toggle/i,
|
||||
/input/i,
|
||||
/select/i,
|
||||
/dropdown/i,
|
||||
/menu/i,
|
||||
/tab/i,
|
||||
/chip/i,
|
||||
/tag/i,
|
||||
/close/i,
|
||||
/dismiss/i,
|
||||
/action/i
|
||||
]
|
||||
export default defineRule({
|
||||
meta: {
|
||||
id: 'touch-target-size',
|
||||
category: 'accessibility',
|
||||
description: 'Interactive elements should be at least 44x44px'
|
||||
},
|
||||
match: ['FRAME', 'COMPONENT', 'INSTANCE', 'RECTANGLE', 'ELLIPSE'],
|
||||
check(node, context) {
|
||||
if (!PATTERNS.some((p) => p.test(node.name))) return
|
||||
if (node.width >= 44 && node.height >= 44) return
|
||||
context.report({
|
||||
node,
|
||||
message: `Touch target too small: ${node.width}×${node.height}px`,
|
||||
suggest: 'Resize to at least 44×44px or add padding'
|
||||
})
|
||||
}
|
||||
})
|
||||
94
packages/core/src/lint/types.ts
Normal file
94
packages/core/src/lint/types.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
export type Severity = 'error' | 'warning' | 'info' | 'off'
|
||||
|
||||
export type Category =
|
||||
| 'layout'
|
||||
| 'accessibility'
|
||||
| 'naming'
|
||||
| 'structure'
|
||||
| 'components'
|
||||
| 'design-tokens'
|
||||
| 'typography'
|
||||
|
||||
export interface RuleMeta {
|
||||
id: string
|
||||
severity: Severity
|
||||
category: Category
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface LintMessage {
|
||||
ruleId: string
|
||||
severity: Exclude<Severity, 'off'>
|
||||
message: string
|
||||
nodeId: string
|
||||
nodeName: string
|
||||
nodePath: string[]
|
||||
suggest?: string
|
||||
}
|
||||
|
||||
export interface LintResult {
|
||||
messages: LintMessage[]
|
||||
errorCount: number
|
||||
warningCount: number
|
||||
infoCount: number
|
||||
}
|
||||
|
||||
export interface LintConfig {
|
||||
extends?: string | string[]
|
||||
rules: Record<string, Severity | { severity: Severity; options?: Record<string, unknown> }>
|
||||
}
|
||||
|
||||
export interface LintNode {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
width: number
|
||||
height: number
|
||||
x: number
|
||||
y: number
|
||||
rotation: number
|
||||
visible: boolean
|
||||
locked: boolean
|
||||
layoutMode: string
|
||||
itemSpacing: number
|
||||
paddingTop: number
|
||||
paddingRight: number
|
||||
paddingBottom: number
|
||||
paddingLeft: number
|
||||
cornerRadius: number
|
||||
childIds: string[]
|
||||
componentId?: string
|
||||
text: string
|
||||
fontSize: number
|
||||
styleRunCount: number
|
||||
boundVariables: Record<string, string>
|
||||
fills: Array<{
|
||||
type: string
|
||||
visible: boolean
|
||||
opacity: number
|
||||
color?: { r: number; g: number; b: number }
|
||||
}>
|
||||
strokes: Array<{
|
||||
visible: boolean
|
||||
opacity: number
|
||||
color?: { r: number; g: number; b: number }
|
||||
}>
|
||||
effects: Array<{
|
||||
type: string
|
||||
visible: boolean
|
||||
radius: number
|
||||
}>
|
||||
}
|
||||
|
||||
export interface RuleContext {
|
||||
report(issue: { node: LintNode; message: string; suggest?: string }): void
|
||||
getConfig(): unknown
|
||||
getParent(node: LintNode): LintNode | null
|
||||
getChildren(node: LintNode): LintNode[]
|
||||
}
|
||||
|
||||
export interface Rule {
|
||||
meta: RuleMeta
|
||||
match?: string[]
|
||||
check(node: LintNode, context: RuleContext): void
|
||||
}
|
||||
44
packages/core/src/lint/utils.ts
Normal file
44
packages/core/src/lint/utils.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
export function isDefaultName(name: string): boolean {
|
||||
return /^(Frame|Rectangle|Ellipse|Line|Text|Group|Vector|Polygon|Star|Section|Component|Instance|Slice)\s*\d*$/i.test(
|
||||
name
|
||||
)
|
||||
}
|
||||
|
||||
export function isMultipleOf(value: number, base: number, tolerance = 0.01): boolean {
|
||||
if (base === 0) return false
|
||||
const remainder = value % base
|
||||
return remainder < tolerance || base - remainder < tolerance
|
||||
}
|
||||
|
||||
export function getNodePath(node: {
|
||||
name: string
|
||||
parent?: { name: string; parent?: unknown }
|
||||
}): string[] {
|
||||
const path: string[] = []
|
||||
let current: typeof node | undefined = node
|
||||
while (current) {
|
||||
path.unshift(current.name)
|
||||
current = current.parent as typeof node | undefined
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
export function relativeLuminance(rgb: { r: number; g: number; b: number }): number {
|
||||
const [r, g, b] = [rgb.r, rgb.g, rgb.b].map((c) =>
|
||||
c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
|
||||
) as [number, number, number]
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
}
|
||||
|
||||
export function contrastRatio(
|
||||
a: { r: number; g: number; b: number },
|
||||
b: { r: number; g: number; b: number }
|
||||
): number {
|
||||
const l1 = relativeLuminance(a)
|
||||
const l2 = relativeLuminance(b)
|
||||
const lighter = Math.max(l1, l2)
|
||||
const darker = Math.min(l1, l2)
|
||||
return (lighter + 0.05) / (darker + 0.05)
|
||||
}
|
||||
|
||||
export const SPACING_SCALE = [0, 1, 2, 4, 8, 12, 16, 20, 24, 32, 40, 48, 56, 64, 80, 96, 128]
|
||||
|
|
@ -5,7 +5,7 @@ description: Browse node trees, search by name or type, and dig into properties
|
|||
|
||||
# Inspecting Files
|
||||
|
||||
The CLI lets you explore `.fig` files without opening the editor. Every command also works on the live app — just omit the file argument.
|
||||
The CLI lets you explore design documents without opening the editor. Every command also works on the live app — just omit the file argument.
|
||||
|
||||
::: tip Install
|
||||
```sh
|
||||
|
|
@ -148,6 +148,19 @@ open-pencil tree # inspect the live document
|
|||
open-pencil eval -c "..." # query the editor
|
||||
```
|
||||
|
||||
## Lint Designs
|
||||
|
||||
Check documents for naming, layout, structure, and accessibility issues:
|
||||
|
||||
```sh
|
||||
open-pencil lint design.fig
|
||||
open-pencil lint design.pen --preset strict
|
||||
open-pencil lint design.fig --rule color-contrast
|
||||
open-pencil lint design.fig --list-rules
|
||||
```
|
||||
|
||||
Use `--json` for machine-readable output.
|
||||
|
||||
## JSON Output
|
||||
|
||||
All commands support `--json` for machine-readable output — pipe into `jq`, feed to CI scripts, or process with other tools:
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ Going the other direction, export any selection back to JSX with Tailwind classe
|
|||
|
||||
## CLI
|
||||
|
||||
Inspect, export, and analyze `.fig` files without opening the editor. List pages, search nodes, extract design tokens, render to PNG — all from the terminal with machine-readable JSON output.
|
||||
Inspect, lint, export, and analyze design documents without opening the editor. List pages, search nodes, extract design tokens, catch layout or accessibility issues, and render to PNG — all from the terminal with machine-readable JSON output.
|
||||
|
||||
The CLI also connects to the running desktop app via RPC, so you can script the editor while you're using it.
|
||||
|
||||
|
|
|
|||
38
tests/engine/lint.test.ts
Normal file
38
tests/engine/lint.test.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { SceneGraph, createLinter } from '@open-pencil/core'
|
||||
|
||||
describe('createLinter', () => {
|
||||
test('reports default names and empty frames', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
const frame = graph.createNode('FRAME', page.id, { name: 'Frame 1', width: 100, height: 100 })
|
||||
|
||||
const result = createLinter({ preset: 'recommended' }).lintGraph(graph, [frame.id])
|
||||
const ruleIds = result.messages.map((message) => message.ruleId)
|
||||
|
||||
expect(ruleIds).toContain('no-default-names')
|
||||
expect(ruleIds).toContain('no-empty-frames')
|
||||
})
|
||||
|
||||
test('reports color contrast issues for low-contrast text', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
const frame = graph.createNode('FRAME', page.id, {
|
||||
name: 'Card',
|
||||
width: 200,
|
||||
height: 80,
|
||||
fills: [{ type: 'SOLID', visible: true, opacity: 1, color: { r: 1, g: 1, b: 1 } }]
|
||||
})
|
||||
graph.createNode('TEXT', frame.id, {
|
||||
name: 'Label',
|
||||
width: 80,
|
||||
height: 20,
|
||||
text: 'Hello',
|
||||
fills: [{ type: 'SOLID', visible: true, opacity: 1, color: { r: 0.8, g: 0.8, b: 0.8 } }]
|
||||
})
|
||||
|
||||
const result = createLinter({ preset: 'recommended' }).lintGraph(graph, [frame.id])
|
||||
expect(result.messages.some((message) => message.ruleId === 'color-contrast')).toBe(true)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue