Files
2026-07-13 13:12:33 +08:00

62 lines
2.5 KiB
JavaScript

import { readFileSync, readdirSync, existsSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { parseTokenDefinitions } from './lib/css-utils.mjs'
// Contract completeness: every value theme (a folder under src/themes/ that
// ships a tokens.css) must define every required L1 role from contract.json.
// Expressive skins (no tokens.css, or a sparse one) are exempt — they inherit
// the ground's tokens. This is what lets a new/stranger theme drop in without
// silently leaving a role undefined (which renders as an invisible element).
const root = fileURLToPath(new URL('..', import.meta.url))
const themesDir = join(root, 'src', 'themes')
const contract = JSON.parse(readFileSync(join(themesDir, 'contract.json'), 'utf8'))
const required = contract.required ?? []
const failures = []
let checked = 0
for (const entry of readdirSync(themesDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue
const tokensPath = join(themesDir, entry.name, 'tokens.css')
if (!existsSync(tokensPath)) {
// Only expressive skins may omit tokens.css. Cross-check the manifest: a
// kind:'value' folder without one would still register, appear in the
// picker, and silently render the :root fallback when selected — the
// "phantom theme" this guard exists to catch (e.g. a token.css typo).
const manifestPath = join(themesDir, entry.name, 'manifest.ts')
if (existsSync(manifestPath)) {
const manifest = readFileSync(manifestPath, 'utf8')
if (/kind\s*:\s*['"]value['"]/.test(manifest)) {
failures.push(
`theme "${entry.name}" declares kind:'value' in its manifest but has no tokens.css — it would ship as a selectable theme whose palette never applies`,
)
}
}
continue // expressive skin — inherits the ground's tokens, exempt
}
const css = readFileSync(tokensPath, 'utf8')
const defined = new Set(parseTokenDefinitions(css).keys())
const missing = required.filter((role) => !defined.has(role))
if (missing.length) {
failures.push(
`theme "${entry.name}" (src/themes/${entry.name}/tokens.css) is missing required L1 role(s): ${missing.join(', ')}`,
)
}
checked++
}
if (checked === 0) {
failures.push('no value-theme token files found (src/themes/*/tokens.css)')
}
if (failures.length) {
console.error('Theme contract check failed:\n' + failures.join('\n'))
process.exit(1)
}
console.log(
`Theme contract check passed (${checked} value theme(s) satisfy ${required.length} required roles).`,
)