1use sim_codec_doc::{Inline, MarkupBlock, MarkupDoc, MarkupEdit};
9use sim_kernel::{Error, Expr, NumberLiteral, Result, Symbol};
10use sim_lib_scene::{node, sym};
11use sim_lib_view::{Mode, universal_scene};
12
13use crate::doc::{article_from_markup, markup_from_article, title};
14
15pub const ARTICLE_FORMATTED_LENS: &str = "view:doc-article";
17
18pub const ARTICLE_SOURCE_LENS: &str = "view:doc-source";
20
21pub fn with_cache(block: Expr, scene: Expr) -> Expr {
23 let Expr::Map(mut entries) = block else {
24 return block;
25 };
26 entries.push((Expr::Symbol(Symbol::new("cache")), scene));
27 Expr::Map(entries)
28}
29
30pub fn article_formatted(doc: &Expr) -> Expr {
32 let markup = markup_or_empty(doc);
33 let canvas = markup.blocks.iter().map(block_formatted).collect();
34 node(
35 "stack",
36 vec![
37 ("role", sym("article")),
38 ("dir", sym("row")),
39 (
40 "children",
41 Expr::List(vec![
42 article_outline(doc),
43 node(
44 "stack",
45 vec![
46 ("role", sym("canvas")),
47 ("dir", sym("column")),
48 ("children", Expr::List(canvas)),
49 ],
50 ),
51 ]),
52 ),
53 ],
54 )
55}
56
57pub fn article_source(doc: &Expr) -> Expr {
59 let markup = markup_or_empty(doc);
60 node(
61 "stack",
62 vec![
63 ("role", sym("source")),
64 ("dir", sym("column")),
65 ("children", Expr::List(vec![text(source_text(&markup))])),
66 ],
67 )
68}
69
70pub fn article_outline(doc: &Expr) -> Expr {
72 let markup = markup_or_empty(doc);
73 let sections = markup
74 .blocks
75 .iter()
76 .filter_map(|block| match block {
77 MarkupBlock::Heading { text, .. } => Some(text_node(inline_text(text))),
78 _ => None,
79 })
80 .collect();
81 node(
82 "tree",
83 vec![
84 ("label", Expr::String("outline".to_owned())),
85 ("nodes", Expr::List(sections)),
86 ],
87 )
88}
89
90pub fn markup_edit_from_intent(doc: &Expr, intent: &Expr) -> Result<MarkupEdit> {
97 let kind = sim_lib_intent::intent_kind_of(intent)
98 .ok_or_else(|| Error::Eval("intent is missing kind".to_owned()))?;
99 if kind != sim_lib_intent::intent_kind("edit-field") {
100 return Err(Error::Eval("expected intent/edit-field".to_owned()));
101 }
102 let markup = markup_from_article(doc)?;
103 let path = sim_lib_intent::field(intent, "path")
104 .ok_or_else(|| Error::Eval("edit intent is missing path".to_owned()))?;
105 let index = block_index_from_path(path)?;
106 let old = markup
107 .blocks
108 .get(index)
109 .cloned()
110 .ok_or_else(|| Error::Eval("edit intent block index is out of range".to_owned()))?;
111 let value = sim_lib_intent::field(intent, "value")
112 .ok_or_else(|| Error::Eval("edit intent is missing value".to_owned()))?;
113 let new = MarkupBlock::from_expr(value)?;
114 Ok(MarkupEdit::ReplaceBlock { index, old, new })
115}
116
117pub fn export_intermediate(doc: &Expr) -> Expr {
119 article_from_markup(&markup_or_empty(doc))
120}
121
122pub fn export_markdown(doc: &Expr) -> String {
124 source_text(&markup_or_empty(doc))
125}
126
127fn markup_or_empty(doc: &Expr) -> MarkupDoc {
128 markup_from_article(doc).unwrap_or_else(|_| MarkupDoc {
129 title: title(doc).or_else(|| Some("untitled".to_owned())),
130 blocks: Vec::new(),
131 attrs: Default::default(),
132 source: None,
133 })
134}
135
136fn source_text(markup: &MarkupDoc) -> String {
137 let body = markup.to_source_text();
138 let Some(title) = &markup.title else {
139 return body;
140 };
141 if title_is_first_heading(markup, title) {
142 return body;
143 }
144 if body.is_empty() {
145 format!("# {title}\n")
146 } else {
147 format!("# {title}\n\n{body}")
148 }
149}
150
151fn title_is_first_heading(markup: &MarkupDoc, title: &str) -> bool {
152 matches!(
153 markup.blocks.first(),
154 Some(MarkupBlock::Heading { level: 1, text, .. }) if inline_text(text) == title
155 )
156}
157
158fn block_formatted(block: &MarkupBlock) -> Expr {
159 match block {
160 MarkupBlock::Heading { text, .. } => node(
161 "text",
162 vec![
163 ("role", sym("heading")),
164 ("text", Expr::String(inline_text(text))),
165 ],
166 ),
167 MarkupBlock::Paragraph { content, .. } => {
168 node("text", vec![("text", Expr::String(inline_text(content)))])
169 }
170 MarkupBlock::CodeBlock { code, .. } => boxed_text("code", code),
171 MarkupBlock::MathBlock { source, .. } => boxed_text("equation", &source.text),
172 MarkupBlock::Quote { blocks, .. } => node(
173 "box",
174 vec![
175 ("role", sym("quote")),
176 (
177 "children",
178 Expr::List(blocks.iter().map(block_formatted).collect()),
179 ),
180 ],
181 ),
182 MarkupBlock::List { items, .. } => node(
183 "stack",
184 vec![
185 ("role", sym("list")),
186 ("dir", sym("column")),
187 (
188 "children",
189 Expr::List(
190 items
191 .iter()
192 .map(|item| {
193 node(
194 "stack",
195 vec![
196 ("role", sym("list-item")),
197 ("dir", sym("column")),
198 (
199 "children",
200 Expr::List(item.iter().map(block_formatted).collect()),
201 ),
202 ],
203 )
204 })
205 .collect(),
206 ),
207 ),
208 ],
209 ),
210 MarkupBlock::Table { header, rows, .. } => table_scene(header, rows),
211 MarkupBlock::Figure { src, caption, .. } => node(
212 "box",
213 vec![
214 ("role", sym("figure")),
215 ("src", Expr::String(src.clone())),
216 (
217 "children",
218 Expr::List(vec![text_node(inline_text(caption))]),
219 ),
220 ],
221 ),
222 MarkupBlock::Raw { backend, text, .. } if backend.as_str() == "view-doc/embed" => {
223 embed_scene(text)
224 }
225 MarkupBlock::Raw { backend, text, .. } if backend.as_str() == "view-doc/citation" => node(
226 "text",
227 vec![
228 ("role", sym("citation")),
229 ("text", Expr::String(text.clone())),
230 ],
231 ),
232 MarkupBlock::Raw { text, .. } => text_node(text.clone()),
233 }
234}
235
236fn boxed_text(role: &str, content: &str) -> Expr {
237 node(
238 "box",
239 vec![
240 ("role", sym(role)),
241 ("children", Expr::List(vec![text_node(content.to_owned())])),
242 ],
243 )
244}
245
246fn embed_scene(text: &str) -> Expr {
247 let inner = universal_scene(&Expr::String(text.to_owned()), Mode::Builder);
248 node(
249 "embed",
250 vec![
251 ("lens", Expr::Symbol(Symbol::new("view:default"))),
252 ("scene", inner),
253 ],
254 )
255}
256
257fn table_scene(header: &[Vec<Inline>], rows: &[Vec<Vec<Inline>>]) -> Expr {
258 let rows = std::iter::once(header)
259 .chain(rows.iter().map(Vec::as_slice))
260 .map(|row| {
261 Expr::List(
262 row.iter()
263 .map(|cell| Expr::String(inline_text(cell)))
264 .collect(),
265 )
266 })
267 .collect();
268 node("table", vec![("rows", Expr::List(rows))])
269}
270
271fn block_index_from_path(path: &Expr) -> Result<usize> {
272 let segments = path_segments(path)?;
273 if segments.len() != 2 || segment_name(segments[0]).as_deref() != Some("blocks") {
274 return Err(Error::Eval(
275 "edit intent path must address blocks/<index>".to_owned(),
276 ));
277 }
278 segment_index(segments[1])
279}
280
281fn path_segments(path: &Expr) -> Result<Vec<&Expr>> {
282 match path {
283 Expr::List(items) if items.len() == 1 => match &items[0] {
284 Expr::Vector(items) => Ok(items.iter().collect()),
285 _ => Ok(items.iter().collect()),
286 },
287 Expr::List(items) => Ok(items.iter().collect()),
288 _ => Err(Error::Eval("edit intent path must be a list".to_owned())),
289 }
290}
291
292fn segment_name(segment: &Expr) -> Option<String> {
293 match segment {
294 Expr::String(text) => Some(text.clone()),
295 Expr::Symbol(symbol) => Some(symbol.name.to_string()),
296 _ => None,
297 }
298}
299
300fn segment_index(segment: &Expr) -> Result<usize> {
301 match segment {
302 Expr::Number(NumberLiteral { canonical, .. }) => canonical
303 .parse()
304 .map_err(|_| Error::Eval("block index must be an integer".to_owned())),
305 Expr::String(text) => text
306 .parse()
307 .map_err(|_| Error::Eval("block index must be an integer".to_owned())),
308 _ => Err(Error::Eval("block index must be a number".to_owned())),
309 }
310}
311
312fn inline_text(items: &[Inline]) -> String {
313 let mut out = String::new();
314 for item in items {
315 match item {
316 Inline::Text(text) | Inline::Code(text) => out.push_str(text),
317 Inline::Emph(children) | Inline::Strong(children) => {
318 out.push_str(&inline_text(children))
319 }
320 Inline::Link { label, .. } => out.push_str(&inline_text(label)),
321 Inline::Math(source) => out.push_str(&source.text),
322 Inline::Raw { text, .. } => out.push_str(text),
323 }
324 }
325 out
326}
327
328fn text(content: String) -> Expr {
329 text_node(content)
330}
331
332fn text_node(content: String) -> Expr {
333 node("text", vec![("text", Expr::String(content))])
334}