• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

facet-rs / facet / 19814513449

01 Dec 2025 07:17AM UTC coverage: 54.338% (-3.6%) from 57.923%
19814513449

Pull #978

github

web-flow
Merge 083173627 into e3f8681ec
Pull Request #978: Introducing dodeca SSG

1868 of 5616 new or added lines in 18 files covered. (33.26%)

507 existing lines in 8 files now uncovered.

20756 of 38198 relevant lines covered (54.34%)

155.59 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/dodeca/src/render.rs
1
use crate::db::{Heading, Page, Section, SiteTree};
2
use crate::template::{Context, Engine, InMemoryLoader, Value};
3
use crate::types::Route;
4
use std::collections::HashMap;
5

6
/// Options for rendering
7
#[derive(Default, Clone, Copy)]
8
pub struct RenderOptions {
9
    /// Whether to inject live reload script
10
    pub livereload: bool,
11
    /// Development mode - show error pages instead of failing
12
    pub dev_mode: bool,
13
}
14

15
/// Inject livereload script if enabled
NEW
16
pub fn inject_livereload(html: &str, options: RenderOptions) -> String {
×
NEW
17
    if options.livereload {
×
NEW
18
        let livereload_script = r##"<script>
×
NEW
19
(function() {
×
NEW
20
    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
×
NEW
21
    const wsUrl = protocol + '//' + window.location.host + '/__livereload';
×
NEW
22
    let ws;
×
NEW
23
    let reconnectTimer;
×
NEW
24

×
NEW
25
    function connect() {
×
NEW
26
        ws = new WebSocket(wsUrl);
×
NEW
27
        ws.onopen = function() { console.log('[livereload] connected'); };
×
NEW
28
        ws.onmessage = function(event) {
×
NEW
29
            if (event.data === 'reload') {
×
NEW
30
                console.log('[livereload] reloading...');
×
NEW
31
                window.location.reload();
×
NEW
32
            }
×
NEW
33
        };
×
NEW
34
        ws.onclose = function() {
×
NEW
35
            console.log('[livereload] disconnected, reconnecting...');
×
NEW
36
            clearTimeout(reconnectTimer);
×
NEW
37
            reconnectTimer = setTimeout(connect, 1000);
×
NEW
38
        };
×
NEW
39
    }
×
NEW
40
    connect();
×
NEW
41
})();
×
NEW
42
</script>"##;
×
NEW
43
        html.replace("</body>", &format!("{livereload_script}</body>"))
×
44
    } else {
NEW
45
        html.to_string()
×
46
    }
NEW
47
}
×
48

49
// ============================================================================
50
// Pure render functions for Salsa tracked queries
51
// ============================================================================
52

53
/// Pure function to render a page to HTML (for Salsa tracking)
54
/// Returns Result - caller decides whether to show error page (dev) or fail (prod)
NEW
55
pub fn try_render_page_to_html(
×
NEW
56
    page: &Page,
×
NEW
57
    site_tree: &SiteTree,
×
NEW
58
    templates: &HashMap<String, String>,
×
NEW
59
) -> std::result::Result<String, String> {
×
NEW
60
    let mut loader = InMemoryLoader::new();
×
NEW
61
    for (path, content) in templates {
×
NEW
62
        loader.add(path.clone(), content.clone());
×
NEW
63
    }
×
NEW
64
    let mut engine = Engine::new(loader);
×
65

NEW
66
    let mut ctx = build_render_context(site_tree);
×
NEW
67
    ctx.set("page", page_to_value(page));
×
NEW
68
    ctx.set(
×
69
        "current_path",
NEW
70
        Value::String(page.route.as_str().to_string()),
×
71
    );
72

NEW
73
    engine
×
NEW
74
        .render("page.html", &ctx)
×
NEW
75
        .map_err(|e| format!("{e:?}"))
×
NEW
76
}
×
77

78
/// Render page - development mode (shows error page on failure)
NEW
79
pub fn render_page_to_html(
×
NEW
80
    page: &Page,
×
NEW
81
    site_tree: &SiteTree,
×
NEW
82
    templates: &HashMap<String, String>,
×
NEW
83
) -> String {
×
NEW
84
    try_render_page_to_html(page, site_tree, templates).unwrap_or_else(|e| render_error_page(&e))
×
NEW
85
}
×
86

