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("&"),
+ '<' => out.push_str("<"),
+ '>' => out.push_str(">"),
+ '"' => out.push_str("""),
+ '\'' => out.push_str("'"),
+ _ => 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&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();
|