fix: browser markdown (#768)
This commit is contained in:
parent
b697efce1a
commit
ea7b1bc922
22 changed files with 1715 additions and 51 deletions
20
Cargo.lock
generated
20
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"]}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
|
|
|
|||
328
packages/oneclient_app/src/components/markdown/image.rs
Normal file
328
packages/oneclient_app/src/components/markdown/image.rs
Normal 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.));
|
||||
}
|
||||
}
|
||||
118
packages/oneclient_app/src/components/markdown/mod.rs
Normal file
118
packages/oneclient_app/src/components/markdown/mod.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
|
||||
589
packages/oneclient_app/src/components/markdown/parse.rs
Normal file
589
packages/oneclient_app/src/components/markdown/parse.rs
Normal 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("")[0]), "[img:alt]");
|
||||
assert_eq!(paragraph_text(&blocks("see  here")[0]), "see [img:alt] here");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_inside_a_link_stays_in_the_link() {
|
||||
assert_eq!(
|
||||
paragraph_text(&blocks("text [](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| [](https://curseforge.com) | [](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));
|
||||
}
|
||||
}
|
||||
275
packages/oneclient_app/src/components/markdown/render.rs
Normal file
275
packages/oneclient_app/src/components/markdown/render.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 |()| {
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
201
packages/oneclient_content/src/packages/markdown.rs
Normal file
201
packages/oneclient_content/src/packages/markdown.rs
Normal 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(""), "{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("[](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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue