+2
-1
.markdownlint.json
+2
-1
.markdownlint.json
+2
Cargo.lock
+2
Cargo.lock
+3
-4
README.md
+3
-4
README.md
···
65
65
66
66
### Navigation
67
67
68
-
| Key | Action |
69
-
| ------------ | ------------------- |
68
+
| Key | Action |
69
+
| ------------- | ------------------- |
70
70
| `→`, `j`, `n` | Next slide |
71
71
| `←`, `k`, `p` | Previous slide |
72
-
| `1-9` | Jump to slide |
73
-
| `q` | Quit |
72
+
| `q` | Quit |
74
73
75
74
## Design Principles
76
75
+8
-35
ROADMAP.md
+8
-35
ROADMAP.md
···
1
-
# Slides
1
+
# lantern
2
2
3
3
## Plumbing
4
4
5
-
__Objective:__ Establish a clean, testable core with `clap` and a minimal `ratatui` loop.
5
+
__Outcome:__ initialize workspace with clap CLI and ratatui terminal setup
6
6
7
-
| Task | Description | Key Crates |
8
-
| ---------------------------- | ------------------------------------------------------------------------------ | --------------------------- |
9
-
| __✓ Project Scaffolding__ | Initialize workspace with `slides-core`, `slides-cli`, and `slides-ui` crates. | `cargo`, `just`, `clap` |
10
-
| | Use `cargo-generate` and a `justfile` for scripts. | |
11
-
| __✓ CLI Definition__ | Implement root command `slides` with subcommands: | `clap`[^1] |
12
-
| | • `present` (TUI) | |
13
-
| | • `print` (stdout) | |
14
-
| | • `init` (scaffold deck) | |
15
-
| | • `check` (lint slides). | |
16
-
| __✓ Logging & Colors__ | Integrate structured logs via `tracing`. | `owo-colors`[^2], `tracing` |
17
-
| | Use __owo-colors__ for color abstraction (no dynamic dispatch). | |
18
-
| __✓ Terminal & Event Setup__ | Configure alternate screen, raw mode, input loop, resize handler. | `crossterm`[^3], `ratatui` |
19
-
| __CI/CD + Tooling__ | Setup `cargo fmt`, `clippy`, `test`, and `cross` matrix CI. | GitHub Actions |
7
+
Scaffolded multi-crate workspace with present, print, init, and check subcommands, integrated structured logging via tracing, and configured alternate screen with crossterm input handling.
20
8
21
-
## Data Model (Parser & Slides)
9
+
## Data Model
22
10
23
-
__Objective:__ Parse markdown documents into a rich `Slide` struct.
24
-
25
-
| Task | Description | Key Crates |
26
-
| ------------------------ | --------------------------------------------------------------- | -------------------- |
27
-
| __✓ Parser Core__ | Split files on `---` separators. | `pulldown-cmark`[^4] |
28
-
| | Detect title blocks, lists, and code fences. | |
29
-
| | Represent as `Vec<Slide>`. | |
30
-
| __✓ Slide Model__ | Define structs: `Slide`, `Block`, `TextSpan`, `CodeBlock`, etc. | Internal |
31
-
| __✓ Metadata Parsing__ | Optional front matter (YAML/TOML) for theme, author, etc. | `serde_yml`[^5] |
32
-
| __✓ Error & Validation__ | Provide friendly parser errors with file/line info. | `thiserror`[^6] |
33
-
| __✓ Basic CLI UX__ | `lantern present file.md` runs full TUI. | `clap` |
34
-
| | `lantern print` renders to stdout with width constraint. | |
11
+
__Outcome:__ implement markdown parser with metadata and validation
12
+
Built pulldown-cmark-based parser that splits on --- separators into Vec<Slide>, supports YAML/TOML front matter, and provides friendly error messages with file/line context.
35
13
36
14
## Rendering & Navigation
37
15
···
92
70
93
71
| Task | Description | Key Crates |
94
72
| -------------------- | ------------------------------------------------------------------ | ---------------------------- |
95
-
| __Config Discovery__ | Read from `$XDG_CONFIG_HOME/slides/config.toml` for defaults. | `dirs`, `serde` |
73
+
| __CI/CD + Tooling__ | Setup `cargo fmt`, `clippy`, `test`, and `cross` matrix CI | GitHub Actions |
74
+
| __Config Discovery__ | Read from `$XDG_CONFIG_HOME/lantern/config.toml` for defaults | `dirs`, `serde` |
96
75
| __Theme Registry__ | Built-in theme manifest (e.g., `onedark`, `solarized`, `plain`). | Internal |
97
76
| __Release__ | Tag `v1.0.0-rc.1` with changelog and binaries for major platforms. | `cargo-dist`, GitHub Actions |
98
77
···
160
139
| __Deterministic Seeds__ | Add `--seed` for any animations (typing jitter, cursor blink timing) to keep exports repeatable. | internal `timeline` |
161
140
| __Preset Profiles__ | Presets like `social-card`, `doc-screenshot`, `talk-demo` mapping to resolution + theme. | internal profile registry |
162
141
163
-
[^1]: <https://docs.rs/clap/latest/clap/>
164
-
[^2]: <https://docs.rs/owo-colors/latest/owo_colors/>
165
-
[^3]: <https://docs.rs/crossterm/latest/crossterm/>
166
-
[^4]: <https://docs.rs/pulldown-cmark/latest/pulldown_cmark/>
167
-
[^5]: <https://docs.rs/serde_yml>
168
-
[^6]: <https://docs.rs/thiserror>
169
142
[^7]: <https://docs.rs/ratatui/latest/ratatui/>
170
143
[^8]: <https://docs.rs/syntect/latest/syntect/>
171
144
[^9]: <https://docs.rs/notify/latest/notify/>
+3
-2
core/Cargo.toml
+3
-2
core/Cargo.toml
+12
core/src/highlighter.rs
+12
core/src/highlighter.rs
···
204
204
ui_title: Color::new(200, 200, 200),
205
205
ui_text: Color::new(220, 220, 220),
206
206
ui_background: Color::new(30, 30, 30),
207
+
admonition_note: Color::new(100, 150, 200),
208
+
admonition_tip: Color::new(150, 100, 200),
209
+
admonition_warning: Color::new(200, 150, 50),
210
+
admonition_danger: Color::new(200, 50, 50),
211
+
admonition_success: Color::new(50, 200, 100),
212
+
admonition_info: Color::new(100, 200, 200),
207
213
};
208
214
209
215
assert!(is_dark_theme(&dark_theme));
···
231
237
ui_title: Color::new(40, 40, 40),
232
238
ui_text: Color::new(20, 20, 20),
233
239
ui_background: Color::new(250, 250, 250),
240
+
admonition_note: Color::new(0, 100, 200),
241
+
admonition_tip: Color::new(100, 0, 200),
242
+
admonition_warning: Color::new(200, 100, 0),
243
+
admonition_danger: Color::new(200, 0, 0),
244
+
admonition_success: Color::new(0, 150, 50),
245
+
admonition_info: Color::new(0, 150, 200),
234
246
};
235
247
236
248
assert!(!is_dark_theme(&light_theme));
+352
-87
core/src/parser.rs
+352
-87
core/src/parser.rs
···
18
18
sections.into_iter().map(parse_slide).collect()
19
19
}
20
20
21
+
/// Preprocess markdown to convert admonition syntax to a format we can parse
22
+
///
23
+
/// Converts both GitHub/Obsidian syntax (`> [!NOTE]`) and fence syntax (`:::note`)
24
+
/// into a special HTML-like format that we can detect in the event stream
25
+
fn preprocess_admonitions(markdown: &str) -> String {
26
+
let mut result = String::new();
27
+
let lines: Vec<&str> = markdown.lines().collect();
28
+
let mut i = 0;
29
+
30
+
while i < lines.len() {
31
+
let line = lines[i];
32
+
let trimmed = line.trim();
33
+
34
+
if let Some(admonition_type) = parse_fence_admonition(trimmed) {
35
+
result.push_str(&format!("<admonition type=\"{admonition_type}\">\n"));
36
+
i += 1;
37
+
while i < lines.len() {
38
+
let content_line = lines[i];
39
+
if content_line.trim() == ":::" {
40
+
result.push_str("</admonition>\n");
41
+
i += 1;
42
+
break;
43
+
}
44
+
result.push_str(content_line);
45
+
result.push('\n');
46
+
i += 1;
47
+
}
48
+
continue;
49
+
}
50
+
51
+
if trimmed.starts_with('>') {
52
+
if let Some((admonition_type, title)) = parse_blockquote_admonition(trimmed) {
53
+
result.push_str(&format!("<admonition type=\"{admonition_type}\""));
54
+
if let Some(t) = title {
55
+
result.push_str(&format!(" title=\"{t}\""));
56
+
}
57
+
result.push_str(">\n");
58
+
i += 1;
59
+
60
+
while i < lines.len() {
61
+
let next_line = lines[i];
62
+
let next_trimmed = next_line.trim();
63
+
if next_trimmed.starts_with('>') {
64
+
let content = next_trimmed.strip_prefix('>').unwrap_or("").trim();
65
+
if !content.is_empty() {
66
+
result.push_str(content);
67
+
result.push('\n');
68
+
}
69
+
i += 1;
70
+
} else {
71
+
break;
72
+
}
73
+
}
74
+
result.push_str("</admonition>\n");
75
+
continue;
76
+
}
77
+
}
78
+
79
+
result.push_str(line);
80
+
result.push('\n');
81
+
i += 1;
82
+
}
83
+
84
+
result
85
+
}
86
+
87
+
/// Parse fence-style admonition: `:::note` or `:::warning Title`
88
+
fn parse_fence_admonition(line: &str) -> Option<String> {
89
+
let trimmed = line.trim();
90
+
if !trimmed.starts_with(":::") {
91
+
return None;
92
+
}
93
+
94
+
let content = trimmed.strip_prefix(":::").unwrap_or("").trim();
95
+
if content.is_empty() {
96
+
return None;
97
+
}
98
+
99
+
let parts: Vec<&str> = content.splitn(2, ' ').collect();
100
+
let admonition_type = parts[0].to_lowercase();
101
+
102
+
if admonition_type.is_empty() { None } else { Some(admonition_type) }
103
+
}
104
+
105
+
/// Parse blockquote-style admonition: `> [!NOTE]` or `> [!TIP] Custom Title`
106
+
fn parse_blockquote_admonition(line: &str) -> Option<(String, Option<String>)> {
107
+
let content = line.trim().strip_prefix('>')?.trim();
108
+
109
+
if !content.starts_with("[!") {
110
+
return None;
111
+
}
112
+
113
+
let rest = content.strip_prefix("[!")?;
114
+
let close_bracket = rest.find(']')?;
115
+
let admonition_type = rest[..close_bracket].to_lowercase();
116
+
117
+
let title = rest[close_bracket + 1..].trim();
118
+
let title = if title.is_empty() { None } else { Some(title.to_string()) };
119
+
120
+
Some((admonition_type, title))
121
+
}
122
+
123
+
/// Parse HTML admonition tag: `<admonition type="note" title="Title">`
124
+
fn parse_admonition_html_start(html: &str) -> Option<(AdmonitionType, Option<String>)> {
125
+
let html = html.trim();
126
+
if !html.starts_with("<admonition") {
127
+
return None;
128
+
}
129
+
130
+
let type_start = html.find("type=\"")?;
131
+
let type_value_start = type_start + 6;
132
+
let type_end = html[type_value_start..].find('"')? + type_value_start;
133
+
let admonition_type_str = &html[type_value_start..type_end];
134
+
let admonition_type = admonition_type_str.parse().ok()?;
135
+
136
+
let title = if let Some(title_start) = html.find("title=\"") {
137
+
let title_value_start = title_start + 7;
138
+
let title_end = html[title_value_start..].find('"')? + title_value_start;
139
+
Some(html[title_value_start..title_end].to_string())
140
+
} else {
141
+
None
142
+
};
143
+
144
+
Some((admonition_type, title))
145
+
}
146
+
21
147
/// Split markdown content on `---` separators
148
+
///
149
+
/// Ignores `---` inside fenced code blocks to avoid incorrect slide splits
22
150
fn split_slides(markdown: &str) -> Vec<String> {
23
151
let mut slides = Vec::new();
24
152
let mut current = String::new();
153
+
let mut in_code_block = false;
25
154
26
155
for line in markdown.lines() {
27
156
let trimmed = line.trim();
28
-
if trimmed == "---" {
157
+
158
+
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
159
+
in_code_block = !in_code_block;
160
+
}
161
+
162
+
if trimmed == "---" && !in_code_block {
29
163
if !current.trim().is_empty() {
30
164
slides.push(current);
31
165
current = String::new();
···
45
179
46
180
/// Parse a single slide from markdown
47
181
fn parse_slide(markdown: String) -> Result<Slide> {
182
+
let preprocessed = preprocess_admonitions(&markdown);
48
183
let mut options = Options::empty();
49
184
options.insert(Options::ENABLE_TABLES);
50
185
options.insert(Options::ENABLE_STRIKETHROUGH);
51
-
let parser = Parser::new_ext(&markdown, options);
186
+
let parser = Parser::new_ext(&preprocessed, options);
52
187
let mut blocks = Vec::new();
53
188
let mut block_stack: Vec<BlockBuilder> = Vec::new();
54
189
let mut current_style = TextStyle::default();
···
57
192
match event {
58
193
Event::Start(tag) => match tag {
59
194
Tag::Heading { level, .. } => {
60
-
block_stack.push(BlockBuilder::Heading {
61
-
level: level as u8,
62
-
spans: Vec::new(),
63
-
});
195
+
block_stack.push(BlockBuilder::Heading { level: level as u8, spans: Vec::new() });
64
196
}
65
197
Tag::Paragraph => {
66
-
block_stack.push(BlockBuilder::Paragraph {
67
-
spans: Vec::new(),
68
-
});
198
+
block_stack.push(BlockBuilder::Paragraph { spans: Vec::new() });
69
199
}
70
200
Tag::CodeBlock(kind) => {
71
201
let language = match kind {
···
78
208
}
79
209
pulldown_cmark::CodeBlockKind::Indented => None,
80
210
};
81
-
block_stack.push(BlockBuilder::Code {
82
-
language,
83
-
code: String::new(),
84
-
});
211
+
block_stack.push(BlockBuilder::Code { language, code: String::new() });
85
212
}
86
213
Tag::List(first) => {
87
214
block_stack.push(BlockBuilder::List {
···
134
261
Event::End(tag_end) => match tag_end {
135
262
TagEnd::Heading(_) | TagEnd::Paragraph | TagEnd::CodeBlock => {
136
263
if let Some(builder) = block_stack.pop() {
137
-
blocks.push(builder.build());
264
+
let block = builder.build();
265
+
if let Some(BlockBuilder::Admonition { blocks: adm_blocks, .. }) = block_stack.last_mut() {
266
+
adm_blocks.push(block);
267
+
} else {
268
+
blocks.push(block);
269
+
}
138
270
}
139
271
}
140
272
TagEnd::List(_) => {
141
273
if let Some(builder) = block_stack.pop() {
142
-
blocks.push(builder.build());
274
+
let block = builder.build();
275
+
if let Some(BlockBuilder::Admonition { blocks: adm_blocks, .. }) = block_stack.last_mut() {
276
+
adm_blocks.push(block);
277
+
} else {
278
+
blocks.push(block);
279
+
}
143
280
}
144
281
}
145
282
TagEnd::BlockQuote(_) => {
146
283
if let Some(builder) = block_stack.pop() {
147
-
blocks.push(builder.build());
284
+
let block = builder.build();
285
+
if let Some(BlockBuilder::Admonition { blocks: adm_blocks, .. }) = block_stack.last_mut() {
286
+
adm_blocks.push(block);
287
+
} else {
288
+
blocks.push(block);
289
+
}
148
290
}
149
291
}
150
292
TagEnd::Table => {
151
293
if let Some(builder) = block_stack.pop() {
152
-
blocks.push(builder.build());
294
+
let block = builder.build();
295
+
if let Some(BlockBuilder::Admonition { blocks: adm_blocks, .. }) = block_stack.last_mut() {
296
+
adm_blocks.push(block);
297
+
} else {
298
+
blocks.push(block);
299
+
}
153
300
}
154
301
}
155
302
TagEnd::TableHead => {
156
-
if let Some(BlockBuilder::Table {
157
-
current_row,
158
-
headers,
159
-
in_header,
160
-
..
161
-
}) = block_stack.last_mut()
162
-
{
303
+
if let Some(BlockBuilder::Table { current_row, headers, in_header, .. }) = block_stack.last_mut() {
163
304
if !current_row.is_empty() {
164
305
*headers = std::mem::take(current_row);
165
306
}
···
167
308
}
168
309
}
169
310
TagEnd::TableRow => {
170
-
if let Some(BlockBuilder::Table {
171
-
current_row,
172
-
rows,
173
-
..
174
-
}) = block_stack.last_mut()
175
-
{
311
+
if let Some(BlockBuilder::Table { current_row, rows, .. }) = block_stack.last_mut() {
176
312
if !current_row.is_empty() {
177
313
rows.push(std::mem::take(current_row));
178
314
}
179
315
}
180
316
}
181
317
TagEnd::TableCell => {
182
-
if let Some(BlockBuilder::Table {
183
-
current_cell,
184
-
current_row,
185
-
..
186
-
}) = block_stack.last_mut()
187
-
{
318
+
if let Some(BlockBuilder::Table { current_cell, current_row, .. }) = block_stack.last_mut() {
188
319
current_row.push(std::mem::take(current_cell));
189
320
}
190
321
}
191
322
TagEnd::Item => {
192
-
if let Some(BlockBuilder::List {
193
-
current_item, items, ..
194
-
}) = block_stack.last_mut()
195
-
{
323
+
if let Some(BlockBuilder::List { current_item, items, .. }) = block_stack.last_mut() {
196
324
if !current_item.is_empty() {
197
-
items.push(ListItem {
198
-
spans: std::mem::take(current_item),
199
-
nested: None,
200
-
});
325
+
items.push(ListItem { spans: std::mem::take(current_item), nested: None });
201
326
}
202
327
}
203
328
}
···
235
360
blocks.push(Block::Rule);
236
361
}
237
362
363
+
Event::Html(html) => {
364
+
if let Some((admonition_type, title)) = parse_admonition_html_start(&html) {
365
+
block_stack.push(BlockBuilder::Admonition { admonition_type, title, blocks: Vec::new() });
366
+
} else if html.trim().starts_with("</admonition>") {
367
+
if let Some(builder) = block_stack.pop() {
368
+
blocks.push(builder.build());
369
+
}
370
+
} else if !block_stack.is_empty() {
371
+
if let Some(BlockBuilder::Admonition { blocks: adm_blocks, .. }) = block_stack.last_mut() {
372
+
let inner_markdown = html.to_string();
373
+
let inner_options = Options::empty();
374
+
let inner_parser = Parser::new_ext(&inner_markdown, inner_options);
375
+
let mut inner_block_stack: Vec<BlockBuilder> = Vec::new();
376
+
let inner_style = TextStyle::default();
377
+
378
+
for inner_event in inner_parser {
379
+
match inner_event {
380
+
Event::Start(Tag::Paragraph) => {
381
+
inner_block_stack.push(BlockBuilder::Paragraph { spans: Vec::new() });
382
+
}
383
+
Event::Text(text) => {
384
+
if let Some(builder) = inner_block_stack.last_mut() {
385
+
builder.add_text(text.to_string(), &inner_style);
386
+
}
387
+
}
388
+
Event::End(TagEnd::Paragraph) => {
389
+
if let Some(builder) = inner_block_stack.pop() {
390
+
adm_blocks.push(builder.build());
391
+
}
392
+
}
393
+
_ => {}
394
+
}
395
+
}
396
+
}
397
+
}
398
+
}
399
+
238
400
_ => {}
239
401
}
240
402
}
···
271
433
alignments: Vec<Alignment>,
272
434
in_header: bool,
273
435
},
436
+
Admonition {
437
+
admonition_type: AdmonitionType,
438
+
title: Option<String>,
439
+
blocks: Vec<Block>,
440
+
},
274
441
}
275
442
276
443
impl BlockBuilder {
···
278
445
match self {
279
446
Self::Heading { spans, .. } | Self::Paragraph { spans, .. } => {
280
447
if !text.is_empty() {
281
-
spans.push(TextSpan {
282
-
text,
283
-
style: current_style.clone(),
284
-
});
448
+
spans.push(TextSpan { text, style: current_style.clone() });
285
449
}
286
450
}
287
451
Self::Code { code, .. } => {
···
289
453
}
290
454
Self::List { current_item, .. } => {
291
455
if !text.is_empty() {
292
-
current_item.push(TextSpan {
293
-
text,
294
-
style: current_style.clone(),
295
-
});
456
+
current_item.push(TextSpan { text, style: current_style.clone() });
296
457
}
297
458
}
298
459
Self::Table { current_cell, .. } => {
299
460
if !text.is_empty() {
300
-
current_cell.push(TextSpan {
301
-
text,
302
-
style: current_style.clone(),
303
-
});
461
+
current_cell.push(TextSpan { text, style: current_style.clone() });
304
462
}
305
463
}
464
+
Self::Admonition { .. } => {}
306
465
_ => {}
307
466
}
308
467
}
···
310
469
fn add_code_span(&mut self, code: String) {
311
470
match self {
312
471
Self::Heading { spans, .. } | Self::Paragraph { spans, .. } => {
313
-
spans.push(TextSpan {
314
-
text: code,
315
-
style: TextStyle {
316
-
code: true,
317
-
..Default::default()
318
-
},
319
-
});
472
+
spans.push(TextSpan { text: code, style: TextStyle { code: true, ..Default::default() } });
320
473
}
321
474
Self::List { current_item, .. } => {
322
-
current_item.push(TextSpan {
323
-
text: code,
324
-
style: TextStyle {
325
-
code: true,
326
-
..Default::default()
327
-
},
328
-
});
475
+
current_item.push(TextSpan { text: code, style: TextStyle { code: true, ..Default::default() } });
329
476
}
330
477
Self::Table { current_cell, .. } => {
331
-
current_cell.push(TextSpan {
332
-
text: code,
333
-
style: TextStyle {
334
-
code: true,
335
-
..Default::default()
336
-
},
337
-
});
478
+
current_cell.push(TextSpan { text: code, style: TextStyle { code: true, ..Default::default() } });
338
479
}
480
+
Self::Admonition { .. } => {}
339
481
_ => {}
340
482
}
341
483
}
···
347
489
Self::Code { language, code } => Block::Code(CodeBlock { language, code }),
348
490
Self::List { ordered, items, .. } => Block::List(List { ordered, items }),
349
491
Self::BlockQuote { blocks } => Block::BlockQuote { blocks },
350
-
Self::Table {
351
-
headers,
352
-
rows,
353
-
alignments,
354
-
..
355
-
} => Block::Table(Table {
356
-
headers,
357
-
rows,
358
-
alignments,
359
-
}),
492
+
Self::Table { headers, rows, alignments, .. } => Block::Table(Table { headers, rows, alignments }),
493
+
Self::Admonition { admonition_type, title, blocks } => {
494
+
Block::Admonition(Admonition { admonition_type, title, blocks })
495
+
}
360
496
}
361
497
}
362
498
}
···
389
525
}
390
526
391
527
#[test]
528
+
fn split_slides_ignores_separator_in_code_block() {
529
+
let markdown = r#"# Slide 1
530
+
531
+
```markdown
532
+
---
533
+
```
534
+
535
+
Content after code block
536
+
537
+
---
538
+
539
+
# Slide 2"#;
540
+
let slides = split_slides(markdown);
541
+
assert_eq!(slides.len(), 2);
542
+
assert!(slides[0].contains("Slide 1"));
543
+
assert!(slides[0].contains("---"));
544
+
assert!(slides[0].contains("Content after code block"));
545
+
assert!(slides[1].contains("Slide 2"));
546
+
}
547
+
548
+
#[test]
392
549
fn parse_heading() {
393
550
let slides = parse_slides("# Hello World").unwrap();
394
551
assert_eq!(slides.len(), 1);
···
548
705
}
549
706
_ => panic!("Expected table"),
550
707
}
708
+
}
709
+
710
+
#[test]
711
+
fn preprocess_github_admonition() {
712
+
let markdown = r#"> [!NOTE]
713
+
> This is a note"#;
714
+
let preprocessed = preprocess_admonitions(markdown);
715
+
assert!(preprocessed.contains("<admonition"));
716
+
assert!(preprocessed.contains("type=\"note\""));
717
+
assert!(preprocessed.contains("</admonition>"));
718
+
719
+
let mut options = Options::empty();
720
+
options.insert(Options::ENABLE_TABLES);
721
+
options.insert(Options::ENABLE_STRIKETHROUGH);
722
+
let parser = Parser::new_ext(&preprocessed, options);
723
+
let events: Vec<_> = parser.collect();
724
+
725
+
let has_html = events.iter().any(|e| matches!(e, Event::Html(_)));
726
+
assert!(has_html, "Should have HTML events");
727
+
}
728
+
729
+
#[test]
730
+
fn parse_github_admonition_note() {
731
+
let markdown = r#"> [!NOTE]
732
+
> This is a note"#;
733
+
let slides = parse_slides(markdown).unwrap();
734
+
assert_eq!(slides.len(), 1);
735
+
736
+
match &slides[0].blocks[0] {
737
+
Block::Admonition(admonition) => {
738
+
assert_eq!(admonition.admonition_type, AdmonitionType::Note);
739
+
assert_eq!(admonition.title, None);
740
+
assert_eq!(admonition.blocks.len(), 1);
741
+
}
742
+
_ => panic!("Expected admonition, got: {:?}", slides[0].blocks[0]),
743
+
}
744
+
}
745
+
746
+
#[test]
747
+
fn parse_github_admonition_with_title() {
748
+
let markdown = r#"> [!WARNING] Custom Warning
749
+
> Be careful!"#;
750
+
let slides = parse_slides(markdown).unwrap();
751
+
752
+
match &slides[0].blocks[0] {
753
+
Block::Admonition(admonition) => {
754
+
assert_eq!(admonition.admonition_type, AdmonitionType::Warning);
755
+
assert_eq!(admonition.title, Some("Custom Warning".to_string()));
756
+
assert_eq!(admonition.blocks.len(), 1);
757
+
}
758
+
_ => panic!("Expected admonition"),
759
+
}
760
+
}
761
+
762
+
#[test]
763
+
fn parse_fence_admonition() {
764
+
let markdown = r#":::tip
765
+
This is a helpful tip
766
+
:::"#;
767
+
let slides = parse_slides(markdown).unwrap();
768
+
assert_eq!(slides.len(), 1);
769
+
770
+
match &slides[0].blocks[0] {
771
+
Block::Admonition(admonition) => {
772
+
assert_eq!(admonition.admonition_type, AdmonitionType::Tip);
773
+
assert_eq!(admonition.blocks.len(), 1);
774
+
}
775
+
_ => panic!("Expected admonition"),
776
+
}
777
+
}
778
+
779
+
#[test]
780
+
fn parse_admonition_danger_alias() {
781
+
let markdown = r#"> [!DANGER]
782
+
> Dangerous content"#;
783
+
let slides = parse_slides(markdown).unwrap();
784
+
785
+
match &slides[0].blocks[0] {
786
+
Block::Admonition(admonition) => {
787
+
assert_eq!(admonition.admonition_type, AdmonitionType::Danger);
788
+
}
789
+
_ => panic!("Expected admonition"),
790
+
}
791
+
}
792
+
793
+
#[test]
794
+
fn admonition_type_from_str_note() {
795
+
assert_eq!("note".parse::<AdmonitionType>(), Ok(AdmonitionType::Note));
796
+
assert_eq!("NOTE".parse::<AdmonitionType>(), Ok(AdmonitionType::Note));
797
+
}
798
+
799
+
#[test]
800
+
fn admonition_type_from_str_tip_alias() {
801
+
assert_eq!("tip".parse::<AdmonitionType>(), Ok(AdmonitionType::Tip));
802
+
assert_eq!("hint".parse::<AdmonitionType>(), Ok(AdmonitionType::Tip));
803
+
}
804
+
805
+
#[test]
806
+
fn admonition_type_from_str_warning_aliases() {
807
+
assert_eq!("warning".parse::<AdmonitionType>(), Ok(AdmonitionType::Warning));
808
+
assert_eq!("caution".parse::<AdmonitionType>(), Ok(AdmonitionType::Warning));
809
+
assert_eq!("attention".parse::<AdmonitionType>(), Ok(AdmonitionType::Warning));
810
+
}
811
+
812
+
#[test]
813
+
fn admonition_type_from_str_invalid() {
814
+
assert!("invalid".parse::<AdmonitionType>().is_err());
815
+
assert!("".parse::<AdmonitionType>().is_err());
551
816
}
552
817
}
+223
core/src/printer.rs
+223
core/src/printer.rs
···
1
1
use crate::highlighter;
2
2
use crate::slide::{Block, CodeBlock, List, Table, TextSpan, TextStyle};
3
3
use crate::theme::ThemeColors;
4
+
use owo_colors::OwoColorize;
5
+
use unicode_width::UnicodeWidthChar;
4
6
5
7
/// Print slides to stdout with formatted output
6
8
///
···
71
73
}
72
74
Block::Table(table) => {
73
75
print_table(writer, table, theme, width)?;
76
+
}
77
+
Block::Admonition(admonition) => {
78
+
print_admonition(writer, admonition, theme, width, indent)?;
74
79
}
75
80
}
76
81
···
226
231
Ok(())
227
232
}
228
233
234
+
/// Print an admonition with icon, colored border, and title
235
+
fn print_admonition<W: std::io::Write>(
236
+
writer: &mut W, admonition: &crate::slide::Admonition, theme: &ThemeColors, width: usize, indent: usize,
237
+
) -> std::io::Result<()> {
238
+
use crate::slide::AdmonitionType;
239
+
240
+
let (icon, color, default_title) = match admonition.admonition_type {
241
+
AdmonitionType::Note => ("\u{24D8}", &theme.admonition_note, "Note"),
242
+
AdmonitionType::Tip => ("\u{1F4A1}", &theme.admonition_tip, "Tip"),
243
+
AdmonitionType::Important => ("\u{2757}", &theme.admonition_tip, "Important"),
244
+
AdmonitionType::Warning => ("\u{26A0}", &theme.admonition_warning, "Warning"),
245
+
AdmonitionType::Caution => ("\u{26A0}", &theme.admonition_warning, "Caution"),
246
+
AdmonitionType::Danger => ("\u{26D4}", &theme.admonition_danger, "Danger"),
247
+
AdmonitionType::Error => ("\u{2717}", &theme.admonition_danger, "Error"),
248
+
AdmonitionType::Info => ("\u{24D8}", &theme.admonition_info, "Info"),
249
+
AdmonitionType::Success => ("\u{2713}", &theme.admonition_success, "Success"),
250
+
AdmonitionType::Question => ("?", &theme.admonition_info, "Question"),
251
+
AdmonitionType::Example => ("\u{25B8}", &theme.admonition_success, "Example"),
252
+
AdmonitionType::Quote => ("\u{201C}", &theme.admonition_info, "Quote"),
253
+
AdmonitionType::Abstract => ("\u{00A7}", &theme.admonition_note, "Abstract"),
254
+
AdmonitionType::Todo => ("\u{2610}", &theme.admonition_info, "Todo"),
255
+
AdmonitionType::Bug => ("\u{1F41B}", &theme.admonition_danger, "Bug"),
256
+
AdmonitionType::Failure => ("\u{2717}", &theme.admonition_danger, "Failure"),
257
+
};
258
+
259
+
let title = admonition.title.as_deref().unwrap_or(default_title);
260
+
let indent_str = " ".repeat(indent);
261
+
let box_width = width.saturating_sub(indent);
262
+
263
+
let top_border = "\u{256D}".to_string() + &"\u{2500}".repeat(box_width.saturating_sub(2)) + "\u{256E}";
264
+
writeln!(writer, "{}{}", indent_str, color.to_owo_color(&top_border))?;
265
+
266
+
let icon_display_width = icon.chars().next().and_then(|c| c.width()).unwrap_or(1);
267
+
268
+
write!(writer, "{}{} ", indent_str, color.to_owo_color(&"\u{2502}"))?;
269
+
write!(writer, "{icon} ")?;
270
+
write!(writer, "{}", color.to_owo_color(&title).bold())?;
271
+
272
+
let title_padding = box_width.saturating_sub(4 + icon_display_width + 1 + title.len());
273
+
write!(writer, "{}", " ".repeat(title_padding))?;
274
+
writeln!(writer, " {}", color.to_owo_color(&"\u{2502}"))?;
275
+
276
+
if !admonition.blocks.is_empty() {
277
+
let separator = "\u{251C}".to_string() + &"\u{2500}".repeat(box_width.saturating_sub(2)) + "\u{2524}";
278
+
writeln!(writer, "{}{}", indent_str, color.to_owo_color(&separator))?;
279
+
280
+
for block in &admonition.blocks {
281
+
match block {
282
+
Block::Paragraph { spans } => {
283
+
print_wrapped_admonition_paragraph(writer, spans, theme, color, &indent_str, box_width)?;
284
+
}
285
+
_ => {
286
+
write!(writer, "{}{} ", indent_str, color.to_owo_color(&"\u{2502}"))?;
287
+
print_block(writer, block, theme, box_width.saturating_sub(4), indent + 2)?;
288
+
writeln!(writer, "{}", color.to_owo_color(&"\u{2502}"))?;
289
+
}
290
+
}
291
+
}
292
+
}
293
+
294
+
let bottom_border = "\u{2570}".to_string() + &"\u{2500}".repeat(box_width.saturating_sub(2)) + "\u{256F}";
295
+
writeln!(writer, "{}{}", indent_str, color.to_owo_color(&bottom_border))?;
296
+
297
+
Ok(())
298
+
}
299
+
300
+
/// Print a wrapped paragraph inside an admonition with proper text wrapping
301
+
fn print_wrapped_admonition_paragraph<W: std::io::Write>(
302
+
writer: &mut W, spans: &[TextSpan], theme: &ThemeColors, border_color: &crate::theme::Color, indent_str: &str,
303
+
box_width: usize,
304
+
) -> std::io::Result<()> {
305
+
let text = spans.iter().map(|s| s.text.as_str()).collect::<Vec<_>>().join("");
306
+
let words: Vec<&str> = text.split_whitespace().collect();
307
+
308
+
let content_width = box_width.saturating_sub(4);
309
+
let mut current_line = String::new();
310
+
311
+
for word in words {
312
+
if current_line.is_empty() {
313
+
current_line = word.to_string();
314
+
} else if current_line.len() + 1 + word.len() <= content_width {
315
+
current_line.push(' ');
316
+
current_line.push_str(word);
317
+
} else {
318
+
write!(writer, "{}{} ", indent_str, border_color.to_owo_color(&"\u{2502}"))?;
319
+
write!(writer, "{}", theme.body(¤t_line))?;
320
+
let padding = content_width.saturating_sub(current_line.len());
321
+
write!(writer, "{}", " ".repeat(padding))?;
322
+
writeln!(writer, "{}", border_color.to_owo_color(&"\u{2502}"))?;
323
+
current_line = word.to_string();
324
+
}
325
+
}
326
+
327
+
if !current_line.is_empty() {
328
+
write!(writer, "{}{} ", indent_str, border_color.to_owo_color(&"\u{2502}"))?;
329
+
write!(writer, "{}", theme.body(¤t_line))?;
330
+
let padding = content_width.saturating_sub(current_line.len());
331
+
write!(writer, "{}", " ".repeat(padding))?;
332
+
writeln!(writer, "{}", border_color.to_owo_color(&"\u{2502}"))?;
333
+
}
334
+
335
+
Ok(())
336
+
}
337
+
229
338
/// Print a table with borders and proper column width calculation
230
339
///
231
340
/// Calculates column widths based on content and distributes available space
···
547
656
548
657
assert!(separator.contains("─┼─"));
549
658
assert!(separator.contains("─"));
659
+
}
660
+
661
+
#[test]
662
+
fn print_admonition_with_wrapping() {
663
+
use crate::slide::{Admonition, AdmonitionType};
664
+
665
+
let admonition = Admonition {
666
+
admonition_type: AdmonitionType::Tip,
667
+
title: Some("Tip".to_string()),
668
+
blocks: vec![Block::Paragraph {
669
+
spans: vec![TextSpan::plain(
670
+
"Variables are immutable by default - use mut only when you need to change values",
671
+
)],
672
+
}],
673
+
};
674
+
675
+
let slide = Slide::with_blocks(vec![Block::Admonition(admonition)]);
676
+
let theme = ThemeColors::default();
677
+
let mut output = Vec::new();
678
+
679
+
let result = print_slides(&mut output, &[slide], &theme, 80);
680
+
assert!(result.is_ok());
681
+
682
+
let text = String::from_utf8_lossy(&output);
683
+
assert!(text.contains("Tip"));
684
+
assert!(text.contains("Variables are immutable"));
685
+
assert!(text.contains("mut"));
686
+
assert!(text.contains("╭") && text.contains("╮"));
687
+
assert!(text.contains("├") && text.contains("┤"));
688
+
assert!(text.contains("╰") && text.contains("╯"));
689
+
assert!(text.contains("│"));
690
+
}
691
+
692
+
#[test]
693
+
fn print_admonition_border_length() {
694
+
use crate::slide::{Admonition, AdmonitionType};
695
+
696
+
let admonition = Admonition {
697
+
admonition_type: AdmonitionType::Note,
698
+
title: None,
699
+
blocks: vec![Block::Paragraph { spans: vec![TextSpan::plain("Test content")] }],
700
+
};
701
+
702
+
let slide = Slide::with_blocks(vec![Block::Admonition(admonition)]);
703
+
let theme = ThemeColors::default();
704
+
let mut output = Vec::new();
705
+
706
+
let width = 60;
707
+
let result = print_slides(&mut output, &[slide], &theme, width);
708
+
assert!(result.is_ok());
709
+
710
+
let text = String::from_utf8_lossy(&output);
711
+
let lines: Vec<&str> = text.lines().collect();
712
+
713
+
for line in &lines {
714
+
if line.contains("╭") || line.contains("├") || line.contains("╰") {
715
+
let stripped = strip_ansi_codes(line);
716
+
let visible_len = stripped.chars().count();
717
+
assert!(
718
+
visible_len <= width,
719
+
"Border line too long: {visible_len} chars (max {width})\nLine: {stripped}"
720
+
);
721
+
}
722
+
}
723
+
}
724
+
725
+
fn strip_ansi_codes(s: &str) -> String {
726
+
let mut result = String::new();
727
+
let mut chars = s.chars().peekable();
728
+
729
+
while let Some(c) = chars.next() {
730
+
if c == '\x1b' {
731
+
if chars.peek() == Some(&'[') {
732
+
chars.next();
733
+
for ch in chars.by_ref() {
734
+
if ch.is_ascii_alphabetic() {
735
+
break;
736
+
}
737
+
}
738
+
}
739
+
} else {
740
+
result.push(c);
741
+
}
742
+
}
743
+
744
+
result
745
+
}
746
+
747
+
#[test]
748
+
fn print_admonition_wraps_long_text() {
749
+
use crate::slide::{Admonition, AdmonitionType};
750
+
751
+
let long_text = "This is a very long text that should definitely wrap across multiple lines when rendered in a narrow width to ensure readability and proper formatting";
752
+
753
+
let admonition = Admonition {
754
+
admonition_type: AdmonitionType::Warning,
755
+
title: Some("Warning".to_string()),
756
+
blocks: vec![Block::Paragraph { spans: vec![TextSpan::plain(long_text)] }],
757
+
};
758
+
759
+
let slide = Slide::with_blocks(vec![Block::Admonition(admonition)]);
760
+
let theme = ThemeColors::default();
761
+
let mut output = Vec::new();
762
+
763
+
let result = print_slides(&mut output, &[slide], &theme, 50);
764
+
assert!(result.is_ok());
765
+
766
+
let text = String::from_utf8_lossy(&output);
767
+
let content_lines: Vec<&str> = text
768
+
.lines()
769
+
.filter(|line| line.contains("│") && !line.contains("╭") && !line.contains("├") && !line.contains("╰"))
770
+
.collect();
771
+
772
+
assert!(content_lines.len() > 2, "Long text should wrap to multiple lines");
550
773
}
551
774
}
+73
core/src/slide.rs
+73
core/src/slide.rs
···
1
+
use std::str::FromStr;
2
+
1
3
use serde::{Deserialize, Serialize};
2
4
3
5
/// A single slide in a presentation
···
46
48
BlockQuote { blocks: Vec<Block> },
47
49
/// Table
48
50
Table(Table),
51
+
/// Admonition/alert box with type, optional title, and content
52
+
Admonition(Admonition),
49
53
}
50
54
51
55
/// Styled text span within a block
···
128
132
Left,
129
133
Center,
130
134
Right,
135
+
}
136
+
137
+
/// Admonition type determines styling and icon
138
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
139
+
#[serde(rename_all = "lowercase")]
140
+
pub enum AdmonitionType {
141
+
Note,
142
+
Tip,
143
+
Important,
144
+
Warning,
145
+
Caution,
146
+
Danger,
147
+
Error,
148
+
Info,
149
+
Success,
150
+
Question,
151
+
Example,
152
+
Quote,
153
+
Abstract,
154
+
Todo,
155
+
Bug,
156
+
Failure,
157
+
}
158
+
159
+
/// Error type for parsing AdmonitionType
160
+
#[derive(Debug, Clone, PartialEq, Eq)]
161
+
pub struct ParseAdmonitionTypeError;
162
+
163
+
impl std::fmt::Display for ParseAdmonitionTypeError {
164
+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165
+
write!(f, "invalid admonition type")
166
+
}
167
+
}
168
+
169
+
impl std::error::Error for ParseAdmonitionTypeError {}
170
+
171
+
impl FromStr for AdmonitionType {
172
+
type Err = ParseAdmonitionTypeError;
173
+
174
+
/// Parse admonition type from string (case-insensitive)
175
+
///
176
+
/// Supports GitHub and Obsidian aliases
177
+
fn from_str(s: &str) -> Result<Self, Self::Err> {
178
+
match s.to_lowercase().as_str() {
179
+
"note" => Ok(Self::Note),
180
+
"tip" | "hint" => Ok(Self::Tip),
181
+
"important" => Ok(Self::Important),
182
+
"warning" | "caution" | "attention" => Ok(Self::Warning),
183
+
"danger" | "error" => Ok(Self::Danger),
184
+
"info" => Ok(Self::Info),
185
+
"success" | "check" | "done" => Ok(Self::Success),
186
+
"question" | "help" | "faq" => Ok(Self::Question),
187
+
"example" => Ok(Self::Example),
188
+
"quote" => Ok(Self::Quote),
189
+
"abstract" | "summary" | "tldr" => Ok(Self::Abstract),
190
+
"todo" => Ok(Self::Todo),
191
+
"bug" => Ok(Self::Bug),
192
+
"failure" | "fail" | "missing" => Ok(Self::Failure),
193
+
_ => Err(ParseAdmonitionTypeError),
194
+
}
195
+
}
196
+
}
197
+
198
+
/// Admonition/alert box with styled content
199
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
200
+
pub struct Admonition {
201
+
pub admonition_type: AdmonitionType,
202
+
pub title: Option<String>,
203
+
pub blocks: Vec<Block>,
131
204
}
132
205
133
206
#[cfg(test)]
+25
-11
core/src/term.rs
+25
-11
core/src/term.rs
···
1
+
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
2
+
use std::{io, time::Duration};
3
+
4
+
#[cfg(not(test))]
1
5
use crossterm::{
2
-
event::{self, Event, KeyCode, KeyEvent, KeyModifiers},
3
6
execute,
4
7
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
5
8
};
6
-
use std::{io, time::Duration};
7
9
8
10
/// Terminal manager that handles setup and cleanup
9
11
///
···
25
27
///
26
28
/// Enables alternate screen and raw mode for full terminal control.
27
29
pub fn setup() -> io::Result<Self> {
28
-
let mut stdout = io::stdout();
29
-
execute!(stdout, EnterAlternateScreen)?;
30
-
enable_raw_mode()?;
30
+
#[cfg(not(test))]
31
+
{
32
+
let mut stdout = io::stdout();
33
+
execute!(stdout, EnterAlternateScreen)?;
34
+
enable_raw_mode()?;
35
+
}
31
36
32
37
Ok(Self::default())
33
38
}
···
36
41
///
37
42
/// Called automatically on drop, but can be called manually for explicit cleanup.
38
43
pub fn restore(&mut self) -> io::Result<()> {
39
-
if self.in_raw_mode {
40
-
disable_raw_mode()?;
41
-
self.in_raw_mode = false;
44
+
#[cfg(not(test))]
45
+
{
46
+
if self.in_raw_mode {
47
+
disable_raw_mode()?;
48
+
self.in_raw_mode = false;
49
+
}
50
+
51
+
if self.in_alternate_screen {
52
+
let mut stdout = io::stdout();
53
+
execute!(stdout, LeaveAlternateScreen)?;
54
+
self.in_alternate_screen = false;
55
+
}
42
56
}
43
57
44
-
if self.in_alternate_screen {
45
-
let mut stdout = io::stdout();
46
-
execute!(stdout, LeaveAlternateScreen)?;
58
+
#[cfg(test)]
59
+
{
60
+
self.in_raw_mode = false;
47
61
self.in_alternate_screen = false;
48
62
}
49
63
+19
core/src/theme.rs
+19
core/src/theme.rs
···
147
147
pub ui_title: Color,
148
148
pub ui_text: Color,
149
149
pub ui_background: Color,
150
+
pub admonition_note: Color,
151
+
pub admonition_tip: Color,
152
+
pub admonition_warning: Color,
153
+
pub admonition_danger: Color,
154
+
pub admonition_success: Color,
155
+
pub admonition_info: Color,
150
156
}
151
157
152
158
impl Default for ThemeColors {
···
202
208
let ui_title = parse_hex_color(&palette.base06)?;
203
209
let ui_text = parse_hex_color(&palette.base05)?;
204
210
211
+
let admonition_note = parse_hex_color(&palette.base0d)?;
212
+
let admonition_tip = parse_hex_color(&palette.base0e)?;
213
+
let admonition_warning = parse_hex_color(&palette.base0a)?;
214
+
let admonition_danger = parse_hex_color(&palette.base08)?;
215
+
let admonition_success = parse_hex_color(&palette.base0b)?;
216
+
let admonition_info = parse_hex_color(&palette.base0c)?;
217
+
205
218
Some(Self {
206
219
heading: Color::new(heading.0, heading.1, heading.2),
207
220
heading_bold: true,
···
222
235
ui_title: Color::new(ui_title.0, ui_title.1, ui_title.2),
223
236
ui_text: Color::new(ui_text.0, ui_text.1, ui_text.2),
224
237
ui_background: Color::new(ui_background.0, ui_background.1, ui_background.2),
238
+
admonition_note: Color::new(admonition_note.0, admonition_note.1, admonition_note.2),
239
+
admonition_tip: Color::new(admonition_tip.0, admonition_tip.1, admonition_tip.2),
240
+
admonition_warning: Color::new(admonition_warning.0, admonition_warning.1, admonition_warning.2),
241
+
admonition_danger: Color::new(admonition_danger.0, admonition_danger.1, admonition_danger.2),
242
+
admonition_success: Color::new(admonition_success.0, admonition_success.1, admonition_success.2),
243
+
admonition_info: Color::new(admonition_info.0, admonition_info.1, admonition_info.2),
225
244
})
226
245
}
227
246
+133
docs/src/extensions.md
+133
docs/src/extensions.md
···
1
+
# Extensions
2
+
3
+
## Admonitions/Alerts
4
+
5
+
Admonitions (also called alerts or callouts) are special highlighted blocks that draw attention to important information. Lantern supports both GitHub-flavored markdown syntax and a custom fence syntax.
6
+
7
+
### Supported Types
8
+
9
+
All admonitions are rendered with colored borders and icons:
10
+
11
+
- **Note/Info** - Blue - General information
12
+
- **Tip/Hint/Important** - Purple - Helpful suggestions
13
+
- **Warning/Caution/Attention** - Yellow - Important warnings
14
+
- **Danger/Error** - Red - Critical issues
15
+
- **Success/Check/Done** - Green - Success messages
16
+
- **Question/Help/FAQ** - Cyan - Questions and help
17
+
- **Example** - Green - Example content
18
+
- **Quote** - Cyan - Quotations
19
+
- **Abstract/Summary/TLDR** - Blue - Summaries
20
+
- **Todo** - Cyan - Todo items
21
+
- **Bug** - Red - Bug reports
22
+
- **Failure/Fail/Missing** - Red - Failures
23
+
24
+
### GitHub/Obsidian Syntax
25
+
26
+
```markdown
27
+
> [!NOTE]
28
+
> Useful information that users should know, even when skimming content.
29
+
30
+
> [!TIP]
31
+
> Helpful advice for doing things better or more easily.
32
+
33
+
> [!IMPORTANT]
34
+
> Key information users need to know to achieve their goal.
35
+
36
+
> [!WARNING]
37
+
> Urgent info that needs immediate user attention to avoid problems.
38
+
39
+
> [!CAUTION]
40
+
> Advises about risks or negative outcomes of certain actions.
41
+
```
42
+
43
+
### Obsidian
44
+
45
+
```markdown
46
+
> [!quote]
47
+
> Lorem ipsum dolor sit amet
48
+
49
+
> [!quote] Optional Title
50
+
> Lorem ipsum dolor sit amet
51
+
52
+
> [!example]
53
+
> Lorem ipsum dolor sit amet
54
+
55
+
> [!bug]
56
+
> Lorem ipsum dolor sit amet
57
+
58
+
> [!danger]
59
+
> Lorem ipsum dolor sit amet
60
+
61
+
> [!failure]
62
+
> Lorem ipsum dolor sit amet
63
+
64
+
> [!warning]
65
+
> Lorem ipsum dolor sit amet
66
+
67
+
> [!question]
68
+
> Lorem ipsum dolor sit amet
69
+
70
+
> [!success]
71
+
> Lorem ipsum dolor sit amet
72
+
73
+
> [!tip]
74
+
> Lorem ipsum dolor sit amet
75
+
76
+
> [!todo]
77
+
> Lorem ipsum dolor sit amet
78
+
79
+
> [!abstract]
80
+
> Lorem ipsum dolor sit amet
81
+
82
+
> [!note]
83
+
> Lorem ipsum dolor sit amet
84
+
```
85
+
86
+
#### Aliases
87
+
88
+
| Main | Alias |
89
+
| -------- | ------------------ |
90
+
| danger | error |
91
+
| failure | fail, missing |
92
+
| warning | caution, attention |
93
+
| question | help, faq |
94
+
| success | check, done |
95
+
| tip | hint, important |
96
+
| abstract | summary, tldr |
97
+
98
+
### Fence Syntax
99
+
100
+
You can also use a custom fence syntax with `:::`:
101
+
102
+
```markdown
103
+
:::note
104
+
This is a note using fence syntax
105
+
:::
106
+
107
+
:::warning
108
+
This is a warning with fence syntax
109
+
:::
110
+
111
+
:::tip
112
+
Pro tip: You can use either syntax!
113
+
:::
114
+
```
115
+
116
+
### Custom Titles
117
+
118
+
For GitHub/Obsidian syntax, you can provide a custom title:
119
+
120
+
```markdown
121
+
> [!WARNING] Custom Warning Title
122
+
> This warning has a custom title instead of the default "Warning"
123
+
```
124
+
125
+
### Implementation Details
126
+
127
+
Admonitions are:
128
+
129
+
- Parsed during markdown preprocessing
130
+
- Converted to internal AST representation
131
+
- Rendered with themed colors from the active color scheme
132
+
- Displayed with Unicode icons (ⓘ, ⚠, ✓, etc.)
133
+
- Support nested markdown content (paragraphs, lists, code, etc.)
-1
docs/src/quickstart.md
-1
docs/src/quickstart.md
+449
examples/learn-markdown.md
+449
examples/learn-markdown.md
···
1
+
---
2
+
theme: default
3
+
author: Learn Markdown
4
+
---
5
+
6
+
# Markdown Basics
7
+
8
+
A quick reference for Markdown syntax
9
+
10
+
---
11
+
12
+
## Headings
13
+
14
+
Markdown supports multiple heading styles:
15
+
16
+
```markdown
17
+
# This is an h1
18
+
## This is an h2
19
+
### This is an h3
20
+
#### This is an h4
21
+
##### This is an h5
22
+
###### This is an h6
23
+
```
24
+
25
+
Alternative syntax for h1 and h2:
26
+
27
+
```markdown
28
+
This is an h1
29
+
=============
30
+
31
+
This is an h2
32
+
-------------
33
+
```
34
+
35
+
---
36
+
37
+
## Text Formatting
38
+
39
+
**Bold text:**
40
+
41
+
```markdown
42
+
**This text is in bold.**
43
+
__And so is this text.__
44
+
```
45
+
46
+
*Italic text:*
47
+
48
+
```markdown
49
+
*This text is in italics.*
50
+
_And so is this text._
51
+
```
52
+
53
+
Combined:
54
+
55
+
```markdown
56
+
***This text is in both.***
57
+
**_As is this!_**
58
+
*__And this!__*
59
+
```
60
+
61
+
Strikethrough:
62
+
63
+
```markdown
64
+
~~This text is rendered with strikethrough.~~
65
+
```
66
+
67
+
---
68
+
69
+
## Paragraphs
70
+
71
+
Paragraphs are separated by blank lines:
72
+
73
+
```markdown
74
+
This is a paragraph. I'm typing in a paragraph.
75
+
76
+
Now I'm in paragraph 2.
77
+
I'm still in paragraph 2 too!
78
+
79
+
I'm in paragraph three!
80
+
```
81
+
82
+
Line breaks require two spaces at the end or `<br />`:
83
+
84
+
```markdown
85
+
I end with two spaces (highlight to see them).
86
+
There's a <br /> above me!
87
+
```
88
+
89
+
---
90
+
91
+
## Block Quotes
92
+
93
+
Use `>` to create block quotes:
94
+
95
+
```markdown
96
+
> This is a block quote. You can either
97
+
> manually wrap your lines and put a `>`
98
+
> before every line or you can let your
99
+
> lines get really long and wrap on their own.
100
+
```
101
+
102
+
Nested quotes:
103
+
104
+
```markdown
105
+
> You can also use more than one level
106
+
>> of indentation?
107
+
> How neat is that?
108
+
```
109
+
110
+
---
111
+
112
+
## Lists
113
+
114
+
**Unordered lists** use `*`, `+`, or `-`:
115
+
116
+
```markdown
117
+
* Item
118
+
* Item
119
+
* Another item
120
+
121
+
- Item
122
+
- Item
123
+
- One last item
124
+
```
125
+
126
+
**Ordered lists** use numbers:
127
+
128
+
```markdown
129
+
1. Item one
130
+
2. Item two
131
+
3. Item three
132
+
```
133
+
134
+
Nested lists:
135
+
136
+
```markdown
137
+
1. Item one
138
+
2. Item two
139
+
3. Item three
140
+
* Sub-item
141
+
* Sub-item
142
+
4. Item four
143
+
```
144
+
145
+
---
146
+
147
+
## Task Lists
148
+
149
+
Create checkboxes with `[ ]` and `[x]`:
150
+
151
+
```markdown
152
+
- [ ] First task to complete
153
+
- [ ] Second task that needs done
154
+
- [x] This task has been completed
155
+
```
156
+
157
+
> [!NOTE]
158
+
> Task lists are a GitHub-flavored Markdown extension
159
+
160
+
---
161
+
162
+
## Code
163
+
164
+
**Inline code** uses backticks:
165
+
166
+
```markdown
167
+
John didn't even know what the `go_to()` function did!
168
+
```
169
+
170
+
**Code blocks** use triple backticks or indentation:
171
+
172
+
````markdown
173
+
```rust
174
+
fn main() {
175
+
println!("Hello, world!");
176
+
}
177
+
```
178
+
179
+
This is code
180
+
So is this
181
+
````
182
+
183
+
---
184
+
185
+
## Horizontal Rules
186
+
187
+
Create horizontal rules with three or more:
188
+
189
+
```markdown
190
+
***
191
+
---
192
+
- - -
193
+
****************
194
+
```
195
+
196
+
All render as:
197
+
198
+
***
199
+
200
+
___
201
+
202
+
- - -
203
+
204
+
---
205
+
206
+
## Links
207
+
208
+
**Inline links:**
209
+
210
+
```markdown
211
+
[Click me!](http://test.com/)
212
+
[Click me!](http://test.com/ "Link to Test.com")
213
+
[Go to music](/music/)
214
+
```
215
+
216
+
**Reference links:**
217
+
218
+
```markdown
219
+
[Click this link][link1] for more info!
220
+
[Also check out this link][foobar] if you want.
221
+
222
+
[link1]: http://test.com/ "Cool!"
223
+
[foobar]: http://foobar.biz/ "Alright!"
224
+
```
225
+
226
+
**Implicit reference:**
227
+
228
+
```markdown
229
+
[This][] is a link.
230
+
231
+
[This]: http://thisisalink.com/
232
+
```
233
+
234
+
---
235
+
236
+
## Internal Links
237
+
238
+
Link to headings using slugified IDs:
239
+
240
+
```markdown
241
+
- [Heading](#heading)
242
+
- [Another heading](#another-heading)
243
+
- [Chapter](#chapter)
244
+
- [Subchapter <h3 />](#subchapter-h3-)
245
+
```
246
+
247
+
> [!TIP]
248
+
> Heading IDs are created by lowercasing and replacing spaces with hyphens
249
+
250
+
---
251
+
252
+
## Images
253
+
254
+
**Inline images:**
255
+
256
+
```markdown
257
+

258
+
```
259
+
260
+
**Reference images:**
261
+
262
+
```markdown
263
+
![This is the alt-attribute.][myimage]
264
+
265
+
[myimage]: relative/urls/cool/image.jpg "Optional title"
266
+
```
267
+
268
+
> [!NOTE]
269
+
> Images use the same syntax as links, but with a `!` prefix
270
+
271
+
---
272
+
273
+
## Automatic Links
274
+
275
+
URLs and email addresses can be auto-linked:
276
+
277
+
```markdown
278
+
<http://testwebsite.com/>
279
+
<foo@bar.com>
280
+
```
281
+
282
+
These are equivalent to:
283
+
284
+
```markdown
285
+
[http://testwebsite.com/](http://testwebsite.com/)
286
+
[foo@bar.com](mailto:foo@bar.com)
287
+
```
288
+
289
+
---
290
+
291
+
## Escaping
292
+
293
+
Use backslash to escape special characters:
294
+
295
+
```markdown
296
+
I want to type *this* but not in italics:
297
+
\*this text surrounded by asterisks\*
298
+
```
299
+
300
+
Special characters you can escape:
301
+
302
+
```markdown
303
+
\ backslash
304
+
` backtick
305
+
* asterisk
306
+
_ underscore
307
+
{} curly braces
308
+
[] square brackets
309
+
() parentheses
310
+
# hash mark
311
+
+ plus sign
312
+
- minus sign
313
+
. dot
314
+
! exclamation mark
315
+
```
316
+
317
+
---
318
+
319
+
## HTML Elements
320
+
321
+
You can use HTML in Markdown:
322
+
323
+
```markdown
324
+
Your computer crashed? Try sending a
325
+
<kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>Del</kbd>
326
+
```
327
+
328
+
> [!WARNING]
329
+
> You cannot use Markdown syntax within HTML element contents
330
+
331
+
---
332
+
333
+
## Tables
334
+
335
+
Create tables with pipes and hyphens:
336
+
337
+
```markdown
338
+
| Col1 | Col2 | Col3 |
339
+
| :----------- | :------: | ------------: |
340
+
| Left-aligned | Centered | Right-aligned |
341
+
| blah | blah | blah |
342
+
```
343
+
344
+
Compact syntax also works:
345
+
346
+
```markdown
347
+
Col 1 | Col2 | Col3
348
+
:-- | :-: | --:
349
+
Ugh this is ugly | make it | stop
350
+
```
351
+
352
+
Alignment is controlled by colons:
353
+
354
+
- `:--` = left-aligned
355
+
- `:-:` = centered
356
+
- `--:` = right-aligned
357
+
358
+
---
359
+
360
+
## Admonitions
361
+
362
+
> [!IMPORTANT]
363
+
> Admonitions are NOT standard Markdown - they are an extension
364
+
365
+
Common admonition types:
366
+
367
+
```markdown
368
+
> [!NOTE]
369
+
> Useful information
370
+
371
+
> [!TIP]
372
+
> Helpful advice
373
+
374
+
> [!IMPORTANT]
375
+
> Critical information
376
+
377
+
> [!WARNING]
378
+
> Proceed with caution
379
+
380
+
> [!CAUTION]
381
+
> Potential risks ahead
382
+
```
383
+
384
+
---
385
+
386
+
## Admonition Examples
387
+
388
+
> [!NOTE]
389
+
> This is a note admonition with helpful context
390
+
391
+
> [!TIP]
392
+
> Use Markdown for clear, readable documentation
393
+
394
+
> [!WARNING]
395
+
> Not all Markdown processors support the same features
396
+
397
+
> [!IMPORTANT]
398
+
> Always check your Markdown processor's documentation for supported features
399
+
400
+
---
401
+
402
+
## Comments
403
+
404
+
HTML comments work in Markdown:
405
+
406
+
```markdown
407
+
<!-- This is a comment and won't be rendered -->
408
+
```
409
+
410
+
Comments are useful for:
411
+
412
+
- Leaving notes for yourself or collaborators
413
+
- Temporarily hiding content
414
+
- Adding metadata that shouldn't display
415
+
416
+
---
417
+
418
+
## Summary
419
+
420
+
Markdown provides:
421
+
422
+
- **Simple syntax** for formatted text
423
+
- **Readable source** that looks good even as plain text
424
+
- **Portable format** supported by many tools
425
+
- **Extensions** like tables, task lists, and admonitions
426
+
427
+
> [!SUCCESS]
428
+
> You now know the basics of Markdown!
429
+
430
+
---
431
+
432
+
## Resources
433
+
434
+
**Learn more:**
435
+
436
+
- Markdown Guide (https://www.markdownguide.org/)
437
+
- GitHub Flavored Markdown (https://github.github.com/gfm/)
438
+
- CommonMark Spec (https://commonmark.org/)
439
+
440
+
**Practice:**
441
+
442
+
- Markdown Tutorial (https://www.markdowntutorial.com/)
443
+
- Dillinger (https://dillinger.io/) - Online Markdown editor
444
+
445
+
---
446
+
447
+
## Thank You
448
+
449
+
Happy writing!
+9
-2
examples/learn-rust.md
+9
-2
examples/learn-rust.md
···
66
66
mutable += 2;
67
67
```
68
68
69
+
> [!TIP]
70
+
> Variables are immutable by default - use `mut` only when you need to change values
71
+
69
72
---
70
73
71
74
## Numbers
···
405
408
// Now var2 can be used again
406
409
```
407
410
408
-
**Key rule:** Either many immutable references OR one mutable reference.
411
+
> [!WARNING]
412
+
> Only ONE mutable reference OR many immutable references at a time
409
413
410
414
---
411
415
412
416
## Memory Safety
413
417
414
-
Rust's borrow checker ensures:
418
+
> [!IMPORTANT]
419
+
> Rust's borrow checker ensures memory safety at compile time
420
+
421
+
Guarantees:
415
422
416
423
- No use after free
417
424
- No double free
+1
ui/Cargo.toml
+1
ui/Cargo.toml
+92
ui/src/renderer.rs
+92
ui/src/renderer.rs
···
7
7
style::{Modifier, Style},
8
8
text::{Line, Span, Text},
9
9
};
10
+
use unicode_width::UnicodeWidthChar;
10
11
11
12
/// Render a slide's blocks into ratatui Text
12
13
///
···
23
24
Block::Rule => render_rule(theme, &mut lines),
24
25
Block::BlockQuote { blocks } => render_blockquote(blocks, theme, &mut lines),
25
26
Block::Table(table) => render_table(table, theme, &mut lines),
27
+
Block::Admonition(admonition) => render_admonition(admonition, theme, &mut lines),
26
28
}
27
29
28
30
lines.push(Line::raw(""));
···
133
135
lines.push(Line::from(line_spans));
134
136
}
135
137
}
138
+
}
139
+
140
+
/// Render an admonition with colored border and icon
141
+
fn render_admonition(
142
+
admonition: &lantern_core::slide::Admonition, theme: &ThemeColors, lines: &mut Vec<Line<'static>>,
143
+
) {
144
+
use lantern_core::slide::AdmonitionType;
145
+
146
+
let (icon, color, default_title) = match admonition.admonition_type {
147
+
AdmonitionType::Note => ("\u{24D8}", &theme.admonition_note, "Note"),
148
+
AdmonitionType::Tip => ("\u{1F4A1}", &theme.admonition_tip, "Tip"),
149
+
AdmonitionType::Important => ("\u{2757}", &theme.admonition_tip, "Important"),
150
+
AdmonitionType::Warning => ("\u{26A0}", &theme.admonition_warning, "Warning"),
151
+
AdmonitionType::Caution => ("\u{26A0}", &theme.admonition_warning, "Caution"),
152
+
AdmonitionType::Danger => ("\u{26D4}", &theme.admonition_danger, "Danger"),
153
+
AdmonitionType::Error => ("\u{2717}", &theme.admonition_danger, "Error"),
154
+
AdmonitionType::Info => ("\u{24D8}", &theme.admonition_info, "Info"),
155
+
AdmonitionType::Success => ("\u{2713}", &theme.admonition_success, "Success"),
156
+
AdmonitionType::Question => ("?", &theme.admonition_info, "Question"),
157
+
AdmonitionType::Example => ("\u{25B8}", &theme.admonition_success, "Example"),
158
+
AdmonitionType::Quote => ("\u{201C}", &theme.admonition_info, "Quote"),
159
+
AdmonitionType::Abstract => ("\u{00A7}", &theme.admonition_note, "Abstract"),
160
+
AdmonitionType::Todo => ("\u{2610}", &theme.admonition_info, "Todo"),
161
+
AdmonitionType::Bug => ("\u{1F41B}", &theme.admonition_danger, "Bug"),
162
+
AdmonitionType::Failure => ("\u{2717}", &theme.admonition_danger, "Failure"),
163
+
};
164
+
165
+
let title = admonition.title.as_deref().unwrap_or(default_title);
166
+
let color_style = to_ratatui_style(color, false);
167
+
let bold_color_style = to_ratatui_style(color, true);
168
+
169
+
let top_border = format!("\u{256D}{}\u{256E}", "\u{2500}".repeat(58));
170
+
lines.push(Line::from(Span::styled(top_border, color_style)));
171
+
172
+
let icon_display_width = icon.chars().next().and_then(|c| c.width()).unwrap_or(1);
173
+
174
+
let title_line = vec![
175
+
Span::styled("\u{2502} ".to_string(), color_style),
176
+
Span::raw(format!("{icon} ")),
177
+
Span::styled(title.to_string(), bold_color_style),
178
+
Span::styled(
179
+
" ".repeat(56_usize.saturating_sub(icon_display_width + 1 + title.len())),
180
+
color_style,
181
+
),
182
+
Span::styled(" \u{2502}".to_string(), color_style),
183
+
];
184
+
lines.push(Line::from(title_line));
185
+
186
+
if !admonition.blocks.is_empty() {
187
+
let separator = format!("\u{251C}{}\u{2524}", "\u{2500}".repeat(58));
188
+
lines.push(Line::from(Span::styled(separator, color_style)));
189
+
190
+
for block in &admonition.blocks {
191
+
if let Block::Paragraph { spans } = block {
192
+
let text: String = spans.iter().map(|s| s.text.as_str()).collect();
193
+
let words: Vec<&str> = text.split_whitespace().collect();
194
+
let content_width = 56; // 60 total - 2 for borders - 2 for spaces
195
+
196
+
let mut current_line = String::new();
197
+
for word in words {
198
+
if current_line.is_empty() {
199
+
current_line = word.to_string();
200
+
} else if current_line.len() + 1 + word.len() <= content_width {
201
+
current_line.push(' ');
202
+
current_line.push_str(word);
203
+
} else {
204
+
let mut line_spans = vec![Span::styled("\u{2502} ".to_string(), color_style)];
205
+
line_spans.push(Span::raw(current_line.clone()));
206
+
let padding = content_width.saturating_sub(current_line.len());
207
+
line_spans.push(Span::raw(" ".repeat(padding)));
208
+
line_spans.push(Span::styled(" \u{2502}".to_string(), color_style));
209
+
lines.push(Line::from(line_spans));
210
+
current_line = word.to_string();
211
+
}
212
+
}
213
+
214
+
if !current_line.is_empty() {
215
+
let mut line_spans = vec![Span::styled("\u{2502} ".to_string(), color_style)];
216
+
line_spans.push(Span::raw(current_line.clone()));
217
+
let padding = content_width.saturating_sub(current_line.len());
218
+
line_spans.push(Span::raw(" ".repeat(padding)));
219
+
line_spans.push(Span::styled(" \u{2502}".to_string(), color_style));
220
+
lines.push(Line::from(line_spans));
221
+
}
222
+
}
223
+
}
224
+
}
225
+
226
+
let bottom_border = format!("\u{2570}{}\u{256F}", "\u{2500}".repeat(58));
227
+
lines.push(Line::from(Span::styled(bottom_border, color_style)));
136
228
}
137
229
138
230
/// Render a table with basic formatting