sqzass.git

sqzass

~ A static site generator written in Rust
git clone https://github.com/debuman/sqzass.git
commit bf1a9dcb0855c5d03db06ab3bcbb80ed1f63fb52
parent 06799ca4f8d7fe6fe3cb7b5580aa96cf0a41dad5
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Wed, 29 Jul 2026 10:59:37 +0900

구문 강조 엔진을 이중화한다 — 기본 Oniguruma, musl 아티팩트만 pure-rust

fancy-regex를 고른 원래 근거는 "빌드 타임 작업이라 속도차 무의미"였다.
1000페이지·전 블록 고유 코드 실측이 그 전제를 반증했다: 렌더 시간의
사실상 전부가 syntect 정규식이고, onig로 3.9s → 1.26s(3.1배)다.
minimal도 41ms → 20ms, 바이너리는 1.1MB 작아진다. musl 정적 링크를
깨는 건 여전히 C 바인딩이므로, 결정을 통째로 뒤집는 대신 깨지는
지점에만 원래 결정을 남긴다: 릴리스의 musl 잡만 --no-default-features
--features pure-rust로 빌드한다. 그 플래그가 유일한 가드다 — musl-gcc는
onig_sys 링크에 성공할 수도 있어서, 링크 실패는 순수 Rust를 지켜 주지
않는다.

엔진 피처는 [features] 경유로만 켠다. syntect의 엔진 피처를 직접 켜면
엔진=fancy + two-face 덤프=onig 어긋남이 컴파일은 되면서 일부 정규식을
조용히 평문으로 강등시킨다. 둘 다 켜지면 syntect·two-face 모두 onig로
수렴하므로(--all-features를 쓰는 AUR 경로 포함) 상호 배타 가드는 필요
없고, 피처 없음만 우리 이름으로 말하는 compile_error로 막는다.

