max / quasi
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
8 files changed,
+287 insertions,
-21 deletions
| @@ -109,8 +109,8 @@ | |||
| 109 | 109 | pub use crate::response::{Message, Outcome, Response}; | |
| 110 | 110 | pub use crate::router::{Handler, Router}; | |
| 111 | 111 | pub use crate::screen::{ | |
| 112 | - | Act, Action, Cells, Choice, Column, Destination, Field, Figure, Meter, Node, RegionKind, Rest, | |
| 113 | - | Row, Screen, Slot, Tag, | |
| 112 | + | Act, Action, Cells, Choice, Column, Destination, Field, Figure, Meter, Node, Prose, RegionKind, | |
| 113 | + | Rest, Row, Screen, Slot, Tag, | |
| 114 | 114 | }; | |
| 115 | 115 | ||
| 116 | 116 | #[cfg(test)] |
| @@ -376,6 +376,82 @@ | |||
| 376 | 376 | } | |
| 377 | 377 | } | |
| 378 | 378 | ||
| 379 | + | /// Prose in a row part: what it says, and whether it is markdown. | |
| 380 | + | /// | |
| 381 | + | /// `secondary` has always been a `String`, and three call sites had markdown to | |
| 382 | + | /// put in it: the goingson projects card's description, the mail list's body | |
| 383 | + | /// preview, and a contact's note next. Each put the **source** in, so a row read | |
| 384 | + | /// `**Ships Q3.** See [the brief](https://...)` where the screen it stands in | |
| 385 | + | /// for reads the sentence. Flattening at the call site fixes what the user sees | |
| 386 | + | /// and loses the fact on the way: a renderer receiving the row cannot tell text | |
| 387 | + | /// an author typed from markdown somebody already flattened, so it cannot decide | |
| 388 | + | /// for itself, and the flattening is copied per site. | |
| 389 | + | /// | |
| 390 | + | /// This is [`Meter`]'s answer, not [`Node`]'s. The row still holds no node -- | |
| 391 | + | /// the 2026-08-08 ruling, and the door through which a description becomes a | |
| 392 | + | /// templating language -- it holds a two-case value saying which of two things | |
| 393 | + | /// its string is. A webview renders the markdown inline, a terminal can emit | |
| 394 | + | /// bold, and a renderer that wants neither flattens it, each from the same | |
| 395 | + | /// description. | |
| 396 | + | /// | |
| 397 | + | /// [`Text`](Self::Text) is the default in every sense: `From<&str>` and | |
| 398 | + | /// `From<String>` both produce it, so `.secondary("...")` means what it always | |
| 399 | + | /// meant and no existing call site changes. | |
| 400 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 401 | + | pub enum Prose { | |
| 402 | + | /// Text as written. A renderer escapes it and draws it, and nothing in it | |
| 403 | + | /// is markup however it is punctuated. | |
| 404 | + | Text(String), | |
| 405 | + | /// Markdown source, carried as source for the reason [`Node::Rich`] does: | |
| 406 | + | /// every renderer has an honest answer because each renders it its own way, | |
| 407 | + | /// and nothing here is markup a renderer has to trust. | |
| 408 | + | Rich(String), | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | impl Prose { | |
| 412 | + | /// Markdown, to be rendered by whoever draws it. | |
| 413 | + | pub fn rich(source: impl Into<String>) -> Self { | |
| 414 | + | Self::Rich(source.into()) | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | /// The string, whichever case this is. | |
| 418 | + | /// | |
| 419 | + | /// For a renderer that treats both the same, and for a test that does not | |
| 420 | + | /// care. A renderer that draws this without looking at the case is drawing | |
| 421 | + | /// markdown as text, which is the bug this type exists to make visible | |
| 422 | + | /// rather than impossible. | |
| 423 | + | #[must_use] | |
| 424 | + | pub fn source(&self) -> &str { | |
| 425 | + | match self { | |
| 426 | + | Self::Text(text) | Self::Rich(text) => text, | |
| 427 | + | } | |
| 428 | + | } | |
| 429 | + | ||
| 430 | + | /// Whether there is anything to draw. | |
| 431 | + | #[must_use] | |
| 432 | + | pub fn is_empty(&self) -> bool { | |
| 433 | + | self.source().is_empty() | |
| 434 | + | } | |
| 435 | + | } | |
| 436 | + | ||
| 437 | + | impl From<String> for Prose { | |
| 438 | + | fn from(text: String) -> Self { | |
| 439 | + | Self::Text(text) | |
| 440 | + | } | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | impl From<&str> for Prose { | |
| 444 | + | fn from(text: &str) -> Self { | |
| 445 | + | Self::Text(text.to_owned()) | |
| 446 | + | } | |
| 447 | + | } | |
| 448 | + | ||
| 449 | + | impl From<&String> for Prose { | |
| 450 | + | fn from(text: &String) -> Self { | |
| 451 | + | Self::Text(text.clone()) | |
| 452 | + | } | |
| 453 | + | } | |
| 454 | + | ||
| 379 | 455 | /// How much of a set is done, owned. | |
| 380 | 456 | /// | |
| 381 | 457 | /// The borrowed original is [`layout::Meter`], which arrived at 0.10.0 for this. | |
| @@ -992,8 +1068,12 @@ | |||
| 992 | 1068 | pub struct Row { | |
| 993 | 1069 | /// The thing itself. What the row is called. | |
| 994 | 1070 | pub primary: String, | |
| 995 | - | /// Supporting text under the primary. | |
| 996 | - | pub secondary: Option<String>, | |
| 1071 | + | /// Supporting text under the primary, plain or markdown. | |
| 1072 | + | /// | |
| 1073 | + | /// [`Prose`] rather than a `String` since the row-prose decision: the part | |
| 1074 | + | /// is still a string and still not a node, and it now says which of the two | |
| 1075 | + | /// kinds of string it is. | |
| 1076 | + | pub secondary: Option<Prose>, | |
| 997 | 1077 | /// A short trailing fact: a count, a size, a date. | |
| 998 | 1078 | pub meta: Option<String>, | |
| 999 | 1079 | /// Small labelled things belonging to the row: badges, chips, tags. | |
| @@ -1122,7 +1202,7 @@ | |||
| 1122 | 1202 | ||
| 1123 | 1203 | /// Supporting text under the primary. | |
| 1124 | 1204 | #[must_use] | |
| 1125 | - | pub fn secondary(mut self, text: impl Into<String>) -> Self { | |
| 1205 | + | pub fn secondary(mut self, text: impl Into<Prose>) -> Self { | |
| 1126 | 1206 | self.secondary = Some(text.into()); | |
| 1127 | 1207 | self | |
| 1128 | 1208 | } | |
| @@ -1250,8 +1330,9 @@ | |||
| 1250 | 1330 | /// description that sanitised would be deciding what a host can draw. | |
| 1251 | 1331 | /// | |
| 1252 | 1332 | /// Not available inside a [`Row`]: a row part holds no node, by the | |
| 1253 | - | /// 2026-08-08 ruling, and that is why the projects card keeps its raw | |
| 1254 | - | /// markdown in `secondary` rather than gaining this. | |
| 1333 | + | /// 2026-08-08 ruling. What a row can hold is [`Prose`], which carries the | |
| 1334 | + | /// same markdown source under the same reasoning without being a node, so | |
| 1335 | + | /// the projects card no longer keeps its raw markdown in `secondary`. | |
| 1255 | 1336 | Rich { | |
| 1256 | 1337 | /// The markdown, as written. | |
| 1257 | 1338 | source: String, |
| @@ -29,7 +29,7 @@ | |||
| 29 | 29 | use makeover_webview::list::{Cell, cells_html}; | |
| 30 | 30 | use makeover_webview::meter::meter_html; | |
| 31 | 31 | use makeover_webview::placeholder::placeholder_html; | |
| 32 | - | use quasi_router::screen::{Act, Cells, Destination, Field, Node, Row, Slot, Tag}; | |
| 32 | + | use quasi_router::screen::{Act, Cells, Destination, Field, Node, Prose, Row, Slot, Tag}; | |
| 33 | 33 | use quasi_router::{Action, Method, Params}; | |
| 34 | 34 | ||
| 35 | 35 | /// The class-name prefix, applied through [`Emit::class_prefix`]. | |
| @@ -118,6 +118,32 @@ | |||
| 118 | 118 | } | |
| 119 | 119 | } | |
| 120 | 120 | ||
| 121 | + | /// A row part's prose into markup. | |
| 122 | + | /// | |
| 123 | + | /// [`Prose::Text`] escapes, exactly as it did when the part was a `String`. | |
| 124 | + | /// [`Prose::Rich`] renders through docengine's `phrase` preset, which is the | |
| 125 | + | /// preset this call site asked for: markdown with no block structure at all and | |
| 126 | + | /// no links, keeping the inline emphasis. A heading, a list and a quote each | |
| 127 | + | /// contribute their words without claiming a block of a row that has no room | |
| 128 | + | /// for one, and not even a paragraph survives. | |
| 129 | + | /// | |
| 130 | + | /// Links go because a row usually carries [`Row::activate`], so the row itself | |
| 131 | + | /// is already a target and an anchor inside it is a second target inside the | |
| 132 | + | /// first: ambiguous to click, worse to reach by keyboard, and pointing somewhere | |
| 133 | + | /// a one-line summary cannot usefully send anyone. Their text stays. | |
| 134 | + | /// | |
| 135 | + | /// This is the renderer deciding, which is the point of the description | |
| 136 | + | /// carrying the kind rather than a flattened string. A terminal renderer facing | |
| 137 | + | /// the same [`Prose::Rich`] can emit bold instead, and one that wants neither | |
| 138 | + | /// can call `docengine::render_plain`. None of them has to be told by the | |
| 139 | + | /// screen author which to do. | |
| 140 | + | fn row_prose_html(prose: &Prose) -> String { | |
| 141 | + | match prose { | |
| 142 | + | Prose::Text(text) => escape(text), | |
| 143 | + | Prose::Rich(source) => docengine::render_phrase(source), | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | ||
| 121 | 147 | /// Markdown source into markup, for [`Node::Rich`]. | |
| 122 | 148 | /// | |
| 123 | 149 | /// The strict preset, which is a deliberate difference from the | |
| @@ -443,7 +469,7 @@ | |||
| 443 | 469 | out.push_str("<span"); | |
| 444 | 470 | class_attr(&["row-secondary"], opts, out); | |
| 445 | 471 | out.push('>'); | |
| 446 | - | out.push_str(&escape(secondary)); | |
| 472 | + | out.push_str(&row_prose_html(secondary)); | |
| 447 | 473 | out.push_str("</span>"); | |
| 448 | 474 | } | |
| 449 | 475 |
| @@ -9,7 +9,9 @@ | |||
| 9 | 9 | ||
| 10 | 10 | use makeover_layout as layout; | |
| 11 | 11 | use quasi_http::Render; | |
| 12 | - | use quasi_router::screen::{Act, Cells, Choice, Column, Field, Figure, Meter, Rest, Row, Tag}; | |
| 12 | + | use quasi_router::screen::{ | |
| 13 | + | Act, Cells, Choice, Column, Field, Figure, Meter, Prose, Rest, Row, Tag, | |
| 14 | + | }; | |
| 13 | 15 | use quasi_router::{Action, Node, RegionKind, Screen, Slot}; | |
| 14 | 16 | ||
| 15 | 17 | use crate::{Emit, Shell, Webview}; | |
| @@ -1100,7 +1102,9 @@ | |||
| 1100 | 1102 | let shell = Shell::default().layered(["base"]).styled("/style.css"); | |
| 1101 | 1103 | let html = shell.document("Console", "<main>x</main>"); | |
| 1102 | 1104 | ||
| 1103 | - | let stmt = html.find("@layer makeover, base;").expect("the order is stated"); | |
| 1105 | + | let stmt = html | |
| 1106 | + | .find("@layer makeover, base;") | |
| 1107 | + | .expect("the order is stated"); | |
| 1104 | 1108 | let sheet = html.find("/style.css").expect("the sheet is linked"); | |
| 1105 | 1109 | let body = html.find("<main>").expect("the body is placed"); | |
| 1106 | 1110 | assert!(stmt < sheet); | |
| @@ -1207,3 +1211,97 @@ | |||
| 1207 | 1211 | // its text fails here too. | |
| 1208 | 1212 | assert_eq!(html.matches("<script>").count(), 10); | |
| 1209 | 1213 | } | |
| 1214 | + | ||
| 1215 | + | #[test] | |
| 1216 | + | fn a_rows_plain_prose_is_escaped_exactly_as_it_always_was() { | |
| 1217 | + | // The default case, and the one that must not change: `.secondary("...")` | |
| 1218 | + | // still means text, and text is never markup however it is punctuated. | |
| 1219 | + | let row = Row::new("Atlas").secondary("**not bold** <b>not bold either</b>"); | |
| 1220 | + | let node = Node::list(vec![row]); | |
| 1221 | + | ||
| 1222 | + | let html = Webview::new().fragment(&node); | |
| 1223 | + | ||
| 1224 | + | assert!(html.contains("**not bold**"), "got: {html}"); | |
| 1225 | + | assert!(!html.contains("<strong>"), "got: {html}"); | |
| 1226 | + | assert!(!html.contains("<b>"), "got: {html}"); | |
| 1227 | + | assert!(html.contains("<b>"), "got: {html}"); | |
| 1228 | + | } | |
| 1229 | + | ||
| 1230 | + | #[test] | |
| 1231 | + | fn a_rows_rich_prose_is_rendered_inline() { | |
| 1232 | + | // The row-prose decision. The description says the string is markdown and | |
| 1233 | + | // this renderer draws it as markdown, one line's worth. | |
| 1234 | + | let row = Row::new("Atlas").secondary(Prose::rich("**Ships Q3.** `soon`")); | |
| 1235 | + | let node = Node::list(vec![row]); | |
| 1236 | + | ||
| 1237 | + | let html = Webview::new().fragment(&node); | |
| 1238 | + | ||
| 1239 | + | assert!(html.contains("<strong>Ships Q3.</strong>"), "got: {html}"); | |
| 1240 | + | assert!(html.contains("<code>soon</code>"), "got: {html}"); | |
| 1241 | + | } | |
| 1242 | + | ||
| 1243 | + | #[test] | |
| 1244 | + | fn a_rows_rich_prose_keeps_no_blocks() { | |
| 1245 | + | // A row is one line tall. A heading, a list and a quote each contribute | |
| 1246 | + | // their words and none of them claims a block. | |
| 1247 | + | let row = Row::new("Borealis").secondary(Prose::rich("# Goal\n\n> ship it\n\n- one\n- two")); | |
| 1248 | + | let node = Node::list(vec![row]); | |
| 1249 | + | ||
| 1250 | + | let html = Webview::new().fragment(&node); | |
| 1251 | + | ||
| 1252 | + | // The outer `<ul class="list">` is the list itself; what must not appear is | |
| 1253 | + | // a second one inside the row's own span. | |
| 1254 | + | let secondary = html | |
| 1255 | + | .split_once(r#"<span class="row-secondary">"#) | |
| 1256 | + | .expect("the row draws its secondary") | |
| 1257 | + | .1 | |
| 1258 | + | .split_once("</span>") | |
| 1259 | + | .expect("the part closes") | |
| 1260 | + | .0; | |
| 1261 | + | for block in ["<h1", "<blockquote", "<ul", "<li", "<p"] { | |
| 1262 | + | assert!(!secondary.contains(block), "no {block} in a row: {html}"); | |
| 1263 | + | } | |
| 1264 | + | for word in ["Goal", "ship it", "one", "two"] { | |
| 1265 | + | assert!(html.contains(word), "{word} survives: {html}"); | |
| 1266 | + | } | |
| 1267 | + | } | |
| 1268 | + | ||
| 1269 | + | #[test] | |
| 1270 | + | fn a_rows_rich_prose_carries_no_second_click_target() { | |
| 1271 | + | // The row is already the target through `activate`. An anchor inside it | |
| 1272 | + | // would be a second target inside the first. | |
| 1273 | + | let row = Row::new("Atlas") | |
| 1274 | + | .secondary(Prose::rich( | |
| 1275 | + | "see [the brief](https://example.com/a/long/path)", | |
| 1276 | + | )) | |
| 1277 | + | .activate(Action::get("/projects/1")); | |
| 1278 | + | let node = Node::list(vec![row]); | |
| 1279 | + | ||
| 1280 | + | let html = Webview::new().fragment(&node); | |
| 1281 | + | ||
| 1282 | + | assert!(html.contains("see the brief"), "the text survives: {html}"); | |
| 1283 | + | assert!(!html.contains("example.com"), "no href: {html}"); | |
| 1284 | + | // The row itself is an anchor, drawn from `activate`. That one is the | |
| 1285 | + | // target; the assertion is that the prose did not add a second. | |
| 1286 | + | assert_eq!( | |
| 1287 | + | html.matches("<a ").count(), | |
| 1288 | + | 1, | |
| 1289 | + | "the row is the only target: {html}" | |
| 1290 | + | ); | |
| 1291 | + | } | |
| 1292 | + | ||
| 1293 | + | #[test] | |
| 1294 | + | fn a_rows_rich_prose_cannot_smuggle_markup() { | |
| 1295 | + | // `Prose::Rich` carries source, not markup, so the renderer decides what is | |
| 1296 | + | // drawable. Raw HTML in the source is not. | |
| 1297 | + | let row = Row::new("Atlas").secondary(Prose::rich( | |
| 1298 | + | "hi <script>alert(1)</script> <img src=x onerror=alert(1)> [x](javascript:alert(1))", | |
| 1299 | + | )); | |
| 1300 | + | let node = Node::list(vec![row]); | |
| 1301 | + | ||
| 1302 | + | let html = Webview::new().fragment(&node); | |
| 1303 | + | ||
| 1304 | + | assert!(!html.contains("<script"), "got: {html}"); | |
| 1305 | + | assert!(!html.contains("onerror"), "got: {html}"); | |
| 1306 | + | assert!(!html.contains("javascript:"), "got: {html}"); | |
| 1307 | + | } |
| @@ -34,6 +34,10 @@ | |||
| 34 | 34 | # cargo refuses it outright — so the crate that is on both sides of the | |
| 35 | 35 | # build-dependency line has to declare the smaller set centrally. | |
| 36 | 36 | quasi-store = { git = "https://makenot.work/git/max/quasi.git", default-features = false } | |
| 37 | + | # The markdown engine. Already in the tree through quasi-webview, which renders | |
| 38 | + | # every `Node::Rich` and every `Prose::Rich` with it; declared here because the | |
| 39 | + | # core crate needs the other direction, markdown to text, for a row's preview. | |
| 40 | + | docengine = { git = "https://makenot.work/git/max/docengine.git" } | |
| 37 | 41 | ||
| 38 | 42 | rusqlite = { version = "0.40.0", features = ["bundled"] } | |
| 39 | 43 | thiserror = "2.0.17" |
| @@ -17,6 +17,10 @@ | |||
| 17 | 17 | # thing that decides what a screen is. | |
| 18 | 18 | quasi-router = { workspace = true } | |
| 19 | 19 | quasi-store = { workspace = true, features = ["runtime"] } | |
| 20 | + | # Not a host crate: it turns markdown into text and knows nothing about where | |
| 21 | + | # the text goes. The rule above is about hosts, and this is the same kind of | |
| 22 | + | # thing `thiserror` is. | |
| 23 | + | docengine = { workspace = true } | |
| 20 | 24 | rusqlite = { workspace = true } | |
| 21 | 25 | thiserror = { workspace = true } | |
| 22 | 26 | tracing = { workspace = true } |
| @@ -40,8 +40,8 @@ | |||
| 40 | 40 | ||
| 41 | 41 | use quasi_router::layout::{FieldKind, Selector, Tone}; | |
| 42 | 42 | use quasi_router::{ | |
| 43 | - | Act, Action, Choice, Field, Node, Params, RegionKind, Response, RouteError, Router, Screen, | |
| 44 | - | Slot, | |
| 43 | + | Act, Action, Choice, Field, Node, Params, Prose, RegionKind, Response, RouteError, Router, | |
| 44 | + | Screen, Slot, | |
| 45 | 45 | }; | |
| 46 | 46 | ||
| 47 | 47 | use crate::state::AppState; | |
| @@ -77,14 +77,29 @@ | |||
| 77 | 77 | /// Truncation is the description's business rather than the renderer's here, | |
| 78 | 78 | /// because what is being said is "a preview", and a renderer handed the whole | |
| 79 | 79 | /// body cannot know that is what it was for. | |
| 80 | - | fn preview(body: &str) -> Option<String> { | |
| 81 | - | let line = body.lines().find(|line| !line.trim().is_empty())?.trim(); | |
| 82 | - | Some(if line.chars().count() > 80 { | |
| 80 | + | /// | |
| 81 | + | /// The body is markdown, so it is flattened before it is cut, and the result is | |
| 82 | + | /// [`Prose::Text`] rather than [`Prose::Rich`]. Both halves matter and neither | |
| 83 | + | /// is the obvious one: | |
| 84 | + | /// | |
| 85 | + | /// - **Flatten first, then cut.** Cutting markdown source at 80 characters | |
| 86 | + | /// lands mid-syntax sooner or later, and `**bo…` renders as those four | |
| 87 | + | /// characters. Cutting text cannot do that. | |
| 88 | + | /// - **Text, not Rich.** What comes out is no longer markdown, so saying `Rich` | |
| 89 | + | /// would be a lie a renderer acts on. This is a description choosing to | |
| 90 | + | /// summarise, which is a different thing from the whole body travelling as | |
| 91 | + | /// source: compare goingson's projects card, which passes its description as | |
| 92 | + | /// `Prose::rich` and lets each renderer decide, because it is short and | |
| 93 | + | /// bounded and there is nothing to cut. | |
| 94 | + | fn preview(body: &str) -> Option<Prose> { | |
| 95 | + | let plain = docengine::render_plain(body); | |
| 96 | + | let line = plain.lines().find(|line| !line.trim().is_empty())?.trim(); | |
| 97 | + | Some(Prose::Text(if line.chars().count() > 80 { | |
| 83 | 98 | let cut: String = line.chars().take(79).collect(); | |
| 84 | 99 | format!("{cut}…") | |
| 85 | 100 | } else { | |
| 86 | 101 | line.to_owned() | |
| 87 | - | }) | |
| 102 | + | })) | |
| 88 | 103 | } | |
| 89 | 104 | ||
| 90 | 105 | /// One note as a row. |
| @@ -356,12 +356,50 @@ | |||
| 356 | 356 | ||
| 357 | 357 | #[test] | |
| 358 | 358 | fn a_long_preview_is_cut_and_a_short_one_is_not() { | |
| 359 | - | assert_eq!(preview("short"), Some("short".to_owned())); | |
| 360 | - | assert_eq!(preview(" \n\n first line "), Some("first line".to_owned())); | |
| 359 | + | assert_eq!(preview("short"), Some(Prose::Text("short".to_owned()))); | |
| 360 | + | assert_eq!( | |
| 361 | + | preview(" \n\n first line "), | |
| 362 | + | Some(Prose::Text("first line".to_owned())) | |
| 363 | + | ); | |
| 361 | 364 | assert_eq!(preview(" "), None); | |
| 362 | 365 | ||
| 363 | 366 | let long = "x".repeat(200); | |
| 364 | 367 | let cut = preview(&long).unwrap(); | |
| 365 | - | assert_eq!(cut.chars().count(), 80); | |
| 366 | - | assert!(cut.ends_with('…')); | |
| 368 | + | assert_eq!(cut.source().chars().count(), 80); | |
| 369 | + | assert!(cut.source().ends_with('…')); | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | #[test] | |
| 373 | + | fn a_preview_says_what_the_markdown_says() { | |
| 374 | + | // The body is markdown, so a row showing its source would show the syntax: | |
| 375 | + | // `**Bold** and a [link](...)` rather than the sentence. | |
| 376 | + | assert_eq!( | |
| 377 | + | preview("**Bold** and a [link](https://example.com/a/long/path)"), | |
| 378 | + | Some(Prose::Text("Bold and a link".to_owned())) | |
| 379 | + | ); | |
| 380 | + | // A heading is a first line like any other once the `#` is gone. | |
| 381 | + | assert_eq!( | |
| 382 | + | preview("# Title\n\nBody"), | |
| 383 | + | Some(Prose::Text("Title".to_owned())) | |
| 384 | + | ); | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | #[test] | |
| 388 | + | fn a_preview_is_cut_after_flattening_never_before() { | |
| 389 | + | // The reason the order matters: cutting source lands mid-syntax sooner or | |
| 390 | + | // later, and `**bo…` reaches the reader as those four characters. | |
| 391 | + | // Trimmed: `** ` is not a closing delimiter, so an untrimmed fixture would | |
| 392 | + | // be literal asterisks and would pass this test for the wrong reason. | |
| 393 | + | let body = format!("**{}**", "word ".repeat(40).trim_end()); | |
| 394 | + | let cut = preview(&body).expect("a preview"); | |
| 395 | + | assert!(!cut.source().contains('*'), "got: {cut:?}"); | |
| 396 | + | assert_eq!(cut.source().chars().count(), 80); | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | #[test] | |
| 400 | + | fn a_preview_is_text_because_it_is_no_longer_markdown() { | |
| 401 | + | // Saying `Rich` here would be a lie a renderer acts on: what is left after | |
| 402 | + | // flattening and cutting is text, and a renderer must not re-parse it. | |
| 403 | + | let cut = preview("**Bold**").expect("a preview"); | |
| 404 | + | assert!(matches!(cut, Prose::Text(_)), "got: {cut:?}"); | |
| 367 | 405 | } |