Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "markdown_parser"
authors = ["Warp Team <dev@warp.dev>"]
version = "0.1.0"
edition = "2024"
publish.workspace = true
license.workspace = true
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow.workspace = true
enum-iterator.workspace = true
itertools.workspace = true
nom.workspace = true
html5ever.workspace = true
markup5ever_rcdom = "0.35.0"
serde_yaml.workspace = true
thiserror.workspace = true
@@ -0,0 +1,227 @@
# Markdown Table Test Cases
## 01_simple_2x2
| Header 1 | Header 2 |
| --- | --- |
| Cell 1 | Cell 2 |
---
## 02_three_columns
| Name | Age | City |
| --- | --- | --- |
| Alice | 30 | NYC |
| Bob | 25 | LA |
---
## 03_multiple_rows
| ID | Value |
| --- | --- |
| 1 | Apple |
| 2 | Banana |
| 3 | Cherry |
| 4 | Date |
| 5 | Elderberry |
---
## 04_left_aligned
| Left 1 | Left 2 |
| :--- | :--- |
| Short | Text |
| Much longer text | Another |
---
## 05_right_aligned
| Right 1 | Right 2 |
| ---: | ---: |
| Short | Text |
| Much longer text | Another |
---
## 06_center_aligned
| Center 1 | Center 2 |
| :---: | :---: |
| Short | Text |
| Much longer text | Another |
---
## 07_mixed_alignment
| Left | Center | Right |
| :--- | :---: | ---: |
| L | C | R |
| Left-aligned | Centered | Right-aligned |
---
## 08_bold
| Header | Value |
| --- | --- |
| **Bold** | Normal |
| Text | **Bold too** |
---
## 09_italic
| Header | Value |
| --- | --- |
| *Italic* | Normal |
| Text | *Italic too* |
---
## 10_inline_code
| Function | Returns |
| --- | --- |
| `foo()` | `String` |
| `bar()` | `i32` |
---
## 11_links
| Site | URL |
| --- | --- |
| Google | [Link](https://google.com) |
| GitHub | [Link](https://github.com) |
---
## 12_strikethrough
| Item | Status |
| --- | --- |
| ~~Deprecated~~ | Old |
| Active | Current |
---
## 13_mixed_formatting
| Feature | Description |
| --- | --- |
| **Bold** with *italic* | Mixed |
| `code` and **bold** | Combined |
| ~~Strike~~ and *italic* | More |
---
## 14_empty_cells
| A | B | C |
| --- | --- | --- |
| | filled | |
| filled | | filled |
---
## 15_whitespace_cells
| A | B |
| --- | --- |
| | space |
| tab | text |
---
## 16_escaped_pipes
| Expression | Result |
| --- | --- |
| A \| B | OR operation |
| X \| Y \| Z | Multiple |
---
## 17_long_content
| Short | Very Long Content |
| --- | --- |
| A | This is a very long cell with lots of text that should wrap or truncate |
| B | Another cell with substantial content |
---
## 18_html_entities
| Symbol | Code |
| --- | --- |
| &lt; | Less than |
| &gt; | Greater than |
| &amp; | Ampersand |
---
## 19_unicode_emoji
| Icon | Name |
| --- | --- |
| 🚀 | Rocket |
| ⭐ | Star |
| 🎉 | Party |
---
## 20_wide_table
| Build Identifier | Release Channel | Feature Flag State | Workspace Session Token | Active Pane Title | Suggested Command Preview | Generated File Path | Git Branch Name | Pull Request Status | Reviewer Assignment | Telemetry Event Name | Render Mode | Table Layout Strategy | Horizontal Overflow Sentinel | Unbroken Content Sample | Final Notes |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| build_2026_04_08_very_long_identifier_alpha | dogfood_internal_preview_rollout_candidate | markdown_table_horizontal_scroll_enabled | ws_session_token_01_ABCDEFGHIJKLMNOPQRSTUVWXYZ | agent_mode_diff_review_surface_with_extra_context | cargo_nextest_run_no_fail_fast_workspace_markdown_parser | crates/markdown_parser/examples/table-demo/all_test_cases.md | zach/wide-markdown-table-scroll | awaiting_follow_up_visual_regression_check | reviewer_assignment_pending_product_design | markdown_table_rendered_in_example_viewport | constrained_width_preview_panel | preserve_column_intrinsic_widths_before_wrapping | horizontal_scroll_should_be_required_here | SUPERLONGUNBROKENTEXTVALUE0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ | first_row_designed_to_force_width |
| build_2026_04_08_very_long_identifier_beta | stable_candidate_post_validation | markdown_table_horizontal_scroll_enabled | ws_session_token_02_ZYXWVUTSRQPONMLKJIHGFEDCBA | markdown_parser_demo_showing_extreme_width_case | cargo_run_features_with_local_server_markdown_demo | crates/markdown_parser/examples/table-demo/render_snapshot_reference.png | zach/wide-markdown-table-scroll | local_only_validation_before_pr | reviewer_assignment_not_requested_yet | markdown_table_horizontal_scroll_exercised | embedded_example_renderer | keep_headers_verbose_and_cells_intentionally_wide | overflow_region_should_extend_far_past_viewport | ANOTHEREXTREMELYLONGUNBROKENVALUE_for_horizontal_scroll_testing_only | second_row_keeps_pressure_on_layout |
| build_2026_04_08_very_long_identifier_gamma | canary_rollout_with_extra_observability | markdown_table_horizontal_scroll_enabled | ws_session_token_03_0123456789_repeat_repeat | full_width_table_case_for_manual_agent_testing | cargo_clippy_workspace_all_targets_all_features_tests | app/src/features/markdown/table_renderer/visual_debug_reference.rs | zach/wide-markdown-table-scroll | no_pr_needed_for_manual_local_test | reviewer_assignment_not_applicable | markdown_table_scroll_behavior_verified_manually | split_pane_code_review_view | avoid_collapsing_columns_even_with_dense_content | viewport_must_scroll_horizontally_to_reveal_tail_columns | YETANOTHERLONGUNBROKENCONTENTBLOCK_THAT_SHOULD_NOT_WRAP_EASILY | third_row_confirms_consistent_behavior |
---
## 21_deep_table
| ID | Value |
| --- | --- |
| 1 | Row 1 |
| 2 | Row 2 |
| 3 | Row 3 |
| 4 | Row 4 |
| 5 | Row 5 |
| 6 | Row 6 |
| 7 | Row 7 |
| 8 | Row 8 |
| 9 | Row 9 |
| 10 | Row 10 |
| 11 | Row 11 |
| 12 | Row 12 |
| 13 | Row 13 |
| 14 | Row 14 |
| 15 | Row 15 |
| 16 | Row 16 |
| 17 | Row 17 |
| 18 | Row 18 |
| 19 | Row 19 |
| 20 | Row 20 |
---
## 22_large_grid
| C1 | C2 | C3 | C4 | C5 | C6 |
| --- | --- | --- | --- | --- | --- |
| R1C1 | R1C2 | R1C3 | R1C4 | R1C5 | R1C6 |
| R2C1 | R2C2 | R2C3 | R2C4 | R2C5 | R2C6 |
| R3C1 | R3C2 | R3C3 | R3C4 | R3C5 | R3C6 |
| R4C1 | R4C2 | R4C3 | R4C4 | R4C5 | R4C6 |
| R5C1 | R5C2 | R5C3 | R5C4 | R5C5 | R5C6 |
| R6C1 | R6C2 | R6C3 | R6C4 | R6C5 | R6C6 |
| R7C1 | R7C2 | R7C3 | R7C4 | R7C5 | R7C6 |
| R8C1 | R8C2 | R8C3 | R8C4 | R8C5 | R8C6 |
+580
View File
@@ -0,0 +1,580 @@
use anyhow::Result;
use serde_yaml::{Mapping, Value};
use std::{
cell::Cell,
collections::{HashMap, VecDeque},
rc::Rc,
};
use html5ever::{
Attribute, ParseOpts, parse_document, tendril::TendrilSink, tree_builder::TreeBuilderOpts,
};
use markup5ever_rcdom::{Node, NodeData, RcDom};
use crate::{
CodeBlockText, FormattedIndentTextInline, FormattedTaskList, FormattedText,
FormattedTextFragment, FormattedTextHeader, FormattedTextInline, FormattedTextLine,
FormattedTextStyles, Hyperlink, OrderedFormattedIndentTextInline,
markdown_parser::RUNNABLE_BLOCK_MARKDOWN_LANG, weight::CustomWeight,
};
// Top element element tags we are not parsing for right now.
// Note that we have "<b>" here because GDocs always include a top level <b> element to add additional
// GDocs specific meta-data for its rich text content.
const TOP_LEVEL_ELEMENT_TAGS_TO_SKIP: &[&str] = &[
"head", "body", "html", "meta", "table", "b", "div", "ul", "ol", "li", "input",
];
const PHRASING_ELEMENT_TAGS: &[&str] = &[
"span", "i", "code", "strong", "em", "br", "a", "s", "u", "ins",
];
pub const WARP_EMBED_ATTRIBUTE_NAME: &str = "data-warp-embedded-item";
#[derive(Clone, Debug, PartialEq, Eq)]
struct ListArg {
indent_level: usize,
item_type: ListType,
start_number: Rc<Cell<Option<usize>>>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ListType {
Checkbox(bool),
ListItem { ordered: bool },
}
#[derive(Clone, Default)]
struct Styling {
bold: bool,
italic: bool,
underline: bool,
strikethrough: bool,
inline_code: bool,
link: Option<String>,
}
impl Styling {
fn update_with_attributes(&mut self, attributes: &[Attribute]) {
for attribute in attributes {
let attribute_name = attribute.name.local.to_string();
if attribute_name == "style" {
let attribute_value = attribute.value.to_string();
let style_dict = parse_style_into_dict(attribute_value.as_str());
if let Some(font_style) = style_dict.get("font-style") {
self.italic = *font_style == "italic";
}
if let Some(text_decoration) = style_dict.get("text-decoration") {
// `text-decoration` can be used for multiple things. If we are missing line-through
// here, don't unset the strikethrough.
if *text_decoration == "line-through" {
self.strikethrough = true;
}
if *text_decoration == "underline" {
self.underline = true;
}
}
if let Some(font_weight) = style_dict.get("font-weight") {
if *font_weight == "bold" || *font_weight == "bolder" {
self.bold = true;
} else {
let maybe_integer = font_weight.parse::<i32>();
if let Ok(weight_value) = maybe_integer {
self.bold = weight_value > 400;
}
}
}
// Note that there is not a definitive way in HTML to represent inline code.
// Different text editors all use different ways to represent inline code and are
// not always compatible to each other, here we chose Notion's way of representing
// inline code which is a non-transparent background. We could revisit this in the future.
if let Some(color) = style_dict.get("background") {
self.inline_code = *color != "transparent";
}
} else if attribute_name == "href" {
let attribute_value = attribute.value.to_string();
self.link = Some(attribute_value);
}
}
}
}
/// Find an attribute by name, if it's present.
fn get_attribute<'a>(attributes: &'a [Attribute], name: &str) -> Option<&'a str> {
attributes.iter().find_map(|attribute| {
if &attribute.name.local == name {
Some(attribute.value.as_ref())
} else {
None
}
})
}
fn includes_attribute(attributes: &[Attribute], name: &str) -> bool {
get_attribute(attributes, name).is_some()
}
fn type_matches(attributes: &[Attribute], value: &str) -> bool {
get_attribute(attributes, "type") == Some(value)
}
// Top-level function to parse a HTML string into a FormattedText document.
pub fn parse_html(html: &str) -> Result<FormattedText> {
let opts = ParseOpts {
tree_builder: TreeBuilderOpts {
drop_doctype: true,
..Default::default()
},
..Default::default()
};
// Parse the document into a Dom element tree.
let dom = parse_document(RcDom::default(), opts)
.from_utf8()
.read_from(&mut html.as_bytes())?;
let mut result = VecDeque::new();
// Top-level nodes to visit.
let mut nodes: Vec<(Rc<Node>, Option<ListArg>)> = Vec::new();
nodes.push((Rc::clone(&dom.document), None));
let mut pending_inline_nodes = Vec::new();
// Active indent level decorating the current node.
let mut last_active_indent_level: Option<ListArg> = None;
while let Some((node, mut indent_level)) = nodes.pop() {
// If the indentation level has changed, we should push all pending inline nodes into the previous
// indentation level first.
if last_active_indent_level != indent_level && !pending_inline_nodes.is_empty() {
if let Some(parsed_node) =
parse_pending_inline_nodes(&pending_inline_nodes, last_active_indent_level.as_ref())
{
result.push_back(parsed_node);
}
pending_inline_nodes.clear();
}
match &node.data {
// Nodes we are not processing. Just push its children into the visit queue.
NodeData::Document
| NodeData::Doctype { .. }
| NodeData::ProcessingInstruction { .. }
| NodeData::Comment { .. } => {
for child in node.children.borrow().iter().rev() {
nodes.push((Rc::clone(child), indent_level.clone()));
}
}
// If we observe plain text in the top level nodes. Add them as plain text lines.
NodeData::Text { contents } => {
if !contents.borrow().trim().is_empty() {
pending_inline_nodes.push(node);
}
}
NodeData::Element { name, attrs, .. } => {
let node_name = name.local.to_string();
let mut decorated_styling = Styling::default();
// Confluence does not follow the common pattern of marking code blocks in <pre> specifically in its view mode.
// Instead, it marks code blocks in <span> with a specific attribute.
let is_confluence_code_block = node_name.as_str() == "span"
&& includes_attribute(&attrs.borrow(), "data-ds--code--code-block");
// If the node is an element we are skip processing, push its children into the visit queue and skip
// to the next iteration.
if TOP_LEVEL_ELEMENT_TAGS_TO_SKIP.contains(&node_name.as_str()) {
let is_unordered_list = node_name.as_str() == "ul";
let is_ordered_list = node_name.as_str() == "ol";
let start_number = if is_ordered_list {
get_attribute(&attrs.borrow(), "start").and_then(|value| value.parse().ok())
} else {
None
};
if is_unordered_list || is_ordered_list {
let level = match &indent_level {
Some(level) => level.indent_level + 1,
_ => 0,
};
indent_level = Some(ListArg {
item_type: ListType::ListItem {
ordered: is_ordered_list,
},
start_number: Rc::new(Cell::new(start_number)),
indent_level: level,
});
};
// Check if the first node represents input.
if let Some(NodeData::Element {
name,
attrs: child_node_attr,
..
}) = node.children.borrow().iter().next().map(|node| &node.data)
{
let child_node_name = name.local.to_string();
if let Some(indent_arg) = &mut indent_level
&& child_node_name.as_str() == "input"
&& type_matches(&child_node_attr.borrow(), "checkbox")
{
indent_arg.item_type = ListType::Checkbox(includes_attribute(
&child_node_attr.borrow(),
"checked",
));
}
}
for child in node.children.borrow().iter().rev() {
nodes.push((Rc::clone(child), indent_level.clone()));
}
if !pending_inline_nodes.is_empty() {
if let Some(parsed_nodes) = parse_pending_inline_nodes(
&pending_inline_nodes,
last_active_indent_level.as_ref(),
) {
result.push_back(parsed_nodes);
}
pending_inline_nodes.clear();
}
last_active_indent_level = indent_level;
continue;
} else if PHRASING_ELEMENT_TAGS.contains(&node_name.as_str())
&& !is_confluence_code_block
{
pending_inline_nodes.push(node);
last_active_indent_level = indent_level;
continue;
}
if !pending_inline_nodes.is_empty() {
if let Some(parsed_nodes) = parse_pending_inline_nodes(
&pending_inline_nodes,
last_active_indent_level.as_ref(),
) {
result.push_back(parsed_nodes);
}
pending_inline_nodes.clear();
}
// Update styling based on the node's attribute.
decorated_styling.update_with_attributes(&attrs.borrow());
result.push_back(match node_name.as_str() {
// If it's a code block, process its children node as plain text.
"pre" => {
if let Some(val) = get_attribute(&attrs.borrow(), WARP_EMBED_ATTRIBUTE_NAME)
{
FormattedTextLine::Embedded(Mapping::from_iter([(
Value::String("id".to_string()),
Value::String(val.to_string()),
)]))
} else {
// TODO: Support Github's code block representation.
let (content, language) =
parse_code_block_and_language(&node.children.borrow());
FormattedTextLine::CodeBlock(CodeBlockText {
lang: language.unwrap_or(RUNNABLE_BLOCK_MARKDOWN_LANG.to_string()),
code: content,
})
}
}
"span" if is_confluence_code_block => {
FormattedTextLine::CodeBlock(CodeBlockText {
lang: get_attribute(&attrs.borrow(), "data-code-lang")
.unwrap_or(RUNNABLE_BLOCK_MARKDOWN_LANG)
.to_string(),
code: parse_text_only(&node.children.borrow()),
})
}
"h1" => FormattedTextLine::Heading(FormattedTextHeader {
heading_size: 1,
text: parse_phrasing_content(
&node.children.borrow(),
decorated_styling.clone(),
),
}),
"h2" => FormattedTextLine::Heading(FormattedTextHeader {
heading_size: 2,
text: parse_phrasing_content(
&node.children.borrow(),
decorated_styling.clone(),
),
}),
"h3" => FormattedTextLine::Heading(FormattedTextHeader {
heading_size: 3,
text: parse_phrasing_content(
&node.children.borrow(),
decorated_styling.clone(),
),
}),
"h4" => FormattedTextLine::Heading(FormattedTextHeader {
heading_size: 4,
text: parse_phrasing_content(
&node.children.borrow(),
decorated_styling.clone(),
),
}),
"h5" => FormattedTextLine::Heading(FormattedTextHeader {
heading_size: 5,
text: parse_phrasing_content(
&node.children.borrow(),
decorated_styling.clone(),
),
}),
"h6" => FormattedTextLine::Heading(FormattedTextHeader {
heading_size: 6,
text: parse_phrasing_content(
&node.children.borrow(),
decorated_styling.clone(),
),
}),
"br" => FormattedTextLine::LineBreak,
"hr" => FormattedTextLine::HorizontalRule,
_ => {
// Take into consideration the indent level when parsing the nodes.
let parsed_node = parse_pending_inline_nodes(
&node.children.borrow(),
indent_level.as_ref(),
);
match parsed_node {
Some(node) => node,
None => FormattedTextLine::Line(Vec::new()),
}
}
})
}
}
last_active_indent_level = indent_level;
}
if !pending_inline_nodes.is_empty() {
if let Some(parsed_nodes) =
parse_pending_inline_nodes(&pending_inline_nodes, last_active_indent_level.as_ref())
{
result.push_back(parsed_nodes);
}
pending_inline_nodes.clear();
}
Ok(FormattedText { lines: result })
}
// Push all pending inline nodes into the result. Take into consideration the active indent level.
fn parse_pending_inline_nodes(
nodes: &[Rc<Node>],
last_active_indent_level: Option<&ListArg>,
) -> Option<FormattedTextLine> {
let internal = parse_phrasing_content(nodes, Default::default());
if !internal.is_empty() {
Some(match last_active_indent_level {
Some(list) => match list.item_type {
ListType::ListItem { ordered: true } => {
FormattedTextLine::OrderedList(OrderedFormattedIndentTextInline {
// Take the start number, so that it's only applied to the first item in
// the list.
number: list.start_number.take(),
indented_text: FormattedIndentTextInline {
indent_level: list.indent_level,
text: internal,
},
})
}
ListType::ListItem { ordered: false } => {
FormattedTextLine::UnorderedList(FormattedIndentTextInline {
indent_level: list.indent_level,
text: internal,
})
}
ListType::Checkbox(checked) => FormattedTextLine::TaskList(FormattedTaskList {
complete: checked,
indent_level: list.indent_level,
text: internal,
}),
},
None => FormattedTextLine::Line(internal),
})
} else {
None
}
}
// Parse the phrasing content: https://developer.mozilla.org/en-US/docs/Web/HTML/Content_categories#phrasing_content
// into an inline formatted text.
fn parse_phrasing_content(nodes: &[Rc<Node>], text_styling: Styling) -> FormattedTextInline {
let mut result = Vec::new();
for node in nodes {
if is_spacing_span(node) {
result.push(phrasing_to_formatted_text(" ", &text_styling));
continue;
}
match &node.data {
// We should not observe these in the phrasing content.
NodeData::Document
| NodeData::Doctype { .. }
| NodeData::ProcessingInstruction { .. }
| NodeData::Comment { .. } => {}
// Push text fragment based on the current text_styling.
NodeData::Text { contents } => {
let content = contents.borrow().trim_end_matches('\n').to_string();
// Filter out singular empty lines after trimming as they are from HTML formatting and shouldn't
// be inserted into the content. The rare valid case we might be missing here is <p>\n</p>.
// But all major rich text editors I have tested use <br> to represent it instead.
if content.is_empty() {
continue;
}
result.push(phrasing_to_formatted_text(content, &text_styling));
}
NodeData::Element { name, attrs, .. } => {
let node_name = name.local.to_string();
let mut decorated_styling = text_styling.clone();
decorated_styling.update_with_attributes(&attrs.borrow());
match node_name.as_ref() {
"b" | "strong" => decorated_styling.bold = true,
"i" | "em" => decorated_styling.italic = true,
"s" => decorated_styling.strikethrough = true,
"u" | "ins" => decorated_styling.underline = true,
"code" => decorated_styling.inline_code = true,
// TODO: We need to add more phrasing styling we support (e.g. links) here.
// https://linear.app/warpdotdev/issue/CLD-335/add-html-parsing-for-headers-and-lists
_ => (),
};
result.extend(parse_phrasing_content(
node.children.borrow().as_ref(),
decorated_styling,
));
}
}
}
result
}
/// Converts styled phrasing text to a fragment of formatted text.
fn phrasing_to_formatted_text(text: impl Into<String>, styling: &Styling) -> FormattedTextFragment {
let weight = if styling.bold {
Some(CustomWeight::Bold)
} else {
None
};
FormattedTextFragment {
text: text.into(),
styles: FormattedTextStyles {
weight,
italic: styling.italic,
underline: styling.underline,
strikethrough: styling.strikethrough,
hyperlink: styling.link.clone().map(Hyperlink::Url),
inline_code: styling.inline_code,
},
}
}
/// Chrome and Safari replace some spaces in copied content with non-breaking spaces. This matches
/// such spaces.
///
/// See [this ProseMirror thread](https://discuss.prosemirror.net/t/non-breaking-spaces-being-added-to-pasted-html/3911/4).
fn is_spacing_span(node: &Rc<Node>) -> bool {
if let NodeData::Element { name, attrs, .. } = &node.data {
if &name.local != "span" {
return false;
}
// The span must have either no attrs or a single `class="Apple-converted-space"` class.
let attrs = attrs.borrow();
if attrs.len() > 1 {
return false;
}
let css_class = attrs.first().filter(|attr| &attr.name.local == "class");
if css_class.is_some_and(|class| &*class.value != "Apple-converted-space") {
return false;
}
let content = node.children.borrow();
if content.len() != 1 {
return false;
}
match &content[0].data {
NodeData::Text { contents } => &**contents.borrow() == "\u{00a0}",
_ => false,
}
} else {
false
}
}
// Only parse out text content in the provided nodes and their children.
fn parse_text_only(nodes: &[Rc<Node>]) -> String {
let mut text = String::new();
for node in nodes {
if let NodeData::Text { contents } = &node.data {
text.push_str(contents.borrow().to_string().as_str());
} else {
text.push_str(&parse_text_only(&node.children.borrow()));
}
}
text
}
// Recursively parse out the code block content and it's language info.
fn parse_code_block_and_language(nodes: &[Rc<Node>]) -> (String, Option<String>) {
let mut text = String::new();
let mut language = None;
for node in nodes {
match &node.data {
NodeData::Text { contents } => text.push_str(contents.borrow().to_string().as_str()),
NodeData::Element { name, attrs, .. } => {
let node_name = name.local.to_string();
if node_name == "code"
&& let Some(parsed_lang) = get_attribute(&attrs.borrow(), "class")
.and_then(|s| s.strip_prefix("language-").map(|result| result.to_string()))
{
language = Some(parsed_lang);
}
let (child_str, new_lang) = parse_code_block_and_language(&node.children.borrow());
text.push_str(&child_str);
if new_lang.is_some() {
language = new_lang;
}
}
_ => text.push_str(&parse_text_only(&node.children.borrow())),
}
}
(text, language)
}
// Parse a HMTL style string into its corresponding name -> value hashmap
// For example "font-style:italic;font-weight:400" will be parsed into
// {"font-style": "italic", "font-weight": "400"}.
fn parse_style_into_dict(style: &str) -> HashMap<&str, &str> {
let style_pairs = style.split(';');
let mut style_dict = HashMap::new();
for pair in style_pairs {
if pair.contains(':') {
let name_and_value: Vec<&str> = pair.split(':').collect();
style_dict.insert(name_and_value[0].trim(), name_and_value[1].trim());
}
}
style_dict
}
#[cfg(test)]
#[path = "html_parser_test.rs"]
mod tests;
@@ -0,0 +1,567 @@
use super::*;
// Simple transformer to make testing easier.
fn test_parse_html(source: &str) -> Vec<FormattedTextLine> {
parse_html(source).unwrap().lines.into()
}
#[test]
fn test_parse_plain_text() {
assert_eq!(
test_parse_html("<meta charset='utf-8'>Some"),
vec![FormattedTextLine::Line(vec![
FormattedTextFragment::plain_text("Some"),
])]
);
assert_eq!(
test_parse_html("<meta charset='utf-8'><p>Some</p><p>tests</p>"),
vec![
FormattedTextLine::Line(vec![FormattedTextFragment::plain_text("Some"),]),
FormattedTextLine::Line(vec![FormattedTextFragment::plain_text("tests"),])
]
);
// Example from GDocs.
assert_eq!(
test_parse_html(
"<meta charset='utf-8'><meta charset=\"utf-8\"><b style=\"font-weight:normal;\" id=\"docs-internal-guid-27b0e865-7fff-b40d-5b19-8e9e7ccf7c8c\">\
<p dir=\"ltr\" style=\"line-height:1.38;margin-top:0pt;margin-bottom:0pt;\"><span style=\"font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:400;font-style:normal;\
font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;\">Some plain</span></p><p dir=\"ltr\" style=\"line-height:1.38;margin-top:0pt;margin-bottom:0pt;\">\
<span style=\"font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;\
white-space:pre-wrap;\">text</span></p></b>"
),
vec![
FormattedTextLine::Line(vec![FormattedTextFragment::plain_text("Some plain"),]),
FormattedTextLine::Line(vec![FormattedTextFragment::plain_text("text"),])
]
);
}
#[test]
fn test_parse_text_styles() {
assert_eq!(
test_parse_html(
"<meta charset='utf-8'>So<span style=\"font-weight:600\" data-token-index=\"1\" class=\"notion-enable-hover\">me</span>"
),
vec![FormattedTextLine::Line(vec![
FormattedTextFragment::plain_text("So"),
FormattedTextFragment::bold("me"),
])]
);
assert_eq!(
test_parse_html(
"<meta charset='utf-8'><p>So<strong>me</strong></p><p><em><strong>tes</strong></em>ts</p>"
),
vec![
FormattedTextLine::Line(vec![
FormattedTextFragment::plain_text("So"),
FormattedTextFragment::bold("me")
]),
FormattedTextLine::Line(vec![
FormattedTextFragment::bold_italic("tes"),
FormattedTextFragment::plain_text("ts")
])
]
);
assert_eq!(
test_parse_html(
"<meta charset='utf-8'><meta charset=\"utf-8\"><b style=\"font-weight:normal;\" id=\"docs-internal-guid-a96b449f-7fff-d755-78a4-efcebc867940\">\
<p dir=\"ltr\" style=\"line-height:1.38;margin-top:0pt;margin-bottom:0pt;\"><span style=\"font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;\
font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;\">So</span>\
<span style=\"font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:400;font-style:italic;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;\">\
me</span><span style=\"font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:700;font-style:italic;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;\">\
p</span><span style=\"font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:700;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;\">\
la</span><span style=\"font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;\">in\
</span></p><span style=\"font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;\">t\
</span><span style=\"font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:400;font-style:italic;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;\">ext</span></b>"
),
vec![
FormattedTextLine::Line(vec![
FormattedTextFragment::plain_text("So"),
FormattedTextFragment::italic("me"),
FormattedTextFragment::bold_italic("p"),
FormattedTextFragment::bold("la"),
FormattedTextFragment::plain_text("in")
]),
FormattedTextLine::Line(vec![
FormattedTextFragment::plain_text("t"),
FormattedTextFragment::italic("ext")
])
]
);
assert_eq!(
test_parse_html(
"<meta charset='utf-8'><meta charset=\"utf-8\"><b style=\"font-weight:normal;\" id=\"docs-internal-guid-27b0e865-7fff-b40d-5b19-8e9e7ccf7c8c\">\
<p dir=\"ltr\" style=\"line-height:1.38;margin-top:0pt;margin-bottom:0pt;\"><span style=\"font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:400;font-style:normal;\
font-variant:normal;text-decoration:underline;vertical-align:baseline;white-space:pre;white-space:pre-wrap;\">This is underlined</span></p><p dir=\"ltr\" style=\"line-height:1.38;margin-top:0pt;margin-bottom:0pt;\">\
<span style=\"font-size:11pt;font-family:Arial;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;\
white-space:pre-wrap;\"> text</span></p></b>"
),
vec![
FormattedTextLine::Line(vec![FormattedTextFragment::underline("This is underlined")]),
FormattedTextLine::Line(vec![FormattedTextFragment::plain_text(" text")]),
]
);
assert_eq!(
test_parse_html(
"<meta charset='utf-8'>abc <a href=\"https://google.com/\" style=\"cursor:pointer;color:inherit;word-wrap:break-word;text-decoration:inherit\" class=\"notion-link-token notion-focusable-token notion-enable-hover\" rel=\"noopener noreferrer\"\
data-token-index=\"1\" tabindex=\"0\"><span style=\"border-bottom:0.05em solid;border-color:rgba(55,53,47,0.4);opacity:0.7\" class=\"link-annotation-unknown-block-id--760432549\">NewLinadafekene</span></a> ghi def"
),
vec![FormattedTextLine::Line(vec![
FormattedTextFragment::plain_text("abc "),
FormattedTextFragment::hyperlink("NewLinadafekene", "https://google.com/"),
FormattedTextFragment::plain_text(" ghi def")
]),]
);
assert_eq!(
test_parse_html(
r#"<meta charset='utf-8'>the <span style="font-family:&quot;SFMono-Regular&quot;, Menlo, Consolas, &quot;PT Mono&quot;, &quot;Liberation Mono&quot;, Courier, monospace;line-height:normal;background:rgba(135,131,120,.15);color:#EB5757;border-radius:3px;font-size:85%;padding:0.2em 0.4em" data-token-index="1" spellcheck="false" class="notion-enable-hover">&lt;ul&gt;</span> and <span style="font-family:&quot;SFMono-Regular&quot;, Menlo, Consolas, &quot;PT Mono&quot;, &quot;Liberation Mono&quot;, Courier, monospace;line-height:normal;background:rgba(135,131,120,.15);color:#EB5757;border-radius:3px;font-size:85%;padding:0.2em 0.4em" data-token-index="3" spellcheck="false" class="notion-enable-hover">&lt;li&gt;</span>"#
),
vec![FormattedTextLine::Line(vec![
FormattedTextFragment::plain_text("the "),
FormattedTextFragment::inline_code("<ul>"),
FormattedTextFragment::plain_text(" and "),
FormattedTextFragment::inline_code("<li>"),
]),]
);
assert_eq!(
test_parse_html(
r#"<meta charset='utf-8'><span style="text-decoration:line-through" data-token-index="0" class="notion-enable-hover">Strike</span><span style="font-weight:600" data-token-index="1" class="notion-enable-hover">Bold</span>"#
),
vec![FormattedTextLine::Line(vec![
FormattedTextFragment::strikethrough("Strike"),
FormattedTextFragment::bold("Bold"),
]),]
)
}
#[test]
fn test_block() {
assert_eq!(
test_parse_html(
"<meta charset='utf-8'><pre><code class=\"language-jsx\">git checkout -b branch</code></pre>"
),
vec![FormattedTextLine::CodeBlock(CodeBlockText {
lang: "jsx".to_string(),
code: "git checkout -b branch".to_string()
})]
);
assert_eq!(
test_parse_html(
"<meta charset='utf-8'><p>tests</p><pre><code class=\"language-jsx\">git checkout -b branch</code></pre><p>More</p>"
),
vec![
FormattedTextLine::Line(vec![FormattedTextFragment::plain_text("tests"),]),
FormattedTextLine::CodeBlock(CodeBlockText {
lang: "jsx".to_string(),
code: "git checkout -b branch".to_string()
}),
FormattedTextLine::Line(vec![FormattedTextFragment::plain_text("More"),])
]
);
}
#[test]
fn test_transform_non_breaking_spaces() {
let expected_text = vec![FormattedTextLine::Line(vec![
FormattedTextFragment::plain_text(
"Open the Docker desktop app. This is necessary to create the symbolic links that will make the",
),
FormattedTextFragment::plain_text(" "),
FormattedTextFragment::inline_code("docker"),
FormattedTextFragment::plain_text(" "),
FormattedTextFragment::plain_text("CLI available."),
])];
let chromium_html = r#"Open the Docker desktop app. This is necessary to create the symbolic links that will make the<span> </span><code>docker</code><span> </span>CLI available."#;
assert_eq!(test_parse_html(chromium_html), expected_text);
let safari_html = r#"Open the Docker desktop app. This is necessary to create the symbolic links that will make the<span class="Apple-converted-space"> </span><code>docker</code><span class="Apple-converted-space"> </span>CLI available."#;
assert_eq!(test_parse_html(safari_html), expected_text);
}
// TODO: remove/update this test when we eventually support these HTML element types!
#[test]
fn test_unsupported_html_types() {
assert_eq!(
test_parse_html(
"<meta charset='utf-8'><p>Test test</p><blockquote><p>Block quotes with <strong>bold text</strong></p></blockquote>"
),
vec![
FormattedTextLine::Line(vec![FormattedTextFragment::plain_text("Test test"),]),
FormattedTextLine::Line(vec![
FormattedTextFragment::plain_text("Block quotes with "),
FormattedTextFragment::bold("bold text")
])
]
);
assert_eq!(
test_parse_html(
"<meta charset='utf-8'><table><thead><tr><th>Text 1</th><th>Text 2</th></tr></thead><tbody><tr><td>Test</td><td>Test</td></tr></tbody></table>"
),
vec![
FormattedTextLine::Line(vec![
FormattedTextFragment::plain_text("Text 1"),
FormattedTextFragment::plain_text("Text 2")
]),
FormattedTextLine::Line(vec![
FormattedTextFragment::plain_text("Test"),
FormattedTextFragment::plain_text("Test")
])
]
);
}
#[test]
fn test_sub_lists() {
assert_eq!(
test_parse_html(
"<meta charset='utf-8'><ul><li>def<ul><li>abc<ul><li>sub-list</li></ul></li><li>abc</li></ul></li></ul>"
),
vec![
FormattedTextLine::UnorderedList(FormattedIndentTextInline {
indent_level: 0,
text: vec![FormattedTextFragment::plain_text("def")]
}),
FormattedTextLine::UnorderedList(FormattedIndentTextInline {
indent_level: 1,
text: vec![FormattedTextFragment::plain_text("abc")]
}),
FormattedTextLine::UnorderedList(FormattedIndentTextInline {
indent_level: 2,
text: vec![FormattedTextFragment::plain_text("sub-list")]
}),
FormattedTextLine::UnorderedList(FormattedIndentTextInline {
indent_level: 1,
text: vec![FormattedTextFragment::plain_text("abc")]
})
]
);
assert_eq!(
test_parse_html(
"<meta charset='utf-8'><ul><li>d<strong>ef</strong><ul><li><em>abc</em><ul><li>sub-list</li></ul></li><li>abc</li></ul></li></ul><p>normal text</p>"
),
vec![
FormattedTextLine::UnorderedList(FormattedIndentTextInline {
indent_level: 0,
text: vec![
FormattedTextFragment::plain_text("d"),
FormattedTextFragment::bold("ef")
]
}),
FormattedTextLine::UnorderedList(FormattedIndentTextInline {
indent_level: 1,
text: vec![FormattedTextFragment::italic("abc")]
}),
FormattedTextLine::UnorderedList(FormattedIndentTextInline {
indent_level: 2,
text: vec![FormattedTextFragment::plain_text("sub-list")]
}),
FormattedTextLine::UnorderedList(FormattedIndentTextInline {
indent_level: 1,
text: vec![FormattedTextFragment::plain_text("abc")]
}),
FormattedTextLine::Line(vec![FormattedTextFragment::plain_text("normal text")])
]
);
assert_eq!(
test_parse_html(
"<meta charset='utf-8'><ul><li>abc<ul><li>def</li></ul></li></ul><ol><li>abc<ol><li>def</li></ol></li></ol>"
),
vec![
FormattedTextLine::UnorderedList(FormattedIndentTextInline {
indent_level: 0,
text: vec![FormattedTextFragment::plain_text("abc")]
}),
FormattedTextLine::UnorderedList(FormattedIndentTextInline {
indent_level: 1,
text: vec![FormattedTextFragment::plain_text("def")]
}),
FormattedTextLine::OrderedList(OrderedFormattedIndentTextInline {
number: None,
indented_text: FormattedIndentTextInline {
indent_level: 0,
text: vec![FormattedTextFragment::plain_text("abc".to_string())]
}
}),
FormattedTextLine::OrderedList(OrderedFormattedIndentTextInline {
number: None,
indented_text: FormattedIndentTextInline {
indent_level: 1,
text: vec![FormattedTextFragment::plain_text("def".to_string())]
}
}),
]
);
assert_eq!(
test_parse_html("<meta charset='utf-8'><ul><li>abc<ol><li>def</li></ol></li></ul>"),
vec![
FormattedTextLine::UnorderedList(FormattedIndentTextInline {
indent_level: 0,
text: vec![FormattedTextFragment::plain_text("abc".to_string())]
}),
FormattedTextLine::OrderedList(OrderedFormattedIndentTextInline {
number: None,
indented_text: FormattedIndentTextInline {
indent_level: 1,
text: vec![FormattedTextFragment::plain_text("def")]
}
}),
]
);
}
#[test]
fn test_formatted_sub_lists() {
assert_eq!(
test_parse_html(
"<meta charset='utf-8'><ul>\n<li>By default, the client goes to staging\n<ul>\n<li>To target localhost, build the client with</li>\n</ul>\n</li>\n</ul>\n"
),
vec![
FormattedTextLine::UnorderedList(FormattedIndentTextInline {
indent_level: 0,
text: vec![FormattedTextFragment::plain_text(
"By default, the client goes to staging"
)]
}),
FormattedTextLine::UnorderedList(FormattedIndentTextInline {
indent_level: 1,
text: vec![FormattedTextFragment::plain_text(
"To target localhost, build the client with"
)]
}),
]
);
}
#[test]
fn test_ordered_lists() {
let html = r#"
<meta charset='utf-8'>
<ol start="3">
<li>First</li>
<li>Second<ol>
<li>A</li>
<li>B</li>
</ol>
</li>
<li>Third</li>
<li>Fourth<ol start="invalid">
<li>G</li>
<li>H</li>
</ol>
</li>
</ol>
"#;
assert_eq!(
test_parse_html(html),
vec![
FormattedTextLine::OrderedList(OrderedFormattedIndentTextInline {
number: Some(3),
indented_text: FormattedIndentTextInline {
indent_level: 0,
text: vec![FormattedTextFragment::plain_text("First")]
}
}),
FormattedTextLine::OrderedList(OrderedFormattedIndentTextInline {
number: None,
indented_text: FormattedIndentTextInline {
indent_level: 0,
text: vec![FormattedTextFragment::plain_text("Second")]
}
}),
FormattedTextLine::OrderedList(OrderedFormattedIndentTextInline {
number: None,
indented_text: FormattedIndentTextInline {
indent_level: 1,
text: vec![FormattedTextFragment::plain_text("A")]
}
}),
FormattedTextLine::OrderedList(OrderedFormattedIndentTextInline {
number: None,
indented_text: FormattedIndentTextInline {
indent_level: 1,
text: vec![FormattedTextFragment::plain_text("B")]
}
}),
FormattedTextLine::OrderedList(OrderedFormattedIndentTextInline {
number: None,
indented_text: FormattedIndentTextInline {
indent_level: 0,
text: vec![FormattedTextFragment::plain_text("Third")]
}
}),
FormattedTextLine::OrderedList(OrderedFormattedIndentTextInline {
number: None,
indented_text: FormattedIndentTextInline {
indent_level: 0,
text: vec![FormattedTextFragment::plain_text("Fourth")]
}
}),
FormattedTextLine::OrderedList(OrderedFormattedIndentTextInline {
number: None,
indented_text: FormattedIndentTextInline {
indent_level: 1,
text: vec![FormattedTextFragment::plain_text("G")]
}
}),
FormattedTextLine::OrderedList(OrderedFormattedIndentTextInline {
number: None,
indented_text: FormattedIndentTextInline {
indent_level: 1,
text: vec![FormattedTextFragment::plain_text("H")]
}
}),
]
);
}
#[test]
fn test_horizontal_rules() {
assert_eq!(
test_parse_html("<meta charset='utf-8'><ul>\n<li>bcf</li>\n</ul>\n<hr>\n<p>abc</p>"),
vec![
FormattedTextLine::UnorderedList(FormattedIndentTextInline {
indent_level: 0,
text: vec![FormattedTextFragment::plain_text("bcf")]
}),
FormattedTextLine::HorizontalRule,
FormattedTextLine::Line(vec![FormattedTextFragment::plain_text("abc")])
]
);
}
#[test]
fn test_headings() {
assert_eq!(
test_parse_html(
"<h1>Heading 1</h1><h2>Heading 2</h2><h3>Heading 3</h3><h4>Heading 4</h4><h5>Heading 5</h5><h6>Heading 6</h6>"
),
vec![
FormattedTextLine::Heading(FormattedTextHeader {
heading_size: 1,
text: vec![FormattedTextFragment::plain_text("Heading 1")]
}),
FormattedTextLine::Heading(FormattedTextHeader {
heading_size: 2,
text: vec![FormattedTextFragment::plain_text("Heading 2")]
}),
FormattedTextLine::Heading(FormattedTextHeader {
heading_size: 3,
text: vec![FormattedTextFragment::plain_text("Heading 3")]
}),
FormattedTextLine::Heading(FormattedTextHeader {
heading_size: 4,
text: vec![FormattedTextFragment::plain_text("Heading 4")]
}),
FormattedTextLine::Heading(FormattedTextHeader {
heading_size: 5,
text: vec![FormattedTextFragment::plain_text("Heading 5")]
}),
FormattedTextLine::Heading(FormattedTextHeader {
heading_size: 6,
text: vec![FormattedTextFragment::plain_text("Heading 6")]
}),
]
);
}
#[test]
fn test_task_lists() {
// HTML copied from Github.
assert_eq!(
test_parse_html(
r#"<meta charset='utf-8'><ul data-sourcepos="3:1-4:26" class="contains-task-list" style="box-sizing: border-box; padding-left: 2em; margin-top: 0px; margin-bottom: 0px !important; position: relative; color: rgb(31, 35, 40); font-family: -apple-system, &quot;system-ui&quot;, &quot;Segoe UI&quot;, &quot;Noto Sans&quot;, Helvetica, Arial, sans-serif, &quot;Apple Color Emoji&quot;, &quot;Segoe UI Emoji&quot;; font-size: 16px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(255, 255, 255); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><li data-sourcepos="3:1-4:26" class="task-list-item" style="box-sizing: border-box; list-style-type: none;"><input type="checkbox" id="" disabled="" class="task-list-item-checkbox" style="box-sizing: border-box; font: inherit; margin: 0px 0.2em 0.25em -1.4em; overflow: visible; padding: 0px; vertical-align: middle;"><span> </span>Checklist item 1<ul data-sourcepos="4:5-4:26" class="contains-task-list" style="box-sizing: border-box; padding-left: 2em; margin-top: 0px; margin-bottom: 0px; position: relative;"><li data-sourcepos="4:5-4:26" class="task-list-item" style="box-sizing: border-box; list-style-type: none;"><input type="checkbox" id="" disabled="" class="task-list-item-checkbox" checked="" style="box-sizing: border-box; font: inherit; margin: 0px 0.2em 0.25em -1.4em; overflow: visible; padding: 0px; vertical-align: middle;"><span> </span>Checklist item 2</li></ul></li></ul>"#
),
vec![
FormattedTextLine::TaskList(FormattedTaskList {
complete: false,
indent_level: 0,
text: vec![
FormattedTextFragment::plain_text(" "),
FormattedTextFragment::plain_text("Checklist item 1")
]
}),
FormattedTextLine::TaskList(FormattedTaskList {
complete: true,
indent_level: 1,
text: vec![
FormattedTextFragment::plain_text(" "),
FormattedTextFragment::plain_text("Checklist item 2")
]
}),
]
);
}
#[test]
fn test_google_docs_formatted_sub_list() {
assert_eq!(
test_parse_html(
r#"<meta charset='utf-8'><meta charset="utf-8"><b style="font-weight:normal;" id="docs-internal-guid-0eb9725b-7fff-4ed4-502c-49a79adaf702"><ul style="margin-top:0;margin-bottom:0;padding-inline-start:48px;"><li dir="ltr" style="list-style-type:disc;font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;" aria-level="1"><p dir="ltr" style="line-height:1.2;margin-top:0pt;margin-bottom:0pt;" role="presentation"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">Favoriting drive objects</span></p></li><ul style="margin-top:0;margin-bottom:0;padding-inline-start:48px;"><li dir="ltr" style="list-style-type:circle;font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;" aria-level="2"><p dir="ltr" style="line-height:1.2;margin-top:0pt;margin-bottom:0pt;" role="presentation"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">Something else</span></p></li></ul><li dir="ltr" style="list-style-type:disc;font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;" aria-level="1"><p dir="ltr" style="line-height:1.2;margin-top:0pt;margin-bottom:0pt;" role="presentation"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">Drive keyboard nav</span></p></li></ul></b>"#,
),
vec![
FormattedTextLine::UnorderedList(FormattedIndentTextInline {
indent_level: 0,
text: vec![FormattedTextFragment::plain_text(
"Favoriting drive objects"
)]
}),
FormattedTextLine::UnorderedList(FormattedIndentTextInline {
indent_level: 1,
text: vec![FormattedTextFragment::plain_text("Something else")]
}),
FormattedTextLine::UnorderedList(FormattedIndentTextInline {
indent_level: 0,
text: vec![FormattedTextFragment::plain_text("Drive keyboard nav")]
}),
]
)
}
#[test]
fn test_code_and_inline_code() {
let github_code_block = r#"<meta charset='utf-8'><p data-sourcepos="59:1-59:27" dir="auto" style="box-sizing: border-box; margin-top: 0px; margin-bottom: 16px; color: rgb(31, 35, 40); font-family: -apple-system, &quot;system-ui&quot;, &quot;Segoe UI&quot;, &quot;Noto Sans&quot;, Helvetica, Arial, sans-serif, &quot;Apple Color Emoji&quot;, &quot;Segoe UI Emoji&quot;; font-size: 16px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(255, 255, 255); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;">Some<span> </span><code style="box-sizing: border-box; font-family: ui-monospace, SFMono-Regular, &quot;SF Mono&quot;, Menlo, Consolas, &quot;Liberation Mono&quot;, monospace; font-size: 13.6px; padding: 0.2em 0.4em; margin: 0px; white-space: break-spaces; background-color: var(--bgColor-neutral-muted, var(--color-neutral-muted)); border-radius: 6px;">inline code</code><span> </span>to parse</p><div class="snippet-clipboard-content notranslate position-relative overflow-auto" style="box-sizing: border-box; position: relative !important; overflow: auto !important; color: rgb(31, 35, 40); font-family: -apple-system, &quot;system-ui&quot;, &quot;Segoe UI&quot;, &quot;Noto Sans&quot;, Helvetica, Arial, sans-serif, &quot;Apple Color Emoji&quot;, &quot;Segoe UI Emoji&quot;; font-size: 16px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(255, 255, 255); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><pre class="notranslate" style="box-sizing: border-box; font-family: ui-monospace, SFMono-Regular, &quot;SF Mono&quot;, Menlo, Consolas, &quot;Liberation Mono&quot;, monospace; font-size: 13.6px; margin-top: 0px; margin-bottom: 16px; overflow-wrap: normal; padding: 16px; overflow: auto; line-height: 1.45; color: var(--fgColor-default, var(--color-fg-default)); background-color: var(--bgColor-muted, var(--color-canvas-subtle)); border-radius: 6px;"><code style="box-sizing: border-box; font-family: ui-monospace, SFMono-Regular, &quot;SF Mono&quot;, Menlo, Consolas, &quot;Liberation Mono&quot;, monospace; font-size: 13.6px; padding: 0px; margin: 0px; white-space: pre; background: transparent; border-radius: 6px; word-break: normal; border: 0px; display: inline; overflow: visible; line-height: inherit; overflow-wrap: normal;">Some code block</code></pre></div>"#;
assert_eq!(
test_parse_html(github_code_block),
vec![
FormattedTextLine::Line(vec![
FormattedTextFragment::plain_text("Some"),
FormattedTextFragment::plain_text(" "),
FormattedTextFragment::inline_code("inline code"),
FormattedTextFragment::plain_text(" "),
FormattedTextFragment::plain_text("to parse"),
]),
FormattedTextLine::CodeBlock(CodeBlockText {
lang: RUNNABLE_BLOCK_MARKDOWN_LANG.to_string(),
code: "Some code block".to_string()
}),
]
);
}
// Test for CLD-860
#[test]
fn test_confluence_code_block() {
let confluence_code_block = r#"<span data-code-lang="shell" data-ds--code--code-block="" class="prismjs css-1vd0zfg"><code class="language-shell"><span class="comment linenumber ds-line-number" data-ds--line-number="1" style="flex-shrink: 0; box-sizing: border-box; padding-left: 8px; margin-right: 8px; text-align: right; user-select: none; display: inline-block !important; min-width: calc(1ch + 16px) !important; font-style: normal !important; color: var(--ds-text-subtlest, #505F79) !important; padding-right: 8px !important; float: left;"></span><span class="">This is a code block</span></code></span>"#;
assert_eq!(
test_parse_html(confluence_code_block),
vec![FormattedTextLine::CodeBlock(CodeBlockText {
lang: "shell".to_string(),
code: "This is a code block".to_string()
}),]
);
}
+715
View File
@@ -0,0 +1,715 @@
use std::any::Any;
use std::ops::Range;
use std::sync::Arc;
use std::{collections::VecDeque, fmt, fmt::Debug};
pub mod html_parser;
pub mod markdown_parser;
pub mod weight;
pub use html_parser::parse_html;
use itertools::Itertools;
pub use markdown_parser::{
parse_image_prefix, parse_image_run_line, parse_inline_markdown, parse_markdown,
parse_markdown_with_gfm_tables,
};
use serde_yaml::Mapping;
use weight::CustomWeight;
/// Trait for an "action" that can be dispatched via a hyperlink click handler.
/// This purposefully shadows the `Action` trait from `warpui`.
///
/// Since `warpui` depends on this crate, we can't depend on the `warpui::Action` trait directly.
/// Instead, we create a new trait with a blanket implementation that implicitly results
/// in any `warpui::Action` implementing this `Action`.
pub trait Action: Any + Debug + Send + Sync {
fn as_any(&self) -> &dyn Any;
}
impl<T> Action for T
where
T: Any + Debug + Send + Sync,
{
fn as_any(&self) -> &dyn Any {
self
}
}
pub trait LineCount {
fn num_lines(&self) -> usize;
}
/// A simple line-based delta between two [`FormattedText`] values.
///
/// `common_prefix_lines` is the number of leading lines that are identical
/// between the old and new formatted text. `new_suffix`
/// contains the replacement lines from the new value to replace from after common_prefix_lines
/// to the end of the buffer.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FormattedTextDelta {
/// The number of actual lines in the common prefix (corresponding to a row in Point)
/// Note that a FormattedTextLine can have multiple lines - this refers to the actual line count,
/// not the FormattedTextLine count.
pub common_prefix_lines: usize,
/// The number of existing formatted text lines to be replaced
pub old_suffix_formatted_text_lines: usize,
pub new_suffix: VecDeque<FormattedTextLine>,
}
impl FormattedTextDelta {
pub fn is_noop(&self) -> bool {
self.old_suffix_formatted_text_lines == 0 && self.new_suffix.is_empty()
}
}
pub fn compute_formatted_text_delta(old: FormattedText, new: FormattedText) -> FormattedTextDelta {
let mut common_prefix_formatted_text_lines = 0usize;
let mut common_prefix_lines = 0usize;
let old_len = old.lines.len();
let new_len = new.lines.len();
let shared_len = old_len.min(new_len);
while common_prefix_formatted_text_lines < shared_len {
let old_line = &old.lines[common_prefix_formatted_text_lines];
let new_line = &new.lines[common_prefix_formatted_text_lines];
// Special handling for code blocks: only compare the code, not the language
// This is because the lang string in our internal buffer representation may not match
// the lang string in the parsed markdown exactly (e.g. "Python" vs "python path=/path/to/file.py start=1")
let lines_equal = match (old_line, new_line) {
(FormattedTextLine::CodeBlock(old_block), FormattedTextLine::CodeBlock(new_block)) => {
old_block.code == new_block.code
}
_ => old_line == new_line,
};
if !lines_equal {
break;
}
common_prefix_formatted_text_lines += 1;
common_prefix_lines += old_line.num_lines();
}
let old_suffix_formatted_text_lines =
old_len.saturating_sub(common_prefix_formatted_text_lines);
let new_suffix = new
.lines
.iter()
.skip(common_prefix_formatted_text_lines)
.cloned()
.collect();
FormattedTextDelta {
common_prefix_lines,
old_suffix_formatted_text_lines,
new_suffix,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FormattedText {
pub lines: VecDeque<FormattedTextLine>,
}
impl FormattedText {
pub fn new(lines: impl Into<VecDeque<FormattedTextLine>>) -> Self {
Self {
lines: lines.into(),
}
}
/// Creates a new FormattedText where the first and last line breaks are removed, if any.
pub fn new_trimmed(lines: impl Into<VecDeque<FormattedTextLine>>) -> Self {
let mut new = Self::new(lines);
new.trim();
new
}
fn trim(&mut self) {
// Since we exhaust contiguous new lines into a single line break,
// there won't be multiple contiguous line breaks; there's at most one to remove.
if let Some(FormattedTextLine::LineBreak) = self.lines.front() {
self.lines.pop_front();
}
// Similarly for the end.
if let Some(FormattedTextLine::LineBreak) = self.lines.back() {
self.lines.pop_back();
}
}
/// Returns the raw text of the markdown, without any of the markdown
/// markers.
pub fn raw_text(&self) -> String {
self.lines.iter().map(|line| line.raw_text()).join("")
}
pub fn append_line(mut self, line: FormattedTextLine) -> Self {
self.lines.push_back(line);
self
}
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum FormattedTextLine {
Heading(FormattedTextHeader),
Line(FormattedTextInline),
OrderedList(OrderedFormattedIndentTextInline),
UnorderedList(FormattedIndentTextInline),
CodeBlock(CodeBlockText),
TaskList(FormattedTaskList),
LineBreak,
HorizontalRule,
Embedded(Mapping),
Image(FormattedImage),
Table(FormattedTable),
}
impl FormattedTextLine {
pub fn raw_text(&self) -> String {
let mut text = match self {
Self::CodeBlock(text) => text.code.clone(),
Self::Heading(header) => header
.text
.iter()
.map(|fragment| fragment.raw_text())
.join(""),
Self::Line(line) => line.iter().map(|fragment| fragment.raw_text()).join(""),
Self::TaskList(line) => line
.text
.iter()
.map(|fragment| fragment.raw_text())
.join(""),
Self::OrderedList(list) => list
.indented_text
.text
.iter()
.map(|fragment| fragment.raw_text())
.join(""),
Self::UnorderedList(list) => list
.text
.iter()
.map(|fragment| fragment.raw_text())
.join(""),
Self::LineBreak | Self::HorizontalRule | Self::Embedded(_) => "\n".to_string(),
Self::Image(image) => format!("{}\n", image.alt_text),
Self::Table(table) => table.to_internal_format(),
};
// Each `FormattedTextLine` unit represents a complete line. If it doesn't already end in
// a newline, add one.
if !text.ends_with('\n') {
text.push('\n');
}
text
}
pub fn set_weight(&mut self, weight: Option<CustomWeight>) -> &Self {
match self {
Self::Heading(header) => {
for fragment in &mut header.text {
fragment.styles.weight = weight;
}
}
Self::Line(line) => {
for fragment in line {
fragment.styles.weight = weight;
}
}
Self::OrderedList(list) => {
for fragment in &mut list.indented_text.text {
fragment.styles.weight = weight;
}
}
Self::UnorderedList(list) => {
for fragment in &mut list.text {
fragment.styles.weight = weight;
}
}
Self::TaskList(list) => {
for fragment in &mut list.text {
fragment.styles.weight = weight;
}
}
Self::Table(_)
| Self::CodeBlock(_)
| Self::LineBreak
| Self::HorizontalRule
| Self::Embedded(_)
| Self::Image(_) => {}
}
self
}
fn inline_fragments(&self) -> Option<&FormattedTextInline> {
match &self {
FormattedTextLine::Heading(header) => Some(&header.text),
FormattedTextLine::Line(texts) => Some(texts),
FormattedTextLine::OrderedList(texts) => Some(&texts.indented_text.text),
FormattedTextLine::UnorderedList(texts) => Some(&texts.text),
FormattedTextLine::TaskList(list) => Some(&list.text),
FormattedTextLine::CodeBlock(_)
| FormattedTextLine::LineBreak
| FormattedTextLine::HorizontalRule
| FormattedTextLine::Embedded(_)
| FormattedTextLine::Image(_)
| FormattedTextLine::Table(_) => None,
}
}
pub fn hyperlinks(&self, skip_raw_links: bool) -> Vec<(Range<usize>, Hyperlink)> {
let mut hyperlinks: Vec<(Range<usize>, Hyperlink)> = Vec::new();
if let Some(inline_fragments) = self.inline_fragments() {
let mut char_count = 0;
for fragment in inline_fragments {
let range_start = char_count;
char_count += fragment.text.chars().count();
if let Some(link) = &fragment.styles.hyperlink
&& (!skip_raw_links
|| !matches!(&link, Hyperlink::Url(url) if url == &fragment.text))
{
hyperlinks.push((range_start..char_count, link.clone()));
}
}
}
hyperlinks
}
pub fn is_empty_line(&self) -> bool {
matches!(self, Self::Line(line) if line.iter().all(|fragment| fragment.text.is_empty()))
}
}
impl LineCount for FormattedTextLine {
fn num_lines(&self) -> usize {
match self {
Self::CodeBlock(text) => text.code.matches('\n').count(),
Self::Heading(_) => 1,
Self::Line(_) => 1,
Self::OrderedList(_) => 1,
Self::UnorderedList(_) => 1,
Self::TaskList(_) => 1,
Self::LineBreak => 0,
Self::HorizontalRule => 0,
Self::Embedded(_) => 1,
Self::Image(_) => 1,
Self::Table(table) => 1 + table.rows.len(), // Header + data rows (separator not counted as a line)
}
}
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct FormattedTextHeader {
pub heading_size: usize,
pub text: FormattedTextInline,
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct FormattedTaskList {
pub complete: bool,
pub indent_level: usize,
pub text: FormattedTextInline,
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct FormattedIndentTextInline {
pub indent_level: usize,
pub text: FormattedTextInline,
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct CodeBlockText {
pub lang: String,
pub code: String,
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct OrderedFormattedIndentTextInline {
/// The number of this item, which may be `None` if it was unspecified or invalid in the source
/// document.
pub number: Option<usize>,
pub indented_text: FormattedIndentTextInline,
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct FormattedImage {
pub alt_text: String,
pub source: String,
/// Optional CommonMark image title, e.g. the `title` in `![alt](src "title")`.
/// Empty titles are normalized to `None` by the parser.
pub title: Option<String>,
}
/// Column alignment for table cells
#[derive(Debug, Clone, Copy, Eq, PartialEq, Default, Hash)]
pub enum TableAlignment {
#[default]
Left,
Center,
Right,
}
/// A formatted table with headers, alignments, and rows
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct FormattedTable {
pub headers: Vec<FormattedTextInline>,
pub alignments: Vec<TableAlignment>,
pub rows: Vec<Vec<FormattedTextInline>>,
}
impl FormattedTable {
/// Parse from the internal tab-separated format used in `warp-markdown-table` code blocks.
pub fn from_internal_format(content: &str) -> Self {
let parse_line = |line: &str| -> Vec<FormattedTextInline> {
line.split('\t')
.map(|cell| vec![FormattedTextFragment::plain_text(cell)])
.collect()
};
let mut lines = content.lines().peekable();
let headers = lines.next().map(parse_line).unwrap_or_default();
let rows: Vec<Vec<FormattedTextInline>> = lines.map(parse_line).collect();
let col_count = headers.len();
Self {
headers,
alignments: vec![TableAlignment::default(); col_count],
rows,
}
}
pub fn from_internal_format_with_alignments(
content: &str,
mut alignments: Vec<TableAlignment>,
) -> Self {
let mut table = Self::from_internal_format(content);
let col_count = table.headers.len();
alignments.resize(col_count, TableAlignment::default());
alignments.truncate(col_count);
table.alignments = alignments;
table
}
/// Serialize to the internal tab-separated format used in `warp-markdown-table` code blocks.
/// Inline formatting is preserved as markdown syntax so it survives the buffer round-trip.
pub fn to_internal_format(&self) -> String {
if self.headers.is_empty() && self.rows.is_empty() {
return String::new();
}
let mut result = String::new();
let headers: Vec<String> = self.headers.iter().map(inline_to_markdown).collect();
result.push_str(&headers.join("\t"));
result.push('\n');
for row in &self.rows {
let cells: Vec<String> = row.iter().map(inline_to_markdown).collect();
result.push_str(&cells.join("\t"));
result.push('\n');
}
result
}
/// Pad ragged rows/headers to a uniform column count.
pub fn normalize_shape(&mut self) {
let mut column_count = self
.headers
.len()
.max(self.rows.iter().map(Vec::len).max().unwrap_or(0));
if column_count == 0 {
column_count = 1;
}
self.headers.resize_with(column_count, Vec::new);
self.alignments
.resize(column_count, TableAlignment::default());
for row in &mut self.rows {
row.resize_with(column_count, Vec::new);
}
}
/// Serialize to GFM pipe-table markdown.
pub fn to_plain_text(&self) -> String {
fn inline_to_text(inline: &FormattedTextInline) -> String {
inline.iter().map(|f| f.text.as_str()).collect()
}
let mut lines = Vec::new();
let headers: Vec<String> = self.headers.iter().map(inline_to_text).collect();
lines.push(format!("| {} |", headers.join(" | ")));
let separator: Vec<String> = self
.alignments
.iter()
.map(|alignment| match alignment {
TableAlignment::Left => "---".to_string(),
TableAlignment::Center => ":---:".to_string(),
TableAlignment::Right => "---:".to_string(),
})
.collect();
lines.push(format!("| {} |", separator.join(" | ")));
for row in &self.rows {
let cells: Vec<String> = row.iter().map(inline_to_text).collect();
lines.push(format!("| {} |", cells.join(" | ")));
}
lines.join("\n")
}
}
/// Convert a `FormattedTextInline` back to markdown syntax.
fn inline_to_markdown(inline: &FormattedTextInline) -> String {
let mut result = String::new();
for fragment in inline {
let mut text = fragment.text.clone();
if text.is_empty() {
continue;
}
if fragment.styles.inline_code {
result.push('`');
result.push_str(&text);
result.push('`');
continue;
}
if let Some(Hyperlink::Url(url)) = &fragment.styles.hyperlink {
text = format!("[{text}]({url})");
}
if fragment.styles.strikethrough {
text = format!("~~{text}~~");
}
if fragment.styles.underline {
text = format!("<u>{text}</u>");
}
let is_bold = fragment
.styles
.weight
.is_some_and(|w| matches!(w, CustomWeight::Bold));
if is_bold && fragment.styles.italic {
text = format!("***{text}***");
} else if is_bold {
text = format!("**{text}**");
} else if fragment.styles.italic {
text = format!("*{text}*");
}
result.push_str(&text);
}
result
}
pub type FormattedTableAlignment = TableAlignment;
pub type FormattedTextInline = Vec<FormattedTextFragment>;
/// A fragment of formatted text, containing the text itself and formatting flags/metadata.
#[derive(Debug, Clone, Eq, PartialEq, Default)]
pub struct FormattedTextFragment {
pub text: String,
pub styles: FormattedTextStyles,
}
#[derive(Debug, Clone)]
pub enum Hyperlink {
Url(String),
Action(Arc<dyn Action>),
}
impl Hyperlink {
/// Returns the URL if this is a URL, or `None` otherwise.
pub fn url(self) -> Option<String> {
match self {
Hyperlink::Url(url) => Some(url),
Hyperlink::Action(_) => None,
}
}
}
impl PartialEq for Hyperlink {
// Stub implementation for [`Hyperlink`] that only compares URLs and not Actions.
// This is an unfortunate byproduct of the fact that an [`Action`] does not implement [`PartialEq`]
// but we require [`PartialEq`] to consolidate [`FormattedTextStyles`].
// To get around this, we only compare URLs, which works for style consolidation since this is only
// needed when generating formatted text from markdown, which provably does not support URLs that dispatch
// actions
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::Url(left), Self::Url(right)) => left == right,
_ => false,
}
}
}
impl Eq for Hyperlink {}
/// Formatted text styling, with no attached content.
#[derive(Clone, Default, Eq, PartialEq)]
pub struct FormattedTextStyles {
pub weight: Option<CustomWeight>,
pub italic: bool,
pub underline: bool,
pub strikethrough: bool,
pub inline_code: bool,
pub hyperlink: Option<Hyperlink>,
}
impl FormattedTextFragment {
pub fn plain_text(text: impl Into<String>) -> Self {
Self {
text: text.into(),
styles: Default::default(),
}
}
pub fn weighted(text: impl Into<String>, weight: Option<CustomWeight>) -> Self {
Self {
text: text.into(),
styles: FormattedTextStyles {
weight,
..Default::default()
},
}
}
pub fn with_weight(&mut self, weight: Option<CustomWeight>) -> &Self {
self.styles.weight = weight;
self
}
pub fn bold(text: impl Into<String>) -> Self {
Self {
text: text.into(),
styles: FormattedTextStyles {
weight: Some(CustomWeight::Bold),
..Default::default()
},
}
}
pub fn italic(text: impl Into<String>) -> Self {
Self {
text: text.into(),
styles: FormattedTextStyles {
italic: true,
..Default::default()
},
}
}
pub fn bold_italic(text: impl Into<String>) -> Self {
Self {
text: text.into(),
styles: FormattedTextStyles {
weight: Some(CustomWeight::Bold),
italic: true,
..Default::default()
},
}
}
pub fn hyperlink(tag: impl Into<String>, url: impl Into<String>) -> Self {
Self {
text: tag.into(),
styles: FormattedTextStyles {
hyperlink: Some(Hyperlink::Url(url.into())),
..Default::default()
},
}
}
/// Constructs a new hyperlink that dispatches an action when clicked.
pub fn hyperlink_action<A: Action>(tag: impl Into<String>, action: A) -> Self {
Self {
text: tag.into(),
styles: FormattedTextStyles {
hyperlink: Some(Hyperlink::Action(Arc::new(action))),
..Default::default()
},
}
}
pub fn inline_code(text: impl Into<String>) -> Self {
Self {
text: text.into(),
styles: FormattedTextStyles {
inline_code: true,
..Default::default()
},
}
}
pub fn strikethrough(text: impl Into<String>) -> Self {
Self {
text: text.into(),
styles: FormattedTextStyles {
strikethrough: true,
..Default::default()
},
}
}
pub fn underline(text: impl Into<String>) -> Self {
Self {
text: text.into(),
styles: FormattedTextStyles {
underline: true,
..Default::default()
},
}
}
pub fn raw_text(&self) -> &String {
&self.text
}
}
impl fmt::Debug for FormattedTextStyles {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
// For readability, only show active styles.
let mut first = true;
if let Some(weight) = self.weight {
if !first {
f.write_str(" | ")?;
}
write!(f, "{weight:?}")?;
first = false;
}
if self.italic {
if !first {
f.write_str(" | ")?;
}
f.write_str("Italic")?;
first = false;
}
if self.strikethrough {
if !first {
f.write_str(" | ")?;
}
f.write_str("Strikethrough")?;
first = false;
}
if self.inline_code {
if !first {
f.write_str(" | ")?;
}
f.write_str("InlineCode")?;
first = false;
}
if let Some(link) = &self.hyperlink {
if !first {
f.write_str(" | ")?;
}
write!(f, "Hyperlink({link:?})")?;
first = false;
}
if first {
// No styles are active, so this is plain text.
f.write_str("PlainText")?;
}
Ok(())
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
use enum_iterator::Sequence;
/// All [`Weight`]s that are not [`Weight::Normal`] are considered custom weights.
/// Avoid importing `CustomWeight`, and prefer using [`Weight`] throughout the codebase,
/// except in cases where you want to specifically track explicit weight overrides.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Sequence)]
pub enum CustomWeight {
Thin,
ExtraLight,
Light,
Medium,
Semibold,
Bold,
ExtraBold,
Black,
}
impl CustomWeight {
/// Returns true if the weight is bold or heavier.
pub fn is_at_least_bold(&self) -> bool {
matches!(
self,
CustomWeight::Bold | CustomWeight::ExtraBold | CustomWeight::Black
)
}
/// We do not support nested weights at this time! The outer weight will
/// be the only respected weight.
pub fn merge_weights(
first: Option<CustomWeight>,
second: Option<CustomWeight>,
) -> Option<CustomWeight> {
// We don't currently support text containing text of varying weights.
// We will just respect the outer weight if you specify a non-Normal weight.
first.or(second)
}
}