www.git

www

~ Personal site — projects, posts, about. Built with sqzass.
git clone https://github.com/debuman/www.git

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")