1use serde::{Deserialize, Serialize};
102use std::fs;
103use std::path::{Path, PathBuf};
104use std::sync::Arc;
105use thiserror::Error;
106
107#[derive(Error, Debug)]
108pub enum ConfigError {
109 #[error("IO error: {0}")]
110 Io(#[from] std::io::Error),
111 #[error("failed to parse {}: {source}", path.display())]
117 Toml {
118 path: PathBuf,
119 #[source]
120 source: Box<toml::de::Error>,
121 source_text: String,
122 },
123 #[error("Config validation error: {0}")]
124 Validation(String),
125}
126
127impl ConfigError {
128 pub fn to_clapfig_error(&self) -> Option<clapfig::error::ClapfigError> {
134 match self {
135 ConfigError::Toml {
136 path,
137 source,
138 source_text,
139 } => Some(clapfig::error::ClapfigError::ParseError {
140 path: path.clone(),
141 source: source.clone(),
142 source_text: Some(Arc::from(source_text.as_str())),
143 }),
144 _ => None,
145 }
146 }
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
154#[serde(default, deny_unknown_fields)]
155pub struct SiteConfig {
156 #[serde(default = "default_site_title")]
158 pub site_title: String,
159 #[serde(default = "default_assets_dir")]
162 pub assets_dir: String,
163 #[serde(default = "default_site_description_file")]
166 pub site_description_file: String,
167 pub colors: ColorConfig,
169 pub thumbnails: ThumbnailsConfig,
171 pub full_index: FullIndexConfig,
173 pub images: ImagesConfig,
175 pub theme: ThemeConfig,
177 pub font: FontConfig,
179 pub processing: ProcessingConfig,
181}
182
183#[derive(Debug, Clone, Default, Deserialize)]
185#[serde(deny_unknown_fields)]
186pub struct PartialSiteConfig {
187 pub site_title: Option<String>,
188 pub assets_dir: Option<String>,
189 pub site_description_file: Option<String>,
190 pub colors: Option<PartialColorConfig>,
191 pub thumbnails: Option<PartialThumbnailsConfig>,
192 pub full_index: Option<PartialFullIndexConfig>,
193 pub images: Option<PartialImagesConfig>,
194 pub theme: Option<PartialThemeConfig>,
195 pub font: Option<PartialFontConfig>,
196 pub processing: Option<PartialProcessingConfig>,
197}
198
199fn default_site_title() -> String {
200 "Gallery".to_string()
201}
202
203fn default_assets_dir() -> String {
204 "assets".to_string()
205}
206
207fn default_site_description_file() -> String {
208 "site".to_string()
209}
210
211impl Default for SiteConfig {
212 fn default() -> Self {
213 Self {
214 site_title: default_site_title(),
215 assets_dir: default_assets_dir(),
216 site_description_file: default_site_description_file(),
217 colors: ColorConfig::default(),
218 thumbnails: ThumbnailsConfig::default(),
219 full_index: FullIndexConfig::default(),
220 images: ImagesConfig::default(),
221 theme: ThemeConfig::default(),
222 font: FontConfig::default(),
223 processing: ProcessingConfig::default(),
224 }
225 }
226}
227
228impl SiteConfig {
229 pub fn validate(&self) -> Result<(), ConfigError> {
231 if self.images.quality > 100 {
232 return Err(ConfigError::Validation(
233 "images.quality must be 0-100".into(),
234 ));
235 }
236 if self.thumbnails.aspect_ratio[0] == 0 || self.thumbnails.aspect_ratio[1] == 0 {
237 return Err(ConfigError::Validation(
238 "thumbnails.aspect_ratio values must be non-zero".into(),
239 ));
240 }
241 if self.full_index.thumb_ratio[0] == 0 || self.full_index.thumb_ratio[1] == 0 {
242 return Err(ConfigError::Validation(
243 "full_index.thumb_ratio values must be non-zero".into(),
244 ));
245 }
246 if self.full_index.thumb_size == 0 {
247 return Err(ConfigError::Validation(
248 "full_index.thumb_size must be non-zero".into(),
249 ));
250 }
251 if self.images.sizes.is_empty() {
252 return Err(ConfigError::Validation(
253 "images.sizes must not be empty".into(),
254 ));
255 }
256 Ok(())
257 }
258
259 pub fn merge(mut self, other: PartialSiteConfig) -> Self {
261 if let Some(st) = other.site_title {
262 self.site_title = st;
263 }
264 if let Some(ad) = other.assets_dir {
265 self.assets_dir = ad;
266 }
267 if let Some(sd) = other.site_description_file {
268 self.site_description_file = sd;
269 }
270 if let Some(c) = other.colors {
271 self.colors = self.colors.merge(c);
272 }
273 if let Some(t) = other.thumbnails {
274 self.thumbnails = self.thumbnails.merge(t);
275 }
276 if let Some(fi) = other.full_index {
277 self.full_index = self.full_index.merge(fi);
278 }
279 if let Some(i) = other.images {
280 self.images = self.images.merge(i);
281 }
282 if let Some(t) = other.theme {
283 self.theme = self.theme.merge(t);
284 }
285 if let Some(f) = other.font {
286 self.font = self.font.merge(f);
287 }
288 if let Some(p) = other.processing {
289 self.processing = self.processing.merge(p);
290 }
291 self
292 }
293}
294
295#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
297#[serde(default, deny_unknown_fields)]
298pub struct ProcessingConfig {
299 pub max_processes: Option<usize>,
303}
304
305#[derive(Debug, Clone, Default, Deserialize)]
306#[serde(deny_unknown_fields)]
307pub struct PartialProcessingConfig {
308 pub max_processes: Option<usize>,
309}
310
311impl ProcessingConfig {
312 pub fn merge(mut self, other: PartialProcessingConfig) -> Self {
313 if other.max_processes.is_some() {
314 self.max_processes = other.max_processes;
315 }
316 self
317 }
318}
319
320#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
322#[serde(rename_all = "lowercase")]
323pub enum FontType {
324 #[default]
325 Sans,
326 Serif,
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
351#[serde(default, deny_unknown_fields)]
352pub struct FontConfig {
353 pub font: String,
355 pub weight: String,
357 pub font_type: FontType,
359 #[serde(skip_serializing_if = "Option::is_none")]
363 pub source: Option<String>,
364}
365
366#[derive(Debug, Clone, Default, Deserialize)]
367#[serde(deny_unknown_fields)]
368pub struct PartialFontConfig {
369 pub font: Option<String>,
370 pub weight: Option<String>,
371 pub font_type: Option<FontType>,
372 pub source: Option<String>,
373}
374
375impl Default for FontConfig {
376 fn default() -> Self {
377 Self {
378 font: "Noto Sans".to_string(),
379 weight: "600".to_string(),
380 font_type: FontType::Sans,
381 source: None,
382 }
383 }
384}
385
386impl FontConfig {
387 pub fn merge(mut self, other: PartialFontConfig) -> Self {
388 if let Some(f) = other.font {
389 self.font = f;
390 }
391 if let Some(w) = other.weight {
392 self.weight = w;
393 }
394 if let Some(t) = other.font_type {
395 self.font_type = t;
396 }
397 if other.source.is_some() {
398 self.source = other.source;
399 }
400 self
401 }
402
403 pub fn is_local(&self) -> bool {
405 self.source.is_some()
406 }
407
408 pub fn stylesheet_url(&self) -> Option<String> {
411 if self.is_local() {
412 return None;
413 }
414 let family = self.font.replace(' ', "+");
415 Some(format!(
416 "https://fonts.googleapis.com/css2?family={}:wght@{}&display=swap",
417 family, self.weight
418 ))
419 }
420
421 pub fn font_face_css(&self) -> Option<String> {
424 let src = self.source.as_ref()?;
425 let format = font_format_from_extension(src);
426 Some(format!(
427 r#"@font-face {{
428 font-family: "{}";
429 src: url("/{}") format("{}");
430 font-weight: {};
431 font-display: swap;
432}}"#,
433 self.font, src, format, self.weight
434 ))
435 }
436
437 pub fn font_family_css(&self) -> String {
439 let fallbacks = match self.font_type {
440 FontType::Serif => r#"Georgia, "Times New Roman", serif"#,
441 FontType::Sans => "Helvetica, Verdana, sans-serif",
442 };
443 format!(r#""{}", {}"#, self.font, fallbacks)
444 }
445}
446
447fn font_format_from_extension(path: &str) -> &'static str {
449 match path.rsplit('.').next().map(|e| e.to_lowercase()).as_deref() {
450 Some("woff2") => "woff2",
451 Some("woff") => "woff",
452 Some("ttf") => "truetype",
453 Some("otf") => "opentype",
454 _ => "woff2", }
456}
457
458pub fn effective_threads(config: &ProcessingConfig) -> usize {
463 let cores = std::thread::available_parallelism()
464 .map(|n| n.get())
465 .unwrap_or(1);
466 config.max_processes.map(|n| n.min(cores)).unwrap_or(cores)
467}
468
469#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
471#[serde(default, deny_unknown_fields)]
472pub struct ThumbnailsConfig {
473 pub aspect_ratio: [u32; 2],
475 pub size: u32,
477}
478
479#[derive(Debug, Clone, Default, Deserialize)]
480#[serde(deny_unknown_fields)]
481pub struct PartialThumbnailsConfig {
482 pub aspect_ratio: Option<[u32; 2]>,
483 pub size: Option<u32>,
484}
485
486impl ThumbnailsConfig {
487 pub fn merge(mut self, other: PartialThumbnailsConfig) -> Self {
488 if let Some(ar) = other.aspect_ratio {
489 self.aspect_ratio = ar;
490 }
491 if let Some(s) = other.size {
492 self.size = s;
493 }
494 self
495 }
496}
497
498impl Default for ThumbnailsConfig {
499 fn default() -> Self {
500 Self {
501 aspect_ratio: [4, 5],
502 size: 400,
503 }
504 }
505}
506
507#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
514#[serde(default, deny_unknown_fields)]
515pub struct FullIndexConfig {
516 pub generates: bool,
518 pub show_link: bool,
520 pub thumb_ratio: [u32; 2],
522 pub thumb_size: u32,
524 pub thumb_gap: String,
526}
527
528#[derive(Debug, Clone, Default, Deserialize)]
529#[serde(deny_unknown_fields)]
530pub struct PartialFullIndexConfig {
531 pub generates: Option<bool>,
532 pub show_link: Option<bool>,
533 pub thumb_ratio: Option<[u32; 2]>,
534 pub thumb_size: Option<u32>,
535 pub thumb_gap: Option<String>,
536}
537
538impl FullIndexConfig {
539 pub fn merge(mut self, other: PartialFullIndexConfig) -> Self {
540 if let Some(g) = other.generates {
541 self.generates = g;
542 }
543 if let Some(l) = other.show_link {
544 self.show_link = l;
545 }
546 if let Some(r) = other.thumb_ratio {
547 self.thumb_ratio = r;
548 }
549 if let Some(s) = other.thumb_size {
550 self.thumb_size = s;
551 }
552 if let Some(g) = other.thumb_gap {
553 self.thumb_gap = g;
554 }
555 self
556 }
557}
558
559impl Default for FullIndexConfig {
560 fn default() -> Self {
561 Self {
562 generates: false,
563 show_link: false,
564 thumb_ratio: [4, 5],
565 thumb_size: 400,
566 thumb_gap: "0.2rem".to_string(),
567 }
568 }
569}
570
571#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
573#[serde(default, deny_unknown_fields)]
574pub struct ImagesConfig {
575 pub sizes: Vec<u32>,
577 pub quality: u32,
579}
580
581#[derive(Debug, Clone, Default, Deserialize)]
582#[serde(deny_unknown_fields)]
583pub struct PartialImagesConfig {
584 pub sizes: Option<Vec<u32>>,
585 pub quality: Option<u32>,
586}
587
588impl ImagesConfig {
589 pub fn merge(mut self, other: PartialImagesConfig) -> Self {
590 if let Some(s) = other.sizes {
591 self.sizes = s;
592 }
593 if let Some(q) = other.quality {
594 self.quality = q;
595 }
596 self
597 }
598}
599
600impl Default for ImagesConfig {
601 fn default() -> Self {
602 Self {
603 sizes: vec![800, 1400, 2080],
604 quality: 90,
605 }
606 }
607}
608
609#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
617#[serde(deny_unknown_fields)]
618pub struct ClampSize {
619 pub size: String,
621 pub min: String,
623 pub max: String,
625}
626
627#[derive(Debug, Clone, Default, Deserialize)]
628#[serde(deny_unknown_fields)]
629pub struct PartialClampSize {
630 pub size: Option<String>,
631 pub min: Option<String>,
632 pub max: Option<String>,
633}
634
635impl ClampSize {
636 pub fn merge(mut self, other: PartialClampSize) -> Self {
637 if let Some(s) = other.size {
638 self.size = s;
639 }
640 if let Some(m) = other.min {
641 self.min = m;
642 }
643 if let Some(m) = other.max {
644 self.max = m;
645 }
646 self
647 }
648}
649
650impl ClampSize {
651 pub fn to_css(&self) -> String {
653 format!("clamp({}, {}, {})", self.min, self.size, self.max)
654 }
655}
656
657#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
659#[serde(default, deny_unknown_fields)]
660pub struct ThemeConfig {
661 pub mat_x: ClampSize,
663 pub mat_y: ClampSize,
665 pub thumbnail_gap: String,
667 pub grid_padding: String,
669}
670
671#[derive(Debug, Clone, Default, Deserialize)]
672#[serde(deny_unknown_fields)]
673pub struct PartialThemeConfig {
674 pub mat_x: Option<PartialClampSize>,
675 pub mat_y: Option<PartialClampSize>,
676 pub thumbnail_gap: Option<String>,
677 pub grid_padding: Option<String>,
678}
679
680impl ThemeConfig {
681 pub fn merge(mut self, other: PartialThemeConfig) -> Self {
682 if let Some(x) = other.mat_x {
683 self.mat_x = self.mat_x.merge(x);
684 }
685 if let Some(y) = other.mat_y {
686 self.mat_y = self.mat_y.merge(y);
687 }
688 if let Some(g) = other.thumbnail_gap {
689 self.thumbnail_gap = g;
690 }
691 if let Some(p) = other.grid_padding {
692 self.grid_padding = p;
693 }
694 self
695 }
696}
697
698impl Default for ThemeConfig {
699 fn default() -> Self {
700 Self {
701 mat_x: ClampSize {
702 size: "3vw".to_string(),
703 min: "1rem".to_string(),
704 max: "2.5rem".to_string(),
705 },
706 mat_y: ClampSize {
707 size: "6vw".to_string(),
708 min: "2rem".to_string(),
709 max: "5rem".to_string(),
710 },
711 thumbnail_gap: "0.2rem".to_string(),
712 grid_padding: "2rem".to_string(),
713 }
714 }
715}
716
717#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
719#[serde(default, deny_unknown_fields)]
720pub struct ColorConfig {
721 pub light: ColorScheme,
723 pub dark: ColorScheme,
725}
726
727#[derive(Debug, Clone, Default, Deserialize)]
728#[serde(deny_unknown_fields)]
729pub struct PartialColorConfig {
730 pub light: Option<PartialColorScheme>,
731 pub dark: Option<PartialColorScheme>,
732}
733
734impl ColorConfig {
735 pub fn merge(mut self, other: PartialColorConfig) -> Self {
736 if let Some(l) = other.light {
737 self.light = self.light.merge(l);
738 }
739 if let Some(d) = other.dark {
740 self.dark = self.dark.merge(d);
741 }
742 self
743 }
744}
745
746impl Default for ColorConfig {
747 fn default() -> Self {
748 Self {
749 light: ColorScheme::default_light(),
750 dark: ColorScheme::default_dark(),
751 }
752 }
753}
754
755#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
757#[serde(default, deny_unknown_fields)]
758pub struct ColorScheme {
759 pub background: String,
761 pub text: String,
763 pub text_muted: String,
765 pub border: String,
767 pub separator: String,
769 pub link: String,
771 pub link_hover: String,
773}
774
775#[derive(Debug, Clone, Default, Deserialize)]
776#[serde(deny_unknown_fields)]
777pub struct PartialColorScheme {
778 pub background: Option<String>,
779 pub text: Option<String>,
780 pub text_muted: Option<String>,
781 pub border: Option<String>,
782 pub separator: Option<String>,
783 pub link: Option<String>,
784 pub link_hover: Option<String>,
785}
786
787impl ColorScheme {
788 pub fn merge(mut self, other: PartialColorScheme) -> Self {
789 if let Some(v) = other.background {
790 self.background = v;
791 }
792 if let Some(v) = other.text {
793 self.text = v;
794 }
795 if let Some(v) = other.text_muted {
796 self.text_muted = v;
797 }
798 if let Some(v) = other.border {
799 self.border = v;
800 }
801 if let Some(v) = other.separator {
802 self.separator = v;
803 }
804 if let Some(v) = other.link {
805 self.link = v;
806 }
807 if let Some(v) = other.link_hover {
808 self.link_hover = v;
809 }
810 self
811 }
812}
813
814impl ColorScheme {
815 pub fn default_light() -> Self {
816 Self {
817 background: "#ffffff".to_string(),
818 text: "#111111".to_string(),
819 text_muted: "#666666".to_string(),
820 border: "#e0e0e0".to_string(),
821 separator: "#e0e0e0".to_string(),
822 link: "#333333".to_string(),
823 link_hover: "#000000".to_string(),
824 }
825 }
826
827 pub fn default_dark() -> Self {
828 Self {
829 background: "#000000".to_string(),
830 text: "#fafafa".to_string(),
831 text_muted: "#999999".to_string(),
832 border: "#333333".to_string(),
833 separator: "#333333".to_string(),
834 link: "#cccccc".to_string(),
835 link_hover: "#ffffff".to_string(),
836 }
837 }
838}
839
840impl Default for ColorScheme {
841 fn default() -> Self {
842 Self::default_light()
843 }
844}
845
846pub fn load_partial_config(path: &Path) -> Result<Option<PartialSiteConfig>, ConfigError> {
855 let config_path = path.join("config.toml");
856 if !config_path.exists() {
857 return Ok(None);
858 }
859 let content = fs::read_to_string(&config_path)?;
860 let partial: PartialSiteConfig = toml::from_str(&content).map_err(|e| ConfigError::Toml {
861 path: config_path.clone(),
862 source: Box::new(e),
863 source_text: content,
864 })?;
865 Ok(Some(partial))
866}
867
868pub fn load_config(root: &Path) -> Result<SiteConfig, ConfigError> {
870 let base = SiteConfig::default();
871 let partial = load_partial_config(root)?;
872 if let Some(p) = partial {
873 let merged = base.merge(p);
874 merged.validate()?;
875 Ok(merged)
876 } else {
877 Ok(base)
878 }
879}
880
881pub fn stock_config_toml() -> &'static str {
885 r##"# Simple Gal Configuration
886# ========================
887# All settings are optional. Remove or comment out any you don't need.
888# Values shown below are the defaults.
889#
890# Config files can be placed at any level of the directory tree:
891# content/config.toml -> root (overrides stock defaults)
892# content/020-Travel/config.toml -> group (overrides root)
893# content/020-Travel/010-Japan/config.toml -> gallery (overrides group)
894#
895# Each level only needs the keys it wants to override.
896# Unknown keys will cause an error.
897
898# Site title shown in breadcrumbs and the browser tab for the home page.
899site_title = "Gallery"
900
901# Directory for static assets (favicon, fonts, etc.), relative to content root.
902# Contents are copied verbatim to the output root during generation.
903# If the directory doesn't exist, it is silently skipped.
904assets_dir = "assets"
905
906# Stem of the site description file in the content root.
907# If site.md or site.txt exists, its content is rendered on the index page.
908# site_description_file = "site"
909
910# ---------------------------------------------------------------------------
911# Thumbnail generation
912# ---------------------------------------------------------------------------
913[thumbnails]
914# Aspect ratio as [width, height] for thumbnail crops.
915# Common choices: [1, 1] for square, [4, 5] for portrait, [3, 2] for landscape.
916aspect_ratio = [4, 5]
917
918# Short-edge size in pixels for generated thumbnails.
919size = 400
920
921# ---------------------------------------------------------------------------
922# Full index ("All Photos") page
923# ---------------------------------------------------------------------------
924# When enabled, generates a single page at /all-photos/ showing every image
925# from every public album in one thumbnail grid. Off by default.
926[full_index]
927# If true, renders the All Photos page.
928generates = false
929
930# If true, adds an "All Photos" entry to the navigation menu.
931show_link = false
932
933# Aspect ratio [width, height] for full-index thumbnails.
934thumb_ratio = [4, 5]
935
936# Short-edge size in pixels for full-index thumbnails.
937thumb_size = 400
938
939# Gap between thumbnails on the All Photos grid (CSS value).
940thumb_gap = "0.2rem"
941
942# ---------------------------------------------------------------------------
943# Responsive image generation
944# ---------------------------------------------------------------------------
945[images]
946# Pixel widths (longer edge) to generate for responsive <picture> elements.
947sizes = [800, 1400, 2080]
948
949# AVIF encoding quality (0 = worst, 100 = best).
950quality = 90
951
952# ---------------------------------------------------------------------------
953# Theme / layout
954# ---------------------------------------------------------------------------
955[theme]
956# Gap between thumbnails in album and image grids (CSS value).
957thumbnail_gap = "0.2rem"
958
959# Padding around the thumbnail grid container (CSS value).
960grid_padding = "2rem"
961
962# Horizontal mat around images, as CSS clamp(min, size, max).
963# See docs/dev/photo-page-layout.md for the layout spec.
964[theme.mat_x]
965size = "3vw"
966min = "1rem"
967max = "2.5rem"
968
969# Vertical mat around images, as CSS clamp(min, size, max).
970[theme.mat_y]
971size = "6vw"
972min = "2rem"
973max = "5rem"
974
975# ---------------------------------------------------------------------------
976# Colors - Light mode (prefers-color-scheme: light)
977# ---------------------------------------------------------------------------
978[colors.light]
979background = "#ffffff"
980text = "#111111"
981text_muted = "#666666" # Nav, breadcrumbs, captions
982border = "#e0e0e0"
983separator = "#e0e0e0" # Header underline, nav menu divider
984link = "#333333"
985link_hover = "#000000"
986
987# ---------------------------------------------------------------------------
988# Colors - Dark mode (prefers-color-scheme: dark)
989# ---------------------------------------------------------------------------
990[colors.dark]
991background = "#000000"
992text = "#fafafa"
993text_muted = "#999999"
994border = "#333333"
995separator = "#333333" # Header underline, nav menu divider
996link = "#cccccc"
997link_hover = "#ffffff"
998
999# ---------------------------------------------------------------------------
1000# Font
1001# ---------------------------------------------------------------------------
1002[font]
1003# Google Fonts family name.
1004font = "Noto Sans"
1005
1006# Font weight to load from Google Fonts.
1007weight = "600"
1008
1009# Font category: "sans" or "serif". Determines fallback fonts in the CSS stack.
1010# sans -> Helvetica, Verdana, sans-serif
1011# serif -> Georgia, "Times New Roman", serif
1012font_type = "sans"
1013
1014# Local font file path, relative to the site root (e.g. "fonts/MyFont.woff2").
1015# When set, generates @font-face CSS instead of loading from Google Fonts.
1016# Place the font file in your assets directory so it gets copied to the output.
1017# Supported formats: .woff2, .woff, .ttf, .otf
1018# source = "fonts/MyFont.woff2"
1019
1020# ---------------------------------------------------------------------------
1021# Processing
1022# ---------------------------------------------------------------------------
1023[processing]
1024# Maximum parallel image-processing workers.
1025# Omit or comment out to auto-detect (= number of CPU cores).
1026# max_processes = 4
1027
1028# ---------------------------------------------------------------------------
1029# Custom CSS & HTML Snippets
1030# ---------------------------------------------------------------------------
1031# Drop any of these files into your assets/ directory to inject custom content.
1032# No configuration needed — the files are detected automatically.
1033#
1034# assets/custom.css → <link rel="stylesheet"> after main styles (CSS overrides)
1035# assets/head.html → raw HTML at the end of <head> (analytics, meta tags)
1036# assets/body-end.html → raw HTML before </body> (tracking scripts, widgets)
1037"##
1038}
1039
1040pub fn generate_color_css(colors: &ColorConfig) -> String {
1048 format!(
1049 r#":root {{
1050 --color-bg: {light_bg};
1051 --color-text: {light_text};
1052 --color-text-muted: {light_text_muted};
1053 --color-border: {light_border};
1054 --color-link: {light_link};
1055 --color-link-hover: {light_link_hover};
1056 --color-separator: {light_separator};
1057}}
1058
1059@media (prefers-color-scheme: dark) {{
1060 :root {{
1061 --color-bg: {dark_bg};
1062 --color-text: {dark_text};
1063 --color-text-muted: {dark_text_muted};
1064 --color-border: {dark_border};
1065 --color-link: {dark_link};
1066 --color-link-hover: {dark_link_hover};
1067 --color-separator: {dark_separator};
1068 }}
1069}}"#,
1070 light_bg = colors.light.background,
1071 light_text = colors.light.text,
1072 light_text_muted = colors.light.text_muted,
1073 light_border = colors.light.border,
1074 light_separator = colors.light.separator,
1075 light_link = colors.light.link,
1076 light_link_hover = colors.light.link_hover,
1077 dark_bg = colors.dark.background,
1078 dark_text = colors.dark.text,
1079 dark_text_muted = colors.dark.text_muted,
1080 dark_border = colors.dark.border,
1081 dark_separator = colors.dark.separator,
1082 dark_link = colors.dark.link,
1083 dark_link_hover = colors.dark.link_hover,
1084 )
1085}
1086
1087pub fn generate_theme_css(theme: &ThemeConfig) -> String {
1089 format!(
1090 r#":root {{
1091 --mat-x: {mat_x};
1092 --mat-y: {mat_y};
1093 --thumbnail-gap: {thumbnail_gap};
1094 --grid-padding: {grid_padding};
1095}}"#,
1096 mat_x = theme.mat_x.to_css(),
1097 mat_y = theme.mat_y.to_css(),
1098 thumbnail_gap = theme.thumbnail_gap,
1099 grid_padding = theme.grid_padding,
1100 )
1101}
1102
1103pub fn generate_font_css(font: &FontConfig) -> String {
1107 let vars = format!(
1108 r#":root {{
1109 --font-family: {family};
1110 --font-weight: {weight};
1111}}"#,
1112 family = font.font_family_css(),
1113 weight = font.weight,
1114 );
1115 match font.font_face_css() {
1116 Some(face) => format!("{}\n\n{}", face, vars),
1117 None => vars,
1118 }
1119}
1120
1121#[cfg(test)]
1122mod tests {
1123 use super::*;
1124 use tempfile::TempDir;
1125
1126 #[test]
1127 fn default_config_has_colors() {
1128 let config = SiteConfig::default();
1129 assert_eq!(config.colors.light.background, "#ffffff");
1130 assert_eq!(config.colors.dark.background, "#000000");
1131 }
1132
1133 #[test]
1134 fn default_config_has_site_title() {
1135 let config = SiteConfig::default();
1136 assert_eq!(config.site_title, "Gallery");
1137 }
1138
1139 #[test]
1140 fn parse_custom_site_title() {
1141 let toml = r#"site_title = "My Portfolio""#;
1142 let partial: PartialSiteConfig = toml::from_str(toml).unwrap();
1143 let config = SiteConfig::default().merge(partial);
1144 assert_eq!(config.site_title, "My Portfolio");
1145 }
1146
1147 #[test]
1148 fn default_config_has_image_settings() {
1149 let config = SiteConfig::default();
1150 assert_eq!(config.thumbnails.aspect_ratio, [4, 5]);
1151 assert_eq!(config.images.sizes, vec![800, 1400, 2080]);
1152 assert_eq!(config.images.quality, 90);
1153 assert_eq!(config.theme.mat_x.to_css(), "clamp(1rem, 3vw, 2.5rem)");
1154 assert_eq!(config.theme.mat_y.to_css(), "clamp(2rem, 6vw, 5rem)");
1155 }
1156
1157 #[test]
1158 fn parse_partial_config() {
1159 let toml = r##"
1160[colors.light]
1161background = "#fafafa"
1162"##;
1163 let partial: PartialSiteConfig = toml::from_str(toml).unwrap();
1164 let config = SiteConfig::default().merge(partial);
1165
1166 assert_eq!(config.colors.light.background, "#fafafa");
1168 assert_eq!(config.colors.light.text, "#111111");
1170 assert_eq!(config.colors.dark.background, "#000000");
1171 assert_eq!(config.images.sizes, vec![800, 1400, 2080]);
1173 }
1174
1175 #[test]
1176 fn parse_image_settings() {
1177 let toml = r##"
1178[thumbnails]
1179aspect_ratio = [1, 1]
1180
1181[images]
1182sizes = [400, 800]
1183quality = 85
1184"##;
1185 let partial: PartialSiteConfig = toml::from_str(toml).unwrap();
1186 let config = SiteConfig::default().merge(partial);
1187
1188 assert_eq!(config.thumbnails.aspect_ratio, [1, 1]);
1189 assert_eq!(config.images.sizes, vec![400, 800]);
1190 assert_eq!(config.images.quality, 85);
1191 assert_eq!(config.colors.light.background, "#ffffff");
1193 }
1194
1195 #[test]
1196 fn toml_error_carries_path_and_source_text() {
1197 let tmp = TempDir::new().unwrap();
1198 fs::write(
1201 tmp.path().join("config.toml"),
1202 "[theme]\nthumbnail_gap = 0.2rem\n",
1203 )
1204 .unwrap();
1205 let err = load_config(tmp.path()).unwrap_err();
1206 match &err {
1207 ConfigError::Toml {
1208 path,
1209 source_text,
1210 source,
1211 } => {
1212 assert!(path.ends_with("config.toml"));
1213 assert!(source_text.contains("thumbnail_gap"));
1214 assert!(source.span().is_some());
1215 }
1216 other => panic!("expected Toml variant, got {:?}", other),
1217 }
1218 }
1219
1220 #[test]
1221 fn to_clapfig_error_wraps_parse_failure() {
1222 let tmp = TempDir::new().unwrap();
1223 fs::write(
1224 tmp.path().join("config.toml"),
1225 "[theme]\nthumbnail_gap = 0.2rem\n",
1226 )
1227 .unwrap();
1228 let err = load_config(tmp.path()).unwrap_err();
1229 let clap_err = err
1230 .to_clapfig_error()
1231 .expect("parse errors should be convertible to ClapfigError");
1232
1233 let (path, parse_err, source_text) = clap_err
1236 .parse_error()
1237 .expect("ClapfigError should be a ParseError");
1238 assert!(path.ends_with("config.toml"));
1239 assert!(parse_err.span().is_some());
1240 assert!(source_text.unwrap().contains("thumbnail_gap"));
1241 }
1242
1243 #[test]
1244 fn to_clapfig_error_is_none_for_validation_failure() {
1245 let err = ConfigError::Validation("quality out of range".into());
1246 assert!(err.to_clapfig_error().is_none());
1247 }
1248
1249 #[test]
1250 fn clapfig_render_plain_includes_path_and_snippet() {
1251 let tmp = TempDir::new().unwrap();
1254 fs::write(
1255 tmp.path().join("config.toml"),
1256 "[theme]\nthumbnail_gap = 0.2rem\n",
1257 )
1258 .unwrap();
1259 let err = load_config(tmp.path()).unwrap_err();
1260 let clap_err = err.to_clapfig_error().unwrap();
1261 let out = clapfig::render::render_plain(&clap_err);
1262
1263 assert!(out.contains("config.toml"), "missing path in {out}");
1264 assert!(
1265 out.contains("thumbnail_gap"),
1266 "missing source snippet in {out}"
1267 );
1268 assert!(out.contains('^'), "missing caret in {out}");
1269 }
1270
1271 #[test]
1272 fn default_full_index_is_off() {
1273 let config = SiteConfig::default();
1274 assert!(!config.full_index.generates);
1275 assert!(!config.full_index.show_link);
1276 assert_eq!(config.full_index.thumb_ratio, [4, 5]);
1277 assert_eq!(config.full_index.thumb_size, 400);
1278 assert_eq!(config.full_index.thumb_gap, "0.2rem");
1279 }
1280
1281 #[test]
1282 fn parse_full_index_settings() {
1283 let toml = r##"
1284[full_index]
1285generates = true
1286show_link = true
1287thumb_ratio = [4, 4]
1288thumb_size = 1000
1289thumb_gap = "0.5rem"
1290"##;
1291 let partial: PartialSiteConfig = toml::from_str(toml).unwrap();
1292 let config = SiteConfig::default().merge(partial);
1293
1294 assert!(config.full_index.generates);
1295 assert!(config.full_index.show_link);
1296 assert_eq!(config.full_index.thumb_ratio, [4, 4]);
1297 assert_eq!(config.full_index.thumb_size, 1000);
1298 assert_eq!(config.full_index.thumb_gap, "0.5rem");
1299 }
1300
1301 #[test]
1302 fn full_index_partial_merge_preserves_defaults() {
1303 let toml = r##"
1304[full_index]
1305generates = true
1306"##;
1307 let partial: PartialSiteConfig = toml::from_str(toml).unwrap();
1308 let config = SiteConfig::default().merge(partial);
1309
1310 assert!(config.full_index.generates);
1311 assert!(!config.full_index.show_link);
1313 assert_eq!(config.full_index.thumb_ratio, [4, 5]);
1314 assert_eq!(config.full_index.thumb_size, 400);
1315 }
1316
1317 #[test]
1318 fn full_index_validation_rejects_zero_ratio() {
1319 let mut config = SiteConfig::default();
1320 config.full_index.thumb_ratio = [0, 1];
1321 assert!(config.validate().is_err());
1322 }
1323
1324 #[test]
1325 fn generate_css_uses_config_colors() {
1326 let mut colors = ColorConfig::default();
1327 colors.light.background = "#f0f0f0".to_string();
1328 colors.dark.background = "#1a1a1a".to_string();
1329
1330 let css = generate_color_css(&colors);
1331 assert!(css.contains("--color-bg: #f0f0f0"));
1332 assert!(css.contains("--color-bg: #1a1a1a"));
1333 }
1334
1335 #[test]
1340 fn load_config_returns_default_when_no_file() {
1341 let tmp = TempDir::new().unwrap();
1342 let config = load_config(tmp.path()).unwrap();
1343
1344 assert_eq!(config.colors.light.background, "#ffffff");
1345 assert_eq!(config.colors.dark.background, "#000000");
1346 }
1347
1348 #[test]
1349 fn load_config_reads_file() {
1350 let tmp = TempDir::new().unwrap();
1351 let config_path = tmp.path().join("config.toml");
1352
1353 fs::write(
1354 &config_path,
1355 r##"
1356[colors.light]
1357background = "#123456"
1358text = "#abcdef"
1359"##,
1360 )
1361 .unwrap();
1362
1363 let config = load_config(tmp.path()).unwrap();
1364 assert_eq!(config.colors.light.background, "#123456");
1365 assert_eq!(config.colors.light.text, "#abcdef");
1366 assert_eq!(config.colors.dark.background, "#000000");
1368 }
1369
1370 #[test]
1371 fn load_config_full_config() {
1372 let tmp = TempDir::new().unwrap();
1373 let config_path = tmp.path().join("config.toml");
1374
1375 fs::write(
1376 &config_path,
1377 r##"
1378[colors.light]
1379background = "#fff"
1380text = "#000"
1381text_muted = "#666"
1382border = "#ccc"
1383link = "#00f"
1384link_hover = "#f00"
1385
1386[colors.dark]
1387background = "#111"
1388text = "#eee"
1389text_muted = "#888"
1390border = "#444"
1391link = "#88f"
1392link_hover = "#f88"
1393"##,
1394 )
1395 .unwrap();
1396
1397 let config = load_config(tmp.path()).unwrap();
1398
1399 assert_eq!(config.colors.light.background, "#fff");
1401 assert_eq!(config.colors.light.text, "#000");
1402 assert_eq!(config.colors.light.link, "#00f");
1403
1404 assert_eq!(config.colors.dark.background, "#111");
1406 assert_eq!(config.colors.dark.text, "#eee");
1407 assert_eq!(config.colors.dark.link, "#88f");
1408 }
1409
1410 #[test]
1411 fn load_config_invalid_toml_is_error() {
1412 let tmp = TempDir::new().unwrap();
1413 let config_path = tmp.path().join("config.toml");
1414
1415 fs::write(&config_path, "this is not valid toml [[[").unwrap();
1416
1417 let result = load_config(tmp.path());
1418 assert!(matches!(result, Err(ConfigError::Toml { .. })));
1419 }
1420
1421 #[test]
1422 fn load_config_unknown_keys_is_error() {
1423 let tmp = TempDir::new().unwrap();
1424 let config_path = tmp.path().join("config.toml");
1425
1426 fs::write(
1428 &config_path,
1429 r#"
1430 unknown_key = "foo"
1431 "#,
1432 )
1433 .unwrap();
1434
1435 let result = load_config(tmp.path());
1436 assert!(matches!(result, Err(ConfigError::Toml { .. })));
1437 }
1438
1439 #[test]
1444 fn generate_css_includes_all_variables() {
1445 let colors = ColorConfig::default();
1446 let css = generate_color_css(&colors);
1447
1448 assert!(css.contains("--color-bg:"));
1450 assert!(css.contains("--color-text:"));
1451 assert!(css.contains("--color-text-muted:"));
1452 assert!(css.contains("--color-border:"));
1453 assert!(css.contains("--color-link:"));
1454 assert!(css.contains("--color-link-hover:"));
1455 }
1456
1457 #[test]
1458 fn generate_css_includes_dark_mode_media_query() {
1459 let colors = ColorConfig::default();
1460 let css = generate_color_css(&colors);
1461
1462 assert!(css.contains("@media (prefers-color-scheme: dark)"));
1463 }
1464
1465 #[test]
1466 fn color_scheme_default_is_light() {
1467 let scheme = ColorScheme::default();
1468 assert_eq!(scheme.background, "#ffffff");
1469 }
1470
1471 #[test]
1472 fn clamp_size_to_css() {
1473 let size = ClampSize {
1474 size: "3vw".to_string(),
1475 min: "1rem".to_string(),
1476 max: "2.5rem".to_string(),
1477 };
1478 assert_eq!(size.to_css(), "clamp(1rem, 3vw, 2.5rem)");
1479 }
1480
1481 #[test]
1482 fn generate_theme_css_includes_mat_variables() {
1483 let theme = ThemeConfig::default();
1484 let css = generate_theme_css(&theme);
1485 assert!(css.contains("--mat-x: clamp(1rem, 3vw, 2.5rem)"));
1486 assert!(css.contains("--mat-y: clamp(2rem, 6vw, 5rem)"));
1487 assert!(css.contains("--thumbnail-gap: 0.2rem"));
1488 assert!(css.contains("--grid-padding: 2rem"));
1489 }
1490
1491 #[test]
1492 fn parse_thumbnail_gap_and_grid_padding() {
1493 let toml = r#"
1494[theme]
1495thumbnail_gap = "0.5rem"
1496grid_padding = "1rem"
1497"#;
1498 let partial: PartialSiteConfig = toml::from_str(toml).unwrap();
1499 let config = SiteConfig::default().merge(partial);
1500 assert_eq!(config.theme.thumbnail_gap, "0.5rem");
1501 assert_eq!(config.theme.grid_padding, "1rem");
1502 }
1503
1504 #[test]
1505 fn default_thumbnail_gap_and_grid_padding() {
1506 let config = SiteConfig::default();
1507 assert_eq!(config.theme.thumbnail_gap, "0.2rem");
1508 assert_eq!(config.theme.grid_padding, "2rem");
1509 }
1510
1511 #[test]
1516 fn default_processing_config() {
1517 let config = ProcessingConfig::default();
1518 assert_eq!(config.max_processes, None);
1519 }
1520
1521 #[test]
1522 fn effective_threads_auto() {
1523 let config = ProcessingConfig {
1524 max_processes: None,
1525 };
1526 let threads = effective_threads(&config);
1527 let cores = std::thread::available_parallelism()
1528 .map(|n| n.get())
1529 .unwrap_or(1);
1530 assert_eq!(threads, cores);
1531 }
1532
1533 #[test]
1534 fn effective_threads_clamped_to_cores() {
1535 let config = ProcessingConfig {
1536 max_processes: Some(99999),
1537 };
1538 let threads = effective_threads(&config);
1539 let cores = std::thread::available_parallelism()
1540 .map(|n| n.get())
1541 .unwrap_or(1);
1542 assert_eq!(threads, cores);
1543 }
1544
1545 #[test]
1546 fn effective_threads_user_constrains_down() {
1547 let config = ProcessingConfig {
1548 max_processes: Some(1),
1549 };
1550 assert_eq!(effective_threads(&config), 1);
1551 }
1552
1553 #[test]
1554 fn parse_processing_config() {
1555 let toml = r#"
1556[processing]
1557max_processes = 4
1558"#;
1559 let config: SiteConfig = toml::from_str(toml).unwrap();
1560 assert_eq!(config.processing.max_processes, Some(4));
1561 }
1562
1563 #[test]
1564 fn parse_config_without_processing_uses_default() {
1565 let toml = r##"
1566[colors.light]
1567background = "#fafafa"
1568"##;
1569 let config: SiteConfig = toml::from_str(toml).unwrap();
1570 assert_eq!(config.processing.max_processes, None);
1571 }
1572
1573 #[test]
1582 fn unknown_key_rejected() {
1583 let toml_str = r#"
1584[images]
1585qualty = 90
1586"#;
1587 let result: Result<SiteConfig, _> = toml::from_str(toml_str);
1588 assert!(result.is_err());
1589 let err = result.unwrap_err().to_string();
1590 assert!(err.contains("unknown field"));
1591 }
1592
1593 #[test]
1594 fn unknown_section_rejected() {
1595 let toml_str = r#"
1596[imagez]
1597quality = 90
1598"#;
1599 let result: Result<SiteConfig, _> = toml::from_str(toml_str);
1600 assert!(result.is_err());
1601 }
1602
1603 #[test]
1604 fn unknown_nested_key_rejected() {
1605 let toml_str = r##"
1606[colors.light]
1607bg = "#fff"
1608"##;
1609 let result: Result<SiteConfig, _> = toml::from_str(toml_str);
1610 assert!(result.is_err());
1611 }
1612
1613 #[test]
1614 fn unknown_key_rejected_via_load_config() {
1615 let tmp = TempDir::new().unwrap();
1616 fs::write(
1617 tmp.path().join("config.toml"),
1618 r#"
1619[images]
1620qualty = 90
1621"#,
1622 )
1623 .unwrap();
1624
1625 let result = load_config(tmp.path());
1626 assert!(result.is_err());
1627 }
1628
1629 #[test]
1634 fn validate_quality_boundary_ok() {
1635 let mut config = SiteConfig::default();
1636 config.images.quality = 100;
1637 assert!(config.validate().is_ok());
1638
1639 config.images.quality = 0;
1640 assert!(config.validate().is_ok());
1641 }
1642
1643 #[test]
1644 fn validate_quality_too_high() {
1645 let mut config = SiteConfig::default();
1646 config.images.quality = 101;
1647 let err = config.validate().unwrap_err();
1648 assert!(err.to_string().contains("quality"));
1649 }
1650
1651 #[test]
1652 fn validate_aspect_ratio_zero() {
1653 let mut config = SiteConfig::default();
1654 config.thumbnails.aspect_ratio = [0, 5];
1655 assert!(config.validate().is_err());
1656
1657 config.thumbnails.aspect_ratio = [4, 0];
1658 assert!(config.validate().is_err());
1659 }
1660
1661 #[test]
1662 fn validate_sizes_empty() {
1663 let mut config = SiteConfig::default();
1664 config.images.sizes = vec![];
1665 assert!(config.validate().is_err());
1666 }
1667
1668 #[test]
1669 fn validate_default_config_passes() {
1670 let config = SiteConfig::default();
1671 assert!(config.validate().is_ok());
1672 }
1673
1674 #[test]
1675 fn load_config_validates_values() {
1676 let tmp = TempDir::new().unwrap();
1677 fs::write(
1678 tmp.path().join("config.toml"),
1679 r#"
1680[images]
1681quality = 200
1682"#,
1683 )
1684 .unwrap();
1685
1686 let result = load_config(tmp.path());
1687 assert!(matches!(result, Err(ConfigError::Validation(_))));
1688 }
1689
1690 #[test]
1695 fn load_partial_config_returns_none_when_no_file() {
1696 let tmp = TempDir::new().unwrap();
1697 let result = load_partial_config(tmp.path()).unwrap();
1698 assert!(result.is_none());
1699 }
1700
1701 #[test]
1702 fn load_partial_config_returns_value_when_file_exists() {
1703 let tmp = TempDir::new().unwrap();
1704 fs::write(
1705 tmp.path().join("config.toml"),
1706 r#"
1707[images]
1708quality = 85
1709"#,
1710 )
1711 .unwrap();
1712
1713 let result = load_partial_config(tmp.path()).unwrap();
1714 assert!(result.is_some());
1715 let partial = result.unwrap();
1716 assert_eq!(partial.images.unwrap().quality, Some(85));
1717 }
1718
1719 #[test]
1720 fn merge_with_no_overlay() {
1721 let base = SiteConfig::default();
1722 let config = base.merge(PartialSiteConfig::default());
1723 assert_eq!(config.images.quality, 90);
1724 assert_eq!(config.colors.light.background, "#ffffff");
1725 }
1726
1727 #[test]
1728 fn merge_with_overlay() {
1729 let base = SiteConfig::default();
1730 let toml = r#"
1731[images]
1732quality = 70
1733"#;
1734 let partial: PartialSiteConfig = toml::from_str(toml).unwrap();
1735 let config = base.merge(partial);
1736 assert_eq!(config.images.quality, 70);
1737 assert_eq!(config.images.sizes, vec![800, 1400, 2080]);
1739 }
1740
1741 #[test]
1742 fn load_config_validates_after_merge() {
1743 let tmp = TempDir::new().unwrap();
1744 fs::write(
1746 tmp.path().join("config.toml"),
1747 r#"
1748[images]
1749quality = 200
1750"#,
1751 )
1752 .unwrap();
1753
1754 let result = load_config(tmp.path());
1756 assert!(matches!(result, Err(ConfigError::Validation(_))));
1757 }
1758
1759 #[test]
1764 fn stock_config_toml_is_valid_toml() {
1765 let content = stock_config_toml();
1766 let _: toml::Value = toml::from_str(content).expect("stock config must be valid TOML");
1767 }
1768
1769 #[test]
1770 fn stock_config_toml_roundtrips_to_defaults() {
1771 let content = stock_config_toml();
1772 let config: SiteConfig = toml::from_str(content).unwrap();
1773 assert_eq!(config.images.quality, 90);
1774 assert_eq!(config.images.sizes, vec![800, 1400, 2080]);
1775 assert_eq!(config.thumbnails.aspect_ratio, [4, 5]);
1776 assert_eq!(config.colors.light.background, "#ffffff");
1777 assert_eq!(config.colors.dark.background, "#000000");
1778 assert_eq!(config.theme.thumbnail_gap, "0.2rem");
1779 }
1780
1781 #[test]
1782 fn stock_config_toml_contains_all_sections() {
1783 let content = stock_config_toml();
1784 assert!(content.contains("[thumbnails]"));
1785 assert!(content.contains("[full_index]"));
1786 assert!(content.contains("[images]"));
1787 assert!(content.contains("[theme]"));
1788 assert!(content.contains("[theme.mat_x]"));
1789 assert!(content.contains("[theme.mat_y]"));
1790 assert!(content.contains("[colors.light]"));
1791 assert!(content.contains("[colors.dark]"));
1792 assert!(content.contains("[processing]"));
1793 }
1794
1795 #[test]
1796 fn stock_defaults_equivalent_to_default_trait() {
1797 let config = SiteConfig::default();
1799 assert_eq!(config.images.quality, 90);
1800 }
1801
1802 #[test]
1807 fn merge_partial_theme_mat_x_only() {
1808 let partial: PartialSiteConfig = toml::from_str(
1809 r#"
1810 [theme.mat_x]
1811 size = "5vw"
1812 "#,
1813 )
1814 .unwrap();
1815 let config = SiteConfig::default().merge(partial);
1816
1817 assert_eq!(config.theme.mat_x.size, "5vw");
1819 assert_eq!(config.theme.mat_x.min, "1rem");
1821 assert_eq!(config.theme.mat_x.max, "2.5rem");
1822 assert_eq!(config.theme.mat_y.size, "6vw");
1824 assert_eq!(config.theme.mat_y.min, "2rem");
1825 assert_eq!(config.theme.mat_y.max, "5rem");
1826 assert_eq!(config.theme.thumbnail_gap, "0.2rem");
1828 assert_eq!(config.theme.grid_padding, "2rem");
1829 }
1830
1831 #[test]
1832 fn merge_partial_colors_light_only() {
1833 let partial: PartialSiteConfig = toml::from_str(
1834 r##"
1835 [colors.light]
1836 background = "#fafafa"
1837 text = "#222222"
1838 "##,
1839 )
1840 .unwrap();
1841 let config = SiteConfig::default().merge(partial);
1842
1843 assert_eq!(config.colors.light.background, "#fafafa");
1845 assert_eq!(config.colors.light.text, "#222222");
1846 assert_eq!(config.colors.light.text_muted, "#666666");
1848 assert_eq!(config.colors.light.border, "#e0e0e0");
1849 assert_eq!(config.colors.light.link, "#333333");
1850 assert_eq!(config.colors.light.link_hover, "#000000");
1851 assert_eq!(config.colors.dark.background, "#000000");
1853 assert_eq!(config.colors.dark.text, "#fafafa");
1854 }
1855
1856 #[test]
1857 fn merge_partial_font_weight_only() {
1858 let partial: PartialSiteConfig = toml::from_str(
1859 r#"
1860 [font]
1861 weight = "300"
1862 "#,
1863 )
1864 .unwrap();
1865 let config = SiteConfig::default().merge(partial);
1866
1867 assert_eq!(config.font.weight, "300");
1868 assert_eq!(config.font.font, "Noto Sans");
1869 assert_eq!(config.font.font_type, FontType::Sans);
1870 }
1871
1872 #[test]
1873 fn merge_multiple_sections_independently() {
1874 let partial: PartialSiteConfig = toml::from_str(
1875 r##"
1876 [colors.dark]
1877 background = "#1a1a1a"
1878
1879 [font]
1880 font = "Lora"
1881 font_type = "serif"
1882 "##,
1883 )
1884 .unwrap();
1885 let config = SiteConfig::default().merge(partial);
1886
1887 assert_eq!(config.colors.dark.background, "#1a1a1a");
1889 assert_eq!(config.colors.dark.text, "#fafafa");
1890 assert_eq!(config.colors.light.background, "#ffffff");
1891
1892 assert_eq!(config.font.font, "Lora");
1893 assert_eq!(config.font.font_type, FontType::Serif);
1894 assert_eq!(config.font.weight, "600"); assert_eq!(config.images.quality, 90);
1898 assert_eq!(config.thumbnails.aspect_ratio, [4, 5]);
1899 assert_eq!(config.theme.mat_x.size, "3vw");
1900 }
1901
1902 #[test]
1907 fn default_assets_dir() {
1908 let config = SiteConfig::default();
1909 assert_eq!(config.assets_dir, "assets");
1910 }
1911
1912 #[test]
1913 fn parse_custom_assets_dir() {
1914 let toml = r#"assets_dir = "site-assets""#;
1915 let partial: PartialSiteConfig = toml::from_str(toml).unwrap();
1916 let config = SiteConfig::default().merge(partial);
1917 assert_eq!(config.assets_dir, "site-assets");
1918 }
1919
1920 #[test]
1921 fn merge_preserves_default_assets_dir() {
1922 let partial: PartialSiteConfig = toml::from_str("[images]\nquality = 70\n").unwrap();
1923 let config = SiteConfig::default().merge(partial);
1924 assert_eq!(config.assets_dir, "assets");
1925 }
1926
1927 #[test]
1932 fn default_site_description_file() {
1933 let config = SiteConfig::default();
1934 assert_eq!(config.site_description_file, "site");
1935 }
1936
1937 #[test]
1938 fn parse_custom_site_description_file() {
1939 let toml = r#"site_description_file = "intro""#;
1940 let partial: PartialSiteConfig = toml::from_str(toml).unwrap();
1941 let config = SiteConfig::default().merge(partial);
1942 assert_eq!(config.site_description_file, "intro");
1943 }
1944
1945 #[test]
1946 fn merge_preserves_default_site_description_file() {
1947 let partial: PartialSiteConfig = toml::from_str("[images]\nquality = 70\n").unwrap();
1948 let config = SiteConfig::default().merge(partial);
1949 assert_eq!(config.site_description_file, "site");
1950 }
1951
1952 #[test]
1957 fn default_font_is_google() {
1958 let config = FontConfig::default();
1959 assert!(!config.is_local());
1960 assert!(config.stylesheet_url().is_some());
1961 assert!(config.font_face_css().is_none());
1962 }
1963
1964 #[test]
1965 fn local_font_has_no_stylesheet_url() {
1966 let config = FontConfig {
1967 source: Some("fonts/MyFont.woff2".to_string()),
1968 ..FontConfig::default()
1969 };
1970 assert!(config.is_local());
1971 assert!(config.stylesheet_url().is_none());
1972 }
1973
1974 #[test]
1975 fn local_font_generates_font_face_css() {
1976 let config = FontConfig {
1977 font: "My Custom Font".to_string(),
1978 weight: "400".to_string(),
1979 font_type: FontType::Sans,
1980 source: Some("fonts/MyFont.woff2".to_string()),
1981 };
1982 let css = config.font_face_css().unwrap();
1983 assert!(css.contains("@font-face"));
1984 assert!(css.contains(r#"font-family: "My Custom Font""#));
1985 assert!(css.contains(r#"url("/fonts/MyFont.woff2")"#));
1986 assert!(css.contains(r#"format("woff2")"#));
1987 assert!(css.contains("font-weight: 400"));
1988 assert!(css.contains("font-display: swap"));
1989 }
1990
1991 #[test]
1992 fn parse_font_with_source() {
1993 let toml = r#"
1994[font]
1995font = "My Font"
1996weight = "400"
1997source = "fonts/myfont.woff2"
1998"#;
1999 let partial: PartialSiteConfig = toml::from_str(toml).unwrap();
2000 let config = SiteConfig::default().merge(partial);
2001 assert_eq!(config.font.font, "My Font");
2002 assert_eq!(config.font.source.as_deref(), Some("fonts/myfont.woff2"));
2003 assert!(config.font.is_local());
2004 }
2005
2006 #[test]
2007 fn merge_font_source_preserves_other_fields() {
2008 let partial: PartialSiteConfig = toml::from_str(
2009 r#"
2010[font]
2011source = "fonts/custom.woff2"
2012"#,
2013 )
2014 .unwrap();
2015 let config = SiteConfig::default().merge(partial);
2016 assert_eq!(config.font.font, "Noto Sans"); assert_eq!(config.font.weight, "600"); assert_eq!(config.font.source.as_deref(), Some("fonts/custom.woff2"));
2019 }
2020
2021 #[test]
2022 fn font_format_detection() {
2023 assert_eq!(font_format_from_extension("font.woff2"), "woff2");
2024 assert_eq!(font_format_from_extension("font.woff"), "woff");
2025 assert_eq!(font_format_from_extension("font.ttf"), "truetype");
2026 assert_eq!(font_format_from_extension("font.otf"), "opentype");
2027 assert_eq!(font_format_from_extension("font.unknown"), "woff2");
2028 }
2029
2030 #[test]
2031 fn generate_font_css_includes_font_face_for_local() {
2032 let font = FontConfig {
2033 font: "Local Font".to_string(),
2034 weight: "700".to_string(),
2035 font_type: FontType::Serif,
2036 source: Some("fonts/local.woff2".to_string()),
2037 };
2038 let css = generate_font_css(&font);
2039 assert!(css.contains("@font-face"));
2040 assert!(css.contains("--font-family:"));
2041 assert!(css.contains("--font-weight: 700"));
2042 }
2043
2044 #[test]
2045 fn generate_font_css_no_font_face_for_google() {
2046 let font = FontConfig::default();
2047 let css = generate_font_css(&font);
2048 assert!(!css.contains("@font-face"));
2049 assert!(css.contains("--font-family:"));
2050 }
2051}