Skip to main content

mq_docs/
lib.rs

1use std::collections::VecDeque;
2
3use itertools::Itertools;
4use url::Url;
5
6/// Documentation output format.
7#[derive(Clone, Debug, Default, clap::ValueEnum)]
8pub enum DocFormat {
9    #[default]
10    Markdown,
11    Text,
12    Html,
13}
14
15/// A group of documented symbols belonging to a single module or file.
16struct ModuleDoc {
17    name: String,
18    symbols: VecDeque<[String; 4]>,
19    selectors: VecDeque<[String; 2]>,
20}
21
22/// Generate documentation for mq functions, macros, and selectors.
23///
24/// If `module_names` or `files` is provided, only the specified modules/files are loaded.
25/// Both can be combined. If `include_builtin` is true, built-in functions are also included.
26/// Otherwise, all builtin functions are documented.
27pub fn generate_docs(
28    module_names: &Option<Vec<String>>,
29    files: &Option<Vec<(String, String)>>,
30    format: &DocFormat,
31    include_builtin: bool,
32) -> Result<String, miette::Error> {
33    let has_files = files.as_ref().is_some_and(|f| !f.is_empty());
34    let has_modules = module_names.as_ref().is_some_and(|m| !m.is_empty());
35
36    let module_docs = if has_files || has_modules {
37        let mut docs = Vec::new();
38
39        if include_builtin {
40            let mut hir = mq_hir::Hir::default();
41            hir.add_code(None, "");
42            docs.push(ModuleDoc {
43                name: "Built-in".to_string(),
44                symbols: extract_symbols(&hir, None),
45                selectors: extract_selectors(&hir),
46            });
47        }
48
49        if let Some(file_contents) = files {
50            for (filename, content) in file_contents {
51                let mut hir = mq_hir::Hir::default();
52                hir.builtin.disabled = true;
53                let url = Url::parse(&format!("file:///{filename}")).ok();
54                let (source_id, _) = hir.add_code(url, content);
55                docs.push(ModuleDoc {
56                    name: filename.clone(),
57                    symbols: extract_symbols(&hir, Some(&source_id)),
58                    selectors: extract_selectors(&hir),
59                });
60            }
61        }
62
63        if let Some(module_names) = module_names {
64            for module_name in module_names {
65                let mut hir = mq_hir::Hir::default();
66                hir.builtin.disabled = true;
67                hir.add_code(None, &format!("include \"{module_name}\""));
68
69                let source_id = hir.symbols().find_map(|(_, symbol)| {
70                    if let mq_hir::Symbol {
71                        kind: mq_hir::SymbolKind::Include(module_source_id),
72                        ..
73                    } = &symbol
74                    {
75                        Some(module_source_id)
76                    } else {
77                        None
78                    }
79                });
80
81                docs.push(ModuleDoc {
82                    name: module_name.clone(),
83                    symbols: extract_symbols(&hir, source_id),
84                    selectors: extract_selectors(&hir),
85                });
86            }
87        }
88
89        docs
90    } else {
91        let mut hir = mq_hir::Hir::default();
92        hir.add_code(None, "");
93        vec![ModuleDoc {
94            name: "Built-in functions and macros".to_string(),
95            symbols: extract_symbols(&hir, None),
96            selectors: extract_selectors(&hir),
97        }]
98    };
99
100    match format {
101        DocFormat::Markdown => format_markdown(&module_docs),
102        DocFormat::Text => Ok(format_text(&module_docs)),
103        DocFormat::Html => Ok(format_html(&module_docs)),
104    }
105}
106
107/// Extract function and macro symbols from HIR.
108fn extract_symbols(
109    hir: &mq_hir::Hir,
110    source_id: Option<&mq_hir::SourceId>,
111) -> VecDeque<[String; 4]> {
112    hir.symbols()
113        .sorted_by_key(|(_, symbol)| symbol.value.clone())
114        .filter_map(|(_, symbol)| {
115            if let Some(sid) = source_id
116                && let Some(symbol_sid) = symbol.source.source_id
117                && symbol_sid != *sid
118            {
119                return None;
120            }
121
122            match symbol {
123                mq_hir::Symbol {
124                    kind: mq_hir::SymbolKind::Function(params),
125                    value: Some(value),
126                    doc,
127                    ..
128                }
129                | mq_hir::Symbol {
130                    kind: mq_hir::SymbolKind::Macro(params),
131                    value: Some(value),
132                    doc,
133                    ..
134                } if !symbol.is_internal_function() => {
135                    let name = if symbol.is_deprecated() {
136                        format!("~~`{}`~~", value)
137                    } else {
138                        format!("`{}`", value)
139                    };
140                    let description = doc.iter().map(|(_, d)| d.to_string()).join("\n");
141                    let args = params.iter().map(|p| format!("`{}`", p.name)).join(", ");
142                    let example = format!(
143                        "{}({})",
144                        value,
145                        params.iter().map(|p| p.name.as_str()).join(", ")
146                    );
147
148                    Some([name, description, args, example])
149                }
150                _ => None,
151            }
152        })
153        .collect()
154}
155
156/// Extract selector symbols from HIR.
157fn extract_selectors(hir: &mq_hir::Hir) -> VecDeque<[String; 2]> {
158    hir.symbols()
159        .sorted_by_key(|(_, symbol)| symbol.value.clone())
160        .filter_map(|(_, symbol)| match symbol {
161            mq_hir::Symbol {
162                kind: mq_hir::SymbolKind::Selector,
163                value: Some(value),
164                doc,
165                ..
166            } => {
167                let name = format!("`{}`", value);
168                let description = doc.iter().map(|(_, d)| d.to_string()).join("\n");
169                Some([name, description])
170            }
171            _ => None,
172        })
173        .collect()
174}
175
176/// Format documentation as a Markdown table.
177fn format_markdown(module_docs: &[ModuleDoc]) -> Result<String, miette::Error> {
178    let all_symbols: VecDeque<_> = module_docs
179        .iter()
180        .flat_map(|m| m.symbols.iter())
181        .cloned()
182        .collect();
183    let all_selectors: VecDeque<_> = module_docs
184        .iter()
185        .flat_map(|m| m.selectors.iter())
186        .cloned()
187        .collect();
188
189    let mut doc_csv = all_symbols
190        .iter()
191        .map(|[name, description, args, example]| {
192            mq_lang::RuntimeValue::String([name, description, args, example].into_iter().join("\t"))
193        })
194        .collect::<VecDeque<_>>();
195
196    doc_csv.push_front(mq_lang::RuntimeValue::String(
197        ["Function Name", "Description", "Parameters", "Example"]
198            .iter()
199            .join("\t"),
200    ));
201
202    let mut engine = mq_lang::DefaultEngine::default();
203    engine.load_builtin_module();
204
205    let doc_values = engine
206        .eval(
207            r#"include "csv" | tsv_parse(false) | csv_to_markdown_table()"#,
208            mq_lang::raw_input(&doc_csv.iter().join("\n")).into_iter(),
209        )
210        .map_err(|e| *e)?;
211
212    let mut result = doc_values.values().iter().map(|v| v.to_string()).join("\n");
213
214    if !all_selectors.is_empty() {
215        let mut selector_csv = all_selectors
216            .iter()
217            .map(|[name, description]| {
218                mq_lang::RuntimeValue::String(
219                    [name.as_str(), description.as_str()].into_iter().join("\t"),
220                )
221            })
222            .collect::<VecDeque<_>>();
223
224        selector_csv.push_front(mq_lang::RuntimeValue::String(
225            ["Selector", "Description"].iter().join("\t"),
226        ));
227
228        let mut engine = mq_lang::DefaultEngine::default();
229        engine.load_builtin_module();
230
231        let selector_values = engine
232            .eval(
233                r#"include "csv" | tsv_parse(false) | csv_to_markdown_table()"#,
234                mq_lang::raw_input(&selector_csv.iter().join("\n")).into_iter(),
235            )
236            .map_err(|e| *e)?;
237
238        result.push_str("\n\n## Selectors\n\n");
239        result.push_str(
240            &selector_values
241                .values()
242                .iter()
243                .map(|v| v.to_string())
244                .join("\n"),
245        );
246    }
247
248    Ok(result)
249}
250
251/// Format documentation as plain text.
252fn format_text(module_docs: &[ModuleDoc]) -> String {
253    let functions = module_docs
254        .iter()
255        .flat_map(|m| m.symbols.iter())
256        .map(|[name, description, args, _]| {
257            let name = name.replace('`', "");
258            let args = args.replace('`', "");
259            format!("# {description}\ndef {name}({args})")
260        })
261        .join("\n\n");
262
263    let selectors = module_docs
264        .iter()
265        .flat_map(|m| m.selectors.iter())
266        .map(|[name, description]| {
267            let name = name.replace('`', "");
268            format!("# {description}\nselector {name}")
269        })
270        .join("\n\n");
271
272    if selectors.is_empty() {
273        functions
274    } else {
275        format!("{functions}\n\n{selectors}")
276    }
277}
278
279/// Build HTML table rows for a set of symbols.
280fn build_table_rows(symbols: &VecDeque<[String; 4]>) -> String {
281    symbols
282        .iter()
283        .map(|[name, description, args, example]| {
284            let name_html = if name.starts_with("~~") {
285                let inner = name.trim_start_matches("~~`").trim_end_matches("`~~");
286                format!("<del><code>{}</code></del>", escape_html(inner))
287            } else {
288                let inner = name.trim_start_matches('`').trim_end_matches('`');
289                format!("<code>{}</code>", escape_html(inner))
290            };
291            let args_html = args
292                .split(", ")
293                .filter(|a| !a.is_empty())
294                .map(|a| {
295                    let inner = a.trim_start_matches('`').trim_end_matches('`');
296                    format!("<code>{}</code>", escape_html(inner))
297                })
298                .join(", ");
299            let desc_html = escape_html(description);
300            let example_html = escape_html(example);
301
302            format!(
303                "                <tr>\n\
304                 \x20                 <td>{name_html}</td>\n\
305                 \x20                 <td>{desc_html}</td>\n\
306                 \x20                 <td>{args_html}</td>\n\
307                 \x20                 <td><code>{example_html}</code></td>\n\
308                 \x20               </tr>"
309            )
310        })
311        .join("\n")
312}
313
314/// Build HTML table rows for a set of selectors.
315fn build_selector_table_rows(selectors: &VecDeque<[String; 2]>) -> String {
316    selectors
317        .iter()
318        .map(|[name, description]| {
319            let inner = name.trim_start_matches('`').trim_end_matches('`');
320            let name_html = format!("<code>{}</code>", escape_html(inner));
321            let desc_html = escape_html(description);
322
323            format!(
324                "                <tr>\n\
325                 \x20                 <td>{name_html}</td>\n\
326                 \x20                 <td>{desc_html}</td>\n\
327                 \x20               </tr>"
328            )
329        })
330        .join("\n")
331}
332
333/// Build a module page HTML block.
334fn build_module_page(id: &str, symbols: &VecDeque<[String; 4]>, active: bool) -> String {
335    let rows = build_table_rows(symbols);
336    let count = symbols.len();
337    let active_class = if active { " active" } else { "" };
338    format!(
339        "<div class=\"module-page{active_class}\" id=\"{id}\">\n\
340         \x20 <div class=\"search-box\">\n\
341         \x20   <svg class=\"search-icon\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"11\" cy=\"11\" r=\"8\"/><line x1=\"21\" y1=\"21\" x2=\"16.65\" y2=\"16.65\"/></svg>\n\
342         \x20   <input type=\"text\" class=\"search-input\" placeholder=\"Filter functions...\" />\n\
343         \x20 </div>\n\
344         \x20 <p class=\"count\"><span class=\"count-num\">{count}</span> functions</p>\n\
345         \x20 <table>\n\
346         \x20   <thead><tr><th>Function</th><th>Description</th><th>Parameters</th><th>Example</th></tr></thead>\n\
347         \x20   <tbody>\n{rows}\n\x20   </tbody>\n\
348         \x20 </table>\n\
349         </div>"
350    )
351}
352
353/// Build a selector page HTML block.
354fn build_selector_page(id: &str, selectors: &VecDeque<[String; 2]>, active: bool) -> String {
355    let rows = build_selector_table_rows(selectors);
356    let count = selectors.len();
357    let active_class = if active { " active" } else { "" };
358    format!(
359        "<div class=\"module-page{active_class}\" id=\"{id}\">\n\
360         \x20 <div class=\"search-box\">\n\
361         \x20   <svg class=\"search-icon\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"11\" cy=\"11\" r=\"8\"/><line x1=\"21\" y1=\"21\" x2=\"16.65\" y2=\"16.65\"/></svg>\n\
362         \x20   <input type=\"text\" class=\"search-input\" placeholder=\"Filter selectors...\" />\n\
363         \x20 </div>\n\
364         \x20 <p class=\"count\"><span class=\"count-num\">{count}</span> selectors</p>\n\
365         \x20 <table>\n\
366         \x20   <thead><tr><th>Selector</th><th>Description</th></tr></thead>\n\
367         \x20   <tbody>\n{rows}\n\x20   </tbody>\n\
368         \x20 </table>\n\
369         </div>"
370    )
371}
372
373/// Format documentation as a single-page HTML with sidebar navigation.
374fn format_html(module_docs: &[ModuleDoc]) -> String {
375    let has_multiple = module_docs.len() > 1;
376    let has_selectors = module_docs.iter().any(|m| !m.selectors.is_empty());
377
378    // Build sidebar items for modules (functions)
379    let sidebar_items = if has_multiple {
380        let all_count: usize = module_docs.iter().map(|m| m.symbols.len()).sum();
381        let all_icon = svg_icon(
382            "<rect x=\"3\" y=\"3\" width=\"7\" height=\"7\"/>\
383             <rect x=\"14\" y=\"3\" width=\"7\" height=\"7\"/>\
384             <rect x=\"3\" y=\"14\" width=\"7\" height=\"7\"/>\
385             <rect x=\"14\" y=\"14\" width=\"7\" height=\"7\"/>",
386        );
387        let mut items = format!(
388            "<a class=\"sidebar-link active\" href=\"#\" data-module=\"mod-all\">\
389             <span class=\"sidebar-icon\">{all_icon}</span>\
390             <span class=\"sidebar-label\">All</span>\
391             <span class=\"sidebar-count\">{all_count}</span></a>\n"
392        );
393        for (i, m) in module_docs.iter().enumerate() {
394            let name = escape_html(&m.name);
395            let count = m.symbols.len();
396            let icon = module_icon(&m.name);
397            items.push_str(&format!(
398                "<a class=\"sidebar-link\" href=\"#\" data-module=\"mod-{i}\">\
399                 <span class=\"sidebar-icon\">{icon}</span>\
400                 <span class=\"sidebar-label\">{name}</span>\
401                 <span class=\"sidebar-count\">{count}</span></a>\n"
402            ));
403        }
404        items
405    } else {
406        let m = &module_docs[0];
407        let name = escape_html(&m.name);
408        let count = m.symbols.len();
409        let icon = module_icon(&m.name);
410        format!(
411            "<a class=\"sidebar-link active\" href=\"#\" data-module=\"mod-all\">\
412             <span class=\"sidebar-icon\">{icon}</span>\
413             <span class=\"sidebar-label\">{name}</span>\
414             <span class=\"sidebar-count\">{count}</span></a>\n"
415        )
416    };
417
418    // Build sidebar items for selectors
419    let selector_sidebar_items = if has_selectors {
420        let mut items = String::new();
421        for (i, m) in module_docs.iter().enumerate() {
422            if m.selectors.is_empty() {
423                continue;
424            }
425            let name = escape_html(&m.name);
426            let count = m.selectors.len();
427            let icon = selector_icon();
428            items.push_str(&format!(
429                "<a class=\"sidebar-link\" href=\"#\" data-module=\"sel-{i}\">\
430                 <span class=\"sidebar-icon\">{icon}</span>\
431                 <span class=\"sidebar-label\">{name}</span>\
432                 <span class=\"sidebar-count\">{count}</span></a>\n"
433            ));
434        }
435        items
436    } else {
437        String::new()
438    };
439
440    // Build function pages
441    let mut pages = if has_multiple {
442        let all_symbols: VecDeque<_> = module_docs
443            .iter()
444            .flat_map(|m| m.symbols.iter())
445            .cloned()
446            .collect();
447        let mut pages_html = build_module_page("mod-all", &all_symbols, true);
448        for (i, m) in module_docs.iter().enumerate() {
449            pages_html.push('\n');
450            pages_html.push_str(&build_module_page(&format!("mod-{i}"), &m.symbols, false));
451        }
452        pages_html
453    } else {
454        build_module_page("mod-all", &module_docs[0].symbols, true)
455    };
456
457    // Build selector pages
458    if has_selectors {
459        for (i, m) in module_docs.iter().enumerate() {
460            if m.selectors.is_empty() {
461                continue;
462            }
463            pages.push('\n');
464            pages.push_str(&build_selector_page(
465                &format!("sel-{i}"),
466                &m.selectors,
467                false,
468            ));
469        }
470    }
471
472    // Build selector sidebar section
473    let selector_section = if has_selectors {
474        format!(
475            "        <nav class=\"sidebar-section\">\n\
476             \x20         <div class=\"sidebar-section-title\">Selectors</div>\n\
477             {selector_sidebar_items}\
478             \x20       </nav>\n"
479        )
480    } else {
481        String::new()
482    };
483
484    format!(
485        r#"<!DOCTYPE html>
486<html lang="en">
487  <head>
488    <meta charset="UTF-8" />
489    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
490    <title>mq - Function Reference</title>
491    <link rel="preconnect" href="https://fonts.googleapis.com" />
492    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
493    <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap" rel="stylesheet" />
494    <style>
495      :root {{
496        --bg-primary: #2a3444;
497        --bg-secondary: #232d3b;
498        --bg-tertiary: #3d4a5c;
499        --text-primary: #e2e8f0;
500        --text-secondary: #cbd5e1;
501        --text-muted: #94a3b8;
502        --accent-primary: #67b8e3;
503        --accent-secondary: #4fc3f7;
504        --border-default: #4a5568;
505        --border-muted: #374151;
506        --code-bg: #1e293b;
507        --code-bg-inline: #374151;
508        --code-color: #e2e8f0;
509        --sidebar-width: 260px;
510      }}
511
512      * {{ margin: 0; padding: 0; box-sizing: border-box; }}
513      html {{ height: 100%; scroll-behavior: smooth; }}
514
515      body {{
516        background-color: var(--bg-primary);
517        color: var(--text-primary);
518        font-family: "Montserrat", -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif;
519        font-weight: 400;
520        line-height: 1.6;
521        min-height: 100vh;
522      }}
523
524      /* ---- Layout ---- */
525      .layout {{
526        display: flex;
527        min-height: 100vh;
528      }}
529
530      .sidebar {{
531        background-color: var(--bg-secondary);
532        border-right: 1px solid var(--border-default);
533        display: flex;
534        flex-direction: column;
535        height: 100vh;
536        overflow-y: auto;
537        position: fixed;
538        top: 0;
539        left: 0;
540        width: var(--sidebar-width);
541        z-index: 50;
542      }}
543
544      .sidebar-header {{
545        border-bottom: 1px solid var(--border-default);
546        padding: 1.25rem 1.25rem 1rem;
547      }}
548
549      .sidebar-header h1 {{
550        color: var(--accent-primary);
551        font-size: 1.3rem;
552        font-weight: 700;
553        letter-spacing: -0.3px;
554      }}
555
556      .sidebar-header p {{
557        color: var(--text-muted);
558        font-size: 0.75rem;
559        margin-top: 0.2rem;
560      }}
561
562      .sidebar-section {{
563        padding: 0.75rem 0;
564      }}
565
566      .sidebar-section-title {{
567        color: var(--text-muted);
568        font-size: 0.7rem;
569        font-weight: 600;
570        letter-spacing: 0.8px;
571        padding: 0.25rem 1.25rem 0.5rem;
572        text-transform: uppercase;
573      }}
574
575      .sidebar-link {{
576        align-items: center;
577        border-left: 3px solid transparent;
578        color: var(--text-secondary);
579        cursor: pointer;
580        display: flex;
581        font-size: 0.85rem;
582        gap: 0.6rem;
583        padding: 0.5rem 1.25rem;
584        text-decoration: none;
585        transition: all 0.15s;
586      }}
587
588      .sidebar-link:hover {{
589        background-color: rgba(103, 184, 227, 0.06);
590        color: var(--text-primary);
591      }}
592
593      .sidebar-link.active {{
594        background-color: rgba(103, 184, 227, 0.1);
595        border-left-color: var(--accent-primary);
596        color: var(--accent-primary);
597        font-weight: 600;
598      }}
599
600      .sidebar-icon {{
601        display: flex;
602        align-items: center;
603        flex-shrink: 0;
604      }}
605
606      .sidebar-icon svg {{
607        height: 16px;
608        width: 16px;
609      }}
610
611      .sidebar-label {{
612        flex: 1;
613        overflow: hidden;
614        text-overflow: ellipsis;
615        white-space: nowrap;
616      }}
617
618      .sidebar-count {{
619        background-color: var(--bg-tertiary);
620        border-radius: 10px;
621        color: var(--text-muted);
622        font-size: 0.7rem;
623        font-weight: 600;
624        min-width: 1.6rem;
625        padding: 0.1rem 0.45rem;
626        text-align: center;
627      }}
628
629      .sidebar-link.active .sidebar-count {{
630        background-color: rgba(103, 184, 227, 0.15);
631        color: var(--accent-primary);
632      }}
633
634      .content {{
635        flex: 1;
636        margin-left: var(--sidebar-width);
637        padding: 2rem 2.5rem;
638        max-width: calc(100% - var(--sidebar-width));
639      }}
640
641      /* ---- Mobile sidebar toggle ---- */
642      .sidebar-toggle {{
643        background-color: var(--bg-tertiary);
644        border: 1px solid var(--border-default);
645        border-radius: 8px;
646        color: var(--text-primary);
647        cursor: pointer;
648        display: none;
649        left: 1rem;
650        padding: 0.5rem;
651        position: fixed;
652        top: 1rem;
653        z-index: 60;
654      }}
655
656      .sidebar-toggle svg {{
657        display: block;
658        height: 20px;
659        width: 20px;
660      }}
661
662      .sidebar-overlay {{
663        background-color: rgba(0, 0, 0, 0.5);
664        display: none;
665        inset: 0;
666        position: fixed;
667        z-index: 40;
668      }}
669
670      /* ---- Pages ---- */
671      .module-page {{ display: none; }}
672      .module-page.active {{ display: block; }}
673
674      .page-title {{
675        color: var(--text-primary);
676        font-size: 1.5rem;
677        font-weight: 700;
678        margin-bottom: 1.5rem;
679      }}
680
681      .search-box {{
682        margin-bottom: 1.5rem;
683        position: relative;
684      }}
685
686      .search-box input {{
687        background-color: var(--bg-tertiary);
688        border: 1px solid var(--border-default);
689        border-radius: 8px;
690        color: var(--text-primary);
691        font-family: inherit;
692        font-size: 0.95rem;
693        padding: 0.75rem 1rem 0.75rem 2.5rem;
694        width: 100%;
695        transition: border-color 0.2s;
696      }}
697
698      .search-box input:focus {{
699        border-color: var(--accent-primary);
700        outline: none;
701      }}
702
703      .search-box .search-icon {{
704        color: var(--text-muted);
705        height: 16px;
706        left: 0.85rem;
707        pointer-events: none;
708        position: absolute;
709        top: 50%;
710        transform: translateY(-50%);
711        width: 16px;
712      }}
713
714      .count {{
715        color: var(--text-muted);
716        font-size: 0.85rem;
717        margin-bottom: 1rem;
718      }}
719
720      table {{ border-collapse: collapse; width: 100%; }}
721
722      thead th {{
723        background-color: var(--bg-tertiary);
724        border-bottom: 2px solid var(--accent-primary);
725        color: var(--accent-primary);
726        font-size: 0.8rem;
727        font-weight: 600;
728        letter-spacing: 0.5px;
729        padding: 0.75rem 1rem;
730        position: sticky;
731        text-align: left;
732        text-transform: uppercase;
733        top: 0;
734        z-index: 5;
735      }}
736
737      tbody tr {{
738        border-bottom: 1px solid var(--border-muted);
739        cursor: pointer;
740        transition: background-color 0.15s;
741      }}
742
743      tbody tr:hover {{ background-color: var(--bg-tertiary); }}
744
745      tbody td {{
746        font-size: 0.9rem;
747        padding: 0.65rem 1rem;
748        vertical-align: top;
749      }}
750
751      tbody td:first-child {{ white-space: nowrap; }}
752
753      code {{
754        background-color: var(--code-bg-inline);
755        border-radius: 4px;
756        color: var(--code-color);
757        font-family: "Consolas", "Monaco", "Courier New", monospace;
758        font-size: 0.85em;
759        padding: 0.15em 0.4em;
760      }}
761
762      del code {{ opacity: 0.6; }}
763
764      footer {{
765        border-top: 1px solid var(--border-default);
766        margin-left: var(--sidebar-width);
767        padding: 1.5rem 2.5rem;
768      }}
769
770      footer p {{
771        color: var(--text-muted);
772        font-size: 0.85rem;
773      }}
774
775      footer a {{
776        color: var(--accent-primary);
777        text-decoration: none;
778      }}
779
780      footer a:hover {{
781        color: var(--accent-secondary);
782        text-decoration: underline;
783      }}
784
785      @media (max-width: 768px) {{
786        .sidebar {{
787          transform: translateX(-100%);
788          transition: transform 0.25s ease;
789        }}
790
791        .sidebar.open {{
792          transform: translateX(0);
793        }}
794
795        .sidebar-toggle {{
796          display: block;
797        }}
798
799        .sidebar-overlay.open {{
800          display: block;
801        }}
802
803        .content {{
804          margin-left: 0;
805          max-width: 100%;
806          padding: 1.5rem 1rem;
807          padding-top: 4rem;
808        }}
809
810        footer {{
811          margin-left: 0;
812          padding: 1.5rem 1rem;
813        }}
814
815        table {{ display: block; overflow-x: auto; }}
816
817        tbody td, thead th {{
818          font-size: 0.8rem;
819          padding: 0.6rem 0.75rem;
820        }}
821      }}
822    </style>
823  </head>
824  <body>
825    <button class="sidebar-toggle" id="sidebarToggle">
826      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
827        <line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/>
828      </svg>
829    </button>
830    <div class="sidebar-overlay" id="sidebarOverlay"></div>
831
832    <div class="layout">
833      <aside class="sidebar" id="sidebar">
834        <div class="sidebar-header">
835          <h1>mq</h1>
836          <p>Function Reference</p>
837        </div>
838        <nav class="sidebar-section">
839          <div class="sidebar-section-title">Modules</div>
840{sidebar_items}
841        </nav>
842{selector_section}
843      </aside>
844
845      <div class="content">
846{pages}
847      </div>
848    </div>
849
850    <footer>
851      <p>Generated by <a href="https://github.com/harehare/mq">mq</a></p>
852    </footer>
853
854    <script>
855      // Sidebar navigation
856      document.querySelectorAll(".sidebar-link").forEach(function (link) {{
857        link.addEventListener("click", function (e) {{
858          e.preventDefault();
859          document.querySelectorAll(".sidebar-link").forEach(function (l) {{
860            l.classList.remove("active");
861          }});
862          link.classList.add("active");
863
864          var target = link.getAttribute("data-module");
865          document.querySelectorAll(".module-page").forEach(function (page) {{
866            page.classList.toggle("active", page.id === target);
867          }});
868
869          // Close mobile sidebar
870          document.getElementById("sidebar").classList.remove("open");
871          document.getElementById("sidebarOverlay").classList.remove("open");
872        }});
873      }});
874
875      // Search filter
876      document.querySelectorAll(".search-input").forEach(function (input) {{
877        input.addEventListener("input", function () {{
878          var page = input.closest(".module-page");
879          var q = input.value.toLowerCase();
880          var rows = page.querySelectorAll("tbody tr");
881          var visible = 0;
882          rows.forEach(function (row) {{
883            var text = row.textContent.toLowerCase();
884            var show = text.includes(q);
885            row.style.display = show ? "" : "none";
886            if (show) visible++;
887          }});
888          page.querySelector(".count-num").textContent = visible;
889        }});
890      }});
891
892      // Mobile sidebar toggle
893      document.getElementById("sidebarToggle").addEventListener("click", function () {{
894        document.getElementById("sidebar").classList.toggle("open");
895        document.getElementById("sidebarOverlay").classList.toggle("open");
896      }});
897      document.getElementById("sidebarOverlay").addEventListener("click", function () {{
898        document.getElementById("sidebar").classList.remove("open");
899        document.getElementById("sidebarOverlay").classList.remove("open");
900      }});
901    </script>
902  </body>
903</html>"#,
904    )
905}
906
907/// Generate an inline SVG icon with the given inner elements.
908fn svg_icon(inner: &str) -> String {
909    format!(
910        "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" \
911         stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">{inner}</svg>"
912    )
913}
914
915/// Return an appropriate SVG icon for a module name.
916fn module_icon(name: &str) -> String {
917    if name.starts_with("Built-in") {
918        // cube icon
919        svg_icon(
920            "<path d=\"M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z\"/>\
921             <polyline points=\"3.27 6.96 12 12.01 20.73 6.96\"/>\
922             <line x1=\"12\" y1=\"22.08\" x2=\"12\" y2=\"12\"/>",
923        )
924    } else {
925        // package icon
926        svg_icon(
927            "<line x1=\"16.5\" y1=\"9.4\" x2=\"7.5\" y2=\"4.21\"/>\
928             <path d=\"M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z\"/>\
929             <polyline points=\"3.27 6.96 12 12.01 20.73 6.96\"/>\
930             <line x1=\"12\" y1=\"22.08\" x2=\"12\" y2=\"12\"/>",
931        )
932    }
933}
934
935/// Return an SVG icon for selector items.
936fn selector_icon() -> String {
937    // crosshair/target icon
938    svg_icon(
939        "<circle cx=\"12\" cy=\"12\" r=\"10\"/>\
940         <line x1=\"22\" y1=\"12\" x2=\"18\" y2=\"12\"/>\
941         <line x1=\"6\" y1=\"12\" x2=\"2\" y2=\"12\"/>\
942         <line x1=\"12\" y1=\"6\" x2=\"12\" y2=\"2\"/>\
943         <line x1=\"12\" y1=\"22\" x2=\"12\" y2=\"18\"/>",
944    )
945}
946
947/// Escape HTML special characters.
948fn escape_html(s: &str) -> String {
949    s.replace('&', "&amp;")
950        .replace('<', "&lt;")
951        .replace('>', "&gt;")
952        .replace('"', "&quot;")
953}