oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

1package export
2
3import (
4 "strconv"
5 "strings"
6)
7
8// Markdown renders a document as CommonMark.
9//
10// §11.3 says "Markdown lossless within its subset", and this is where
11// that subset is drawn: headings, paragraphs, bullet and ordered lists
12// (nested), blockquotes, and the bold/italic/code marks. What falls
13// outside it is the font and size marks — Markdown has no way to say
14// "this run is in the sans face at the large size", and inventing an
15// HTML span to carry it would produce a file that is no longer
16// Markdown for the many readers who would then see the tag. A person
17// exporting to Markdown is asking for the words and their structure;
18// the typography is what PDF is for.
19//
20// Paragraph alignment falls outside it too, and this says so out loud
21// rather than letting it go missing quietly. CommonMark has no syntax
22// for alignment at all — not a weaker spelling of it, none — so a
23// centred paragraph exports as its own text, byte-identical to the same
24// paragraph unaligned, and writeBlock's paragraph case never reads
25// `align`. The alternatives fail the same way the font marks' would: an
26// inline `<p align="center">` or a `style` attribute is HTML, not
27// Markdown, and every reader that does not render raw HTML would show
28// the tag; a `:---:` alignment row would silently turn one paragraph
29// into a table. The words and the structure survive, which is the
30// promise; the layout is what the PDF is for.
31//
32// The document title becomes an H1 at the top. It lives on the
33// database row rather than inside the CRDT, so it is not in the tree,
34// and a Markdown file that opens with nothing but the first paragraph
35// loses the one piece of a document a person is most likely to be
36// looking for in a folder of them.
37func Markdown(doc Document) string {
38 var b strings.Builder
39 if doc.Title != "" {
40 b.WriteString("# ")
41 b.WriteString(escapeMarkdown(doc.Title))
42 b.WriteString("\n\n")
43 }
44 writeBlocks(&b, doc.Root.Content, "")
45 // Exactly one trailing newline: blocks each end with a blank line,
46 // which leaves two.
47 return strings.TrimRight(b.String(), "\n") + "\n"
48}
49
50// hardBreak is how a Shift+Enter line break (core/src/fragment.rs's
51// `hard_break`, `<br>` in the read view) is spelled here: a backslash
52// at the end of the line.
53//
54// CommonMark has two spellings and they are not equally safe. Two
55// trailing spaces mean the same thing to a parser and nothing at all
56// to a person reading the file, and every editor, formatter and
57// pre-commit hook that trims trailing whitespace deletes them without
58// anyone noticing — at which point the break the author typed has
59// quietly become a soft wrap, which is the failure this whole fix
60// exists to stop. A backslash is visible in the source, nothing strips
61// it, and it cannot be mistaken for one the author typed: escapeMarkdown
62// escapes that to `\\`. So this is the form that round-trips.
63const hardBreak = "\\\n"
64
65// writeBlocks renders a run of block-level nodes at one indent level.
66// indent is the prefix every line of a nested block carries — that is
67// how a list inside a list stays inside it in the output.
68func writeBlocks(b *strings.Builder, nodes []Node, indent string) {
69 for _, n := range nodes {
70 writeBlock(b, n, indent)
71 }
72}
73
74func writeBlock(b *strings.Builder, n Node, indent string) {
75 switch kindOf(n) {
76 case blockHeading:
77 b.WriteString(indent)
78 b.WriteString(strings.Repeat("#", headingLevel(n)))
79 b.WriteString(" ")
80 // A heading is one line and Markdown has no way to spell a
81 // second one, so a break inside one becomes a space. The
82 // alternative is a backslash that strands the rest of the
83 // heading as a paragraph below it — the words are what has to
84 // survive, which is this file's rule everywhere else too.
85 b.WriteString(strings.ReplaceAll(inlineString(n.Content), hardBreak, " "))
86 b.WriteString("\n\n")
87
88 case blockParagraph:
89 text := inlineString(n.Content)
90 // A break at the end of a paragraph has no line to continue
91 // onto, and the backslash it would leave behind is not a break
92 // in CommonMark at all — it is a literal backslash appearing in
93 // a document whose author never typed one. TrimSuffix in a loop
94 // rather than TrimRight: TrimRight works on a character set, so
95 // it would also eat the second half of an escaped backslash the
96 // author DID type ("\\" from escapeMarkdown) and leave a
97 // dangling escape behind.
98 for strings.HasSuffix(text, hardBreak) {
99 text = strings.TrimSuffix(text, hardBreak)
100 }
101 if strings.TrimSpace(text) == "" {
102 // An empty paragraph is a blank line in the document and
103 // nothing at all in Markdown — two blank lines in a row
104 // are one paragraph break, not two. Emitting it would
105 // change nothing about how the file reads and would make
106 // the round trip look lossy when it is not.
107 return
108 }
109 b.WriteString(indent)
110 // A hard break puts a second line inside this one paragraph,
111 // and that line is still inside whatever block the paragraph
112 // is in — so it carries the same prefix. (writeList and the
113 // blockquote case re-prefix the lines they are given, so
114 // indent is "" underneath either of them; this is what keeps
115 // the stray-list_item path below honest.)
116 b.WriteString(strings.ReplaceAll(text, "\n", "\n"+indent))
117 b.WriteString("\n\n")
118
119 case blockBulletList:
120 writeList(b, n, indent, false)
121
122 case blockOrderedList:
123 writeList(b, n, indent, true)
124
125 case blockQuote:
126 // Render the quoted content into its own buffer and then
127 // prefix every line, so a multi-paragraph or list-containing
128 // quote is quoted throughout rather than only on its first
129 // line.
130 var inner strings.Builder
131 writeBlocks(&inner, n.Content, "")
132 for _, line := range strings.Split(strings.TrimRight(inner.String(), "\n"), "\n") {
133 b.WriteString(indent)
134 if line == "" {
135 b.WriteString(">\n")
136 continue
137 }
138 b.WriteString("> ")
139 b.WriteString(line)
140 b.WriteString("\n")
141 }
142 b.WriteString("\n")
143
144 case blockListItem:
145 // Reached only when a list_item turns up outside a list, which
146 // ProseMirror's schema cannot produce but a client posting raw
147 // Yjs bytes can. Rendered as its own content rather than
148 // dropped — the module rule the core states and this file
149 // inherits: never lose the words.
150 writeBlocks(b, n.Content, indent)
151
152 default:
153 if n.IsText() {
154 // A bare text run outside any block — same provenance as
155 // the stray list_item above. Its own paragraph.
156 b.WriteString(indent)
157 writeInline(b, []Node{n})
158 b.WriteString("\n\n")
159 return
160 }
161 // An element this build does not know: render its children.
162 // A newer editor's node must not take a reader's text with it.
163 writeBlocks(b, n.Content, indent)
164 }
165}
166
167func writeList(b *strings.Builder, list Node, indent string, ordered bool) {
168 for i, item := range list.Content {
169 marker := "- "
170 if ordered {
171 marker = strconv.Itoa(i+1) + ". "
172 }
173 // Continuation lines line up under the text, not under the
174 // marker, or a nested list reads as a sibling of the one above
175 // it.
176 childIndent := indent + strings.Repeat(" ", len(marker))
177
178 var inner strings.Builder
179 writeBlocks(&inner, item.Content, "")
180 lines := strings.Split(strings.TrimRight(inner.String(), "\n"), "\n")
181 for j, line := range lines {
182 switch {
183 case j == 0:
184 b.WriteString(indent)
185 b.WriteString(marker)
186 b.WriteString(line)
187 case line == "":
188 // Keep the blank line, but without trailing spaces.
189 default:
190 b.WriteString(childIndent)
191 b.WriteString(line)
192 }
193 b.WriteString("\n")
194 }
195 }
196 b.WriteString("\n")
197}
198
199func inlineString(nodes []Node) string {
200 var b strings.Builder
201 writeInline(&b, nodes)
202 return b.String()
203}
204
205// writeInline renders a run of inline nodes with their marks.
206//
207// Marks are applied per run rather than merged across adjacent runs
208// that share one: "**bold**" followed by "**er**" reads identically to
209// "**bolder**" once rendered, and merging would mean tracking mark
210// state across a sequence for no visible gain.
211func writeInline(b *strings.Builder, nodes []Node) {
212 for _, n := range nodes {
213 if n.Type == "hard_break" {
214 b.WriteString(hardBreak)
215 // A break has no children through the editor's schema and
216 // can have them through raw Yjs bytes; render them rather
217 // than lose them, exactly as fragment.rs does.
218 writeInline(b, n.Content)
219 continue
220 }
221 if !n.IsText() {
222 // An inline element (or a block that ended up inline).
223 // Render its children so nothing is lost.
224 writeInline(b, n.Content)
225 continue
226 }
227 if n.Text == "" {
228 continue
229 }
230 if n.Has("code") {
231 // Code spans take no other markup and no escaping — the
232 // backticks are the escape. A run containing a backtick
233 // needs a longer fence than any run of backticks inside
234 // it, which is what CommonMark says and what this does.
235 b.WriteString(codeSpan(n.Text))
236 continue
237 }
238 // Leading and trailing spaces have to sit outside the
239 // delimiters: "** bold **" is not emphasis in CommonMark, it
240 // is four literal asterisks and a word.
241 lead, core, trail := splitSpace(n.Text)
242 if core == "" {
243 b.WriteString(n.Text)
244 continue
245 }
246 var opening, closing string
247 if n.Has("strong") {
248 opening, closing = opening+"**", "**"+closing
249 }
250 if n.Has("em") {
251 opening, closing = opening+"*", "*"+closing
252 }
253 b.WriteString(lead)
254 b.WriteString(opening)
255 b.WriteString(escapeMarkdown(core))
256 b.WriteString(closing)
257 b.WriteString(trail)
258 }
259}
260
261// splitSpace divides s into its leading whitespace, its middle, and
262// its trailing whitespace.
263func splitSpace(s string) (lead, core, trail string) {
264 core = strings.TrimLeft(s, " \t")
265 lead = s[:len(s)-len(core)]
266 trimmed := strings.TrimRight(core, " \t")
267 trail = core[len(trimmed):]
268 return lead, trimmed, trail
269}
270
271// codeSpan wraps s in enough backticks to contain it.
272func codeSpan(s string) string {
273 longest, run := 0, 0
274 for _, r := range s {
275 if r == '`' {
276 run++
277 if run > longest {
278 longest = run
279 }
280 } else {
281 run = 0
282 }
283 }
284 fence := strings.Repeat("`", longest+1)
285 // A code span that starts or ends with a backtick needs a space
286 // inside the fence, which CommonMark strips again on the way back.
287 pad := ""
288 if strings.HasPrefix(s, "`") || strings.HasSuffix(s, "`") {
289 pad = " "
290 }
291 return fence + pad + s + pad + fence
292}
293
294// escapeMarkdown backslash-escapes the characters that would otherwise
295// turn a person's own words into markup. A document containing
296// "2 * 3 * 4" must not come back as emphasis, and one containing
297// "# 1 priority" must not come back as a heading.
298//
299// The set is CommonMark's ASCII punctuation, minus the characters that
300// are only special in positions this function's callers never place
301// text in. It escapes more than the strict minimum on purpose: an
302// over-escaped file renders correctly and reads slightly noisily in
303// source, while an under-escaped one renders as something the author
304// did not write.
305// "The start of a line" is tracked rather than inferred from index 0,
306// and it means the first NON-SPACE on a line rather than the first
307// character.
308//
309// Two rounds of this were wrong, in the same direction, and both are
310// worth naming because the second looked like a fix.
311//
312// It first asked whether i == 0, which is the start of the RUN and not
313// the start of a line. A run can contain a literal newline — nothing
314// stops one: POST /d/{sqid}/updates accepts Yjs bytes that never met
315// ProseMirror's schema, and a paste can carry one — so everything
316// after a newline was treated as mid-sentence, and "#" became a
317// heading, "- " a bullet, "1." an ordered list, mid-paragraph.
318//
319// Tracking lineStart fixed exactly the unindented case and no more.
320// CommonMark lets a block marker carry up to THREE spaces of
321// indentation (§4.2 for ATX headings, §5.2 for lists) and all of them
322// interrupt a paragraph, so " # x" one space in is still a heading
323// while `i == lineStart` says it is mid-line. Caught in review, with
324// the seven inputs now in the test table.
325//
326// So the state is "have we seen anything but spaces on this line yet",
327// which is what atLineStart means below. Any run of spaces is treated
328// as still-at-line-start rather than counting to three: over-escaping
329// renders correctly and reads slightly noisily in source, which is the
330// trade this function's own header already chose.
331//
332// Two characters are escaped that a first reading would not expect:
333//
334// - '=' because a line of them is a setext heading, which underlines
335// the paragraph ABOVE it — so it silently promotes text that is
336// nowhere near it. ('-' is already escaped for the bullet case,
337// which covers setext H2 as a side effect.)
338// - '~' because "~~~" opens a code fence that interrupts a paragraph
339// and then swallows every following line until a closing fence. It
340// does not restyle a line, it eats the rest of the document.
341//
342// Both only at the start of a line, so "a = b" is left alone.
343//
344// KNOWN AND NOT FIXED HERE: four or more leading spaces after a
345// newline make an indented code block, and no amount of escaping
346// changes that — the block is made by the whitespace, not by any
347// character this function could put a backslash in front of. The text
348// survives verbatim and is rendered as code. Fixing it means altering
349// the author's own leading whitespace, which is a different decision
350// from escaping and not one to take in passing.
351// TestMarkdownIndentedRunLinesBecomeCodeBlocks records the current
352// behaviour so it stays a decision rather than becoming a discovery.
353func escapeMarkdown(s string) string {
354 var b strings.Builder
355 b.Grow(len(s))
356 lineStart := 0 // byte index where the current line begins
357 atLineStart := true // nothing but spaces seen on this line yet
358 for i, r := range s {
359 switch r {
360 case '\n':
361 b.WriteRune(r)
362 lineStart = i + 1
363 atLineStart = true
364 continue
365 case ' ', '\t':
366 // Indentation does not end the line's start.
367 b.WriteRune(r)
368 continue
369 case '\\', '`', '*', '_', '[', ']', '<', '>':
370 b.WriteByte('\\')
371 b.WriteRune(r)
372 case '#', '+', '-', '=', '~':
373 // Only special at the start of a line. Inside a sentence
374 // they are ordinary punctuation, and escaping every hyphen
375 // in a document would be unreadable.
376 if atLineStart {
377 b.WriteByte('\\')
378 }
379 b.WriteRune(r)
380 case '.', ')':
381 // "1." and "1)" start an ordered list, but only after
382 // digits at the start of a line — so the digits are counted
383 // from the line's first non-space, not from the run.
384 if !atLineStart && startsWithDigits(strings.TrimLeft(s[lineStart:i], " \t")) {
385 b.WriteByte('\\')
386 }
387 b.WriteRune(r)
388 default:
389 b.WriteRune(r)
390 }
391 atLineStart = false
392 }
393 return b.String()
394}
395
396// startsWithDigits reports whether s is entirely ASCII digits — the
397// "1" in a "1." that would start a list.
398func startsWithDigits(s string) bool {
399 if s == "" {
400 return false
401 }
402 for i := 0; i < len(s); i++ {
403 if s[i] < '0' || s[i] > '9' {
404 return false
405 }
406 }
407 return true
408}
409