sqzass.git

sqzass

~ static site generator in Rust
git clone https://github.com/debuman/sqzass.git
commit 73afe06b646f7007b8caa61cb04174e9c30a05c7
parent ae9cb15306bc1e22374536950d2add7b875d9017
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Sat, 25 Jul 2026 22:18:38 +0900

Generate sitemap.xml and robots.txt, and link neighbouring pages

The sitemap carries `<loc>` and, for pages that exist in more than one
language, the `<xhtml:link>` alternate set — including the page's own language,
which is what makes the set valid rather than merely present. On a bilingual
site that is the part search engines actually use.

It carries no `priority` or `changefreq`: Google said in 2023 it ignores both,
and code that computes an ignored value is only an opportunity to be wrong.

It carries no `lastmod` either, and that took more thought. Every source for it
available here is untrustworthy. A file's mtime is its checkout time, so CI
would stamp the whole site as changed this morning, and it would break the
guarantee that two builds produce identical bytes. Git commit times are right
but need full history, and `actions/checkout` is shallow by default — so the
failure mode is not an error, it is every page quietly claiming the same date.
Google drops a site's `lastmod` entirely once it decides the values are wrong,
which makes a plausible-looking wrong one worse than an absent one.

Both files step aside for a `static/` file of the same name, and step aside by
not being generated rather than by being overwritten. Writing your own
robots.txt and having it silently replaced is the failure this avoids.

Neighbours are per-section on purpose. A reader who reaches the end of one part
has finished that part; walking them across the boundary into an unrelated one
implies the sections are chapters of a single ordered book, which they are not.
Section indexes have no neighbours at all — a section does not stand in line
beside its own children.

Diffstat:
Mdocs/content/deploy/_index.ko.md | 18++++++++++++++++++
Mdocs/content/deploy/_index.md | 20++++++++++++++++++++
Mdocs/content/templates/data.ko.md | 1+
Mdocs/content/templates/data.md | 1+
Mdocs/static/css/main.css | 47+++++++++++++++++++++++++++++++++++++++++++++++
Mdocs/templates/page.html | 22++++++++++++++++++++++
Msrc/lib.rs | 15+++++++++++++++
Msrc/render.rs | 3+++
Asrc/seo.rs | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/site.rs | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
10 files changed, 394 insertion(+), 0 deletion(-)

diff --git a/docs/content/deploy/_index.ko.md b/docs/content/deploy/_index.ko.md
@@ -25,6 +25,24 @@ rewrite도 없으므로, 그런 것에 기대는 게 생기면 여기서 가장
 보상은 호스트를 옮기는 일이 복사가 된다는 점입니다. 같은 디렉터리가 Cloudflare Pages
 에서도, Netlify에서도, CloudFront 뒤의 S3에서도, nginx에서도 그대로 맞습니다.
 
+## 그 밖에 만들어지는 것
+
+`sitemap.xml`에 모든 페이지가 들어가고, 두 개 이상의 언어에 존재하는 페이지에는
+`<xhtml:link>` 대체 링크가 붙습니다. 이중 언어 사이트에 대해 구글이 요구하는
+형태입니다. `robots.txt`는 전부 허용하고 sitemap을 가리킵니다.
+
+둘 다 `priority`와 `changefreq`를 담지 않습니다. 구글이 2023년에 둘 다 무시한다고
+확인했기 때문입니다. `lastmod`도 담지 않는데, 이건 선택입니다. 여기서 쓸 수 있는
+출처가 전부 믿을 만하지 않습니다. 파일 mtime은 체크아웃 시각이라 CI에서는 모든
+페이지가 "오늘 아침에 바뀌었다"고 주장하게 되고, 두 번 빌드하면 같은 바이트가
+나온다는 보장도 깨집니다. git 커밋 시각은 정확하지만 전체 이력이 필요한데
+`actions/checkout`은 기본이 얕은 클론이라, 조용히 모든 페이지에 같은 날짜를 찍게
+됩니다. 구글은 한 번 믿을 수 없다고 판단하면 그 사이트의 `lastmod`를 통째로
+무시합니다. 틀린 값이 없는 것보다 나쁘다는 뜻입니다.
+
+`sitemap.xml`이나 `robots.txt`를 직접 `static/`에 넣으면 sqzass는 그 파일을 아예
+만들지 않습니다. 당신 것을 덮어쓰지도, 조용히 무시하지도 않습니다.
+
 ## 알아 둘 파일 둘
 
 `.nojekyll`은 빌드마다 출력에 들어갑니다. 없으면 GitHub Pages가 출력을 Jekyll로 한 번
