62 lines
2.5 KiB
JavaScript
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).`,
|
|
)
|