Skip to main content

wowlab_docgen_cli/hosted_rustdoc/
render.rs

1use 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)) { (&section.title) } }
56                                    @if section.layout == "methods" {
57                                        @for item in &section.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=(&section.id) class="section-header" {
97            (&section.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 &section.items { (render_method(item)) }
104            }
105        } @else {
106            dl class="item-table" {
107                @for item in &section.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}