export interface TokenEntry { /** 토큰 이름 (`--fox-color-primary` → `primary`) */ name: string; /** * 현재 테마에서 실제로 해석된 값 (`#818cf8`). * * 선언 원문이 아니라 계산값을 쓴다 — Lightning CSS가 `light-dark()`를 * `var(--lightningcss-light, …) var(--lightningcss-dark, …)`로 폴리필해 내보내므로 * 원문은 읽기 어렵고, 계산값은 지금 테마에서 눈에 보이는 색과 정확히 일치한다. */ value: string; /** 스타일에서 참조할 커스텀 프로퍼티 전체 이름 */ property: string; } export type TokenRegistry = Record; const PREFIX = "--fox-"; const TYPO_FIELDS = ["-size", "-weight", "-line-height", "-letter-spacing"]; /** * 문서의 모든 스타일시트를 훑어 `--fox-*` 커스텀 프로퍼티를 그룹별로 모은다. * * 토큰 목록을 카탈로그가 따로 들고 있지 않게 하려는 것이 목적이다 — `tokens/`에 값을 * 추가하면 카탈로그에 자동으로 나타나고, 목록이 두 곳에 존재해 어긋날 일이 없다. * CSSOM은 선언 순서를 보존하므로 SCSS map에 적은 순서가 그대로 유지된다. * * 브라우저에서만 동작한다(문서가 있어야 한다). */ export function readTokenRegistry(): TokenRegistry { const registry: TokenRegistry = {}; const seen = new Set(); // 이름·순서는 CSSOM 규칙에서, 값은 계산 스타일에서 가져온다. const computed = getComputedStyle(document.documentElement); for (const sheet of Array.from(document.styleSheets)) { let rules: CSSRuleList; try { rules = sheet.cssRules; } catch { // 교차 출처 스타일시트는 규칙을 읽을 수 없다 — 우리 토큰은 동일 출처이므로 건너뛴다. continue; } for (const rule of Array.from(rules)) { if (!(rule instanceof CSSStyleRule) || rule.selectorText !== ":root") { continue; } for (const property of Array.from(rule.style)) { if (!property.startsWith(PREFIX) || seen.has(property)) { continue; } seen.add(property); const rest = property.slice(PREFIX.length); const separator = rest.indexOf("-"); if (separator <= 0) { continue; } const group = rest.slice(0, separator); const name = rest.slice(separator + 1); (registry[group] ??= []).push({ name, value: computed.getPropertyValue(property).trim() || rule.style.getPropertyValue(property).trim(), property, }); } } } return registry; } /** * 타이포 프리셋은 이름 하나가 4개의 커스텀 프로퍼티로 펼쳐져 있으므로 * (`--fox-typo-title-md-size` 등) 접미사를 떼어 프리셋 이름만 순서대로 복원한다. */ export function typoPresetNames(registry: TokenRegistry): string[] { const names: string[] = []; for (const entry of registry.typo ?? []) { const field = TYPO_FIELDS.find((suffix) => entry.name.endsWith(suffix)); if (!field) { continue; } const preset = entry.name.slice(0, -field.length); if (!names.includes(preset)) { names.push(preset); } } return names; }