87
/// Pure function to render a section to HTML (for Salsa tracking)
88
/// Returns Result - caller decides whether to show error page (dev) or fail (prod)
NEW
89
pub fn try_render_section_to_html(
×
NEW
90
    section: &Section,
×
NEW
91
    site_tree: &SiteTree,
×
NEW
92
    templates: &HashMap<String, String>,
×
NEW
93
) -> std::result::Result<String, String> {
×
NEW
94
    let mut loader = InMemoryLoader::new();
×
NEW
95
    for (path, content) in templates {
×
NEW
96
        loader.add(path.clone(), content.clone());
×
NEW
97
    }
×
NEW
98
    let mut engine = Engine::new(loader);
×
99

NEW
100
    let mut ctx = build_render_context(site_tree);
×
NEW
101
    ctx.set("section", section_to_value(section, site_tree));
×
NEW
102
    ctx.set(
×
103
        "current_path",
NEW
104
        Value::String(section.route.as_str().to_string()),
×
105
    );
106

NEW
107
    let template_name = if section.route.as_str() == "/" {
×
NEW
108
        "index.html"
×
109
    } else {
NEW
110
        "section.html"
×
111
    };
112

NEW
113
    engine
×
NEW
114
        .render(template_name, &ctx)
×
NEW
115
        .map_err(|e| format!("{e:?}"))
×
NEW
116
}
×
117

118
/// Render section - development mode (shows error page on failure)
NEW
119
pub fn render_section_to_html(
×
NEW
120
    section: &Section,
×
NEW
121
    site_tree: &SiteTree,
×
NEW
122
    templates: &HashMap<String, String>,
×
NEW
123
) -> String {
×
NEW
124
    try_render_section_to_html(section, site_tree, templates)
×
NEW
125
        .unwrap_or_else(|e| render_error_page(&e))
×
NEW
126
}
×
127

128
/// Marker that indicates a page contains a render error (for build mode detection)
129
pub const RENDER_ERROR_MARKER: &str = "<!-- DODECA_RENDER_ERROR -->";
130

131
/// Render a visible error page for development
NEW
132
fn render_error_page(error: &str) -> String {
×
NEW
133
    format!(
×
NEW
134
        r#"<!DOCTYPE html>
×
NEW
135
<html lang="en">
×
NEW
136
{marker}
×
NEW
137
<head>
×
NEW
138
    <meta charset="utf-8">
×
NEW
139
    <meta name="viewport" content="width=device-width, initial-scale=1">
×
NEW
140
    <title>Template Error - dodeca</title>
×
NEW
141
    <style>
×
NEW
142
        body {{
×
NEW
143
            font-family: system-ui, -apple-system, sans-serif;
×
NEW
144
            background: #1a1a2e;
×
NEW
145
            color: #eee;
×
NEW
146
            margin: 0;
×
NEW
147
            padding: 2rem;
×
NEW
148
        }}
×
NEW
149
        .error-container {{
×
NEW
150
            max-width: 900px;
×
NEW
151
            margin: 0 auto;
×
NEW
152
        }}
×
NEW
153
        h1 {{
×
NEW
154
            color: #ff6b6b;
×
NEW
155
            border-bottom: 2px solid #ff6b6b;
×
NEW
156
            padding-bottom: 0.5rem;
×
NEW
157
        }}
×
NEW
158
        pre {{
×
NEW
159
            background: #0f0f1a;
×
NEW
160
            border: 1px solid #333;
×
NEW
161
            border-radius: 8px;
×
NEW
162
            padding: 1rem;
×
NEW
163
            overflow-x: auto;
×
NEW
164
            white-space: pre-wrap;
×
NEW
165
            word-wrap: break-word;
×
NEW
166
            font-size: 14px;
×
NEW
167
            line-height: 1.5;
×
NEW
168
        }}
×
NEW
169
        .hint {{
×
NEW
170
            background: #2d2d44;
×
NEW
171
            border-left: 4px solid #4ecdc4;
×
NEW
172
            padding: 1rem;
×
NEW
173
            margin-top: 1rem;
×
NEW
174
        }}
×
NEW
175
    </style>
×
NEW
176
</head>
×
NEW
177
<body>
×
NEW
178
    <div class="error-container">
×
NEW
179
        <h1>Template Render Error</h1>
×
NEW
180
        <pre>{error}</pre>
×
NEW
181
        <div class="hint">
×
NEW
182
            <strong>Hint:</strong> Check your template syntax and ensure all referenced variables exist.
×
NEW
183
        </div>
×
NEW
184
    </div>
×
NEW
185
</body>
×
NEW
186
</html>"#,
×
187
        marker = RENDER_ERROR_MARKER,
188
        error = error
189
    )
NEW
190
}
×
191

