- 1
//! Delivery boundary for declarative adaptive presentations. - 2
//! - 3
//! This adapter deliberately keeps the existing Markdown/document packet as - 4
//! the lossless fallback. Rich data is an optional projection of an already - 5
//! authorized structured result. - 6
- 7
use serde_json::Value; - 8
use vak_presentation::{ - 9
CompileInput, CompiledPresentation, LibraryScope, PresentationLibrary, Primitive, RenderNode, - 10
}; - 11
- 12
/// Lower a compiled presentation to conservative Markdown for surfaces that - 13
/// cannot consume the native render tree. This is deliberately generic: it - 14
/// understands the host primitive vocabulary, never semantic/domain names, - 15
/// and returns the compiler's exact fallback when compilation degraded. - 16
pub fn markdown(compiled: &CompiledPresentation) -> String { - 17
match compiled { - 18
CompiledPresentation::Fallback { text, .. } => text.clone(), - 19
CompiledPresentation::Rich(tree) => render_node(&tree.root, 0), - 20
} - 21
} - 22
- 23
fn render_node(node: &RenderNode, depth: usize) -> String { - 24
let indent = " ".repeat(depth.min(8)); - 25
let text = |key: &str| node.props.get(key).and_then(|v| v.as_str()).unwrap_or(""); - 26
let entries = |key: &str| entry_lines(node, key); - 27
let mut out = String::new(); - 28
match node.primitive { - 29
Primitive::Title => out.push_str(&format!("{}# {}\n", indent, text("text"))), - 30
Primitive::Section => out.push_str(&format!("{}## {}\n", indent, text("title"))), - 31
Primitive::Label | Primitive::Badge => { - 32
out.push_str(&format!("{}{}\n", indent, text("text"))) - 33
} - 34
Primitive::Divider => out.push_str(&format!("{}---\n", indent)), - 35
Primitive::Quote | Primitive::Callout => { - 36
let value = text("text"); - 37
if !value.is_empty() { - 38
out.push_str(&format!("> {}\n", value)); - 39
} - 40
} - 41
Primitive::List | Primitive::Checklist | Primitive::Timeline | Primitive::Steps => { - 42
let value = text("text"); - 43
if !value.is_empty() { - 44
out.push_str(&format!("{}- {}\n", indent, value)); - 45
} - 46
} - 47
Primitive::KeyValue | Primitive::Progress => { - 48
let label = text("label"); - 49
let value = text("value"); - 50
if !label.is_empty() || !value.is_empty() { - 51
out.push_str(&format!("{}**{}:** {}\n", indent, label, value)); - 52
} - 53
} - 54
Primitive::Metric => { - 55
let label = text("label"); - 56
if let Some(value) = node.props.get("value") { - 57
let value = value - 58
.as_str() - 59
.map(str::to_owned) - 60
.unwrap_or_else(|| value.to_string()); - 61
let unit = text("unit"); - 62
out.push_str(&format!( - 63
"{}**{}:** {}{}\n", - 64
indent, - 65
label, - 66
value, - 67
if unit.is_empty() { - 68
String::new() - 69
} else { - 70
format!(" {unit}") - 71
} - 72
)); - 73
} else { - 74
if !label.is_empty() { - 75
out.push_str(&format!("{}**{}**\n", indent, label)); - 76
} - 77
for (key, value) in &node.props { - 78
if matches!(key.as_str(), "label" | "title" | "semantic_type") { - 79
continue; - 80
} - 81
let value = value - 82
.as_str() - 83
.map(str::to_owned) - 84
.unwrap_or_else(|| value.to_string()); - 85
out.push_str(&format!( - 86
"{}- {}: {}\n", - 87
indent, - 88
key.replace('_', " "), - 89
value - 90
)); - 91
} - 92
} - 93
} - 94
Primitive::Text - 95
| Primitive::RichText - 96
| Primitive::Group - 97
| Primitive::Stack - 98
| Primitive::Row - 99
| Primitive::Disclosure - 100
| Primitive::Comparison - 101
| Primitive::Table - 102
| Primitive::Chart - 103
| Primitive::DataGrid - 104
| Primitive::Image - 105
| Primitive::Audio - 106
| Primitive::Video - 107
| Primitive::File - 108
| Primitive::LinkPreview - 109
| Primitive::Gallery - 110
| Primitive::Diff - 111
| Primitive::TestMatrix - 112
| Primitive::Terminal - 113
| Primitive::Artifact - 114
| Primitive::CitationList - 115
| Primitive::Filter - 116
| Primitive::Sort - 117
| Primitive::Search - 118
| Primitive::Stepper - 119
| Primitive::Timer - 120
| Primitive::Loading - 121
| Primitive::Empty - 122
| Primitive::Partial - 123
| Primitive::Error - 124
| Primitive::Unavailable - 125
| Primitive::Stale - 126
| Primitive::Map - 127
| Primitive::Calendar - 128
| Primitive::Board - 129
| Primitive::Graph - 130
| Primitive::Entity - 131
| Primitive::Evidence - 132
| Primitive::Form - 133
| Primitive::Transaction - 134
| Primitive::Alert - 135
| Primitive::Conversation - 136
| Primitive::Simulation - 137
| Primitive::IngredientList - 138
| Primitive::StepList - 139
| Primitive::TakeawayList - 140
| Primitive::SourceList => { - 141
let value = text("text"); - 142
if !value.is_empty() { - 143
out.push_str(&format!("{}{}\n", indent, value)); - 144
} - 145
} - 146
Primitive::Recipe => { - 147
let title = text("title"); - 148
if !title.is_empty() { - 149
out.push_str(&format!("{}## {}\n", indent, title)); - 150
} - 151
for (label, key) in [("Serves", "servings"), ("Time", "total_time")] { - 152
let value = text(key); - 153
if !value.is_empty() { - 154
out.push_str(&format!("{}**{}:** {}\n", indent, label, value)); - 155
} - 156
} - 157
let ingredients = entries("ingredients"); - 158
if !ingredients.is_empty() { - 159
out.push_str(&format!("{}**Ingredients:**\n", indent)); - 160
for ingredient in ingredients { - 161
out.push_str(&format!("{}- {}\n", indent, ingredient)); - 162
} - 163
} - 164
let steps = entries("steps"); - 165
if !steps.is_empty() { - 166
out.push_str(&format!("{}**Steps:**\n", indent)); - 167
for (index, step) in steps.iter().enumerate() { - 168
out.push_str(&format!( - 169
"{}{}. {}\n", - 170
indent, - 171
index.saturating_add(1), - 172
step - 173
)); - 174
} - 175
} - 176
} - 177
Primitive::Research => { - 178
let title = text("title"); - 179
if !title.is_empty() { - 180
out.push_str(&format!("{}## {}\n", indent, title)); - 181
} - 182
let question = text("question"); - 183
if !question.is_empty() { - 184
out.push_str(&format!("{}**Question:** {}\n", indent, question)); - 185
} - 186
for (label, key) in [("Takeaways", "takeaways"), ("Sources", "sources")] { - 187
let values = entries(key); - 188
if !values.is_empty() { - 189
out.push_str(&format!("{}**{}:**\n", indent, label)); - 190
for value in values { - 191
out.push_str(&format!("{}- {}\n", indent, value)); - 192
} - 193
} - 194
} - 195
} - 196
Primitive::UiPreview => { - 197
// The previewed document is untrusted authored markup; plain-text - 198
// surfaces describe it rather than inlining it. - 199
let title = text("title"); - 200
if !title.is_empty() { - 201
out.push_str(&format!("{}## {}\n", indent, title)); - 202
} - 203
let caption = text("caption"); - 204
if !caption.is_empty() { - 205
out.push_str(&format!("{}{}\n", indent, caption)); - 206
} - 207
out.push_str(&format!( - 208
"{}_(interactive preview omitted on this surface)_\n", - 209
indent - 210
)); - 211
} - 212
} - 213
for child in &node.children { - 214
out.push_str(&render_node(child, depth.saturating_add(1))); - 215
} - 216
// Some valid packs bind the complete payload to a primitive whose text - 217
// lowering has no specialized shape (for example a universal entity or a - 218
// small comparison table). Never let a rich selection erase the result on - 219
// a constrained surface. Keep this structural and preserve every field. - 220
if out.trim().is_empty() { - 221
out.push_str(&render_bound_props(node, &indent)); - 222
} - 223
out - 224
} - 225
- 226
fn inline_value(value: &Value, depth: usize) -> String { - 227
if depth >= 8 { - 228
return value.to_string(); - 229
} - 230
match value { - 231
Value::Null => "Unavailable".into(), - 232
Value::String(value) => value.clone(), - 233
Value::Number(value) => value.to_string(), - 234
Value::Bool(value) => { - 235
if *value { - 236
"Yes".into() - 237
} else { - 238
"No".into() - 239
} - 240
} - 241
Value::Array(items) => items - 242
.iter() - 243
.map(|item| inline_value(item, depth + 1)) - 244
.collect::<Vec<_>>() - 245
.join("; "), - 246
Value::Object(fields) => fields - 247
.iter() - 248
.map(|(key, value)| { - 249
format!( - 250
"{}: {}", - 251
key.replace('_', " "), - 252
inline_value(value, depth + 1) - 253
) - 254
}) - 255
.collect::<Vec<_>>() - 256
.join(" · "), - 257
} - 258
} - 259
- 260
fn render_bound_props(node: &RenderNode, indent: &str) -> String { - 261
let mut out = String::new(); - 262
for key in ["title", "summary"] { - 263
if let Some(value) = node.props.get(key).and_then(Value::as_str) - 264
&& !value.is_empty() - 265
{ - 266
if key == "title" { - 267
out.push_str(&format!("{indent}## {value}\n")); - 268
} else { - 269
out.push_str(&format!("{indent}{value}\n")); - 270
} - 271
} - 272
} - 273
for (key, value) in &node.props { - 274
if matches!(key.as_str(), "title" | "summary" | "semantic_type") { - 275
continue; - 276
} - 277
let label = key.replace('_', " "); - 278
if let Value::Array(items) = value { - 279
out.push_str(&format!("{indent}**{label}:**\n")); - 280
for item in items { - 281
out.push_str(&format!("{indent}- {}\n", inline_value(item, 0))); - 282
} - 283
} else { - 284
out.push_str(&format!( - 285
"{indent}**{label}:** {}\n", - 286
inline_value(value, 0) - 287
)); - 288
} - 289
} - 290
out - 291
} - 292
- 293
/// Lower a list-shaped prop to plain lines. Stays structural: a string item is - 294
/// used as-is, an object item is reduced through the same generic label/value - 295
/// keys the rest of this lowering uses, never through domain field names. - 296
fn entry_lines(node: &RenderNode, key: &str) -> Vec<String> { - 297
let Some(items) = node.props.get(key).and_then(|value| value.as_array()) else { - 298
return Vec::new(); - 299
}; - 300
items - 301
.iter() - 302
.filter_map(|item| match item { - 303
Value::String(value) => Some(value.clone()), - 304
Value::Object(fields) => { - 305
let pick = |name: &str| fields.get(name).and_then(|v| v.as_str()).unwrap_or(""); - 306
let label = [pick("label"), pick("name"), pick("title")] - 307
.into_iter() - 308
.find(|value| !value.is_empty()) - 309
.unwrap_or_default(); - 310
let value = [pick("text"), pick("value"), pick("detail")] - 311
.into_iter() - 312
.find(|value| !value.is_empty()) - 313
.unwrap_or_default(); - 314
match (label.is_empty(), value.is_empty()) { - 315
(true, true) => None, - 316
(true, false) => Some(value.to_owned()), - 317
(false, true) => Some(label.to_owned()), - 318
(false, false) => Some(format!("{label}: {value}")), - 319
} - 320
} - 321
Value::Number(value) => Some(value.to_string()), - 322
_ => None, - 323
}) - 324
.collect() - 325
} - 326
- 327
pub fn project( - 328
library: &PresentationLibrary, - 329
semantic_type: &str, - 330
payload: Value, - 331
fallback_text: impl Into<String>, - 332
scope: LibraryScope, - 333
owner: &str, - 334
) -> Option<CompiledPresentation> { - 335
let stored = library.select(semantic_type, scope, owner)?; - 336
Some(vak_presentation::compile( - 337
&stored.spec, - 338
&CompileInput { - 339
semantic_type: semantic_type.to_owned(), - 340
payload, - 341
fallback_text: fallback_text.into(), - 342
}, - 343
)) - 344
} - 345
- 346
pub fn project_preferred( - 347
library: &PresentationLibrary, - 348
semantic_type: &str, - 349
payload: Value, - 350
fallback_text: impl Into<String>, - 351
user_owner: &str, - 352
workspace_owner: &str, - 353
) -> Option<CompiledPresentation> { - 354
let stored = library.select_preferred(semantic_type, user_owner, workspace_owner)?; - 355
Some(vak_presentation::compile( - 356
&stored.spec, - 357
&CompileInput { - 358
semantic_type: semantic_type.to_owned(), - 359
payload, - 360
fallback_text: fallback_text.into(), - 361
}, - 362
)) - 363
} - 364
- 365
#[cfg(test)] - 366
#[allow(clippy::expect_used, clippy::unwrap_used)] - 367
mod tests { - 368
use super::*; - 369
use vak_presentation::{ - 370
LibraryScope, PresentationLibrary, PresentationOrigin, StoredPresentation, digest, - 371
}; - 372
- 373
fn library() -> PresentationLibrary { - 374
let spec: vak_presentation::PresentationSpec = serde_json::from_value(serde_json::json!({ - 375
"schema_version": 1, - 376
"id": "demo.timeline", - 377
"revision": 1, - 378
"accepts": ["demo"], - 379
"root": { "primitive": "title", "props": { "text": { "kind": "binding", "path": "$.title" } }, "children": [] } - 380
})) - 381
.expect("spec"); - 382
let mut library = PresentationLibrary::default(); - 383
library - 384
.register(StoredPresentation { - 385
digest: digest(&spec).expect("digest"), - 386
spec, - 387
origin: PresentationOrigin { - 388
scope: LibraryScope::Workspace, - 389
owner: "workspace".into(), - 390
plugin_id: None, - 391
generation: None, - 392
}, - 393
enabled: false, - 394
}) - 395
.expect("register"); - 396
library - 397
} - 398
- 399
#[test] - 400
fn project_requires_explicit_activation_and_compiles_after_selection() { - 401
let mut library = library(); - 402
assert!( - 403
project( - 404
&library, - 405
"demo", - 406
serde_json::json!({"title":"Preview"}), - 407
"Original", - 408
LibraryScope::Workspace, - 409
"workspace" - 410
) - 411
.is_none() - 412
); - 413
library - 414
.activate("demo.timeline", 1, LibraryScope::Workspace, "workspace") - 415
.expect("activate"); - 416
let result = project( - 417
&library, - 418
"demo", - 419
serde_json::json!({"title":"Ready"}), - 420
"Original", - 421
LibraryScope::Workspace, - 422
"workspace", - 423
) - 424
.expect("projection"); - 425
assert!(matches!(result, CompiledPresentation::Rich(_))); - 426
} - 427
- 428
#[test] - 429
fn markdown_lowering_is_generic_and_preserves_compiler_fallback() { - 430
let mut library = library(); - 431
assert_eq!( - 432
markdown(&CompiledPresentation::Fallback { - 433
text: "Original answer".into(), - 434
reason: "invalid payload".into(), - 435
}), - 436
"Original answer" - 437
); - 438
library - 439
.activate("demo.timeline", 1, LibraryScope::Workspace, "workspace") - 440
.expect("activate"); - 441
let compiled = project( - 442
&library, - 443
"demo", - 444
serde_json::json!({"title":"Ready"}), - 445
"Original", - 446
LibraryScope::Workspace, - 447
"workspace", - 448
) - 449
.expect("projection"); - 450
assert!(markdown(&compiled).contains("Ready")); - 451
} - 452
} - 453
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.