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 }