export interface CssToken { /** `--fox-` 를 뗀 이름 */ name: string; property: string; /** 그 모드에서 선언된 원문. 참조면 `var(--fox-…)`, 아니면 값. */ declaration: string; /** 참조면 가리키는 토큰 이름, 아니면 null */ alias: string | null; } export interface CssSection { id: string; label: string; category: string | null; /** 이 섹션의 값이 어디서 왔는지 */ source: string; tokens: CssToken[]; } const PREFIX = "--fox-"; /** `var(--fox-x)` 형태면 가리키는 토큰 이름을 돌려준다. */ function aliasOf(value: string): string | null { const match = /^var\(\s*--fox-([a-z0-9-]+)\s*\)$/.exec(value.trim()); return match ? match[1] : null; } /** 내용에 `var(--fox-…)`가 중첩돼 정규식으로는 못 자르므로 괄호 짝을 센다. */ function extractFallback(text: string, marker: string): string | null { const start = text.indexOf(marker); if (start < 0) { return null; } let depth = 1; let i = start + marker.length; for (; i < text.length && depth > 0; i += 1) { if (text[i] === "(") depth += 1; else if (text[i] === ")") depth -= 1; } return text.slice(start + marker.length, i - 1).trim(); } /** 괄호 안의 콤마는 무시하고 최상위 콤마로 나눈다. */ function splitTopLevel(text: string): [string, string] | null { let depth = 0; for (let i = 0; i < text.length; i += 1) { const ch = text[i]; if (ch === "(") depth += 1; else if (ch === ")") depth -= 1; else if (ch === "," && depth === 0) { return [text.slice(0, i).trim(), text.slice(i + 1).trim()]; } } return null; } /** * Lightning CSS가 `light-dark()`를 폴리필한 형태를 먼저 보고, 폴리필이 없는 설정을 * 대비해 원형도 처리한다. */ function splitLightDark(declaration: string): { light: string; dark: string } | null { const light = extractFallback(declaration, "var(--lightningcss-light,"); const dark = extractFallback(declaration, "var(--lightningcss-dark,"); if (light !== null && dark !== null) { return { light, dark }; } if (declaration.startsWith("light-dark(")) { const inner = extractFallback(declaration, "light-dark("); const pair = inner === null ? null : splitTopLevel(inner); if (pair) { return { light: pair[0], dark: pair[1] }; } } return null; } function toToken(name: string, declaration: string): CssToken { return { name, property: `${PREFIX}${name}`, declaration, alias: aliasOf(declaration), }; } interface RawTokens { /** `:root` 기본 선언 — 색상은 light-dark 형태, 반응형은 모바일 값 */ base: Map; /** `@media (min-width: …)` 안의 오버라이드 — PC 값 */ pc: Map; } function readRaw(): RawTokens { const base = new Map(); const pc = new Map(); const collect = (rule: CSSStyleRule, into: Map) => { if (rule.selectorText !== ":root") { return; } for (const property of Array.from(rule.style)) { if (property.startsWith(PREFIX) && !into.has(property)) { into.set(property.slice(PREFIX.length), rule.style.getPropertyValue(property).trim()); } } }; 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) { collect(rule, base); } else if (rule instanceof CSSMediaRule && rule.conditionText.includes("min-width")) { // 반응형 오버라이드. `prefers-color-scheme` 블록은 조건에 min-width가 없어 걸러진다. for (const inner of Array.from(rule.cssRules)) { if (inner instanceof CSSStyleRule) { collect(inner, pc); } } } } } return { base, pc }; } const SIZE_PREFIX = /^(border|padding|gap|radius|icon|shadow|backdrop)-/; const PRIMITIVE_PREFIX = /^(primitive|font-family|font-weight|number)-/; /** * 컴파일된 스타일시트에서 섹션을 구성한다. 계산값이 아니라 **선언 원문**을 읽는 것이 * 요점 — 계산값은 참조가 이미 풀려 있고 현재 테마·뷰포트 한쪽만 보인다. */ export function readCssSections(): CssSection[] { const { base, pc } = readRaw(); const colors: { name: string; light: string; dark: string }[] = []; const responsive: string[] = []; const size: string[] = []; const theme: string[] = []; const primitive: string[] = []; for (const [name, declaration] of base) { if (name.startsWith("color-")) { const pair = splitLightDark(declaration); colors.push({ name, light: pair?.light ?? declaration, dark: pair?.dark ?? declaration, }); continue; } if (pc.has(name)) { responsive.push(name); continue; } if (SIZE_PREFIX.test(name)) { size.push(name); continue; } if (name.startsWith("theme-")) { theme.push(name); continue; } if (PRIMITIVE_PREFIX.test(name)) { primitive.push(name); continue; } // 분류에 없는 새 토큰도 버리지 않는다. size.push(name); } return [ { id: "light", label: "Light", category: "Mode", source: "컴파일된 CSS — light-dark()의 라이트 쪽", tokens: colors.map((entry) => toToken(entry.name, entry.light)), }, { id: "dark", label: "Dark", category: "Mode", source: "컴파일된 CSS — light-dark()의 다크 쪽", tokens: colors.map((entry) => toToken(entry.name, entry.dark)), }, { id: "pc", label: "PC", category: "Responsive", source: "컴파일된 CSS — @media (min-width: 768px) 블록", tokens: responsive.map((name) => toToken(name, pc.get(name) ?? "")), }, { id: "mobile", label: "Mobile", category: "Responsive", source: "컴파일된 CSS — :root 기본값(모바일 우선)", tokens: responsive.map((name) => toToken(name, base.get(name) ?? "")), }, { id: "size", label: "Size", category: null, source: "컴파일된 CSS — :root", tokens: size.map((n) => toToken(n, base.get(n) ?? "")) }, { id: "theme", label: "Theme", category: null, source: "컴파일된 CSS — :root", tokens: theme.map((n) => toToken(n, base.get(n) ?? "")) }, { id: "primitive", label: "Primitive", category: null, source: "컴파일된 CSS — :root", tokens: primitive.map((n) => toToken(n, base.get(n) ?? "")) }, ]; }