- 1001
if !inline.is_empty() { - 1002
blocks.push(DocumentBlock::Paragraph { - 1003
id: ids.next(), - 1004
content: inline, - 1005
}); - 1006
} - 1007
} - 1008
} - 1009
} - 1010
flush_inline_run(&mut inline_run, &mut blocks, ids); - 1011
blocks - 1012
} - 1013
- 1014
fn flush_inline_run(nodes: &mut Vec<Node>, blocks: &mut Vec<DocumentBlock>, ids: &mut Ids) { - 1015
if nodes.is_empty() { - 1016
return; - 1017
} - 1018
let content = nodes_to_inline(std::mem::take(nodes)); - 1019
if !content.is_empty() { - 1020
blocks.push(DocumentBlock::Paragraph { - 1021
id: ids.next(), - 1022
content, - 1023
}); - 1024
} - 1025
} - 1026
- 1027
fn table_cells(nodes: Vec<Node>) -> Vec<Vec<InlineNode>> { - 1028
nodes - 1029
.into_iter() - 1030
.filter_map(|node| match node { - 1031
Node::TableCell(children) => Some(nodes_to_inline(children)), - 1032
_ => None, - 1033
}) - 1034
.collect() - 1035
} - 1036
- 1037
fn nodes_to_inline(nodes: Vec<Node>) -> Vec<InlineNode> { - 1038
nodes - 1039
.into_iter() - 1040
.filter_map(|node| match node { - 1041
Node::Text(text) => Some(InlineNode::Text { text }), - 1042
Node::InlineCode(code) => Some(InlineNode::Code { code }), - 1043
Node::SoftBreak => Some(InlineNode::SoftBreak), - 1044
Node::HardBreak => Some(InlineNode::HardBreak), - 1045
Node::RawHtml(html) => Some(InlineNode::RawHtml { html }), - 1046
Node::Strong(children) => Some(InlineNode::Strong { - 1047
content: nodes_to_inline(children), - 1048
}), - 1049
Node::Emphasis(children) => Some(InlineNode::Emphasis { - 1050
content: nodes_to_inline(children), - 1051
}), - 1052
Node::Strikethrough(children) => Some(InlineNode::Strikethrough { - 1053
content: nodes_to_inline(children), - 1054
}), - 1055
Node::Link(url, title, children) => Some(InlineNode::Link { - 1056
label: nodes_to_inline(children), - 1057
safe: safe_link(&url), - 1058
url, - 1059
title, - 1060
}), - 1061
Node::Image(url, title, children) => Some(InlineNode::Image { - 1062
alt: inline_text(&nodes_to_inline(children)), - 1063
safe: safe_media(&url), - 1064
url, - 1065
title, - 1066
}), - 1067
Node::Paragraph(children) | Node::TableCell(children) | Node::Item(children) => { - 1068
Some(InlineNode::Text { - 1069
text: inline_text(&nodes_to_inline(children)), - 1070
}) - 1071
} - 1072
_ => None, - 1073
}) - 1074
.collect() - 1075
} - 1076
- 1077
pub fn inline_text(nodes: &[InlineNode]) -> String { - 1078
let mut output = String::new(); - 1079
for node in nodes { - 1080
match node { - 1081
InlineNode::Text { text } => output.push_str(text), - 1082
InlineNode::Strong { content } - 1083
| InlineNode::Emphasis { content } - 1084
| InlineNode::Strikethrough { content } => output.push_str(&inline_text(content)), - 1085
InlineNode::Code { code } => output.push_str(code), - 1086
InlineNode::Link { label, .. } => output.push_str(&inline_text(label)), - 1087
InlineNode::Image { alt, .. } => output.push_str(alt), - 1088
InlineNode::SoftBreak | InlineNode::HardBreak => output.push('\n'), - 1089
InlineNode::RawHtml { html } => output.push_str(html), - 1090
} - 1091
} - 1092
output - 1093
} - 1094
- 1095
pub fn safe_link(url: &str) -> bool { - 1096
let lower = url.trim().to_ascii_lowercase(); - 1097
lower.starts_with("https://") - 1098
|| lower.starts_with("http://") - 1099
|| lower.starts_with("mailto:") - 1100
|| lower.starts_with('#') - 1101
|| (!lower.contains(':') && !lower.starts_with("//")) - 1102
} - 1103
- 1104
fn safe_media(url: &str) -> bool { - 1105
let lower = url.trim().to_ascii_lowercase(); - 1106
lower.starts_with("https://") - 1107
|| lower.starts_with("http://") - 1108
|| lower.starts_with("data:image/") - 1109
|| lower.starts_with('/') - 1110
|| (!lower.contains(':') && !lower.starts_with("//")) - 1111
} - 1112
- 1113
#[cfg(test)] - 1114
#[allow(clippy::expect_used, clippy::panic)] - 1115
mod tests { - 1116
- 1117
#[test] - 1118
fn a_code_span_linked_to_itself_is_just_the_code_span() { - 1119
use super::{compile_markdown, unlink_self_linked_code}; - 1120
let source = "West [`book.xlsx#'Q3 by region'!B2:C5`](book.xlsx#'Q3 by region'!B2:C5) and \ - 1121
[`q3.docx#p@6`](q3.docx#p@6), but [`a`](b) stays a link."; - 1122
assert_eq!( - 1123
unlink_self_linked_code(source), - 1124
"West `book.xlsx#'Q3 by region'!B2:C5` and `q3.docx#p@6`, but [`a`](b) stays a link." - 1125
); - 1126
let document = compile_markdown(source); - 1127
let nodes = serde_json::to_string(&document.blocks).expect("serialises"); - 1128
assert!(!nodes.contains("]("), "{nodes}"); - 1129
} - 1130
- 1131
use super::{ - 1132
DocumentBlock, InlineNode, OutputContent, OutputTimeline, PRESENTATION_SCHEMA_VERSION, - 1133
compile_markdown, safe_link, - 1134
}; - 1135
use std::collections::BTreeMap; - 1136
- 1137
#[test] - 1138
fn tight_lists_keep_inline_runs_together() { - 1139
let source = "- Stocks include **Axis Bank**, **Adani Ports**, and **HDFC Bank**."; - 1140
let document = compile_markdown(source); - 1141
let DocumentBlock::List { items, .. } = &document.blocks[0] else { - 1142
panic!("expected list"); - 1143
}; - 1144
assert_eq!(items[0].len(), 1); - 1145
let DocumentBlock::Paragraph { content, .. } = &items[0][0] else { - 1146
panic!("expected paragraph"); - 1147
}; - 1148
assert_eq!(content.len(), 7); - 1149
assert_eq!( - 1150
super::inline_text(content), - 1151
"Stocks include Axis Bank, Adani Ports, and HDFC Bank." - 1152
); - 1153
assert_eq!(document.source_markdown, source); - 1154
} - 1155
- 1156
#[test] - 1157
fn adaptive_output_roundtrips_with_fallback_for_replay_and_voice() { - 1158
let mut props = BTreeMap::new(); - 1159
props.insert("text".into(), serde_json::json!("Weekend plan")); - 1160
let content = OutputContent::Adaptive { - 1161
tree: vak_presentation::RenderTree { - 1162
schema_version: vak_presentation::RENDER_TREE_SCHEMA_VERSION, - 1163
spec_id: "plan.timeline".into(), - 1164
revision: 1, - 1165
digest: "digest".into(), - 1166
root: vak_presentation::RenderNode { - 1167
primitive: vak_presentation::Primitive::Title, - 1168
props, - 1169
children: Vec::new(), - 1170
}, - 1171
accessibility_summary: Some("Weekend plan".into()), - 1172
coverage: vak_presentation::Coverage::default(), - 1173
}, - 1174
fallback_text: "Weekend plan\n\nSaturday: travel".into(), - 1175
}; - 1176
let encoded = serde_json::to_string(&content).expect("serialize adaptive output"); - 1177
let decoded: OutputContent = - 1178
serde_json::from_str(&encoded).expect("replay adaptive output"); - 1179
assert_eq!(decoded, content); - 1180
let OutputContent::Adaptive { fallback_text, .. } = decoded else { - 1181
panic!("expected adaptive output"); - 1182
}; - 1183
assert!(fallback_text.contains("Saturday")); - 1184
} - 1185
- 1186
#[test] - 1187
fn compiled_adaptive_renderer_keeps_exact_fallback() { - 1188
let mut props = BTreeMap::new(); - 1189
props.insert("text".into(), serde_json::json!("Weekend plan")); - 1190
let compiled = vak_presentation::CompiledPresentation::Rich(vak_presentation::RenderTree { - 1191
schema_version: vak_presentation::RENDER_TREE_SCHEMA_VERSION, - 1192
spec_id: "plan.timeline".into(), - 1193
revision: 1, - 1194
digest: "digest".into(), - 1195
root: vak_presentation::RenderNode { - 1196
primitive: vak_presentation::Primitive::Title, - 1197
props, - 1198
children: Vec::new(), - 1199
}, - 1200
accessibility_summary: Some("Weekend plan".into()), - 1201
coverage: vak_presentation::Coverage::default(), - 1202
}); - 1203
let content = - 1204
OutputContent::from_compiled_adaptive(compiled, "Weekend plan\n\nSaturday: travel") - 1205
.expect("rich compilation should produce adaptive content"); - 1206
let OutputContent::Adaptive { fallback_text, .. } = content else { - 1207
panic!("expected adaptive content"); - 1208
}; - 1209
assert_eq!(fallback_text, "Weekend plan\n\nSaturday: travel"); - 1210
} - 1211
- 1212
#[test] - 1213
fn schema_v2_replay_ignores_future_fields_and_keeps_fallback() { - 1214
// This fixture represents a persisted v2 timeline. Unknown fields - 1215
// are intentionally tolerated so additive presentation metadata does - 1216
// not make an older desktop/bridge unable to replay a session. - 1217
let fixture = r#"{ - 1218
"schema_version": 2, - 1219
"session_id": "session-legacy", - 1220
"items": [{ - 1221
"id": "item-1", "timestamp": "2026-01-01T00:00:00Z", - 1222
"turn_id": "turn-1", "role": "assistant", "kind": "message", - 1223
"status": "succeeded", - 1224
"content": {"type":"adaptive", "tree": { - 1225
"schema_version": 1, "spec_id":"plan.timeline", "revision":1, - 1226
"digest":"old-digest", "root":{"primitive":"title","props":{"text":"Plan"},"children":[]}, - 1227
"accessibility_summary":"Plan", "coverage":{"rendered_paths":[],"omitted_paths":[]} - 1228
}, "fallback_text":"Plan\n\nSaturday: travel"}, - 1229
"fallback_text":"Plan\n\nSaturday: travel", "future_metadata":{"renderer":"new"} - 1230
}], "diagnostics": [], "future_timeline_field": true - 1231
}"#; - 1232
let timeline: OutputTimeline = serde_json::from_str(fixture).expect("v2 replay fixture"); - 1233
assert_eq!(timeline.schema_version, PRESENTATION_SCHEMA_VERSION); - 1234
assert_eq!(timeline.items[0].fallback_text, "Plan\n\nSaturday: travel"); - 1235
let OutputContent::Adaptive { fallback_text, .. } = &timeline.items[0].content else { - 1236
panic!("expected adaptive replay item"); - 1237
}; - 1238
assert_eq!(fallback_text, &timeline.items[0].fallback_text); - 1239
} - 1240
- 1241
#[test] - 1242
fn schema_v2_delivery_packet_keeps_fallback_when_new_fields_arrive() { - 1243
// Delivery packets are also durable upgrade inputs. The packet's - 1244
// exact Markdown fallback remains authoritative when a newer writer - 1245
// adds fields an older bridge does not know about. - 1246
let fixture = r#"{ - 1247
"schema_version": 2, - 1248
"job_id": "job-legacy", - 1249
"target": "webhook:demo", - 1250
"surface": "webhook", - 1251
"kind": "assistant", - 1252
"payload": {"type":"text","value":"Plan"}, - 1253
"fallback_markdown": "Plan\n\nSaturday: travel", - 1254
"chunks": ["Plan", "Saturday: travel"], - 1255
"actions": [], - 1256
"coverage": [], - 1257
"diagnostics": [], - 1258
"future_delivery_metadata": {"chunk_format":"rich"} - 1259
}"#; - 1260
let packet: crate::DeliveryPacket = - 1261
serde_json::from_str(fixture).expect("schema-v2 delivery packet"); - 1262
assert_eq!(packet.schema_version, 2); - 1263
assert_eq!(packet.fallback_markdown, "Plan\n\nSaturday: travel"); - 1264
assert_eq!(packet.chunks.len(), 2); - 1265
} - 1266
- 1267
#[test] - 1268
fn loose_paragraphs_and_nested_lists_keep_their_boundaries() { - 1269
let document = compile_markdown( - 1270
"- First **paragraph**.\n\n Second paragraph.\n\n - Nested [link](https://example.com)\n\n Last paragraph.", - 1271
); - 1272
let DocumentBlock::List { items, .. } = &document.blocks[0] else { - 1273
panic!("expected list"); - 1274
}; - 1275
assert_eq!(items[0].len(), 4); - 1276
assert!(matches!(items[0][2], DocumentBlock::List { .. })); - 1277
} - 1278
- 1279
#[test] - 1280
fn compiles_commonmark_without_losing_source() { - 1281
let source = "# Result\n\nA **strong** [link](https://example.com).\n\n- one\n- two\n\n| a | b |\n|---|---:|\n| 1 | 2 |\n\n```rust\nfn main() {}\n```"; - 1282
let document = compile_markdown(source); - 1283
assert_eq!(document.source_markdown, source); - 1284
assert!(matches!(document.blocks[0], DocumentBlock::Heading { .. })); - 1285
assert!( - 1286
document.blocks.iter().any( - 1287
|block| matches!(block, DocumentBlock::List { items, .. } if items.len() == 2) - 1288
) - 1289
); - 1290
assert!( - 1291
document - 1292
.blocks - 1293
.iter() - 1294
.any(|block| matches!(block, DocumentBlock::Table { rows, .. } if rows.len() == 1)) - 1295
); - 1296
assert!(document.blocks.iter().any(|block| matches!(block, DocumentBlock::Code { language, .. } if language.as_deref() == Some("rust")))); - 1297
} - 1298
- 1299
#[test] - 1300
fn unsafe_links_and_html_remain_inert() { - 1301
let document = compile_markdown("[bad](javascript:alert(1))\n\n<script>x</script>"); - 1302
let DocumentBlock::Paragraph { content, .. } = &document.blocks[0] else { - 1303
panic!("first block should be a paragraph"); - 1304
}; - 1305
assert!(matches!(&content[0], InlineNode::Link { safe: false, .. })); - 1306
assert!(!safe_link("javascript:alert(1)")); - 1307
assert!(!document.diagnostics.is_empty()); - 1308
} - 1309
- 1310
#[test] - 1311
fn fenced_diff_receives_a_native_block() { - 1312
let document = compile_markdown("```diff\n-old\n+new\n```"); - 1313
assert!(matches!(document.blocks[0], DocumentBlock::Diff { .. })); - 1314
} - 1315
- 1316
#[test] - 1317
fn specialized_renderer_golden_fixture_preserves_source_and_boundaries() { - 1318
// This compact fixture intentionally exercises the presentation - 1319
// primitives that have historically had specialized desktop views. - 1320
// The source remains the canonical fallback: adding a native view - 1321
// must not alter what a voice or constrained channel receives. - 1322
let source = "# Release review\n\n- [x] Diff reviewed\n- [ ] Tests expanded\n\n| Check | Result |\n| --- | --- |\n| Tests | 12 passed |\n\n```diff\n-old\n+new\n```\n\n```bash\n$ cargo test\n```\n\n> **Approval:** publish the artifact\n\n[Report](https://example.com/report)\n\n"; - 1323
let document = compile_markdown(source); - 1324
assert_eq!(document.source_markdown, source); - 1325
assert!( - 1326
document - 1327
.blocks - 1328
.iter() - 1329
.any(|block| matches!(block, DocumentBlock::Diff { .. })) - 1330
); - 1331
assert!( - 1332
document - 1333
.blocks - 1334
.iter() - 1335
.any(|block| matches!(block, DocumentBlock::Table { rows, .. } if rows.len() == 1)) - 1336
); - 1337
assert!(document.blocks.iter().any(|block| matches!(block, DocumentBlock::Code { language, .. } if language.as_deref() == Some("bash")))); - 1338
assert!(document.blocks.iter().any(|block| matches!(block, DocumentBlock::Paragraph { content, .. } if content.iter().any(|node| matches!(node, InlineNode::Link { safe: true, .. }))))); - 1339
- 1340
let encoded = serde_json::to_string(&document).expect("serialize specialized fixture"); - 1341
let replayed: super::PresentationDocument = - 1342
serde_json::from_str(&encoded).expect("replay specialized fixture"); - 1343
assert_eq!(replayed, document); - 1344
assert!(replayed.coverage.iter().all(|entry| { - 1345
matches!( - 1346
entry.disposition, - 1347
super::DocumentCoverageDisposition::Native - 1348
) - 1349
})); - 1350
} - 1351
- 1352
#[test] - 1353
fn specialized_output_envelopes_roundtrip_without_losing_fallback() { - 1354
let fallback = "Publish the report after approval."; - 1355
let outputs = [ - 1356
OutputContent::Approval { - 1357
request_id: "approval-1".into(), - 1358
tool: "publish".into(), - 1359
args_json: "{}".into(), - 1360
reason: "User confirmation required".into(), - 1361
expires_at: None, - 1362
}, - 1363
OutputContent::Artifact { - 1364
artifact: super::ArtifactRef { - 1365
name: "report.pdf".into(), - 1366
path: Some(".vak/scratch/report.pdf".into()), - 1367
media_type: Some("application/pdf".into()), - 1368
description: Some("Generated report".into()), - 1369
size_bytes: Some(2048), - 1370
status: Some(super::ArtifactStatus::Draft { - 1371
version: 2, - 1372
saved_as: Some(super::VersionFile { - 1373
version_id: "version-2".into(), - 1374
path: "report.pdf".into(), - 1375
}), - 1376
}), - 1377
}, - 1378
}, - 1379
OutputContent::Document { - 1380
document: compile_markdown(fallback), - 1381
}, - 1382
]; - 1383
for output in outputs { - 1384
let encoded = serde_json::to_string(&output).expect("serialize output envelope"); - 1385
let replayed: OutputContent = - 1386
serde_json::from_str(&encoded).expect("replay output envelope"); - 1387
assert_eq!(replayed, output); - 1388
} - 1389
assert_eq!(fallback, "Publish the report after approval."); - 1390
} - 1391
- 1392
#[test] - 1393
fn test_unfenced_vak_prefix() { - 1394
let text = "Vak\n{\"semantic_type\":\"metric\",\"payload\":{\"title\":\"Delhi Weather Snapshot\",\"Temperature\":\"33.5°C\"}}\n\nSome prose."; - 1395
let doc = compile_markdown(text); - 1396
assert!( - 1397
doc.blocks - 1398
.iter() - 1399
.any(|b| matches!(b, DocumentBlock::Structured { .. })) - 1400
); - 1401
} - 1402
- 1403
#[test] - 1404
fn mermaid_is_preserved_as_a_safe_diagram_source() { - 1405
let document = compile_markdown("```mermaid\ngraph TD; A-->B;\n```"); - 1406
assert!( - 1407
matches!(document.blocks[0], DocumentBlock::Diagram { ref source, .. } if source.contains("A-->B")) - 1408
); - 1409
assert_eq!( - 1410
document.source_markdown, - 1411
"```mermaid\ngraph TD; A-->B;\n```" - 1412
); - 1413
} - 1414
- 1415
#[test] - 1416
fn malformed_input_roundtrips_with_explicit_coverage() { - 1417
let source = "## Unclosed **strong\n\n<div onclick=\"bad()\">literal</div>"; - 1418
let document = compile_markdown(source); - 1419
let encoded = serde_json::to_string(&document).expect("serialize presentation"); - 1420
let decoded: super::PresentationDocument = - 1421
serde_json::from_str(&encoded).expect("deserialize presentation"); - 1422
assert_eq!(decoded.source_markdown, source); - 1423
assert!(!decoded.coverage.is_empty()); - 1424
assert_eq!(decoded, document); - 1425
assert!(!decoded.diagnostics.is_empty()); - 1426
} - 1427
} - 1428
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.