diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
index 42d42436..d6c55893 100644
--- a/.github/workflows/check.yml
+++ b/.github/workflows/check.yml
@@ -23,6 +23,10 @@ name: Check
on:
pull_request:
+env:
+ # Always display a backtrace, to help track down errors.
+ RUST_BACKTRACE: 1
+
jobs:
check:
strategy:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c234087a..12fc2741 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,7 +22,15 @@ Changelog
[Github master](https://github.com/bjones1/CodeChat_Editor)
--------------------------------------------------------------------------------
-* No changes.
+* Update Graphviz to latest build; make Graphviz output a block element, not an
+ inline element.
+* Allow creating Mermaid and Graphviz diagrams using simpler code block syntax.
+* Support math free of Markdown escaping. This is a backward-incompatible
+ change: you must manually remove Markdown escaping from math written before
+ this release.
+* Fix failures when trying to edit a doc block which contains only diagrams.
+* Fix data corruption bug after sending a re-translation back to the Client.
+* Correct incorrect whitespace removal in Graphviz and Mermaid diagrams.
Version 0.1.43 -- 2025-Dec-05
--------------------------------------------------------------------------------
diff --git a/README.md b/README.md
index c824ea31..d9d35962 100644
--- a/README.md
+++ b/README.md
@@ -111,62 +111,92 @@ Mathematics
--------------------------------------------------------------------------------
The CodeChat Editor uses [MathJax](https://www.mathjax.org/) to support typeset
-mathematics. Place the delimiters `$` or `\\(` and `\\)` immediately before and
-after in-line mathematics; place `$$` or `\\\[` and `\\\]` immediately before
-and after displayed mathematics. For example,
+mathematics. Place the delimiters `$` immediately before and after in-line
+mathematics; place `$$` immediately before and after displayed mathematics. For
+example,
-| Source | Rendered |
-| --------------------------------------------- | ------------------------------------------- |
-| `$x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$` | $x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$ |
-| `\\(a^2\\)` | \\(a^2\\) |
-| `$$a^2$$` | $$a^2$$ |
-| `\\\[a^2\\\]` | \\\[a^2\\\] |
+| Source | Rendered |
+| ------------------------------------------ | ---------------------------------------- |
+| `$x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$` | $x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$ |
+| `$$a^2$$` | $$a^2$$ |
See [Latex Mathematics](https://en.wikibooks.org/wiki/LaTeX/Mathematics#Symbols)
for the syntax used to write mathematics expressions.
-### Escaping
-
-Markdown recognizes several characters common in mathematical expressions; these
-must be backslash escaped when used in math expressions to avoid problems. The
-following characters should be escaped: `*`, `_`, `\`, `[`, `]`, `<`.
-
-| Wrong source | Wrong rendered | Correct source | Correctly Rendered |
-| ---------------- | -------------- | ------------------ | ------------------ |
-| `${a}_1, b_{2}$` | ${a}*1, b*{2}$ | `${a}\_1, b\_{2}$` | ${a}\_1, b\_{2}$ |
-| `$a*1, b*2$` | $a*1, b*2$ | `$a\*1, b\*2$` | $a\*1, b\*2$ |
-| `$[a](b)$` | $[a](b)$ | `$\[a\](b)$` | $\[a\](b)$ |
-| `$3 b$` | $3 b$ | `$3 \ b$` | $3 \ b$ |
-| `$a \; b$` | $a \; b$ | `$a \\; b$` | $a \\; b$ |
-
Diagrams
--------------------------------------------------------------------------------
### Mermaid
-The CodeChat Editor contains rudimentary support for diagrams created by
+The CodeChat Editor supports diagrams created by
[Mermaid](https://mermaid.js.org/). For example,
-| Source | Rendered |
-| --------------------------------------------- | ------------------------------------------- |
-| `
| Source | +Rendered | +
|---|---|
| + +````markdown +```mermaid +graph TD; A --> B; +``` +```` + + | + +```mermaid +graph TD; A --> B; +``` + + | +
| Source | +Rendered | +
|---|---|
| + +````markdown +```graphviz +digraph { A -> B } +``` +```` + + | + +```graphviz +digraph { A -> B } +``` + + | +
` tag
+ if get_node_tag_name(child) == Some("pre")
+ // with no attributes
+ && let NodeData::Element {
+ attrs: ref_child_attrs, ..
+ } = &child.data
+ && ref_child_attrs.borrow().is_empty()
+ // with one `` child
+ && let code_children = child.children.borrow()
+ && code_children.len() == 1
+ && let code_child = code_children.iter().next().unwrap()
+ && get_node_tag_name(code_child) == Some("code")
+ // with only a `class=language-mermaid` attribute
+ && let NodeData::Element {
+ attrs: ref_code_child_attrs, ..
+ } = &code_child.data
+ && let code_child_attrs = ref_code_child_attrs.borrow()
+ && code_child_attrs.len() == 1
+ && let Some(attr) = code_child_attrs.iter().next()
+ && *attr.name.local == *"class"
+ && let Some(element_name) = CODE_BLOCK_LANGUAGE_TO_CUSTOM_ELEMENT.get(&*attr.value)
+ // with only one Text child
+ && let text_children = &code_child.children.borrow()
+ && text_children.len() == 1
+ && let Some(text_child) = text_children.iter().next()
+ && let NodeData::Text { .. } = &text_child.data
+ {
+ // Make the parent node a `element_name` node, with the child's text.
+ let wc_mermaid = Node::new(NodeData::Element {
+ name: QualName::new(None, Namespace::from(""), LocalName::from(*element_name)),
+ attrs: RefCell::new(vec![]),
+ template_contents: RefCell::new(None),
+ mathml_annotation_xml_integration_point: false,
+ });
+ wc_mermaid.children.borrow_mut().push(text_child.clone());
+ Some(wc_mermaid)
+ } else {
+ // See if this is a math node to replace; if not, this returns `None`.
+ replace_math_node(child, true)
+ };
+
+ // Replace the child if we found a replacement; otherwise, walk it.
+ if let Some(replacement_child) = possible_replacement_child {
+ *child = replacement_child;
+ } else {
+ hydrating_walk_node(child.clone());
+ }
+ }
+}
+
+fn replace_math_node(child: &Rc, is_hydrate: bool) -> Option> {
+ // Look for math produced by pulldown-cmark: ` Some(("\\(", "\\)")),
+ "math math-display" => Some(("$$", "$$")),
+ _ => None,
+ };
+
+ // Since we've already borrowed `child`, we can't `borrow_mut` to modify it. Instead, create a new `span` with delimited text and return that.
+ if let Some(delim) = delim {
+ let contents_str = &*contents.borrow();
+ let delimited_text_str = if is_hydrate {
+ format!("{}{}{}", delim.0, contents_str, delim.1)
+ } else {
+ // Only apply the dehydration is the delimiters are correct.
+ if !contents_str.starts_with(delim.0) || !contents_str.ends_with(delim.1) {
+ return None;
+ }
+ // Return the contents without the beginning and ending delimiters.
+ contents_str[delim.0.len()..contents_str.len() - delim.1.len()].to_string()
+ };
+ let delimited_text_node = Node::new(NodeData::Text {
+ contents: RefCell::new(delimited_text_str.into()),
+ });
+ let span = Node::new(NodeData::Element {
+ name: QualName::new(None, Namespace::from(""), LocalName::from("span")),
+ attrs: RefCell::new(vec![Attribute {
+ name: QualName::new(None, Namespace::from(""), LocalName::from("class")),
+ value: attr_value.clone(),
+ }]),
+ template_contents: RefCell::new(None),
+ mathml_annotation_xml_integration_point: false,
+ });
+ span.children.borrow_mut().push(delimited_text_node);
+ Some(span)
+ } else {
+ None
+ }
+ } else {
+ None
+ }
+}
+
+fn dehydrate_html(html: &str) -> io::Result {
+ transform_html(html, dehydrating_walk_node)
+}
+
+fn dehydrating_walk_node(node: Rc) {
+ for child in node.children.borrow_mut().iter_mut() {
+ // Look for a custom element tag
+ let possible_replacement_child = if let Some(child_name) = get_node_tag_name(child)
+ && let Some(language_name) = CUSTOM_ELEMENT_TO_CODE_BLOCK_LANGUAGE.get(child_name)
+ // with no attributes
+ && let NodeData::Element {
+ attrs: ref_attrs, ..
+ } = &child.data
+ && ref_attrs.borrow().is_empty()
+ // and only one Text child
+ && let text_children = &child.children.borrow()
+ && text_children.len() == 1
+ && let Some(text_child) = text_children.iter().next()
+ && let NodeData::Text { .. } = &text_child.data
+ {
+ // Create `text_child contents
`.
+ let pre = Node::new(NodeData::Element {
+ name: QualName::new(None, Namespace::from(""), LocalName::from("pre")),
+ attrs: RefCell::new(vec![]),
+ template_contents: RefCell::new(None),
+ mathml_annotation_xml_integration_point: false,
+ });
+ let code = Node::new(NodeData::Element {
+ name: QualName::new(None, Namespace::from(""), LocalName::from("code")),
+ attrs: RefCell::new(vec![Attribute {
+ name: QualName::new(None, Namespace::from(""), LocalName::from("class")),
+ value: (*language_name).into(),
+ }]),
+ template_contents: RefCell::new(None),
+ mathml_annotation_xml_integration_point: false,
+ });
+ code.children.borrow_mut().push(text_child.clone());
+ pre.children.borrow_mut().push(code);
+ Some(pre)
+ } else {
+ replace_math_node(child, false)
+ };
+
+ // Replace the child if we found a replacement; otherwise, walk it.
+ if let Some(replacement_child) = possible_replacement_child {
+ *child = replacement_child;
+ } else {
+ dehydrating_walk_node(child.clone());
+ }
+ }
+}
+
+fn get_node_tag_name(node: &Rc) -> Option<&str> {
+ match &node.data {
+ NodeData::Document => Some("html"),
+ NodeData::Element { name, .. } => Some(&name.local),
+ _ => None,
+ }
+}
+
+// Translate from Markdown class names for code blocks to the appropriate HTML custom element.
+static CODE_BLOCK_LANGUAGE_TO_CUSTOM_ELEMENT: phf::Map<&'static str, &'static str> = phf_map! {
+ "language-mermaid" => "wc-mermaid",
+ "language-graphviz" => "graphviz-graph",
+};
+
+static CUSTOM_ELEMENT_TO_CODE_BLOCK_LANGUAGE: phf::Map<&'static str, &'static str> = phf_map! {
+ "wc-mermaid" => "language-mermaid",
+ "graphviz-graph" => "language-graphviz"
+};
+
// ### Diff support
//
// This section provides methods to diff the previous and current
diff --git a/server/src/processing/tests.rs b/server/src/processing/tests.rs
index c5f9c0c2..653ebf29 100644
--- a/server/src/processing/tests.rs
+++ b/server/src/processing/tests.rs
@@ -24,6 +24,7 @@
use std::{path::PathBuf, str::FromStr};
// ### Third-party
+use indoc::indoc;
use predicates::prelude::predicate::str;
use pretty_assertions::assert_eq;
@@ -42,9 +43,10 @@ use crate::{
processing::{
CodeDocBlockVecToSourceError, CodeMirrorDiffable, CodeMirrorDocBlockDelete,
CodeMirrorDocBlockTransaction, CodeMirrorDocBlockUpdate, CodechatForWebToSourceError,
- SourceToCodeChatForWebError, byte_index_of, code_doc_block_vec_to_source,
- code_mirror_to_code_doc_blocks, codechat_for_web_to_source, diff_code_mirror_doc_blocks,
- diff_str, source_to_codechat_for_web,
+ HtmlToMarkdownWrapped, SourceToCodeChatForWebError, byte_index_of,
+ code_doc_block_vec_to_source, code_mirror_to_code_doc_blocks, codechat_for_web_to_source,
+ dehydrate_html, diff_code_mirror_doc_blocks, diff_str, hydrate_html, markdown_to_html,
+ source_to_codechat_for_web,
},
test_utils::stringit,
};
@@ -733,7 +735,7 @@ fn test_source_to_codechat_for_web_1() {
"\n\n",
vec![
build_codemirror_doc_block(0, 1, "", "//", "\n"),
- build_codemirror_doc_block(1, 2, " ", "//", "Test
\n"),
+ build_codemirror_doc_block(1, 2, " ", "//", "Test
\n "),
]
)))
);
@@ -752,8 +754,8 @@ fn test_source_to_codechat_for_web_1() {
"c_cpp",
"\n\n",
vec![
- build_codemirror_doc_block(0, 1, "", "//", "\n\n"),
- build_codemirror_doc_block(1, 2, " ", "//", "Test
\n"),
+ build_codemirror_doc_block(0, 1, "", "//", "\n"),
+ build_codemirror_doc_block(1, 2, " ", "//", "Test
\n
"),
]
)))
);
@@ -1234,3 +1236,148 @@ fn test_diff_2() {
]
);
}
+
+#[test]
+fn test_hydrate_html_1() {
+ // These tests check the translation from Markdown to "wet" HTML (what the user provides) instead of dry -> wet HTML.
+ assert_eq!(
+ hydrate_html(&markdown_to_html(indoc!(
+ "```mermaid
+ flowchart LR
+ start --> stop
+ ```
+ "
+ )))
+ .unwrap(),
+ indoc!(
+ "
+ flowchart LR
+ start --> stop
+
+ "
+ )
+ );
+
+ assert_eq!(
+ hydrate_html(&markdown_to_html(indoc!(
+ "```graphviz
+ digraph {
+ start -> stop
+ }
+ ```
+ "
+ )))
+ .unwrap(),
+ indoc!(
+ "
+ digraph {
+ start -> stop
+ }
+
+ "
+ )
+ );
+
+ // Ensure math doesn't need escaping.
+ assert_eq!(
+ hydrate_html(&markdown_to_html(indoc!(
+ "
+ ${a}_1, b_{2}$
+ $a*1, b*2$
+ $[a](b)$
+ $3 b$
+ $a \\; b$
+
+ $${a}_1, b_{2}, a*1, b*2, [a](b), 3 b, a \\; b$$
+ "
+ )))
+ .unwrap(),
+ indoc!(
+ r#"
+ \({a}_1, b_{2}\)
+ \(a*1, b*2\)
+ \([a](b)\)
+ \(3 <a> b\)
+ \(a \; b\)
+ $${a}_1, b_{2}, a*1, b*2, [a](b), 3 <a> b, a \; b$$
+ "#
+ )
+ );
+}
+
+#[test]
+fn test_dehydrate_html_1() {
+ let converter = HtmlToMarkdownWrapped::new();
+ assert_eq!(
+ converter
+ .convert(
+ &dehydrate_html(indoc!(
+ "
+ flowchart LR
+ start --> stop
+
+ "
+ ))
+ .unwrap()
+ )
+ .unwrap(),
+ indoc!(
+ "
+ ```mermaid
+ flowchart LR
+ start --> stop
+ ```
+ "
+ )
+ );
+
+ assert_eq!(
+ converter
+ .convert(
+ &dehydrate_html(indoc!(
+ "
+ digraph {
+ start -> stop
+ }
+
+ "
+ ))
+ .unwrap()
+ )
+ .unwrap(),
+ indoc!(
+ "
+ ```graphviz
+ digraph {
+ start -> stop
+ }
+ ```
+ "
+ )
+ );
+
+ assert_eq!(
+ converter
+ .convert(
+ &dehydrate_html(indoc!(
+ r#"
+ \({a}_1, b_{2}\)
+ \(a*1, b*2\)
+ \([a](b)\)
+ \(3 <a> b\)
+ \(a \; b\)
+ $${a}_1, b_{2}, a*1, b*2, [a](b), 3 <a> b, a \; b$$
+ "#
+ ))
+ .unwrap()
+ )
+ .unwrap(),
+ indoc!(
+ "
+ ${a}_1, b_{2}$ $a*1, b*2$ $[a](b)$ $3 b$ $a \\; b$
+
+ $${a}_1, b_{2}, a*1, b*2, [a](b), 3 b, a \\; b$$
+ "
+ )
+ );
+}
diff --git a/server/src/translation.rs b/server/src/translation.rs
index 769e21cc..3d8e9c35 100644
--- a/server/src/translation.rs
+++ b/server/src/translation.rs
@@ -211,17 +211,20 @@ use lazy_static::lazy_static;
use log::{debug, error, warn};
use rand::random;
use regex::Regex;
-use tokio::sync::mpsc::{Receiver, Sender};
-use tokio::{fs::File, select, sync::mpsc};
+use tokio::{
+ fs::File,
+ select,
+ sync::mpsc::{self, Receiver, Sender},
+};
-use crate::lexer::supported_languages::MARKDOWN_MODE;
-use crate::processing::CodeMirrorDocBlockVec;
// ### Local
use crate::{
+ lexer::supported_languages::MARKDOWN_MODE,
processing::{
CodeChatForWeb, CodeMirror, CodeMirrorDiff, CodeMirrorDiffable, CodeMirrorDocBlock,
- SourceFileMetadata, TranslationResultsString, codechat_for_web_to_source,
- diff_code_mirror_doc_blocks, diff_str, source_to_codechat_for_web_string,
+ CodeMirrorDocBlockVec, SourceFileMetadata, TranslationResultsString,
+ codechat_for_web_to_source, diff_code_mirror_doc_blocks, diff_str,
+ source_to_codechat_for_web_string,
},
queue_send, queue_send_func,
webserver::{
@@ -1040,7 +1043,7 @@ impl TranslationTask {
false,
)
&& let TranslationResultsString::CodeChat(ccfw) = ccfws.0
- && let CodeMirrorDiffable::Plain(ref code_mirror_translated) =
+ && let CodeMirrorDiffable::Plain(code_mirror_translated) =
ccfw.source
&& self.sent_full
{
@@ -1078,8 +1081,9 @@ impl TranslationTask {
cfw.metadata.clone(),
cfw.version,
cfw_version,
- code_mirror_translated,
+ &code_mirror_translated,
);
+ debug!("Sending re-translation update back to the Client.");
queue_send_func!(self.to_client_tx.send(EditorMessage {
id: self.id,
message: EditorMessageContents::Update(
@@ -1095,6 +1099,10 @@ impl TranslationTask {
)
}));
self.id += MESSAGE_ID_INCREMENT;
+ // Update with what was just sent to the client.
+ self.code_mirror_doc = code_mirror_translated.doc;
+ self.code_mirror_doc_blocks =
+ Some(code_mirror_translated.doc_blocks);
}
};
// Correct EOL endings for use with the IDE.
diff --git a/server/src/webserver.rs b/server/src/webserver.rs
index 1e1473c7..17b870c9 100644
--- a/server/src/webserver.rs
+++ b/server/src/webserver.rs
@@ -466,9 +466,6 @@ const MATHJAX_TAGS: &str = concatdoc!(
obj.data.contentEditable = false;
}],
},
- tex: {
- inlineMath: [['$', '$'], ['\\(', '\\)']]
- },
};
"#,
// Per the
diff --git a/server/tests/fixtures/overall/overall_core/test_5/test.py b/server/tests/fixtures/overall/overall_core/test_5/test.py
new file mode 100644
index 00000000..23103ab8
--- /dev/null
+++ b/server/tests/fixtures/overall/overall_core/test_5/test.py
@@ -0,0 +1,3 @@
+# The contents of this file don't matter -- tests will supply the content,
+# instead of loading it from disk. However, it does need to exist for
+# `canonicalize` to find the correct path to this file.
diff --git a/server/tests/fixtures/overall/overall_core/test_client/test.py b/server/tests/fixtures/overall/overall_core/test_client/test.py
index 67d091ac..37645baf 100644
--- a/server/tests/fixtures/overall/overall_core/test_client/test.py
+++ b/server/tests/fixtures/overall/overall_core/test_client/test.py
@@ -2,12 +2,21 @@
code()
# Graphviz:
#
-# digraph { A -> B }
+# ```graphviz
+# digraph {
+# A -> B
+# }
+# ```
#
# Mermaid:
#
-# graph TD; A --> B;
+# ```mermaid
+# graph TD
+# A --> B
+# ```
#
# MathJax:
#
# $x^2$
+#
+# $$x^3$$
\ No newline at end of file
diff --git a/server/tests/overall_core/mod.rs b/server/tests/overall_core/mod.rs
index d644e8a2..d3b85a5d 100644
--- a/server/tests/overall_core/mod.rs
+++ b/server/tests/overall_core/mod.rs
@@ -53,7 +53,12 @@
//
// ### Standard library
use std::{
- collections::HashMap, env, error::Error, panic::AssertUnwindSafe, path::PathBuf, time::Duration,
+ collections::HashMap,
+ env,
+ error::Error,
+ panic::AssertUnwindSafe,
+ path::{Path, PathBuf},
+ time::Duration,
};
// ### Third-party
@@ -63,8 +68,8 @@ use futures::FutureExt;
use indoc::indoc;
use pretty_assertions::assert_eq;
use thirtyfour::{
- By, ChromiumLikeCapabilities, DesiredCapabilities, Key, WebDriver, error::WebDriverError,
- start_webdriver_process,
+ By, ChromiumLikeCapabilities, DesiredCapabilities, Key, WebDriver, WebElement,
+ error::WebDriverError, start_webdriver_process,
};
use tokio::time::sleep;
@@ -147,7 +152,7 @@ macro_rules! harness {
($func: ident) => {
pub async fn harness<
'a,
- F: FnOnce(CodeChatEditorServer, &'a WebDriver, PathBuf) -> Fut,
+ F: FnOnce(CodeChatEditorServer, &'a WebDriver, &'a Path) -> Fut,
Fut: Future