tools/check.py (6837B)
1 #!/usr/bin/env python3
2 """빌드가 잡아 주지 않는 세 가지를 검사한다.
3
4 sqzass 는 마크다운의 `@/` 링크와 템플릿 변수와 에셋 이름을 전부 확인하고 틀리면
5 빌드를 세운다. 확인하지 않는 것은 `[extra]` 안이다 — 거기는 생성기가 읽지 않는
6 자유 테이블이라, 프로젝트 카탈로그의 url 오타는 조용히 배포된다.
7
8 i18n 도 마찬가지로 반쪽이다. 템플릿이 실제로 부르는 키가 없으면 빌드가 서지만,
9 어느 템플릿도 아직 안 부르는 키가 한쪽 언어에만 있는 것은 그냥 지나간다. 그
10 상태는 그 키를 처음 쓰는 날 빌드를 세운다.
11
12 세 번째는 템플릿이 손으로 쓴 링크다. sqzass 는 마크다운의 `@/` 는 확인해 주지만
13 `href="{{ home }}"` 처럼 템플릿이 조립한 주소는 확인하지 않는다. 한국어 홈을
14 없앤 날 로고 링크가 열한 장에서 `/ko/` 를 가리킨 채로 남았고, 빌드도 CI 도
15 아무 말을 하지 않았다. public/ 이 있을 때만 돈다 — 없으면 조용히 건너뛴다.
16
17 python3 tools/check.py
18 """
19
20 import re
21 import sys
22 import tomllib
23 import urllib.parse
24 from pathlib import Path
25
26 ROOT = Path(__file__).resolve().parent.parent
27 LANGS = {"en": "", "ko": "/ko"} # 코드 → URL 접두사
28 fails = []
29
30
31 def front_matter(path: Path) -> dict:
32 """`+++` 로 감싼 TOML 머리말만 떼어 읽는다."""
33 text = path.read_text(encoding="utf-8")
34 if not text.startswith("+++"):
35 return {}
36 end = text.find("+++", 3)
37 if end == -1:
38 fails.append(f"{path.relative_to(ROOT)}: 닫히지 않은 +++ 머리말")
39 return {}
40 return tomllib.loads(text[3:end])
41
42
43 def catalog_urls(fm: dict) -> set:
44 """카탈로그가 주장하는 프로젝트 URL 전부."""
45 extra = fm.get("extra", {})
46 urls = {item["url"] for item in extra.get("featured", [])}
47 for group in extra.get("group", []):
48 urls |= {item["url"] for item in group.get("item", [])}
49 return urls
50
51
52 def check_projects():
53 """카탈로그와 실제 페이지가 양방향으로 일치하는지."""
54 projects = ROOT / "content" / "projects"
55 for lang, prefix in LANGS.items():
56 suffix = ".ko.md" if lang == "ko" else ".md"
57 index = projects / ("_index.ko.md" if lang == "ko" else "_index.md")
58 if not index.exists():
59 fails.append(f"{index.relative_to(ROOT)} 가 없습니다")
60 continue
61
62 claimed = catalog_urls(front_matter(index))
63 actual = {
64 f"{prefix}/projects/{p.name[: -len(suffix)]}/"
65 for p in projects.glob(f"*{suffix}")
66 if not p.name.startswith("_index")
67 # en 패스에서 `foo.ko.md` 가 `foo.ko` 로 잡히지 않게 거른다.
68 and (lang == "ko" or not p.name.endswith(".ko.md"))
69 }
70
71 for url in sorted(claimed - actual):
72 fails.append(f"[{lang}] 카탈로그가 가리키는 페이지가 없습니다: {url}")
73 for url in sorted(actual - claimed):
74 fails.append(f"[{lang}] 페이지가 카탈로그에 없습니다: {url}")
75
76
77 def check_preview():
78 """홈의 미리보기·모자이크 카탈로그가 실제 페이지와 파일을 가리키는지.
79
80 이것도 [extra] 라 생성기가 안 읽는다. url 이 어긋나면 에러가 아니라 그냥
81 미리보기가 안 나오고, 그건 배포된 뒤에야 눈으로만 보인다.
82 """
83 for lang, prefix in LANGS.items():
84 index = ROOT / "content" / ("_index.ko.md" if lang == "ko" else "_index.md")
85 if not index.exists():
86 continue # 그 언어에 첫 화면이 없을 수 있다. 한국어가 그렇다.
87 extra = front_matter(index).get("extra", {})
88 for item in extra.get("mosaic", []):
89 slug = item["url"].rstrip("/").rsplit("/", 1)[-1]
90 suffix = ".ko.md" if lang == "ko" else ".md"
91 if not (ROOT / "content" / "projects" / f"{slug}{suffix}").exists():
92 fails.append(f"[{lang}] 모자이크가 가리키는 프로젝트가 없습니다: {item['url']}")
93 for key in ("cover", "mark"):
94 if item[key] and not (ROOT / "static" / item[key]).exists():
95 fails.append(f"[{lang}] 모자이크 {key} 파일이 없습니다: {item[key]}")
96
97 for item in extra.get("preview", []):
98 url = item["url"]
99 slug = url.rstrip("/").rsplit("/", 1)[-1]
100 suffix = ".ko.md" if lang == "ko" else ".md"
101 if not (ROOT / "content" / "posts" / f"{slug}{suffix}").exists():
102 fails.append(f"[{lang}] 미리보기가 가리키는 글이 없습니다: {url}")
103 for key in ("poster", "video"):
104 if not (ROOT / "static" / item[key]).exists():
105 fails.append(f"[{lang}] 미리보기 {key} 파일이 없습니다: {item[key]}")
106
107
108 def check_i18n():
109 """두 언어의 키 집합이 같은지. 다르면 그 키를 처음 쓰는 날 빌드가 선다."""
110 keys = {}
111 for lang in LANGS:
112 path = ROOT / "i18n" / f"{lang}.toml"
113 if not path.exists():
114 fails.append(f"{path.relative_to(ROOT)} 가 없습니다")
115 return
116 keys[lang] = set(tomllib.loads(path.read_text(encoding="utf-8")))
117
118 a, b = LANGS
119 for key in sorted(keys[a] - keys[b]):
120 fails.append(f"i18n: '{key}' 가 {a} 에만 있습니다")
121 for key in sorted(keys[b] - keys[a]):
122 fails.append(f"i18n: '{key}' 가 {b} 에만 있습니다")
123
124
125 def check_links():
126 """빌드된 HTML 의 내부 링크가 전부 실제 파일에 닿는지.
127
128 디렉터리가 있는 것과 페이지가 있는 것은 다르다 — public/ko/ 는 하위 갈래
129 때문에 존재하지만 그 안에 index.html 이 없으면 그 주소는 404 다. 처음 짠
130 검사는 exists() 를 써서 이걸 통과시켰고, 그래서 죽은 링크가 하나 배포됐다.
131 is_file() 로 묻는다.
132 """
133 pub = ROOT / "public"
134 if not pub.is_dir():
135 return
136
137 dead = {}
138 for f in pub.rglob("*.html"):
139 for href in re.findall(r'href="([^"#?]+)', f.read_text(encoding="utf-8")):
140 if href.startswith(("http", "mailto:", "#", "//")):
141 continue
142 t = pub / urllib.parse.unquote(href).lstrip("/")
143 if not (t.is_file() or (t / "index.html").is_file()):
144 dead.setdefault(href, set()).add(str(f.relative_to(pub)))
145
146 for href, pages in sorted(dead.items()):
147 where = ", ".join(sorted(pages)[:3]) + ("…" if len(pages) > 3 else "")
148 fails.append(f"죽은 내부 링크: {href} ({len(pages)}장: {where})")
149
150
151 check_projects()
152 check_preview()
153 check_i18n()
154 check_links()
155
156 if fails:
157 print("\n".join(fails), file=sys.stderr)
158 sys.exit(1)
159 print("ok")