192
/// Build the render context with config and global functions
NEW
193
fn build_render_context(site_tree: &SiteTree) -> Context {
×
NEW
194
    let mut ctx = Context::new();
×
195

196
    // Add config
NEW
197
    let mut config_map = HashMap::new();
×
NEW
198
    config_map.insert("title".to_string(), Value::String("facet".to_string()));
×
NEW
199
    config_map.insert(
×
NEW
200
        "description".to_string(),
×
NEW
201
        Value::String("A Rust reflection library".to_string()),
×
202
    );
NEW
203
    config_map.insert("base_url".to_string(), Value::String("/".to_string()));
×
NEW
204
    ctx.set("config", Value::Dict(config_map));
×
205

206
    // Register get_url function
NEW
207
    ctx.register_fn(
×
208
        "get_url",
NEW
209
        Box::new(move |_args, kwargs| {
×
NEW
210
            let path = kwargs
×
NEW
211
                .iter()
×
NEW
212
                .find(|(k, _)| k == "path")
×
NEW
213
                .map(|(_, v)| v.to_string())
×
NEW
214
                .unwrap_or_default();
×
215

NEW
216
            let url = if path.starts_with('/') {
×
NEW
217
                path
×
NEW
218
            } else if path.is_empty() {
×
NEW
219
                "/".to_string()
×
220
            } else {
NEW
221
                format!("/{}", path)
×
222
            };
NEW
223
            Ok(Value::String(url))
×
NEW
224
        }),
×
225
    );
226

227
    // Register get_section function
NEW
228
    let sections = site_tree.sections.clone();
×
NEW
229
    let pages = site_tree.pages.clone();
×
NEW
230
    ctx.register_fn(
×
231
        "get_section",
NEW
232
        Box::new(move |_args, kwargs| {
×
NEW
233
            let path = kwargs
×
NEW
234
                .iter()
×
NEW
235
                .find(|(k, _)| k == "path")
×
NEW
236
                .map(|(_, v)| v.to_string())
×
NEW
237
                .unwrap_or_default();
×
238

NEW
239
            let route = path_to_route(&path);
×
240

NEW
241
            if let Some(section) = sections.get(&route) {
×
NEW
242
                let mut section_map = HashMap::new();
×
NEW
243
                section_map.insert(
×
NEW
244
                    "title".to_string(),
×
NEW
245
                    Value::String(section.title.as_str().to_string()),
×
246
                );
NEW
247
                section_map.insert(
×
NEW
248
                    "permalink".to_string(),
×
NEW
249
                    Value::String(section.route.as_str().to_string()),
×
250
                );
NEW
251
                section_map.insert("path".to_string(), Value::String(path.clone()));
×
NEW
252
                section_map.insert(
×
NEW
253
                    "content".to_string(),
×
NEW
254
                    Value::String(section.body_html.as_str().to_string()),
×
255
                );
NEW
256
                section_map.insert("toc".to_string(), headings_to_toc(&section.headings));
×
257

NEW
258
                let section_pages: Vec<Value> = pages
×
NEW
259
                    .values()
×
NEW
260
                    .filter(|p| p.section_route == section.route)
×
NEW
261
                    .map(|p| {
×
NEW
262
                        let mut page_map = HashMap::new();
×
NEW
263
                        page_map.insert(
×
NEW
264
                            "title".to_string(),
×
NEW
265
                            Value::String(p.title.as_str().to_string()),
×
266
                        );
NEW
267
                        page_map.insert(
×
NEW
268
                            "permalink".to_string(),
×
NEW
269
                            Value::String(p.route.as_str().to_string()),
×
270
                        );
NEW
271
                        page_map.insert(
×
NEW
272
                            "path".to_string(),
×
NEW
273
                            Value::String(route_to_path(p.route.as_str())),
×
274
                        );
NEW
275
                        page_map.insert("weight".to_string(), Value::Int(p.weight as i64));
×
NEW
276
                        page_map.insert("toc".to_string(), headings_to_toc(&p.headings));
×
NEW
277
                        Value::Dict(page_map)
×
NEW
278
                    })
×
NEW
279
                    .collect();
×
NEW
280
                section_map.insert("pages".to_string(), Value::List(section_pages));
×
281

NEW
282
                let subsections: Vec<Value> = sections
×
NEW
283
                    .values()
×
NEW
284
                    .filter(|s| {
×
NEW
285
                        s.route != section.route
×
NEW
286
                            && s.route.as_str().starts_with(section.route.as_str())
×
NEW
287
                            && s.route.as_str()[section.route.as_str().len()..]
×
NEW
288
                                .trim_matches('/')
×
NEW
289
                                .chars()
×
NEW
290
                                .filter(|c| *c == '/')
×
NEW
291
                                .count()
×
292
                                == 0
NEW
293
                    })
×
NEW
294
                    .map(|s| Value::String(route_to_path(s.route.as_str())))
×
NEW
295
                    .collect();
×
NEW
296
                section_map.insert("subsections".to_string(), Value::List(subsections));
×
297

NEW
298
                Ok(Value::Dict(section_map))
×
299
            } else {
NEW
300
                Ok(Value::None)
×
301
            }
NEW
302
        }),
×
303
    );
304

NEW
305
    ctx
×
NEW
306
}
×
307