두 덤프는 같지 않다. fancy 덤프는 문법 7개(PowerShell, JavaScript
(Babel), Salt State, ARM Assembly 등)를 통째로 빼므로 그 펜스는 정적
아티팩트에서 에러 없이 평문이 된다. ```js조차 두 빌드가 다른 문법
(Babel vs plain)으로 해석해 마크업 구조가 다르다. 이 차이는 버그가
아니라 installation.md가 지는 공개 계약이고, CI는 새 pure-rust 잡으로
엔진별 재현성만 지킨다 — 크로스 엔진 바이트 비교는 애초에 성립하지
않는다.

세 조합(기본/pure-rust/--all-features) 테스트 145개 전부 통과, 재작성 0.
onig 이중 빌드 결정성 확인. pure-rust여도 C 컴파일러는 필요하다 —
rustc가 링커 드라이버로 쓴다. 사라지는 것은 C 소스 컴파일이지 툴체인이
아니고, 문서도 정확히 그렇게 말한다.

Diffstat:
M.github/workflows/ci.yml | 31+++++++++++++++++++++++++++++++
M.github/workflows/release.yml | 13+++++++++----
MCargo.lock | 29+++++++++++++++++++++++++++++
MCargo.toml | 33+++++++++++++++++++--------------
Mdocs/content/start/installation.ko.md | 19+++++++++++++++++--
Mdocs/content/start/installation.md | 23++++++++++++++++++++---
Msrc/lib.rs | 7+++++++
7 files changed, 132 insertion(+), 23 deletion(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
@@ -47,3 +47,34 @@ jobs:
           cargo run --quiet -- build -i docs -o "$RUNNER_TEMP/a"
           cargo run --quiet -- build -i docs -o "$RUNNER_TEMP/b"
           diff -r "$RUNNER_TEMP/a" "$RUNNER_TEMP/b"
+
+  # musl 릴리스 아티팩트가 쓰는 pure-rust 엔진 조합이 썩지 않게 지킨다.
+  # 크로스 엔진 바이트 비교는 하지 않는다 — fancy 덤프에는 문법 7개
+  # (PowerShell·JS(Babel)·Salt State·ARM Assembly 등)가 통째로 없어서 해당
+  # 펜스가 평문 폴백이 되고 js 토큰 해석도 갈린다. 아티팩트 간 차이는 버그가
+  # 아니라 문서화된 계약이다(docs/content/start/installation.md). 결정성은
+  # 엔진별로 지킨다.
+  pure-rust:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v7
+
+      - uses: dtolnay/rust-toolchain@stable
+        with:
+          components: clippy
+
+      - uses: Swatinem/rust-cache@v2
+        with:
+          key: pure-rust
+
+      - name: Clippy
+        run: cargo clippy --all-targets --no-default-features --features pure-rust -- -D warnings
+
+      - name: Test
+        run: cargo test --no-default-features --features pure-rust
+
+      - name: Build is reproducible
+        run: |
+          cargo run --quiet --no-default-features --features pure-rust -- build -i docs -o "$RUNNER_TEMP/a"
+          cargo run --quiet --no-default-features --features pure-rust -- build -i docs -o "$RUNNER_TEMP/b"
+          diff -r "$RUNNER_TEMP/a" "$RUNNER_TEMP/b"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
@@ -21,13 +21,18 @@ jobs:
       fail-fast: false
       matrix:
         include:
-          # musl 정적 링크. 이게 되는지가 이 프로젝트의 의존성 선택을 지배했다 —
-          # syntect를 default-fancy(순수 Rust)로 고정한 이유가 Oniguruma의 C
-          # 바인딩이 여기서 깨지기 때문이다. 이 잡이 그 결정의 검증이다.
+          # musl 정적 링크. 정적 아티팩트만 pure-rust 엔진으로 빌드한다 —
+          # Oniguruma(C 바인딩)가 정적 링크를 깨는 지점이 여기라서다. 기본
+          # 빌드는 onig(코드 무거운 코퍼스 실측 3배)지만, 이 잡의
+          # --no-default-features가 onig를 컴파일 타임에 차단한다. musl-gcc가
+          # onig_sys 링크에 *성공*할 수도 있으므로, 링크 실패가 순수 Rust를
+          # 지켜줄 거라 기대하지 말 것 — 플래그가 유일한 가드다.
           - os: ubuntu-latest
             target: x86_64-unknown-linux-musl
+            flags: --no-default-features --features pure-rust
           - os: macos-latest
             target: aarch64-apple-darwin
+            flags: ""
 
     runs-on: ${{ matrix.os }}
     steps:
@@ -46,7 +51,7 @@ jobs:
         run: sudo apt-get update && sudo apt-get install -y musl-tools
 
       - name: Build
-        run: cargo build --release --locked --target ${{ matrix.target }}
+        run: cargo build --release --locked --target ${{ matrix.target }} ${{ matrix.flags }}
 
       # 빌드된 바이너리가 실제로 사이트를 만드는지 본다. 링크만 성공하고 런타임에
       # 죽는 조합이 있으므로(정적 빌드에서 특히), 여기서 한 번 돌려 본다.
diff --git a/Cargo.lock b/Cargo.lock
@@ -874,6 +874,28 @@ version = "1.70.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
 
+[[package]]
+name = "onig"
+version = "6.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2"
+dependencies = [
+ "bitflags",
+ "libc",
+ "once_cell",
+ "onig_sys",
+]
+
+[[package]]
+name = "onig_sys"
+version = "69.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7"
+dependencies = [
+ "cc",
+ "pkg-config",
+]
+
 [[package]]
 name = "percent-encoding"
 version = "2.3.2"
@@ -925,6 +947,12 @@ version = "0.2.17"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
 
+[[package]]
+name = "pkg-config"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+
 [[package]]
 name = "plist"
 version = "1.10.0"
@@ -1282,6 +1310,7 @@ dependencies = [
  "flate2",
  "fnv",
  "once_cell",
+ "onig",
  "plist",
  "regex-syntax",
  "serde",
diff --git a/Cargo.toml b/Cargo.toml
@@ -27,7 +27,8 @@ blake3 = "1"
 clap      = { version = "4.6", features = ["derive"] }
 # ⚠️ comrak의 default = ["cli", "syntect-onig", "bon"] 이다. 그대로 쓰면
 #    (1) comrak 자체 CLI 의존성(clap/shell-words/xdg/fmt2io)이 딸려오고
-#    (2) syntect가 **Oniguruma(C 바인딩)**로 들어와 musl 정적 빌드/크로스컴파일이 깨진다.
+#    (2) syntect가 comrak 경유로 무조건 Oniguruma에 고정된다 — 엔진 선택은
+#        아래 [features]의 몫이라(musl 아티팩트는 pure-rust여야 한다) 차단한다.
 #    SyntaxHighlighterAdapter / HeadingAdapter 트레이트는 cfg 게이트가 없어 피처 없이도
 #    쓸 수 있고, 피처 뒤에 있는 건 comrak 내장 SyntectAdapter뿐인데 그건 우리가 직접
 #    구현하므로(업스트림 #330이 재작업 대상으로 지적) 필요 없다.
@@ -37,20 +38,17 @@ notify = "8.2"
 notify-debouncer-full = "0.7"
 serde     = { version = "1", features = ["derive"] }
 serde_json = "1"
-syntect = { version = "5.3", default-features = false, features = ["default-fancy"] }
+# 구문 강조 엔진은 [features]에서 고른다. 여기서는 둘 다 피처를 비워 둔다 —
+# syntect의 엔진 피처를 직접 켜면 엔진=fancy + two-face 덤프=onig 같은 어긋남이
+# 가능하고, 그 조합은 컴파일은 되지만 일부 정규식에서 조용히 평문으로 강등된다.
+syntect = { version = "5.3", default-features = false }
 tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "signal"] }
 toml      = "1.1"
 tower-http = { version = "0.7", features = ["fs"] }
-two-face = { version = "0.5", default-features = false, features = ["syntect-fancy"] }
+two-face = { version = "0.5", default-features = false }
 walkdir   = "2"
 
 # --- M1 (아래는 해당 기능 착수할 때 주석 해제) ---
-# syntect 기본 피처는 regex-onig(C 바인딩)이라 musl 정적 빌드/크로스컴파일이 깨진다.
-# default-fancy = 순수 Rust. 빌드 타임 작업이라 속도 차이는 무의미하다.
-# syntect  = { version = "5.3", default-features = false, features = ["default-fancy"] }
-# two-face는 syntect 기본 세트에 없는 TOML/TypeScript/Dockerfile을 채운다(문서 사이트 필수).
-# 피처를 안 맞추면 onig가 다시 딸려오므로 syntect-fancy를 반드시 명시할 것.
-# two-face = { version = "0.5", default-features = false, features = ["syntect-fancy"] }
 # blake3   = "1"                                    # 에셋 콘텐츠 해시
 # axum     = "0.8"                                  # 개발 서버
 # tower-http = { version = "0.7", features = ["fs"] }
@@ -69,15 +67,22 @@ walkdir   = "2"
 #    2000페이지 한국어 코퍼스 실측: 정답 1018건 → 27건.
 # pagefind = { version = "1.5", default-features = false, optional = true }
 
-# --- M3 이후 ---
-# rayon = "1"   # 병렬 렌더. 문서 사이트 ~60페이지엔 불필요하고, 출력 URL 충돌 시
-#               # 비결정성 문제를 같이 떠안게 된다. 페이지가 수백 개가 되면 그때.
-
 [dev-dependencies]
 insta = "1"
 
 [features]
-default = []
+# 구문 강조의 정규식 엔진. 기본은 Oniguruma(C) — fancy 대비 코드 무거운 코퍼스
+# 실측 3.0배(1000p 전 블록 고유: 3.9s → 1.3s). musl 정적 아티팩트만 pure-rust로
+# 빌드한다(release.yml의 --no-default-features --features pure-rust — C 바인딩이
+# 정적 링크를 깨는 지점이 거기라서다). 둘 다 켜지면 syntect·two-face 모두 onig로
+# 수렴하므로(syntect regex.rs에 명시된 우선순위) --all-features(AUR)도 안전하다.
+# 두 덤프는 같지 않다: fancy 덤프는 문법 7개(PowerShell·JS(Babel)·Salt State·
+# ARM Assembly 등)를 통째로 빼서 해당 펜스가 평문 폴백이 되고, js 토큰조차
+# 두 빌드가 다른 문법(Babel vs plain JS)으로 해석한다 — installation.md에
+# 문서화된 계약이고, 같은 바이너리 안에서는 여전히 결정적이다.
+default = ["onig"]
+onig = ["syntect/default-onig", "two-face/syntect-onig"]
+pure-rust = ["syntect/default-fancy", "two-face/syntect-fancy"]
 # search = ["dep:pagefind"]
 
 [profile.release]
diff --git a/docs/content/start/installation.ko.md b/docs/content/start/installation.ko.md
@@ -23,8 +23,16 @@ cargo build --release
 | 요구사항 | 버전 |
 |---|---|
 | Rust | 1.97 이상 |
+| C 컴파일러 | rustc가 링커 구동에 씁니다. 기본 빌드는 구문 강조의 정규식 엔진인 Oniguruma도 이걸로 컴파일합니다. |
 | 그 외 | — |
 
+정적 릴리스가 싣는 구성 그대로 — 순수 Rust 정규식 엔진, C 소스 컴파일
+없음 — 는 플래그 하나 거리입니다:
+
+```bash
+cargo build --release --no-default-features --features pure-rust
+```
+
 ## 미리 빌드된 바이너리
 
 [릴리스](https://github.com/sqzer-x/sqzass/releases)마다 정적 링크된
@@ -38,8 +46,15 @@ sudo install -m755 sqzass-*/sqzass /usr/local/bin/
 ```
 
 리눅스 빌드는 정적이라 glibc를 요구하지 않고, 빌드한 배포판보다 오래된 배포판에서도
