File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
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<string, string>;
/** `@media (min-width: …)` 안의 오버라이드 — PC 값 */
pc: Map<string, string>;
}
function readRaw(): RawTokens {
const base = new Map<string, string>();
const pc = new Map<string, string>();
const collect = (rule: CSSStyleRule, into: Map<string, string>) => {
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) ?? "")) },
];
}