diff --git a/docs/content/deploy/_index.md b/docs/content/deploy/_index.md
@@ -26,6 +26,26 @@ would break here first.
 The payoff is that moving hosts is a copy. The same directory is correct on
 Cloudflare Pages, Netlify, S3 behind CloudFront, or nginx.
 
+## What else is written
+
+`sitemap.xml` lists every page, with `<xhtml:link>` alternates for pages that
+exist in more than one language — which is the form Google asks for when a site
+is bilingual. `robots.txt` allows everything and points at the sitemap.
+
+Neither carries `priority` or `changefreq`, because Google confirmed in 2023
+that it ignores both. Neither carries `lastmod` either, and that one is a
+choice: the honest sources for it are all unreliable here. A file's mtime is
+its checkout time, so in CI every page would claim to have changed this
+morning, and it would break the guarantee that two builds produce the same
+bytes. Git commit times are accurate but need full history, and
+`actions/checkout` clones shallow by default — so it would quietly stamp every
+page with the same date. Google ignores a site's `lastmod` entirely once it
+finds it untrustworthy, which makes a wrong one worse than none.
+
+Put your own `sitemap.xml` or `robots.txt` in `static/` and sqzass will not
+generate that file at all. It does not overwrite yours, and it does not
+silently ignore it either.
+
 ## Two files worth knowing about
 
 `.nojekyll` is written into every build. Without it GitHub Pages runs the
diff --git a/docs/content/templates/data.ko.md b/docs/content/templates/data.ko.md
@@ -37,6 +37,7 @@ toc = true
 | `page.toc_entries` | 목차 자체 — `{level, id, title, children}`, 중첩된 형태. |
 | `page.translations` | 이 페이지가 존재하는 언어만. 비어 있으면 전환 UI를 그리지 않으면 됩니다. |
 | `page.section` | 이 페이지가 속한 섹션. 최상위 페이지에는 없습니다. |
+| `page.prev`, `page.next` | **같은 섹션 안에서의** 이웃 페이지. |
 | `page.children` | 섹션의 자식 페이지들. 일반 페이지에서는 비어 있습니다. |
 | `page.is_section` | |
 | `page.extra` | 당신의 `[extra]` 테이블. |
diff --git a/docs/content/templates/data.md b/docs/content/templates/data.md
@@ -38,6 +38,7 @@ and `weight`.
 | `page.toc_entries` | The contents themselves — `{level, id, title, children}`, nested. |
 | `page.translations` | Only languages this page exists in. Empty means no switcher. |
 | `page.section` | The section this page belongs to, or nothing at the top level. |
+| `page.prev`, `page.next` | The neighbouring pages **within this section**. |
 | `page.children` | A section's own pages. Empty on ordinary pages. |
 | `page.is_section` | |
 | `page.extra` | Your `[extra]` table. |
diff --git a/docs/static/css/main.css b/docs/static/css/main.css
@@ -523,6 +523,53 @@ html[lang="ko"] .prose h4 {
   font-weight: 700;
 }
 
+/* ---------- pager ---------- */
+
+.pager {
+  display: flex;
+  gap: 1rem;
+  margin-top: 4rem;
+  padding-top: 1.5rem;
+  border-top: 1px solid var(--rule);
+}
+
+.pager a {
+  flex: 1;
+  display: block;
+  padding: 0.85rem 1rem;
+  border: 1px solid var(--rule);
+  border-radius: 4px;
+  color: inherit;
+  text-decoration: none;
+}
+.pager a:hover { border-color: var(--accent); }
+
+/* A single "next" sits on the right, where the reader is heading. */
+.pager-next { text-align: right; }
+.pager-next:only-child { margin-left: auto; flex: 0 1 auto; }
+
+.pager span {
+  display: block;
+  font-family: var(--mono);
+  font-size: 0.7rem;
+  letter-spacing: 0.08em;
+  text-transform: uppercase;
+  color: var(--ink-3);
+  margin-bottom: 0.2rem;
+}
+.pager strong { font-weight: 600; }
+
+html[lang="ko"] .pager span {
+  font-family: var(--sans);
+  letter-spacing: -0.01em;
+  text-transform: none;
+}
+
+@media (max-width: 40rem) {
+  .pager { flex-direction: column; }
+  .pager-next { text-align: left; }
+}
+
 /* ---------- search palette ----------
    A <dialog>. Escape, the backdrop, the focus trap and returning focus to the
    trigger are all the element's own behaviour; none of it is written here. */
