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 "&": "&",
1711 "<": "<",
1712 ">": ">",
1713 "\"": """,
1714 "'": "'"
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('&', "&")
1727 .replace('<', "<")
1728 .replace('>', ">")
1729}
1730
1731fn escape_attr(input: &str) -> String {
1732 escape_html(input).replace('"', """)
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}