You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/eidos/lint.ts

249 lines
8.3 KiB

/**
* Lints an eidos CSS file against a `CompiledMorfo` contract.
*
* The compiler emits `contracts.cssSelectors` — the closed set of
* `[data-*]` selectors the morfo runtime can produce. Every selector
* in an eidos CSS file falls into one of three buckets:
*
* - `morfo-backed`: every `data-*` attr is declared on a part. The
* enum value (if any) matches the part's allowed values. The
* runtime is what writes this attr — the eidos rule reacts to it.
* - `eidos-only`: the marker is present (`[data-toggle]`) but at
* least one `data-*` in the selector is not declared in the
* morfo. Valid by convention — those are eidos design tokens
* authored on the host element by the consumer (e.g. `data-color`,
* `data-size`, `data-variant`).
* - `invalid`: the selector references an attr that *is* declared on
* the morfo part, but with a value outside the declared enum.
* E.g. `[data-toggle][data-state='maybe']` when the morfo enum is
* `{'on', 'off'}`. This is a bug.
*
* No CSS-AST dependency — `[data-*]` selectors in eidos are flat and
* regex-extractable.
*/
import type { CompiledMorfo } from '../morfo/compile'
export interface LintAttrRef {
readonly attr: string
readonly value?: string
}
export interface LintFinding {
readonly raw: string // the full selector list as authored
readonly rule: string // one selector inside the comma list
readonly status: 'morfo-backed' | 'eidos-only' | 'invalid'
readonly attrs: readonly LintAttrRef[]
readonly eidosOnlyAttrs?: readonly LintAttrRef[]
readonly reason?: string
}
export interface LintReport {
readonly component: string
readonly findings: readonly LintFinding[]
readonly counts: {
readonly morfoBacked: number
readonly eidosOnly: number
readonly invalid: number
}
/** `data-*` attrs the runtime promised but eidos never styled. */
readonly unusedFromContract: readonly string[]
}
const SELECTOR_LINE = /(?:^|\})\s*([^\s{}][^{}]*?)\s*\{/g
const ATTR_BRACKET = /\[([a-z][\w-]*)(?:\s*=\s*(?:'([^']*)'|"([^"]*)"|([^\]]+)))?\]/gi
const COMMENT = /\/\*[\s\S]*?\*\//g
export function lintEidosCss(cssText: string, compiled: CompiledMorfo): LintReport {
const text = cssText.replace(COMMENT, '')
// Index every (attr, value) pair the morfo declares per part.
// Empty `Set<string>` value means "presence-only" (no enum).
const declaredByPart = new Map<string, Map<string, ReadonlySet<string>>>()
for (const [partKebab, contracts] of compiled.contracts.dataAttrsByPart) {
const byAttr = new Map<string, ReadonlySet<string>>()
for (const c of contracts) {
byAttr.set(c.attr, new Set(c.values ?? []))
}
declaredByPart.set(partKebab, byAttr)
}
// Marker → part kebab. `data-{component}` ↔ provider, `data-{component}-{kebab}` ↔ kebab.
const markerToPart = new Map<string, string>()
for (const [kebab, marker] of Object.entries(compiled.parts.attrs)) {
markerToPart.set(marker, kebab)
}
// Track which (part, attr, value) triples actually appear in CSS.
const seenContract = new Set<string>()
const findings: LintFinding[] = []
const ruleMatches = [...text.matchAll(SELECTOR_LINE)]
for (const match of ruleMatches) {
const raw = match[1].trim()
const rules = splitTopLevelCommas(raw)
for (const rule of rules) {
const attrs = extractAttrs(rule)
if (attrs.length === 0) continue
const finding = classify(rule, attrs, markerToPart, declaredByPart, seenContract)
findings.push({ raw, rule, ...finding })
}
}
// Diff seenContract against everything the compiler promised.
const unusedFromContract: string[] = []
for (const sel of compiled.contracts.cssSelectors) {
const key = `${sel.part}|${sel.attr}|${sel.value ?? ''}`
if (!seenContract.has(key)) {
unusedFromContract.push(sel.selector)
}
}
const counts = {
morfoBacked: findings.filter((f) => f.status === 'morfo-backed').length,
eidosOnly: findings.filter((f) => f.status === 'eidos-only').length,
invalid: findings.filter((f) => f.status === 'invalid').length
}
return {
component: compiled.kebab,
findings: Object.freeze(findings),
counts,
unusedFromContract: Object.freeze(unusedFromContract.sort())
}
}
function classify(
rule: string,
attrs: readonly LintAttrRef[],
markerToPart: ReadonlyMap<string, string>,
declaredByPart: ReadonlyMap<string, ReadonlyMap<string, ReadonlySet<string>>>,
seenContract: Set<string>
): Pick<LintFinding, 'status' | 'attrs' | 'eidosOnlyAttrs' | 'reason'> {
let currentPart: string | undefined
let currentPartAttrs: ReadonlyMap<string, ReadonlySet<string>> | undefined
let hasKnownMarker = false
const eidosOnlyAttrs: LintAttrRef[] = []
let invalidReason: string | undefined
for (const ref of attrs) {
const markerPart = markerToPart.get(ref.attr)
if (markerPart) {
currentPart = markerPart
currentPartAttrs = declaredByPart.get(markerPart)
hasKnownMarker = true
continue
}
if (!currentPartAttrs) {
if (ref.attr.startsWith('data-')) eidosOnlyAttrs.push(ref)
continue
}
// Skip non-data attrs (`role`, `aria-*` checked but not enforced here),
// the marker is already handled.
if (!currentPartAttrs.has(ref.attr)) {
// `aria-*` and `role` are still part of the contract surface but the
// compiler tracks them in the part's static/dynamic plans, not in
// dataAttrsByPart. Treat any non-data attr as "eidos may use it".
if (!ref.attr.startsWith('data-')) continue
eidosOnlyAttrs.push(ref)
continue
}
const allowed = currentPartAttrs.get(ref.attr)!
if (ref.value !== undefined && allowed.size > 0 && !allowed.has(ref.value)) {
invalidReason = `[${ref.attr}='${ref.value}'] is not in the morfo enum {${[...allowed].map((v) => `'${v}'`).join(', ')}} for part "${currentPart}".`
}
seenContract.add(`${currentPart}|${ref.attr}|${ref.value ?? ''}`)
}
if (invalidReason) {
return { status: 'invalid', attrs, reason: invalidReason }
}
if (eidosOnlyAttrs.length) {
const attrsText = eidosOnlyAttrs.map(formatAttrRef).join(', ')
const reason = hasKnownMarker
? `Eidos design token(s): ${attrsText} not declared on nearest morfo part.`
: `Selector has no known morfo part marker: ${attrsText}.`
return {
status: 'eidos-only',
attrs,
eidosOnlyAttrs,
reason
}
}
return { status: 'morfo-backed', attrs }
}
function formatAttrRef(ref: LintAttrRef): string {
return `[${ref.attr}${ref.value !== undefined ? `='${ref.value}'` : ''}]`
}
function extractAttrs(rule: string): LintAttrRef[] {
const out: LintAttrRef[] = []
for (const m of rule.matchAll(ATTR_BRACKET)) {
const attr = m[1].toLowerCase()
const value = m[2] ?? m[3] ?? m[4]
out.push(value !== undefined ? { attr, value } : { attr })
}
return out
}
function splitTopLevelCommas(input: string): string[] {
const out: string[] = []
let depth = 0
let buf = ''
for (const ch of input) {
if (ch === '(' || ch === '[') depth++
else if (ch === ')' || ch === ']') depth--
if (ch === ',' && depth === 0) {
out.push(buf.trim())
buf = ''
} else {
buf += ch
}
}
if (buf.trim()) out.push(buf.trim())
return out
}
export function formatLintReport(report: LintReport): string {
const lines: string[] = []
lines.push(`eidos lint — ${report.component}`)
lines.push(
` morfo-backed: ${report.counts.morfoBacked} eidos-only: ${report.counts.eidosOnly} invalid: ${report.counts.invalid}`
)
lines.push('')
const grouped = {
'morfo-backed': [] as LintFinding[],
'eidos-only': [] as LintFinding[],
invalid: [] as LintFinding[]
}
for (const f of report.findings) grouped[f.status].push(f)
if (grouped.invalid.length) {
lines.push(' ✗ invalid (selector value not in morfo enum):')
for (const f of grouped.invalid) {
lines.push(` ${f.rule}`)
if (f.reason) lines.push(` ${f.reason}`)
}
lines.push('')
}
if (grouped['morfo-backed'].length) {
lines.push(' ✓ morfo-backed (runtime writes the attr, eidos reacts):')
for (const f of grouped['morfo-backed']) lines.push(` ${f.rule}`)
lines.push('')
}
if (grouped['eidos-only'].length) {
lines.push(' · eidos-only (wrapper attrs or non-morfo DOM signals):')
for (const f of grouped['eidos-only']) lines.push(` ${f.rule}`)
lines.push('')
}
if (report.unusedFromContract.length) {
lines.push(' · contract selectors with no eidos rule yet:')
for (const sel of report.unusedFromContract) lines.push(` ${sel}`)
}
return lines.join('\n')
}

Powered by TurnKey Linux.