fix: browser markdown (#768)

This commit is contained in:
Maciek 2026-09-04 18:36:14 +02:00 • committed by LynithDev
parent b697efce1a
commit ea7b1bc922
22 changed files with 1715 additions and 51 deletions

20
Cargo.lock generated
View file

@ -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",

View file

@ -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"]}

View file

@ -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"

View file

@ -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.))
}

View file

@ -122,7 +122,7 @@ impl Component for LocalImage {
}
}
fn decode(bytes: &Bytes) -> Option<ImageHandle> {
pub(super) fn decode(bytes: &Bytes) -> Option<ImageHandle> {
let data = unsafe { SkData::new_bytes(bytes) };
let img = SkImage::from_encoded(data)?;
Some(ImageHandle::new(img, bytes.clone()))

View file

@ -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<String>, alt: impl Into<String>) -> 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<ScaledImage> 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<dyn ElementExt>) -> bool {
let Some(other) = (other.as_ref() as &dyn Any).downcast_ref::<ScaledImageElement>() else {
return false;
};
self != other
}
fn diff(&self, other: &Rc<dyn ElementExt>) -> DiffModifies {
let Some(other) = (other.as_ref() as &dyn Any).downcast_ref::<ScaledImageElement>() 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<dyn Any>)> {
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::<Size2D>())
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::<ScaledImageElement>()
.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::<ScaledImageElement>()
.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.));
}
}

View file

@ -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<String>) -> 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())
}
}

View file

@ -0,0 +1,589 @@
use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
#[derive(Clone, PartialEq)]
pub enum Block {
Heading {
level: HeadingLevel,
spans: Vec<TextSpan>,
},
Paragraph {
content: Vec<Inline>,
},
Code {
code: String,
},
List(List),
Image {
url: String,
alt: String,
},
Link {
url: String,
title: Option<String>,
content: Vec<Inline>,
},
Blockquote {
content: Vec<Inline>,
},
Table {
headers: Vec<Vec<TextSpan>>,
rows: Vec<Vec<Vec<TextSpan>>>,
},
Rule,
}
#[derive(Clone, PartialEq)]
pub struct List {
pub start: Option<u64>,
pub items: Vec<ListItem>,
}
#[derive(Clone, PartialEq)]
pub struct ListItem {
pub content: Vec<Inline>,
pub nested_lists: Vec<List>,
}
#[derive(Clone, PartialEq)]
pub enum Inline {
Span(TextSpan),
Image {
url: String,
alt: String,
},
Link {
url: String,
title: Option<String>,
content: Vec<Inline>,
},
}
#[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<String>) -> Self {
Self {
text: text.into(),
bold: false,
italic: false,
code: false,
}
}
}
pub fn parse(content: &str) -> Vec<Block> {
let mut options = Options::empty();
options.insert(Options::ENABLE_TABLES);
Walker::default().walk(Parser::new_ext(content, options))
}
#[derive(Default)]
struct Walker {
blocks: Vec<Block>,
spans: Vec<TextSpan>,
content: Vec<Inline>,
lists: Vec<List>,
items: Vec<ListItem>,
heading: Option<HeadingLevel>,
in_paragraph: bool,
in_code_block: bool,
code: String,
in_blockquote: bool,
blockquote: Vec<Inline>,
in_table_cell: bool,
headers: Vec<Vec<TextSpan>>,
rows: Vec<Vec<Vec<TextSpan>>>,
row: Vec<Vec<TextSpan>>,
cell: Vec<TextSpan>,
in_link: bool,
link_url: Option<String>,
link_title: Option<String>,
link_content: Vec<Inline>,
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<Block> {
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<Inline> = 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<TextSpan>) {
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<Block> {
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 <b>bold</b> word")[0]), "a bold word");
}
#[test]
fn block_html_keeps_its_text() {
assert_eq!(paragraph_text(&blocks("<div>\nhello\n</div>")[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));
}
}

View file

@ -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<TextSpan>],
rows: &[Vec<Vec<TextSpan>>],
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<TextSpan>], 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<String>,
content: Vec<Inline>,
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<String>,
content: Vec<Inline>,
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::<LinkConfirmState>().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)
}
}

View file

@ -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;

View file

@ -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 |()| {

View file

@ -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}");
}
}

View file

@ -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) {

View file

@ -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<usize>, 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()
}),
)
}
}

View file

@ -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()

View file

@ -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

View file

@ -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<usize>,
block: bool,
in_table: bool,
}
fn html_islands(input: &str) -> Vec<HtmlIsland> {
let mut options = Options::empty();
options.insert(Options::ENABLE_TABLES);
let mut islands: Vec<HtmlIsland> = 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(
"<p align=\"center\">\n<img src=\"banner.png\" alt=\"Banner\">\n</p>\n\nBody text here.",
);
assert!(out.contains("![Banner](banner.png)"), "{out}");
assert!(!out.contains("<p"), "{out}");
assert!(out.contains("\n\nBody text here."), "{out}");
}
#[test]
fn breaks_lines_on_inline_br() {
let out = normalize_markdown("Line one<br>Line two<br/>Line three");
assert!(!out.contains("<br"), "{out}");
assert_eq!(out.matches(" \n").count(), 2, "{out}");
}
#[test]
fn keeps_a_break_flat_inside_a_table() {
let out = normalize_markdown("| a | b |\n| - | - |\n| one<br>two | c |\n");
assert!(!out.contains("<br"), "{out}");
assert_eq!(out.lines().count(), 3, "{out}");
}
#[test]
fn leaves_html_inside_fenced_code_alone() {
let input = "Example:\n\n```html\n<br>\n<p align=\"center\">x</p>\n```\n";
assert_eq!(normalize_markdown(input), input);
}
#[test]
fn unwraps_a_div_of_headings_and_links() {
let out = normalize_markdown(
"<div align=\"center\">\n<h1>Mod</h1>\n<h3>Tagline</h3>\n<a href=\"https://example.com\"><img src=\"badge.svg\" alt=\"Badge\"></a>\n</div>\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("<div"), "{out}");
}
#[test]
fn converts_a_details_block() {
let out = normalize_markdown(
"## Changelog\n\n<details>\n<summary>1.0.0</summary>\n\nFixed a crash.\n\n</details>\n",
);
assert!(!out.contains("<details>"), "{out}");
assert!(out.contains("Fixed a crash."), "{out}");
}
#[test]
fn does_not_mistake_other_tags_for_breaks() {
assert_eq!(replace_break_tags("<brick>x</brick>"), "<brick>x</brick>");
assert_eq!(replace_break_tags("a<br class=\"x\">b"), format!("a{BREAK}b"));
}
}

View file

@ -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};

View file

@ -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) => {

View file

@ -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 {

View file

@ -80,6 +80,12 @@ pub enum PackageBody {
Raw(String),
}
impl PackageBody {
pub fn raw(markdown: impl AsRef<str>) -> Self {
Self::Raw(crate::packages::markdown::normalize_markdown(markdown.as_ref()))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectMember {
pub name: String,

View file

@ -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<HashMap<String, Bytes>>,
@ -132,11 +135,77 @@ fn downscale(bytes: &[u8], max_edge: u32) -> Option<Bytes> {
#[tracing::instrument(level = "debug", skip(net))]
async fn download(net: &RequestClient, url: &str) -> LauncherResult<Bytes> {
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::<std::net::IpAddr>() {
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<String> {