//! Universal Outcome Transcoder.
//!
//! Compiles session outcomes, dynamic living canvases (tables, dataframes,
//! decision matrices), and cited claims into standalone, zero-dependency,
//! responsive HTML documents suitable for distribution, archiving, or viewing.
use serde_json::Value;
/// Escape HTML special characters to prevent injection.
pub fn html_escape(input: &str) -> String {
let mut escaped = String::with_capacity(input.len());
for c in input.chars() {
match c {
'&' => escaped.push_str("&"),
'<' => escaped.push_str("<"),
'>' => escaped.push_str(">"),
'"' => escaped.push_str("""),
'\'' => escaped.push_str("'"),
other => escaped.push(other),
}
}
escaped
}
/// Transcodes an outcome markdown transcript with structured vak-blocks
/// into a self-contained interactive HTML document.
pub fn transcode_to_html(title: &str, content: &str) -> String {
let safe_title = html_escape(title);
let body_html = transcode_body(content);
format!(
r#"
{safe_title}
{safe_title}
{body_html}
"#
)
}
fn transcode_body(content: &str) -> String {
let mut out = String::new();
let mut lines = content.lines().peekable();
while let Some(line) = lines.next() {
let trimmed = line.trim();
// 1. Check for structured vak code blocks
if trimmed.starts_with("```vak-table") || trimmed.starts_with("```vak-dataframe") {
let mut json_str = String::new();
for next in lines.by_ref() {
if next.trim() == "```" {
break;
}
json_str.push_str(next);
json_str.push('\n');
}
if let Ok(v) = serde_json::from_str::(&json_str) {
out.push_str(&render_vak_table(&v));
}
continue;
}
if trimmed.starts_with("```vak-decision") || trimmed.starts_with("```vak-comparison") {
let mut json_str = String::new();
for next in lines.by_ref() {
if next.trim() == "```" {
break;
}
json_str.push_str(next);
json_str.push('\n');
}
if let Ok(v) = serde_json::from_str::(&json_str) {
out.push_str(&render_vak_decision(&v));
}
continue;
}
// Standard code blocks
if trimmed.starts_with("```") {
let lang = trimmed.trim_start_matches('`').trim();
let mut code_str = String::new();
for next in lines.by_ref() {
if next.trim() == "```" {
break;
}
code_str.push_str(next);
code_str.push('\n');
}
out.push_str(&format!(
"
{}
\n",
html_escape(lang),
html_escape(&code_str)
));
continue;
}
// Headings
if let Some(rest) = trimmed.strip_prefix("### ") {
out.push_str(&format!("
{}
\n", render_inline(rest)));
continue;
}
if let Some(rest) = trimmed.strip_prefix("## ") {
out.push_str(&format!("
{}
\n", render_inline(rest)));
continue;
}
if let Some(rest) = trimmed.strip_prefix("# ") {
out.push_str(&format!("
{}
\n", render_inline(rest)));
continue;
}
// Blockquotes
if let Some(rest) = trimmed.strip_prefix("> ") {
out.push_str(&format!(
"
{}
\n",
render_inline(rest)
));
continue;
}
// Unordered Lists
if let Some(rest) = trimmed
.strip_prefix("- ")
.or_else(|| trimmed.strip_prefix("* "))
{
out.push_str(&format!("
\n", render_inline(trimmed)));
}
out
}
fn render_inline(text: &str) -> String {
let out = html_escape(text);
// Transform footnote citations [^1] into [^1]
let mut res = String::new();
let mut rest = out.as_str();
while let Some(start) = rest.find("[^") {
res.push_str(&rest[..start]);
let after = &rest[start..];
if let Some(end) = after.find(']') {
let tag = &after[..end + 1];
res.push_str(&format!(
"{}",
html_escape(tag),
html_escape(tag)
));
rest = &after[end + 1..];
} else {
res.push_str(after);
rest = "";
break;
}
}
res.push_str(rest);
res
}
fn render_vak_table(v: &Value) -> String {
let title = v
.get("title")
.and_then(Value::as_str)
.unwrap_or("Data Table");
let cols = v.get("columns").and_then(Value::as_array);
let rows = v.get("rows").and_then(Value::as_array);
let mut out = format!(
"
\n
\n {}\n \n
\n
\n
\n
\n",
html_escape(title)
);
if let Some(cols) = cols {
for c in cols {
let col_name = c.as_str().unwrap_or("");
out.push_str(&format!(
"
{} ▲▼
\n",
html_escape(col_name)
));
}
}
out.push_str("
\n \n");
if let Some(rows) = rows {
for row in rows {
out.push_str("
\n");
if let Some(cells) = row.as_array() {
for cell in cells {
let text = match cell {
Value::String(s) => s.clone(),
other => other.to_string(),
};
out.push_str(&format!("
{}
\n", html_escape(&text)));
}
}
out.push_str("
\n");
}
}
out.push_str(" \n
\n
\n
\n");
out
}
fn render_vak_decision(v: &Value) -> String {
let mut out = String::from("
\n");
if let Some(options) = v.get("options").and_then(Value::as_array) {
for opt in options {
let label = opt
.get("label")
.or_else(|| opt.get("name"))
.and_then(Value::as_str)
.unwrap_or("Option");
let summary = opt
.get("summary")
.or_else(|| opt.get("description"))
.and_then(Value::as_str)
.unwrap_or("");
out.push_str(&format!(
"
\n
{}
\n
{}
\n",
html_escape(label),
html_escape(summary)
));
if let Some(pros) = opt.get("pros").and_then(Value::as_array) {
out.push_str("