use std::fmt::Write;
use crate::model::*;
use crate::render::layout::render_layout_items;
/// highlight.js local assets injected into <head>.
const HLJS_HEAD: &str = r#"<link rel="stylesheet" href="highlight-light.min.css" id="hljs-light">
<link rel="stylesheet" href="highlight-dark.min.css" id="hljs-dark" disabled>
<script defer src="highlight.min.js"></script>
<script defer src="wcl-grammar.js"></script>"#;
/// Theme detection + highlight.js init + toggle logic.
const THEME_SCRIPT: &str = r#"<script>
(function() {
// Determine initial theme: saved preference > system preference > light
function getPreferred() {
var saved = localStorage.getItem('wdoc-theme');
if (saved === 'dark' || saved === 'light') return saved;
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) return 'dark';
return 'light';
}
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
var light = document.getElementById('hljs-light');
var dark = document.getElementById('hljs-dark');
if (light && dark) {
light.disabled = (theme === 'dark');
dark.disabled = (theme !== 'dark');
}
var icon = document.getElementById('wdoc-theme-icon');
if (icon) icon.textContent = (theme === 'dark') ? '\u{2600}\u{FE0F}' : '\u{1F319}';
localStorage.setItem('wdoc-theme', theme);
}
// Apply immediately (before DOM ready) to prevent flash
applyTheme(getPreferred());
document.addEventListener('DOMContentLoaded', function() {
// highlight.js init
if (typeof hljs !== 'undefined') {
if (typeof hljsDefineWcl !== 'undefined') hljs.registerLanguage('wcl', hljsDefineWcl);
hljs.highlightAll();
}
// Toggle button
var toggle = document.getElementById('wdoc-theme-toggle');
if (toggle) {
toggle.addEventListener('click', function() {
var current = document.documentElement.getAttribute('data-theme') || 'light';
applyTheme(current === 'dark' ? 'light' : 'dark');
// Re-highlight with new theme
if (typeof hljs !== 'undefined') {
document.querySelectorAll('pre code').forEach(function(el) {
el.removeAttribute('data-highlighted');
hljs.highlightElement(el);
});
}
});
}
// Listen for system theme changes
if (window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
if (!localStorage.getItem('wdoc-theme')) {
applyTheme(e.matches ? 'dark' : 'light');
}
});
}
});
})();
</script>"#;
/// Render a single page as a complete HTML document.
pub fn render_page(doc: &WdocDocument, page: &Page, css_path: &str) -> String {
let mut html = String::with_capacity(4096);
// DOCTYPE + head
write!(
html,
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title} — {doc_title}</title>
<link rel="stylesheet" href="{css_path}">
{HLJS_HEAD}
</head>
<body>
"#,
title = page.title,
doc_title = doc.title,
HLJS_HEAD = HLJS_HEAD,
)
.unwrap();
// Nav sidebar
render_nav(doc, &page.section_id, &mut html);
// Main content
html.push_str("<main class=\"wdoc-content\">\n");
render_layout_items(&page.layout.children, &mut html);
html.push_str("</main>\n");
// Theme + highlight.js script
if page_has_runtime(page) {
html.push_str(&page_signal_runtime(page));
}
html.push_str(THEME_SCRIPT);
html.push_str("\n</body>\n</html>\n");
html
}
fn page_has_runtime(page: &Page) -> bool {
!page.signals.is_empty() || !page.bindings.is_empty()
}
fn page_signal_runtime(page: &Page) -> String {
let signals = page
.signals
.iter()
.map(|signal| {
serde_json::json!({
"name": signal.name,
"initial": signal.initial,
"type": signal.type_name,
})
})
.collect::<Vec<_>>();
let bindings = page
.bindings
.iter()
.map(|binding| {
serde_json::json!({
"name": binding.name,
"signal": binding.signal,
"target": binding.target,
"property": binding.property,
"path": binding.path,
"format": binding.format,
})
})
.collect::<Vec<_>>();
let data = serde_json::json!({
"signals": signals,
"bindings": bindings,
})
.to_string()
.replace("</", "<\\/");
format!("<script>(function(cfg){{if(window.__wdocPageSignalsInit){{window.__wdocPageSignalsInit(cfg);return;}}function val(v){{return v&&typeof v==='object'&&Object.prototype.hasOwnProperty.call(v,'initial')?v.initial:v;}}function clone(v){{return v==null||typeof v!=='object'?v:JSON.parse(JSON.stringify(v));}}function text(v){{if(v==null)return'';return typeof v==='string'?v:JSON.stringify(v);}}function readPath(v,p){{if(!p)return v;return String(p).replace(/\\[(\\d+)\\]/g,'.$1').split('.').filter(Boolean).reduce(function(a,k){{return a==null?undefined:a[k];}},v);}}function writePath(v,p,n){{if(!p)return n;var root=clone(v),cur=root,parts=String(p).replace(/\\[(\\d+)\\]/g,'.$1').split('.').filter(Boolean);for(var i=0;i<parts.length-1;i++){{var k=parts[i];if(cur[k]==null)cur[k]=/^\\d+$/.test(parts[i+1])?[]:{{}};cur=cur[k];}}cur[parts[parts.length-1]]=n;return root;}}function fmt(v,f){{var s=text(v);return f?String(f).replace(/\\{{value\\}}/g,s):s;}}function findTarget(id){{return document.querySelector('[data-wdoc-id=\"'+css(id)+'\"]')||document.querySelector('[data-wdoc-content-id=\"'+css(id)+'\"]')||document.getElementById(id);}}function css(s){{return String(s).replace(/\\\\/g,'\\\\\\\\').replace(/\"/g,'\\\\\"');}}function applyProp(el,prop,value){{if(!el)return;var s=text(value);if(prop==='text'||prop==='content'){{el.textContent=s;return;}}if(prop==='html'){{el.innerHTML=s;return;}}if(prop==='class'){{el.setAttribute('class',s);return;}}if(prop.indexOf('style.')===0){{el.style.setProperty(prop.slice(6).replace(/_/g,'-'),s);return;}}if(window.__wdocDiagramApplyProperty&&el.hasAttribute('data-wdoc-id')&&window.__wdocDiagramApplyProperty(el,prop,value))return;el.setAttribute(prop.replace(/_/g,'-'),s);}}function apply(){{bindings.forEach(function(b){{applyProp(findTarget(b.target),b.property,fmt(readPath(signals[b.signal],b.path),b.format));}});}}function setSignal(name,value,path){{signals[name]=writePath(signals[name],path,value);apply();document.dispatchEvent(new CustomEvent('wdoc:signal-change',{{detail:{{name:name,value:signals[name]}}}}));}}var signals={{}},bindings=cfg.bindings||[];(cfg.signals||[]).forEach(function(s){{signals[s.name]=clone(val(s));}});window.__wdocSignals=signals;window.__wdocSetSignal=setSignal;window.__wdocPageSignalsInit=function(next){{cfg=next||cfg;bindings=cfg.bindings||[];signals={{}};(cfg.signals||[]).forEach(function(s){{signals[s.name]=clone(val(s));}});window.__wdocSignals=signals;apply();}};if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',apply);else apply();}})({data});</script>\n")
}
fn render_nav(doc: &WdocDocument, active_section: &str, html: &mut String) {
html.push_str("<nav class=\"wdoc-nav\">\n");
writeln!(html, "<div class=\"wdoc-nav-title\">{}</div>", doc.title).unwrap();
html.push_str("<ul>\n");
render_nav_sections(&doc.sections, &doc.pages, active_section, html);
html.push_str("</ul>\n");
// Theme toggle at bottom of nav
html.push_str(
r#"<div class="wdoc-theme-toggle" id="wdoc-theme-toggle">
<span id="wdoc-theme-icon" class="wdoc-theme-icon">🌙</span>
<div class="wdoc-theme-toggle-track"><div class="wdoc-theme-toggle-knob"></div></div>
<span>Dark mode</span>
</div>
"#,
);
html.push_str("</nav>\n");
}
fn render_nav_sections(
sections: &[Section],
pages: &[Page],
active_section: &str,
html: &mut String,
) {
for section in sections {
let active_class = if active_section == section.id {
" class=\"active\""
} else {
""
};
// Find the first page for this section
let page_file = pages
.iter()
.find(|p| p.section_id == section.id)
.map(|p| format!("{}.html", p.id))
.unwrap_or_else(|| "#".to_string());
writeln!(
html,
"<li><a href=\"{page_file}\"{active_class}>{title}</a>",
title = section.title,
)
.unwrap();
if !section.children.is_empty() {
html.push_str("<ul>\n");
render_nav_sections(§ion.children, pages, active_section, html);
html.push_str("</ul>\n");
}
html.push_str("</li>\n");
}
}