package export import ( "strconv" "strings" ) // Markdown renders a document as CommonMark. // // §11.3 says "Markdown lossless within its subset", and this is where // that subset is drawn: headings, paragraphs, bullet and ordered lists // (nested), blockquotes, and the bold/italic/code marks. What falls // outside it is the font and size marks — Markdown has no way to say // "this run is in the sans face at the large size", and inventing an // HTML span to carry it would produce a file that is no longer // Markdown for the many readers who would then see the tag. A person // exporting to Markdown is asking for the words and their structure; // the typography is what PDF is for. // // Paragraph alignment falls outside it too, and this says so out loud // rather than letting it go missing quietly. CommonMark has no syntax // for alignment at all — not a weaker spelling of it, none — so a // centred paragraph exports as its own text, byte-identical to the same // paragraph unaligned, and writeBlock's paragraph case never reads // `align`. The alternatives fail the same way the font marks' would: an // inline `

` or a `style` attribute is HTML, not // Markdown, and every reader that does not render raw HTML would show // the tag; a `:---:` alignment row would silently turn one paragraph // into a table. The words and the structure survive, which is the // promise; the layout is what the PDF is for. // // The document title becomes an H1 at the top. It lives on the // database row rather than inside the CRDT, so it is not in the tree, // and a Markdown file that opens with nothing but the first paragraph // loses the one piece of a document a person is most likely to be // looking for in a folder of them. func Markdown(doc Document) string { var b strings.Builder if doc.Title != "" { b.WriteString("# ") b.WriteString(escapeMarkdown(doc.Title)) b.WriteString("\n\n") } writeBlocks(&b, doc.Root.Content, "") // Exactly one trailing newline: blocks each end with a blank line, // which leaves two. return strings.TrimRight(b.String(), "\n") + "\n" } // hardBreak is how a Shift+Enter line break (core/src/fragment.rs's // `hard_break`, `
` in the read view) is spelled here: a backslash // at the end of the line. // // CommonMark has two spellings and they are not equally safe. Two // trailing spaces mean the same thing to a parser and nothing at all // to a person reading the file, and every editor, formatter and // pre-commit hook that trims trailing whitespace deletes them without // anyone noticing — at which point the break the author typed has // quietly become a soft wrap, which is the failure this whole fix // exists to stop. A backslash is visible in the source, nothing strips // it, and it cannot be mistaken for one the author typed: escapeMarkdown // escapes that to `\\`. So this is the form that round-trips. const hardBreak = "\\\n" // writeBlocks renders a run of block-level nodes at one indent level. // indent is the prefix every line of a nested block carries — that is // how a list inside a list stays inside it in the output. func writeBlocks(b *strings.Builder, nodes []Node, indent string) { for _, n := range nodes { writeBlock(b, n, indent) } } func writeBlock(b *strings.Builder, n Node, indent string) { switch kindOf(n) { case blockHeading: b.WriteString(indent) b.WriteString(strings.Repeat("#", headingLevel(n))) b.WriteString(" ") // A heading is one line and Markdown has no way to spell a // second one, so a break inside one becomes a space. The // alternative is a backslash that strands the rest of the // heading as a paragraph below it — the words are what has to // survive, which is this file's rule everywhere else too. b.WriteString(strings.ReplaceAll(inlineString(n.Content), hardBreak, " ")) b.WriteString("\n\n") case blockParagraph: text := inlineString(n.Content) // A break at the end of a paragraph has no line to continue // onto, and the backslash it would leave behind is not a break // in CommonMark at all — it is a literal backslash appearing in // a document whose author never typed one. TrimSuffix in a loop // rather than TrimRight: TrimRight works on a character set, so // it would also eat the second half of an escaped backslash the // author DID type ("\\" from escapeMarkdown) and leave a // dangling escape behind. for strings.HasSuffix(text, hardBreak) { text = strings.TrimSuffix(text, hardBreak) } if strings.TrimSpace(text) == "" { // An empty paragraph is a blank line in the document and // nothing at all in Markdown — two blank lines in a row // are one paragraph break, not two. Emitting it would // change nothing about how the file reads and would make // the round trip look lossy when it is not. return } b.WriteString(indent) // A hard break puts a second line inside this one paragraph, // and that line is still inside whatever block the paragraph // is in — so it carries the same prefix. (writeList and the // blockquote case re-prefix the lines they are given, so // indent is "" underneath either of them; this is what keeps // the stray-list_item path below honest.) b.WriteString(strings.ReplaceAll(text, "\n", "\n"+indent)) b.WriteString("\n\n") case blockBulletList: writeList(b, n, indent, false) case blockOrderedList: writeList(b, n, indent, true) case blockQuote: // Render the quoted content into its own buffer and then // prefix every line, so a multi-paragraph or list-containing // quote is quoted throughout rather than only on its first // line. var inner strings.Builder writeBlocks(&inner, n.Content, "") for _, line := range strings.Split(strings.TrimRight(inner.String(), "\n"), "\n") { b.WriteString(indent) if line == "" { b.WriteString(">\n") continue } b.WriteString("> ") b.WriteString(line) b.WriteString("\n") } b.WriteString("\n") case blockListItem: // Reached only when a list_item turns up outside a list, which // ProseMirror's schema cannot produce but a client posting raw // Yjs bytes can. Rendered as its own content rather than // dropped — the module rule the core states and this file // inherits: never lose the words. writeBlocks(b, n.Content, indent) default: if n.IsText() { // A bare text run outside any block — same provenance as // the stray list_item above. Its own paragraph. b.WriteString(indent) writeInline(b, []Node{n}) b.WriteString("\n\n") return } // An element this build does not know: render its children. // A newer editor's node must not take a reader's text with it. writeBlocks(b, n.Content, indent) } } func writeList(b *strings.Builder, list Node, indent string, ordered bool) { for i, item := range list.Content { marker := "- " if ordered { marker = strconv.Itoa(i+1) + ". " } // Continuation lines line up under the text, not under the // marker, or a nested list reads as a sibling of the one above // it. childIndent := indent + strings.Repeat(" ", len(marker)) var inner strings.Builder writeBlocks(&inner, item.Content, "") lines := strings.Split(strings.TrimRight(inner.String(), "\n"), "\n") for j, line := range lines { switch { case j == 0: b.WriteString(indent) b.WriteString(marker) b.WriteString(line) case line == "": // Keep the blank line, but without trailing spaces. default: b.WriteString(childIndent) b.WriteString(line) } b.WriteString("\n") } } b.WriteString("\n") } func inlineString(nodes []Node) string { var b strings.Builder writeInline(&b, nodes) return b.String() } // writeInline renders a run of inline nodes with their marks. // // Marks are applied per run rather than merged across adjacent runs // that share one: "**bold**" followed by "**er**" reads identically to // "**bolder**" once rendered, and merging would mean tracking mark // state across a sequence for no visible gain. func writeInline(b *strings.Builder, nodes []Node) { for _, n := range nodes { if n.Type == "hard_break" { b.WriteString(hardBreak) // A break has no children through the editor's schema and // can have them through raw Yjs bytes; render them rather // than lose them, exactly as fragment.rs does. writeInline(b, n.Content) continue } if !n.IsText() { // An inline element (or a block that ended up inline). // Render its children so nothing is lost. writeInline(b, n.Content) continue } if n.Text == "" { continue } if n.Has("code") { // Code spans take no other markup and no escaping — the // backticks are the escape. A run containing a backtick // needs a longer fence than any run of backticks inside // it, which is what CommonMark says and what this does. b.WriteString(codeSpan(n.Text)) continue } // Leading and trailing spaces have to sit outside the // delimiters: "** bold **" is not emphasis in CommonMark, it // is four literal asterisks and a word. lead, core, trail := splitSpace(n.Text) if core == "" { b.WriteString(n.Text) continue } var opening, closing string if n.Has("strong") { opening, closing = opening+"**", "**"+closing } if n.Has("em") { opening, closing = opening+"*", "*"+closing } b.WriteString(lead) b.WriteString(opening) b.WriteString(escapeMarkdown(core)) b.WriteString(closing) b.WriteString(trail) } } // splitSpace divides s into its leading whitespace, its middle, and // its trailing whitespace. func splitSpace(s string) (lead, core, trail string) { core = strings.TrimLeft(s, " \t") lead = s[:len(s)-len(core)] trimmed := strings.TrimRight(core, " \t") trail = core[len(trimmed):] return lead, trimmed, trail } // codeSpan wraps s in enough backticks to contain it. func codeSpan(s string) string { longest, run := 0, 0 for _, r := range s { if r == '`' { run++ if run > longest { longest = run } } else { run = 0 } } fence := strings.Repeat("`", longest+1) // A code span that starts or ends with a backtick needs a space // inside the fence, which CommonMark strips again on the way back. pad := "" if strings.HasPrefix(s, "`") || strings.HasSuffix(s, "`") { pad = " " } return fence + pad + s + pad + fence } // escapeMarkdown backslash-escapes the characters that would otherwise // turn a person's own words into markup. A document containing // "2 * 3 * 4" must not come back as emphasis, and one containing // "# 1 priority" must not come back as a heading. // // The set is CommonMark's ASCII punctuation, minus the characters that // are only special in positions this function's callers never place // text in. It escapes more than the strict minimum on purpose: an // over-escaped file renders correctly and reads slightly noisily in // source, while an under-escaped one renders as something the author // did not write. // "The start of a line" is tracked rather than inferred from index 0, // and it means the first NON-SPACE on a line rather than the first // character. // // Two rounds of this were wrong, in the same direction, and both are // worth naming because the second looked like a fix. // // It first asked whether i == 0, which is the start of the RUN and not // the start of a line. A run can contain a literal newline — nothing // stops one: POST /d/{sqid}/updates accepts Yjs bytes that never met // ProseMirror's schema, and a paste can carry one — so everything // after a newline was treated as mid-sentence, and "#" became a // heading, "- " a bullet, "1." an ordered list, mid-paragraph. // // Tracking lineStart fixed exactly the unindented case and no more. // CommonMark lets a block marker carry up to THREE spaces of // indentation (§4.2 for ATX headings, §5.2 for lists) and all of them // interrupt a paragraph, so " # x" one space in is still a heading // while `i == lineStart` says it is mid-line. Caught in review, with // the seven inputs now in the test table. // // So the state is "have we seen anything but spaces on this line yet", // which is what atLineStart means below. Any run of spaces is treated // as still-at-line-start rather than counting to three: over-escaping // renders correctly and reads slightly noisily in source, which is the // trade this function's own header already chose. // // Two characters are escaped that a first reading would not expect: // // - '=' because a line of them is a setext heading, which underlines // the paragraph ABOVE it — so it silently promotes text that is // nowhere near it. ('-' is already escaped for the bullet case, // which covers setext H2 as a side effect.) // - '~' because "~~~" opens a code fence that interrupts a paragraph // and then swallows every following line until a closing fence. It // does not restyle a line, it eats the rest of the document. // // Both only at the start of a line, so "a = b" is left alone. // // KNOWN AND NOT FIXED HERE: four or more leading spaces after a // newline make an indented code block, and no amount of escaping // changes that — the block is made by the whitespace, not by any // character this function could put a backslash in front of. The text // survives verbatim and is rendered as code. Fixing it means altering // the author's own leading whitespace, which is a different decision // from escaping and not one to take in passing. // TestMarkdownIndentedRunLinesBecomeCodeBlocks records the current // behaviour so it stays a decision rather than becoming a discovery. func escapeMarkdown(s string) string { var b strings.Builder b.Grow(len(s)) lineStart := 0 // byte index where the current line begins atLineStart := true // nothing but spaces seen on this line yet for i, r := range s { switch r { case '\n': b.WriteRune(r) lineStart = i + 1 atLineStart = true continue case ' ', '\t': // Indentation does not end the line's start. b.WriteRune(r) continue case '\\', '`', '*', '_', '[', ']', '<', '>': b.WriteByte('\\') b.WriteRune(r) case '#', '+', '-', '=', '~': // Only special at the start of a line. Inside a sentence // they are ordinary punctuation, and escaping every hyphen // in a document would be unreadable. if atLineStart { b.WriteByte('\\') } b.WriteRune(r) case '.', ')': // "1." and "1)" start an ordered list, but only after // digits at the start of a line — so the digits are counted // from the line's first non-space, not from the run. if !atLineStart && startsWithDigits(strings.TrimLeft(s[lineStart:i], " \t")) { b.WriteByte('\\') } b.WriteRune(r) default: b.WriteRune(r) } atLineStart = false } return b.String() } // startsWithDigits reports whether s is entirely ASCII digits — the // "1" in a "1." that would start a list. func startsWithDigits(s string) bool { if s == "" { return false } for i := 0; i < len(s); i++ { if s[i] < '0' || s[i] > '9' { return false } } return true }