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
}