From ea7b1bc92241a6cd056fc71fb4c08bb86eca3984 Mon Sep 17 00:00:00 2001 From: Maciek <108873945+rozwader@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:36:14 +0200 Subject: [PATCH] fix: browser markdown (#768) --- Cargo.lock | 20 + Cargo.toml | 5 +- packages/oneclient_app/Cargo.toml | 5 + .../oneclient_app/src/components/button.rs | 2 +- .../src/components/local_image.rs | 2 +- .../src/components/markdown/image.rs | 328 ++++++++++ .../src/components/markdown/mod.rs | 118 ++++ .../src/components/markdown/parse.rs | 589 ++++++++++++++++++ .../src/components/markdown/render.rs | 275 ++++++++ packages/oneclient_app/src/components/mod.rs | 2 + .../src/components/screenshot_viewer.rs | 4 +- packages/oneclient_app/src/platform.rs | 30 +- packages/oneclient_app/src/recovery.rs | 2 +- .../src/view/app/browser/package/panels.rs | 58 +- .../src/view/onboarding/terms.rs | 33 +- packages/oneclient_content/Cargo.toml | 2 +- .../src/packages/markdown.rs | 201 ++++++ .../oneclient_content/src/packages/mod.rs | 1 + .../src/packages/provider/curseforge.rs | 2 +- .../src/packages/provider/modrinth.rs | 2 +- .../oneclient_content/src/packages/types.rs | 6 + packages/oneclient_core/src/images/mod.rs | 79 ++- 22 files changed, 1715 insertions(+), 51 deletions(-) create mode 100644 packages/oneclient_app/src/components/markdown/image.rs create mode 100644 packages/oneclient_app/src/components/markdown/mod.rs create mode 100644 packages/oneclient_app/src/components/markdown/parse.rs create mode 100644 packages/oneclient_app/src/components/markdown/render.rs create mode 100644 packages/oneclient_content/src/packages/markdown.rs diff --git a/Cargo.lock b/Cargo.lock index 7f275acd..9de9063a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2350,6 +2350,22 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "freya-testing" +version = "0.5.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa3e16eb0522ce2db64df537943329af02978d142f51a75786a02a0cee8f2a10" +dependencies = [ + "accesskit", + "freya-clipboard", + "freya-components", + "freya-core", + "freya-engine", + "futures-channel", + "ragnarok", + "torin", +] + [[package]] name = "freya-winit" version = "0.5.0-rc.4" @@ -4475,7 +4491,9 @@ dependencies = [ "directories", "dunce", "freya", + "freya-core", "freya-skia-safe", + "freya-testing", "image", "mimalloc", "notify", @@ -4494,6 +4512,7 @@ dependencies = [ "open", "percent-encoding", "polyio", + "pulldown-cmark", "regex", "rfd", "rust-embed", @@ -4581,6 +4600,7 @@ dependencies = [ "oneclient_events", "oneclient_net", "polyio", + "pulldown-cmark", "reqwest 0.13.3", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 8560d487..a2ef4691 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -115,6 +115,7 @@ indicatif = {version = "=0.18.4", features = ["tokio"]} junction = {version = "=2.0.0"} notify = {version = "=8.2.0"} parking_lot = {version = "=0.12.5"} +pulldown-cmark = {version = "=0.13.4", default-features = false} rand = {version = "=0.10.1"} regex = {version = "=1.12.3"} rust-embed = {version = "=8.12.0", features = ["include-exclude", "compression", "debug-embed"]} @@ -138,7 +139,9 @@ syn = {version = "=2.0.117"} thiserror = {version = "=2.0.18"} # ui -freya = {version = "=0.5.0-rc.4", features = ["engine", "winit", "router", "remote-asset", "sdk", "markdown", "query", "radio", "titlebar"]} +freya = {version = "=0.5.0-rc.4", features = ["engine", "winit", "router", "remote-asset", "sdk", "query", "radio", "titlebar"]} +freya-core = {version = "=0.5.0-rc.4"} +freya-testing = {version = "=0.5.0-rc.4"} freya-skia-safe = "=0.99.1" # required for the SKSL player renderer image = {version = "=0.25.10", default-features = false, features = ["png", "jpeg"]} diff --git a/packages/oneclient_app/Cargo.toml b/packages/oneclient_app/Cargo.toml index 68e2efff..6f7d3a0c 100644 --- a/packages/oneclient_app/Cargo.toml +++ b/packages/oneclient_app/Cargo.toml @@ -57,6 +57,11 @@ image.workspace = true freya-skia-safe.workspace = true freya.workspace = true +freya-core.workspace = true +pulldown-cmark.workspace = true + +[dev-dependencies] +freya-testing.workspace = true [target.'cfg(target_os = "macos")'.dependencies] mimalloc = "=0.1.52" diff --git a/packages/oneclient_app/src/components/button.rs b/packages/oneclient_app/src/components/button.rs index 8b2c735a..c8163a43 100644 --- a/packages/oneclient_app/src/components/button.rs +++ b/packages/oneclient_app/src/components/button.rs @@ -446,7 +446,7 @@ pub fn open_folder_button(folder: std::path::PathBuf) -> Button { .icon() .on_press(move |_| { std::fs::create_dir_all(&folder).ok(); - crate::platform::open_url(&folder.to_string_lossy()); + crate::platform::open_path(&folder.to_string_lossy()); }) .child(Icon::new(IconType::Folder).size(16.)) } diff --git a/packages/oneclient_app/src/components/local_image.rs b/packages/oneclient_app/src/components/local_image.rs index 8d39fa8a..0909fb4c 100644 --- a/packages/oneclient_app/src/components/local_image.rs +++ b/packages/oneclient_app/src/components/local_image.rs @@ -122,7 +122,7 @@ impl Component for LocalImage { } } -fn decode(bytes: &Bytes) -> Option { +pub(super) fn decode(bytes: &Bytes) -> Option { let data = unsafe { SkData::new_bytes(bytes) }; let img = SkImage::from_encoded(data)?; Some(ImageHandle::new(img, bytes.clone())) diff --git a/packages/oneclient_app/src/components/markdown/image.rs b/packages/oneclient_app/src/components/markdown/image.rs new file mode 100644 index 00000000..d1f1c0ae --- /dev/null +++ b/packages/oneclient_app/src/components/markdown/image.rs @@ -0,0 +1,328 @@ +use std::any::Any; +use std::borrow::Cow; +use std::rc::Rc; + +use freya::elements::image::ImageHandle; +use freya::engine::prelude::{ClipOp, Paint, SkRect}; +use freya::prelude::*; +use freya_core::element::{ClipContext, ElementExt, LayoutContext}; +use freya_core::tree::DiffModifies; + +use super::super::local_image::decode; +use crate::hooks::{loaded_image, use_cached_image}; +use crate::theme::colors; + +const MAX_EDGE: u32 = 1600; + +#[derive(PartialEq)] +pub struct MarkdownImage { + url: String, + alt: String, +} + +impl MarkdownImage { + pub fn new(url: impl Into, alt: impl Into) -> Self { + Self { + url: url.into(), + alt: alt.into(), + } + } +} + +impl Component for MarkdownImage { + fn render(&self) -> impl IntoElement { + let query = use_cached_image(Some(self.url.clone()), MAX_EDGE); + let loaded = loaded_image(Some(&self.url), &query); + + let mut cache = use_state(|| None::<(usize, ImageHandle)>); + let holder = loaded.and_then(|(_, bytes)| { + let ptr = bytes.as_ptr() as usize; + + if let Some((cached_ptr, holder)) = cache.read().clone() + && cached_ptr == ptr + { + return Some(holder); + } + + let holder = decode(&bytes)?; + cache.set(Some((ptr, holder.clone()))); + + Some(holder) + }); + + match holder { + Some(holder) => scaled_image(holder) + .a11y_role(AccessibilityRole::Image) + .a11y_alt(self.alt.clone()) + .into_element(), + None if self.alt.is_empty() => rect().into_element(), + None => label() + .text(self.alt.clone()) + .color(colors::fg_secondary()) + .into_element(), + } + } +} + +#[derive(Clone)] +pub struct ScaledImage { + key: DiffKey, + element: ScaledImageElement, +} + +#[derive(Clone, PartialEq)] +pub struct ScaledImageElement { + accessibility: AccessibilityData, + layout: LayoutData, + image_handle: ImageHandle, + sampling_mode: SamplingMode, + corner_radius: CornerRadius, +} + +pub fn scaled_image(image_handle: ImageHandle) -> ScaledImage { + ScaledImage { + key: DiffKey::None, + element: ScaledImageElement { + accessibility: AccessibilityData::default(), + layout: LayoutData::default(), + image_handle, + sampling_mode: SamplingMode::default(), + corner_radius: CornerRadius::default(), + }, + } +} + +impl ScaledImage { + pub fn sampling_mode(mut self, sampling_mode: SamplingMode) -> Self { + self.element.sampling_mode = sampling_mode; + self + } + + pub fn corner_radius(mut self, corner_radius: CornerRadius) -> Self { + self.element.corner_radius = corner_radius; + self + } +} + +impl KeyExt for ScaledImage { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl LayoutExt for ScaledImage { + fn get_layout(&mut self) -> &mut LayoutData { + &mut self.element.layout + } +} + +impl AccessibilityExt for ScaledImage { + fn get_accessibility_data(&mut self) -> &mut AccessibilityData { + &mut self.element.accessibility + } +} + +impl MaybeExt for ScaledImage {} + +impl From for Element { + fn from(value: ScaledImage) -> Self { + Element::Element { + key: value.key, + element: Rc::new(value.element), + elements: Vec::new(), + } + } +} + +impl ElementExt for ScaledImageElement { + fn changed(&self, other: &Rc) -> bool { + let Some(other) = (other.as_ref() as &dyn Any).downcast_ref::() else { + return false; + }; + self != other + } + + fn diff(&self, other: &Rc) -> DiffModifies { + let Some(other) = (other.as_ref() as &dyn Any).downcast_ref::() else { + return DiffModifies::all(); + }; + + let mut diff = DiffModifies::empty(); + + if self.accessibility != other.accessibility { + diff.insert(DiffModifies::ACCESSIBILITY); + } + + if self.layout != other.layout { + diff.insert(DiffModifies::LAYOUT); + } + + if self.image_handle != other.image_handle { + diff.insert(DiffModifies::STYLE); + + if self.image_handle.image.dimensions() != other.image_handle.image.dimensions() { + diff.insert(DiffModifies::LAYOUT); + } + } + + if self.sampling_mode != other.sampling_mode || self.corner_radius != other.corner_radius { + diff.insert(DiffModifies::STYLE); + } + + diff + } + + fn layout(&'_ self) -> Cow<'_, LayoutData> { + Cow::Borrowed(&self.layout) + } + + fn accessibility(&'_ self) -> Cow<'_, AccessibilityData> { + Cow::Borrowed(&self.accessibility) + } + + fn style(&'_ self) -> Cow<'_, StyleState> { + Cow::Owned(StyleState { + corner_radius: self.corner_radius, + ..StyleState::default() + }) + } + + fn should_hook_measurement(&self) -> bool { + true + } + + fn measure(&self, context: LayoutContext) -> Option<(Size2D, Rc)> { + let image = &self.image_handle.image; + let natural = Size2D::new(image.width() as f32, image.height() as f32); + + let available = + (*context.area_size - context.torin_node.margin.into()).max(Size2D::zero()); + + let ratio = (available.width / natural.width).min(1.); + let ratio = if ratio.is_finite() && ratio > 0. { + ratio + } else { + 1. + }; + + let size = Size2D::new(natural.width * ratio, natural.height * ratio); + + Some((size, Rc::new(size))) + } + + fn clip(&self, context: ClipContext) { + let rrect = self.render_rect(context.visible_area, context.scale_factor as f32); + context.canvas.clip_rrect(rrect, ClipOp::Intersect, true); + } + + fn render(&self, context: RenderContext) { + let Some(size) = context + .layout_node + .data + .as_ref() + .and_then(|data| data.downcast_ref::()) + else { + return; + }; + + let area = context.layout_node.visible_area(); + let rect = SkRect::new( + area.min_x(), + area.min_y(), + area.min_x() + size.width, + area.min_y() + size.height, + ); + + context.canvas.save(); + let clip_rrect = self.render_rect(&area, context.scale_factor as f32); + context + .canvas + .clip_rrect(clip_rrect, ClipOp::Intersect, true); + + let mut paint = Paint::default(); + paint.set_anti_alias(true); + + context.canvas.draw_image_rect_with_sampling_options( + &self.image_handle.image, + None, + rect, + self.sampling_mode.sampling_options(), + &paint, + ); + + context.canvas.restore(); + } +} + +#[cfg(test)] +mod tests { + use freya::prelude::Bytes; + use freya::engine::prelude::AlphaType; + use freya_testing::prelude::*; + + use super::*; + + fn handle(width: u32, height: u32) -> ImageHandle { + let pixels = Bytes::from(vec![200u8; (width * height * 4) as usize]); + ImageHandle::from_rgba(width, height, pixels, AlphaType::Opaque).expect("rgba handle") + } + + fn measured(panel: (f32, f32), image: (u32, u32)) -> Size2D { + let app = move || { + rect() + .width(Size::px(panel.0)) + .height(Size::px(panel.1)) + .child(scaled_image(handle(image.0, image.1))) + }; + + let test = launch_test(app); + test.find(|node, element| { + (element as &dyn Any) + .downcast_ref::() + .map(|_| node.layout()) + }) + .expect("image node") + .area + .size + } + + #[test] + fn scales_an_image_inlined_in_a_paragraph() { + let app = || { + rect().width(Size::px(300.)).height(Size::px(400.)).child( + paragraph() + .span(Span::new("before ")) + .child(scaled_image(handle(600, 200))) + .span(Span::new(" after")), + ) + }; + + let test = launch_test(app); + let size = test + .find(|node, element| { + (element as &dyn Any) + .downcast_ref::() + .map(|_| node.layout()) + }) + .expect("image node") + .area + .size; + + assert_eq!(size, Size2D::new(300., 100.)); + } + + #[test] + fn scales_a_wide_image_down_to_the_panel() { + assert_eq!(measured((300., 400.), (600, 200)), Size2D::new(300., 100.)); + } + + #[test] + fn leaves_a_small_image_at_its_natural_size() { + assert_eq!(measured((300., 400.), (88, 31)), Size2D::new(88., 31.)); + } + + #[test] + fn short_panel_does_not_shrink_the_width() { + assert_eq!(measured((300., 60.), (600, 200)), Size2D::new(300., 100.)); + } +} diff --git a/packages/oneclient_app/src/components/markdown/mod.rs b/packages/oneclient_app/src/components/markdown/mod.rs new file mode 100644 index 00000000..b281dba7 --- /dev/null +++ b/packages/oneclient_app/src/components/markdown/mod.rs @@ -0,0 +1,118 @@ +use std::borrow::Cow; + +use freya::prelude::*; +use pulldown_cmark::HeadingLevel; + +mod image; +mod parse; +mod render; + +#[derive(Clone, PartialEq)] +pub struct MarkdownStyle { + pub color: Color, + pub color_link: Color, + pub color_code: Color, + pub background_code: Color, + pub background_blockquote: Color, + pub border_blockquote: Color, + pub background_divider: Color, + pub headings: [f32; 6], + pub paragraph_size: f32, + pub code_font_size: f32, + pub table_font_size: f32, + pub code_font_family: Cow<'static, str>, +} + +impl Default for MarkdownStyle { + fn default() -> Self { + Self { + color: Color::WHITE, + color_link: Color::from_rgb(90, 160, 255), + color_code: Color::WHITE, + background_code: Color::from_rgb(32, 32, 32), + background_blockquote: Color::from_rgb(32, 32, 32), + border_blockquote: Color::from_rgb(80, 80, 80), + background_divider: Color::from_rgb(64, 64, 64), + headings: [32., 28., 24., 20., 18., 16.], + paragraph_size: 16., + code_font_size: 14., + table_font_size: 14., + code_font_family: Cow::Borrowed("Jetbrains Mono"), + } + } +} + +impl MarkdownStyle { + fn heading_size(&self, level: HeadingLevel) -> f32 { + self.headings[match level { + HeadingLevel::H1 => 0, + HeadingLevel::H2 => 1, + HeadingLevel::H3 => 2, + HeadingLevel::H4 => 3, + HeadingLevel::H5 => 4, + HeadingLevel::H6 => 5, + }] + } +} + +#[derive(PartialEq)] +pub struct Markdown { + content: String, + style: MarkdownStyle, + layout: LayoutData, + key: DiffKey, +} + +impl Markdown { + pub fn new(content: impl Into) -> Self { + Self { + content: content.into(), + style: MarkdownStyle::default(), + layout: LayoutData::default(), + key: DiffKey::None, + } + } + + pub fn style(mut self, style: MarkdownStyle) -> Self { + self.style = style; + self + } +} + +impl KeyExt for Markdown { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl LayoutExt for Markdown { + fn get_layout(&mut self) -> &mut LayoutData { + &mut self.layout + } +} + +impl ContainerExt for Markdown {} + +impl Component for Markdown { + fn render(&self) -> impl IntoElement { + let content = use_reactive(&self.content); + let blocks = use_memo(move || parse::parse(&content.read())); + let blocks = blocks.read(); + + rect() + .vertical() + .layout(self.layout.clone()) + .spacing(12.) + .children( + blocks + .iter() + .enumerate() + .map(|(idx, block)| render::render_block(block, idx, &self.style)), + ) + } + + fn render_key(&self) -> DiffKey { + self.key.clone().or(self.default_key()) + } +} + diff --git a/packages/oneclient_app/src/components/markdown/parse.rs b/packages/oneclient_app/src/components/markdown/parse.rs new file mode 100644 index 00000000..13a87f99 --- /dev/null +++ b/packages/oneclient_app/src/components/markdown/parse.rs @@ -0,0 +1,589 @@ +use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd}; + +#[derive(Clone, PartialEq)] +pub enum Block { + Heading { + level: HeadingLevel, + spans: Vec, + }, + Paragraph { + content: Vec, + }, + Code { + code: String, + }, + List(List), + Image { + url: String, + alt: String, + }, + Link { + url: String, + title: Option, + content: Vec, + }, + Blockquote { + content: Vec, + }, + Table { + headers: Vec>, + rows: Vec>>, + }, + Rule, +} + +#[derive(Clone, PartialEq)] +pub struct List { + pub start: Option, + pub items: Vec, +} + +#[derive(Clone, PartialEq)] +pub struct ListItem { + pub content: Vec, + pub nested_lists: Vec, +} + +#[derive(Clone, PartialEq)] +pub enum Inline { + Span(TextSpan), + Image { + url: String, + alt: String, + }, + Link { + url: String, + title: Option, + content: Vec, + }, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct TextSpan { + pub text: String, + pub bold: bool, + pub italic: bool, + pub code: bool, +} + +impl TextSpan { + fn new(text: impl Into) -> Self { + Self { + text: text.into(), + bold: false, + italic: false, + code: false, + } + } +} + +pub fn parse(content: &str) -> Vec { + let mut options = Options::empty(); + options.insert(Options::ENABLE_TABLES); + + Walker::default().walk(Parser::new_ext(content, options)) +} + +#[derive(Default)] +struct Walker { + blocks: Vec, + + spans: Vec, + content: Vec, + + lists: Vec, + items: Vec, + + heading: Option, + in_paragraph: bool, + + in_code_block: bool, + code: String, + + in_blockquote: bool, + blockquote: Vec, + + in_table_cell: bool, + headers: Vec>, + rows: Vec>>, + row: Vec>, + cell: Vec, + + in_link: bool, + link_url: Option, + link_title: Option, + link_content: Vec, + + in_image: bool, + image_url: String, + image_title: String, + image_alt: String, + + bold: bool, + italic: bool, +} + +impl Walker { + fn walk(mut self, parser: Parser<'_>) -> Vec { + for event in parser { + match event { + Event::Start(tag) => self.start(tag), + Event::End(tag) => self.end(tag), + Event::Text(text) => self.text(&text), + Event::Code(code) => self.code(&code), + Event::SoftBreak => self.break_of(" "), + Event::HardBreak => self.break_of("\n"), + Event::InlineHtml(_) => {} + Event::Html(html) => self.html_block(&html), + Event::Rule => self.blocks.push(Block::Rule), + _ => {} + } + } + + self.blocks + } + + fn start(&mut self, tag: Tag<'_>) { + match tag { + Tag::Heading { level, .. } => { + self.heading = Some(level); + self.spans.clear(); + } + Tag::Paragraph => { + if !self.in_blockquote && self.items.is_empty() { + self.in_paragraph = true; + self.spans.clear(); + self.content.clear(); + } + } + Tag::CodeBlock(_) => { + self.in_code_block = true; + self.code.clear(); + } + Tag::List(start) => self.lists.push(List { + start, + items: Vec::new(), + }), + Tag::Item => self.items.push(ListItem { + content: Vec::new(), + nested_lists: Vec::new(), + }), + Tag::Strong => self.bold = true, + Tag::Emphasis => self.italic = true, + Tag::BlockQuote(_) => { + self.in_blockquote = true; + self.blockquote.clear(); + } + Tag::Image { + dest_url, title, .. + } => { + self.in_image = true; + self.image_url = dest_url.to_string(); + self.image_title = title.to_string(); + self.image_alt.clear(); + } + Tag::Link { + dest_url, title, .. + } => { + self.in_link = true; + self.link_url = Some(dest_url.to_string()); + self.link_title = Some(title.to_string()); + self.link_content.clear(); + } + Tag::Table(_) => { + self.headers.clear(); + self.rows.clear(); + self.row.clear(); + } + Tag::TableRow => self.row.clear(), + Tag::TableCell => { + self.in_table_cell = true; + self.cell.clear(); + } + _ => {} + } + } + + fn end(&mut self, tag: TagEnd) { + match tag { + TagEnd::Heading(_) => { + if let Some(level) = self.heading.take() { + let spans = std::mem::take(&mut self.spans); + self.blocks.push(Block::Heading { level, spans }); + } + } + TagEnd::Paragraph => { + let spans: Vec = self.spans.drain(..).map(Inline::Span).collect(); + if self.in_blockquote { + self.blockquote.extend(spans); + } else if let Some(item) = self.items.last_mut() { + item.content.extend(spans); + } else if self.in_paragraph { + self.in_paragraph = false; + self.content.extend(spans); + let content = std::mem::take(&mut self.content); + self.blocks.push(Block::Paragraph { content }); + } + } + TagEnd::CodeBlock => { + self.in_code_block = false; + let code = std::mem::take(&mut self.code); + self.blocks.push(Block::Code { + code: code.trim_end().to_string(), + }); + } + TagEnd::List(_) => { + if let Some(list) = self.lists.pop() { + match self.items.last_mut() { + Some(item) => item.nested_lists.push(list), + None => self.blocks.push(Block::List(list)), + } + } + } + TagEnd::Item => { + if let (Some(item), Some(list)) = (self.items.pop(), self.lists.last_mut()) { + list.items.push(item); + } + } + TagEnd::Strong => self.bold = false, + TagEnd::Emphasis => self.italic = false, + TagEnd::BlockQuote(_) => { + self.in_blockquote = false; + let content = std::mem::take(&mut self.blockquote); + self.blocks.push(Block::Blockquote { content }); + } + TagEnd::Table => self.blocks.push(Block::Table { + headers: std::mem::take(&mut self.headers), + rows: std::mem::take(&mut self.rows), + }), + TagEnd::TableHead => self.headers = std::mem::take(&mut self.row), + TagEnd::TableRow => { + let row = std::mem::take(&mut self.row); + self.rows.push(row); + } + TagEnd::TableCell => { + self.in_table_cell = false; + let cell = std::mem::take(&mut self.cell); + self.row.push(cell); + } + TagEnd::Image => { + self.in_image = false; + let url = std::mem::take(&mut self.image_url); + let alt = if self.image_alt.is_empty() { + std::mem::take(&mut self.image_title) + } else { + std::mem::take(&mut self.image_alt) + }; + self.place(Inline::Image { url, alt }, |url, alt| Block::Image { url, alt }); + } + TagEnd::Link => { + self.in_link = false; + let Some(url) = self.link_url.take() else { + return; + }; + let title = self.link_title.take(); + let content = std::mem::take(&mut self.link_content); + + if self.in_table_cell { + for item in content { + flatten(item, &mut self.cell); + } + return; + } + + let inline = Inline::Link { + url: url.clone(), + title: title.clone(), + content: content.clone(), + }; + if self.in_blockquote { + self.blockquote.push(inline); + } else if let Some(item) = self.items.last_mut() { + item.content.push(inline); + } else if self.in_paragraph { + self.content.extend(self.spans.drain(..).map(Inline::Span)); + self.content.push(inline); + } else if self.heading.is_some() { + for item in content { + flatten(item, &mut self.spans); + } + } else { + self.blocks.push(Block::Link { + url, + title, + content, + }); + } + } + _ => {} + } + } + + fn place(&mut self, inline: Inline, as_block: impl FnOnce(String, String) -> Block) { + if self.in_table_cell { + flatten(inline, &mut self.cell); + } else if self.in_link { + self.link_content.push(inline); + } else if self.in_blockquote { + self.blockquote.push(inline); + } else if let Some(item) = self.items.last_mut() { + item.content.push(inline); + } else if self.in_paragraph { + self.content.extend(self.spans.drain(..).map(Inline::Span)); + self.content.push(inline); + } else if self.heading.is_some() { + flatten(inline, &mut self.spans); + } else if let Inline::Image { url, alt } = inline { + self.blocks.push(as_block(url, alt)); + } + } + + fn text(&mut self, text: &str) { + if self.in_code_block { + self.code.push_str(text); + return; + } + if self.in_image { + self.image_alt.push_str(text); + return; + } + + self.push_span(TextSpan { + text: text.to_string(), + bold: self.bold, + italic: self.italic, + code: false, + }); + } + + fn code(&mut self, code: &str) { + if self.in_image { + self.image_alt.push_str(code); + return; + } + + self.push_span(TextSpan { + text: code.to_string(), + bold: self.bold, + italic: self.italic, + code: true, + }); + } + + fn break_of(&mut self, text: &str) { + if self.in_image { + self.image_alt.push(' '); + return; + } + + self.push_span(TextSpan::new(text)); + } + + fn html_block(&mut self, html: &str) { + let text = strip_tags(html); + if text.trim().is_empty() { + return; + } + + self.blocks.push(Block::Paragraph { + content: vec![Inline::Span(TextSpan::new(text))], + }); + } + + fn push_span(&mut self, span: TextSpan) { + if self.in_table_cell { + self.cell.push(span); + } else if self.in_link { + self.link_content.push(Inline::Span(span)); + } else if self.in_blockquote && !self.in_paragraph { + self.blockquote.push(Inline::Span(span)); + } else if let Some(item) = self.items.last_mut() + && !self.in_paragraph + { + item.content.push(Inline::Span(span)); + } else { + self.spans.push(span); + } + } +} + +/// Headings and table cells hold plain spans, so links and images collapse to their text. +fn flatten(inline: Inline, out: &mut Vec) { + match inline { + Inline::Span(span) => out.push(span), + Inline::Image { alt, .. } => { + if !alt.is_empty() { + out.push(TextSpan::new(alt)); + } + } + Inline::Link { content, .. } => { + for item in content { + flatten(item, out); + } + } + } +} + +fn strip_tags(html: &str) -> String { + let mut out = String::with_capacity(html.len()); + let mut rest = html; + + while let Some(open) = rest.find('<') { + out.push_str(&rest[..open]); + match rest[open..].find('>') { + Some(close) => rest = &rest[open + close + 1..], + None => { + out.push_str(&rest[open..]); + return out; + } + } + } + out.push_str(rest); + + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn blocks(markdown: &str) -> Vec { + parse(markdown) + } + + fn paragraph_text(block: &Block) -> String { + let Block::Paragraph { content } = block else { + panic!("expected a paragraph"); + }; + content + .iter() + .map(|inline| match inline { + Inline::Span(span) => span.text.clone(), + Inline::Image { alt, .. } => format!("[img:{alt}]"), + Inline::Link { content, .. } => content + .iter() + .map(|inline| match inline { + Inline::Span(span) => span.text.clone(), + Inline::Image { alt, .. } => format!("[img:{alt}]"), + Inline::Link { .. } => String::new(), + }) + .collect(), + }) + .collect() + } + + #[test] + fn hard_break_is_a_line_break_and_soft_break_is_a_space() { + assert_eq!(paragraph_text(&blocks("one \ntwo")[0]), "one\ntwo"); + assert_eq!(paragraph_text(&blocks("one\ntwo")[0]), "one two"); + } + + #[test] + fn code_block_keeps_its_lines() { + let Block::Code { code } = &blocks("```rust\nlet a = 1;\nlet b = 2;\n```")[0] else { + panic!("expected a code block"); + }; + assert_eq!(code, "let a = 1;\nlet b = 2;"); + } + + #[test] + fn inline_html_keeps_the_text_it_wrapped() { + assert_eq!(paragraph_text(&blocks("a bold word")[0]), "a bold word"); + } + + #[test] + fn block_html_keeps_its_text() { + assert_eq!(paragraph_text(&blocks("
\nhello\n
")[0]).trim(), "hello"); + } + + #[test] + fn images_arrive_as_paragraph_content() { + assert_eq!(paragraph_text(&blocks("![alt](a.png)")[0]), "[img:alt]"); + assert_eq!(paragraph_text(&blocks("see ![alt](a.png) here")[0]), "see [img:alt] here"); + } + + #[test] + fn an_image_inside_a_link_stays_in_the_link() { + assert_eq!( + paragraph_text(&blocks("text [![badge](b.svg)](https://example.com)")[0]), + "text [img:badge]" + ); + } + + #[test] + fn nested_lists_keep_their_nesting() { + let Block::List(list) = &blocks("- one\n - inner\n- two")[0] else { + panic!("expected a list"); + }; + assert_eq!(list.items.len(), 2); + assert_eq!(list.items[0].nested_lists.len(), 1); + assert_eq!(list.items[0].nested_lists[0].items.len(), 1); + } + + #[test] + fn ordered_lists_keep_their_start() { + let Block::List(list) = &blocks("3. three\n4. four")[0] else { + panic!("expected a list"); + }; + assert_eq!(list.start, Some(3)); + } + + #[test] + fn tables_split_head_from_body() { + let Block::Table { headers, rows } = &blocks("| a | b |\n| - | - |\n| 1 | 2 |\n| 3 | 4 |")[0] + else { + panic!("expected a table"); + }; + assert_eq!(headers.len(), 2); + assert_eq!(rows.len(), 2); + assert_eq!(rows[1][1][0].text, "4"); + } + + #[test] + fn emphasis_and_code_carry_their_style() { + let Block::Paragraph { content } = &blocks("**b** *i* `c`")[0] else { + panic!("expected a paragraph"); + }; + let spans: Vec<&TextSpan> = content + .iter() + .filter_map(|inline| match inline { + Inline::Span(span) => Some(span), + _ => None, + }) + .collect(); + assert!(spans.iter().any(|s| s.text == "b" && s.bold)); + assert!(spans.iter().any(|s| s.text == "i" && s.italic)); + assert!(spans.iter().any(|s| s.text == "c" && s.code)); + } + + #[test] + fn a_linked_heading_keeps_its_text_and_leaks_no_link_block() { + let parsed = blocks("## [Docs](https://example.com)"); + assert_eq!(parsed.len(), 1); + let Block::Heading { spans, .. } = &parsed[0] else { + panic!("expected a heading"); + }; + assert_eq!(spans[0].text, "Docs"); + } + + #[test] + fn a_badge_table_keeps_its_badges_inside_the_table() { + let parsed = blocks( + "| a | b |\n| - | - |\n| [![CF](cf.svg)](https://curseforge.com) | [![MR](mr.svg)](https://modrinth.com) |\n", + ); + assert_eq!(parsed.len(), 1); + let Block::Table { rows, .. } = &parsed[0] else { + panic!("expected a table"); + }; + assert_eq!(rows[0][0][0].text, "CF"); + assert_eq!(rows[0][1][0].text, "MR"); + } + + #[test] + fn headings_blockquotes_and_rules_are_their_own_blocks() { + let parsed = blocks("# Title\n\n> quoted\n\n---\n"); + assert!(matches!(parsed[0], Block::Heading { .. })); + assert!(matches!(parsed[1], Block::Blockquote { .. })); + assert!(matches!(parsed[2], Block::Rule)); + } +} diff --git a/packages/oneclient_app/src/components/markdown/render.rs b/packages/oneclient_app/src/components/markdown/render.rs new file mode 100644 index 00000000..bc968ce1 --- /dev/null +++ b/packages/oneclient_app/src/components/markdown/render.rs @@ -0,0 +1,275 @@ +use freya::prelude::*; + +use super::MarkdownStyle; +use super::image::MarkdownImage; +use super::parse::{Block, Inline, List, TextSpan}; +use crate::hooks::LinkConfirmState; + +pub fn render_block(block: &Block, key: usize, style: &MarkdownStyle) -> Element { + match block { + Block::Heading { level, spans } => { + render_spans(spans, style.heading_size(*level), style.color, style.color_code) + .font_weight(FontWeight::BOLD) + .key(key) + .into() + } + Block::Paragraph { content } => render_content(content, style.paragraph_size, style) + .key(key) + .into(), + Block::Code { code } => rect() + .key(key) + .width(Size::fill()) + .background(style.background_code) + .corner_radius(CornerRadius::new_all(6.)) + .padding(Gaps::new_all(12.)) + .child( + label() + .text(code.clone()) + .font_family(style.code_font_family.clone()) + .font_size(style.code_font_size) + .color(style.color_code), + ) + .into(), + Block::List(list) => render_list(list, style).key(key).into(), + Block::Image { url, alt } => rect() + .key(key) + .child(MarkdownImage::new(url.clone(), alt.clone())) + .into(), + Block::Link { + url, + title, + content, + } => MarkdownLink::new(url.clone(), title.clone(), content.clone(), style.clone()) + .key(key) + .into(), + Block::Blockquote { content } => rect() + .key(key) + .width(Size::fill()) + .padding(Gaps::new(12., 12., 12., 16.)) + .border( + Border::new() + .width(4.) + .fill(style.border_blockquote) + .alignment(BorderAlignment::Inner), + ) + .background(style.background_blockquote) + .child(render_content(content, style.paragraph_size, style).font_slant(FontSlant::Italic)) + .into(), + Block::Rule => rect() + .key(key) + .width(Size::fill()) + .height(Size::px(1.)) + .background(style.background_divider) + .into(), + Block::Table { headers, rows } => render_table(headers, rows, style).key(key).into(), + } +} + +fn styled_span(span: &TextSpan, text_color: Color, code_color: Color) -> Span<'static> { + let mut styled = Span::new(span.text.clone()); + if span.bold { + styled = styled.font_weight(FontWeight::BOLD); + } + if span.italic { + styled = styled.font_slant(FontSlant::Italic); + } + if span.code { + styled.font_family("monospace").color(code_color) + } else { + styled.color(text_color) + } +} + +fn render_spans( + spans: &[TextSpan], + base_font_size: f32, + text_color: Color, + code_color: Color, +) -> Paragraph { + paragraph().font_size(base_font_size).spans_iter( + spans + .iter() + .map(|span| styled_span(span, text_color, code_color)), + ) +} + +fn render_content(content: &[Inline], base_font_size: f32, style: &MarkdownStyle) -> Paragraph { + let mut result = paragraph().font_size(base_font_size); + + for item in content { + result = match item { + Inline::Span(span) => result.span(styled_span(span, style.color, style.color_code)), + Inline::Image { url, alt } => { + result.child(MarkdownImage::new(url.clone(), alt.clone())) + } + Inline::Link { + url, + title, + content, + } => result.child(MarkdownLink::new( + url.clone(), + title.clone(), + content.clone(), + style.clone(), + )), + }; + } + + result +} + +fn render_list(list: &List, style: &MarkdownStyle) -> Rect { + rect() + .vertical() + .spacing(4.) + .padding(Gaps::new(0., 0., 0., 20.)) + .children(list.items.iter().enumerate().map(|(item_idx, item)| { + rect() + .key(item_idx) + .horizontal() + .cross_align(Alignment::Start) + .spacing(8.) + .child( + label() + .text(match list.start { + Some(start) => format!("{}.", start + item_idx as u64), + None => "•".to_string(), + }) + .font_size(style.paragraph_size) + .color(style.color), + ) + .child( + rect() + .vertical() + .spacing(4.) + .child(render_content(&item.content, style.paragraph_size, style)) + .children( + item.nested_lists + .iter() + .map(|nested| render_list(nested, style)), + ), + ) + })) +} + +fn render_table( + headers: &[Vec], + rows: &[Vec>], + style: &MarkdownStyle, +) -> Rect { + rect() + .vertical() + .width(Size::fill()) + .corner_radius(CornerRadius::new_all(8.)) + .border( + Border::new() + .width(1.) + .fill(style.background_divider) + .alignment(BorderAlignment::Inner), + ) + .child(table_row(headers, style, true)) + .children(rows.iter().enumerate().flat_map(|(row_idx, row)| { + [ + rect() + .key(format!("divider-{row_idx}")) + .width(Size::fill()) + .height(Size::px(1.)) + .background(style.background_divider) + .into_element(), + table_row(row, style, false).key(row_idx).into_element(), + ] + })) +} + +fn table_row(cells: &[Vec], style: &MarkdownStyle, header: bool) -> Rect { + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .maybe(header, |row| row.background(style.background_code)) + .children(cells.iter().enumerate().map(|(col_idx, spans)| { + rect() + .key(col_idx) + .width(Size::flex(1.)) + .padding(Gaps::new_all(8.)) + .child( + render_spans(spans, style.table_font_size, style.color, style.color_code) + .maybe(header, |cell| cell.font_weight(FontWeight::BOLD)), + ) + })) +} + +#[derive(PartialEq)] +struct MarkdownLink { + url: String, + title: Option, + content: Vec, + style: MarkdownStyle, + key: DiffKey, +} + +impl KeyExt for MarkdownLink { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl MarkdownLink { + fn new( + url: String, + title: Option, + content: Vec, + style: MarkdownStyle, + ) -> Self { + Self { + url, + title, + content, + style, + key: DiffKey::None, + } + } +} + +impl Component for MarkdownLink { + fn render(&self) -> impl IntoElement { + let pending = try_consume_root_context::().map(|state| state.0); + let mut hovering = use_state(|| false); + + let url = self.url.clone(); + let alt = self.title.clone().filter(|title| !title.is_empty()); + + let mut text = paragraph().font_size(self.style.paragraph_size); + for item in &self.content { + text = match item { + Inline::Span(span) => { + text.span(styled_span(span, self.style.color_link, self.style.color_code)) + } + Inline::Image { url, alt } => { + text.child(MarkdownImage::new(url.clone(), alt.clone())) + } + Inline::Link { content, .. } => content.iter().fold(text, |text, item| match item { + Inline::Span(span) => { + text.span(styled_span(span, self.style.color_link, self.style.color_code)) + } + _ => text, + }), + }; + } + + rect() + .cursor(CursorIcon::Pointer) + .on_pointer_enter(move |_| hovering.set(true)) + .on_pointer_leave(move |_| hovering.set(false)) + .on_press(move |_| match pending { + Some(mut pending) => pending.set(Some(url.clone())), + None => tracing::warn!("no link confirmation available, not opening {url}"), + }) + .maybe(hovering(), |element| { + element.text_decoration(TextDecoration::Underline) + }) + .a11y_role(AccessibilityRole::Link) + .map(alt, |element, alt| element.a11y_alt(alt)) + .child(text) + } +} diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index 28674b2a..75dd453b 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -19,6 +19,7 @@ mod java_prompt; mod link_confirm; mod local_image; mod log_viewer; +mod markdown; mod memory_field; mod microsoft_login; mod navbar; @@ -63,6 +64,7 @@ pub use generic_prompt::GenericPromptOverlay; pub use java_prompt::JavaPromptOverlay; pub use link_confirm::ConfirmLinkOverlay; pub use local_image::LocalImage; +pub use markdown::{Markdown, MarkdownStyle}; pub use log_viewer::LogViewer; pub use memory_field::memory_field; pub use microsoft_login::use_microsoft_login; diff --git a/packages/oneclient_app/src/components/screenshot_viewer.rs b/packages/oneclient_app/src/components/screenshot_viewer.rs index 0cd348a7..3a9ee50c 100644 --- a/packages/oneclient_app/src/components/screenshot_viewer.rs +++ b/packages/oneclient_app/src/components/screenshot_viewer.rs @@ -162,7 +162,7 @@ impl Component for ScreenshotViewer { .secondary() .on_press(move |_| { if let Some(dir) = open_path.parent() { - crate::platform::open_url( + crate::platform::open_path( &dir.to_string_lossy(), ); } @@ -225,7 +225,7 @@ pub fn screenshot_context_menu( ContextMenu::new(x, y) .action(IconType::Folder, "Open in folder", move |()| { if let Some(dir) = open_path.parent() { - crate::platform::open_url(&dir.to_string_lossy()); + crate::platform::open_path(&dir.to_string_lossy()); } }) .action(IconType::Copy01, "Copy", move |()| { diff --git a/packages/oneclient_app/src/platform.rs b/packages/oneclient_app/src/platform.rs index df8899c9..c35754f1 100644 --- a/packages/oneclient_app/src/platform.rs +++ b/packages/oneclient_app/src/platform.rs @@ -1,3 +1,5 @@ +const ALLOWED_URL_SCHEMES: [&str; 3] = ["http", "https", "mailto"]; + pub fn focus_window() { use freya::prelude::{Platform, WinitPlatformExt}; @@ -8,8 +10,32 @@ pub fn focus_window() { } pub fn open_url(url: &str) { - if let Err(err) = open::that_detached(url) { - tracing::warn!("failed to open url {url}: {err}"); + if !has_allowed_scheme(url) { + tracing::warn!("refused to open url with a disallowed scheme: {url}"); + return; + } + + open_target(url); +} + +/// For paths the launcher itself produced, which carry no url scheme. +pub fn open_path(path: &str) { + open_target(path); +} + +fn has_allowed_scheme(url: &str) -> bool { + let Some((scheme, _)) = url.split_once(':') else { + return false; + }; + + ALLOWED_URL_SCHEMES + .iter() + .any(|allowed| scheme.eq_ignore_ascii_case(allowed)) +} + +fn open_target(target: &str) { + if let Err(err) = open::that_detached(target) { + tracing::warn!("failed to open {target}: {err}"); } } diff --git a/packages/oneclient_app/src/recovery.rs b/packages/oneclient_app/src/recovery.rs index c442399f..a5fc246c 100644 --- a/packages/oneclient_app/src/recovery.rs +++ b/packages/oneclient_app/src/recovery.rs @@ -19,7 +19,7 @@ pub fn open_data_folder() { return; }; - crate::platform::open_url(&dir.to_string_lossy()); + crate::platform::open_path(&dir.to_string_lossy()); } pub fn restore_latest(actions: &Actions) { diff --git a/packages/oneclient_app/src/view/app/browser/package/panels.rs b/packages/oneclient_app/src/view/app/browser/package/panels.rs index f6e5240d..7ae602be 100644 --- a/packages/oneclient_app/src/view/app/browser/package/panels.rs +++ b/packages/oneclient_app/src/view/app/browser/package/panels.rs @@ -1,10 +1,11 @@ use super::*; use oneclient_content::packages::ProviderId; +use oneclient_content::packages::markdown::normalize_markdown; use oneclient_content::packages::types::{PackageBody, ProjectDetail, ReleaseType, VersionSummary}; use crate::Actions; -use crate::components::{Button, Icon, IconType, Segment, SegmentedControl}; +use crate::components::{Button, Icon, IconType, Markdown, MarkdownStyle, Segment, SegmentedControl}; use crate::hooks::VERSIONS_PAGE_SIZE; use crate::theme::colors; use crate::ui::border_all_color; @@ -38,11 +39,34 @@ pub(super) fn tabs(active_tab: State, has_gallery: bool) -> impl IntoElem pub(super) fn about_panel(project: &ProjectDetail) -> impl IntoElement { let body = match &project.body { PackageBody::Raw(md) => md.clone(), - PackageBody::Url(url) => format!("{}\n\n[View online]({url})", project.summary), + PackageBody::Url(url) => normalize_markdown(&format!( + "{}\n\n[View online]({})", + project.summary, + escape_link_destination(url) + )), }; MarkdownPanel { body }.into_element() } +fn escape_link_destination(url: &str) -> String { + let mut out = String::with_capacity(url.len()); + + for character in url.chars() { + match character { + '(' => out.push_str("%28"), + ')' => out.push_str("%29"), + '<' => out.push_str("%3C"), + '>' => out.push_str("%3E"), + ' ' => out.push_str("%20"), + '\\' => out.push_str("%5C"), + character if character.is_control() => {} + character => out.push(character), + } + } + + out +} + #[derive(PartialEq)] struct MarkdownPanel { body: String, @@ -57,23 +81,21 @@ impl Component for MarkdownPanel { .background(PANEL_BG) .border(border_all_color(1., colors::component_border())) .child( - MarkdownViewer::new(self.body.clone()) + Markdown::new(self.body.clone()) .width(Size::fill()) - .color(colors::fg_primary()) - .color_link(colors::code_info()) - .background_code(colors::component_bg()) - .color_code(colors::fg_primary()) - .background_blockquote(colors::component_bg()) - .border_blockquote(colors::brand()) - .background_divider(colors::component_border()) - .heading_h1(26.) - .heading_h2(22.) - .heading_h3(18.) - .heading_h4(16.) - .heading_h5(14.) - .heading_h6(13.) - .paragraph_size(13.) - .code_font_size(12.), + .style(MarkdownStyle { + color: colors::fg_primary(), + color_link: colors::code_info(), + color_code: colors::fg_primary(), + background_code: colors::component_bg(), + background_blockquote: colors::component_bg(), + border_blockquote: colors::brand(), + background_divider: colors::component_border(), + headings: [26., 22., 18., 16., 14., 13.], + paragraph_size: 13., + code_font_size: 12., + ..MarkdownStyle::default() + }), ) } } diff --git a/packages/oneclient_app/src/view/onboarding/terms.rs b/packages/oneclient_app/src/view/onboarding/terms.rs index 3fe0cacc..bf0c7644 100644 --- a/packages/oneclient_app/src/view/onboarding/terms.rs +++ b/packages/oneclient_app/src/view/onboarding/terms.rs @@ -3,7 +3,8 @@ use freya::query::UseQuery; use freya::router::RouterContext; use crate::components::{ - Button, Icon, IconType, OverlayPopup, ScrollArea, Segment, SegmentedControl, toggle, + Button, Icon, IconType, Markdown, MarkdownStyle, OverlayPopup, ScrollArea, Segment, + SegmentedControl, toggle, }; use crate::hooks::{ TermsQuery, has_migration_data, terms_document, terms_error, terms_is_loading, use_dispatch, @@ -165,23 +166,21 @@ fn markdown_body(markdown: &str) -> Element { .height(Size::flex(1.0)) .padding(Gaps::new_all(16.)) .child( - MarkdownViewer::new(markdown.to_string()) + Markdown::new(markdown.to_string()) .width(Size::fill()) - .color(colors::fg_primary()) - .color_link(colors::code_info()) - .background_code(colors::component_bg()) - .color_code(colors::fg_primary()) - .background_blockquote(colors::component_bg()) - .border_blockquote(colors::brand()) - .background_divider(colors::component_border()) - .heading_h1(20.) - .heading_h2(17.) - .heading_h3(15.) - .heading_h4(14.) - .heading_h5(13.) - .heading_h6(12.) - .paragraph_size(13.) - .code_font_size(12.), + .style(MarkdownStyle { + color: colors::fg_primary(), + color_link: colors::code_info(), + color_code: colors::fg_primary(), + background_code: colors::component_bg(), + background_blockquote: colors::component_bg(), + border_blockquote: colors::brand(), + background_divider: colors::component_border(), + headings: [20., 17., 15., 14., 13., 12.], + paragraph_size: 13., + code_font_size: 12., + ..MarkdownStyle::default() + }), ), ) .into_element() diff --git a/packages/oneclient_content/Cargo.toml b/packages/oneclient_content/Cargo.toml index 960914e6..42ad1dd2 100644 --- a/packages/oneclient_content/Cargo.toml +++ b/packages/oneclient_content/Cargo.toml @@ -26,8 +26,8 @@ futures-lite.workspace = true futures-util.workspace = true tokio.workspace = true -# HTML package descriptions -> markdown htmd.workspace = true +pulldown-cmark.workspace = true chrono.workspace = true reqwest.workspace = true diff --git a/packages/oneclient_content/src/packages/markdown.rs b/packages/oneclient_content/src/packages/markdown.rs new file mode 100644 index 00000000..2d5d6b0e --- /dev/null +++ b/packages/oneclient_content/src/packages/markdown.rs @@ -0,0 +1,201 @@ +use std::ops::Range; + +use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd}; + +const BREAK: &str = "\u{e000}"; +const BREAK_MARKDOWN: &str = " \n"; +const BREAK_IN_TABLE: &str = " "; + +pub fn normalize_markdown(input: &str) -> String { + let islands = html_islands(input); + if islands.is_empty() { + return input.to_owned(); + } + + let mut out = String::with_capacity(input.len()); + let mut cursor = 0; + + for island in islands { + out.push_str(&input[cursor..island.range.start]); + out.push_str(&convert(&input[island.range.clone()], &island)); + cursor = island.range.end; + } + out.push_str(&input[cursor..]); + + out +} + +struct HtmlIsland { + range: Range, + block: bool, + in_table: bool, +} + +fn html_islands(input: &str) -> Vec { + let mut options = Options::empty(); + options.insert(Options::ENABLE_TABLES); + + let mut islands: Vec = Vec::new(); + let mut tables = 0usize; + + for (event, range) in Parser::new_ext(input, options).into_offset_iter() { + let block = match event { + Event::Start(Tag::Table(_)) => { + tables += 1; + continue; + } + Event::End(TagEnd::Table) => { + tables = tables.saturating_sub(1); + continue; + } + Event::Html(_) => true, + Event::InlineHtml(_) => false, + _ => continue, + }; + + match islands.last_mut() { + Some(last) if last.range.end >= range.start => { + last.range.end = range.end; + last.block |= block; + } + _ => islands.push(HtmlIsland { + range, + block, + in_table: tables > 0, + }), + } + } + + islands +} + +fn convert(html: &str, island: &HtmlIsland) -> String { + let markdown = match htmd::convert(&replace_break_tags(html)) { + Ok(markdown) => markdown, + Err(err) => { + tracing::warn!("failed to convert an html block to markdown, keeping it as-is: {err}"); + return html.to_owned(); + } + }; + + let markdown = markdown.replace( + BREAK, + if island.in_table { + BREAK_IN_TABLE + } else { + BREAK_MARKDOWN + }, + ); + + if !island.block { + return markdown; + } + + match markdown.trim() { + "" => "\n\n".to_owned(), + trimmed => format!("\n\n{trimmed}\n\n"), + } +} + +fn replace_break_tags(html: &str) -> String { + let mut out = String::with_capacity(html.len()); + let mut rest = html; + + while let Some(open) = rest.find('<') { + let Some(close) = rest[open..].find('>').map(|end| open + end) else { + break; + }; + + out.push_str(&rest[..open]); + if is_break_tag(&rest[open + 1..close]) { + out.push_str(BREAK); + } else { + out.push_str(&rest[open..=close]); + } + rest = &rest[close + 1..]; + } + + out.push_str(rest); + out +} + +fn is_break_tag(tag: &str) -> bool { + let tag = tag.trim_end_matches('/'); + let name = tag + .split_once(|c: char| c.is_ascii_whitespace()) + .map_or(tag, |(name, _)| name); + + name.eq_ignore_ascii_case("br") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn leaves_plain_markdown_untouched() { + let input = "# Title\n\nSome *text* with a [link](https://example.com).\n\n- a\n- b\n"; + assert_eq!(normalize_markdown(input), input); + } + + #[test] + fn converts_a_centered_header_block() { + let out = normalize_markdown( + "

\n\"Banner\"\n

\n\nBody text here.", + ); + + assert!(out.contains("![Banner](banner.png)"), "{out}"); + assert!(!out.contains("Line two
Line three"); + + assert!(!out.contains("two | c |\n"); + + assert!(!out.contains("x

\n```\n"; + assert_eq!(normalize_markdown(input), input); + } + + #[test] + fn unwraps_a_div_of_headings_and_links() { + let out = normalize_markdown( + "
\n

Mod

\n

Tagline

\n\"Badge\"\n
\n\nAfter.", + ); + + assert!(out.contains("# Mod"), "{out}"); + assert!(out.contains("### Tagline"), "{out}"); + assert!(out.contains("[![Badge](badge.svg)](https://example.com)"), "{out}"); + assert!(!out.contains("\n1.0.0\n\nFixed a crash.\n\n\n", + ); + + assert!(!out.contains("
"), "{out}"); + assert!(out.contains("Fixed a crash."), "{out}"); + } + + #[test] + fn does_not_mistake_other_tags_for_breaks() { + assert_eq!(replace_break_tags("x"), "x"); + assert_eq!(replace_break_tags("a
b"), format!("a{BREAK}b")); + } +} diff --git a/packages/oneclient_content/src/packages/mod.rs b/packages/oneclient_content/src/packages/mod.rs index cf97c60c..841dffde 100644 --- a/packages/oneclient_content/src/packages/mod.rs +++ b/packages/oneclient_content/src/packages/mod.rs @@ -10,6 +10,7 @@ pub mod types; pub mod updates; mod file_identity; +pub mod markdown; // Re-exported so `oneclient_core::packages::ContentType` keeps working pub use oneclient_common::domain::{ContentType, GameLoader, HashAlgorithm, ProviderId}; diff --git a/packages/oneclient_content/src/packages/provider/curseforge.rs b/packages/oneclient_content/src/packages/provider/curseforge.rs index def49270..f2e439bf 100644 --- a/packages/oneclient_content/src/packages/provider/curseforge.rs +++ b/packages/oneclient_content/src/packages/provider/curseforge.rs @@ -339,7 +339,7 @@ async fn fetch_body( match response { Ok(response) => match html_to_markdown(&response.data) { - Some(markdown) => PackageBody::Raw(markdown), + Some(markdown) => PackageBody::raw(markdown), None => fallback(), }, Err(err) => { diff --git a/packages/oneclient_content/src/packages/provider/modrinth.rs b/packages/oneclient_content/src/packages/provider/modrinth.rs index e0cc3e0f..f3c95495 100644 --- a/packages/oneclient_content/src/packages/provider/modrinth.rs +++ b/packages/oneclient_content/src/packages/provider/modrinth.rs @@ -481,7 +481,7 @@ struct ModrinthLicense { impl ModrinthProject { fn into_detail(self) -> ProjectDetail { let body = if let Some(raw) = self.body { - PackageBody::Raw(raw) + PackageBody::raw(raw) } else if let Some(url) = self.body_url { PackageBody::Url(url) } else { diff --git a/packages/oneclient_content/src/packages/types.rs b/packages/oneclient_content/src/packages/types.rs index 2a5d7b2f..d1cc044b 100644 --- a/packages/oneclient_content/src/packages/types.rs +++ b/packages/oneclient_content/src/packages/types.rs @@ -80,6 +80,12 @@ pub enum PackageBody { Raw(String), } +impl PackageBody { + pub fn raw(markdown: impl AsRef) -> Self { + Self::Raw(crate::packages::markdown::normalize_markdown(markdown.as_ref())) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProjectMember { pub name: String, diff --git a/packages/oneclient_core/src/images/mod.rs b/packages/oneclient_core/src/images/mod.rs index 67eee52b..679e8a55 100644 --- a/packages/oneclient_core/src/images/mod.rs +++ b/packages/oneclient_core/src/images/mod.rs @@ -9,10 +9,13 @@ use polyio::sha1_bytes; use oneclient_net::RequestError; use oneclient_common::paths; use oneclient_net::RequestClient; -use crate::LauncherResult; +use crate::{LauncherError, LauncherResult}; pub const DEFAULT_IMAGE_EDGE: u32 = 1600; +/// Image urls come from untrusted remote descriptions, so cap what is fetched and decoded. +const MAX_IMAGE_BYTES: usize = 8 * 1024 * 1024; + #[derive(Debug, Default)] pub struct ImageCacheStore { memory: Mutex>, @@ -132,11 +135,77 @@ fn downscale(bytes: &[u8], max_edge: u32) -> Option { #[tracing::instrument(level = "debug", skip(net))] async fn download(net: &RequestClient, url: &str) -> LauncherResult { - let parsed = url.parse().map_err(RequestError::from)?; + let parsed: reqwest::Url = url.parse().map_err(RequestError::from)?; + + if !matches!(parsed.scheme(), "http" | "https") { + return Err(refused(url, "unsupported scheme")); + } + if !is_public_host(&parsed) { + return Err(refused(url, "host is not public")); + } + let request = reqwest::Request::new(Method::GET, parsed); - let res = net.send(request).await?; - let bytes = res.bytes().await.map_err(RequestError::from)?; - Ok(bytes) + let mut res = net.send(request).await?; + + if res + .content_length() + .is_some_and(|len| len > MAX_IMAGE_BYTES as u64) + { + return Err(refused(url, "declared body is too large")); + } + + let mut bytes = Vec::new(); + while let Some(chunk) = res.chunk().await.map_err(RequestError::from)? { + if bytes.len() + chunk.len() > MAX_IMAGE_BYTES { + return Err(refused(url, "body is too large")); + } + bytes.extend_from_slice(&chunk); + } + + Ok(Bytes::from(bytes)) +} + +fn refused(url: &str, reason: &str) -> LauncherError { + LauncherError::StdIoError(std::io::Error::other(format!( + "refused to fetch image {url}: {reason}" + ))) +} + +fn is_public_host(url: &reqwest::Url) -> bool { + let Some(host) = url.host_str() else { + return false; + }; + let host = host.trim_start_matches('[').trim_end_matches(']'); + + if let Ok(ip) = host.parse::() { + return is_public_ip(ip); + } + + let host = host.to_ascii_lowercase(); + host != "localhost" && !host.ends_with(".localhost") +} + +fn is_public_ip(ip: std::net::IpAddr) -> bool { + match ip { + std::net::IpAddr::V4(ip) => { + !(ip.is_private() + || ip.is_loopback() + || ip.is_link_local() + || ip.is_unspecified() + || ip.is_broadcast() + || ip.is_documentation() + || ip.octets()[0] == 100 && (ip.octets()[1] & 0xc0) == 0x40) + } + std::net::IpAddr::V6(ip) => match ip.to_ipv4_mapped() { + Some(ip) => is_public_ip(ip.into()), + None => { + !(ip.is_loopback() + || ip.is_unspecified() + || (ip.segments()[0] & 0xfe00) == 0xfc00 + || (ip.segments()[0] & 0xffc0) == 0xfe80) + } + }, + } } fn extension_from_url(url: &str) -> Option {