diff --git a/Cargo.lock b/Cargo.lock index ab56192..bbba9a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2948,7 +2948,7 @@ dependencies = [ [[package]] name = "portal" -version = "0.3.26" +version = "0.3.28" dependencies = [ "anyhow", "arc-swap", diff --git a/src/content.rs b/src/content.rs index 60bac79..a3c40e0 100644 --- a/src/content.rs +++ b/src/content.rs @@ -219,7 +219,73 @@ pub fn render_inline_markdown(text: &str) -> String { }); let mut out = String::new(); html::push_html(&mut out, filtered); - out.trim().to_string() + apply_image_hints(out.trim()) +} + +/// Translate a markdown image's title (`![alt](url "right 9rem")`) +/// into layout: `left`/`right` float via a class, a bare number +/// (optionally with `rem`) into a validated `max-width`. Unknown +/// tokens are ignored; the title attribute is dropped either way. +/// Only touches `` tags in our own render output, and only ever +/// emits a numeric max-width - no arbitrary CSS reaches the page. +fn apply_image_hints(html: &str) -> String { + let mut out = String::new(); + let mut rest = html; + while let Some(pos) = rest.find("').map(|e| e + 1).unwrap_or(after.len()); + out.push_str(&rewrite_img_tag(&after[..end])); + rest = &after[end..]; + } + out.push_str(rest); + out +} + +fn rewrite_img_tag(tag: &str) -> String { + // Pull the title value, if any. + let title = tag + .find("title=\"") + .map(|i| &tag[i + 7..]) + .and_then(|r| r.find('"').map(|e| &r[..e])) + .unwrap_or(""); + + let mut class = String::new(); + let mut max_rem: Option = None; + for tok in title.split_whitespace() { + match tok { + "left" => class = "md-float-left".into(), + "right" => class = "md-float-right".into(), + other => { + if let Ok(n) = other.trim_end_matches("rem").parse::() { + if n > 0.0 && n <= 60.0 { + max_rem = Some(n); + } + } + } + } + } + + // Strip the title attribute from the tag. + let mut cleaned = tag.to_string(); + if let Some(i) = cleaned.find(" title=\"") { + if let Some(e) = cleaned[i + 8..].find('"') { + cleaned.replace_range(i..i + 8 + e + 1, ""); + } + } + + // Inject class/style right after ` "application/json", Some("png") => "image/png", Some("webp") => "image/webp", + Some("avif") => "image/avif", Some("woff2") => "font/woff2", _ => "application/octet-stream", }; @@ -2088,6 +2155,21 @@ alternatives: render_inline_markdown("![local](/images/a.jpg)"), "\"local\"" ); + // Image title hints: float + max-width, title dropped. + assert_eq!( + render_inline_markdown("![J](https://x.no/j.jpg \"right 9rem\")"), + "\"J\"" + ); + assert_eq!( + render_inline_markdown("![J](https://x.no/j.jpg \"12\")"), + "\"J\"" + ); + // Unknown hint tokens are ignored; a safe image with no title + // is untouched. + assert_eq!( + render_inline_markdown("![J](https://x.no/j.jpg \"wat\")"), + "\"J\"" + ); assert_eq!(render_inline_markdown("[ok](/shape)"), "ok"); assert_eq!(render_inline_markdown("[mail](mailto:bl@uhhm.no)"), "mail"); } diff --git a/style/main.css b/style/main.css index fa14244..1822019 100644 --- a/style/main.css +++ b/style/main.css @@ -552,6 +552,30 @@ main.not-found { border: 0.06rem solid var(--line); } +/* Content hint via a markdown image title (`![alt](url "right 9rem")`): + float and shrink so text wraps around a portrait. */ +.alt-description img.md-float-left, +.item-card-description img.md-float-left, +.feature p img.md-float-left, +.alt-description img.md-float-right, +.item-card-description img.md-float-right, +.feature p img.md-float-right { + width: auto; + max-width: 45%; +} +.alt-description img.md-float-left, +.item-card-description img.md-float-left, +.feature p img.md-float-left { + float: left; + margin: 0.2rem 1.1rem 0.5rem 0; +} +.alt-description img.md-float-right, +.item-card-description img.md-float-right, +.feature p img.md-float-right { + float: right; + margin: 0.2rem 0 0.5rem 1.1rem; +} + .alt-description code, .feature p code { font-size: 0.9em;