308
/// Convert a heading to a Value dict with children field
NEW
309
fn heading_to_value(h: &Heading, children: Vec<Value>) -> Value {
×
NEW
310
    let mut map = HashMap::new();
×
NEW
311
    map.insert("title".to_string(), Value::String(h.title.clone()));
×
NEW
312
    map.insert("id".to_string(), Value::String(h.id.clone()));
×
NEW
313
    map.insert("level".to_string(), Value::Int(h.level as i64));
×
NEW
314
    map.insert("permalink".to_string(), Value::String(format!("#{}", h.id)));
×
NEW
315
    map.insert("children".to_string(), Value::List(children));
×
NEW
316
    Value::Dict(map)
×
NEW
317
}
×
318

319
/// Convert headings to a hierarchical TOC Value (Zola-style nested structure)
NEW
320
fn headings_to_toc(headings: &[Heading]) -> Value {
×
NEW
321
    build_toc_tree(headings)
×
NEW
322
}
×
323

324
/// Convert headings to hierarchical Value list for template context
NEW
325
fn headings_to_value(headings: &[Heading]) -> Value {
×
NEW
326
    build_toc_tree(headings)
×
NEW
327
}
×
328

329
/// Build a hierarchical tree from a flat list of headings
NEW
330
fn build_toc_tree(headings: &[Heading]) -> Value {
×
NEW
331
    if headings.is_empty() {
×
NEW
332
        return Value::List(vec![]);
×
NEW
333
    }
×
334

335
    // Find the minimum level to use as the "top level"
NEW
336
    let min_level = headings.iter().map(|h| h.level).min().unwrap_or(1);
×
337

338
    // Build tree recursively
NEW
339
    let (result, _) = build_toc_subtree(headings, 0, min_level);
×
NEW
340
    Value::List(result)
×
NEW
341
}
×
342

343
/// Recursively build TOC subtree, returns (list of Value nodes, next index to process)
NEW
344
fn build_toc_subtree(headings: &[Heading], start: usize, parent_level: u8) -> (Vec<Value>, usize) {
×
NEW
345
    let mut result = Vec::new();
×
NEW
346
    let mut i = start;
×
347

NEW
348
    while i < headings.len() {
×
NEW
349
        let h = &headings[i];
×
350

351
        // If we hit a heading at or above parent level (lower number), we're done with this subtree
NEW
352
        if h.level < parent_level {
×
NEW
353
            break;
×
NEW
354
        }
×
355

356
        // If this heading is at the expected level, add it with its children
NEW
357
        if h.level == parent_level {
×
NEW
358
            // Collect children (headings with level > parent_level until we hit another at parent_level)
×
NEW
359
            let (children, next_i) = build_toc_subtree(headings, i + 1, parent_level + 1);
×
NEW
360
            result.push(heading_to_value(h, children));
×
NEW
361
            i = next_i;
×
NEW
362
        } else {
×
NEW
363
            // Heading is deeper than expected - just move on
×
NEW
364
            i += 1;
×
NEW
365
        }
×
366
    }
367

NEW
368
    (result, i)
×
NEW
369
}
×
370

371
/// Convert a Page to a Value for template context
NEW
372
fn page_to_value(page: &Page) -> Value {
×
NEW
373
    let mut map = HashMap::new();
×
NEW
374
    map.insert(
×
NEW
375
        "title".to_string(),
×
NEW
376
        Value::String(page.title.as_str().to_string()),
×
377
    );
NEW
378
    map.insert(
×
NEW
379
        "content".to_string(),
×
NEW
380
        Value::String(page.body_html.as_str().to_string()),
×
381
    );
NEW
382
    map.insert(
×
NEW
383
        "permalink".to_string(),
×
NEW
384
        Value::String(page.route.as_str().to_string()),
×
385
    );
NEW
386
    map.insert(
×
NEW
387
        "path".to_string(),
×
NEW
388
        Value::String(route_to_path(page.route.as_str())),
×
389
    );
NEW
390
    map.insert("weight".to_string(), Value::Int(page.weight as i64));
×
NEW
391
    map.insert("toc".to_string(), headings_to_value(&page.headings));
×
NEW
392
    Value::Dict(map)
×
NEW
393
}
×
394

