oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1package export_test
2
3import (
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.
14func 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
23func 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.
70func 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.
90func 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.
107func 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.
200func 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.
214func 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.
228func 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.
241func 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.
260func 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
281func 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
288func 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.
306func 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 "\\".
325func 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.
341func 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.
356func 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.
371func 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.
409func 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.
448func 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