sqzass.git

sqzass

~ A static site generator written in Rust
git clone https://github.com/debuman/sqzass.git

src/links.rs (13568B)

      1 //! 내부 링크 해석.
      2 //!
      3 //! 마크다운에서 `@/start/installation.md` 처럼 **소스 파일**을 가리키면, 렌더 시점에
      4 //! 그 페이지의 실제 URL로 바뀐다. 파일을 옮기거나 슬러그를 바꿔도 링크가 따라온다.
      5 //!
      6 //! 해석은 comrak의 `link_url_rewriter`를 통해 **AST 단계에서** 일어난다. 최종 HTML에
      7 //! 정규식을 돌리지 않는다 — 그 방식은 속성을 홑따옴표로 쓰거나 따옴표를 빼면 조용히
      8 //! 처리에서 빠져서, 링크가 깨졌는지조차 알 수 없게 된다.
      9 
     10 use comrak::options::URLRewriter;
     11 use std::collections::BTreeMap;
     12 use std::sync::{Arc, Mutex};
     13 
     14 pub const PREFIX: &str = "@/";
     15 
     16 /// 언어별 URL 표. `translation_key` → (언어 → URL)
     17 pub type LinkIndex = BTreeMap<String, BTreeMap<String, String>>;
     18 
     19 /// 이 사이트가 실제로 내보내는 URL들. 루트 절대 경로 링크를 검증하는 데 쓴다.
     20 #[derive(Debug, Clone, Default)]
     21 pub struct KnownUrls {
     22     /// 페이지 URL (`/start/install/`)과 에셋 URL (`/css/main.abc.css`)
     23     pub urls: std::collections::BTreeSet<String>,
     24 }
     25 
     26 /// 표 두 개는 `Arc`다. 리졸버 자체는 페이지마다 새로 만들어야 하지만(`unresolved`가
     27 /// 페이지별 상태다), 표는 사이트 전체에서 같은 하나다. 값으로 들고 있던 시절에는
     28 /// 페이지마다 전 URL 집합이 통째로 복사돼 빌드 전체가 O(n²)였다 — 5000페이지
     29 /// 실측에서 hwaro보다 7배 느린 주범이 이 복사였다.
     30 pub struct Resolver {
     31     index: Arc<LinkIndex>,
     32     language: String,
     33     default_language: String,
     34     /// 해석하지 못한 링크. 렌더가 끝난 뒤 빌드를 실패시키는 데 쓴다.
     35     unresolved: Mutex<Vec<String>>,
     36     /// 이 사이트가 내보내는 URL 전체. 비어 있으면 절대 경로 검사를 건너뛴다.
     37     known: Arc<KnownUrls>,
     38 }
     39 
     40 impl Resolver {
     41     pub fn new(index: Arc<LinkIndex>, language: &str, default_language: &str) -> Self {
     42         Self {
     43             index,
     44             language: language.to_string(),
     45             default_language: default_language.to_string(),
     46             unresolved: Mutex::new(Vec::new()),
     47             known: Arc::default(),
     48         }
     49     }
     50 
     51     /// 루트 절대 경로 링크를 검증할 수 있게 사이트가 내보내는 URL 목록을 준다.
     52     ///
     53     /// `@/`만 검사하면 "깨진 참조는 빌드를 멈춘다"가 반쪽이 된다. 사람이 가장
     54     /// 자연스럽게 쓰는 건 `[설치](/start/install/)`인데, 그건 지금까지 아무도
     55     /// 검사하지 않았고 프로덕션에서 404가 됐다.
     56     pub fn with_known_urls(mut self, known: Arc<KnownUrls>) -> Self {
     57         self.known = known;
     58         self
     59     }
     60 
     61     /// 사이트 안을 가리키는 루트 절대 경로인지. 스킴·프로토콜 상대·`#`·`?`는 제외.
     62     fn is_local_absolute(url: &str) -> bool {
     63         url.starts_with('/') && !url.starts_with("//")
     64     }
     65 
     66     /// 링크가 실제로 존재하는 곳을 가리키는지.
     67     fn known_target(&self, url: &str) -> bool {
     68         // 프래그먼트와 쿼리를 떼고 본다. `/start/#설치`는 `/start/`를 가리킨다.
     69         let path = url.split(['#', '?']).next().unwrap_or(url);
     70         if path.is_empty() {
     71             return true; // `#anchor` 단독 — 같은 페이지 안이다.
     72         }
     73         if self.known.urls.contains(path) {
     74             return true;
     75         }
     76         // 디렉터리 URL은 슬래시가 있으나 없으나 같은 곳을 가리킨다. 호스트가
     77         // 리다이렉트해 주므로 죽은 링크는 아니다.
     78         let with_slash = format!("{path}/");
     79         self.known.urls.contains(&with_slash)
     80     }
     81 
     82     /// 해석에 실패한 링크들. 비어 있지 않으면 빌드를 멈춰야 한다.
     83     pub fn take_unresolved(&self) -> Vec<String> {
     84         self.unresolved
     85             .lock()
     86             .unwrap_or_else(|e| e.into_inner())
     87             .split_off(0)
     88     }
     89 
     90     fn resolve(&self, url: &str) -> Option<String> {
     91         let (path, fragment) = match url.split_once('#') {
     92             Some((p, f)) => (p, Some(f)),
     93             None => (url, None),
     94         };
     95         // 언어 접미사를 떼기 **전 이름을 먼저** 찾는다.
     96         //
     97         // 여기에는 선언된 언어 목록이 없어서 "짧은 ASCII 꼬리는 언어"라는 어림짐작을
     98         // 쓸 수밖에 없는데, 콘텐츠 쪽은 설정에 선언된 코드만 언어로 인정한다. 두 규칙이
     99         // 어긋나면 `notes.ab.md` 같은 파일이 키는 `notes.ab`로 잡히고 링크는 `notes`를
    100         // 찾아 빌드가 깨진다. 목록을 여기로 한 벌 더 복제하면 그 둘은 언젠가 또 갈라지므로,
    101         // 대신 두 형태를 차례로 본다 — 안 뗀 이름이 실제 키일 때만 먼저 걸린다.
    102         let raw = path.trim_start_matches(PREFIX).trim_start_matches('/');
    103         let raw_stem = raw.strip_suffix(".md").unwrap_or(raw);
    104         let by_lang = self.index.get(raw_stem).or_else(|| {
    105             self.index
    106                 .get(&translation_key(path.trim_start_matches(PREFIX)))
    107         })?;
    108 
    109         // 지금 페이지와 같은 언어판을 먼저 찾는다. 한국어 문서가
    110         // `@/start/installation.md` 라고 써도 한국어판으로 간다.
    111         let target = by_lang
    112             .get(&self.language)
    113             .or_else(|| by_lang.get(&self.default_language))?;
    114 
    115         Some(match fragment {
    116             Some(f) => format!("{target}#{f}"),
    117             None => target.clone(),
    118         })
    119     }
    120 }
    121 
    122 impl URLRewriter for Resolver {
    123     fn to_html(&self, url: &str) -> String {
    124         if !url.starts_with(PREFIX) {
    125             // `@/`가 아니어도 사이트 안을 가리키는 절대 경로라면 존재를 확인한다.
    126             if !self.known.urls.is_empty()
    127                 && Self::is_local_absolute(url)
    128                 && !self.known_target(url)
    129             {
    130                 self.unresolved
    131                     .lock()
    132                     .unwrap_or_else(|e| e.into_inner())
    133                     .push(url.to_string());
    134             }
    135             return url.to_string();
    136         }
    137         match self.resolve(url) {
    138             Some(resolved) => resolved,
    139             None => {
    140                 self.unresolved
    141                     .lock()
    142                     .unwrap_or_else(|e| e.into_inner())
    143                     .push(url.to_string());
    144                 // 원문을 그대로 둔다. 어차피 빌드가 실패할 것이고, 실패 메시지에
    145                 // 원래 쓴 문자열이 보이는 편이 찾기 쉽다.
    146                 url.to_string()
    147             }
    148         }
    149     }
    150 }
    151 
    152 /// `start/installation.md`, `start/installation.ko.md` → `start/installation`
    153 ///
    154 /// 언어 접미사를 떼는 이유: 같은 마크다운을 양쪽 언어판에서 그대로 쓰기 위해서다.
    155 /// 저자는 언어를 신경 쓰지 않고 소스 경로만 적으면 된다.
    156 fn translation_key(path: &str) -> String {
    157     let path = path.trim_start_matches('/');
    158     let stem = path.strip_suffix(".md").unwrap_or(path);
    159     match stem.rsplit_once('.') {
    160         // 마지막 조각이 짧으면 언어 코드로 본다. `my.file` 같은 이름을 언어로
    161         // 오인하지 않도록 길이로 거른다(ISO 639-1/2는 2~3자).
    162         Some((base, tail))
    163             if (2..=3).contains(&tail.len()) && tail.chars().all(|c| c.is_ascii_alphabetic()) =>
    164         {
    165             base.to_string()
    166         }
    167         _ => stem.to_string(),
    168     }
    169 }
    170 
    171 #[cfg(test)]
    172 mod language_suffix_tests {
    173     use super::*;
    174 
    175     fn index(pairs: &[(&str, &str)]) -> Arc<LinkIndex> {
    176         let mut idx = LinkIndex::new();
    177         for (key, url) in pairs {
    178             idx.entry((*key).to_string())
    179                 .or_default()
    180                 .insert("en".to_string(), (*url).to_string());
    181         }
    182         Arc::new(idx)
    183     }
    184 
    185     /// 콘텐츠 쪽은 **선언된** 언어 코드만 떼고, 링크 쪽은 목록을 갖고 있지 않다.
    186     /// 두 규칙이 어긋나면 멀쩡한 파일을 가리키는 링크가 빌드를 깨뜨린다.
    187     #[test]
    188     fn a_dotted_name_that_is_not_a_language_still_resolves() {
    189         let r = Resolver::new(index(&[("notes.ab", "/notes.ab/")]), "en", "en");
    190         assert_eq!(r.resolve("@/notes.ab.md"), Some("/notes.ab/".to_string()));
    191         assert!(r.take_unresolved().is_empty());
    192     }
    193 
    194     #[test]
    195     fn a_real_language_suffix_is_still_stripped() {
    196         let r = Resolver::new(index(&[("start/install", "/start/install/")]), "en", "en");
    197         assert_eq!(
    198             r.resolve("@/start/install.ko.md"),
    199             Some("/start/install/".to_string())
    200         );
    201         assert_eq!(
    202             r.resolve("@/start/install.md"),
    203             Some("/start/install/".to_string())
    204         );
    205     }
    206 
    207     #[test]
    208     fn fragments_survive_both_paths() {
    209         let r = Resolver::new(index(&[("notes.ab", "/notes.ab/")]), "en", "en");
    210         assert_eq!(
    211             r.resolve("@/notes.ab.md#설치"),
    212             Some("/notes.ab/#설치".to_string())
    213         );
    214     }
    215 }
    216 
    217 #[cfg(test)]
    218 mod absolute_link_tests {
    219     use super::*;
    220     use comrak::options::URLRewriter;
    221     use std::collections::BTreeSet;
    222 
    223     fn resolver(urls: &[&str]) -> Resolver {
    224         Resolver::new(Arc::default(), "en", "en").with_known_urls(Arc::new(KnownUrls {
    225             urls: urls
    226                 .iter()
    227                 .map(|s| (*s).to_string())
    228                 .collect::<BTreeSet<_>>(),
    229         }))
    230     }
    231 
    232     /// `@/`만 검사하면 "깨진 참조는 빌드를 멈춘다"가 반쪽이다. 사람이 가장
    233     /// 자연스럽게 쓰는 건 `[설치](/start/install/)`이고, 그건 검사된 적이 없었다.
    234     #[test]
    235     fn a_dead_absolute_path_is_unresolved() {
    236         let r = resolver(&["/about/"]);
    237         r.to_html("/nope/");
    238         assert_eq!(r.take_unresolved(), vec!["/nope/".to_string()]);
    239     }
    240 
    241     #[test]
    242     fn live_paths_anchors_and_external_urls_pass() {
    243         let r = resolver(&["/about/", "/images/x.png"]);
    244         for url in [
    245             "/about/",
    246             "/about/#section",
    247             "/about/?q=1",
    248             "/images/x.png",
    249             "#same-page",
    250             "https://example.com/x",
    251             // 프로토콜 상대 URL은 남의 호스트다. 우리 사이트가 아니다.
    252             "//cdn.example.com/x",
    253             "mailto:a@b.c",
    254         ] {
    255             r.to_html(url);
    256         }
    257         assert!(r.take_unresolved().is_empty());
    258     }
    259 
    260     /// 알려진 URL이 없으면(에셋 수집 전 등) 검사를 아예 하지 않는다.
    261     /// 반쯤 아는 상태로 판정하면 멀쩡한 링크를 죽었다고 말하게 된다.
    262     #[test]
    263     fn no_known_urls_means_no_checking() {
    264         let r = Resolver::new(Arc::default(), "en", "en");
    265         r.to_html("/anything/");
    266         assert!(r.take_unresolved().is_empty());
    267     }
    268 }
    269 
    270 #[cfg(test)]
    271 mod tests {
    272     use super::*;
    273 
    274     fn index() -> Arc<LinkIndex> {
    275         let mut i = LinkIndex::new();
    276         i.insert(
    277             "start/installation".into(),
    278             [
    279                 ("en".to_string(), "/start/installation/".to_string()),
    280                 ("ko".to_string(), "/ko/start/installation/".to_string()),
    281             ]
    282             .into(),
    283         );
    284         i.insert(
    285             "start/_index".into(),
    286             [("en".to_string(), "/start/".to_string())].into(),
    287         );
    288         Arc::new(i)
    289     }
    290 
    291     #[test]
    292     fn resolves_to_the_current_language() {
    293         let r = Resolver::new(index(), "ko", "en");
    294         assert_eq!(
    295             r.to_html("@/start/installation.md"),
    296             "/ko/start/installation/"
    297         );
    298         assert!(r.take_unresolved().is_empty());
    299     }
    300 
    301     #[test]
    302     fn falls_back_to_the_default_language() {
    303         // 한국어판이 없는 페이지를 한국어 문서에서 가리키면 영어판으로 간다.
    304         // 링크를 깨뜨리는 것보다 낫다.
    305         let r = Resolver::new(index(), "ko", "en");
    306         assert_eq!(r.to_html("@/start/_index.md"), "/start/");
    307     }
    308 
    309     #[test]
    310     fn keeps_the_fragment() {
    311         let r = Resolver::new(index(), "en", "en");
    312         assert_eq!(
    313             r.to_html("@/start/installation.md#requirements"),
    314             "/start/installation/#requirements"
    315         );
    316     }
    317 
    318     #[test]
    319     fn leaves_ordinary_urls_alone() {
    320         let r = Resolver::new(index(), "en", "en");
    321         for u in [
    322             "https://example.com",
    323             "/already/absolute/",
    324             "./relative",
    325             "#fragment",
    326             "mailto:a@b.c",
    327         ] {
    328             assert_eq!(r.to_html(u), u);
    329         }
    330         assert!(r.take_unresolved().is_empty());
    331     }
    332 
    333     #[test]
    334     fn records_unresolved_links_instead_of_shipping_them() {
    335         let r = Resolver::new(index(), "en", "en");
    336         r.to_html("@/does/not/exist.md");
    337         assert_eq!(r.take_unresolved(), vec!["@/does/not/exist.md".to_string()]);
    338     }
    339 
    340     #[test]
    341     fn language_suffixes_map_to_the_same_key() {
    342         assert_eq!(
    343             translation_key("start/installation.md"),
    344             "start/installation"
    345         );
    346         assert_eq!(
    347             translation_key("start/installation.ko.md"),
    348             "start/installation"
    349         );
    350         assert_eq!(translation_key("/start/_index.md"), "start/_index");
    351     }
    352 
    353     #[test]
    354     fn dotted_filenames_are_not_mistaken_for_languages() {
    355         // `notes.backup.md`의 "backup"은 언어 코드가 아니다.
    356         assert_eq!(translation_key("notes.backup.md"), "notes.backup");
    357         assert_eq!(translation_key("v1.2.md"), "v1.2");
    358     }
    359 }