395
/// Convert a Section to a Value for template context
NEW
396
fn section_to_value(section: &Section, site_tree: &SiteTree) -> Value {
×
NEW
397
    let mut map = HashMap::new();
×
NEW
398
    map.insert(
×
NEW
399
        "title".to_string(),
×
NEW
400
        Value::String(section.title.as_str().to_string()),
×
401
    );
NEW
402
    map.insert(
×
NEW
403
        "content".to_string(),
×
NEW
404
        Value::String(section.body_html.as_str().to_string()),
×
405
    );
NEW
406
    map.insert(
×
NEW
407
        "permalink".to_string(),
×
NEW
408
        Value::String(section.route.as_str().to_string()),
×
409
    );
NEW
410
    map.insert(
×
NEW
411
        "path".to_string(),
×
NEW
412
        Value::String(route_to_path(section.route.as_str())),
×
413
    );
NEW
414
    map.insert("weight".to_string(), Value::Int(section.weight as i64));
×
415

416
    // Add pages in this section (including their headings)
NEW
417
    let section_pages: Vec<Value> = site_tree
×
NEW
418
        .pages
×
NEW
419
        .values()
×
NEW
420
        .filter(|p| p.section_route == section.route)
×
NEW
421
        .map(|p| {
×
NEW
422
            let mut page_map = HashMap::new();
×
NEW
423
            page_map.insert(
×
NEW
424
                "title".to_string(),
×
NEW
425
                Value::String(p.title.as_str().to_string()),
×
426
            );
NEW
427
            page_map.insert(
×
NEW
428
                "permalink".to_string(),
×
NEW
429
                Value::String(p.route.as_str().to_string()),
×
430
            );
NEW
431
            page_map.insert(
×
NEW
432
                "path".to_string(),
×
NEW
433
                Value::String(route_to_path(p.route.as_str())),
×
434
            );
NEW
435
            page_map.insert("weight".to_string(), Value::Int(p.weight as i64));
×
NEW
436
            page_map.insert("toc".to_string(), headings_to_value(&p.headings));
×
NEW
437
            Value::Dict(page_map)
×
NEW
438
        })
×
NEW
439
        .collect();
×
NEW
440
    map.insert("pages".to_string(), Value::List(section_pages));
×
441

442
    // Add subsections
NEW
443
    let subsections: Vec<Value> = site_tree
×
NEW
444
        .sections
×
NEW
445
        .values()
×
NEW
446
        .filter(|s| {
×
NEW
447
            s.route != section.route
×
NEW
448
                && s.route.as_str().starts_with(section.route.as_str())
×
NEW
449
                && s.route.as_str()[section.route.as_str().len()..]
×
NEW
450
                    .trim_matches('/')
×
NEW
451
                    .chars()
×
NEW
452
                    .filter(|c| *c == '/')
×
NEW
453
                    .count()
×
454
                    == 0
NEW
455
        })
×
NEW
456
        .map(|s| Value::String(route_to_path(s.route.as_str())))
×
NEW
457
        .collect();
×
NEW
458
    map.insert("subsections".to_string(), Value::List(subsections));
×
NEW
459
    map.insert("toc".to_string(), headings_to_value(&section.headings));
×
460

NEW
461
    Value::Dict(map)
×
NEW
462
}
×
463

464
/// Convert a source path like "learn/_index.md" to a route like "/learn/"
NEW
465
fn path_to_route(path: &str) -> Route {
×
NEW
466
    let mut p = path.to_string();
×
467

468
    // Remove .md extension
NEW
469
    if p.ends_with(".md") {
×
NEW
470
        p = p[..p.len() - 3].to_string();
×
NEW
471
    }
×
472

473
    // Handle _index
NEW
474
    if p.ends_with("/_index") {
×
NEW
475
        p = p[..p.len() - 7].to_string();
×
NEW
476
    } else if p == "_index" {
×
NEW
477
        p = String::new();
×
NEW
478
    }
×
479

480
    // Ensure leading and trailing slashes
NEW
481
    if p.is_empty() {
×
NEW
482
        Route::root()
×
483
    } else {
NEW
484
        Route::new(format!("/{}/", p))
×
485
    }
NEW
486
}
×
487

488
/// Convert a route like "/learn/" back to a path like "learn/_index.md"
NEW
489
fn route_to_path(route: &str) -> String {
×
NEW
490
    let r = route.trim_matches('/');
×
NEW
491
    if r.is_empty() {
×
NEW
492
        "_index.md".to_string()
×
493
    } else {
NEW
494
        format!("{}/_index.md", r)
×
495
    }
NEW
496
}
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc