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(--name, 내용)`에서 괄호 짝을 세어 `내용`만 잘라낸다. * 내용 자체가 `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()`를 폴리필해 * `var(--lightningcss-light, A) var(--lightningcss-dark, B)`로 내보내므로 그 형태를 먼저 보고, * 폴리필이 없는 설정(브라우저 타깃을 좁힌 경우)을 대비해 원형도 함께 처리한다. */ 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)-/; /** * 컴파일된 CSS만 읽어 섹션을 구성한다. * * **매니페스트가 아니라 실제 스타일시트가 값의 출처다.** 토큰 SCSS를 고치면(원칙적으로 * 자동 생성물이라 고치면 안 되지만) 이 화면에 즉시 반영되므로, 생성물과 화면이 어긋날 * 여지가 없다. * 선언 **원문**을 읽는 것이 요점이다 — 계산값을 읽으면 참조(`var(--fox-…)`)가 이미 * 풀려 버리고, 현재 테마·뷰포트 한쪽 값만 보인다. 원문에는 light/dark와 PC/모바일이 * 모두 남아 있어 현재 화면 상태와 무관하게 네 조합을 전부 볼 수 있다. */ 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 뒤에 붙여 눈에 띄게 둔다. 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) ?? "")) }, ]; }