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