serve: replace hand-built HTML with Tera templates
Add tera templating engine and move board markup into template files
(base, board, backlog panel, column panel, task partial). Templates
are embedded at compile time via include_str!. Tera's auto-escaping
replaces the manual html_escape helper.
change tuvlptuvroyusxukxxppqqkqnrnkqlxw
commit 1da709ffa85ec4dd77fef884019ea66dd1b668cf
author Alpha Chen <alpha@kejadlen.dev>
date
parent zyovnooq
diff --git a/Cargo.lock b/Cargo.lock
index 1d79cf0..9b4c46f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -32,6 +32,15 @@ version = "0.2.21"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
 
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
 [[package]]
 name = "anstream"
 version = "0.6.21"
@@ -232,6 +241,12 @@ dependencies = [
  "serde",
 ]
 
+[[package]]
+name = "bumpalo"
+version = "3.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
+
 [[package]]
 name = "byteorder"
 version = "1.5.0"
@@ -260,6 +275,39 @@ version = "1.0.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
 
+[[package]]
+name = "chrono"
+version = "0.4.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
+dependencies = [
+ "iana-time-zone",
+ "num-traits",
+ "windows-link",
+]
+
+[[package]]
+name = "chrono-tz"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb"
+dependencies = [
+ "chrono",
+ "chrono-tz-build",
+ "phf",
+]
+
+[[package]]
+name = "chrono-tz-build"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1"
+dependencies = [
+ "parse-zoneinfo",
+ "phf",
+ "phf_codegen",
+]
+
 [[package]]
 name = "clap"
 version = "4.5.60"
@@ -360,6 +408,12 @@ version = "0.9.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
 
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
 [[package]]
 name = "cpufeatures"
 version = "0.2.17"
@@ -384,6 +438,25 @@ version = "2.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
 
