kaloko 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +119 -0
- package/bin/kaloko.mjs +4 -0
- package/bin/qawalk.mjs +4 -0
- package/package.json +42 -0
- package/schemas/bundle.v1.json +19 -0
- package/schemas/config.v1.json +268 -0
- package/schemas/revision.v1.json +36 -0
- package/schemas/run.v1.json +549 -0
- package/schemas/scenario.v1.json +668 -0
- package/schemas/tokens.v1.json +102 -0
- package/skills/kaloko/SKILL.md +94 -0
- package/skills/kaloko/reference/acceptance.md +208 -0
- package/skills/kaloko/reference/apps.md +161 -0
- package/skills/kaloko/reference/collaboration.md +218 -0
- package/skills/kaloko/reference/design.md +143 -0
- package/skills/kaloko/reference/documentation.md +72 -0
- package/skills/kaloko/reference/environments.md +140 -0
- package/skills/kaloko/reference/evaluation.md +62 -0
- package/skills/kaloko/reference/execution.md +75 -0
- package/skills/kaloko/reference/mcp.md +69 -0
- package/skills/kaloko/reference/stacks.md +122 -0
- package/src/cli.mjs +1344 -0
- package/src/lib/access.mjs +169 -0
- package/src/lib/apps/capture.mjs +59 -0
- package/src/lib/apps/drivers.mjs +284 -0
- package/src/lib/apps/helper.swift +102 -0
- package/src/lib/apps/tree.mjs +222 -0
- package/src/lib/browser-daemon.mjs +41 -0
- package/src/lib/browser.mjs +161 -0
- package/src/lib/build.mjs +149 -0
- package/src/lib/calibrate.mjs +97 -0
- package/src/lib/capture.mjs +233 -0
- package/src/lib/compare.mjs +88 -0
- package/src/lib/config.mjs +117 -0
- package/src/lib/console.mjs +13 -0
- package/src/lib/coverage.mjs +75 -0
- package/src/lib/design-cli.mjs +440 -0
- package/src/lib/design-core.mjs +163 -0
- package/src/lib/design.mjs +164 -0
- package/src/lib/doctor.mjs +149 -0
- package/src/lib/draft.mjs +76 -0
- package/src/lib/drift.mjs +145 -0
- package/src/lib/evaluate.mjs +222 -0
- package/src/lib/git.mjs +70 -0
- package/src/lib/htmlview.mjs +130 -0
- package/src/lib/jev.mjs +95 -0
- package/src/lib/mail.mjs +182 -0
- package/src/lib/manifest-core.mjs +127 -0
- package/src/lib/pack-help.mjs +55 -0
- package/src/lib/packs.mjs +121 -0
- package/src/lib/pr.mjs +78 -0
- package/src/lib/probe.mjs +379 -0
- package/src/lib/recheck.mjs +70 -0
- package/src/lib/run.mjs +85 -0
- package/src/lib/scenario.mjs +98 -0
- package/src/lib/schema.mjs +25 -0
- package/src/lib/secrets.mjs +59 -0
- package/src/lib/service.mjs +52 -0
- package/src/lib/stack.mjs +130 -0
- package/src/lib/tokens.mjs +352 -0
- package/src/lib/util.mjs +97 -0
- package/templates/ACCEPTANCE.md +55 -0
- package/templates/cs/ACCEPTANCE.md +55 -0
- package/templates/kaloko.config.yml +76 -0
- package/templates/mail/letter-opener.mjs +47 -0
- package/templates/scenario.yml +119 -0
- package/templates/steps/consent.mjs +33 -0
- package/viewer/index.html +2772 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import Ajv2020 from 'ajv/dist/2020.js'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { join, dirname } from 'node:path'
|
|
5
|
+
|
|
6
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
|
|
7
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false, useDefaults: true })
|
|
8
|
+
const cache = new Map()
|
|
9
|
+
|
|
10
|
+
export function validator (name) {
|
|
11
|
+
if (!cache.has(name)) {
|
|
12
|
+
const schema = JSON.parse(readFileSync(join(root, 'schemas', `${name}.json`), 'utf8'))
|
|
13
|
+
cache.set(name, ajv.compile(schema))
|
|
14
|
+
}
|
|
15
|
+
return cache.get(name)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Returns a list of human-readable problems; empty when valid. */
|
|
19
|
+
export function schemaErrors (name, data) {
|
|
20
|
+
const validate = validator(name)
|
|
21
|
+
if (validate(data)) return []
|
|
22
|
+
return validate.errors.map((e) => `${e.instancePath || '/'} ${e.message}${e.params?.allowedValues ? ` (${e.params.allowedValues.join(', ')})` : ''}`)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const packageRoot = root
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Secrets are named in the config (`*_env`, `{ env: NAME }`) and read from the process
|
|
2
|
+
// environment only when needed. Every value read here is remembered, so captures, HTML and
|
|
3
|
+
// logs can be scrubbed before they are written or shared.
|
|
4
|
+
import { UserError } from './util.mjs'
|
|
5
|
+
|
|
6
|
+
const known = new Set()
|
|
7
|
+
|
|
8
|
+
/** Value of a secret variable; throws with the variable name (never the value) when it is missing. */
|
|
9
|
+
export function secret (name, what, env = process.env) {
|
|
10
|
+
const value = env[name]
|
|
11
|
+
if (value == null || value === '') throw new UserError(`Set ${name}${what ? ` (${what})` : ''} in the environment, the project .env or ~/.config/kaloko/.env.`)
|
|
12
|
+
remember(value)
|
|
13
|
+
return value
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Names of the variables a config fragment refers to that are not set. */
|
|
17
|
+
export function missing (names, env = process.env) {
|
|
18
|
+
return names.filter((n) => n && (env[n] == null || env[n] === ''))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function remember (value) {
|
|
22
|
+
// very short values (a PIN, "x") would scrub ordinary words from the evidence
|
|
23
|
+
if (typeof value === 'string' && value.length >= 4) known.add(value)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Replaces every remembered secret (and its base64 / URL-encoded forms) with a marker. */
|
|
27
|
+
export function redact (text) {
|
|
28
|
+
if (typeof text !== 'string' || !known.size) return text
|
|
29
|
+
// longest first: one secret's form may be part of another's (a password inside a Basic pair)
|
|
30
|
+
const forms = [...new Set([...known].flatMap((v) => [v, encodeURIComponent(v), Buffer.from(v).toString('base64')]))].sort((a, b) => b.length - a.length)
|
|
31
|
+
let out = text
|
|
32
|
+
for (const form of forms) if (out.includes(form)) out = out.split(form).join('[redacted]')
|
|
33
|
+
return out
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Deep copy with every string redacted (capture records, console entries). */
|
|
37
|
+
export function redactDeep (value) {
|
|
38
|
+
if (typeof value === 'string') return redact(value)
|
|
39
|
+
if (Array.isArray(value)) return value.map(redactDeep)
|
|
40
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redactDeep(v)]))
|
|
41
|
+
return value
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const _forget = () => known.clear() // tests
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Remembers every secret an environment refers to that is set (`*_env` except user names,
|
|
48
|
+
* `{ env: NAME }` header values), so captures are scrubbed even when a person or an agent
|
|
49
|
+
* typed the password and the CLI never read it itself.
|
|
50
|
+
*/
|
|
51
|
+
export function rememberEnvironment (envConfig, env = process.env) {
|
|
52
|
+
const visit = (node, key) => {
|
|
53
|
+
if (!node || typeof node !== 'object') return
|
|
54
|
+
for (const [k, v] of Object.entries(node)) {
|
|
55
|
+
if (typeof v === 'string' && ((k.endsWith('_env') && k !== 'user_env') || (k === 'env' && key !== 'base_url'))) { if (env[v]) remember(env[v]) } else visit(v, k)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
visit({ access: envConfig.access, http_auth: envConfig.http_auth, accounts: envConfig.accounts, mail: envConfig.mail })
|
|
59
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Client of the Kaloko service API (kaloko.app or KALOKO_URL) with the organization token.
|
|
2
|
+
import { UserError } from './util.mjs'
|
|
3
|
+
|
|
4
|
+
/** Base URL of the service; configs written before the rename name app.qawalk.com, which now means kaloko.app. */
|
|
5
|
+
export function serviceUrl (config, vars = process.env) {
|
|
6
|
+
const url = (vars.KALOKO_URL || config.share?.url || 'https://kaloko.app').replace(/\/$/, '')
|
|
7
|
+
return url.replace(/^https:\/\/app\.qawalk\.com$/, 'https://kaloko.app')
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function service (config) {
|
|
11
|
+
const base = serviceUrl(config) + '/api/v1'
|
|
12
|
+
const tokenEnv = config.share?.token_env || 'KALOKO_TOKEN'
|
|
13
|
+
const token = process.env[tokenEnv]
|
|
14
|
+
if (!token) throw new UserError(`Set ${tokenEnv} (create a token in Kaloko → Settings).`)
|
|
15
|
+
// an agent names itself (KALOKO_AGENT, e.g. "claude-code"); the service shows it in presence, history and audit (#42)
|
|
16
|
+
const headers = { Authorization: `Bearer ${token}`, ...(process.env.KALOKO_AGENT ? { 'X-QAwalk-Agent': process.env.KALOKO_AGENT.slice(0, 60) } : {}) }
|
|
17
|
+
const check = async (res, what) => {
|
|
18
|
+
if (res.ok) return res.json()
|
|
19
|
+
const body = await res.json().catch(() => ({}))
|
|
20
|
+
throw new UserError(`${what} → HTTP ${res.status}: ${body.error || res.statusText}`)
|
|
21
|
+
}
|
|
22
|
+
// the service limits requests per token (HTTP 429, nothing was processed): wait as told and try again
|
|
23
|
+
// read-your-writes across requests: the service answers with a database bookmark the next request sends back
|
|
24
|
+
let bookmark = null
|
|
25
|
+
const limited = async (send) => {
|
|
26
|
+
for (let attempt = 0; ; attempt++) {
|
|
27
|
+
const res = await send()
|
|
28
|
+
const bm = res.headers?.get?.('X-D1-Bookmark'); if (bm) { bookmark = bm; headers['X-D1-Bookmark'] = bm }
|
|
29
|
+
if (res.status !== 429 || attempt >= 6) return res
|
|
30
|
+
const wait = Math.min(60, Number(res.headers.get('Retry-After')) || 2 ** attempt)
|
|
31
|
+
await new Promise((r) => setTimeout(r, wait * 1000))
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
async request (method, path, body) {
|
|
36
|
+
const res = await limited(() => fetch(base + path, { method, headers: { ...headers, ...(body ? { 'Content-Type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined }))
|
|
37
|
+
return check(res, `${method} ${path}`)
|
|
38
|
+
},
|
|
39
|
+
async fetch (path) {
|
|
40
|
+
const res = await fetch(base + path, { headers })
|
|
41
|
+
if (!res.ok) { const body = await res.json().catch(() => ({})); throw new UserError(`GET ${path} → HTTP ${res.status}: ${body.error || res.statusText}`) }
|
|
42
|
+
return res
|
|
43
|
+
},
|
|
44
|
+
async upload (path, data, name, extra = {}) {
|
|
45
|
+
for (let attempt = 0; ; attempt++) {
|
|
46
|
+
const res = await limited(() => fetch(base + path, { method: 'PUT', headers: { ...headers, ...extra }, body: data }))
|
|
47
|
+
if (res.ok || attempt >= 2 || res.status < 500) return check(res, `upload ${name}`)
|
|
48
|
+
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)))
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Stack steps: one step that stands for many pages of the same template (magazine articles, product pages, an SEO
|
|
2
|
+
// landing catalogue). The pages come from urls, a file, a sitemap, a crawl of a listing or a script; every page is
|
|
3
|
+
// captured with the step's criteria and a few index fields, and the canvas shows them as a stack with an index.
|
|
4
|
+
import { readFile } from 'node:fs/promises'
|
|
5
|
+
import { resolve, extname } from 'node:path'
|
|
6
|
+
import { pathToFileURL } from 'node:url'
|
|
7
|
+
import { createHash } from 'node:crypto'
|
|
8
|
+
import { UserError } from './util.mjs'
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_LIMIT = 500
|
|
11
|
+
export const DEFAULT_SEMANTIC_SAMPLE = 20
|
|
12
|
+
export const DEFAULT_FIELDS = [
|
|
13
|
+
{ id: 'title', label: 'Title', from: 'title' },
|
|
14
|
+
{ id: 'h1', label: 'H1', from: 'h1' },
|
|
15
|
+
{ id: 'words', label: 'Words', from: 'words' },
|
|
16
|
+
{ id: 'http', label: 'HTTP', from: 'http' }
|
|
17
|
+
]
|
|
18
|
+
export const fieldsOf = (node) => (node.fields?.length ? node.fields.map((f) => ({ label: f.label || f.id, from: f.from || f.id, ...f })) : DEFAULT_FIELDS)
|
|
19
|
+
|
|
20
|
+
const slug = (s) => String(s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
|
|
21
|
+
/** Stable, readable id of a page: the path as a slug (≤ 60 chars) plus a short hash when needed. */
|
|
22
|
+
export function itemId (path, taken) {
|
|
23
|
+
let base = slug(path).slice(0, 60) || 'root'
|
|
24
|
+
if (taken.has(base)) base = `${base.slice(0, 52)}-${createHash('sha1').update(path).digest('hex').slice(0, 7)}`
|
|
25
|
+
taken.add(base)
|
|
26
|
+
return base
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const pathOf = (u, origin) => { try { const x = new URL(u, origin); return x.origin === origin ? x.pathname + x.search : x.href } catch { return String(u) } }
|
|
30
|
+
|
|
31
|
+
/** Parses <loc> entries of a sitemap or sitemap index. */
|
|
32
|
+
export function sitemapLocs (xml) {
|
|
33
|
+
const locs = [...String(xml).matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/gi)].map((m) => m[1].replace(/&/g, '&'))
|
|
34
|
+
return { index: /<sitemapindex[\s>]/i.test(xml), locs }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Reads a list file: text (one per line), CSV (first column) or JSON ([path] or [{path|url, title, key}]). */
|
|
38
|
+
export async function listFile (file) {
|
|
39
|
+
const text = await readFile(file, 'utf8').catch(() => { throw new UserError(`items.file ${file} cannot be read.`) })
|
|
40
|
+
if (extname(file) === '.json') {
|
|
41
|
+
const data = JSON.parse(text)
|
|
42
|
+
return (Array.isArray(data) ? data : data.items || []).map((x) => (typeof x === 'string' ? { path: x } : { path: x.path || x.url, title: x.title, key: x.key }))
|
|
43
|
+
}
|
|
44
|
+
return text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith('#')).map((l) => ({ path: extname(file) === '.csv' ? l.split(/[,;]/)[0].replace(/^"|"$/g, '') : l })).filter((x) => x.path && !/^(path|url)$/i.test(x.path))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The pages of a stack step, deduplicated, filtered (match/exclude), limited: [{ id, path, url, title? }].
|
|
49
|
+
* `base` is the environment's URL for the locale; `page` is used for crawl and script sources. `match` and
|
|
50
|
+
* `exclude` are regexes on the path with its query; `{locale}` in any source string is the walked locale.
|
|
51
|
+
*/
|
|
52
|
+
export async function resolveItems ({ node, base, page, scenarioDir, env, locale, limit, fetchImpl = fetch }) {
|
|
53
|
+
// `{locale}` in any string of the source (sitemap, match, crawl.from, file…) becomes the walked locale
|
|
54
|
+
const sub = (v) => (typeof v === 'string' ? v.replaceAll('{locale}', locale || '') : Array.isArray(v) ? v.map(sub) : v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, sub(x)])) : v)
|
|
55
|
+
const src = sub(node.items || {})
|
|
56
|
+
const origin = new URL(base).origin
|
|
57
|
+
const abs = (p) => new URL(p, base.endsWith('/') ? base : base + '/').href.replace(/^(https?:\/\/[^/]+)\/\//, '$1/')
|
|
58
|
+
const toUrl = (p) => (/^https?:\/\//.test(p) ? p : new URL(p, origin).href)
|
|
59
|
+
let raw = []
|
|
60
|
+
if (src.urls) raw = src.urls.map((p) => ({ path: p }))
|
|
61
|
+
else if (src.file) raw = await listFile(resolve(scenarioDir, src.file))
|
|
62
|
+
else if (src.sitemap) {
|
|
63
|
+
const get = async (u) => { const r = await fetchImpl(u, { headers: { 'User-Agent': 'Kaloko' } }); if (!r.ok) throw new UserError(`items.sitemap ${u}: HTTP ${r.status}`); return r.text() }
|
|
64
|
+
const first = sitemapLocs(await get(toUrl(src.sitemap)))
|
|
65
|
+
let locs = first.locs
|
|
66
|
+
if (first.index) { locs = []; for (const u of first.locs.slice(0, 50)) locs.push(...sitemapLocs(await get(u)).locs) }
|
|
67
|
+
raw = locs.map((u) => ({ path: u }))
|
|
68
|
+
} else if (src.crawl) {
|
|
69
|
+
if (!page) throw new UserError('items.crawl needs a browser page.')
|
|
70
|
+
let url = toUrl(src.crawl.from)
|
|
71
|
+
for (let i = 0; i < (src.crawl.pages || 1) && url; i++) {
|
|
72
|
+
await page.goto(url, { waitUntil: 'domcontentloaded' })
|
|
73
|
+
const found = await page.$$eval(src.crawl.links, (as) => as.map((a) => ({ href: a.href, title: (a.innerText || a.title || '').trim().slice(0, 200) })))
|
|
74
|
+
raw.push(...found.map((f) => ({ path: f.href, title: f.title || undefined })))
|
|
75
|
+
url = src.crawl.next ? await page.$eval(src.crawl.next, (a) => a.href).catch(() => null) : null
|
|
76
|
+
}
|
|
77
|
+
} else if (src.script) {
|
|
78
|
+
const mod = await import(pathToFileURL(resolve(scenarioDir, src.script)).href)
|
|
79
|
+
const fn = mod.default || mod.items
|
|
80
|
+
if (typeof fn !== 'function') throw new UserError(`items.script ${src.script} must export a default function.`)
|
|
81
|
+
raw = (await fn({ page, env, locale, url: abs })) || []
|
|
82
|
+
raw = raw.map((x) => (typeof x === 'string' ? { path: x } : { path: x.path || x.url, title: x.title, key: x.key }))
|
|
83
|
+
}
|
|
84
|
+
const match = src.match ? new RegExp(src.match) : null
|
|
85
|
+
const exclude = src.exclude ? new RegExp(src.exclude) : null
|
|
86
|
+
const seen = new Set(); const taken = new Set(); const out = []
|
|
87
|
+
const max = limit || src.limit || DEFAULT_LIMIT
|
|
88
|
+
for (const r of raw) {
|
|
89
|
+
if (!r.path) continue
|
|
90
|
+
const path = pathOf(r.path, origin)
|
|
91
|
+
const test = path.startsWith('http') ? (({ pathname, search }) => pathname + search)(new URL(path)) : path // path with its query
|
|
92
|
+
if (match && !match.test(test)) continue
|
|
93
|
+
if (exclude && exclude.test(test)) continue
|
|
94
|
+
if (seen.has(path)) continue
|
|
95
|
+
seen.add(path)
|
|
96
|
+
out.push({ id: r.key ? itemId(String(r.key), taken) : itemId(path, taken), path, url: path.startsWith('http') ? path : toUrl(path), ...(r.title ? { title: r.title } : {}) })
|
|
97
|
+
if (out.length >= max) break
|
|
98
|
+
}
|
|
99
|
+
return { items: out, total: seen.size, truncated: out.length >= max && raw.length > out.length }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** In-page extraction of the index fields (self-contained; runs through page.evaluate). */
|
|
103
|
+
export function readFields (specs) {
|
|
104
|
+
const text = (el) => (el ? (el.innerText || el.textContent || '').replace(/\s+/g, ' ').trim() : null)
|
|
105
|
+
const meta = (sel) => document.querySelector(sel)?.getAttribute('content') || null
|
|
106
|
+
const out = {}
|
|
107
|
+
for (const f of specs) {
|
|
108
|
+
const from = f.from || f.id
|
|
109
|
+
try {
|
|
110
|
+
if (from === 'title') out[f.id] = document.title || null
|
|
111
|
+
else if (from === 'h1') out[f.id] = text(document.querySelector('h1'))
|
|
112
|
+
else if (from === 'description') out[f.id] = meta("meta[name='description']")
|
|
113
|
+
else if (from === 'canonical') out[f.id] = document.querySelector("link[rel='canonical']")?.href || null
|
|
114
|
+
else if (from === 'lang') out[f.id] = document.documentElement.lang || null
|
|
115
|
+
else if (from === 'url') out[f.id] = location.href
|
|
116
|
+
else if (from === 'path') out[f.id] = location.pathname + location.search
|
|
117
|
+
else if (from === 'words') { const root = document.querySelector('main, article, [role=main]') || document.body; out[f.id] = (text(root) || '').split(/\s+/).filter(Boolean).length }
|
|
118
|
+
else if (from === 'http') out[f.id] = null // filled from the navigation response
|
|
119
|
+
else if (from.startsWith('count:')) out[f.id] = document.querySelectorAll(from.slice(6)).length
|
|
120
|
+
else {
|
|
121
|
+
const at = from.lastIndexOf('@')
|
|
122
|
+
const sel = at > 0 ? from.slice(0, at) : from
|
|
123
|
+
const el = document.querySelector(sel)
|
|
124
|
+
out[f.id] = at > 0 ? el?.getAttribute(from.slice(at + 1)) ?? null : text(el)
|
|
125
|
+
}
|
|
126
|
+
if (typeof out[f.id] === 'string') out[f.id] = out[f.id].slice(0, 300)
|
|
127
|
+
} catch { out[f.id] = null }
|
|
128
|
+
}
|
|
129
|
+
return out
|
|
130
|
+
}
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
// Design tokens (#42): W3C DTCG token sets, resolved to name → value, and the checks that keep live
|
|
2
|
+
// files and captured pages on the set. Pure except checkTokens, which reads a design folder.
|
|
3
|
+
import { readFile, readdir } from 'node:fs/promises'
|
|
4
|
+
import { join, relative, sep } from 'node:path'
|
|
5
|
+
|
|
6
|
+
// Both the older string values ("#2E6BFF", "16px") and the DTCG 2025.10 shapes are read: colors as
|
|
7
|
+
// { colorSpace, components, alpha, hex }, dimensions and durations as { value, unit }, composite types
|
|
8
|
+
// (typography, shadow, border, transition, gradient), aliases as {group.token} or { "$ref": "#/json/pointer" },
|
|
9
|
+
// groups with $extends, $root and $deprecated. Modes come from a resolver document (sets, modifiers,
|
|
10
|
+
// resolutionOrder) or, as before, from top-level groups named like a mode.
|
|
11
|
+
|
|
12
|
+
const isToken = (v) => v && typeof v === 'object' && !Array.isArray(v) && '$value' in v
|
|
13
|
+
const isGroup = (v) => v && typeof v === 'object' && !Array.isArray(v) && !('$value' in v)
|
|
14
|
+
const clone = (v) => (v == null ? v : JSON.parse(JSON.stringify(v)))
|
|
15
|
+
/** Deep merge for $extends and resolver sets: later wins, groups merge, tokens replace. */
|
|
16
|
+
function merge (base, over) {
|
|
17
|
+
const out = clone(base) || {}
|
|
18
|
+
for (const [k, v] of Object.entries(over || {})) out[k] = isGroup(v) && isGroup(out[k]) ? merge(out[k], v) : clone(v)
|
|
19
|
+
return out
|
|
20
|
+
}
|
|
21
|
+
/** RFC 6901 JSON Pointer in a document ("#/a/b/0"). */
|
|
22
|
+
export function pointer (doc, ref) {
|
|
23
|
+
if (typeof ref !== 'string' || !ref.startsWith('#')) return undefined
|
|
24
|
+
let cur = doc
|
|
25
|
+
for (const raw of ref.slice(1).split('/').slice(1)) {
|
|
26
|
+
const k = raw.replace(/~1/g, '/').replace(/~0/g, '~')
|
|
27
|
+
if (cur == null || typeof cur !== 'object' || !(k in cur)) return undefined
|
|
28
|
+
cur = cur[k]
|
|
29
|
+
}
|
|
30
|
+
return cur
|
|
31
|
+
}
|
|
32
|
+
/** A group or token by dotted path ("color.brand"). */
|
|
33
|
+
function byPath (doc, path) {
|
|
34
|
+
let cur = doc
|
|
35
|
+
for (const k of path.split('.')) { if (cur == null || typeof cur !== 'object' || !(k in cur)) return undefined; cur = cur[k] }
|
|
36
|
+
return cur
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Applies $extends (a group inherits another group, local keys override). */
|
|
40
|
+
function applyExtends (doc, node = doc, depth = 0) {
|
|
41
|
+
if (!isGroup(node) || depth > 20) return node
|
|
42
|
+
let out = {}
|
|
43
|
+
for (const [k, v] of Object.entries(node)) out[k] = isGroup(v) ? applyExtends(doc, v, depth + 1) : v
|
|
44
|
+
const ext = node.$extends
|
|
45
|
+
if (typeof ext === 'string') {
|
|
46
|
+
const target = ext.startsWith('#') ? pointer(doc, ext) : byPath(doc, ext.replace(/^\{|\}$/g, ''))
|
|
47
|
+
if (isGroup(target)) { const { $extends, ...local } = out; out = merge(applyExtends(doc, target, depth + 1), local) }
|
|
48
|
+
}
|
|
49
|
+
return out
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Is this a DTCG resolver document (sets, modifiers, resolutionOrder)? */
|
|
53
|
+
export const isResolver = (doc) => !!doc && typeof doc === 'object' && Array.isArray(doc.resolutionOrder)
|
|
54
|
+
|
|
55
|
+
/** Modifier contexts of a resolver (light, dark, …), or the legacy mode groups of a set. */
|
|
56
|
+
export function modesOf (set) {
|
|
57
|
+
if (isResolver(set)) return [...new Set(Object.values(set.modifiers || {}).flatMap((m) => Object.keys(m.contexts || {})))]
|
|
58
|
+
const declared = set?.$extensions?.['qawalk.modes']
|
|
59
|
+
if (Array.isArray(declared)) return declared.filter((m) => set[m])
|
|
60
|
+
return ['light', 'dark', 'high-contrast'].filter((m) => set?.[m] && typeof set[m] === 'object' && !('$value' in set[m]))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* One token set out of a resolver: the resolution order merged, each modifier at the context the input picks
|
|
65
|
+
* (a string mode picks that context in every modifier that has it; otherwise the modifier's default or first).
|
|
66
|
+
* Sources are inline token objects or { $ref } into the resolver itself; files are inlined by inlineResolver.
|
|
67
|
+
*/
|
|
68
|
+
export function resolveResolver (doc, input = null) {
|
|
69
|
+
const want = typeof input === 'string' ? input : null
|
|
70
|
+
const pick = (name, mod) => {
|
|
71
|
+
const ctx = mod.contexts || {}
|
|
72
|
+
const chosen = (input && typeof input === 'object' && input[name]) || (want && ctx[want] ? want : null) || mod.default || Object.keys(ctx)[0]
|
|
73
|
+
return ctx[chosen] || []
|
|
74
|
+
}
|
|
75
|
+
const sourcesOf = (list) => (list || []).map((src) => (src && src.$ref ? pointer(doc, src.$ref) : src)).filter(isGroup)
|
|
76
|
+
let out = {}
|
|
77
|
+
for (const item of doc.resolutionOrder || []) {
|
|
78
|
+
const ref = item?.$ref || ''
|
|
79
|
+
const target = ref ? pointer(doc, ref) : item
|
|
80
|
+
if (!target) continue
|
|
81
|
+
if (ref.startsWith('#/modifiers/') || (!ref && target.contexts)) { for (const s of sourcesOf(pick(ref.split('/')[2] || target.name || '', target))) out = merge(out, s) }
|
|
82
|
+
else if (Array.isArray(target.sources)) { for (const s of sourcesOf(target.sources)) out = merge(out, s) }
|
|
83
|
+
else if (isGroup(target)) out = merge(out, target)
|
|
84
|
+
}
|
|
85
|
+
return out
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Replaces file sources ({ "$ref": "light.json" }) of a resolver by their content, so it can be stored alone. */
|
|
89
|
+
export async function inlineResolver (doc, readFileRef) {
|
|
90
|
+
const out = clone(doc)
|
|
91
|
+
const inline = async (list) => Promise.all((list || []).map(async (src) => (src?.$ref && !src.$ref.startsWith('#') ? JSON.parse(await readFileRef(src.$ref)) : src)))
|
|
92
|
+
for (const set of Object.values(out.sets || {})) set.sources = await inline(set.sources)
|
|
93
|
+
for (const mod of Object.values(out.modifiers || {})) for (const k of Object.keys(mod.contexts || {})) mod.contexts[k] = await inline(mod.contexts[k])
|
|
94
|
+
out.resolutionOrder = await Promise.all(out.resolutionOrder.map(async (item) => (item?.$ref && !item.$ref.startsWith('#') ? JSON.parse(await readFileRef(item.$ref)) : item)))
|
|
95
|
+
return out
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Tokens of a set as [{ name, type, value, raw, deprecated, description }]: aliases and JSON Pointer references
|
|
100
|
+
* resolved, $extends applied, `$root` tokens named after their group, `value` CSS-ready (composite types: an
|
|
101
|
+
* object of CSS-ready parts). A mode picks a resolver context or a legacy mode group.
|
|
102
|
+
*/
|
|
103
|
+
export function flattenTokens (set, mode = null) {
|
|
104
|
+
let doc
|
|
105
|
+
if (isResolver(set)) doc = resolveResolver(set, mode)
|
|
106
|
+
else {
|
|
107
|
+
const modes = modesOf(set)
|
|
108
|
+
const base = Object.fromEntries(Object.entries(set || {}).filter(([k]) => !modes.includes(k)))
|
|
109
|
+
doc = mode && set?.[mode] ? merge(base, set[mode]) : base
|
|
110
|
+
}
|
|
111
|
+
doc = applyExtends(doc)
|
|
112
|
+
const out = new Map()
|
|
113
|
+
const walk = (obj, path, inherited, deprecated) => {
|
|
114
|
+
for (const [k, v] of Object.entries(obj || {})) {
|
|
115
|
+
if (k.startsWith('$') && k !== '$root') continue
|
|
116
|
+
const dep = v && typeof v === 'object' && '$deprecated' in v ? v.$deprecated : deprecated
|
|
117
|
+
if (isToken(v)) out.set((k === '$root' ? path : [...path, k]).join('.'), { type: v.$type || inherited || null, raw: v.$value, deprecated: dep || false, description: v.$description || null })
|
|
118
|
+
else if (isGroup(v)) walk(v, [...path, k], v.$type || inherited, dep)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
walk(doc, [], null, false)
|
|
122
|
+
const resolve = (value, depth = 0) => {
|
|
123
|
+
if (depth > 10) return value
|
|
124
|
+
if (typeof value === 'string') {
|
|
125
|
+
const m = value.match(/^\{([^}]+)\}$/)
|
|
126
|
+
if (!m) return value
|
|
127
|
+
const t = out.get(m[1].replace(/\.\$root$/, ''))
|
|
128
|
+
return t ? resolve(t.raw, depth + 1) : value
|
|
129
|
+
}
|
|
130
|
+
if (value && typeof value === 'object' && typeof value.$ref === 'string') { const at = pointer(doc, value.$ref); return resolve(isToken(at) ? at.$value : at, depth + 1) } // a pointer at a token means its value
|
|
131
|
+
if (Array.isArray(value)) return value.map((x) => resolve(x, depth + 1))
|
|
132
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k, x]) => [k, resolve(x, depth + 1)]))
|
|
133
|
+
return value
|
|
134
|
+
}
|
|
135
|
+
return [...out].map(([name, t]) => {
|
|
136
|
+
const raw = resolve(t.raw)
|
|
137
|
+
return { name, type: t.type, value: cssValue(t.type, raw), raw, deprecated: t.deprecated, description: t.description }
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const WEIGHTS = { thin: 100, hairline: 100, 'extra-light': 200, 'ultra-light': 200, light: 300, normal: 400, regular: 400, book: 400, medium: 500, 'semi-bold': 600, 'demi-bold': 600, bold: 700, 'extra-bold': 800, 'ultra-bold': 800, black: 900, heavy: 900, 'extra-black': 950, 'ultra-black': 950 }
|
|
142
|
+
const hex2 = (n) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0')
|
|
143
|
+
/** A DTCG color object as #rrggbb[aa]: srgb, srgb-linear and hsl are converted, other spaces use their hex. */
|
|
144
|
+
export function colorObject (c) {
|
|
145
|
+
if (!c || typeof c !== 'object') return null
|
|
146
|
+
const a = c.alpha == null ? 1 : Number(c.alpha)
|
|
147
|
+
const withAlpha = (hex) => (hex ? (a < 1 ? hex.slice(0, 7) + hex2(a * 255) : hex.slice(0, 7)) : null)
|
|
148
|
+
const [x, y, z] = (c.components || []).map((v) => (v === 'none' ? 0 : Number(v)))
|
|
149
|
+
const space = String(c.colorSpace || '').toLowerCase()
|
|
150
|
+
const ok = [x, y, z].every(Number.isFinite)
|
|
151
|
+
if (space === 'srgb' && ok) return withAlpha('#' + [x, y, z].map((v) => hex2(v * 255)).join(''))
|
|
152
|
+
if (space === 'srgb-linear' && ok) { const g = (v) => (v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055); return withAlpha('#' + [x, y, z].map((v) => hex2(g(v) * 255)).join('')) }
|
|
153
|
+
if (space === 'hsl' && ok) {
|
|
154
|
+
const s = y / 100, l = z / 100, k = (n) => (n + x / 30) % 12, f = (n) => l - s * Math.min(l, 1 - l) * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1))
|
|
155
|
+
return withAlpha('#' + [f(0), f(8), f(4)].map((v) => hex2(v * 255)).join(''))
|
|
156
|
+
}
|
|
157
|
+
return c.hex ? withAlpha(normColor(c.hex)) : null
|
|
158
|
+
}
|
|
159
|
+
const unitValue = (v) => (v && typeof v === 'object' && 'value' in v ? `${v.value}${v.unit || ''}` : v)
|
|
160
|
+
/** A resolved DTCG value as CSS: strings and numbers stay, objects of known types become CSS values or parts. */
|
|
161
|
+
export function cssValue (type, v) {
|
|
162
|
+
if (v == null) return v
|
|
163
|
+
if (type === 'color' || (typeof v === 'object' && 'colorSpace' in v)) return typeof v === 'string' ? v : colorObject(v)
|
|
164
|
+
if (type === 'dimension' || type === 'duration' || (typeof v === 'object' && 'unit' in v && 'value' in v)) return unitValue(v)
|
|
165
|
+
if (type === 'fontFamily') return Array.isArray(v) ? v.join(', ') : v
|
|
166
|
+
if (type === 'fontWeight') return typeof v === 'string' ? (WEIGHTS[v.toLowerCase()] ?? v) : v
|
|
167
|
+
if (type === 'cubicBezier' && Array.isArray(v)) return `cubic-bezier(${v.join(', ')})`
|
|
168
|
+
if (type === 'typography' && typeof v === 'object') return { fontFamily: cssValue('fontFamily', v.fontFamily), fontSize: cssValue('dimension', v.fontSize), fontWeight: cssValue('fontWeight', v.fontWeight), lineHeight: v.lineHeight, letterSpacing: cssValue('dimension', v.letterSpacing) }
|
|
169
|
+
if (type === 'border' && typeof v === 'object') return { color: cssValue('color', v.color), width: cssValue('dimension', v.width), style: typeof v.style === 'string' ? v.style : null }
|
|
170
|
+
if (type === 'shadow') return [].concat(v).map((s) => ({ color: cssValue('color', s.color), offsetX: cssValue('dimension', s.offsetX), offsetY: cssValue('dimension', s.offsetY), blur: cssValue('dimension', s.blur), spread: cssValue('dimension', s.spread) }))
|
|
171
|
+
if (type === 'gradient' && Array.isArray(v)) return v.map((s) => ({ color: cssValue('color', s.color), position: s.position }))
|
|
172
|
+
if (type === 'transition' && typeof v === 'object') return { duration: unitValue(v.duration), delay: unitValue(v.delay), timingFunction: cssValue('cubicBezier', v.timingFunction) }
|
|
173
|
+
return v
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Structural problems of a set or resolver: names, references that point nowhere, unreadable values. */
|
|
177
|
+
export function validateTokens (set) {
|
|
178
|
+
if (!set || typeof set !== 'object' || Array.isArray(set)) return ['A token set is a JSON object.']
|
|
179
|
+
const problems = []
|
|
180
|
+
const modes = modesOf(set)
|
|
181
|
+
const docs = isResolver(set) ? (modes.length ? modes.map((m) => resolveResolver(set, m)) : [resolveResolver(set)]) : [set]
|
|
182
|
+
const names = (obj, path = []) => {
|
|
183
|
+
for (const [k, v] of Object.entries(obj || {})) {
|
|
184
|
+
if (k.startsWith('$')) continue
|
|
185
|
+
if (/[{}.]/.test(k)) problems.push(`${[...path, k].join('.')}: a name must not contain { } or .`)
|
|
186
|
+
if (isGroup(v)) names(v, [...path, k])
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
for (const doc of docs) {
|
|
190
|
+
names(doc)
|
|
191
|
+
for (const t of flattenTokens(doc)) {
|
|
192
|
+
for (const r of JSON.stringify(t.raw ?? null).match(/"\{[^}"]+\}"/g) || []) problems.push(`${t.name}: ${r.slice(1, -1)} points at no token`)
|
|
193
|
+
if (t.type === 'color' && !normColor(t.value)) problems.push(`${t.name}: the color value cannot be read`)
|
|
194
|
+
if ((t.type === 'dimension' || t.type === 'duration') && typeof t.value !== 'string' && typeof t.value !== 'number') problems.push(`${t.name}: a ${t.type} needs { value, unit }`)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return [...new Set(problems)]
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---- value normalisation ----
|
|
201
|
+
|
|
202
|
+
const NAMED = { white: '#ffffff', black: '#000000' }
|
|
203
|
+
export function normColor (v) {
|
|
204
|
+
if (v && typeof v === 'object') return colorObject(v)
|
|
205
|
+
if (typeof v !== 'string') return null
|
|
206
|
+
const s = v.trim().toLowerCase()
|
|
207
|
+
if (NAMED[s]) return NAMED[s]
|
|
208
|
+
let m = s.match(/^#([0-9a-f]{3,8})$/)
|
|
209
|
+
if (m) {
|
|
210
|
+
let h = m[1]
|
|
211
|
+
if (h.length === 3 || h.length === 4) h = h.split('').map((c) => c + c).join('')
|
|
212
|
+
return '#' + h.slice(0, 6) + (h.length === 8 && h.slice(6) !== 'ff' ? h.slice(6) : '')
|
|
213
|
+
}
|
|
214
|
+
m = s.match(/^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:[\s,/]+([\d.]+%?))?\s*\)$/)
|
|
215
|
+
if (m) {
|
|
216
|
+
const hex = (n) => Math.max(0, Math.min(255, Math.round(Number(n)))).toString(16).padStart(2, '0')
|
|
217
|
+
const a = m[4] == null ? 1 : m[4].endsWith('%') ? Number(m[4].slice(0, -1)) / 100 : Number(m[4])
|
|
218
|
+
return '#' + hex(m[1]) + hex(m[2]) + hex(m[3]) + (a < 1 ? hex(a * 255) : '')
|
|
219
|
+
}
|
|
220
|
+
return null
|
|
221
|
+
}
|
|
222
|
+
export function normDimension (v, rootPx = 16) {
|
|
223
|
+
if (typeof v === 'number') return v
|
|
224
|
+
if (typeof v !== 'string') return null
|
|
225
|
+
const m = v.trim().match(/^(-?[\d.]+)(px|rem|em)?$/)
|
|
226
|
+
if (!m) return null
|
|
227
|
+
const n = Number(m[1])
|
|
228
|
+
return m[2] === 'rem' || m[2] === 'em' ? n * rootPx : n
|
|
229
|
+
}
|
|
230
|
+
const normFont = (v) => String(Array.isArray(v) ? v[0] : v || '').split(',')[0].trim().replace(/^["']|["']$/g, '').toLowerCase()
|
|
231
|
+
|
|
232
|
+
/** Lookup tables of a flattened set by kind. */
|
|
233
|
+
export function tokenIndex (flatIn) {
|
|
234
|
+
const colors = new Map(), dims = new Map(), fonts = new Map(), deprecated = new Map()
|
|
235
|
+
// composite tokens contribute their parts (typography.fontSize, border.color, shadow colors and offsets);
|
|
236
|
+
// deprecated tokens are no suggestion, and a value only they have is reported as deprecated
|
|
237
|
+
const flat = []
|
|
238
|
+
for (const t of flatIn) {
|
|
239
|
+
if (t.deprecated) { const c = normColor(t.value); if (c) deprecated.set(c, { name: t.name, note: typeof t.deprecated === 'string' ? t.deprecated : null }); continue }
|
|
240
|
+
if (t.value && typeof t.value === 'object') {
|
|
241
|
+
for (const part of [].concat(t.value)) for (const [k, v] of Object.entries(part || {})) {
|
|
242
|
+
if (v == null) continue
|
|
243
|
+
const type = k === 'color' ? 'color' : k === 'fontFamily' ? 'fontFamily' : /size|width|offset|blur|spread|spacing/i.test(k) ? 'dimension' : null
|
|
244
|
+
if (type) flat.push({ name: `${t.name}.${k}`, type, value: v })
|
|
245
|
+
}
|
|
246
|
+
} else flat.push(t)
|
|
247
|
+
}
|
|
248
|
+
for (const t of flat) {
|
|
249
|
+
const c = (t.type === 'color' || !t.type) ? normColor(t.value) : null
|
|
250
|
+
if (c) { if (!colors.has(c)) colors.set(c, t.name); continue }
|
|
251
|
+
if (t.type === 'fontFamily' || /font.?family/i.test(t.name)) { const f = normFont(t.value); if (f && !fonts.has(f)) fonts.set(f, t.name); continue }
|
|
252
|
+
const d = normDimension(t.value?.value != null ? `${t.value.value}${t.value.unit || ''}` : t.value)
|
|
253
|
+
// several tokens may share a value (8px spacing, 8px radius): keep all names, pick by property later
|
|
254
|
+
if (d != null && (t.type === 'dimension' || t.type === 'fontSize' || t.type === 'spacing' || t.type === 'borderRadius' || !t.type)) dims.set(d, [...(dims.get(d) || []), t.name])
|
|
255
|
+
}
|
|
256
|
+
return { colors, dims, fonts, deprecated }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16))
|
|
260
|
+
function nearestColor (c, colors) {
|
|
261
|
+
let best = null, dist = Infinity
|
|
262
|
+
const [r, g, b] = rgb(c)
|
|
263
|
+
for (const [hex, name] of colors) { const [x, y, z] = rgb(hex); const d = (r - x) ** 2 + (g - y) ** 2 + (b - z) ** 2; if (d < dist) { dist = d; best = name } }
|
|
264
|
+
return best
|
|
265
|
+
}
|
|
266
|
+
/** Token names that suit a CSS property (font sizes for font-size, radii for border-radius, …). */
|
|
267
|
+
export const kindFor = (prop) => /radius/.test(prop) ? /radius|round|corner/i : /font-size|line-height|letter/.test(prop) ? /font|text|size|type|leading|line/i : /padding|margin|gap|inset/.test(prop) ? /space|spacing|gap|gutter|inset|size/i : null
|
|
268
|
+
function nearestDim (n, dims, want = null) {
|
|
269
|
+
let best = null, dist = Infinity
|
|
270
|
+
for (const [v, names] of dims) {
|
|
271
|
+
const name = want ? names.find((x) => want.test(x)) : names[0]
|
|
272
|
+
if (!name) continue
|
|
273
|
+
const d = Math.abs(v - n); if (d < dist) { dist = d; best = name }
|
|
274
|
+
}
|
|
275
|
+
return best
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const COLOR_PROPS = /^(color|background|background-color|border(-top|-right|-bottom|-left)?(-color)?|outline(-color)?|fill|stroke|box-shadow|text-decoration-color|caret-color|accent-color)$/
|
|
279
|
+
const DIM_PROPS = /^(font-size|margin(-top|-right|-bottom|-left|-inline|-block)?|padding(-top|-right|-bottom|-left|-inline|-block)?|gap|row-gap|column-gap|border-radius|border(-top|-right|-bottom|-left)?-(left-|right-)?radius)$/
|
|
280
|
+
const IGNORE = new Set(['0', '0px', 'auto', 'inherit', 'initial', 'unset', 'none', 'transparent', 'currentcolor', '100%', '50%', 'revert'])
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Checks one declaration value against the token index; returns offenders [{ value, suggest }].
|
|
284
|
+
* var(--…) and calc() with vars are fine: the value then comes from the set by construction.
|
|
285
|
+
*/
|
|
286
|
+
export function checkDeclaration (property, value, index) {
|
|
287
|
+
const prop = property.trim().toLowerCase()
|
|
288
|
+
const v = value.trim().replace(/\s*!important$/, '')
|
|
289
|
+
if (!v || v.includes('var(')) return []
|
|
290
|
+
const out = []
|
|
291
|
+
if (COLOR_PROPS.test(prop)) {
|
|
292
|
+
for (const m of v.matchAll(/#[0-9a-f]{3,8}\b|rgba?\([^)]*\)|\b(white|black)\b/gi)) {
|
|
293
|
+
const c = normColor(m[0])
|
|
294
|
+
if (c && !index.colors.has(c)) out.push({ value: m[0], suggest: nearestColor(c, index.colors), ...(index.deprecated?.has(c) ? { deprecated: index.deprecated.get(c).name, note: index.deprecated.get(c).note } : {}) })
|
|
295
|
+
}
|
|
296
|
+
} else if (DIM_PROPS.test(prop)) {
|
|
297
|
+
for (const part of v.split(/\s+/)) {
|
|
298
|
+
if (IGNORE.has(part.toLowerCase())) continue
|
|
299
|
+
const d = normDimension(part)
|
|
300
|
+
if (d != null && d !== 0 && !index.dims.has(Math.abs(d))) out.push({ value: part, suggest: nearestDim(Math.abs(d), index.dims, kindFor(prop)) })
|
|
301
|
+
}
|
|
302
|
+
} else if (prop === 'font-family') {
|
|
303
|
+
const f = normFont(v)
|
|
304
|
+
if (f && !['inherit', 'system-ui', 'sans-serif', 'serif', 'monospace'].includes(f) && index.fonts.size && !index.fonts.has(f)) out.push({ value: v, suggest: [...index.fonts.values()][0] })
|
|
305
|
+
}
|
|
306
|
+
return out
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Declarations of a CSS text with line numbers (comments removed; good enough for design files). */
|
|
310
|
+
export function declarations (css) {
|
|
311
|
+
const out = []
|
|
312
|
+
const text = css.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '))
|
|
313
|
+
const re = /([a-z-]+)\s*:\s*([^;{}]+)(?=;|})/gi
|
|
314
|
+
let m
|
|
315
|
+
while ((m = re.exec(text))) {
|
|
316
|
+
if (m[1].startsWith('--')) continue
|
|
317
|
+
out.push({ property: m[1], value: m[2], line: text.slice(0, m.index).split('\n').length })
|
|
318
|
+
}
|
|
319
|
+
return out
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function files (dir, base = dir) {
|
|
323
|
+
const out = []
|
|
324
|
+
for (const e of await readdir(dir, { withFileTypes: true }).catch(() => [])) {
|
|
325
|
+
if (e.name.startsWith('.')) continue
|
|
326
|
+
const full = join(dir, e.name)
|
|
327
|
+
if (e.isDirectory()) out.push(...await files(full, base))
|
|
328
|
+
else if (/\.(css|html)$/i.test(e.name)) out.push(relative(base, full).split(sep).join('/'))
|
|
329
|
+
}
|
|
330
|
+
return out
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Raw values outside the token set across the CSS and HTML (style blocks and attributes) of a design folder. */
|
|
334
|
+
export async function checkTokens ({ dir, tokens, mode = null }) {
|
|
335
|
+
const index = tokenIndex(flattenTokens(tokens, mode))
|
|
336
|
+
const offenders = []
|
|
337
|
+
let checked = 0
|
|
338
|
+
for (const file of await files(dir)) {
|
|
339
|
+
const text = await readFile(join(dir, file), 'utf8')
|
|
340
|
+
const chunks = file.endsWith('.css') ? [{ css: text, offset: 0 }] : [
|
|
341
|
+
...[...text.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)].map((m) => ({ css: m[1], offset: text.slice(0, m.index).split('\n').length - 1 })),
|
|
342
|
+
...[...text.matchAll(/\sstyle\s*=\s*"([^"]*)"/gi)].map((m) => ({ css: `x{${m[1]}}`, offset: text.slice(0, m.index).split('\n').length - 1 }))
|
|
343
|
+
]
|
|
344
|
+
for (const { css, offset } of chunks) {
|
|
345
|
+
for (const d of declarations(css)) {
|
|
346
|
+
checked++
|
|
347
|
+
for (const o of checkDeclaration(d.property, d.value, index)) offenders.push({ file, line: d.line + offset, property: d.property, ...o })
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return { checked, offenders, tokens: { colors: index.colors.size, dimensions: index.dims.size, fonts: index.fonts.size } }
|
|
352
|
+
}
|