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