+[[package]]
+name = "crossbeam-deque"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
+dependencies = [
+ "crossbeam-epoch",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-epoch"
+version = "0.9.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
+dependencies = [
+ "crossbeam-utils",
+]
+
 [[package]]
 name = "crossbeam-queue"
 version = "0.3.12"
@@ -420,6 +493,12 @@ dependencies = [
  "zeroize",
 ]
 
+[[package]]
+name = "deunicode"
+version = "1.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04"
+
 [[package]]
 name = "difflib"
 version = "0.4.0"
@@ -670,6 +749,30 @@ version = "0.32.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
 
+[[package]]
+name = "globset"
+version = "0.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3"
+dependencies = [
+ "aho-corasick",
+ "bstr",
+ "log",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "globwalk"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757"
+dependencies = [
+ "bitflags",
+ "ignore",
+ "walkdir",
+]
+
 [[package]]
 name = "hashbrown"
 version = "0.15.5"
@@ -780,6 +883,15 @@ version = "1.0.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
 
+[[package]]
+name = "humansize"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7"
+dependencies = [
+ "libm",
+]
+
 [[package]]
 name = "hyper"
 version = "1.8.1"
@@ -816,6 +928,30 @@ dependencies = [
  "tower-service",
 ]
 
+[[package]]
+name = "iana-time-zone"
+version = "0.1.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
 [[package]]
 name = "icu_collections"
 version = "2.1.1"
@@ -924,6 +1060,22 @@ dependencies = [
  "icu_properties",
 ]
 
+[[package]]
+name = "ignore"
+version = "0.4.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a"
+dependencies = [
+ "crossbeam-deque",
+ "globset",
+ "log",
+ "memchr",
+ "regex-automata",
+ "same-file",
+ "walkdir",
+ "winapi-util",
+]
+
 [[package]]
 name = "indenter"
 version = "0.3.4"
@@ -1004,6 +1156,16 @@ dependencies = [
  "jiff-tzdb",
 ]
 
+[[package]]
+name = "js-sys"
+version = "0.3.91"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
+dependencies = [
+ "once_cell",
+ "wasm-bindgen",
+]
+
 [[package]]
 name = "lazy_static"
 version = "1.5.0"
@@ -1255,6 +1417,15 @@ dependencies = [
  "windows-link",
 ]
 
+[[package]]
+name = "parse-zoneinfo"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24"
+dependencies = [
+ "regex",
+]
+
 [[package]]
 name = "pem-rfc7468"
 version = "0.7.0"
@@ -1270,6 +1441,87 @@ version = "2.3.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
 
+[[package]]
+name = "pest"
+version = "2.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662"
+dependencies = [
+ "memchr",
+ "ucd-trie",
+]
+
+[[package]]
+name = "pest_derive"
+version = "2.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77"
+dependencies = [
+ "pest",
+ "pest_generator",
+]
+
+[[package]]
+name = "pest_generator"
+version = "2.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f"
+dependencies = [
+ "pest",
+ "pest_meta",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "pest_meta"
+version = "2.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220"
+dependencies = [
+ "pest",
+ "sha2",
+]
+
+[[package]]
+name = "phf"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
+dependencies = [
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_codegen"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
+dependencies = [
+ "phf_shared",
+ "rand",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
+dependencies = [
+ "siphasher",
+]
+
 [[package]]
 name = "pin-project-lite"
 version = "0.2.17"
@@ -1458,6 +1710,7 @@ dependencies = [
  "serde_json",
  "sqlx",
  "tempfile",
+ "tera",
  "thiserror",
  "tokio",
  "tracing-subscriber",
@@ -1550,12 +1803,27 @@ dependencies = [
  "windows-sys 0.61.2",
 ]
 
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
 [[package]]
 name = "ryu"
 version = "1.0.23"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
 
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
 [[package]]
 name = "scopeguard"
 version = "1.2.0"
@@ -1691,12 +1959,28 @@ dependencies = [
  "rand_core",
 ]
 
+[[package]]
+name = "siphasher"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
+
 [[package]]
 name = "slab"
 version = "0.4.12"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
 
+[[package]]
+name = "slug"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724"
+dependencies = [
+ "deunicode",
+ "wasm-bindgen",
+]
+
 [[package]]
 name = "smallvec"
 version = "1.15.1"
@@ -1993,6 +2277,28 @@ dependencies = [
  "windows-sys 0.61.2",
 ]
 
+[[package]]
+name = "tera"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8004bca281f2d32df3bacd59bc67b312cb4c70cea46cbd79dbe8ac5ed206722"
+dependencies = [
+ "chrono",
+ "chrono-tz",
+ "globwalk",
+ "humansize",
+ "lazy_static",
+ "percent-encoding",
+ "pest",
+ "pest_derive",
+ "rand",
+ "regex",
+ "serde",
+ "serde_json",
+ "slug",
+ "unicode-segmentation",
+]
+
 [[package]]
 name = "termtree"
 version = "0.5.1"
@@ -2198,6 +2504,12 @@ version = "1.19.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
 
+[[package]]
+name = "ucd-trie"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
+
 [[package]]
 name = "unicode-bidi"
 version = "0.3.18"
@@ -2225,6 +2537,12 @@ version = "0.1.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
 
+[[package]]
+name = "unicode-segmentation"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
+
 [[package]]
 name = "unicode-xid"
 version = "0.2.6"
@@ -2282,6 +2600,16 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
 [[package]]
 name = "wasi"
 version = "0.11.1+wasi-snapshot-preview1"
@@ -2312,6 +2640,51 @@ version = "0.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
 
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.114"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.114"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.114"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.114"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
+dependencies = [
+ "unicode-ident",
+]
+
 [[package]]
 name = "wasm-encoder"
 version = "0.244.0"
@@ -2356,12 +2729,74 @@ dependencies = [
  "wasite",
 ]
 
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "windows-link"
 version = "0.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
 
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
 [[package]]
 name = "windows-sys"
 version = "0.48.0"
diff --git a/Cargo.toml b/Cargo.toml
index c448719..4308770 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -18,6 +18,7 @@ rand = "*"
 serde = { version = "*", features = ["derive"] }
 serde_json = "*"
 sqlx = { version = "*", features = ["runtime-tokio", "sqlite", "migrate"] }
+tera = "*"
 thiserror = "*"
 tokio = { version = "*", features = ["full"] }
 xdg = "*"
diff --git a/src/bin/ranger/commands/serve.rs b/src/bin/ranger/commands/serve.rs
index 5113e22..fb76d05 100644
--- a/src/bin/ranger/commands/serve.rs
+++ b/src/bin/ranger/commands/serve.rs
@@ -6,23 +6,51 @@ use ranger::key;
 use ranger::models::Task;
 use ranger::ops;
 use ranger::ops::task::ListFilter;
+use serde::Serialize;
 use sqlx::SqlitePool;
 use std::net::SocketAddr;
+use std::sync::Arc;
+use tera::{Context, Tera};
 use tokio::net::TcpListener;
 
 /// Static CSS embedded at compile time from `static/style.css`.
 const STYLE_CSS: &str = include_str!("../../../../static/style.css");
 
+/// Raw template strings embedded at compile time.
+const TEMPLATES: &[(&str, &str)] = &[
+    ("base.html", include_str!("../../../../templates/base.html")),
+    (
+        "board.html",
+        include_str!("../../../../templates/board.html"),
+    ),
+    (
+        "panels/backlog.html",
+        include_str!("../../../../templates/panels/backlog.html"),
+    ),
+    (
+        "panels/column.html",
+        include_str!("../../../../templates/panels/column.html"),
+    ),
+    ("task.html", include_str!("../../../../templates/task.html")),
+];
+
 #[derive(Clone)]
 struct AppState {
     pool: SqlitePool,
     backlog_name: String,
+    tera: Arc<Tera>,
 }
 
 pub async fn run(pool: &SqlitePool, port: u16, backlog_name: String) -> color_eyre::Result<()> {
+    let mut tera = Tera::default();
+    for &(name, content) in TEMPLATES {
+        tera.add_raw_template(name, content)?;
+    }
+
     let state = AppState {
         pool: pool.clone(),
         backlog_name,
+        tera: Arc::new(tera),
     };
 
     let app = Router::new()
@@ -52,6 +80,7 @@ async fn index(State(state): State<AppState>) -> Html<String> {
     }
 }
 
+#[derive(Serialize)]
 struct TaskView {
     key_prefix: String,
     key_rest: String,
@@ -62,6 +91,13 @@ struct TaskView {
     done_subtask_count: usize,
 }
 
+#[derive(Serialize)]
+struct ColumnView {
+    label: String,
+    state_class: String,
+    tasks: Vec<TaskView>,
+}
+
 async fn render_board(state: &AppState) -> color_eyre::Result<String> {
     let mut conn = state.pool.acquire().await?;
     let backlog = ops::backlog::get_by_name(&mut conn, &state.backlog_name).await?;
@@ -96,141 +132,28 @@ async fn render_board(state: &AppState) -> color_eyre::Result<String> {
     let total = in_progress.len() + queued.len() + icebox.len() + done.len();
     let active = in_progress.len() + queued.len();
 
-    let backlog_panel = render_backlog_panel(&in_progress, &queued);
-    let icebox_panel = render_column_panel("icebox", &icebox);
-    let done_panel = render_column_panel("done", &done);
-
-    Ok(format!(
-        r##"<!DOCTYPE html>
-<html lang="en">
-<head>
-<meta charset="utf-8">
-<meta name="viewport" content="width=device-width, initial-scale=1">
-<title>ranger › {backlog_name}</title>
-<link rel="stylesheet" href="/static/style.css">
-</head>
-<body>
-<header>
-  <h1>ranger<span class="sep">›</span>{backlog_name}</h1>
-  <div class="counts">{active} active · {total} total</div>
-</header>
-<div class="board">
-  {backlog_panel}
-  {icebox_panel}
-  {done_panel}
-</div>
-</body>
-</html>"##,
-        backlog_name = html_escape(&state.backlog_name),
-    ))
-}
-
-fn render_backlog_panel(in_progress: &[TaskView], queued: &[TaskView]) -> String {
-    let count = in_progress.len() + queued.len();
-    let mut html = String::new();
-    html.push_str(&format!(
-        r#"<div class="panel">
-  <div class="panel-header">
-    <h2>Backlog</h2>
-    <span class="count">{count}</span>
-  </div>"#
-    ));
-
-    if in_progress.is_empty() && queued.is_empty() {
-        html.push_str(r#"<div class="empty">No active tasks</div>"#);
-    } else {
-        if !in_progress.is_empty() {
-            html.push_str(
-                r#"<div class="section-label section-label-in-progress"><span class="dot">●</span> In Progress</div>"#,
-            );
-            html.push_str(r#"<div class="state-in-progress">"#);
-            for task in in_progress {
-                html.push_str(&render_task(task));
-            }
-            html.push_str("</div>");
-        }
-
-        if !queued.is_empty() {
-            html.push_str(r#"<div class="section-label section-label-queued"><span class="dot">●</span> Queued</div>"#);
-            html.push_str(r#"<div class="state-queued">"#);
-            for task in queued {
-                html.push_str(&render_task(task));
-            }
-            html.push_str("</div>");
-        }
-    }
-
-    html.push_str("</div>");
-    html
-}
-
-fn render_column_panel(name: &str, tasks: &[TaskView]) -> String {
-    let label = match name {
-        "icebox" => "Icebox",
-        "done" => "Done",
-        _ => name,
-    };
-    let state_class = match name {
-        "done" => "state-done",
-        "icebox" => "state-icebox",
-        _ => "",
-    };
-    let count = tasks.len();
-    let mut html = String::new();
-    html.push_str(&format!(
-        r#"<div class="panel">
-  <div class="panel-header">
-    <h2>{label}</h2>
-    <span class="count">{count}</span>
-  </div>"#
-    ));
-
-    if tasks.is_empty() {
-        html.push_str(&format!(
-            r#"<div class="empty">No {lower} tasks</div>"#,
-            lower = label.to_lowercase()
-        ));
-    } else {
-        if !state_class.is_empty() {
-            html.push_str(&format!(r#"<div class="{state_class}">"#));
-        }
-        for task in tasks {
-            html.push_str(&render_task(task));
-        }
-        if !state_class.is_empty() {
-            html.push_str("</div>");
-        }
-    }
-
-    html.push_str("</div>");
-    html
-}
-
-fn render_task(task: &TaskView) -> String {
-    let mut html = String::new();
-    html.push_str(r#"<div class="task">"#);
-    html.push_str(r#"<div class="task-header">"#);
-    html.push_str(&format!(
-        r#"<span class="key"><span class="key-prefix">{}</span><span class="key-rest">{}</span></span>"#,
-        html_escape(&task.key_prefix),
-        html_escape(&task.key_rest)
-    ));
-    html.push_str(&format!(
-        r#"<span class="title">{}</span>"#,
-        html_escape(&task.title)
-    ));
-    html.push_str("</div>");
-    if let Some(desc) = &task.description {
-        html.push_str(&format!(r#"<div class="desc">{}</div>"#, html_escape(desc)));
-    }
-    if task.has_subtasks {
-        html.push_str(&format!(
-            r#"<div class="subtask-indicator">◆ {}/{} subtasks</div>"#,
-            task.done_subtask_count, task.subtask_count
-        ));
-    }
-    html.push_str("</div>");
-    html
+    let columns = vec![
+        ColumnView {
+            label: "Icebox".to_string(),
+            state_class: "state-icebox".to_string(),
+            tasks: icebox,
+        },
+        ColumnView {
+            label: "Done".to_string(),
+            state_class: "state-done".to_string(),
+            tasks: done,
+        },
+    ];
+
+    let mut context = Context::new();
+    context.insert("backlog_name", &state.backlog_name);
+    context.insert("active", &active);
+    context.insert("total", &total);
+    context.insert("in_progress", &in_progress);
+    context.insert("queued", &queued);
+    context.insert("columns", &columns);
+
+    Ok(state.tera.render("board.html", &context)?)
 }
 
 async fn to_task_views(
@@ -273,10 +196,3 @@ async fn to_task_views(
     }
     Ok(views)
 }
-
-fn html_escape(s: &str) -> String {
-    s.replace('&', "&amp;")
-        .replace('<', "&lt;")
-        .replace('>', "&gt;")
-        .replace('"', "&quot;")
-}
diff --git a/templates/base.html b/templates/base.html
new file mode 100644
index 0000000..b789588
--- /dev/null
+++ b/templates/base.html
@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>ranger › {{ backlog_name }}</title>
+<link rel="stylesheet" href="/static/style.css">
+</head>
+<body>
+{% block body %}{% endblock body %}
+</body>
+</html>
diff --git a/templates/board.html b/templates/board.html
new file mode 100644
index 0000000..2a9585a
--- /dev/null
+++ b/templates/board.html
@@ -0,0 +1,12 @@
+{% extends "base.html" %}
+
+{% block body %}
+<header>
+  <h1>ranger<span class="sep">›</span>{{ backlog_name }}</h1>
+  <div class="counts">{{ active }} active · {{ total }} total</div>
+</header>
+<div class="board">
+  {% include "panels/backlog.html" %}
+  {% include "panels/column.html" with context %}
+</div>
+{% endblock body %}
diff --git a/templates/panels/backlog.html b/templates/panels/backlog.html
new file mode 100644
index 0000000..d80e9c3
--- /dev/null
+++ b/templates/panels/backlog.html
@@ -0,0 +1,26 @@
+<div class="panel">
+  <div class="panel-header">
+    <h2>Backlog</h2>
+    <span class="count">{{ in_progress | length + queued | length }}</span>
+  </div>
+  {% if in_progress | length == 0 and queued | length == 0 %}
+    <div class="empty">No active tasks</div>
+  {% else %}
+    {% if in_progress | length > 0 %}
+      <div class="section-label section-label-in-progress"><span class="dot">●</span> In Progress</div>
+      <div class="state-in-progress">
+        {% for task in in_progress %}
+          {% include "task.html" %}
+        {% endfor %}
+      </div>
+    {% endif %}
+    {% if queued | length > 0 %}
+      <div class="section-label section-label-queued"><span class="dot">●</span> Queued</div>
+      <div class="state-queued">
+        {% for task in queued %}
+          {% include "task.html" %}
+        {% endfor %}
+      </div>
+    {% endif %}
+  {% endif %}
+</div>
diff --git a/templates/panels/column.html b/templates/panels/column.html
new file mode 100644
index 0000000..660dbcd
--- /dev/null
+++ b/templates/panels/column.html
@@ -0,0 +1,17 @@
+{% for column in columns %}
+<div class="panel">
+  <div class="panel-header">
+    <h2>{{ column.label }}</h2>
+    <span class="count">{{ column.tasks | length }}</span>
+  </div>
+  {% if column.tasks | length == 0 %}
+    <div class="empty">No {{ column.label | lower }} tasks</div>
+  {% else %}
+    <div class="{{ column.state_class }}">
+      {% for task in column.tasks %}
+        {% include "task.html" %}
+      {% endfor %}
+    </div>
+  {% endif %}
+</div>
+{% endfor %}
diff --git a/templates/task.html b/templates/task.html
new file mode 100644
index 0000000..44fedfe
--- /dev/null
+++ b/templates/task.html
@@ -0,0 +1,12 @@
+<div class="task">
+  <div class="task-header">
+    <span class="key"><span class="key-prefix">{{ task.key_prefix }}</span><span class="key-rest">{{ task.key_rest }}</span></span>
+    <span class="title">{{ task.title }}</span>
+  </div>
+  {% if task.description %}
+    <div class="desc">{{ task.description }}</div>
+  {% endif %}
+  {% if task.has_subtasks %}
+    <div class="subtask-indicator">◆ {{ task.done_subtask_count }}/{{ task.subtask_count }} subtasks</div>
+  {% endif %}
+</div>