- 1
//! Universal Outcome Transcoder. - 2
//! - 3
//! Compiles session outcomes, dynamic living canvases (tables, dataframes, - 4
//! decision matrices), and cited claims into standalone, zero-dependency, - 5
//! responsive HTML documents suitable for distribution, archiving, or viewing. - 6
- 7
use serde_json::Value; - 8
- 9
/// Escape HTML special characters to prevent injection. - 10
pub fn html_escape(input: &str) -> String { - 11
let mut escaped = String::with_capacity(input.len()); - 12
for c in input.chars() { - 13
match c { - 14
'&' => escaped.push_str("&"), - 15
'<' => escaped.push_str("<"), - 16
'>' => escaped.push_str(">"), - 17
'"' => escaped.push_str("""), - 18
'\'' => escaped.push_str("'"), - 19
other => escaped.push(other), - 20
} - 21
} - 22
escaped - 23
} - 24
- 25
/// Transcodes an outcome markdown transcript with structured vak-blocks - 26
/// into a self-contained interactive HTML document. - 27
pub fn transcode_to_html(title: &str, content: &str) -> String { - 28
let safe_title = html_escape(title); - 29
let body_html = transcode_body(content); - 30
- 31
format!( - 32
r#"<!DOCTYPE html> - 33
<html lang="en"> - 34
<head> - 35
<meta charset="UTF-8"> - 36
<meta name="viewport" content="width=device-width, initial-scale=1.0"> - 37
<title>{safe_title}</title> - 38
<style> - 39
:root {{ - 40
--bg: #0d1117; - 41
--surface: #161b22; - 42
--border: #30363d; - 43
--text: #c9d1d9; - 44
--text-bright: #f0f6fc; - 45
--text-muted: #8b949e; - 46
--accent: #58a6ff; - 47
--accent-muted: rgba(56, 139, 253, 0.15); - 48
--success: #3fb950; - 49
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; - 50
--code-font: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, monospace; - 51
}} - 52
@media (prefers-color-scheme: light) {{ - 53
:root {{ - 54
--bg: #ffffff; - 55
--surface: #f6f8fa; - 56
--border: #d0d7de; - 57
--text: #24292f; - 58
--text-bright: #1f2328; - 59
--text-muted: #57606a; - 60
--accent: #0969da; - 61
--accent-muted: rgba(9, 105, 218, 0.1); - 62
--success: #1a7f37; - 63
}} - 64
}} - 65
* {{ box-sizing: border-box; margin: 0; padding: 0; }} - 66
body {{ - 67
background-color: var(--bg); - 68
color: var(--text); - 69
font-family: var(--font); - 70
font-size: 15px; - 71
line-height: 1.6; - 72
padding: 40px 20px; - 73
display: flex; - 74
justify-content: center; - 75
}} - 76
.container {{ - 77
max-width: 880px; - 78
width: 100%; - 79
}} - 80
header {{ - 81
margin-bottom: 32px; - 82
padding-bottom: 20px; - 83
border-bottom: 1px solid var(--border); - 84
}} - 85
h1 {{ font-size: 28px; font-weight: 600; color: var(--text-bright); margin-bottom: 8px; }} - 86
h2 {{ font-size: 20px; font-weight: 600; color: var(--text-bright); margin: 24px 0 12px; }} - 87
h3 {{ font-size: 16px; font-weight: 600; color: var(--text-bright); margin: 20px 0 8px; }} - 88
p {{ margin-bottom: 16px; }} - 89
ul, ol {{ margin: 0 0 16px 24px; }} - 90
li {{ margin-bottom: 4px; }} - 91
code {{ font-family: var(--code-font); font-size: 85%; background: var(--surface); padding: 2px 6px; border-radius: 4px; border: 1px solid var(--border); }} - 92
pre {{ background: var(--surface); border: 1px solid var(--border); border-radius: 6px; padding: 16px; overflow-x: auto; margin-bottom: 20px; }} - 93
pre code {{ background: none; border: none; padding: 0; font-size: 13px; }} - 94
blockquote {{ border-left: 4px solid var(--accent); padding: 8px 16px; margin: 16px 0; background: var(--accent-muted); color: var(--text); border-radius: 0 4px 4px 0; }} - 95
.card-table-container {{ - 96
background: var(--surface); - 97
border: 1px solid var(--border); - 98
border-radius: 8px; - 99
margin: 24px 0; - 100
overflow: hidden; - 101
}} - 102
.table-header-bar {{ - 103
padding: 12px 16px; - 104
border-bottom: 1px solid var(--border); - 105
display: flex; - 106
justify-content: space-between; - 107
align-items: center; - 108
gap: 12px; - 109
}} - 110
.table-title {{ font-weight: 600; font-size: 14px; color: var(--text-bright); }} - 111
.table-search {{ - 112
background: var(--bg); - 113
border: 1px solid var(--border); - 114
border-radius: 4px; - 115
padding: 4px 10px; - 116
font-size: 12px; - 117
color: var(--text); - 118
outline: none; - 119
}} - 120
.table-scroll {{ overflow-x: auto; }} - 121
table {{ - 122
width: 100%; - 123
border-collapse: collapse; - 124
font-size: 13px; - 125
text-align: left; - 126
}} - 127
th {{ - 128
background: var(--surface); - 129
color: var(--text-bright); - 130
font-weight: 600; - 131
padding: 10px 14px; - 132
border-bottom: 1px solid var(--border); - 133
cursor: pointer; - 134
user-select: none; - 135
white-space: nowrap; - 136
}} - 137
th:hover {{ background: var(--accent-muted); }} - 138
th .sort-icon {{ margin-left: 4px; opacity: 0.5; font-size: 10px; }} - 139
td {{ - 140
padding: 8px 14px; - 141
border-bottom: 1px solid var(--border); - 142
}} - 143
tr:last-child td {{ border-bottom: none; }} - 144
tbody tr:hover {{ background: var(--accent-muted); }} - 145
.decision-grid {{ - 146
display: grid; - 147
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); - 148
gap: 16px; - 149
margin: 24px 0; - 150
}} - 151
.decision-card {{ - 152
background: var(--surface); - 153
border: 1px solid var(--border); - 154
border-radius: 8px; - 155
padding: 16px; - 156
display: flex; - 157
flex-direction: column; - 158
}} - 159
.decision-title {{ font-size: 16px; font-weight: 600; color: var(--text-bright); margin-bottom: 8px; }} - 160
.decision-summary {{ font-size: 13px; color: var(--text-muted); margin-bottom: 12px; }} - 161
.pro-list, .con-list {{ font-size: 12px; margin: 4px 0 8px 16px; }} - 162
.pro-list li {{ color: var(--success); }} - 163
.con-list li {{ color: #f85149; }} - 164
.citation-sup {{ - 165
font-size: 75%; - 166
vertical-align: super; - 167
line-height: 0; - 168
color: var(--accent); - 169
text-decoration: none; - 170
cursor: pointer; - 171
padding: 0 2px; - 172
}} - 173
.citation-sup:hover {{ text-decoration: underline; }} - 174
footer {{ - 175
margin-top: 48px; - 176
padding-top: 16px; - 177
border-top: 1px solid var(--border); - 178
font-size: 12px; - 179
color: var(--text-muted); - 180
display: flex; - 181
justify-content: space-between; - 182
}} - 183
</style> - 184
</head> - 185
<body> - 186
<div class="container"> - 187
<header> - 188
<h1>{safe_title}</h1> - 189
</header> - 190
<main> - 191
{body_html} - 192
</main> - 193
<footer> - 194
<span>Generated by vak universal engine</span> - 195
<span>Self-contained living document</span> - 196
</footer> - 197
</div> - 198
<script> - 199
document.querySelectorAll('.card-table-container').forEach(container => {{ - 200
const input = container.querySelector('.table-search'); - 201
const table = container.querySelector('table'); - 202
if (!table) return; - 203
const tbody = table.querySelector('tbody'); - 204
const headers = table.querySelectorAll('th'); - 205
if (input) {{ - 206
input.addEventListener('input', () => {{ - 207
const q = input.value.toLowerCase(); - 208
tbody.querySelectorAll('tr').forEach(tr => {{ - 209
tr.style.display = tr.innerText.toLowerCase().includes(q) ? '' : 'none'; - 210
}}); - 211
}}); - 212
}} - 213
headers.forEach((th, colIdx) => {{ - 214
let asc = true; - 215
th.addEventListener('click', () => {{ - 216
const rows = Array.from(tbody.querySelectorAll('tr')); - 217
rows.sort((a, b) => {{ - 218
const cellA = a.children[colIdx]?.innerText.trim() || ''; - 219
const cellB = b.children[colIdx]?.innerText.trim() || ''; - 220
const numA = parseFloat(cellA); - 221
const numB = parseFloat(cellB); - 222
if (!isNaN(numA) && !isNaN(numB)) {{ - 223
return asc ? numA - numB : numB - numA; - 224
}} - 225
return asc ? cellA.localeCompare(cellB) : cellB.localeCompare(cellA); - 226
}}); - 227
asc = !asc; - 228
rows.forEach(r => tbody.appendChild(r)); - 229
}}); - 230
}}); - 231
}}); - 232
</script> - 233
</body> - 234
</html>"# - 235
) - 236
} - 237
- 238
fn transcode_body(content: &str) -> String { - 239
let mut out = String::new(); - 240
let mut lines = content.lines().peekable(); - 241
- 242
while let Some(line) = lines.next() { - 243
let trimmed = line.trim(); - 244
- 245
// 1. Check for structured vak code blocks - 246
if trimmed.starts_with("```vak-table") || trimmed.starts_with("```vak-dataframe") { - 247
let mut json_str = String::new(); - 248
for next in lines.by_ref() { - 249
if next.trim() == "```" { - 250
break; - 251
} - 252
json_str.push_str(next); - 253
json_str.push('\n'); - 254
} - 255
if let Ok(v) = serde_json::from_str::<Value>(&json_str) { - 256
out.push_str(&render_vak_table(&v)); - 257
} - 258
continue; - 259
} - 260
- 261
if trimmed.starts_with("```vak-decision") || trimmed.starts_with("```vak-comparison") { - 262
let mut json_str = String::new(); - 263
for next in lines.by_ref() { - 264
if next.trim() == "```" { - 265
break; - 266
} - 267
json_str.push_str(next); - 268
json_str.push('\n'); - 269
} - 270
if let Ok(v) = serde_json::from_str::<Value>(&json_str) { - 271
out.push_str(&render_vak_decision(&v)); - 272
} - 273
continue; - 274
} - 275
- 276
// Standard code blocks - 277
if trimmed.starts_with("```") { - 278
let lang = trimmed.trim_start_matches('`').trim(); - 279
let mut code_str = String::new(); - 280
for next in lines.by_ref() { - 281
if next.trim() == "```" { - 282
break; - 283
} - 284
code_str.push_str(next); - 285
code_str.push('\n'); - 286
} - 287
out.push_str(&format!( - 288
"<pre><code class=\"language-{}\">{}</code></pre>\n", - 289
html_escape(lang), - 290
html_escape(&code_str) - 291
)); - 292
continue; - 293
} - 294
- 295
// Headings - 296
if let Some(rest) = trimmed.strip_prefix("### ") { - 297
out.push_str(&format!("<h3>{}</h3>\n", render_inline(rest))); - 298
continue; - 299
} - 300
if let Some(rest) = trimmed.strip_prefix("## ") { - 301
out.push_str(&format!("<h2>{}</h2>\n", render_inline(rest))); - 302
continue; - 303
} - 304
if let Some(rest) = trimmed.strip_prefix("# ") { - 305
out.push_str(&format!("<h1>{}</h1>\n", render_inline(rest))); - 306
continue; - 307
} - 308
- 309
// Blockquotes - 310
if let Some(rest) = trimmed.strip_prefix("> ") { - 311
out.push_str(&format!( - 312
"<blockquote>{}</blockquote>\n", - 313
render_inline(rest) - 314
)); - 315
continue; - 316
} - 317
- 318
// Unordered Lists - 319
if let Some(rest) = trimmed - 320
.strip_prefix("- ") - 321
.or_else(|| trimmed.strip_prefix("* ")) - 322
{ - 323
out.push_str(&format!("<ul><li>{}</li></ul>\n", render_inline(rest))); - 324
continue; - 325
} - 326
- 327
// Empty lines - 328
if trimmed.is_empty() { - 329
continue; - 330
} - 331
- 332
// Regular paragraphs - 333
out.push_str(&format!("<p>{}</p>\n", render_inline(trimmed))); - 334
} - 335
- 336
out - 337
} - 338
- 339
fn render_inline(text: &str) -> String { - 340
let out = html_escape(text); - 341
- 342
// Transform footnote citations [^1] into <a class="citation-sup">[^1]</a> - 343
let mut res = String::new(); - 344
let mut rest = out.as_str(); - 345
while let Some(start) = rest.find("[^") { - 346
res.push_str(&rest[..start]); - 347
let after = &rest[start..]; - 348
if let Some(end) = after.find(']') { - 349
let tag = &after[..end + 1]; - 350
res.push_str(&format!( - 351
"<a class=\"citation-sup\" href=\"#{}\">{}</a>", - 352
html_escape(tag), - 353
html_escape(tag) - 354
)); - 355
rest = &after[end + 1..]; - 356
} else { - 357
res.push_str(after); - 358
rest = ""; - 359
break; - 360
} - 361
} - 362
res.push_str(rest); - 363
res - 364
} - 365
- 366
fn render_vak_table(v: &Value) -> String { - 367
let title = v - 368
.get("title") - 369
.and_then(Value::as_str) - 370
.unwrap_or("Data Table"); - 371
let cols = v.get("columns").and_then(Value::as_array); - 372
let rows = v.get("rows").and_then(Value::as_array); - 373
- 374
let mut out = format!( - 375
"<div class=\"card-table-container\">\n <div class=\"table-header-bar\">\n <span class=\"table-title\">{}</span>\n <input type=\"text\" class=\"table-search\" placeholder=\"Search table...\">\n </div>\n <div class=\"table-scroll\">\n <table>\n <thead><tr>\n", - 376
html_escape(title) - 377
); - 378
- 379
if let Some(cols) = cols { - 380
for c in cols { - 381
let col_name = c.as_str().unwrap_or(""); - 382
out.push_str(&format!( - 383
" <th>{} <span class=\"sort-icon\">▲▼</span></th>\n", - 384
html_escape(col_name) - 385
)); - 386
} - 387
} - 388
out.push_str(" </tr></thead>\n <tbody>\n"); - 389
- 390
if let Some(rows) = rows { - 391
for row in rows { - 392
out.push_str(" <tr>\n"); - 393
if let Some(cells) = row.as_array() { - 394
for cell in cells { - 395
let text = match cell { - 396
Value::String(s) => s.clone(), - 397
other => other.to_string(), - 398
}; - 399
out.push_str(&format!(" <td>{}</td>\n", html_escape(&text))); - 400
} - 401
} - 402
out.push_str(" </tr>\n"); - 403
} - 404
} - 405
- 406
out.push_str(" </tbody>\n </table>\n </div>\n</div>\n"); - 407
out - 408
} - 409
- 410
fn render_vak_decision(v: &Value) -> String { - 411
let mut out = String::from("<div class=\"decision-grid\">\n"); - 412
if let Some(options) = v.get("options").and_then(Value::as_array) { - 413
for opt in options { - 414
let label = opt - 415
.get("label") - 416
.or_else(|| opt.get("name")) - 417
.and_then(Value::as_str) - 418
.unwrap_or("Option"); - 419
let summary = opt - 420
.get("summary") - 421
.or_else(|| opt.get("description")) - 422
.and_then(Value::as_str) - 423
.unwrap_or(""); - 424
out.push_str(&format!( - 425
" <div class=\"decision-card\">\n <div class=\"decision-title\">{}</div>\n <div class=\"decision-summary\">{}</div>\n", - 426
html_escape(label), - 427
html_escape(summary) - 428
)); - 429
- 430
if let Some(pros) = opt.get("pros").and_then(Value::as_array) { - 431
out.push_str(" <ul class=\"pro-list\">\n"); - 432
for p in pros { - 433
out.push_str(&format!( - 434
" <li>+ {}</li>\n", - 435
html_escape(p.as_str().unwrap_or("")) - 436
)); - 437
} - 438
out.push_str(" </ul>\n"); - 439
} - 440
- 441
if let Some(cons) = opt.get("cons").and_then(Value::as_array) { - 442
out.push_str(" <ul class=\"con-list\">\n"); - 443
for c in cons { - 444
out.push_str(&format!( - 445
" <li>- {}</li>\n", - 446
html_escape(c.as_str().unwrap_or("")) - 447
)); - 448
} - 449
out.push_str(" </ul>\n"); - 450
} - 451
- 452
out.push_str(" </div>\n"); - 453
} - 454
} - 455
out.push_str("</div>\n"); - 456
out - 457
} - 458
- 459
#[cfg(test)] - 460
mod tests { - 461
use super::*; - 462
- 463
#[test] - 464
fn transcoder_renders_standalone_html_with_table_and_decision() { - 465
let text = r#" - 466
# Executive Analysis - 467
- 468
A comprehensive evaluation[^1] of architecture choices. - 469
- 470
```vak-table - 471
{ - 472
"title": "System Latencies", - 473
"columns": ["Service", "p50 (ms)", "p99 (ms)"], - 474
"rows": [ - 475
["Auth Gateway", "12", "45"], - 476
["Payment Broker", "34", "120"] - 477
] - 478
} - 479
``` - 480
- 481
## Architectural Tradeoffs - 482
- 483
```vak-decision - 484
{ - 485
"options": [ - 486
{ - 487
"label": "Option A: Event-Driven", - 488
"summary": "Decoupled async message bus", - 489
"pros": ["High scalability", "Fault tolerant"], - 490
"cons": ["Eventual consistency"] - 491
}, - 492
{ - 493
"label": "Option B: Sync REST", - 494
"summary": "Point-to-point HTTP/2", - 495
"pros": ["Immediate consistency"], - 496
"cons": ["Cascading latency"] - 497
} - 498
] - 499
} - 500
``` - 501
- 502
[^1]: Verified via 2026 Telemetry Audit - 503
"#; - 504
- 505
let html = transcode_to_html("System Architecture Brief", text); - 506
- 507
assert!(html.contains("<!DOCTYPE html>")); - 508
assert!(html.contains("System Architecture Brief")); - 509
assert!(html.contains("System Latencies")); - 510
assert!(html.contains("Auth Gateway")); - 511
assert!(html.contains("Option A: Event-Driven")); - 512
assert!(html.contains("Option B: Sync REST")); - 513
assert!(html.contains("citation-sup")); - 514
assert!(html.contains("document.querySelectorAll('.card-table-container')")); - 515
} - 516
} - 517
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.