diff --git a/AGENTS.md b/AGENTS.md
index a1beca1..c8684dd 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -13,7 +13,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- Web views: `wry` 0.57 (Apache-2.0 OR MIT, default features off, macOS only) lays a child WKWebView over the window.
- Terminal: `alacritty_terminal` 0.26 (Apache-2.0, default features off, native only) for the grid, its parser and the pseudo-terminal; `futures` carries its events. `vte` 0.15 (Apache-2.0 OR MIT), the parser it re-exports, reads `AnsiText`'s codes alone, so colored output builds for wasm32.
- Diffs: `similar` 3.2 (Apache-2.0) for line and word diffs and three-way merges; its `unicode` feature splits words at punctuation.
-- Markdown: `pulldown-cmark` 0.13 (MIT, default features off) for CommonMark with tables, tasks, strikethrough, footnotes and math.
+- Markdown: `pulldown-cmark` 0.13 (MIT, default features off) for CommonMark with tables, tasks, strikethrough, footnotes and math; `BlockData::from_markdown` adds GitHub's alerts, a callout's form, and reads each field's inline Markdown from the source by offset, as written.
- Pictures: `image` 0.25 (MIT OR Apache-2.0), the crate gpui decodes with, default features off; it turns decoded frames and rims map tiles.
- Maps: Natural Earth's 110m countries (public domain). `assets/maps/countries.geojson`, trimmed by `scripts/countries.py` to an ISO code, a name and positions to 0.01°, backs `maps::WorldMap`; the gallery's tiles are cut from the same file, z0 to z3, by `scripts/tiles.py` with Pillow.
- Web: on wasm32, `web-time` 1.1 (MIT OR Apache-2.0) gives the `Instant` gpui's executor returns, std's own natively; jiff takes `js`, rust-embed `debug-embed`, similar `wasm32_web_time`. The gallery's web entry takes `gpui_web`, `wasm-bindgen`, `js-sys` and `web-sys` as wasm-only dev-dependencies, and a Hebrew cut of Noto Sans Hebrew 2.003 (OFL, `examples/gallery/fonts`): gpui_web asks the browser for emoji and CJK alone. The `wasm-bindgen` CLI at Cargo.lock's version and binaryen's `wasm-opt` finish the bundle.
diff --git a/src/documents/blocks/export.rs b/src/documents/blocks/export.rs
new file mode 100644
index 0000000..26691b9
--- /dev/null
+++ b/src/documents/blocks/export.rs
@@ -0,0 +1,199 @@
+use std::ops::Range;
+
+use pulldown_cmark::{Event, Parser};
+
+use super::{BlockData, BlockKind, Media};
+
+impl BlockData {
+ /// The document as Markdown, GitHub's flavor: a callout is a `[!NOTE]` quote, a toggle a ``, a diagram a `mermaid` fence, math a `math` fence. Fields already hold Markdown and pass through as written, so a blank line in prose starts a new block; a heading, caption, toggle title or table cell keeps one line, and a caption's lone bracket comes back escaped. HTML in a field is read as HTML. What Markdown has no form for takes its nearest: a table's merges, media's width and alignment, columns as paragraphs, a synced block as its text, video and embeds as links.
+ pub fn to_markdown(blocks: &[BlockData]) -> String {
+ let mut out = String::new();
+ let mut previous: Option<&BlockKind> = None;
+ let mut number = 0;
+ for block in blocks {
+ number = match block.kind {
+ BlockKind::Numbered => number + 1,
+ _ => 0,
+ };
+ let Some(text) = write(block, number) else {
+ continue;
+ };
+ if let Some(previous) = previous {
+ out.push_str(if same_list(previous, &block.kind) {
+ "\n"
+ } else {
+ "\n\n"
+ });
+ }
+ out.push_str(&text);
+ previous = Some(&block.kind);
+ }
+ out.push('\n');
+ out
+ }
+}
+
+/// Whether two kinds follow on in one Markdown list.
+fn same_list(previous: &BlockKind, next: &BlockKind) -> bool {
+ let bulleted = |kind: &BlockKind| matches!(kind, BlockKind::Bullet | BlockKind::Todo(_));
+ (bulleted(previous) && bulleted(next))
+ || (*previous == BlockKind::Numbered && *next == BlockKind::Numbered)
+}
+
+/// One block as Markdown; nothing for an empty paragraph, which Markdown cannot hold.
+fn write(block: &BlockData, number: usize) -> Option {
+ let texts = &block.texts;
+ let text = || texts[0].as_str();
+ let written = match &block.kind {
+ BlockKind::Paragraph | BlockKind::Synced(_) if text().trim().is_empty() => return None,
+ BlockKind::Paragraph | BlockKind::Synced(_) => text().to_string(),
+ BlockKind::Heading(level) => {
+ let level = usize::from((*level).clamp(1, 6));
+ format!("{} {}", "#".repeat(level), text().replace('\n', " "))
+ }
+ BlockKind::Bullet => item("- ", text()),
+ BlockKind::Numbered => item(&format!("{number}. "), text()),
+ BlockKind::Todo(done) => item(if *done { "- [x] " } else { "- [ ] " }, text()),
+ BlockKind::Quote => quoted(text()),
+ BlockKind::Callout => format!("> [!NOTE]\n{}", quoted(text())),
+ BlockKind::Toggle(open) => {
+ let open = if *open { " open" } else { "" };
+ let title = texts[0].replace('\n', " ");
+ let head = format!("\n{title}
");
+ match texts[1].trim() {
+ "" => format!("{head}\n\n "),
+ body => format!("{head}\n\n{body}\n\n "),
+ }
+ }
+ BlockKind::Code => fenced(text(), ""),
+ BlockKind::Diagram => fenced(text(), "mermaid"),
+ BlockKind::Math => fenced(text(), "math"),
+ BlockKind::Divider => "---".to_string(),
+ BlockKind::Table { columns, .. } => table(texts, *columns),
+ BlockKind::Image(media) => {
+ let caption = brackets(&text().replace('\n', " "));
+ format!("", address(media))
+ }
+ BlockKind::Video(media) | BlockKind::Embed(media) => {
+ let words = if text().is_empty() {
+ &media.source
+ } else {
+ text()
+ };
+ format!("[{words}]({})", address(media))
+ }
+ BlockKind::Columns(_) => {
+ let filled: Vec<&str> = texts
+ .iter()
+ .map(|text| text.trim())
+ .filter(|text| !text.is_empty())
+ .collect();
+ if filled.is_empty() {
+ return None;
+ }
+ filled.join("\n\n")
+ }
+ };
+ Some(written)
+}
+
+/// A list item: the marker, then each further line under its text.
+fn item(marker: &str, text: &str) -> String {
+ let indent = " ".repeat(marker.len());
+ let mut lines = text.lines();
+ let mut out = format!("{marker}{}", lines.next().unwrap_or_default());
+ for line in lines {
+ out.push('\n');
+ out.push_str(&indent);
+ out.push_str(line);
+ }
+ out
+}
+
+fn quoted(text: &str) -> String {
+ let lines: Vec = text
+ .split('\n')
+ .map(|line| match line {
+ "" => ">".to_string(),
+ line => format!("> {line}"),
+ })
+ .collect();
+ lines.join("\n")
+}
+
+/// Code between fences longer than any run of backticks inside it.
+fn fenced(text: &str, language: &str) -> String {
+ let longest = text
+ .split(|ch| ch != '`')
+ .map(str::len)
+ .max()
+ .unwrap_or_default();
+ let fence = "`".repeat(longest.max(2) + 1);
+ format!("{fence}{language}\n{text}\n{fence}")
+}
+
+fn table(texts: &[String], columns: usize) -> String {
+ let row = |cells: &[String]| {
+ let cells: Vec = cells
+ .iter()
+ .map(|cell| pipes(cell).replace('\n', " "))
+ .collect();
+ format!("| {} |", cells.join(" | "))
+ };
+ let mut rows = texts.chunks(columns);
+ let head = rows.next().expect("a table holds its header row");
+ let mut out = vec![row(head), format!("|{}", " --- |".repeat(columns))];
+ out.extend(rows.map(row));
+ out.join("\n")
+}
+
+/// A link's address, in angle brackets when it holds a space or a bracket.
+fn address(media: &Media) -> String {
+ match media.source.contains([' ', '(', ')', '<', '>']) {
+ true => format!("<{}>", media.source.replace('<', "\\<").replace('>', "\\>")),
+ false => media.source.to_string(),
+ }
+}
+
+/// A caption that cannot end its image early: a bracket left open or shut alone outside code, or a last backslash, is escaped.
+fn brackets(text: &str) -> String {
+ let code: Vec> = Parser::new(text)
+ .into_offset_iter()
+ .filter_map(|(event, range)| matches!(event, Event::Code(_)).then_some(range))
+ .collect();
+ let (mut open, mut lone, mut slashes) = (Vec::new(), Vec::new(), 0);
+ for (at, ch) in text.char_indices() {
+ let free = slashes % 2 == 0 && !code.iter().any(|span| span.contains(&at));
+ match ch {
+ '[' if free => open.push(at),
+ ']' if free && open.pop().is_none() => lone.push(at),
+ _ => {}
+ }
+ slashes = if ch == '\\' { slashes + 1 } else { 0 };
+ }
+ lone.extend(open);
+ let mut out = String::with_capacity(text.len() + lone.len() + 1);
+ for (at, ch) in text.char_indices() {
+ if lone.contains(&at) {
+ out.push('\\');
+ }
+ out.push(ch);
+ }
+ if slashes % 2 == 1 {
+ out.push('\\');
+ }
+ out
+}
+
+/// A cell's pipes escaped where they are not already, so none splits its row.
+fn pipes(cell: &str) -> String {
+ let (mut out, mut slashes) = (String::with_capacity(cell.len()), 0);
+ for ch in cell.chars() {
+ if ch == '|' && slashes % 2 == 0 {
+ out.push('\\');
+ }
+ slashes = if ch == '\\' { slashes + 1 } else { 0 };
+ out.push(ch);
+ }
+ out
+}
diff --git a/src/documents/blocks/import/details.rs b/src/documents/blocks/import/details.rs
new file mode 100644
index 0000000..6830c23
--- /dev/null
+++ b/src/documents/blocks/import/details.rs
@@ -0,0 +1,186 @@
+use std::ops::Range;
+
+use pulldown_cmark::{Event, Tag};
+
+use super::{Reader, Toggle};
+use crate::documents::blocks::{BlockData, BlockKind};
+
+/// An HTML tag: where it starts and ends, its name, whether it closes, and its attributes' names.
+struct Mark {
+ at: usize,
+ end: usize,
+ closes: bool,
+ name: String,
+ attributes: Vec,
+}
+
+/// The tags in `html`, past comments; a quoted value is read as a value.
+fn marks(html: &str) -> Vec {
+ let bytes = html.as_bytes();
+ let (mut marks, mut at) = (Vec::new(), 0);
+ while let Some(found) = html[at..].find('<').map(|found| at + found) {
+ if html[found..].starts_with("")
+ .map_or(html.len(), |end| found + end + 3);
+ continue;
+ }
+ let closes = bytes.get(found + 1) == Some(&b'/');
+ let name_at = found + 1 + usize::from(closes);
+ let name_end = html[name_at..]
+ .find(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '-'))
+ .map_or(html.len(), |end| name_at + end);
+ if !bytes.get(name_at).is_some_and(u8::is_ascii_alphabetic) {
+ at = found + 1;
+ continue;
+ }
+ let (end, attributes) = attributes(html, name_end);
+ marks.push(Mark {
+ at: found,
+ end,
+ closes,
+ name: html[name_at..name_end].to_ascii_lowercase(),
+ attributes,
+ });
+ at = end;
+ }
+ marks
+}
+
+/// Reads a tag's attributes from `from` to its `>`: where the tag ends, and the names.
+fn attributes(html: &str, from: usize) -> (usize, Vec) {
+ let bytes = html.as_bytes();
+ let (mut names, mut word, mut value, mut quote) = (Vec::new(), None, false, None);
+ for (at, byte) in bytes.iter().copied().enumerate().skip(from) {
+ if let Some(open) = quote {
+ if byte == open {
+ (quote, value) = (None, false);
+ }
+ continue;
+ }
+ let ends =
+ byte.is_ascii_whitespace() || byte == b'>' || (!value && matches!(byte, b'=' | b'/'));
+ if !ends {
+ match byte {
+ b'"' | b'\'' => quote = Some(byte),
+ _ => word = word.or(Some(at)),
+ }
+ continue;
+ }
+ if let Some(start) = word.take() {
+ if !value {
+ names.push(html[start..at].to_ascii_lowercase());
+ }
+ value = false;
+ }
+ match byte {
+ b'=' => value = true,
+ b'>' => return (at + 1, names),
+ _ => {}
+ }
+ }
+ if let Some(start) = word.filter(|_| !value) {
+ names.push(html[start..].to_ascii_lowercase());
+ }
+ (html.len(), names)
+}
+
+fn named<'a>(marks: &'a [Mark], name: &str, closes: bool, from: usize) -> Option<&'a Mark> {
+ marks
+ .iter()
+ .find(|mark| mark.name == name && mark.closes == closes && mark.at >= from)
+}
+
+impl Reader<'_> {
+ pub(super) fn html_block(&mut self, range: Range) {
+ let source = self.source;
+ let block = &source[range.clone()];
+ let marks = marks(block);
+ let first = marks
+ .first()
+ .filter(|mark| block[..mark.at].trim().is_empty() && mark.name == "details");
+ let Some(first) = first.filter(|mark| !mark.closes) else {
+ self.html = true;
+ return self.put(BlockKind::Paragraph, block.trim().to_string());
+ };
+ let summary = named(&marks, "summary", false, first.end);
+ let shut = summary.and_then(|open| named(&marks, "summary", true, open.end));
+ let (title, head) = match (summary, shut) {
+ (Some(open), Some(shut)) => (block[open.end..shut.at].trim(), shut.end),
+ _ => ("", first.end),
+ };
+ let head = range.start + head;
+ self.toggle = Some(Toggle {
+ open: first.attributes.iter().any(|name| name == "open"),
+ title: title.to_string(),
+ body: head,
+ depth: 0,
+ });
+ self.scan_toggle(head, range.end);
+ }
+
+ /// Skips a toggle's body, Markdown kept as written, to the `` that closes it.
+ pub(super) fn in_toggle(&mut self, event: Event, range: Range) {
+ if let Event::Start(Tag::HtmlBlock) = event {
+ self.scan_toggle(range.start, range.end);
+ }
+ }
+
+ /// Follows `` tags in `from..to`; the close that matches the toggle's open ends it.
+ fn scan_toggle(&mut self, from: usize, to: usize) {
+ let source = self.source;
+ let Some(mut depth) = self.toggle.as_ref().map(|toggle| toggle.depth) else {
+ return;
+ };
+ let mut closed = None;
+ let marks = marks(&source[from..to]);
+ for mark in marks.into_iter().filter(|mark| mark.name == "details") {
+ match (mark.closes, depth) {
+ (false, _) => depth += 1,
+ (true, 0) => {
+ closed = Some(mark);
+ break;
+ }
+ (true, _) => depth -= 1,
+ }
+ }
+ let Some(close) = closed else {
+ if let Some(toggle) = &mut self.toggle {
+ toggle.depth = depth;
+ }
+ return;
+ };
+ self.end_toggle(from + close.at);
+ self.html = true;
+ let rest = source[from + close.end..to].trim();
+ if !rest.is_empty() {
+ self.put(BlockKind::Paragraph, rest.to_string());
+ }
+ }
+
+ fn end_toggle(&mut self, end: usize) {
+ let Some(toggle) = self.toggle.take() else {
+ return;
+ };
+ let body = self.source[toggle.body..end].trim().to_string();
+ self.bodies.push(toggle.body..end);
+ if let Some((_, _, parts)) = &mut self.quote {
+ parts.push(body);
+ return;
+ }
+ self.emit_item();
+ let kind = BlockKind::Toggle(toggle.open);
+ self.out.push(BlockData::new(kind, [toggle.title, body]));
+ }
+
+ /// A toggle the source never closes keeps the rest as its body.
+ pub(super) fn unclosed(&mut self) {
+ if let Some(toggle) = &self.toggle {
+ log::error!(
+ "block editor: the at byte {} never closes; its body runs to the end",
+ toggle.body
+ );
+ self.end_toggle(self.source.len());
+ }
+ }
+}
diff --git a/src/documents/blocks/import/mod.rs b/src/documents/blocks/import/mod.rs
new file mode 100644
index 0000000..296b894
--- /dev/null
+++ b/src/documents/blocks/import/mod.rs
@@ -0,0 +1,378 @@
+use std::ops::Range;
+
+use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
+
+use super::{BlockData, BlockKind, Media};
+
+mod details;
+
+impl BlockData {
+ /// Blocks from Markdown, as `to_markdown` writes it: inline Markdown stays as written in each field. A nested list comes out flat, and a quote or callout holds its paragraphs as text.
+ pub fn from_markdown(source: &str) -> Vec {
+ let options = Options::ENABLE_TABLES
+ | Options::ENABLE_STRIKETHROUGH
+ | Options::ENABLE_TASKLISTS
+ | Options::ENABLE_MATH
+ | Options::ENABLE_GFM;
+ let mut reader = Reader {
+ source,
+ ..Reader::default()
+ };
+ let mut events = Parser::new_ext(source, options).into_offset_iter();
+ for (event, range) in events.by_ref() {
+ reader.read(event, range);
+ }
+ reader.unclosed();
+ let mut spans: Vec> = events
+ .reference_definitions()
+ .iter()
+ .map(|(_, definition)| definition.span.clone())
+ .filter(|span| !reader.bodies.iter().any(|body| body.contains(&span.start)))
+ .collect();
+ spans.sort_by_key(|span| span.start);
+ let definitions: Vec<&str> = spans.into_iter().map(|span| source[span].trim()).collect();
+ if !definitions.is_empty() {
+ reader.put(
+ BlockKind::Paragraph,
+ definitions.join(
+ "
+",
+ ),
+ );
+ }
+ log::debug!(
+ "block editor: {} blocks read from markdown",
+ reader.out.len()
+ );
+ reader.out
+ }
+}
+
+/// What a paragraph holds alone, which makes it a block of its own.
+#[derive(Default)]
+enum Sole {
+ #[default]
+ Nothing,
+ Math(String),
+ Image(String, Option>),
+ Mixed,
+}
+
+/// Inline text being read: its stretch of the source, and how deep in inline tags.
+#[derive(Default)]
+struct Leaf {
+ range: Option>,
+ depth: usize,
+ sole: Sole,
+}
+
+struct Item {
+ kind: BlockKind,
+ parts: Vec,
+ emitted: bool,
+}
+
+struct Toggle {
+ open: bool,
+ title: String,
+ body: usize,
+ depth: usize,
+}
+
+#[derive(Default)]
+struct Reader<'a> {
+ source: &'a str,
+ out: Vec,
+ leaf: Option,
+ /// The outermost quote: callout or not, depth, and its paragraphs.
+ quote: Option<(bool, usize, Vec)>,
+ lists: Vec,
+ items: Vec- ,
+ code: Option<(BlockKind, String)>,
+ /// Columns, cells so far, and the row being read.
+ table: Option<(usize, Vec, Vec)>,
+ toggle: Option,
+ /// Toggle bodies, kept as written.
+ bodies: Vec>,
+ html: bool,
+}
+
+/// Inline lines as one field: each further line loses its indent and quote marks.
+fn clean(slice: &str, quotes: usize) -> String {
+ let mut lines = slice.split('\n');
+ let mut out = lines.next().unwrap_or_default().to_string();
+ for line in lines {
+ let mut line = line.trim_start();
+ for _ in 0..quotes {
+ line = line.strip_prefix('>').unwrap_or(line).trim_start();
+ }
+ out.push('\n');
+ out.push_str(line);
+ }
+ out
+}
+
+impl Reader<'_> {
+ fn read(&mut self, event: Event, range: Range) {
+ if self.toggle.is_some() {
+ return self.in_toggle(event, range);
+ }
+ if self.html {
+ self.html = !matches!(event, Event::End(TagEnd::HtmlBlock));
+ return;
+ }
+ if let Some((_, code)) = &mut self.code {
+ match event {
+ Event::Text(text) => code.push_str(&text),
+ Event::End(TagEnd::CodeBlock) => self.end_code(),
+ _ => {}
+ }
+ return;
+ }
+ match event {
+ Event::Start(Tag::HtmlBlock) => self.html_block(range),
+ Event::Start(Tag::Paragraph | Tag::Heading { .. } | Tag::TableCell) => {
+ self.flush_leaf();
+ self.leaf = Some(Leaf::default());
+ }
+ Event::End(TagEnd::Paragraph) => self.end_paragraph(),
+ Event::End(TagEnd::Heading(level)) => {
+ let text = self.leaf_text();
+ self.put(BlockKind::Heading(level as u8), text);
+ }
+ Event::Start(Tag::BlockQuote(kind)) => match &mut self.quote {
+ Some((_, depth, _)) => *depth += 1,
+ None => {
+ self.flush_leaf();
+ self.quote = Some((kind.is_some(), 1, Vec::new()));
+ }
+ },
+ Event::End(TagEnd::BlockQuote(_)) => self.end_quote(),
+ Event::Start(Tag::CodeBlock(kind)) => {
+ self.flush_leaf();
+ let fence = match kind {
+ CodeBlockKind::Fenced(language) => language.trim().to_string(),
+ CodeBlockKind::Indented => String::new(),
+ };
+ let kind = match fence.as_str() {
+ "mermaid" => BlockKind::Diagram,
+ "math" => BlockKind::Math,
+ _ => BlockKind::Code,
+ };
+ self.code = Some((kind, String::new()));
+ }
+ Event::Start(Tag::List(start)) => {
+ self.flush_leaf();
+ self.emit_item();
+ self.lists.push(start.is_some());
+ }
+ Event::End(TagEnd::List(_)) => {
+ self.lists.pop();
+ }
+ Event::Start(Tag::Item) => {
+ let numbered = self.lists.last().copied().unwrap_or_default();
+ let kind = match numbered {
+ true => BlockKind::Numbered,
+ false => BlockKind::Bullet,
+ };
+ self.items.push(Item {
+ kind,
+ parts: Vec::new(),
+ emitted: false,
+ });
+ self.leaf = Some(Leaf::default());
+ }
+ Event::TaskListMarker(done) => {
+ if let Some(item) = self.items.last_mut() {
+ item.kind = BlockKind::Todo(done);
+ }
+ }
+ Event::End(TagEnd::Item) => {
+ self.flush_leaf();
+ self.emit_item();
+ self.items.pop();
+ }
+ Event::Rule => self.put(BlockKind::Divider, String::new()),
+ Event::Start(Tag::Table(aligns)) => {
+ self.table = Some((aligns.len(), Vec::new(), Vec::new()));
+ }
+ Event::End(TagEnd::TableCell) => {
+ let text = self.leaf_text().replace("\\|", "|");
+ if let Some((_, _, row)) = &mut self.table {
+ row.push(text);
+ }
+ }
+ Event::End(TagEnd::TableHead | TagEnd::TableRow) => {
+ if let Some((columns, cells, row)) = &mut self.table {
+ row.resize(*columns, String::new());
+ cells.append(row);
+ }
+ }
+ Event::End(TagEnd::Table) => self.end_table(),
+ event => self.inline(event, range),
+ }
+ }
+
+ fn inline(&mut self, event: Event, range: Range) {
+ let Some(leaf) = &mut self.leaf else {
+ return;
+ };
+ let (start, end) = leaf.range.as_ref().map_or((range.start, range.end), |had| {
+ (had.start.min(range.start), had.end.max(range.end))
+ });
+ leaf.range = Some(start..end);
+ let top = leaf.depth == 0;
+ if let (false, Sole::Image(_, alt)) =
+ (top || matches!(event, Event::End(_)), &mut leaf.sole)
+ {
+ let had = alt.get_or_insert(range.clone());
+ *had = had.start.min(range.start)..had.end.max(range.end);
+ }
+ match event {
+ Event::Start(Tag::Image { dest_url, .. }) if top => {
+ leaf.sole = match leaf.sole {
+ Sole::Nothing => Sole::Image(dest_url.to_string(), None),
+ _ => Sole::Mixed,
+ };
+ leaf.depth += 1;
+ }
+ Event::Start(_) => {
+ if top {
+ leaf.sole = Sole::Mixed;
+ }
+ leaf.depth += 1;
+ }
+ Event::End(_) => leaf.depth = leaf.depth.saturating_sub(1),
+ Event::DisplayMath(math) if top => {
+ leaf.sole = match leaf.sole {
+ Sole::Nothing => Sole::Math(math.to_string()),
+ _ => Sole::Mixed,
+ };
+ }
+ _ if top => leaf.sole = Sole::Mixed,
+ _ => {}
+ }
+ }
+
+ fn quotes(&self) -> usize {
+ self.quote.as_ref().map_or(0, |(_, depth, _)| *depth)
+ }
+
+ fn leaf_text(&mut self) -> String {
+ let (source, quotes) = (self.source, self.quotes());
+ self.leaf
+ .take()
+ .and_then(|leaf| leaf.range)
+ .map(|range| clean(&source[range], quotes))
+ .unwrap_or_default()
+ }
+
+ /// Text an item read before a block inside it began.
+ fn flush_leaf(&mut self) {
+ if self.leaf.as_ref().is_some_and(|leaf| leaf.range.is_some()) {
+ let text = self.leaf_text();
+ self.put(BlockKind::Paragraph, text);
+ }
+ self.leaf = None;
+ }
+
+ /// Text where it belongs: in the open quote, the open item, or a block of its own.
+ fn put(&mut self, kind: BlockKind, text: String) {
+ if let Some((_, _, parts)) = &mut self.quote {
+ parts.push(text);
+ return;
+ }
+ if kind == BlockKind::Paragraph
+ && let Some(item) = self.items.last_mut()
+ && !item.emitted
+ {
+ item.parts.push(text);
+ return;
+ }
+ self.emit_item();
+ let data = match kind {
+ BlockKind::Divider => BlockData::new(kind, Vec::::new()),
+ kind => BlockData::new(kind, [text]),
+ };
+ self.out.push(data);
+ }
+
+ fn emit_item(&mut self) {
+ let quoted = self.quote.is_some();
+ let Some(item) = self.items.last_mut().filter(|item| !item.emitted) else {
+ return;
+ };
+ item.emitted = true;
+ if quoted {
+ return;
+ }
+ let data = BlockData::new(item.kind.clone(), [item.parts.join("\n\n")]);
+ self.out.push(data);
+ }
+
+ fn end_paragraph(&mut self) {
+ let alone = self.quote.is_none() && self.items.is_empty();
+ let sole = self
+ .leaf
+ .as_mut()
+ .map(|leaf| std::mem::take(&mut leaf.sole));
+ match sole {
+ Some(Sole::Math(math)) if alone => {
+ self.leaf = None;
+ self.put(BlockKind::Math, math.trim().to_string());
+ }
+ Some(Sole::Image(source, alt)) if alone => {
+ self.leaf = None;
+ let source_text = self.source;
+ let caption = alt.map_or(String::new(), |alt| source_text[alt].to_string());
+ self.put(BlockKind::Image(Media::new(source)), caption);
+ }
+ _ => {
+ let text = self.leaf_text();
+ self.put(BlockKind::Paragraph, text);
+ }
+ }
+ }
+
+ fn end_quote(&mut self) {
+ let Some((callout, depth, parts)) = &mut self.quote else {
+ return;
+ };
+ *depth -= 1;
+ if *depth > 0 {
+ return;
+ }
+ let kind = match callout {
+ true => BlockKind::Callout,
+ false => BlockKind::Quote,
+ };
+ let text = parts.join("\n\n");
+ self.quote = None;
+ self.put(kind, text);
+ }
+
+ fn end_code(&mut self) {
+ let Some((kind, mut code)) = self.code.take() else {
+ return;
+ };
+ if code.ends_with('\n') {
+ code.pop();
+ }
+ self.put(kind, code);
+ }
+
+ fn end_table(&mut self) {
+ let Some((columns, mut cells, _)) = self.table.take() else {
+ return;
+ };
+ if cells.len() < columns * 2 {
+ cells.resize(columns * 2, String::new());
+ }
+ let kind = BlockKind::Table {
+ columns,
+ merged: Vec::new(),
+ };
+ self.emit_item();
+ self.out.push(BlockData::new(kind, cells));
+ }
+}
diff --git a/src/documents/blocks/mod.rs b/src/documents/blocks/mod.rs
index 75ac910..be72bce 100644
--- a/src/documents/blocks/mod.rs
+++ b/src/documents/blocks/mod.rs
@@ -1,5 +1,7 @@
mod content;
mod edit;
+mod export;
+mod import;
mod keys;
mod kind;
mod media;
diff --git a/src/documents/blocks/tests/markdown.rs b/src/documents/blocks/tests/markdown.rs
new file mode 100644
index 0000000..257258d
--- /dev/null
+++ b/src/documents/blocks/tests/markdown.rs
@@ -0,0 +1,238 @@
+use super::{BlockData, BlockKind};
+use crate::documents::Media;
+
+fn block(kind: BlockKind, texts: &[&str]) -> BlockData {
+ BlockData::new(kind, texts.iter().copied())
+}
+
+fn table(columns: usize, cells: &[&str]) -> BlockData {
+ let kind = BlockKind::Table {
+ columns,
+ merged: Vec::new(),
+ };
+ block(kind, cells)
+}
+
+#[test]
+fn every_kind_markdown_holds_comes_back_as_it_went() {
+ let document = vec![
+ block(BlockKind::Heading(1), &["Project *notes*"]),
+ block(
+ BlockKind::Paragraph,
+ &["Blend **toward white**,\nthen `clamp`."],
+ ),
+ block(BlockKind::Bullet, &["Tokens"]),
+ block(BlockKind::Todo(true), &["Light and dark"]),
+ block(BlockKind::Todo(false), &["Motion"]),
+ block(BlockKind::Numbered, &["First"]),
+ block(BlockKind::Numbered, &["Second"]),
+ block(BlockKind::Quote, &["One line\nand the next"]),
+ block(BlockKind::Callout, &["Drag blocks by their handle."]),
+ block(
+ BlockKind::Toggle(true),
+ &["Why gamma?", "It keeps **midtones**."],
+ ),
+ block(BlockKind::Toggle(false), &["Closed", ""]),
+ block(BlockKind::Code, &["let fence = \"```\";\nlift(color)"]),
+ block(BlockKind::Diagram, &["graph LR; a --> b"]),
+ block(BlockKind::Math, &["c' = c + (1 - c)\\,t"]),
+ block(BlockKind::Divider, &[]),
+ table(2, &["Token", "Light", "a|b", "0.50"]),
+ block(BlockKind::Image(Media::new("atrium.jpg")), &["An atrium"]),
+ block(BlockKind::Heading(4), &["Deep"]),
+ ];
+ let markdown = BlockData::to_markdown(&document);
+ assert_eq!(BlockData::from_markdown(&markdown), document, "{markdown}");
+}
+
+#[test]
+fn lists_stay_tight_and_count_up() {
+ let document = vec![
+ block(BlockKind::Bullet, &["a"]),
+ block(BlockKind::Todo(false), &["b"]),
+ block(BlockKind::Numbered, &["c"]),
+ block(BlockKind::Numbered, &["d\ne"]),
+ block(BlockKind::Paragraph, &[""]),
+ block(BlockKind::Callout, &["Note"]),
+ ];
+ assert_eq!(
+ BlockData::to_markdown(&document),
+ "- a\n- [ ] b\n\n1. c\n2. d\n e\n\n> [!NOTE]\n> Note\n"
+ );
+}
+
+#[test]
+fn markdown_written_by_hand_reads_as_blocks() {
+ let source = "#### Deep\n\n- one\n - inner\n- two\n\n> quoted\nlazy\n\n| a | b |\n|---|---|\n| 1 |\n\nInline $x$ math\n\n
raw
\n";
+ assert_eq!(
+ BlockData::from_markdown(source),
+ vec![
+ block(BlockKind::Heading(4), &["Deep"]),
+ block(BlockKind::Bullet, &["one"]),
+ block(BlockKind::Bullet, &["inner"]),
+ block(BlockKind::Bullet, &["two"]),
+ block(BlockKind::Quote, &["quoted\nlazy"]),
+ table(2, &["a", "b", "1", ""]),
+ block(BlockKind::Paragraph, &["Inline $x$ math"]),
+ block(BlockKind::Paragraph, &["raw
"]),
+ ]
+ );
+}
+
+#[test]
+fn what_markdown_cannot_hold_takes_its_nearest_form() {
+ let document = vec![
+ block(BlockKind::Columns(2), &["Left", "Right"]),
+ block(BlockKind::Video(Media::new("clip.mp4")), &[""]),
+ block(BlockKind::Synced("shared".into()), &["Kept"]),
+ ];
+ assert_eq!(
+ BlockData::to_markdown(&document),
+ "Left\n\nRight\n\n[clip.mp4](clip.mp4)\n\nKept\n"
+ );
+}
+
+#[test]
+fn a_caption_keeps_its_marks_and_a_toggle_its_nested_details() {
+ let document = vec![
+ block(
+ BlockKind::Image(Media::new("a.png")),
+ &["**bold** and *slant*"],
+ ),
+ block(
+ BlockKind::Toggle(false),
+ &[
+ "Outer",
+ "Inner
x \n\nAfter",
+ ],
+ ),
+ block(BlockKind::Paragraph, &["Last"]),
+ ];
+ let markdown = BlockData::to_markdown(&document);
+ assert_eq!(BlockData::from_markdown(&markdown), document, "{markdown}");
+}
+
+#[test]
+fn reference_definitions_stay_with_the_document() {
+ let source = "See [the site][id].\n\n[id]: https://ely.dev \"Ely\"\n";
+ let blocks = BlockData::from_markdown(source);
+ assert_eq!(
+ blocks,
+ vec![
+ block(BlockKind::Paragraph, &["See [the site][id]."]),
+ block(BlockKind::Paragraph, &["[id]: https://ely.dev \"Ely\""]),
+ ]
+ );
+ assert_eq!(
+ BlockData::from_markdown(&BlockData::to_markdown(&blocks)),
+ blocks
+ );
+}
+
+#[test]
+fn a_toggle_left_open_keeps_the_rest_as_its_body() {
+ assert_eq!(
+ BlockData::from_markdown("\nOpen
\n\nBody\n\nMore\n"),
+ vec![block(BlockKind::Toggle(false), &["Open", "Body\n\nMore"])]
+ );
+}
+
+#[test]
+fn brackets_and_comments_keep_their_blocks() {
+ let document = vec![
+ block(
+ BlockKind::Image(Media::new("a)b c.png")),
+ &[r"`[x` \[y\] [z]"],
+ ),
+ block(
+ BlockKind::Toggle(true),
+ &["Closes", "\n\nkept"],
+ ),
+ block(
+ BlockKind::Toggle(true),
+ &["Opens", "\n\nkept"],
+ ),
+ block(
+ BlockKind::Toggle(false),
+ &["Quoted", r#"kept
"#],
+ ),
+ block(BlockKind::Paragraph, &["After"]),
+ ];
+ let markdown = BlockData::to_markdown(&document);
+ assert_eq!(BlockData::from_markdown(&markdown), document, "{markdown}");
+}
+
+#[test]
+fn a_bracket_left_alone_comes_back_escaped_in_its_image() {
+ let image = || BlockKind::Image(Media::new("a.png"));
+ let document = vec![block(image(), &[r"a ] b [c\"])];
+ assert_eq!(
+ BlockData::from_markdown(&BlockData::to_markdown(&document)),
+ vec![block(image(), &[r"a \] b \[c\\"])]
+ );
+}
+
+#[test]
+fn math_with_a_blank_line_and_a_piped_cell_come_back() {
+ let document = vec![
+ block(BlockKind::Math, &["a = b\n\nc = d $$"]),
+ table(2, &["Code", "Note", "`a|b`", "x | y"]),
+ block(BlockKind::Paragraph, &["$$\nx\n$$"]),
+ ];
+ let markdown = BlockData::to_markdown(&document);
+ let mut expected = document.clone();
+ expected[2] = block(BlockKind::Math, &["x"]);
+ assert_eq!(BlockData::from_markdown(&markdown), expected, "{markdown}");
+}
+
+#[test]
+fn one_line_fields_keep_one_line() {
+ let document = vec![
+ block(BlockKind::Heading(2), &["Two\nlines"]),
+ block(BlockKind::Toggle(true), &["Title\nrest", "Body"]),
+ block(BlockKind::Image(Media::new("a.png")), &["Cap\ntion"]),
+ ];
+ assert_eq!(
+ BlockData::from_markdown(&BlockData::to_markdown(&document)),
+ vec![
+ block(BlockKind::Heading(2), &["Two lines"]),
+ block(BlockKind::Toggle(true), &["Title rest", "Body"]),
+ block(BlockKind::Image(Media::new("a.png")), &["Cap tion"]),
+ ]
+ );
+}
+
+#[test]
+fn toggle_tags_read_as_html_reads_them() {
+ let document = vec![
+ block(
+ BlockKind::Toggle(true),
+ &[
+ r#"a b"#,
+ "\n\nkept",
+ ],
+ ),
+ block(BlockKind::Paragraph, &["After"]),
+ ];
+ let markdown = BlockData::to_markdown(&document);
+ assert_eq!(BlockData::from_markdown(&markdown), document, "{markdown}");
+ let source = "\nT
\n\nB\n\n \n";
+ assert_eq!(
+ BlockData::from_markdown(source),
+ vec![block(BlockKind::Toggle(true), &["T", "B"])]
+ );
+ let bare = "\n\nBody\n\n \n";
+ assert_eq!(
+ BlockData::from_markdown(bare),
+ vec![block(BlockKind::Toggle(false), &["", "Body"])]
+ );
+}
+
+#[test]
+fn an_unquoted_value_runs_to_a_space() {
+ let source = "\n\nBody\n\n \n";
+ assert_eq!(
+ BlockData::from_markdown(source),
+ vec![block(BlockKind::Toggle(false), &["", "Body"])]
+ );
+}
diff --git a/src/documents/blocks/tests/mod.rs b/src/documents/blocks/tests/mod.rs
index 28da1ba..ac1c96c 100644
--- a/src/documents/blocks/tests/mod.rs
+++ b/src/documents/blocks/tests/mod.rs
@@ -3,6 +3,7 @@ use gpui::{AppContext as _, Entity, Focusable, TestAppContext, VisualTestContext
use super::{BlockData, BlockEditor, BlockKind};
use crate::{forms, theme::Theme};
+mod markdown;
mod scroll;
mod stale;
diff --git a/tasks/github.md b/tasks/github.md
index 02163dd..57e493e 100644
--- a/tasks/github.md
+++ b/tasks/github.md
@@ -20,6 +20,6 @@
- [x] G12 Issue #7: an IME asks for its candidate box past the laid-out text; the box clamps to the last layout, as `bounds_for` does; tested
- [x] G13 Issue #12: a floating option list occludes, so the wheel scrolls the list and not the page under it; tested
- [x] G14 Issue #7: BlockEditor scrolls in its own `ScrollArea` with its scrollbar and keeps the caret in view as blocks are added; tested
-- [ ] G15 Issue #7: BlockEditor turns into Markdown and back; tested
+- [x] G15 Issue #7: BlockEditor turns into Markdown and back; tested. Five reviews, the cap: the fifth finding (a slash ended an unquoted HTML value) is fixed and tested, unreviewed
- [ ] G16 Issue #7: a selection across blocks: drag or Shift-press selects, copy, cut, delete and typing act on it, undo restores; tested
- [ ] G17 Push, reply on #7 and #12