Skip to main content

rustpress_theme/
lib.rs

1use std::fs;
2use std::path::Path;
3
4use anyhow::{Context, Result};
5use rustpress_md::Heading;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct ThemeConfig {
9    pub skin: String,
10    pub allow_switch: bool,
11    pub github_url: String,
12}
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct SiteRender {
16    pub title: String,
17    pub lang: String,
18    pub base: String,
19    pub home_href: String,
20    pub theme: ThemeConfig,
21    pub search_enabled: bool,
22    pub access_enabled: bool,
23    pub access_password: String,
24    pub password_hint: String,
25    pub top_nav: Vec<TopNavItem>,
26    pub nav: Vec<NavItem>,
27    pub languages: Vec<LanguageOption>,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct TopNavItem {
32    pub title: String,
33    pub href: Option<String>,
34    pub items: Vec<TopNavLink>,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct TopNavLink {
39    pub title: String,
40    pub href: String,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct NavItem {
45    pub title: String,
46    pub href: String,
47    pub active_prefix: String,
48    pub items: Vec<NavItem>,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct LanguageOption {
53    pub label: String,
54    pub href: String,
55    pub current: bool,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct PageRender {
60    pub title: String,
61    pub route: String,
62    pub html: String,
63    pub headings: Vec<Heading>,
64    pub masked: bool,
65    pub search: bool,
66}
67
68pub fn write_theme_assets(out_dir: &Path, site: &SiteRender) -> Result<()> {
69    let assets = out_dir.join("assets");
70    fs::create_dir_all(&assets)
71        .with_context(|| format!("failed to create {}", assets.display()))?;
72    fs::write(assets.join("rustpress.css"), css())?;
73    fs::write(assets.join("rustpress.js"), js(site))?;
74    Ok(())
75}
76
77pub fn render_page(site: &SiteRender, page: &PageRender) -> String {
78    let title = if page.title == site.title {
79        site.title.clone()
80    } else {
81        format!("{} | {}", page.title, site.title)
82    };
83    let base = site.base.trim_end_matches('/');
84    let asset_base = if base.is_empty() { "" } else { base };
85    let search_markup = if site.search_enabled {
86        r#"<button class="rp-icon-button" data-rp-search-open aria-label="Open search" title="Search">
87<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"></circle><path d="m20 20-3.5-3.5"></path></svg>
88</button>"#
89    } else {
90        ""
91    };
92    let skin_switcher = if site.theme.allow_switch {
93        render_skin_switcher(site)
94    } else {
95        String::new()
96    };
97    let github_link = render_github_link(site);
98    let language_switcher = render_language_switcher(site);
99    let access_mask = if site.access_enabled && page.masked {
100        render_access_mask(site)
101    } else {
102        String::new()
103    };
104
105    format!(
106        r#"<!doctype html>
107<html lang="{lang}" data-rp-skin="{skin}">
108<head>
109<meta charset="utf-8">
110<meta name="viewport" content="width=device-width, initial-scale=1">
111<title>{title}</title>
112<link rel="stylesheet" href="{asset_base}/assets/rustpress.css">
113</head>
114<body data-rp-route="{route}" data-rp-masked="{masked}">
115<header class="rp-topbar">
116  <a class="rp-brand" href="{base_href}">{site_title}</a>
117  {top_nav}
118  <div class="rp-topbar-actions">
119    {search_markup}
120    {language_switcher}
121    {skin_switcher}
122    <button class="rp-icon-button rp-menu-button" data-rp-menu aria-label="Toggle navigation" title="Navigation">
123      <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"></path></svg>
124    </button>
125    {github_link}
126  </div>
127</header>
128<div class="rp-shell">
129  <aside class="rp-sidebar" data-rp-sidebar>
130    <nav aria-label="Main navigation">
131      {nav}
132    </nav>
133  </aside>
134  <main class="rp-main" data-rp-content>
135    <article class="rp-doc">
136      {content}
137    </article>
138    {toc}
139  </main>
140</div>
141{search_dialog}
142{access_mask}
143<script type="module" src="{asset_base}/assets/rustpress.js"></script>
144{mermaid_script}
145</body>
146</html>
147"#,
148        lang = escape_attr(&site.lang),
149        skin = escape_attr(&site.theme.skin),
150        title = escape_html(&title),
151        asset_base = asset_base,
152        route = escape_attr(&page.route),
153        masked = page.masked,
154        base_href = escape_attr(&href_for(site, &site.home_href)),
155        site_title = escape_html(&site.title),
156        top_nav = render_top_nav(site, page),
157        search_markup = search_markup,
158        language_switcher = language_switcher,
159        skin_switcher = skin_switcher,
160        github_link = github_link,
161        nav = render_nav(site, page),
162        content = page.html,
163        toc = render_toc(page),
164        search_dialog = render_search_dialog(site),
165        access_mask = access_mask,
166        mermaid_script = mermaid_script(),
167    )
168}
169
170fn mermaid_script() -> &'static str {
171    r##"<script type="module">
172import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs";
173
174const mermaidBlocks = Array.from(document.querySelectorAll("pre.mermaid"));
175const mermaidSources = new Map(mermaidBlocks.map(block => [block, block.textContent || ""]));
176
177function mermaidColor(name, fallback) {
178  const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
179  return value || fallback;
180}
181
182function mermaidConfig() {
183  const isDark = document.documentElement.dataset.rpSkin === "dark";
184  const background = mermaidColor("--rp-mermaid-bg", isDark ? "#151a20" : "#ffffff");
185  const text = mermaidColor("--rp-mermaid-text", isDark ? "#edf2f4" : "#1d2528");
186  const line = mermaidColor("--rp-mermaid-line", isDark ? "#9fb4bd" : "#6c7a80");
187  const node = mermaidColor("--rp-mermaid-node-bg", isDark ? "#17382f" : "#e8f5f1");
188  const nodeBorder = mermaidColor("--rp-mermaid-node-border", isDark ? "#66c2a5" : "#176b5b");
189  const cluster = mermaidColor("--rp-mermaid-cluster-bg", isDark ? "#101820" : "#f2f8f6");
190  const clusterBorder = mermaidColor("--rp-mermaid-cluster-border", isDark ? "#3d5c54" : "#b7ccc5");
191  const label = mermaidColor("--rp-mermaid-label-bg", isDark ? "#181d23" : "#ffffff");
192
193  return {
194    startOnLoad: false,
195    theme: "base",
196    themeVariables: {
197      darkMode: isDark,
198      background,
199      primaryColor: node,
200      primaryTextColor: text,
201      primaryBorderColor: nodeBorder,
202      secondaryColor: cluster,
203      secondaryTextColor: text,
204      secondaryBorderColor: clusterBorder,
205      tertiaryColor: label,
206      tertiaryTextColor: text,
207      tertiaryBorderColor: clusterBorder,
208      mainBkg: node,
209      nodeBorder,
210      nodeTextColor: text,
211      textColor: text,
212      titleColor: text,
213      lineColor: line,
214      defaultLinkColor: line,
215      clusterBkg: cluster,
216      clusterBorder,
217      edgeLabelBackground: label,
218      labelTextColor: text,
219      actorBkg: node,
220      actorBorder: nodeBorder,
221      actorTextColor: text,
222      actorLineColor: line,
223      signalColor: line,
224      signalTextColor: text,
225      noteBkg: label,
226      noteTextColor: text,
227      noteBorderColor: clusterBorder
228    }
229  };
230}
231
232async function renderMermaid() {
233  if (mermaidBlocks.length === 0) return;
234  for (const block of mermaidBlocks) {
235    block.removeAttribute("data-processed");
236    block.textContent = mermaidSources.get(block) || "";
237  }
238  mermaid.initialize(mermaidConfig());
239  try {
240    await mermaid.run({ nodes: mermaidBlocks });
241  } catch (error) {
242    console.warn("RustPress Mermaid render failed", error);
243  }
244}
245
246renderMermaid();
247document.addEventListener("rustpress:skinchange", renderMermaid);
248</script>"##
249}
250
251fn render_top_nav(site: &SiteRender, page: &PageRender) -> String {
252    if site.top_nav.is_empty() {
253        return String::new();
254    }
255
256    let items = site
257        .top_nav
258        .iter()
259        .map(|item| {
260            if item.items.is_empty() {
261                let Some(href) = &item.href else {
262                    return String::new();
263                };
264                let active = link_is_active(&page.route, href);
265                return format!(
266                    r#"<a class="rp-topnav-link{active}" href="{href}">{title}</a>"#,
267                    active = if active { " is-active" } else { "" },
268                    href = href_for(site, href),
269                    title = escape_html(&item.title)
270                );
271            }
272
273            let active = item
274                .href
275                .as_ref()
276                .is_some_and(|href| link_is_active(&page.route, href))
277                || item
278                    .items
279                    .iter()
280                    .any(|child| link_is_active(&page.route, &child.href));
281            let trigger = if let Some(href) = &item.href {
282                format!(
283                    r#"<a class="rp-topnav-trigger" href="{href}" aria-haspopup="true">{title}</a>"#,
284                    href = href_for(site, href),
285                    title = escape_html(&item.title)
286                )
287            } else {
288                format!(
289                    r#"<span class="rp-topnav-trigger" tabindex="0" aria-haspopup="true">{title}</span>"#,
290                    title = escape_html(&item.title)
291                )
292            };
293            let links = item
294                .items
295                .iter()
296                .map(|child| {
297                    let child_active = link_is_active(&page.route, &child.href);
298                    format!(
299                        r#"<a class="rp-topnav-menu-link{active}" href="{href}">{title}</a>"#,
300                        active = if child_active { " is-active" } else { "" },
301                        href = href_for(site, &child.href),
302                        title = escape_html(&child.title)
303                    )
304                })
305                .collect::<Vec<_>>()
306                .join("\n");
307
308            format!(
309                r#"<div class="rp-topnav-group{active}">{trigger}<div class="rp-topnav-menu">{links}</div></div>"#,
310                active = if active { " is-active" } else { "" },
311                trigger = trigger,
312                links = links
313            )
314        })
315        .filter(|item| !item.is_empty())
316        .collect::<Vec<_>>()
317        .join("\n");
318
319    format!(r#"<nav class="rp-topnav" aria-label="Top navigation">{items}</nav>"#)
320}
321
322fn render_nav(site: &SiteRender, page: &PageRender) -> String {
323    render_nav_items(site, page, &site.nav, 0)
324}
325
326fn render_nav_items(
327    site: &SiteRender,
328    page: &PageRender,
329    items: &[NavItem],
330    level: usize,
331) -> String {
332    items
333        .iter()
334        .map(|item| {
335            let active = nav_item_is_active(item, &page.route);
336            if item.items.is_empty() {
337                return format!(
338                    r#"<a class="rp-nav-link rp-nav-level-{level}{active}" href="{href}">{title}</a>"#,
339                    level = level,
340                    active = if active { " is-active" } else { "" },
341                    href = href_for(site, &item.href),
342                    title = escape_html(&item.title)
343                );
344            }
345
346            let children = render_nav_items(site, page, &item.items, level + 1);
347            format!(
348                r#"<div class="rp-nav-group{active}"><a class="rp-nav-group-title" href="{href}">{title}</a><div class="rp-nav-children">{children}</div></div>"#,
349                active = if active { " is-active" } else { "" },
350                href = href_for(site, &item.href),
351                title = escape_html(&item.title),
352                children = children
353            )
354        })
355        .collect::<Vec<_>>()
356        .join("\n")
357}
358
359fn nav_item_is_active(item: &NavItem, route: &str) -> bool {
360    route == item.href
361        || (item.href != "/" && route.starts_with(&item.active_prefix))
362        || item
363            .items
364            .iter()
365            .any(|child| nav_item_is_active(child, route))
366}
367
368fn render_toc(page: &PageRender) -> String {
369    let links = page
370        .headings
371        .iter()
372        .filter(|heading| heading.level > 1 && heading.level < 4)
373        .map(|heading| {
374            format!(
375                r##"<a class="rp-toc-link rp-toc-level-{level}" href="#{anchor}">{title}</a>"##,
376                level = heading.level,
377                anchor = escape_attr(&heading.anchor),
378                title = escape_html(&heading.text)
379            )
380        })
381        .collect::<Vec<_>>()
382        .join("\n");
383
384    if links.is_empty() {
385        String::new()
386    } else {
387        format!(r#"<aside class="rp-toc" aria-label="On this page">{links}</aside>"#)
388    }
389}
390
391fn render_skin_switcher(site: &SiteRender) -> String {
392    let options = ["light", "dark"]
393        .iter()
394        .map(|skin| {
395            let selected = skin == &site.theme.skin.as_str();
396            format!(
397                r#"<button class="rp-select-option{selected}" type="button" role="option" aria-selected="{aria_selected}" data-rp-skin-option data-rp-skin-value="{value}">{label}</button>"#,
398                value = escape_attr(skin),
399                selected = if selected { " is-selected" } else { "" },
400                aria_selected = selected,
401                label = skin_label(skin)
402            )
403        })
404        .collect::<Vec<_>>()
405        .join("");
406    format!(
407        r#"<div class="rp-select rp-skin-select" data-rp-select data-rp-skin-select title="Color theme"><button class="rp-select-button" type="button" data-rp-select-trigger data-rp-skin-trigger aria-haspopup="listbox" aria-expanded="false"><span class="rp-select-label">Theme</span><span class="rp-select-value" data-rp-skin-current>{current}</span></button><div class="rp-select-menu" role="listbox">{options}</div></div>"#,
408        current = skin_label(&site.theme.skin)
409    )
410}
411
412fn render_github_link(site: &SiteRender) -> String {
413    let href = site.theme.github_url.trim();
414    if href.is_empty() {
415        return String::new();
416    }
417
418    format!(
419        r#"<a class="rp-icon-button rp-github-link" href="{href}" target="_blank" rel="noopener noreferrer" aria-label="GitHub repository" title="GitHub">
420<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2C6.48 2 2 6.59 2 12.25c0 4.53 2.87 8.37 6.84 9.72.5.09.68-.22.68-.49 0-.24-.01-.88-.01-1.72-2.78.62-3.37-1.37-3.37-1.37-.45-1.18-1.11-1.49-1.11-1.49-.91-.64.07-.63.07-.63 1 .07 1.53 1.06 1.53 1.06.89 1.56 2.34 1.11 2.91.85.09-.66.35-1.11.63-1.37-2.22-.26-4.55-1.14-4.55-5.06 0-1.12.39-2.03 1.03-2.75-.1-.26-.45-1.3.1-2.71 0 0 .84-.28 2.75 1.05A9.32 9.32 0 0 1 12 7.01c.85 0 1.71.12 2.51.35 1.91-1.33 2.75-1.05 2.75-1.05.55 1.41.2 2.45.1 2.71.64.72 1.03 1.63 1.03 2.75 0 3.93-2.34 4.8-4.57 5.05.36.32.68.95.68 1.91 0 1.38-.01 2.49-.01 2.83 0 .27.18.59.69.49A10.22 10.22 0 0 0 22 12.25C22 6.59 17.52 2 12 2Z"></path></svg>
421</a>"#,
422        href = escape_attr(href)
423    )
424}
425
426fn skin_label(skin: &str) -> &'static str {
427    match skin {
428        "dark" => "Dark",
429        _ => "Light",
430    }
431}
432
433fn render_language_switcher(site: &SiteRender) -> String {
434    if site.languages.is_empty() {
435        return String::new();
436    }
437
438    let options = site
439        .languages
440        .iter()
441        .map(|language| {
442            let selected = language.current;
443            format!(
444                r#"<button class="rp-select-option{selected}" type="button" role="option" aria-selected="{aria_selected}" data-rp-language-option data-rp-language-href="{value}">{label}</button>"#,
445                value = escape_attr(&href_for(site, &language.href)),
446                selected = if selected { " is-selected" } else { "" },
447                aria_selected = selected,
448                label = escape_html(&language.label)
449            )
450        })
451        .collect::<Vec<_>>()
452        .join("");
453    let current = site
454        .languages
455        .iter()
456        .find(|language| language.current)
457        .map(|language| language.label.as_str())
458        .unwrap_or("Language");
459    format!(
460        r#"<div class="rp-select rp-language-select" data-rp-select data-rp-language-select title="Language"><button class="rp-select-button" type="button" data-rp-select-trigger data-rp-language-trigger aria-haspopup="listbox" aria-expanded="false"><span class="rp-select-label">Language</span><span class="rp-select-value" data-rp-language-current>{current}</span></button><div class="rp-select-menu" role="listbox">{options}</div></div>"#,
461        current = escape_html(current)
462    )
463}
464
465fn render_search_dialog(site: &SiteRender) -> String {
466    if !site.search_enabled {
467        return String::new();
468    }
469
470    r#"<dialog class="rp-search" data-rp-search>
471  <form method="dialog" class="rp-search-box">
472    <input data-rp-search-input type="search" autocomplete="off" placeholder="Search docs">
473    <button class="rp-icon-button" value="close" aria-label="Close search" title="Close">
474      <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"></path></svg>
475    </button>
476  </form>
477  <div class="rp-search-results" data-rp-search-results></div>
478</dialog>"#
479        .to_string()
480}
481
482fn render_access_mask(site: &SiteRender) -> String {
483    format!(
484        r#"<div class="rp-access-mask" data-rp-access-mask>
485  <form class="rp-access-panel" data-rp-access-form>
486    <h2>Masked content</h2>
487    <p>This is a front-end viewing mask. Static files still contain the page content.</p>
488    <input data-rp-access-input type="password" placeholder="{hint}" aria-label="{hint}" autocomplete="current-password" required>
489    <p class="rp-access-error" data-rp-access-error hidden>Incorrect password.</p>
490    <button type="submit">View page</button>
491  </form>
492</div>"#,
493        hint = escape_attr(&site.password_hint)
494    )
495}
496
497fn href_for(site: &SiteRender, href: &str) -> String {
498    if href.starts_with("http://")
499        || href.starts_with("https://")
500        || href.starts_with("mailto:")
501        || href.starts_with('#')
502    {
503        href.to_string()
504    } else if href == "/" {
505        site.base.clone()
506    } else if href.starts_with('/') {
507        format!("{}{}", site.base, href.trim_start_matches('/'))
508    } else {
509        format!("{}{}", site.base, href)
510    }
511}
512
513fn link_is_active(route: &str, href: &str) -> bool {
514    href.starts_with('/') && (route == href || (href != "/" && route.starts_with(href)))
515}
516
517fn css() -> &'static str {
518    r#":root {
519  color-scheme: light;
520  --rp-bg: #f7f7f4;
521  --rp-panel: #ffffff;
522  --rp-text: #1d2528;
523  --rp-muted: #607179;
524  --rp-line: #dbe1de;
525  --rp-accent: #176b5b;
526  --rp-accent-soft: #dff0eb;
527  --rp-danger: #b42318;
528  --rp-code-bg: #172026;
529  --rp-code-text: #edf7f6;
530  --rp-shadow: 0 12px 30px rgb(27 40 42 / 12%);
531  --rp-grid-line: rgb(23 107 91 / 5%);
532  --rp-mermaid-bg: #ffffff;
533  --rp-mermaid-text: #1d2528;
534  --rp-mermaid-line: #6c7a80;
535  --rp-mermaid-node-bg: #e8f5f1;
536  --rp-mermaid-node-border: #176b5b;
537  --rp-mermaid-cluster-bg: #f2f8f6;
538  --rp-mermaid-cluster-border: #b7ccc5;
539  --rp-mermaid-label-bg: #ffffff;
540  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
541}
542
543[data-rp-skin="dark"] {
544  color-scheme: dark;
545  --rp-bg: #111418;
546  --rp-panel: #181d23;
547  --rp-text: #edf2f4;
548  --rp-muted: #a0abb3;
549  --rp-line: #2d3741;
550  --rp-accent: #66c2a5;
551  --rp-accent-soft: #12392f;
552  --rp-danger: #fca5a5;
553  --rp-code-bg: #0b1117;
554  --rp-code-text: #f2fbff;
555  --rp-shadow: 0 14px 34px rgb(0 0 0 / 34%);
556  --rp-grid-line: rgb(255 255 255 / 5%);
557  --rp-mermaid-bg: #151a20;
558  --rp-mermaid-text: #edf2f4;
559  --rp-mermaid-line: #9fb4bd;
560  --rp-mermaid-node-bg: #17382f;
561  --rp-mermaid-node-border: #66c2a5;
562  --rp-mermaid-cluster-bg: #101820;
563  --rp-mermaid-cluster-border: #3d5c54;
564  --rp-mermaid-label-bg: #181d23;
565}
566
567* { box-sizing: border-box; }
568html { scroll-padding-top: 88px; }
569body {
570  margin: 0;
571  background:
572    linear-gradient(var(--rp-grid-line) 1px, transparent 1px),
573    linear-gradient(90deg, var(--rp-grid-line) 1px, transparent 1px),
574    var(--rp-bg);
575  background-size: 32px 32px;
576  color: var(--rp-text);
577  line-height: 1.65;
578  font-size: 16px;
579}
580a { color: var(--rp-accent); text-decoration: none; }
581a:hover { text-decoration: underline; }
582
583.rp-topbar {
584  position: sticky;
585  top: 0;
586  z-index: 20;
587  height: 64px;
588  display: flex;
589  align-items: center;
590  justify-content: space-between;
591  padding: 0 24px;
592  border-bottom: 1px solid var(--rp-line);
593  background: color-mix(in srgb, var(--rp-panel) 92%, transparent);
594  backdrop-filter: blur(16px);
595}
596.rp-brand {
597  min-width: 0;
598  color: var(--rp-text);
599  font-weight: 750;
600  font-size: 18px;
601  overflow: hidden;
602  text-overflow: ellipsis;
603  white-space: nowrap;
604}
605.rp-topnav {
606  flex: 0 1 auto;
607  min-width: 0;
608  display: flex;
609  align-items: center;
610  justify-content: flex-end;
611  gap: 4px;
612  margin: 0 12px 0 auto;
613}
614.rp-topnav-link,
615.rp-topnav-trigger {
616  height: 36px;
617  display: flex;
618  align-items: center;
619  padding: 0 10px;
620  border-radius: 8px;
621  color: var(--rp-muted);
622  font-size: 14px;
623  line-height: 1;
624  white-space: nowrap;
625  cursor: pointer;
626}
627.rp-topnav-link:hover,
628.rp-topnav-link.is-active,
629.rp-topnav-group.is-active > .rp-topnav-trigger,
630.rp-topnav-group:hover > .rp-topnav-trigger,
631.rp-topnav-group:focus-within > .rp-topnav-trigger {
632  background: var(--rp-accent-soft);
633  color: var(--rp-accent);
634  text-decoration: none;
635}
636.rp-topnav-group {
637  position: relative;
638  height: 64px;
639  display: flex;
640  align-items: center;
641}
642.rp-topnav-trigger::after {
643  content: "";
644  width: 6px;
645  height: 6px;
646  margin-left: 8px;
647  border-right: 1.5px solid currentColor;
648  border-bottom: 1.5px solid currentColor;
649  transform: translateY(-2px) rotate(45deg);
650}
651.rp-topnav-menu {
652  position: absolute;
653  top: 100%;
654  right: 0;
655  z-index: 30;
656  min-width: 190px;
657  display: none;
658  gap: 2px;
659  padding: 8px;
660  border: 1px solid var(--rp-line);
661  border-radius: 8px;
662  background: var(--rp-panel);
663  box-shadow: var(--rp-shadow);
664}
665.rp-topnav-group:hover > .rp-topnav-menu,
666.rp-topnav-group:focus-within > .rp-topnav-menu {
667  display: grid;
668}
669.rp-topnav-menu-link {
670  display: block;
671  padding: 8px 10px;
672  border-radius: 8px;
673  color: var(--rp-muted);
674  font-size: 14px;
675  line-height: 1.35;
676}
677.rp-topnav-menu-link:hover,
678.rp-topnav-menu-link.is-active {
679  background: var(--rp-accent-soft);
680  color: var(--rp-accent);
681  text-decoration: none;
682}
683.rp-topbar-actions {
684  display: flex;
685  gap: 8px;
686  align-items: center;
687  min-width: 0;
688}
689.rp-icon-button {
690  width: 36px;
691  height: 36px;
692  display: inline-grid;
693  place-items: center;
694  border: 1px solid var(--rp-line);
695  background: var(--rp-panel);
696  color: var(--rp-text);
697  border-radius: 8px;
698  cursor: pointer;
699}
700.rp-icon-button svg {
701  width: 18px;
702  height: 18px;
703  fill: none;
704  stroke: currentColor;
705  stroke-width: 2;
706  stroke-linecap: round;
707}
708.rp-icon-button:hover {
709  border-color: var(--rp-accent);
710  color: var(--rp-accent);
711  text-decoration: none;
712}
713.rp-github-link svg {
714  fill: currentColor;
715  stroke: none;
716}
717.rp-select {
718  position: relative;
719  flex: 0 0 auto;
720}
721.rp-select-button {
722  appearance: none;
723  -webkit-appearance: none;
724  min-width: 124px;
725  height: 36px;
726  display: flex;
727  align-items: center;
728  justify-content: space-between;
729  gap: 8px;
730  padding: 0 32px 0 10px;
731  border: 1px solid color-mix(in srgb, var(--rp-line) 82%, var(--rp-muted));
732  background: linear-gradient(180deg, var(--rp-panel), color-mix(in srgb, var(--rp-panel) 88%, var(--rp-bg)));
733  border-radius: 8px;
734  color: var(--rp-muted);
735  font-size: 13px;
736  line-height: 1;
737  box-shadow: inset 0 -1px 0 rgb(0 0 0 / 4%);
738  cursor: pointer;
739  transition: border-color 140ms ease, box-shadow 140ms ease, color 140ms ease;
740}
741.rp-language-select .rp-select-button { min-width: 152px; }
742.rp-select-button:hover,
743.rp-select.is-open .rp-select-button,
744.rp-select-button:focus-visible {
745  border-color: var(--rp-accent);
746  color: var(--rp-accent);
747  box-shadow: 0 0 0 3px color-mix(in srgb, var(--rp-accent-soft) 72%, transparent);
748  outline: 0;
749}
750.rp-select-button::after {
751  content: "";
752  position: absolute;
753  right: 12px;
754  top: 50%;
755  width: 7px;
756  height: 7px;
757  border-right: 1.5px solid currentColor;
758  border-bottom: 1.5px solid currentColor;
759  transform: translateY(-65%) rotate(45deg);
760  pointer-events: none;
761}
762.rp-select-label {
763  color: currentColor;
764  font-size: 12px;
765  font-weight: 650;
766}
767.rp-select-value {
768  min-width: 0;
769  overflow: hidden;
770  color: var(--rp-text);
771  text-overflow: ellipsis;
772  white-space: nowrap;
773}
774.rp-select-menu {
775  position: absolute;
776  top: calc(100% + 8px);
777  right: 0;
778  z-index: 35;
779  min-width: 100%;
780  display: none;
781  gap: 2px;
782  padding: 6px;
783  border: 1px solid var(--rp-line);
784  border-radius: 8px;
785  background: var(--rp-panel);
786  box-shadow: var(--rp-shadow);
787}
788.rp-select.is-open .rp-select-menu { display: grid; }
789.rp-select-option {
790  width: 100%;
791  min-width: 132px;
792  min-height: 32px;
793  display: block;
794  padding: 7px 10px;
795  border: 0;
796  border-radius: 7px;
797  background: transparent;
798  color: var(--rp-muted);
799  font: inherit;
800  font-size: 13px;
801  line-height: 1.35;
802  text-align: left;
803  white-space: nowrap;
804  cursor: pointer;
805}
806.rp-select-option:hover,
807.rp-select-option:focus-visible,
808.rp-select-option.is-selected {
809  background: var(--rp-accent-soft);
810  color: var(--rp-accent);
811  outline: 0;
812}
813.rp-menu-button { display: none; }
814
815.rp-shell {
816  display: grid;
817  grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
818  max-width: 1440px;
819  margin: 0 auto;
820}
821.rp-sidebar {
822  position: sticky;
823  top: 64px;
824  height: calc(100vh - 64px);
825  padding: 24px 16px 24px 24px;
826  border-right: 1px solid var(--rp-line);
827  overflow: auto;
828}
829.rp-nav-group {
830  display: grid;
831  gap: 4px;
832  margin: 4px 0 12px;
833}
834.rp-nav-group-title {
835  display: block;
836  padding: 7px 10px;
837  color: var(--rp-text);
838  border-radius: 8px;
839  font-size: 13px;
840  font-weight: 750;
841  line-height: 1.35;
842}
843.rp-nav-group-title:hover,
844.rp-nav-group.is-active > .rp-nav-group-title {
845  background: var(--rp-accent-soft);
846  color: var(--rp-accent);
847  text-decoration: none;
848}
849.rp-nav-children {
850  display: grid;
851  gap: 2px;
852  margin-left: 10px;
853  padding-left: 10px;
854  border-left: 1px solid var(--rp-line);
855}
856.rp-nav-link {
857  display: block;
858  padding: 8px 10px;
859  color: var(--rp-muted);
860  border-radius: 8px;
861  font-size: 14px;
862  line-height: 1.35;
863}
864.rp-nav-level-1 {
865  font-size: 13px;
866}
867.rp-nav-link:hover,
868.rp-nav-link.is-active {
869  background: var(--rp-accent-soft);
870  color: var(--rp-accent);
871  text-decoration: none;
872}
873
874.rp-main {
875  display: grid;
876  grid-template-columns: minmax(0, 820px) minmax(180px, 240px);
877  gap: 42px;
878  min-width: 0;
879  padding: 48px 32px 96px;
880}
881.rp-doc {
882  min-width: 0;
883}
884.rp-doc > :first-child { margin-top: 0; }
885.rp-doc h1,
886.rp-doc h2,
887.rp-doc h3,
888.rp-doc h4 {
889  line-height: 1.2;
890  margin: 2em 0 0.65em;
891  overflow-wrap: anywhere;
892}
893.rp-doc h1 { font-size: 42px; margin-top: 0; }
894.rp-doc h2 { font-size: 28px; padding-top: 12px; border-top: 1px solid var(--rp-line); }
895.rp-doc h3 { font-size: 22px; }
896.rp-doc p,
897.rp-doc li { color: var(--rp-text); }
898.rp-doc blockquote {
899  margin: 20px 0;
900  padding: 4px 18px;
901  border-left: 4px solid var(--rp-accent);
902  background: var(--rp-accent-soft);
903}
904.rp-doc table {
905  width: 100%;
906  border-collapse: collapse;
907  display: block;
908  overflow-x: auto;
909}
910.rp-doc th,
911.rp-doc td {
912  border: 1px solid var(--rp-line);
913  padding: 8px 10px;
914}
915.rp-doc code {
916  padding: 2px 5px;
917  background: var(--rp-accent-soft);
918  border-radius: 5px;
919  font-size: 0.9em;
920}
921.rp-doc pre {
922  overflow: auto;
923  padding: 16px;
924  border-radius: 8px;
925  background: var(--rp-code-bg);
926  color: var(--rp-code-text);
927  box-shadow: var(--rp-shadow);
928}
929.rp-doc pre code {
930  padding: 0;
931  background: transparent;
932  color: inherit;
933  border-radius: 0;
934}
935.rp-code {
936  position: relative;
937  margin: 20px 0;
938  overflow: hidden;
939  border: 1px solid color-mix(in srgb, var(--rp-code-bg) 78%, var(--rp-line));
940  border-radius: 8px;
941  background: var(--rp-code-bg);
942  box-shadow: var(--rp-shadow);
943}
944.rp-code-header {
945  display: flex;
946  align-items: center;
947  min-height: 34px;
948  padding: 0 54px 0 14px;
949  border-bottom: 1px solid rgb(255 255 255 / 8%);
950  background: color-mix(in srgb, var(--rp-code-bg) 82%, black);
951  color: rgb(237 247 246 / 72%);
952  font-size: 12px;
953  font-weight: 700;
954  line-height: 1;
955}
956.rp-code-copy {
957  appearance: none;
958  -webkit-appearance: none;
959  position: absolute;
960  top: 8px;
961  right: 8px;
962  z-index: 2;
963  width: 30px;
964  height: 30px;
965  display: inline-grid;
966  place-items: center;
967  padding: 0;
968  border: 1px solid rgb(255 255 255 / 16%);
969  border-radius: 7px;
970  background: color-mix(in srgb, var(--rp-code-bg) 78%, white);
971  color: rgb(237 247 246 / 76%);
972  cursor: pointer;
973  transition: border-color 140ms ease, background 140ms ease, color 140ms ease, opacity 140ms ease;
974}
975.rp-code-copy svg {
976  width: 16px;
977  height: 16px;
978  fill: none;
979  stroke: currentColor;
980  stroke-width: 2;
981  stroke-linecap: round;
982  stroke-linejoin: round;
983}
984.rp-code-copy-check {
985  display: none;
986}
987.rp-code-copy:hover,
988.rp-code-copy:focus-visible {
989  border-color: var(--rp-accent);
990  background: color-mix(in srgb, var(--rp-code-bg) 64%, var(--rp-accent));
991  color: var(--rp-code-text);
992}
993.rp-code-copy:focus-visible {
994  outline: 2px solid var(--rp-accent);
995  outline-offset: 2px;
996}
997.rp-code-copy:disabled {
998  cursor: default;
999  opacity: 0.9;
1000}
1001.rp-code-copy[data-rp-copied="true"] {
1002  border-color: var(--rp-accent);
1003  background: var(--rp-accent-soft);
1004  color: var(--rp-accent);
1005}
1006.rp-code-copy[data-rp-copied="true"] .rp-code-copy-icon {
1007  display: none;
1008}
1009.rp-code-copy[data-rp-copied="true"] .rp-code-copy-check {
1010  display: block;
1011}
1012.rp-code pre {
1013  margin: 0;
1014  padding-right: 56px;
1015  border-radius: 0;
1016  box-shadow: none;
1017}
1018.rp-code code {
1019  display: block;
1020  min-width: max-content;
1021}
1022.rp-code-line-numbers pre {
1023  display: grid;
1024  grid-template-columns: minmax(42px, auto) minmax(0, max-content);
1025  align-items: start;
1026  padding-left: 0;
1027}
1028.rp-code-lines {
1029  display: block;
1030  min-width: 42px;
1031  padding: 0 12px 0 16px;
1032  border-right: 1px solid rgb(255 255 255 / 12%);
1033  color: rgb(237 247 246 / 42%);
1034  font: inherit;
1035  line-height: inherit;
1036  text-align: right;
1037  user-select: none;
1038  white-space: pre;
1039}
1040.rp-code-line-numbers .rp-code-content {
1041  padding-left: 16px;
1042}
1043.rp-doc pre.mermaid {
1044  background: var(--rp-mermaid-bg);
1045  color: var(--rp-mermaid-text);
1046  border: 1px solid var(--rp-mermaid-cluster-border);
1047}
1048.rp-doc pre.mermaid svg {
1049  max-width: 100%;
1050  height: auto;
1051}
1052.heading-anchor {
1053  display: inline-block;
1054  width: 0.85em;
1055  margin-left: -0.85em;
1056  opacity: 0;
1057  color: var(--rp-accent);
1058}
1059h1:hover .heading-anchor,
1060h2:hover .heading-anchor,
1061h3:hover .heading-anchor,
1062h4:hover .heading-anchor,
1063h5:hover .heading-anchor,
1064h6:hover .heading-anchor { opacity: 1; text-decoration: none; }
1065.rp-toc {
1066  position: sticky;
1067  top: 88px;
1068  height: max-content;
1069  max-height: calc(100vh - 112px);
1070  overflow: auto;
1071  padding-left: 16px;
1072  border-left: 1px solid var(--rp-line);
1073}
1074.rp-toc-link {
1075  display: block;
1076  padding: 4px 0;
1077  color: var(--rp-muted);
1078  font-size: 13px;
1079  line-height: 1.35;
1080}
1081.rp-toc-level-3 { padding-left: 14px; }
1082
1083.rp-search::backdrop { background: rgb(20 28 31 / 45%); }
1084.rp-search {
1085  width: min(720px, calc(100vw - 32px));
1086  max-height: min(720px, calc(100vh - 48px));
1087  border: 1px solid var(--rp-line);
1088  border-radius: 8px;
1089  padding: 0;
1090  background: var(--rp-panel);
1091  color: var(--rp-text);
1092  box-shadow: var(--rp-shadow);
1093}
1094.rp-search-box {
1095  display: flex;
1096  gap: 8px;
1097  padding: 12px;
1098  border-bottom: 1px solid var(--rp-line);
1099}
1100.rp-search-box input {
1101  flex: 1;
1102  min-width: 0;
1103  border: 1px solid var(--rp-line);
1104  border-radius: 8px;
1105  padding: 0 12px;
1106  font: inherit;
1107}
1108.rp-search-results {
1109  display: grid;
1110  gap: 4px;
1111  padding: 8px;
1112}
1113.rp-search-result {
1114  display: block;
1115  padding: 10px 12px;
1116  border-radius: 8px;
1117  color: var(--rp-text);
1118}
1119.rp-search-result:hover {
1120  background: var(--rp-accent-soft);
1121  text-decoration: none;
1122}
1123.rp-search-result span {
1124  display: block;
1125  color: var(--rp-muted);
1126  font-size: 13px;
1127}
1128.rp-access-mask {
1129  position: fixed;
1130  inset: 0;
1131  z-index: 40;
1132  display: grid;
1133  place-items: center;
1134  padding: 24px;
1135  background: color-mix(in srgb, var(--rp-bg) 86%, transparent);
1136  backdrop-filter: blur(14px);
1137}
1138.rp-access-mask.is-unlocked { display: none; }
1139.rp-access-panel {
1140  width: min(420px, 100%);
1141  padding: 24px;
1142  background: var(--rp-panel);
1143  border: 1px solid var(--rp-line);
1144  border-radius: 8px;
1145  box-shadow: var(--rp-shadow);
1146}
1147.rp-access-panel h2 {
1148  margin: 0 0 8px;
1149  font-size: 22px;
1150}
1151.rp-access-panel p {
1152  margin: 0 0 18px;
1153  color: var(--rp-muted);
1154}
1155.rp-access-panel input {
1156  width: 100%;
1157  height: 40px;
1158  border: 1px solid var(--rp-line);
1159  border-radius: 8px;
1160  padding: 0 10px;
1161  font: inherit;
1162}
1163.rp-access-panel input[aria-invalid="true"] {
1164  border-color: var(--rp-danger);
1165}
1166.rp-access-error {
1167  margin: 8px 0 0;
1168  color: var(--rp-danger);
1169  font-size: 13px;
1170  line-height: 1.4;
1171}
1172.rp-access-panel button {
1173  margin-top: 12px;
1174  width: 100%;
1175  height: 40px;
1176  border: 0;
1177  border-radius: 8px;
1178  background: var(--rp-accent);
1179  color: white;
1180  font-weight: 700;
1181  cursor: pointer;
1182}
1183
1184@media (max-width: 1080px) {
1185  .rp-main { grid-template-columns: minmax(0, 1fr); }
1186  .rp-toc { display: none; }
1187}
1188
1189@media (max-width: 760px) {
1190  .rp-topbar { padding: 0 12px; }
1191  .rp-topnav { display: none; }
1192  .rp-select-button { min-width: 74px; max-width: 96px; padding: 0 28px 0 8px; }
1193  .rp-language-select .rp-select-button { min-width: 92px; max-width: 112px; }
1194  .rp-select-label { display: none; }
1195  .rp-select-menu { right: 0; }
1196  .rp-select-option { min-width: 118px; }
1197  .rp-menu-button { display: inline-grid; }
1198  .rp-shell { display: block; }
1199  .rp-sidebar {
1200    display: none;
1201    position: fixed;
1202    inset: 64px 0 auto 0;
1203    z-index: 18;
1204    height: calc(100vh - 64px);
1205    background: var(--rp-panel);
1206    border-right: 0;
1207    border-bottom: 1px solid var(--rp-line);
1208    padding: 16px;
1209  }
1210  .rp-sidebar.is-open { display: block; }
1211  .rp-main { padding: 32px 18px 72px; }
1212  .rp-doc h1 { font-size: 32px; }
1213  .rp-doc h2 { font-size: 24px; }
1214  .heading-anchor { margin-left: 0; width: auto; opacity: 1; margin-right: 6px; }
1215}
1216"#
1217}
1218
1219fn js(site: &SiteRender) -> String {
1220    format!(
1221        r##"const base = {base:?};
1222const defaultSkin = {skin:?};
1223const accessPassword = {access_password:?};
1224const supportedSkins = ["light", "dark"];
1225
1226const root = document.documentElement;
1227const savedSkin = localStorage.getItem("rustpress:skin");
1228root.dataset.rpSkin = supportedSkins.includes(savedSkin) ? savedSkin : defaultSkin;
1229if (!supportedSkins.includes(root.dataset.rpSkin)) root.dataset.rpSkin = "light";
1230
1231const selectMenus = Array.from(document.querySelectorAll("[data-rp-select]"));
1232for (const selectMenu of selectMenus) {{
1233  const trigger = selectMenu.querySelector("[data-rp-select-trigger]");
1234  if (!trigger) continue;
1235  trigger.addEventListener("click", event => {{
1236    event.stopPropagation();
1237    const wasOpen = selectMenu.classList.contains("is-open");
1238    closeSelectMenus();
1239    if (!wasOpen) openSelectMenu(selectMenu);
1240  }});
1241}}
1242document.addEventListener("click", closeSelectMenus);
1243document.addEventListener("keydown", event => {{
1244  if (event.key === "Escape") closeSelectMenus();
1245}});
1246
1247const skinSelect = document.querySelector("[data-rp-skin-select]");
1248if (skinSelect) {{
1249  const skinCurrent = skinSelect.querySelector("[data-rp-skin-current]");
1250  const skinOptions = Array.from(skinSelect.querySelectorAll("[data-rp-skin-option]"));
1251  setSkin(root.dataset.rpSkin, false);
1252  for (const option of skinOptions) {{
1253    option.addEventListener("click", event => {{
1254      event.stopPropagation();
1255      setSkin(option.dataset.rpSkinValue, true);
1256      closeSelectMenus();
1257    }});
1258  }}
1259
1260  function setSkin(skin, persist) {{
1261    if (!supportedSkins.includes(skin)) skin = "light";
1262    const previousSkin = root.dataset.rpSkin;
1263    root.dataset.rpSkin = skin;
1264    if (skinCurrent) skinCurrent.textContent = skin === "dark" ? "Dark" : "Light";
1265    for (const option of skinOptions) {{
1266      const selected = option.dataset.rpSkinValue === skin;
1267      option.classList.toggle("is-selected", selected);
1268      option.setAttribute("aria-selected", selected ? "true" : "false");
1269    }}
1270    if (persist) localStorage.setItem("rustpress:skin", skin);
1271    if (previousSkin !== skin) {{
1272      document.dispatchEvent(new CustomEvent("rustpress:skinchange", {{ detail: {{ skin }} }}));
1273    }}
1274  }}
1275}}
1276
1277const languageSelect = document.querySelector("[data-rp-language-select]");
1278if (languageSelect) {{
1279  for (const option of languageSelect.querySelectorAll("[data-rp-language-option]")) {{
1280    option.addEventListener("click", event => {{
1281      event.stopPropagation();
1282      if (option.dataset.rpLanguageHref) window.location.href = option.dataset.rpLanguageHref;
1283    }});
1284  }}
1285}}
1286
1287function openSelectMenu(selectMenu) {{
1288  selectMenu.classList.add("is-open");
1289  const trigger = selectMenu.querySelector("[data-rp-select-trigger]");
1290  if (trigger) trigger.setAttribute("aria-expanded", "true");
1291}}
1292
1293function closeSelectMenus() {{
1294  for (const selectMenu of selectMenus) {{
1295    selectMenu.classList.remove("is-open");
1296    const trigger = selectMenu.querySelector("[data-rp-select-trigger]");
1297    if (trigger) trigger.setAttribute("aria-expanded", "false");
1298  }}
1299}}
1300
1301const menu = document.querySelector("[data-rp-menu]");
1302const sidebar = document.querySelector("[data-rp-sidebar]");
1303if (menu && sidebar) {{
1304  menu.addEventListener("click", () => sidebar.classList.toggle("is-open"));
1305}}
1306
1307const codeCopyButtons = Array.from(document.querySelectorAll("[data-rp-copy-code]"));
1308for (const button of codeCopyButtons) {{
1309  button.addEventListener("click", async () => {{
1310    const codeBlock = button.closest(".rp-code");
1311    const codeContent = codeBlock ? codeBlock.querySelector(".rp-code-content") : null;
1312    if (!codeContent) return;
1313    try {{
1314      await copyCodeText(codeContent.textContent || "");
1315      showCodeCopied(button);
1316    }} catch (error) {{
1317      console.warn("RustPress copy code failed", error);
1318    }}
1319  }});
1320}}
1321
1322async function copyCodeText(text) {{
1323  if (window.navigator && navigator.clipboard && typeof navigator.clipboard.writeText === "function") {{
1324    try {{
1325      await navigator.clipboard.writeText(text);
1326      return;
1327    }} catch (error) {{
1328      fallbackCopyText(text);
1329      return;
1330    }}
1331  }}
1332  fallbackCopyText(text);
1333}}
1334
1335function fallbackCopyText(text) {{
1336  const textarea = document.createElement("textarea");
1337  textarea.value = text;
1338  textarea.setAttribute("readonly", "");
1339  textarea.style.position = "fixed";
1340  textarea.style.top = "-1000px";
1341  textarea.style.left = "-1000px";
1342  textarea.style.opacity = "0";
1343  document.body.appendChild(textarea);
1344  textarea.focus();
1345  textarea.select();
1346  textarea.setSelectionRange(0, textarea.value.length);
1347  let copied = false;
1348  try {{
1349    copied = typeof document.execCommand === "function" && document.execCommand("copy");
1350  }} finally {{
1351    textarea.remove();
1352  }}
1353  if (!copied) throw new Error("copy command failed");
1354}}
1355
1356function showCodeCopied(button) {{
1357  button.dataset.rpCopied = "true";
1358  button.disabled = true;
1359  button.setAttribute("aria-label", "Copied");
1360  button.setAttribute("title", "Copied");
1361  window.clearTimeout(button.rpCopyReset);
1362  button.rpCopyReset = window.setTimeout(() => {{
1363    delete button.dataset.rpCopied;
1364    button.disabled = false;
1365    button.setAttribute("aria-label", "Copy code");
1366    button.setAttribute("title", "Copy code");
1367  }}, 1500);
1368}}
1369
1370const mask = document.querySelector("[data-rp-access-mask]");
1371const accessForm = document.querySelector("[data-rp-access-form]");
1372const accessInput = document.querySelector("[data-rp-access-input]");
1373const accessError = document.querySelector("[data-rp-access-error]");
1374if (mask && accessForm) {{
1375  const key = "rustpress:access:" + location.pathname;
1376  if (sessionStorage.getItem(key) === "unlocked") mask.classList.add("is-unlocked");
1377  accessForm.addEventListener("submit", event => {{
1378    event.preventDefault();
1379    if (!accessInput || accessInput.value !== accessPassword) {{
1380      if (accessError) accessError.hidden = false;
1381      if (accessInput) {{
1382        accessInput.setAttribute("aria-invalid", "true");
1383        accessInput.focus();
1384      }}
1385      return;
1386    }}
1387    sessionStorage.setItem(key, "unlocked");
1388    mask.classList.add("is-unlocked");
1389  }});
1390  if (accessInput) {{
1391    accessInput.addEventListener("input", () => {{
1392      accessInput.removeAttribute("aria-invalid");
1393      if (accessError) accessError.hidden = true;
1394    }});
1395  }}
1396}}
1397
1398const searchDialog = document.querySelector("[data-rp-search]");
1399const searchOpen = document.querySelector("[data-rp-search-open]");
1400const searchInput = document.querySelector("[data-rp-search-input]");
1401const searchResults = document.querySelector("[data-rp-search-results]");
1402let searchIndexPromise;
1403
1404if (searchDialog && searchOpen && searchInput && searchResults) {{
1405  let lastShiftPress = 0;
1406
1407  searchOpen.addEventListener("click", () => {{
1408    openSearch();
1409  }});
1410
1411  document.addEventListener("keydown", event => {{
1412    if (event.key !== "Shift" || event.repeat) return;
1413    const now = Date.now();
1414    if (now - lastShiftPress <= 500) {{
1415      event.preventDefault();
1416      openSearch();
1417      lastShiftPress = 0;
1418    }} else {{
1419      lastShiftPress = now;
1420    }}
1421  }});
1422
1423  function openSearch() {{
1424    if (typeof searchDialog.showModal === "function") searchDialog.showModal();
1425    else searchDialog.setAttribute("open", "");
1426    searchInput.focus();
1427    loadSearchIndex();
1428  }}
1429
1430  searchInput.addEventListener("input", () => runSearch(searchInput.value));
1431}}
1432
1433function loadSearchIndex() {{
1434  if (!searchIndexPromise) {{
1435    searchIndexPromise = fetch(joinBase("assets/search-index.json"))
1436      .then(response => response.ok ? response.json() : Promise.reject(new Error("search index missing")))
1437      .catch(() => ({{ pages: [] }}));
1438  }}
1439  return searchIndexPromise;
1440}}
1441
1442function runSearch(query) {{
1443  const normalized = query.trim().toLowerCase();
1444  if (!normalized) {{
1445    searchResults.innerHTML = "";
1446    return;
1447  }}
1448  loadSearchIndex().then(index => {{
1449    const tokens = tokenize(normalized);
1450    const results = index.pages
1451      .map(page => ({{ page, score: scorePage(page, tokens), snippet: snippet(page, tokens) }}))
1452      .filter(result => result.score > 0)
1453      .sort((a, b) => b.score - a.score)
1454      .slice(0, 12);
1455    searchResults.innerHTML = results.length
1456      ? results.map(renderResult).join("")
1457      : "<p class=\"rp-search-empty\">No results</p>";
1458  }});
1459}}
1460
1461function scorePage(page, tokens) {{
1462  const title = (page.title || "").toLowerCase();
1463  const body = (page.body || "").toLowerCase();
1464  let score = 0;
1465  for (const token of tokens) {{
1466    if (title.includes(token)) score += 8;
1467    if (body.includes(token)) score += 2;
1468  }}
1469  return score;
1470}}
1471
1472function snippet(page, tokens) {{
1473  const body = page.body || "";
1474  const lower = body.toLowerCase();
1475  const first = tokens.map(token => lower.indexOf(token)).filter(index => index >= 0).sort((a, b) => a - b)[0] || 0;
1476  const start = Math.max(0, first - 56);
1477  const text = body.slice(start, start + 150);
1478  return (start > 0 ? "..." : "") + text;
1479}}
1480
1481function renderResult(result) {{
1482  const page = result.page;
1483  return `<a class="rp-search-result" href="${{escapeHtml(page.url || "#")}}">${{escapeHtml(page.title || "Untitled")}}<span>${{escapeHtml(result.snippet || page.url || "")}}</span></a>`;
1484}}
1485
1486function tokenize(input) {{
1487  const latin = input.match(/[a-z0-9]+/g) || [];
1488  const cjk = Array.from(input.matchAll(/[\u3400-\u9fff]/g)).map(match => match[0]);
1489  return [...latin, ...cjk].filter(Boolean);
1490}}
1491
1492function joinBase(path) {{
1493  return base.replace(/\/$/, "") + "/" + path.replace(/^\//, "");
1494}}
1495
1496function escapeHtml(value) {{
1497  return String(value).replace(/[&<>"']/g, char => ({{
1498    "&": "&amp;",
1499    "<": "&lt;",
1500    ">": "&gt;",
1501    "\"": "&quot;",
1502    "'": "&#39;"
1503  }}[char]));
1504}}
1505"##,
1506        base = site.base,
1507        skin = site.theme.skin,
1508        access_password = site.access_password
1509    )
1510}
1511
1512fn escape_html(input: &str) -> String {
1513    input
1514        .replace('&', "&amp;")
1515        .replace('<', "&lt;")
1516        .replace('>', "&gt;")
1517}
1518
1519fn escape_attr(input: &str) -> String {
1520    escape_html(input).replace('"', "&quot;")
1521}
1522
1523#[cfg(test)]
1524mod tests {
1525    use super::*;
1526
1527    fn site() -> SiteRender {
1528        SiteRender {
1529            title: "Docs".to_string(),
1530            lang: "en-US".to_string(),
1531            base: "/".to_string(),
1532            home_href: "/".to_string(),
1533            theme: ThemeConfig {
1534                skin: "light".to_string(),
1535                allow_switch: true,
1536                github_url: "https://github.com/ZenithInc/rust-press".to_string(),
1537            },
1538            search_enabled: true,
1539            access_enabled: true,
1540            access_password: "rustpress".to_string(),
1541            password_hint: "Password".to_string(),
1542            top_nav: vec![
1543                TopNavItem {
1544                    title: "Guide".to_string(),
1545                    href: Some("/guide/".to_string()),
1546                    items: vec![TopNavLink {
1547                        title: "CLI".to_string(),
1548                        href: "/guide/cli/".to_string(),
1549                    }],
1550                },
1551                TopNavItem {
1552                    title: "Reference".to_string(),
1553                    href: Some("/reference/".to_string()),
1554                    items: vec![],
1555                },
1556            ],
1557            nav: vec![NavItem {
1558                title: "Home".to_string(),
1559                href: "/".to_string(),
1560                active_prefix: "/".to_string(),
1561                items: Vec::new(),
1562            }],
1563            languages: vec![
1564                LanguageOption {
1565                    label: "English".to_string(),
1566                    href: "/".to_string(),
1567                    current: true,
1568                },
1569                LanguageOption {
1570                    label: "Deutsch".to_string(),
1571                    href: "/de/".to_string(),
1572                    current: false,
1573                },
1574            ],
1575        }
1576    }
1577
1578    #[test]
1579    fn renders_theme_switcher_and_mask() {
1580        let html = render_page(
1581            &site(),
1582            &PageRender {
1583                title: "Home".to_string(),
1584                route: "/".to_string(),
1585                html: "<h1>Home</h1>".to_string(),
1586                headings: vec![],
1587                masked: true,
1588                search: true,
1589            },
1590        );
1591
1592        assert!(html.contains("data-rp-skin-select"));
1593        assert!(html.contains(r#"data-rp-skin-value="light">Light</button>"#));
1594        assert!(html.contains(r#"data-rp-skin-value="dark">Dark</button>"#));
1595        assert!(!html.contains("classic"));
1596        assert!(!html.contains("dense"));
1597        assert!(html.contains(r#"<html lang="en-US""#));
1598        assert!(html.contains("data-rp-language-select"));
1599        assert!(!html.contains("<select"));
1600        assert!(!html.contains("<option"));
1601        assert!(html.contains(r#"data-rp-language-href="/">English</button>"#));
1602        assert!(html.contains(r#"class="rp-icon-button rp-github-link""#));
1603        assert!(html.contains(r#"href="https://github.com/ZenithInc/rust-press""#));
1604        assert!(html.contains(r#"aria-label="GitHub repository""#));
1605        assert!(html.contains("data-rp-access-mask"));
1606        assert!(html.contains("data-rp-access-error"));
1607        assert!(html.contains("autocomplete=\"current-password\""));
1608        assert!(html.contains("front-end viewing mask"));
1609        assert!(html.contains("rp-topnav-group"));
1610        assert!(html.contains("rp-topnav-trigger"));
1611        assert!(html.contains("Reference"));
1612        assert!(!html.contains("<details"));
1613        assert!(html.contains("theme: \"base\""));
1614        assert!(html.contains("themeVariables"));
1615        assert!(html.contains("mermaid.run({ nodes: mermaidBlocks })"));
1616        assert!(!html.contains("startOnLoad: true"));
1617
1618        let js = js(&site());
1619        assert!(js.contains("lastShiftPress"));
1620        assert!(js.contains(r#"const accessPassword = "rustpress";"#));
1621        assert!(js.contains("accessInput.value !== accessPassword"));
1622        assert!(js.contains(r#"accessInput.setAttribute("aria-invalid", "true")"#));
1623        assert!(js.contains(r#"event.key !== "Shift""#));
1624        assert!(js.contains("rustpress:skinchange"));
1625        assert!(js.contains(r#"new CustomEvent("rustpress:skinchange""#));
1626    }
1627
1628    #[test]
1629    fn css_includes_mermaid_theme_colors() {
1630        let styles = css();
1631        assert!(styles.contains("--rp-mermaid-bg: #ffffff;"));
1632        assert!(styles.contains("--rp-mermaid-text: #1d2528;"));
1633        assert!(styles.contains("--rp-mermaid-line: #6c7a80;"));
1634        assert!(styles.contains("--rp-mermaid-bg: #151a20;"));
1635        assert!(styles.contains("--rp-mermaid-text: #edf2f4;"));
1636        assert!(styles.contains("--rp-mermaid-line: #9fb4bd;"));
1637        assert!(styles.contains(".rp-doc pre.mermaid svg"));
1638        assert!(styles.contains(".rp-github-link svg"));
1639        assert!(styles.contains("max-width: 100%;"));
1640    }
1641
1642    #[test]
1643    fn css_and_js_include_code_copy_support() {
1644        let styles = css();
1645        assert!(styles.contains(".rp-code-copy"));
1646        assert!(styles.contains(".rp-code-copy[data-rp-copied=\"true\"]"));
1647        assert!(styles.contains(".rp-code-copy:disabled"));
1648        assert!(styles.contains("padding-right: 56px;"));
1649        assert!(styles.contains(".rp-code-line-numbers pre"));
1650        assert!(
1651            styles.contains("grid-template-columns: minmax(42px, auto) minmax(0, max-content);")
1652        );
1653        assert!(styles.contains(".rp-code-lines"));
1654        assert!(styles.contains("user-select: none;"));
1655        assert!(styles.contains(".rp-code-line-numbers .rp-code-content"));
1656
1657        let script = js(&site());
1658        assert!(script.contains("[data-rp-copy-code]"));
1659        assert!(script.contains(".rp-code-content"));
1660        assert!(script.contains("navigator.clipboard.writeText"));
1661        assert!(script.contains("fallbackCopyText"));
1662        assert!(script.contains("document.execCommand(\"copy\")"));
1663        assert!(script.contains("1500"));
1664    }
1665
1666    #[test]
1667    fn omits_github_link_without_theme_url() {
1668        let mut site = site();
1669        site.theme.github_url.clear();
1670
1671        let html = render_page(
1672            &site,
1673            &PageRender {
1674                title: "Home".to_string(),
1675                route: "/".to_string(),
1676                html: "<h1>Home</h1>".to_string(),
1677                headings: vec![],
1678                masked: false,
1679                search: true,
1680            },
1681        );
1682
1683        assert!(!html.contains("rp-github-link"));
1684        assert!(!html.contains("GitHub repository"));
1685    }
1686}