"""Phosphor Icons(SVG) → @fox/core/icons 컴포넌트 변환기. 입력: $FOX_ICONS_SRC/assets/{weight}/*.svg (phosphor-icons/core 체크아웃, 기본 ~/Downloads/phosphor-core) 출력: @fox/core/icons/glyphs/*.tsx (아이콘 1개당 1파일) + fox-icon.tsx + index.ts 실행: python3 @fox/tools/build-icons.py 왜 Figma가 아니라 Phosphor 원본에서 뽑나 - 시안(`ico/*` 1,512종 × Format 2 × Weight 6)은 Phosphor를 그대로 올린 것이다. `ico/House` Regular와 Phosphor `house.svg`는 문 너비 16.67%·문 span 25%·벽 두께 8.33%가 전부 같다. Figma를 1,512번 호출하는 대신 같은 결과를 원본에서 얻는다. 이름은 PascalCase ↔ kebab-case로 1:1 대응한다. - 시안의 Format=Stroke/Outline은 편집용 구분(획 유지 / 획을 면으로 확장)이라 렌더 결과가 같다. Phosphor가 배포하는 SVG가 Outline 쪽이고, 기존 @fox 아이콘도 이 면 데이터를 쓰고 있었다. 규칙 - viewBox는 Phosphor 원본의 `0 0 256 256`을 그대로 쓴다. 기존 8종은 Figma export라 16/24 박스에 `translate`가 붙어 있었지만, 그건 256 뷰박스를 축소한 것과 같은 그림이다(House 기준 bbox x=32 → 32/256×16=2 = 기존 translate x와 일치). 즉 이 변경은 픽셀 동일하다. - path 개수를 가정하지 않는다 — fill 8종이 2~3개, duotone 2종(cell-signal-none·wifi-none)은 보조 path가 없다. - 크기도 색도 갖지 않는다. 감싼 쪽이 정한다(@fox 아이콘 규약). """ import os import re import shutil SRC = os.environ.get("FOX_ICONS_SRC", os.path.expanduser("~/Downloads/phosphor-core")) OUT = os.environ.get( "FOX_ICONS_OUT", os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "core", "icons"), ) # 시안 `Weight=` 변형과 같은 순서. regular만 파일명에 접미사가 없다. WEIGHTS = ["thin", "light", "regular", "bold", "fill", "duotone"] PATH_RE = re.compile(r"]*)>") ATTR_RE = re.compile(r'([a-z-]+)="([^"]*)"') # 예약어·기존 컴포넌트와 부딪히지 않게 접두사를 붙인다. `ico/ArrowSquareUp` → `FoxArrowSquareUpIcon`. def component_name(kebab): return "Fox" + "".join(p[:1].upper() + p[1:] for p in kebab.split("-")) + "Icon" def read_paths(weight, kebab): """한 SVG에서 (d, opacity) 목록을 순서대로 뽑는다.""" fname = f"{kebab}.svg" if weight == "regular" else f"{kebab}-{weight}.svg" full = os.path.join(SRC, "assets", weight, fname) if not os.path.exists(full): raise SystemExit(f"원본 없음: {full}") out = [] for raw in PATH_RE.findall(open(full, encoding="utf-8").read()): attrs = dict(ATTR_RE.findall(raw)) d = attrs.get("d") if not d: raise SystemExit(f"d 속성 없는 path: {full}") out.append((d, attrs.get("opacity"))) if not out: raise SystemExit(f"path가 없다: {full}") return out def ts_paths(paths): """[["M...", 0.2], ["M..."]] 형태의 TS 리터럴.""" items = [] for d, opacity in paths: # d에는 따옴표가 들어가지 않는다(숫자·문자 명령뿐) — 검증은 아래 assert가 한다. assert '"' not in d and "\\" not in d, "d에 이스케이프가 필요한 문자가 있다" items.append(f'["{d}"]' if opacity is None else f'["{d}", {opacity}]') return "[" + ", ".join(items) + "]" # ── 수집 ───────────────────────────────────────────────────────────────────── regular_dir = os.path.join(SRC, "assets", "regular") if not os.path.isdir(regular_dir): raise SystemExit( f"원본 디렉터리가 없다: {regular_dir}\n" "phosphor-icons/core 를 받아 FOX_ICONS_SRC로 지정하세요." ) names = sorted(f[:-4] for f in os.listdir(regular_dir) if f.endswith(".svg")) if not names: raise SystemExit("regular 아이콘을 하나도 못 찾았다.") glyph_dir = os.path.join(OUT, "glyphs") # 이름이 사라진 아이콘의 잔재가 남지 않도록 통째로 다시 만든다. if os.path.isdir(glyph_dir): shutil.rmtree(glyph_dir) os.makedirs(glyph_dir) GENERATED = "// 이 파일은 `python3 @fox/tools/build-icons.py`가 만든다. 손으로 고치지 않는다." path_count = 0 for kebab in names: comp = component_name(kebab) rows = [] for w in WEIGHTS: paths = read_paths(w, kebab) path_count += len(paths) rows.append(f" {w}: {ts_paths(paths)},") open(os.path.join(glyph_dir, f"{kebab}.tsx"), "w", encoding="utf-8").write( f"""{GENERATED} import {{ FoxIcon, type FoxGlyph, type FoxIconProps }} from "../fox-icon"; const GLYPH: FoxGlyph = {{ {chr(10).join(rows)} }}; /** 시안 `ico/{comp[3:-4]}`. */ export function {comp}(props: FoxIconProps) {{ return ; }} """ ) # ── 공용 렌더러 ─────────────────────────────────────────────────────────────── open(os.path.join(OUT, "fox-icon.tsx"), "w", encoding="utf-8").write( f"""{GENERATED} // // 모든 아이콘이 공유하는 껍데기. 아이콘은 **크기도 색도 갖지 않는다** — 감싼 쪽이 // `inline-size`/`block-size`로 정하고 `viewBox`가 따라 늘어나며, 색은 `fill: currentColor`라 // 감싼 쪽의 `color`를 쓴다. 장식 요소라 `aria-hidden`이며, 접근성 이름은 소비처가 책임진다. /** 시안의 `Weight=` 변형. */ export type FoxIconWeight = | "thin" | "light" | "regular" | "bold" | "fill" | "duotone"; /** `[d, opacity?]` — duotone의 배경 path만 opacity를 갖는다. */ export type FoxGlyphPath = readonly [d: string, opacity?: number]; export type FoxGlyph = Readonly>; export interface FoxIconProps {{ /** 기본 `regular`. 시안이 굵기를 지정한 자리에서만 바꾼다. */ weight?: FoxIconWeight; }} interface FoxIconInternalProps extends FoxIconProps {{ glyph: FoxGlyph; }} export function FoxIcon({{ weight = "regular", glyph }}: FoxIconInternalProps) {{ return ( ); }} """ ) # ── 배럴 ───────────────────────────────────────────────────────────────────── exports = "\n".join(f'export * from "./glyphs/{k}";' for k in names) open(os.path.join(OUT, "index.ts"), "w", encoding="utf-8").write( f"""{GENERATED} // // 아이콘 {len(names)}종. 배럴이 크지만 프로덕션 번들에는 실제 쓰는 글리프만 들어간다 // (검증: 안 쓰는 아이콘의 path 문자열이 프로덕션 `.js` 어디에도 없음). dev 빌드는 // 트리셰이킹을 하지 않아 청크가 커지지만 빌드 시간에 눈에 띄는 영향은 없었다. export {{ FoxIcon }} from "./fox-icon"; export type {{ FoxGlyph, FoxGlyphPath, FoxIconProps, FoxIconWeight, }} from "./fox-icon"; {exports} """ ) # MIT는 사본에 저작권 고지를 함께 두도록 요구한다. 원본과 같이 갱신되게 생성기가 옮긴다. license_src = os.path.join(SRC, "LICENSE") if not os.path.exists(license_src): raise SystemExit(f"원본 라이선스가 없다: {license_src}") shutil.copyfile(license_src, os.path.join(OUT, "PHOSPHOR-LICENSE.txt")) print(f"아이콘 {len(names)}종 × weight {len(WEIGHTS)} = {len(names) * len(WEIGHTS)}개 변형") print(f"path {path_count}개") print(f"출력: {glyph_dir}/*.tsx · {OUT}/fox-icon.tsx · {OUT}/index.ts")