-돕니다. 이 프로젝트가 syntect를 순수 Rust 정규식 엔진으로 고정한 이유가 이것입니다.
-기본값은 C 바인딩이고, C 바인딩은 정확히 musl 정적 빌드를 깨뜨립니다.
+돕니다. 순수 Rust 정규식 엔진을 쓰는 유일한 빌드이기도 합니다 — 다른 모든 곳에서
+sqzass는 코드가 많은 사이트에서 눈에 띄게 빠른 Oniguruma로 강조하는데, Oniguruma는
+C 바인딩이고 C 바인딩은 정확히 musl 정적 빌드를 깨뜨립니다. 두 엔진의 문법 집합도
+같지 않습니다. 순수 Rust 엔진이 일부 문법의 정규식을 못 돌리기 때문에 정적
+아티팩트에는 문법 일곱 개가 통째로 빠집니다 — PowerShell, JavaScript (Babel),
+Salt State, ARM Assembly 등이요. 네이티브 빌드가 온전히 칠하는 ` ```powershell `
+이나 ` ```jsx ` 펜스가 정적 아티팩트에서는 에러 없이 평문이 됩니다. ` ```js `
+조차 마크업 구조가 다릅니다. 네이티브 빌드는 Babel 문법으로, 정적 빌드는 일반
+JavaScript로 해석합니다. 각 바이너리 자체는 여전히 완전히 결정적입니다.
 
 > [!NOTE]
 > 아직 릴리스가 없습니다 — 워크플로는 있고 첫 태그를 아직 붙이지 않았습니다.
diff --git a/docs/content/start/installation.md b/docs/content/start/installation.md
@@ -23,8 +23,16 @@ The binary lands at `target/release/sqzass`.
 | Requirement | Version |
 |---|---|
 | Rust | 1.97 or newer |
+| A C compiler | rustc drives the linker with it; the default build also compiles Oniguruma, the highlighter's regex engine, with it. |
 | Everything else | — |
 
+The exact configuration the static release ships — the pure-Rust regex
+engine, no C source compiled — is a feature flag away:
+
+```bash
+cargo build --release --no-default-features --features pure-rust
+```
+
 ## Prebuilt binaries
 
 Attached to each [release](https://github.com/sqzer-x/sqzass/releases): a
@@ -38,9 +46,18 @@ sudo install -m755 sqzass-*/sqzass /usr/local/bin/
 ```
 
 The Linux build is static, so it has no glibc requirement and runs on
