diff --git a/docs/templates/base.html b/docs/templates/base.html
@@ -1,7 +1,9 @@
{% from "partials/sidebar.html" import nav %}
{% from "partials/toc.html" import toc_list %}
{%- set ko = page.language == "ko" %}
-{%- set home = "/ko/" if ko else "/" %}
+{#- 서브경로에 올라갈 수도 있으므로 홈 링크에 site.base_path를 붙인다.
+ 페이지 URL들은 이미 이 접두사를 품고 있어서 손댈 필요가 없다. #}
+{%- set home = site.base_path ~ ("/ko/" if ko else "/") %}
<!doctype html>
{#- The default is written here rather than left to `prefers-color-scheme`, so
a reader with a light OS still gets the dark page the design assumes — and,
@@ -17,7 +19,7 @@
{#- `lang`이지 `t`가 아니다. `t`는 번역 함수이고, 루프 변수가 그 이름을 가리면
이 블록 안에서 라벨을 하나 넣는 순간 조용히 깨진다. #}
{%- for lang in page.translations %}
-<link rel="alternate" hreflang="{{ lang.code }}" href="{{ site.base_url }}{{ lang.url }}">
+<link rel="alternate" hreflang="{{ lang.code }}" href="{{ site.origin }}{{ lang.url }}">
{%- endfor %}
<link rel="stylesheet" href="{{ asset("css/main.css") }}">
{%- if site.highlight_css %}
@@ -47,7 +49,7 @@
<div class="tools">
<button id="search-trigger" class="search-trigger" type="button"
- data-index="/search-{{ page.language }}.json">
+ data-index="{{ site.base_path }}/search-{{ page.language }}.json">
{#- The label is replaced with the reader's own modifier by search.js;
this is the value someone with JavaScript off sees. #}
<span>{{ t("search") }}</span><kbd>Ctrl /</kbd>
diff --git a/src/assets.rs b/src/assets.rs
@@ -24,12 +24,18 @@ pub struct Assets {
pub files: BTreeMap<String, Vec<u8>>,
/// 논리 경로(`css/main.css`) → 출력 URL(`/css/main.a1b2c3d4.css`)
pub manifest: BTreeMap<String, String>,
+ /// 사이트가 도메인 루트가 아닐 때의 접두사. 페이지 URL과 같은 규칙을 따라야
+ /// `<link href>`와 내비게이션 링크가 같은 곳을 가리킨다.
+ base_path: String,
}
impl Assets {
/// `static/` 아래를 전부 읽어들인다.
- pub fn collect(root: &Path, cfg: &AssetsConfig) -> Result<Self> {
- let mut out = Self::default();
+ pub fn collect(root: &Path, cfg: &AssetsConfig, base_path: &str) -> Result<Self> {
+ let mut out = Self {
+ base_path: base_path.to_string(),
+ ..Self::default()
+ };
let dir = root.join(&cfg.source_dir);
if !dir.is_dir() {
return Ok(out);
@@ -64,8 +70,9 @@ impl Assets {
} else {
logical.to_string()
};
+ let base = &self.base_path;
self.manifest
- .insert(logical.to_string(), format!("/{out_path}"));
+ .insert(logical.to_string(), format!("{base}/{out_path}"));
self.files.insert(out_path, bytes);
}
@@ -186,18 +193,31 @@ mod tests {
std::fs::write(sdir.join("main.css"), "body{color:red}").unwrap();
std::fs::write(root.join("static/CNAME"), "example.com").unwrap();
- let a = Assets::collect(&root, &cfg()).unwrap();
+ let a = Assets::collect(&root, &cfg(), "").unwrap();
assert!(a.url("css/main.css").unwrap().starts_with("/css/main."));
assert_eq!(a.url("CNAME"), Some("/CNAME"));
assert_eq!(a.files.len(), 2);
+ // 서브경로에 올라가는 사이트는 에셋 URL도 같은 접두사를 가져야 한다.
+ // 페이지 링크만 접두사를 갖고 스타일시트는 안 갖는 상태가 가장 나쁘다:
+ // 사이트는 뜨는데 아무 스타일도 안 먹는다.
+ let sub = Assets::collect(&root, &cfg(), "/repo").unwrap();
+ assert!(
+ sub.url("css/main.css")
+ .unwrap()
+ .starts_with("/repo/css/main."),
+ "실제: {:?}",
+ sub.url("css/main.css")
+ );
+ assert_eq!(sub.url("CNAME"), Some("/repo/CNAME"));
+
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn missing_static_dir_is_not_an_error() {
let root = std::env::temp_dir().join("sqzass-assets-nonexistent-xyz");
- let a = Assets::collect(&root, &cfg()).unwrap();
+ let a = Assets::collect(&root, &cfg(), "").unwrap();
assert!(a.files.is_empty());
}
}
diff --git a/src/config.rs b/src/config.rs
@@ -9,8 +9,6 @@ use std::path::Path;
pub const CONFIG_FILE: &str = "sqzass.toml";
-/// `sqzass.toml`이 가질 수 있는 최상위 키. 오타 감지에 쓴다.
-
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
@@ -189,16 +187,74 @@ impl Config {
Ok(())
}
- /// `base_url`에서 뒤쪽 슬래시를 제거한 형태. URL 조립에 쓴다.
+ /// `base_url`에서 뒤쪽 슬래시를 제거한 형태.
pub fn base_url_trimmed(&self) -> &str {
self.base_url.trim_end_matches('/')
}
+
+ /// 스킴과 호스트만. `https://user.github.io/repo` → `https://user.github.io`
+ ///
+ /// 절대 URL(canonical, sitemap, OpenGraph)을 조립할 때 쓴다. 페이지 URL이 이미
+ /// 서브경로를 품고 있으므로 여기에 경로가 또 들어가면 두 번 붙는다.
+ pub fn origin(&self) -> &str {
+ let trimmed = self.base_url_trimmed();
+ // `://` 다음의 첫 슬래시가 경로의 시작이다. 스킴이 없으면 경로도 없다고 본다.
+ match trimmed.find("://") {
+ Some(i) => match trimmed[i + 3..].find('/') {
+ Some(j) => &trimmed[..i + 3 + j],
+ None => trimmed,
+ },
+ None => trimmed,
+ }
+ }
+
+ /// 사이트가 도메인 루트가 아닌 곳에 놓일 때의 경로. 루트면 빈 문자열.
+ ///
+ /// `https://user.github.io/repo` → `/repo`
+ ///
+ /// GitHub·GitLab·Codeberg의 **프로젝트 페이지가 기본으로 이 모양**이다. 도메인을
+ /// 따로 사지 않은 사람이 가장 먼저 만나는 형태이므로, 여기서 URL이 어긋나면
+ /// 사이트 전체가 404가 된다 — 빌드는 성공한 채로.
+ pub fn base_path(&self) -> &str {
+ let trimmed = self.base_url_trimmed();
+ let origin = self.origin();
+ trimmed.strip_prefix(origin).unwrap_or("")
+ }
}
#[cfg(test)]
mod tests {
use super::*;
+ /// 프로젝트 페이지(`user.github.io/repo`)는 도메인을 사지 않은 사람의 기본
+ /// 상황이다. 여기서 origin과 경로를 잘못 나누면 사이트 전체가 404가 된다.
+ #[test]
+ fn splits_origin_from_base_path() {
+ let cfg = |url: &str| -> Config {
+ toml::from_str(&format!("title = \"t\"\nbase_url = \"{url}\"\n")).unwrap()
+ };
+
+ let root = cfg("https://sqzass.sqzer.com");
+ assert_eq!(root.origin(), "https://sqzass.sqzer.com");
+ assert_eq!(root.base_path(), "", "루트 사이트에는 접두사가 없다");
+
+ let trailing = cfg("https://sqzass.sqzer.com/");
+ assert_eq!(trailing.origin(), "https://sqzass.sqzer.com");
+ assert_eq!(trailing.base_path(), "");
+
+ let project = cfg("https://user.github.io/repo");
+ assert_eq!(project.origin(), "https://user.github.io");
+ assert_eq!(project.base_path(), "/repo");
+
+ let nested = cfg("https://example.com/a/b/");
+ assert_eq!(nested.origin(), "https://example.com");
+ assert_eq!(nested.base_path(), "/a/b");
+
+ let port = cfg("http://localhost:3000/site");
+ assert_eq!(port.origin(), "http://localhost:3000");
+ assert_eq!(port.base_path(), "/site");
+ }
+
/// 조용히 무시되는 설정은 "깨진 참조는 빌드를 멈춘다"의 정확한 위반이다.
#[test]
fn unknown_keys_are_an_error() {
diff --git a/src/content.rs b/src/content.rs
@@ -219,10 +219,14 @@ fn build_url(name: &NameParts, slug: &str, cfg: &Config) -> (String, PathBuf) {
segments.push(slug.to_string());
}
+ // URL에는 서브경로가 들어가고 출력 경로에는 들어가지 않는다. 호스트가 서빙하는
+ // 루트가 곧 출력 디렉터리이고, 서브경로는 그 루트가 도메인 어디에 놓이는지의
+ // 문제이기 때문이다.
+ let base = cfg.base_path();
let url = if segments.is_empty() {
- "/".to_string()
+ format!("{base}/")
} else {
- format!("/{}/", segments.join("/"))
+ format!("{base}/{}/", segments.join("/"))
};
let mut out = PathBuf::new();
@@ -327,6 +331,24 @@ mod tests {
/// front matter 오타도 조용히 무시하지 않는다. 그리고 에러가 가리키는 줄은
/// **원본 파일의** 줄이어야 한다 — 펜스를 손으로 자른 이유가 그것이다.
+ /// URL에는 서브경로가 들어가고 출력 경로에는 들어가지 않는다. 호스트가 서빙하는
+ /// 루트가 곧 출력 디렉터리이기 때문이다.
+ #[test]
+ fn base_path_reaches_urls_but_not_output_paths() {
+ let cfg: Config =
+ toml::from_str("title = \"t\"\nbase_url = \"https://user.github.io/repo\"\n").unwrap();
+
+ let parts = NameParts::parse(Path::new("start/install.md"), &cfg);
+ let (url, out) = build_url(&parts, &parts.stem, &cfg);
+ assert_eq!(url, "/repo/start/install/");
+ assert_eq!(out, Path::new("start/install/index.html"));
+
+ let root = NameParts::parse(Path::new("_index.md"), &cfg);
+ let (url, out) = build_url(&root, &root.stem, &cfg);
+ assert_eq!(url, "/repo/");
+ assert_eq!(out, Path::new("index.html"));
+ }
+
#[test]
fn unknown_front_matter_key_errors_at_the_real_line() {
let dir = std::env::temp_dir().join(format!("sqzass-fm-{}", std::process::id()));
diff --git a/src/lib.rs b/src/lib.rs
@@ -89,7 +89,8 @@ fn render_site(opts: &BuildOptions, cfg: Config) -> Result<BuildOutput> {
let site = Site::build(&pages, &cfg);
// 에셋을 먼저 처리해야 템플릿이 해시가 붙은 최종 URL을 조회할 수 있다.
- let mut assets = assets::Assets::collect(root, &cfg.assets).map_err(Kind::Io.tag())?;
+ let mut assets =
+ assets::Assets::collect(root, &cfg.assets, cfg.base_path()).map_err(Kind::Io.tag())?;
let mut md =
markdown::Renderer::new(&cfg.markdown).with_links(site.link_index(), &cfg.default_language);
@@ -140,13 +141,13 @@ fn render_site(opts: &BuildOptions, cfg: Config) -> Result<BuildOutput> {
// `static/`에 같은 이름이 있으면 그쪽이 이긴다. 직접 넣은 robots.txt가
// 조용히 무시당하는 것보다는 생성을 건너뛰는 쪽이 낫다.
- let base = cfg.base_url_trimmed();
+ let origin = cfg.origin();
out.files
.entry(seo::SITEMAP_PATH.into())
- .or_insert_with(|| seo::sitemap(&pages, &site, base).into_bytes());
+ .or_insert_with(|| seo::sitemap(&pages, &site, origin).into_bytes());
out.files
.entry(seo::ROBOTS_PATH.into())
- .or_insert_with(|| seo::robots(base).into_bytes());
+ .or_insert_with(|| seo::robots(origin, cfg.base_path()).into_bytes());
// GitHub Pages는 이 파일이 없으면 출력을 Jekyll로 한 번 더 굴려서
// `_`로 시작하는 디렉터리를 통째로 삼킨다.
@@ -217,7 +218,8 @@ fn render_page(
let site_ctx = SiteCtx {
title: cfg.title.clone(),
description: cfg.description.clone(),
- base_url: cfg.base_url_trimmed().to_string(),
+ origin: cfg.origin().to_string(),
+ base_path: cfg.base_path().to_string(),
language: page.language.clone(),
sections: site::section_ctx(site.sections(&page.language), site.pages),
highlight_css: highlight_css.map(str::to_string),
@@ -257,7 +259,8 @@ fn render_page(
title: page.title.clone(),
description: page.front.description.clone(),
url: page.url.clone(),
- permalink: format!("{}{}", cfg.base_url_trimmed(), page.url),
+ // page.url이 서브경로를 품고 있으므로 origin만 붙인다.
+ permalink: format!("{}{}", cfg.origin(), page.url),
content: rendered.html,
weight: page.front.weight,
draft: page.front.draft,
@@ -273,6 +276,7 @@ fn render_page(
prev,
next,
is_section: page.is_section,
+ extra: page.front.extra.clone(),
};
let html = templates
diff --git a/src/render.rs b/src/render.rs
@@ -201,7 +201,12 @@ fn format_value(out: &mut Output, state: &State, value: &Value) -> Result<(), mi
pub struct SiteCtx {
pub title: String,
pub description: String,
- pub base_url: String,
+ /// 스킴과 호스트만. 절대 URL은 `site.origin ~ page.url`로 만든다 — 페이지 URL이
+ /// 이미 서브경로를 품고 있어서, 설정한 `base_url`을 그대로 붙이면 두 번 들어간다.
+ pub origin: String,
+ /// 사이트가 도메인 루트가 아닐 때의 경로 접두사. 루트면 빈 문자열.
+ /// 템플릿이 직접 쓰는 URL(홈 링크 등)에만 필요하다.
+ pub base_path: String,
/// 지금 렌더 중인 페이지의 언어.
pub language: String,
/// 이 언어의 최상위 섹션들. 사이드바는 여기서 만든다.
@@ -236,6 +241,8 @@ pub struct PageCtx {
pub next: Option<crate::site::PageRefCtx>,
/// 섹션 인덱스인지. 템플릿이 목록을 그릴지 판단한다.
pub is_section: bool,
+ /// front matter의 `[extra]`. sqzass는 여기를 읽지 않는다 — 템플릿이 읽는다.
+ pub extra: toml::Table,
}
#[cfg(test)]
diff --git a/src/seo.rs b/src/seo.rs
@@ -24,7 +24,7 @@ pub const ROBOTS_PATH: &str = "robots.txt";
/// `actions/checkout`은 기본이 얕은 클론이라, 조용히 모든 페이지가 같은 날짜를
/// 갖게 된다. 틀린 `lastmod`는 없는 것보다 나쁘다 — 구글은 부정확한 값을 보면
/// 그 사이트의 `lastmod`를 통째로 무시한다.
-pub fn sitemap(pages: &[Page], site: &Site, base_url: &str) -> String {
+pub fn sitemap(pages: &[Page], site: &Site, origin: &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\" \
@@ -33,7 +33,7 @@ pub fn sitemap(pages: &[Page], site: &Site, base_url: &str) -> String {
for page in pages {
out.push_str(" <url>\n <loc>");
- escape_into(&mut out, &format!("{base_url}{}", page.url));
+ escape_into(&mut out, &format!("{origin}{}", page.url));
out.push_str("</loc>\n");
// 번역이 있는 페이지만 대체 링크를 낸다. 혼자인 페이지에 자기 자신만
@@ -44,7 +44,7 @@ pub fn sitemap(pages: &[Page], site: &Site, base_url: &str) -> String {
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}"));
+ escape_into(&mut out, &format!("{origin}{url}"));
out.push_str("\"/>\n");
}
}
@@ -58,8 +58,9 @@ pub fn sitemap(pages: &[Page], site: &Site, base_url: &str) -> String {
/// 전부 허용하고 sitemap을 가리킨다.
///
/// 크롤러를 막고 싶으면 `static/robots.txt`에 직접 쓰면 된다.
-pub fn robots(base_url: &str) -> String {
- format!("User-agent: *\nAllow: /\n\nSitemap: {base_url}/{SITEMAP_PATH}\n")
+pub fn robots(origin: &str, base_path: &str) -> String {
+ // sitemap은 출력 루트에 있고, 출력 루트는 도메인의 `base_path` 아래에 놓인다.
+ format!("User-agent: *\nAllow: {base_path}/\n\nSitemap: {origin}{base_path}/{SITEMAP_PATH}\n")
}
/// XML 텍스트/속성값 이스케이프.
@@ -85,7 +86,7 @@ mod tests {
#[test]
fn robots_points_at_the_sitemap() {
- let r = robots("https://example.com");
+ let r = robots("https://example.com", "");
assert!(
r.contains("Sitemap: https://example.com/sitemap.xml"),
"{r}"
|