| 1 | // The Markdown subset the operations accept text in — core/src/markdown.rs |
| 2 | // line for line, because a batch applied here and the same batch applied |
| 3 | // through the core must produce the same runs. |
| 4 | // |
| 5 | // - Paragraphs split on blank lines; the lines of one paragraph join with |
| 6 | // a space. |
| 7 | // - A line starting `- ` or `1. ` (any digits) is its own paragraph with |
| 8 | // the marker stripped. |
| 9 | // - Inline: **bold**, *italic*, `code`, ==highlight==, [text](url). A |
| 10 | // delimiter with no closing partner is literal text. Inside a code span |
| 11 | // nothing else is parsed. A link's text may carry marks; a link inside a |
| 12 | // link is literal. |
| 13 | // - The only escape is \*, which yields a literal *. |
| 14 | // |
| 15 | // A run is {t, b?, i?, c?, h?, href?}: the view's run shape, false and |
| 16 | // absent marks omitted. parseLines is the plain variant for code blocks. |
| 17 | |
| 18 | export function sameMarks (a, b) { |
| 19 | return !!a.b === !!b.b && !!a.i === !!b.i && !!a.c === !!b.c && !!a.h === !!b.h && |
| 20 | (a.href ?? null) === (b.href ?? null) |
| 21 | } |
| 22 | |
| 23 | /** A run with only the marks that are on, so deepEqual sees the view shape. */ |
| 24 | export function run (t, marks = {}) { |
| 25 | const r = { t } |
| 26 | if (marks.b) r.b = true |
| 27 | if (marks.i) r.i = true |
| 28 | if (marks.c) r.c = true |
| 29 | if (marks.h) r.h = true |
| 30 | if (marks.href != null) r.href = marks.href |
| 31 | return r |
| 32 | } |
| 33 | |
| 34 | /** Appends a run to a paragraph, merging into the last one when the marks match. */ |
| 35 | export function pushRun (paragraph, r) { |
| 36 | const last = paragraph[paragraph.length - 1] |
| 37 | if (last && sameMarks(last, r)) last.t += r.t |
| 38 | else paragraph.push(r) |
| 39 | } |
| 40 | |
| 41 | /** Rust's str::lines(): split on \n or \r\n, the final line ending optional, no phantom last line. */ |
| 42 | export function lines (input) { |
| 43 | const out = [] |
| 44 | let start = 0 |
| 45 | while (start < input.length) { |
| 46 | const nl = input.indexOf('\n', start) |
| 47 | if (nl === -1) { out.push(input.slice(start)); break } |
| 48 | const line = input.slice(start, nl) |
| 49 | out.push(line.endsWith('\r') ? line.slice(0, -1) : line) |
| 50 | start = nl + 1 |
| 51 | } |
| 52 | return out |
| 53 | } |
| 54 | |
| 55 | export function parse (input) { |
| 56 | const sources = [] |
| 57 | let current = null |
| 58 | for (let line of lines(input)) { |
| 59 | line = line.trim() |
| 60 | if (line === '') { |
| 61 | if (current !== null) { sources.push(current); current = null } |
| 62 | continue |
| 63 | } |
| 64 | const item = listItem(line) |
| 65 | if (item !== null) { |
| 66 | if (current !== null) sources.push(current) |
| 67 | current = item |
| 68 | continue |
| 69 | } |
| 70 | current = current === null ? line : current + ' ' + line |
| 71 | } |
| 72 | if (current !== null) sources.push(current) |
| 73 | return sources.map(parseInline) |
| 74 | } |
| 75 | |
| 76 | export function parseLines (input) { |
| 77 | return lines(input).map(line => (line === '' ? [] : [run(line)])) |
| 78 | } |
| 79 | |
| 80 | function listItem (line) { |
| 81 | if (line.startsWith('- ')) return line.slice(2).trimStart() |
| 82 | let digits = 0 |
| 83 | while (digits < line.length && line.charCodeAt(digits) >= 48 && line.charCodeAt(digits) <= 57) digits++ |
| 84 | if (digits > 0 && line.startsWith('. ', digits)) return line.slice(digits + 2).trimStart() |
| 85 | return null |
| 86 | } |
| 87 | |
| 88 | class Parser { |
| 89 | constructor () { |
| 90 | this.runs = [] |
| 91 | this.buf = '' |
| 92 | this.marks = { b: false, i: false, c: false, h: false, href: null } |
| 93 | } |
| 94 | |
| 95 | flush () { |
| 96 | if (this.buf === '') return |
| 97 | pushRun(this.runs, run(this.buf, this.marks)) |
| 98 | this.buf = '' |
| 99 | } |
| 100 | |
| 101 | toggle (delim, rest, key) { |
| 102 | if (this.marks[key]) { |
| 103 | this.flush() |
| 104 | this.marks[key] = false |
| 105 | return true |
| 106 | } |
| 107 | if (hasPartner(rest, delim)) { |
| 108 | this.flush() |
| 109 | this.marks[key] = true |
| 110 | return true |
| 111 | } |
| 112 | return false |
| 113 | } |
| 114 | |
| 115 | parse (src) { |
| 116 | let i = 0 |
| 117 | while (i < src.length) { |
| 118 | const rest = src.slice(i) |
| 119 | if (rest.startsWith('\\*')) { this.buf += '*'; i += 2; continue } |
| 120 | if (rest.startsWith('**')) { |
| 121 | if (this.toggle('**', rest.slice(2), 'b')) { i += 2; continue } |
| 122 | } else if (rest.startsWith('==')) { |
| 123 | if (this.toggle('==', rest.slice(2), 'h')) { i += 2; continue } |
| 124 | } else if (rest.startsWith('*')) { |
| 125 | if (this.toggle('*', rest.slice(1), 'i')) { i += 1; continue } |
| 126 | } else if (rest.startsWith('`')) { |
| 127 | const end = rest.indexOf('`', 1) |
| 128 | if (end !== -1) { |
| 129 | this.flush() |
| 130 | const was = this.marks.c |
| 131 | this.marks.c = true |
| 132 | this.buf += rest.slice(1, end) |
| 133 | this.flush() |
| 134 | this.marks.c = was |
| 135 | i += end + 1 |
| 136 | continue |
| 137 | } |
| 138 | } else if (rest.startsWith('[') && this.marks.href === null) { |
| 139 | const l = link(rest) |
| 140 | if (l !== null) { |
| 141 | this.flush() |
| 142 | const outer = { ...this.marks } |
| 143 | this.marks.href = l.url |
| 144 | this.parse(l.text) |
| 145 | this.flush() |
| 146 | this.marks = outer |
| 147 | i += l.len |
| 148 | continue |
| 149 | } |
| 150 | } |
| 151 | const ch = String.fromCodePoint(rest.codePointAt(0)) |
| 152 | this.buf += ch |
| 153 | i += ch.length |
| 154 | } |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | /** Whether delim occurs in rest as itself: for a lone *, one that is not half of a **. */ |
| 159 | function hasPartner (rest, delim) { |
| 160 | if (delim !== '*') return rest.includes(delim) |
| 161 | let i = 0 |
| 162 | while (i < rest.length) { |
| 163 | if (rest[i] === '*') { |
| 164 | if (rest[i + 1] === '*') { i += 2; continue } |
| 165 | return true |
| 166 | } |
| 167 | i++ |
| 168 | } |
| 169 | return false |
| 170 | } |
| 171 | |
| 172 | /** [text](url) at the start of s: the text, the url, and the length the whole link spans. */ |
| 173 | function link (s) { |
| 174 | const close = s.indexOf('](') |
| 175 | if (close === -1) return null |
| 176 | const text = s.slice(1, close) |
| 177 | const urlStart = close + 2 |
| 178 | const end = s.indexOf(')', urlStart) |
| 179 | if (end === -1) return null |
| 180 | const url = s.slice(urlStart, end) |
| 181 | if (text === '' || url === '') return null |
| 182 | return { text, url, len: end + 1 } |
| 183 | } |
| 184 | |
| 185 | function parseInline (src) { |
| 186 | const p = new Parser() |
| 187 | p.parse(src) |
| 188 | p.flush() |
| 189 | return p.runs |
| 190 | } |
| 191 | |