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