Skip to main content

mdbook_kanagawa_theme/
lib.rs

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
10/// mdBook preprocessor that injects a Kanagawa-themed landing page
11/// and wires Kanagawa CSS into the generated HTML output.
12pub struct KanagawaTheme;
13
14impl Default for KanagawaTheme {
15    /// Construct a `KanagawaTheme` using the default constructor.
16    fn default() -> Self {
17        Self::new()
18    }
19}
20
21impl KanagawaTheme {
22    /// Create a new `KanagawaTheme` preprocessor with no internal state.
23    #[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        // 1. Guard: If disabled, exit early
53        if cfg.disable_builtin_css {
54            return;
55        }
56
57        let css_dir = ctx.root.join("theme").join("css");
58
59        // 2. Guard: Handle dir creation error early
60        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        // 3. Main Logic:
66        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 // Reference instead of clone
101            );
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)] // This allows us to omit the [preprocessor.kanagawa-theme] table entirely
123pub 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, // Already has #[default] on the Enum
137
138    // Fields that represent "On/Off" or "Something/Nothing" should stay as Option/bool
139    pub css_import: Option<String>,
140    pub disable_builtin_css: bool, // Default is false
141
142    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
151// --- Helper functions for Serde defaults ---
152fn 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
171// --- Implement Default manually to use these same helpers ---
172impl 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}
191// #[derive(Debug, Default, Deserialize)]
192// /// Configuration loaded from `[preprocessor.kanagawa-theme]` in `book.toml`.
193// struct KanagawaConfig {
194//     /// Landing page main title
195//     landing_title: Option<String>,
196//     /// Landing page subtitle
197//     landing_subtitle: Option<String>,
198//     /// Column header for the "Latest posts" card.
199//     header_latest: Option<String>,
200//     /// Column header text for the "Recent notes" card.
201//     header_notes: Option<String>,
202//     /// Column header for the "Popular tags" card.
203//     header_tags: Option<String>,
204//     /// Optional CSS `@import` to prepend at the top of `theme/css/chrome.css`.
205//     css_import: Option<String>,
206//     /// If true, don't write `theme/css/chrome.css` at all
207//     disable_builtin_css: Option<bool>,
208//     /// Card layout preset: "compact" (default) or "wide"
209//     card_layout: CardLayout,
210//     /// Optional CSS `@import` to prepend at the top of the code theme CSS.
211//     code_css_import: Option<String>,
212//     /// If true, don't write `theme/css/kanagawa-code.css` at all.
213//     disable_builtin_code_css: Option<bool>,
214//     /// If true, append a small "Made with mdbook-kanagawa-theme" footer to pages.
215//     support_footer: Option<bool>,
216//     /// Optional URL for the footer link.
217//     support_footer_href: Option<String>,
218//     /// Optional footer text (defaults to "Made with mdbook-kanagawa-theme").
219//     support_footer_text: Option<String>,
220// }
221
222impl Preprocessor for KanagawaTheme {
223    /// Returns the preprocessor name as used in `book.toml`
224    /// under `[preprocessor.kanagawa-theme]`.
225    fn name(&self) -> &'static str {
226        "kanagawa-theme"
227    }
228
229    /// Apply the Kanagawa theme:
230    /// * read configuration from the `PreprocessorContext`,
231    /// * replace `index.md` with a dynamic landing page, and
232    /// * optionally write `theme/css/chrome.css` and `theme/css/kanagawa-code.css`.
233    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    /// Only support the HTML renderer, as the theme CSS and landing page
243    /// are specific to HTML output.
244    fn supports_renderer(&self, renderer: &str) -> Result<bool, Error> {
245        Ok(renderer == "html")
246    }
247}
248
249/// Build the HTML source for the Kanagawa landing page by
250/// filling `LANDING_PAGE_TEMPLATE` with configured titles, headers,
251fn 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    // Core replacements
260    html = html.replace("{{LANDING_TITLE}}", &cfg.landing_title);
261    html = html.replace("{{LANDING_SUBTITLE}}", &cfg.landing_subtitle);
262
263    // Header replacements - This is what was missing!
264    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    // Layout replacement
269    html = html.replace("{{GRID_CLASS}}", grid_class);
270
271    html
272}
273// fn build_landing_page(cfg: &KanagawaConfig) -> String {
274//     let title = cfg.landing_title.as_deref().unwrap_or("mdTheme");
275//     let subtitle = cfg
276//         .landing_subtitle
277//         .as_deref()
278//         .unwrap_or("A dope landing powered by rust");
279
280//     let header_latest = cfg.header_latest.as_deref().unwrap_or("Latest Posts");
281//     let header_notes = cfg.header_notes.as_deref().unwrap_or("Recent Notes");
282//     let header_tags = cfg.header_tags.as_deref().unwrap_or("Popular Tags");
283
284//     let grid_class = match cfg.card_layout {
285//         CardLayout::Wide => "grid grid-wide",
286//         CardLayout::Compact => "grid",
287//     };
288
289//     // let layout = cfg.card_layout.as_deref().unwrap_or("compact");
290
291//     // let grid_class = match layout {
292//     //     "wide" => "grid grid-wide",
293//     //     _ => "grid",
294//     // };
295
296//     let mut html = LANDING_PAGE_TEMPLATE.to_owned();
297//     html = html.replace("{{LANDING_TITLE}}", title);
298//     html = html.replace("{{LANDING_SUBTITLE}}", subtitle);
299//     html = html.replace("{{HEADER_LATEST}}", header_latest);
300//     html = html.replace("{{HEADER_NOTES}}", header_notes);
301//     html = html.replace("{{HEADER_TAGS}}", header_tags);
302//     html = html.replace("{{GRID_CLASS}}", grid_class);
303
304//     html
305// }
306
307/// Build a complete `chrome.css` by:
308/// 1. optionally inserting a user-provided `@import`,
309/// 2. appending Kanagawa CSS variables,
310/// 3. including the mdBook chrome template, and
311/// 4. layering additional Kanagawa styles on top.
312fn 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    // 1) Optional user CSS import (must be first)
318    if let Some(path) = cfg.css_import.as_deref() {
319        // path should be "theme/dracula.css" from book.toml
320        out.push_str("@import url(\"");
321        out.push_str(path);
322        out.push_str("\");\n\n");
323    }
324
325    // 2) Kanagawa variables for each theme class
326    out.push_str(KANAGAWA_VARS);
327    out.push_str("\n\n");
328
329    // 3) mdBook's stock chrome.css template
330    out.push_str(base);
331    out.push_str("\n\n");
332
333    // 4) Extra Kanagawa styles layered on top
334    out.push_str(KANAGAWA_EXTRA_CSS);
335    out.push('\n');
336
337    out
338}
339
340/// Build the Kanagawa code syntax CSS (for highlight.js).
341fn build_code_css(cfg: &KanagawaConfig) -> String {
342    let mut out = String::new();
343
344    // Optional user import at the very top.
345    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
357// Note: Handlebars {{...}} in here are just literal HTML, not evaluated;
358// the page is pure HTML + JS.
359const 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
481/// Theme variables: map the Kanagawa color palette onto mdBook theme classes,
482/// providing dark and light variants via CSS custom properties.
483const 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
535/// Extra Kanagawa styles layered on top of mdBook's chrome.css,
536/// including the animated wave background, landing layout, and card styling.
537const 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
718/// Kanagawa-flavored syntax highlighting for highlight.js.
719/// This assumes mdBook's default highlighter and class names.
720const 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";