wowlab_docgen_cli/hosted_rustdoc/
render.rs1use maud::{DOCTYPE, Markup, PreEscaped, html};
2
3use super::{
4 Error, ErrorKind,
5 catalog::{Fragment, Item, Page, Section},
6};
7
8pub(super) fn pages(reference_html: &str, pages: &[Page]) -> Result<Vec<String>, Error> {
9 let head = rustdoc_head(reference_html)?;
10
11 Ok(pages
12 .iter()
13 .map(|page| {
14 let mut rendered = render_page(&head, page).into_string();
15
16 rendered.push('\n');
17
18 rendered
19 })
20 .collect())
21}
22
23fn render_page(head: &str, page: &Page) -> Markup {
24 html! {
25 (DOCTYPE)
26 html lang="en" {
27 head {
28 meta name="description" content=(page.description);
29 title { (page.title) }
30 (PreEscaped(head))
31 }
32 body class="rustdoc mod crate" {
33 a class="skip-main-content" href="#main-content" { "Skip to main content" }
34 rustdoc-topbar {
35 h2 { a href="/" { "WoW Lab" } }
36 }
37 nav class="sidebar" {
38 div class="sidebar-crate" { h2 { a href="/" { "WoW Lab" } } }
39 div class="sidebar-elems" {
40 ul class="block" {
41 (sidebar_link(page, "index.html", "/", "Workspace crates"))
42 (sidebar_link(page, "tidy-rules.html", "/tidy-rules", "Tidy rules"))
43 (sidebar_link(
44 page,
45 "engine-manifests.html",
46 "/engine-manifests",
47 "Engine manifests",
48 ))
49 (sidebar_link(page, "mcp-tools.html", "/mcp-tools", "MCP tools"))
50 }
51 section id="rustdoc-toc" {
52 h3 { "Page sections" }
53 ul class="block" {
54 @for section in &page.sections {
55 li { a href=(format!("#{}", section.id)) { (§ion.title) } }
56 @if section.layout == "methods" {
57 @for item in §ion.items {
58 li { a href=(format!("#{}", item.id)) { (breakable_text(&item.label)) } }
59 }
60 }
61 }
62 }
63 }
64 }
65 }
66 div class="sidebar-resizer" title="Drag to resize sidebar" {}
67 main {
68 div class="width-limiter" {
69 section id="main-content" class="content" tabindex="-1" {
70 div class="main-heading" {
71 h1 { (page.title) }
72 rustdoc-toolbar {}
73 }
74 div class="docblock" { p { (fragments(&page.introduction)) } }
75 @for section in &page.sections {
76 (render_section(section))
77 }
78 }
79 }
80 }
81 }
82 }
83 }
84}
85
86fn sidebar_link(page: &Page, file: &str, href: &str, label: &str) -> Markup {
87 html! {
88 li {
89 a class=[Some(if page.file == file { "current" } else { "" })] href=(href) { (label) }
90 }
91 }
92}
93
94fn render_section(section: &Section) -> Markup {
95 html! {
96 h2 id=(§ion.id) class="section-header" {
97 (§ion.title)
98 @if let Some(count) = section.count { " " small { "(" (count) ")" } }
99 a href=(format!("#{}", section.id)) class="anchor" { "§" }
100 }
101 @if section.layout == "methods" {
102 div class="methods" {
103 @for item in §ion.items { (render_method(item)) }
104 }
105 } @else {
106 dl class="item-table" {
107 @for item in §ion.items { (render_table_item(item)) }
108 }
109 }
110 }
111}
112
113fn render_method(item: &Item) -> Markup {
114 html! {
115 section id=(&item.id) class="method" {
116 a href=(format!("#{}", item.id)) class="anchor" { "§" }
117 h4 class="code-header" { span class="fn" { (breakable_text(&item.label)) } }
118 div class="docblock" {
119 @for line in &item.lines { p { (fragments(line)) } }
120 @if !item.bullets.is_empty() {
121 p { strong { "Usage guidance" } }
122 ul { @for bullet in &item.bullets { li { (fragments(bullet)) } } }
123 }
124 }
125 }
126 }
127}
128
129fn render_table_item(item: &Item) -> Markup {
130 html! {
131 dt {
132 @if let Some(href) = &item.href {
133 a class="mod" href=(href) { (item_label(item)) }
134 } @else {
135 (item_label(item))
136 }
137 }
138 dd {
139 @for (index, line) in item.lines.iter().enumerate() {
140 @if index > 0 { br; }
141 (fragments(line))
142 }
143 }
144 }
145}
146
147fn item_label(item: &Item) -> Markup {
148 html! {
149 @if item.label_code {
150 code { (breakable_text(&item.label)) }
151 } @else {
152 (breakable_text(&item.label))
153 }
154 }
155}
156
157fn breakable_text(value: &str) -> Markup {
158 html! {
159 @for segment in value.split_inclusive(['-', '_']) {
160 (segment)
161 wbr;
162 }
163 }
164}
165
166fn fragments(values: &[Fragment]) -> Markup {
167 html! {
168 @for fragment in values {
169 @if let Some(href) = &fragment.href {
170 a href=(href) { (fragment_text(fragment)) }
171 } @else {
172 (fragment_text(fragment))
173 }
174 }
175 }
176}
177
178fn fragment_text(fragment: &Fragment) -> Markup {
179 html! {
180 @if fragment.code { code { (&fragment.text) } } @else { (&fragment.text) }
181 }
182}
183
184fn rustdoc_head(reference_html: &str) -> Result<String, Error> {
185 let start = reference_html
186 .find("<head>")
187 .map(|index| index + "<head>".len())
188 .ok_or(ErrorKind::MissingHead)?;
189 let head_tail = reference_html.get(start..).ok_or(ErrorKind::MissingHead)?;
190 let end = head_tail.find("</head>").ok_or(ErrorKind::MissingHead)?;
191 let head = head_tail.get(..end).ok_or(ErrorKind::MissingHead)?;
192 let head = remove_element(head, "<title>", "</title>")?;
193 let head = remove_description(&head)?;
194
195 Ok(head
196 .replace("../", "")
197 .replace("data-root-path=\"../\"", "data-root-path=\"\"")
198 .replace(
199 "data-current-crate=\"wowlab_engine\"",
200 "data-current-crate=\"\"",
201 ))
202}
203
204fn remove_element(source: &str, open: &str, close: &str) -> Result<String, Error> {
205 let start = source.find(open).ok_or(ErrorKind::MissingHead)?;
206 let tail = source.get(start..).ok_or(ErrorKind::MissingHead)?;
207 let end = tail
208 .find(close)
209 .map(|index| start + index + close.len())
210 .ok_or(ErrorKind::MissingHead)?;
211 let mut result = String::with_capacity(source.len() - (end - start));
212
213 result.push_str(source.get(..start).ok_or(ErrorKind::MissingHead)?);
214 result.push_str(source.get(end..).ok_or(ErrorKind::MissingHead)?);
215
216 Ok(result)
217}
218
219fn remove_description(source: &str) -> Result<String, Error> {
220 let marker = "<meta name=\"description\"";
221 let start = source.find(marker).ok_or(ErrorKind::MissingHead)?;
222 let tail = source.get(start..).ok_or(ErrorKind::MissingHead)?;
223 let end = tail
224 .find('>')
225 .map(|index| start + index + 1)
226 .ok_or(ErrorKind::MissingHead)?;
227 let mut result = String::with_capacity(source.len() - (end - start));
228
229 result.push_str(source.get(..start).ok_or(ErrorKind::MissingHead)?);
230 result.push_str(source.get(end..).ok_or(ErrorKind::MissingHead)?);
231
232 Ok(result)
233}