-distributions older than the one it was built on. That is the reason this
-project pins syntect to its pure-Rust regex engine: the default one is a C
-binding, and a C binding is exactly what breaks a musl static build.
+distributions older than the one it was built on. It is also the one build
+that uses the pure-Rust regex engine: everywhere else sqzass highlights with
+Oniguruma, which is measurably faster on code-heavy sites, but Oniguruma is a
+C binding and a C binding is exactly what breaks a musl static build. The
+engines' grammar sets are not identical either: the pure-Rust engine cannot
+run a few grammars' regexes, so the static artifact drops seven of them
+entirely — PowerShell, JavaScript (Babel), Salt State and ARM Assembly among
+them. A ` ```powershell ` or ` ```jsx ` fence that a native build highlights
+in full falls back to plain text in the static artifact, without an error.
+Even ` ```js ` differs in markup structure: the native build resolves it to
+the Babel grammar, the static one to plain JavaScript. Each binary is still
+fully deterministic on its own.
 
 > [!NOTE]
 > There is no release yet — the workflow exists and the first tag has not been
diff --git a/src/lib.rs b/src/lib.rs
@@ -1,5 +1,12 @@
 //! sqzass — Rust로 만든 정적 사이트 생성기.
 
+// 엔진 피처 없이 빌드하면 two-face가 자기 피처 이름으로 에러를 내는데,
+// 사용자가 알아야 할 이름은 우리 것이다.
+#[cfg(not(any(feature = "onig", feature = "pure-rust")))]
+compile_error!(
+    "정규식 엔진 피처가 필요합니다: 기본(onig) 또는 --no-default-features --features pure-rust"
+);
+
 pub mod assets;
 pub mod config;
 pub mod content;