use serde::Deserialize;
use std::path::Path;
#[derive(Deserialize, Default)]
pub struct Config {
#[serde(default)]
pub site: SiteConfig,
#[serde(default)]
pub languages: LanguagesConfig,
#[serde(default)]
pub comments: CommentsConfig,
#[serde(default)]
pub bbs: BbsConfig,
#[serde(default)]
pub layout: LayoutConfig,
#[serde(default)]
pub theme: ThemeConfig,
}
#[derive(Deserialize, Default)]
pub struct LayoutConfig {
#[serde(default = "default_layout_mode")]
pub mode: String,
pub content_width: Option<String>,
pub content_padding: Option<String>,
}
fn default_layout_mode() -> String {
"sidebar".to_string()
}
#[derive(Deserialize, Default)]
pub struct ThemeConfig {
pub accent: Option<String>,
pub bg: Option<String>,
pub bg_subtle: Option<String>,
pub fg: Option<String>,
pub fg_sec: Option<String>,
pub code_bg: Option<String>,
pub border: Option<String>,
}
#[derive(Deserialize, Default)]
pub struct SiteConfig {
pub title: Option<String>,
pub description: Option<String>,
pub cname: Option<String>,
pub favicon: Option<String>,
#[serde(default = "default_title_template")]
pub title_template: String,
}
fn default_title_template() -> String {
"{title}".to_string()
}
#[derive(Deserialize)]
pub struct LanguagesConfig {
#[serde(default = "default_lang")]
pub default: String,
#[serde(default)]
pub order: Vec<String>,
}
impl Default for LanguagesConfig {
fn default() -> Self {
Self {
default: "en".to_string(),
order: Vec::new(),
}
}
}
fn default_lang() -> String {
"en".to_string()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum CommentMode {
#[default]
None,
StaticJson,
Proxied,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
pub enum CommentSource {
#[default]
#[serde(rename = "native")]
Native,
#[serde(rename = "github-discussions")]
GitHubDiscussions,
#[serde(rename = "github-issues")]
GitHubIssues,
#[serde(rename = "disqus")]
Disqus,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct CommentsConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default, with = "legacy_mode_compat")]
pub mode: CommentMode,
#[serde(default)]
pub source: CommentSource,
pub endpoint: Option<String>,
#[serde(default)]
pub auth: Vec<String>,
#[serde(default = "default_archive_dir")]
pub archive_dir: String,
#[serde(default)]
#[allow(dead_code)]
pub disqus_shortname: Option<String>,
#[serde(default)]
#[allow(dead_code)]
pub giscus_repo: Option<String>,
#[serde(default)]
#[allow(dead_code)]
pub giscus_repo_id: Option<String>,
#[serde(default)]
#[allow(dead_code)]
pub giscus_category: Option<String>,
#[serde(default)]
#[allow(dead_code)]
pub giscus_category_id: Option<String>,
}
impl CommentsConfig {
pub fn is_active(&self) -> bool {
self.enabled && !matches!(self.mode, CommentMode::None)
}
pub fn auth_attr(&self) -> String {
self.auth.join(",")
}
pub fn source_attr(&self) -> &'static str {
match self.source {
CommentSource::Native => "native",
CommentSource::GitHubDiscussions => "github-discussions",
CommentSource::GitHubIssues => "github-issues",
CommentSource::Disqus => "disqus",
}
}
}
mod legacy_mode_compat {
use super::CommentMode;
use serde::{Deserialize, Deserializer};
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<CommentMode, D::Error> {
#[derive(Deserialize)]
#[serde(rename_all = "kebab-case")]
enum Raw {
None,
StaticJson,
Proxied,
Disqus,
Giscus,
GithubIssue,
Faas,
SelfHost,
}
Ok(match Raw::deserialize(d)? {
Raw::None => CommentMode::None,
Raw::StaticJson => CommentMode::StaticJson,
Raw::Proxied
| Raw::Disqus
| Raw::Giscus
| Raw::GithubIssue
| Raw::Faas
| Raw::SelfHost => CommentMode::Proxied,
})
}
}
fn default_archive_dir() -> String {
"comments".to_string()
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct BbsConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_boards_path")]
pub boards_path: String,
}
fn default_boards_path() -> String {
"boards".to_string()
}
impl Config {
pub fn load(src: &Path) -> Self {
let path = src.join("lagrange.toml");
let raw = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => return Self::default(),
};
let mut cfg: Self = toml::from_str(&raw).unwrap_or_default();
cfg.normalize_comments_from_raw(&raw);
cfg
}
fn normalize_comments_from_raw(&mut self, raw_toml: &str) {
if self.comments.source != CommentSource::Native {
return; }
let Ok(table) = toml::from_str::<toml::Value>(raw_toml) else {
return;
};
let Some(mode_str) = table
.get("comments")
.and_then(|c| c.as_table())
.and_then(|t| t.get("mode"))
.and_then(|m| m.as_str())
else {
return;
};
self.comments.source = match mode_str {
"giscus" => CommentSource::GitHubDiscussions,
"github-issue" => CommentSource::GitHubIssues,
"disqus" => CommentSource::Disqus,
_ => CommentSource::Native, };
}
pub fn ordered_langs(&self, available: &[String]) -> Vec<String> {
if self.languages.order.is_empty() {
let mut sorted = available.to_vec();
sorted.sort();
return sorted;
}
self.languages
.order
.iter()
.filter(|l| available.contains(l))
.cloned()
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_to_pure_static() {
let cfg = Config::default();
assert!(!cfg.comments.enabled);
assert_eq!(cfg.comments.mode, CommentMode::None);
assert!(!cfg.comments.is_active());
assert!(!cfg.bbs.enabled);
}
#[test]
fn parses_comments_proxied_native() {
let toml = r#"
[comments]
enabled = true
mode = "proxied"
source = "native"
endpoint = "https://c.example.workers.dev"
auth = ["anonymous", "github"]
"#;
let cfg: Config = toml::from_str(toml).unwrap();
assert!(cfg.comments.is_active());
assert_eq!(cfg.comments.mode, CommentMode::Proxied);
assert_eq!(cfg.comments.source, CommentSource::Native);
assert_eq!(cfg.comments.source_attr(), "native");
assert_eq!(
cfg.comments.endpoint.as_deref(),
Some("https://c.example.workers.dev")
);
assert_eq!(cfg.comments.auth, vec!["anonymous", "github"]);
assert_eq!(cfg.comments.auth_attr(), "anonymous,github");
}
#[test]
fn parses_comments_proxied_github_discussions() {
let toml = r#"
[comments]
enabled = true
mode = "proxied"
source = "github-discussions"
endpoint = "https://proxy.example.workers.dev"
"#;
let cfg: Config = toml::from_str(toml).unwrap();
assert_eq!(cfg.comments.mode, CommentMode::Proxied);
assert_eq!(cfg.comments.source, CommentSource::GitHubDiscussions);
assert_eq!(cfg.comments.source_attr(), "github-discussions");
}
#[test]
fn legacy_mode_faas_maps_to_proxied_native() {
let toml = r#"
[comments]
enabled = true
mode = "faas"
endpoint = "https://c.example.workers.dev"
"#;
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("lagrange.toml"), toml).unwrap();
let cfg = Config::load(dir.path());
assert_eq!(cfg.comments.mode, CommentMode::Proxied);
assert_eq!(cfg.comments.source, CommentSource::Native);
}
#[test]
fn legacy_mode_giscus_maps_to_proxied_github_discussions() {
let toml = r#"
[comments]
enabled = true
mode = "giscus"
endpoint = "https://proxy.example.workers.dev"
giscus_repo = "owner/repo"
giscus_repo_id = "R_kgDOtest"
"#;
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("lagrange.toml"), toml).unwrap();
let cfg = Config::load(dir.path());
assert_eq!(cfg.comments.mode, CommentMode::Proxied);
assert_eq!(cfg.comments.source, CommentSource::GitHubDiscussions);
assert_eq!(cfg.comments.giscus_repo.as_deref(), Some("owner/repo"));
}
#[test]
fn legacy_mode_disqus_maps_to_proxied_disqus() {
let toml = r#"
[comments]
enabled = true
mode = "disqus"
endpoint = "https://proxy.example.workers.dev"
disqus_shortname = "mysite"
"#;
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("lagrange.toml"), toml).unwrap();
let cfg = Config::load(dir.path());
assert_eq!(cfg.comments.mode, CommentMode::Proxied);
assert_eq!(cfg.comments.source, CommentSource::Disqus);
}
#[test]
fn explicit_source_wins_over_legacy_mode() {
let toml = r#"
[comments]
enabled = true
mode = "giscus"
source = "disqus"
endpoint = "https://proxy.example.workers.dev"
"#;
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("lagrange.toml"), toml).unwrap();
let cfg = Config::load(dir.path());
assert_eq!(cfg.comments.source, CommentSource::Disqus);
}
#[test]
fn parses_comments_static_json() {
let toml = r#"
[comments]
enabled = true
mode = "static-json"
archive_dir = "comments"
"#;
let cfg: Config = toml::from_str(toml).unwrap();
assert_eq!(cfg.comments.mode, CommentMode::StaticJson);
assert_eq!(cfg.comments.archive_dir, "comments");
}
#[test]
fn parses_bbs_enabled() {
let toml = r#"
[bbs]
enabled = true
boards_path = "forums"
"#;
let cfg: Config = toml::from_str(toml).unwrap();
assert!(cfg.bbs.enabled);
assert_eq!(cfg.bbs.boards_path, "forums");
}
#[test]
fn enabled_none_is_inactive() {
let toml = r#"
[comments]
enabled = true
mode = "none"
"#;
let cfg: Config = toml::from_str(toml).unwrap();
assert!(!cfg.comments.is_active());
}
#[test]
fn legacy_config_without_comments_still_loads() {
let toml = r#"
[site]
title = "Old"
[languages]
default = "en"
"#;
let cfg: Config = toml::from_str(toml).unwrap();
assert_eq!(cfg.site.title.as_deref(), Some("Old"));
assert!(!cfg.comments.is_active());
assert!(!cfg.bbs.enabled);
}
}