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