| 1 | package export_test |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | |
| 8 | "github.com/carlosframework/docs/internal/export" |
| 9 | ) |
| 10 | |
| 11 | // mustParse builds a Document from the JSON shape the core emits, so |
| 12 | // these tests exercise the same decoding path a request does rather |
| 13 | // than hand-building the tree behind it. |
| 14 | func mustParse(t *testing.T, title, js string) export.Document { |
| 15 | t.Helper() |
| 16 | doc, err := export.Parse(title, js) |
| 17 | if err != nil { |
| 18 | t.Fatalf("parse: %v", err) |
| 19 | } |
| 20 | return doc |
| 21 | } |
| 22 | |
| 23 | func TestMarkdownRendersTheDocumentShapes(t *testing.T) { |
| 24 | js := `{"type":"doc","content":[ |
| 25 | {"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"A heading"}]}, |
| 26 | {"type":"paragraph","content":[ |
| 27 | {"type":"text","text":"Plain then "}, |
| 28 | {"type":"text","text":"bold","marks":[{"type":"strong"}]}, |
| 29 | {"type":"text","text":" then "}, |
| 30 | {"type":"text","text":"italic","marks":[{"type":"em"}]}, |
| 31 | {"type":"text","text":" and "}, |
| 32 | {"type":"text","text":"code()","marks":[{"type":"code"}]} |
| 33 | ]}, |
| 34 | {"type":"bullet_list","content":[ |
| 35 | {"type":"list_item","content":[{"type":"paragraph","content":[{"type":"text","text":"One"}]}]}, |
| 36 | {"type":"list_item","content":[{"type":"paragraph","content":[{"type":"text","text":"Two"}]}]} |
| 37 | ]}, |
| 38 | {"type":"ordered_list","content":[ |
| 39 | {"type":"list_item","content":[{"type":"paragraph","content":[{"type":"text","text":"First"}]}]}, |
| 40 | {"type":"list_item","content":[{"type":"paragraph","content":[{"type":"text","text":"Second"}]}]} |
| 41 | ]}, |
| 42 | {"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"Quoted."}]}]} |
| 43 | ]}` |
| 44 | |
| 45 | got := export.Markdown(mustParse(t, "Notes", js)) |
| 46 | want := strings.Join([]string{ |
| 47 | "# Notes", |
| 48 | "", |
| 49 | "## A heading", |
| 50 | "", |
| 51 | "Plain then **bold** then *italic* and `code()`", |
| 52 | "", |
| 53 | "- One", |
| 54 | "- Two", |
| 55 | "", |
| 56 | "1. First", |
| 57 | "2. Second", |
| 58 | "", |
| 59 | "> Quoted.", |
| 60 | "", |
| 61 | }, "\n") |
| 62 | if got != want { |
| 63 | t.Errorf("markdown mismatch\n--- got ---\n%s\n--- want ---\n%s", got, want) |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | // A nested list has to stay nested. The indent is measured from the |
| 68 | // marker's own width, so this catches an off-by-one that would flatten |
| 69 | // the child list into a sibling. |
| 70 | func TestMarkdownNestsLists(t *testing.T) { |
| 71 | js := `{"type":"doc","content":[ |
| 72 | {"type":"bullet_list","content":[ |
| 73 | {"type":"list_item","content":[ |
| 74 | {"type":"paragraph","content":[{"type":"text","text":"Outer"}]}, |
| 75 | {"type":"bullet_list","content":[ |
| 76 | {"type":"list_item","content":[{"type":"paragraph","content":[{"type":"text","text":"Inner"}]}]} |
| 77 | ]} |
| 78 | ]} |
| 79 | ]} |
| 80 | ]}` |
| 81 | got := export.Markdown(mustParse(t, "", js)) |
| 82 | want := "- Outer\n\n - Inner\n" |
| 83 | if got != want { |
| 84 | t.Errorf("nested list\n--- got ---\n%q\n--- want ---\n%q", got, want) |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | // A multi-paragraph blockquote must be quoted throughout, not only on |
| 89 | // its first line. |
| 90 | func TestMarkdownQuotesEveryLineOfABlockquote(t *testing.T) { |
| 91 | js := `{"type":"doc","content":[ |
| 92 | {"type":"blockquote","content":[ |
| 93 | {"type":"paragraph","content":[{"type":"text","text":"One."}]}, |
| 94 | {"type":"paragraph","content":[{"type":"text","text":"Two."}]} |
| 95 | ]} |
| 96 | ]}` |
| 97 | got := export.Markdown(mustParse(t, "", js)) |
| 98 | for _, line := range strings.Split(strings.TrimRight(got, "\n"), "\n") { |
| 99 | if !strings.HasPrefix(line, ">") { |
| 100 | t.Errorf("unquoted line in a blockquote: %q (in %q)", line, got) |
| 101 | } |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | // The thing that makes an export lossy in practice: a person's own |
| 106 | // punctuation being read back as markup. |
| 107 | func TestMarkdownEscapesTextThatWouldBecomeMarkup(t *testing.T) { |
| 108 | cases := []struct { |
| 109 | text string |
| 110 | want string |
| 111 | }{ |
| 112 | {"2 * 3 * 4", `2 \* 3 \* 4`}, |
| 113 | {"snake_case_name", `snake\_case\_name`}, |
| 114 | {"# 1 priority", `\# 1 priority`}, |
| 115 | {"a [link] shape", `a \[link\] shape`}, |
| 116 | {"1. not a list", `1\. not a list`}, |
| 117 | {"back\\slash", `back\\slash`}, |
| 118 | {"<html>", `\<html\>`}, |
| 119 | // A hyphen mid-sentence is ordinary punctuation and must NOT |
| 120 | // be escaped — over-escaping every one of them is unreadable. |
| 121 | {"well-known", "well-known"}, |
| 122 | |
| 123 | // A LITERAL newline inside one text run starts a new line in |
| 124 | // the output, and everything the cases above assert about the |
| 125 | // start of a line has to hold there too. |
| 126 | // |
| 127 | // escapeMarkdown decided "start of a line" by asking whether |
| 128 | // the index was 0 — true only for the start of the whole run — |
| 129 | // so a "#" or a "- " or a "1." after an embedded newline was |
| 130 | // written through unescaped and became a heading, a bullet or |
| 131 | // an ordered list in the exported file. The document then says |
| 132 | // something its author did not write, which is the one thing |
| 133 | // this module's own rule says it must never do. |
| 134 | // |
| 135 | // Reachable without a hostile client: POST /d/{sqid}/updates |
| 136 | // takes arbitrary Yjs bytes that never met ProseMirror's |
| 137 | // schema, and a paste can carry one too. |
| 138 | {"intro\n# not a heading", "intro\n\\# not a heading"}, |
| 139 | {"intro\n- not a bullet", "intro\n\\- not a bullet"}, |
| 140 | {"intro\n+ not a bullet", "intro\n\\+ not a bullet"}, |
| 141 | {"intro\n1. not a list", "intro\n1\\. not a list"}, |
| 142 | {"intro\n1) not a list", "intro\n1\\) not a list"}, |
| 143 | |
| 144 | // CommonMark allows a block marker up to THREE spaces of |
| 145 | // indentation (§4.2 ATX headings, §5.2 lists), and all of them |
| 146 | // interrupt a paragraph. So "at the start of a line" has to |
| 147 | // mean "first non-space on a line", not "first character on a |
| 148 | // line" — the first version of this fix escaped only the |
| 149 | // latter, and every one of these went through untouched. |
| 150 | {"intro\n # one space", "intro\n \\# one space"}, |
| 151 | {"intro\n # three spaces", "intro\n \\# three spaces"}, |
| 152 | {"intro\n - one space", "intro\n \\- one space"}, |
| 153 | {"intro\n 1. one space", "intro\n 1\\. one space"}, |
| 154 | |
| 155 | // A setext heading underlines the line ABOVE it, so a run of |
| 156 | // "=" on its own line silently promotes the preceding |
| 157 | // paragraph to an H1. "-" was already escaped for the bullet |
| 158 | // case, which covers setext H2 as a side effect. |
| 159 | // |
| 160 | // Only the FIRST character needs the backslash, and that is |
| 161 | // the whole escape rather than a partial one: a setext |
| 162 | // underline must be a line of nothing but "=", and a line |
| 163 | // beginning with an escape is not that. Escaping all three |
| 164 | // would be three backslashes to buy what one already bought. |
| 165 | {"intro\n===", "intro\n\\==="}, |
| 166 | |
| 167 | // "~~~" opens a code fence that interrupts a paragraph and |
| 168 | // then swallows every following line until a closing fence — |
| 169 | // so this one does not merely restyle a line, it eats the rest |
| 170 | // of the document. Same first-character-only reasoning: an |
| 171 | // escaped tilde is not a fence opener. |
| 172 | // |
| 173 | // Escaped at the start of a line in any position, including |
| 174 | // the start of the run, which was a hole even before newlines |
| 175 | // were considered. |
| 176 | {"intro\n~~~\nswallowed", "intro\n\\~~~\nswallowed"}, |
| 177 | {"~~~ at the run start", "\\~~~ at the run start"}, |
| 178 | |
| 179 | // And the flip side: the same characters mid-line, after a |
| 180 | // newline or not, are ordinary punctuation. A fix that escapes |
| 181 | // its way out of the problem would make every hyphen and every |
| 182 | // equals sign in every export unreadable, and would have |
| 183 | // passed a test that only checked the bad cases. |
| 184 | {"intro\nwell-known", "intro\nwell-known"}, |
| 185 | {"intro\nversion 1.2", "intro\nversion 1.2"}, |
| 186 | {"a = b and c ~ d", "a = b and c ~ d"}, |
| 187 | {"intro\na = b", "intro\na = b"}, |
| 188 | } |
| 189 | for _, c := range cases { |
| 190 | js := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":` + jsonString(c.text) + `}]}]}` |
| 191 | got := strings.TrimSpace(export.Markdown(mustParse(t, "", js))) |
| 192 | if got != c.want { |
| 193 | t.Errorf("escaping %q: got %q, want %q", c.text, got, c.want) |
| 194 | } |
| 195 | } |
| 196 | } |
| 197 | |
| 198 | // Code spans are the one place escaping must NOT happen, and the fence |
| 199 | // has to grow past any backticks inside. |
| 200 | func TestMarkdownCodeSpansFenceThemselves(t *testing.T) { |
| 201 | js := `{"type":"doc","content":[{"type":"paragraph","content":[ |
| 202 | {"type":"text","text":"a ` + "``" + ` b","marks":[{"type":"code"}]} |
| 203 | ]}]}` |
| 204 | got := strings.TrimSpace(export.Markdown(mustParse(t, "", js))) |
| 205 | want := "```a `` b```" |
| 206 | if got != want { |
| 207 | t.Errorf("got %q, want %q", got, want) |
| 208 | } |
| 209 | } |
| 210 | |
| 211 | // "** bold **" is not emphasis in CommonMark. A run whose text ends in |
| 212 | // a space — which is how a person types "bold " before the next word — |
| 213 | // has to keep that space outside the delimiters. |
| 214 | func TestMarkdownKeepsSpacesOutsideEmphasis(t *testing.T) { |
| 215 | js := `{"type":"doc","content":[{"type":"paragraph","content":[ |
| 216 | {"type":"text","text":"bold ","marks":[{"type":"strong"}]}, |
| 217 | {"type":"text","text":"plain"} |
| 218 | ]}]}` |
| 219 | got := strings.TrimSpace(export.Markdown(mustParse(t, "", js))) |
| 220 | if got != "**bold** plain" { |
| 221 | t.Errorf("got %q, want %q", got, "**bold** plain") |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | // Font and size marks are outside Markdown's subset by design — see |
| 226 | // the package comment. What must not happen is the text going with |
| 227 | // them. |
| 228 | func TestMarkdownKeepsTextOfMarksItCannotExpress(t *testing.T) { |
| 229 | js := `{"type":"doc","content":[{"type":"paragraph","content":[ |
| 230 | {"type":"text","text":"in the sans face","marks":[{"type":"font_sans"},{"type":"size_lg"}]} |
| 231 | ]}]}` |
| 232 | got := strings.TrimSpace(export.Markdown(mustParse(t, "", js))) |
| 233 | if got != "in the sans face" { |
| 234 | t.Errorf("got %q, want the text unchanged", got) |
| 235 | } |
| 236 | } |
| 237 | |
| 238 | // The core carries unknown nodes through so a newer editor build |
| 239 | // cannot lose a reader's text. That guarantee has to survive the |
| 240 | // exporter too. |
| 241 | func TestMarkdownKeepsTextInsideUnknownNodes(t *testing.T) { |
| 242 | js := `{"type":"doc","content":[ |
| 243 | {"type":"table","content":[{"type":"paragraph","content":[{"type":"text","text":"kept"}]}]} |
| 244 | ]}` |
| 245 | got := export.Markdown(mustParse(t, "", js)) |
| 246 | if !strings.Contains(got, "kept") { |
| 247 | t.Errorf("text inside an unknown node was lost: %q", got) |
| 248 | } |
| 249 | } |
| 250 | |
| 251 | // Alignment is outside Markdown's subset the same way the font and |
| 252 | // size marks are, and for a harder reason: CommonMark has no syntax for |
| 253 | // it at all. What must not happen — the rule this file has everywhere |
| 254 | // else — is the words going with it. A centred paragraph exports as its |
| 255 | // own text, byte-identical to the same paragraph unaligned. |
| 256 | // |
| 257 | // Asserted against the unaligned rendering rather than against a |
| 258 | // literal, because the property is "alignment changes nothing here", |
| 259 | // and a literal would let both sides drift together. |
| 260 | func TestMarkdownDropsAlignmentAndKeepsTheText(t *testing.T) { |
| 261 | plain := `{"type":"doc","content":[ |
| 262 | {"type":"paragraph","content":[{"type":"text","text":"Centred on screen."}]}, |
| 263 | {"type":"paragraph","content":[{"type":"text","text":"Right on screen."}]} |
| 264 | ]}` |
| 265 | aligned := `{"type":"doc","content":[ |
| 266 | {"type":"paragraph","attrs":{"align":"center"},"content":[{"type":"text","text":"Centred on screen."}]}, |
| 267 | {"type":"paragraph","attrs":{"align":"right"},"content":[{"type":"text","text":"Right on screen."}]} |
| 268 | ]}` |
| 269 | want := export.Markdown(mustParse(t, "Notes", plain)) |
| 270 | got := export.Markdown(mustParse(t, "Notes", aligned)) |
| 271 | if got != want { |
| 272 | t.Errorf("aligned Markdown is %q, want the unaligned rendering %q", got, want) |
| 273 | } |
| 274 | for _, text := range []string{"Centred on screen.", "Right on screen."} { |
| 275 | if !strings.Contains(got, text) { |
| 276 | t.Errorf("the text of an aligned paragraph was lost: %q", got) |
| 277 | } |
| 278 | } |
| 279 | } |
| 280 | |
| 281 | func TestMarkdownOfAnEmptyDocumentIsJustItsTitle(t *testing.T) { |
| 282 | got := export.Markdown(mustParse(t, "Untitled document", `{"type":"doc","content":[]}`)) |
| 283 | if got != "# Untitled document\n" { |
| 284 | t.Errorf("got %q", got) |
| 285 | } |
| 286 | } |
| 287 | |
| 288 | func TestParseRejectsSomethingThatIsNotADocument(t *testing.T) { |
| 289 | if _, err := export.Parse("t", `{"type":"paragraph"}`); err == nil { |
| 290 | t.Error("a non-doc root must be an error, not an empty export") |
| 291 | } |
| 292 | if _, err := export.Parse("t", `not json`); err == nil { |
| 293 | t.Error("invalid JSON must be an error") |
| 294 | } |
| 295 | } |
| 296 | |
| 297 | // jsonString quotes s the way a JSON encoder would, for building the |
| 298 | // fixtures above. |
| 299 | // |
| 300 | // It asks the JSON encoder rather than reimplementing it. The hand- |
| 301 | // rolled version handled only " and \, so a fixture containing a |
| 302 | // literal newline produced invalid JSON and the test failed inside |
| 303 | // Parse with "invalid character '\n' in string literal" — a fixture |
| 304 | // bug wearing the costume of a parser bug, and one that made a control |
| 305 | // character impossible to write a test for at all. |
| 306 | func jsonString(s string) string { |
| 307 | b, err := json.Marshal(s) |
| 308 | if err != nil { |
| 309 | panic(err) // a string always marshals |
| 310 | } |
| 311 | return string(b) |
| 312 | } |
| 313 | |
| 314 | // Shift+Enter's line break, in the export that has two spellings for |
| 315 | // it and has to pick one. |
| 316 | // |
| 317 | // A backslash at the end of the line, not two trailing spaces. Both |
| 318 | // are CommonMark hard breaks, but only one survives a round trip: |
| 319 | // trailing spaces are invisible in the source, and every editor, |
| 320 | // linter and version-control hook that strips trailing whitespace |
| 321 | // removes them without anyone seeing it happen — so the break the |
| 322 | // author typed silently becomes a soft wrap. A backslash is visible, |
| 323 | // nothing strips it, and it cannot be confused with a backslash the |
| 324 | // author typed, because escapeMarkdown escapes that one to "\\". |
| 325 | func TestMarkdownRendersAHardBreakAsATrailingBackslash(t *testing.T) { |
| 326 | js := `{"type":"doc","content":[{"type":"paragraph","content":[ |
| 327 | {"type":"text","text":"first"}, |
| 328 | {"type":"hard_break"}, |
| 329 | {"type":"text","text":"second"} |
| 330 | ]}]}` |
| 331 | got := export.Markdown(mustParse(t, "", js)) |
| 332 | want := "first\\\nsecond\n" |
| 333 | if got != want { |
| 334 | t.Errorf("got:\n%q\nwant:\n%q", got, want) |
| 335 | } |
| 336 | } |
| 337 | |
| 338 | // A break at the end of a block has nothing to continue onto, and a |
| 339 | // backslash left at the end of a paragraph is not a break in |
| 340 | // CommonMark — it is a literal backslash the author never typed. |
| 341 | func TestMarkdownDropsAHardBreakAtTheEndOfAParagraph(t *testing.T) { |
| 342 | js := `{"type":"doc","content":[{"type":"paragraph","content":[ |
| 343 | {"type":"text","text":"only"}, |
| 344 | {"type":"hard_break"}, |
| 345 | {"type":"hard_break"} |
| 346 | ]}]}` |
| 347 | if got, want := export.Markdown(mustParse(t, "", js)), "only\n"; got != want { |
| 348 | t.Errorf("got %q, want %q", got, want) |
| 349 | } |
| 350 | } |
| 351 | |
| 352 | // A heading is one line in Markdown — there is no way to spell a |
| 353 | // second one — so a break inside a heading becomes a space rather than |
| 354 | // a backslash that would strand the rest of the heading as a |
| 355 | // paragraph. The words are what survives; that is this file's rule. |
| 356 | func TestMarkdownFlattensAHardBreakInsideAHeading(t *testing.T) { |
| 357 | js := `{"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[ |
| 358 | {"type":"text","text":"first"}, |
| 359 | {"type":"hard_break"}, |
| 360 | {"type":"text","text":"second"} |
| 361 | ]}]}` |
| 362 | if got, want := export.Markdown(mustParse(t, "", js)), "## first second\n"; got != want { |
| 363 | t.Errorf("got %q, want %q", got, want) |
| 364 | } |
| 365 | } |
| 366 | |
| 367 | // The line a break opens is still inside whatever block it happened |
| 368 | // in: a list item's continuation indent and a blockquote's own marker |
| 369 | // have to reach it, or the second half of the line leaves the list (or |
| 370 | // the quote) that the first half is in. |
| 371 | func TestMarkdownIndentsTheLineAfterAHardBreak(t *testing.T) { |
| 372 | js := `{"type":"doc","content":[ |
| 373 | {"type":"bullet_list","content":[ |
| 374 | {"type":"list_item","content":[{"type":"paragraph","content":[ |
| 375 | {"type":"text","text":"one"}, |
| 376 | {"type":"hard_break"}, |
| 377 | {"type":"text","text":"two"} |
| 378 | ]}]} |
| 379 | ]}, |
| 380 | {"type":"blockquote","content":[{"type":"paragraph","content":[ |
| 381 | {"type":"text","text":"three"}, |
| 382 | {"type":"hard_break"}, |
| 383 | {"type":"text","text":"four"} |
| 384 | ]}]} |
| 385 | ]}` |
| 386 | got := export.Markdown(mustParse(t, "", js)) |
| 387 | want := strings.Join([]string{ |
| 388 | "- one\\", |
| 389 | " two", |
| 390 | "", |
| 391 | "> three\\", |
| 392 | "> four", |
| 393 | "", |
| 394 | }, "\n") |
| 395 | if got != want { |
| 396 | t.Errorf("got:\n%q\nwant:\n%q", got, want) |
| 397 | } |
| 398 | } |
| 399 | |
| 400 | // A literal newline inside a list item or a blockquote must not walk |
| 401 | // the rest of the line out of the block it is in. |
| 402 | // |
| 403 | // Same defect class as the escaping above and worth its own check, |
| 404 | // because the fix for one does not imply the fix for the other: the |
| 405 | // escaper decides what the characters MEAN, and these two paths decide |
| 406 | // what prefix each line CARRIES. A newline that arrives without its |
| 407 | // indent or its "> " leaves the second half of somebody's sentence |
| 408 | // outside the list. |
| 409 | func TestMarkdownKeepsALiteralNewlineInsideItsBlock(t *testing.T) { |
| 410 | js := `{"type":"doc","content":[ |
| 411 | {"type":"bullet_list","content":[ |
| 412 | {"type":"list_item","content":[{"type":"paragraph","content":[ |
| 413 | {"type":"text","text":"one\ntwo"} |
| 414 | ]}]} |
| 415 | ]}, |
| 416 | {"type":"blockquote","content":[{"type":"paragraph","content":[ |
| 417 | {"type":"text","text":"three\nfour"} |
| 418 | ]}]} |
| 419 | ]}` |
| 420 | got := export.Markdown(mustParse(t, "", js)) |
| 421 | want := strings.Join([]string{ |
| 422 | "- one", |
| 423 | " two", |
| 424 | "", |
| 425 | "> three", |
| 426 | "> four", |
| 427 | "", |
| 428 | }, "\n") |
| 429 | if got != want { |
| 430 | t.Errorf("got:\n%q\nwant:\n%q", got, want) |
| 431 | } |
| 432 | } |
| 433 | |
| 434 | // Four or more leading spaces on a line make an indented code block, |
| 435 | // and escaping cannot prevent it: the block is made by the whitespace, |
| 436 | // not by any character a backslash could go in front of. |
| 437 | // |
| 438 | // So this records what currently happens rather than asserting what |
| 439 | // should. The text survives verbatim — which is the module's actual |
| 440 | // promise, never lose the words — but a reader sees it as code. The |
| 441 | // fix, if it is ever wanted, means rewriting the author's own leading |
| 442 | // whitespace, and that is a different decision from escaping, not one |
| 443 | // to take in passing. |
| 444 | // |
| 445 | // Here so the next person to find it finds a decision rather than a |
| 446 | // surprise, and so anyone who changes the behaviour has to change a |
| 447 | // test that says why. |
| 448 | func TestMarkdownIndentedRunLinesBecomeCodeBlocks(t *testing.T) { |
| 449 | js := `{"type":"doc","content":[{"type":"paragraph","content":[ |
| 450 | {"type":"text","text":"intro\n four spaces in"} |
| 451 | ]}]}` |
| 452 | got := strings.TrimSpace(export.Markdown(mustParse(t, "", js))) |
| 453 | want := "intro\n four spaces in" |
| 454 | if got != want { |
| 455 | t.Errorf("got %q, want %q", got, want) |
| 456 | } |
| 457 | // The words are all still there, which is the guarantee that does |
| 458 | // hold. Asserted separately so a change to the spacing still has |
| 459 | // to keep this true. |
| 460 | if !strings.Contains(got, "four spaces in") { |
| 461 | t.Errorf("the text was lost: %q", got) |
| 462 | } |
| 463 | } |
| 464 | |