chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, relative } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { walkFiles } from './lib/fs-walk.mjs'
|
||||
|
||||
const root = fileURLToPath(new URL('..', import.meta.url))
|
||||
const srcRoot = join(root, 'src')
|
||||
const allowedDesktopGlobal = new Set([
|
||||
'src/platform/capabilities.ts',
|
||||
'src/platform/desktop.ts',
|
||||
'src/vite-env.d.ts',
|
||||
])
|
||||
const bannedPatterns = [
|
||||
{
|
||||
pattern: 'window.opensquillaDesktop',
|
||||
allow: allowedDesktopGlobal,
|
||||
message: 'Electron preload access must stay behind src/platform/.',
|
||||
},
|
||||
]
|
||||
const stalePlatformPatterns = [
|
||||
'activeProfile',
|
||||
'cloudUrl',
|
||||
'getDesktopRpcConnection',
|
||||
'desktop:rpc-connection',
|
||||
]
|
||||
|
||||
// live-turn fold fence: the append-only turn log and its pure reducer are an internal
|
||||
// live-turn detail. Keep their imports inside the chat composables (where the
|
||||
// legacy live refs live) plus ChatView, so the new path cannot leak into other
|
||||
// layers before it is ever authoritative.
|
||||
function isUnderChatComposables(rel) {
|
||||
const normalized = rel.split('\\').join('/')
|
||||
return normalized.startsWith('src/composables/chat/')
|
||||
}
|
||||
function isChatView(rel) {
|
||||
return rel.split('\\').join('/') === 'src/views/ChatView.vue'
|
||||
}
|
||||
const turnLogModulePatterns = [
|
||||
'@/composables/chat/useChatTurnLog',
|
||||
'@/composables/chat/turnParity',
|
||||
'@/utils/chat/foldTurn',
|
||||
]
|
||||
|
||||
// Test files exercise the fenced modules directly (that is their job) and are
|
||||
// not a runtime layer, so they are exempt from the import fence below.
|
||||
function isTestFile(entry) {
|
||||
return /\.(test|spec)\.(ts|tsx)$/.test(entry)
|
||||
}
|
||||
|
||||
const failures = []
|
||||
for (const file of walkFiles(srcRoot, /\.(ts|vue)$/, { skipFile: isTestFile })) {
|
||||
const rel = relative(root, file).replace(/\\/g, '/')
|
||||
const body = readFileSync(file, 'utf8')
|
||||
for (const rule of bannedPatterns) {
|
||||
if (body.includes(rule.pattern) && !rule.allow.has(rel)) {
|
||||
failures.push(`${rel}: ${rule.message} Found "${rule.pattern}".`)
|
||||
}
|
||||
}
|
||||
for (const pattern of stalePlatformPatterns) {
|
||||
if (body.includes(pattern)) {
|
||||
failures.push(`${rel}: stale desktop/cloud platform pattern found: "${pattern}".`)
|
||||
}
|
||||
}
|
||||
if (!isUnderChatComposables(rel) && !isChatView(rel)) {
|
||||
for (const moduleId of turnLogModulePatterns) {
|
||||
if (body.includes(moduleId)) {
|
||||
failures.push(`${rel}: live-turn log "${moduleId}" must stay within src/composables/chat/ or views/ChatView.vue.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(failures.join('\n'))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('Architecture guard passed.')
|
||||
@@ -0,0 +1,73 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = fileURLToPath(new URL('..', import.meta.url))
|
||||
|
||||
function read(rel) {
|
||||
return readFileSync(join(root, rel), 'utf8')
|
||||
}
|
||||
|
||||
const failures = []
|
||||
|
||||
function assertAbsent(rel, pattern, message) {
|
||||
const body = read(rel)
|
||||
if (pattern.test(body)) failures.push(`${rel}: ${message}`)
|
||||
}
|
||||
|
||||
function assertPresent(rel, pattern, message) {
|
||||
const body = read(rel)
|
||||
if (!pattern.test(body)) failures.push(`${rel}: ${message}`)
|
||||
}
|
||||
|
||||
assertAbsent(
|
||||
'src/utils/chat/artifacts.ts',
|
||||
/\btoken\??:\s*string|searchParams\.set\(['"]token['"]|includeSessionKey\s*!==\s*false/,
|
||||
'artifact URLs must not carry bearer tokens or default session keys in query params.',
|
||||
)
|
||||
|
||||
assertPresent(
|
||||
'src/utils/chat/artifacts.ts',
|
||||
/searchParams\.delete\(['"]token['"]\)[\s\S]+searchParams\.delete\(['"]sessionKey['"]\)[\s\S]+searchParams\.delete\(['"]session_key['"]\)/,
|
||||
'artifact URL sanitizer must strip sensitive same-origin query params.',
|
||||
)
|
||||
|
||||
assertAbsent(
|
||||
'src/composables/chat/useChatMarkdownExport.ts',
|
||||
/\bsessionKey\b|\bauthToken\b|\btoken\b|artifactDownloadUrl/,
|
||||
'Markdown export must not persist raw sessions, bearer tokens, or signed artifact URLs.',
|
||||
)
|
||||
|
||||
assertAbsent(
|
||||
'src/components/chat/ChatArtifactList.vue',
|
||||
/artifactPreviewUrl\(|:href="artifactUrl\(|:src="artifactUrl\(/,
|
||||
'artifact previews must not render credential-bearing artifact URLs directly into the DOM.',
|
||||
)
|
||||
|
||||
assertPresent(
|
||||
'src/components/chat/ChatArtifactList.vue',
|
||||
/URL\.createObjectURL\(blob\)/,
|
||||
'artifact previews must render fetched blob object URLs.',
|
||||
)
|
||||
|
||||
// Assistant markdown is sanitized before it reaches the DOM: the renderer must
|
||||
// not bypass DOMPurify, and must never let assistant text render arbitrary form
|
||||
// controls. The only <input> markdown produces is a disabled task-list checkbox.
|
||||
assertAbsent(
|
||||
'src/composables/chat/useChatTextRendering.ts',
|
||||
/forceKeepAttr/,
|
||||
'markdown sanitization must not bypass DOMPurify via forceKeepAttr.',
|
||||
)
|
||||
|
||||
assertPresent(
|
||||
'src/composables/chat/useChatTextRendering.ts',
|
||||
/addHook\(\s*['"]uponSanitizeElement['"][\s\S]*?removeChild/,
|
||||
'markdown sanitizer must drop non-task-list <input> elements (uponSanitizeElement + removeChild).',
|
||||
)
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(failures.join('\n'))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('Chat security guard passed.')
|
||||
@@ -0,0 +1,98 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { hexToRgb, stripAllComments } from './lib/css-utils.mjs'
|
||||
|
||||
// Cross-surface brand-accent guard.
|
||||
//
|
||||
// The product ships several independently-authored surfaces (the Vue console,
|
||||
// the Electron splash, the Electron onboarding window). Issue #403 found the
|
||||
// brand accent had fragmented into six different oranges across them. The Vue
|
||||
// console is already token-guarded by check-webui-colors.mjs; this guard keeps
|
||||
// the DESKTOP launch sequence (splash + onboarding) locked to the one canonical
|
||||
// "strike" accent so it can't drift off again.
|
||||
//
|
||||
// Scope: only a SATURATED ORANGE (a brand-accent candidate) is checked — a hue
|
||||
// in the orange band with real saturation. Danger-reds, greens, warm neutrals
|
||||
// and paper whites are ignored, so the guard is about brand identity, not every
|
||||
// colour. Backgrounds are intentionally out of scope (too many valid neutral
|
||||
// shades to allowlist without false positives).
|
||||
//
|
||||
// The legacy gateway frontend (src/opensquilla/gateway/static/css) is NOT scanned
|
||||
// here — its status (resync vs. freeze) is a separate, still-open decision.
|
||||
const repoRoot = fileURLToPath(new URL('../../', import.meta.url))
|
||||
|
||||
// The canonical "strike" family — the Instrument accent and its documented
|
||||
// hover / deep / secondary / light-theme siblings. Stored as normalized
|
||||
// "r,g,b" so hex and rgb()/rgba() forms compare equal.
|
||||
const CANONICAL = new Set([
|
||||
'242,106,27', // #F26A1B accent (dark)
|
||||
'255,122,46', // #FF7A2E accent-hover (dark)
|
||||
'217,90,17', // #D95A11 accent-deep (dark) / onboarding hover
|
||||
'255,138,76', // #FF8A4C accent-secondary
|
||||
'186,77,15', // #BA4D0F accent (light)
|
||||
'165,68,12', // #A5440C accent-hover (light)
|
||||
'142,58,10', // #8E3A0A accent-deep (light)
|
||||
'182,80,28', // #B6501C accent-secondary (light)
|
||||
])
|
||||
|
||||
// Files that make up the desktop launch sequence.
|
||||
const targets = [
|
||||
'desktop/electron/src/boot.html',
|
||||
'desktop/electron/src/main.ts',
|
||||
]
|
||||
|
||||
// Is this rgb a saturated orange — i.e. a brand-accent candidate?
|
||||
function isBrandOrange([r, g, b]) {
|
||||
const rn = r / 255, gn = g / 255, bn = b / 255
|
||||
const max = Math.max(rn, gn, bn), min = Math.min(rn, gn, bn)
|
||||
const l = (max + min) / 2
|
||||
const d = max - min
|
||||
if (d === 0) return false
|
||||
const s = d / (1 - Math.abs(2 * l - 1))
|
||||
let hue
|
||||
if (max === rn) hue = ((gn - bn) / d) % 6
|
||||
else if (max === gn) hue = (bn - rn) / d + 2
|
||||
else hue = (rn - gn) / d + 4
|
||||
hue = ((hue * 60) + 360) % 360
|
||||
// Orange band, well saturated, mid lightness — excludes red danger (<16),
|
||||
// yellow (>46), and low-saturation warm taupes/papers.
|
||||
return hue >= 16 && hue <= 46 && s >= 0.4 && l >= 0.18 && l <= 0.72
|
||||
}
|
||||
|
||||
const hexRe = /#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b/g
|
||||
const rgbRe = /rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})/g
|
||||
|
||||
const failures = []
|
||||
for (const rel of targets) {
|
||||
let text
|
||||
try {
|
||||
text = readFileSync(repoRoot + rel, 'utf8')
|
||||
} catch {
|
||||
console.warn(`[cross-surface] skipped (not found): ${rel}`)
|
||||
continue
|
||||
}
|
||||
// Comments are stripped so hexes named in them don't trip the guard.
|
||||
const lines = stripAllComments(text).split('\n')
|
||||
lines.forEach((line, i) => {
|
||||
const found = []
|
||||
for (const m of line.matchAll(hexRe)) found.push({ raw: m[0], rgb: hexToRgb(m[0]) })
|
||||
for (const m of line.matchAll(rgbRe)) found.push({ raw: m[0], rgb: [+m[1], +m[2], +m[3]] })
|
||||
for (const { raw, rgb } of found) {
|
||||
if (!isBrandOrange(rgb)) continue
|
||||
if (CANONICAL.has(rgb.join(','))) continue
|
||||
failures.push(
|
||||
`${rel}:${i + 1}: off-canonical brand orange ${raw} (rgb ${rgb.join(',')}); use the strike accent (#F26A1B family).`,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(
|
||||
`Cross-surface accent guard: ${failures.length} off-canonical brand orange(s) — the desktop launch sequence must use the strike accent:\n` +
|
||||
failures.join('\n'),
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('Cross-surface accent guard passed.')
|
||||
@@ -0,0 +1,68 @@
|
||||
// i18n key-parity + English-leakage guard. Wired into `check:architecture` so a
|
||||
// non-en locale can never silently drift from the en key set (vue-i18n would
|
||||
// fall back to en and hide the gap) or ship untranslated English strings.
|
||||
//
|
||||
// en.json is the authoritative key superset. For every other locale we fail on:
|
||||
// - missing keys (present in en, absent here)
|
||||
// - extra keys (present here, absent in en)
|
||||
// - untranslated values (string identical to en and containing Latin letters)
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const localesDir = resolve(here, '..', 'src', 'locales')
|
||||
const OTHER_LOCALES = ['zh-Hans', 'ja', 'fr', 'de', 'es']
|
||||
// English-leakage (value identical to en) is only a reliable "untranslated"
|
||||
// signal for non-Latin scripts. fr/de/es legitimately share many words with
|
||||
// English, so we enforce only KEY PARITY there and check leakage for zh-Hans.
|
||||
const LEAKAGE_LOCALES = new Set(['zh-Hans'])
|
||||
|
||||
function load(name) {
|
||||
return JSON.parse(readFileSync(resolve(localesDir, `${name}.json`), 'utf8'))
|
||||
}
|
||||
|
||||
function flatten(obj, prefix = '', out = {}) {
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const key = prefix ? `${prefix}.${k}` : k
|
||||
if (v && typeof v === 'object' && !Array.isArray(v)) flatten(v, key, out)
|
||||
else out[key] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const enFlat = flatten(load('en'))
|
||||
const enKeys = Object.keys(enFlat)
|
||||
let failed = false
|
||||
|
||||
for (const loc of OTHER_LOCALES) {
|
||||
const flat = flatten(load(loc))
|
||||
const keys = Object.keys(flat)
|
||||
|
||||
const missing = enKeys.filter((k) => !(k in flat))
|
||||
const extra = keys.filter((k) => !(k in enFlat))
|
||||
const untranslated = LEAKAGE_LOCALES.has(loc)
|
||||
? enKeys.filter(
|
||||
(k) => k in flat && typeof flat[k] === 'string' && flat[k] === enFlat[k] && /[A-Za-z]/.test(flat[k]),
|
||||
)
|
||||
: []
|
||||
|
||||
if (missing.length) {
|
||||
failed = true
|
||||
console.error(`[check-i18n] ${loc}: ${missing.length} missing key(s):\n ${missing.join('\n ')}`)
|
||||
}
|
||||
if (extra.length) {
|
||||
failed = true
|
||||
console.error(`[check-i18n] ${loc}: ${extra.length} extra key(s):\n ${extra.join('\n ')}`)
|
||||
}
|
||||
if (untranslated.length) {
|
||||
failed = true
|
||||
console.error(`[check-i18n] ${loc}: ${untranslated.length} untranslated (==en) value(s):\n ${untranslated.join('\n ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
console.error('[check-i18n] FAILED')
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`[check-i18n] OK — ${OTHER_LOCALES.join(', ')} at full key parity with en, no English leakage`)
|
||||
@@ -0,0 +1,149 @@
|
||||
// README locale-parity guard — the docs analog of check-i18n.mjs.
|
||||
//
|
||||
// check-i18n.mjs keeps the webui translation CATALOGS in lockstep with the
|
||||
// language list. This keeps the root README translations in lockstep with the
|
||||
// SAME list, so the README's language coverage can never silently drift from
|
||||
// the app's.
|
||||
//
|
||||
// The canonical language list (SUPPORTED_LOCALES) and the human endonyms
|
||||
// (LOCALE_LABELS) are read from the real webui source — never duplicated here —
|
||||
// so adding/removing a webui locale immediately changes what this check
|
||||
// requires. For each locale it fails on:
|
||||
// - a missing README file (README.md for the default locale, README.<code>.md otherwise)
|
||||
// - a stale/extra translated README whose locale is not supported
|
||||
// - a wrong/missing language-switcher entry in any README (link, href, endonym, or active marker)
|
||||
// - a missing localized link in the docs/README.md footer
|
||||
import { readFileSync, readdirSync, existsSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const webuiSrc = resolve(here, '..', 'src')
|
||||
const repoRoot = resolve(here, '..', '..')
|
||||
|
||||
// README.<x>.md files that are content variants, NOT language translations.
|
||||
const NON_LOCALE_README = new Set(['product', 'release'])
|
||||
|
||||
const failures = []
|
||||
const fail = (msg) => failures.push(msg)
|
||||
|
||||
// --- read canonical list + labels from the webui source (single source of truth) ---
|
||||
function readSupportedLocales() {
|
||||
const src = readFileSync(resolve(webuiSrc, 'i18n', 'index.ts'), 'utf8')
|
||||
const m = src.match(/SUPPORTED_LOCALES\s*=\s*\[([^\]]+)\]\s*as const/)
|
||||
if (!m) throw new Error('could not parse SUPPORTED_LOCALES from src/i18n/index.ts')
|
||||
const codes = [...m[1].matchAll(/['"]([^'"]+)['"]/g)].map((x) => x[1])
|
||||
if (codes.length === 0) throw new Error('SUPPORTED_LOCALES parsed empty')
|
||||
const dm = src.match(/DEFAULT_LOCALE\s*=\s*'([^']+)'/)
|
||||
return { codes, defaultLocale: dm ? dm[1] : 'en' }
|
||||
}
|
||||
|
||||
function readLocaleLabels() {
|
||||
const src = readFileSync(resolve(webuiSrc, 'components', 'LanguageSwitcher.vue'), 'utf8')
|
||||
const m = src.match(/LOCALE_LABELS[^{]*\{([\s\S]*?)\}/)
|
||||
if (!m) throw new Error('could not parse LOCALE_LABELS from src/components/LanguageSwitcher.vue')
|
||||
const labels = {}
|
||||
for (const mm of m[1].matchAll(/(?:'([^']+)'|"([^"]+)"|([A-Za-z][\w-]*))\s*:\s*(?:'([^']+)'|"([^"]+)")/g)) {
|
||||
labels[mm[1] ?? mm[2] ?? mm[3]] = mm[4] ?? mm[5]
|
||||
}
|
||||
if (Object.keys(labels).length === 0) throw new Error('LOCALE_LABELS parsed empty')
|
||||
return labels
|
||||
}
|
||||
|
||||
const { codes, defaultLocale } = readSupportedLocales()
|
||||
const labels = readLocaleLabels()
|
||||
|
||||
for (const code of codes) {
|
||||
if (!(code in labels)) fail(`LOCALE_LABELS is missing an endonym for "${code}"`)
|
||||
}
|
||||
|
||||
const readmeFor = (code) => (code === defaultLocale ? 'README.md' : `README.${code}.md`)
|
||||
|
||||
// --- 1. every supported locale has a README file ---
|
||||
for (const code of codes) {
|
||||
const file = readmeFor(code)
|
||||
if (!existsSync(resolve(repoRoot, file))) fail(`missing ${file} for locale "${code}"`)
|
||||
}
|
||||
|
||||
// --- 2. no stale/extra translated README ---
|
||||
for (const f of readdirSync(repoRoot).filter((f) => /^README\.[\w-]+\.md$/.test(f))) {
|
||||
const tag = f.slice('README.'.length, -'.md'.length)
|
||||
if (NON_LOCALE_README.has(tag)) continue
|
||||
if (!codes.includes(tag)) fail(`stale/extra translated README: ${f} (locale "${tag}" not in SUPPORTED_LOCALES)`)
|
||||
}
|
||||
|
||||
// --- 3. switcher parity in every root README ---
|
||||
function parseTokens(block) {
|
||||
const tokens = []
|
||||
const re = /<b>([^<]+)<\/b>|<a href="([^"]+)">([^<]+)<\/a>/g
|
||||
let mm
|
||||
while ((mm = re.exec(block))) {
|
||||
if (mm[1] !== undefined) tokens.push({ active: true, label: mm[1].trim() })
|
||||
else tokens.push({ active: false, href: mm[2].trim(), label: mm[3].trim() })
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
function switcherTokens(text) {
|
||||
// The switcher is the centered block linking READMEs with the MOST language
|
||||
// tokens — picking by max tokens avoids being fooled by a decoy centered
|
||||
// block that happens to contain a single README link elsewhere in the doc.
|
||||
const candidates = [...text.matchAll(/<p align="center">([\s\S]*?)<\/p>/g)]
|
||||
.map((m) => m[1])
|
||||
.filter((b) => /href="README(?:\.[\w-]+)?\.md"/.test(b))
|
||||
if (!candidates.length) return null
|
||||
return candidates.map(parseTokens).reduce((best, t) => (t.length > best.length ? t : best), [])
|
||||
}
|
||||
|
||||
for (const activeCode of codes) {
|
||||
const file = readmeFor(activeCode)
|
||||
const path = resolve(repoRoot, file)
|
||||
if (!existsSync(path)) continue // already reported as missing
|
||||
const tokens = switcherTokens(readFileSync(path, 'utf8'))
|
||||
if (!tokens) {
|
||||
fail(`${file}: no language-switcher block found`)
|
||||
continue
|
||||
}
|
||||
const expected = codes.map((code) =>
|
||||
code === activeCode
|
||||
? { active: true, label: labels[code] }
|
||||
: { active: false, href: readmeFor(code), label: labels[code] },
|
||||
)
|
||||
if (tokens.length !== expected.length) {
|
||||
fail(`${file}: switcher has ${tokens.length} entries, expected ${expected.length} (${codes.join(', ')})`)
|
||||
continue
|
||||
}
|
||||
expected.forEach((exp, i) => {
|
||||
const got = tokens[i]
|
||||
if (got.label !== exp.label)
|
||||
fail(`${file}: switcher position ${i + 1} endonym "${got.label}" should be "${exp.label}"`)
|
||||
if (got.active !== exp.active)
|
||||
fail(`${file}: switcher position ${i + 1} (${exp.label}) active-marker mismatch (active should be ${exp.active})`)
|
||||
if (!exp.active && got.href !== exp.href)
|
||||
fail(`${file}: switcher link "${exp.label}" href "${got.href}" should be "${exp.href}"`)
|
||||
})
|
||||
}
|
||||
|
||||
// --- 4. docs/README.md footer links every non-default locale (with ../ prefix) ---
|
||||
const docsReadmePath = resolve(repoRoot, 'docs', 'README.md')
|
||||
if (existsSync(docsReadmePath)) {
|
||||
const docs = readFileSync(docsReadmePath, 'utf8')
|
||||
for (const code of codes) {
|
||||
if (code === defaultLocale) continue
|
||||
const needle = `[${labels[code]}](../README.${code}.md)`
|
||||
if (!docs.includes(needle)) fail(`docs/README.md footer missing localized link: ${needle}`)
|
||||
}
|
||||
} else {
|
||||
fail('docs/README.md not found')
|
||||
}
|
||||
|
||||
// --- report ---
|
||||
if (failures.length) {
|
||||
console.error('[check-readme-locales] FAILED:')
|
||||
for (const f of failures) console.error(` - ${f}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(
|
||||
`[check-readme-locales] OK — ${codes.length} locales (${codes.join(', ')}); ` +
|
||||
'README files, switchers, and docs footer in sync with webui SUPPORTED_LOCALES',
|
||||
)
|
||||
@@ -0,0 +1,100 @@
|
||||
import { readFileSync, readdirSync, existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
// Post-build invariant: an expressive skin must be fully lazy — a console user
|
||||
// who never opens a skinned route downloads ZERO of its bytes. We prove it by
|
||||
// asserting each skin's scope rules never land in an eagerly-loaded chunk (the
|
||||
// entry JS/CSS referenced by index.html), while still being emitted as their own
|
||||
// on-demand chunks. Run after `vite build`.
|
||||
const root = fileURLToPath(new URL('..', import.meta.url))
|
||||
const dist = join(root, '..', 'src', 'opensquilla', 'gateway', 'static', 'dist')
|
||||
const themesDir = join(root, 'src', 'themes')
|
||||
const indexHtml = join(dist, 'index.html')
|
||||
|
||||
if (!existsSync(indexHtml)) {
|
||||
console.error('check-theme-bundle: no built index.html — run `vite build` first.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Expressive skin ids = theme folders that ship a skin.css.
|
||||
const skinIds = existsSync(themesDir)
|
||||
? readdirSync(themesDir, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory() && existsSync(join(themesDir, d.name, 'skin.css')))
|
||||
.map((d) => d.name)
|
||||
: []
|
||||
|
||||
if (skinIds.length === 0) {
|
||||
console.log('Theme bundle guard: no expressive skins to check.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const html = readFileSync(indexHtml, 'utf8')
|
||||
// Assets the browser fetches on first paint (module scripts, modulepreloads,
|
||||
// stylesheets in <head>).
|
||||
const eager = new Set([...html.matchAll(/(?:src|href)="\.?\/?(assets\/[^"]+)"/g)].map((m) => m[1]))
|
||||
const read = (relPath) => {
|
||||
const p = join(dist, relPath)
|
||||
return existsSync(p) ? readFileSync(p, 'utf8') : ''
|
||||
}
|
||||
const eagerText = [...eager].map(read).join('\n')
|
||||
|
||||
// Minifiers drop quotes in attribute selectors, so match both forms.
|
||||
const hasScope = (text, id) =>
|
||||
text.includes(`[data-skin="${id}"]`) || text.includes(`[data-skin=${id}]`)
|
||||
|
||||
const failures = []
|
||||
const report = {}
|
||||
for (const id of skinIds) {
|
||||
// 1. the skin's scoped rules must NOT be in any eager chunk
|
||||
if (hasScope(eagerText, id)) {
|
||||
failures.push(`skin "${id}" leaked into an eagerly-loaded chunk (found in entry).`)
|
||||
}
|
||||
// 2. it MUST exist as its own lazy chunk somewhere in dist/assets
|
||||
const assets = readdirSync(join(dist, 'assets'))
|
||||
const lazyCss = assets.filter(
|
||||
(f) => f.endsWith('.css') && !eager.has(`assets/${f}`) && hasScope(read(`assets/${f}`), id),
|
||||
)
|
||||
if (lazyCss.length === 0) {
|
||||
failures.push(`skin "${id}" has no lazy stylesheet chunk (expected an on-demand CSS asset scoped to it).`)
|
||||
}
|
||||
report[id] = { lazyCssChunks: lazyCss.length }
|
||||
}
|
||||
|
||||
// Worlds: a value theme's global "world" layer (world.css) must also stay lazy.
|
||||
// Its DESCENDANT rules ("[data-theme=<id>] <sel>") live in world.css; the eager
|
||||
// tokens.css only emits the palette root block ("[data-theme=<id>]{...}"), so a
|
||||
// trailing space after the attribute distinguishes world rules from the palette.
|
||||
const worldIds = existsSync(themesDir)
|
||||
? readdirSync(themesDir, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory() && existsSync(join(themesDir, d.name, 'world.css')))
|
||||
.map((d) => d.name)
|
||||
: []
|
||||
const hasWorld = (text, id) =>
|
||||
text.includes(`[data-theme="${id}"] `) || text.includes(`[data-theme=${id}] `)
|
||||
for (const id of worldIds) {
|
||||
if (hasWorld(eagerText, id)) {
|
||||
failures.push(`world "${id}" leaked structural rules into an eager chunk (world.css must be lazy).`)
|
||||
}
|
||||
const assets = readdirSync(join(dist, 'assets'))
|
||||
const lazy = assets.filter(
|
||||
(f) => f.endsWith('.css') && !eager.has(`assets/${f}`) && hasWorld(read(`assets/${f}`), id),
|
||||
)
|
||||
if (lazy.length === 0) failures.push(`world "${id}" has no lazy stylesheet chunk.`)
|
||||
}
|
||||
|
||||
// No font woff2 may be inlined into the eager entry (they are the skins' weight).
|
||||
for (const rel of eager) {
|
||||
if (/\.js$/.test(rel) && read(rel).includes('data:font/woff2')) {
|
||||
failures.push(`eager chunk ${rel} inlines a woff2 font — skin fonts must stay separate.`)
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.error('Theme bundle guard failed:\n' + failures.map((f) => ' ' + f).join('\n'))
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(
|
||||
`Theme bundle guard passed — ${skinIds.length} skin(s) + ${worldIds.length} world(s) fully lazy (0 bytes in the entry). ` +
|
||||
`skins: ${skinIds.join(', ') || 'none'}; worlds: ${worldIds.join(', ') || 'none'}.`,
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
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).`,
|
||||
)
|
||||
@@ -0,0 +1,156 @@
|
||||
import { readFileSync, readdirSync, existsSync } from 'node:fs'
|
||||
import { join, relative } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { walkFiles } from './lib/fs-walk.mjs'
|
||||
import { stripCssComments } from './lib/css-utils.mjs'
|
||||
|
||||
// Scope-leak / boundary guard for the Axis-B expressive-skin layer.
|
||||
// 1. Every selector in a theme's skin.css must descend from that theme's
|
||||
// `[data-skin="<id>"]` scope — a skin can never restyle the base app.
|
||||
// 2. A skin must not reach operational L2 surfaces even when scoped.
|
||||
// 3. L2 (components / views / base.css) must not read a skin-private
|
||||
// `--x-*` token — those belong only inside the skin.
|
||||
const root = fileURLToPath(new URL('..', import.meta.url))
|
||||
const srcDir = join(root, 'src')
|
||||
const themesDir = join(srcDir, 'themes')
|
||||
const rel = (p) => relative(root, p).replace(/\\/g, '/')
|
||||
|
||||
// Operational L2 surfaces a skin must never target (belt-and-suspenders: the
|
||||
// skin is already scoped to its route's content area, but this blocks a skin
|
||||
// from ever reaching console chrome or data surfaces).
|
||||
const FORBIDDEN = [
|
||||
'.control-stage', '.control-stat', '.control-panel', '.control-card',
|
||||
'.data-table', '.approval', '.chat-', '.composer', '.sidebar', '.topbar',
|
||||
'.mobile-tab', '[role="alert"]',
|
||||
]
|
||||
|
||||
// Split a selector header into its comma-separated arms, respecting commas
|
||||
// nested inside (), [] and quotes (`:is(a, b)`, `[attr="x,y"]`). Each arm must
|
||||
// be validated INDEPENDENTLY — otherwise `[data-skin="x"] .a, body .b {}` passes
|
||||
// as one blob because the scoped-prefix substring is present, while `body .b`
|
||||
// silently restyles the whole app (the exact leak this guard exists to stop).
|
||||
function splitSelectorList(sel) {
|
||||
const out = []
|
||||
let depth = 0
|
||||
let quote = null
|
||||
let buf = ''
|
||||
for (const ch of sel) {
|
||||
if (quote) {
|
||||
buf += ch
|
||||
if (ch === quote) quote = null
|
||||
continue
|
||||
}
|
||||
if (ch === '"' || ch === "'") { quote = ch; buf += ch; continue }
|
||||
if (ch === '(' || ch === '[') { depth++; buf += ch; continue }
|
||||
if (ch === ')' || ch === ']') { depth = Math.max(0, depth - 1); buf += ch; continue }
|
||||
if (ch === ',' && depth === 0) { out.push(buf); buf = ''; continue }
|
||||
buf += ch
|
||||
}
|
||||
if (buf.trim()) out.push(buf)
|
||||
return out
|
||||
}
|
||||
|
||||
// Brace-aware walk: invoke cb(selectorText, depth) for each rule header.
|
||||
function eachSelector(css, cb) {
|
||||
let depth = 0
|
||||
let buf = ''
|
||||
for (const ch of css) {
|
||||
if (ch === '{') {
|
||||
const sel = buf.trim()
|
||||
if (sel) cb(sel, depth)
|
||||
buf = ''
|
||||
depth++
|
||||
} else if (ch === '}') {
|
||||
buf = ''
|
||||
depth = Math.max(0, depth - 1)
|
||||
} else if (ch === ';' && depth === 0) {
|
||||
buf = '' // top-level statement (@import/@charset) — not a selector
|
||||
} else {
|
||||
buf += ch
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const failures = []
|
||||
|
||||
// --- (1)+(2) skin.css scoping -----------------------------------------------
|
||||
if (existsSync(themesDir)) {
|
||||
for (const entry of readdirSync(themesDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue
|
||||
const skinPath = join(themesDir, entry.name, 'skin.css')
|
||||
if (!existsSync(skinPath)) continue
|
||||
const id = entry.name
|
||||
const scoped = (sel) =>
|
||||
sel.includes(`[data-skin="${id}"]`) || sel.includes(`[data-skin=${id}]`)
|
||||
const css = stripCssComments(readFileSync(skinPath, 'utf8'))
|
||||
eachSelector(css, (sel) => {
|
||||
if (sel.startsWith('@')) return // @media/@supports/@keyframes/@font-face
|
||||
if (/^(from|to|\d+%)(\s*,\s*(from|to|\d+%))*$/.test(sel)) return // keyframe steps
|
||||
for (const raw of splitSelectorList(sel)) {
|
||||
const arm = raw.trim()
|
||||
if (!arm) continue
|
||||
if (!scoped(arm)) {
|
||||
failures.push(`${rel(skinPath)}: selector not scoped to [data-skin="${id}"]: "${arm}"${arm === sel ? '' : ` (arm of "${sel}")`}`)
|
||||
continue
|
||||
}
|
||||
for (const f of FORBIDDEN) {
|
||||
if (arm.includes(f)) {
|
||||
failures.push(`${rel(skinPath)}: skin reaches operational surface "${f}": "${arm}"`)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// world.css (a value theme's global "world" layer) may restyle the whole app
|
||||
// (operational L2 included) -- that is the point -- but every selector must
|
||||
// still be scoped to [data-theme="<id>"] so it can't leak to other themes.
|
||||
for (const entry of readdirSync(themesDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue
|
||||
const worldPath = join(themesDir, entry.name, 'world.css')
|
||||
if (!existsSync(worldPath)) continue
|
||||
const id = entry.name
|
||||
const scoped = (sel) =>
|
||||
sel.includes(`[data-theme="${id}"]`) || sel.includes(`[data-theme=${id}]`)
|
||||
const css = stripCssComments(readFileSync(worldPath, 'utf8'))
|
||||
eachSelector(css, (sel) => {
|
||||
if (sel.startsWith('@')) return
|
||||
if (/^(from|to|\d+%)(\s*,\s*(from|to|\d+%))*$/.test(sel)) return
|
||||
for (const raw of splitSelectorList(sel)) {
|
||||
const arm = raw.trim()
|
||||
if (!arm) continue
|
||||
if (!scoped(arm)) {
|
||||
failures.push(`${rel(worldPath)}: world selector not scoped to [data-theme="${id}"]: "${arm}"${arm === sel ? '' : ` (arm of "${sel}")`}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- (3) L2 must not read skin-private --x-* tokens --------------------------
|
||||
const styleFiles = /\.(vue|css)$/
|
||||
const l2Files = [
|
||||
...walkFiles(join(srcDir, 'components'), styleFiles),
|
||||
...walkFiles(join(srcDir, 'views'), styleFiles),
|
||||
// Global stylesheets (control-visual-system, chat-*, route-fx, …) are L2
|
||||
// surfaces too — imported app-wide from main.ts.
|
||||
...walkFiles(join(srcDir, 'styles'), styleFiles),
|
||||
join(srcDir, 'assets', 'base.css'),
|
||||
join(srcDir, 'assets', 'foundation.css'),
|
||||
]
|
||||
const xLeak = /var\(\s*--x-[\w-]+/
|
||||
for (const f of l2Files) {
|
||||
if (!existsSync(f)) continue
|
||||
readFileSync(f, 'utf8').split('\n').forEach((line, i) => {
|
||||
if (xLeak.test(line)) {
|
||||
failures.push(`${rel(f)}:${i + 1}: L2 must not read skin-private --x-* token: ${line.trim()}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.error('Theme scope guard failed:\n' + failures.join('\n'))
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('Theme scope guard passed.')
|
||||
@@ -0,0 +1,302 @@
|
||||
import { readdirSync, readFileSync, existsSync } from 'node:fs'
|
||||
import { join, relative } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { walkFiles } from './lib/fs-walk.mjs'
|
||||
import { hexToRgb, parseTokenDefinitions } from './lib/css-utils.mjs'
|
||||
|
||||
// Every control-UI surface must use the semantic color tokens from
|
||||
// src/assets/base.css so both themes render correctly. Raw hex / rgb() / hsl()
|
||||
// color literals anywhere under src/ are violations — use a token, or
|
||||
// color-mix(in srgb, var(--token) …).
|
||||
//
|
||||
// base.css is the token source, so it is scanned under a narrower rule: raw
|
||||
// color literals are allowed only inside custom-property definitions
|
||||
// (`--token: …`), comments, and neutral black/white rgba (the primitive the
|
||||
// shadow/border tokens are themselves built from). An off-palette literal in a
|
||||
// real base.css rule is still a violation — that is how theme-parity drift
|
||||
// (e.g. a hardcoded near-black border that vanishes on dark) sneaks in.
|
||||
const root = fileURLToPath(new URL('..', import.meta.url))
|
||||
const srcDir = join(root, 'src')
|
||||
|
||||
// Token-definition sources. Raw color literals are allowed only inside
|
||||
// custom-property definitions in these files: the base.css component file plus
|
||||
// the layered token layer (foundation L0 + each theme's L1 tokens.css). Anywhere
|
||||
// else a literal is a theme-parity violation.
|
||||
function isTokenSource(f) {
|
||||
const r = f.replace(/\\/g, '/')
|
||||
return (
|
||||
r.endsWith('/src/assets/base.css') ||
|
||||
r.endsWith('/src/assets/foundation.css') ||
|
||||
/\/src\/themes\/[^/]+\/tokens\.css$/.test(r) ||
|
||||
// a skin's scoped stylesheet defines its own token pack; structural rules in
|
||||
// it still may not use raw literals (only custom-prop lines are exempt).
|
||||
/\/src\/themes\/[^/]+\/skin\.css$/.test(r)
|
||||
)
|
||||
}
|
||||
|
||||
// Negative lookbehind keeps HTML entities like ↑ out of the hex match.
|
||||
const colorLiteral = /(?<!&)#[0-9a-fA-F]{3,8}\b|\brgba?\(|\bhsla?\(/
|
||||
// SVG fragment references such as url(#cg2) are ids, not colors.
|
||||
const urlRef = /url\(#[\w-]+\)/g
|
||||
// Neutral black/white — the primitive shadows/borders are built from; allowed
|
||||
// in base.css rules. Matches rgba(0,0,0,…), rgba(255,255,255,…), #000(0), #fff(f).
|
||||
const neutralColor =
|
||||
/\brgba?\(\s*(?:0\s*,\s*0\s*,\s*0|255\s*,\s*255\s*,\s*255)\b[^)]*\)|#(?:000000|ffffff|000|fff)\b/gi
|
||||
const customProp = /^\s*--[\w-]+\s*:/
|
||||
|
||||
const files = walkFiles(srcDir, /\.(vue|css)$/)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pass 1 — raw color literals
|
||||
// ---------------------------------------------------------------------------
|
||||
const failures = []
|
||||
for (const file of files) {
|
||||
const rel = relative(root, file).replace(/\\/g, '/')
|
||||
const isBase = isTokenSource(file)
|
||||
const lines = readFileSync(file, 'utf8').split('\n')
|
||||
let inBlockComment = false
|
||||
lines.forEach((line, index) => {
|
||||
let code = line
|
||||
// Strip block comments (base.css is scanned, so its banner hex must not
|
||||
// trip the guard); track multi-line state.
|
||||
if (inBlockComment) {
|
||||
const end = code.indexOf('*/')
|
||||
if (end === -1) return
|
||||
code = code.slice(end + 2)
|
||||
inBlockComment = false
|
||||
}
|
||||
code = code.replace(/\/\*.*?\*\//g, '')
|
||||
const open = code.indexOf('/*')
|
||||
if (open !== -1) {
|
||||
inBlockComment = true
|
||||
code = code.slice(0, open)
|
||||
}
|
||||
code = code.replace(urlRef, '')
|
||||
if (isBase) {
|
||||
if (customProp.test(code)) return // a token definition — allowed
|
||||
code = code.replace(neutralColor, '') // neutral shadow/border primitive
|
||||
}
|
||||
if (colorLiteral.test(code)) {
|
||||
failures.push(
|
||||
`${rel}:${index + 1}: raw color literal; use a base.css token or color-mix(in srgb, var(--token) …). ${line.trim()}`,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pass 2 — references to undefined tokens
|
||||
// ---------------------------------------------------------------------------
|
||||
// A bare `var(--token)` with no fallback that is never defined anywhere renders
|
||||
// to nothing (e.g. a colorless status message). Fallback forms `var(--x, …)`
|
||||
// are intentional and exempt — that is how runtime/host-set vars are consumed.
|
||||
// Scoped to the semantic color/surface namespace: layout vars like
|
||||
// `--router-left`/`--i`/`--composer-h` are legitimately set at runtime via
|
||||
// :style bindings and are out of scope for a color guard.
|
||||
const semanticToken = /^--(bg|surface|text|color|accent|ok|warn|danger|info|success|border|hairline|card|shadow|scrim|sidebar|syntax)/
|
||||
const defined = new Set()
|
||||
const bareUsages = [] // { token, rel, line }
|
||||
const defRe = /(?:^|[\s;{])(--[\w-]+)\s*:/g
|
||||
const bareVarRe = /var\(\s*(--[\w-]+)\s*\)/g
|
||||
for (const file of files) {
|
||||
const rel = relative(root, file).replace(/\\/g, '/')
|
||||
const text = readFileSync(file, 'utf8')
|
||||
for (const m of text.matchAll(defRe)) defined.add(m[1])
|
||||
text.split('\n').forEach((line, index) => {
|
||||
for (const m of line.matchAll(bareVarRe)) {
|
||||
bareUsages.push({ token: m[1], rel, line: index + 1, text: line.trim() })
|
||||
}
|
||||
})
|
||||
}
|
||||
const undefinedTokens = bareUsages.filter(
|
||||
(u) => semanticToken.test(u.token) && !defined.has(u.token),
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pass 3 — per-theme contrast floors (WCAG 2.x). Each value theme's reading
|
||||
// pairs must clear a floor so a new theme cannot ship illegible text. Values are
|
||||
// resolved through var()/color-mix(in srgb, …) within each theme's tokens + the
|
||||
// foundation layer.
|
||||
//
|
||||
// The floors distinguish INK (colour used as `color:` text) from FILL (dots,
|
||||
// bars, badge bodies — never text):
|
||||
// • --text / --text-muted are body reading text → AA normal 4.5:1.
|
||||
// • The colour-carrying INK — --accent, --accent-secondary and the status
|
||||
// spectrum (ok/warn/danger/info/queued) — is used as `color:` text in 100+
|
||||
// sites (links, nav, inline status, error copy). It MUST clear AA 4.5:1 on
|
||||
// the two grounds it actually sits on: the base (--bg) and the card/surface
|
||||
// (--bg-surface). On --bg-elevated (modals/popovers — a minority ground for
|
||||
// coloured text) it is held to the 3:1 UI-component floor. This is the fix
|
||||
// for the old bug where these were floored at 3.0 against --bg only (or, for
|
||||
// accent, not checked at all), which green-lit sub-AA coloured text.
|
||||
// • The matching --*-fill tokens are marks, never text → 3:1 on --bg.
|
||||
const foundationFile = join(srcDir, 'assets', 'foundation.css')
|
||||
const themesRoot = join(srcDir, 'themes')
|
||||
const valueThemeFiles = existsSync(themesRoot)
|
||||
? readdirSync(themesRoot, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory() && existsSync(join(themesRoot, d.name, 'tokens.css')))
|
||||
.map((d) => [d.name, join(themesRoot, d.name, 'tokens.css')])
|
||||
: []
|
||||
|
||||
function tokenMapFor(themeFile) {
|
||||
const map = new Map()
|
||||
for (const p of [foundationFile, themeFile]) {
|
||||
if (!existsSync(p)) continue
|
||||
parseTokenDefinitions(readFileSync(p, 'utf8'), map)
|
||||
}
|
||||
return map
|
||||
}
|
||||
function splitTop(s) {
|
||||
const out = []
|
||||
let depth = 0
|
||||
let buf = ''
|
||||
for (const ch of s) {
|
||||
if (ch === '(') { depth++; buf += ch }
|
||||
else if (ch === ')') { depth--; buf += ch }
|
||||
else if (ch === ',' && depth === 0) { out.push(buf); buf = '' }
|
||||
else buf += ch
|
||||
}
|
||||
if (buf.trim()) out.push(buf)
|
||||
return out
|
||||
}
|
||||
function toRgb(value, map, seen = new Set()) {
|
||||
if (value == null) return null
|
||||
const v = value.trim()
|
||||
const varM = v.match(/^var\(\s*(--[\w-]+)\s*(?:,\s*([\s\S]+))?\)$/)
|
||||
if (varM) {
|
||||
const name = varM[1].slice(2)
|
||||
if (seen.has(name)) return null
|
||||
const next = new Set(seen).add(name)
|
||||
if (map.has(name)) return toRgb(map.get(name), map, next)
|
||||
return varM[2] ? toRgb(varM[2], map, next) : null
|
||||
}
|
||||
let m = v.match(/^#([0-9a-fA-F]{3,8})$/)
|
||||
if (m) return hexToRgb(v)
|
||||
m = v.match(/^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/)
|
||||
if (m) return [Number(m[1]), Number(m[2]), Number(m[3])]
|
||||
m = v.match(/^color-mix\(in srgb,\s*([\s\S]+)\)$/)
|
||||
if (m) {
|
||||
const parts = splitTop(m[1]).map((p) => {
|
||||
const pm = p.trim().match(/^([\s\S]*?)\s+([\d.]+)%$/)
|
||||
return pm
|
||||
? { rgb: toRgb(pm[1], map, new Set(seen)), pct: Number(pm[2]) }
|
||||
: { rgb: toRgb(p, map, new Set(seen)), pct: null }
|
||||
})
|
||||
if (parts.length < 2 || !parts[0].rgb || !parts[1].rgb) return null
|
||||
let [a, b] = parts
|
||||
if (a.pct == null && b.pct == null) { a.pct = 50; b.pct = 50 }
|
||||
else if (a.pct == null) a.pct = 100 - b.pct
|
||||
else if (b.pct == null) b.pct = 100 - a.pct
|
||||
const t = a.pct / (a.pct + b.pct)
|
||||
return [0, 1, 2].map((i) => Math.round(a.rgb[i] * t + b.rgb[i] * (1 - t)))
|
||||
}
|
||||
return null
|
||||
}
|
||||
const chanLin = (c) => { const s = c / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4 }
|
||||
const relLum = (rgb) => 0.2126 * chanLin(rgb[0]) + 0.7152 * chanLin(rgb[1]) + 0.0722 * chanLin(rgb[2])
|
||||
function contrast(a, b) {
|
||||
const la = relLum(a)
|
||||
const lb = relLum(b)
|
||||
const [hi, lo] = la > lb ? [la, lb] : [lb, la]
|
||||
return (hi + 0.05) / (lo + 0.05)
|
||||
}
|
||||
// Colour-carrying ink used as text (see the note above). AA on --bg + --bg-surface;
|
||||
// 3:1 UI floor on --bg-elevated. --accent-hover is included because it paints
|
||||
// link/CTA hover text (a:hover, OverviewView). --accent-deep is intentionally
|
||||
// absent — it is only ever a fill/border/gradient stop, never `color:` text.
|
||||
const INK_TEXT = ['accent', 'accent-hover', 'accent-secondary', 'ok', 'warn', 'danger', 'info', 'queued']
|
||||
// Fill tokens: marks/dots/badge bodies, never text → 3:1 on --bg.
|
||||
const FILL_MARKS = ['ok-fill', 'warn-fill', 'danger-fill', 'info-fill', 'queued-fill']
|
||||
const CONTRAST_CHECKS = [
|
||||
{ fg: 'text', bg: 'bg', min: 4.5 },
|
||||
{ fg: 'text', bg: 'bg-surface', min: 4.5 },
|
||||
{ fg: 'text-muted', bg: 'bg', min: 4.5 },
|
||||
{ fg: 'text-dim', bg: 'bg', min: 3.0 },
|
||||
{ fg: 'accent-foreground', bg: 'accent', min: 3.0 },
|
||||
...INK_TEXT.flatMap((fg) => [
|
||||
{ fg, bg: 'bg', min: 4.5 },
|
||||
{ fg, bg: 'bg-surface', min: 4.5 },
|
||||
{ fg, bg: 'bg-elevated', min: 3.0 },
|
||||
]),
|
||||
...FILL_MARKS.map((fg) => ({ fg, bg: 'bg', min: 3.0 })),
|
||||
]
|
||||
const contrastFailures = []
|
||||
const unresolvable = []
|
||||
for (const [theme, file] of valueThemeFiles) {
|
||||
const map = tokenMapFor(file)
|
||||
const reported = new Set()
|
||||
for (const c of CONTRAST_CHECKS) {
|
||||
const fg = toRgb(`var(--${c.fg})`, map)
|
||||
const bg = toRgb(`var(--${c.bg})`, map)
|
||||
if (!fg || !bg) {
|
||||
// A required role toRgb() can't parse (hsl()/oklch()/named colour/…)
|
||||
// must FAIL, not silently skip — otherwise a theme authored in an
|
||||
// unsupported syntax ships with zero contrast coverage while the guard
|
||||
// reports it as checked.
|
||||
for (const [role, rgb] of [[c.fg, fg], [c.bg, bg]]) {
|
||||
const key = `${theme}:${role}`
|
||||
if (!rgb && !reported.has(key)) {
|
||||
reported.add(key)
|
||||
unresolvable.push(
|
||||
` [${theme}] --${role}: ${map.get(role) ?? '(undefined)'} — not parseable as a colour (supported: hex, rgb()/rgba(), var() chains, color-mix(in srgb, …))`,
|
||||
)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
const r = contrast(fg, bg)
|
||||
if (r < c.min) {
|
||||
contrastFailures.push(` [${theme}] --${c.fg} on --${c.bg}: ${r.toFixed(2)}:1 (floor ${c.min}:1)`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pass 4 — ink/fill discipline (harvested from the Out-of-Register work). A
|
||||
// `*-fill` token is chroma-tuned for small marks (dots, bars, badge bodies); it
|
||||
// is NOT a text colour. Using one as `color:` is a legibility bug — use the
|
||||
// matching ink token. (background-color / border-color etc. are fine.)
|
||||
const fillAsColor = /(?<![-\w])color\s*:\s*var\(\s*--[\w-]*-fill\b/
|
||||
const fillMisuse = []
|
||||
for (const file of files) {
|
||||
const relf = relative(root, file).replace(/\\/g, '/')
|
||||
readFileSync(file, 'utf8').split('\n').forEach((line, i) => {
|
||||
if (fillAsColor.test(line)) {
|
||||
fillMisuse.push(`${relf}:${i + 1}: a *-fill token is for fills, not text — use its ink token. ${line.trim()}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
let failed = false
|
||||
if (failures.length > 0) {
|
||||
failed = true
|
||||
console.error('Raw color literals found — use base.css design tokens:\n' + failures.join('\n'))
|
||||
}
|
||||
if (undefinedTokens.length > 0) {
|
||||
failed = true
|
||||
console.error(
|
||||
'\nReferences to undefined CSS tokens (use a defined token or add a fallback `var(--x, …)`):\n' +
|
||||
undefinedTokens.map((u) => `${u.rel}:${u.line}: ${u.token} is never defined. ${u.text}`).join('\n'),
|
||||
)
|
||||
}
|
||||
if (contrastFailures.length > 0) {
|
||||
failed = true
|
||||
console.error('\nTheme contrast below floor (WCAG):\n' + contrastFailures.join('\n'))
|
||||
}
|
||||
if (unresolvable.length > 0) {
|
||||
failed = true
|
||||
console.error(
|
||||
'\nTheme contrast UNVERIFIABLE — required colour roles the checker cannot parse (author them in a supported syntax so the WCAG floors actually apply):\n' +
|
||||
unresolvable.join('\n'),
|
||||
)
|
||||
}
|
||||
if (fillMisuse.length > 0) {
|
||||
failed = true
|
||||
console.error('\nInk/fill discipline — a fill token used as text colour:\n' + fillMisuse.join('\n'))
|
||||
}
|
||||
if (failed) process.exit(1)
|
||||
|
||||
console.log(
|
||||
`WebUI color guard passed (${valueThemeFiles.length} theme(s) contrast-checked).`,
|
||||
)
|
||||
@@ -0,0 +1,106 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, relative } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { walkFiles } from './lib/fs-walk.mjs'
|
||||
|
||||
// Every control-UI surface must move with the shared motion vocabulary defined in
|
||||
// src/assets/base.css: durations --dur-fast/base/enter/pulse and easings
|
||||
// --ease-standard/out/in/spring. Raw millisecond/second literals and the bare
|
||||
// `ease`/`ease-in`/`ease-out`/`ease-in-out` keywords inside transition/animation
|
||||
// declarations are violations — route them through the tokens so timing and feel
|
||||
// stay consistent (and so "exits one tier faster" can be enforced in one place).
|
||||
//
|
||||
// Allowed: var(--token), cubic-bezier(...)/steps(...) custom curves, `linear`
|
||||
// and `infinite` continuous loops (spinners/pulses keep their own cadence), the
|
||||
// zero value (0s/0ms), and the token definitions in base.css. Any genuinely
|
||||
// intentional one-off can opt out with a trailing `/* motion-allow: why */`.
|
||||
const root = fileURLToPath(new URL('..', import.meta.url))
|
||||
const srcDir = join(root, 'src')
|
||||
|
||||
const customProp = /^\s*--[\w-]+\s*:/
|
||||
const easingKeyword = /\b(?:ease-in-out|ease-in|ease-out|ease)\b/
|
||||
// A time value; we exclude the literal zero (0s / 0ms / 0.0s) separately.
|
||||
const durationLiteral = /\b\d*\.?\d+m?s\b/g
|
||||
const isZero = (v) => /^0(?:\.0+)?m?s$/.test(v)
|
||||
|
||||
const files = walkFiles(srcDir, /\.(vue|css)$/)
|
||||
const failures = []
|
||||
|
||||
// In .vue files only <style> blocks carry CSS; script/template lines (timer
|
||||
// durations, "1s tick" comments) must not be scanned as motion.
|
||||
function styleLineSet(text, isVue) {
|
||||
if (!isVue) return null
|
||||
const inStyle = new Set()
|
||||
let active = false
|
||||
text.split('\n').forEach((line, i) => {
|
||||
if (!active && /<style[\s>]/.test(line)) { active = true; return }
|
||||
if (active && /<\/style>/.test(line)) { active = false; return }
|
||||
if (active) inStyle.add(i)
|
||||
})
|
||||
return inStyle
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const rel = relative(root, file).replace(/\\/g, '/')
|
||||
const text = readFileSync(file, 'utf8')
|
||||
const lines = text.split('\n')
|
||||
const styleLines = styleLineSet(text, file.endsWith('.vue'))
|
||||
let inBlockComment = false
|
||||
lines.forEach((line, index) => {
|
||||
if (styleLines && !styleLines.has(index)) return
|
||||
// Explicit, reviewed opt-out (e.g. a spinner whose cadence is intentional).
|
||||
if (/motion-allow/.test(line)) return
|
||||
let code = line
|
||||
if (inBlockComment) {
|
||||
const end = code.indexOf('*/')
|
||||
if (end === -1) return
|
||||
code = code.slice(end + 2)
|
||||
inBlockComment = false
|
||||
}
|
||||
code = code.replace(/\/\*.*?\*\//g, '')
|
||||
const open = code.indexOf('/*')
|
||||
if (open !== -1) {
|
||||
inBlockComment = true
|
||||
code = code.slice(0, open)
|
||||
}
|
||||
// Token definitions are the source of the vocabulary — allowed to hold raw
|
||||
// values and bare curves.
|
||||
if (customProp.test(code)) return
|
||||
// Stagger delays are layout math (per-item offsets), not the motion-quality
|
||||
// vocabulary — `animation-delay`/`transition-delay` are out of scope.
|
||||
if (/(?:animation|transition)-delay\s*:/.test(code)) return
|
||||
// Strip the allowed forms so only raw literals can remain to be flagged.
|
||||
code = code
|
||||
.replace(/var\([^()]*\)/g, '')
|
||||
.replace(/cubic-bezier\([^()]*\)/g, '')
|
||||
.replace(/steps\([^()]*\)/g, '')
|
||||
// Continuous loops (linear/infinite spinners, breathes, shimmers) keep their
|
||||
// own cadence and symmetric curve, which sit outside the UI-transition
|
||||
// vocabulary — exempt the whole line (both duration and easing).
|
||||
if (/\b(?:infinite|linear)\b/.test(code)) return
|
||||
|
||||
const durations = [...code.matchAll(durationLiteral)]
|
||||
.map((m) => m[0])
|
||||
.filter((v) => !isZero(v))
|
||||
if (durations.length > 0) {
|
||||
failures.push(
|
||||
`${rel}:${index + 1}: raw duration ${durations.join(', ')}; use var(--dur-fast|base|enter|pulse). ${line.trim()}`,
|
||||
)
|
||||
}
|
||||
if (easingKeyword.test(code)) {
|
||||
failures.push(
|
||||
`${rel}:${index + 1}: bare easing keyword; use var(--ease-standard|out|in|spring). ${line.trim()}`,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(
|
||||
`WebUI motion guard: ${failures.length} raw motion literal(s) — route through base.css motion tokens:\n` +
|
||||
failures.join('\n'),
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('WebUI motion guard passed.')
|
||||
@@ -0,0 +1,105 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, relative } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { walkFiles } from './lib/fs-walk.mjs'
|
||||
|
||||
// Every control-UI surface must round its corners with the radius token ladder
|
||||
// defined in src/assets/base.css: the primitives --radius-none/xs/sm/md/lg/xl/
|
||||
// 2xl/full and the semantic aliases --radius-control/card/panel/modal/pill. A
|
||||
// raw length (6px, 0.375rem, 14px, …) inside a border-radius / border-*-radius
|
||||
// declaration is a violation — route it through a token so the "Instrument"
|
||||
// direction's corner tiers stay consistent and can be tuned in one place.
|
||||
//
|
||||
// Allowed raw values: 0, 0px, 50% (true circles — dots/avatars), inherit, and
|
||||
// the fully-round pill literals 999px / 9999px, plus calc(...) expressions built
|
||||
// from var(--radius-*). base.css is the token source, so its --radius-* token
|
||||
// definitions (custom-property lines) are exempt. Any genuinely geometric one-off
|
||||
// (hairline caret, progress/trace bar, scrollbar thumb) can opt out with a
|
||||
// trailing `/* radius-allow: why */`.
|
||||
const root = fileURLToPath(new URL('..', import.meta.url))
|
||||
const srcDir = join(root, 'src')
|
||||
|
||||
const customProp = /^\s*--[\w-]+\s*:/
|
||||
// A border-radius / border-<corner>-radius declaration, capturing just its VALUE
|
||||
// (up to the next ; or }). Scanning only the value avoids false positives from
|
||||
// other lengths on the same line (e.g. a `padding: 0.25rem` shorthand sitting
|
||||
// beside a tokenized `border-radius: var(--radius-sm)`).
|
||||
const radiusValue = /border(?:-[a-z]+)*-radius\s*:\s*([^;}]+)/gi
|
||||
// A raw CSS length: number + px/rem/em unit.
|
||||
const lengthLiteral = /\b\d*\.?\d+(?:px|rem|em)\b/g
|
||||
// Raw length literals that are explicitly allowed even inside a radius decl.
|
||||
const allowedLength = /^(?:0px|999px|9999px)$/
|
||||
|
||||
const files = walkFiles(srcDir, /\.(vue|css)$/)
|
||||
|
||||
// In .vue files only <style> blocks carry CSS; template class strings can
|
||||
// contain the word "radius" and must not be scanned.
|
||||
function styleLineSet(text, isVue) {
|
||||
if (!isVue) return null
|
||||
const inStyle = new Set()
|
||||
let active = false
|
||||
text.split('\n').forEach((line, i) => {
|
||||
if (!active && /<style[\s>]/.test(line)) { active = true; return }
|
||||
if (active && /<\/style>/.test(line)) { active = false; return }
|
||||
if (active) inStyle.add(i)
|
||||
})
|
||||
return inStyle
|
||||
}
|
||||
|
||||
const failures = []
|
||||
for (const file of files) {
|
||||
const rel = relative(root, file).replace(/\\/g, '/')
|
||||
const text = readFileSync(file, 'utf8')
|
||||
const lines = text.split('\n')
|
||||
const styleLines = styleLineSet(text, file.endsWith('.vue'))
|
||||
let inBlockComment = false
|
||||
lines.forEach((line, index) => {
|
||||
if (styleLines && !styleLines.has(index)) return
|
||||
// Explicit, reviewed opt-out (e.g. a hairline caret / progress bar geometry).
|
||||
if (/radius-allow/.test(line)) return
|
||||
let code = line
|
||||
// Strip block comments (base.css is scanned, so its banner text must not
|
||||
// trip the guard); track multi-line state.
|
||||
if (inBlockComment) {
|
||||
const end = code.indexOf('*/')
|
||||
if (end === -1) return
|
||||
code = code.slice(end + 2)
|
||||
inBlockComment = false
|
||||
}
|
||||
code = code.replace(/\/\*.*?\*\//g, '')
|
||||
const open = code.indexOf('/*')
|
||||
if (open !== -1) {
|
||||
inBlockComment = true
|
||||
code = code.slice(0, open)
|
||||
}
|
||||
// The --radius-* token definitions in base.css are the source of the ladder —
|
||||
// allowed to hold raw lengths.
|
||||
if (customProp.test(code)) return
|
||||
// Check ONLY the value of each border-radius declaration on the line.
|
||||
let offending = false
|
||||
for (const decl of code.matchAll(radiusValue)) {
|
||||
// Strip calc(...) so var(--radius-*)-based expressions don't trip on the
|
||||
// literals inside them.
|
||||
const value = decl[1].replace(/calc\([^()]*\)/g, '')
|
||||
const literals = [...value.matchAll(lengthLiteral)]
|
||||
.map((m) => m[0])
|
||||
.filter((v) => !allowedLength.test(v))
|
||||
if (literals.length > 0) offending = true
|
||||
}
|
||||
if (offending) {
|
||||
failures.push(
|
||||
`${rel}:${index + 1}: hardcoded border-radius; use a --radius-* token. ${line.trim()}`,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(
|
||||
`WebUI radius guard: ${failures.length} hardcoded border-radius value(s) — use a base.css --radius-* token:\n` +
|
||||
failures.join('\n'),
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('WebUI radius guard passed.')
|
||||
@@ -0,0 +1,34 @@
|
||||
// Shared CSS/color parsing helpers for the guard scripts. Each of these used
|
||||
// to exist as a per-script copy (with per-script gaps — e.g. one hex parser
|
||||
// missing 4/8-digit forms, one token regex missing a block-final declaration
|
||||
// without a trailing semicolon). Fix bugs here, once.
|
||||
|
||||
/** Strip CSS block comments. */
|
||||
export function stripCssComments(css) {
|
||||
return css.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
}
|
||||
|
||||
/** Strip HTML, CSS and JS line comments from a mixed html/ts/css source.
|
||||
* Line comments keep the char before `//` and never eat `://` (URLs). */
|
||||
export function stripAllComments(text) {
|
||||
return stripCssComments(
|
||||
text.replace(/<!--[\s\S]*?-->/g, ''),
|
||||
).replace(/(^|[^:])\/\/[^\n]*/g, '$1')
|
||||
}
|
||||
|
||||
/** #rgb / #rgba / #rrggbb / #rrggbbaa → [r, g, b] (alpha ignored). */
|
||||
export function hexToRgb(hex) {
|
||||
let h = hex.replace('#', '')
|
||||
if (h.length === 3 || h.length === 4) h = h.split('').map((c) => c + c).join('')
|
||||
return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16))
|
||||
}
|
||||
|
||||
/** Every custom-property definition in a css source, as Map(name → value).
|
||||
* Names are bare (no leading `--`). Handles a block-final declaration with no
|
||||
* trailing semicolon (values stop at `;` or `}`). Later definitions win, so
|
||||
* feeding foundation.css then a theme's tokens.css yields the theme's view. */
|
||||
export function parseTokenDefinitions(css, map = new Map()) {
|
||||
const re = /(?:^|[\s;{])--([\w-]+)\s*:\s*([^;{}]+)/g
|
||||
for (const m of stripCssComments(css).matchAll(re)) map.set(m[1], m[2].trim())
|
||||
return map
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { readdirSync, statSync, existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
// Shared recursive file walker for the guard scripts. Before this existed each
|
||||
// guard carried its own copy with its own exclusion rules, and they drifted —
|
||||
// use this one and pass what differs.
|
||||
|
||||
/**
|
||||
* Recursively collect files under `root` whose BASENAME matches `match`.
|
||||
* A missing root returns [] (some guards scan optional directories).
|
||||
*
|
||||
* @param {string} root
|
||||
* @param {RegExp} match tested against the file's basename
|
||||
* @param {object} [opts]
|
||||
* @param {string[]} [opts.excludeDirs] directory names pruned wholesale
|
||||
* @param {(name: string) => boolean} [opts.skipFile] drop individual files by basename
|
||||
* @returns {string[]} absolute paths
|
||||
*/
|
||||
export function walkFiles(root, match, { excludeDirs = ['node_modules', 'dist'], skipFile } = {}) {
|
||||
const out = []
|
||||
if (!existsSync(root)) return out
|
||||
const visit = (path) => {
|
||||
if (statSync(path).isDirectory()) {
|
||||
for (const entry of readdirSync(path)) {
|
||||
if (excludeDirs.includes(entry)) continue
|
||||
visit(join(path, entry))
|
||||
}
|
||||
return
|
||||
}
|
||||
const name = path.slice(path.lastIndexOf('/') + 1)
|
||||
if (match.test(name) && !(skipFile && skipFile(name))) out.push(path)
|
||||
}
|
||||
visit(root)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const distDir = resolve(import.meta.dirname, '../../src/opensquilla/gateway/static/dist')
|
||||
const textFilePattern = /\.(css|html|js|map)$/
|
||||
|
||||
function normalizeNewlines(value) {
|
||||
return value.replace(/\r\n/g, '\n').replace(/\r/g, '')
|
||||
}
|
||||
|
||||
function normalizeSourceMap(content) {
|
||||
try {
|
||||
const parsed = JSON.parse(normalizeNewlines(content))
|
||||
return JSON.stringify(replaceWindowsNewlines(parsed))
|
||||
} catch {
|
||||
return normalizeNewlines(content)
|
||||
}
|
||||
}
|
||||
|
||||
function replaceWindowsNewlines(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(replaceWindowsNewlines)
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, entry]) => [key, replaceWindowsNewlines(entry)]),
|
||||
)
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return normalizeNewlines(value)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function normalizeFile(path) {
|
||||
const before = readFileSync(path, 'utf8')
|
||||
let after = path.endsWith('.map') ? normalizeSourceMap(before) : normalizeNewlines(before)
|
||||
|
||||
if (path.endsWith('.css')) {
|
||||
after = after.replace(/\n+$/g, '')
|
||||
}
|
||||
|
||||
if (after !== before) {
|
||||
writeFileSync(path, after, 'utf8')
|
||||
}
|
||||
}
|
||||
|
||||
function walk(dir) {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const path = resolve(dir, entry)
|
||||
const stat = statSync(path)
|
||||
if (stat.isDirectory()) {
|
||||
walk(path)
|
||||
continue
|
||||
}
|
||||
if (textFilePattern.test(entry)) {
|
||||
normalizeFile(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(distDir)
|
||||
Reference in New Issue
Block a user