package export_test import ( "encoding/json" "strings" "testing" "github.com/carlosframework/docs/internal/export" ) // mustParse builds a Document from the JSON shape the core emits, so // these tests exercise the same decoding path a request does rather // than hand-building the tree behind it. func mustParse(t *testing.T, title, js string) export.Document { t.Helper() doc, err := export.Parse(title, js) if err != nil { t.Fatalf("parse: %v", err) } return doc } func TestMarkdownRendersTheDocumentShapes(t *testing.T) { js := `{"type":"doc","content":[ {"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"A heading"}]}, {"type":"paragraph","content":[ {"type":"text","text":"Plain then "}, {"type":"text","text":"bold","marks":[{"type":"strong"}]}, {"type":"text","text":" then "}, {"type":"text","text":"italic","marks":[{"type":"em"}]}, {"type":"text","text":" and "}, {"type":"text","text":"code()","marks":[{"type":"code"}]} ]}, {"type":"bullet_list","content":[ {"type":"list_item","content":[{"type":"paragraph","content":[{"type":"text","text":"One"}]}]}, {"type":"list_item","content":[{"type":"paragraph","content":[{"type":"text","text":"Two"}]}]} ]}, {"type":"ordered_list","content":[ {"type":"list_item","content":[{"type":"paragraph","content":[{"type":"text","text":"First"}]}]}, {"type":"list_item","content":[{"type":"paragraph","content":[{"type":"text","text":"Second"}]}]} ]}, {"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"Quoted."}]}]} ]}` got := export.Markdown(mustParse(t, "Notes", js)) want := strings.Join([]string{ "# Notes", "", "## A heading", "", "Plain then **bold** then *italic* and `code()`", "", "- One", "- Two", "", "1. First", "2. Second", "", "> Quoted.", "", }, "\n") if got != want { t.Errorf("markdown mismatch\n--- got ---\n%s\n--- want ---\n%s", got, want) } } // A nested list has to stay nested. The indent is measured from the // marker's own width, so this catches an off-by-one that would flatten // the child list into a sibling. func TestMarkdownNestsLists(t *testing.T) { js := `{"type":"doc","content":[ {"type":"bullet_list","content":[ {"type":"list_item","content":[ {"type":"paragraph","content":[{"type":"text","text":"Outer"}]}, {"type":"bullet_list","content":[ {"type":"list_item","content":[{"type":"paragraph","content":[{"type":"text","text":"Inner"}]}]} ]} ]} ]} ]}` got := export.Markdown(mustParse(t, "", js)) want := "- Outer\n\n - Inner\n" if got != want { t.Errorf("nested list\n--- got ---\n%q\n--- want ---\n%q", got, want) } } // A multi-paragraph blockquote must be quoted throughout, not only on // its first line. func TestMarkdownQuotesEveryLineOfABlockquote(t *testing.T) { js := `{"type":"doc","content":[ {"type":"blockquote","content":[ {"type":"paragraph","content":[{"type":"text","text":"One."}]}, {"type":"paragraph","content":[{"type":"text","text":"Two."}]} ]} ]}` got := export.Markdown(mustParse(t, "", js)) for _, line := range strings.Split(strings.TrimRight(got, "\n"), "\n") { if !strings.HasPrefix(line, ">") { t.Errorf("unquoted line in a blockquote: %q (in %q)", line, got) } } } // The thing that makes an export lossy in practice: a person's own // punctuation being read back as markup. func TestMarkdownEscapesTextThatWouldBecomeMarkup(t *testing.T) { cases := []struct { text string want string }{ {"2 * 3 * 4", `2 \* 3 \* 4`}, {"snake_case_name", `snake\_case\_name`}, {"# 1 priority", `\# 1 priority`}, {"a [link] shape", `a \[link\] shape`}, {"1. not a list", `1\. not a list`}, {"back\\slash", `back\\slash`}, {"", `\`}, // A hyphen mid-sentence is ordinary punctuation and must NOT // be escaped — over-escaping every one of them is unreadable. {"well-known", "well-known"}, // A LITERAL newline inside one text run starts a new line in // the output, and everything the cases above assert about the // start of a line has to hold there too. // // escapeMarkdown decided "start of a line" by asking whether // the index was 0 — true only for the start of the whole run — // so a "#" or a "- " or a "1." after an embedded newline was // written through unescaped and became a heading, a bullet or // an ordered list in the exported file. The document then says // something its author did not write, which is the one thing // this module's own rule says it must never do. // // Reachable without a hostile client: POST /d/{sqid}/updates // takes arbitrary Yjs bytes that never met ProseMirror's // schema, and a paste can carry one too. {"intro\n# not a heading", "intro\n\\# not a heading"}, {"intro\n- not a bullet", "intro\n\\- not a bullet"}, {"intro\n+ not a bullet", "intro\n\\+ not a bullet"}, {"intro\n1. not a list", "intro\n1\\. not a list"}, {"intro\n1) not a list", "intro\n1\\) not a list"}, // CommonMark allows a block marker up to THREE spaces of // indentation (§4.2 ATX headings, §5.2 lists), and all of them // interrupt a paragraph. So "at the start of a line" has to // mean "first non-space on a line", not "first character on a // line" — the first version of this fix escaped only the // latter, and every one of these went through untouched. {"intro\n # one space", "intro\n \\# one space"}, {"intro\n # three spaces", "intro\n \\# three spaces"}, {"intro\n - one space", "intro\n \\- one space"}, {"intro\n 1. one space", "intro\n 1\\. one space"}, // A setext heading underlines the line ABOVE it, so a run of // "=" on its own line silently promotes the preceding // paragraph to an H1. "-" was already escaped for the bullet // case, which covers setext H2 as a side effect. // // Only the FIRST character needs the backslash, and that is // the whole escape rather than a partial one: a setext // underline must be a line of nothing but "=", and a line // beginning with an escape is not that. Escaping all three // would be three backslashes to buy what one already bought. {"intro\n===", "intro\n\\==="}, // "~~~" opens a code fence that interrupts a paragraph and // then swallows every following line until a closing fence — // so this one does not merely restyle a line, it eats the rest // of the document. Same first-character-only reasoning: an // escaped tilde is not a fence opener. // // Escaped at the start of a line in any position, including // the start of the run, which was a hole even before newlines // were considered. {"intro\n~~~\nswallowed", "intro\n\\~~~\nswallowed"}, {"~~~ at the run start", "\\~~~ at the run start"}, // And the flip side: the same characters mid-line, after a // newline or not, are ordinary punctuation. A fix that escapes // its way out of the problem would make every hyphen and every // equals sign in every export unreadable, and would have // passed a test that only checked the bad cases. {"intro\nwell-known", "intro\nwell-known"}, {"intro\nversion 1.2", "intro\nversion 1.2"}, {"a = b and c ~ d", "a = b and c ~ d"}, {"intro\na = b", "intro\na = b"}, } for _, c := range cases { js := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":` + jsonString(c.text) + `}]}]}` got := strings.TrimSpace(export.Markdown(mustParse(t, "", js))) if got != c.want { t.Errorf("escaping %q: got %q, want %q", c.text, got, c.want) } } } // Code spans are the one place escaping must NOT happen, and the fence // has to grow past any backticks inside. func TestMarkdownCodeSpansFenceThemselves(t *testing.T) { js := `{"type":"doc","content":[{"type":"paragraph","content":[ {"type":"text","text":"a ` + "``" + ` b","marks":[{"type":"code"}]} ]}]}` got := strings.TrimSpace(export.Markdown(mustParse(t, "", js))) want := "```a `` b```" if got != want { t.Errorf("got %q, want %q", got, want) } } // "** bold **" is not emphasis in CommonMark. A run whose text ends in // a space — which is how a person types "bold " before the next word — // has to keep that space outside the delimiters. func TestMarkdownKeepsSpacesOutsideEmphasis(t *testing.T) { js := `{"type":"doc","content":[{"type":"paragraph","content":[ {"type":"text","text":"bold ","marks":[{"type":"strong"}]}, {"type":"text","text":"plain"} ]}]}` got := strings.TrimSpace(export.Markdown(mustParse(t, "", js))) if got != "**bold** plain" { t.Errorf("got %q, want %q", got, "**bold** plain") } } // Font and size marks are outside Markdown's subset by design — see // the package comment. What must not happen is the text going with // them. func TestMarkdownKeepsTextOfMarksItCannotExpress(t *testing.T) { js := `{"type":"doc","content":[{"type":"paragraph","content":[ {"type":"text","text":"in the sans face","marks":[{"type":"font_sans"},{"type":"size_lg"}]} ]}]}` got := strings.TrimSpace(export.Markdown(mustParse(t, "", js))) if got != "in the sans face" { t.Errorf("got %q, want the text unchanged", got) } } // The core carries unknown nodes through so a newer editor build // cannot lose a reader's text. That guarantee has to survive the // exporter too. func TestMarkdownKeepsTextInsideUnknownNodes(t *testing.T) { js := `{"type":"doc","content":[ {"type":"table","content":[{"type":"paragraph","content":[{"type":"text","text":"kept"}]}]} ]}` got := export.Markdown(mustParse(t, "", js)) if !strings.Contains(got, "kept") { t.Errorf("text inside an unknown node was lost: %q", got) } } // Alignment is outside Markdown's subset the same way the font and // size marks are, and for a harder reason: CommonMark has no syntax for // it at all. What must not happen — the rule this file has everywhere // else — is the words going with it. A centred paragraph exports as its // own text, byte-identical to the same paragraph unaligned. // // Asserted against the unaligned rendering rather than against a // literal, because the property is "alignment changes nothing here", // and a literal would let both sides drift together. func TestMarkdownDropsAlignmentAndKeepsTheText(t *testing.T) { plain := `{"type":"doc","content":[ {"type":"paragraph","content":[{"type":"text","text":"Centred on screen."}]}, {"type":"paragraph","content":[{"type":"text","text":"Right on screen."}]} ]}` aligned := `{"type":"doc","content":[ {"type":"paragraph","attrs":{"align":"center"},"content":[{"type":"text","text":"Centred on screen."}]}, {"type":"paragraph","attrs":{"align":"right"},"content":[{"type":"text","text":"Right on screen."}]} ]}` want := export.Markdown(mustParse(t, "Notes", plain)) got := export.Markdown(mustParse(t, "Notes", aligned)) if got != want { t.Errorf("aligned Markdown is %q, want the unaligned rendering %q", got, want) } for _, text := range []string{"Centred on screen.", "Right on screen."} { if !strings.Contains(got, text) { t.Errorf("the text of an aligned paragraph was lost: %q", got) } } } func TestMarkdownOfAnEmptyDocumentIsJustItsTitle(t *testing.T) { got := export.Markdown(mustParse(t, "Untitled document", `{"type":"doc","content":[]}`)) if got != "# Untitled document\n" { t.Errorf("got %q", got) } } func TestParseRejectsSomethingThatIsNotADocument(t *testing.T) { if _, err := export.Parse("t", `{"type":"paragraph"}`); err == nil { t.Error("a non-doc root must be an error, not an empty export") } if _, err := export.Parse("t", `not json`); err == nil { t.Error("invalid JSON must be an error") } } // jsonString quotes s the way a JSON encoder would, for building the // fixtures above. // // It asks the JSON encoder rather than reimplementing it. The hand- // rolled version handled only " and \, so a fixture containing a // literal newline produced invalid JSON and the test failed inside // Parse with "invalid character '\n' in string literal" — a fixture // bug wearing the costume of a parser bug, and one that made a control // character impossible to write a test for at all. func jsonString(s string) string { b, err := json.Marshal(s) if err != nil { panic(err) // a string always marshals } return string(b) } // Shift+Enter's line break, in the export that has two spellings for // it and has to pick one. // // A backslash at the end of the line, not two trailing spaces. Both // are CommonMark hard breaks, but only one survives a round trip: // trailing spaces are invisible in the source, and every editor, // linter and version-control hook that strips trailing whitespace // removes them without anyone seeing it happen — so the break the // author typed silently becomes a soft wrap. A backslash is visible, // nothing strips it, and it cannot be confused with a backslash the // author typed, because escapeMarkdown escapes that one to "\\". func TestMarkdownRendersAHardBreakAsATrailingBackslash(t *testing.T) { js := `{"type":"doc","content":[{"type":"paragraph","content":[ {"type":"text","text":"first"}, {"type":"hard_break"}, {"type":"text","text":"second"} ]}]}` got := export.Markdown(mustParse(t, "", js)) want := "first\\\nsecond\n" if got != want { t.Errorf("got:\n%q\nwant:\n%q", got, want) } } // A break at the end of a block has nothing to continue onto, and a // backslash left at the end of a paragraph is not a break in // CommonMark — it is a literal backslash the author never typed. func TestMarkdownDropsAHardBreakAtTheEndOfAParagraph(t *testing.T) { js := `{"type":"doc","content":[{"type":"paragraph","content":[ {"type":"text","text":"only"}, {"type":"hard_break"}, {"type":"hard_break"} ]}]}` if got, want := export.Markdown(mustParse(t, "", js)), "only\n"; got != want { t.Errorf("got %q, want %q", got, want) } } // A heading is one line in Markdown — there is no way to spell a // second one — so a break inside a heading becomes a space rather than // a backslash that would strand the rest of the heading as a // paragraph. The words are what survives; that is this file's rule. func TestMarkdownFlattensAHardBreakInsideAHeading(t *testing.T) { js := `{"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[ {"type":"text","text":"first"}, {"type":"hard_break"}, {"type":"text","text":"second"} ]}]}` if got, want := export.Markdown(mustParse(t, "", js)), "## first second\n"; got != want { t.Errorf("got %q, want %q", got, want) } } // The line a break opens is still inside whatever block it happened // in: a list item's continuation indent and a blockquote's own marker // have to reach it, or the second half of the line leaves the list (or // the quote) that the first half is in. func TestMarkdownIndentsTheLineAfterAHardBreak(t *testing.T) { js := `{"type":"doc","content":[ {"type":"bullet_list","content":[ {"type":"list_item","content":[{"type":"paragraph","content":[ {"type":"text","text":"one"}, {"type":"hard_break"}, {"type":"text","text":"two"} ]}]} ]}, {"type":"blockquote","content":[{"type":"paragraph","content":[ {"type":"text","text":"three"}, {"type":"hard_break"}, {"type":"text","text":"four"} ]}]} ]}` got := export.Markdown(mustParse(t, "", js)) want := strings.Join([]string{ "- one\\", " two", "", "> three\\", "> four", "", }, "\n") if got != want { t.Errorf("got:\n%q\nwant:\n%q", got, want) } } // A literal newline inside a list item or a blockquote must not walk // the rest of the line out of the block it is in. // // Same defect class as the escaping above and worth its own check, // because the fix for one does not imply the fix for the other: the // escaper decides what the characters MEAN, and these two paths decide // what prefix each line CARRIES. A newline that arrives without its // indent or its "> " leaves the second half of somebody's sentence // outside the list. func TestMarkdownKeepsALiteralNewlineInsideItsBlock(t *testing.T) { js := `{"type":"doc","content":[ {"type":"bullet_list","content":[ {"type":"list_item","content":[{"type":"paragraph","content":[ {"type":"text","text":"one\ntwo"} ]}]} ]}, {"type":"blockquote","content":[{"type":"paragraph","content":[ {"type":"text","text":"three\nfour"} ]}]} ]}` got := export.Markdown(mustParse(t, "", js)) want := strings.Join([]string{ "- one", " two", "", "> three", "> four", "", }, "\n") if got != want { t.Errorf("got:\n%q\nwant:\n%q", got, want) } } // Four or more leading spaces on a line make an indented code block, // and escaping cannot prevent it: the block is made by the whitespace, // not by any character a backslash could go in front of. // // So this records what currently happens rather than asserting what // should. The text survives verbatim — which is the module's actual // promise, never lose the words — but a reader sees it as code. The // fix, if it is ever wanted, means rewriting the author's own leading // whitespace, and that is a different decision from escaping, not one // to take in passing. // // Here so the next person to find it finds a decision rather than a // surprise, and so anyone who changes the behaviour has to change a // test that says why. func TestMarkdownIndentedRunLinesBecomeCodeBlocks(t *testing.T) { js := `{"type":"doc","content":[{"type":"paragraph","content":[ {"type":"text","text":"intro\n four spaces in"} ]}]}` got := strings.TrimSpace(export.Markdown(mustParse(t, "", js))) want := "intro\n four spaces in" if got != want { t.Errorf("got %q, want %q", got, want) } // The words are all still there, which is the guarantee that does // hold. Asserted separately so a change to the spacing still has // to keep this true. if !strings.Contains(got, "four spaces in") { t.Errorf("the text was lost: %q", got) } }