File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
"""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"<path\b([^>]*)>")
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 <FoxIcon {{...props}} glyph={{GLYPH}} />;
}}
"""
)
# ── 공용 렌더러 ───────────────────────────────────────────────────────────────
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<Record<FoxIconWeight, readonly FoxGlyphPath[]>>;
export interface FoxIconProps {{
/** 기본 `regular`. 시안이 굵기를 지정한 자리에서만 바꾼다. */
weight?: FoxIconWeight;
}}
interface FoxIconInternalProps extends FoxIconProps {{
glyph: FoxGlyph;
}}
export function FoxIcon({{ weight = "regular", glyph }}: FoxIconInternalProps) {{
return (
<svg viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
{{glyph[weight].map(([d, opacity], index) => (
<path key={{index}} d={{d}} opacity={{opacity}} />
))}}
</svg>
);
}}
"""
)
# ── 배럴 ─────────────────────────────────────────────────────────────────────
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")