use axum::extract::State;
use axum::response::Html;
use std::fmt::Write as _;
use std::sync::Arc;
use crate::{AppState, escape};
pub async fn trigger(State(state): State<Arc<AppState>>) -> Html<String> {
match crate::rebuild_site(&state) {
Ok(()) => Html(r#"<span style="color: #34d399;">Built successfully.</span>"#.to_string()),
Err(e) => Html(format!(
r#"<span style="color: #f87171;">Build error: {}</span>"#,
escape(&e)
)),
}
}
pub async fn render_preview(State(state): State<Arc<AppState>>, body: String) -> Html<String> {
let content = strip_frontmatter(&body);
let (md_config, base_url) = load_md_config_and_base(&state);
let (with_stubs, shortcode_count) = stub_shortcodes(&content);
let mut blocks = Vec::new();
let html =
zorto_core::markdown::render_markdown(&with_stubs, &md_config, &mut blocks, &base_url);
let exec_count = blocks.len();
drop(blocks);
let html = stub_exec_placeholders(html, exec_count);
let disclaimer = build_disclaimer(shortcode_count, exec_count);
if disclaimer.is_empty() {
Html(html)
} else {
Html(format!("{disclaimer}{html}"))
}
}
fn load_md_config_and_base(state: &AppState) -> (zorto_core::config::MarkdownConfig, String) {
let config_path = state.root.join("config.toml");
let raw = std::fs::read_to_string(&config_path).unwrap_or_default();
match toml::from_str::<zorto_core::config::Config>(&raw) {
Ok(cfg) => (cfg.markdown, cfg.base_url),
Err(_) => (zorto_core::config::MarkdownConfig::default(), String::new()),
}
}
fn stub_shortcodes(content: &str) -> (String, usize) {
let (after_body, body_count) = stub_body_shortcodes(content);
let (after_inline, inline_count) = stub_inline_shortcodes(&after_body);
(after_inline, body_count + inline_count)
}
fn stub_body_shortcodes(content: &str) -> (String, usize) {
let bytes = content.as_bytes();
let mut out = String::with_capacity(content.len());
let mut i = 0usize;
let mut count = 0usize;
while i < bytes.len() {
if bytes[i] == b'{' && i + 1 < bytes.len() && bytes[i + 1] == b'%' {
if let Some((name, after_close, end_close)) = parse_body_shortcode(content, i) {
let _ = write!(
out,
r#"<div class="preview-shortcode-stub" data-kind="body" data-name="{name}">[shortcode <code>{{% {name}(…) %}}…{{% end %}}</code> — preview unavailable; run a build to render]</div>"#,
name = escape(&name),
);
count += 1;
i = end_close;
let _ = after_close; continue;
}
}
out.push(bytes[i] as char);
i += 1;
}
(out, count)
}
fn stub_inline_shortcodes(content: &str) -> (String, usize) {
let bytes = content.as_bytes();
let mut out = String::with_capacity(content.len());
let mut i = 0usize;
let mut count = 0usize;
while i < bytes.len() {
if bytes[i] == b'{' && i + 1 < bytes.len() && bytes[i + 1] == b'{' {
if let Some((name, after_close)) = parse_inline_shortcode(content, i) {
let _ = write!(
out,
r#"<span class="preview-shortcode-stub" data-kind="inline" data-name="{name}">[shortcode <code>{{{{ {name}(…) }}}}</code> — preview unavailable]</span>"#,
name = escape(&name),
);
count += 1;
i = after_close;
continue;
}
}
out.push(bytes[i] as char);
i += 1;
}
(out, count)
}
fn parse_inline_shortcode(content: &str, start: usize) -> Option<(String, usize)> {
let s = &content[start..];
let after_open = s.strip_prefix("{{")?;
let after_open_trim = after_open.trim_start();
let consumed_ws = after_open.len() - after_open_trim.len();
let (name, name_end) = take_ident(after_open_trim)?;
if name.is_empty() {
return None;
}
let after_name = &after_open_trim[name_end..];
let after_name_trim = after_name.trim_start();
if !after_name_trim.starts_with('(') {
return None;
}
let after_paren = scan_past_args(after_name_trim, b'(', b')')?;
let close_search = &after_name_trim[after_paren..];
let close_search_trim = close_search.trim_start();
let trim_offset = close_search.len() - close_search_trim.len();
let after_close = close_search_trim.strip_prefix("}}")?;
let consumed = (after_open.len() - after_open_trim.len()) + name_end
+ (after_name.len() - after_name_trim.len())
+ after_paren
+ trim_offset
+ 2; let _ = consumed_ws;
let after_close_idx = start + 2 + consumed;
let _ = after_close;
Some((name.to_string(), after_close_idx))
}
fn parse_body_shortcode(content: &str, start: usize) -> Option<(String, usize, usize)> {
let s = &content[start..];
let after_open = s.strip_prefix("{%")?;
let after_open_trim = after_open.trim_start();
let (name, name_end) = take_ident(after_open_trim)?;
if name.is_empty() || name == "end" {
return None;
}
let after_name = &after_open_trim[name_end..];
let after_name_trim = after_name.trim_start();
if !after_name_trim.starts_with('(') {
return None;
}
let after_paren = scan_past_args(after_name_trim, b'(', b')')?;
let close_search = &after_name_trim[after_paren..];
let close_search_trim = close_search.trim_start();
let trim_offset = close_search.len() - close_search_trim.len();
let after_open_close = close_search_trim.strip_prefix("%}")?;
let opening_consumed = 2 + (after_open.len() - after_open_trim.len())
+ name_end
+ (after_name.len() - after_name_trim.len())
+ after_paren
+ trim_offset
+ 2; let opening_end = start + opening_consumed;
let needle = "{%";
let mut search_from = opening_end;
let _ = after_open_close;
loop {
let rest = content.get(search_from..)?;
let next = rest.find(needle)?;
let pos = search_from + next;
let candidate = &content[pos..];
if let Some(end_close) = parse_end_marker(candidate) {
return Some((name.to_string(), opening_end, pos + end_close));
}
search_from = pos + 2;
}
}
fn parse_end_marker(s: &str) -> Option<usize> {
let after_open = s.strip_prefix("{%")?;
let after_open_trim = after_open.trim_start();
let after_end = after_open_trim.strip_prefix("end")?;
let after_end_trim = after_end.trim_start();
let after_close = after_end_trim.strip_prefix("%}")?;
let consumed = s.len() - after_close.len();
Some(consumed)
}
fn scan_past_args(s: &str, open: u8, close: u8) -> Option<usize> {
let bytes = s.as_bytes();
if bytes.first() != Some(&open) {
return None;
}
let mut i = 1usize;
let mut depth = 1usize;
while i < bytes.len() {
match bytes[i] {
b'"' => {
i += 1;
while i < bytes.len() && bytes[i] != b'"' {
i += 1;
}
if i >= bytes.len() {
return None;
}
i += 1; }
b'\'' => {
i += 1;
while i < bytes.len() && bytes[i] != b'\'' {
i += 1;
}
if i >= bytes.len() {
return None;
}
i += 1;
}
b => {
if b == open {
depth += 1;
} else if b == close {
depth -= 1;
if depth == 0 {
return Some(i + 1);
}
}
i += 1;
}
}
}
None
}
fn take_ident(s: &str) -> Option<(&str, usize)> {
let bytes = s.as_bytes();
let mut end = 0usize;
if bytes.is_empty() {
return None;
}
if !(bytes[0].is_ascii_alphabetic() || bytes[0] == b'_') {
return None;
}
end += 1;
while end < bytes.len() && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_') {
end += 1;
}
Some((&s[..end], end))
}
fn stub_exec_placeholders(html: String, count: usize) -> String {
let mut result = html;
for i in 0..count {
let placeholder = format!("<!-- EXEC_BLOCK_{i} -->");
if !result.contains(&placeholder) {
continue;
}
let replacement = r#"<div class="code-block-preview-suppressed"><div class="preview-suppressed-pill">code execution suppressed in preview — Save & Rebuild to run this block</div></div>"#;
result = result.replacen(&placeholder, replacement, 1);
}
result
}
fn build_disclaimer(shortcode_count: usize, exec_count: usize) -> String {
if shortcode_count == 0 && exec_count == 0 {
return String::new();
}
let mut parts = Vec::new();
if shortcode_count > 0 {
parts.push(format!(
"{shortcode_count} shortcode{plural} stubbed",
plural = if shortcode_count == 1 { "" } else { "s" }
));
}
if exec_count > 0 {
parts.push(format!(
"{exec_count} executable code block{plural} not run",
plural = if exec_count == 1 { "" } else { "s" }
));
}
format!(
r#"<div class="preview-disclaimer">Preview is fragment-only: {}. Save & Rebuild to see the full output.</div>"#,
parts.join(", ")
)
}
fn strip_frontmatter(content: &str) -> String {
let trimmed = content.trim();
if let Some(rest) = trimmed.strip_prefix("+++") {
if let Some(end) = rest.find("\n+++") {
return rest[end + 4..].to_string();
}
}
content.to_string()
}