1use mdbook_preprocessor::{
2 Preprocessor, PreprocessorContext,
3 book::{Book, BookItem},
4 errors::Error,
5};
6use serde::Deserialize;
7use std::fs;
8use std::string::String;
9
10pub struct KanagawaTheme;
13
14impl Default for KanagawaTheme {
15 fn default() -> Self {
17 Self::new()
18 }
19}
20
21impl KanagawaTheme {
22 #[must_use]
24 pub const fn new() -> Self {
25 Self
26 }
27
28 fn read_config(ctx: &PreprocessorContext) -> KanagawaConfig {
29 ctx.config
30 .get::<KanagawaConfig>("preprocessor.kanagawa-theme")
31 .unwrap_or_else(|e| {
32 eprintln!("kanagawa-theme: [Error] Failed to parse config, using defaults: {e}");
33 None
34 })
35 .unwrap_or_default()
36 }
37
38 fn inject_landing_page(book: &mut Book, cfg: &KanagawaConfig) {
39 let mut injected = false;
40 book.for_each_mut(|item| {
41 if let BookItem::Chapter(ch) = item
42 && ch.path.as_ref().and_then(|p| p.file_stem()) == Some("index".as_ref())
43 && !injected
44 {
45 ch.content = build_landing_page(cfg);
46 injected = true;
47 }
48 });
49 }
50
51 fn write_chrome_css(ctx: &PreprocessorContext, cfg: &KanagawaConfig) {
52 if cfg.disable_builtin_css {
54 return;
55 }
56
57 let css_dir = ctx.root.join("theme").join("css");
58
59 if let Err(e) = fs::create_dir_all(&css_dir) {
61 eprintln!("\x1b[31kanagawa-theme: failed to create theme/css dir\x1b[0m: {e}");
62 return;
63 }
64
65 let css = build_full_chrome_css(cfg);
67 if let Err(e) = fs::write(css_dir.join("chrome.css"), css) {
68 eprintln!("\x1b[31kanagawa-theme: failed to write theme/css/chrome.css\x1b[0m: {e}");
69 }
70 }
71
72 fn write_code_css(ctx: &PreprocessorContext, cfg: &KanagawaConfig) {
73 if !cfg.disable_builtin_code_css {
74 let css_dir = ctx.root.join("theme").join("css");
75 if let Err(e) = fs::create_dir_all(&css_dir) {
76 eprintln!(
77 "\x1b[31kanagawa-theme: failed to create theme/css dir for code CSS]x1b[0m: {e}"
78 );
79 } else {
80 let css = build_code_css(cfg);
81 if let Err(e) = fs::write(css_dir.join("kanagawa-code.css"), css) {
82 eprintln!(
83 "\x1b[31kanagawa-theme: failed to write theme/css/kanagawa-code.css\x1b[0m: {e}"
84 );
85 }
86 }
87 }
88 }
89
90 fn add_support_footer(book: &mut Book, cfg: &KanagawaConfig) {
91 if cfg.support_footer {
92 let href = cfg
93 .support_footer_href
94 .as_deref()
95 .unwrap_or("https://github.com/saylesss88/mdbook-kanagawa-theme");
96
97 let footer_html = format!(
98 r#"<footer id="kanagawa-support-footer" style="text-align:center; margin-top: 3rem; font-size: 0.85em; opacity: 0.75;"><p><a href="{href}">{text}</a></p></footer>"#,
99 href = href,
100 text = &cfg.support_footer_text );
102 book.for_each_mut(|item| {
103 if let BookItem::Chapter(ch) = item
104 && !ch.content.contains(r#"id="kanagawa-support-footer""#)
105 {
106 ch.content.push_str(&footer_html);
107 }
108 });
109 }
110 }
111}
112
113#[derive(Debug, Deserialize, Default, PartialEq, Eq)]
114#[serde(rename_all = "lowercase")]
115pub enum CardLayout {
116 #[default]
117 Compact,
118 Wide,
119}
120
121#[derive(Debug, Deserialize)]
122#[serde(default)] pub struct KanagawaConfig {
124 #[serde(default = "default_title")]
125 pub landing_title: String,
126 #[serde(default = "default_subtitle")]
127 pub landing_subtitle: String,
128
129 #[serde(default = "default_latest")]
130 pub header_latest: String,
131 #[serde(default = "default_notes")]
132 pub header_notes: String,
133 #[serde(default = "default_tags")]
134 pub header_tags: String,
135
136 pub card_layout: CardLayout, pub css_import: Option<String>,
140 pub disable_builtin_css: bool, pub code_css_import: Option<String>,
143 pub disable_builtin_code_css: bool,
144
145 pub support_footer: bool,
146 pub support_footer_href: Option<String>,
147 #[serde(default = "default_footer_text")]
148 pub support_footer_text: String,
149}
150
151fn default_title() -> String {
153 "mdTheme".into()
154}
155fn default_subtitle() -> String {
156 "A dope landing powered by rust".into()
157}
158fn default_latest() -> String {
159 "Latest Posts".into()
160}
161fn default_notes() -> String {
162 "Recent Notes".into()
163}
164fn default_tags() -> String {
165 "Popular Tags".into()
166}
167fn default_footer_text() -> String {
168 "Made with mdbook-kanagawa-theme".into()
169}
170
171impl Default for KanagawaConfig {
173 fn default() -> Self {
174 Self {
175 landing_title: default_title(),
176 landing_subtitle: default_subtitle(),
177 header_latest: default_latest(),
178 header_notes: default_notes(),
179 header_tags: default_tags(),
180 card_layout: CardLayout::default(),
181 css_import: None,
182 disable_builtin_css: false,
183 code_css_import: None,
184 disable_builtin_code_css: false,
185 support_footer: false,
186 support_footer_href: None,
187 support_footer_text: default_footer_text(),
188 }
189 }
190}
191impl Preprocessor for KanagawaTheme {
223 fn name(&self) -> &'static str {
226 "kanagawa-theme"
227 }
228
229 fn run(&self, ctx: &PreprocessorContext, mut book: Book) -> Result<Book, Error> {
234 let cfg = Self::read_config(ctx);
235 Self::inject_landing_page(&mut book, &cfg);
236 Self::write_chrome_css(ctx, &cfg);
237 Self::write_code_css(ctx, &cfg);
238 Self::add_support_footer(&mut book, &cfg);
239 Ok(book)
240 }
241
242 fn supports_renderer(&self, renderer: &str) -> Result<bool, Error> {
245 Ok(renderer == "html")
246 }
247}
248
249fn build_landing_page(cfg: &KanagawaConfig) -> String {
252 let grid_class = match cfg.card_layout {
253 CardLayout::Wide => "grid grid-wide",
254 CardLayout::Compact => "grid",
255 };
256
257 let mut html = LANDING_PAGE_TEMPLATE.to_owned();
258
259 html = html.replace("{{LANDING_TITLE}}", &cfg.landing_title);
261 html = html.replace("{{LANDING_SUBTITLE}}", &cfg.landing_subtitle);
262
263 html = html.replace("{{HEADER_LATEST}}", &cfg.header_latest);
265 html = html.replace("{{HEADER_NOTES}}", &cfg.header_notes);
266 html = html.replace("{{HEADER_TAGS}}", &cfg.header_tags);
267
268 html = html.replace("{{GRID_CLASS}}", grid_class);
270
271 html
272}
273fn build_full_chrome_css(cfg: &KanagawaConfig) -> String {
313 let base = include_str!("kanagawa_chrome_template.css");
314
315 let mut out = String::new();
316
317 if let Some(path) = cfg.css_import.as_deref() {
319 out.push_str("@import url(\"");
321 out.push_str(path);
322 out.push_str("\");\n\n");
323 }
324
325 out.push_str(KANAGAWA_VARS);
327 out.push_str("\n\n");
328
329 out.push_str(base);
331 out.push_str("\n\n");
332
333 out.push_str(KANAGAWA_EXTRA_CSS);
335 out.push('\n');
336
337 out
338}
339
340fn build_code_css(cfg: &KanagawaConfig) -> String {
342 let mut out = String::new();
343
344 if let Some(path) = cfg.code_css_import.as_deref() {
346 out.push_str("@import url(\"");
347 out.push_str(path);
348 out.push_str("\");\n\n");
349 }
350
351 out.push_str(KANAGAWA_CODE_CSS);
352 out.push('\n');
353
354 out
355}
356
357const LANDING_PAGE_TEMPLATE: &str = r#"<!-- kanagawa landing -->
360<div class="wave-bg">
361<div class="wave"></div>
362<div class="wave"></div>
363<div class="wave"></div>
364</div>
365
366<div class="landing">
367<h1 class="title">{{LANDING_TITLE}}</h1>
368<p class="subtitle">{{LANDING_SUBTITLE}}</p>
369
370<div class="{{GRID_CLASS}}">
371 <div class="card">
372 <h2>{{HEADER_LATEST}}</h2>
373 <div id="latest-posts"><em>Loading...</em></div>
374 </div>
375 <div class="card">
376 <h2>{{HEADER_NOTES}}</h2>
377 <div id="recent-notes"><em>Loading...</em></div>
378 </div>
379 <div class="card">
380 <h2>{{HEADER_TAGS}}</h2>
381 <div id="tag-cloud" class="tag-cloud"></div>
382 </div>
383</div>
384</div>
385
386<script>
387 (function () {
388 if (!window.CONTENT_COLLECTIONS) {
389 console.warn("kanagawa-theme: window.CONTENT_COLLECTIONS not found; is mdbook-content-loader enabled?");
390 return;
391 }
392
393 var data = window.CONTENT_COLLECTIONS;
394 var entries = data.entries || [];
395 var collections = data.collections || {};
396
397 var link = function (p) {
398 return (p.path || "").replace(/\.md(?:own|arkdown)?$/i, ".html");
399 };
400
401 // Render latest posts into #latest-posts (used on load and when filtering)
402 function renderLatest(posts) {
403 var latestEl = document.getElementById("latest-posts");
404 if (!latestEl) return;
405
406 var list = posts.slice(0, 6);
407 latestEl.innerHTML = list.length
408 ? list.map(function (p) {
409 return (
410 '<div class="post-preview">' +
411 '<h3><a href="' + link(p) + '">' + (p.title || p.path) + '</a></h3>' +
412 (p.date ? '<time>' + new Date(p.date).toISOString().slice(0,10) + '</time>' : '') +
413 '<div class="preview">' + (p.preview_html || "") + "</div>" +
414 "</div>"
415 );
416 }).join("")
417 : "<p>No posts yet.</p>";
418 }
419
420 // Initial latest posts (blog, then fallback to posts)
421 var initialPosts = (collections.blog || collections.posts || []);
422 renderLatest(initialPosts);
423
424 // Notes
425 var notes = (collections.notes || []).slice(0, 8);
426 var notesEl = document.getElementById("recent-notes");
427 if (notesEl) {
428 notesEl.innerHTML = notes.length
429 ? notes.map(function (p) {
430 return '• <a href="' + link(p) + '">' + (p.title || p.path) + "</a><br>";
431 }).join("")
432 : "<p>No notes yet.</p>";
433 }
434
435 // Tag cloud
436 var tagCounts = {};
437 (entries || []).forEach(function (p) {
438 (p.tags || []).forEach(function (t) {
439 tagCounts[t] = (tagCounts[t] || 0) + 1;
440 });
441 });
442
443 var tags = Object.entries(tagCounts)
444 .sort(function (a, b) { return b[1] - a[1]; })
445 .slice(0, 15);
446
447 var tagEl = document.getElementById("tag-cloud");
448 if (tagEl) {
449 // Render tags as clickable buttons
450 tagEl.innerHTML = tags.map(function (pair) {
451 var tag = pair[0], n = pair[1];
452 return '<button class="tag-pill" type="button" data-tag="' + tag + '">' +
453 tag + " (" + n + ")" +
454 "</button>";
455 }).join("");
456
457 // Clicking a tag filters "Latest posts" by that tag
458 tagEl.addEventListener("click", function (ev) {
459 var btn = ev.target.closest(".tag-pill");
460 if (!btn) return;
461 var tag = btn.getAttribute("data-tag");
462
463 var source = (collections.blog || collections.posts || []);
464 var filtered = source.filter(function (p) {
465 return (p.tags || []).includes(tag);
466 });
467
468 renderLatest(filtered.length ? filtered : source);
469 });
470 }
471 })();
472</script>
473
474<style>
475 .post-preview { margin-bottom: 1.5rem; padding-bottom: 1rem; border-bottom: 1px solid rgba(126,156,216,0.2); }
476 .post-preview:last-child { border-bottom: none; }
477 .preview { margin-top: 0.5rem; opacity: 0.9; font-size: 0.95em; }
478</style>
479"#;
480
481const KANAGAWA_VARS: &str = r":root.navy,
484.navy,
485html.navy,
486body.navy {
487 /* Dark Kanagawa Wave-like */
488 --bg: #1F1F28; /* sumiInk1: default background */
489 --bg-alt: #2A2A37; /* sumiInk2: lighter background (cards) */
490 --fg: #DCD7BA; /* fujiWhite: main foreground */
491 --fg-light: #9CABCA; /* springViolet2: softer text / subtitles */
492
493 /* Waves use blue-ish Kanagawa tones */
494 --wave-1: #1F1F28; /* background */
495 --wave-2: #223249; /* waveBlue1 */
496 --wave-3: #2D4F67; /* waveBlue2 */
497
498 --accent: #7E9CD8; /* crystalBlue: functions / titles */
499 --red: #E46876; /* waveRed */
500 --blue: #7FB4CA; /* springBlue */
501
502 --heading: #7AA89F; /* waveAqua2 */
503 --links: #7FB4CA; /* springBlue: inline links */
504 --bold: #C8C093; /* oldWhite */
505 --sidebar-title: #E46876; /* Kanagawa Wave Red */
506 --sidebar-active: var(--sidebar-title)
507
508}
509
510:root.light,
511.light,
512html.light,
513body.light,
514:root.rust,
515.rust,
516html.rust,
517body.rust {
518 /* Simple light variant, slightly bluish */
519 --bg: #F5F5F5;
520 --bg-alt: #E8E8E8;
521 --fg: #283548;
522 --fg-light: #4C5A6E;
523 --wave-1: #E0E8F0;
524 --wave-2: #C8D8E8;
525 --wave-3: #A8C8E0;
526 --accent: #345E8F;
527 --red: #C4746E;
528 --blue: #7FB4CA;
529
530 /* kanagawa aqua for headings */
531 --heading: #7AA89F; /* waveAqua2 */
532}
533";
534
535const KANAGAWA_EXTRA_CSS: &str = r"
538.sidebar .chapter li.part-title {
539 color: var(--sidebar-title, var(--red));
540 font-weight: 700;
541 letter-spacing: 0.02em;
542}
543
544/* If your category headers are just text nodes inside the li */
545#sidebar .chapter li.chapter-item > a.active {
546 color: var(--sidebar-title, var(--red)) !important;
547 font-weight: 600;
548}
549
550body {
551 background: var(--bg);
552 color: var(--fg);
553}
554
555a {
556 color: var(--accent);
557 text-decoration: none;
558}
559
560a:hover {
561 text-decoration: underline;
562}
563
564/* Bold / strong emphasis */
565.content strong,
566.content b {
567 color: var(--bold) !important;
568 font-weight: 600;
569}
570
571.content a:link,
572.content a:visited {
573 color: var(--links) !important;
574}
575
576.content a:hover,
577.content a:focus {
578 color: var(--accent) !important;
579 text-decoration: underline;
580}
581
582.content h1,
583.content h2,
584.content h3,
585.content h4,
586.content h5,
587.content h6,
588.content .header:link,
589.content .header:visited,
590.content .header:hover,
591.content .header:active {
592 color: var(--heading) !important;
593}
594.content h1 { font-weight: 500; }
595.content h2 { font-weight: 500; }
596
597
598.wave-bg {
599 position: fixed;
600 inset: 0;
601 z-index: -1;
602 background: var(--bg);
603 // overflow: hidden;
604}
605
606.wave {
607 position: absolute;
608 bottom: 0;
609 left: -50%;
610 width: 200%;
611 height: 40vh;
612 background: var(--wave-1);
613 border-radius: 45%;
614 animation: wave 20s linear infinite;
615}
616
617.wave:nth-child(2) {
618 background: var(--wave-2);
619 animation-duration: 25s;
620 opacity: 0.7;
621}
622
623.wave:nth-child(3) {
624 background: var(--wave-3);
625 animation-duration: 30s;
626 opacity: 0.5;
627}
628
629@keyframes wave {
630 from { transform: translateX(0); }
631 to { transform: translateX(-50%); }
632}
633
634.landing {
635 min-height: 100vh;
636 display: flex;
637 flex-direction: column;
638 justify-content: center;
639 align-items: center;
640 text-align: center;
641 padding: 2rem;
642}
643
644.title {
645 font-size: 4.5rem;
646 font-weight: 300;
647 margin: 0 0 1rem;
648 color: var(--accent);
649 text-shadow: 0 2px 10px rgba(0,0,0,0.3);
650}
651
652.subtitle {
653 font-size: 1.6rem;
654 max-width: 700px;
655 color: var(--fg-light);
656 margin-bottom: 4rem;
657}
658
659.grid {
660 display: grid;
661 grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
662 gap: 2rem;
663 max-width: 1200px;
664 width: 100%;
665 margin: 0 auto;
666}
667
668.grid-wide {
669 max-width: 1600px;
670}
671
672@media (min-width: 1200px) {
673 .grid-wide {
674 grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
675 }
676}
677
678.card {
679 background: var(--bg-alt);
680 padding: 2rem;
681 border-radius: 8px;
682 box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
683 transition: transform 0.2s ease, border-color 0.2s ease;
684 // border: 1px solid rgba(126,156,216,0.2);
685 // backdrop-filter: blur(10px);
686}
687
688.card:hover {
689 border-color: var(--accent);
690 transform: translateY(-2px)
691}
692
693.card h2 {
694 margin-top: 0;
695 color: var(--accent);
696 border-bottom: 1px solid var(--accent);
697 padding-bottom: 0.5rem;
698}
699
700.tag-cloud .tag-pill {
701 display: inline-block;
702 background: rgba(126,156,216,0.15);
703 color: var(--accent);
704 padding: 0.5rem 1rem;
705 margin: 0.4rem;
706 border-radius: 2rem;
707 font-size: 0.9rem;
708 transition: all 0.2s;
709 cursor: pointer;
710}
711
712.tag-cloud .tag-pill:hover {
713 background: var(--accent);
714 color: var(--bg);
715}
716";
717
718const KANAGAWA_CODE_CSS: &str = r"
721/* Block code: slightly lifted off main bg/card */
722pre code.hljs {
723 background: #2a3146; /* pick a shade with clear contrast vs --bg and --bg-alt */
724 color: var(--fg);
725 border: 1px solid rgba(0, 0, 0, 0.5);
726 border-radius: 6px;
727}
728
729/* Inline highlighted code (no box) */
730:not(pre) > code.hljs {
731 background: transparent;
732 border: none;
733 padding: 0;
734}
735
736/* Keywords, control flow */
737.hljs-keyword,
738.hljs-selector-tag,
739.hljs-literal {
740 color: #E46876; /* waveRed */
741}
742
743/* Strings, attributes */
744.hljs-string,
745.hljs-attr,
746.hljs-template-tag {
747 color: #98BB6C; /* springGreen */
748}
749
750/* Numbers, builtins, types */
751.hljs-number,
752.hljs-built_in,
753.hljs-type {
754 color: #7E9CD8; /* crystalBlue */
755}
756
757/* Comments */
758.hljs-comment {
759 color: #727169;
760 font-style: italic;
761}
762
763/* Function names */
764.hljs-title,
765.hljs-title.function_ {
766 color: #7FB4CA; /* springBlue */
767}
768
769/* Constants, variables */
770.hljs-variable,
771.hljs-constant,
772.hljs-symbol {
773 color: #FFA066; /* surimiOrange */
774}
775
776/* Punctuation / operators */
777.hljs-operator,
778.hljs-punctuation {
779 color: var(--fg);
780}
781";