diff --git a/docs/templates/page.html b/docs/templates/page.html
@@ -1,7 +1,29 @@
 {% extends "base.html" %}
+{%- set ko = page.language == "ko" %}
 
 {% block content %}
 <div class="prose">
 {{ page.content | safe }}
 </div>
+
+{#- Where to go when the page ends. Only within this section: a reader who
+    finishes the last page of one part has finished that part, and sending them
+    across the boundary into an unrelated one pretends the sections are a single
+    ordered book. #}
+{%- if page.prev or page.next %}
+<nav class="pager" aria-label="{{ "이어 보기" if ko else "Pagination" }}">
+  {%- if page.prev %}
+  <a class="pager-prev" href="{{ page.prev.url }}" rel="prev">
+    <span>{{ "이전" if ko else "Previous" }}</span>
+    <strong>{{ page.prev.title }}</strong>
+  </a>
+  {%- endif %}
+  {%- if page.next %}
+  <a class="pager-next" href="{{ page.next.url }}" rel="next">
+    <span>{{ "다음" if ko else "Next" }}</span>
+    <strong>{{ page.next.title }}</strong>
+  </a>
+  {%- endif %}
+</nav>
+{%- endif %}
 {% endblock %}
diff --git a/src/lib.rs b/src/lib.rs
@@ -8,6 +8,7 @@ pub mod links;
 pub mod markdown;
 pub mod render;
 pub mod search;
+pub mod seo;
 pub mod serve;
 pub mod site;
 
@@ -117,6 +118,16 @@ pub fn build_to_memory(opts: &BuildOptions) -> Result<BuildOutput> {
         out.files.insert(search::path(&language), json);
     }
 
+    // `static/`에 같은 이름이 있으면 그쪽이 이긴다. 직접 넣은 robots.txt가
+    // 조용히 무시당하는 것보다는 생성을 건너뛰는 쪽이 낫다.
+    let base = cfg.base_url_trimmed();
+    out.files
+        .entry(seo::SITEMAP_PATH.into())
+        .or_insert_with(|| seo::sitemap(&pages, &site, base).into_bytes());
+    out.files
+        .entry(seo::ROBOTS_PATH.into())
+        .or_insert_with(|| seo::robots(base).into_bytes());
+
     // GitHub Pages는 이 파일이 없으면 출력을 Jekyll로 한 번 더 굴려서
     // `_`로 시작하는 디렉터리를 통째로 삼킨다.
     out.files.insert(".nojekyll".into(), Vec::new());
@@ -198,6 +209,8 @@ fn render_page(
         site.section_ref_of(page)
     };
 
+    let (prev, next) = site.neighbours_of(page);
+
     let entry = search::Entry {
         t: page.title.clone(),
         d: page.front.description.clone(),
@@ -226,6 +239,8 @@ fn render_page(
         translations: site.translations_of(page),
         children: site.children_of(page),
         section,
+        prev,
+        next,
         is_section: page.is_section,
     };
 
diff --git a/src/render.rs b/src/render.rs
@@ -212,6 +212,9 @@ pub struct PageCtx {
     pub children: Vec<crate::site::PageRefCtx>,
     /// 이 페이지가 속한 섹션. 최상위 페이지에는 없다 — 브레드크럼이 쓴다.
     pub section: Option<crate::site::PageRefCtx>,
+    /// 같은 섹션의 이전/다음 페이지. 섹션이 정한 순서를 따른다.
+    pub prev: Option<crate::site::PageRefCtx>,
+    pub next: Option<crate::site::PageRefCtx>,
     /// 섹션 인덱스인지. 템플릿이 목록을 그릴지 판단한다.
     pub is_section: bool,
 }
diff --git a/src/seo.rs b/src/seo.rs
@@ -0,0 +1,155 @@
+//! `sitemap.xml`과 `robots.txt`.
+//!
+//! 둘 다 사이트를 빌드하면 그냥 생긴다. 크롤러가 이름으로 찾는 파일이라 이름이 곧
+//! 계약이고, 그래서 콘텐츠 해시를 붙이지 않는다.
+//!
+//! **`static/`에 같은 이름의 파일이 있으면 그쪽이 이긴다.** 생성물을 덮어쓰는 게
+//! 아니라 애초에 만들지 않는다 — 자기 `robots.txt`를 넣었는데 조용히 무시당하는
+//! 것만큼 나쁜 것도 없다.
+
+use crate::content::Page;
+use crate::site::Site;
+
+pub const SITEMAP_PATH: &str = "sitemap.xml";
+pub const ROBOTS_PATH: &str = "robots.txt";
+
+/// `<loc>`과 언어 대체 링크만 낸다.
+///
+/// **`priority`와 `changefreq`는 넣지 않는다.** 구글은 2023년에 둘 다 무시한다고
+/// 명시했고, 무시되는 값을 만드는 코드는 그냥 틀릴 기회일 뿐이다.
+///
+/// **`lastmod`도 넣지 않는다.** 믿을 만한 출처가 없기 때문이다. 파일 mtime은
+/// 체크아웃 시각이라 CI에서는 전부 "방금"이 되고, 빌드가 결정적이어야 한다는
+/// 원칙과도 어긋난다. git 커밋 시각은 정확하지만 전체 이력이 있어야 하는데
+/// `actions/checkout`은 기본이 얕은 클론이라, 조용히 모든 페이지가 같은 날짜를
+/// 갖게 된다. 틀린 `lastmod`는 없는 것보다 나쁘다 — 구글은 부정확한 값을 보면
+/// 그 사이트의 `lastmod`를 통째로 무시한다.
+pub fn sitemap(pages: &[Page], site: &Site, base_url: &str) -> String {
+    let mut out = String::from(
+        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
+         <urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\" \
+         xmlns:xhtml=\"http://www.w3.org/1999/xhtml\">\n",
+    );
+
+    for page in pages {
+        out.push_str("  <url>\n    <loc>");
+        escape_into(&mut out, &format!("{base_url}{}", page.url));
+        out.push_str("</loc>\n");
+
+        // 번역이 있는 페이지만 대체 링크를 낸다. 혼자인 페이지에 자기 자신만
+        // 가리키는 hreflang을 다는 건 바이트 낭비다.
+        let langs = site.language_set_of(page);
+        if langs.len() > 1 {
+            for (code, url) in langs {
+                out.push_str("    <xhtml:link rel=\"alternate\" hreflang=\"");
+                escape_into(&mut out, &code);
+                out.push_str("\" href=\"");
+                escape_into(&mut out, &format!("{base_url}{url}"));
+                out.push_str("\"/>\n");
+            }
+        }
+        out.push_str("  </url>\n");
+    }
+
+    out.push_str("</urlset>\n");
+    out
+}
+
+/// 전부 허용하고 sitemap을 가리킨다.
+///
+/// 크롤러를 막고 싶으면 `static/robots.txt`에 직접 쓰면 된다.
+pub fn robots(base_url: &str) -> String {
+    format!("User-agent: *\nAllow: /\n\nSitemap: {base_url}/{SITEMAP_PATH}\n")
+}
+
+/// XML 텍스트/속성값 이스케이프.
+///
+/// URL에는 쿼리가 붙을 수 있고 `&`는 XML에서 엔티티의 시작이다. 이걸 빼먹으면
+/// sitemap 전체가 파싱되지 않는데, 크롤러는 그 사실을 알려주지 않는다.
+fn escape_into(out: &mut String, s: &str) {
+    for ch in s.chars() {
+        match ch {
+            '&' => out.push_str("&amp;"),
+            '<' => out.push_str("&lt;"),
+            '>' => out.push_str("&gt;"),
+            '"' => out.push_str("&quot;"),
+            '\'' => out.push_str("&apos;"),
+            _ => out.push(ch),
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn robots_points_at_the_sitemap() {
+        let r = robots("https://example.com");
+        assert!(
+            r.contains("Sitemap: https://example.com/sitemap.xml"),
+            "{r}"
+        );
+    }
+
+    #[test]
+    fn escapes_ampersands() {
+        let mut s = String::new();
+        escape_into(&mut s, "https://example.com/a?b=1&c=2");
+        assert_eq!(s, "https://example.com/a?b=1&amp;c=2");
+    }
+
+    /// 번역이 있는 페이지에는 hreflang 세트를, 없는 페이지에는 `<loc>`만.
+    #[test]
+    fn sitemap_lists_every_page_and_its_translations() {
+        let cfg: crate::config::Config = toml::from_str(
+            r#"
+            title = "t"
+            base_url = "https://example.com"
+            default_language = "en"
+            [languages.en]
+            name = "English"
+            weight = 1
+            [languages.ko]
+            name = "한국어"
+            weight = 2
+            "#,
+        )
+        .unwrap();
+
+        let root = std::env::temp_dir().join(format!("sqzass-seo-{}", std::process::id()));
+        let _ = std::fs::remove_dir_all(&root);
+        for (rel, title) in [
+            ("_index.md", "Home"),
+            ("_index.ko.md", "홈"),
+            ("solo.md", "Solo"),
+        ] {
+            let path = root.join("content").join(rel);
+            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
+            std::fs::write(path, format!("+++\ntitle = \"{title}\"\n+++\n\nbody\n")).unwrap();
+        }
+        let pages = crate::content::discover(&root, &cfg, false).unwrap();
+        let site = Site::build(&pages, &cfg);
+        let xml = sitemap(&pages, &site, "https://example.com");
+        let _ = std::fs::remove_dir_all(&root);
+
+        assert_eq!(xml.matches("<loc>").count(), 3, "페이지 3개: {xml}");
+        assert!(xml.contains("<loc>https://example.com/</loc>"), "{xml}");
+        assert!(xml.contains("<loc>https://example.com/ko/</loc>"), "{xml}");
+        assert!(
+            xml.contains("<loc>https://example.com/solo/</loc>"),
+            "{xml}"
+        );
+
+        // 번역이 있는 홈은 자기 자신을 포함해 둘 다 나열해야 한다.
+        assert_eq!(xml.matches("hreflang=\"en\"").count(), 2, "{xml}");
+        assert_eq!(xml.matches("hreflang=\"ko\"").count(), 2, "{xml}");
+        // 혼자인 페이지에는 대체 링크가 없다.
+        let solo = xml.split("<url>").find(|u| u.contains("/solo/")).unwrap();
+        assert!(!solo.contains("xhtml:link"), "{solo}");
+
+        assert!(!xml.contains("priority"), "구글이 무시하는 값이다: {xml}");
+        assert!(!xml.contains("changefreq"), "구글이 무시하는 값이다: {xml}");
+        assert!(!xml.contains("lastmod"), "믿을 만한 출처가 없다: {xml}");
+    }
+}
diff --git a/src/site.rs b/src/site.rs
@@ -168,6 +168,57 @@ impl<'a> Site<'a> {
         out
     }
 
+    /// 같은 섹션 안에서의 (이전, 다음) 페이지. 섹션이 정한 순서를 그대로 따른다.
+    ///
+    /// 섹션 인덱스에는 이웃이 없다 — 섹션은 자기 자식들과 같은 줄에 서 있지 않다.
+    pub fn neighbours_of(&self, page: &Page) -> (Option<PageRefCtx>, Option<PageRefCtx>) {
+        if page.is_section {
+            return (None, None);
+        }
+        let Some(section) = self.section_of(page) else {
+            return (None, None);
+        };
+        let at = section
+            .pages
+            .iter()
+            .position(|&i| self.pages[i].url == page.url);
+        let Some(at) = at else {
+            return (None, None);
+        };
+
+        let make = |i: usize| {
+            let p = &self.pages[section.pages[i]];
+            PageRefCtx {
+                title: p.title.clone(),
+                description: p.front.description.clone(),
+                url: p.url.clone(),
+                weight: p.front.weight,
+            }
+        };
+        let prev = at.checked_sub(1).map(&make);
+        let next = (at + 1 < section.pages.len()).then(|| make(at + 1));
+        (prev, next)
+    }
+
+    /// 이 페이지가 존재하는 **모든** 언어판, 자기 자신 포함. `(언어 코드, URL)`.
+    ///
+    /// `translations_of`와 달리 자기 자신을 뺀 목록이 아니다. sitemap의 hreflang
+    /// 세트는 각 URL이 자기 자신까지 포함해 전부를 나열해야 유효하다.
+    pub fn language_set_of(&self, page: &Page) -> Vec<(String, String)> {
+        let Some(by_lang) = self.translations.get(&page.translation_key) else {
+            return Vec::new();
+        };
+        let mut out: Vec<(String, String, i64)> = by_lang
+            .iter()
+            .map(|(code, &idx)| {
+                let weight = self.cfg.languages.get(code).map_or(0, |l| l.weight);
+                (code.clone(), self.pages[idx].url.clone(), weight)
+            })
+            .collect();
+        out.sort_by(|a, b| (a.2, &a.0).cmp(&(b.2, &b.0)));
+        out.into_iter().map(|(c, u, _)| (c, u)).collect()
+    }
+
     /// 이 페이지의 다른 언어판. **번역이 실제로 있는 것만** 담는다.
     pub fn translations_of(&self, page: &Page) -> Vec<LanguageLink> {
         let Some(by_lang) = self.translations.get(&page.translation_key) else {
@@ -425,6 +476,67 @@ mod tests {
         format!("+++\ntitle = \"{title}\"\n{extra}+++\n\nbody\n")
     }
 
+    #[test]
+    fn neighbours_follow_the_section_order() {
+        let cfg = cfg();
+        let f = Fixture::new(
+            "neighbours",
+            &[
+                ("_index.md", &fm("Home", "")),
+                ("start/_index.md", &fm("Getting started", "weight = 10\n")),
+                ("start/a.md", &fm("A", "weight = 1\n")),
+                ("start/b.md", &fm("B", "weight = 2\n")),
+                ("start/c.md", &fm("C", "weight = 3\n")),
+            ],
+        );
+        let pages = f.pages(&cfg);
+        let site = Site::build(&pages, &cfg);
+        let by_title = |t: &str| pages.iter().find(|p| p.title == t).unwrap();
+
+        let (prev, next) = site.neighbours_of(by_title("B"));
+        assert_eq!(prev.map(|p| p.title), Some("A".into()));
+        assert_eq!(next.map(|p| p.title), Some("C".into()));
+
+        // 양 끝은 한쪽만 있다. 다른 섹션으로 넘어가지 않는다.
+        let (prev, next) = site.neighbours_of(by_title("A"));
+        assert!(prev.is_none(), "첫 페이지에 이전이 있다");
+        assert_eq!(next.map(|p| p.title), Some("B".into()));
+        let (prev, next) = site.neighbours_of(by_title("C"));
+        assert_eq!(prev.map(|p| p.title), Some("B".into()));
+        assert!(next.is_none(), "마지막 페이지에 다음이 있다");
+
+        // 섹션 인덱스는 자식들과 같은 줄에 서 있지 않다.
+        let (prev, next) = site.neighbours_of(by_title("Getting started"));
+        assert!(prev.is_none() && next.is_none());
+    }
+
+    #[test]
+    fn language_set_includes_the_page_itself() {
+        let cfg = cfg();
+        let f = Fixture::new(
+            "langset",
+            &[
+                ("_index.md", &fm("Home", "")),
+                ("_index.ko.md", &fm("홈", "")),
+                ("solo.md", &fm("Solo", "")),
+            ],
+        );
+        let pages = f.pages(&cfg);
+        let site = Site::build(&pages, &cfg);
+        let by_title = |t: &str| pages.iter().find(|p| p.title == t).unwrap();
+
+        // sitemap의 hreflang 세트는 자기 자신까지 나열해야 유효하다.
+        let set = site.language_set_of(by_title("Home"));
+        assert_eq!(
+            set,
+            vec![
+                ("en".to_string(), "/".to_string()),
+                ("ko".to_string(), "/ko/".to_string())
+            ]
+        );
+        assert_eq!(site.language_set_of(by_title("Solo")).len(), 1);
+    }
+
     #[test]
     fn builds_nested_sections_per_language() {
         let cfg = cfg();